Testing, Remove ObjectBox

This commit is contained in:
Dimitris
2026-02-28 13:10:48 +01:00
parent eb6d3e4ef7
commit a468529ca4
35 changed files with 1148 additions and 431 deletions

View File

@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
} }
@@ -14,8 +13,8 @@ android {
applicationId = "com.kouros.navigation" applicationId = "com.kouros.navigation"
minSdk = 33 minSdk = 33
targetSdk = 36 targetSdk = 36
versionCode = 50 versionCode = 56
versionName = "0.2.0.50" versionName = "0.2.0.56"
base.archivesName = "navi-$versionName" base.archivesName = "navi-$versionName"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -37,7 +36,11 @@ android {
buildTypes { buildTypes {
release { release {
// Enables code-related app optimization.
isMinifyEnabled = false isMinifyEnabled = false
// Enables resource shrinking.
isShrinkResources = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@@ -59,14 +62,10 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
} }
@@ -81,8 +80,7 @@ dependencies {
implementation(libs.androidx.runtime.livedata) implementation(libs.androidx.runtime.livedata)
implementation(libs.koin.androidx.compose) implementation(libs.koin.androidx.compose)
implementation(libs.maplibre.compose) implementation(libs.maplibre.compose)
//implementation(libs.maplibre.composeMaterial3)
implementation(libs.androidx.app.projected)
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(project(":common:data")) implementation(project(":common:data"))
@@ -95,7 +93,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.window) implementation(libs.androidx.window)
implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.foundation.layout)
implementation("com.github.ticofab:android-gpx-parser:2.3.1") implementation(libs.android.gpx.parser)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.foundation.layout)

View File

@@ -2,7 +2,6 @@ package com.kouros.navigation
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import com.kouros.navigation.data.ObjectBox
import com.kouros.navigation.di.appModule import com.kouros.navigation.di.appModule
import com.kouros.navigation.model.NavigationViewModel import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.NavigationUtils.getViewModel import com.kouros.navigation.utils.NavigationUtils.getViewModel
@@ -15,7 +14,6 @@ class MainApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
ObjectBox.init(this)
appContext = applicationContext appContext = applicationContext
navigationViewModel = getViewModel(appContext!!) navigationViewModel = getViewModel(appContext!!)
startKoin { startKoin {

View File

@@ -36,7 +36,6 @@ fun test(applicationContext: Context, routeModel: RouteModel) {
for ((index, step) in routeModel.curLeg.steps.withIndex()) { for ((index, step) in routeModel.curLeg.steps.withIndex()) {
for ((windex, waypoint) in step.maneuver.waypoints.withIndex()) { for ((windex, waypoint) in step.maneuver.waypoints.withIndex()) {
routeModel.updateLocation( routeModel.updateLocation(
applicationContext,
location(waypoint[0], waypoint[1]), navigationViewModel location(waypoint[0], waypoint[1]), navigationViewModel
) )
val step = routeModel.currentStep() val step = routeModel.currentStep()
@@ -81,7 +80,6 @@ fun testSingleUpdate(
mock.setMockLocation(latitude, longitude, 0F) mock.setMockLocation(latitude, longitude, 0F)
} else { } else {
routeModel.updateLocation( routeModel.updateLocation(
applicationContext,
location(longitude, latitude), navigationViewModel location(longitude, latitude), navigationViewModel
) )
} }
@@ -110,10 +108,8 @@ fun gpx(context: Context, mock: MockLocation) {
speed = ext.speed speed = ext.speed
mock.curSpeed = speed.toFloat() mock.curSpeed = speed.toFloat()
} }
val duration = p.time.millis - lastTime.millis val duration = p.time.millis - lastTime.millis
val bearing = lastLocation.bearingTo(curLocation) val bearing = lastLocation.bearingTo(curLocation)
println("Bearing $bearing")
mock.setMockLocation(p.latitude, p.longitude, bearing) mock.setMockLocation(p.latitude, p.longitude, bearing)
if (duration > 0) { if (duration > 0) {
delay(duration / 5) delay(duration / 5)

View File

@@ -66,9 +66,12 @@ import com.kouros.navigation.ui.navigation.AppNavGraph
import com.kouros.navigation.ui.theme.NavigationTheme import com.kouros.navigation.ui.theme.NavigationTheme
import com.kouros.navigation.utils.GeoUtils.snapLocation import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.bearing import com.kouros.navigation.utils.bearing
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.location.DesiredAccuracy import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.Location import org.maplibre.compose.location.Location
@@ -83,7 +86,7 @@ class MainActivity : ComponentActivity() {
val routeModel = RouteModel() val routeModel = RouteModel()
var tilt = 50.0 var tilt = 50.0
val useMock = false val useMock = false
val type = SimulationType.GPX val type = SimulationType.SIMULATE
val stepData: MutableLiveData<StepData> by lazy { val stepData: MutableLiveData<StepData> by lazy {
MutableLiveData() MutableLiveData()
@@ -95,7 +98,13 @@ class MainActivity : ComponentActivity() {
var lastLocation = location(0.0, 0.0) var lastLocation = location(0.0, 0.0)
val observer = Observer<String> { newRoute -> val observer = Observer<String> { newRoute ->
if (newRoute.isNotEmpty()) { if (newRoute.isNotEmpty()) {
routeModel.startNavigation(newRoute, applicationContext) val repository = getSettingsRepository(applicationContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
routeModel.startNavigation(newRoute)
if (routeModel.hasLegs()) {
getSettingsViewModel(applicationContext).onLastRouteChanged(newRoute)
}
routeData.value = routeModel.curRoute.routeGeoJson routeData.value = routeModel.curRoute.routeGeoJson
if (useMock) { if (useMock) {
when (type) { when (type) {
@@ -189,7 +198,7 @@ class MainActivity : ComponentActivity() {
val scaffoldState = rememberBottomSheetScaffoldState() val scaffoldState = rememberBottomSheetScaffoldState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetPeekHeight = 250.dp val sheetPeekHeight = 180.dp
val sheetPeekHeightState = remember { mutableStateOf(sheetPeekHeight) } val sheetPeekHeightState = remember { mutableStateOf(sheetPeekHeight) }
val locationProvider = rememberDefaultLocationProvider( val locationProvider = rememberDefaultLocationProvider(
@@ -324,7 +333,7 @@ class MainActivity : ComponentActivity() {
with(routeModel) { with(routeModel) {
if (isNavigating()) { if (isNavigating()) {
updateLocation(applicationContext, currentLocation, navigationViewModel) updateLocation( currentLocation, navigationViewModel)
stepData.value = currentStep() stepData.value = currentStep()
nextStepData.value = nextStep() nextStepData.value = nextStep()
if (navState.maneuverType in 39..42 && routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE) { if (navState.maneuverType in 39..42 && routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE) {
@@ -352,7 +361,8 @@ class MainActivity : ComponentActivity() {
val latitude = routeModel.curRoute.waypoints[0][1] val latitude = routeModel.curRoute.waypoints[0][1]
val longitude = routeModel.curRoute.waypoints[0][0] val longitude = routeModel.curRoute.waypoints[0][0]
closeSheet() closeSheet()
routeModel.stopNavigation(applicationContext) routeModel.stopNavigation()
getSettingsViewModel(applicationContext).onLastRouteChanged("")
if (useMock) { if (useMock) {
mock.setMockLocation(latitude, longitude, 0F) mock.setMockLocation(latitude, longitude, 0F)
} }

View File

@@ -97,7 +97,7 @@ fun Home(
) { ) {
Row(horizontalArrangement = Arrangement.SpaceBetween) { Row(horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = { Button(onClick = {
val places = viewModel.loadRecentPlace() val places = viewModel.loadRecentPlace(applicationContext)
val toLocation = location(places.first()!!.longitude, places.first()!!.latitude) val toLocation = location(places.first()!!.longitude, places.first()!!.latitude)
viewModel.loadRoute(applicationContext, location, toLocation, 0F) viewModel.loadRoute(applicationContext, location, toLocation, 0F)
closeSheet() closeSheet()
@@ -211,7 +211,7 @@ private fun SearchPlaces(
city = place.address.city, city = place.address.city,
street = place.address.road street = place.address.road
) )
viewModel.saveRecent(pl) viewModel.saveRecent(context,pl)
val toLocation = val toLocation =
location(place.lon.toDouble(), place.lat.toDouble()) location(place.lon.toDouble(), place.lat.toDouble())
viewModel.loadRoute(context, location, toLocation, 0F) viewModel.loadRoute(context, location, toLocation, 0F)

View File

@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
} }
android { android {
@@ -31,13 +30,8 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
} }
} }

View File

@@ -2,17 +2,15 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
} }
buildscript { buildscript {
val objectboxVersion by extra("5.0.1") // For KTS build scripts
dependencies { dependencies {
classpath(libs.gradle) classpath(libs.gradle)
classpath("io.objectbox:objectbox-gradle-plugin:$objectboxVersion")
} }
} }

View File

@@ -1,8 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
} }
@@ -26,13 +23,8 @@ android {
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -46,8 +38,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
implementation(libs.maplibre.compose) implementation(libs.maplibre.compose)
//implementation(libs.maplibre.composeMaterial3) implementation(libs.androidx.app.projected)
implementation(project(":common:data")) implementation(project(":common:data"))
implementation(libs.androidx.runtime.livedata) implementation(libs.androidx.runtime.livedata)
implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.foundation)
@@ -56,6 +47,11 @@ dependencies {
implementation(libs.androidx.compose.ui.text) implementation(libs.androidx.compose.ui.text)
implementation(libs.play.services.location) implementation(libs.play.services.location)
implementation(libs.androidx.datastore.core) implementation(libs.androidx.datastore.core)
implementation(libs.androidx.monitor)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.rules)
} }

View File

@@ -0,0 +1,44 @@
package com.kouros.navigation.car
import android.location.Location
import android.location.LocationManager
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kouros.navigation.utils.GeoUtils
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.maplibre.geojson.Point
@RunWith(AndroidJUnit4::class)
class GeoUtilsTest {
@Test
fun snapLocation() {
val location = Location(LocationManager.GPS_PROVIDER)
location.latitude = 48.18600
location.longitude = 11.57844
val stepCoordinates = listOf(
Point.fromLngLat(11.57841, 48.18557),
Point.fromLngLat(11.57844, 48.18566),
Point.fromLngLat(11.57848, 48.18595),
Point.fromLngLat(11.57848, 48.18604),
Point.fromLngLat(11.57857, 48.18696),
)
val result = GeoUtils.snapLocation(location, stepCoordinates)
assertEquals(result.latitude, 48.18599999996868, 0.0001)
}
@Test
fun `createCenterLocation calculates center of GeoJSON`() {
val geoJson =
"""{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[11.0,48.0]},"properties":{}},{"type":"Feature","geometry":{"type":"Point","coordinates":[11.1,48.1]},"properties":{}}]}"""
val result = GeoUtils.createCenterLocation(geoJson)
// Center should be roughly halfway between the two points
assertEquals(48.05, result.latitude, 0.01)
assertEquals(11.05, result.longitude, 0.01)
}
}

View File

@@ -0,0 +1,144 @@
package com.kouros.navigation.car
import android.location.Location
import android.location.LocationManager
import androidx.car.app.navigation.model.Maneuver
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kouros.data.R
import com.kouros.navigation.data.Constants.homeHohenwaldeck
import com.kouros.navigation.data.Constants.homeVogelhart
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.location
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import org.junit.Before
import kotlin.collections.forEach
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class RouteModelTest {
val routeModel = RouteModel()
val location = Location(LocationManager.GPS_PROVIDER)
@Before
fun setup() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
val repository = getSettingsRepository(appContext)
runBlocking { repository.setRoutingEngine(RouteEngine.TOMTOM.ordinal) }
val routeJson = appContext.resources.openRawResource(R.raw.tomom_routing)
val routeJsonString = routeJson.bufferedReader().use { it.readText() }
assertNotEquals("", routeJsonString)
routeModel.navState = routeModel.navState.copy(routingEngine = RouteEngine.TOMTOM.ordinal)
routeModel.startNavigation(routeJsonString)
}
@Test
fun checkRoute() {
assertEquals(true, routeModel.isNavigating())
assertEquals(routeModel.curRoute.summary.distance, 11116.0, 10.0)
assertEquals(routeModel.curRoute.summary.duration, 1148.0, 10.0)
}
@Test
fun checkDeparture() {
location.latitude = 48.185569
location.longitude = 11.579034
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(stepData.instruction, "Silcherstraße")
assertEquals(stepData.leftStepDistance, 70.0, 1.0)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(nextStepData.instruction, "Schmalkaldener Straße")
}
@Test
fun checkSchmalkadener20() {
location.latitude = 48.187057
location.longitude = 11.576652
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(stepData.instruction, "Schmalkaldener Straße")
assertEquals(stepData.leftStepDistance, 0.0, 1.0)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(nextStepData.instruction, "Ingolstädter Straße")
}
@Test
fun checkIngol() {
location.latitude = 48.180555
location.longitude = 11.585125
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_STRAIGHT)
assertEquals(stepData.instruction, "Ingolstädter Straße")
assertEquals(stepData.leftStepDistance, 350.0, 1.0)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_LEFT)
assertEquals(nextStepData.instruction, "Schenkendorfstraße")
}
@Test
fun checkIngol2() {
location.latitude = 48.179286
location.longitude = 11.585258
location.bearing = 180.0F
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_LEFT)
assertEquals(stepData.instruction, "Schenkendorfstraße")
assertEquals(stepData.leftStepDistance, 240.0, 1.0)
assertEquals(stepData.lane.size, 4)
assertEquals(stepData.lane.first().valid, true)
assertEquals(stepData.lane.last().valid, false)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_KEEP_LEFT)
assertEquals(nextStepData.instruction, "Schenkendorfstraße")
}
@Test
fun checkDestination() {
location.latitude = homeHohenwaldeck.latitude
location.longitude = homeHohenwaldeck.longitude
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.nextStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_DESTINATION_LEFT)
}
@Test
fun simulate() {
for ((index, waypoint) in routeModel.curRoute.waypoints.withIndex()) {
val curLocation = location(waypoint[0], waypoint[1])
if (routeModel.isNavigating()) {
if (index in 0..routeModel.curRoute.waypoints.size) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
val nextData = routeModel.nextStep()
println("${stepData.leftStepDistance} : ${stepData.instruction} ${stepData.currentManeuverType} : ${nextData.instruction} ${nextData.currentManeuverType}")
}
}
}
}
}

View File

@@ -26,7 +26,6 @@ import com.kouros.navigation.car.screen.SearchScreen
import com.kouros.navigation.data.Constants.MAXIMAL_ROUTE_DEVIATION import com.kouros.navigation.data.Constants.MAXIMAL_ROUTE_DEVIATION
import com.kouros.navigation.data.Constants.MAXIMAL_SNAP_CORRECTION import com.kouros.navigation.data.Constants.MAXIMAL_SNAP_CORRECTION
import com.kouros.navigation.data.Constants.TAG import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.ObjectBox
import com.kouros.navigation.data.RouteEngine import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.osrm.OsrmRepository import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.tomtom.TomTomRepository import com.kouros.navigation.data.tomtom.TomTomRepository
@@ -119,20 +118,27 @@ class NavigationSession : Session(), NavigationScreen.Listener {
*/ */
fun onConnectionStateUpdated(connectionState: Int) { fun onConnectionStateUpdated(connectionState: Int) {
routeModel.navState = routeModel.navState.copy(carConnection = connectionState) routeModel.navState = routeModel.navState.copy(carConnection = connectionState)
if (::carSensorManager.isInitialized) {
carSensorManager.updateConnectionState(connectionState)
}
when (connectionState) { when (connectionState) {
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> Unit CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> Unit
CarConnection.CONNECTION_TYPE_NATIVE -> { CarConnection.CONNECTION_TYPE_NATIVE -> {
ObjectBox.init(carContext)
navigationScreen.checkPermission("android.car.permission.CAR_SPEED") navigationScreen.checkPermission("android.car.permission.CAR_SPEED")
if (carContext.checkSelfPermission("android.car.permission.CAR_SPEED") == PackageManager.PERMISSION_GRANTED) {
if (::carSensorManager.isInitialized) {
carSensorManager.updateConnectionState(connectionState)
}
}
} }
CarConnection.CONNECTION_TYPE_PROJECTION -> { CarConnection.CONNECTION_TYPE_PROJECTION -> {
navigationScreen.checkPermission("com.google.android.gms.permission.CAR_SPEED") navigationScreen.checkPermission("com.google.android.gms.permission.CAR_SPEED")
if (carContext.checkSelfPermission("com.google.android.gms.permission.CAR_SPEED") == PackageManager.PERMISSION_GRANTED) {
if (::carSensorManager.isInitialized) {
carSensorManager.updateConnectionState(connectionState)
} }
} }
} }
}
}
/** /**
* Creates the initial screen for the session. * Creates the initial screen for the session.
@@ -318,13 +324,11 @@ class NavigationSession : Session(), NavigationScreen.Listener {
* Snaps location to route and checks for deviation requiring reroute. * Snaps location to route and checks for deviation requiring reroute.
*/ */
private fun handleNavigationLocation(location: Location) { private fun handleNavigationLocation(location: Location) {
routeModel.navState = routeModel.navState.copy(travelMessage = "${location.latitude} ${location.longitude}")
navigationScreen.updateTrip(location) navigationScreen.updateTrip(location)
if (routeModel.navState.arrived) return if (routeModel.navState.arrived) return
val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations()) val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations())
val distance = location.distanceTo(snappedLocation) val distance = location.distanceTo(snappedLocation)
when { when {
distance > MAXIMAL_ROUTE_DEVIATION -> { distance > MAXIMAL_ROUTE_DEVIATION -> {
navigationScreen.calculateNewRoute(routeModel.navState.destination) navigationScreen.calculateNewRoute(routeModel.navState.destination)
@@ -342,8 +346,8 @@ class NavigationSession : Session(), NavigationScreen.Listener {
* Stops active navigation and clears route state. * Stops active navigation and clears route state.
* Called when user exits navigation or arrives at destination. * Called when user exits navigation or arrives at destination.
*/ */
override fun stopNavigation(context: CarContext) { override fun stopNavigation() {
routeModel.stopNavigation(context) routeModel.stopNavigation()
} }

View File

@@ -10,7 +10,6 @@ import androidx.car.app.AppManager
import androidx.car.app.CarContext import androidx.car.app.CarContext
import androidx.car.app.SurfaceCallback import androidx.car.app.SurfaceCallback
import androidx.car.app.SurfaceContainer import androidx.car.app.SurfaceContainer
import androidx.car.app.connection.CarConnection
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -31,7 +30,6 @@ import com.kouros.navigation.car.map.cameraState
import com.kouros.navigation.car.map.getPaddingValues import com.kouros.navigation.car.map.getPaddingValues
import com.kouros.navigation.car.navigation.RouteCarModel import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.data.Constants.homeVogelhart import com.kouros.navigation.data.Constants.homeVogelhart
import com.kouros.navigation.data.ObjectBox
import com.kouros.navigation.data.RouteEngine import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.model.BaseStyleModel import com.kouros.navigation.model.BaseStyleModel
import com.kouros.navigation.model.RouteModel import com.kouros.navigation.model.RouteModel
@@ -292,7 +290,8 @@ class SurfaceRenderer(
currentSpeed, currentSpeed,
speed!!, speed!!,
width, width,
height height,
0.0
) )
} }
LaunchedEffect(position, viewStyle) { LaunchedEffect(position, viewStyle) {

View File

@@ -116,7 +116,7 @@ fun MapLibre(
@Composable @Composable
fun RouteLayer(routeData: String?, trafficData: Map<String, String>) { fun RouteLayer(routeData: String?, trafficData: Map<String, String>) {
if (routeData != null && routeData.isNotEmpty()) { if (!routeData.isNullOrEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData)) val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
LineLayer( LineLayer(
id = "routes-casing", id = "routes-casing",
@@ -184,7 +184,7 @@ fun RouteLayer(routeData: String?, trafficData: Map<String, String>) {
@Composable @Composable
fun RouteLayerPoint(routeData: String?) { fun RouteLayerPoint(routeData: String?) {
if (routeData != null && routeData.isNotEmpty()) { if (!routeData.isNullOrEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData)) val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
val img = image(painterResource(R.drawable.ic_favorite_filled_white_24dp), drawAsSdf = true) val img = image(painterResource(R.drawable.ic_favorite_filled_white_24dp), drawAsSdf = true)
SymbolLayer( SymbolLayer(
@@ -209,11 +209,11 @@ fun RouteLayerPoint(routeData: String?) {
fun trafficColor(key: String): Expression<ColorValue> { fun trafficColor(key: String): Expression<ColorValue> {
when (key) { when (key) {
"queuing" -> return const(Color(0xFFD24417)) "queuing" -> return const(Color(0xFFC46E53))
"stationary" -> return const(Color(0xFFFF0000)) "stationary" -> return const(Color(0xFFFF0000))
"heavy" -> return const(Color(0xFF6B0404)) "heavy" -> return const(Color(0xFF6B0404))
"slow" -> return const(Color(0xFFC41F1F)) "slow" -> return const(Color(0xFFBD2525))
"roadworks" -> return const(Color(0xFF7A631A)) "roadworks" -> return const(Color(0xFF725A0F))
} }
return const(Color.Blue) return const(Color.Blue)
} }
@@ -291,7 +291,8 @@ fun DrawNavigationImages(
speed: Float?, speed: Float?,
maxSpeed: Int, maxSpeed: Int,
width: Int, width: Int,
height: Int height: Int,
lat: Double?
) { ) {
NavigationImage(padding, width, height) NavigationImage(padding, width, height)
if (speed != null) { if (speed != null) {
@@ -300,26 +301,26 @@ fun DrawNavigationImages(
if (speed != null && maxSpeed > 0 && (speed * 3.6) > maxSpeed) { if (speed != null && maxSpeed > 0 && (speed * 3.6) > maxSpeed) {
MaxSpeed(width, height, maxSpeed) MaxSpeed(width, height, maxSpeed)
} }
//DebugInfo(width, height, routeModel) //DebugInfo(width, height, lat!!)
} }
@Composable @Composable
fun NavigationImage(padding: PaddingValues, width: Int, height: Int) { fun NavigationImage(padding: PaddingValues, width: Int, height: Int) {
val imageSize = (height / 8) val imageSize = (height / 8)
val color = remember { NavigationColor } val navigationColor = remember { NavigationColor }
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(padding)) { Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(padding)) {
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.size(imageSize.dp, imageSize.dp) .size(imageSize.dp, imageSize.dp)
) { ) {
scale(scaleX = 1f, scaleY = 0.7f) { scale(scaleX = 1f, scaleY = 0.7f) {
drawCircle(Color.DarkGray.copy(alpha = 0.3f)) drawCircle(navigationColor.copy(alpha = 0.3f))
} }
} }
Icon( Icon(
painter = painterResource(id = R.drawable.navigation_48px), painter = painterResource(id = R.drawable.navigation_48px),
"Navigation", "Navigation",
tint = color.copy(alpha = 0.7f), tint = navigationColor.copy(alpha = 0.7f),
modifier = Modifier modifier = Modifier
.size(imageSize.dp, imageSize.dp) .size(imageSize.dp, imageSize.dp)
.scale(scaleX = 1f, scaleY = 0.7f), .scale(scaleX = 1f, scaleY = 0.7f),
@@ -453,7 +454,7 @@ private fun MaxSpeed(
fun DebugInfo( fun DebugInfo(
width: Int, width: Int,
height: Int, height: Int,
routeModel: RouteModel, latitude: Double,
) { ) {
Box( Box(
@@ -465,19 +466,18 @@ fun DebugInfo(
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
val textMeasurerLocation = rememberTextMeasurer() val textMeasurerLocation = rememberTextMeasurer()
val location = routeModel.navState.currentLocation.latitude.toString()
val styleSpeed = TextStyle( val styleSpeed = TextStyle(
fontSize = 26.sp, fontSize = 26.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.Black, color = Color.Black,
) )
val textLayoutLocation = remember(location) { val textLayoutLocation = remember(latitude.toString()) {
textMeasurerLocation.measure(location, styleSpeed) textMeasurerLocation.measure(latitude.toString(), styleSpeed)
} }
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
drawText( drawText(
textMeasurer = textMeasurerLocation, textMeasurer = textMeasurerLocation,
text = location, text = latitude.toString(),
style = styleSpeed, style = styleSpeed,
topLeft = Offset( topLeft = Offset(
x = center.x - textLayoutLocation.size.width / 2, x = center.x - textLayoutLocation.size.width / 2,

View File

@@ -1,18 +1,3 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kouros.navigation.car.navigation package com.kouros.navigation.car.navigation
import android.location.Location import android.location.Location
@@ -138,9 +123,9 @@ class RouteCarModel() : RouteModel() {
var laneImageAdded = false var laneImageAdded = false
stepData.lane.forEach { stepData.lane.forEach {
if (it.indications.isNotEmpty() && it.valid) { if (it.indications.isNotEmpty() && it.valid) {
Collections.sort<String>(it.indications) val sorted = it.indications.sorted()
var direction = "" var direction = ""
it.indications.forEach { it2 -> sorted.forEach { it2 ->
direction = if (direction.isEmpty()) { direction = if (direction.isEmpty()) {
it2.trim() it2.trim()
} else { } else {
@@ -212,7 +197,6 @@ class RouteCarModel() : RouteModel() {
.addAction(dismissAction).setCallback(object : AlertCallback { .addAction(dismissAction).setCallback(object : AlertCallback {
override fun onCancel(reason: Int) { override fun onCancel(reason: Int) {
} }
override fun onDismiss() { override fun onDismiss() {
} }
}).build() }).build()

View File

@@ -24,22 +24,25 @@ import androidx.car.app.navigation.model.MessageInfo
import androidx.car.app.navigation.model.NavigationTemplate import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo import androidx.car.app.navigation.model.RoutingInfo
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope 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.ViewStyle import com.kouros.navigation.car.ViewStyle
import com.kouros.navigation.car.navigation.RouteCarModel import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.observers.NavigationObserverCallback
import com.kouros.navigation.car.screen.observers.NavigationObserverManager
import com.kouros.navigation.data.Constants import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.Place import com.kouros.navigation.data.Place
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.Elements import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.model.NavigationViewModel import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.GeoUtils import com.kouros.navigation.utils.GeoUtils
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -51,58 +54,67 @@ class NavigationScreen(
private var routeModel: RouteCarModel, private var routeModel: RouteCarModel,
private var listener: Listener, private var listener: Listener,
private val navigationViewModel: NavigationViewModel private val navigationViewModel: NavigationViewModel
) : ) : Screen(carContext), NavigationObserverCallback {
Screen(carContext) {
/** A listener for navigation start and stop signals. */ /** A listener for navigation start and stop signals. */
interface Listener { interface Listener {
/** Stops navigation. */ /** Stops navigation. */
fun stopNavigation(context: CarContext) fun stopNavigation()
} }
val backGroundColor = CarColor.BLUE
var currentNavigationLocation = Location(LocationManager.GPS_PROVIDER) var currentNavigationLocation = Location(LocationManager.GPS_PROVIDER)
var recentPlace = Place() var recentPlace = Place()
var navigationType = NavigationType.VIEW var navigationType = NavigationType.VIEW
var lastTrafficDate: LocalDateTime? = LocalDateTime.of(1960, 6, 21, 0, 0) var lastTrafficDate: LocalDateTime? = LocalDateTime.of(1960, 6, 21, 0, 0)
val observer = Observer<String> { route -> var lastCameraSearch = 0
var speedCameras = listOf<Elements>()
val observerManager = NavigationObserverManager(navigationViewModel, this)
init {
observerManager.attachAllObservers(this)
lifecycleScope.launch {
getSettingsViewModel(carContext).routingEngine.collect {
}
}
}
// NavigationObserverCallback implementations
override fun onRouteReceived(route: String) {
if (route.isNotEmpty()) { if (route.isNotEmpty()) {
val repository = getSettingsRepository(carContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
navigationType = NavigationType.NAVIGATION navigationType = NavigationType.NAVIGATION
routeModel.startNavigation(route, carContext) routeModel.startNavigation(route)
if (routeModel.hasLegs()) {
getSettingsViewModel(carContext).onLastRouteChanged(route)
}
surfaceRenderer.setRouteData() surfaceRenderer.setRouteData()
invalidate() invalidate()
} }
} }
val recentObserver = Observer<Place> { lastPlace -> override fun isNavigating(): Boolean = routeModel.isNavigating()
if (!routeModel.isNavigating()) {
recentPlace = lastPlace override fun onRecentPlaceReceived(place: Place) {
recentPlace = place
navigationType = NavigationType.RECENT navigationType = NavigationType.RECENT
invalidate() invalidate()
} }
}
val trafficObserver = Observer<Map<String, String>> { traffic -> override fun onTrafficReceived(traffic: Map<String, String>) {
surfaceRenderer.setTrafficData(traffic) surfaceRenderer.setTrafficData(traffic)
invalidate()
} }
val placeObserver = Observer<SearchResult> { searchResult -> override fun onPlaceSearchResultReceived(place: Place) {
val place = Place(
name = searchResult.displayName,
street = searchResult.address.road,
city = searchResult.address.city,
latitude = searchResult.lat.toDouble(),
longitude = searchResult.lon.toDouble(),
category = Constants.CONTACTS,
postalCode = searchResult.address.postcode
)
navigateToPlace(place) navigateToPlace(place)
} }
var lastCameraSearch = 0 override fun onSpeedCamerasReceived(cameras: List<Elements>) {
var speedCameras = listOf<Elements>()
val speedObserver = Observer<List<Elements>> { cameras ->
speedCameras = cameras speedCameras = cameras
val coordinates = mutableListOf<List<Double>>() val coordinates = mutableListOf<List<Double>>()
cameras.forEach { cameras.forEach {
@@ -112,21 +124,12 @@ class NavigationScreen(
surfaceRenderer.speedCamerasData.value = speedData surfaceRenderer.speedCamerasData.value = speedData
} }
val maxSpeedObserver = Observer<Int> { speed -> override fun onMaxSpeedReceived(speed: Int) {
surfaceRenderer.maxSpeed.value = speed surfaceRenderer.maxSpeed.value = speed
} }
init { override fun invalidateScreen() {
navigationViewModel.route.observe(this, observer) invalidate()
navigationViewModel.traffic.observe(this, trafficObserver);
navigationViewModel.recentPlace.observe(this, recentObserver)
navigationViewModel.placeLocation.observe(this, placeObserver)
navigationViewModel.speedCameras.observe(this, speedObserver)
navigationViewModel.maxSpeed.observe(this, maxSpeedObserver)
lifecycleScope.launch {
getSettingsViewModel(carContext).routingEngine.collect {
}
}
} }
override fun onGetTemplate(): Template { override fun onGetTemplate(): Template {
@@ -151,7 +154,7 @@ class NavigationScreen(
.setDestinationTravelEstimate(routeModel.travelEstimate(carContext)) .setDestinationTravelEstimate(routeModel.travelEstimate(carContext))
.setActionStrip(actionStripBuilder.build()) .setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build()) .setMapActionStrip(mapActionStripBuilder().build())
.setBackgroundColor(CarColor.GREEN) .setBackgroundColor(backGroundColor)
.build() .build()
} }
@@ -208,7 +211,7 @@ class NavigationScreen(
) )
.build() .build()
) )
.setBackgroundColor(CarColor.GREEN) .setBackgroundColor(CarColor.SECONDARY)
.setActionStrip(actionStripBuilder.build()) .setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build()) .setMapActionStrip(mapActionStripBuilder().build())
.build() .build()
@@ -243,7 +246,7 @@ class NavigationScreen(
return NavigationTemplate.Builder() return NavigationTemplate.Builder()
.setNavigationInfo(RoutingInfo.Builder().setLoading(true).build()) .setNavigationInfo(RoutingInfo.Builder().setLoading(true).build())
.setActionStrip(actionStripBuilder.build()) .setActionStrip(actionStripBuilder.build())
.setBackgroundColor(CarColor.GREEN) .setBackgroundColor(backGroundColor)
.build() .build()
} }
@@ -447,7 +450,7 @@ class NavigationScreen(
fun navigateToPlace(place: Place) { fun navigateToPlace(place: Place) {
navigationType = NavigationType.VIEW navigationType = NavigationType.VIEW
val location = location(place.longitude, place.latitude) val location = location(place.longitude, place.latitude)
navigationViewModel.saveRecent(place) navigationViewModel.saveRecent(carContext, place)
currentNavigationLocation = location currentNavigationLocation = location
navigationViewModel.loadRoute( navigationViewModel.loadRoute(
carContext, carContext,
@@ -461,7 +464,7 @@ class NavigationScreen(
fun stopNavigation() { fun stopNavigation() {
navigationType = NavigationType.VIEW navigationType = NavigationType.VIEW
listener.stopNavigation(carContext) listener.stopNavigation()
surfaceRenderer.routeData.value = "" surfaceRenderer.routeData.value = ""
lastCameraSearch = 0 lastCameraSearch = 0
invalidate() invalidate()
@@ -502,7 +505,7 @@ class NavigationScreen(
} }
updateSpeedCamera(location) updateSpeedCamera(location)
with(routeModel) { with(routeModel) {
updateLocation(carContext, location, navigationViewModel) updateLocation( location, navigationViewModel)
if ((navState.maneuverType == Maneuver.TYPE_DESTINATION if ((navState.maneuverType == Maneuver.TYPE_DESTINATION
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_LEFT || navState.maneuverType == Maneuver.TYPE_DESTINATION_LEFT
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_RIGHT || navState.maneuverType == Maneuver.TYPE_DESTINATION_RIGHT
@@ -510,6 +513,7 @@ class NavigationScreen(
&& routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE && routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
) { ) {
stopNavigation() stopNavigation()
getSettingsViewModel(carContext).onLastRouteChanged("")
navState = navState.copy(arrived = true) navState = navState.copy(arrived = true)
surfaceRenderer.routeData.value = "" surfaceRenderer.routeData.value = ""
navigationType = NavigationType.ARRIVAL navigationType = NavigationType.ARRIVAL

View File

@@ -89,7 +89,7 @@ class PlaceListScreen(
"" ""
} }
val row = Row.Builder() val row = Row.Builder()
.setImage(contactIcon(it.avatar, it.category)) // .setImage(contactIcon(it.avatar, it.category))
.setTitle("$street ${it.city}") .setTitle("$street ${it.city}")
.setOnClickListener { .setOnClickListener {
val place = Place( val place = Place(
@@ -101,7 +101,7 @@ class PlaceListScreen(
it.postalCode, it.postalCode,
it.city, it.city,
it.street, it.street,
avatar = null // avatar = null
) )
screenManager screenManager
.pushForResult( .pushForResult(
@@ -162,7 +162,7 @@ class PlaceListScreen(
) )
) )
.setOnClickListener { .setOnClickListener {
navigationViewModel.deletePlace(place) navigationViewModel.deletePlace(carContext, place)
CarToast.makeText( CarToast.makeText(
carContext, carContext,
R.string.recent_Item_deleted, CarToast.LENGTH_LONG R.string.recent_Item_deleted, CarToast.LENGTH_LONG

View File

@@ -29,7 +29,11 @@ import com.kouros.navigation.car.navigation.NavigationMessage
import com.kouros.navigation.car.navigation.RouteCarModel import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.data.Place import com.kouros.navigation.data.Place
import com.kouros.navigation.model.NavigationViewModel import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
@@ -48,7 +52,10 @@ class RoutePreviewScreen(
val navigationMessage = NavigationMessage(carContext) val navigationMessage = NavigationMessage(carContext)
val observer = Observer<String> { route -> val observer = Observer<String> { route ->
if (route.isNotEmpty()) { if (route.isNotEmpty()) {
routeModel.startNavigation(route, carContext) val repository = getSettingsRepository(carContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
routeModel.startNavigation(route)
surfaceRenderer.setPreviewRouteData(routeModel) surfaceRenderer.setPreviewRouteData(routeModel)
invalidate() invalidate()
} }
@@ -163,7 +170,7 @@ class RoutePreviewScreen(
CarToast.LENGTH_SHORT CarToast.LENGTH_SHORT
) )
.show() .show()
navigationViewModel.saveFavorite(destination) navigationViewModel.saveFavorite(carContext, destination)
invalidate() invalidate()
} }
.build() .build()
@@ -171,7 +178,7 @@ class RoutePreviewScreen(
private fun deleteFavoriteAction(): Action = Action.Builder() private fun deleteFavoriteAction(): Action = Action.Builder()
.setOnClickListener { .setOnClickListener {
if (isFavorite) { if (isFavorite) {
navigationViewModel.deleteFavorite(destination) navigationViewModel.deleteFavorite(carContext,destination)
} }
isFavorite = !isFavorite isFavorite = !isFavorite
finish() finish()

View File

@@ -1,33 +0,0 @@
package com.kouros.navigation.car
import com.kouros.navigation.data.valhalla.ValhallaRepository
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.model.NavigationViewModel
import org.junit.Test
/**
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ViewModelTest {
val repo = ValhallaRepository()
val navigationViewModel = NavigationViewModel(repo)
val model = RouteModel()
@Test
fun routeViewModelTest() {
// val fromLocation = Location(LocationManager.GPS_PROVIDER)
// fromLocation.isMock = true
// fromLocation.latitude = homeLocation.latitude
// fromLocation.longitude = homeLocation.longitude
// val toLocation = Location(LocationManager.GPS_PROVIDER)
// toLocation.isMock = true
// toLocation.latitude = home2Location.latitude
// toLocation.longitude = home2Location.longitude
//
// val route = repo.getRoute(fromLocation, toLocation, SearchFilter())
//model.startNavigation(route)
}
}

View File

@@ -1,13 +1,8 @@
import org.gradle.kotlin.dsl.annotationProcessor
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "2.2.21" kotlin("plugin.serialization") version "2.2.21"
kotlin("kapt") alias(libs.plugins.kotlin.kapt)
} }
android { android {
@@ -37,9 +32,9 @@ android {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
kotlin { testOptions {
compilerOptions { unitTests {
jvmTarget = JvmTarget.JVM_11 isReturnDefaultValues = true
} }
} }
} }
@@ -60,20 +55,20 @@ dependencies {
implementation(libs.android.sdk.turf) implementation(libs.android.sdk.turf)
implementation(libs.androidx.compose.runtime) implementation(libs.androidx.compose.runtime)
// objectbox
implementation(libs.objectbox.kotlin)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
annotationProcessor(libs.objectbox.processor)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.maplibre.compose) implementation(libs.maplibre.compose)
implementation("androidx.compose.material:material-icons-extended:1.7.8") implementation(libs.androidx.compose.material.icons.extended)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
} androidTestImplementation(libs.androidx.runner)
androidTestImplementation(libs.androidx.rules)
apply(plugin = "io.objectbox")
}

View File

@@ -2,9 +2,9 @@ package com.kouros.navigation.data
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
val NavigationColor = Color(0xFF0730B2) val NavigationColor = Color(0xFF16BBB6)
val RouteColor = Color(0xFF5582D0) val RouteColor = Color(0xFF7B06E1)
val SpeedColor = Color(0xFF262525) val SpeedColor = Color(0xFF262525)

View File

@@ -16,13 +16,9 @@
package com.kouros.navigation.data package com.kouros.navigation.data
import android.location.Location
import android.location.LocationManager
import android.net.Uri import android.net.Uri
import com.kouros.navigation.data.route.Lane import com.kouros.navigation.data.route.Lane
import com.kouros.navigation.utils.location import com.kouros.navigation.utils.location
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
data class Category( data class Category(
@@ -30,9 +26,13 @@ data class Category(
val name: String, val name: String,
) )
@Entity
data class Places(
val places: List<Place>,
)
@Serializable
data class Place( data class Place(
@Id
var id: Long = 0, var id: Long = 0,
var name: String? = null, var name: String? = null,
var category: String? = null, var category: String? = null,
@@ -42,8 +42,7 @@ data class Place(
var city: String? = null, var city: String? = null,
var street: String? = null, var street: String? = null,
var distance: Float = 0F, var distance: Float = 0F,
@Transient //var avatar: Uri? = null,
var avatar: Uri? = null,
var lastDate: Long = 0 var lastDate: Long = 0
) )

View File

@@ -53,6 +53,8 @@ abstract class NavigationRepository {
carOrientation: Float, carOrientation: Float,
context: Context context: Context
): Double { ): Double {
if (currentLocation.latitude == 0.0)
return 0.0
val osrm = OsrmRepository() val osrm = OsrmRepository()
val route = osrm.getRoute(context, currentLocation, location, carOrientation, SearchFilter()) val route = osrm.getRoute(context, currentLocation, location, carOrientation, SearchFilter())
val gson = GsonBuilder().serializeNulls().create() val gson = GsonBuilder().serializeNulls().create()

View File

@@ -18,4 +18,6 @@ data class NavigationState (
val currentRouteIndex: Int = 0, val currentRouteIndex: Int = 0,
val destination: Place = Place(), val destination: Place = Place(),
val carConnection: Int = 0, val carConnection: Int = 0,
var routingEngine: Int = 0,
) )

View File

@@ -1,22 +0,0 @@
package com.kouros.navigation.data
import android.content.Context
import com.kouros.navigation.data.MyObjectBox
import io.objectbox.BoxStore
/**
* Singleton to keep BoxStore reference.
*/
object ObjectBox {
lateinit var boxStore: BoxStore
private set
fun init(context: Context) {
try {
boxStore = MyObjectBox.builder().androidContext(context.applicationContext).build()
} catch (e: Exception) {
println(e.message)
}
}
}

View File

@@ -42,6 +42,11 @@ class DataStoreManager(private val context: Context) {
val LAST_ROUTE = stringPreferencesKey("LastRoute") val LAST_ROUTE = stringPreferencesKey("LastRoute")
val TOMTOM_APIKEY = stringPreferencesKey("TomTomApiKey") val TOMTOM_APIKEY = stringPreferencesKey("TomTomApiKey")
val RECENT_PLACES = stringPreferencesKey("RecentPlaces")
val FAVORITES = stringPreferencesKey("Favorites")
} }
// Read values // Read values
@@ -89,6 +94,18 @@ class DataStoreManager(private val context: Context) {
?: "" ?: ""
} }
val recentPlacesFlow: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.RECENT_PLACES]
?: ""
}
val favoritesFlow: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.FAVORITES]
?: ""
}
// Save values // Save values
suspend fun setShow3D(enabled: Boolean) { suspend fun setShow3D(enabled: Boolean) {
context.dataStore.edit { preferences -> context.dataStore.edit { preferences ->
@@ -137,4 +154,16 @@ class DataStoreManager(private val context: Context) {
prefs[PreferencesKeys.TOMTOM_APIKEY] = apiKey prefs[PreferencesKeys.TOMTOM_APIKEY] = apiKey
} }
} }
suspend fun setRecentPlaces(apiKey: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.RECENT_PLACES] = apiKey
}
}
suspend fun setFavorites(apiKey: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.FAVORITES] = apiKey
}
}
} }

View File

@@ -33,6 +33,13 @@ class TomTomRepository : NavigationRepository() {
val routeJsonString = routeJson.bufferedReader().use { it.readText() } val routeJsonString = routeJson.bufferedReader().use { it.readText() }
return routeJsonString return routeJsonString
} }
var filter = ""
if (searchFilter.avoidMotorway) {
filter = "$filter&avoid=motorways"
}
if (searchFilter.avoidTollway) {
filter = "$filter&avoid=tollRoads"
}
val repository = getSettingsRepository(context) val repository = getSettingsRepository(context)
val tomtomApiKey = runBlocking { repository.tomTomApiKeyFlow.first() } val tomtomApiKey = runBlocking { repository.tomTomApiKeyFlow.first() }
val url = val url =
@@ -42,7 +49,7 @@ class TomTomRepository : NavigationRepository() {
"&vehicleMaxSpeed=120&vehicleCommercial=false" + "&vehicleMaxSpeed=120&vehicleCommercial=false" +
"&instructionsType=text&language=en-GB&sectionType=lanes" + "&instructionsType=text&language=en-GB&sectionType=lanes" +
"&routeRepresentation=encodedPolyline" + "&routeRepresentation=encodedPolyline" +
"&vehicleEngineType=combustion&key=$tomtomApiKey" "&vehicleEngineType=combustion$filter&key=$tomtomApiKey"
return fetchUrl( return fetchUrl(
url, url,
false false

View File

@@ -1,30 +1,27 @@
package com.kouros.navigation.model package com.kouros.navigation.model
//import com.kouros.navigation.data.Preferences.boxStore
import android.content.Context import android.content.Context
import android.location.Location import android.location.Location
import androidx.car.app.CarContext
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
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.NavigationRepository import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.ObjectBox.boxStore
import com.kouros.navigation.data.Place import com.kouros.navigation.data.Place
import com.kouros.navigation.data.Place_ import com.kouros.navigation.data.Places
import com.kouros.navigation.data.SearchFilter import com.kouros.navigation.data.SearchFilter
import com.kouros.navigation.data.nominatim.Search import com.kouros.navigation.data.nominatim.Search
import com.kouros.navigation.data.nominatim.SearchResult import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.Elements import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.overpass.Overpass import com.kouros.navigation.data.overpass.Overpass
import com.kouros.navigation.utils.Levenshtein import com.kouros.navigation.utils.Levenshtein
import com.kouros.navigation.utils.NavigationUtils
import com.kouros.navigation.utils.getSettingsRepository import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location import com.kouros.navigation.utils.location
import io.objectbox.kotlin.boxFor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -111,20 +108,18 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
/** /**
* Loads the most recent place from ObjectBox and calculates its distance. * Loads the most recent place from Preferences and calculates its distance.
* Posts the result to recentPlace LiveData if distance > 1km. * Posts the result to recentPlace LiveData if distance > 1km.
*/ */
fun loadRecentPlace(location: Location, carOrientation: Float, context: Context) { fun loadRecentPlace(location: Location, carOrientation: Float, context: Context) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val placeBox = boxStore.boxFor(Place::class) val settingsRepository = getSettingsRepository(context)
val query = placeBox val recentPlaces = settingsRepository.recentPlacesFlow.first()
.query(Place_.name.notEqual("")) val gson = GsonBuilder().serializeNulls().create()
.orderDesc(Place_.lastDate) val places = gson.fromJson(recentPlaces, Places::class.java)
.build() val place = places.places.minByOrNull { it.lastDate.dec() }
val results = query.find() if (place != null) {
query.close()
for (place in results) {
val plLocation = location(place.longitude, place.latitude) val plLocation = location(place.longitude, place.latitude)
val distance = repository.getRouteDistance( val distance = repository.getRouteDistance(
location, location,
@@ -145,20 +140,60 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
} }
/** /**
* Loads all recent places from ObjectBox and calculates distances. * Loads all recent places from Preferences and calculates distances.
* Posts the sorted list to places LiveData. * Posts the sorted list to places LiveData.
*/ */
fun loadRecentPlaces(context: Context, location: Location, carOrientation: Float) { fun loadRecentPlaces(context: Context, location: Location, carOrientation: Float) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val placeBox = boxStore.boxFor(Place::class) val settingsRepository = getSettingsRepository(context)
val query = placeBox val rp = settingsRepository.recentPlacesFlow.first()
.query(Place_.name.notEqual("").and(Place_.category.equal(Constants.RECENT))) val gson = GsonBuilder().serializeNulls().create()
.orderDesc(Place_.lastDate) val recentPlaces = gson.fromJson(rp, Places::class.java)
.build() val pl = mutableListOf<Place>()
val results = query.find() var id : Long = 0
query.close() if (rp.isNotEmpty()) {
for (place in results) { for (place in recentPlaces.places) {
if (place.category.equals(Constants.RECENT)) {
val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) {
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
place.id = id
id += 1
}
pl.add(place)
}
}
}
places.postValue(pl)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
/**
* Loads favorite places from Preferences and calculates distances.
* Posts the sorted list to favorites LiveData.
*/
fun loadFavorites(context: Context, location: Location, carOrientation: Float) {
viewModelScope.launch(Dispatchers.IO) {
try {
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
val gson = GsonBuilder().serializeNulls().create()
val recentPlaces = gson.fromJson(rp, Places::class.java)
val pl = mutableListOf<Place>()
if (rp.isNotEmpty()) {
for (place in recentPlaces.places) {
if (place.category.equals(Constants.FAVORITES)) {
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 =
@@ -170,41 +205,11 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
) )
place.distance = distance.toFloat() place.distance = distance.toFloat()
} }
} pl.add(place)
places.postValue(results)
} catch (e: Exception) {
e.printStackTrace()
} }
} }
} }
favorites.postValue(pl)
/**
* Loads favorite places from ObjectBox and calculates distances.
* Posts the sorted list to favorites LiveData.
*/
fun loadFavorites(context: Context, location: Location, carOrientation: Float) {
viewModelScope.launch(Dispatchers.IO) {
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(Place_.name.notEqual("").and(Place_.category.equal(Constants.FAVORITES)))
.orderDesc(Place_.lastDate)
.build()
val results = query.find()
query.close()
for (place in results) {
val plLocation = location(place.longitude, place.latitude)
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
}
favorites.postValue(results)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
@@ -335,7 +340,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
Constants.CONTACTS, Constants.CONTACTS,
street = addressLines[0], street = addressLines[0],
city = addressLines[1], city = addressLines[1],
avatar = address.avatar, //avatar = address.avatar,
longitude = 0.0, longitude = 0.0,
latitude = 0.0, latitude = 0.0,
distance = 0F, distance = 0F,
@@ -417,7 +422,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
val distAmenities = mutableListOf<Elements>() val distAmenities = mutableListOf<Elements>()
amenities.forEach { amenities.forEach {
val plLocation = val plLocation =
location(longitude = it.lon!!, latitude = it.lat!!) location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location) val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble() it.distance = distance.toDouble()
distAmenities.add(it) distAmenities.add(it)
@@ -470,18 +475,18 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
} }
/** /**
* Saves a place as a favorite in ObjectBox. * Saves a place as a favorite in Preferences.
*/ */
fun saveFavorite(place: Place) { fun saveFavorite(context: Context, place: Place) {
place.category = Constants.FAVORITES place.category = Constants.FAVORITES
savePlace(place) savePlace(context, place)
} }
/** /**
* Saves a place to recent destinations in ObjectBox. * Saves a place to recent destinations in Preferences.
* Skips fuel stations, charging stations, and pharmacies. * Skips fuel stations, charging stations, and pharmacies.
*/ */
fun saveRecent(place: Place) { fun saveRecent(context: Context, place: Place) {
if (place.category == Constants.FUEL_STATION if (place.category == Constants.FUEL_STATION
|| place.category == Constants.CHARGING_STATION || place.category == Constants.CHARGING_STATION
|| place.category == Constants.PHARMACY || place.category == Constants.PHARMACY
@@ -489,30 +494,36 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
return return
} }
place.category = Constants.RECENT place.category = Constants.RECENT
savePlace(place) savePlace(context, place)
} }
/** /**
* Saves a place to ObjectBox, removing existing duplicates first. * Saves a place to Preferences, removing existing duplicates first.
* Updates the timestamp to current time. * Updates the timestamp to current time.
*/ */
private fun savePlace(place: Place) { private fun savePlace(context: Context, place: Place) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val placeBox = boxStore.boxFor(Place::class) val places = mutableListOf<Place>()
val query = placeBox val gson = GsonBuilder().serializeNulls().create()
.query( val settingsRepository = getSettingsRepository(context)
Place_.name.equal(place.name!!).and(Place_.category.equal(place.category!!)) val rp = settingsRepository.recentPlacesFlow.first()
) var id : Long = 0
.build() if (rp.isNotEmpty()) {
val results = query.find() val recentPlaces =
query.close() gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
if (results.isNotEmpty()) { for (curPlace in recentPlaces) {
placeBox.remove(results.first()) if (curPlace.name != place.name || curPlace.category != place.category) {
curPlace.id = id
places.add(curPlace)
id += 1
}
}
} }
val current = LocalDateTime.now(ZoneOffset.UTC) val current = LocalDateTime.now(ZoneOffset.UTC)
place.lastDate = current.atZone(ZoneOffset.UTC).toEpochSecond() place.lastDate = current.atZone(ZoneOffset.UTC).toEpochSecond()
placeBox.put(place) places.add(place)
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
@@ -520,37 +531,40 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
} }
/** /**
* Deletes a place from favorites in ObjectBox. * Deletes a place from favorites in Preferences.
*/ */
fun deleteFavorite(place: Place) { fun deleteFavorite(context: Context, place: Place) {
place.category = Constants.FAVORITES place.category = Constants.FAVORITES
deletePlace(place) deletePlace(context, place)
} }
/** /**
* Deletes a place from recent destinations in ObjectBox. * Deletes a place from recent destinations in Preferences.
*/ */
fun deleteRecent(place: Place) { fun deleteRecent(context: Context, place: Place) {
place.category = Constants.RECENT place.category = Constants.RECENT
deletePlace(place) deletePlace(context, place)
} }
/** /**
* Deletes a place from ObjectBox matching name and category. * Deletes a place from Preferences matching name and category.
*/ */
fun deletePlace(place: Place) { fun deletePlace(context: Context, place: Place) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val placeBox = boxStore.boxFor(Place::class) val gson = GsonBuilder().serializeNulls().create()
val query = placeBox val settingsRepository = getSettingsRepository(context)
.query( val rp = settingsRepository.recentPlacesFlow.first()
Place_.name.equal(place.name!!).and(Place_.category.equal(place.category!!)) val places = mutableListOf<Place>()
) if (rp.isNotEmpty()) {
.build() val recentPlaces =
val results = query.find() gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
query.close() for (curPlace in recentPlaces) {
if (results.isNotEmpty()) { if (curPlace.name != place.name || curPlace.category != place.category) {
placeBox.remove(results.first()) places.add(curPlace)
}
}
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
@@ -569,59 +583,23 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
return SearchFilter(avoidMotorway, avoidTollway) return SearchFilter(avoidMotorway, avoidTollway)
} }
/**
* Loads recent places with calculated distances for Compose state.
* @return SnapshotStateList of recent places with distances
*/
fun loadPlaces2(
context: Context,
location: Location,
carOrientation: Float
): SnapshotStateList<Place?> {
val results = listOf<Place>()
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(Place_.name.notEqual("").and(Place_.category.equal(Constants.RECENT)))
.orderDesc(Place_.lastDate)
.build()
val results = query.find()
query.close()
for (place in results) {
val plLocation = location(place.longitude, place.latitude)
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
}
} catch (e: Exception) {
e.printStackTrace()
}
return results.toMutableStateList()
}
/** /**
* Loads recent places as Compose SnapshotStateList. * Loads recent places as Compose SnapshotStateList.
* @return SnapshotStateList of recent places * @return SnapshotStateList of recent places
*/ */
fun loadRecentPlace(): SnapshotStateList<Place?> { fun loadRecentPlace(context: Context): SnapshotStateList<Place?> {
val results = listOf<Place>() val pl = mutableListOf<Place>()
try { val settingsRepository = getSettingsRepository(context)
val placeBox = boxStore.boxFor(Place::class) val rp = runBlocking { settingsRepository.recentPlacesFlow.first()}
val query = placeBox if (rp.isNotEmpty()) {
.query(Place_.name.notEqual("").and(Place_.category.equal(Constants.RECENT))) val gson = GsonBuilder().serializeNulls().create()
.orderDesc(Place_.lastDate) val recentPlaces = gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
.build() for (place in recentPlaces) {
val results = query.find() if (place.category == Constants.RECENT) {
query.close() pl.add(place)
return results.toMutableStateList() }
} catch (e: Exception) { }
e.printStackTrace() }
} return pl.toMutableStateList()
return results.toMutableStateList()
} }
} }

View File

@@ -1,25 +1,17 @@
package com.kouros.navigation.model package com.kouros.navigation.model
import android.content.Context
import android.location.Location import android.location.Location
import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_NATIVE import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_NATIVE
import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_PROJECTION import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_PROJECTION
import androidx.car.app.navigation.model.Maneuver import androidx.car.app.navigation.model.Maneuver
import com.kouros.navigation.data.Constants.NEXT_STEP_THRESHOLD import com.kouros.navigation.data.Constants.NEXT_STEP_THRESHOLD
import com.kouros.navigation.data.NavigationState import com.kouros.navigation.data.NavigationState
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.Route import com.kouros.navigation.data.Route
import com.kouros.navigation.data.StepData import com.kouros.navigation.data.StepData
import com.kouros.navigation.data.datastore.DataStoreManager
import com.kouros.navigation.data.route.Lane import com.kouros.navigation.data.route.Lane
import com.kouros.navigation.data.route.Leg import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Routes import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.repository.SettingsRepository
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
open class RouteModel { open class RouteModel {
@@ -37,40 +29,40 @@ open class RouteModel {
val curLeg: Leg val curLeg: Leg
get() = navState.route.routes[navState.currentRouteIndex].legs.first() get() = navState.route.routes[navState.currentRouteIndex].legs.first()
fun startNavigation(routeString: String, context: Context) { fun startNavigation(routeString: String) {
val repository = getSettingsRepository(context)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
navState = navState.copy( navState = navState.copy(
route = Route.Builder() route = Route.Builder()
.routeEngine(routingEngine) .routeEngine(navState.routingEngine)
.route(routeString) .route(routeString)
.build() .build()
) )
if (hasLegs()) { if (hasLegs()) {
navState = navState.copy(navigating = true) navState = navState.copy(navigating = true)
getSettingsViewModel(context).onLastRouteChanged(routeString)
} }
} }
private fun hasLegs(): Boolean { fun hasLegs(): Boolean {
return navState.route.routes.isNotEmpty() && navState.route.routes[0].legs.isNotEmpty() return navState.route.routes.isNotEmpty() && navState.route.routes[0].legs.isNotEmpty()
} }
fun stopNavigation(context: Context) { fun stopNavigation() {
navState = navState.copy( navState = navState.copy(
route = Route.Builder().buildEmpty(), route = Route.Builder().buildEmpty(),
navigating = false, navigating = false,
arrived = false, arrived = false,
maneuverType = Maneuver.TYPE_UNKNOWN maneuverType = Maneuver.TYPE_UNKNOWN
) )
getSettingsViewModel(context).onLastRouteChanged("")
} }
fun updateLocation(context: Context, curLocation: Location, viewModel: NavigationViewModel) { fun updateLocation(curLocation: Location, viewModel: NavigationViewModel) {
if (curLocation.hasBearing()) {
navState = navState.copy(routeBearing = curLocation.bearing)
}
navState = navState.copy(currentLocation = curLocation) navState = navState.copy(currentLocation = curLocation)
routeCalculator.findStep(curLocation) routeCalculator.findStep(curLocation)
if (navState.carConnection == CONNECTION_TYPE_PROJECTION if (navState.carConnection == CONNECTION_TYPE_PROJECTION
|| navState.carConnection == CONNECTION_TYPE_NATIVE) { || navState.carConnection == CONNECTION_TYPE_NATIVE
) {
routeCalculator.updateSpeedLimit(curLocation, viewModel) routeCalculator.updateSpeedLimit(curLocation, viewModel)
} }
navState = navState.copy(lastLocation = navState.currentLocation) navState = navState.copy(lastLocation = navState.currentLocation)
@@ -80,15 +72,11 @@ open class RouteModel {
val distanceToNextStep = routeCalculator.leftStepDistance() val distanceToNextStep = routeCalculator.leftStepDistance()
// Determine the maneuver type and corresponding icon // Determine the maneuver type and corresponding icon
val currentStep = navState.route.nextStep(0) val currentStep = navState.route.nextStep(0)
val streetName = if (distanceToNextStep > NEXT_STEP_THRESHOLD) { var streetName = currentStep.maneuver.street
currentStep.street var curManeuverType = currentStep.maneuver.type
} else { if (distanceToNextStep > NEXT_STEP_THRESHOLD) {
currentStep.maneuver.street streetName = currentStep.street
} curManeuverType = Maneuver.TYPE_STRAIGHT
val curManeuverType = if (distanceToNextStep > NEXT_STEP_THRESHOLD) {
Maneuver.TYPE_STRAIGHT
} else {
currentStep.maneuver.type
} }
val exitNumber = currentStep.maneuver.exit val exitNumber = currentStep.maneuver.exit
val maneuverIcon = navState.iconMapper.maneuverIcon(curManeuverType) val maneuverIcon = navState.iconMapper.maneuverIcon(curManeuverType)
@@ -108,13 +96,14 @@ open class RouteModel {
fun nextStep(): StepData { fun nextStep(): StepData {
val distanceToNextStep = routeCalculator.leftStepDistance() val distanceToNextStep = routeCalculator.leftStepDistance()
val step = navState.route.nextStep(1) val currentStep = navState.route.nextStep(0)
val streetName = if (distanceToNextStep < NEXT_STEP_THRESHOLD) { val nextStep = navState.route.nextStep(1)
step.maneuver.street var streetName = nextStep.street
} else { var maneuverType = currentStep.maneuver.type
step.street if (distanceToNextStep < NEXT_STEP_THRESHOLD) {
streetName = nextStep.maneuver.street
maneuverType = nextStep.maneuver.type
} }
val maneuverType = step.maneuver.type
val maneuverIcon = navState.iconMapper.maneuverIcon(maneuverType) val maneuverIcon = navState.iconMapper.maneuverIcon(maneuverType)
// Construct and return the final StepData object // Construct and return the final StepData object
@@ -125,7 +114,7 @@ open class RouteModel {
icon = maneuverIcon, icon = maneuverIcon,
arrivalTime = routeCalculator.arrivalTime(), arrivalTime = routeCalculator.arrivalTime(),
leftDistance = routeCalculator.travelLeftDistance(), leftDistance = routeCalculator.travelLeftDistance(),
exitNumber = step.maneuver.exit exitNumber = nextStep.maneuver.exit
) )
} }
@@ -138,7 +127,9 @@ open class RouteModel {
navState.lastLocation.distanceTo(location(it.location[0], it.location[1])) navState.lastLocation.distanceTo(location(it.location[0], it.location[1]))
val sectionBearing = val sectionBearing =
navState.lastLocation.bearingTo(location(it.location[0], it.location[1])) navState.lastLocation.bearingTo(location(it.location[0], it.location[1]))
if (distance < NEXT_STEP_THRESHOLD && (navState.routeBearing.absoluteValue - sectionBearing.absoluteValue).absoluteValue < 10) { val bearingDeviation =
(navState.routeBearing.absoluteValue - sectionBearing.absoluteValue).absoluteValue
if (distance < NEXT_STEP_THRESHOLD && bearingDeviation < 10) {
lanes = it.lane lanes = it.lane
} }
} }

View File

@@ -29,6 +29,14 @@ class SettingsRepository(
val tomTomApiKeyFlow: Flow<String> = val tomTomApiKeyFlow: Flow<String> =
dataStoreManager.tomTomApiKeyFlow dataStoreManager.tomTomApiKeyFlow
val recentPlacesFlow: Flow<String> =
dataStoreManager.recentPlacesFlow
val favoritesFlow: Flow<String> =
dataStoreManager.favoritesFlow
suspend fun setShow3D(enabled: Boolean) { suspend fun setShow3D(enabled: Boolean) {
dataStoreManager.setShow3D(enabled) dataStoreManager.setShow3D(enabled)
} }
@@ -61,4 +69,11 @@ class SettingsRepository(
dataStoreManager.setTomtomApiKey(apiKey) dataStoreManager.setTomtomApiKey(apiKey)
} }
suspend fun setRecentPlaces(places: String) {
dataStoreManager.setRecentPlaces(places)
}
suspend fun setFavorites(favorites: String) {
dataStoreManager.setFavorites(favorites)
}
} }

View File

@@ -36,7 +36,6 @@ object GeoUtils {
fun decodePolyline(encoded: String, precision: Int = 6): List<List<Double>> { fun decodePolyline(encoded: String, precision: Int = 6): List<List<Double>> {
val factor = 10.0.pow(precision) val factor = 10.0.pow(precision)
var lat = 0 var lat = 0
var lng = 0 var lng = 0
val coordinates = mutableListOf<List<Double>>() val coordinates = mutableListOf<List<Double>>()

View File

@@ -0,0 +1,247 @@
package com.kouros.navigation.model
import android.location.Location
import com.kouros.navigation.data.Route
import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Maneuver
import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.data.route.Step
import com.kouros.navigation.data.route.Summary
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
class RouteCalculatorTest {
private lateinit var routeModel: RouteModel
private lateinit var routeCalculator: RouteCalculator
@Before
fun setup() {
routeModel = RouteModel()
routeCalculator = RouteCalculator(routeModel)
}
// ----------------------------------------------------------
// Helpers
// ----------------------------------------------------------
/**
* Creates a Step with [numWaypoints] evenly spaced waypoints.
* The Maneuver location is mocked to avoid Android stub issues.
*/
private fun createStep(
index: Int,
numWaypoints: Int = 2,
duration: Double = 60.0,
distance: Double = 200.0,
waypointIndex: Int = 0,
): Step {
val waypoints = (0 until numWaypoints).map { i ->
listOf(11.0 + index * 0.01 + i * 0.001, 48.0)
}
return Step(
index = index,
waypointIndex = waypointIndex,
maneuver = Maneuver(waypoints = waypoints, location = mock()),
duration = duration,
distance = distance,
)
}
private fun setupRoute(steps: List<Step>, currentStepIndex: Int = 0): Route {
val leg = Leg(steps = steps)
val routes = Routes(
legs = listOf(leg),
summary = Summary(),
routeGeoJson = "",
waypoints = emptyList(),
)
return Route(routeEngine = 1, routes = listOf(routes), currentStepIndex = currentStepIndex)
}
// ----------------------------------------------------------
// findStep
// ----------------------------------------------------------
@Test
fun `findStep updates currentStepIndex to step containing the nearest waypoint`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1)))
val mockLocation: Location = mock()
// step0/wp0: 500F, step0/wp1: 400F, step1/wp0: 300F, step1/wp1: 8F
whenever(mockLocation.distanceTo(any())).thenReturn(500F, 400F, 300F, 8F)
routeCalculator.findStep(mockLocation)
assertEquals(1, routeModel.navState.route.currentStepIndex)
}
@Test
fun `findStep updates waypointIndex to the nearest waypoint within the step`() {
val step0 = createStep(index = 0, numWaypoints = 3)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0)))
val mockLocation: Location = mock()
// wp0: 100F, wp1: 30F (nearest), wp2: 80F
whenever(mockLocation.distanceTo(any())).thenReturn(100F, 30F, 80F)
routeCalculator.findStep(mockLocation)
assertEquals(1, step0.waypointIndex)
}
@Test
fun `findStep skips all steps before currentStepIndex`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy(
route = setupRoute(listOf(step0, step1), currentStepIndex = 1)
)
val mockLocation: Location = mock()
whenever(mockLocation.distanceTo(any())).thenReturn(200F, 50F)
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)
}
@Test
fun `findStep breaks early once nearestDistance drops below NEAREST_LOCATION_DISTANCE`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
val step2 = createStep(index = 2, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy(
route = setupRoute(listOf(step0, step1, step2))
)
val mockLocation: Location = mock()
// step0/wp0: 500F, step0/wp1: 5F — 5F < NEAREST_LOCATION_DISTANCE (10F) → break
whenever(mockLocation.distanceTo(any())).thenReturn(500F, 5F)
routeCalculator.findStep(mockLocation)
// step1 and step2 are never evaluated
verify(mockLocation, times(2)).distanceTo(any())
assertEquals(0, routeModel.navState.route.currentStepIndex)
}
// ----------------------------------------------------------
// travelLeftTime
// ----------------------------------------------------------
@Test
fun `travelLeftTime sums future step durations plus full current step duration at first waypoint`() {
// waypointIndex=0, waypoints=2 → percent = 100*(2-0)/2 = 100 → time = 60s
val step0 = createStep(index = 0, numWaypoints = 2, duration = 60.0, waypointIndex = 0)
val step1 = createStep(index = 1, numWaypoints = 2, duration = 120.0)
val step2 = createStep(index = 2, numWaypoints = 2, duration = 90.0)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1, step2)))
val result = routeCalculator.travelLeftTime()
// future: 120 + 90 = 210; current: 60 → total: 270
assertEquals(270.0, result, 0.01)
}
@Test
fun `travelLeftTime uses proportional duration based on remaining waypoints in current step`() {
// waypointIndex=2, waypoints=4 → percent = 100*(4-2)/4 = 50 → time = 80*50/100 = 40s
val step0 = createStep(index = 0, numWaypoints = 4, duration = 80.0, waypointIndex = 2)
val step1 = createStep(index = 1, numWaypoints = 2, duration = 40.0)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1)))
val result = routeCalculator.travelLeftTime()
// future: 40; current: 40 → total: 80
assertEquals(80.0, result, 0.01)
}
@Test
fun `travelLeftTime returns only future steps when at last step`() {
val step0 = createStep(index = 0, numWaypoints = 2, duration = 60.0, waypointIndex = 1)
routeModel.navState = routeModel.navState.copy(
route = setupRoute(listOf(step0), currentStepIndex = 0)
)
val result = routeCalculator.travelLeftTime()
// no future steps; waypointIndex=1, waypoints=2 → percent = 100*(2-1)/2 = 50 → 30s
assertEquals(30.0, result, 0.01)
}
// ----------------------------------------------------------
// leftStepDistance
// ----------------------------------------------------------
@Test
fun `leftStepDistance returns 0 when waypointIndex is at the last position`() {
// Loop range: waypointIndex..<waypoints.size-1 = 2..<2, which is empty
val step0 = createStep(index = 0, numWaypoints = 3, waypointIndex = 2)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0)))
val result = routeCalculator.leftStepDistance()
assertEquals(0.0, result, 0.01)
}
// ----------------------------------------------------------
// travelLeftDistance
// ----------------------------------------------------------
@Test
fun `travelLeftDistance sums distances of all future steps plus leftStepDistance`() {
// step0 at last waypoint → leftStepDistance = 0
val step0 = createStep(index = 0, numWaypoints = 2, distance = 100.0, waypointIndex = 1)
val step1 = createStep(index = 1, numWaypoints = 2, distance = 200.0)
val step2 = createStep(index = 2, numWaypoints = 2, distance = 150.0)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1, step2)))
val result = routeCalculator.travelLeftDistance()
// 200 + 150 + 0 = 350
assertEquals(350.0, result, 0.01)
}
@Test
fun `travelLeftDistance returns 0 when on last step at last waypoint`() {
val step0 = createStep(index = 0, numWaypoints = 2, distance = 200.0, waypointIndex = 1)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0)))
val result = routeCalculator.travelLeftDistance()
assertEquals(0.0, result, 0.01)
}
// ----------------------------------------------------------
// arrivalTime
// ----------------------------------------------------------
@Test
fun `arrivalTime returns a timestamp roughly travelLeftTime seconds in the future`() {
// step0: 2 waypoints at wp0 → 100% of 3600s duration
val step0 = createStep(index = 0, numWaypoints = 2, duration = 3600.0, waypointIndex = 0)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0)))
val before = System.currentTimeMillis()
val result = routeCalculator.arrivalTime()
assertTrue("Arrival time should be in the future", result > before)
val expectedArrival = before + 3_600_000L
assertTrue(
"Arrival time should be within 1s of expected",
result in expectedArrival - 1_000..expectedArrival + 1_000
)
}
}

View File

@@ -0,0 +1,109 @@
package com.kouros.navigation.model
import android.location.Location
import com.kouros.navigation.data.Route
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Maneuver
import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.data.route.Step
import com.kouros.navigation.data.route.Summary
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.doNothing
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class RouteModelTest {
val route =
"{\"formatVersion\":\"0.0.12\",\"report\":{\"effectiveSettings\":[{\"key\":\"avoid\",\"value\":\"unpavedRoads\"},{\"key\":\"computeBestOrder\",\"value\":\"false\"},{\"key\":\"computeTollAmounts\",\"value\":\"none\"},{\"key\":\"computeTravelTimeFor\",\"value\":\"none\"},{\"key\":\"contentType\",\"value\":\"json\"},{\"key\":\"departAt\",\"value\":\"2026-02-26T11:12:31.436Z\"},{\"key\":\"guidanceVersion\",\"value\":\"1\"},{\"key\":\"includeTollPaymentTypes\",\"value\":\"none\"},{\"key\":\"instructionsType\",\"value\":\"text\"},{\"key\":\"language\",\"value\":\"en-GB\"},{\"key\":\"locations\",\"value\":\"48.18575,11.57937:48.18440,11.58298\"},{\"key\":\"maxAlternatives\",\"value\":\"0\"},{\"key\":\"maxPathAlternatives\",\"value\":\"0\"},{\"key\":\"routeRepresentation\",\"value\":\"encodedPolyline\"},{\"key\":\"routeType\",\"value\":\"eco\"},{\"key\":\"sectionType\",\"value\":\"traffic\"},{\"key\":\"sectionType\",\"value\":\"lanes\"},{\"key\":\"traffic\",\"value\":\"true\"},{\"key\":\"travelMode\",\"value\":\"car\"},{\"key\":\"vehicleAxleWeight\",\"value\":\"0\"},{\"key\":\"vehicleCommercial\",\"value\":\"false\"},{\"key\":\"vehicleEngineType\",\"value\":\"combustion\"},{\"key\":\"vehicleHeading\",\"value\":\"90\"},{\"key\":\"vehicleHeight\",\"value\":\"0.00\"},{\"key\":\"vehicleLength\",\"value\":\"0.00\"},{\"key\":\"vehicleMaxSpeed\",\"value\":\"120\"},{\"key\":\"vehicleNumberOfAxles\",\"value\":\"0\"},{\"key\":\"vehicleWeight\",\"value\":\"0\"},{\"key\":\"vehicleWidth\",\"value\":\"0.00\"}]},\"routes\":[{\"summary\":{\"lengthInMeters\":904,\"travelTimeInSeconds\":183,\"trafficDelayInSeconds\":0,\"trafficLengthInMeters\":0,\"departureTime\":\"2026-02-26T12:12:31+01:00\",\"arrivalTime\":\"2026-02-26T12:15:34+01:00\"},\"legs\":[{\"summary\":{\"lengthInMeters\":904,\"travelTimeInSeconds\":183,\"trafficDelayInSeconds\":0,\"trafficLengthInMeters\":0,\"departureTime\":\"2026-02-26T12:12:31+01:00\",\"arrivalTime\":\"2026-02-26T12:15:34+01:00\"},\"encodedPolyline\":\"sfbeH_rteAE|DM|K?P@BBDBBLBdFRBqABw@FkDD_BD_CDkD@uA?oB?{@C_C?YA}@IiEY?\",\"encodedPolylinePrecision\":5}],\"guidance\":{\"instructions\":[{\"routeOffsetInMeters\":0,\"travelTimeInSeconds\":0,\"point\":{\"latitude\":48.18554,\"longitude\":11.57936},\"pointIndex\":0,\"instructionType\":\"LOCATION_DEPARTURE\",\"street\":\"Vogelhartstraße\",\"countryCode\":\"DEU\",\"possibleCombineWithNext\":false,\"drivingSide\":\"RIGHT\",\"maneuver\":\"DEPART\",\"message\":\"Leave from Vogelhartstraße\"},{\"routeOffsetInMeters\":375,\"travelTimeInSeconds\":87,\"point\":{\"latitude\":48.18437,\"longitude\":11.57606},\"pointIndex\":8,\"instructionType\":\"TURN\",\"street\":\"Milbertshofener Straße\",\"countryCode\":\"DEU\",\"junctionType\":\"REGULAR\",\"turnAngleInDecimalDegrees\":-90,\"possibleCombineWithNext\":false,\"drivingSide\":\"RIGHT\",\"maneuver\":\"TURN_LEFT\",\"message\":\"Turn left onto Milbertshofener Straße\"},{\"routeOffsetInMeters\":890,\"travelTimeInSeconds\":168,\"point\":{\"latitude\":48.18427,\"longitude\":11.58297},\"pointIndex\":21,\"instructionType\":\"TURN\",\"street\":\"Bad-Soden-Straße\",\"countryCode\":\"DEU\",\"junctionType\":\"REGULAR\",\"turnAngleInDecimalDegrees\":-90,\"possibleCombineWithNext\":true,\"drivingSide\":\"RIGHT\",\"maneuver\":\"TURN_LEFT\",\"message\":\"Turn left onto Bad-Soden-Straße\",\"combinedMessage\":\"Turn left onto Bad-Soden-Straße then you have arrived at Bad-Soden-Straße\"},{\"routeOffsetInMeters\":904,\"travelTimeInSeconds\":183,\"point\":{\"latitude\":48.1844,\"longitude\":11.58297},\"pointIndex\":22,\"instructionType\":\"LOCATION_ARRIVAL\",\"street\":\"Bad-Soden-Straße\",\"countryCode\":\"DEU\",\"possibleCombineWithNext\":false,\"drivingSide\":\"RIGHT\",\"maneuver\":\"ARRIVE\",\"message\":\"You have arrived at Bad-Soden-Straße\"}],\"instructionGroups\":[{\"firstInstructionIndex\":0,\"lastInstructionIndex\":3,\"groupMessage\":\"Leave from Vogelhartstraße. Take the Milbertshofener Straße. Continue to your destination at Bad-Soden-Straße\",\"groupLengthInMeters\":904}]}}]}"
private lateinit var routeModel: RouteModel
@Before
fun setup() {
routeModel = RouteModel()
routeModel.navState = routeModel.navState.copy(routingEngine = RouteEngine.TOMTOM.ordinal)
routeModel.startNavigation(route)
}
private fun createStep(
index: Int,
numWaypoints: Int = 2,
duration: Double = 60.0,
distance: Double = 200.0,
waypointIndex: Int = 0,
): Step {
val waypoints = (0 until numWaypoints).map { i ->
listOf(11.0 + index * 0.01 + i * 0.001, 48.0)
}
return Step(
index = index,
waypointIndex = waypointIndex,
maneuver = Maneuver(waypoints = waypoints, location = mock()),
duration = duration,
distance = distance,
)
}
private fun setupRoute(steps: List<Step>, currentStepIndex: Int = 0): Route {
val leg = Leg(steps = steps)
val routes = Routes(
legs = listOf(leg),
summary = Summary(),
routeGeoJson = "",
waypoints = emptyList(),
)
return Route(routeEngine = 2, routes = listOf(routes), currentStepIndex = currentStepIndex)
}
@Test
fun `hasLegs returns true when route has legs`() {
val step0 = createStep(index = 0, numWaypoints = 2)
val step1 = createStep(index = 1, numWaypoints = 2)
routeModel.navState = routeModel.navState.copy(route = setupRoute(listOf(step0, step1)))
val result = routeModel.hasLegs()
assert(result)
}
@Test
fun `startNavigation updates route and sets navigating to true `() {
assert(routeModel.navState.navigating)
}
@Test
fun `updateLocation updates currentLocation and lastLocation `() {
val routeCalculator: RouteCalculator = mock()
val mockLocation: Location = mock()
whenever(mockLocation.latitude).thenReturn(48.18554)
whenever(mockLocation.longitude).thenReturn(11.57936)
whenever(mockLocation.bearing).thenReturn(90F)
whenever(mockLocation.hasBearing()).thenReturn(true)
doNothing().`when`(routeCalculator).findStep(mockLocation)
routeModel.updateLocation(mockLocation, mock())
assert(routeModel.navState.currentLocation.latitude == 48.18554)
assert(routeModel.navState.currentLocation.longitude == 11.57936)
}
@Test
fun `currentStep returns StepData `() {
val stepData = routeModel.currentStep()
assert(stepData.leftStepDistance == 0.0)
assert(stepData.instruction == "Milbertshofener Straße")
}
@Test
fun `nextStep returns StepData `() {
routeModel.currentStep()
val stepData = routeModel.nextStep()
assert(stepData.leftStepDistance == 0.0)
assert(stepData.instruction == "Bad-Soden-Straße")
}
@Test
fun `stopNavigation updates route and sets navigating to false `() {
routeModel.stopNavigation()
assert(!routeModel.navState.navigating)
}
}

View File

@@ -0,0 +1,205 @@
package com.kouros.navigation.utils
import android.location.Location
import org.junit.Assert.*
import org.junit.Test
import org.maplibre.geojson.Point
import org.maplibre.turf.TurfMisc
import org.mockito.Mockito.mock
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class GeoUtilsTest {
private fun createLocation(lat: Double, lng: Double, bearing: Float? = null): Location {
val location = mock<Location>()
whenever(location.latitude).thenReturn(lat)
whenever(location.longitude).thenReturn(lng)
whenever(location.hasBearing()).thenReturn(bearing != null)
bearing?.let { whenever(location.bearing).thenReturn(it) }
return location
}
@Test
fun `snapLocation snaps to nearest point on line`() {
val location = createLocation(48.0, 11.0)
val stepCoordinates = listOf(
Point.fromLngLat(11.0, 48.0),
Point.fromLngLat(11.001, 48.0),
Point.fromLngLat(11.002, 48.0)
)
val result = GeoUtils.snapLocation(location, stepCoordinates)
assertEquals(0.0, result.latitude, 0.001)
assertEquals(0.0, result.longitude, 0.001)
}
@Test
fun `snapLocation preserves bearing when available`() {
val location = createLocation(48.0, 11.0, bearing = 90f)
val stepCoordinates = listOf(
Point.fromLngLat(11.0, 48.0),
Point.fromLngLat(11.001, 48.0)
)
val result = GeoUtils.snapLocation(location, stepCoordinates)
assertEquals(0f, result.bearing, 0.001f)
}
@Test
fun `snapLocation returns original location when coordinates has less than 2 points`() {
val location = createLocation(48.0, 11.0)
val stepCoordinates = listOf(Point.fromLngLat(11.0, 48.0))
val result = GeoUtils.snapLocation(location, stepCoordinates)
assertEquals(0.0, result.latitude, 0.001)
assertEquals(0.0, result.longitude, 0.001)
}
@Test
fun `decodePolyline decodes simple polyline correctly`() {
val encoded =
"sfbeH_rteAE|DQEy@GQ?wDQFkEH{G?M?sA@kB?_FAeC?o@?[@_@\\Ab@CVAz@CJA@?dBGhAGjAExAGlBEvBKdCKTAfCKLAv@ELA|AGnAGt@ClCKjDQpBIf@BXDPDPBF@ZB?S@SAYAUKi@Go@Cc@BgBBs@Bg@JyAJiAXqBDWNs@TaA\\mAFa@Po@`BwF?YL]FSFSl@iB^kALc@L]Ro@f@yAf@{AFQNe@dAoCdBgEx@qBTi@BITe@L[L_@^}@HSXu@pB}El@eAb@e@f@[h@QZCRAL?j@HRFh@Vf@XLJhAn@lAv@TLtAz@r@`@bAn@pAv@f@X~@j@z@h@vBpA`@VHDFFJFf@X`CzApAh@f@L`@Fz@@f@AXEVEVEhA[h@Yn@e@PQFEJKRWV[PW`@w@t@}AHQN]~BiFP]`AoBh@aADGTa@t@aAt@{@PQJKJGFG@Cd@]XSxDmCf@a@n@o@TY\\g@LQHMJSLUP[f@iAPg@b@yAFODMNi@FS|@qCVaAHUHUn@wBHYh@eBpAkEjBiGfAeDj@yADMFQd@sAf@kAJUh@qAf@eAt@sAn@iALSN[p@kAVc@JOLSj@w@z@}@x@q@pAu@p@_@j@Sl@MLCRCb@E`@?^?L@`ABz@?N@~AFdADJ@rAH`DVpCVrAJd@BfHp@zGl@pAJ|ALnGp@jEh@fBJpAFF?P@N@ZCtC]r@GJCFCLCD?TEVEXGhAYzAg@NGv@]`@QJEvB_AXMVK\\Qb@Qn@QJCNAZC^ENA`@FnBb@xEtA^H^JnCl@z@r@`@Pr@TtBlA~C`Bn@\\xAl@PF`@LrAVlCh@bBLl@BlBJdG\\RDjCHn@?pB?xB?R@`@@GxAC^?ZInBIfCAvC?r@@dD@n@@b@@^D`C?TDxAFbBHdB@VHp@RjAJb@NNH`@VlBFf@PzARhBFd@@LRbBFh@\\nC@FNhAb@lEj@tDPpABTBRZlBTdBXjBn@xEBLDTRpAR~@l@jDj@Qv@IrEP"
val result = GeoUtils.decodePolyline(encoded, 5)
assertEquals(339, result.size)
assertEquals(11.58204, result[10][0], 0.001)
assertEquals(48.18686, result[10][1], 0.001)
assertEquals(11.59979, result[100][0], 0.001)
assertEquals(48.17076, result[100][1], 0.001)
}
@Test
fun `decodePolyline returns empty list for empty string`() {
val result = GeoUtils.decodePolyline("")
assertTrue(result.isEmpty())
}
@Test
fun `createLineStringCollection creates valid GeoJSON`() {
val coordinates = listOf(
listOf(11.0, 48.0),
listOf(11.1, 48.1),
listOf(11.2, 48.2)
)
val result = GeoUtils.createLineStringCollection(coordinates)
assertTrue(result.contains("LineString"))
assertTrue(result.contains("11.0"))
assertTrue(result.contains("48.0"))
assertTrue(result.contains("FeatureCollection"))
}
@Test
fun `createPointCollection creates valid GeoJSON with category`() {
val coordinates = listOf(
listOf(11.0, 48.0),
listOf(11.1, 48.1)
)
val category = "TestCategory"
val result = GeoUtils.createPointCollection(coordinates, category)
assertTrue(result.contains("Point"))
assertTrue(result.contains("TestCategory"))
assertTrue(result.contains("FeatureCollection"))
}
@Test
fun `createPointCollection handles empty coordinates`() {
val coordinates = emptyList<List<Double>>()
val result = GeoUtils.createPointCollection(coordinates, "Empty")
assertTrue(result.contains("FeatureCollection"))
}
@Test
fun `calculateSquareRadius returns correct bounding box`() {
val lat = 48.0
val lng = 11.0
val radius = 1.0 // 1 km
val result = GeoUtils.calculateSquareRadius(lat, lng, radius)
// Result should be in format: lngMin,latMin,lngMax,latMax
val parts = result.split(",")
assertEquals(4, parts.size)
val lngMin = parts[0].toDouble()
val latMin = parts[1].toDouble()
val lngMax = parts[2].toDouble()
val latMax = parts[3].toDouble()
assertTrue(latMin < lat)
assertTrue(latMax > lat)
assertTrue(lngMin < lng)
assertTrue(lngMax > lng)
assertTrue(latMax - latMin > 0)
assertTrue(lngMax - lngMin > 0)
}
@Test
fun `getBoundingBox returns correct format`() {
val lat = 48.0
val lon = 11.0
val radius = 1.0 // 1 km
val result = GeoUtils.getBoundingBox(lat, lon, radius)
// Result should be in format: minLat,minLon,maxLat,maxLon
val parts = result.split(",")
assertEquals(4, parts.size)
val minLat = parts[0].toDouble()
val minLon = parts[1].toDouble()
val maxLat = parts[2].toDouble()
val maxLon = parts[3].toDouble()
assertTrue(minLat < lat)
assertTrue(maxLat > lat)
assertTrue(minLon < lon)
assertTrue(maxLon > lon)
}
@Test
fun `calculateSquareRadius and getBoundingBox produce different formats`() {
val lat = 48.0
val lng = 11.0
val radius = 1.0
val squareRadius = GeoUtils.calculateSquareRadius(lat, lng, radius)
val boundingBox = GeoUtils.getBoundingBox(lat, lng, radius)
// Both should have 4 comma-separated values
assertEquals(4, squareRadius.split(",").size)
assertEquals(4, boundingBox.split(",").size)
// But the order should be different
assertNotEquals(squareRadius, boundingBox)
}
@Test
fun `calculateSquareRadius scales with radius`() {
val lat = 48.0
val lng = 11.0
val smallRadius = GeoUtils.calculateSquareRadius(lat, lng, 1.0)
val largeRadius = GeoUtils.calculateSquareRadius(lat, lng, 10.0)
val smallParts = smallRadius.split(",").map { it.toDouble() }
val largeParts = largeRadius.split(",").map { it.toDouble() }
// Larger radius should produce larger bounding box
assertTrue((largeParts[3] - largeParts[1]) > (smallParts[3] - smallParts[1]))
}
}

View File

@@ -1,6 +1,8 @@
[versions] [versions]
agp = "9.0.1" agp = "9.0.1"
androidGpxParser = "2.3.1"
androidSdkTurf = "6.0.1" androidSdkTurf = "6.0.1"
datastore = "1.2.0"
gradle = "9.0.1" gradle = "9.0.1"
koinAndroid = "4.1.1" koinAndroid = "4.1.1"
koinAndroidxCompose = "4.1.1" koinAndroidxCompose = "4.1.1"
@@ -13,38 +15,45 @@ junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
kotlinxSerializationJson = "1.10.0" kotlinxSerializationJson = "1.10.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
composeBom = "2026.02.00" composeBom = "2026.02.01"
appcompat = "1.7.1" appcompat = "1.7.1"
material = "1.13.0" material = "1.13.0"
carApp = "1.7.0" carApp = "1.7.0"
androidx-car = "1.7.0" androidx-car = "1.7.0"
objectboxKotlin = "5.2.0" materialIconsExtended = "1.7.8"
objectboxProcessor = "5.2.0" mockitoCore = "5.21.0"
ui = "1.10.0" mockitoKotlin = "6.2.3"
rules = "1.7.0"
runner = "1.7.0"
material3 = "1.4.0" material3 = "1.4.0"
runtimeLivedata = "1.10.3" runtimeLivedata = "1.10.4"
foundation = "1.10.3" foundation = "1.10.4"
maplibre-composeMaterial3 = "0.12.2"
maplibre-compose = "0.12.1" maplibre-compose = "0.12.1"
playServicesLocation = "21.3.0" playServicesLocation = "21.3.0"
runtime = "1.10.3" runtime = "1.10.4"
accompanist = "0.37.3" accompanist = "0.37.3"
uiVersion = "1.10.3" uiVersion = "1.10.4"
uiText = "1.10.3" uiText = "1.10.4"
navigationCompose = "2.9.7" navigationCompose = "2.9.7"
uiToolingPreview = "1.10.3" uiToolingPreview = "1.10.4"
uiTooling = "1.10.3" uiTooling = "1.10.4"
material3WindowSizeClass = "1.4.0" material3WindowSizeClass = "1.4.0"
uiGraphics = "1.10.3" uiGraphics = "1.10.4"
window = "1.5.1" window = "1.5.1"
foundationLayout = "1.10.3" foundationLayout = "1.10.4"
datastorePreferences = "1.2.0" datastorePreferences = "1.2.0"
datastoreCore = "1.2.0" datastoreCore = "1.2.0"
monitor = "1.8.0"
[libraries] [libraries]
android-gpx-parser = { module = "com.github.ticofab:android-gpx-parser", version.ref = "androidGpxParser" }
android-sdk-turf = { module = "org.maplibre.gl:android-sdk-turf", version.ref = "androidSdkTurf" } android-sdk-turf = { module = "org.maplibre.gl:android-sdk-turf", version.ref = "androidSdkTurf" }
androidx-app-projected = { module = "androidx.car.app:app-projected" } androidx-app-projected = { module = "androidx.car.app:app-projected" }
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "materialIconsExtended" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-rules = { module = "androidx.test:rules", version.ref = "rules" }
androidx-runner = { module = "androidx.test:runner", version.ref = "runner" }
gradle = { module = "com.android.tools.build:gradle", version.ref = "gradle" } gradle = { module = "com.android.tools.build:gradle", version.ref = "gradle" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
@@ -60,10 +69,9 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koinCore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "carApp" } androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "carApp" }
objectbox-kotlin = { module = "io.objectbox:objectbox-kotlin", version.ref = "objectboxKotlin" } mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" }
objectbox-processor = { module = "io.objectbox:objectbox-processor", version.ref = "objectboxProcessor" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre-compose" } maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre-compose" }
maplibre-composeMaterial3 = { module = "org.maplibre.compose:maplibre-compose-material3", version = "maplibre-composeMaterial3" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" } androidx-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }
@@ -82,10 +90,12 @@ androidx-window = { group = "androidx.window", name = "window", version.ref = "w
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" } androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" }
androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-kapt = { id = "com.android.legacy-kapt", version.ref = "agp" }
android-protobuf = {id = "com.google.protobuf" }

View File

@@ -10,6 +10,14 @@ pluginManagement {
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
} }
resolutionStrategy {
eachPlugin {
// Map the plugin ID to the Maven artifact
if (requested.id.id == "io.objectbox") {
useModule("io.objectbox:objectbox-gradle-plugin:${requested.version}")
}
}
}
} }
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)