From 29e58f6a246254241b27f1546b52fc9af2c35e78 Mon Sep 17 00:00:00 2001 From: Dimitris Date: Fri, 8 May 2026 11:08:40 +0200 Subject: [PATCH] ApplicationConfig, Favorites --- CLAUDE.md | 4 +- .../navigation/car/screen/PlaceListScreen.kt | 16 +++---- .../car/screen/RoutePreviewScreen.kt | 47 ++++++------------- .../car/screen/NavigationScreenTest.kt | 6 +-- .../screen/observers/CategoryObserverTest.kt | 5 +- .../car/screen/observers/ObserversTest.kt | 3 +- common/data/build.gradle.kts | 15 ++++++ .../navigation/data/ApplicationConfig.kt | 32 ++----------- .../java/com/kouros/navigation/data/Data.kt | 2 + .../navigation/data/NavigationRepository.kt | 15 ++++-- .../navigation/data/overpass/Elements.kt | 2 +- .../navigation/data/overpass/Overpass.kt | 15 ++++-- .../navigation/model/NavigationViewModel.kt | 20 ++++++-- .../navigation/model/RouteCalculator.kt | 18 ++++--- .../navigation/model/RouteCalculatorTest.kt | 23 ++++----- 15 files changed, 110 insertions(+), 113 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9dc7667..089d458 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/common/car/src/main/java/com/kouros/navigation/car/screen/PlaceListScreen.kt b/common/car/src/main/java/com/kouros/navigation/car/screen/PlaceListScreen.kt index 6e73f99..e4b6522 100644 --- a/common/car/src/main/java/com/kouros/navigation/car/screen/PlaceListScreen.kt +++ b/common/car/src/main/java/com/kouros/navigation/car/screen/PlaceListScreen.kt @@ -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) { diff --git a/common/car/src/main/java/com/kouros/navigation/car/screen/RoutePreviewScreen.kt b/common/car/src/main/java/com/kouros/navigation/car/screen/RoutePreviewScreen.kt index fa580f4..7f6ecdb 100644 --- a/common/car/src/main/java/com/kouros/navigation/car/screen/RoutePreviewScreen.kt +++ b/common/car/src/main/java/com/kouros/navigation/car/screen/RoutePreviewScreen.kt @@ -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> { 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 diff --git a/common/car/src/test/java/com/kouros/navigation/car/screen/NavigationScreenTest.kt b/common/car/src/test/java/com/kouros/navigation/car/screen/NavigationScreenTest.kt index d2ace59..355923f 100644 --- a/common/car/src/test/java/com/kouros/navigation/car/screen/NavigationScreenTest.kt +++ b/common/car/src/test/java/com/kouros/navigation/car/screen/NavigationScreenTest.kt @@ -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) } } diff --git a/common/car/src/test/java/com/kouros/navigation/car/screen/observers/CategoryObserverTest.kt b/common/car/src/test/java/com/kouros/navigation/car/screen/observers/CategoryObserverTest.kt index e2aa879..ca32972 100644 --- a/common/car/src/test/java/com/kouros/navigation/car/screen/observers/CategoryObserverTest.kt +++ b/common/car/src/test/java/com/kouros/navigation/car/screen/observers/CategoryObserverTest.kt @@ -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)) } } diff --git a/common/car/src/test/java/com/kouros/navigation/car/screen/observers/ObserversTest.kt b/common/car/src/test/java/com/kouros/navigation/car/screen/observers/ObserversTest.kt index 7e6f73c..4e49819 100644 --- a/common/car/src/test/java/com/kouros/navigation/car/screen/observers/ObserversTest.kt +++ b/common/car/src/test/java/com/kouros/navigation/car/screen/observers/ObserversTest.kt @@ -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), ) } } diff --git a/common/data/build.gradle.kts b/common/data/build.gradle.kts index 4a7fc2d..a6ad6b4 100644 --- a/common/data/build.gradle.kts +++ b/common/data/build.gradle.kts @@ -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 { diff --git a/common/data/src/main/java/com/kouros/navigation/data/ApplicationConfig.kt b/common/data/src/main/java/com/kouros/navigation/data/ApplicationConfig.kt index 7867731..2f0f815 100644 --- a/common/data/src/main/java/com/kouros/navigation/data/ApplicationConfig.kt +++ b/common/data/src/main/java/com/kouros/navigation/data/ApplicationConfig.kt @@ -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.envOrLookup(key: String): String { - return System.getenv(key) ?: this[key]!! - } - - val envVars: Map = 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() } -} \ No newline at end of file diff --git a/common/data/src/main/java/com/kouros/navigation/data/Data.kt b/common/data/src/main/java/com/kouros/navigation/data/Data.kt index fe7f97f..8523eff 100644 --- a/common/data/src/main/java/com/kouros/navigation/data/Data.kt +++ b/common/data/src/main/java/com/kouros/navigation/data/Data.kt @@ -55,6 +55,8 @@ data class Place( var route: String = "", @Transient var stopOver: Boolean = false, + @Transient + var favorite: Boolean = false ) data class ContactData( diff --git a/common/data/src/main/java/com/kouros/navigation/data/NavigationRepository.kt b/common/data/src/main/java/com/kouros/navigation/data/NavigationRepository.kt index c911d31..bff1dd3 100644 --- a/common/data/src/main/java/com/kouros/navigation/data/NavigationRepository.kt +++ b/common/data/src/main/java/com/kouros/navigation/data/NavigationRepository.kt @@ -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() + ) + } } }) } diff --git a/common/data/src/main/java/com/kouros/navigation/data/overpass/Elements.kt b/common/data/src/main/java/com/kouros/navigation/data/overpass/Elements.kt index ba8d5be..644d5b4 100644 --- a/common/data/src/main/java/com/kouros/navigation/data/overpass/Elements.kt +++ b/common/data/src/main/java/com/kouros/navigation/data/overpass/Elements.kt @@ -3,7 +3,7 @@ package com.kouros.navigation.data.overpass data class Elements( val bounds: Bounds, - val geometry: List, + val geometry: List = emptyList(), val id: Long = 0, val lat: Double= 0.0, val lon: Double = 0.0, diff --git a/common/data/src/main/java/com/kouros/navigation/data/overpass/Overpass.kt b/common/data/src/main/java/com/kouros/navigation/data/overpass/Overpass.kt index df09680..592f79a 100644 --- a/common/data/src/main/java/com/kouros/navigation/data/overpass/Overpass.kt +++ b/common/data/src/main/java/com/kouros/navigation/data/overpass/Overpass.kt @@ -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() + ) + } } }) diff --git a/common/data/src/main/java/com/kouros/navigation/model/NavigationViewModel.kt b/common/data/src/main/java/com/kouros/navigation/model/NavigationViewModel.kt index befe4b2..8eed352 100644 --- a/common/data/src/main/java/com/kouros/navigation/model/NavigationViewModel.kt +++ b/common/data/src/main/java/com/kouros/navigation/model/NavigationViewModel.kt @@ -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() 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() diff --git a/common/data/src/main/java/com/kouros/navigation/model/RouteCalculator.kt b/common/data/src/main/java/com/kouros/navigation/model/RouteCalculator.kt index 4106790..cf90e92 100644 --- a/common/data/src/main/java/com/kouros/navigation/model/RouteCalculator.kt +++ b/common/data/src/main/java/com/kouros/navigation/model/RouteCalculator.kt @@ -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 { diff --git a/common/data/src/test/java/com/kouros/navigation/model/RouteCalculatorTest.kt b/common/data/src/test/java/com/kouros/navigation/model/RouteCalculatorTest.kt index b0881c1..5a630ae 100644 --- a/common/data/src/test/java/com/kouros/navigation/model/RouteCalculatorTest.kt +++ b/common/data/src/test/java/com/kouros/navigation/model/RouteCalculatorTest.kt @@ -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 have’Checked 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 // ----------------------------------------------------------