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 # 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 ## 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 ## Build Commands
@@ -125,16 +125,16 @@ class PlaceListScreen(
/** /**
* Creates an Action to navigate to a specific place. * Creates an Action to navigate to a specific place.
*/ */
private fun clickOnPlace(it: Place) { private fun clickOnPlace(itPlace: Place) {
place = Place( place = Place(
0, 0,
it.name, itPlace.name,
it.category, itPlace.category,
it.latitude, itPlace.latitude,
it.longitude, itPlace.longitude,
it.postalCode, itPlace.postalCode,
it.city, itPlace.city,
it.street, itPlace.street,
// avatar = null // avatar = null
) )
if (surfaceRenderer.navigation) { if (surfaceRenderer.navigation) {
@@ -3,6 +3,7 @@ package com.kouros.navigation.car.screen
import android.text.SpannableString import android.text.SpannableString
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.util.Log
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.car.app.CarContext import androidx.car.app.CarContext
import androidx.car.app.CarToast import androidx.car.app.CarToast
@@ -32,6 +33,8 @@ import androidx.lifecycle.lifecycleScope
import com.kouros.data.R import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.RouteCarModel 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.Place
import com.kouros.navigation.data.ViewStyle import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.data.route.Routes import com.kouros.navigation.data.route.Routes
@@ -54,7 +57,7 @@ class RoutePreviewScreen(
private var showAlternativeRoute: Boolean private var showAlternativeRoute: Boolean
) : ) :
Screen(carContext) { Screen(carContext) {
private var isFavorite = false private var isFavorite = destination.favorite
val maxListItems: Int = 3 val maxListItems: Int = 3
@@ -90,6 +93,7 @@ class RoutePreviewScreen(
invalidate() invalidate()
} }
} }
val trafficObserver = Observer<Map<String, String>> { traffic -> val trafficObserver = Observer<Map<String, String>> { traffic ->
if (traffic.isNotEmpty()) { if (traffic.isNotEmpty()) {
navigationViewModel.traffic.value = emptyMap() navigationViewModel.traffic.value = emptyMap()
@@ -113,7 +117,6 @@ class RoutePreviewScreen(
}) })
repository.routingEngineFlow.asLiveData().observe(this, Observer { repository.routingEngineFlow.asLiveData().observe(this, Observer {
routingEngine = it routingEngine = it
}) })
repository.alternativeRoutesFlow.asLiveData().observe(this, Observer { repository.alternativeRoutesFlow.asLiveData().observe(this, Observer {
@@ -157,9 +160,6 @@ class RoutePreviewScreen(
header.addEndHeaderAction( header.addEndHeaderAction(
favoriteAction() favoriteAction()
) )
header.addEndHeaderAction(
deleteFavoriteAction()
)
} }
val message = val message =
if (routeModel.isNavigating() && routeModel.curRoute.waypoints.isNotEmpty()) { if (routeModel.isNavigating() && routeModel.curRoute.waypoints.isNotEmpty()) {
@@ -261,34 +261,17 @@ class RoutePreviewScreen(
else else
R.drawable.ic_favorite_white_24dp R.drawable.ic_favorite_white_24dp
, FLAG_IS_PERSISTENT, , FLAG_IS_PERSISTENT,
) { onClickAction = {
isFavorite = !isFavorite isFavorite = !isFavorite
CarToast.makeText( destination.favorite = isFavorite
carContext, if (isFavorite) {
if (isFavorite) navigationViewModel.saveFavorite(carContext, destination)
carContext } else {
.getString(R.string.favorites) navigationViewModel.deleteFavorite(carContext, destination)
else }
carContext.getString( invalidate()
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)
} }
isFavorite = !isFavorite )
finish()
})
private fun createRouteText(route: Routes): CarText { private fun createRouteText(route: Routes): CarText {
val time = route.summary.duration val time = route.summary.duration
@@ -120,12 +120,12 @@ class NavigationScreenTest {
`when`(mockRouteModel.isManeuverArrival()).thenReturn(true) `when`(mockRouteModel.isManeuverArrival()).thenReturn(true)
`when`(mockRouteModel.routeCalculator).thenReturn(mockRouteCalculator) `when`(mockRouteModel.routeCalculator).thenReturn(mockRouteCalculator)
`when`(mockRouteCalculator.leftStepDistance()).thenReturn(19.0) `when`(mockRouteCalculator.leftStepDistance()).thenReturn(9.0)
`when`(mockRouteModel.navState).thenReturn(NavigationState()) `when`(mockRouteModel.navState).thenReturn(NavigationState())
// Act // Act
//navigationScreen.checkArrival()
// Assert // 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 { 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( return Elements(
lon = lon, lon = lon,
lat = lat, 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 { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
@@ -10,10 +14,21 @@ android {
namespace = "com.kouros.data" namespace = "com.kouros.data"
compileSdk = 36 compileSdk = 36
val properties = Properties().apply {
val localPropertiesFile = project.rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
load(localPropertiesFile.inputStream())
}
}
defaultConfig { defaultConfig {
minSdk = 33 minSdk = 33
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro") consumerProguardFiles("consumer-rules.pro")
buildConfigField("String", "USER", "\"${properties.getProperty("USER") ?: ""}\"")
buildConfigField("String", "PASSWORD", "\"${properties.getProperty("PASSWORD") ?: ""}\"")
} }
buildFeatures { buildFeatures {
@@ -1,7 +1,6 @@
package com.kouros.navigation.data package com.kouros.navigation.data
import android.util.Log import com.kouros.data.BuildConfig
import java.io.File
data class ApplicationConfig( data class ApplicationConfig(
val user: String, val user: String,
@@ -10,35 +9,10 @@ data class ApplicationConfig(
companion object { companion object {
fun load(): ApplicationConfig { 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( return ApplicationConfig(
user = envVars.envOrLookup("USER"), user = BuildConfig.USER,
password = envVars.envOrLookup("PASSWORD"), 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 = "", var route: String = "",
@Transient @Transient
var stopOver: Boolean = false, var stopOver: Boolean = false,
@Transient
var favorite: Boolean = false
) )
data class ContactData( data class ContactData(
@@ -12,6 +12,7 @@ import java.net.URL
abstract class NavigationRepository { abstract class NavigationRepository {
private val config by lazy { ApplicationConfig.load() }
private val nominatimUrl = "https://nominatim.openstreetmap.org/" private val nominatimUrl = "https://nominatim.openstreetmap.org/"
//private val nominatimUrl = "https://kouros-online.de/nominatim/" //private val nominatimUrl = "https://kouros-online.de/nominatim/"
@@ -61,11 +62,15 @@ abstract class NavigationRepository {
try { try {
if (authenticator) { if (authenticator) {
Authenticator.setDefault(object : Authenticator() { Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication { override fun getPasswordAuthentication(): PasswordAuthentication? {
return PasswordAuthentication( return if (config.user.isEmpty() || config.password.isEmpty()) {
"kouros", null
"eo7sbjyWpmjSVFyELgbfrryqJ6ddNeq9".toCharArray() } else {
) PasswordAuthentication(
config.user,
config.password.toCharArray()
)
}
} }
}) })
} }
@@ -3,7 +3,7 @@ package com.kouros.navigation.data.overpass
data class Elements( data class Elements(
val bounds: Bounds, val bounds: Bounds,
val geometry: List<Geometry>, val geometry: List<Geometry> = emptyList(),
val id: Long = 0, val id: Long = 0,
val lat: Double= 0.0, val lat: Double= 0.0,
val lon: Double = 0.0, val lon: Double = 0.0,
@@ -13,6 +13,7 @@ import java.net.URL
class Overpass { class Overpass {
private val config by lazy { com.kouros.navigation.data.ApplicationConfig.load() }
private val gson = GsonBuilder().serializeNulls().create() private val gson = GsonBuilder().serializeNulls().create()
var overpassUrl = if (BuildConfig.DEBUG) var overpassUrl = if (BuildConfig.DEBUG)
@@ -121,11 +122,15 @@ class Overpass {
return try { return try {
Authenticator.setDefault(object : Authenticator() { Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication { override fun getPasswordAuthentication(): PasswordAuthentication? {
return PasswordAuthentication( return if (config.user.isEmpty() || config.password.isEmpty()) {
"kouros", null
"eo7sbjyWpmjSVFyELgbfrryqJ6ddNeq9".toCharArray() } else {
) PasswordAuthentication(
config.user,
config.password.toCharArray()
)
}
} }
}) })
@@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.kouros.navigation.data.Constants 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_BEARING_DEVIATION
import com.kouros.navigation.data.Constants.SPEED_UPDATE_DISTANCE import com.kouros.navigation.data.Constants.SPEED_UPDATE_DISTANCE
import com.kouros.navigation.data.Constants.TAG import com.kouros.navigation.data.Constants.TAG
@@ -163,8 +164,11 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
if (rp.isNotEmpty()) { if (rp.isNotEmpty()) {
for (place in places.places) { for (place in places.places) {
if (place.category == Constants.RECENT 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) val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) { if (place.latitude != 0.0) {
val distance = val distance =
@@ -399,11 +403,16 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
* Queries Overpass API for nearby amenities of a specific category. * Queries Overpass API for nearby amenities of a specific category.
* Posts sorted results to elements LiveData. * 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) { viewModelScope.launch(Dispatchers.IO) {
val repository = getSettingsRepository(carContext) val repository = getSettingsRepository(carContext)
val amenities = Overpass().getAmenities("amenity", category, location, 5.0) 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>() val distAmenities = mutableListOf<Elements>()
amenities.forEach { amenities.forEach {
val plLocation = val plLocation =
@@ -594,7 +603,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
synchronized(this) { synchronized(this) {
val stations = val stations =
FuelPrices().getFuelPrices(location, 3) 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.lastDate = current.atZone(ZoneOffset.UTC).toEpochSecond()
place.route = "" place.route = ""
places.add(place) places.add(place)
recentPlaces.postValue(places)
settingsRepository.setRecentPlaces(gson.toJson(Places(places))) settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -683,7 +693,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
} }
} }
settingsRepository.setRecentPlaces(gson.toJson(Places(places))) settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
recentPlaces.value = places recentPlaces.postValue(places)
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -23,10 +23,8 @@ class RouteCalculator(var routeModel: RouteModel) {
fun findStep(location: Location) { fun findStep(location: Location) {
val route = routeModel.navState.route val route = routeModel.navState.route
val steps = routeModel.curLeg.steps val steps = routeModel.curLeg.steps
val startIndex = route.currentStepIndex // Search from one step prior to handle deviations/U-turns
val startIndex = (route.currentStepIndex - 1).coerceAtLeast(0)
// Windowed search for performance (current + 2 steps)
//val endIndex = (startIndex + 2).coerceAtMost(steps.size - 1)
val endIndex = steps.size - 1 val endIndex = steps.size - 1
var nearestDistance = MAXIMUM_LOCATION_DISTANCE var nearestDistance = MAXIMUM_LOCATION_DISTANCE
@@ -35,7 +33,8 @@ class RouteCalculator(var routeModel: RouteModel) {
for (i in startIndex..endIndex) { for (i in startIndex..endIndex) {
val step = steps[i] val step = steps[i]
val waypoints = step.maneuver.waypoints 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 lastDistance = Float.MAX_VALUE
var increaseCount = 0 var increaseCount = 0
@@ -50,13 +49,12 @@ class RouteCalculator(var routeModel: RouteModel) {
bestMatch = StepMatch(step.index, j, waypoint) bestMatch = StepMatch(step.index, j, waypoint)
} }
// Track if we are getting further away
if (distance > lastDistance) { if (distance > lastDistance) {
increaseCount++ increaseCount++
} else { } else {
increaseCount = 0 increaseCount = 0
} }
// Absolute early exit: found a close point and now moved far away
if (stopSearch(nearestDistance, distance, increaseCount)) { if (stopSearch(nearestDistance, distance, increaseCount)) {
bestMatch?.let { match -> bestMatch?.let { match ->
route.currentStepIndex = match.stepIndex route.currentStepIndex = match.stepIndex
@@ -73,14 +71,14 @@ class RouteCalculator(var routeModel: RouteModel) {
route.currentStepIndex = match.stepIndex route.currentStepIndex = match.stepIndex
steps[match.stepIndex].waypointIndex = match.waypointIndex steps[match.stepIndex].waypointIndex = match.waypointIndex
steps[match.stepIndex].wayPointLocation = match.location steps[match.stepIndex].wayPointLocation = match.location
} }
Log.d(TAG, "FindStep not optimized: count=$searchCount, dist=$nearestDistance") Log.d(TAG, "FindStep not optimized: count=$searchCount, dist=$nearestDistance")
} }
private fun stopSearch(nearestDistance: Float, distance: Float, increaseCount: Int): Boolean { private fun stopSearch(nearestDistance: Float, distance: Float, increaseCount: Int): Boolean {
return (nearestDistance < NEAREST_LOCATION_DISTANCE && distance > NEAREST_LOCATION_DISTANCE * 10) // Increased multiplier from 10 to 20 to be less aggressive in sharp curves
|| increaseCount > 10 return (nearestDistance < NEAREST_LOCATION_DISTANCE && distance > NEAREST_LOCATION_DISTANCE * 20)
|| increaseCount > 15
} }
fun travelLeftTime(): Double { fun travelLeftTime(): Double {
@@ -101,7 +101,7 @@ class RouteCalculatorTest {
} }
@Test @Test
fun `findStep skips all steps before currentStepIndex`() { fun `findStep considers previous step when searching`() {
val step0 = createStep(index = 0, numWaypoints = 2) val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2) val step1 = createStep(index = 1, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy( routeModel.navState = routeModel.navState.copy(
@@ -109,17 +109,17 @@ class RouteCalculatorTest {
) )
val mockLocation: Location = mock() 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) routeCalculator.findStep(mockLocation)
// step0 is skipped, so distanceTo is only called for step1's 2 waypoints assertEquals(0, routeModel.navState.route.currentStepIndex)
verify(mockLocation, times(2)).distanceTo(any())
assertEquals(1, routeModel.navState.route.currentStepIndex)
} }
@Test @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 step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2) val step1 = createStep(index = 1, numWaypoints = 2)
val step2 = createStep(index = 2, numWaypoints = 2) val step2 = createStep(index = 2, numWaypoints = 2)
@@ -128,17 +128,18 @@ class RouteCalculatorTest {
) )
val mockLocation: Location = mock() val mockLocation: Location = mock()
// step0/wp0: 500F, step0/wp1: 5F, step1/wp0: 200F // step0/wp0: 500F, step0/wp1: 5F, step1/wp0: 150F, step1/wp1: 160F, step2/wp0: 210F, step2/wp1: 220F
// 5F < NEAREST_LOCATION_DISTANCE (10F) AND 200F > 10 * 10F → break // 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, 200F) whenever(mockLocation.distanceTo(any())).thenReturn(500F, 5F, 150F, 160F, 210F, 220F)
routeCalculator.findStep(mockLocation) routeCalculator.findStep(mockLocation)
// should stop after the 3rd call (step1/wp0 triggers stopSearch) // It should haveChecked step 0 (2), step 1 (2), and the first point of step 2 (1) where it finally breaks.
verify(mockLocation, times(3)).distanceTo(any()) verify(mockLocation, times(5)).distanceTo(any())
assertEquals(0, routeModel.navState.route.currentStepIndex) assertEquals(0, routeModel.navState.route.currentStepIndex)
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
// travelLeftTime // travelLeftTime
// ---------------------------------------------------------- // ----------------------------------------------------------