diff --git a/Package.resolved b/Package.resolved
index b838dac1c..a8103b1bc 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -9,15 +9,6 @@
"version" : "6.22.1"
}
},
- {
- "identity" : "mockable",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/Kolos65/Mockable.git",
- "state" : {
- "revision" : "55f846e4ea37ca37166a6d533b5144b956385b41",
- "version" : "0.5.1"
- }
- },
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
@@ -50,8 +41,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/swiftui-dsl",
"state" : {
- "revision" : "e2da0e969a345c00e7524adf391d9be232be9c26",
- "version" : "0.21.1"
+ "revision" : "cecdd8143ad58bfe36072680cd47d750d2a7479e",
+ "version" : "0.23.0"
}
},
{
diff --git a/android/car-app/.gitignore b/android/car-app/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/android/car-app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/car-app/build.gradle b/android/car-app/build.gradle
new file mode 100644
index 000000000..57455d8e0
--- /dev/null
+++ b/android/car-app/build.gradle
@@ -0,0 +1,73 @@
+import com.vanniktech.maven.publish.AndroidSingleVariantLibrary
+
+plugins {
+ alias libs.plugins.androidLibrary
+ alias libs.plugins.ktfmt
+ alias libs.plugins.mavenPublish
+}
+
+android {
+ namespace 'com.stadiamaps.ferrostar.car.app'
+ compileSdk {
+ version = release(36)
+ }
+
+ defaultConfig {
+ minSdk 26
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ coreLibraryDesugaringEnabled = true
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+dependencies {
+ // For as long as we support API 25; once we can raise support to 26, all is fine
+ coreLibraryDesugaring libs.desugar.jdk.libs
+
+ implementation libs.androidx.ktx
+ implementation libs.androidx.appcompat
+ implementation libs.material
+
+ implementation libs.androidx.car.app
+ implementation libs.kotlinx.datetime
+ implementation libs.kotlinx.coroutines
+
+ implementation project(':core')
+ implementation project(':ui-formatters')
+ implementation project(':ui-shared')
+
+ testImplementation libs.junit
+ androidTestImplementation libs.androidx.test.junit
+ androidTestImplementation libs.androidx.test.espresso
+}
+
+mavenPublishing {
+ publishToMavenCentral()
+
+ if (!project.hasProperty(SKIP_SIGNING_PROPERTY)) {
+ signAllPublications()
+ }
+
+ configure(new AndroidSingleVariantLibrary("release", true, true))
+
+ apply from: "${rootProject.projectDir}/common-pom.gradle"
+
+ pom {
+ name = "Ferrostar CarApp"
+ description = "Composable map UI car app components for Ferrostar built with MapLibre"
+
+ commonPomConfig(it, true)
+ }
+}
\ No newline at end of file
diff --git a/android/car-app/consumer-rules.pro b/android/car-app/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/android/car-app/proguard-rules.pro b/android/car-app/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/android/car-app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ExampleInstrumentedTest.kt b/android/car-app/src/androidTest/java/com/stadiamaps/ferrostar/car/app/ExampleInstrumentedTest.kt
similarity index 81%
rename from android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ExampleInstrumentedTest.kt
rename to android/car-app/src/androidTest/java/com/stadiamaps/ferrostar/car/app/ExampleInstrumentedTest.kt
index 16249b200..7ae73ffb6 100644
--- a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ExampleInstrumentedTest.kt
+++ b/android/car-app/src/androidTest/java/com/stadiamaps/ferrostar/car/app/ExampleInstrumentedTest.kt
@@ -1,4 +1,4 @@
-package com.stadiamaps.ferrostar.core
+package com.stadiamaps.ferrostar.car.app
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
@@ -17,6 +17,6 @@ class ExampleInstrumentedTest {
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.stadiamaps.ferrostar.core.test", appContext.packageName)
+ assertEquals("com.stadiamaps.ferrostar.car.app.test", appContext.packageName)
}
}
diff --git a/android/car-app/src/main/AndroidManifest.xml b/android/car-app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a5918e68a
--- /dev/null
+++ b/android/car-app/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationDestination.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationDestination.kt
new file mode 100644
index 000000000..338689dff
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationDestination.kt
@@ -0,0 +1,43 @@
+package com.stadiamaps.ferrostar.car.app.intent
+
+import android.location.Location
+import uniffi.ferrostar.GeographicCoordinate
+
+/**
+ * A parsed navigation destination from an external intent.
+ *
+ * At least one of [coordinate] or [query] will be non-null when returned from
+ * [NavigationIntentParser].
+ *
+ * @param latitude Destination latitude, or null if only a query string is available.
+ * @param longitude Destination longitude, or null if only a query string is available.
+ * @param query Human-readable search query or place name, or null if only coordinates are
+ * available.
+ */
+data class NavigationDestination(
+ val latitude: Double?,
+ val longitude: Double?,
+ val query: String?
+) {
+ /** The destination as a [Location], or null if only a query is available. */
+ val location: Location?
+ get() =
+ if (latitude != null && longitude != null) {
+ Location("").also {
+ it.latitude = latitude
+ it.longitude = longitude
+ }
+ } else {
+ null
+ }
+
+ /** A human-readable display name for this destination. */
+ val displayName: String
+ get() =
+ query
+ ?: if (latitude != null && longitude != null) {
+ "%.4f, %.4f".format(latitude, longitude)
+ } else {
+ "Unknown location"
+ }
+}
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationIntentHandler.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationIntentHandler.kt
new file mode 100644
index 000000000..0038b7d9f
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationIntentHandler.kt
@@ -0,0 +1,24 @@
+package com.stadiamaps.ferrostar.car.app.intent
+
+import androidx.car.app.Screen
+
+/**
+ * Handles a parsed [NavigationDestination] by deciding which [Screen] to present.
+ *
+ * Implement this to control what happens when a navigation intent arrives from an external app or
+ * voice assistant. Common patterns:
+ * - Navigate immediately (return a navigation screen)
+ * - Show a confirmation or disclaimer screen first
+ * - Show search results or a waypoint picker
+ * - Show an alert/warning for unsupported destination types
+ *
+ * Example:
+ * ```
+ * val handler = NavigationIntentHandler { destination ->
+ * MyNavigationScreen(carContext, destination)
+ * }
+ * ```
+ */
+fun interface NavigationIntentHandler {
+ fun handleNavigationIntent(destination: NavigationDestination): Screen
+}
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationIntentParser.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationIntentParser.kt
new file mode 100644
index 000000000..7590d17d5
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationIntentParser.kt
@@ -0,0 +1,85 @@
+package com.stadiamaps.ferrostar.car.app.intent
+
+import android.content.Intent
+import android.net.Uri
+import uniffi.ferrostar.GeographicCoordinate
+
+/**
+ * Parses navigation intents into [NavigationDestination] values.
+ *
+ * Supports two common URI schemes out of the box:
+ * - `geo:lat,lng` and `geo:0,0?q=query` — standard Android geo URIs
+ * - `google.navigation:q=lat,lng` and `google.navigation:q=place+name` — Google Maps URIs
+ *
+ * This class is `open` so apps can subclass to support additional URI schemes:
+ * ```
+ * class MyParser : NavigationIntentParser() {
+ * override fun parseUri(uri: Uri) = parseMyScheme(uri) ?: super.parseUri(uri)
+ * }
+ * ```
+ */
+open class NavigationIntentParser {
+
+ /** Parses a navigation [Intent] into a [NavigationDestination], or null if unrecognized. */
+ fun parse(intent: Intent): NavigationDestination? {
+ val uri = intent.data ?: return null
+ return parseUri(uri)
+ }
+
+ /** Parses a navigation [Uri] into a [NavigationDestination], or null if unrecognized. */
+ open fun parseUri(uri: Uri): NavigationDestination? =
+ when (uri.scheme) {
+ "geo" ->
+ parseGeoSsp(
+ coordString = uri.schemeSpecificPart?.substringBefore('?').orEmpty(),
+ query = uri.getQueryParameter("q"))
+ "google.navigation" ->
+ uri.getQueryParameter("q")?.let { parseGoogleNavigationSsp(it) }
+ else -> null
+ }
+
+ companion object {
+ /**
+ * Parses the coordinate and optional query parts of a `geo:` URI.
+ *
+ * @param coordString The coordinate portion (before `?`), e.g. `"37.8,-122.4"` or `"0,0"`.
+ * Altitude is ignored if present (e.g. `"37.8,-122.4,100"`).
+ * @param query The already-decoded value of the `q` parameter, if present.
+ */
+ fun parseGeoSsp(coordString: String, query: String?): NavigationDestination? {
+ val coords = parseCoordinates(coordString)
+ // geo:0,0 is conventionally used as "no coordinates, use query instead"
+ val hasCoordinates = coords != null && !(coords.lat == 0.0 && coords.lng == 0.0)
+
+ return when {
+ hasCoordinates -> NavigationDestination(coords!!.lat, coords.lng, query)
+ query != null -> NavigationDestination(null, null, query)
+ else -> null
+ }
+ }
+
+ /**
+ * Parses the already-decoded `q` value from a `google.navigation:` URI.
+ *
+ * @param q The decoded value of the `q` parameter, e.g. `"37.8,-122.4"` or `"Starbucks"`.
+ */
+ fun parseGoogleNavigationSsp(q: String): NavigationDestination? {
+ val coords = parseCoordinates(q)
+ return if (coords != null) {
+ NavigationDestination(coords.lat, coords.lng, null)
+ } else {
+ NavigationDestination(null, null, q)
+ }
+ }
+
+ internal fun parseCoordinates(str: String): GeographicCoordinate? {
+ // limit=3 so altitude (geo:lat,lng,alt per RFC 5870) is captured and ignored
+ val parts = str.split(",", limit = 3)
+ if (parts.size < 2) return null
+ val lat = parts[0].trim().toDoubleOrNull() ?: return null
+ val lng = parts[1].trim().toDoubleOrNull() ?: return null
+ if (lat < -90.0 || lat > 90.0 || lng < -180.0 || lng > 180.0) return null
+ return GeographicCoordinate(lat, lng)
+ }
+ }
+}
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/NavigationManagerBridge.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/NavigationManagerBridge.kt
new file mode 100644
index 000000000..1a0d464b3
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/NavigationManagerBridge.kt
@@ -0,0 +1,147 @@
+package com.stadiamaps.ferrostar.car.app.navigation
+
+import android.content.Context
+import android.util.Log
+import androidx.car.app.navigation.NavigationManager
+import androidx.car.app.navigation.NavigationManagerCallback
+import androidx.car.app.navigation.model.Destination
+import androidx.lifecycle.Lifecycle
+import com.stadiamaps.ferrostar.car.app.template.models.FerrostarTrip
+import com.stadiamaps.ferrostar.core.NavigationViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.onEach
+import uniffi.ferrostar.DrivingSide
+
+/**
+ * Manages navigation throughout the trip.
+ *
+ * This manages lifecycle hooks, background managers and attaches it to the UI.
+ *
+ * @param navigationManager The Car App Library NavigationManager from CarContext.
+ * @param context The context used to resolve maneuver icon drawables.
+ * @param notificationManager Optional notification manager for HUN updates (NF-3).
+ * @param viewModel The Ferrostar NavigationViewModel to observe.
+ * @param backupDrivingSide Driving side for maneuver mapping. Defaults to RIGHT. This is only necessary if your routing server does not provide driving side.
+ * @param onStopNavigation Called when the head unit requests navigation stop.
+ * @param onAutoDriveEnabled Enables simulation when called (NF-7). Optional.
+ * @param isCarForeground Returns true when the car app screen is visible to the user. When true,
+ * turn-by-turn notifications are suppressed since the screen itself shows the guidance. Pass
+ * `{ lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) }` from your [Screen].
+ */
+class NavigationManagerBridge(
+ private val navigationManager: NavigationManager,
+ private val context: Context,
+ private val notificationManager: TurnByTurnNotificationManager? = null,
+ private val viewModel: NavigationViewModel,
+ private val backupDrivingSide: DrivingSide = DrivingSide.RIGHT,
+ private val onStopNavigation: () -> Unit,
+ private val onAutoDriveEnabled: (() -> Unit)? = null,
+ private val isCarForeground: () -> Boolean = { false }
+) {
+
+ private var tripJob: Job? = null
+ private var notificationJob: Job? = null
+ private var wasNavigating = false
+
+ /**
+ * Starts observing the view model's navigation state and driving the [NavigationManager].
+ *
+ * Call this when the navigation session begins. The [scope] should be tied to the Car App Session
+ * or Screen lifecycle.
+ */
+ fun start(scope: CoroutineScope) {
+ navigationManager.setNavigationManagerCallback(
+ object : NavigationManagerCallback {
+ override fun onStopNavigation() {
+ this@NavigationManagerBridge.onStopNavigation()
+ }
+
+ override fun onAutoDriveEnabled() {
+ this@NavigationManagerBridge.onAutoDriveEnabled?.invoke()
+ }
+ })
+
+ // Trip lifecycle and updateTrip on every state change.
+ tripJob =
+ viewModel.navigationUiState
+ .onEach { state ->
+ val isNavigating = state.isNavigating()
+
+ if (isNavigating && !wasNavigating) {
+ navigationManager.navigationStarted()
+ wasNavigating = true
+ }
+
+ if (isNavigating) {
+ state.tripState?.let {
+ val trip =
+ FerrostarTrip.Builder(context)
+ .setTripState(it)
+ .setBackupDrivingSide(backupDrivingSide)
+ .apply { state.destination?.let { dest -> setDestination(dest) } }
+ .build()
+ try {
+ navigationManager.updateTrip(trip)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to update trip", e)
+ }
+ }
+ }
+
+ if (!isNavigating && wasNavigating) {
+ notificationManager?.clear()
+ navigationManager.navigationEnded()
+ wasNavigating = false
+ }
+ }
+ .launchIn(scope)
+
+ // Notification flow: emits once per instruction trigger zone entry.
+ notificationJob =
+ viewModel.navigationUiState
+ .mapNotNull { state ->
+ state.spokenInstruction
+ }
+ .distinctUntilChanged { old, new ->
+ // Only emit if the instruction text has changed.
+ // This is the core scheduling logic that emits a new instruction
+ // only when the text has actually changed (trigger distance has arrived)
+ old.text == new.text
+ }
+ .onEach { instruction ->
+ if (!isCarForeground()) {
+ notificationManager?.update(instruction)
+ }
+ }
+ .launchIn(scope)
+ }
+
+ /**
+ * Stops observing navigation state and cleans up the [NavigationManager].
+ *
+ * Call this when the navigation session ends or the Car App Session is destroyed.
+ */
+ fun stop() {
+ tripJob?.cancel()
+ tripJob = null
+ notificationJob?.cancel()
+ notificationJob = null
+
+ notificationManager?.clear()
+
+ if (wasNavigating) {
+ navigationManager.navigationEnded()
+ wasNavigating = false
+ }
+
+ navigationManager.clearNavigationManagerCallback()
+ }
+
+ companion object {
+ private const val TAG = "NavManagerBridge"
+ }
+}
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/TurnByTurnNotificationManager.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/TurnByTurnNotificationManager.kt
new file mode 100644
index 000000000..52bb068e0
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/TurnByTurnNotificationManager.kt
@@ -0,0 +1,87 @@
+package com.stadiamaps.ferrostar.car.app.navigation
+
+import android.annotation.SuppressLint
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import androidx.annotation.DrawableRes
+import androidx.car.app.notification.CarAppExtender
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.stadiamaps.ferrostar.car.app.R
+import uniffi.ferrostar.SpokenInstruction
+
+/**
+ * Posts and updates a persistent heads-up notification for turn-by-turn guidance on Android Auto.
+ *
+ * This satisfies NF-3 compliance: when the user switches away from the navigation app, the
+ * notification keeps them informed of the next maneuver.
+ *
+ * @param context Application context.
+ * @param channelId Notification channel ID. Defaults to "ferrostar_navigation".
+ * @param notificationId Notification ID. Defaults to 502.
+ * @param smallIconRes Drawable resource ID for the notification's small icon.
+ * @param contentIntent Optional [PendingIntent] fired when the user taps the HUN or rail widget,
+ * typically used to bring the car app back to the foreground.
+ */
+class TurnByTurnNotificationManager(
+ private val context: Context,
+ private val channelId: String = DEFAULT_CHANNEL_ID,
+ private val notificationId: Int = DEFAULT_NOTIFICATION_ID,
+ @DrawableRes private val smallIconRes: Int,
+ private val contentIntent: PendingIntent? = null
+) {
+
+ private val notificationManager = NotificationManagerCompat.from(context)
+
+ init {
+ val channel =
+ NotificationChannel(
+ channelId, "Navigation",
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ description = context.getString(R.string.notification_description)
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ /** Posts or updates the turn-by-turn notification with the given [instruction]. */
+ @SuppressLint("MissingPermission") // Caller is responsible for POST_NOTIFICATIONS permission.
+ fun update(instruction: SpokenInstruction) {
+ val notification =
+ NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(smallIconRes)
+ .setContentTitle(instruction.text)
+ .setOngoing(true)
+ .setCategory(NotificationCompat.CATEGORY_NAVIGATION)
+ .extend(
+ CarAppExtender.Builder()
+ .setContentTitle(instruction.text)
+ .apply {
+ contentIntent?.let {
+ setContentIntent(it)
+ }
+ }
+ .setImportance(NotificationManager.IMPORTANCE_HIGH)
+ .build()
+ )
+ .build()
+
+ try {
+ notificationManager.notify(notificationId, notification)
+ } catch (_: SecurityException) {
+ // POST_NOTIFICATIONS permission not granted; nothing we can do here.
+ }
+ }
+
+ /** Cancels the turn-by-turn notification. */
+ fun clear() {
+ notificationManager.cancel(notificationId)
+ }
+
+ companion object {
+ const val DEFAULT_CHANNEL_ID = "ferrostar_navigation"
+ const val DEFAULT_NOTIFICATION_ID = 502
+ }
+}
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/NavigationTemplateBuilder.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/NavigationTemplateBuilder.kt
new file mode 100644
index 000000000..bd7da4f90
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/NavigationTemplateBuilder.kt
@@ -0,0 +1,148 @@
+package com.stadiamaps.ferrostar.car.app.template
+
+import androidx.car.app.CarContext
+import androidx.car.app.model.Action
+import androidx.car.app.model.ActionStrip
+import androidx.car.app.model.Template
+import androidx.car.app.navigation.model.NavigationTemplate
+import com.stadiamaps.ferrostar.car.app.R
+import com.stadiamaps.ferrostar.car.app.template.icons.InterfaceCarIcons
+import com.stadiamaps.ferrostar.car.app.template.models.FerrostarRoutingInfo
+import com.stadiamaps.ferrostar.car.app.template.models.toCarTravelEstimate
+import com.stadiamaps.ferrostar.core.extensions.progress
+import uniffi.ferrostar.DrivingSide
+import uniffi.ferrostar.TripState
+
+class NavigationTemplateBuilder(
+ private val carContext: CarContext
+) {
+ private val carIcons = InterfaceCarIcons(carContext)
+ private var tripState: TripState? = null
+ private var backupDrivingSide: DrivingSide = DrivingSide.RIGHT
+
+ private var onStopTapped: (() -> Unit)? = null
+
+ private var isMuted: Boolean = false
+ private var onMuteTapped: (() -> Unit)? = null
+
+ private var onZoomInTapped: (() -> Unit)? = null
+
+ private var onZoomOutTapped: (() -> Unit)? = null
+
+ private var cameraIsCenteredOnUser: Boolean = true
+ private var onCycleCameraTapped: (() -> Unit)? = null
+
+ fun setTripState(tripState: TripState?): NavigationTemplateBuilder {
+ this.tripState = tripState
+ return this
+ }
+
+ fun setBackupDrivingSide(drivingSide: DrivingSide): NavigationTemplateBuilder {
+ this.backupDrivingSide = drivingSide
+ return this
+ }
+
+ fun setOnStopNavigation(onStopTapped: () -> Unit): NavigationTemplateBuilder {
+ this.onStopTapped = onStopTapped
+ return this
+ }
+
+ fun setOnMute(
+ isMuted: Boolean?,
+ onMuteTapped: () -> Unit
+ ): NavigationTemplateBuilder {
+ this.isMuted = isMuted ?: false
+ this.onMuteTapped = onMuteTapped
+ return this
+ }
+
+ fun setOnZoom(
+ onZoomInTapped: () -> Unit,
+ onZoomOutTapped: () -> Unit
+ ): NavigationTemplateBuilder {
+ this.onZoomInTapped = onZoomInTapped
+ this.onZoomOutTapped = onZoomOutTapped
+ return this
+ }
+
+ fun setOnCycleCamera(
+ cameraIsCenteredOnUser: Boolean?,
+ onCycleCameraTapped: () -> Unit
+ ): NavigationTemplateBuilder {
+ this.cameraIsCenteredOnUser = cameraIsCenteredOnUser ?: true
+ this.onCycleCameraTapped = onCycleCameraTapped
+ return this
+ }
+
+ fun build(): Template =
+ NavigationTemplate.Builder()
+ .setActionStrip(buildActionStrip())
+ .setMapActionStrip(buildMapActionStrip())
+ .apply {
+ tripState?.let { state ->
+ val routingInfo = FerrostarRoutingInfo.Builder(carContext)
+ .setTripState(state)
+ .build()
+
+ routingInfo?.let { setNavigationInfo(it) }
+
+ state.progress()?.let {
+ setDestinationTravelEstimate(it.toCarTravelEstimate())
+ }
+ }
+ }
+ .build()
+
+ private fun buildActionStrip(): ActionStrip {
+ return ActionStrip.Builder()
+ .apply {
+ onStopTapped?.let {
+ addAction(
+ Action.Builder()
+ .setTitle(carContext.getString(R.string.stop_nav))
+ .setOnClickListener(it)
+ .build()
+ )
+ }
+ }
+ .build()
+ }
+
+ private fun buildMapActionStrip(): ActionStrip =
+ ActionStrip.Builder()
+ .apply {
+ onMuteTapped?.let {
+ addAction(
+ Action.Builder()
+ .setIcon(carIcons.mute(isMuted))
+ .setOnClickListener(it)
+ .build()
+ )
+ }
+ onZoomInTapped?.let {
+ addAction(
+ Action.Builder()
+ .setIcon(carIcons.add)
+ .setOnClickListener(it)
+ .build()
+ )
+ }
+ onZoomOutTapped?.let {
+ addAction(
+ Action.Builder()
+ .setIcon(carIcons.remove)
+ .setOnClickListener(it)
+ .build()
+ )
+ }
+ onCycleCameraTapped?.let {
+ addAction(
+ Action.Builder()
+ .setIcon(carIcons.camera(cameraIsCenteredOnUser))
+ .setOnClickListener(it)
+ .build()
+ )
+ }
+ }
+ .build()
+}
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/icons/InterfaceCarIcons.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/icons/InterfaceCarIcons.kt
new file mode 100644
index 000000000..82141f051
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/icons/InterfaceCarIcons.kt
@@ -0,0 +1,40 @@
+package com.stadiamaps.ferrostar.car.app.template.icons
+
+import android.content.Context
+import androidx.car.app.model.CarIcon
+import androidx.core.graphics.drawable.IconCompat
+import com.stadiamaps.ferrostar.car.app.R
+
+class InterfaceCarIcons(context: Context) {
+ val add: CarIcon =
+ CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.add_24px)).build()
+
+ val remove: CarIcon =
+ CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.remove_24px)).build()
+
+ val volumeMute: CarIcon =
+ CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.volume_mute_24px)).build()
+
+ val volumeUp: CarIcon =
+ CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.volume_up_24px)).build()
+
+ fun mute(isMuted: Boolean): CarIcon =
+ if (isMuted) {
+ volumeMute
+ } else {
+ volumeUp
+ }
+
+ val route: CarIcon =
+ CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.route_24px)).build()
+
+ val navigation: CarIcon =
+ CarIcon.Builder(IconCompat.createWithResource(context, R.drawable.navigation_24px)).build()
+
+ fun camera(isCenteredOnUser: Boolean): CarIcon =
+ if (isCenteredOnUser) {
+ route
+ } else {
+ navigation
+ }
+}
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/LaneBuilder.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/LaneBuilder.kt
new file mode 100644
index 000000000..ecf8440de
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/LaneBuilder.kt
@@ -0,0 +1,29 @@
+package com.stadiamaps.ferrostar.car.app.template.models
+
+import androidx.car.app.navigation.model.Lane
+import androidx.car.app.navigation.model.LaneDirection
+import uniffi.ferrostar.LaneInfo
+
+fun LaneInfo.toCarLane(): Lane =
+ Lane.Builder()
+ .apply {
+ for (direction in directions) {
+ val shape = LaneInfo.asLaneShape(direction)
+ val isRecommended = active && direction == activeDirection
+ addDirection(LaneDirection.create(shape, isRecommended))
+ }
+ }
+ .build()
+
+fun LaneInfo.Companion.asLaneShape(indications: String): Int =
+ when (indications) {
+ "uturn" -> LaneDirection.SHAPE_U_TURN_LEFT
+ "sharp right" -> LaneDirection.SHAPE_SHARP_RIGHT
+ "right" -> LaneDirection.SHAPE_NORMAL_RIGHT
+ "slight right" -> LaneDirection.SHAPE_SLIGHT_RIGHT
+ "straight" -> LaneDirection.SHAPE_STRAIGHT
+ "slight left" -> LaneDirection.SHAPE_SLIGHT_LEFT
+ "left" -> LaneDirection.SHAPE_NORMAL_LEFT
+ "sharp left" -> LaneDirection.SHAPE_SHARP_LEFT
+ else -> LaneDirection.SHAPE_UNKNOWN
+ }
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/ManeuverBuilder.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/ManeuverBuilder.kt
new file mode 100644
index 000000000..68c789152
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/ManeuverBuilder.kt
@@ -0,0 +1,144 @@
+package com.stadiamaps.ferrostar.car.app.template.models
+
+import android.content.Context
+import androidx.car.app.model.CarColor
+import androidx.car.app.model.CarIcon
+import androidx.car.app.navigation.model.Maneuver
+import com.stadiamaps.ferrostar.ui.shared.icons.ManeuverIcon
+import uniffi.ferrostar.DrivingSide
+import uniffi.ferrostar.ManeuverModifier
+import uniffi.ferrostar.ManeuverType
+import uniffi.ferrostar.VisualInstructionContent
+
+fun VisualInstructionContent.toCarManeuver(
+ context: Context,
+ drivingSide: DrivingSide = DrivingSide.RIGHT,
+ roundaboutExitNumber: Int? = null
+): Maneuver {
+ val type = maneuverType.toCarManeuverType(maneuverModifier, drivingSide)
+
+ val maneuverIcon: ManeuverIcon? = if (maneuverType != null && maneuverModifier != null) {
+ ManeuverIcon(context, maneuverType!!, maneuverModifier!!)
+ } else {
+ null
+ }
+
+ return Maneuver.Builder(type)
+ .apply {
+ maneuverIcon?.iconCompat()?.let {
+ setIcon(
+ CarIcon.Builder(it)
+ .setTint(CarColor.PRIMARY)
+ .build()
+ )
+ }
+ roundaboutExitNumber?.let {
+ if (type.isRoundaboutManeuverType()) {
+ setRoundaboutExitNumber(it)
+ }
+ }
+ }
+ .build()
+}
+
+fun ManeuverType?.toCarManeuverType(
+ modifier: ManeuverModifier?,
+ drivingSide: DrivingSide = DrivingSide.RIGHT
+): Int {
+ return when (this) {
+ ManeuverType.TURN ->
+ when (modifier) {
+ ManeuverModifier.U_TURN -> Maneuver.TYPE_U_TURN_LEFT
+ ManeuverModifier.SHARP_RIGHT -> Maneuver.TYPE_TURN_SHARP_RIGHT
+ ManeuverModifier.RIGHT -> Maneuver.TYPE_TURN_NORMAL_RIGHT
+ ManeuverModifier.SLIGHT_RIGHT -> Maneuver.TYPE_TURN_SLIGHT_RIGHT
+ ManeuverModifier.STRAIGHT -> Maneuver.TYPE_STRAIGHT
+ ManeuverModifier.SLIGHT_LEFT -> Maneuver.TYPE_TURN_SLIGHT_LEFT
+ ManeuverModifier.LEFT -> Maneuver.TYPE_TURN_NORMAL_LEFT
+ ManeuverModifier.SHARP_LEFT -> Maneuver.TYPE_TURN_SHARP_LEFT
+ null -> Maneuver.TYPE_UNKNOWN
+ }
+ ManeuverType.NEW_NAME -> Maneuver.TYPE_NAME_CHANGE
+ ManeuverType.DEPART -> Maneuver.TYPE_DEPART
+ ManeuverType.ARRIVE -> Maneuver.TYPE_DESTINATION
+ ManeuverType.MERGE ->
+ when (modifier) {
+ ManeuverModifier.SLIGHT_RIGHT,
+ ManeuverModifier.RIGHT,
+ ManeuverModifier.SHARP_RIGHT -> Maneuver.TYPE_MERGE_RIGHT
+ ManeuverModifier.SLIGHT_LEFT,
+ ManeuverModifier.LEFT,
+ ManeuverModifier.SHARP_LEFT -> Maneuver.TYPE_MERGE_LEFT
+ else -> Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED
+ }
+ ManeuverType.ON_RAMP ->
+ when (modifier) {
+ ManeuverModifier.SLIGHT_RIGHT,
+ ManeuverModifier.RIGHT,
+ ManeuverModifier.SHARP_RIGHT -> Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT
+ ManeuverModifier.SLIGHT_LEFT,
+ ManeuverModifier.LEFT,
+ ManeuverModifier.SHARP_LEFT -> Maneuver.TYPE_ON_RAMP_NORMAL_LEFT
+ else -> Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT
+ }
+ ManeuverType.OFF_RAMP ->
+ when (modifier) {
+ ManeuverModifier.SLIGHT_RIGHT,
+ ManeuverModifier.RIGHT,
+ ManeuverModifier.SHARP_RIGHT -> Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT
+ ManeuverModifier.SLIGHT_LEFT,
+ ManeuverModifier.LEFT,
+ ManeuverModifier.SHARP_LEFT -> Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT
+ else -> Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT
+ }
+ ManeuverType.FORK ->
+ when (modifier) {
+ ManeuverModifier.SLIGHT_RIGHT,
+ ManeuverModifier.RIGHT,
+ ManeuverModifier.SHARP_RIGHT -> Maneuver.TYPE_FORK_RIGHT
+ ManeuverModifier.SLIGHT_LEFT,
+ ManeuverModifier.LEFT,
+ ManeuverModifier.SHARP_LEFT -> Maneuver.TYPE_FORK_LEFT
+ else -> Maneuver.TYPE_FORK_RIGHT
+ }
+ ManeuverType.END_OF_ROAD ->
+ when (modifier) {
+ ManeuverModifier.RIGHT,
+ ManeuverModifier.SLIGHT_RIGHT,
+ ManeuverModifier.SHARP_RIGHT -> Maneuver.TYPE_TURN_NORMAL_RIGHT
+ ManeuverModifier.LEFT,
+ ManeuverModifier.SLIGHT_LEFT,
+ ManeuverModifier.SHARP_LEFT -> Maneuver.TYPE_TURN_NORMAL_LEFT
+ else -> Maneuver.TYPE_UNKNOWN
+ }
+ ManeuverType.CONTINUE -> Maneuver.TYPE_STRAIGHT
+ ManeuverType.ROUNDABOUT,
+ ManeuverType.ROTARY -> drivingSide.roundaboutEnterAndExit()
+ ManeuverType.ROUNDABOUT_TURN -> drivingSide.roundaboutEnterAndExit()
+ ManeuverType.EXIT_ROUNDABOUT,
+ ManeuverType.EXIT_ROTARY -> drivingSide.roundaboutExit()
+ ManeuverType.NOTIFICATION -> Maneuver.TYPE_UNKNOWN
+ null -> Maneuver.TYPE_UNKNOWN
+ }
+}
+
+private fun DrivingSide.roundaboutEnterAndExit(): Int =
+ when (this) {
+ DrivingSide.RIGHT -> Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW
+ DrivingSide.LEFT -> Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW
+ }
+
+private fun DrivingSide.roundaboutExit(): Int =
+ when (this) {
+ DrivingSide.RIGHT -> Maneuver.TYPE_ROUNDABOUT_EXIT_CCW
+ DrivingSide.LEFT -> Maneuver.TYPE_ROUNDABOUT_EXIT_CW
+ }
+
+fun Int.isRoundaboutManeuverType(): Boolean =
+ this == Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW ||
+ this == Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW ||
+ this == Maneuver.TYPE_ROUNDABOUT_ENTER_CW ||
+ this == Maneuver.TYPE_ROUNDABOUT_ENTER_CCW ||
+ this == Maneuver.TYPE_ROUNDABOUT_EXIT_CW ||
+ this == Maneuver.TYPE_ROUNDABOUT_EXIT_CCW
+
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/RoutingInfoBuilder.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/RoutingInfoBuilder.kt
new file mode 100644
index 000000000..9b92d6bbb
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/RoutingInfoBuilder.kt
@@ -0,0 +1,45 @@
+package com.stadiamaps.ferrostar.car.app.template.models
+
+import android.os.Build
+import androidx.car.app.CarContext
+import androidx.car.app.navigation.model.RoutingInfo
+import com.stadiamaps.ferrostar.core.extensions.currentStep
+import com.stadiamaps.ferrostar.core.extensions.progress
+import com.stadiamaps.ferrostar.core.extensions.visualInstruction
+import uniffi.ferrostar.DrivingSide
+import uniffi.ferrostar.TripState
+
+class FerrostarRoutingInfo {
+ class Builder(private val context: CarContext) {
+ private var tripState: TripState? = null
+ private var backupDrivingSide: DrivingSide = DrivingSide.RIGHT
+
+ fun setTripState(tripState: TripState): Builder {
+ this.tripState = tripState
+ return this
+ }
+
+ fun setBackupDrivingSide(drivingSide: DrivingSide): Builder {
+ this.backupDrivingSide = drivingSide
+ return this
+ }
+
+ fun build(): RoutingInfo? {
+ val instruction = tripState?.visualInstruction() ?: return null
+ val progress = tripState?.progress() ?: return null
+ val currentStep = tripState?.currentStep() ?: return null
+
+ val drivingSide = currentStep.drivingSide ?: backupDrivingSide
+ val roundaboutExitNumber = currentStep.roundaboutExitNumber?.toInt()
+
+ return RoutingInfo.Builder()
+ .setCurrentStep(
+ instruction.toCarStep(context, drivingSide, roundaboutExitNumber),
+ progress.toCarDistanceToNextManeuver()
+ )
+ .build()
+ }
+ }
+}
+
+
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/StepBuilder.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/StepBuilder.kt
new file mode 100644
index 000000000..e5334e174
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/StepBuilder.kt
@@ -0,0 +1,35 @@
+package com.stadiamaps.ferrostar.car.app.template.models
+
+import android.content.Context
+import androidx.car.app.navigation.model.Lane
+import androidx.car.app.navigation.model.LaneDirection
+import androidx.car.app.navigation.model.Step
+import uniffi.ferrostar.DrivingSide
+import uniffi.ferrostar.LaneInfo
+import uniffi.ferrostar.VisualInstruction
+
+/**
+ * Builds a Car App Library [Step] from a Ferrostar [VisualInstruction].
+ *
+ * @param context The context used to resolve maneuver icon drawables.
+ * @param drivingSide The driving side, used for roundabout direction.
+ * @param roundaboutExitNumber The roundabout exit number, if applicable.
+ */
+fun VisualInstruction.toCarStep(
+ context: Context,
+ drivingSide: DrivingSide,
+ roundaboutExitNumber: Int?
+): Step {
+ val maneuver = primaryContent.toCarManeuver(context, drivingSide, roundaboutExitNumber)
+ return Step.Builder(primaryContent.text)
+ .setManeuver(maneuver)
+ .apply {
+ secondaryContent?.text?.let {
+ setRoad(it)
+ }
+ primaryContent.laneInfo?.forEach { lane ->
+ addLane(lane.toCarLane())
+ }
+ }
+ .build()
+}
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/TravelEstimateBuilder.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/TravelEstimateBuilder.kt
new file mode 100644
index 000000000..7fa6bde96
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/TravelEstimateBuilder.kt
@@ -0,0 +1,63 @@
+package com.stadiamaps.ferrostar.car.app.template.models
+
+import android.icu.util.MeasureUnit
+import androidx.car.app.model.CarColor
+import androidx.car.app.model.DateTimeWithZone
+import androidx.car.app.model.Distance
+import androidx.car.app.navigation.model.TravelEstimate
+import com.stadiamaps.ferrostar.core.extensions.estimatedArrivalTime
+import com.stadiamaps.ferrostar.ui.formatters.LocalizedDistanceFormatter
+import java.util.TimeZone
+import kotlinx.datetime.toInstant
+import uniffi.ferrostar.TripProgress
+
+
+/**
+ * Converts a distance in meters to a Car App Library [Distance] using locale-appropriate units.
+ *
+ * Uses the same measurement system detection as [LocalizedDistanceFormatter]: SI locales get
+ * meters/km, US locales get feet/miles, UK locales get yards/miles.
+ */
+fun Double.toCarDistance(): Distance {
+ val formatter = LocalizedDistanceFormatter()
+ val roundedDistance = formatter.roundedDistanceForUnit(this)
+ val unit = when (formatter.recommendedUnit(this)) {
+ MeasureUnit.MILE -> Distance.UNIT_MILES
+ MeasureUnit.YARD -> Distance.UNIT_YARDS
+ MeasureUnit.FOOT -> Distance.UNIT_FEET
+ MeasureUnit.KILOMETER -> Distance.UNIT_KILOMETERS
+ MeasureUnit.METER -> Distance.UNIT_METERS
+ else -> Distance.UNIT_METERS
+ }
+ return Distance.create(roundedDistance, unit)
+}
+
+/**
+ * Converts the distance to next maneuver to a Car App Library [Distance].
+ *
+ * Returns a zero-meter [Distance] if the receiver is null.
+ */
+fun TripProgress?.toCarDistanceToNextManeuver(): Distance =
+ (this?.distanceToNextManeuver ?: 0.0).toCarDistance()
+
+/**
+ * Builds a Car App Library [TravelEstimate] from this [TripProgress].
+ *
+ * Computes the ETA by adding [TripProgress.durationRemaining] to the current system time.
+ */
+fun TripProgress.toCarTravelEstimate(): TravelEstimate {
+ val arrivalMillis = estimatedArrivalTime()
+ .toInstant(kotlinx.datetime.TimeZone.currentSystemDefault())
+ .toEpochMilliseconds()
+
+ val arrivalDateTimeWithZone =
+ DateTimeWithZone.create(arrivalMillis, TimeZone.getDefault())
+
+ return TravelEstimate.Builder(
+ distanceRemaining.toCarDistance(),
+ arrivalDateTimeWithZone
+ )
+ .setRemainingTimeSeconds(durationRemaining.toLong())
+ .setRemainingTimeColor(CarColor.GREEN)
+ .build()
+}
diff --git a/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/TripBuilder.kt b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/TripBuilder.kt
new file mode 100644
index 000000000..5123edde9
--- /dev/null
+++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/TripBuilder.kt
@@ -0,0 +1,67 @@
+package com.stadiamaps.ferrostar.car.app.template.models
+
+import android.content.Context
+import androidx.car.app.navigation.model.Destination
+import androidx.car.app.navigation.model.Trip
+import com.stadiamaps.ferrostar.core.extensions.currentRoadName
+import com.stadiamaps.ferrostar.core.extensions.currentStep
+import com.stadiamaps.ferrostar.core.extensions.progress
+import com.stadiamaps.ferrostar.core.extensions.visualInstruction
+import uniffi.ferrostar.DrivingSide
+import uniffi.ferrostar.TripState
+
+class FerrostarTrip {
+ class Builder(private val context: Context) {
+
+ private var tripState: TripState? = null
+ private var destination: Destination? = null
+ private var backupDrivingSide: DrivingSide = DrivingSide.RIGHT
+
+ fun setTripState(tripState: TripState): Builder {
+ this.tripState = tripState
+ return this
+ }
+
+ fun setDestination(destination: String): Builder {
+ this.destination = Destination.Builder()
+ .setName(destination)
+ .build()
+
+ return this
+ }
+
+ fun setBackupDrivingSide(drivingSide: DrivingSide): Builder {
+ this.backupDrivingSide = drivingSide
+ return this
+ }
+
+ fun build(): Trip {
+ val instruction = tripState?.visualInstruction()
+ val progress = tripState?.progress()
+ val currentStep = tripState?.currentStep()
+
+ return Trip.Builder()
+ .apply {
+ if (instruction != null && progress != null && currentStep != null) {
+ val drivingSide = currentStep.drivingSide ?: backupDrivingSide
+ val roundaboutExitNumber = currentStep.roundaboutExitNumber?.toInt()
+
+ val step = instruction.toCarStep(context, drivingSide, roundaboutExitNumber)
+ val estimate = progress.toCarTravelEstimate()
+
+ addStep(step, estimate)
+
+ destination?.let {
+ addDestination(it, estimate)
+ }
+ }
+ }
+ .apply {
+ tripState?.currentRoadName()?.let {
+ setCurrentRoad(it)
+ }
+ }
+ .build()
+ }
+ }
+}
diff --git a/android/car-app/src/main/res/drawable/add_24px.xml b/android/car-app/src/main/res/drawable/add_24px.xml
new file mode 100644
index 000000000..d6fd3d379
--- /dev/null
+++ b/android/car-app/src/main/res/drawable/add_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/car-app/src/main/res/drawable/navigation_24px.xml b/android/car-app/src/main/res/drawable/navigation_24px.xml
new file mode 100644
index 000000000..6c4832fbe
--- /dev/null
+++ b/android/car-app/src/main/res/drawable/navigation_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/car-app/src/main/res/drawable/remove_24px.xml b/android/car-app/src/main/res/drawable/remove_24px.xml
new file mode 100644
index 000000000..46c12d354
--- /dev/null
+++ b/android/car-app/src/main/res/drawable/remove_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/car-app/src/main/res/drawable/route_24px.xml b/android/car-app/src/main/res/drawable/route_24px.xml
new file mode 100644
index 000000000..ab36ca41b
--- /dev/null
+++ b/android/car-app/src/main/res/drawable/route_24px.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/android/car-app/src/main/res/drawable/volume_mute_24px.xml b/android/car-app/src/main/res/drawable/volume_mute_24px.xml
new file mode 100644
index 000000000..9f256ee27
--- /dev/null
+++ b/android/car-app/src/main/res/drawable/volume_mute_24px.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/android/car-app/src/main/res/drawable/volume_up_24px.xml b/android/car-app/src/main/res/drawable/volume_up_24px.xml
new file mode 100644
index 000000000..bc9c5c827
--- /dev/null
+++ b/android/car-app/src/main/res/drawable/volume_up_24px.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/android/car-app/src/main/res/values/strings.xml b/android/car-app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..19496eea6
--- /dev/null
+++ b/android/car-app/src/main/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ Stop
+ Turn-by-turn navigation guidance
+
diff --git a/android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/LaneBuilderTest.kt b/android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/LaneBuilderTest.kt
new file mode 100644
index 000000000..26667ecfe
--- /dev/null
+++ b/android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/LaneBuilderTest.kt
@@ -0,0 +1,60 @@
+package com.stadiamaps.ferrostar.car.app
+
+import androidx.car.app.navigation.model.LaneDirection
+import com.stadiamaps.ferrostar.car.app.template.models.asLaneShape
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import uniffi.ferrostar.LaneInfo
+
+class LaneBuilderTest {
+
+ @Test
+ fun `uturn maps to SHAPE_U_TURN_LEFT`() {
+ assertEquals(LaneDirection.SHAPE_U_TURN_LEFT, LaneInfo.asLaneShape("uturn"))
+ }
+
+ @Test
+ fun `sharp right maps to SHAPE_SHARP_RIGHT`() {
+ assertEquals(LaneDirection.SHAPE_SHARP_RIGHT, LaneInfo.asLaneShape("sharp right"))
+ }
+
+ @Test
+ fun `right maps to SHAPE_NORMAL_RIGHT`() {
+ assertEquals(LaneDirection.SHAPE_NORMAL_RIGHT, LaneInfo.asLaneShape("right"))
+ }
+
+ @Test
+ fun `slight right maps to SHAPE_SLIGHT_RIGHT`() {
+ assertEquals(LaneDirection.SHAPE_SLIGHT_RIGHT, LaneInfo.asLaneShape("slight right"))
+ }
+
+ @Test
+ fun `straight maps to SHAPE_STRAIGHT`() {
+ assertEquals(LaneDirection.SHAPE_STRAIGHT, LaneInfo.asLaneShape("straight"))
+ }
+
+ @Test
+ fun `slight left maps to SHAPE_SLIGHT_LEFT`() {
+ assertEquals(LaneDirection.SHAPE_SLIGHT_LEFT, LaneInfo.asLaneShape("slight left"))
+ }
+
+ @Test
+ fun `left maps to SHAPE_NORMAL_LEFT`() {
+ assertEquals(LaneDirection.SHAPE_NORMAL_LEFT, LaneInfo.asLaneShape("left"))
+ }
+
+ @Test
+ fun `sharp left maps to SHAPE_SHARP_LEFT`() {
+ assertEquals(LaneDirection.SHAPE_SHARP_LEFT, LaneInfo.asLaneShape("sharp left"))
+ }
+
+ @Test
+ fun `unknown indication maps to SHAPE_UNKNOWN`() {
+ assertEquals(LaneDirection.SHAPE_UNKNOWN, LaneInfo.asLaneShape("bogus"))
+ }
+
+ @Test
+ fun `empty string maps to SHAPE_UNKNOWN`() {
+ assertEquals(LaneDirection.SHAPE_UNKNOWN, LaneInfo.asLaneShape(""))
+ }
+}
diff --git a/android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/ManeuverBuilderTest.kt b/android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/ManeuverBuilderTest.kt
new file mode 100644
index 000000000..809eb8536
--- /dev/null
+++ b/android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/ManeuverBuilderTest.kt
@@ -0,0 +1,385 @@
+package com.stadiamaps.ferrostar.car.app
+
+import androidx.car.app.navigation.model.Maneuver
+import com.stadiamaps.ferrostar.car.app.template.models.isRoundaboutManeuverType
+import com.stadiamaps.ferrostar.car.app.template.models.toCarManeuverType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import uniffi.ferrostar.DrivingSide
+import uniffi.ferrostar.ManeuverModifier
+import uniffi.ferrostar.ManeuverType
+
+class ManeuverBuilderTest {
+
+ // --- Null / catch-all types ---
+
+ @Test
+ fun `null type returns UNKNOWN`() {
+ assertEquals(Maneuver.TYPE_UNKNOWN, null.toCarManeuverType(null))
+ }
+
+ @Test
+ fun `NOTIFICATION returns UNKNOWN`() {
+ assertEquals(Maneuver.TYPE_UNKNOWN, ManeuverType.NOTIFICATION.toCarManeuverType(null))
+ }
+
+ // --- Simple single-value types ---
+
+ @Test
+ fun `NEW_NAME returns NAME_CHANGE`() {
+ assertEquals(Maneuver.TYPE_NAME_CHANGE, ManeuverType.NEW_NAME.toCarManeuverType(null))
+ }
+
+ @Test
+ fun `DEPART returns DEPART`() {
+ assertEquals(Maneuver.TYPE_DEPART, ManeuverType.DEPART.toCarManeuverType(null))
+ }
+
+ @Test
+ fun `ARRIVE returns DESTINATION`() {
+ assertEquals(Maneuver.TYPE_DESTINATION, ManeuverType.ARRIVE.toCarManeuverType(null))
+ }
+
+ @Test
+ fun `CONTINUE returns STRAIGHT`() {
+ assertEquals(Maneuver.TYPE_STRAIGHT, ManeuverType.CONTINUE.toCarManeuverType(null))
+ }
+
+ // --- TURN ---
+
+ @Test
+ fun `TURN U_TURN returns U_TURN_LEFT`() {
+ assertEquals(Maneuver.TYPE_U_TURN_LEFT, ManeuverType.TURN.toCarManeuverType(ManeuverModifier.U_TURN))
+ }
+
+ @Test
+ fun `TURN SHARP_RIGHT returns TURN_SHARP_RIGHT`() {
+ assertEquals(Maneuver.TYPE_TURN_SHARP_RIGHT, ManeuverType.TURN.toCarManeuverType(ManeuverModifier.SHARP_RIGHT))
+ }
+
+ @Test
+ fun `TURN RIGHT returns TURN_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_TURN_NORMAL_RIGHT, ManeuverType.TURN.toCarManeuverType(ManeuverModifier.RIGHT))
+ }
+
+ @Test
+ fun `TURN SLIGHT_RIGHT returns TURN_SLIGHT_RIGHT`() {
+ assertEquals(Maneuver.TYPE_TURN_SLIGHT_RIGHT, ManeuverType.TURN.toCarManeuverType(ManeuverModifier.SLIGHT_RIGHT))
+ }
+
+ @Test
+ fun `TURN STRAIGHT returns STRAIGHT`() {
+ assertEquals(Maneuver.TYPE_STRAIGHT, ManeuverType.TURN.toCarManeuverType(ManeuverModifier.STRAIGHT))
+ }
+
+ @Test
+ fun `TURN SLIGHT_LEFT returns TURN_SLIGHT_LEFT`() {
+ assertEquals(Maneuver.TYPE_TURN_SLIGHT_LEFT, ManeuverType.TURN.toCarManeuverType(ManeuverModifier.SLIGHT_LEFT))
+ }
+
+ @Test
+ fun `TURN LEFT returns TURN_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_TURN_NORMAL_LEFT, ManeuverType.TURN.toCarManeuverType(ManeuverModifier.LEFT))
+ }
+
+ @Test
+ fun `TURN SHARP_LEFT returns TURN_SHARP_LEFT`() {
+ assertEquals(Maneuver.TYPE_TURN_SHARP_LEFT, ManeuverType.TURN.toCarManeuverType(ManeuverModifier.SHARP_LEFT))
+ }
+
+ @Test
+ fun `TURN null modifier returns UNKNOWN`() {
+ assertEquals(Maneuver.TYPE_UNKNOWN, ManeuverType.TURN.toCarManeuverType(null))
+ }
+
+ // --- MERGE ---
+
+ @Test
+ fun `MERGE SLIGHT_RIGHT returns MERGE_RIGHT`() {
+ assertEquals(Maneuver.TYPE_MERGE_RIGHT, ManeuverType.MERGE.toCarManeuverType(ManeuverModifier.SLIGHT_RIGHT))
+ }
+
+ @Test
+ fun `MERGE RIGHT returns MERGE_RIGHT`() {
+ assertEquals(Maneuver.TYPE_MERGE_RIGHT, ManeuverType.MERGE.toCarManeuverType(ManeuverModifier.RIGHT))
+ }
+
+ @Test
+ fun `MERGE SHARP_RIGHT returns MERGE_RIGHT`() {
+ assertEquals(Maneuver.TYPE_MERGE_RIGHT, ManeuverType.MERGE.toCarManeuverType(ManeuverModifier.SHARP_RIGHT))
+ }
+
+ @Test
+ fun `MERGE SLIGHT_LEFT returns MERGE_LEFT`() {
+ assertEquals(Maneuver.TYPE_MERGE_LEFT, ManeuverType.MERGE.toCarManeuverType(ManeuverModifier.SLIGHT_LEFT))
+ }
+
+ @Test
+ fun `MERGE LEFT returns MERGE_LEFT`() {
+ assertEquals(Maneuver.TYPE_MERGE_LEFT, ManeuverType.MERGE.toCarManeuverType(ManeuverModifier.LEFT))
+ }
+
+ @Test
+ fun `MERGE SHARP_LEFT returns MERGE_LEFT`() {
+ assertEquals(Maneuver.TYPE_MERGE_LEFT, ManeuverType.MERGE.toCarManeuverType(ManeuverModifier.SHARP_LEFT))
+ }
+
+ @Test
+ fun `MERGE STRAIGHT returns MERGE_SIDE_UNSPECIFIED`() {
+ assertEquals(Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED, ManeuverType.MERGE.toCarManeuverType(ManeuverModifier.STRAIGHT))
+ }
+
+ @Test
+ fun `MERGE null modifier returns MERGE_SIDE_UNSPECIFIED`() {
+ assertEquals(Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED, ManeuverType.MERGE.toCarManeuverType(null))
+ }
+
+ // --- ON_RAMP ---
+
+ @Test
+ fun `ON_RAMP SLIGHT_RIGHT returns ON_RAMP_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, ManeuverType.ON_RAMP.toCarManeuverType(ManeuverModifier.SLIGHT_RIGHT))
+ }
+
+ @Test
+ fun `ON_RAMP RIGHT returns ON_RAMP_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, ManeuverType.ON_RAMP.toCarManeuverType(ManeuverModifier.RIGHT))
+ }
+
+ @Test
+ fun `ON_RAMP SHARP_RIGHT returns ON_RAMP_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, ManeuverType.ON_RAMP.toCarManeuverType(ManeuverModifier.SHARP_RIGHT))
+ }
+
+ @Test
+ fun `ON_RAMP SLIGHT_LEFT returns ON_RAMP_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, ManeuverType.ON_RAMP.toCarManeuverType(ManeuverModifier.SLIGHT_LEFT))
+ }
+
+ @Test
+ fun `ON_RAMP LEFT returns ON_RAMP_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, ManeuverType.ON_RAMP.toCarManeuverType(ManeuverModifier.LEFT))
+ }
+
+ @Test
+ fun `ON_RAMP SHARP_LEFT returns ON_RAMP_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, ManeuverType.ON_RAMP.toCarManeuverType(ManeuverModifier.SHARP_LEFT))
+ }
+
+ @Test
+ fun `ON_RAMP null modifier defaults to ON_RAMP_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, ManeuverType.ON_RAMP.toCarManeuverType(null))
+ }
+
+ // --- OFF_RAMP ---
+
+ @Test
+ fun `OFF_RAMP SLIGHT_RIGHT returns OFF_RAMP_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT, ManeuverType.OFF_RAMP.toCarManeuverType(ManeuverModifier.SLIGHT_RIGHT))
+ }
+
+ @Test
+ fun `OFF_RAMP RIGHT returns OFF_RAMP_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT, ManeuverType.OFF_RAMP.toCarManeuverType(ManeuverModifier.RIGHT))
+ }
+
+ @Test
+ fun `OFF_RAMP SHARP_RIGHT returns OFF_RAMP_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT, ManeuverType.OFF_RAMP.toCarManeuverType(ManeuverModifier.SHARP_RIGHT))
+ }
+
+ @Test
+ fun `OFF_RAMP SLIGHT_LEFT returns OFF_RAMP_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT, ManeuverType.OFF_RAMP.toCarManeuverType(ManeuverModifier.SLIGHT_LEFT))
+ }
+
+ @Test
+ fun `OFF_RAMP LEFT returns OFF_RAMP_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT, ManeuverType.OFF_RAMP.toCarManeuverType(ManeuverModifier.LEFT))
+ }
+
+ @Test
+ fun `OFF_RAMP SHARP_LEFT returns OFF_RAMP_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT, ManeuverType.OFF_RAMP.toCarManeuverType(ManeuverModifier.SHARP_LEFT))
+ }
+
+ @Test
+ fun `OFF_RAMP null modifier defaults to OFF_RAMP_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT, ManeuverType.OFF_RAMP.toCarManeuverType(null))
+ }
+
+ // --- FORK ---
+
+ @Test
+ fun `FORK SLIGHT_RIGHT returns FORK_RIGHT`() {
+ assertEquals(Maneuver.TYPE_FORK_RIGHT, ManeuverType.FORK.toCarManeuverType(ManeuverModifier.SLIGHT_RIGHT))
+ }
+
+ @Test
+ fun `FORK RIGHT returns FORK_RIGHT`() {
+ assertEquals(Maneuver.TYPE_FORK_RIGHT, ManeuverType.FORK.toCarManeuverType(ManeuverModifier.RIGHT))
+ }
+
+ @Test
+ fun `FORK SHARP_RIGHT returns FORK_RIGHT`() {
+ assertEquals(Maneuver.TYPE_FORK_RIGHT, ManeuverType.FORK.toCarManeuverType(ManeuverModifier.SHARP_RIGHT))
+ }
+
+ @Test
+ fun `FORK SLIGHT_LEFT returns FORK_LEFT`() {
+ assertEquals(Maneuver.TYPE_FORK_LEFT, ManeuverType.FORK.toCarManeuverType(ManeuverModifier.SLIGHT_LEFT))
+ }
+
+ @Test
+ fun `FORK LEFT returns FORK_LEFT`() {
+ assertEquals(Maneuver.TYPE_FORK_LEFT, ManeuverType.FORK.toCarManeuverType(ManeuverModifier.LEFT))
+ }
+
+ @Test
+ fun `FORK SHARP_LEFT returns FORK_LEFT`() {
+ assertEquals(Maneuver.TYPE_FORK_LEFT, ManeuverType.FORK.toCarManeuverType(ManeuverModifier.SHARP_LEFT))
+ }
+
+ @Test
+ fun `FORK null modifier defaults to FORK_RIGHT`() {
+ assertEquals(Maneuver.TYPE_FORK_RIGHT, ManeuverType.FORK.toCarManeuverType(null))
+ }
+
+ // --- END_OF_ROAD ---
+
+ @Test
+ fun `END_OF_ROAD RIGHT returns TURN_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_TURN_NORMAL_RIGHT, ManeuverType.END_OF_ROAD.toCarManeuverType(ManeuverModifier.RIGHT))
+ }
+
+ @Test
+ fun `END_OF_ROAD SLIGHT_RIGHT returns TURN_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_TURN_NORMAL_RIGHT, ManeuverType.END_OF_ROAD.toCarManeuverType(ManeuverModifier.SLIGHT_RIGHT))
+ }
+
+ @Test
+ fun `END_OF_ROAD SHARP_RIGHT returns TURN_NORMAL_RIGHT`() {
+ assertEquals(Maneuver.TYPE_TURN_NORMAL_RIGHT, ManeuverType.END_OF_ROAD.toCarManeuverType(ManeuverModifier.SHARP_RIGHT))
+ }
+
+ @Test
+ fun `END_OF_ROAD LEFT returns TURN_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_TURN_NORMAL_LEFT, ManeuverType.END_OF_ROAD.toCarManeuverType(ManeuverModifier.LEFT))
+ }
+
+ @Test
+ fun `END_OF_ROAD SLIGHT_LEFT returns TURN_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_TURN_NORMAL_LEFT, ManeuverType.END_OF_ROAD.toCarManeuverType(ManeuverModifier.SLIGHT_LEFT))
+ }
+
+ @Test
+ fun `END_OF_ROAD SHARP_LEFT returns TURN_NORMAL_LEFT`() {
+ assertEquals(Maneuver.TYPE_TURN_NORMAL_LEFT, ManeuverType.END_OF_ROAD.toCarManeuverType(ManeuverModifier.SHARP_LEFT))
+ }
+
+ @Test
+ fun `END_OF_ROAD STRAIGHT returns UNKNOWN`() {
+ assertEquals(Maneuver.TYPE_UNKNOWN, ManeuverType.END_OF_ROAD.toCarManeuverType(ManeuverModifier.STRAIGHT))
+ }
+
+ @Test
+ fun `END_OF_ROAD null modifier returns UNKNOWN`() {
+ assertEquals(Maneuver.TYPE_UNKNOWN, ManeuverType.END_OF_ROAD.toCarManeuverType(null))
+ }
+
+ // --- Roundabout / Rotary — driving side determines CW vs CCW ---
+
+ @Test
+ fun `ROUNDABOUT right-hand traffic enters CCW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW,
+ ManeuverType.ROUNDABOUT.toCarManeuverType(null, DrivingSide.RIGHT))
+ }
+
+ @Test
+ fun `ROUNDABOUT left-hand traffic enters CW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW,
+ ManeuverType.ROUNDABOUT.toCarManeuverType(null, DrivingSide.LEFT))
+ }
+
+ @Test
+ fun `ROTARY right-hand traffic enters CCW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW,
+ ManeuverType.ROTARY.toCarManeuverType(null, DrivingSide.RIGHT))
+ }
+
+ @Test
+ fun `ROTARY left-hand traffic enters CW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW,
+ ManeuverType.ROTARY.toCarManeuverType(null, DrivingSide.LEFT))
+ }
+
+ @Test
+ fun `ROUNDABOUT_TURN right-hand traffic enters CCW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW,
+ ManeuverType.ROUNDABOUT_TURN.toCarManeuverType(null, DrivingSide.RIGHT))
+ }
+
+ @Test
+ fun `ROUNDABOUT_TURN left-hand traffic enters CW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW,
+ ManeuverType.ROUNDABOUT_TURN.toCarManeuverType(null, DrivingSide.LEFT))
+ }
+
+ @Test
+ fun `EXIT_ROUNDABOUT right-hand traffic exits CCW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_EXIT_CCW,
+ ManeuverType.EXIT_ROUNDABOUT.toCarManeuverType(null, DrivingSide.RIGHT))
+ }
+
+ @Test
+ fun `EXIT_ROUNDABOUT left-hand traffic exits CW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_EXIT_CW,
+ ManeuverType.EXIT_ROUNDABOUT.toCarManeuverType(null, DrivingSide.LEFT))
+ }
+
+ @Test
+ fun `EXIT_ROTARY right-hand traffic exits CCW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_EXIT_CCW,
+ ManeuverType.EXIT_ROTARY.toCarManeuverType(null, DrivingSide.RIGHT))
+ }
+
+ @Test
+ fun `EXIT_ROTARY left-hand traffic exits CW`() {
+ assertEquals(Maneuver.TYPE_ROUNDABOUT_EXIT_CW,
+ ManeuverType.EXIT_ROTARY.toCarManeuverType(null, DrivingSide.LEFT))
+ }
+
+ // --- isRoundaboutManeuverType ---
+
+ @Test
+ fun `roundabout enter-and-exit constants are identified as roundabout types`() {
+ assertTrue(Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW.isRoundaboutManeuverType())
+ assertTrue(Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW.isRoundaboutManeuverType())
+ }
+
+ @Test
+ fun `roundabout enter constants are identified as roundabout types`() {
+ assertTrue(Maneuver.TYPE_ROUNDABOUT_ENTER_CW.isRoundaboutManeuverType())
+ assertTrue(Maneuver.TYPE_ROUNDABOUT_ENTER_CCW.isRoundaboutManeuverType())
+ }
+
+ @Test
+ fun `roundabout exit constants are identified as roundabout types`() {
+ assertTrue(Maneuver.TYPE_ROUNDABOUT_EXIT_CW.isRoundaboutManeuverType())
+ assertTrue(Maneuver.TYPE_ROUNDABOUT_EXIT_CCW.isRoundaboutManeuverType())
+ }
+
+ @Test
+ fun `non-roundabout constants are not identified as roundabout types`() {
+ assertFalse(Maneuver.TYPE_STRAIGHT.isRoundaboutManeuverType())
+ assertFalse(Maneuver.TYPE_UNKNOWN.isRoundaboutManeuverType())
+ assertFalse(Maneuver.TYPE_DEPART.isRoundaboutManeuverType())
+ assertFalse(Maneuver.TYPE_DESTINATION.isRoundaboutManeuverType())
+ assertFalse(Maneuver.TYPE_TURN_NORMAL_RIGHT.isRoundaboutManeuverType())
+ assertFalse(Maneuver.TYPE_TURN_NORMAL_LEFT.isRoundaboutManeuverType())
+ assertFalse(Maneuver.TYPE_FORK_RIGHT.isRoundaboutManeuverType())
+ assertFalse(Maneuver.TYPE_MERGE_RIGHT.isRoundaboutManeuverType())
+ }
+}
diff --git a/android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/NavigationIntentParserTest.kt b/android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/NavigationIntentParserTest.kt
new file mode 100644
index 000000000..2bfbaf841
--- /dev/null
+++ b/android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/NavigationIntentParserTest.kt
@@ -0,0 +1,113 @@
+package com.stadiamaps.ferrostar.car.app
+
+import com.stadiamaps.ferrostar.car.app.intent.NavigationDestination
+import com.stadiamaps.ferrostar.car.app.intent.NavigationIntentParser
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class NavigationIntentParserTest {
+
+ // parseGeoSsp
+
+ @Test
+ fun `geo coordinates only`() {
+ val result = NavigationIntentParser.parseGeoSsp("37.8100,-122.4200", null)
+ assertNotNull(result)
+ assertEquals(37.81, result!!.latitude!!, 0.0001)
+ assertEquals(-122.42, result.longitude!!, 0.0001)
+ assertNull(result.query)
+ }
+
+ @Test
+ fun `geo coordinates with altitude ignored`() {
+ val result = NavigationIntentParser.parseGeoSsp("37.8100,-122.4200,100", null)
+ assertNotNull(result)
+ assertEquals(37.81, result!!.latitude!!, 0.0001)
+ assertEquals(-122.42, result.longitude!!, 0.0001)
+ }
+
+ @Test
+ fun `geo query only`() {
+ val result = NavigationIntentParser.parseGeoSsp("0,0", "coffee shops")
+ assertNotNull(result)
+ assertNull(result!!.latitude)
+ assertNull(result.longitude)
+ assertEquals("coffee shops", result.query)
+ }
+
+ @Test
+ fun `geo coordinates and query`() {
+ val result = NavigationIntentParser.parseGeoSsp("37.81,-122.42", "Pier 39")
+ assertNotNull(result)
+ assertEquals(37.81, result!!.latitude!!, 0.0001)
+ assertEquals(-122.42, result.longitude!!, 0.0001)
+ assertEquals("Pier 39", result.query)
+ }
+
+ @Test
+ fun `geo zero coordinates with query uses query`() {
+ val result = NavigationIntentParser.parseGeoSsp("0,0", "Starbucks")
+ assertNotNull(result)
+ assertNull(result!!.latitude)
+ assertNull(result.longitude)
+ assertEquals("Starbucks", result.query)
+ }
+
+ @Test
+ fun `geo zero coordinates with no query returns null`() {
+ val result = NavigationIntentParser.parseGeoSsp("0,0", null)
+ assertNull(result)
+ }
+
+ @Test
+ fun `geo invalid latitude returns null`() {
+ assertNull(NavigationIntentParser.parseGeoSsp("91.0,0.0", null))
+ }
+
+ @Test
+ fun `geo invalid longitude returns null`() {
+ assertNull(NavigationIntentParser.parseGeoSsp("0.0,181.0", null))
+ }
+
+ // parseGoogleNavigationSsp
+
+ @Test
+ fun `google navigation coordinates`() {
+ val result = NavigationIntentParser.parseGoogleNavigationSsp("37.81,-122.42")
+ assertNotNull(result)
+ assertEquals(37.81, result!!.latitude!!, 0.0001)
+ assertEquals(-122.42, result.longitude!!, 0.0001)
+ assertNull(result.query)
+ }
+
+ @Test
+ fun `google navigation place name`() {
+ val result = NavigationIntentParser.parseGoogleNavigationSsp("Starbucks Seattle")
+ assertNotNull(result)
+ assertNull(result!!.latitude)
+ assertNull(result.longitude)
+ assertEquals("Starbucks Seattle", result.query)
+ }
+
+ // NavigationDestination display name
+
+ @Test
+ fun `display name uses query when available`() {
+ val dest = NavigationDestination(37.81, -122.42, "Pier 39")
+ assertEquals("Pier 39", dest.displayName)
+ }
+
+ @Test
+ fun `display name uses coordinates when no query`() {
+ val dest = NavigationDestination(37.81, -122.42, null)
+ assertEquals("37.8100, -122.4200", dest.displayName)
+ }
+
+ @Test
+ fun `display name uses unknown when neither`() {
+ val dest = NavigationDestination(null, null, null)
+ assertEquals("Unknown location", dest.displayName)
+ }
+}
diff --git a/android/core/build.gradle b/android/core/build.gradle
index 5d771e7c3..3c3f487ed 100644
--- a/android/core/build.gradle
+++ b/android/core/build.gradle
@@ -32,12 +32,6 @@ android {
testOptions {
targetSdk 36
}
- lint {
- targetSdk 36
- }
- testOptions {
- targetSdk 36
- }
}
dependencies {
@@ -69,6 +63,7 @@ dependencies {
// ValhallaCoreTest.kt
androidTestImplementation libs.okhttp.mock
androidTestImplementation libs.kotlinx.coroutines.test
+ androidTestImplementation libs.turbine
androidTestImplementation libs.androidx.test.junit
androidTestImplementation libs.androidx.test.espresso
}
diff --git a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/FerrostarCoreTest.kt b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/FerrostarCoreTest.kt
index dae649d24..266eae42e 100644
--- a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/FerrostarCoreTest.kt
+++ b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/FerrostarCoreTest.kt
@@ -1,6 +1,7 @@
package com.stadiamaps.ferrostar.core
import com.stadiamaps.ferrostar.core.http.OkHttpClientProvider.Companion.toOkHttpClientProvider
+import com.stadiamaps.ferrostar.core.location.SimulatedLocationProvider
import com.stadiamaps.ferrostar.core.service.ForegroundServiceManager
import java.time.Instant
import kotlinx.coroutines.test.runTest
@@ -453,14 +454,6 @@ class FerrostarCoreTest {
)),
)
- locationProvider.lastLocation =
- UserLocation(
- coordinates = GeographicCoordinate(0.0, 0.0),
- horizontalAccuracy = 6.0,
- courseOverGround = null,
- timestamp = Instant.now(),
- speed = null,
- )
core.startNavigation(
routes.first(),
NavigationControllerConfig(
diff --git a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ValhallaCoreTest.kt b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ValhallaCoreTest.kt
index ed298afa2..e6600ed0d 100644
--- a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ValhallaCoreTest.kt
+++ b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ValhallaCoreTest.kt
@@ -9,6 +9,7 @@
package com.stadiamaps.ferrostar.core
import com.stadiamaps.ferrostar.core.http.OkHttpClientProvider.Companion.toOkHttpClientProvider
+import com.stadiamaps.ferrostar.core.location.SimulatedLocationProvider
import java.time.Instant
import kotlinx.coroutines.test.TestResult
import kotlinx.coroutines.test.runTest
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt
index ce7f10acd..a00450943 100644
--- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt
+++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt
@@ -2,20 +2,22 @@ package com.stadiamaps.ferrostar.core
import androidx.annotation.VisibleForTesting
import com.stadiamaps.ferrostar.core.http.HttpClientProvider
+import com.stadiamaps.ferrostar.core.location.NavigationLocationProviding
+import com.stadiamaps.ferrostar.core.location.toAndroidLocation
+import com.stadiamaps.ferrostar.core.location.toUserLocation
import com.stadiamaps.ferrostar.core.service.ForegroundServiceManager
import java.time.Instant
-import java.util.concurrent.Executors
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import uniffi.ferrostar.GeographicCoordinate
-import uniffi.ferrostar.Heading
import uniffi.ferrostar.NavState
import uniffi.ferrostar.NavigationControllerConfig
import uniffi.ferrostar.NavigationSession
@@ -62,12 +64,12 @@ fun NavigationState.isNavigating(): Boolean =
class FerrostarCore(
val routeProvider: RouteProvider,
val httpClient: HttpClientProvider,
- val locationProvider: LocationProvider,
+ val locationProvider: NavigationLocationProviding,
val foregroundServiceManager: ForegroundServiceManager? = null,
navigationControllerConfig: NavigationControllerConfig,
val sessionBuilder: FerrostarSessionBuilder =
FerrostarSessionBuilder(navigationControllerConfig),
-) : LocationUpdateListener {
+) {
companion object {
private const val TAG = "FerrostarCore"
}
@@ -128,8 +130,8 @@ class FerrostarCore(
var isCalculatingNewRoute: Boolean = false
private set
- private val _executor = Executors.newSingleThreadScheduledExecutor()
private val _scope = CoroutineScope(Dispatchers.IO)
+ private var _locationJob: Job? = null
private var _navigationSession: NavigationSession? = null
private val _navState: MutableStateFlow = MutableStateFlow(null)
@@ -155,7 +157,7 @@ class FerrostarCore(
constructor(
wellKnownRouteProvider: WellKnownRouteProvider,
httpClient: HttpClientProvider,
- locationProvider: LocationProvider,
+ locationProvider: NavigationLocationProviding,
navigationControllerConfig: NavigationControllerConfig,
foregroundServiceManager: ForegroundServiceManager? = null,
) : this(
@@ -168,7 +170,7 @@ class FerrostarCore(
constructor(
routeAdapter: RouteAdapter,
httpClient: HttpClientProvider,
- locationProvider: LocationProvider,
+ locationProvider: NavigationLocationProviding,
navigationControllerConfig: NavigationControllerConfig,
foregroundServiceManager: ForegroundServiceManager? = null,
) : this(
@@ -181,7 +183,7 @@ class FerrostarCore(
constructor(
customRouteProvider: CustomRouteProvider,
httpClient: HttpClientProvider,
- locationProvider: LocationProvider,
+ locationProvider: NavigationLocationProviding,
navigationControllerConfig: NavigationControllerConfig,
foregroundServiceManager: ForegroundServiceManager? = null,
) : this(
@@ -248,8 +250,7 @@ class FerrostarCore(
_navigationSession = navigationSession
val startingLocation =
- locationProvider.lastLocation
- ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null)
+ _lastLocation ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null)
val initialNavState = navigationSession.getInitialState(startingLocation)
val newState = NavigationState(tripState = initialNavState.tripState, route.geometry, false)
@@ -258,7 +259,9 @@ class FerrostarCore(
_navState.value = initialNavState
_state.value = newState
- locationProvider.addListener(this, _executor)
+ _locationJob = _scope.launch {
+ locationProvider.locationUpdates().collect { location -> onLocationUpdated(location.toUserLocation()) }
+ }
}
/**
@@ -280,8 +283,7 @@ class FerrostarCore(
_navigationSession = navigationSession
val startingLocation =
- locationProvider.lastLocation
- ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null)
+ _lastLocation ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null)
val newState = NavigationState(tripState = navState.tripState, route.geometry, false)
handleStateUpdate(navState, startingLocation)
@@ -289,7 +291,9 @@ class FerrostarCore(
_navState.value = navState
_state.value = newState
- locationProvider.addListener(this, _executor)
+ _locationJob = _scope.launch {
+ locationProvider.locationUpdates().collect { location -> onLocationUpdated(location.toUserLocation()) }
+ }
}
/**
@@ -310,8 +314,7 @@ class FerrostarCore(
_navigationSession = navigationSession
val startingLocation =
- locationProvider.lastLocation
- ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null)
+ _lastLocation ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null)
_queuedUtteranceIds.clear()
spokenInstructionObserver?.stopAndClearQueue()
@@ -342,11 +345,10 @@ class FerrostarCore(
}
}
- fun stopNavigation(stopLocationUpdates: Boolean = true) {
+ fun stopNavigation() {
foregroundServiceManager?.stopService()
- if (stopLocationUpdates) {
- locationProvider.removeListener(this)
- }
+ _locationJob?.cancel()
+ _locationJob = null
_navigationSession?.destroy()
_navigationSession = null
_state.value = NavigationState()
@@ -435,7 +437,7 @@ class FerrostarCore(
} ?: true // Default to true if no prior automatic recalculation
}
- override fun onLocationUpdated(location: UserLocation) {
+ private fun onLocationUpdated(location: UserLocation) {
_lastLocation = location
val session = _navigationSession
@@ -453,9 +455,6 @@ class FerrostarCore(
}
}
- override fun onHeadingUpdated(heading: Heading) {
- // TODO: Publish new heading to flow
- }
}
/**
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt
deleted file mode 100644
index d92bfc1a3..000000000
--- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt
+++ /dev/null
@@ -1,297 +0,0 @@
-package com.stadiamaps.ferrostar.core
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.location.LocationListener
-import android.location.LocationManager
-import android.os.Build
-import android.os.Handler
-import android.os.Looper
-import android.os.SystemClock
-import java.time.Instant
-import java.util.concurrent.Executor
-import kotlin.time.DurationUnit
-import kotlin.time.toDuration
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import uniffi.ferrostar.CourseOverGround
-import uniffi.ferrostar.GeographicCoordinate
-import uniffi.ferrostar.Heading
-import uniffi.ferrostar.LocationBias
-import uniffi.ferrostar.LocationSimulationState
-import uniffi.ferrostar.Route
-import uniffi.ferrostar.Speed
-import uniffi.ferrostar.UserLocation
-import uniffi.ferrostar.advanceLocationSimulation
-import uniffi.ferrostar.locationSimulationFromRoute
-
-interface LocationProvider {
- val lastLocation: UserLocation?
-
- val lastHeading: Heading?
-
- fun addListener(listener: LocationUpdateListener, executor: Executor)
-
- fun removeListener(listener: LocationUpdateListener)
-}
-
-interface LocationUpdateListener {
- fun onLocationUpdated(location: UserLocation)
-
- fun onHeadingUpdated(heading: Heading)
-}
-
-/**
- * A location provider that uses the Android system location services.
- *
- * NOTE: This does NOT attempt to check permissions. The caller is responsible for ensuring that
- * permissions are granted.
- */
-class AndroidSystemLocationProvider(context: Context) : LocationProvider {
- companion object {
- private const val TAG = "AndroidLocationProvider"
- }
-
- override var lastLocation: UserLocation? = null
- private set
-
- override var lastHeading: Heading? = null
- private set
-
- val locationManager: LocationManager =
- context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
- private val listeners: MutableMap = mutableMapOf()
-
- /**
- * Adds a location update listener.
- *
- * NOTE: This does NOT attempt to check permissions. The caller is responsible for ensuring that
- * permissions are enabled before calling this.
- */
- // TODO: This SuppressLint feels wrong; can't we push this "taint" up?
- @SuppressLint("MissingPermission")
- override fun addListener(listener: LocationUpdateListener, executor: Executor) {
- android.util.Log.d(TAG, "Add location listener")
- if (listeners.contains(listener)) {
- android.util.Log.d(TAG, "Already registered; skipping")
- return
- }
- val androidListener = LocationListener {
- val userLocation = it.toUserLocation()
- lastLocation = userLocation
- listener.onLocationUpdated(userLocation)
- }
- listeners[listener] = androidListener
-
- val handler = Handler(Looper.getMainLooper())
-
- executor.execute {
- handler.post {
- val last = locationManager.getLastKnownLocation(getBestProvider())
- last?.let { androidListener.onLocationChanged(last) }
- locationManager.requestLocationUpdates(getBestProvider(), 100L, 5.0f, androidListener)
- }
- }
- }
-
- override fun removeListener(listener: LocationUpdateListener) {
- android.util.Log.d(TAG, "Remove location listener")
- val androidListener = listeners.remove(listener)
-
- if (androidListener != null) {
- locationManager.removeUpdates(androidListener)
- }
- }
-
- private fun getBestProvider(): String {
- val providers = locationManager.getProviders(true).toSet()
- // Oh, how we love Android... Fused provider is brand new,
- // and we can't express this any other way than with duplicate clauses.
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- when {
- providers.contains(LocationManager.FUSED_PROVIDER) -> LocationManager.FUSED_PROVIDER
- providers.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
- providers.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
- else -> LocationManager.PASSIVE_PROVIDER
- }
- } else {
- when {
- providers.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
- providers.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
- else -> LocationManager.PASSIVE_PROVIDER
- }
- }
- }
-}
-
-/**
- * Location provider for testing without relying on simulator location spoofing.
- *
- * This allows for more granular unit tests.
- */
-class SimulatedLocationProvider : LocationProvider {
- private var simulationState: LocationSimulationState? = null
- private val scope = CoroutineScope(Dispatchers.Default)
- private var simulationJob: Job? = null
- private var listeners: MutableList> = mutableListOf()
-
- override var lastLocation: UserLocation? = null
- set(value) {
- field = value
- onLocationUpdated()
- }
-
- override var lastHeading: Heading? = null
- set(value) {
- field = value
- onHeadingUpdated()
- }
-
- /** A factor by which simulated route playback speed is multiplied. */
- var warpFactor: UInt = 1u
-
- override fun addListener(listener: LocationUpdateListener, executor: Executor) {
- listeners.add(listener to executor)
-
- if (simulationJob?.isActive != true) {
- simulationJob = scope.launch { startSimulation() }
- }
- }
-
- override fun removeListener(listener: LocationUpdateListener) {
- listeners.removeIf { it.first == listener }
-
- if (listeners.isEmpty()) {
- simulationJob?.cancel()
- }
- }
-
- fun setSimulatedRoute(route: Route, bias: LocationBias = LocationBias.None) {
- simulationState = locationSimulationFromRoute(route, resampleDistance = 10.0, bias)
- lastLocation = simulationState?.currentLocation
-
- if (listeners.isNotEmpty() && simulationJob?.isActive != true) {
- simulationJob = scope.launch { startSimulation() }
- }
- }
-
- private suspend fun startSimulation() {
- var pendingCompletion = false
-
- while (true) {
- delay((1.0 / warpFactor.toFloat()).toDuration(DurationUnit.SECONDS))
- val initialState = simulationState ?: return
- val updatedState = advanceLocationSimulation(initialState)
-
- // Stop if the route has been fully simulated (no state change).
- if (updatedState == initialState) {
- if (pendingCompletion) {
- return
- } else {
- pendingCompletion = true
- }
- }
-
- simulationState = updatedState
- lastLocation = updatedState.currentLocation
- }
- }
-
- private fun onLocationUpdated() {
- val location = lastLocation
- if (location != null) {
- for ((listener, executor) in listeners) {
- executor.execute { listener.onLocationUpdated(location) }
- }
- }
- }
-
- private fun onHeadingUpdated() {
- val heading = lastHeading
- if (heading != null) {
- for ((listener, executor) in listeners) {
- executor.execute { listener.onHeadingUpdated(heading) }
- }
- }
- }
-}
-
-fun UserLocation.toAndroidLocation(): android.location.Location {
- val location = android.location.Location("FerrostarCore")
-
- location.latitude = this.coordinates.lat
- location.longitude = this.coordinates.lng
- location.accuracy = this.horizontalAccuracy.toFloat()
-
- // NOTE: We have a lot of checks in place which we could remove (+ improve correctness)
- // if we supported API 26.
- val course = this.courseOverGround
- if (course != null) {
- location.bearing = course.degrees.toFloat()
-
- val accuracy = course.accuracy
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && accuracy != null) {
- // NOTE: Course accuracy information is not available until API 26
- location.bearingAccuracyDegrees = accuracy.toFloat()
- }
- }
-
- location.time = this.timestamp.toEpochMilli()
-
- // FIXME: This is not entirely correct, but might be an acceptable approximation.
- // Feedback welcome as the purpose is not really documented.
- location.elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
-
- return location
-}
-
-fun android.location.Location.toUserLocation(): UserLocation {
- return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- UserLocation(
- GeographicCoordinate(latitude, longitude),
- if (hasAccuracy()) {
- accuracy.toDouble()
- } else {
- Double.MAX_VALUE
- },
- if (hasBearing()) {
- CourseOverGround(
- bearing.toUInt().toUShort(),
- if (hasBearingAccuracy()) {
- bearingAccuracyDegrees.toUInt().toUShort()
- } else {
- null
- })
- } else {
- null
- },
- Instant.ofEpochMilli(time),
- if (hasSpeed() && hasSpeedAccuracy()) {
- Speed(speed.toDouble(), speedAccuracyMetersPerSecond.toDouble())
- } else {
- null
- })
- } else {
- UserLocation(
- GeographicCoordinate(latitude, longitude),
- if (hasAccuracy()) {
- accuracy.toDouble()
- } else {
- Double.MAX_VALUE
- },
- if (hasBearing()) {
- CourseOverGround(bearing.toUInt().toUShort(), null)
- } else {
- null
- },
- Instant.ofEpochMilli(time),
- if (hasSpeed()) {
- Speed(speed.toDouble(), null)
- } else {
- null
- })
- }
-}
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt
index e8b0ef857..315dcd483 100644
--- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt
+++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt
@@ -12,6 +12,7 @@ import com.stadiamaps.ferrostar.core.extensions.deviation
import com.stadiamaps.ferrostar.core.extensions.preferredUserLocation
import com.stadiamaps.ferrostar.core.extensions.progress
import com.stadiamaps.ferrostar.core.extensions.remainingSteps
+import com.stadiamaps.ferrostar.core.extensions.spokenInstruction
import com.stadiamaps.ferrostar.core.extensions.visualInstruction
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -24,6 +25,7 @@ import uniffi.ferrostar.RouteDeviation
import uniffi.ferrostar.RouteStep
import uniffi.ferrostar.SpokenInstruction
import uniffi.ferrostar.TripProgress
+import uniffi.ferrostar.TripState
import uniffi.ferrostar.UserLocation
import uniffi.ferrostar.VisualInstruction
@@ -37,6 +39,10 @@ data class NavigationUiState(
* in the `location` and `snappedLocation` properties.
*/
val heading: Float?,
+ /** The destination for the current trip. Useful for Car-App implementations. */
+ val destination: String?,
+ /** The core trip state from ferrostar */
+ val tripState: TripState?,
/** The geometry of the full route. */
val routeGeometry: List?,
/** Visual instructions which should be displayed based on the user's current progress. */
@@ -67,6 +73,25 @@ data class NavigationUiState(
val currentAnnotation: AnnotationWrapper<*>?
) {
companion object {
+ fun empty(): NavigationUiState =
+ NavigationUiState(
+ location = null,
+ heading = null,
+ destination = null,
+ tripState = null,
+ routeGeometry = null,
+ visualInstruction = null,
+ spokenInstruction = null,
+ progress = null,
+ isCalculatingNewRoute = null,
+ routeDeviation =null,
+ isMuted = null,
+ currentStepRoadName = null,
+ currentStepGeometryIndex = null,
+ remainingSteps =null,
+ currentAnnotation = null
+ )
+
fun fromFerrostar(
coreState: NavigationState,
isMuted: Boolean?,
@@ -76,9 +101,11 @@ data class NavigationUiState(
location = coreState.tripState.preferredUserLocation(),
// TODO: Heading/course over ground
heading = null,
+ destination = null,
+ tripState = coreState.tripState,
routeGeometry = coreState.routeGeometry,
visualInstruction = coreState.tripState.visualInstruction(),
- spokenInstruction = null,
+ spokenInstruction = coreState.tripState.spokenInstruction(),
progress = coreState.tripState.progress(),
isCalculatingNewRoute = coreState.isCalculatingNewRoute,
routeDeviation = coreState.tripState.deviation(),
@@ -86,7 +113,8 @@ data class NavigationUiState(
currentStepRoadName = coreState.tripState.currentRoadName(),
currentStepGeometryIndex = coreState.tripState.currentStepGeometryIndex(),
remainingSteps = coreState.tripState.remainingSteps(),
- currentAnnotation = annotation)
+ currentAnnotation = annotation,
+ )
}
fun isNavigating(): Boolean = progress != null
@@ -95,12 +123,11 @@ data class NavigationUiState(
interface NavigationViewModel {
val navigationUiState: StateFlow
- fun toggleMute()
+ fun setDestination(destination: String?)
- fun stopNavigation(stopLocationUpdates: Boolean = true)
+ fun toggleMute()
- // TODO: We think the camera may eventually need to be owned by the view model, but that's going
- // to be a very big refactor (maybe even crossing into the MapLibre Compose project)
+ fun stopNavigation()
}
/**
@@ -116,6 +143,8 @@ open class DefaultNavigationViewModel(
private val annotationPublisher: AnnotationPublisher<*> = NoOpAnnotationPublisher()
) : ViewModel(), NavigationViewModel {
+ private val destination = MutableStateFlow(null)
+
private val muteState: StateFlow =
ferrostarCore.spokenInstructionObserver?.muteState ?: MutableStateFlow(null)
@@ -130,6 +159,9 @@ open class DefaultNavigationViewModel(
// This awkward dance is required because Kotlin doesn't have a way to map over
// StateFlows without converting to a generic Flow in the process.
}
+ .combine(destination) { uiState, destination ->
+ uiState.copy(destination = destination)
+ }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
@@ -139,8 +171,12 @@ open class DefaultNavigationViewModel(
ferrostarCore.spokenInstructionObserver?.isMuted,
null))
- override fun stopNavigation(stopLocationUpdates: Boolean) {
- ferrostarCore.stopNavigation(stopLocationUpdates = stopLocationUpdates)
+ override fun setDestination(destination: String?) {
+ this.destination.value = destination
+ }
+
+ override fun stopNavigation() {
+ ferrostarCore.stopNavigation()
}
override fun toggleMute() {
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/extensions/TripStateExtensions.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/extensions/TripStateExtensions.kt
index 8fd0ff7b8..b6a7e4946 100644
--- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/extensions/TripStateExtensions.kt
+++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/extensions/TripStateExtensions.kt
@@ -1,6 +1,7 @@
package com.stadiamaps.ferrostar.core.extensions
import uniffi.ferrostar.RouteDeviation
+import uniffi.ferrostar.SpokenInstruction
import uniffi.ferrostar.TripState
/**
@@ -31,6 +32,19 @@ fun TripState.visualInstruction() =
null
}
+/**
+ * Get the spoken instruction for the current step.
+ *
+ * @return The spoken instruction that should be announced, or null if not navigating or no
+ * instruction is currently triggered.
+ */
+fun TripState.spokenInstruction(): SpokenInstruction? =
+ when (this) {
+ is TripState.Navigating -> this.spokenInstruction
+ is TripState.Complete,
+ is TripState.Idle -> null
+ }
+
/**
* Get the deviation handler from the trip.
*
@@ -87,6 +101,11 @@ fun TripState.remainingSteps() =
is TripState.Idle -> null
}
+/**
+ * The current step that's being displayed to the user.
+ */
+fun TripState.currentStep() = remainingSteps()?.first()
+
/**
* Get the remaining waypoints (starting at the *next* waypoint "goal") in the current trip.
*
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProvider.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProvider.kt
new file mode 100644
index 000000000..db59db698
--- /dev/null
+++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProvider.kt
@@ -0,0 +1,69 @@
+package com.stadiamaps.ferrostar.core.location
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Build
+import android.os.Looper
+import android.util.Log
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+
+/**
+ * A location provider that uses the Android system location services (no Google Play Services
+ * dependency).
+ *
+ * NOTE: This does NOT attempt to check permissions. The caller is responsible for ensuring that
+ * location permissions are granted before use.
+ */
+class AndroidLocationProvider(context: Context) : NavigationLocationProviding {
+
+ private val locationManager =
+ context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+
+ @SuppressLint("MissingPermission")
+ override suspend fun lastLocation(): Location? =
+ locationManager.getLastKnownLocation(getBestProvider())
+
+ @SuppressLint("MissingPermission")
+ override fun locationUpdates(intervalMillis: Long): Flow = callbackFlow {
+ val provider = getBestProvider()
+ val listener = LocationListener { location -> trySend(location) }
+
+ // Emit last known location immediately so the first update isn't delayed by the interval.
+ locationManager.getLastKnownLocation(provider)?.let { trySend(it) }
+
+ Log.d(TAG, "Requesting location updates from provider: $provider")
+ locationManager.requestLocationUpdates(provider, intervalMillis, 0f, listener, Looper.getMainLooper())
+
+ awaitClose {
+ Log.d(TAG, "Removing location updates from provider: $provider")
+ locationManager.removeUpdates(listener)
+ }
+ }
+
+ private fun getBestProvider(): String {
+ val providers = locationManager.getProviders(true).toSet()
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ when {
+ providers.contains(LocationManager.FUSED_PROVIDER) -> LocationManager.FUSED_PROVIDER
+ providers.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
+ providers.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
+ else -> LocationManager.PASSIVE_PROVIDER
+ }
+ } else {
+ when {
+ providers.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER
+ providers.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER
+ else -> LocationManager.PASSIVE_PROVIDER
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "AndroidLocationProvider"
+ }
+}
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProvider.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProvider.kt
new file mode 100644
index 000000000..ba9eba703
--- /dev/null
+++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProvider.kt
@@ -0,0 +1,47 @@
+package com.stadiamaps.ferrostar.core.location
+
+import android.location.Location
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import uniffi.ferrostar.LocationBias
+import uniffi.ferrostar.Route
+
+class NavigationLocationProvider(
+ private val liveProviding: NavigationLocationProviding,
+ private val simulatedProvider: SimulatedLocationProvider
+): NavigationLocationProviding {
+ private val _isSimulating = MutableStateFlow(false)
+ val isSimulating: StateFlow
+ get() = _isSimulating.asStateFlow()
+
+ fun enableSimulationOn(route: Route, bias: LocationBias = LocationBias.None) {
+ simulatedProvider.setRoute(route, bias)
+ _isSimulating.value = true
+ }
+
+ fun disableSimulation() {
+ _isSimulating.value = false
+ }
+
+ override suspend fun lastLocation(): Location? =
+ if (isSimulating.value) {
+ simulatedProvider.lastLocation()
+ } else {
+ liveProviding.lastLocation()
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ override fun locationUpdates(intervalMillis: Long): Flow =
+ isSimulating
+ .flatMapLatest { isSimulating ->
+ if (isSimulating) {
+ simulatedProvider.locationUpdates(intervalMillis)
+ } else {
+ liveProviding.locationUpdates(intervalMillis)
+ }
+ }
+}
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProviding.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProviding.kt
new file mode 100644
index 000000000..5888223d7
--- /dev/null
+++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProviding.kt
@@ -0,0 +1,11 @@
+package com.stadiamaps.ferrostar.core.location
+
+import android.location.Location
+import kotlinx.coroutines.flow.Flow
+
+interface NavigationLocationProviding {
+ suspend fun lastLocation(): Location?
+
+ fun locationUpdates(intervalMillis: Long = 1000): Flow
+}
+
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProvider.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProvider.kt
new file mode 100644
index 000000000..cb43af66d
--- /dev/null
+++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProvider.kt
@@ -0,0 +1,78 @@
+package com.stadiamaps.ferrostar.core.location
+
+import android.location.Location
+import kotlin.time.DurationUnit
+import kotlin.time.toDuration
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.shareIn
+import uniffi.ferrostar.LocationBias
+import uniffi.ferrostar.LocationSimulationState
+import uniffi.ferrostar.Route
+import uniffi.ferrostar.advanceLocationSimulation
+import uniffi.ferrostar.locationSimulationFromRoute
+
+class SimulatedLocationProvider(
+ scope: CoroutineScope = CoroutineScope(Dispatchers.Default),
+ /** A factor by which simulated route playback speed is multiplied. */
+ var warpFactor: UInt = 1u,
+ initialLocation: Location? = null
+) : NavigationLocationProviding {
+
+ // Emitting a new value here restarts the simulation from the beginning of the new route.
+ private val _routeFlow = MutableStateFlow(null)
+
+ // Tracks current position within the active simulation run, seeded with initialLocation
+ // so lastLocation() returns a sensible value before any route has been simulated.
+ private var _lastLocation: Location? = initialLocation
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ private val sharedUpdates: Flow =
+ _routeFlow
+ .flatMapLatest { initialState ->
+ // Capture into a local val so the non-null smart cast carries into the nested
+ // flow lambda — parameter smart casts don't cross lambda boundaries.
+ val startState: LocationSimulationState = initialState ?: return@flatMapLatest emptyFlow()
+ flow {
+ var state = startState
+ var pendingCompletion = false
+
+ while (true) {
+ delay((1.0 / warpFactor.toFloat()).toDuration(DurationUnit.SECONDS))
+ val updatedState = advanceLocationSimulation(state)
+
+ // Stop if the route has been fully simulated (no state change).
+ if (updatedState == state) {
+ if (pendingCompletion) {
+ return@flow
+ } else {
+ pendingCompletion = true
+ }
+ }
+
+ state = updatedState
+ val loc = updatedState.currentLocation.toAndroidLocation()
+ _lastLocation = loc
+ emit(loc)
+ }
+ }
+ }
+ .shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
+
+ fun setRoute(route: Route, bias: LocationBias = LocationBias.None) {
+ _routeFlow.value = locationSimulationFromRoute(route, resampleDistance = 10.0, bias)
+ }
+
+ override suspend fun lastLocation(): Location? =
+ _lastLocation ?: _routeFlow.value?.currentLocation?.toAndroidLocation()
+
+ override fun locationUpdates(intervalMillis: Long): Flow = sharedUpdates
+}
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/UserLocation.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/UserLocation.kt
new file mode 100644
index 000000000..e64a2326c
--- /dev/null
+++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/UserLocation.kt
@@ -0,0 +1,87 @@
+package com.stadiamaps.ferrostar.core.location
+
+import android.location.Location
+import android.os.Build
+import android.os.SystemClock
+import java.time.Instant
+import uniffi.ferrostar.CourseOverGround
+import uniffi.ferrostar.GeographicCoordinate
+import uniffi.ferrostar.Speed
+import uniffi.ferrostar.UserLocation
+
+fun Location.toUserLocation(): UserLocation {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ UserLocation(
+ GeographicCoordinate(latitude, longitude),
+ if (hasAccuracy()) {
+ accuracy.toDouble()
+ } else {
+ Double.MAX_VALUE
+ },
+ if (hasBearing()) {
+ CourseOverGround(
+ bearing.toUInt().toUShort(),
+ if (hasBearingAccuracy()) {
+ bearingAccuracyDegrees.toUInt().toUShort()
+ } else {
+ null
+ })
+ } else {
+ null
+ },
+ Instant.ofEpochMilli(time),
+ if (hasSpeed() && hasSpeedAccuracy()) {
+ Speed(speed.toDouble(), speedAccuracyMetersPerSecond.toDouble())
+ } else {
+ null
+ })
+ } else {
+ UserLocation(
+ GeographicCoordinate(latitude, longitude),
+ if (hasAccuracy()) {
+ accuracy.toDouble()
+ } else {
+ Double.MAX_VALUE
+ },
+ if (hasBearing()) {
+ CourseOverGround(bearing.toUInt().toUShort(), null)
+ } else {
+ null
+ },
+ Instant.ofEpochMilli(time),
+ if (hasSpeed()) {
+ Speed(speed.toDouble(), null)
+ } else {
+ null
+ })
+ }
+}
+
+fun UserLocation.toAndroidLocation(): Location {
+ val location = Location("FerrostarCore")
+
+ location.latitude = this.coordinates.lat
+ location.longitude = this.coordinates.lng
+ location.accuracy = this.horizontalAccuracy.toFloat()
+
+ // NOTE: We have a lot of checks in place which we could remove (+ improve correctness)
+ // if we supported API 26.
+ val course = this.courseOverGround
+ if (course != null) {
+ location.bearing = course.degrees.toFloat()
+
+ val accuracy = course.accuracy
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && accuracy != null) {
+ // NOTE: Course accuracy information is not available until API 26
+ location.bearingAccuracyDegrees = accuracy.toFloat()
+ }
+ }
+
+ location.time = this.timestamp.toEpochMilli()
+
+ // FIXME: This is not entirely correct, but might be an acceptable approximation.
+ // Feedback welcome as the purpose is not really documented.
+ location.elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
+
+ return location
+}
diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt
index 94765a9fd..88a081fb4 100644
--- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt
+++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt
@@ -91,7 +91,9 @@ fun NavigationUiState.Companion.pedestrianExample(): NavigationUiState =
class MockNavigationViewModel(override val navigationUiState: StateFlow) :
ViewModel(), NavigationViewModel {
+ override fun setDestination(destination: String?) {}
+
override fun toggleMute() {}
- override fun stopNavigation(stopLocationUpdates: Boolean) {}
+ override fun stopNavigation() {}
}
diff --git a/android/core/src/test/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt b/android/core/src/test/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt
deleted file mode 100644
index 929c4669c..000000000
--- a/android/core/src/test/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package com.stadiamaps.ferrostar.core
-
-import java.time.Instant
-import java.util.concurrent.CountDownLatch
-import java.util.concurrent.Executors
-import java.util.concurrent.TimeUnit
-import org.junit.Assert.*
-import org.junit.Test
-import uniffi.ferrostar.GeographicCoordinate
-import uniffi.ferrostar.Heading
-import uniffi.ferrostar.UserLocation
-
-class SimulatedLocationProviderTest {
- @Test
- fun `initial values are null`() {
- val locationProvider = SimulatedLocationProvider()
-
- assertNull(locationProvider.lastLocation)
- assertNull(locationProvider.lastHeading)
- }
-
- @Test
- fun `set location`() {
- val locationProvider = SimulatedLocationProvider()
- val location = UserLocation(GeographicCoordinate(42.02, 24.0), 12.0, null, Instant.now(), null)
-
- locationProvider.lastLocation = location
-
- assertEquals(locationProvider.lastLocation, location)
- }
-
- @Test
- fun `test listener events`() {
- val latch = CountDownLatch(1)
- val locationProvider = SimulatedLocationProvider()
- val location = UserLocation(GeographicCoordinate(42.02, 24.0), 12.0, null, Instant.now(), null)
-
- val listener =
- object : LocationUpdateListener {
- override fun onLocationUpdated(location: UserLocation) {
- assertEquals(location, location)
-
- latch.countDown()
- }
-
- override fun onHeadingUpdated(heading: Heading) {
- fail("Unexpected heading update")
- }
- }
-
- locationProvider.addListener(listener, Executors.newSingleThreadExecutor())
-
- locationProvider.lastLocation = location
-
- assertEquals(locationProvider.lastLocation, location)
- latch.await(1, TimeUnit.SECONDS)
- }
-}
diff --git a/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProviderTest.kt b/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProviderTest.kt
new file mode 100644
index 000000000..61234b70c
--- /dev/null
+++ b/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProviderTest.kt
@@ -0,0 +1,156 @@
+package com.stadiamaps.ferrostar.core.location
+
+import android.content.Context
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Looper
+import android.util.Log
+import app.cash.turbine.test
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Before
+import org.junit.Test
+
+class AndroidLocationProviderTest {
+ private val mockContext = mockk()
+ private val mockLocationManager = mockk()
+ private val mockLocation = mockk()
+
+ @Before
+ fun setup() {
+ mockkStatic(Log::class)
+ every { Log.d(any(), any()) } returns 0
+
+ mockkStatic(Looper::class)
+ every { Looper.getMainLooper() } returns mockk(relaxed = true)
+
+ every { mockContext.getSystemService(Context.LOCATION_SERVICE) } returns mockLocationManager
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `lastLocation returns null when no last known location exists`() = runTest {
+ every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER)
+ every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns null
+
+ val provider = AndroidLocationProvider(mockContext)
+ assertNull(provider.lastLocation())
+ }
+
+ @Test
+ fun `lastLocation returns location from location manager`() = runTest {
+ every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER)
+ every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns
+ mockLocation
+
+ val provider = AndroidLocationProvider(mockContext)
+ assertSame(mockLocation, provider.lastLocation())
+ }
+
+ @Test
+ fun `getBestProvider prefers GPS over network`() = runTest {
+ val networkLocation = mockk()
+ every { mockLocationManager.getProviders(true) } returns
+ listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
+ every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns
+ mockLocation
+ every { mockLocationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) } returns
+ networkLocation
+
+ val provider = AndroidLocationProvider(mockContext)
+ assertSame(mockLocation, provider.lastLocation())
+ }
+
+ @Test
+ fun `getBestProvider falls back to network when GPS unavailable`() = runTest {
+ val networkLocation = mockk()
+ every { mockLocationManager.getProviders(true) } returns
+ listOf(LocationManager.NETWORK_PROVIDER)
+ every { mockLocationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) } returns
+ networkLocation
+
+ val provider = AndroidLocationProvider(mockContext)
+ assertSame(networkLocation, provider.lastLocation())
+ }
+
+ @Test
+ fun `getBestProvider falls back to passive when no other provider is available`() = runTest {
+ every { mockLocationManager.getProviders(true) } returns emptyList()
+ every { mockLocationManager.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER) } returns
+ null
+
+ val provider = AndroidLocationProvider(mockContext)
+ assertNull(provider.lastLocation())
+ }
+
+ @Test
+ fun `locationUpdates emits last known location immediately on subscribe`() = runTest {
+ val listenerSlot = slot()
+ every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER)
+ every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns
+ mockLocation
+ every {
+ mockLocationManager.requestLocationUpdates(
+ any(), any(), any(), capture(listenerSlot), any())
+ } just Runs
+ every { mockLocationManager.removeUpdates(any()) } just Runs
+
+ val provider = AndroidLocationProvider(mockContext)
+ provider.locationUpdates().test {
+ assertSame(mockLocation, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `locationUpdates emits when the location listener fires`() = runTest {
+ val listenerSlot = slot()
+ val newLocation = mockk()
+ every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER)
+ every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns null
+ every {
+ mockLocationManager.requestLocationUpdates(
+ any(), any(), any(), capture(listenerSlot), any())
+ } just Runs
+ every { mockLocationManager.removeUpdates(any()) } just Runs
+
+ val provider = AndroidLocationProvider(mockContext)
+ provider.locationUpdates().test {
+ listenerSlot.captured.onLocationChanged(newLocation)
+ assertSame(newLocation, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `locationUpdates unregisters listener when flow is cancelled`() = runTest {
+ val listenerSlot = slot()
+ every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER)
+ every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns null
+ every {
+ mockLocationManager.requestLocationUpdates(
+ any(), any(), any(), capture(listenerSlot), any())
+ } just Runs
+ every { mockLocationManager.removeUpdates(any()) } just Runs
+
+ val provider = AndroidLocationProvider(mockContext)
+ provider.locationUpdates().test { cancelAndIgnoreRemainingEvents() }
+
+ verify { mockLocationManager.removeUpdates(any()) }
+ }
+}
diff --git a/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProviderTest.kt b/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProviderTest.kt
new file mode 100644
index 000000000..445cb2903
--- /dev/null
+++ b/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProviderTest.kt
@@ -0,0 +1,23 @@
+package com.stadiamaps.ferrostar.core.location
+
+import android.location.Location
+import io.mockk.mockk
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+class SimulatedLocationProviderTest {
+ @Test
+ fun `lastLocation is null when no route or initialLocation is set`() = runTest {
+ val provider = SimulatedLocationProvider()
+ assertNull(provider.lastLocation())
+ }
+
+ @Test
+ fun `lastLocation returns initialLocation before any route is set`() = runTest {
+ val location = mockk(relaxed = true)
+ val provider = SimulatedLocationProvider(initialLocation = location)
+ assertSame(location, provider.lastLocation())
+ }
+}
diff --git a/android/demo-app/build.gradle b/android/demo-app/build.gradle
index 25cf3a921..2ddf3a603 100644
--- a/android/demo-app/build.gradle
+++ b/android/demo-app/build.gradle
@@ -10,7 +10,8 @@ android {
defaultConfig {
applicationId "com.stadiamaps.ferrostar.demo"
- minSdk 26
+ // Most of Ferrostar can be used with minSdk 25. Car App requires 29
+ minSdk 29
targetSdk 36
versionCode 1
versionName "1.0"
@@ -63,12 +64,17 @@ dependencies {
implementation libs.androidx.compose.ui.tooling
implementation libs.androidx.compose.material3
+ implementation libs.androidx.car.app
+
implementation project(':core')
+ implementation project(':car-app')
+ implementation project(':google-play-services')
implementation project(':ui-compose')
implementation project(':ui-maplibre')
- implementation project(':google-play-services')
+ implementation project(':ui-maplibre-car-app')
implementation libs.maplibre.compose
+ implementation libs.maplibre.compose.car.app
implementation platform(libs.okhttp.bom)
implementation libs.okhttp.core
@@ -82,4 +88,4 @@ dependencies {
androidTestImplementation libs.androidx.test.espresso
androidTestImplementation libs.androidx.compose.ui.test.junit4
debugImplementation libs.androidx.compose.ui.test.manifest
-}
\ No newline at end of file
+}
diff --git a/android/demo-app/src/main/AndroidManifest.xml b/android/demo-app/src/main/AndroidManifest.xml
index dcdc85d76..1bc81e14f 100644
--- a/android/demo-app/src/main/AndroidManifest.xml
+++ b/android/demo-app/src/main/AndroidManifest.xml
@@ -10,6 +10,11 @@
+
+
+
+
+
+
+
+
+ android:foregroundServiceType="location" />
+
+
+
+
+
+
+
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AppModule.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AppModule.kt
index 8534b4928..94582e3a7 100644
--- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AppModule.kt
+++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AppModule.kt
@@ -7,22 +7,21 @@ import com.stadiamaps.ferrostar.core.AlternativeRouteProcessor
import com.stadiamaps.ferrostar.core.AndroidTtsObserver
import com.stadiamaps.ferrostar.core.CorrectiveAction
import com.stadiamaps.ferrostar.core.FerrostarCore
-import com.stadiamaps.ferrostar.core.LocationProvider
import com.stadiamaps.ferrostar.core.RouteDeviationHandler
-import com.stadiamaps.ferrostar.core.SimulatedLocationProvider
import com.stadiamaps.ferrostar.core.http.HttpClientProvider
import com.stadiamaps.ferrostar.core.http.OkHttpClientProvider.Companion.toOkHttpClientProvider
+import com.stadiamaps.ferrostar.core.location.NavigationLocationProvider
+import com.stadiamaps.ferrostar.core.location.SimulatedLocationProvider
+import com.stadiamaps.ferrostar.core.location.toAndroidLocation
import com.stadiamaps.ferrostar.core.service.FerrostarForegroundServiceManager
import com.stadiamaps.ferrostar.core.service.ForegroundServiceManager
import com.stadiamaps.ferrostar.core.withJsonOptions
-import com.stadiamaps.ferrostar.googleplayservices.FusedLocationProvider
+import com.stadiamaps.ferrostar.googleplayservices.FusedNavigationLocationProvider
+import com.stadiamaps.ferrostar.support.initialSimulatedLocation
import java.time.Duration
-import java.time.Instant
import okhttp3.OkHttpClient
-import uniffi.ferrostar.GeographicCoordinate
import uniffi.ferrostar.GraphHopperVoiceUnits
import uniffi.ferrostar.NavigationControllerConfig
-import uniffi.ferrostar.UserLocation
import uniffi.ferrostar.WellKnownRouteProvider
/**
@@ -77,18 +76,14 @@ object AppModule {
appContext = context
}
- // TODO: Make this configurable in the UI.
- val simulation = false
- val locationProvider: LocationProvider by lazy {
- if (simulation) {
- SimulatedLocationProvider().apply {
- warpFactor = 2u
- lastLocation =
- UserLocation(GeographicCoordinate(51.049315, 13.73552), 1.0, null, Instant.now(), null)
- }
- } else {
- FusedLocationProvider(appContext)
- }
+ val locationProvider: NavigationLocationProvider by lazy {
+ NavigationLocationProvider(
+ liveProviding = FusedNavigationLocationProvider(appContext),
+ simulatedProvider = SimulatedLocationProvider(
+ warpFactor = 2u,
+ initialLocation = initialSimulatedLocation.toAndroidLocation()
+ )
+ )
}
private val httpClient: HttpClientProvider by lazy {
OkHttpClient.Builder().callTimeout(Duration.ofSeconds(15)).build().toOkHttpClientProvider()
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AutocompleteOverlay.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AutocompleteOverlay.kt
deleted file mode 100644
index bcd4dce4b..000000000
--- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AutocompleteOverlay.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.stadiamaps.ferrostar
-
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import com.stadiamaps.autocomplete.AutocompleteSearch
-import com.stadiamaps.autocomplete.center
-import com.stadiamaps.ferrostar.composeui.views.components.gridviews.InnerGridView
-import com.stadiamaps.ferrostar.core.LocationProvider
-import com.stadiamaps.ferrostar.core.SimulatedLocationProvider
-import com.stadiamaps.ferrostar.core.toAndroidLocation
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import uniffi.ferrostar.GeographicCoordinate
-import uniffi.ferrostar.UserLocation
-import uniffi.ferrostar.Waypoint
-import uniffi.ferrostar.WaypointKind
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun AutocompleteOverlay(
- modifier: Modifier = Modifier,
- scope: CoroutineScope,
- isNavigating: Boolean,
- locationProvider: LocationProvider,
- loc: UserLocation
-) {
- if (!isNavigating) {
- InnerGridView(
- modifier = modifier.fillMaxSize().padding(bottom = 16.dp, top = 16.dp),
- topCenter = {
- AppModule.stadiaApiKey?.let { apiKey ->
- AutocompleteSearch(apiKey = apiKey, userLocation = loc.toAndroidLocation()) { feature ->
- feature.center()?.let { center ->
- // Fetch a route in the background
- scope.launch(Dispatchers.IO) {
- // TODO: Fail gracefully
- val routes =
- AppModule.ferrostarCore.getRoutes(
- loc,
- listOf(
- Waypoint(
- coordinate =
- GeographicCoordinate(center.latitude, center.longitude),
- kind = WaypointKind.BREAK),
- ))
-
- val route = routes.first()
- AppModule.ferrostarCore.startNavigation(route = route)
-
- if (locationProvider is SimulatedLocationProvider) {
- locationProvider.setSimulatedRoute(route)
- }
- }
- }
- }
- }
- })
- }
-}
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt
index a60c08408..543e50a6c 100644
--- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt
+++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt
@@ -3,23 +3,17 @@ package com.stadiamaps.ferrostar
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
-import android.os.Bundle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.maplibre.compose.camera.MapViewCamera
import com.maplibre.compose.rememberSaveableMapViewCamera
import com.maplibre.compose.symbols.Circle
-import com.stadiamaps.autocomplete.center
import com.stadiamaps.ferrostar.composeui.config.NavigationViewComponentBuilder
import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig
import com.stadiamaps.ferrostar.composeui.config.withCustomOverlayView
@@ -32,14 +26,12 @@ import org.maplibre.android.geometry.LatLng
@Composable
fun DemoNavigationScene(
- savedInstanceState: Bundle?,
viewModel: DemoNavigationViewModel = AppModule.viewModel
) {
// Keeps the screen on at consistent brightness while this Composable is in the view hierarchy.
KeepScreenOnDisposableEffect()
val context = LocalContext.current
- val scope = rememberCoroutineScope()
// Get location permissions.
// NOTE: This is NOT a robust suggestion for how to get permissions in a production app.
@@ -56,15 +48,12 @@ fun DemoNavigationScene(
Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
}
- val navigationUiState by viewModel.navigationUiState.collectAsState(scope.coroutineContext)
- val location by viewModel.location.collectAsState()
-
val permissionsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
permissions ->
when {
permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) -> {
- viewModel.startLocationUpdates()
+ viewModel.setLocationPermissions(true)
}
permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> {
// TODO: Probably alert the user that this is unusable for navigation
@@ -76,11 +65,10 @@ fun DemoNavigationScene(
}
}
- // FIXME: This is restarting navigation every time the screen is rotated.
- LaunchedEffect(savedInstanceState) {
+ LaunchedEffect(Unit) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED) {
- viewModel.startLocationUpdates()
+ viewModel.setLocationPermissions(true)
} else {
permissionsLauncher.launch(allPermissions)
}
@@ -88,6 +76,7 @@ fun DemoNavigationScene(
// Set up the map!
val camera = rememberSaveableMapViewCamera(MapViewCamera.TrackingUserLocation())
+
DynamicallyOrientingNavigationView(
modifier = Modifier.fillMaxSize(),
styleUrl = AppModule.mapStyleUrl,
@@ -99,16 +88,11 @@ fun DemoNavigationScene(
NavigationViewComponentBuilder.Default()
.withCustomOverlayView(
customOverlayView = { modifier ->
- location?.let { loc ->
- AutocompleteOverlay(
- modifier = modifier,
- scope = scope,
- isNavigating = navigationUiState.isNavigating(),
- locationProvider = viewModel.locationProvider,
- loc = loc)
- }
- }),
- onTapExit = { viewModel.stopNavigation() }) { uiState ->
+ NotNavigatingOverlay(modifier, viewModel)
+ },
+ ),
+ onTapExit = { viewModel.stopNavigation() },
+ ) { uiState ->
// Trivial, if silly example of how to add your own overlay layers.
// (Also incidentally highlights the lag inherent in MapLibre location tracking
// as-is.)
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt
index e1a36f52d..c394df2a4 100644
--- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt
+++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt
@@ -1,44 +1,56 @@
package com.stadiamaps.ferrostar
+import android.location.Location
import android.util.Log
+import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewModelScope
+import com.maplibre.compose.camera.CameraState
+import com.maplibre.compose.camera.MapViewCamera
+import com.maplibre.compose.camera.extensions.incrementZoom
+import com.maplibre.compose.camera.models.CameraPadding
import com.stadiamaps.ferrostar.core.DefaultNavigationViewModel
import com.stadiamaps.ferrostar.core.FerrostarCore
-import com.stadiamaps.ferrostar.core.LocationProvider
-import com.stadiamaps.ferrostar.core.LocationUpdateListener
import com.stadiamaps.ferrostar.core.NavigationUiState
import com.stadiamaps.ferrostar.core.annotation.AnnotationPublisher
import com.stadiamaps.ferrostar.core.annotation.valhalla.valhallaExtendedOSRMAnnotationPublisher
-import java.util.concurrent.Executors
+import com.stadiamaps.ferrostar.core.boundingBox
+import com.stadiamaps.ferrostar.core.location.NavigationLocationProvider
+import com.stadiamaps.ferrostar.core.location.toUserLocation
+import com.stadiamaps.ferrostar.support.initialSimulatedLocation
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.flow.update
-import uniffi.ferrostar.Heading
+import kotlinx.coroutines.launch
+import org.maplibre.android.geometry.LatLngBounds
+import uniffi.ferrostar.GeographicCoordinate
import uniffi.ferrostar.UserLocation
+import uniffi.ferrostar.Waypoint
+import uniffi.ferrostar.WaypointKind
+@OptIn(ExperimentalCoroutinesApi::class)
class DemoNavigationViewModel(
// This is a simple example, but these would typically be dependency injected
val ferrostarCore: FerrostarCore = AppModule.ferrostarCore,
- val locationProvider: LocationProvider = AppModule.locationProvider,
+ val locationProvider: NavigationLocationProvider = AppModule.locationProvider,
annotationPublisher: AnnotationPublisher<*> = valhallaExtendedOSRMAnnotationPublisher()
-) : DefaultNavigationViewModel(ferrostarCore, annotationPublisher), LocationUpdateListener {
- private val locationStateFlow = MutableStateFlow(null)
- val location = locationStateFlow.asStateFlow()
- private val executor = Executors.newSingleThreadScheduledExecutor()
+) : DefaultNavigationViewModel(ferrostarCore, annotationPublisher) {
- fun startLocationUpdates() {
- locationStateFlow.update { locationProvider.lastLocation }
- locationProvider.addListener(this, executor)
- }
+ private val _hasLocationPermission = MutableStateFlow(false)
- fun stopLocationUpdates() {
- locationProvider.removeListener(this)
- }
+ private val _simulated = MutableStateFlow(false)
+ val simulated = _simulated.asStateFlow()
+
+ private val locationStateFlow = MutableStateFlow(null)
+ val location = locationStateFlow.asStateFlow()
// Here's an example of injecting a custom location into the navigation UI state when isNavigating
// is false.
@@ -54,21 +66,36 @@ class DemoNavigationViewModel(
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
- initialValue =
- NavigationUiState(
- null,
- null,
- null,
- null,
- null,
- null,
- false,
- null,
- null,
- null,
- null,
- null,
- null))
+ initialValue = NavigationUiState.empty())
+
+ init {
+ viewModelScope.launch {
+ _hasLocationPermission
+ .flatMapLatest { hasPermission ->
+ if (hasPermission) {
+ locationProvider.locationUpdates(5000L)
+ .map { it.toUserLocation() }
+ } else {
+ flowOf(initialSimulatedLocation)
+ }
+ }
+ .collect {
+ locationStateFlow.emit(it)
+ }
+ }
+ }
+
+ fun setLocationPermissions(permitted: Boolean) {
+ _hasLocationPermission.value = permitted
+ }
+
+ fun toggleSimulation() {
+ _simulated.value = !_simulated.value
+ }
+
+ fun enableAutoDriveSimulation() {
+ _simulated.value = true
+ }
override fun toggleMute() {
val spokenInstructionObserver = ferrostarCore.spokenInstructionObserver
@@ -79,15 +106,91 @@ class DemoNavigationViewModel(
spokenInstructionObserver.setMuted(!spokenInstructionObserver.isMuted)
}
- override fun stopNavigation(stopLocationUpdates: Boolean) {
- ferrostarCore.stopNavigation(stopLocationUpdates = stopLocationUpdates)
+ fun startNavigation(destination: Location, name: String?) {
+ viewModelScope.launch(Dispatchers.IO) {
+ // TODO: Fail gracefully
+ val lastLocation = location.value ?: return@launch
+
+ // TODO: Add label to waypoint?
+ // TODO: Assign the destination to the `NavigationManagerBridge`
+ Log.d(TAG, "fetching route to $destination with name $name")
+
+ val routes =
+ ferrostarCore.getRoutes(
+ lastLocation,
+ listOf(
+ Waypoint(
+ coordinate =
+ GeographicCoordinate(destination.latitude, destination.longitude),
+ kind = WaypointKind.BREAK),
+ ))
+
+ val route = routes.first()
+
+ if (simulated.value) {
+ locationProvider.enableSimulationOn(route)
+ }
+
+ ferrostarCore.startNavigation(route = route)
+ }
+ }
+
+ override fun stopNavigation() {
+ locationProvider.disableSimulation()
+ ferrostarCore.stopNavigation()
+ }
+
+ val mapViewCamera = mutableStateOf(
+ MapViewCamera.TrackingUserLocation()
+ )
+ val cameraPadding = mutableStateOf(CameraPadding())
+ val navigationCamera = mutableStateOf(MapViewCamera.TrackingUserLocationWithBearing(zoom = 16.0, pitch = 45.0))
+
+ fun isTrackingUser(): Boolean =
+ when (mapViewCamera.value.state) {
+ is CameraState.TrackingUserLocation,
+ is CameraState.TrackingUserLocationWithBearing -> true
+ else -> false
+ }
+
+ fun zoomIn() {
+ mapViewCamera.value = mapViewCamera.value.incrementZoom(1.0)
+ }
+
+ fun zoomOut() {
+ mapViewCamera.value = mapViewCamera.value.incrementZoom(-1.0)
+ }
+
+ fun centerCamera() {
+ if (isTrackingUser()) {
+ centerOnRoute()
+ } else {
+ centerOnUser()
+ }
+ }
+
+ private fun centerOnRoute() {
+ val boundingBox = navigationUiState.value.routeGeometry?.boundingBox()
+ boundingBox?.let {
+ val latLngBounds = LatLngBounds.from(
+ boundingBox.north,
+ boundingBox.east,
+ boundingBox.south,
+ boundingBox.west
+ )
+ mapViewCamera.value = MapViewCamera.BoundingBox(
+ latLngBounds,
+ pitch = 0.0,
+ padding = cameraPadding.value
+ )
+ }
}
- override fun onLocationUpdated(location: UserLocation) {
- locationStateFlow.update { location }
+ private fun centerOnUser() {
+ mapViewCamera.value = navigationCamera.value
}
- override fun onHeadingUpdated(heading: Heading) {
- // TODO: Heading
+ companion object {
+ const val TAG = "DemoNavigationViewModel"
}
}
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/MainActivity.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/MainActivity.kt
index 3b47d4600..3be81db87 100644
--- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/MainActivity.kt
+++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/MainActivity.kt
@@ -67,7 +67,7 @@ class MainActivity : ComponentActivity(), AndroidTtsStatusListener {
setContent {
FerrostarTheme {
// A surface container using the 'background' color from the theme
- Surface { DemoNavigationScene(savedInstanceState) }
+ Surface { DemoNavigationScene() }
}
}
}
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/NotNavigatingOverlay.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/NotNavigatingOverlay.kt
new file mode 100644
index 000000000..c1a26c9a5
--- /dev/null
+++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/NotNavigatingOverlay.kt
@@ -0,0 +1,79 @@
+package com.stadiamaps.ferrostar
+
+import android.graphics.Color
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.unit.dp
+import com.stadiamaps.autocomplete.AutocompleteSearch
+import com.stadiamaps.autocomplete.center
+import com.stadiamaps.ferrostar.composeui.views.components.gridviews.InnerGridView
+import com.stadiamaps.ferrostar.core.location.toAndroidLocation
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NotNavigatingOverlay(
+ modifier: Modifier = Modifier,
+ viewModel: DemoNavigationViewModel,
+) {
+ val location by viewModel.location.collectAsState()
+ val isSimulating by viewModel.simulated.collectAsState()
+ val uiState by viewModel.navigationUiState.collectAsState()
+
+ if (!uiState.isNavigating()) {
+ InnerGridView(
+ modifier = modifier.fillMaxSize().padding(bottom = 16.dp, top = 16.dp),
+ topCenter = {
+ AppModule.stadiaApiKey?.let { apiKey ->
+ AutocompleteSearch(
+ apiKey = apiKey,
+ userLocation = location?.toAndroidLocation()
+ ) { feature ->
+ feature.center()?.let { center ->
+ viewModel.startNavigation(center, feature.properties.name)
+ }
+ }
+ }
+ },
+ bottomEnd = {
+ Column(
+ modifier = Modifier.padding(bottom = 24.dp),
+ horizontalAlignment = Alignment.End
+ ) {
+ Button({ viewModel.toggleSimulation() }) {
+ val nextLocation = if (!isSimulating) {
+ "simulated"
+ } else {
+ "GPS"
+ }
+ Text("Set location to $nextLocation")
+ }
+
+ val currentLocation = if (isSimulating) {
+ "simulated"
+ } else {
+ "GPS"
+ }
+
+ Text(
+ "Location is $currentLocation",
+ style = MaterialTheme.typography.titleSmall.copy(
+ color = MaterialTheme.colorScheme.onTertiary,
+ shadow = Shadow(blurRadius = 4.0f)
+ )
+ )
+ }
+ }
+ )
+ }
+}
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoCarAppService.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoCarAppService.kt
new file mode 100644
index 000000000..5ac445519
--- /dev/null
+++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoCarAppService.kt
@@ -0,0 +1,37 @@
+package com.stadiamaps.ferrostar.auto
+
+import android.content.Intent
+import android.content.pm.ApplicationInfo
+import androidx.car.app.CarAppService
+import androidx.car.app.Screen
+import androidx.car.app.Session
+import androidx.car.app.SessionInfo
+import androidx.car.app.validation.HostValidator
+import com.stadiamaps.ferrostar.AppModule
+import com.stadiamaps.ferrostar.car.app.intent.NavigationIntentParser
+
+class DemoCarAppService : CarAppService() {
+
+ override fun createHostValidator(): HostValidator =
+ if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
+ HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ } else {
+ // For reference:
+// HostValidator.Builder(applicationContext)
+// .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
+// .build()
+ HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ }
+
+ override fun onCreateSession(sessionInfo: SessionInfo): Session {
+ return DemoCarAppSession()
+ }
+}
+
+class DemoCarAppSession : Session() {
+ override fun onCreateScreen(intent: Intent): Screen {
+ AppModule.init(carContext)
+ val destination = NavigationIntentParser().parse(intent)
+ return DemoNavigationScreen(carContext, initialDestination = destination)
+ }
+}
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoMapTemplateBuilder.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoMapTemplateBuilder.kt
new file mode 100644
index 000000000..594dc55b2
--- /dev/null
+++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoMapTemplateBuilder.kt
@@ -0,0 +1,12 @@
+package com.stadiamaps.ferrostar.auto
+
+import androidx.car.app.model.Action
+import androidx.car.app.model.ActionStrip
+import androidx.car.app.model.Template
+import androidx.car.app.navigation.model.NavigationTemplate
+
+/** Builds a [NavigationTemplate] for the idle (not navigating) state with pan support. */
+fun buildDemoMapTemplate(): Template =
+ NavigationTemplate.Builder()
+ .setActionStrip(ActionStrip.Builder().addAction(Action.PAN).build())
+ .build()
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt
new file mode 100644
index 000000000..2d2f2295e
--- /dev/null
+++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt
@@ -0,0 +1,173 @@
+package com.stadiamaps.ferrostar.auto
+
+import androidx.car.app.CarContext
+import androidx.car.app.model.Template
+import androidx.car.app.navigation.NavigationManager
+import androidx.lifecycle.Lifecycle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import com.maplibre.compose.camera.CameraState
+import com.maplibre.compose.camera.MapViewCamera
+import com.maplibre.compose.car.ComposableScreen
+import com.stadiamaps.ferrostar.AppModule
+import com.stadiamaps.ferrostar.R
+import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.SurfaceAreaTracker
+import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.screenSurfaceState
+import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.surfaceStableFractionalCameraPadding
+import com.stadiamaps.ferrostar.car.app.navigation.NavigationManagerBridge
+import com.stadiamaps.ferrostar.car.app.template.NavigationTemplateBuilder
+import com.stadiamaps.ferrostar.core.NavigationUiState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import androidx.car.app.navigation.model.Destination
+import com.stadiamaps.ferrostar.car.app.intent.NavigationDestination
+import com.stadiamaps.ferrostar.car.app.navigation.TurnByTurnNotificationManager
+import uniffi.ferrostar.DrivingSide
+
+/**
+ * A basic Android Auto navigation screen that demonstrates how to use the Ferrostar car-app library
+ * components with a MapLibre map surface.
+ *
+ * This screen:
+ * - Renders a [CarAppNavigationView] on the car display surface via [ComposableScreen]
+ * - Observes the navigation view model for navigation state
+ * - Builds a [NavigationTemplate] with routing info (maneuver + distance) when navigating
+ * - Wires up [NavigationManagerBridge] for NF-4/NF-5 compliance
+ * - Posts turn-by-turn notifications via [TurnByTurnNotificationManager] for NF-3 compliance
+ */
+class DemoNavigationScreen(
+ carContext: CarContext,
+ private val initialDestination: NavigationDestination? = null
+) : ComposableScreen(carContext) {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
+ private val viewModel = AppModule.viewModel
+ private var observeJob: Job? = null
+
+ private val notificationManager =
+ TurnByTurnNotificationManager(context = carContext, smallIconRes = R.drawable.ic_navigation)
+
+ private val navigationManagerBridge =
+ NavigationManagerBridge(
+ navigationManager = carContext.getCarService(NavigationManager::class.java),
+ viewModel = viewModel,
+ context = carContext,
+ notificationManager = notificationManager,
+ onStopNavigation = { viewModel.stopNavigation() },
+ onAutoDriveEnabled = { viewModel.enableAutoDriveSimulation() },
+ isCarForeground = { lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) }
+ )
+
+ private var uiState: NavigationUiState? by mutableStateOf(null)
+
+ private val surfaceAreaTracker = SurfaceAreaTracker { surfaceGestureCallback = it }
+
+ init {
+ navigationManagerBridge.start(scope)
+
+ observeJob =
+ viewModel.navigationUiState
+ .onEach { state ->
+ uiState = state
+ invalidate()
+ }
+ .launchIn(scope)
+
+ // If launched with a coordinate destination (e.g. from a geo: or google.navigation: intent),
+ // delegate to the view model which waits for a location fix before routing.
+ initialDestination?.location?.let {
+ viewModel.startNavigation(it, initialDestination.displayName)
+ }
+}
+
+ @Composable
+ override fun content() {
+ val surfaceArea by screenSurfaceState(surfaceAreaTracker)
+ val normalPaddingState = rememberUpdatedState(surfaceStableFractionalCameraPadding(surfaceArea?.compositeArea))
+ val trackingPaddingState = rememberUpdatedState(surfaceStableFractionalCameraPadding(surfaceArea?.compositeArea, top = 0.5f))
+ val camera = remember { mutableStateOf(viewModel.mapViewCamera.value) }
+
+ // Transition to navigation camera and publish destination when navigation starts
+ LaunchedEffect(uiState?.isNavigating()) {
+ if (uiState?.isNavigating() == true) {
+ viewModel.mapViewCamera.value = viewModel.navigationCamera.value
+ }
+ }
+
+ // Sync camera with padding based on current state
+ LaunchedEffect(Unit) {
+ snapshotFlow {
+ val base = viewModel.mapViewCamera.value
+ val normalPadding = normalPaddingState.value
+ val trackingPadding = trackingPaddingState.value
+ if (uiState?.isNavigating() == true) {
+ when (base.state) {
+ is CameraState.TrackingUserLocation,
+ is CameraState.TrackingUserLocationWithBearing -> base.copy(padding = trackingPadding)
+ else -> base.copy(padding = normalPadding)
+ }
+ } else {
+ base
+ }
+ }.collect { camera.value = it }
+ }
+
+ DemoNavigationView(
+ viewModel,
+ camera = camera,
+ surfaceAreaTracker = surfaceAreaTracker
+ )
+ }
+
+ override fun onGetTemplate(): Template {
+ uiState?.let { state ->
+ if (state.isNavigating()) {
+ return NavigationTemplateBuilder(carContext)
+ // Optional. Your route should include this
+ .setBackupDrivingSide(DrivingSide.RIGHT)
+ .setOnStopNavigation {
+ viewModel.stopNavigation()
+ }
+ .setOnMute(uiState?.isMuted) {
+ viewModel.toggleMute()
+ }
+ .setOnZoom(
+ onZoomInTapped = { viewModel.zoomIn() },
+ onZoomOutTapped = { viewModel.zoomOut() }
+ )
+ .setOnCycleCamera(viewModel.isTrackingUser()) {
+ viewModel.centerCamera()
+ }
+ .setTripState(state.tripState)
+ .build()
+ }
+ }
+
+ // Fall back to a basic map template of your App's preference here.
+ return buildDemoMapTemplate()
+ }
+
+ init {
+ lifecycle.addObserver(
+ object : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) {
+ navigationManagerBridge.stop()
+ observeJob?.cancel()
+ scope.cancel()
+ }
+ })
+ }
+}
diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt
new file mode 100644
index 000000000..eb84a0789
--- /dev/null
+++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt
@@ -0,0 +1,56 @@
+package com.stadiamaps.ferrostar.auto
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Modifier
+import com.maplibre.compose.camera.MapViewCamera
+import com.maplibre.compose.symbols.Circle
+import com.stadiamaps.ferrostar.AppModule
+import com.stadiamaps.ferrostar.DemoNavigationViewModel
+import com.stadiamaps.ferrostar.ui.maplibre.car.app.CarAppNavigationView
+import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.SurfaceAreaTracker
+import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig
+import com.stadiamaps.ferrostar.composeui.config.withSpeedLimitStyle
+import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SignageStyle
+import org.maplibre.android.geometry.LatLng
+import kotlin.math.min
+
+@Composable
+fun DemoNavigationView(
+ viewModel: DemoNavigationViewModel = AppModule.viewModel,
+ camera: MutableState,
+ surfaceAreaTracker: SurfaceAreaTracker? = null,
+) {
+ CarAppNavigationView(
+ modifier = Modifier.fillMaxSize(),
+ styleUrl = AppModule.mapStyleUrl,
+ camera = camera,
+ viewModel = viewModel,
+ config = VisualNavigationViewConfig.Default()
+ .withSpeedLimitStyle(SignageStyle.MUTCD),
+ surfaceAreaTracker = surfaceAreaTracker,
+ ) { uiState ->
+ // Trivial, if silly example of how to add your own overlay layers.
+ // (Also incidentally highlights the lag inherent in MapLibre location tracking
+ // as-is.)
+ uiState.location?.let { location ->
+ Circle(
+ center = LatLng(location.coordinates.lat, location.coordinates.lng),
+ radius = 10f,
+ color = "Blue",
+ zIndex = 3,
+ )
+
+ if (location.horizontalAccuracy > 15) {
+ Circle(
+ center = LatLng(location.coordinates.lat, location.coordinates.lng),
+ radius = min(location.horizontalAccuracy.toFloat(), 150f),
+ color = "Blue",
+ opacity = 0.2f,
+ zIndex = 2,
+ )
+ }
+ }
+ }
+}
diff --git a/android/demo-app/src/main/res/drawable/ic_navigation.xml b/android/demo-app/src/main/res/drawable/ic_navigation.xml
new file mode 100644
index 000000000..2d75a97a9
--- /dev/null
+++ b/android/demo-app/src/main/res/drawable/ic_navigation.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/android/demo-app/src/main/res/xml/automotive_app_desc.xml b/android/demo-app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 000000000..0fb852c0f
--- /dev/null
+++ b/android/demo-app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/google-play-services/build.gradle b/android/google-play-services/build.gradle
index 0ad880810..fedf06654 100644
--- a/android/google-play-services/build.gradle
+++ b/android/google-play-services/build.gradle
@@ -36,6 +36,9 @@ dependencies {
implementation libs.play.services.location
testImplementation libs.junit
+ testImplementation libs.kotlinx.coroutines.test
+ testImplementation libs.mockk
+ testImplementation libs.turbine
androidTestImplementation libs.androidx.test.junit
androidTestImplementation libs.androidx.test.espresso
}
diff --git a/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProvider.kt b/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProvider.kt
index 654161cbb..d9508748f 100644
--- a/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProvider.kt
+++ b/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProvider.kt
@@ -2,74 +2,109 @@ package com.stadiamaps.ferrostar.googleplayservices
import android.annotation.SuppressLint
import android.content.Context
+import android.location.Location
+import android.os.Looper
import android.util.Log
-import com.google.android.gms.location.FusedLocationProviderClient
-import com.google.android.gms.location.LocationListener
+import com.google.android.gms.location.CurrentLocationRequest
+import com.google.android.gms.location.LastLocationRequest
+import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
+import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
-import com.stadiamaps.ferrostar.core.LocationProvider
-import com.stadiamaps.ferrostar.core.LocationUpdateListener
-import com.stadiamaps.ferrostar.core.toUserLocation
-import java.util.concurrent.Executor
-import uniffi.ferrostar.Heading
-import uniffi.ferrostar.UserLocation
-
-class FusedLocationProvider(
- context: Context,
- private val fusedLocationProviderClient: FusedLocationProviderClient =
- LocationServices.getFusedLocationProviderClient(context),
- private val priority: Int = Priority.PRIORITY_HIGH_ACCURACY
-) : LocationProvider {
+import com.google.android.gms.tasks.CancellationTokenSource
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.suspendCancellableCoroutine
- companion object {
- private const val TAG = "FusedLocationProvider"
- }
+interface LocationProviding {
+ suspend fun getLastLocation(
+ priority: Int = Priority.PRIORITY_HIGH_ACCURACY,
+ ): Location?
- override var lastLocation: UserLocation? = null
- private set
+ suspend fun getNextLocation(
+ priority: Int = Priority.PRIORITY_HIGH_ACCURACY,
+ timeoutMillis: Long = 60000
+ ): Location?
- override var lastHeading: Heading? = null
- private set
+ fun locationUpdates(
+ priority: Int = Priority.PRIORITY_HIGH_ACCURACY,
+ intervalMillis: Long = 1000
+ ): Flow
+}
- private val listeners: MutableMap = mutableMapOf()
+class FusedLocationProvider(context: Context) : LocationProviding {
+ private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
@SuppressLint("MissingPermission")
- override fun addListener(listener: LocationUpdateListener, executor: Executor) {
- Log.d(TAG, "Adding listener")
- if (listeners.contains(listener)) {
- Log.d(TAG, "Listener already added")
- return
- }
-
- val androidListener = LocationListener {
- val userLocation = it.toUserLocation()
- lastLocation = userLocation
- listener.onLocationUpdated(userLocation)
- }
- listeners[listener] = androidListener
-
- val locationRequest =
- LocationRequest.Builder(priority, 1000L)
- .setMinUpdateDistanceMeters(5.0f)
- .setWaitForAccurateLocation(false)
- .build()
-
- if (lastLocation == null) {
- fusedLocationProviderClient.lastLocation.addOnSuccessListener { location ->
- if (location != null) {
- androidListener.onLocationChanged(location)
+ override suspend fun getLastLocation(priority: Int): Location? =
+ suspendCoroutine { continuation ->
+ Log.d(TAG, "Requesting last location")
+ val requestStart = System.currentTimeMillis()
+
+ fusedLocationClient
+ .getLastLocation(LastLocationRequest.Builder().build())
+ .addOnSuccessListener { location ->
+ val durationSeconds = (System.currentTimeMillis() - requestStart) / 1000.0
+ Log.d(TAG, "Obtained last location in $durationSeconds s")
+ continuation.resume(location)
+ }
+ .addOnFailureListener { exception -> continuation.resumeWithException(exception) }
+ }
+
+ // Get the next fresh location update with timeout
+ @SuppressLint("MissingPermission")
+ override suspend fun getNextLocation(priority: Int, timeoutMillis: Long): Location? =
+ suspendCancellableCoroutine { continuation ->
+ val requestStart = System.currentTimeMillis()
+ Log.d(TAG, "Requesting next location with priority: $priority")
+ val cancellationTokenSource = CancellationTokenSource()
+
+ // https://developers.google.com/android/reference/com/google/android/gms/location/CurrentLocationRequest.Builder
+ val request =
+ CurrentLocationRequest.Builder()
+ .setDurationMillis(timeoutMillis)
+ .setPriority(priority)
+ .build()
+
+ fusedLocationClient
+ .getCurrentLocation(request, cancellationTokenSource.token)
+ .addOnSuccessListener { location ->
+ val durationSeconds = (System.currentTimeMillis() - requestStart) / 1000.0
+ Log.d(TAG,"Obtained next location in $durationSeconds s")
+ continuation.resume(location)
+ }
+ .addOnFailureListener { exception -> continuation.resumeWithException(exception) }
+
+ continuation.invokeOnCancellation {
+ val durationSeconds = (System.currentTimeMillis() - requestStart) / 1000.0
+ Log.d(TAG,"Next location cancelled after $durationSeconds s")
+ cancellationTokenSource.cancel()
}
}
- }
- fusedLocationProviderClient.requestLocationUpdates(locationRequest, executor, androidListener)
- }
- override fun removeListener(listener: LocationUpdateListener) {
- val activeListener = listeners.remove(listener)
+ // Continuous location updates as Flow
+ @SuppressLint("MissingPermission")
+ override fun locationUpdates(priority: Int, intervalMillis: Long): Flow = callbackFlow {
+ val request = LocationRequest.Builder(priority, intervalMillis).build()
+
+ val callback =
+ object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ result.lastLocation?.let { trySend(it) }
+ }
+ }
- if (activeListener != null) {
- fusedLocationProviderClient.removeLocationUpdates(activeListener)
- }
+ fusedLocationClient.requestLocationUpdates(request, callback, Looper.getMainLooper())
+
+ awaitClose { fusedLocationClient.removeLocationUpdates(callback) }
+ }
+
+ companion object {
+ private const val TAG = "LocationProvider"
}
}
diff --git a/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProvider.kt b/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProvider.kt
new file mode 100644
index 000000000..bf7a8048d
--- /dev/null
+++ b/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProvider.kt
@@ -0,0 +1,18 @@
+package com.stadiamaps.ferrostar.googleplayservices
+
+import android.content.Context
+import android.location.Location
+import com.google.android.gms.location.Priority
+import com.stadiamaps.ferrostar.core.location.NavigationLocationProviding
+import kotlinx.coroutines.flow.Flow
+
+class FusedNavigationLocationProvider(
+ context: Context,
+ private val locationProvider: FusedLocationProvider = FusedLocationProvider(context)
+): NavigationLocationProviding {
+ override suspend fun lastLocation(): Location? =
+ locationProvider.getLastLocation(Priority.PRIORITY_HIGH_ACCURACY)
+
+ override fun locationUpdates(intervalMillis: Long): Flow =
+ locationProvider.locationUpdates(Priority.PRIORITY_HIGH_ACCURACY, intervalMillis)
+}
diff --git a/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProviderTest.kt b/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProviderTest.kt
new file mode 100644
index 000000000..b79c59073
--- /dev/null
+++ b/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProviderTest.kt
@@ -0,0 +1,202 @@
+package com.stadiamaps.ferrostar.googleplayservices
+
+import android.content.Context
+import android.location.Location
+import android.os.Looper
+import android.util.Log
+import app.cash.turbine.test
+import com.google.android.gms.location.CurrentLocationRequest
+import com.google.android.gms.location.FusedLocationProviderClient
+import com.google.android.gms.location.LocationCallback
+import com.google.android.gms.location.LocationResult
+import com.google.android.gms.location.LocationServices
+import com.google.android.gms.tasks.CancellationToken
+import com.google.android.gms.tasks.CancellationTokenSource
+import com.google.android.gms.tasks.OnFailureListener
+import com.google.android.gms.tasks.OnSuccessListener
+import com.google.android.gms.tasks.Task
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkConstructor
+import io.mockk.mockkStatic
+import io.mockk.slot
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Before
+import org.junit.Test
+
+class FusedLocationProviderTest {
+ private val mockContext = mockk()
+ private val mockFusedClient = mockk()
+ private val mockLocation = mockk()
+
+ @Before
+ fun setup() {
+ mockkStatic(LocationServices::class)
+ every { LocationServices.getFusedLocationProviderClient(mockContext) } returns mockFusedClient
+
+ mockkStatic(Log::class)
+ every { Log.d(any(), any()) } returns 0
+
+ mockkStatic(Looper::class)
+ every { Looper.getMainLooper() } returns mockk(relaxed = true)
+ }
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ // --- Helpers ---
+
+ private fun successTask(location: Location?): Task {
+ val task = mockk>()
+ every { task.addOnSuccessListener(any>()) } answers {
+ firstArg>().onSuccess(location)
+ task
+ }
+ every { task.addOnFailureListener(any()) } returns task
+ return task
+ }
+
+ private fun failureTask(exception: Exception): Task {
+ val task = mockk>()
+ every { task.addOnSuccessListener(any>()) } returns task
+ every { task.addOnFailureListener(any()) } answers {
+ firstArg().onFailure(exception)
+ task
+ }
+ return task
+ }
+
+ private fun pendingTask(): Task {
+ val task = mockk>()
+ every { task.addOnSuccessListener(any>()) } returns task
+ every { task.addOnFailureListener(any()) } returns task
+ return task
+ }
+
+ // --- getLastLocation ---
+
+ @Test
+ fun `getLastLocation returns location on success`() = runTest {
+ every { mockFusedClient.getLastLocation(any()) } returns successTask(mockLocation)
+
+ val provider = FusedLocationProvider(mockContext)
+ assertSame(mockLocation, provider.getLastLocation())
+ }
+
+ @Test
+ fun `getLastLocation returns null when no location is cached`() = runTest {
+ every { mockFusedClient.getLastLocation(any()) } returns successTask(null)
+
+ val provider = FusedLocationProvider(mockContext)
+ assertNull(provider.getLastLocation())
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun `getLastLocation throws on failure`() = runTest {
+ every { mockFusedClient.getLastLocation(any()) } returns
+ failureTask(RuntimeException("Location unavailable"))
+
+ val provider = FusedLocationProvider(mockContext)
+ provider.getLastLocation()
+ }
+
+ // --- getNextLocation ---
+
+ @Test
+ fun `getNextLocation returns location on success`() = runTest {
+ every {
+ mockFusedClient.getCurrentLocation(any(), any())
+ } returns successTask(mockLocation)
+
+ val provider = FusedLocationProvider(mockContext)
+ assertSame(mockLocation, provider.getNextLocation())
+ }
+
+ @Test
+ fun `getNextLocation cancels the token when the coroutine is cancelled`() = runTest {
+ mockkConstructor(CancellationTokenSource::class)
+ val mockToken = mockk()
+ every { anyConstructed().token } returns mockToken
+ every { anyConstructed().cancel() } just Runs
+ every {
+ mockFusedClient.getCurrentLocation(any(), any())
+ } returns pendingTask()
+
+ val provider = FusedLocationProvider(mockContext)
+ val job = launch { provider.getNextLocation() }
+ runCurrent() // let the coroutine reach the suspension point before cancelling
+ job.cancel()
+ job.join()
+
+ verify { anyConstructed().cancel() }
+ }
+
+ // --- locationUpdates ---
+
+ @Test
+ fun `locationUpdates emits when the location callback fires`() = runTest {
+ val callbackSlot = slot()
+ every {
+ mockFusedClient.requestLocationUpdates(any(), capture(callbackSlot), any())
+ } returns mockk()
+ every { mockFusedClient.removeLocationUpdates(any()) } returns mockk()
+
+ val mockResult = mockk()
+ every { mockResult.lastLocation } returns mockLocation
+
+ val provider = FusedLocationProvider(mockContext)
+ provider.locationUpdates().test {
+ callbackSlot.captured.onLocationResult(mockResult)
+ assertSame(mockLocation, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `locationUpdates skips null locations from callback`() = runTest {
+ val callbackSlot = slot()
+ every {
+ mockFusedClient.requestLocationUpdates(any(), capture(callbackSlot), any())
+ } returns mockk()
+ every { mockFusedClient.removeLocationUpdates(any()) } returns mockk()
+
+ val nullResult = mockk()
+ every { nullResult.lastLocation } returns null
+
+ val validResult = mockk()
+ every { validResult.lastLocation } returns mockLocation
+
+ val provider = FusedLocationProvider(mockContext)
+ provider.locationUpdates().test {
+ callbackSlot.captured.onLocationResult(nullResult) // should be dropped
+ callbackSlot.captured.onLocationResult(validResult)
+ assertSame(mockLocation, awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `locationUpdates unregisters callback when flow is cancelled`() = runTest {
+ val callbackSlot = slot()
+ every {
+ mockFusedClient.requestLocationUpdates(any(), capture(callbackSlot), any())
+ } returns mockk()
+ every { mockFusedClient.removeLocationUpdates(any()) } returns mockk()
+
+ val provider = FusedLocationProvider(mockContext)
+ provider.locationUpdates().test { cancelAndIgnoreRemainingEvents() }
+
+ verify { mockFusedClient.removeLocationUpdates(any()) }
+ }
+}
diff --git a/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProviderTest.kt b/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProviderTest.kt
new file mode 100644
index 000000000..f4d493a78
--- /dev/null
+++ b/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProviderTest.kt
@@ -0,0 +1,60 @@
+package com.stadiamaps.ferrostar.googleplayservices
+
+import android.content.Context
+import android.location.Location
+import com.google.android.gms.location.Priority
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+class FusedNavigationLocationProviderTest {
+ private val mockContext = mockk()
+ private val mockLocationProvider = mockk()
+ private val mockLocation = mockk()
+
+ @After
+ fun teardown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun `lastLocation delegates to getLastLocation with high accuracy priority`() = runTest {
+ coEvery { mockLocationProvider.getLastLocation(Priority.PRIORITY_HIGH_ACCURACY) } returns
+ mockLocation
+
+ val provider = FusedNavigationLocationProvider(mockContext, mockLocationProvider)
+ assertSame(mockLocation, provider.lastLocation())
+ }
+
+ @Test
+ fun `lastLocation returns null when delegate returns null`() = runTest {
+ coEvery { mockLocationProvider.getLastLocation(Priority.PRIORITY_HIGH_ACCURACY) } returns null
+
+ val provider = FusedNavigationLocationProvider(mockContext, mockLocationProvider)
+ assertNull(provider.lastLocation())
+ }
+
+ @Test
+ fun `locationUpdates delegates to provider with high accuracy priority`() = runTest {
+ val intervalMillis = 1000L
+ val locationFlow = flowOf(mockLocation)
+ every {
+ mockLocationProvider.locationUpdates(Priority.PRIORITY_HIGH_ACCURACY, intervalMillis)
+ } returns locationFlow
+
+ val provider = FusedNavigationLocationProvider(mockContext, mockLocationProvider)
+ val result = provider.locationUpdates(intervalMillis)
+
+ // Verify delegation to the underlying provider with the correct arguments
+ verify { mockLocationProvider.locationUpdates(Priority.PRIORITY_HIGH_ACCURACY, intervalMillis) }
+ assertSame(locationFlow, result)
+ }
+}
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 8ed7bef04..9c21b5f19 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -11,6 +11,7 @@ kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.7.1"
kotlinx-serialization = "1.10.0"
androidx-appcompat = "1.7.1"
+androidx-car-app = "1.7.0"
androidx-activity-compose = "1.12.4"
compose = "2026.02.01"
okhttp = "5.3.2"
@@ -50,6 +51,8 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icon-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+# Car App Library (for car-app module)
+androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "androidx-car-app" }
# Material
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
# OkHttp & Moshi
@@ -59,6 +62,7 @@ okhttp-mock = { group = "com.github.gmazzo", name = "okhttp-mock", version.ref =
kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
# MapLibre
maplibre-compose = { group = "io.github.rallista", name = "maplibre-compose", version.ref = "maplibre-compose" }
+maplibre-compose-car-app = { group = "io.github.rallista", name = "maplibre-compose-car-app", version.ref = "maplibre-compose" }
# Google Play Services (for Google module)
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" }
# Testing
diff --git a/android/settings.gradle b/android/settings.gradle
index 89a40fc93..383c0420c 100644
--- a/android/settings.gradle
+++ b/android/settings.gradle
@@ -20,3 +20,5 @@ include ':ui-maplibre'
include ':google-play-services'
include ':ui-formatters'
include ':ui-shared'
+include ':ui-maplibre-car-app'
+include ':car-app'
diff --git a/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/measurement/LocalizedSpeed.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/measurement/LocalizedSpeed.kt
new file mode 100644
index 000000000..d2f668714
--- /dev/null
+++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/measurement/LocalizedSpeed.kt
@@ -0,0 +1,6 @@
+package com.stadiamaps.ferrostar.composeui.measurement
+
+import android.content.Context
+import com.stadiamaps.ferrostar.composeui.R
+import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeedUnit
+
diff --git a/android/ui-maplibre-car-app/.gitignore b/android/ui-maplibre-car-app/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/android/ui-maplibre-car-app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/android/ui-maplibre-car-app/build.gradle b/android/ui-maplibre-car-app/build.gradle
new file mode 100644
index 000000000..d86f281bb
--- /dev/null
+++ b/android/ui-maplibre-car-app/build.gradle
@@ -0,0 +1,86 @@
+import com.vanniktech.maven.publish.AndroidSingleVariantLibrary
+import com.vanniktech.maven.publish.JavadocJar
+import com.vanniktech.maven.publish.SourcesJar
+
+plugins {
+ alias libs.plugins.androidLibrary
+ alias libs.plugins.ktfmt
+ alias libs.plugins.compose.compiler
+ alias libs.plugins.mavenPublish
+}
+
+android {
+ namespace 'com.stadiamaps.ferrostar.ui.maplibre.car.app'
+ compileSdk {
+ version = release(36)
+ }
+
+ defaultConfig {
+ minSdk 29
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles "consumer-rules.pro"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ coreLibraryDesugaringEnabled true
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ buildFeatures {
+ compose true
+ }
+}
+
+dependencies {
+ // For as long as we support API 25; once we can raise support to 26, all is fine
+ coreLibraryDesugaring libs.desugar.jdk.libs
+
+ implementation libs.androidx.ktx
+ implementation libs.androidx.appcompat
+ implementation libs.material
+
+ implementation platform(libs.androidx.compose.bom)
+ implementation libs.androidx.compose.ui
+ implementation libs.androidx.compose.ui.graphics
+ implementation libs.androidx.compose.ui.tooling
+ implementation libs.androidx.compose.material3
+
+ implementation project(':core')
+ implementation project(':ui-compose')
+ implementation project(':ui-maplibre')
+
+ testImplementation libs.junit
+ androidTestImplementation libs.androidx.test.junit
+ androidTestImplementation libs.androidx.test.espresso
+}
+
+mavenPublishing {
+ publishToMavenCentral()
+
+ if (!project.hasProperty(SKIP_SIGNING_PROPERTY)) {
+ signAllPublications()
+ }
+
+ configure(new AndroidSingleVariantLibrary(
+ // Temporarily disable JavaDoc due to dokka issues with the Compose library using newer bytecode
+ new JavadocJar.Empty(),
+ new SourcesJar.Sources(),
+ "release"
+ ))
+
+ apply from: "${rootProject.projectDir}/common-pom.gradle"
+
+ pom {
+ name = "Ferrostar MapLibre Android Auto UI"
+ description = "Composable map UI car app components for Ferrostar built with MapLibre"
+
+ commonPomConfig(it, true)
+ }
+}
\ No newline at end of file
diff --git a/android/ui-maplibre-car-app/consumer-rules.pro b/android/ui-maplibre-car-app/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/android/ui-maplibre-car-app/proguard-rules.pro b/android/ui-maplibre-car-app/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/android/ui-maplibre-car-app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/android/ui-maplibre-car-app/src/androidTest/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleInstrumentedTest.kt b/android/ui-maplibre-car-app/src/androidTest/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleInstrumentedTest.kt
new file mode 100644
index 000000000..1d6f048a8
--- /dev/null
+++ b/android/ui-maplibre-car-app/src/androidTest/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleInstrumentedTest.kt
@@ -0,0 +1,22 @@
+package com.stadiamaps.ferrostar.ui.maplibre.car.app
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("com.stadiamaps.ferrostar.ui.maplibre.car.app.test", appContext.packageName)
+ }
+}
diff --git a/android/ui-maplibre-car-app/src/main/AndroidManifest.xml b/android/ui-maplibre-car-app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a5918e68a
--- /dev/null
+++ b/android/ui-maplibre-car-app/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt
new file mode 100644
index 000000000..9c31300ef
--- /dev/null
+++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt
@@ -0,0 +1,116 @@
+package com.stadiamaps.ferrostar.ui.maplibre.car.app
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.maplibre.compose.camera.MapViewCamera
+import com.maplibre.compose.ramani.LocationRequestProperties
+import com.maplibre.compose.ramani.MapLibreComposable
+import com.maplibre.compose.rememberSaveableMapViewCamera
+import com.maplibre.compose.settings.AttributionSettings
+import com.maplibre.compose.settings.CompassSettings
+import com.maplibre.compose.settings.LogoSettings
+import com.maplibre.compose.settings.MapControls
+import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.SurfaceAreaTracker
+import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.screenSurfaceState
+import com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime.surfaceStableFractionalPadding
+import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig
+import com.stadiamaps.ferrostar.composeui.views.components.CurrentRoadNameView
+import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SpeedLimitView
+import com.stadiamaps.ferrostar.core.NavigationUiState
+import com.stadiamaps.ferrostar.core.NavigationViewModel
+import com.stadiamaps.ferrostar.maplibreui.NavigationMapView
+import com.stadiamaps.ferrostar.maplibreui.extensions.NavigationDefault
+import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder
+import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera
+
+/**
+ * A navigation view designed for Android Auto car displays.
+ *
+ * Renders a [NavigationMapView] with speed limit and road name overlays positioned within the
+ * stable area of the car display surface (the area not covered by the NavigationTemplate's chrome).
+ */
+@Composable
+fun CarAppNavigationView(
+ modifier: Modifier,
+ styleUrl: String,
+ camera: MutableState = rememberSaveableMapViewCamera(),
+ navigationCamera: MapViewCamera = navigationMapViewCamera(),
+ viewModel: NavigationViewModel,
+ locationRequestProperties: LocationRequestProperties =
+ LocationRequestProperties.NavigationDefault(),
+ config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(),
+ routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(),
+ surfaceAreaTracker: SurfaceAreaTracker? = null,
+ mapContent: @Composable @MapLibreComposable() ((NavigationUiState) -> Unit)? = null,
+) {
+ val uiState by viewModel.navigationUiState.collectAsState()
+ val mapControls = remember {
+ mutableStateOf(
+ MapControls(
+ attribution = AttributionSettings(enabled = false),
+ compass = CompassSettings(enabled = false),
+ logo = LogoSettings(enabled = false)))
+ }
+
+ val surfaceArea by surfaceAreaTracker
+ ?.let { screenSurfaceState(it) }
+ ?: remember { mutableStateOf(null) }
+
+ val gridPadding = surfaceStableFractionalPadding(surfaceArea?.compositeArea)
+
+ // Wrap mapContent to also wire surface gesture handling inside the MapLibreComposable context.
+ val wrappedContent: (@Composable @MapLibreComposable (NavigationUiState) -> Unit)? =
+ if (surfaceAreaTracker != null || mapContent != null) {
+ { uiState ->
+ surfaceAreaTracker?.rememberGestureDelegate()
+ mapContent?.invoke(uiState)
+ }
+ } else null
+
+ Box(modifier) {
+ NavigationMapView(
+ styleUrl,
+ camera,
+ uiState = uiState,
+ mapControls = mapControls,
+ locationRequestProperties = locationRequestProperties,
+ routeOverlayBuilder = routeOverlayBuilder,
+ onMapReadyCallback = {
+ // No definition
+ },
+ content = wrappedContent
+ )
+
+ Box(
+ modifier = Modifier.fillMaxSize()
+ .padding(gridPadding)
+ ) {
+ // Speed limit in top-start
+ uiState.currentAnnotation?.speedLimit?.let { speedLimit ->
+ config.speedLimitStyle?.let { speedLimitStyle ->
+ SpeedLimitView(
+ speedLimit = speedLimit,
+ signageStyle = speedLimitStyle,
+ modifier = Modifier.align(Alignment.TopStart))
+ }
+ }
+
+ // Road name at bottom-center
+ uiState.currentStepRoadName?.let { roadName ->
+ CurrentRoadNameView(
+ currentRoadName = roadName,
+ modifier = Modifier.align(Alignment.BottomCenter)
+ )
+ }
+ }
+ }
+}
diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt
new file mode 100644
index 000000000..eb1fa5690
--- /dev/null
+++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt
@@ -0,0 +1,109 @@
+package com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime
+
+import android.graphics.Rect
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import com.maplibre.compose.ramani.MapLibreComposable
+import com.maplibre.compose.surface.SurfaceGestureCallback
+import com.maplibre.compose.surface.rememberMapSurfaceGestureCallback
+
+/**
+ * Bridges [SurfaceGestureCallback] events into Compose-observable [MutableState], while
+ * forwarding gesture events (scroll, fling, scale) to a map gesture delegate.
+ *
+ * Usage:
+ * 1. Create an instance, passing the registration lambda so it self-registers immediately:
+ * ```
+ * private val surfaceAreaTracker = SurfaceAreaTracker { surfaceGestureCallback = it }
+ * ```
+ * 2. Pass the tracker to [CarAppNavigationView], which handles both gesture wiring and
+ * safe-area-aware overlay placement internally.
+ * 3. To observe surface area state outside the map context (e.g. for camera padding), call
+ * [screenSurfaceState] in any [Composable] scope.
+ */
+class SurfaceAreaTracker(register: (SurfaceAreaTracker) -> Unit) : SurfaceGestureCallback {
+ val stableArea: MutableState = mutableStateOf(null)
+ val visibleArea: MutableState = mutableStateOf(null)
+
+ @Volatile
+ var delegate: SurfaceGestureCallback? = null
+
+ init {
+ register(this)
+ }
+
+ override fun onStableAreaChanged(stableArea: Rect) {
+ this.stableArea.value = stableArea
+ }
+
+ override fun onVisibleAreaChanged(visibleArea: Rect) {
+ this.visibleArea.value = visibleArea
+ }
+
+ override fun onScroll(distanceX: Float, distanceY: Float) {
+ delegate?.onScroll(distanceX, distanceY)
+ }
+
+ override fun onFling(velocityX: Float, velocityY: Float) {
+ delegate?.onFling(velocityX, velocityY)
+ }
+
+ override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) {
+ delegate?.onScale(focusX, focusY, scaleFactor)
+ }
+
+ /**
+ * Wires up map gesture handling (scroll, fling, scale). Must be called within a
+ * [MapLibreComposable] context. Use this when the surface area state is already observed
+ * elsewhere (e.g. via [screenSurfaceState] in a parent composable).
+ */
+ @Composable
+ @MapLibreComposable
+ fun rememberGestureDelegate() {
+ rememberMapSurfaceGestureCallback { delegate = it }
+ }
+
+ /**
+ * Wires up map gesture handling (scroll, fling, scale) and returns a [State] tracking the
+ * current [SurfaceArea]. Must be called within a [MapLibreComposable] context.
+ */
+ @Composable
+ @MapLibreComposable
+ fun rememberSurfaceArea(): State {
+ rememberGestureDelegate()
+ return screenSurfaceState(stableArea, visibleArea)
+ }
+}
+
+data class SurfaceArea(
+ val stableArea: Rect,
+ val visibleArea: Rect,
+ val compositeArea: Rect
+)
+
+@Composable
+fun screenSurfaceState(tracker: SurfaceAreaTracker): State =
+ screenSurfaceState(tracker.stableArea, tracker.visibleArea)
+
+@Composable
+fun screenSurfaceState(
+ stableArea: MutableState = remember { mutableStateOf(null) },
+ visibleArea: MutableState = remember { mutableStateOf(null) }
+): State {
+ return remember(stableArea, visibleArea) {
+ derivedStateOf {
+ val stable = stableArea.value ?: return@derivedStateOf null
+ val visible = visibleArea.value ?: return@derivedStateOf null
+ SurfaceArea(
+ stableArea = stable,
+ visibleArea = visible,
+ compositeArea = Rect(stable.left, visible.top, stable.right, visible.bottom)
+ )
+ }
+ }
+}
+
diff --git a/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceStablePadding.kt b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceStablePadding.kt
new file mode 100644
index 000000000..c91ed3150
--- /dev/null
+++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceStablePadding.kt
@@ -0,0 +1,103 @@
+package com.stadiamaps.ferrostar.ui.maplibre.car.app.runtime
+
+import android.graphics.Rect
+import androidx.annotation.FloatRange
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.unit.dp
+import com.maplibre.compose.camera.models.CameraPadding
+
+@Composable
+fun surfaceStablePadding(
+ stableArea: Rect?,
+ additionalPadding: PaddingValues? = null
+): PaddingValues {
+ val density = LocalDensity.current
+ val surfaceSize = LocalWindowInfo.current.containerSize
+ val layoutDirection = LocalLayoutDirection.current
+
+ val extraStart = additionalPadding?.calculateStartPadding(layoutDirection) ?: 0.dp
+ val extraTop = additionalPadding?.calculateTopPadding() ?: 0.dp
+ val extraEnd = additionalPadding?.calculateEndPadding(layoutDirection) ?: 0.dp
+ val extraBottom = additionalPadding?.calculateBottomPadding() ?: 0.dp
+
+ val padding =
+ if (stableArea != null) {
+ with(density) {
+ PaddingValues(
+ start = stableArea.left.toDp() + extraStart,
+ top = stableArea.top.toDp() + extraTop,
+ end = (surfaceSize.width - stableArea.right).toDp() + extraEnd,
+ bottom = (surfaceSize.height - stableArea.bottom).toDp() + extraBottom
+ )
+ }
+ } else {
+ additionalPadding ?: PaddingValues(0.dp)
+ }
+
+ return padding
+}
+
+@Composable
+fun surfaceStableFractionalPadding(
+ stableArea: Rect?,
+ @FloatRange(from = 0.0, to = 1.0) start: Float = 0.0f,
+ @FloatRange(from = 0.0, to = 1.0) top: Float = 0.0f,
+ @FloatRange(from = 0.0, to = 1.0) end: Float = 0.0f,
+ @FloatRange(from = 0.0, to = 1.0) bottom: Float = 0.0f
+): PaddingValues {
+ val density = LocalDensity.current
+ val surfaceSize = LocalWindowInfo.current.containerSize
+ val stableWidth = stableArea?.width() ?: surfaceSize.width
+ val stableHeight = stableArea?.height() ?: surfaceSize.height
+
+ val padding =
+ if (stableArea != null) {
+ with(density) {
+ PaddingValues(
+ start = (stableArea.left + stableWidth * start).toDp(),
+ top = (stableArea.top + stableHeight * top).toDp(),
+ end = (surfaceSize.width - stableArea.right + stableWidth * end).toDp(),
+ bottom = (surfaceSize.height - stableArea.bottom + stableHeight * bottom).toDp()
+ )
+ }
+ } else {
+ PaddingValues(0.dp)
+ }
+
+ return padding
+}
+
+// CameraPadding
+
+@Composable
+fun surfaceStableCameraPadding(
+ stableArea: Rect?,
+ additionalPadding: PaddingValues? = null
+): CameraPadding {
+ val padding = surfaceStablePadding(stableArea, additionalPadding)
+ return CameraPadding.padding(padding)
+}
+
+@Composable
+fun surfaceStableFractionalCameraPadding(
+ stableArea: Rect?,
+ @FloatRange(from = 0.0, to = 1.0) start: Float = 0.0f,
+ @FloatRange(from = 0.0, to = 1.0) top: Float = 0.0f,
+ @FloatRange(from = 0.0, to = 1.0) end: Float = 0.0f,
+ @FloatRange(from = 0.0, to = 1.0) bottom: Float = 0.0f
+): CameraPadding {
+ val padding = surfaceStableFractionalPadding(
+ stableArea,
+ start = start,
+ top = top,
+ end = end,
+ bottom = bottom
+ )
+ return CameraPadding.padding(padding)
+}
diff --git a/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/ExampleUnitTest.kt b/android/ui-maplibre-car-app/src/test/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleUnitTest.kt
similarity index 85%
rename from android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/ExampleUnitTest.kt
rename to android/ui-maplibre-car-app/src/test/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleUnitTest.kt
index 1dfb1e72a..41ffea42a 100644
--- a/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/ExampleUnitTest.kt
+++ b/android/ui-maplibre-car-app/src/test/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleUnitTest.kt
@@ -1,4 +1,4 @@
-package com.stadiamaps.ferrostar.googleplayservices
+package com.stadiamaps.ferrostar.ui.maplibre.car.app
import org.junit.Assert.*
import org.junit.Test
diff --git a/android/ui-maplibre/src/main/AndroidManifest.xml b/android/ui-maplibre/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a5918e68a
--- /dev/null
+++ b/android/ui-maplibre/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt
index cd31ba813..c6b0901b1 100644
--- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt
+++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt
@@ -16,7 +16,7 @@ import com.maplibre.compose.ramani.LocationRequestProperties
import com.maplibre.compose.ramani.MapLibreComposable
import com.maplibre.compose.settings.MapControls
import com.stadiamaps.ferrostar.core.NavigationUiState
-import com.stadiamaps.ferrostar.core.toAndroidLocation
+import com.stadiamaps.ferrostar.core.location.toAndroidLocation
import com.stadiamaps.ferrostar.maplibreui.extensions.NavigationDefault
import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder
import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera
diff --git a/guide/src/SUMMARY.md b/guide/src/SUMMARY.md
index 8f6d52b27..565f3006d 100644
--- a/guide/src/SUMMARY.md
+++ b/guide/src/SUMMARY.md
@@ -20,6 +20,7 @@
- [SwiftUI](./swiftui-customization.md)
- [Jetpack Compose](./jetpack-compose-customization.md)
- [Android Foreground Services](./android-foreground-service.md)
+ - [Android Auto/Car App](./android-auto-car-app.md)
- [Web](./web-customization.md)
- [iOS Dynamic Island](./ios-dynamic-island.md)
- [iOS CarPlay](./ios-carplay.md)
diff --git a/guide/src/android-auto-car-app.md b/guide/src/android-auto-car-app.md
new file mode 100644
index 000000000..f4c90cba6
--- /dev/null
+++ b/guide/src/android-auto-car-app.md
@@ -0,0 +1,236 @@
+# Implementing Android Auto (Car App)
+
+Ferrostar provides tooling to construct an Android Auto navigation app. The Demo App's
+`auto` directory is a good reference implementation.
+
+## Basic Setup
+
+### Android Manifest & XML
+
+1. Add the navigation service to your app's manifest [`AndroidManifest.xml#L52`](android/demo-app/src/main/AndroidManifest.xml#L52)
+2. Set the minimum car app version in the manifest [`AndroidManifest.xml#L29`](android/demo-app/src/main/AndroidManifest.xml#L29). Configure this based on which Car App Library features you use.
+3. Add the automotive app descriptor [`automotive_app_desc.xml`](android/demo-app/src/main/res/xml/automotive_app_desc.xml) and link it in your manifest [`AndroidManifest.xml#L32`](android/demo-app/src/main/AndroidManifest.xml#L32)
+
+### Car App Service
+
+Extend `CarAppService` and return a `Session` from `onCreateSession`. In your `Session`,
+initialize any app-level dependencies and return your navigation `Screen`:
+
+```kotlin
+class MyCarAppService : CarAppService() {
+ override fun createHostValidator(): HostValidator =
+ if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
+ HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
+ } else {
+ HostValidator.Builder(applicationContext)
+ .addAllowedHosts(androidx.car.app.R.array.hosts_allowlist_sample)
+ .build()
+ }
+
+ override fun onCreateSession(sessionInfo: SessionInfo): Session = MyCarAppSession()
+}
+
+class MyCarAppSession : Session() {
+ override fun onCreateScreen(intent: Intent): Screen {
+ AppModule.init(carContext)
+ val destination = NavigationIntentParser().parse(intent)
+ return MyNavigationScreen(carContext, initialDestination = destination)
+ }
+}
+```
+
+See [`DemoCarAppService`](android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoCarAppService.kt) for a complete example.
+
+### Car App Screen
+
+Extend `ComposableScreen` from the MapLibre Compose Car App library. Your screen is
+responsible for three things: managing the `SurfaceAreaTracker`, building the
+`NavigationTemplate`, and rendering the map surface.
+
+#### Surface Area Tracking
+
+Create a [`SurfaceAreaTracker`](android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt)
+and register it as the surface gesture callback in one step:
+
+```kotlin
+private val surfaceAreaTracker = SurfaceAreaTracker { surfaceGestureCallback = it }
+```
+
+This bridges surface area events (stable area, visible area, gestures) into
+Compose-observable state. Pass it to `CarAppNavigationView`, which handles safe-area
+overlay placement and gesture wiring internally.
+
+#### Navigation Manager Bridge
+
+Wire up [`NavigationManagerBridge`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/NavigationManagerBridge.kt)
+to connect Ferrostar's view model to the Car App Library's `NavigationManager`. This
+handles the navigation lifecycle (NF-5), trip updates (NF-4), and turn-by-turn
+notifications (NF-3):
+
+```kotlin
+private val navigationManagerBridge = NavigationManagerBridge(
+ navigationManager = carContext.getCarService(NavigationManager::class.java),
+ viewModel = viewModel,
+ context = carContext,
+ notificationManager = TurnByTurnNotificationManager(carContext, R.drawable.ic_navigation),
+ onStopNavigation = { viewModel.stopNavigation() },
+ onAutoDriveEnabled = { viewModel.enableAutoDriveSimulation() },
+ isCarForeground = { lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) }
+)
+```
+
+Call `navigationManagerBridge.start(scope)` in your `init` block and
+`navigationManagerBridge.stop()` in your `onDestroy` lifecycle observer.
+
+#### Composable Content
+
+In `content()`, use `screenSurfaceState` to observe the stable area for camera padding,
+and pass the tracker to `CarAppNavigationView`:
+
+```kotlin
+@Composable
+override fun content() {
+ val surfaceArea by screenSurfaceState(surfaceAreaTracker)
+ val camera = remember { mutableStateOf(viewModel.mapViewCamera.value) }
+
+ CarAppNavigationView(
+ modifier = Modifier.fillMaxSize(),
+ styleUrl = myMapStyleUrl,
+ camera = camera,
+ viewModel = viewModel,
+ surfaceAreaTracker = surfaceAreaTracker,
+ )
+}
+```
+
+#### Navigation Template
+
+In `onGetTemplate()`, use [`NavigationTemplateBuilder`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/NavigationTemplateBuilder.kt)
+to produce the appropriate template for the current navigation state:
+
+```kotlin
+override fun onGetTemplate(): Template {
+ uiState?.let { state ->
+ if (state.isNavigating()) {
+ return NavigationTemplateBuilder(carContext)
+ .setTripState(state.tripState)
+ .setOnStopNavigation { viewModel.stopNavigation() }
+ .build()
+ }
+ }
+ return buildMyMapTemplate()
+}
+```
+
+See [`DemoNavigationScreen`](android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt) for a complete example.
+
+## Requirements
+
+Google has specific review guidelines for Android Auto navigation apps. You can
+find them here: [Car App Quality Guidelines](https://developer.android.com/docs/quality-guidelines/car-app-quality).
+Search for `NF` to find the navigation app specific guidelines.
+
+This document summarizes the navigation app guidelines (as of March 2026) and
+provides guidance on how Ferrostar can be used to implement them in your
+Android Auto app.
+
+### NF-1 - Turn by Turn Navigation
+
+> The app must provide turn-by-turn navigation directions.
+
+[`NavigationTemplateBuilder`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/NavigationTemplateBuilder.kt)
+translates Ferrostar's active navigation state into a `NavigationTemplate` for Android Auto,
+including maneuver icons, distance/time estimates, lane guidance, and action strip controls.
+
+### NF-2 - Only Map Content on the Surface (with Exceptions)
+
+> The app draws only map content on the surface of the navigation templates. Text-based
+> turn-by-turn directions, lane guidance, and estimated arrival time must be displayed on
+> the relevant components of the navigation template. Additional information relevant to the
+> drive, speed limit, road obstructions, etc., can be drawn on the safe area of the map.
+
+[`CarAppNavigationView`](android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt)
+handles this automatically. Pass a `SurfaceAreaTracker` and the view constrains speed
+limit and road name overlays to the display's composite stable area, keeping them clear
+of the template chrome.
+
+### NF-3 - Turn by Turn Notifications
+
+> When the app provides text-based turn-by-turn directions, it must also trigger navigation
+> notifications. For more information, see
+> [Turn-by-turn notifications](https://developer.android.com/training/cars/apps/navigation#turn-by-turn-notifications).
+
+[`TurnByTurnNotificationManager`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/TurnByTurnNotificationManager.kt)
+posts heads-up notifications for each new spoken instruction. Pass it to
+`NavigationManagerBridge`, which suppresses notifications automatically when the car
+screen is in the foreground (the map surface is already showing guidance).
+
+### NF-4 - Next Step to Cluster
+
+> When the navigation app provides text-based turn-by-turn directions, it must send
+> next-turn information to the vehicle's cluster display. For more information, see
+> [Navigation metadata](https://developer.android.com/training/cars/apps/navigation#navigation-metadata).
+
+[`NavigationManagerBridge`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/NavigationManagerBridge.kt)
+calls `NavigationManager.updateTrip()` on every navigation state change. The trip is
+built by `FerrostarTrip`, which populates the current step (maneuver and instruction),
+travel estimate (distance and ETA), current road name, and destination — the fields
+the vehicle cluster display reads.
+
+### NF-5 - Don't Interfere if not Navigating
+
+> The app must not provide turn-by-turn notifications, voice guidance, or cluster
+> information when another navigation app is providing turn-by-turn instructions. For more
+> information, see
+> [Start, end, and stop navigation](https://developer.android.com/training/cars/apps/navigation#starting-ending-stopping-navigation).
+
+[`NavigationManagerBridge`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/NavigationManagerBridge.kt)
+calls `NavigationManager.navigationStarted()` and `NavigationManager.navigationEnded()`
+at the correct lifecycle points, letting the Car App host arbitrate between competing
+navigation apps.
+
+### NF-6 - App Must Handle Navigation Intents
+
+> The app must handle navigation requests from other apps. For more information, see
+> [Support navigation intents](https://developer.android.com/training/cars/apps/navigation#support-navigation-intents).
+
+[`NavigationIntentParser`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationIntentParser.kt)
+parses incoming navigation intents into a [`NavigationDestination`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationDestination.kt).
+It supports `geo:` and `google.navigation:` URI schemes out of the box and is `open`
+for subclassing to support additional schemes:
+
+```kotlin
+class MyIntentParser : NavigationIntentParser() {
+ override fun parseUri(uri: Uri) = parseMyScheme(uri) ?: super.parseUri(uri)
+}
+```
+
+Call it from `Session.onCreateScreen` and pass the result to your navigation screen:
+
+```kotlin
+override fun onCreateScreen(intent: Intent): Screen {
+ val destination = NavigationIntentParser().parse(intent)
+ return MyNavigationScreen(carContext, initialDestination = destination)
+}
+```
+
+### NF-7 - Test Drive Mode
+
+> The app must provide a "test drive" mode that simulates driving. For more information,
+> see [Simulate navigation](https://developer.android.com/training/cars/apps/navigation#simulating-navigation).
+
+Pass an `onAutoDriveEnabled` callback to `NavigationManagerBridge`. It is invoked when
+the Car App host requests simulation (e.g. during review):
+
+```kotlin
+NavigationManagerBridge(
+ ...
+ onAutoDriveEnabled = { viewModel.enableAutoDriveSimulation() },
+)
+```
+
+You can also trigger auto-drive manually via adb for testing:
+
+```sh
+adb shell dumpsys activity service com.stadiamaps.ferrostar.auto.DemoCarAppService AUTO_DRIVE
+```