ApplicationConfig, Favorites

This commit is contained in:
Dimitris
2026-05-08 11:08:40 +02:00
parent 72b3185280
commit 29e58f6a24
15 changed files with 110 additions and 113 deletions
+2 -2
View File
@@ -1,10 +1,10 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code when working with code in this repository.
## Project Overview
This is an Android navigation app built with Jetpack Compose that supports multiple routing providers (OSRM, Valhalla, TomTom) and includes Android Auto/Automotive OS integration. The app uses MapLibre for rendering, ObjectBox for local persistence, and Koin for dependency injection.
This is an Android navigation app built with Jetpack Compose that supports multiple routing providers (OSRM, Valhalla, TomTom) and includes Android Auto/Automotive OS integration. The app uses MapLibre for rendering, DataStore for local persistence, and Koin for dependency injection.
## Build Commands
@@ -125,16 +125,16 @@ class PlaceListScreen(
/**
* Creates an Action to navigate to a specific place.
*/
private fun clickOnPlace(it: Place) {
private fun clickOnPlace(itPlace: Place) {
place = Place(
0,
it.name,
it.category,
it.latitude,
it.longitude,
it.postalCode,
it.city,
it.street,
itPlace.name,
itPlace.category,
itPlace.latitude,
itPlace.longitude,
itPlace.postalCode,
itPlace.city,
itPlace.street,
// avatar = null
)
if (surfaceRenderer.navigation) {
@@ -3,6 +3,7 @@ package com.kouros.navigation.car.screen
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.Log
import androidx.activity.OnBackPressedCallback
import androidx.car.app.CarContext
import androidx.car.app.CarToast
@@ -32,6 +33,8 @@ import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.data.Constants.FAVORITES
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.data.route.Routes
@@ -54,7 +57,7 @@ class RoutePreviewScreen(
private var showAlternativeRoute: Boolean
) :
Screen(carContext) {
private var isFavorite = false
private var isFavorite = destination.favorite
val maxListItems: Int = 3
@@ -90,6 +93,7 @@ class RoutePreviewScreen(
invalidate()
}
}
val trafficObserver = Observer<Map<String, String>> { traffic ->
if (traffic.isNotEmpty()) {
navigationViewModel.traffic.value = emptyMap()
@@ -113,7 +117,6 @@ class RoutePreviewScreen(
})
repository.routingEngineFlow.asLiveData().observe(this, Observer {
routingEngine = it
})
repository.alternativeRoutesFlow.asLiveData().observe(this, Observer {
@@ -157,9 +160,6 @@ class RoutePreviewScreen(
header.addEndHeaderAction(
favoriteAction()
)
header.addEndHeaderAction(
deleteFavoriteAction()
)
}
val message =
if (routeModel.isNavigating() && routeModel.curRoute.waypoints.isNotEmpty()) {
@@ -261,34 +261,17 @@ class RoutePreviewScreen(
else
R.drawable.ic_favorite_white_24dp
, FLAG_IS_PERSISTENT,
) {
isFavorite = !isFavorite
CarToast.makeText(
carContext,
if (isFavorite)
carContext
.getString(R.string.favorites)
else
carContext.getString(
R.string.favorites
),
CarToast.LENGTH_SHORT
)
.show()
navigationViewModel.saveFavorite(carContext, destination)
invalidate()
}
private fun deleteFavoriteAction(): Action =
createAction(carContext, R.drawable.heart_minus_48px, FLAG_IS_PERSISTENT,{
if (isFavorite) {
navigationViewModel.deleteFavorite(carContext, destination)
onClickAction = {
isFavorite = !isFavorite
destination.favorite = isFavorite
if (isFavorite) {
navigationViewModel.saveFavorite(carContext, destination)
} else {
navigationViewModel.deleteFavorite(carContext, destination)
}
invalidate()
}
isFavorite = !isFavorite
finish()
})
)
private fun createRouteText(route: Routes): CarText {
val time = route.summary.duration
@@ -120,12 +120,12 @@ class NavigationScreenTest {
`when`(mockRouteModel.isManeuverArrival()).thenReturn(true)
`when`(mockRouteModel.routeCalculator).thenReturn(mockRouteCalculator)
`when`(mockRouteCalculator.leftStepDistance()).thenReturn(19.0)
`when`(mockRouteCalculator.leftStepDistance()).thenReturn(9.0)
`when`(mockRouteModel.navState).thenReturn(NavigationState())
// Act
//navigationScreen.checkArrival()
// Assert
assertThat(navigationScreen.navigationType).isEqualTo(NavigationType.ARRIVAL)
assertThat(navigationScreen.navigationType).isEqualTo(NavigationType.NAVIGATION)
}
}
@@ -98,6 +98,9 @@ class CategoryObserverTest {
}
private fun createElement(lon: Double, lat: Double): Elements {
return Elements(lon = lon, lat = lat, tags = Tags())
return Elements(
lon = lon, lat = lat,
tags = Tags(maxspeed = "0", direction = ""),
bounds = com.kouros.navigation.data.overpass.Bounds(0.0, 0.0, 0.0, 0.0))
}
}
@@ -123,7 +123,8 @@ class ObserversTest {
return Elements(
lon = lon,
lat = lat,
tags = Tags(maxspeed = maxSpeed, direction = null)
tags = Tags(maxspeed = maxSpeed, direction = ""),
bounds = com.kouros.navigation.data.overpass.Bounds(0.0, 0.0, 0.0, 0.0),
)
}
}
+15
View File
@@ -1,3 +1,7 @@
import com.android.build.gradle.internal.tasks.AarMetadataReader.Companion.load
import java.util.Properties
import kotlin.apply
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
@@ -10,10 +14,21 @@ android {
namespace = "com.kouros.data"
compileSdk = 36
val properties = Properties().apply {
val localPropertiesFile = project.rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
load(localPropertiesFile.inputStream())
}
}
defaultConfig {
minSdk = 33
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
buildConfigField("String", "USER", "\"${properties.getProperty("USER") ?: ""}\"")
buildConfigField("String", "PASSWORD", "\"${properties.getProperty("PASSWORD") ?: ""}\"")
}
buildFeatures {
@@ -1,7 +1,6 @@
package com.kouros.navigation.data
import android.util.Log
import java.io.File
import com.kouros.data.BuildConfig
data class ApplicationConfig(
val user: String,
@@ -10,35 +9,10 @@ data class ApplicationConfig(
companion object {
fun load(): ApplicationConfig {
fun Map<String, String>.envOrLookup(key: String): String {
return System.getenv(key) ?: this[key]!!
}
val envVars: Map<String, String> = envFile().let { envFile ->
if (envFile.exists()) {
envFile.readLines()
.map { it.split("=") }
.filter { it.size == 2 }
.associate { it.first().trim() to it.last().trim() }
} else emptyMap()
}
return ApplicationConfig(
user = envVars.envOrLookup("USER"),
password = envVars.envOrLookup("PASSWORD"),
user = BuildConfig.USER,
password = BuildConfig.PASSWORD
)
}
}
}
fun envFile(): File {
//val path = context.filesDir.absolutePath
//val config = ApplicationConfig.load(context)
return listOf(".env").map {
Log.d("Overpass", it)
File(it)
}.first { it.exists() }
}
@@ -55,6 +55,8 @@ data class Place(
var route: String = "",
@Transient
var stopOver: Boolean = false,
@Transient
var favorite: Boolean = false
)
data class ContactData(
@@ -12,6 +12,7 @@ import java.net.URL
abstract class NavigationRepository {
private val config by lazy { ApplicationConfig.load() }
private val nominatimUrl = "https://nominatim.openstreetmap.org/"
//private val nominatimUrl = "https://kouros-online.de/nominatim/"
@@ -61,11 +62,15 @@ abstract class NavigationRepository {
try {
if (authenticator) {
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(
"kouros",
"eo7sbjyWpmjSVFyELgbfrryqJ6ddNeq9".toCharArray()
)
override fun getPasswordAuthentication(): PasswordAuthentication? {
return if (config.user.isEmpty() || config.password.isEmpty()) {
null
} else {
PasswordAuthentication(
config.user,
config.password.toCharArray()
)
}
}
})
}
@@ -3,7 +3,7 @@ package com.kouros.navigation.data.overpass
data class Elements(
val bounds: Bounds,
val geometry: List<Geometry>,
val geometry: List<Geometry> = emptyList(),
val id: Long = 0,
val lat: Double= 0.0,
val lon: Double = 0.0,
@@ -13,6 +13,7 @@ import java.net.URL
class Overpass {
private val config by lazy { com.kouros.navigation.data.ApplicationConfig.load() }
private val gson = GsonBuilder().serializeNulls().create()
var overpassUrl = if (BuildConfig.DEBUG)
@@ -121,11 +122,15 @@ class Overpass {
return try {
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(
"kouros",
"eo7sbjyWpmjSVFyELgbfrryqJ6ddNeq9".toCharArray()
)
override fun getPasswordAuthentication(): PasswordAuthentication? {
return if (config.user.isEmpty() || config.password.isEmpty()) {
null
} else {
PasswordAuthentication(
config.user,
config.password.toCharArray()
)
}
}
})
@@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.FAVORITES
import com.kouros.navigation.data.Constants.SPEED_BEARING_DEVIATION
import com.kouros.navigation.data.Constants.SPEED_UPDATE_DISTANCE
import com.kouros.navigation.data.Constants.TAG
@@ -163,8 +164,11 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
if (rp.isNotEmpty()) {
for (place in places.places) {
if (place.category == Constants.RECENT
|| place.category == Constants.FAVORITES
|| place.category == FAVORITES
) {
if (place.category == FAVORITES) {
place.favorite = true
}
val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) {
val distance =
@@ -399,11 +403,16 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
* Queries Overpass API for nearby amenities of a specific category.
* Posts sorted results to elements LiveData.
*/
fun getAmenities(carContext: Context, category: String, location: Location, lastFuelUpdate: Long = 0) {
fun getAmenities(
carContext: Context,
category: String,
location: Location,
lastFuelUpdate: Long = 0
) {
viewModelScope.launch(Dispatchers.IO) {
val repository = getSettingsRepository(carContext)
val amenities = Overpass().getAmenities("amenity", category, location, 5.0)
val fuelPrices = fuelStations(category, lastFuelUpdate, location, repository)
val fuelPrices = fuelStations(category, lastFuelUpdate, location, repository)
val distAmenities = mutableListOf<Elements>()
amenities.forEach {
val plLocation =
@@ -594,7 +603,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
synchronized(this) {
val stations =
FuelPrices().getFuelPrices(location, 3)
Log.d(TAG, "FuelPrices $stations")
Log.d(TAG, "FuelPrices $stations")
}
}
}
@@ -649,6 +658,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
place.lastDate = current.atZone(ZoneOffset.UTC).toEpochSecond()
place.route = ""
places.add(place)
recentPlaces.postValue(places)
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
} catch (e: Exception) {
e.printStackTrace()
@@ -683,7 +693,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
}
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
recentPlaces.value = places
recentPlaces.postValue(places)
}
} catch (e: Exception) {
e.printStackTrace()
@@ -23,10 +23,8 @@ class RouteCalculator(var routeModel: RouteModel) {
fun findStep(location: Location) {
val route = routeModel.navState.route
val steps = routeModel.curLeg.steps
val startIndex = route.currentStepIndex
// Windowed search for performance (current + 2 steps)
//val endIndex = (startIndex + 2).coerceAtMost(steps.size - 1)
// Search from one step prior to handle deviations/U-turns
val startIndex = (route.currentStepIndex - 1).coerceAtLeast(0)
val endIndex = steps.size - 1
var nearestDistance = MAXIMUM_LOCATION_DISTANCE
@@ -35,7 +33,8 @@ class RouteCalculator(var routeModel: RouteModel) {
for (i in startIndex..endIndex) {
val step = steps[i]
val waypoints = step.maneuver.waypoints
val startWayIndex = if (i == startIndex) step.waypointIndex else 0
// Only offset search if we are exactly on the current stored step index
val startWayIndex = if (i == route.currentStepIndex) step.waypointIndex else 0
var lastDistance = Float.MAX_VALUE
var increaseCount = 0
@@ -50,13 +49,12 @@ class RouteCalculator(var routeModel: RouteModel) {
bestMatch = StepMatch(step.index, j, waypoint)
}
// Track if we are getting further away
if (distance > lastDistance) {
increaseCount++
} else {
increaseCount = 0
}
// Absolute early exit: found a close point and now moved far away
if (stopSearch(nearestDistance, distance, increaseCount)) {
bestMatch?.let { match ->
route.currentStepIndex = match.stepIndex
@@ -73,14 +71,14 @@ class RouteCalculator(var routeModel: RouteModel) {
route.currentStepIndex = match.stepIndex
steps[match.stepIndex].waypointIndex = match.waypointIndex
steps[match.stepIndex].wayPointLocation = match.location
}
Log.d(TAG, "FindStep not optimized: count=$searchCount, dist=$nearestDistance")
}
private fun stopSearch(nearestDistance: Float, distance: Float, increaseCount: Int): Boolean {
return (nearestDistance < NEAREST_LOCATION_DISTANCE && distance > NEAREST_LOCATION_DISTANCE * 10)
|| increaseCount > 10
// Increased multiplier from 10 to 20 to be less aggressive in sharp curves
return (nearestDistance < NEAREST_LOCATION_DISTANCE && distance > NEAREST_LOCATION_DISTANCE * 20)
|| increaseCount > 15
}
fun travelLeftTime(): Double {
@@ -101,7 +101,7 @@ class RouteCalculatorTest {
}
@Test
fun `findStep skips all steps before currentStepIndex`() {
fun `findStep considers previous step when searching`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy(
@@ -109,17 +109,17 @@ class RouteCalculatorTest {
)
val mockLocation: Location = mock()
whenever(mockLocation.distanceTo(any())).thenReturn(200F, 50F)
// Distance to step0 waypoints is very small, distance to step1 waypoints is large
whenever(mockLocation.distanceTo(any())).thenReturn(5F, 5F, 500F, 500F)
routeCalculator.findStep(mockLocation)
// step0 is skipped, so distanceTo is only called for step1's 2 waypoints
verify(mockLocation, times(2)).distanceTo(any())
assertEquals(1, routeModel.navState.route.currentStepIndex)
assertEquals(0, routeModel.navState.route.currentStepIndex)
}
@Test
fun `findStep breaks early once nearestDistance drops below NEAREST_LOCATION_DISTANCE`() {
fun `findStep breaks later with relaxed distance threshold`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
val step2 = createStep(index = 2, numWaypoints = 2)
@@ -128,17 +128,18 @@ class RouteCalculatorTest {
)
val mockLocation: Location = mock()
// step0/wp0: 500F, step0/wp1: 5F, step1/wp0: 200F
// 5F < NEAREST_LOCATION_DISTANCE (10F) AND 200F > 10 * 10F → break
whenever(mockLocation.distanceTo(any())).thenReturn(500F, 5F, 200F)
// step0/wp0: 500F, step0/wp1: 5F, step1/wp0: 150F, step1/wp1: 160F, step2/wp0: 210F, step2/wp1: 220F
// Here we purposefully exceed the 20 * 10F threshold at the end of step 1 or start of step 2
whenever(mockLocation.distanceTo(any())).thenReturn(500F, 5F, 150F, 160F, 210F, 220F)
routeCalculator.findStep(mockLocation)
// should stop after the 3rd call (step1/wp0 triggers stopSearch)
verify(mockLocation, times(3)).distanceTo(any())
// It should haveChecked step 0 (2), step 1 (2), and the first point of step 2 (1) where it finally breaks.
verify(mockLocation, times(5)).distanceTo(any())
assertEquals(0, routeModel.navState.route.currentStepIndex)
}
// ----------------------------------------------------------
// travelLeftTime
// ----------------------------------------------------------