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

View File

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

View File

@@ -36,7 +36,6 @@ fun test(applicationContext: Context, routeModel: RouteModel) {
for ((index, step) in routeModel.curLeg.steps.withIndex()) {
for ((windex, waypoint) in step.maneuver.waypoints.withIndex()) {
routeModel.updateLocation(
applicationContext,
location(waypoint[0], waypoint[1]), navigationViewModel
)
val step = routeModel.currentStep()
@@ -81,7 +80,6 @@ fun testSingleUpdate(
mock.setMockLocation(latitude, longitude, 0F)
} else {
routeModel.updateLocation(
applicationContext,
location(longitude, latitude), navigationViewModel
)
}
@@ -110,10 +108,8 @@ fun gpx(context: Context, mock: MockLocation) {
speed = ext.speed
mock.curSpeed = speed.toFloat()
}
val duration = p.time.millis - lastTime.millis
val bearing = lastLocation.bearingTo(curLocation)
println("Bearing $bearing")
mock.setMockLocation(p.latitude, p.longitude, bearing)
if (duration > 0) {
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.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.bearing
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.Location
@@ -83,7 +86,7 @@ class MainActivity : ComponentActivity() {
val routeModel = RouteModel()
var tilt = 50.0
val useMock = false
val type = SimulationType.GPX
val type = SimulationType.SIMULATE
val stepData: MutableLiveData<StepData> by lazy {
MutableLiveData()
@@ -95,7 +98,13 @@ class MainActivity : ComponentActivity() {
var lastLocation = location(0.0, 0.0)
val observer = Observer<String> { newRoute ->
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
if (useMock) {
when (type) {
@@ -189,7 +198,7 @@ class MainActivity : ComponentActivity() {
val scaffoldState = rememberBottomSheetScaffoldState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val sheetPeekHeight = 250.dp
val sheetPeekHeight = 180.dp
val sheetPeekHeightState = remember { mutableStateOf(sheetPeekHeight) }
val locationProvider = rememberDefaultLocationProvider(
@@ -324,7 +333,7 @@ class MainActivity : ComponentActivity() {
with(routeModel) {
if (isNavigating()) {
updateLocation(applicationContext, currentLocation, navigationViewModel)
updateLocation( currentLocation, navigationViewModel)
stepData.value = currentStep()
nextStepData.value = nextStep()
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 longitude = routeModel.curRoute.waypoints[0][0]
closeSheet()
routeModel.stopNavigation(applicationContext)
routeModel.stopNavigation()
getSettingsViewModel(applicationContext).onLastRouteChanged("")
if (useMock) {
mock.setMockLocation(latitude, longitude, 0F)
}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
@@ -26,13 +23,8 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
}
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
@@ -46,8 +38,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.ui)
implementation(libs.maplibre.compose)
//implementation(libs.maplibre.composeMaterial3)
implementation(libs.androidx.app.projected)
implementation(project(":common:data"))
implementation(libs.androidx.runtime.livedata)
implementation(libs.androidx.compose.foundation)
@@ -56,6 +47,11 @@ dependencies {
implementation(libs.androidx.compose.ui.text)
implementation(libs.play.services.location)
implementation(libs.androidx.datastore.core)
implementation(libs.androidx.monitor)
androidTestImplementation(libs.androidx.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_SNAP_CORRECTION
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.ObjectBox
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.tomtom.TomTomRepository
@@ -119,19 +118,26 @@ class NavigationSession : Session(), NavigationScreen.Listener {
*/
fun onConnectionStateUpdated(connectionState: Int) {
routeModel.navState = routeModel.navState.copy(carConnection = connectionState)
if (::carSensorManager.isInitialized) {
carSensorManager.updateConnectionState(connectionState)
}
when (connectionState) {
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> Unit
CarConnection.CONNECTION_TYPE_NATIVE -> {
ObjectBox.init(carContext)
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 -> {
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)
}
}
}
}
}
/**
@@ -318,13 +324,11 @@ class NavigationSession : Session(), NavigationScreen.Listener {
* Snaps location to route and checks for deviation requiring reroute.
*/
private fun handleNavigationLocation(location: Location) {
routeModel.navState = routeModel.navState.copy(travelMessage = "${location.latitude} ${location.longitude}")
navigationScreen.updateTrip(location)
if (routeModel.navState.arrived) return
val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations())
val distance = location.distanceTo(snappedLocation)
when {
distance > MAXIMAL_ROUTE_DEVIATION -> {
navigationScreen.calculateNewRoute(routeModel.navState.destination)
@@ -342,8 +346,8 @@ class NavigationSession : Session(), NavigationScreen.Listener {
* Stops active navigation and clears route state.
* Called when user exits navigation or arrives at destination.
*/
override fun stopNavigation(context: CarContext) {
routeModel.stopNavigation(context)
override fun stopNavigation() {
routeModel.stopNavigation()
}

View File

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

View File

@@ -116,7 +116,7 @@ fun MapLibre(
@Composable
fun RouteLayer(routeData: String?, trafficData: Map<String, String>) {
if (routeData != null && routeData.isNotEmpty()) {
if (!routeData.isNullOrEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
LineLayer(
id = "routes-casing",
@@ -184,7 +184,7 @@ fun RouteLayer(routeData: String?, trafficData: Map<String, String>) {
@Composable
fun RouteLayerPoint(routeData: String?) {
if (routeData != null && routeData.isNotEmpty()) {
if (!routeData.isNullOrEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
val img = image(painterResource(R.drawable.ic_favorite_filled_white_24dp), drawAsSdf = true)
SymbolLayer(
@@ -209,11 +209,11 @@ fun RouteLayerPoint(routeData: String?) {
fun trafficColor(key: String): Expression<ColorValue> {
when (key) {
"queuing" -> return const(Color(0xFFD24417))
"queuing" -> return const(Color(0xFFC46E53))
"stationary" -> return const(Color(0xFFFF0000))
"heavy" -> return const(Color(0xFF6B0404))
"slow" -> return const(Color(0xFFC41F1F))
"roadworks" -> return const(Color(0xFF7A631A))
"slow" -> return const(Color(0xFFBD2525))
"roadworks" -> return const(Color(0xFF725A0F))
}
return const(Color.Blue)
}
@@ -291,7 +291,8 @@ fun DrawNavigationImages(
speed: Float?,
maxSpeed: Int,
width: Int,
height: Int
height: Int,
lat: Double?
) {
NavigationImage(padding, width, height)
if (speed != null) {
@@ -300,26 +301,26 @@ fun DrawNavigationImages(
if (speed != null && maxSpeed > 0 && (speed * 3.6) > maxSpeed) {
MaxSpeed(width, height, maxSpeed)
}
//DebugInfo(width, height, routeModel)
//DebugInfo(width, height, lat!!)
}
@Composable
fun NavigationImage(padding: PaddingValues, width: Int, height: Int) {
val imageSize = (height / 8)
val color = remember { NavigationColor }
val navigationColor = remember { NavigationColor }
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(padding)) {
Canvas(
modifier = Modifier
.size(imageSize.dp, imageSize.dp)
) {
scale(scaleX = 1f, scaleY = 0.7f) {
drawCircle(Color.DarkGray.copy(alpha = 0.3f))
drawCircle(navigationColor.copy(alpha = 0.3f))
}
}
Icon(
painter = painterResource(id = R.drawable.navigation_48px),
"Navigation",
tint = color.copy(alpha = 0.7f),
tint = navigationColor.copy(alpha = 0.7f),
modifier = Modifier
.size(imageSize.dp, imageSize.dp)
.scale(scaleX = 1f, scaleY = 0.7f),
@@ -453,7 +454,7 @@ private fun MaxSpeed(
fun DebugInfo(
width: Int,
height: Int,
routeModel: RouteModel,
latitude: Double,
) {
Box(
@@ -465,19 +466,18 @@ fun DebugInfo(
contentAlignment = Alignment.CenterStart
) {
val textMeasurerLocation = rememberTextMeasurer()
val location = routeModel.navState.currentLocation.latitude.toString()
val styleSpeed = TextStyle(
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
)
val textLayoutLocation = remember(location) {
textMeasurerLocation.measure(location, styleSpeed)
val textLayoutLocation = remember(latitude.toString()) {
textMeasurerLocation.measure(latitude.toString(), styleSpeed)
}
Canvas(modifier = Modifier.fillMaxSize()) {
drawText(
textMeasurer = textMeasurerLocation,
text = location,
text = latitude.toString(),
style = styleSpeed,
topLeft = Offset(
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
import android.location.Location
@@ -138,9 +123,9 @@ class RouteCarModel() : RouteModel() {
var laneImageAdded = false
stepData.lane.forEach {
if (it.indications.isNotEmpty() && it.valid) {
Collections.sort<String>(it.indications)
val sorted = it.indications.sorted()
var direction = ""
it.indications.forEach { it2 ->
sorted.forEach { it2 ->
direction = if (direction.isEmpty()) {
it2.trim()
} else {
@@ -212,7 +197,6 @@ class RouteCarModel() : RouteModel() {
.addAction(dismissAction).setCallback(object : AlertCallback {
override fun onCancel(reason: Int) {
}
override fun onDismiss() {
}
}).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.RoutingInfo
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.ViewStyle
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.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.GeoUtils
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.time.Duration
import java.time.LocalDateTime
import java.time.ZoneOffset
@@ -51,58 +54,67 @@ class NavigationScreen(
private var routeModel: RouteCarModel,
private var listener: Listener,
private val navigationViewModel: NavigationViewModel
) :
Screen(carContext) {
) : Screen(carContext), NavigationObserverCallback {
/** A listener for navigation start and stop signals. */
interface Listener {
/** Stops navigation. */
fun stopNavigation(context: CarContext)
fun stopNavigation()
}
val backGroundColor = CarColor.BLUE
var currentNavigationLocation = Location(LocationManager.GPS_PROVIDER)
var recentPlace = Place()
var navigationType = NavigationType.VIEW
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()) {
val repository = getSettingsRepository(carContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
routeModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
navigationType = NavigationType.NAVIGATION
routeModel.startNavigation(route, carContext)
routeModel.startNavigation(route)
if (routeModel.hasLegs()) {
getSettingsViewModel(carContext).onLastRouteChanged(route)
}
surfaceRenderer.setRouteData()
invalidate()
}
}
val recentObserver = Observer<Place> { lastPlace ->
if (!routeModel.isNavigating()) {
recentPlace = lastPlace
navigationType = NavigationType.RECENT
invalidate()
}
}
val trafficObserver = Observer<Map<String, String>> { traffic ->
surfaceRenderer.setTrafficData(traffic)
override fun isNavigating(): Boolean = routeModel.isNavigating()
override fun onRecentPlaceReceived(place: Place) {
recentPlace = place
navigationType = NavigationType.RECENT
invalidate()
}
val placeObserver = Observer<SearchResult> { searchResult ->
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
)
override fun onTrafficReceived(traffic: Map<String, String>) {
surfaceRenderer.setTrafficData(traffic)
}
override fun onPlaceSearchResultReceived(place: Place) {
navigateToPlace(place)
}
var lastCameraSearch = 0
var speedCameras = listOf<Elements>()
val speedObserver = Observer<List<Elements>> { cameras ->
override fun onSpeedCamerasReceived(cameras: List<Elements>) {
speedCameras = cameras
val coordinates = mutableListOf<List<Double>>()
cameras.forEach {
@@ -112,21 +124,12 @@ class NavigationScreen(
surfaceRenderer.speedCamerasData.value = speedData
}
val maxSpeedObserver = Observer<Int> { speed ->
override fun onMaxSpeedReceived(speed: Int) {
surfaceRenderer.maxSpeed.value = speed
}
init {
navigationViewModel.route.observe(this, observer)
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 invalidateScreen() {
invalidate()
}
override fun onGetTemplate(): Template {
@@ -151,7 +154,7 @@ class NavigationScreen(
.setDestinationTravelEstimate(routeModel.travelEstimate(carContext))
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.setBackgroundColor(CarColor.GREEN)
.setBackgroundColor(backGroundColor)
.build()
}
@@ -208,7 +211,7 @@ class NavigationScreen(
)
.build()
)
.setBackgroundColor(CarColor.GREEN)
.setBackgroundColor(CarColor.SECONDARY)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(mapActionStripBuilder().build())
.build()
@@ -243,7 +246,7 @@ class NavigationScreen(
return NavigationTemplate.Builder()
.setNavigationInfo(RoutingInfo.Builder().setLoading(true).build())
.setActionStrip(actionStripBuilder.build())
.setBackgroundColor(CarColor.GREEN)
.setBackgroundColor(backGroundColor)
.build()
}
@@ -447,7 +450,7 @@ class NavigationScreen(
fun navigateToPlace(place: Place) {
navigationType = NavigationType.VIEW
val location = location(place.longitude, place.latitude)
navigationViewModel.saveRecent(place)
navigationViewModel.saveRecent(carContext, place)
currentNavigationLocation = location
navigationViewModel.loadRoute(
carContext,
@@ -461,7 +464,7 @@ class NavigationScreen(
fun stopNavigation() {
navigationType = NavigationType.VIEW
listener.stopNavigation(carContext)
listener.stopNavigation()
surfaceRenderer.routeData.value = ""
lastCameraSearch = 0
invalidate()
@@ -502,7 +505,7 @@ class NavigationScreen(
}
updateSpeedCamera(location)
with(routeModel) {
updateLocation(carContext, location, navigationViewModel)
updateLocation( location, navigationViewModel)
if ((navState.maneuverType == Maneuver.TYPE_DESTINATION
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_LEFT
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_RIGHT
@@ -510,6 +513,7 @@ class NavigationScreen(
&& routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
) {
stopNavigation()
getSettingsViewModel(carContext).onLastRouteChanged("")
navState = navState.copy(arrived = true)
surfaceRenderer.routeData.value = ""
navigationType = NavigationType.ARRIVAL

View File

@@ -89,7 +89,7 @@ class PlaceListScreen(
""
}
val row = Row.Builder()
.setImage(contactIcon(it.avatar, it.category))
// .setImage(contactIcon(it.avatar, it.category))
.setTitle("$street ${it.city}")
.setOnClickListener {
val place = Place(
@@ -101,7 +101,7 @@ class PlaceListScreen(
it.postalCode,
it.city,
it.street,
avatar = null
// avatar = null
)
screenManager
.pushForResult(
@@ -162,7 +162,7 @@ class PlaceListScreen(
)
)
.setOnClickListener {
navigationViewModel.deletePlace(place)
navigationViewModel.deletePlace(carContext, place)
CarToast.makeText(
carContext,
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.data.Place
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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import java.math.BigDecimal
import java.math.RoundingMode
@@ -48,7 +52,10 @@ class RoutePreviewScreen(
val navigationMessage = NavigationMessage(carContext)
val observer = Observer<String> { route ->
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)
invalidate()
}
@@ -163,7 +170,7 @@ class RoutePreviewScreen(
CarToast.LENGTH_SHORT
)
.show()
navigationViewModel.saveFavorite(destination)
navigationViewModel.saveFavorite(carContext, destination)
invalidate()
}
.build()
@@ -171,7 +178,7 @@ class RoutePreviewScreen(
private fun deleteFavoriteAction(): Action = Action.Builder()
.setOnClickListener {
if (isFavorite) {
navigationViewModel.deleteFavorite(destination)
navigationViewModel.deleteFavorite(carContext,destination)
}
isFavorite = !isFavorite
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,14 +1,9 @@
import org.gradle.kotlin.dsl.annotationProcessor
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
kotlin("plugin.serialization") version "2.2.21"
kotlin("kapt")
}
alias(libs.plugins.kotlin.kapt)
}
android {
namespace = "com.kouros.data"
@@ -37,9 +32,9 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
testOptions {
unitTests {
isReturnDefaultValues = true
}
}
}
@@ -60,20 +55,20 @@ dependencies {
implementation(libs.android.sdk.turf)
implementation(libs.androidx.compose.runtime)
// objectbox
implementation(libs.objectbox.kotlin)
implementation(libs.androidx.material3)
annotationProcessor(libs.objectbox.processor)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
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.mockito.core)
testImplementation(libs.mockito.kotlin)
androidTestImplementation(libs.androidx.junit)
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
val NavigationColor = Color(0xFF0730B2)
val NavigationColor = Color(0xFF16BBB6)
val RouteColor = Color(0xFF5582D0)
val RouteColor = Color(0xFF7B06E1)
val SpeedColor = Color(0xFF262525)

View File

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

View File

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

View File

@@ -18,4 +18,6 @@ data class NavigationState (
val currentRouteIndex: Int = 0,
val destination: Place = Place(),
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 TOMTOM_APIKEY = stringPreferencesKey("TomTomApiKey")
val RECENT_PLACES = stringPreferencesKey("RecentPlaces")
val FAVORITES = stringPreferencesKey("Favorites")
}
// 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
suspend fun setShow3D(enabled: Boolean) {
context.dataStore.edit { preferences ->
@@ -137,4 +154,16 @@ class DataStoreManager(private val context: Context) {
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() }
return routeJsonString
}
var filter = ""
if (searchFilter.avoidMotorway) {
filter = "$filter&avoid=motorways"
}
if (searchFilter.avoidTollway) {
filter = "$filter&avoid=tollRoads"
}
val repository = getSettingsRepository(context)
val tomtomApiKey = runBlocking { repository.tomTomApiKeyFlow.first() }
val url =
@@ -42,7 +49,7 @@ class TomTomRepository : NavigationRepository() {
"&vehicleMaxSpeed=120&vehicleCommercial=false" +
"&instructionsType=text&language=en-GB&sectionType=lanes" +
"&routeRepresentation=encodedPolyline" +
"&vehicleEngineType=combustion&key=$tomtomApiKey"
"&vehicleEngineType=combustion$filter&key=$tomtomApiKey"
return fetchUrl(
url,
false

View File

@@ -1,30 +1,27 @@
package com.kouros.navigation.model
//import com.kouros.navigation.data.Preferences.boxStore
import android.content.Context
import android.location.Location
import androidx.car.app.CarContext
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.platform.isDebugInspectorInfoEnabled
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.GsonBuilder
import com.kouros.navigation.data.Constants
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.Places
import com.kouros.navigation.data.SearchFilter
import com.kouros.navigation.data.nominatim.Search
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.overpass.Overpass
import com.kouros.navigation.utils.Levenshtein
import com.kouros.navigation.utils.NavigationUtils
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import io.objectbox.kotlin.boxFor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
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.
*/
fun loadRecentPlace(location: Location, carOrientation: Float, context: Context) {
viewModelScope.launch(Dispatchers.IO) {
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(Place_.name.notEqual(""))
.orderDesc(Place_.lastDate)
.build()
val results = query.find()
query.close()
for (place in results) {
val settingsRepository = getSettingsRepository(context)
val recentPlaces = settingsRepository.recentPlacesFlow.first()
val gson = GsonBuilder().serializeNulls().create()
val places = gson.fromJson(recentPlaces, Places::class.java)
val place = places.places.minByOrNull { it.lastDate.dec() }
if (place != null) {
val plLocation = location(place.longitude, place.latitude)
val distance = repository.getRouteDistance(
location,
@@ -145,33 +140,39 @@ 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.
*/
fun loadRecentPlaces(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.RECENT)))
.orderDesc(Place_.lastDate)
.build()
val results = query.find()
query.close()
for (place in results) {
val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) {
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
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>()
var id : Long = 0
if (rp.isNotEmpty()) {
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(results)
places.postValue(pl)
} catch (e: Exception) {
e.printStackTrace()
}
@@ -179,32 +180,36 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
/**
* Loads favorite places from ObjectBox and calculates distances.
* 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 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()
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)
if (place.latitude != 0.0) {
val distance =
repository.getRouteDistance(
location,
plLocation,
carOrientation,
context
)
place.distance = distance.toFloat()
}
pl.add(place)
}
}
}
favorites.postValue(results)
favorites.postValue(pl)
} catch (e: Exception) {
e.printStackTrace()
}
@@ -335,7 +340,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
Constants.CONTACTS,
street = addressLines[0],
city = addressLines[1],
avatar = address.avatar,
//avatar = address.avatar,
longitude = 0.0,
latitude = 0.0,
distance = 0F,
@@ -417,7 +422,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
val distAmenities = mutableListOf<Elements>()
amenities.forEach {
val plLocation =
location(longitude = it.lon!!, latitude = it.lat!!)
location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
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
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.
*/
fun saveRecent(place: Place) {
fun saveRecent(context: Context, place: Place) {
if (place.category == Constants.FUEL_STATION
|| place.category == Constants.CHARGING_STATION
|| place.category == Constants.PHARMACY
@@ -489,30 +494,36 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
return
}
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.
*/
private fun savePlace(place: Place) {
private fun savePlace(context: Context, place: Place) {
viewModelScope.launch(Dispatchers.IO) {
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(
Place_.name.equal(place.name!!).and(Place_.category.equal(place.category!!))
)
.build()
val results = query.find()
query.close()
if (results.isNotEmpty()) {
placeBox.remove(results.first())
val places = mutableListOf<Place>()
val gson = GsonBuilder().serializeNulls().create()
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
var id : Long = 0
if (rp.isNotEmpty()) {
val recentPlaces =
gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
for (curPlace in recentPlaces) {
if (curPlace.name != place.name || curPlace.category != place.category) {
curPlace.id = id
places.add(curPlace)
id += 1
}
}
}
val current = LocalDateTime.now(ZoneOffset.UTC)
place.lastDate = current.atZone(ZoneOffset.UTC).toEpochSecond()
placeBox.put(place)
places.add(place)
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
} catch (e: Exception) {
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
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
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) {
try {
val placeBox = boxStore.boxFor(Place::class)
val query = placeBox
.query(
Place_.name.equal(place.name!!).and(Place_.category.equal(place.category!!))
)
.build()
val results = query.find()
query.close()
if (results.isNotEmpty()) {
placeBox.remove(results.first())
val gson = GsonBuilder().serializeNulls().create()
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
val places = mutableListOf<Place>()
if (rp.isNotEmpty()) {
val recentPlaces =
gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
for (curPlace in recentPlaces) {
if (curPlace.name != place.name || curPlace.category != place.category) {
places.add(curPlace)
}
}
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
}
} catch (e: Exception) {
e.printStackTrace()
@@ -569,59 +583,23 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
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.
* @return SnapshotStateList of recent places
*/
fun loadRecentPlace(): 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()
return results.toMutableStateList()
} catch (e: Exception) {
e.printStackTrace()
fun loadRecentPlace(context: Context): SnapshotStateList<Place?> {
val pl = mutableListOf<Place>()
val settingsRepository = getSettingsRepository(context)
val rp = runBlocking { settingsRepository.recentPlacesFlow.first()}
if (rp.isNotEmpty()) {
val gson = GsonBuilder().serializeNulls().create()
val recentPlaces = gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
for (place in recentPlaces) {
if (place.category == Constants.RECENT) {
pl.add(place)
}
}
}
return results.toMutableStateList()
return pl.toMutableStateList()
}
}

View File

@@ -1,25 +1,17 @@
package com.kouros.navigation.model
import android.content.Context
import android.location.Location
import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_NATIVE
import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_PROJECTION
import androidx.car.app.navigation.model.Maneuver
import com.kouros.navigation.data.Constants.NEXT_STEP_THRESHOLD
import com.kouros.navigation.data.NavigationState
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.Route
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.Leg
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 kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlin.math.absoluteValue
open class RouteModel {
@@ -37,40 +29,40 @@ open class RouteModel {
val curLeg: Leg
get() = navState.route.routes[navState.currentRouteIndex].legs.first()
fun startNavigation(routeString: String, context: Context) {
val repository = getSettingsRepository(context)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
fun startNavigation(routeString: String) {
navState = navState.copy(
route = Route.Builder()
.routeEngine(routingEngine)
.routeEngine(navState.routingEngine)
.route(routeString)
.build()
)
if (hasLegs()) {
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()
}
fun stopNavigation(context: Context) {
fun stopNavigation() {
navState = navState.copy(
route = Route.Builder().buildEmpty(),
navigating = false,
arrived = false,
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)
routeCalculator.findStep(curLocation)
if (navState.carConnection == CONNECTION_TYPE_PROJECTION
|| navState.carConnection == CONNECTION_TYPE_NATIVE) {
|| navState.carConnection == CONNECTION_TYPE_NATIVE
) {
routeCalculator.updateSpeedLimit(curLocation, viewModel)
}
navState = navState.copy(lastLocation = navState.currentLocation)
@@ -80,15 +72,11 @@ open class RouteModel {
val distanceToNextStep = routeCalculator.leftStepDistance()
// Determine the maneuver type and corresponding icon
val currentStep = navState.route.nextStep(0)
val streetName = if (distanceToNextStep > NEXT_STEP_THRESHOLD) {
currentStep.street
} else {
currentStep.maneuver.street
}
val curManeuverType = if (distanceToNextStep > NEXT_STEP_THRESHOLD) {
Maneuver.TYPE_STRAIGHT
} else {
currentStep.maneuver.type
var streetName = currentStep.maneuver.street
var curManeuverType = currentStep.maneuver.type
if (distanceToNextStep > NEXT_STEP_THRESHOLD) {
streetName = currentStep.street
curManeuverType = Maneuver.TYPE_STRAIGHT
}
val exitNumber = currentStep.maneuver.exit
val maneuverIcon = navState.iconMapper.maneuverIcon(curManeuverType)
@@ -108,13 +96,14 @@ open class RouteModel {
fun nextStep(): StepData {
val distanceToNextStep = routeCalculator.leftStepDistance()
val step = navState.route.nextStep(1)
val streetName = if (distanceToNextStep < NEXT_STEP_THRESHOLD) {
step.maneuver.street
} else {
step.street
val currentStep = navState.route.nextStep(0)
val nextStep = navState.route.nextStep(1)
var streetName = nextStep.street
var maneuverType = currentStep.maneuver.type
if (distanceToNextStep < NEXT_STEP_THRESHOLD) {
streetName = nextStep.maneuver.street
maneuverType = nextStep.maneuver.type
}
val maneuverType = step.maneuver.type
val maneuverIcon = navState.iconMapper.maneuverIcon(maneuverType)
// Construct and return the final StepData object
@@ -125,7 +114,7 @@ open class RouteModel {
icon = maneuverIcon,
arrivalTime = routeCalculator.arrivalTime(),
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]))
val sectionBearing =
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
}
}

View File

@@ -29,6 +29,14 @@ class SettingsRepository(
val tomTomApiKeyFlow: Flow<String> =
dataStoreManager.tomTomApiKeyFlow
val recentPlacesFlow: Flow<String> =
dataStoreManager.recentPlacesFlow
val favoritesFlow: Flow<String> =
dataStoreManager.favoritesFlow
suspend fun setShow3D(enabled: Boolean) {
dataStoreManager.setShow3D(enabled)
}
@@ -61,4 +69,11 @@ class SettingsRepository(
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>> {
val factor = 10.0.pow(precision)
var lat = 0
var lng = 0
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]
agp = "9.0.1"
androidGpxParser = "2.3.1"
androidSdkTurf = "6.0.1"
datastore = "1.2.0"
gradle = "9.0.1"
koinAndroid = "4.1.1"
koinAndroidxCompose = "4.1.1"
@@ -13,38 +15,45 @@ junitVersion = "1.3.0"
espressoCore = "3.7.0"
kotlinxSerializationJson = "1.10.0"
lifecycleRuntimeKtx = "2.10.0"
composeBom = "2026.02.00"
composeBom = "2026.02.01"
appcompat = "1.7.1"
material = "1.13.0"
carApp = "1.7.0"
androidx-car = "1.7.0"
objectboxKotlin = "5.2.0"
objectboxProcessor = "5.2.0"
ui = "1.10.0"
materialIconsExtended = "1.7.8"
mockitoCore = "5.21.0"
mockitoKotlin = "6.2.3"
rules = "1.7.0"
runner = "1.7.0"
material3 = "1.4.0"
runtimeLivedata = "1.10.3"
foundation = "1.10.3"
maplibre-composeMaterial3 = "0.12.2"
runtimeLivedata = "1.10.4"
foundation = "1.10.4"
maplibre-compose = "0.12.1"
playServicesLocation = "21.3.0"
runtime = "1.10.3"
runtime = "1.10.4"
accompanist = "0.37.3"
uiVersion = "1.10.3"
uiText = "1.10.3"
uiVersion = "1.10.4"
uiText = "1.10.4"
navigationCompose = "2.9.7"
uiToolingPreview = "1.10.3"
uiTooling = "1.10.3"
uiToolingPreview = "1.10.4"
uiTooling = "1.10.4"
material3WindowSizeClass = "1.4.0"
uiGraphics = "1.10.3"
uiGraphics = "1.10.4"
window = "1.5.1"
foundationLayout = "1.10.3"
foundationLayout = "1.10.4"
datastorePreferences = "1.2.0"
datastoreCore = "1.2.0"
monitor = "1.8.0"
[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" }
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-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" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
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" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "carApp" }
objectbox-kotlin = { module = "io.objectbox:objectbox-kotlin", version.ref = "objectboxKotlin" }
objectbox-processor = { module = "io.objectbox:objectbox-processor", version.ref = "objectboxProcessor" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
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-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata", version.ref = "runtimeLivedata" }
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-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-datastore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "datastoreCore" }
androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "monitor" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
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()
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 {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)