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 +```