Testing, Remove ObjectBox
This commit is contained in:
@@ -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")
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -18,4 +18,6 @@ data class NavigationState (
|
||||
val currentRouteIndex: Int = 0,
|
||||
val destination: Place = Place(),
|
||||
val carConnection: Int = 0,
|
||||
var routingEngine: Int = 0,
|
||||
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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§ionType=lanes" +
|
||||
"&routeRepresentation=encodedPolyline" +
|
||||
"&vehicleEngineType=combustion&key=$tomtomApiKey"
|
||||
"&vehicleEngineType=combustion$filter&key=$tomtomApiKey"
|
||||
return fetchUrl(
|
||||
url,
|
||||
false
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>>()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user