diff --git a/cardinal-android/app/build.gradle.kts b/cardinal-android/app/build.gradle.kts
index 141cbb441fdc351754522ec3af6045607edf339f..70561d7ec509d0103293d6a3d642b52f0e801eff 100644
--- a/cardinal-android/app/build.gradle.kts
+++ b/cardinal-android/app/build.gradle.kts
@@ -216,6 +216,7 @@ dependencies {
implementation(libs.ferrostar.maplibreui)
implementation(libs.ferrostar.composeui)
implementation(libs.okhttp3)
+ implementation(libs.logging.interceptor)
implementation(libs.androidaddressformatter)
implementation(libs.eos.telemetry)
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/CardinalFerrostarWrapperFactory.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/CardinalFerrostarWrapperFactory.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1d45533b889f0fd96442f10702ab30494431d09b
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/CardinalFerrostarWrapperFactory.kt
@@ -0,0 +1,60 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.data.navigation
+
+import android.content.Context
+import com.stadiamaps.ferrostar.core.SpokenInstructionObserver
+import dagger.hilt.android.qualifiers.ApplicationContext
+import earth.maps.cardinal.data.LocationRepository
+import earth.maps.cardinal.data.OrientationRepository
+import earth.maps.cardinal.data.RoutingMode
+import earth.maps.cardinal.data.room.RoutingProfileRepository
+import earth.maps.cardinal.routing.FerrostarWrapper
+import earth.maps.cardinal.routing.RoutingOptions
+import okhttp3.OkHttpClient
+import javax.inject.Inject
+
+class CardinalFerrostarWrapperFactory @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val locationRepository: LocationRepository,
+ private val orientationRepository: OrientationRepository,
+ private val androidTtsObserver: SpokenInstructionObserver,
+ private val routingProfileRepository: RoutingProfileRepository,
+ private val okHttpClient: OkHttpClient
+) : FerrostarWrapperFactory {
+
+ override fun create(
+ mode: RoutingMode,
+ endpoint: String,
+ routingOptions: RoutingOptions?
+ ): FerrostarWrapper {
+
+ return FerrostarWrapper(
+ context = context,
+ locationRepository = locationRepository,
+ orientationRepository = orientationRepository,
+ mode = mode,
+ localValhallaEndpoint = endpoint,
+ androidTtsObserver = androidTtsObserver,
+ routingProfileRepository = routingProfileRepository,
+ routingOptions = routingOptions,
+ okHttpClient = okHttpClient
+ )
+ }
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/CustomDeviationDetector.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/CustomDeviationDetector.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1db3649e3a240f0a4f96051cb1ddfdd5e467e15e
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/CustomDeviationDetector.kt
@@ -0,0 +1,66 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package earth.maps.cardinal.data.navigation
+
+import earth.maps.cardinal.data.LatLng
+import earth.maps.cardinal.domain.DeviationDetector
+
+class CustomDeviationDetector(
+ private val distanceCalculator: PolylineDistanceCalculator
+) : DeviationDetector {
+
+ private var lastRouteHash: Int = 0
+ private var lastOffRouteTs: Long = 0L
+
+ override fun isOffRoute(
+ location: LatLng,
+ route: List
+ ): Boolean {
+
+ if (route.size < MIN_ROUTE_POINTS) return false
+
+ val currentHash = route.hashCode()
+ if (currentHash != lastRouteHash) {
+ distanceCalculator.resetCache()
+ lastRouteHash = currentHash
+ }
+
+ val distance = distanceCalculator.distanceFromPolyline(location, route)
+
+ val now = System.currentTimeMillis()
+
+ val isOffRoute = if (distance > OFF_ROUTE_THRESHOLD_METERS) {
+ if (now - lastOffRouteTs > OFF_ROUTE_DEBOUNCE_MS) {
+ lastOffRouteTs = now
+ true
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+
+ return isOffRoute
+ }
+
+ companion object {
+ private const val OFF_ROUTE_THRESHOLD_METERS = 15.0
+ private const val OFF_ROUTE_DEBOUNCE_MS = 1500L
+ private const val MIN_ROUTE_POINTS = 2
+ }
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/FerrostarRouteDeviationDetector.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/FerrostarRouteDeviationDetector.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3b8d957e7d2b6093dc8280a135b5d4e8702ea4d9
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/FerrostarRouteDeviationDetector.kt
@@ -0,0 +1,58 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.data.navigation
+
+import earth.maps.cardinal.data.LatLng
+import earth.maps.cardinal.domain.DeviationDetector
+import uniffi.ferrostar.Route
+import uniffi.ferrostar.RouteDeviation
+import uniffi.ferrostar.RouteDeviationDetector
+import uniffi.ferrostar.RouteStep
+import uniffi.ferrostar.UserLocation
+
+class FerrostarRouteDeviationDetector(
+ private val deviationDetector: DeviationDetector
+) : RouteDeviationDetector {
+
+ override fun checkRouteDeviation(
+ location: UserLocation,
+ route: Route,
+ currentRouteStep: RouteStep
+ ): RouteDeviation {
+
+ val raw = LatLng(
+ location.coordinates.lat,
+ location.coordinates.lng
+ )
+
+ val geometry = route.geometry.map {
+ LatLng(it.lat, it.lng)
+ }
+
+ return if (deviationDetector.isOffRoute(raw, geometry)) {
+ RouteDeviation.OffRoute(DEVIATION_FROM_ROUTE_LINE)
+ } else {
+ RouteDeviation.NoDeviation
+ }
+ }
+
+ companion object {
+ private const val DEVIATION_FROM_ROUTE_LINE = 30.0
+ }
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/FerrostarWrapperFactory.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/FerrostarWrapperFactory.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6b04e5f8a9a85c0e18a9d6bd730a890204339b91
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/FerrostarWrapperFactory.kt
@@ -0,0 +1,30 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package earth.maps.cardinal.data.navigation
+
+import earth.maps.cardinal.data.RoutingMode
+import earth.maps.cardinal.routing.FerrostarWrapper
+import earth.maps.cardinal.routing.RoutingOptions
+
+interface FerrostarWrapperFactory {
+ fun create(
+ mode: RoutingMode,
+ endpoint: String,
+ routingOptions: RoutingOptions? = null
+ ): FerrostarWrapper
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/PolylineDistanceCalculator.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/PolylineDistanceCalculator.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2c8599a66be1c54c20dba88ee00879c609eed624
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/navigation/PolylineDistanceCalculator.kt
@@ -0,0 +1,157 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.data.navigation
+
+import earth.maps.cardinal.data.LatLng
+import javax.inject.Inject
+import kotlin.math.cos
+import kotlin.math.hypot
+
+class PolylineDistanceCalculator @Inject constructor() {
+
+ private var lastSegmentIndex = 0
+
+ fun distanceFromPolyline(
+ point: LatLng,
+ polyline: List
+ ): Double {
+ if (polyline.size < MIN_POLYLINE_POINTS) return Double.MAX_VALUE
+
+ val previousIndex = lastSegmentIndex
+
+ var minDistance = Double.MAX_VALUE
+ var bestIndex = previousIndex
+
+ val start = maxOf(0, previousIndex - SEARCH_WINDOW)
+ val end = minOf(polyline.size - 2, previousIndex + SEARCH_WINDOW)
+
+ // 1) Windowed search (fast path)
+ for (i in start..end) {
+ val d = distanceToSegmentMeters(point, polyline[i], polyline[i + 1])
+
+ if (d < minDistance) {
+ minDistance = d
+ bestIndex = i
+
+ if (minDistance < EARLY_EXIT_DISTANCE_METERS) {
+ lastSegmentIndex = bestIndex
+ return minDistance
+ }
+ }
+ }
+
+ // 2) Fallback conditions
+
+ // A) Edge detection → likely missed correct segment
+ val isNearWindowEdge =
+ bestIndex <= start + EDGE_MARGIN ||
+ bestIndex >= end - EDGE_MARGIN
+
+ // B) Movement jump → GPS jumped far from previous segment
+ val jumped = kotlin.math.abs(bestIndex - previousIndex) > SEARCH_WINDOW
+
+ // C) Distance too large → obvious mismatch
+ val isDistanceLarge = minDistance > FALLBACK_TRIGGER_DISTANCE_METERS
+
+ val shouldFallback = jumped || isNearWindowEdge || isDistanceLarge
+
+ // 3) Full scan fallback (rare but critical)
+ if (shouldFallback) {
+ var globalMin = minDistance
+ var globalBestIndex = bestIndex
+
+ for (i in 0 until polyline.size - 1) {
+ val d = distanceToSegmentMeters(point, polyline[i], polyline[i + 1])
+ if (d < globalMin) {
+ globalMin = d
+ globalBestIndex = i
+ }
+ }
+
+ minDistance = globalMin
+ bestIndex = globalBestIndex
+ }
+
+ lastSegmentIndex = bestIndex
+ return minDistance
+ }
+
+ /*
+ * Accurate distance using local projection (meters)
+ */
+ private fun distanceToSegmentMeters(p: LatLng, a: LatLng, b: LatLng): Double {
+
+ val latRad = Math.toRadians(p.latitude)
+
+ val ax = projectX(a.longitude, latRad)
+ val ay = projectY(a.latitude)
+
+ val bx = projectX(b.longitude, latRad)
+ val by = projectY(b.latitude)
+
+ val px = projectX(p.longitude, latRad)
+ val py = projectY(p.latitude)
+
+ val dx = bx - ax
+ val dy = by - ay
+
+ if (dx == ZERO && dy == ZERO) {
+ return hypot(px - ax, py - ay)
+ }
+
+ val t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)
+ val clamped = t.coerceIn(ZERO, ONE)
+
+ val projX = ax + clamped * dx
+ val projY = ay + clamped * dy
+
+ return hypot(px - projX, py - projY)
+ }
+
+ /*
+ * Longitude → meters (scaled by latitude)
+ */
+ private fun projectX(lon: Double, latRad: Double): Double {
+ return lon * cos(latRad) * METERS_PER_DEGREE
+ }
+
+ /*
+ * Latitude → meters
+ */
+ private fun projectY(lat: Double): Double {
+ return lat * METERS_PER_DEGREE
+ }
+
+ fun resetCache() {
+ lastSegmentIndex = 0
+ }
+
+ companion object {
+ private const val FALLBACK_TRIGGER_DISTANCE_METERS = 50.0
+ private const val EDGE_MARGIN = 2
+ private const val METERS_PER_DEGREE = 111_320.0
+ private const val MIN_POLYLINE_POINTS = 2
+
+ private const val SEARCH_WINDOW = 10
+ private const val EARLY_EXIT_DISTANCE_METERS = 5.0
+
+ private const val ZERO = 0.0
+ private const val ONE = 1.0
+ }
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/di/FerrostarWrapperModule.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/FerrostarWrapperModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e0fef8e29c405a0f613259a4d93f548a8a85188e
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/FerrostarWrapperModule.kt
@@ -0,0 +1,36 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package earth.maps.cardinal.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import earth.maps.cardinal.data.navigation.CardinalFerrostarWrapperFactory
+import earth.maps.cardinal.data.navigation.FerrostarWrapperFactory
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class FerrostarWrapperModule {
+
+ @Binds
+ abstract fun bindFerrostarWrapperFactory(
+ cardinalFerrostarWrapperFactory: CardinalFerrostarWrapperFactory
+ ): FerrostarWrapperFactory
+}
+
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/di/NetworkModule.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/NetworkModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..638ca4136483e9dd46ba6c02136691286c67d82a
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/NetworkModule.kt
@@ -0,0 +1,55 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package earth.maps.cardinal.di
+
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import earth.maps.cardinal.BuildConfig
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(): OkHttpClient {
+ val builder = OkHttpClient.Builder()
+ .connectTimeout(CONNECT_TIMEOUT_SEC, TimeUnit.SECONDS)
+ .readTimeout(READ_TIMEOUT_SEC, TimeUnit.SECONDS)
+ .writeTimeout(WRITE_TIMEOUT_SEC, TimeUnit.SECONDS)
+ .callTimeout(CALL_TIMEOUT_SEC, TimeUnit.SECONDS)
+ if (BuildConfig.DEBUG) {
+ val logging = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BASIC
+ }
+ builder.addInterceptor(logging)
+ }
+ return builder.build()
+ }
+
+ private const val CONNECT_TIMEOUT_SEC = 10L
+ private const val READ_TIMEOUT_SEC = 30L
+ private const val WRITE_TIMEOUT_SEC = 30L
+ private const val CALL_TIMEOUT_SEC = 30L
+}
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/domain/DeviationDetector.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/domain/DeviationDetector.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3ee5119151e450c4ca3e2540b8637b51ef76341f
--- /dev/null
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/domain/DeviationDetector.kt
@@ -0,0 +1,28 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package earth.maps.cardinal.domain
+
+import earth.maps.cardinal.data.LatLng
+
+interface DeviationDetector {
+ fun isOffRoute(
+ location: LatLng,
+ route: List
+ ): Boolean
+}
+
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapper.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapper.kt
index ae1b0010f1e513cfd66f297d31c975ce2d3414e2..d3a4f722497ef056d139c5f67b01059a23c03711 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapper.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapper.kt
@@ -31,6 +31,9 @@ import com.stadiamaps.ferrostar.core.toUserLocation
import earth.maps.cardinal.data.LocationRepository
import earth.maps.cardinal.data.OrientationRepository
import earth.maps.cardinal.data.RoutingMode
+import earth.maps.cardinal.data.navigation.CustomDeviationDetector
+import earth.maps.cardinal.data.navigation.FerrostarRouteDeviationDetector
+import earth.maps.cardinal.data.navigation.PolylineDistanceCalculator
import earth.maps.cardinal.data.room.RoutingProfileRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -54,14 +57,14 @@ import kotlin.time.toJavaInstant
class FerrostarWrapper(
context: Context,
private val locationRepository: LocationRepository,
- private val orientationRepository: OrientationRepository,
+ val orientationRepository: OrientationRepository,
private val mode: RoutingMode,
private val localValhallaEndpoint: String,
private val androidTtsObserver: SpokenInstructionObserver,
routingProfileRepository: RoutingProfileRepository,
- routingOptions: RoutingOptions? = null
+ routingOptions: RoutingOptions? = null,
+ private val okHttpClient: OkHttpClient
) {
-
private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val foregroundServiceManager: ForegroundServiceManager =
@@ -119,13 +122,15 @@ class FerrostarWrapper(
?: routingProfileRepository.createDefaultOptionsForMode(mode)
?.toValhallaOptionsJson()
)),
- httpClient = OkHttpClientProvider(OkHttpClient()),
+ httpClient = OkHttpClientProvider(okHttpClient),
locationProvider = locationProvider,
navigationControllerConfig = NavigationControllerConfig(
waypointAdvance = WaypointAdvanceMode.WaypointWithinRange(WAYPOINT_ADVANCE_RANGE),
stepAdvanceCondition = stepAdvanceDistanceEntryAndExit(STEP_ADVANCE_DISTANCE_TO_END.toUShort(), STEP_ADVANCE_DISTANCE_AFTER_END.toUShort(), STEP_ADVANCE_MINIMUM_HORIZONTAL_ACCURACY.toUShort()),
arrivalStepAdvanceCondition = stepAdvanceDistanceToEndOfStep(ARRIVAL_STEP_ADVANCE_DISTANCE.toUShort(), ARRIVAL_STEP_ADVANCE_MINIMUM_HORIZONTAL_ACCURACY.toUShort()),
- routeDeviationTracking = RouteDeviationTracking.StaticThreshold(ROUTE_DEVIATION_MINIMUM_ACCURACY.toUShort(), ROUTE_DEVIATION_MAX_DEVIATION),
+ routeDeviationTracking = RouteDeviationTracking.Custom(
+ detector = getDeviationDetector()
+ ),
snappedLocationCourseFiltering = COURSE_FILTERING
),
foregroundServiceManager = foregroundServiceManager,
@@ -150,13 +155,15 @@ class FerrostarWrapper(
profile = mode.value,
optionsJson = previousRouteOptions?.toValhallaOptionsJson()
)),
- httpClient = OkHttpClientProvider(OkHttpClient()),
+ httpClient = OkHttpClientProvider(okHttpClient),
locationProvider = locationProvider,
navigationControllerConfig = NavigationControllerConfig(
waypointAdvance = WaypointAdvanceMode.WaypointWithinRange(WAYPOINT_ADVANCE_RANGE),
stepAdvanceCondition = stepAdvanceDistanceEntryAndExit(STEP_ADVANCE_DISTANCE_TO_END.toUShort(), STEP_ADVANCE_DISTANCE_AFTER_END.toUShort(), STEP_ADVANCE_MINIMUM_HORIZONTAL_ACCURACY.toUShort()),
arrivalStepAdvanceCondition = stepAdvanceDistanceToEndOfStep(ARRIVAL_STEP_ADVANCE_DISTANCE.toUShort(), ARRIVAL_STEP_ADVANCE_MINIMUM_HORIZONTAL_ACCURACY.toUShort()),
- routeDeviationTracking = RouteDeviationTracking.StaticThreshold(ROUTE_DEVIATION_MINIMUM_ACCURACY.toUShort(), ROUTE_DEVIATION_MAX_DEVIATION),
+ routeDeviationTracking = RouteDeviationTracking.Custom(
+ detector = getDeviationDetector()
+ ),
snappedLocationCourseFiltering = COURSE_FILTERING
),
foregroundServiceManager = foregroundServiceManager
@@ -164,6 +171,12 @@ class FerrostarWrapper(
core.spokenInstructionObserver = androidTtsObserver
}
+ private fun getDeviationDetector(): FerrostarRouteDeviationDetector {
+ val distanceCalculator = PolylineDistanceCalculator()
+ val deviationDetector = CustomDeviationDetector(distanceCalculator)
+ return FerrostarRouteDeviationDetector(deviationDetector)
+ }
+
companion object {
const val WAYPOINT_ADVANCE_RANGE = 100.0
const val STEP_ADVANCE_DISTANCE_TO_END = 30u
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapperRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapperRepository.kt
index d1c4c49e0f1494520920f03c00d981afff3be193..a553b77b0d1d09eeb4a346176df9f760d472c36f 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapperRepository.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapperRepository.kt
@@ -18,12 +18,9 @@
package earth.maps.cardinal.routing
-import android.content.Context
import com.stadiamaps.ferrostar.core.SpokenInstructionObserver
-import dagger.hilt.android.qualifiers.ApplicationContext
-import earth.maps.cardinal.data.LocationRepository
-import earth.maps.cardinal.data.OrientationRepository
import earth.maps.cardinal.data.RoutingMode
+import earth.maps.cardinal.data.navigation.FerrostarWrapperFactory
import earth.maps.cardinal.data.room.RoutingProfileRepository
import earth.maps.cardinal.data.tts.MapsTtsObserver
import kotlinx.coroutines.flow.MutableStateFlow
@@ -35,12 +32,9 @@ import javax.inject.Singleton
@Singleton
class FerrostarWrapperRepository @Inject constructor(
- @param:ApplicationContext
- private val context: Context,
- private val locationRepository: LocationRepository,
- private val orientationRepository: OrientationRepository,
private val routingProfileRepository: RoutingProfileRepository,
- private val androidTtsObserver: SpokenInstructionObserver
+ private val androidTtsObserver: SpokenInstructionObserver,
+ private val factory: FerrostarWrapperFactory
) {
private val _isInitialized = MutableStateFlow(false)
val isInitialized = _isInitialized.asStateFlow()
@@ -80,59 +74,29 @@ class FerrostarWrapperRepository @Inject constructor(
}
fun setValhallaEndpoint(endpoint: String) {
- _walking = FerrostarWrapper(
- context,
- locationRepository,
- orientationRepository,
- RoutingMode.PEDESTRIAN,
- endpoint,
- androidTtsObserver,
- routingProfileRepository
+ _walking = factory.create(
+ mode = RoutingMode.PEDESTRIAN,
+ endpoint = endpoint
)
- _cycling = FerrostarWrapper(
- context,
- locationRepository,
- orientationRepository,
- RoutingMode.BICYCLE,
- endpoint,
- androidTtsObserver,
- routingProfileRepository
+ _cycling = factory.create(
+ mode = RoutingMode.BICYCLE,
+ endpoint = endpoint
)
- _driving = FerrostarWrapper(
- context,
- locationRepository,
- orientationRepository,
- RoutingMode.AUTO,
- endpoint,
- androidTtsObserver,
- routingProfileRepository
+ _driving = factory.create(
+ mode = RoutingMode.AUTO,
+ endpoint = endpoint
)
- _truck = FerrostarWrapper(
- context,
- locationRepository,
- orientationRepository,
- RoutingMode.TRUCK,
- endpoint,
- androidTtsObserver,
- routingProfileRepository
+ _truck = factory.create(
+ mode = RoutingMode.TRUCK,
+ endpoint = endpoint
)
- _motorScooter = FerrostarWrapper(
- context,
- locationRepository,
- orientationRepository,
- RoutingMode.MOTOR_SCOOTER,
- endpoint,
- androidTtsObserver,
- routingProfileRepository
+ _motorScooter = factory.create(
+ mode = RoutingMode.MOTOR_SCOOTER,
+ endpoint = endpoint
)
- _motorcycle = FerrostarWrapper(
- context,
- locationRepository,
- orientationRepository,
- RoutingMode.MOTORCYCLE,
- endpoint,
- androidTtsObserver,
- routingProfileRepository
+ _motorcycle = factory.create(
+ mode = RoutingMode.MOTORCYCLE,
+ endpoint = endpoint
)
_isInitialized.value = true
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/RoutingOptions.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/RoutingOptions.kt
index 0ac67a692299098d8d283aa1d42a23b4b58473cb..034c203eb08eb080f467cd586763237a73b92c9c 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/RoutingOptions.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/RoutingOptions.kt
@@ -70,9 +70,9 @@ data class AutoRoutingOptions(
override val costingType: String = COSTING_TYPE_AUTO,
// Maneuver and access penalties
- override val maneuverPenalty: Double? = DEFAULT_MANEUVER_PENALTY,
- override val gateCost: Double? = DEFAULT_GATE_COST,
- override val tollBoothCost: Double? = DEFAULT_TOLL_BOOTH_COST,
+ override val maneuverPenalty: Double? = null,
+ override val gateCost: Double? = null,
+ override val tollBoothCost: Double? = null,
override val privateAccessPenalty: Double? = null,
// Road type preferences (0-1 range)
diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/ValhallaRoutingService.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/ValhallaRoutingService.kt
index 55cd65bbc7c5afc805445c8d1b2596ebe3b8528a..f464f781dd694665f086f9c12bb8ee072cad7460 100644
--- a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/ValhallaRoutingService.kt
+++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/ValhallaRoutingService.kt
@@ -19,11 +19,15 @@
package earth.maps.cardinal.routing
import android.util.Log
+import earth.maps.cardinal.BuildConfig
import earth.maps.cardinal.data.AppPreferenceRepository
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
+import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.logging.LogLevel
+import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.post
import io.ktor.client.request.setBody
@@ -44,7 +48,21 @@ class ValhallaRoutingService(private val appPreferenceRepository: AppPreferenceR
isLenient = true
})
}
- install(Logging)
+ install(HttpTimeout) {
+ requestTimeoutMillis = 30_000
+ connectTimeoutMillis = 10_000
+ socketTimeoutMillis = 30_000
+ }
+ if (BuildConfig.DEBUG) {
+ install(Logging) {
+ logger = object : Logger {
+ override fun log(message: String) {
+ Log.d("KtorClient", message)
+ }
+ }
+ level = LogLevel.ALL
+ }
+ }
}
override suspend fun getRoute(
diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/data/navigation/CustomDeviationDetectorTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/navigation/CustomDeviationDetectorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..af72c88a27e34dfbb1fd3dbf7c89c5a9442cdac9
--- /dev/null
+++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/navigation/CustomDeviationDetectorTest.kt
@@ -0,0 +1,174 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package earth.maps.cardinal.data.navigation
+
+import earth.maps.cardinal.data.LatLng
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class CustomDeviationDetectorTest {
+
+ private val distanceCalculator = mockk(relaxed = true)
+
+ private lateinit var detector: CustomDeviationDetector
+
+ @Before
+ fun setup() {
+ detector = CustomDeviationDetector(distanceCalculator)
+ }
+
+ private fun latLng(lat: Double, lon: Double) = LatLng(lat, lon)
+
+ private val route = listOf(
+ latLng(0.0, 0.0),
+ latLng(0.0, 1.0)
+ )
+
+ @Test
+ fun `should return false when route has less than 2 points`() {
+ val result = detector.isOffRoute(
+ location = latLng(0.0, 0.0),
+ route = listOf(latLng(0.0, 0.0))
+ )
+
+ assertFalse(result)
+ verify(exactly = 0) { distanceCalculator.distanceFromPolyline(any(), any()) }
+ }
+
+ @Test
+ fun `should return false when distance is within threshold`() {
+ every {
+ distanceCalculator.distanceFromPolyline(any(), any())
+ } returns 10.0 // below 15
+
+ val result = detector.isOffRoute(latLng(0.0, 0.5), route)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `should return true when distance exceeds threshold`() {
+ every {
+ distanceCalculator.distanceFromPolyline(any(), any())
+ } returns 20.0
+
+ val result = detector.isOffRoute(latLng(1.0, 1.0), route)
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `should debounce repeated off route triggers`() {
+ every {
+ distanceCalculator.distanceFromPolyline(any(), any())
+ } returns 20.0
+
+ val location = latLng(1.0, 1.0)
+
+ val first = detector.isOffRoute(location, route)
+ val second = detector.isOffRoute(location, route)
+
+ assertTrue(first)
+ assertFalse(second) // debounced
+ }
+
+ @Test
+ fun `should allow off route after debounce period`() {
+ every {
+ distanceCalculator.distanceFromPolyline(any(), any())
+ } returns 20.0
+
+ val location = latLng(1.0, 1.0)
+
+ val first = detector.isOffRoute(location, route)
+
+ Thread.sleep(1600) // > 1500ms debounce
+
+ val second = detector.isOffRoute(location, route)
+
+ assertTrue(first)
+ assertTrue(second)
+ }
+
+ @Test
+ fun `should reset cache when route changes`() {
+ every {
+ distanceCalculator.distanceFromPolyline(any(), any())
+ } returns 10.0
+
+ val route1 = listOf(latLng(0.0, 0.0), latLng(0.0, 1.0))
+ val route2 = listOf(latLng(1.0, 1.0), latLng(2.0, 2.0))
+
+ detector.isOffRoute(latLng(0.0, 0.5), route1)
+ detector.isOffRoute(latLng(0.0, 0.5), route2)
+
+ verify(atLeast = 1) {
+ distanceCalculator.resetCache()
+ }
+ }
+
+ @Test
+ fun `should not reset cache for same route`() {
+ every {
+ distanceCalculator.distanceFromPolyline(any(), any())
+ } returns 10.0
+
+ detector.isOffRoute(latLng(0.0, 0.5), route)
+ detector.isOffRoute(latLng(0.0, 0.6), route)
+
+ verify(exactly = 1) {
+ distanceCalculator.resetCache()
+ }
+ }
+
+ @Test
+ fun `should return false when distance equals threshold`() {
+ every {
+ distanceCalculator.distanceFromPolyline(any(), any())
+ } returns 15.0
+
+ val result = detector.isOffRoute(latLng(0.0, 0.5), route)
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `should handle fluctuating distances correctly`() {
+ every {
+ distanceCalculator.distanceFromPolyline(any(), any())
+ } returnsMany listOf(10.0, 20.0, 10.0, 20.0)
+
+ val location = latLng(0.0, 0.5)
+
+ val r1 = detector.isOffRoute(location, route) // false
+ val r2 = detector.isOffRoute(location, route) // true
+ val r3 = detector.isOffRoute(location, route) // false
+ val r4 = detector.isOffRoute(location, route) // debounced
+
+ assertFalse(r1)
+ assertTrue(r2)
+ assertFalse(r3)
+ assertFalse(r4)
+ }
+
+}
diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/data/navigation/FerrostarRouteDeviationDetectorTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/navigation/FerrostarRouteDeviationDetectorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..94635fc63dcb1103d28f6defa0c0ec4d4c32510c
--- /dev/null
+++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/navigation/FerrostarRouteDeviationDetectorTest.kt
@@ -0,0 +1,227 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.data.navigation
+
+import earth.maps.cardinal.data.LatLng
+import earth.maps.cardinal.domain.DeviationDetector
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import uniffi.ferrostar.Route
+import uniffi.ferrostar.RouteDeviation
+import uniffi.ferrostar.UserLocation
+
+class FerrostarRouteDeviationDetectorTest {
+
+ private val deviationDetector = mockk()
+
+ private lateinit var detector: FerrostarRouteDeviationDetector
+
+ @Before
+ fun setup() {
+ detector = FerrostarRouteDeviationDetector(deviationDetector)
+ }
+
+ private fun latLng(lat: Double, lng: Double) = LatLng(lat, lng)
+
+ @Test
+ fun `should return NoDeviation when deviationDetector returns false`() {
+ every {
+ deviationDetector.isOffRoute(any(), any())
+ } returns false
+
+ val location = mockk {
+ every { coordinates.lat } returns 12.0
+ every { coordinates.lng } returns 77.0
+ }
+
+ val route = mockk {
+ every { geometry } returns listOf(
+ mockk {
+ every { lat } returns 12.0
+ every { lng } returns 77.0
+ }
+ )
+ }
+
+ val result = detector.checkRouteDeviation(location, route, mockk())
+
+ assertTrue(result is RouteDeviation.NoDeviation)
+ }
+
+ @Test
+ fun `should return OffRoute when deviationDetector returns true`() {
+ every {
+ deviationDetector.isOffRoute(any(), any())
+ } returns true
+
+ val location = mockk {
+ every { coordinates.lat } returns 12.0
+ every { coordinates.lng } returns 77.0
+ }
+
+ val route = mockk {
+ every { geometry } returns listOf(
+ mockk {
+ every { lat } returns 12.0
+ every { lng } returns 77.0
+ }
+ )
+ }
+
+ val result = detector.checkRouteDeviation(location, route, mockk())
+
+ assertTrue(result is RouteDeviation.OffRoute)
+
+ val offRoute = result as RouteDeviation.OffRoute
+ assertEquals(30.0, offRoute.deviationFromRouteLine, 0.0)
+ }
+
+ @Test
+ fun `should map UserLocation to LatLng correctly`() {
+ val slotLocation = slot()
+
+ every {
+ deviationDetector.isOffRoute(capture(slotLocation), any())
+ } returns false
+
+ val location = mockk {
+ every { coordinates.lat } returns 10.5
+ every { coordinates.lng } returns 20.5
+ }
+
+ val route = mockk {
+ every { geometry } returns emptyList()
+ }
+
+ detector.checkRouteDeviation(location, route, mockk())
+
+ assertEquals(10.5, slotLocation.captured.latitude, 0.0)
+ assertEquals(20.5, slotLocation.captured.longitude, 0.0)
+ }
+
+ @Test
+ fun `should map route geometry to LatLng list`() {
+ val slotRoute = slot>()
+
+ every {
+ deviationDetector.isOffRoute(any(), capture(slotRoute))
+ } returns false
+
+ val route = mockk {
+ every { geometry } returns listOf(
+ mockk {
+ every { lat } returns 1.0
+ every { lng } returns 2.0
+ },
+ mockk {
+ every { lat } returns 3.0
+ every { lng } returns 4.0
+ }
+ )
+ }
+
+ val location = mockk {
+ every { coordinates.lat } returns 0.0
+ every { coordinates.lng } returns 0.0
+ }
+
+ detector.checkRouteDeviation(location, route, mockk())
+
+ val captured = slotRoute.captured
+
+ assertEquals(2, captured.size)
+ assertEquals(1.0, captured[0].latitude, 0.0)
+ assertEquals(2.0, captured[0].longitude, 0.0)
+ assertEquals(3.0, captured[1].latitude, 0.0)
+ assertEquals(4.0, captured[1].longitude, 0.0)
+ }
+
+ @Test
+ fun `should call deviationDetector once`() {
+ every {
+ deviationDetector.isOffRoute(any(), any())
+ } returns false
+
+ val location = mockk {
+ every { coordinates.lat } returns 0.0
+ every { coordinates.lng } returns 0.0
+ }
+
+ val route = mockk {
+ every { geometry } returns emptyList()
+ }
+
+ detector.checkRouteDeviation(location, route, mockk())
+
+ verify(exactly = 1) {
+ deviationDetector.isOffRoute(any(), any())
+ }
+ }
+
+ @Test
+ fun `should handle empty geometry`() {
+ every {
+ deviationDetector.isOffRoute(any(), any())
+ } returns false
+
+ val location = mockk {
+ every { coordinates.lat } returns 0.0
+ every { coordinates.lng } returns 0.0
+ }
+
+ val route = mockk {
+ every { geometry } returns emptyList()
+ }
+
+ val result = detector.checkRouteDeviation(location, route, mockk())
+
+ assertTrue(result is RouteDeviation.NoDeviation)
+ }
+
+ @Test
+ fun `should handle multiple geometry points when off route`() {
+ every {
+ deviationDetector.isOffRoute(any(), any())
+ } returns true
+
+ val location = mockk {
+ every { coordinates.lat } returns 5.0
+ every { coordinates.lng } returns 5.0
+ }
+
+ val route = mockk {
+ every { geometry } returns listOf(
+ mockk { every { lat } returns 1.0; every { lng } returns 1.0 },
+ mockk { every { lat } returns 2.0; every { lng } returns 2.0 },
+ mockk { every { lat } returns 3.0; every { lng } returns 3.0 }
+ )
+ }
+
+ val result = detector.checkRouteDeviation(location, route, mockk())
+
+ assertTrue(result is RouteDeviation.OffRoute)
+ }
+
+}
diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/data/navigation/PolylineDistanceCalculatorTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/navigation/PolylineDistanceCalculatorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ccbd0885dacf00f7597cf9c2b54e5fa2ad984e2e
--- /dev/null
+++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/navigation/PolylineDistanceCalculatorTest.kt
@@ -0,0 +1,169 @@
+/*
+ * Cardinal Maps
+ * Copyright (C) 2026 Cardinal Maps Authors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package earth.maps.cardinal.data.navigation
+
+import earth.maps.cardinal.data.LatLng
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class PolylineDistanceCalculatorTest {
+
+ private lateinit var calculator: PolylineDistanceCalculator
+
+ @Before
+ fun setup() {
+ calculator = PolylineDistanceCalculator()
+ }
+
+ // Helper
+ private fun latLng(lat: Double, lon: Double) = LatLng(lat, lon)
+
+ @Test
+ fun `should return MAX_VALUE when polyline has less than 2 points`() {
+ val point = latLng(0.0, 0.0)
+
+ val result = calculator.distanceFromPolyline(point, emptyList())
+
+ assertEquals(Double.MAX_VALUE, result, 0.0)
+ }
+
+ @Test
+ fun `should return near zero when point lies on segment`() {
+ val polyline = listOf(
+ latLng(0.0, 0.0),
+ latLng(0.0, 1.0)
+ )
+
+ val point = latLng(0.0, 0.5)
+
+ val distance = calculator.distanceFromPolyline(point, polyline)
+
+ assertTrue(distance < 1.0) // < 1 meter
+ }
+
+ @Test
+ fun `should calculate small distance when point is near segment`() {
+ val polyline = listOf(
+ latLng(0.0, 0.0),
+ latLng(0.0, 1.0)
+ )
+
+ val point = latLng(0.0001, 0.5) // ~11m away
+
+ val distance = calculator.distanceFromPolyline(point, polyline)
+
+ assertTrue(distance in 5.0..20.0)
+ }
+
+ @Test
+ fun `should return large distance when point is far`() {
+ val polyline = listOf(
+ latLng(0.0, 0.0),
+ latLng(0.0, 1.0)
+ )
+
+ val point = latLng(1.0, 1.0) // ~100km away
+
+ val distance = calculator.distanceFromPolyline(point, polyline)
+
+ assertTrue(distance > 100_000)
+ }
+
+ @Test
+ fun `should early exit when very close to segment`() {
+ val polyline = listOf(
+ latLng(0.0, 0.0),
+ latLng(0.0, 1.0),
+ latLng(1.0, 1.0) // extra segment
+ )
+
+ val point = latLng(0.0, 0.1)
+
+ val distance = calculator.distanceFromPolyline(point, polyline)
+
+ assertTrue(distance < 5.0)
+ }
+
+ @Test
+ fun `should reuse last segment index for nearby points`() {
+ val polyline = listOf(
+ latLng(0.0, 0.0),
+ latLng(0.0, 1.0),
+ latLng(0.0, 2.0)
+ )
+
+ val point1 = latLng(0.0, 0.4)
+ val point2 = latLng(0.0, 0.5)
+
+ val d1 = calculator.distanceFromPolyline(point1, polyline)
+ val d2 = calculator.distanceFromPolyline(point2, polyline)
+
+ assertTrue(d1 < 5.0)
+ assertTrue(d2 < 5.0)
+ }
+
+ @Test
+ fun `resetCache should reset segment index`() {
+ val polyline = listOf(
+ latLng(0.0, 0.0),
+ latLng(0.0, 1.0)
+ )
+
+ val point = latLng(0.0, 0.5)
+
+ calculator.distanceFromPolyline(point, polyline)
+
+ calculator.resetCache()
+
+ val distance = calculator.distanceFromPolyline(point, polyline)
+
+ assertTrue(distance < 5.0)
+ }
+
+ @Test
+ fun `should handle degenerate segment safely`() {
+ val polyline = listOf(
+ latLng(0.0, 0.0),
+ latLng(0.0, 0.0) // same point
+ )
+
+ val point = latLng(0.1, 0.1)
+
+ val distance = calculator.distanceFromPolyline(point, polyline)
+
+ assertTrue(distance > 0)
+ }
+
+ @Test
+ fun `should pick closest segment among multiple`() {
+ val polyline = listOf(
+ latLng(0.0, 0.0),
+ latLng(0.0, 1.0),
+ latLng(1.0, 1.0)
+ )
+
+ val point = latLng(0.0001, 0.8)
+
+ val distance = calculator.distanceFromPolyline(point, polyline)
+
+ assertTrue(distance < 20.0)
+ }
+}
diff --git a/cardinal-android/gradle/libs.versions.toml b/cardinal-android/gradle/libs.versions.toml
index af49fb8b7775b00b81f0b2f63dc2be3011e037bd..61105af825a9a13e5266013d14d1f4191da66729 100644
--- a/cardinal-android/gradle/libs.versions.toml
+++ b/cardinal-android/gradle/libs.versions.toml
@@ -15,6 +15,7 @@ ktor = "3.3.3"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.1"
composeBom = "2025.12.00"
+loggingInterceptor = "5.3.2"
maplibreCompose = "0.12.1"
robolectric = "4.16"
room = "2.8.4"
@@ -65,6 +66,7 @@ ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "k
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
+logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "loggingInterceptor" }
maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibreCompose" }
maplibre-compose-material3 = { module = "org.maplibre.compose:maplibre-compose-material3", version.ref = "maplibreCompose" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }