From 42f7ae0af7df916efba4bdeba06174c20361680c Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sat, 14 Mar 2026 14:54:17 -0700 Subject: [PATCH 01/15] feat: modern android location providers --- .../ferrostar/core/FerrostarCore.kt | 47 ++- .../com/stadiamaps/ferrostar/core/Location.kt | 297 ------------------ .../ferrostar/core/NavigationViewModel.kt | 9 +- .../core/location/AndroidLocationProvider.kt | 69 ++++ .../location/NavigationLocationProvider.kt | 47 +++ .../location/NavigationLocationProviding.kt | 11 + .../location/SimulatedLocationProvider.kt | 76 +++++ .../ferrostar/core/location/UserLocation.kt | 87 +++++ .../core/mock/MockNavigationState.kt | 2 +- .../com/stadiamaps/ferrostar/AppModule.kt | 31 +- .../ferrostar/AutocompleteOverlay.kt | 64 ---- .../ferrostar/DemoNavigationScene.kt | 30 +- .../ferrostar/DemoNavigationViewModel.kt | 96 ++++-- .../ferrostar/NotNavigatingOverlay.kt | 79 +++++ .../FusedLocationProvider.kt | 150 +++++---- .../FusedNavigationLocationProvider.kt | 18 ++ .../ferrostar/maplibreui/NavigationMapView.kt | 2 +- 17 files changed, 612 insertions(+), 503 deletions(-) delete mode 100644 android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt create mode 100644 android/core/src/main/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProvider.kt create mode 100644 android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProvider.kt create mode 100644 android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProviding.kt create mode 100644 android/core/src/main/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProvider.kt create mode 100644 android/core/src/main/java/com/stadiamaps/ferrostar/core/location/UserLocation.kt delete mode 100644 android/demo-app/src/main/java/com/stadiamaps/ferrostar/AutocompleteOverlay.kt create mode 100644 android/demo-app/src/main/java/com/stadiamaps/ferrostar/NotNavigatingOverlay.kt create mode 100644 android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProvider.kt 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..fb1063605 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 @@ -97,10 +97,7 @@ interface NavigationViewModel { fun toggleMute() - fun stopNavigation(stopLocationUpdates: Boolean = true) - - // 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() } /** @@ -139,8 +136,8 @@ open class DefaultNavigationViewModel( ferrostarCore.spokenInstructionObserver?.isMuted, null)) - override fun stopNavigation(stopLocationUpdates: Boolean) { - ferrostarCore.stopNavigation(stopLocationUpdates = stopLocationUpdates) + override fun stopNavigation() { + ferrostarCore.stopNavigation() } override fun toggleMute() { 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..d7735a10c --- /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 = "AndroidNavigationLocationProvider" + } +} 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..47bf0eebb --- /dev/null +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProvider.kt @@ -0,0 +1,76 @@ +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.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 + + 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..b819e1070 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 @@ -93,5 +93,5 @@ class MockNavigationViewModel(override val navigationUiState: StateFlow - 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..c5765928e 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 @@ -6,7 +6,10 @@ import android.os.Build import android.os.Bundle import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -39,7 +42,7 @@ fun DemoNavigationScene( KeepScreenOnDisposableEffect() val context = LocalContext.current - val scope = rememberCoroutineScope() +// val scope = rememberCoroutineScope() // Get location permissions. // NOTE: This is NOT a robust suggestion for how to get permissions in a production app. @@ -56,15 +59,16 @@ 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 navigationUiState by viewModel.navigationUiState.collectAsState(scope.coroutineContext) +// val location by viewModel.location.collectAsState() + val isSimulating by viewModel.simulated.collectAsState() val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> when { permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) -> { - viewModel.startLocationUpdates() + // viewModel.setLocationPermissions(it) } permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> { // TODO: Probably alert the user that this is unusable for navigation @@ -80,7 +84,7 @@ fun DemoNavigationScene( LaunchedEffect(savedInstanceState) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { - viewModel.startLocationUpdates() + viewModel.setLocationPermissions(true) } else { permissionsLauncher.launch(allPermissions) } @@ -88,6 +92,7 @@ fun DemoNavigationScene( // Set up the map! val camera = rememberSaveableMapViewCamera(MapViewCamera.TrackingUserLocation()) + DynamicallyOrientingNavigationView( modifier = Modifier.fillMaxSize(), styleUrl = AppModule.mapStyleUrl, @@ -99,16 +104,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..99d022df6 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,49 @@ package com.stadiamaps.ferrostar +import android.location.Location import android.util.Log import androidx.lifecycle.viewModelScope 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.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 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. @@ -70,6 +75,31 @@ class DemoNavigationViewModel( null, null)) + 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 + } + override fun toggleMute() { val spokenInstructionObserver = ferrostarCore.spokenInstructionObserver if (spokenInstructionObserver == null) { @@ -79,15 +109,33 @@ class DemoNavigationViewModel( spokenInstructionObserver.setMuted(!spokenInstructionObserver.isMuted) } - override fun stopNavigation(stopLocationUpdates: Boolean) { - ferrostarCore.stopNavigation(stopLocationUpdates = stopLocationUpdates) - } + fun startNavigation(destination: Location) { + viewModelScope.launch(Dispatchers.IO) { + // TODO: Fail gracefully + val lastLocation = location.value ?: return@launch + + val routes = + ferrostarCore.getRoutes( + lastLocation, + listOf( + Waypoint( + coordinate = + GeographicCoordinate(destination.latitude, destination.longitude), + kind = WaypointKind.BREAK), + )) + + val route = routes.first() - override fun onLocationUpdated(location: UserLocation) { - locationStateFlow.update { location } + if (simulated.value) { + locationProvider.enableSimulationOn(route) + } + + ferrostarCore.startNavigation(route = route) + } } - override fun onHeadingUpdated(heading: Heading) { - // TODO: Heading + override fun stopNavigation() { + locationProvider.disableSimulation() + ferrostarCore.stopNavigation() } } 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..e2affeb74 --- /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) + } + } + } + }, + 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/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..1e36e826c 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,118 @@ 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 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) + override suspend fun getLastLocation(priority: Int): Location? = + suspendCancellableCoroutine { continuation -> + val requestStart = System.currentTimeMillis() + Log.d(TAG, "Requesting last location") + val cancellationTokenSource = CancellationTokenSource() + + val request = LastLocationRequest.Builder() .build() - if (lastLocation == null) { - fusedLocationProviderClient.lastLocation.addOnSuccessListener { location -> - if (location != null) { - androidListener.onLocationChanged(location) + fusedLocationClient + .getLastLocation(request) + .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) } + + continuation.invokeOnCancellation { + val durationSeconds = (System.currentTimeMillis() - requestStart) / 1000.0 + Log.d(TAG, "Last location cancelled after $durationSeconds s") + cancellationTokenSource.cancel() } } - } - fusedLocationProviderClient.requestLocationUpdates(locationRequest, executor, androidListener) - } - override fun removeListener(listener: LocationUpdateListener) { - val activeListener = listeners.remove(listener) + // 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() + } + } + + // 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) } + } + } + + fusedLocationClient.requestLocationUpdates(request, callback, Looper.getMainLooper()) - if (activeListener != null) { - fusedLocationProviderClient.removeLocationUpdates(activeListener) - } + 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/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index cd31ba813..c6b0901b1 100644 --- a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/maplibreui/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 From 58e0e36e70e385936eb79be43d391842a66063b2 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sat, 14 Mar 2026 16:35:24 -0700 Subject: [PATCH 02/15] feat: automated flow based location providing --- android/core/build.gradle | 5 +- .../ferrostar/core/ExampleInstrumentedTest.kt | 22 -- .../ferrostar/core/FerrostarCoreTest.kt | 9 +- .../core/SimulatedLocationProviderTest.kt | 112 ++++++++++ .../ferrostar/core/ValhallaCoreTest.kt | 1 + .../core/location/AndroidLocationProvider.kt | 2 +- .../location/SimulatedLocationProvider.kt | 2 + .../core/SimulatedLocationProviderTest.kt | 58 ------ .../location/AndroidLocationProviderTest.kt | 147 +++++++++++++ .../location/SimulatedLocationProviderTest.kt | 26 +++ android/google-play-services/build.gradle | 6 + .../FusedLocationProvider.kt | 17 +- .../googleplayservices/ExampleUnitTest.kt | 16 -- .../FusedLocationProviderTest.kt | 195 ++++++++++++++++++ .../FusedNavigationLocationProviderTest.kt | 60 ++++++ 15 files changed, 557 insertions(+), 121 deletions(-) delete mode 100644 android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ExampleInstrumentedTest.kt create mode 100644 android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt delete mode 100644 android/core/src/test/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt create mode 100644 android/core/src/test/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProviderTest.kt create mode 100644 android/core/src/test/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProviderTest.kt delete mode 100644 android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/ExampleUnitTest.kt create mode 100644 android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProviderTest.kt create mode 100644 android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProviderTest.kt diff --git a/android/core/build.gradle b/android/core/build.gradle index 5d771e7c3..73f81d1bd 100644 --- a/android/core/build.gradle +++ b/android/core/build.gradle @@ -31,13 +31,11 @@ android { } testOptions { targetSdk 36 + unitTests.returnDefaultValues = true } lint { targetSdk 36 } - testOptions { - targetSdk 36 - } } dependencies { @@ -69,6 +67,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/ExampleInstrumentedTest.kt b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ExampleInstrumentedTest.kt deleted file mode 100644 index 16249b200..000000000 --- a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.stadiamaps.ferrostar.core - -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.core.test", appContext.packageName) - } -} 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/SimulatedLocationProviderTest.kt b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt new file mode 100644 index 000000000..ea9155162 --- /dev/null +++ b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt @@ -0,0 +1,112 @@ +package com.stadiamaps.ferrostar.core + +import app.cash.turbine.test +import com.stadiamaps.ferrostar.core.location.SimulatedLocationProvider +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import uniffi.ferrostar.RouteAdapter +import uniffi.ferrostar.WellKnownRouteProvider + +private const val valhallaEndpointUrl = "https://api.stadiamaps.com/navigate/v1" + +class SimulatedLocationProviderTest { + private fun parseRoute() = + RouteAdapter.fromWellKnownRouteProvider( + WellKnownRouteProvider.Valhalla(valhallaEndpointUrl, "auto")) + .parseResponse(simpleRoute.trimIndent().toByteArray()) + .first() + + @Test + fun locationUpdatesEmitsAfterSetRoute() = runTest { + val provider = SimulatedLocationProvider(scope = backgroundScope, warpFactor = 8u) + val route = parseRoute() + + provider.locationUpdates().test { + provider.setRoute(route) + + val first = awaitItem() + assertNotNull(first) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun lastLocationTracksRecentlyEmittedLocation() = runTest { + val provider = SimulatedLocationProvider(scope = backgroundScope, warpFactor = 8u) + val route = parseRoute() + + provider.locationUpdates().test { + provider.setRoute(route) + + awaitItem() // first + val second = awaitItem() + + assertEquals(second, provider.lastLocation()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun setRouteRestartsSimulationFromBeginning() = runTest { + val provider = SimulatedLocationProvider(scope = backgroundScope, warpFactor = 8u) + val route = parseRoute() + val startCoord = route.geometry.first() + + provider.locationUpdates().test { + provider.setRoute(route) + + // Advance a few steps into the simulation + repeat(5) { awaitItem() } + + // Reset with the same route — simulation should restart from the beginning + provider.setRoute(route) + val restarted = awaitItem() + + assertEquals(startCoord.lat, restarted.latitude, 0.001) + assertEquals(startCoord.lng, restarted.longitude, 0.001) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun multipleCollectorsReceiveSameLocationsViaSharedReplay() = runTest { + val provider = SimulatedLocationProvider(scope = backgroundScope, warpFactor = 8u) + val route = parseRoute() + + val collector1 = mutableListOf() + val collector2 = mutableListOf() + + provider.setRoute(route) + + // Collect 3 items on each subscriber concurrently + val job1 = launch { provider.locationUpdates().take(3).collect { collector1.add(it) } } + val job2 = launch { provider.locationUpdates().take(3).collect { collector2.add(it) } } + + job1.join() + job2.join() + + assertEquals(3, collector1.size) + assertEquals(3, collector2.size) + + // Both collectors should have started from the same replayed position + assertEquals(collector1.first().latitude, collector2.first().latitude, 0.0001) + assertEquals(collector1.first().longitude, collector2.first().longitude, 0.0001) + } + + @Test + fun locationUpdatesEmitsNothingBeforeSetRoute() = runTest { + val provider = SimulatedLocationProvider(scope = backgroundScope) + assertNull(provider.lastLocation()) + + provider.locationUpdates().test { + // No route set — should not emit anything + expectNoEvents() + cancel() + } + } +} 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/location/AndroidLocationProvider.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProvider.kt index d7735a10c..db59db698 100644 --- 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 @@ -64,6 +64,6 @@ class AndroidLocationProvider(context: Context) : NavigationLocationProviding { } companion object { - private const val TAG = "AndroidNavigationLocationProvider" + private const val TAG = "AndroidLocationProvider" } } 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 index 47bf0eebb..cb43af66d 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -33,6 +34,7 @@ class SimulatedLocationProvider( // 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 -> 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..fed579e4f --- /dev/null +++ b/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProviderTest.kt @@ -0,0 +1,147 @@ +package com.stadiamaps.ferrostar.core.location + +import android.content.Context +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import app.cash.turbine.test +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +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() { + 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..a57c4cb83 --- /dev/null +++ b/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProviderTest.kt @@ -0,0 +1,26 @@ +package com.stadiamaps.ferrostar.core.location + +import android.location.Location +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 = + Location("test").apply { + latitude = 60.534716 + longitude = -149.543469 + } + val provider = SimulatedLocationProvider(initialLocation = location) + assertSame(location, provider.lastLocation()) + } +} diff --git a/android/google-play-services/build.gradle b/android/google-play-services/build.gradle index 0ad880810..e74d9fe49 100644 --- a/android/google-play-services/build.gradle +++ b/android/google-play-services/build.gradle @@ -22,6 +22,9 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + testOptions { + unitTests.returnDefaultValues = true + } } dependencies { @@ -36,6 +39,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 1e36e826c..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 @@ -15,6 +15,7 @@ import com.google.android.gms.location.Priority 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 @@ -41,28 +42,18 @@ class FusedLocationProvider(context: Context) : LocationProviding { @SuppressLint("MissingPermission") override suspend fun getLastLocation(priority: Int): Location? = - suspendCancellableCoroutine { continuation -> - val requestStart = System.currentTimeMillis() + suspendCoroutine { continuation -> Log.d(TAG, "Requesting last location") - val cancellationTokenSource = CancellationTokenSource() - - val request = LastLocationRequest.Builder() - .build() + val requestStart = System.currentTimeMillis() fusedLocationClient - .getLastLocation(request) + .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) } - - continuation.invokeOnCancellation { - val durationSeconds = (System.currentTimeMillis() - requestStart) / 1000.0 - Log.d(TAG, "Last location cancelled after $durationSeconds s") - cancellationTokenSource.cancel() - } } // Get the next fresh location update with timeout diff --git a/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/ExampleUnitTest.kt b/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/ExampleUnitTest.kt deleted file mode 100644 index 1dfb1e72a..000000000 --- a/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.stadiamaps.ferrostar.googleplayservices - -import org.junit.Assert.* -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} 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..9a38403d3 --- /dev/null +++ b/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProviderTest.kt @@ -0,0 +1,195 @@ +package com.stadiamaps.ferrostar.googleplayservices + +import android.content.Context +import android.location.Location +import android.os.Looper +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 + } + + @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) + } +} From 55071d8101584c2eb7464c9ebe4ba214e74b1340 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sat, 14 Mar 2026 17:42:20 -0700 Subject: [PATCH 03/15] feat: moves formatters to shared ui module --- android/demo-app/build.gradle | 4 +- android/settings.gradle | 5 +- .../{composeui => ui-compose}/build.gradle | 1 + .../consumer-rules.pro | 0 .../src/main/AndroidManifest.xml | 0 .../config/NavigationViewComponentBuilder.kt | 0 .../config/VisualNavigationViewConfig.kt | 0 .../composeui/measurement/LocalizedSpeed.kt | 6 + .../composeui/models/CameraControlState.kt | 0 .../composeui/models/NavigationViewMetrics.kt | 0 .../DefaultForegroundNotificationBuilder.kt | 12 +- .../runtime/KeepScreenOnDisposableEffect.kt | 0 .../composeui/runtime/WindowInsetSupport.kt | 0 .../composeui/support/GreenScreenPreview.kt | 0 .../composeui/theme/InstructionRowTheme.kt | 0 .../composeui/theme/NavigationUITheme.kt | 0 .../composeui/theme/RoadNameViewTheme.kt | 0 .../composeui/theme/TripProgressViewTheme.kt | 0 .../views/components/CurrentRoadView.kt | 0 .../views/components/InstructionsView.kt | 4 +- .../views/components/TripProgressView.kt | 12 +- .../components/controls/NavigationUIButton.kt | 0 .../controls/NavigationUIZoomButton.kt | 0 .../components/controls/PillDragHandle.kt | 0 .../components/gridviews/InnerGridView.kt | 0 .../gridviews/NavigatingInnerGridView.kt | 0 .../components/maneuver/ManeuverImage.kt | 0 .../maneuver/ManeuverInstructionView.kt | 4 +- .../components/speedlimit/SpeedLimitView.kt | 2 +- .../speedlimit/USStyleSpeedLimitView.kt | 4 +- .../ViennaConventionStyleSpeedLimitView.kt | 4 +- .../LandscapeNavigationOverlayView.kt | 0 .../overlays/PortraitNavigationOverlayView.kt | 0 .../main/res/drawable/direction_arrive.xml | 0 .../res/drawable/direction_arrive_left.xml | 0 .../res/drawable/direction_arrive_right.xml | 0 .../drawable/direction_arrive_straight.xml | 0 .../src/main/res/drawable/direction_close.xml | 0 .../main/res/drawable/direction_continue.xml | 0 .../res/drawable/direction_continue_left.xml | 0 .../res/drawable/direction_continue_right.xml | 0 .../direction_continue_slight_left.xml | 0 .../direction_continue_slight_right.xml | 0 .../drawable/direction_continue_straight.xml | 0 .../drawable/direction_continue_u_turn.xml | 0 .../main/res/drawable/direction_depart.xml | 0 .../res/drawable/direction_depart_left.xml | 0 .../res/drawable/direction_depart_right.xml | 0 .../drawable/direction_depart_straight.xml | 0 .../drawable/direction_end_of_road_left.xml | 0 .../drawable/direction_end_of_road_right.xml | 0 .../src/main/res/drawable/direction_flag.xml | 0 .../src/main/res/drawable/direction_fork.xml | 0 .../main/res/drawable/direction_fork_left.xml | 0 .../res/drawable/direction_fork_right.xml | 0 .../drawable/direction_fork_slight_left.xml | 0 .../drawable/direction_fork_slight_right.xml | 0 .../res/drawable/direction_fork_straight.xml | 0 .../main/res/drawable/direction_invalid.xml | 0 .../res/drawable/direction_invalid_left.xml | 0 .../res/drawable/direction_invalid_right.xml | 0 .../direction_invalid_slight_left.xml | 0 .../direction_invalid_slight_right.xml | 0 .../drawable/direction_invalid_straight.xml | 0 .../res/drawable/direction_invalid_u_turn.xml | 0 .../res/drawable/direction_merge_left.xml | 0 .../res/drawable/direction_merge_right.xml | 0 .../drawable/direction_merge_slight_left.xml | 0 .../drawable/direction_merge_slight_right.xml | 0 .../res/drawable/direction_merge_straight.xml | 0 .../res/drawable/direction_new_name_left.xml | 0 .../res/drawable/direction_new_name_right.xml | 0 .../direction_new_name_sharp_left.xml | 0 .../direction_new_name_sharp_right.xml | 0 .../direction_new_name_slight_left.xml | 0 .../direction_new_name_slight_right.xml | 0 .../drawable/direction_new_name_straight.xml | 0 .../drawable/direction_notification_left.xml | 0 .../drawable/direction_notification_right.xml | 0 .../direction_notification_sharp_left.xml | 0 .../direction_notification_sharp_right.xml | 0 .../direction_notification_slight_left.xml | 0 .../direction_notification_slight_right.xml | 0 .../direction_notification_straight.xml | 0 .../main/res/drawable/direction_off_ramp.xml | 0 .../res/drawable/direction_off_ramp_left.xml | 0 .../res/drawable/direction_off_ramp_right.xml | 0 .../direction_off_ramp_slight_left.xml | 0 .../direction_off_ramp_slight_right.xml | 0 .../main/res/drawable/direction_on_ramp.xml | 0 .../res/drawable/direction_on_ramp_left.xml | 0 .../res/drawable/direction_on_ramp_right.xml | 0 .../drawable/direction_on_ramp_sharp_left.xml | 0 .../direction_on_ramp_sharp_right.xml | 0 .../direction_on_ramp_slight_left.xml | 0 .../direction_on_ramp_slight_right.xml | 0 .../drawable/direction_on_ramp_straight.xml | 0 .../src/main/res/drawable/direction_ramp.xml | 0 .../main/res/drawable/direction_rotary.xml | 0 .../res/drawable/direction_rotary_left.xml | 0 .../res/drawable/direction_rotary_right.xml | 0 .../drawable/direction_rotary_sharp_left.xml | 0 .../drawable/direction_rotary_sharp_right.xml | 0 .../drawable/direction_rotary_slight_left.xml | 0 .../direction_rotary_slight_right.xml | 0 .../drawable/direction_rotary_straight.xml | 0 .../res/drawable/direction_roundabout.xml | 0 .../drawable/direction_roundabout_left.xml | 0 .../drawable/direction_roundabout_right.xml | 0 .../direction_roundabout_sharp_left.xml | 0 .../direction_roundabout_sharp_right.xml | 0 .../direction_roundabout_slight_left.xml | 0 .../direction_roundabout_slight_right.xml | 0 .../direction_roundabout_straight.xml | 0 .../res/drawable/direction_traffic_circle.xml | 0 .../direction_traffic_circle_left.xml | 0 .../direction_traffic_circle_right.xml | 0 .../direction_traffic_circle_slight_left.xml | 0 .../direction_traffic_circle_slight_right.xml | 0 .../main/res/drawable/direction_turn_left.xml | 0 .../res/drawable/direction_turn_right.xml | 0 .../drawable/direction_turn_sharp_left.xml | 0 .../drawable/direction_turn_sharp_right.xml | 0 .../drawable/direction_turn_slight_left.xml | 0 .../drawable/direction_turn_slight_right.xml | 0 .../res/drawable/direction_turn_straight.xml | 0 .../main/res/drawable/direction_u_turn.xml | 0 .../main/res/drawable/direction_updown.xml | 0 .../main/res/drawable/notification_icon.xml | 0 .../src/main/res/drawable/rounded_button.xml | 0 .../expanded_navigation_notification.xml | 0 .../res/layout/navigation_notification.xml | 0 .../src/main/res/values/colors.xml | 0 .../src/main/res/values/strings.xml | 5 - .../ferrostar/RoundToNearestTest.kt | 2 +- .../ferrostar/support/SnapshotTestSupport.kt | 0 .../ferrostar/views/InnerGridViewTest.kt | 0 .../ferrostar/views/InstructionViewTest.kt | 0 .../ferrostar/views/ManeuverImageTest.kt | 0 .../views/NavigatingInnerGridViewTest.kt | 0 .../ferrostar/views/NavigationUIButtonTest.kt | 0 .../views/RTLInstructionViewTests.kt | 2 +- .../ferrostar/views/TripProgressViewTest.kt | 0 .../views/USStyleSpeedLimitViewTest.kt | 0 .../views/ViennaStyleSpeedLimitViewTest.kt | 0 ...InnerGridViewTest_testInnerGridViewAll.png | Bin ...dViewTest_testInnerGridViewSpecialized.png | Bin ...nstructionViewTest_testInstructionView.png | Bin ...onViewTest_testInstructionViewExpanded.png | Bin ...ImageTest_testManeuverImageCustomColor.png | Bin ...verImageTest_testManeuverImageForkLeft.png | Bin ...erImageTest_testManeuverImageTurnRight.png | Bin ...testNavigatingInnerGridViewNonTracking.png | Bin ...atingInnerGridViewNonTrackingLandscape.png | Bin ...st_testNavigatingInnerGridViewTracking.png | Bin ...vigatingInnerGridViewTrackingLandscape.png | Bin ...ionUIButtonTest_testNavigationUIButton.png | Bin ...nTest_testNavigationUIButtonCustomized.png | Bin ...IButtonTest_testNavigationUIZoomButton.png | Bin ...t_testNavigationUIZoomButtonCustomized.png | Bin ...uctionViewTests_testRTLInstructionView.png | Bin ..._TripProgressViewTest_testProgressView.png | Bin ...rogressViewTest_testProgressView24Hour.png | Bin ...est_testProgressViewInformationalStyle.png | Bin ...gressViewTest_testProgressViewWithExit.png | Bin ...eSpeedLimitViewTest_testFastSpeedValue.png | Bin ...imitViewTest_testImplausibleSpeedValue.png | Bin ...ewTest_testKilometersPerHourSpeedValue.png | Bin ...SpeedLimitViewTest_testKnotsSpeedValue.png | Bin ...leSpeedLimitViewTest_testLowSpeedValue.png | Bin ...eSpeedLimitViewTest_testFastSpeedValue.png | Bin ...imitViewTest_testImplausibleSpeedValue.png | Bin ...SpeedLimitViewTest_testKnotsSpeedValue.png | Bin ...leSpeedLimitViewTest_testLowSpeedValue.png | Bin ...ViewTest_testMetersPerSecondSpeedValue.png | Bin ...mitViewTest_testMilesPerHourSpeedValue.png | Bin .../{maplibreui => ui-formatters}/.gitignore | 0 android/ui-formatters/build.gradle | 43 ++++ .../consumer-rules.pro | 0 android/ui-formatters/proguard-rules.pro | 21 ++ .../LocalizedDistanceFormatterDETest.kt | 4 +- ...erDistanceMeasurementSystemOverrideTest.kt | 6 +- .../LocalizedDistanceFormatterUKTest.kt | 3 +- .../LocalizedDistanceFormatterUSTest.kt | 3 +- .../src/main/AndroidManifest.xml | 0 .../ui/formatters}/DateTimeFormatter.kt | 2 +- .../ui/formatters}/DistanceFormatter.kt | 213 ++++++++++-------- .../ui/formatters}/DurationFormatter.kt | 2 +- .../ui/formatters}/FormatterCollection.kt | 2 +- .../formatters}/MeasurementSpeedFormatter.kt | 4 +- .../ui/formatters/runtime/LocalizedUnit.kt} | 4 +- .../src/main/res/values/strings.xml | 7 + .../CombinedDurationFormatterTests.kt | 4 +- .../SingularDurationFormatterTest.kt | 4 +- android/ui-maplibre/.gitignore | 1 + .../{maplibreui => ui-maplibre}/build.gradle | 2 +- android/ui-maplibre/consumer-rules.pro | 0 .../maplibreui/ExampleInstrumentedTest.kt | 0 .../ui-maplibre/src/main/AndroidManifest.xml | 4 + .../ferrostar/maplibreui/NavigationMapView.kt | 0 .../extensions/LocationRequestProperties.kt | 0 .../extensions/VisualNavigationViewConfig.kt | 0 .../maplibreui/routeline/BorderedPolyline.kt | 0 .../routeline/RouteOverlayBuilder.kt | 0 .../maplibreui/runtime/MapControls.kt | 0 .../maplibreui/runtime/NavigationCamera.kt | 0 .../DynamicallyOrientingNavigationView.kt | 0 .../views/LandscapeNavigationView.kt | 0 .../views/PortraitNavigationView.kt | 0 .../config/VisualNavigationViewConfigTest.kt | 0 210 files changed, 252 insertions(+), 144 deletions(-) rename android/{composeui => ui-compose}/build.gradle (98%) rename android/{composeui => ui-compose}/consumer-rules.pro (100%) rename android/{composeui => ui-compose}/src/main/AndroidManifest.xml (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/config/NavigationViewComponentBuilder.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/config/VisualNavigationViewConfig.kt (100%) create mode 100644 android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/measurement/LocalizedSpeed.kt rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/models/CameraControlState.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/models/NavigationViewMetrics.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt (90%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/KeepScreenOnDisposableEffect.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/WindowInsetSupport.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/support/GreenScreenPreview.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/theme/InstructionRowTheme.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/theme/NavigationUITheme.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/theme/RoadNameViewTheme.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/theme/TripProgressViewTheme.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/CurrentRoadView.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/InstructionsView.kt (98%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/TripProgressView.kt (95%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIButton.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIZoomButton.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/PillDragHandle.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/InnerGridView.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/NavigatingInnerGridView.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverImage.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverInstructionView.kt (95%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/SpeedLimitView.kt (93%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/USStyleSpeedLimitView.kt (97%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/ViennaConventionStyleSpeedLimitView.kt (96%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/LandscapeNavigationOverlayView.kt (100%) rename android/{composeui => ui-compose}/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/PortraitNavigationOverlayView.kt (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_arrive.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_arrive_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_arrive_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_arrive_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_close.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_continue.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_continue_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_continue_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_continue_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_continue_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_continue_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_continue_u_turn.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_depart.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_depart_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_depart_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_depart_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_end_of_road_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_end_of_road_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_flag.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_fork.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_fork_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_fork_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_fork_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_fork_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_fork_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_invalid.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_invalid_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_invalid_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_invalid_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_invalid_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_invalid_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_invalid_u_turn.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_merge_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_merge_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_merge_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_merge_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_merge_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_new_name_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_new_name_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_new_name_sharp_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_new_name_sharp_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_new_name_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_new_name_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_new_name_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_notification_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_notification_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_notification_sharp_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_notification_sharp_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_notification_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_notification_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_notification_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_off_ramp.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_off_ramp_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_off_ramp_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_off_ramp_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_off_ramp_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_on_ramp.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_on_ramp_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_on_ramp_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_on_ramp_sharp_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_on_ramp_sharp_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_on_ramp_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_on_ramp_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_on_ramp_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_ramp.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_rotary.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_rotary_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_rotary_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_rotary_sharp_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_rotary_sharp_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_rotary_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_rotary_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_rotary_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_roundabout.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_roundabout_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_roundabout_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_roundabout_sharp_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_roundabout_sharp_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_roundabout_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_roundabout_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_roundabout_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_traffic_circle.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_traffic_circle_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_traffic_circle_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_traffic_circle_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_traffic_circle_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_turn_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_turn_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_turn_sharp_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_turn_sharp_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_turn_slight_left.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_turn_slight_right.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_turn_straight.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_u_turn.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/direction_updown.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/notification_icon.xml (100%) rename android/{composeui => ui-compose}/src/main/res/drawable/rounded_button.xml (100%) rename android/{composeui => ui-compose}/src/main/res/layout/expanded_navigation_notification.xml (100%) rename android/{composeui => ui-compose}/src/main/res/layout/navigation_notification.xml (100%) rename android/{composeui => ui-compose}/src/main/res/values/colors.xml (100%) rename android/{composeui => ui-compose}/src/main/res/values/strings.xml (85%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt (94%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/support/SnapshotTestSupport.kt (100%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/views/InnerGridViewTest.kt (100%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/views/InstructionViewTest.kt (100%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/views/ManeuverImageTest.kt (100%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/views/NavigatingInnerGridViewTest.kt (100%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/views/NavigationUIButtonTest.kt (100%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/views/RTLInstructionViewTests.kt (95%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/views/TripProgressViewTest.kt (100%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/views/USStyleSpeedLimitViewTest.kt (100%) rename android/{composeui => ui-compose}/src/test/java/com/stadiamaps/ferrostar/views/ViennaStyleSpeedLimitViewTest.kt (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewAll.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewSpecialized.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionView.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionViewExpanded.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageCustomColor.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageForkLeft.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageTurnRight.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTracking.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTrackingLandscape.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTracking.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTrackingLandscape.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButton.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButtonCustomized.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButton.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButtonCustomized.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_RTLInstructionViewTests_testRTLInstructionView.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView24Hour.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewInformationalStyle.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewWithExit.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testFastSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testImplausibleSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKilometersPerHourSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKnotsSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testLowSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testFastSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testImplausibleSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testKnotsSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testLowSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMetersPerSecondSpeedValue.png (100%) rename android/{composeui => ui-compose}/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMilesPerHourSpeedValue.png (100%) rename android/{maplibreui => ui-formatters}/.gitignore (100%) create mode 100644 android/ui-formatters/build.gradle rename android/{maplibreui => ui-formatters}/consumer-rules.pro (100%) create mode 100644 android/ui-formatters/proguard-rules.pro rename android/{composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui => ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters}/LocalizedDistanceFormatterDETest.kt (92%) rename android/{composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui => ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters}/LocalizedDistanceFormatterDistanceMeasurementSystemOverrideTest.kt (84%) rename android/{composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui => ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters}/LocalizedDistanceFormatterUKTest.kt (91%) rename android/{composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui => ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters}/LocalizedDistanceFormatterUSTest.kt (93%) rename android/{maplibreui => ui-formatters}/src/main/AndroidManifest.xml (100%) rename android/{composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting => ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters}/DateTimeFormatter.kt (93%) rename android/{composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting => ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters}/DistanceFormatter.kt (53%) rename android/{composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting => ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters}/DurationFormatter.kt (98%) rename android/{composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting => ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters}/FormatterCollection.kt (93%) rename android/{composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting => ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters}/MeasurementSpeedFormatter.kt (93%) rename android/{composeui/src/main/java/com/stadiamaps/ferrostar/composeui/measurement/LocalizedSpeed.kt => ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/runtime/LocalizedUnit.kt} (83%) create mode 100644 android/ui-formatters/src/main/res/values/strings.xml rename android/{composeui/src/test/java/com/stadiamaps/ferrostar/formatting => ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters}/CombinedDurationFormatterTests.kt (92%) rename android/{composeui/src/test/java/com/stadiamaps/ferrostar/formatting => ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters}/SingularDurationFormatterTest.kt (93%) create mode 100644 android/ui-maplibre/.gitignore rename android/{maplibreui => ui-maplibre}/build.gradle (98%) create mode 100644 android/ui-maplibre/consumer-rules.pro rename android/{maplibreui => ui-maplibre}/src/androidTest/java/com/stadiamaps/ferrostar/maplibreui/ExampleInstrumentedTest.kt (100%) create mode 100644 android/ui-maplibre/src/main/AndroidManifest.xml rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt (100%) rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/LocationRequestProperties.kt (100%) rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/VisualNavigationViewConfig.kt (100%) rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/BorderedPolyline.kt (100%) rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteOverlayBuilder.kt (100%) rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapControls.kt (100%) rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt (100%) rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt (100%) rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt (100%) rename android/{maplibreui => ui-maplibre}/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt (100%) rename android/{maplibreui => ui-maplibre}/src/test/java/com/stadiamaps/ferrostar/maplibreui/config/VisualNavigationViewConfigTest.kt (100%) diff --git a/android/demo-app/build.gradle b/android/demo-app/build.gradle index 49fcf8b3d..25cf3a921 100644 --- a/android/demo-app/build.gradle +++ b/android/demo-app/build.gradle @@ -64,8 +64,8 @@ dependencies { implementation libs.androidx.compose.material3 implementation project(':core') - implementation project(':composeui') - implementation project(':maplibreui') + implementation project(':ui-compose') + implementation project(':ui-maplibre') implementation project(':google-play-services') implementation libs.maplibre.compose diff --git a/android/settings.gradle b/android/settings.gradle index e34d6ca0a..6e46753f8 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -14,7 +14,8 @@ dependencyResolutionManagement { } rootProject.name = 'Ferrostar' include ':core' -include ':composeui' +include ':ui-compose' include ':demo-app' -include ':maplibreui' +include ':ui-maplibre' include ':google-play-services' +include ':ui-formatters' diff --git a/android/composeui/build.gradle b/android/ui-compose/build.gradle similarity index 98% rename from android/composeui/build.gradle rename to android/ui-compose/build.gradle index 91244558e..6599a9eb3 100644 --- a/android/composeui/build.gradle +++ b/android/ui-compose/build.gradle @@ -50,6 +50,7 @@ dependencies { implementation libs.androidx.compose.material.icon.extended implementation project(':core') + implementation project(':ui-formatters') testImplementation libs.junit androidTestImplementation libs.androidx.test.junit diff --git a/android/composeui/consumer-rules.pro b/android/ui-compose/consumer-rules.pro similarity index 100% rename from android/composeui/consumer-rules.pro rename to android/ui-compose/consumer-rules.pro diff --git a/android/composeui/src/main/AndroidManifest.xml b/android/ui-compose/src/main/AndroidManifest.xml similarity index 100% rename from android/composeui/src/main/AndroidManifest.xml rename to android/ui-compose/src/main/AndroidManifest.xml diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/config/NavigationViewComponentBuilder.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/config/NavigationViewComponentBuilder.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/config/NavigationViewComponentBuilder.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/config/NavigationViewComponentBuilder.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/config/VisualNavigationViewConfig.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/config/VisualNavigationViewConfig.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/config/VisualNavigationViewConfig.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/config/VisualNavigationViewConfig.kt 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/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/models/CameraControlState.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/models/CameraControlState.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/models/CameraControlState.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/models/CameraControlState.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/models/NavigationViewMetrics.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/models/NavigationViewMetrics.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/models/NavigationViewMetrics.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/models/NavigationViewMetrics.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt similarity index 90% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt index 350dd65cd..ce0884575 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt +++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt @@ -6,12 +6,12 @@ import android.content.Context import android.os.Build import android.widget.RemoteViews import com.stadiamaps.ferrostar.composeui.R -import com.stadiamaps.ferrostar.composeui.formatting.DateTimeFormatter -import com.stadiamaps.ferrostar.composeui.formatting.DistanceFormatter -import com.stadiamaps.ferrostar.composeui.formatting.DurationFormatter -import com.stadiamaps.ferrostar.composeui.formatting.EstimatedArrivalDateTimeFormatter -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDurationFormatter +import com.stadiamaps.ferrostar.ui.formatters.DateTimeFormatter +import com.stadiamaps.ferrostar.ui.formatters.DistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.DurationFormatter +import com.stadiamaps.ferrostar.ui.formatters.EstimatedArrivalDateTimeFormatter +import com.stadiamaps.ferrostar.ui.formatters.LocalizedDistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.LocalizedDurationFormatter import com.stadiamaps.ferrostar.composeui.views.components.maneuver.maneuverIcon import com.stadiamaps.ferrostar.core.extensions.estimatedArrivalTime import com.stadiamaps.ferrostar.core.service.ForegroundNotificationBuilder diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/KeepScreenOnDisposableEffect.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/KeepScreenOnDisposableEffect.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/KeepScreenOnDisposableEffect.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/KeepScreenOnDisposableEffect.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/WindowInsetSupport.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/WindowInsetSupport.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/WindowInsetSupport.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/runtime/WindowInsetSupport.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/support/GreenScreenPreview.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/support/GreenScreenPreview.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/support/GreenScreenPreview.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/support/GreenScreenPreview.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/InstructionRowTheme.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/theme/InstructionRowTheme.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/InstructionRowTheme.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/theme/InstructionRowTheme.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/NavigationUITheme.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/theme/NavigationUITheme.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/NavigationUITheme.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/theme/NavigationUITheme.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/RoadNameViewTheme.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/theme/RoadNameViewTheme.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/RoadNameViewTheme.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/theme/RoadNameViewTheme.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/TripProgressViewTheme.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/theme/TripProgressViewTheme.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/theme/TripProgressViewTheme.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/theme/TripProgressViewTheme.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/CurrentRoadView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/CurrentRoadView.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/CurrentRoadView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/CurrentRoadView.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/InstructionsView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/InstructionsView.kt similarity index 98% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/InstructionsView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/InstructionsView.kt index 6f1f5f8e7..8662e5f10 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/InstructionsView.kt +++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/InstructionsView.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import com.stadiamaps.ferrostar.composeui.formatting.DistanceFormatter -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.DistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.LocalizedDistanceFormatter import com.stadiamaps.ferrostar.composeui.theme.DefaultInstructionRowTheme import com.stadiamaps.ferrostar.composeui.theme.InstructionRowTheme import com.stadiamaps.ferrostar.composeui.views.components.controls.PillDragHandle diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/TripProgressView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/TripProgressView.kt similarity index 95% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/TripProgressView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/TripProgressView.kt index 38a18ecc2..3ecdff2ef 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/TripProgressView.kt +++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/TripProgressView.kt @@ -29,12 +29,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.stadiamaps.ferrostar.composeui.R -import com.stadiamaps.ferrostar.composeui.formatting.DateTimeFormatter -import com.stadiamaps.ferrostar.composeui.formatting.DistanceFormatter -import com.stadiamaps.ferrostar.composeui.formatting.DurationFormatter -import com.stadiamaps.ferrostar.composeui.formatting.EstimatedArrivalDateTimeFormatter -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDurationFormatter +import com.stadiamaps.ferrostar.ui.formatters.DateTimeFormatter +import com.stadiamaps.ferrostar.ui.formatters.DistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.DurationFormatter +import com.stadiamaps.ferrostar.ui.formatters.EstimatedArrivalDateTimeFormatter +import com.stadiamaps.ferrostar.ui.formatters.LocalizedDistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.LocalizedDurationFormatter import com.stadiamaps.ferrostar.composeui.theme.DefaultTripProgressViewTheme import com.stadiamaps.ferrostar.composeui.theme.TripProgressViewStyle import com.stadiamaps.ferrostar.composeui.theme.TripProgressViewTheme diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIButton.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIButton.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIButton.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIButton.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIZoomButton.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIZoomButton.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIZoomButton.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/NavigationUIZoomButton.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/PillDragHandle.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/PillDragHandle.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/PillDragHandle.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/controls/PillDragHandle.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/InnerGridView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/InnerGridView.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/InnerGridView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/InnerGridView.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/NavigatingInnerGridView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/NavigatingInnerGridView.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/NavigatingInnerGridView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/gridviews/NavigatingInnerGridView.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverImage.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverImage.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverImage.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverImage.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverInstructionView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverInstructionView.kt similarity index 95% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverInstructionView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverInstructionView.kt index 590a36cae..ddd38c233 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverInstructionView.kt +++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverInstructionView.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.stadiamaps.ferrostar.composeui.formatting.DistanceFormatter -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.DistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.LocalizedDistanceFormatter import com.stadiamaps.ferrostar.composeui.theme.DefaultInstructionRowTheme import com.stadiamaps.ferrostar.composeui.theme.InstructionRowTheme diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/SpeedLimitView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/SpeedLimitView.kt similarity index 93% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/SpeedLimitView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/SpeedLimitView.kt index efa025cf5..522fd0307 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/SpeedLimitView.kt +++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/SpeedLimitView.kt @@ -5,7 +5,7 @@ import android.icu.util.ULocale import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import com.stadiamaps.ferrostar.composeui.formatting.MeasurementSpeedFormatter +import com.stadiamaps.ferrostar.ui.formatters.MeasurementSpeedFormatter import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeed import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeedUnit diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/USStyleSpeedLimitView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/USStyleSpeedLimitView.kt similarity index 97% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/USStyleSpeedLimitView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/USStyleSpeedLimitView.kt index c2eba60e0..09b9c9db5 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/USStyleSpeedLimitView.kt +++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/USStyleSpeedLimitView.kt @@ -25,8 +25,8 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.stadiamaps.ferrostar.composeui.R -import com.stadiamaps.ferrostar.composeui.formatting.MeasurementSpeedFormatter -import com.stadiamaps.ferrostar.composeui.measurement.localizedString +import com.stadiamaps.ferrostar.ui.formatters.MeasurementSpeedFormatter +import com.stadiamaps.ferrostar.ui.formatters.runtime.localizedString import com.stadiamaps.ferrostar.composeui.support.GreenScreenPreview import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeed import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeedUnit diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/ViennaConventionStyleSpeedLimitView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/ViennaConventionStyleSpeedLimitView.kt similarity index 96% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/ViennaConventionStyleSpeedLimitView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/ViennaConventionStyleSpeedLimitView.kt index 157b66060..794a7b9d2 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/ViennaConventionStyleSpeedLimitView.kt +++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/speedlimit/ViennaConventionStyleSpeedLimitView.kt @@ -22,8 +22,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.stadiamaps.ferrostar.composeui.formatting.MeasurementSpeedFormatter -import com.stadiamaps.ferrostar.composeui.measurement.localizedString +import com.stadiamaps.ferrostar.ui.formatters.MeasurementSpeedFormatter +import com.stadiamaps.ferrostar.ui.formatters.runtime.localizedString import com.stadiamaps.ferrostar.composeui.support.GreenScreenPreview import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeed import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeedUnit diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/LandscapeNavigationOverlayView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/LandscapeNavigationOverlayView.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/LandscapeNavigationOverlayView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/LandscapeNavigationOverlayView.kt diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/PortraitNavigationOverlayView.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/PortraitNavigationOverlayView.kt similarity index 100% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/PortraitNavigationOverlayView.kt rename to android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/overlays/PortraitNavigationOverlayView.kt diff --git a/android/composeui/src/main/res/drawable/direction_arrive.xml b/android/ui-compose/src/main/res/drawable/direction_arrive.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_arrive.xml rename to android/ui-compose/src/main/res/drawable/direction_arrive.xml diff --git a/android/composeui/src/main/res/drawable/direction_arrive_left.xml b/android/ui-compose/src/main/res/drawable/direction_arrive_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_arrive_left.xml rename to android/ui-compose/src/main/res/drawable/direction_arrive_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_arrive_right.xml b/android/ui-compose/src/main/res/drawable/direction_arrive_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_arrive_right.xml rename to android/ui-compose/src/main/res/drawable/direction_arrive_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_arrive_straight.xml b/android/ui-compose/src/main/res/drawable/direction_arrive_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_arrive_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_arrive_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_close.xml b/android/ui-compose/src/main/res/drawable/direction_close.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_close.xml rename to android/ui-compose/src/main/res/drawable/direction_close.xml diff --git a/android/composeui/src/main/res/drawable/direction_continue.xml b/android/ui-compose/src/main/res/drawable/direction_continue.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_continue.xml rename to android/ui-compose/src/main/res/drawable/direction_continue.xml diff --git a/android/composeui/src/main/res/drawable/direction_continue_left.xml b/android/ui-compose/src/main/res/drawable/direction_continue_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_continue_left.xml rename to android/ui-compose/src/main/res/drawable/direction_continue_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_continue_right.xml b/android/ui-compose/src/main/res/drawable/direction_continue_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_continue_right.xml rename to android/ui-compose/src/main/res/drawable/direction_continue_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_continue_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_continue_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_continue_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_continue_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_continue_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_continue_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_continue_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_continue_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_continue_straight.xml b/android/ui-compose/src/main/res/drawable/direction_continue_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_continue_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_continue_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_continue_u_turn.xml b/android/ui-compose/src/main/res/drawable/direction_continue_u_turn.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_continue_u_turn.xml rename to android/ui-compose/src/main/res/drawable/direction_continue_u_turn.xml diff --git a/android/composeui/src/main/res/drawable/direction_depart.xml b/android/ui-compose/src/main/res/drawable/direction_depart.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_depart.xml rename to android/ui-compose/src/main/res/drawable/direction_depart.xml diff --git a/android/composeui/src/main/res/drawable/direction_depart_left.xml b/android/ui-compose/src/main/res/drawable/direction_depart_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_depart_left.xml rename to android/ui-compose/src/main/res/drawable/direction_depart_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_depart_right.xml b/android/ui-compose/src/main/res/drawable/direction_depart_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_depart_right.xml rename to android/ui-compose/src/main/res/drawable/direction_depart_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_depart_straight.xml b/android/ui-compose/src/main/res/drawable/direction_depart_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_depart_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_depart_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_end_of_road_left.xml b/android/ui-compose/src/main/res/drawable/direction_end_of_road_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_end_of_road_left.xml rename to android/ui-compose/src/main/res/drawable/direction_end_of_road_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_end_of_road_right.xml b/android/ui-compose/src/main/res/drawable/direction_end_of_road_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_end_of_road_right.xml rename to android/ui-compose/src/main/res/drawable/direction_end_of_road_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_flag.xml b/android/ui-compose/src/main/res/drawable/direction_flag.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_flag.xml rename to android/ui-compose/src/main/res/drawable/direction_flag.xml diff --git a/android/composeui/src/main/res/drawable/direction_fork.xml b/android/ui-compose/src/main/res/drawable/direction_fork.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_fork.xml rename to android/ui-compose/src/main/res/drawable/direction_fork.xml diff --git a/android/composeui/src/main/res/drawable/direction_fork_left.xml b/android/ui-compose/src/main/res/drawable/direction_fork_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_fork_left.xml rename to android/ui-compose/src/main/res/drawable/direction_fork_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_fork_right.xml b/android/ui-compose/src/main/res/drawable/direction_fork_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_fork_right.xml rename to android/ui-compose/src/main/res/drawable/direction_fork_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_fork_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_fork_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_fork_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_fork_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_fork_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_fork_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_fork_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_fork_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_fork_straight.xml b/android/ui-compose/src/main/res/drawable/direction_fork_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_fork_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_fork_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_invalid.xml b/android/ui-compose/src/main/res/drawable/direction_invalid.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_invalid.xml rename to android/ui-compose/src/main/res/drawable/direction_invalid.xml diff --git a/android/composeui/src/main/res/drawable/direction_invalid_left.xml b/android/ui-compose/src/main/res/drawable/direction_invalid_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_invalid_left.xml rename to android/ui-compose/src/main/res/drawable/direction_invalid_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_invalid_right.xml b/android/ui-compose/src/main/res/drawable/direction_invalid_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_invalid_right.xml rename to android/ui-compose/src/main/res/drawable/direction_invalid_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_invalid_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_invalid_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_invalid_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_invalid_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_invalid_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_invalid_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_invalid_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_invalid_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_invalid_straight.xml b/android/ui-compose/src/main/res/drawable/direction_invalid_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_invalid_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_invalid_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_invalid_u_turn.xml b/android/ui-compose/src/main/res/drawable/direction_invalid_u_turn.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_invalid_u_turn.xml rename to android/ui-compose/src/main/res/drawable/direction_invalid_u_turn.xml diff --git a/android/composeui/src/main/res/drawable/direction_merge_left.xml b/android/ui-compose/src/main/res/drawable/direction_merge_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_merge_left.xml rename to android/ui-compose/src/main/res/drawable/direction_merge_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_merge_right.xml b/android/ui-compose/src/main/res/drawable/direction_merge_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_merge_right.xml rename to android/ui-compose/src/main/res/drawable/direction_merge_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_merge_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_merge_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_merge_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_merge_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_merge_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_merge_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_merge_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_merge_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_merge_straight.xml b/android/ui-compose/src/main/res/drawable/direction_merge_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_merge_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_merge_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_new_name_left.xml b/android/ui-compose/src/main/res/drawable/direction_new_name_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_new_name_left.xml rename to android/ui-compose/src/main/res/drawable/direction_new_name_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_new_name_right.xml b/android/ui-compose/src/main/res/drawable/direction_new_name_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_new_name_right.xml rename to android/ui-compose/src/main/res/drawable/direction_new_name_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_new_name_sharp_left.xml b/android/ui-compose/src/main/res/drawable/direction_new_name_sharp_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_new_name_sharp_left.xml rename to android/ui-compose/src/main/res/drawable/direction_new_name_sharp_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_new_name_sharp_right.xml b/android/ui-compose/src/main/res/drawable/direction_new_name_sharp_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_new_name_sharp_right.xml rename to android/ui-compose/src/main/res/drawable/direction_new_name_sharp_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_new_name_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_new_name_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_new_name_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_new_name_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_new_name_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_new_name_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_new_name_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_new_name_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_new_name_straight.xml b/android/ui-compose/src/main/res/drawable/direction_new_name_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_new_name_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_new_name_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_notification_left.xml b/android/ui-compose/src/main/res/drawable/direction_notification_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_notification_left.xml rename to android/ui-compose/src/main/res/drawable/direction_notification_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_notification_right.xml b/android/ui-compose/src/main/res/drawable/direction_notification_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_notification_right.xml rename to android/ui-compose/src/main/res/drawable/direction_notification_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_notification_sharp_left.xml b/android/ui-compose/src/main/res/drawable/direction_notification_sharp_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_notification_sharp_left.xml rename to android/ui-compose/src/main/res/drawable/direction_notification_sharp_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_notification_sharp_right.xml b/android/ui-compose/src/main/res/drawable/direction_notification_sharp_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_notification_sharp_right.xml rename to android/ui-compose/src/main/res/drawable/direction_notification_sharp_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_notification_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_notification_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_notification_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_notification_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_notification_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_notification_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_notification_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_notification_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_notification_straight.xml b/android/ui-compose/src/main/res/drawable/direction_notification_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_notification_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_notification_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_off_ramp.xml b/android/ui-compose/src/main/res/drawable/direction_off_ramp.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_off_ramp.xml rename to android/ui-compose/src/main/res/drawable/direction_off_ramp.xml diff --git a/android/composeui/src/main/res/drawable/direction_off_ramp_left.xml b/android/ui-compose/src/main/res/drawable/direction_off_ramp_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_off_ramp_left.xml rename to android/ui-compose/src/main/res/drawable/direction_off_ramp_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_off_ramp_right.xml b/android/ui-compose/src/main/res/drawable/direction_off_ramp_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_off_ramp_right.xml rename to android/ui-compose/src/main/res/drawable/direction_off_ramp_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_off_ramp_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_off_ramp_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_off_ramp_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_off_ramp_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_off_ramp_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_off_ramp_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_off_ramp_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_off_ramp_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_on_ramp.xml b/android/ui-compose/src/main/res/drawable/direction_on_ramp.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_on_ramp.xml rename to android/ui-compose/src/main/res/drawable/direction_on_ramp.xml diff --git a/android/composeui/src/main/res/drawable/direction_on_ramp_left.xml b/android/ui-compose/src/main/res/drawable/direction_on_ramp_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_on_ramp_left.xml rename to android/ui-compose/src/main/res/drawable/direction_on_ramp_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_on_ramp_right.xml b/android/ui-compose/src/main/res/drawable/direction_on_ramp_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_on_ramp_right.xml rename to android/ui-compose/src/main/res/drawable/direction_on_ramp_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_on_ramp_sharp_left.xml b/android/ui-compose/src/main/res/drawable/direction_on_ramp_sharp_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_on_ramp_sharp_left.xml rename to android/ui-compose/src/main/res/drawable/direction_on_ramp_sharp_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_on_ramp_sharp_right.xml b/android/ui-compose/src/main/res/drawable/direction_on_ramp_sharp_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_on_ramp_sharp_right.xml rename to android/ui-compose/src/main/res/drawable/direction_on_ramp_sharp_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_on_ramp_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_on_ramp_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_on_ramp_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_on_ramp_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_on_ramp_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_on_ramp_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_on_ramp_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_on_ramp_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_on_ramp_straight.xml b/android/ui-compose/src/main/res/drawable/direction_on_ramp_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_on_ramp_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_on_ramp_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_ramp.xml b/android/ui-compose/src/main/res/drawable/direction_ramp.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_ramp.xml rename to android/ui-compose/src/main/res/drawable/direction_ramp.xml diff --git a/android/composeui/src/main/res/drawable/direction_rotary.xml b/android/ui-compose/src/main/res/drawable/direction_rotary.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_rotary.xml rename to android/ui-compose/src/main/res/drawable/direction_rotary.xml diff --git a/android/composeui/src/main/res/drawable/direction_rotary_left.xml b/android/ui-compose/src/main/res/drawable/direction_rotary_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_rotary_left.xml rename to android/ui-compose/src/main/res/drawable/direction_rotary_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_rotary_right.xml b/android/ui-compose/src/main/res/drawable/direction_rotary_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_rotary_right.xml rename to android/ui-compose/src/main/res/drawable/direction_rotary_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_rotary_sharp_left.xml b/android/ui-compose/src/main/res/drawable/direction_rotary_sharp_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_rotary_sharp_left.xml rename to android/ui-compose/src/main/res/drawable/direction_rotary_sharp_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_rotary_sharp_right.xml b/android/ui-compose/src/main/res/drawable/direction_rotary_sharp_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_rotary_sharp_right.xml rename to android/ui-compose/src/main/res/drawable/direction_rotary_sharp_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_rotary_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_rotary_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_rotary_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_rotary_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_rotary_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_rotary_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_rotary_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_rotary_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_rotary_straight.xml b/android/ui-compose/src/main/res/drawable/direction_rotary_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_rotary_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_rotary_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_roundabout.xml b/android/ui-compose/src/main/res/drawable/direction_roundabout.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_roundabout.xml rename to android/ui-compose/src/main/res/drawable/direction_roundabout.xml diff --git a/android/composeui/src/main/res/drawable/direction_roundabout_left.xml b/android/ui-compose/src/main/res/drawable/direction_roundabout_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_roundabout_left.xml rename to android/ui-compose/src/main/res/drawable/direction_roundabout_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_roundabout_right.xml b/android/ui-compose/src/main/res/drawable/direction_roundabout_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_roundabout_right.xml rename to android/ui-compose/src/main/res/drawable/direction_roundabout_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_roundabout_sharp_left.xml b/android/ui-compose/src/main/res/drawable/direction_roundabout_sharp_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_roundabout_sharp_left.xml rename to android/ui-compose/src/main/res/drawable/direction_roundabout_sharp_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_roundabout_sharp_right.xml b/android/ui-compose/src/main/res/drawable/direction_roundabout_sharp_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_roundabout_sharp_right.xml rename to android/ui-compose/src/main/res/drawable/direction_roundabout_sharp_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_roundabout_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_roundabout_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_roundabout_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_roundabout_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_roundabout_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_roundabout_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_roundabout_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_roundabout_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_roundabout_straight.xml b/android/ui-compose/src/main/res/drawable/direction_roundabout_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_roundabout_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_roundabout_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_traffic_circle.xml b/android/ui-compose/src/main/res/drawable/direction_traffic_circle.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_traffic_circle.xml rename to android/ui-compose/src/main/res/drawable/direction_traffic_circle.xml diff --git a/android/composeui/src/main/res/drawable/direction_traffic_circle_left.xml b/android/ui-compose/src/main/res/drawable/direction_traffic_circle_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_traffic_circle_left.xml rename to android/ui-compose/src/main/res/drawable/direction_traffic_circle_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_traffic_circle_right.xml b/android/ui-compose/src/main/res/drawable/direction_traffic_circle_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_traffic_circle_right.xml rename to android/ui-compose/src/main/res/drawable/direction_traffic_circle_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_traffic_circle_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_traffic_circle_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_traffic_circle_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_traffic_circle_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_traffic_circle_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_traffic_circle_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_traffic_circle_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_traffic_circle_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_turn_left.xml b/android/ui-compose/src/main/res/drawable/direction_turn_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_turn_left.xml rename to android/ui-compose/src/main/res/drawable/direction_turn_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_turn_right.xml b/android/ui-compose/src/main/res/drawable/direction_turn_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_turn_right.xml rename to android/ui-compose/src/main/res/drawable/direction_turn_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_turn_sharp_left.xml b/android/ui-compose/src/main/res/drawable/direction_turn_sharp_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_turn_sharp_left.xml rename to android/ui-compose/src/main/res/drawable/direction_turn_sharp_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_turn_sharp_right.xml b/android/ui-compose/src/main/res/drawable/direction_turn_sharp_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_turn_sharp_right.xml rename to android/ui-compose/src/main/res/drawable/direction_turn_sharp_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_turn_slight_left.xml b/android/ui-compose/src/main/res/drawable/direction_turn_slight_left.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_turn_slight_left.xml rename to android/ui-compose/src/main/res/drawable/direction_turn_slight_left.xml diff --git a/android/composeui/src/main/res/drawable/direction_turn_slight_right.xml b/android/ui-compose/src/main/res/drawable/direction_turn_slight_right.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_turn_slight_right.xml rename to android/ui-compose/src/main/res/drawable/direction_turn_slight_right.xml diff --git a/android/composeui/src/main/res/drawable/direction_turn_straight.xml b/android/ui-compose/src/main/res/drawable/direction_turn_straight.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_turn_straight.xml rename to android/ui-compose/src/main/res/drawable/direction_turn_straight.xml diff --git a/android/composeui/src/main/res/drawable/direction_u_turn.xml b/android/ui-compose/src/main/res/drawable/direction_u_turn.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_u_turn.xml rename to android/ui-compose/src/main/res/drawable/direction_u_turn.xml diff --git a/android/composeui/src/main/res/drawable/direction_updown.xml b/android/ui-compose/src/main/res/drawable/direction_updown.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_updown.xml rename to android/ui-compose/src/main/res/drawable/direction_updown.xml diff --git a/android/composeui/src/main/res/drawable/notification_icon.xml b/android/ui-compose/src/main/res/drawable/notification_icon.xml similarity index 100% rename from android/composeui/src/main/res/drawable/notification_icon.xml rename to android/ui-compose/src/main/res/drawable/notification_icon.xml diff --git a/android/composeui/src/main/res/drawable/rounded_button.xml b/android/ui-compose/src/main/res/drawable/rounded_button.xml similarity index 100% rename from android/composeui/src/main/res/drawable/rounded_button.xml rename to android/ui-compose/src/main/res/drawable/rounded_button.xml diff --git a/android/composeui/src/main/res/layout/expanded_navigation_notification.xml b/android/ui-compose/src/main/res/layout/expanded_navigation_notification.xml similarity index 100% rename from android/composeui/src/main/res/layout/expanded_navigation_notification.xml rename to android/ui-compose/src/main/res/layout/expanded_navigation_notification.xml diff --git a/android/composeui/src/main/res/layout/navigation_notification.xml b/android/ui-compose/src/main/res/layout/navigation_notification.xml similarity index 100% rename from android/composeui/src/main/res/layout/navigation_notification.xml rename to android/ui-compose/src/main/res/layout/navigation_notification.xml diff --git a/android/composeui/src/main/res/values/colors.xml b/android/ui-compose/src/main/res/values/colors.xml similarity index 100% rename from android/composeui/src/main/res/values/colors.xml rename to android/ui-compose/src/main/res/values/colors.xml diff --git a/android/composeui/src/main/res/values/strings.xml b/android/ui-compose/src/main/res/values/strings.xml similarity index 85% rename from android/composeui/src/main/res/values/strings.xml rename to android/ui-compose/src/main/res/values/strings.xml index fd396169e..3969ad174 100644 --- a/android/composeui/src/main/res/values/strings.xml +++ b/android/ui-compose/src/main/res/values/strings.xml @@ -21,9 +21,4 @@ Speed Limit - - m/s - km/h - mph - kn \ No newline at end of file diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt similarity index 94% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt index eba3ae581..403e4a308 100644 --- a/android/composeui/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt +++ b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt @@ -1,6 +1,6 @@ package com.stadiamaps.ferrostar -import com.stadiamaps.ferrostar.composeui.formatting.roundToNearest +import com.stadiamaps.ferrostar.ui.formatters.roundToNearest import org.junit.Assert.assertEquals import org.junit.Test diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/support/SnapshotTestSupport.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/support/SnapshotTestSupport.kt similarity index 100% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/support/SnapshotTestSupport.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/support/SnapshotTestSupport.kt diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/InnerGridViewTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/InnerGridViewTest.kt similarity index 100% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/views/InnerGridViewTest.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/InnerGridViewTest.kt diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/InstructionViewTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/InstructionViewTest.kt similarity index 100% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/views/InstructionViewTest.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/InstructionViewTest.kt diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/ManeuverImageTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/ManeuverImageTest.kt similarity index 100% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/views/ManeuverImageTest.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/ManeuverImageTest.kt diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/NavigatingInnerGridViewTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/NavigatingInnerGridViewTest.kt similarity index 100% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/views/NavigatingInnerGridViewTest.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/NavigatingInnerGridViewTest.kt diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/NavigationUIButtonTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/NavigationUIButtonTest.kt similarity index 100% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/views/NavigationUIButtonTest.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/NavigationUIButtonTest.kt diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/RTLInstructionViewTests.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/RTLInstructionViewTests.kt similarity index 95% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/views/RTLInstructionViewTests.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/RTLInstructionViewTests.kt index 1a344fd2b..db14fb687 100644 --- a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/RTLInstructionViewTests.kt +++ b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/RTLInstructionViewTests.kt @@ -6,7 +6,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import app.cash.paparazzi.DeviceConfig.Companion.PIXEL_5 import app.cash.paparazzi.Paparazzi -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.LocalizedDistanceFormatter import com.stadiamaps.ferrostar.composeui.views.components.InstructionsView import com.stadiamaps.ferrostar.support.WithSnapshotBackground import org.junit.Rule diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/TripProgressViewTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/TripProgressViewTest.kt similarity index 100% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/views/TripProgressViewTest.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/TripProgressViewTest.kt diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/USStyleSpeedLimitViewTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/USStyleSpeedLimitViewTest.kt similarity index 100% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/views/USStyleSpeedLimitViewTest.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/USStyleSpeedLimitViewTest.kt diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/views/ViennaStyleSpeedLimitViewTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/ViennaStyleSpeedLimitViewTest.kt similarity index 100% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/views/ViennaStyleSpeedLimitViewTest.kt rename to android/ui-compose/src/test/java/com/stadiamaps/ferrostar/views/ViennaStyleSpeedLimitViewTest.kt diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewAll.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewAll.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewAll.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewAll.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewSpecialized.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewSpecialized.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewSpecialized.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InnerGridViewTest_testInnerGridViewSpecialized.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionView.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionView.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionView.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionView.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionViewExpanded.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionViewExpanded.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionViewExpanded.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_InstructionViewTest_testInstructionViewExpanded.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageCustomColor.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageCustomColor.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageCustomColor.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageCustomColor.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageForkLeft.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageForkLeft.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageForkLeft.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageForkLeft.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageTurnRight.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageTurnRight.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageTurnRight.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ManeuverImageTest_testManeuverImageTurnRight.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTracking.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTracking.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTracking.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTracking.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTrackingLandscape.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTrackingLandscape.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTrackingLandscape.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewNonTrackingLandscape.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTracking.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTracking.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTracking.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTracking.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTrackingLandscape.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTrackingLandscape.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTrackingLandscape.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigatingInnerGridViewTest_testNavigatingInnerGridViewTrackingLandscape.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButton.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButton.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButton.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButton.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButtonCustomized.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButtonCustomized.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButtonCustomized.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIButtonCustomized.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButton.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButton.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButton.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButton.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButtonCustomized.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButtonCustomized.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButtonCustomized.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_NavigationUIButtonTest_testNavigationUIZoomButtonCustomized.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_RTLInstructionViewTests_testRTLInstructionView.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_RTLInstructionViewTests_testRTLInstructionView.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_RTLInstructionViewTests_testRTLInstructionView.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_RTLInstructionViewTests_testRTLInstructionView.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView24Hour.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView24Hour.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView24Hour.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressView24Hour.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewInformationalStyle.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewInformationalStyle.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewInformationalStyle.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewInformationalStyle.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewWithExit.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewWithExit.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewWithExit.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_TripProgressViewTest_testProgressViewWithExit.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testFastSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testFastSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testFastSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testFastSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testImplausibleSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testImplausibleSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testImplausibleSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testImplausibleSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKilometersPerHourSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKilometersPerHourSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKilometersPerHourSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKilometersPerHourSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKnotsSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKnotsSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKnotsSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testKnotsSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testLowSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testLowSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testLowSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_USStyleSpeedLimitViewTest_testLowSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testFastSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testFastSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testFastSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testFastSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testImplausibleSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testImplausibleSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testImplausibleSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testImplausibleSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testKnotsSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testKnotsSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testKnotsSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testKnotsSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testLowSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testLowSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testLowSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testLowSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMetersPerSecondSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMetersPerSecondSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMetersPerSecondSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMetersPerSecondSpeedValue.png diff --git a/android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMilesPerHourSpeedValue.png b/android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMilesPerHourSpeedValue.png similarity index 100% rename from android/composeui/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMilesPerHourSpeedValue.png rename to android/ui-compose/src/test/snapshots/images/com.stadiamaps.ferrostar.views_ViennaStyleSpeedLimitViewTest_testMilesPerHourSpeedValue.png diff --git a/android/maplibreui/.gitignore b/android/ui-formatters/.gitignore similarity index 100% rename from android/maplibreui/.gitignore rename to android/ui-formatters/.gitignore diff --git a/android/ui-formatters/build.gradle b/android/ui-formatters/build.gradle new file mode 100644 index 000000000..d1e387bad --- /dev/null +++ b/android/ui-formatters/build.gradle @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.androidLibrary) +} + +android { + namespace 'com.stadiamaps.ferrostar.ui.formatters' + 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 { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } +} + +dependencies { + implementation libs.androidx.ktx + implementation libs.androidx.appcompat + implementation libs.material + + // Used in the public API + api libs.kotlinx.datetime + + implementation project(':core') + + testImplementation libs.junit + androidTestImplementation libs.androidx.test.junit + androidTestImplementation libs.androidx.test.espresso +} \ No newline at end of file diff --git a/android/maplibreui/consumer-rules.pro b/android/ui-formatters/consumer-rules.pro similarity index 100% rename from android/maplibreui/consumer-rules.pro rename to android/ui-formatters/consumer-rules.pro diff --git a/android/ui-formatters/proguard-rules.pro b/android/ui-formatters/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/android/ui-formatters/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/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterDETest.kt b/android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterDETest.kt similarity index 92% rename from android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterDETest.kt rename to android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterDETest.kt index 932454bdc..2fcb2ca0f 100644 --- a/android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterDETest.kt +++ b/android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterDETest.kt @@ -1,7 +1,7 @@ -package com.stadiamaps.ferrostar.composeui +package com.stadiamaps.ferrostar.ui.formatters import android.icu.util.ULocale -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.LocalizedDistanceFormatter import org.junit.Assert import org.junit.Test diff --git a/android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterDistanceMeasurementSystemOverrideTest.kt b/android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterDistanceMeasurementSystemOverrideTest.kt similarity index 84% rename from android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterDistanceMeasurementSystemOverrideTest.kt rename to android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterDistanceMeasurementSystemOverrideTest.kt index 8cca1b351..d940e5365 100644 --- a/android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterDistanceMeasurementSystemOverrideTest.kt +++ b/android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterDistanceMeasurementSystemOverrideTest.kt @@ -1,8 +1,8 @@ -package com.stadiamaps.ferrostar.composeui +package com.stadiamaps.ferrostar.ui.formatters import android.icu.util.ULocale -import com.stadiamaps.ferrostar.composeui.formatting.DistanceMeasurementSystem -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter +import com.stadiamaps.ferrostar.ui.formatters.DistanceMeasurementSystem +import com.stadiamaps.ferrostar.ui.formatters.LocalizedDistanceFormatter import org.junit.Assert import org.junit.Test diff --git a/android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterUKTest.kt b/android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterUKTest.kt similarity index 91% rename from android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterUKTest.kt rename to android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterUKTest.kt index 3a3935b4b..159e3e73d 100644 --- a/android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterUKTest.kt +++ b/android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterUKTest.kt @@ -1,7 +1,6 @@ -package com.stadiamaps.ferrostar.composeui +package com.stadiamaps.ferrostar.ui.formatters import android.icu.util.ULocale -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter import org.junit.Assert.assertEquals import org.junit.Test diff --git a/android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterUSTest.kt b/android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterUSTest.kt similarity index 93% rename from android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterUSTest.kt rename to android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterUSTest.kt index 205937fac..e287b2673 100644 --- a/android/composeui/src/androidTest/java/com/stadiamaps/ferrostar/composeui/LocalizedDistanceFormatterUSTest.kt +++ b/android/ui-formatters/src/androidTest/java/com/stadiamaps/ferrostar/ui/formatters/LocalizedDistanceFormatterUSTest.kt @@ -1,8 +1,7 @@ -package com.stadiamaps.ferrostar.composeui +package com.stadiamaps.ferrostar.ui.formatters import android.icu.util.ULocale import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDistanceFormatter import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith diff --git a/android/maplibreui/src/main/AndroidManifest.xml b/android/ui-formatters/src/main/AndroidManifest.xml similarity index 100% rename from android/maplibreui/src/main/AndroidManifest.xml rename to android/ui-formatters/src/main/AndroidManifest.xml diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/DateTimeFormatter.kt b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/DateTimeFormatter.kt similarity index 93% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/DateTimeFormatter.kt rename to android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/DateTimeFormatter.kt index 8576731ce..a16404eeb 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/DateTimeFormatter.kt +++ b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/DateTimeFormatter.kt @@ -1,4 +1,4 @@ -package com.stadiamaps.ferrostar.composeui.formatting +package com.stadiamaps.ferrostar.ui.formatters import android.icu.util.ULocale import java.time.format.FormatStyle diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/DistanceFormatter.kt b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/DistanceFormatter.kt similarity index 53% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/DistanceFormatter.kt rename to android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/DistanceFormatter.kt index aabe1fff2..fb880deeb 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/DistanceFormatter.kt +++ b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/DistanceFormatter.kt @@ -1,4 +1,4 @@ -package com.stadiamaps.ferrostar.composeui.formatting +package com.stadiamaps.ferrostar.ui.formatters import android.icu.number.NumberFormatter import android.icu.number.Precision @@ -9,6 +9,7 @@ import android.icu.util.Measure import android.icu.util.MeasureUnit import android.icu.util.ULocale import android.os.Build +import kotlin.math.roundToInt private const val METERS_PER_MILE = 1609.344 private const val FEET_PER_METER = 3.28084 @@ -44,7 +45,21 @@ private enum class DecimalPrecision { * Regrettably, the Android standard libraries lack a reliable method of determining which unit to * use when displaying distances. This interface allows you to implement */ -fun interface DistanceFormatter { +interface DistanceFormatter { + + /** + * The recommended unit for the given locale and distance. + * + * This will automatically choose larger or smaller unit types + * depending on the distance. + */ + fun recommendedUnit(distanceInMeters: Double): MeasureUnit + + /** + * The distance rounded based on its value and unit type. + */ + fun roundedDistanceForUnit(distanceInMeters: Double): Double + /** * Formats a distance, given in meters, as human-readable (ex: rounded, localized, etc.) output. */ @@ -64,116 +79,136 @@ class LocalizedDistanceFormatter( var localeOverride: ULocale? = null, var distanceMeasurementSystemOverride: DistanceMeasurementSystem? = null ) : DistanceFormatter { - override fun format(distanceInMeters: Double): String { + + companion object { + // For longer distances (as we approach 1000 feet), use miles + // (289m is just under 950ft, at which point we'd round up to 1,000) + const val IMPERIAL_CUTOFF = 289 + + // Use miles for longer distances (300m is around 0.2mi) + const val IMPERIAL_WITH_YARDS_CUTOFF = 300 + + // Use kilometers above 1km (1000m). + const val METRIC_CUTOFF = 1_000 + } + + override fun recommendedUnit(distanceInMeters: Double): MeasureUnit { val locale = localeOverride ?: ULocale.getDefault(ULocale.Category.FORMAT) val measurementSystem = distanceMeasurementSystemOverride ?: getMeasurementSystem(locale) - val unit: MeasureUnit - val distance: Double - val precision: DecimalPrecision when (measurementSystem) { DistanceMeasurementSystem.IMPERIAL -> { - if (distanceInMeters > 289) { - // For longer distances (as we approach 1000 feet), use miles - // (289m is just under 950ft, at which point we'd round up to 1,000) - unit = MeasureUnit.MILE - - val distanceInMiles = distanceInMeters / METERS_PER_MILE - distance = distanceInMiles - precision = - if (distanceInMiles > 10) { - DecimalPrecision.NEAREST_INTEGER - } else { - DecimalPrecision.NEAREST_TENTH - } + return if (distanceInMeters > IMPERIAL_CUTOFF) { + MeasureUnit.MILE } else { - unit = MeasureUnit.FOOT - precision = DecimalPrecision.NEAREST_INTEGER - - val distanceInFeet = distanceInMeters * FEET_PER_METER - distance = - if (distanceInFeet < 50) { - // Less than 50 feet, round to the nearest 5ft - distanceInFeet.roundToNearest(5) - } else if (distanceInFeet < 100) { - // Between 50ft and 100ft, round to the nearest 10ft - distanceInFeet.roundToNearest(10) - } else if (distanceInFeet < 500) { - // Between 100ft and 500ft, round to the nearest 50ft - distanceInFeet.roundToNearest(50) - } else { - // Above 500 ft switches to 100ft - distanceInFeet.roundToNearest(100) - } + MeasureUnit.FOOT } } DistanceMeasurementSystem.IMPERIAL_WITH_YARDS -> { - if (distanceInMeters > 300) { - // Use miles for longer distances (300m is around 0.2mi) - unit = MeasureUnit.MILE - - val distanceInMiles = distanceInMeters / METERS_PER_MILE - distance = distanceInMiles - precision = - if (distanceInMiles > 10) { - DecimalPrecision.NEAREST_INTEGER - } else { - DecimalPrecision.NEAREST_TENTH - } + return if (distanceInMeters > IMPERIAL_WITH_YARDS_CUTOFF) { + MeasureUnit.MILE } else { - unit = MeasureUnit.YARD - precision = DecimalPrecision.NEAREST_INTEGER - - val distanceInYards = distanceInMeters * YARDS_PER_METER - - distance = - if (distanceInYards < 10) { - // Less than 10 yards, round to the nearest 5 - distanceInYards.roundToNearest(5) - } else { - // Otherwise, round to the nearest 10 - distanceInYards.roundToNearest(10) - } + MeasureUnit.YARD } } DistanceMeasurementSystem.SI -> { - if (distanceInMeters > 1_000) { - // Longer distances: use km - unit = MeasureUnit.KILOMETER - distance = distanceInMeters / 1_000 - precision = - if (distanceInMeters > 10_000) { - // For distances > 10km, display in km and round to the nearest km. - DecimalPrecision.NEAREST_INTEGER - } else { - // Between 1km and 10km, display in km but increase resolution to 0.1km - DecimalPrecision.NEAREST_TENTH - } + return if (distanceInMeters > METRIC_CUTOFF) { + MeasureUnit.KILOMETER + } else { + MeasureUnit.METER + } + } + } + } + + override fun roundedDistanceForUnit(distanceInMeters: Double): Double = + when (recommendedUnit(distanceInMeters)) { + MeasureUnit.MILE -> distanceInMeters / METERS_PER_MILE + + MeasureUnit.YARD -> { + val distanceInYards = distanceInMeters * YARDS_PER_METER + if (distanceInYards < 10) { + // Less than 10 yards, round to the nearest 5 + distanceInYards.roundToNearest(5) + } else { + // Otherwise, round to the nearest 10 + distanceInYards.roundToNearest(10) + } + } + + MeasureUnit.FOOT -> { + val distanceInFeet = distanceInMeters * FEET_PER_METER + if (distanceInFeet < 50) { + // Less than 50 feet, round to the nearest 5ft + distanceInFeet.roundToNearest(5) + } else if (distanceInFeet < 100) { + // Between 50ft and 100ft, round to the nearest 10ft + distanceInFeet.roundToNearest(10) + } else if (distanceInFeet < 500) { + // Between 100ft and 500ft, round to the nearest 50ft + distanceInFeet.roundToNearest(50) + } else { + // Above 500 ft switches to 100ft + distanceInFeet.roundToNearest(100) + } + } + + MeasureUnit.KILOMETER -> distanceInMeters / 1_000 + + MeasureUnit.METER -> { + if (distanceInMeters > 100) { + // Round to nearest 100 meters + distanceInMeters.roundToNearest(100) + } else if (distanceInMeters > 10) { + // Round to nearest 10 meters between 10m and 100m + distanceInMeters.roundToNearest(10) } else { - // Shorter distances: use m - unit = MeasureUnit.METER - distance = - if (distanceInMeters > 100) { - // Round to nearest 100 meters - distanceInMeters.roundToNearest(100) - } else if (distanceInMeters > 10) { - // Round to nearest 10 meters between 10m and 100m - distanceInMeters.roundToNearest(10) - } else { - // Otherwise, round to the nearest 5 - distanceInMeters.roundToNearest(5) - } - precision = DecimalPrecision.NEAREST_INTEGER + // Otherwise, round to the nearest 5 + distanceInMeters.roundToNearest(5) } } + + // Unsupported unit. Keep meters + else -> distanceInMeters } + private fun precision(distanceInMeters: Double): DecimalPrecision = + when (recommendedUnit(distanceInMeters)) { + MeasureUnit.MILE -> { + val distanceInMiles = distanceInMeters / METERS_PER_MILE + if (distanceInMiles > 10) { + DecimalPrecision.NEAREST_INTEGER + } else { + DecimalPrecision.NEAREST_TENTH + } + } + + MeasureUnit.KILOMETER -> { + if (distanceInMeters > 10_000) { + // For distances > 10km, display in km and round to the nearest km. + DecimalPrecision.NEAREST_INTEGER + } else { + // Between 1km and 10km, display in km but increase resolution to 0.1km + DecimalPrecision.NEAREST_TENTH + } + } + + // Unsupported unit. Keep meters + else -> DecimalPrecision.NEAREST_INTEGER + } + + override fun format(distanceInMeters: Double): String { + val locale = localeOverride ?: ULocale.getDefault(ULocale.Category.FORMAT) + val unit: MeasureUnit = recommendedUnit(distanceInMeters) + val distance: Double = roundedDistanceForUnit(distanceInMeters) + val precision: DecimalPrecision = precision(distanceInMeters) + return formatDistance(distance, unit, locale, precision) } } internal fun Double.roundToNearest(wholeNumber: Int): Double { - return Math.round(this / wholeNumber).toDouble() * wholeNumber + return (this / wholeNumber).roundToInt().toDouble() * wholeNumber } internal fun getMeasurementSystem(locale: ULocale): DistanceMeasurementSystem = diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/DurationFormatter.kt b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/DurationFormatter.kt similarity index 98% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/DurationFormatter.kt rename to android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/DurationFormatter.kt index 867a3baa4..c85fada20 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/DurationFormatter.kt +++ b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/DurationFormatter.kt @@ -1,4 +1,4 @@ -package com.stadiamaps.ferrostar.composeui.formatting +package com.stadiamaps.ferrostar.ui.formatters import kotlin.time.DurationUnit diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/FormatterCollection.kt b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/FormatterCollection.kt similarity index 93% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/FormatterCollection.kt rename to android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/FormatterCollection.kt index 279819cfb..84d28c326 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/FormatterCollection.kt +++ b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/FormatterCollection.kt @@ -1,4 +1,4 @@ -package com.stadiamaps.ferrostar.composeui.formatting +package com.stadiamaps.ferrostar.ui.formatters interface FormatterCollection { diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/MeasurementSpeedFormatter.kt b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/MeasurementSpeedFormatter.kt similarity index 93% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/MeasurementSpeedFormatter.kt rename to android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/MeasurementSpeedFormatter.kt index 1bff4647f..38b98d7a0 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/formatting/MeasurementSpeedFormatter.kt +++ b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/MeasurementSpeedFormatter.kt @@ -1,10 +1,10 @@ -package com.stadiamaps.ferrostar.composeui.formatting +package com.stadiamaps.ferrostar.ui.formatters import android.content.Context import android.icu.util.ULocale -import com.stadiamaps.ferrostar.composeui.measurement.localizedString import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeed import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeedUnit +import com.stadiamaps.ferrostar.ui.formatters.runtime.localizedString import java.util.Locale class MeasurementSpeedFormatter(context: Context, val measurementSpeed: MeasurementSpeed) { diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/measurement/LocalizedSpeed.kt b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/runtime/LocalizedUnit.kt similarity index 83% rename from android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/measurement/LocalizedSpeed.kt rename to android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/runtime/LocalizedUnit.kt index 2625337a4..64cc8e39a 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/measurement/LocalizedSpeed.kt +++ b/android/ui-formatters/src/main/java/com/stadiamaps/ferrostar/ui/formatters/runtime/LocalizedUnit.kt @@ -1,8 +1,8 @@ -package com.stadiamaps.ferrostar.composeui.measurement +package com.stadiamaps.ferrostar.ui.formatters.runtime import android.content.Context -import com.stadiamaps.ferrostar.composeui.R import com.stadiamaps.ferrostar.core.measurement.MeasurementSpeedUnit +import com.stadiamaps.ferrostar.ui.formatters.R fun MeasurementSpeedUnit.localizedString(context: Context): String { return when (this) { diff --git a/android/ui-formatters/src/main/res/values/strings.xml b/android/ui-formatters/src/main/res/values/strings.xml new file mode 100644 index 000000000..9d920f971 --- /dev/null +++ b/android/ui-formatters/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + m/s + km/h + mph + kn + \ No newline at end of file diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/formatting/CombinedDurationFormatterTests.kt b/android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/CombinedDurationFormatterTests.kt similarity index 92% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/formatting/CombinedDurationFormatterTests.kt rename to android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/CombinedDurationFormatterTests.kt index 26f6375cd..4835403ce 100644 --- a/android/composeui/src/test/java/com/stadiamaps/ferrostar/formatting/CombinedDurationFormatterTests.kt +++ b/android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/CombinedDurationFormatterTests.kt @@ -1,7 +1,5 @@ -package com.stadiamaps.ferrostar.formatting +package com.stadiamaps.ferrostar.ui.formatters -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDurationFormatter -import com.stadiamaps.ferrostar.composeui.formatting.UnitStyle import kotlin.time.DurationUnit import org.junit.Assert.assertEquals import org.junit.Test diff --git a/android/composeui/src/test/java/com/stadiamaps/ferrostar/formatting/SingularDurationFormatterTest.kt b/android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/SingularDurationFormatterTest.kt similarity index 93% rename from android/composeui/src/test/java/com/stadiamaps/ferrostar/formatting/SingularDurationFormatterTest.kt rename to android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/SingularDurationFormatterTest.kt index bfa6ea965..1c166f70d 100644 --- a/android/composeui/src/test/java/com/stadiamaps/ferrostar/formatting/SingularDurationFormatterTest.kt +++ b/android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/SingularDurationFormatterTest.kt @@ -1,7 +1,5 @@ -package com.stadiamaps.ferrostar.formatting +package com.stadiamaps.ferrostar.ui.formatters -import com.stadiamaps.ferrostar.composeui.formatting.LocalizedDurationFormatter -import com.stadiamaps.ferrostar.composeui.formatting.UnitStyle import kotlin.time.DurationUnit import org.junit.Assert.assertEquals import org.junit.Test diff --git a/android/ui-maplibre/.gitignore b/android/ui-maplibre/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/android/ui-maplibre/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/maplibreui/build.gradle b/android/ui-maplibre/build.gradle similarity index 98% rename from android/maplibreui/build.gradle rename to android/ui-maplibre/build.gradle index 67cc9b772..5893c9011 100644 --- a/android/maplibreui/build.gradle +++ b/android/ui-maplibre/build.gradle @@ -50,7 +50,7 @@ dependencies { api libs.maplibre.compose implementation project(':core') - implementation project(':composeui') + implementation project(':ui-compose') testImplementation libs.junit androidTestImplementation libs.androidx.test.junit diff --git a/android/ui-maplibre/consumer-rules.pro b/android/ui-maplibre/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/android/maplibreui/src/androidTest/java/com/stadiamaps/ferrostar/maplibreui/ExampleInstrumentedTest.kt b/android/ui-maplibre/src/androidTest/java/com/stadiamaps/ferrostar/maplibreui/ExampleInstrumentedTest.kt similarity index 100% rename from android/maplibreui/src/androidTest/java/com/stadiamaps/ferrostar/maplibreui/ExampleInstrumentedTest.kt rename to android/ui-maplibre/src/androidTest/java/com/stadiamaps/ferrostar/maplibreui/ExampleInstrumentedTest.kt 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/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/LocationRequestProperties.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/LocationRequestProperties.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/LocationRequestProperties.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/LocationRequestProperties.kt diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/VisualNavigationViewConfig.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/VisualNavigationViewConfig.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/VisualNavigationViewConfig.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/extensions/VisualNavigationViewConfig.kt diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/BorderedPolyline.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/BorderedPolyline.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/BorderedPolyline.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/BorderedPolyline.kt diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteOverlayBuilder.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteOverlayBuilder.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteOverlayBuilder.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/routeline/RouteOverlayBuilder.kt diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapControls.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapControls.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapControls.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/MapControls.kt diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/runtime/NavigationCamera.kt diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/LandscapeNavigationView.kt diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt similarity index 100% rename from android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt rename to android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/PortraitNavigationView.kt diff --git a/android/maplibreui/src/test/java/com/stadiamaps/ferrostar/maplibreui/config/VisualNavigationViewConfigTest.kt b/android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/config/VisualNavigationViewConfigTest.kt similarity index 100% rename from android/maplibreui/src/test/java/com/stadiamaps/ferrostar/maplibreui/config/VisualNavigationViewConfigTest.kt rename to android/ui-maplibre/src/test/java/com/stadiamaps/ferrostar/maplibreui/config/VisualNavigationViewConfigTest.kt From 388b4d1d863ecb85bb979e4c7c8187fbd6d38f26 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sat, 14 Mar 2026 17:47:08 -0700 Subject: [PATCH 04/15] feat: moves formatters to shared ui module --- .../ferrostar/RoundToNearestTest.kt | 38 ------------------- .../ui/formatters/RoundToNearestTest.kt | 37 ++++++++++++++++++ 2 files changed, 37 insertions(+), 38 deletions(-) delete mode 100644 android/ui-compose/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt create mode 100644 android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/RoundToNearestTest.kt diff --git a/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt b/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt deleted file mode 100644 index 403e4a308..000000000 --- a/android/ui-compose/src/test/java/com/stadiamaps/ferrostar/RoundToNearestTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.stadiamaps.ferrostar - -import com.stadiamaps.ferrostar.ui.formatters.roundToNearest -import org.junit.Assert.assertEquals -import org.junit.Test - -class RoundToNearestTest { - @Test - fun `Round to nearest integer`() { - assertEquals(1.0, 1.0.roundToNearest(1), Double.MIN_VALUE) - assertEquals(1.0, 0.5.roundToNearest(1), Double.MIN_VALUE) - assertEquals(12.0, 11.6.roundToNearest(1), Double.MIN_VALUE) - } - - @Test - fun `Round to nearest 5`() { - assertEquals(0.0, 1.0.roundToNearest(5), Double.MIN_VALUE) - assertEquals(5.0, 3.0.roundToNearest(5), Double.MIN_VALUE) - assertEquals(5.0, 7.0.roundToNearest(5), Double.MIN_VALUE) - assertEquals(10.0, 8.0.roundToNearest(5), Double.MIN_VALUE) - } - - @Test - fun `Round to nearest 10`() { - assertEquals(0.0, 1.0.roundToNearest(10), Double.MIN_VALUE) - assertEquals(0.0, 3.0.roundToNearest(10), Double.MIN_VALUE) - assertEquals(10.0, 7.0.roundToNearest(10), Double.MIN_VALUE) - assertEquals(30.0, 28.0.roundToNearest(10), Double.MIN_VALUE) - } - - @Test - fun `Round to nearest 100`() { - assertEquals(0.0, 1.0.roundToNearest(100), Double.MIN_VALUE) - assertEquals(0.0, 40.0.roundToNearest(100), Double.MIN_VALUE) - assertEquals(100.0, 50.0.roundToNearest(100), Double.MIN_VALUE) - assertEquals(300.0, 280.0.roundToNearest(100), Double.MIN_VALUE) - } -} diff --git a/android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/RoundToNearestTest.kt b/android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/RoundToNearestTest.kt new file mode 100644 index 000000000..42b54196e --- /dev/null +++ b/android/ui-formatters/src/test/java/com/stadiamaps/ferrostar/ui/formatters/RoundToNearestTest.kt @@ -0,0 +1,37 @@ +package com.stadiamaps.ferrostar.ui.formatters + +import org.junit.Assert +import org.junit.Test + +class RoundToNearestTest { + @Test + fun `Round to nearest integer`() { + Assert.assertEquals(1.0, 1.0.roundToNearest(1), Double.MIN_VALUE) + Assert.assertEquals(1.0, 0.5.roundToNearest(1), Double.MIN_VALUE) + Assert.assertEquals(12.0, 11.6.roundToNearest(1), Double.MIN_VALUE) + } + + @Test + fun `Round to nearest 5`() { + Assert.assertEquals(0.0, 1.0.roundToNearest(5), Double.MIN_VALUE) + Assert.assertEquals(5.0, 3.0.roundToNearest(5), Double.MIN_VALUE) + Assert.assertEquals(5.0, 7.0.roundToNearest(5), Double.MIN_VALUE) + Assert.assertEquals(10.0, 8.0.roundToNearest(5), Double.MIN_VALUE) + } + + @Test + fun `Round to nearest 10`() { + Assert.assertEquals(0.0, 1.0.roundToNearest(10), Double.MIN_VALUE) + Assert.assertEquals(0.0, 3.0.roundToNearest(10), Double.MIN_VALUE) + Assert.assertEquals(10.0, 7.0.roundToNearest(10), Double.MIN_VALUE) + Assert.assertEquals(30.0, 28.0.roundToNearest(10), Double.MIN_VALUE) + } + + @Test + fun `Round to nearest 100`() { + Assert.assertEquals(0.0, 1.0.roundToNearest(100), Double.MIN_VALUE) + Assert.assertEquals(0.0, 40.0.roundToNearest(100), Double.MIN_VALUE) + Assert.assertEquals(100.0, 50.0.roundToNearest(100), Double.MIN_VALUE) + Assert.assertEquals(300.0, 280.0.roundToNearest(100), Double.MIN_VALUE) + } +} From f3a1f087dd3ef92bdf4b3379fdbacdd100d71bbd Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sat, 14 Mar 2026 17:57:44 -0700 Subject: [PATCH 05/15] feat: moves formatters to shared ui module --- android/ui-formatters/build.gradle | 43 ++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/android/ui-formatters/build.gradle b/android/ui-formatters/build.gradle index d1e387bad..630610938 100644 --- a/android/ui-formatters/build.gradle +++ b/android/ui-formatters/build.gradle @@ -1,5 +1,9 @@ +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary + plugins { - alias(libs.plugins.androidLibrary) + alias libs.plugins.androidLibrary + alias libs.plugins.ktfmt + alias libs.plugins.mavenPublish } android { @@ -9,7 +13,7 @@ android { } defaultConfig { - minSdk 26 + minSdk 25 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -22,22 +26,45 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { - implementation libs.androidx.ktx - implementation libs.androidx.appcompat - implementation libs.material + // For as long as we support API 25; once we can raise support to 26, all is fine + coreLibraryDesugaring libs.desugar.jdk.libs + + implementation platform(libs.kotlin.bom) // Used in the public API api libs.kotlinx.datetime + implementation libs.androidx.ktx + implementation libs.androidx.appcompat + implementation libs.androidx.activity.compose + implementation project(':core') testImplementation libs.junit androidTestImplementation libs.androidx.test.junit androidTestImplementation libs.androidx.test.espresso -} \ No newline at end of file +} + +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 Formatters" + description = "Distance, duration and arrival formatters for Ferrostar" + commonPomConfig(it, true) + } +} From 11e3d32c9e47f48b138ba1a813702618691707cd Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sat, 14 Mar 2026 18:36:51 -0700 Subject: [PATCH 06/15] feat: shared ui for items that apply to compose-ui and car app --- android/settings.gradle | 1 + android/ui-compose/build.gradle | 1 + .../DefaultForegroundNotificationBuilder.kt | 17 +- .../components/maneuver/ManeuverImage.kt | 26 ++-- android/ui-shared/.gitignore | 1 + android/ui-shared/build.gradle | 70 +++++++++ android/ui-shared/consumer-rules.pro | 0 android/ui-shared/proguard-rules.pro | 21 +++ .../ferrostar/ui/shared/ManeuverIconTest.kt | 146 ++++++++++++++++++ .../ui-shared/src/main/AndroidManifest.xml | 4 + .../ferrostar/ui/shared/icons/ManeuverIcon.kt | 46 ++++++ .../main/res/drawable/direction_arrive.xml | 0 .../res/drawable/direction_arrive_left.xml | 0 .../res/drawable/direction_arrive_right.xml | 0 .../drawable/direction_arrive_straight.xml | 0 .../src/main/res/drawable/direction_close.xml | 0 .../main/res/drawable/direction_continue.xml | 0 .../res/drawable/direction_continue_left.xml | 0 .../res/drawable/direction_continue_right.xml | 0 .../direction_continue_slight_left.xml | 0 .../direction_continue_slight_right.xml | 0 .../drawable/direction_continue_straight.xml | 0 .../drawable/direction_continue_u_turn.xml | 0 .../main/res/drawable/direction_depart.xml | 0 .../res/drawable/direction_depart_left.xml | 0 .../res/drawable/direction_depart_right.xml | 0 .../drawable/direction_depart_straight.xml | 0 .../drawable/direction_end_of_road_left.xml | 0 .../drawable/direction_end_of_road_right.xml | 0 .../src/main/res/drawable/direction_flag.xml | 0 .../src/main/res/drawable/direction_fork.xml | 0 .../main/res/drawable/direction_fork_left.xml | 0 .../res/drawable/direction_fork_right.xml | 0 .../drawable/direction_fork_slight_left.xml | 0 .../drawable/direction_fork_slight_right.xml | 0 .../res/drawable/direction_fork_straight.xml | 0 .../main/res/drawable/direction_invalid.xml | 0 .../res/drawable/direction_invalid_left.xml | 0 .../res/drawable/direction_invalid_right.xml | 0 .../direction_invalid_slight_left.xml | 0 .../direction_invalid_slight_right.xml | 0 .../drawable/direction_invalid_straight.xml | 0 .../res/drawable/direction_invalid_u_turn.xml | 0 .../res/drawable/direction_merge_left.xml | 0 .../res/drawable/direction_merge_right.xml | 0 .../drawable/direction_merge_slight_left.xml | 0 .../drawable/direction_merge_slight_right.xml | 0 .../res/drawable/direction_merge_straight.xml | 0 .../res/drawable/direction_new_name_left.xml | 0 .../res/drawable/direction_new_name_right.xml | 0 .../direction_new_name_sharp_left.xml | 0 .../direction_new_name_sharp_right.xml | 0 .../direction_new_name_slight_left.xml | 0 .../direction_new_name_slight_right.xml | 0 .../drawable/direction_new_name_straight.xml | 0 .../drawable/direction_notification_left.xml | 0 .../drawable/direction_notification_right.xml | 0 .../direction_notification_sharp_left.xml | 0 .../direction_notification_sharp_right.xml | 0 .../direction_notification_slight_left.xml | 0 .../direction_notification_slight_right.xml | 0 .../direction_notification_straight.xml | 0 .../main/res/drawable/direction_off_ramp.xml | 0 .../res/drawable/direction_off_ramp_left.xml | 0 .../res/drawable/direction_off_ramp_right.xml | 0 .../direction_off_ramp_slight_left.xml | 0 .../direction_off_ramp_slight_right.xml | 0 .../main/res/drawable/direction_on_ramp.xml | 0 .../res/drawable/direction_on_ramp_left.xml | 0 .../res/drawable/direction_on_ramp_right.xml | 0 .../drawable/direction_on_ramp_sharp_left.xml | 0 .../direction_on_ramp_sharp_right.xml | 0 .../direction_on_ramp_slight_left.xml | 0 .../direction_on_ramp_slight_right.xml | 0 .../drawable/direction_on_ramp_straight.xml | 0 .../src/main/res/drawable/direction_ramp.xml | 0 .../main/res/drawable/direction_rotary.xml | 0 .../res/drawable/direction_rotary_left.xml | 0 .../res/drawable/direction_rotary_right.xml | 0 .../drawable/direction_rotary_sharp_left.xml | 0 .../drawable/direction_rotary_sharp_right.xml | 0 .../drawable/direction_rotary_slight_left.xml | 0 .../direction_rotary_slight_right.xml | 0 .../drawable/direction_rotary_straight.xml | 0 .../res/drawable/direction_roundabout.xml | 0 .../drawable/direction_roundabout_left.xml | 0 .../drawable/direction_roundabout_right.xml | 0 .../direction_roundabout_sharp_left.xml | 0 .../direction_roundabout_sharp_right.xml | 0 .../direction_roundabout_slight_left.xml | 0 .../direction_roundabout_slight_right.xml | 0 .../direction_roundabout_straight.xml | 0 .../res/drawable/direction_traffic_circle.xml | 0 .../direction_traffic_circle_left.xml | 0 .../direction_traffic_circle_right.xml | 0 .../direction_traffic_circle_slight_left.xml | 0 .../direction_traffic_circle_slight_right.xml | 0 .../main/res/drawable/direction_turn_left.xml | 0 .../res/drawable/direction_turn_right.xml | 0 .../drawable/direction_turn_sharp_left.xml | 0 .../drawable/direction_turn_sharp_right.xml | 0 .../drawable/direction_turn_slight_left.xml | 0 .../drawable/direction_turn_slight_right.xml | 0 .../res/drawable/direction_turn_straight.xml | 0 .../main/res/drawable/direction_u_turn.xml | 0 .../main/res/drawable/direction_updown.xml | 0 .../ferrostar/ui/shared/ExampleUnitTest.kt | 16 ++ 107 files changed, 327 insertions(+), 22 deletions(-) create mode 100644 android/ui-shared/.gitignore create mode 100644 android/ui-shared/build.gradle create mode 100644 android/ui-shared/consumer-rules.pro create mode 100644 android/ui-shared/proguard-rules.pro create mode 100644 android/ui-shared/src/androidTest/java/com/stadiamaps/ferrostar/ui/shared/ManeuverIconTest.kt create mode 100644 android/ui-shared/src/main/AndroidManifest.xml create mode 100644 android/ui-shared/src/main/java/com/stadiamaps/ferrostar/ui/shared/icons/ManeuverIcon.kt rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_arrive.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_arrive_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_arrive_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_arrive_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_close.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_continue.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_continue_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_continue_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_continue_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_continue_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_continue_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_continue_u_turn.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_depart.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_depart_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_depart_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_depart_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_end_of_road_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_end_of_road_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_flag.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_fork.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_fork_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_fork_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_fork_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_fork_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_fork_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_invalid.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_invalid_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_invalid_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_invalid_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_invalid_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_invalid_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_invalid_u_turn.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_merge_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_merge_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_merge_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_merge_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_merge_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_new_name_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_new_name_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_new_name_sharp_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_new_name_sharp_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_new_name_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_new_name_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_new_name_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_notification_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_notification_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_notification_sharp_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_notification_sharp_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_notification_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_notification_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_notification_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_off_ramp.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_off_ramp_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_off_ramp_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_off_ramp_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_off_ramp_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_on_ramp.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_on_ramp_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_on_ramp_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_on_ramp_sharp_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_on_ramp_sharp_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_on_ramp_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_on_ramp_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_on_ramp_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_ramp.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_rotary.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_rotary_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_rotary_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_rotary_sharp_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_rotary_sharp_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_rotary_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_rotary_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_rotary_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_roundabout.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_roundabout_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_roundabout_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_roundabout_sharp_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_roundabout_sharp_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_roundabout_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_roundabout_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_roundabout_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_traffic_circle.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_traffic_circle_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_traffic_circle_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_traffic_circle_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_traffic_circle_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_turn_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_turn_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_turn_sharp_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_turn_sharp_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_turn_slight_left.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_turn_slight_right.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_turn_straight.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_u_turn.xml (100%) rename android/{ui-compose => ui-shared}/src/main/res/drawable/direction_updown.xml (100%) create mode 100644 android/ui-shared/src/test/java/com/stadiamaps/ferrostar/ui/shared/ExampleUnitTest.kt diff --git a/android/settings.gradle b/android/settings.gradle index 6e46753f8..89a40fc93 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,3 +19,4 @@ include ':demo-app' include ':ui-maplibre' include ':google-play-services' include ':ui-formatters' +include ':ui-shared' diff --git a/android/ui-compose/build.gradle b/android/ui-compose/build.gradle index 6599a9eb3..907a4bf94 100644 --- a/android/ui-compose/build.gradle +++ b/android/ui-compose/build.gradle @@ -51,6 +51,7 @@ dependencies { implementation project(':core') implementation project(':ui-formatters') + implementation project(':ui-shared') testImplementation libs.junit androidTestImplementation libs.androidx.test.junit diff --git a/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt index ce0884575..858856926 100644 --- a/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt +++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/notification/DefaultForegroundNotificationBuilder.kt @@ -12,9 +12,9 @@ import com.stadiamaps.ferrostar.ui.formatters.DurationFormatter import com.stadiamaps.ferrostar.ui.formatters.EstimatedArrivalDateTimeFormatter import com.stadiamaps.ferrostar.ui.formatters.LocalizedDistanceFormatter import com.stadiamaps.ferrostar.ui.formatters.LocalizedDurationFormatter -import com.stadiamaps.ferrostar.composeui.views.components.maneuver.maneuverIcon import com.stadiamaps.ferrostar.core.extensions.estimatedArrivalTime import com.stadiamaps.ferrostar.core.service.ForegroundNotificationBuilder +import com.stadiamaps.ferrostar.ui.shared.icons.ManeuverIcon import kotlin.time.ExperimentalTime import uniffi.ferrostar.TripProgress import uniffi.ferrostar.TripState @@ -95,10 +95,17 @@ class DefaultForegroundNotificationBuilder( RemoteViews(context.packageName, R.layout.navigation_notification) } - val instructionImage = visualInstruction.primaryContent.maneuverIcon - remoteViews.setImageViewResource( - R.id.instruction_image, - context.resources.getIdentifier(instructionImage, "drawable", context.packageName)) + val maneuverType = visualInstruction.primaryContent.maneuverType + val maneuverModifier = visualInstruction.primaryContent.maneuverModifier + val maneuverIcon = if (maneuverType != null && maneuverModifier != null) { + ManeuverIcon(context, maneuverType, maneuverModifier) + } else { + null + } + + maneuverIcon?.resourceId?.let { + remoteViews.setImageViewResource(R.id.instruction_image, it) + } // Set the text remoteViews.setTextViewText( diff --git a/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverImage.kt b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverImage.kt index 1c4d39b05..360729ca9 100644 --- a/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverImage.kt +++ b/android/ui-compose/src/main/java/com/stadiamaps/ferrostar/composeui/views/components/maneuver/ManeuverImage.kt @@ -1,6 +1,5 @@ package com.stadiamaps.ferrostar.composeui.views.components.maneuver -import android.annotation.SuppressLint import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor @@ -13,35 +12,28 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.stadiamaps.ferrostar.composeui.R +import com.stadiamaps.ferrostar.ui.shared.icons.ManeuverIcon import uniffi.ferrostar.ManeuverModifier import uniffi.ferrostar.ManeuverType import uniffi.ferrostar.VisualInstructionContent -val VisualInstructionContent.maneuverIcon: String - get() { - val descriptor = - listOfNotNull( - maneuverType?.name?.replace(" ", "_"), maneuverModifier?.name?.replace(" ", "_")) - .joinToString(separator = "_") - return "direction_${descriptor}".lowercase() - } - /** An icon view using the public domain drawables from Mapbox. */ -@SuppressLint("DiscouragedApi") @Composable fun ManeuverImage(content: VisualInstructionContent, tint: Color = LocalContentColor.current) { + val maneuverType = content.maneuverType ?: return + val maneuverModifier = content.maneuverModifier ?: return + val context = LocalContext.current - val resourceId = - context.resources.getIdentifier(content.maneuverIcon, "drawable", context.packageName) + val maneuverIcon = ManeuverIcon(context, maneuverType, maneuverModifier) - if (resourceId != 0) { + // Only display the icon if the resource was found. + // Ignore resolution failures for the moment. + maneuverIcon.resourceId?.let { Icon( - painter = painterResource(id = resourceId), + painter = painterResource(id = it), contentDescription = stringResource(id = R.string.maneuver_image), tint = tint, modifier = Modifier.size(64.dp)) - } else { - // Ignore resolution failures for the moment. } } diff --git a/android/ui-shared/.gitignore b/android/ui-shared/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/android/ui-shared/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/ui-shared/build.gradle b/android/ui-shared/build.gradle new file mode 100644 index 000000000..c594790e6 --- /dev/null +++ b/android/ui-shared/build.gradle @@ -0,0 +1,70 @@ +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary + +plugins { + alias libs.plugins.androidLibrary + alias libs.plugins.ktfmt + alias libs.plugins.mavenPublish +} + +android { + namespace 'com.stadiamaps.ferrostar.ui.shared' + compileSdk { + version = release(36) + } + + defaultConfig { + minSdk 25 + + 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_11 + targetCompatibility JavaVersion.VERSION_11 + } +} + +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 platform(libs.kotlin.bom) + + // Used in the public API + api libs.kotlinx.datetime + + implementation libs.androidx.ktx + implementation libs.androidx.appcompat + implementation libs.androidx.activity.compose + + implementation project(':core') + + 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 Formatters" + description = "Distance, duration and arrival formatters for Ferrostar" + commonPomConfig(it, true) + } +} \ No newline at end of file diff --git a/android/ui-shared/consumer-rules.pro b/android/ui-shared/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/android/ui-shared/proguard-rules.pro b/android/ui-shared/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/android/ui-shared/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-shared/src/androidTest/java/com/stadiamaps/ferrostar/ui/shared/ManeuverIconTest.kt b/android/ui-shared/src/androidTest/java/com/stadiamaps/ferrostar/ui/shared/ManeuverIconTest.kt new file mode 100644 index 000000000..c57dbc362 --- /dev/null +++ b/android/ui-shared/src/androidTest/java/com/stadiamaps/ferrostar/ui/shared/ManeuverIconTest.kt @@ -0,0 +1,146 @@ +package com.stadiamaps.ferrostar.ui.shared + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.stadiamaps.ferrostar.ui.shared.icons.ManeuverIcon +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import uniffi.ferrostar.ManeuverModifier +import uniffi.ferrostar.ManeuverType + +@RunWith(AndroidJUnit4::class) +class ManeuverIconTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().targetContext + } + + @Test + fun identifierFormat() { + assertEquals( + "direction_turn_left", + ManeuverIcon(context, ManeuverType.TURN, ManeuverModifier.LEFT).identifier) + assertEquals( + "direction_new_name_sharp_right", + ManeuverIcon(context, ManeuverType.NEW_NAME, ManeuverModifier.SHARP_RIGHT).identifier) + assertEquals( + "direction_continue_u_turn", + ManeuverIcon(context, ManeuverType.CONTINUE, ManeuverModifier.U_TURN).identifier) + assertEquals( + "direction_end_of_road_left", + ManeuverIcon(context, ManeuverType.END_OF_ROAD, ManeuverModifier.LEFT).identifier) + } + + @Test + fun knownDrawablesHaveNonNullResourceId() { + val combinations = + listOf( + // Turn + ManeuverType.TURN to ManeuverModifier.LEFT, + ManeuverType.TURN to ManeuverModifier.SHARP_LEFT, + ManeuverType.TURN to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.TURN to ManeuverModifier.RIGHT, + ManeuverType.TURN to ManeuverModifier.SHARP_RIGHT, + ManeuverType.TURN to ManeuverModifier.SLIGHT_RIGHT, + ManeuverType.TURN to ManeuverModifier.STRAIGHT, + // NewName + ManeuverType.NEW_NAME to ManeuverModifier.LEFT, + ManeuverType.NEW_NAME to ManeuverModifier.SHARP_LEFT, + ManeuverType.NEW_NAME to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.NEW_NAME to ManeuverModifier.RIGHT, + ManeuverType.NEW_NAME to ManeuverModifier.SHARP_RIGHT, + ManeuverType.NEW_NAME to ManeuverModifier.SLIGHT_RIGHT, + ManeuverType.NEW_NAME to ManeuverModifier.STRAIGHT, + // Depart + ManeuverType.DEPART to ManeuverModifier.LEFT, + ManeuverType.DEPART to ManeuverModifier.RIGHT, + ManeuverType.DEPART to ManeuverModifier.STRAIGHT, + // Arrive + ManeuverType.ARRIVE to ManeuverModifier.LEFT, + ManeuverType.ARRIVE to ManeuverModifier.RIGHT, + ManeuverType.ARRIVE to ManeuverModifier.STRAIGHT, + // Merge + ManeuverType.MERGE to ManeuverModifier.LEFT, + ManeuverType.MERGE to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.MERGE to ManeuverModifier.RIGHT, + ManeuverType.MERGE to ManeuverModifier.SLIGHT_RIGHT, + ManeuverType.MERGE to ManeuverModifier.STRAIGHT, + // OnRamp + ManeuverType.ON_RAMP to ManeuverModifier.LEFT, + ManeuverType.ON_RAMP to ManeuverModifier.SHARP_LEFT, + ManeuverType.ON_RAMP to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.ON_RAMP to ManeuverModifier.RIGHT, + ManeuverType.ON_RAMP to ManeuverModifier.SHARP_RIGHT, + ManeuverType.ON_RAMP to ManeuverModifier.SLIGHT_RIGHT, + ManeuverType.ON_RAMP to ManeuverModifier.STRAIGHT, + // OffRamp + ManeuverType.OFF_RAMP to ManeuverModifier.LEFT, + ManeuverType.OFF_RAMP to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.OFF_RAMP to ManeuverModifier.RIGHT, + ManeuverType.OFF_RAMP to ManeuverModifier.SLIGHT_RIGHT, + // Fork + ManeuverType.FORK to ManeuverModifier.LEFT, + ManeuverType.FORK to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.FORK to ManeuverModifier.RIGHT, + ManeuverType.FORK to ManeuverModifier.SLIGHT_RIGHT, + ManeuverType.FORK to ManeuverModifier.STRAIGHT, + // EndOfRoad + ManeuverType.END_OF_ROAD to ManeuverModifier.LEFT, + ManeuverType.END_OF_ROAD to ManeuverModifier.RIGHT, + // Continue + ManeuverType.CONTINUE to ManeuverModifier.LEFT, + ManeuverType.CONTINUE to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.CONTINUE to ManeuverModifier.RIGHT, + ManeuverType.CONTINUE to ManeuverModifier.SLIGHT_RIGHT, + ManeuverType.CONTINUE to ManeuverModifier.STRAIGHT, + ManeuverType.CONTINUE to ManeuverModifier.U_TURN, + // Roundabout + ManeuverType.ROUNDABOUT to ManeuverModifier.LEFT, + ManeuverType.ROUNDABOUT to ManeuverModifier.SHARP_LEFT, + ManeuverType.ROUNDABOUT to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.ROUNDABOUT to ManeuverModifier.RIGHT, + ManeuverType.ROUNDABOUT to ManeuverModifier.SHARP_RIGHT, + ManeuverType.ROUNDABOUT to ManeuverModifier.SLIGHT_RIGHT, + ManeuverType.ROUNDABOUT to ManeuverModifier.STRAIGHT, + // Rotary + ManeuverType.ROTARY to ManeuverModifier.LEFT, + ManeuverType.ROTARY to ManeuverModifier.SHARP_LEFT, + ManeuverType.ROTARY to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.ROTARY to ManeuverModifier.RIGHT, + ManeuverType.ROTARY to ManeuverModifier.SHARP_RIGHT, + ManeuverType.ROTARY to ManeuverModifier.SLIGHT_RIGHT, + ManeuverType.ROTARY to ManeuverModifier.STRAIGHT, + // Notification + ManeuverType.NOTIFICATION to ManeuverModifier.LEFT, + ManeuverType.NOTIFICATION to ManeuverModifier.SHARP_LEFT, + ManeuverType.NOTIFICATION to ManeuverModifier.SLIGHT_LEFT, + ManeuverType.NOTIFICATION to ManeuverModifier.RIGHT, + ManeuverType.NOTIFICATION to ManeuverModifier.SHARP_RIGHT, + ManeuverType.NOTIFICATION to ManeuverModifier.SLIGHT_RIGHT, + ManeuverType.NOTIFICATION to ManeuverModifier.STRAIGHT, + ) + + for ((type, modifier) in combinations) { + val icon = ManeuverIcon(context, type, modifier) + assertNotNull("Expected non-null resourceId for '${icon.identifier}'", icon.resourceId) + } + } + + @Test + fun missingDrawableReturnsNullResourceId() { + // No drawable exists for these type+modifier combinations + assertNull(ManeuverIcon(context, ManeuverType.TURN, ManeuverModifier.U_TURN).resourceId) + assertNull( + ManeuverIcon(context, ManeuverType.ROUNDABOUT_TURN, ManeuverModifier.LEFT).resourceId) + assertNull( + ManeuverIcon(context, ManeuverType.EXIT_ROUNDABOUT, ManeuverModifier.LEFT).resourceId) + } +} diff --git a/android/ui-shared/src/main/AndroidManifest.xml b/android/ui-shared/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a5918e68a --- /dev/null +++ b/android/ui-shared/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/ui-shared/src/main/java/com/stadiamaps/ferrostar/ui/shared/icons/ManeuverIcon.kt b/android/ui-shared/src/main/java/com/stadiamaps/ferrostar/ui/shared/icons/ManeuverIcon.kt new file mode 100644 index 000000000..e1714955a --- /dev/null +++ b/android/ui-shared/src/main/java/com/stadiamaps/ferrostar/ui/shared/icons/ManeuverIcon.kt @@ -0,0 +1,46 @@ +package com.stadiamaps.ferrostar.ui.shared.icons + +import android.annotation.SuppressLint +import android.content.Context +import androidx.core.graphics.drawable.IconCompat +import uniffi.ferrostar.ManeuverModifier +import uniffi.ferrostar.ManeuverType + +@SuppressLint("DiscouragedApi") +class ManeuverIcon( + private val context: Context, + maneuverType: ManeuverType, + maneuverModifier: ManeuverModifier +) { + + private val _identifier: String + val identifier: String + get() = _identifier + private val _resourceId: Int + + init { + val descriptor = + listOfNotNull( + maneuverType.name.replace(" ", "_"), + maneuverModifier.name.replace(" ", "_") + ) + .joinToString(separator = "_") + + this._identifier = "direction_${descriptor}".lowercase() + this._resourceId = context.resources.getIdentifier(this.identifier, "drawable", context.packageName) + } + + val resourceId: Int? + get() { + if (_resourceId == 0) { + return null + } + + return _resourceId + } + + fun iconCompat(): IconCompat? = + resourceId?.let { + IconCompat.createWithResource(context, it) + } +} diff --git a/android/ui-compose/src/main/res/drawable/direction_arrive.xml b/android/ui-shared/src/main/res/drawable/direction_arrive.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_arrive.xml rename to android/ui-shared/src/main/res/drawable/direction_arrive.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_arrive_left.xml b/android/ui-shared/src/main/res/drawable/direction_arrive_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_arrive_left.xml rename to android/ui-shared/src/main/res/drawable/direction_arrive_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_arrive_right.xml b/android/ui-shared/src/main/res/drawable/direction_arrive_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_arrive_right.xml rename to android/ui-shared/src/main/res/drawable/direction_arrive_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_arrive_straight.xml b/android/ui-shared/src/main/res/drawable/direction_arrive_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_arrive_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_arrive_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_close.xml b/android/ui-shared/src/main/res/drawable/direction_close.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_close.xml rename to android/ui-shared/src/main/res/drawable/direction_close.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_continue.xml b/android/ui-shared/src/main/res/drawable/direction_continue.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_continue.xml rename to android/ui-shared/src/main/res/drawable/direction_continue.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_continue_left.xml b/android/ui-shared/src/main/res/drawable/direction_continue_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_continue_left.xml rename to android/ui-shared/src/main/res/drawable/direction_continue_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_continue_right.xml b/android/ui-shared/src/main/res/drawable/direction_continue_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_continue_right.xml rename to android/ui-shared/src/main/res/drawable/direction_continue_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_continue_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_continue_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_continue_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_continue_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_continue_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_continue_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_continue_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_continue_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_continue_straight.xml b/android/ui-shared/src/main/res/drawable/direction_continue_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_continue_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_continue_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_continue_u_turn.xml b/android/ui-shared/src/main/res/drawable/direction_continue_u_turn.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_continue_u_turn.xml rename to android/ui-shared/src/main/res/drawable/direction_continue_u_turn.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_depart.xml b/android/ui-shared/src/main/res/drawable/direction_depart.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_depart.xml rename to android/ui-shared/src/main/res/drawable/direction_depart.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_depart_left.xml b/android/ui-shared/src/main/res/drawable/direction_depart_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_depart_left.xml rename to android/ui-shared/src/main/res/drawable/direction_depart_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_depart_right.xml b/android/ui-shared/src/main/res/drawable/direction_depart_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_depart_right.xml rename to android/ui-shared/src/main/res/drawable/direction_depart_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_depart_straight.xml b/android/ui-shared/src/main/res/drawable/direction_depart_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_depart_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_depart_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_end_of_road_left.xml b/android/ui-shared/src/main/res/drawable/direction_end_of_road_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_end_of_road_left.xml rename to android/ui-shared/src/main/res/drawable/direction_end_of_road_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_end_of_road_right.xml b/android/ui-shared/src/main/res/drawable/direction_end_of_road_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_end_of_road_right.xml rename to android/ui-shared/src/main/res/drawable/direction_end_of_road_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_flag.xml b/android/ui-shared/src/main/res/drawable/direction_flag.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_flag.xml rename to android/ui-shared/src/main/res/drawable/direction_flag.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_fork.xml b/android/ui-shared/src/main/res/drawable/direction_fork.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_fork.xml rename to android/ui-shared/src/main/res/drawable/direction_fork.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_fork_left.xml b/android/ui-shared/src/main/res/drawable/direction_fork_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_fork_left.xml rename to android/ui-shared/src/main/res/drawable/direction_fork_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_fork_right.xml b/android/ui-shared/src/main/res/drawable/direction_fork_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_fork_right.xml rename to android/ui-shared/src/main/res/drawable/direction_fork_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_fork_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_fork_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_fork_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_fork_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_fork_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_fork_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_fork_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_fork_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_fork_straight.xml b/android/ui-shared/src/main/res/drawable/direction_fork_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_fork_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_fork_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_invalid.xml b/android/ui-shared/src/main/res/drawable/direction_invalid.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_invalid.xml rename to android/ui-shared/src/main/res/drawable/direction_invalid.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_invalid_left.xml b/android/ui-shared/src/main/res/drawable/direction_invalid_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_invalid_left.xml rename to android/ui-shared/src/main/res/drawable/direction_invalid_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_invalid_right.xml b/android/ui-shared/src/main/res/drawable/direction_invalid_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_invalid_right.xml rename to android/ui-shared/src/main/res/drawable/direction_invalid_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_invalid_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_invalid_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_invalid_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_invalid_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_invalid_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_invalid_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_invalid_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_invalid_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_invalid_straight.xml b/android/ui-shared/src/main/res/drawable/direction_invalid_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_invalid_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_invalid_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_invalid_u_turn.xml b/android/ui-shared/src/main/res/drawable/direction_invalid_u_turn.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_invalid_u_turn.xml rename to android/ui-shared/src/main/res/drawable/direction_invalid_u_turn.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_merge_left.xml b/android/ui-shared/src/main/res/drawable/direction_merge_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_merge_left.xml rename to android/ui-shared/src/main/res/drawable/direction_merge_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_merge_right.xml b/android/ui-shared/src/main/res/drawable/direction_merge_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_merge_right.xml rename to android/ui-shared/src/main/res/drawable/direction_merge_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_merge_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_merge_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_merge_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_merge_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_merge_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_merge_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_merge_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_merge_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_merge_straight.xml b/android/ui-shared/src/main/res/drawable/direction_merge_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_merge_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_merge_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_new_name_left.xml b/android/ui-shared/src/main/res/drawable/direction_new_name_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_new_name_left.xml rename to android/ui-shared/src/main/res/drawable/direction_new_name_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_new_name_right.xml b/android/ui-shared/src/main/res/drawable/direction_new_name_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_new_name_right.xml rename to android/ui-shared/src/main/res/drawable/direction_new_name_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_new_name_sharp_left.xml b/android/ui-shared/src/main/res/drawable/direction_new_name_sharp_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_new_name_sharp_left.xml rename to android/ui-shared/src/main/res/drawable/direction_new_name_sharp_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_new_name_sharp_right.xml b/android/ui-shared/src/main/res/drawable/direction_new_name_sharp_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_new_name_sharp_right.xml rename to android/ui-shared/src/main/res/drawable/direction_new_name_sharp_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_new_name_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_new_name_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_new_name_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_new_name_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_new_name_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_new_name_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_new_name_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_new_name_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_new_name_straight.xml b/android/ui-shared/src/main/res/drawable/direction_new_name_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_new_name_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_new_name_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_notification_left.xml b/android/ui-shared/src/main/res/drawable/direction_notification_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_notification_left.xml rename to android/ui-shared/src/main/res/drawable/direction_notification_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_notification_right.xml b/android/ui-shared/src/main/res/drawable/direction_notification_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_notification_right.xml rename to android/ui-shared/src/main/res/drawable/direction_notification_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_notification_sharp_left.xml b/android/ui-shared/src/main/res/drawable/direction_notification_sharp_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_notification_sharp_left.xml rename to android/ui-shared/src/main/res/drawable/direction_notification_sharp_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_notification_sharp_right.xml b/android/ui-shared/src/main/res/drawable/direction_notification_sharp_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_notification_sharp_right.xml rename to android/ui-shared/src/main/res/drawable/direction_notification_sharp_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_notification_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_notification_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_notification_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_notification_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_notification_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_notification_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_notification_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_notification_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_notification_straight.xml b/android/ui-shared/src/main/res/drawable/direction_notification_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_notification_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_notification_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_off_ramp.xml b/android/ui-shared/src/main/res/drawable/direction_off_ramp.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_off_ramp.xml rename to android/ui-shared/src/main/res/drawable/direction_off_ramp.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_off_ramp_left.xml b/android/ui-shared/src/main/res/drawable/direction_off_ramp_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_off_ramp_left.xml rename to android/ui-shared/src/main/res/drawable/direction_off_ramp_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_off_ramp_right.xml b/android/ui-shared/src/main/res/drawable/direction_off_ramp_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_off_ramp_right.xml rename to android/ui-shared/src/main/res/drawable/direction_off_ramp_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_off_ramp_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_off_ramp_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_off_ramp_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_off_ramp_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_off_ramp_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_off_ramp_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_off_ramp_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_off_ramp_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_on_ramp.xml b/android/ui-shared/src/main/res/drawable/direction_on_ramp.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_on_ramp.xml rename to android/ui-shared/src/main/res/drawable/direction_on_ramp.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_on_ramp_left.xml b/android/ui-shared/src/main/res/drawable/direction_on_ramp_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_on_ramp_left.xml rename to android/ui-shared/src/main/res/drawable/direction_on_ramp_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_on_ramp_right.xml b/android/ui-shared/src/main/res/drawable/direction_on_ramp_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_on_ramp_right.xml rename to android/ui-shared/src/main/res/drawable/direction_on_ramp_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_on_ramp_sharp_left.xml b/android/ui-shared/src/main/res/drawable/direction_on_ramp_sharp_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_on_ramp_sharp_left.xml rename to android/ui-shared/src/main/res/drawable/direction_on_ramp_sharp_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_on_ramp_sharp_right.xml b/android/ui-shared/src/main/res/drawable/direction_on_ramp_sharp_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_on_ramp_sharp_right.xml rename to android/ui-shared/src/main/res/drawable/direction_on_ramp_sharp_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_on_ramp_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_on_ramp_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_on_ramp_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_on_ramp_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_on_ramp_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_on_ramp_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_on_ramp_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_on_ramp_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_on_ramp_straight.xml b/android/ui-shared/src/main/res/drawable/direction_on_ramp_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_on_ramp_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_on_ramp_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_ramp.xml b/android/ui-shared/src/main/res/drawable/direction_ramp.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_ramp.xml rename to android/ui-shared/src/main/res/drawable/direction_ramp.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_rotary.xml b/android/ui-shared/src/main/res/drawable/direction_rotary.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_rotary.xml rename to android/ui-shared/src/main/res/drawable/direction_rotary.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_rotary_left.xml b/android/ui-shared/src/main/res/drawable/direction_rotary_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_rotary_left.xml rename to android/ui-shared/src/main/res/drawable/direction_rotary_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_rotary_right.xml b/android/ui-shared/src/main/res/drawable/direction_rotary_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_rotary_right.xml rename to android/ui-shared/src/main/res/drawable/direction_rotary_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_rotary_sharp_left.xml b/android/ui-shared/src/main/res/drawable/direction_rotary_sharp_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_rotary_sharp_left.xml rename to android/ui-shared/src/main/res/drawable/direction_rotary_sharp_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_rotary_sharp_right.xml b/android/ui-shared/src/main/res/drawable/direction_rotary_sharp_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_rotary_sharp_right.xml rename to android/ui-shared/src/main/res/drawable/direction_rotary_sharp_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_rotary_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_rotary_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_rotary_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_rotary_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_rotary_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_rotary_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_rotary_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_rotary_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_rotary_straight.xml b/android/ui-shared/src/main/res/drawable/direction_rotary_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_rotary_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_rotary_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_roundabout.xml b/android/ui-shared/src/main/res/drawable/direction_roundabout.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_roundabout.xml rename to android/ui-shared/src/main/res/drawable/direction_roundabout.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_roundabout_left.xml b/android/ui-shared/src/main/res/drawable/direction_roundabout_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_roundabout_left.xml rename to android/ui-shared/src/main/res/drawable/direction_roundabout_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_roundabout_right.xml b/android/ui-shared/src/main/res/drawable/direction_roundabout_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_roundabout_right.xml rename to android/ui-shared/src/main/res/drawable/direction_roundabout_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_roundabout_sharp_left.xml b/android/ui-shared/src/main/res/drawable/direction_roundabout_sharp_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_roundabout_sharp_left.xml rename to android/ui-shared/src/main/res/drawable/direction_roundabout_sharp_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_roundabout_sharp_right.xml b/android/ui-shared/src/main/res/drawable/direction_roundabout_sharp_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_roundabout_sharp_right.xml rename to android/ui-shared/src/main/res/drawable/direction_roundabout_sharp_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_roundabout_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_roundabout_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_roundabout_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_roundabout_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_roundabout_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_roundabout_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_roundabout_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_roundabout_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_roundabout_straight.xml b/android/ui-shared/src/main/res/drawable/direction_roundabout_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_roundabout_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_roundabout_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_traffic_circle.xml b/android/ui-shared/src/main/res/drawable/direction_traffic_circle.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_traffic_circle.xml rename to android/ui-shared/src/main/res/drawable/direction_traffic_circle.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_traffic_circle_left.xml b/android/ui-shared/src/main/res/drawable/direction_traffic_circle_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_traffic_circle_left.xml rename to android/ui-shared/src/main/res/drawable/direction_traffic_circle_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_traffic_circle_right.xml b/android/ui-shared/src/main/res/drawable/direction_traffic_circle_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_traffic_circle_right.xml rename to android/ui-shared/src/main/res/drawable/direction_traffic_circle_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_traffic_circle_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_traffic_circle_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_traffic_circle_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_traffic_circle_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_traffic_circle_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_traffic_circle_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_traffic_circle_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_traffic_circle_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_turn_left.xml b/android/ui-shared/src/main/res/drawable/direction_turn_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_turn_left.xml rename to android/ui-shared/src/main/res/drawable/direction_turn_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_turn_right.xml b/android/ui-shared/src/main/res/drawable/direction_turn_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_turn_right.xml rename to android/ui-shared/src/main/res/drawable/direction_turn_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_turn_sharp_left.xml b/android/ui-shared/src/main/res/drawable/direction_turn_sharp_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_turn_sharp_left.xml rename to android/ui-shared/src/main/res/drawable/direction_turn_sharp_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_turn_sharp_right.xml b/android/ui-shared/src/main/res/drawable/direction_turn_sharp_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_turn_sharp_right.xml rename to android/ui-shared/src/main/res/drawable/direction_turn_sharp_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_turn_slight_left.xml b/android/ui-shared/src/main/res/drawable/direction_turn_slight_left.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_turn_slight_left.xml rename to android/ui-shared/src/main/res/drawable/direction_turn_slight_left.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_turn_slight_right.xml b/android/ui-shared/src/main/res/drawable/direction_turn_slight_right.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_turn_slight_right.xml rename to android/ui-shared/src/main/res/drawable/direction_turn_slight_right.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_turn_straight.xml b/android/ui-shared/src/main/res/drawable/direction_turn_straight.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_turn_straight.xml rename to android/ui-shared/src/main/res/drawable/direction_turn_straight.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_u_turn.xml b/android/ui-shared/src/main/res/drawable/direction_u_turn.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_u_turn.xml rename to android/ui-shared/src/main/res/drawable/direction_u_turn.xml diff --git a/android/ui-compose/src/main/res/drawable/direction_updown.xml b/android/ui-shared/src/main/res/drawable/direction_updown.xml similarity index 100% rename from android/ui-compose/src/main/res/drawable/direction_updown.xml rename to android/ui-shared/src/main/res/drawable/direction_updown.xml diff --git a/android/ui-shared/src/test/java/com/stadiamaps/ferrostar/ui/shared/ExampleUnitTest.kt b/android/ui-shared/src/test/java/com/stadiamaps/ferrostar/ui/shared/ExampleUnitTest.kt new file mode 100644 index 000000000..68a8dfaf9 --- /dev/null +++ b/android/ui-shared/src/test/java/com/stadiamaps/ferrostar/ui/shared/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.stadiamaps.ferrostar.ui.shared + +import org.junit.Assert.* +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} From 36e8dfb6f7ba0bd0232bcc84285b5d8e53283b2b Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sun, 15 Mar 2026 08:28:07 -0700 Subject: [PATCH 07/15] feat: initializing car app --- android/car-app/.gitignore | 1 + android/car-app/build.gradle | 46 ++++++ android/car-app/consumer-rules.pro | 0 android/car-app/proguard-rules.pro | 21 +++ .../car/app/ExampleInstrumentedTest.kt | 22 +++ android/car-app/src/main/AndroidManifest.xml | 4 + .../car/app/intent/NavigationDestination.kt | 36 +++++ .../car/app/intent/NavigationIntentHandler.kt | 24 +++ .../car/app/intent/NavigationIntentParser.kt | 85 ++++++++++ .../app/navigation/NavigationManagerBridge.kt | 148 ++++++++++++++++++ .../TurnByTurnNotificationManager.kt | 72 +++++++++ .../app/template/NavigationTemplateBuilder.kt | 148 ++++++++++++++++++ .../app/template/icons/InterfaceCarIcons.kt | 40 +++++ .../car/app/template/models/LaneBuilder.kt | 29 ++++ .../app/template/models/ManeuverBuilder.kt | 144 +++++++++++++++++ .../app/template/models/RoutingInfoBuilder.kt | 49 ++++++ .../car/app/template/models/StepBuilder.kt | 35 +++++ .../template/models/TravelEstimateBuilder.kt | 63 ++++++++ .../car/app/template/models/TripBuilder.kt | 64 ++++++++ .../src/main/res/drawable/add_24px.xml | 10 ++ .../src/main/res/drawable/navigation_24px.xml | 10 ++ .../src/main/res/drawable/remove_24px.xml | 10 ++ .../src/main/res/drawable/route_24px.xml | 10 ++ .../main/res/drawable/volume_mute_24px.xml | 11 ++ .../src/main/res/drawable/volume_up_24px.xml | 11 ++ .../car-app/src/main/res/values/strings.xml | 4 + .../car/app/NavigationIntentParserTest.kt | 113 +++++++++++++ .../ferrostar/core/NavigationViewModel.kt | 7 +- .../core/extensions/TripStateExtensions.kt | 5 + android/demo-app/build.gradle | 6 +- android/demo-app/src/main/AndroidManifest.xml | 24 ++- .../ferrostar/DemoNavigationViewModel.kt | 1 + .../src/main/res/xml/automotive_app_desc.xml | 4 + android/gradle/libs.versions.toml | 3 + android/settings.gradle | 2 + android/ui-maplibre-car-app/.gitignore | 1 + android/ui-maplibre-car-app/build.gradle | 86 ++++++++++ .../ui-maplibre-car-app/consumer-rules.pro | 0 .../ui-maplibre-car-app/proguard-rules.pro | 21 +++ .../car/app/ExampleInstrumentedTest.kt | 22 +++ .../src/main/AndroidManifest.xml | 4 + .../maplibre/car/app/CarAppNavigationView.kt | 102 ++++++++++++ .../maplibre/car/app/runtime/ScreenState.kt | 89 +++++++++++ .../car/app/runtime/SurfaceStablePadding.kt | 103 ++++++++++++ .../ui/maplibre/car/app/ExampleUnitTest.kt | 16 ++ 45 files changed, 1702 insertions(+), 4 deletions(-) create mode 100644 android/car-app/.gitignore create mode 100644 android/car-app/build.gradle create mode 100644 android/car-app/consumer-rules.pro create mode 100644 android/car-app/proguard-rules.pro create mode 100644 android/car-app/src/androidTest/java/com/stadiamaps/ferrostar/car/app/ExampleInstrumentedTest.kt create mode 100644 android/car-app/src/main/AndroidManifest.xml create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationDestination.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationIntentHandler.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationIntentParser.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/NavigationManagerBridge.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/TurnByTurnNotificationManager.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/NavigationTemplateBuilder.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/icons/InterfaceCarIcons.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/LaneBuilder.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/ManeuverBuilder.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/RoutingInfoBuilder.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/StepBuilder.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/TravelEstimateBuilder.kt create mode 100644 android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/TripBuilder.kt create mode 100644 android/car-app/src/main/res/drawable/add_24px.xml create mode 100644 android/car-app/src/main/res/drawable/navigation_24px.xml create mode 100644 android/car-app/src/main/res/drawable/remove_24px.xml create mode 100644 android/car-app/src/main/res/drawable/route_24px.xml create mode 100644 android/car-app/src/main/res/drawable/volume_mute_24px.xml create mode 100644 android/car-app/src/main/res/drawable/volume_up_24px.xml create mode 100644 android/car-app/src/main/res/values/strings.xml create mode 100644 android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/NavigationIntentParserTest.kt create mode 100644 android/demo-app/src/main/res/xml/automotive_app_desc.xml create mode 100644 android/ui-maplibre-car-app/.gitignore create mode 100644 android/ui-maplibre-car-app/build.gradle create mode 100644 android/ui-maplibre-car-app/consumer-rules.pro create mode 100644 android/ui-maplibre-car-app/proguard-rules.pro create mode 100644 android/ui-maplibre-car-app/src/androidTest/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleInstrumentedTest.kt create mode 100644 android/ui-maplibre-car-app/src/main/AndroidManifest.xml create mode 100644 android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt create mode 100644 android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt create mode 100644 android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/SurfaceStablePadding.kt create mode 100644 android/ui-maplibre-car-app/src/test/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleUnitTest.kt 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..f60234499 --- /dev/null +++ b/android/car-app/build.gradle @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.androidLibrary) +} + +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 { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } +} + +dependencies { + 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 +} \ 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/car-app/src/androidTest/java/com/stadiamaps/ferrostar/car/app/ExampleInstrumentedTest.kt b/android/car-app/src/androidTest/java/com/stadiamaps/ferrostar/car/app/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..7ae73ffb6 --- /dev/null +++ b/android/car-app/src/androidTest/java/com/stadiamaps/ferrostar/car/app/ExampleInstrumentedTest.kt @@ -0,0 +1,22 @@ +package com.stadiamaps.ferrostar.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.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..0c256e5e8 --- /dev/null +++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/intent/NavigationDestination.kt @@ -0,0 +1,36 @@ +package com.stadiamaps.ferrostar.car.app.intent + +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 [GeographicCoordinate], or null if only a query is available. */ + val coordinate: GeographicCoordinate? + get() = + if (latitude != null && longitude != null) GeographicCoordinate(latitude, 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..a49ce33e4 --- /dev/null +++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/NavigationManagerBridge.kt @@ -0,0 +1,148 @@ +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 com.stadiamaps.ferrostar.car.app.template.models.FerrostarTrip +import com.stadiamaps.ferrostar.core.NavigationUiState +import com.stadiamaps.ferrostar.core.NavigationViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import uniffi.ferrostar.DrivingSide + +/** + * Bridges Ferrostar's [NavigationViewModel] to Car App Library's [NavigationManager]. + * + * This component handles: + * - Calling [NavigationManager.navigationStarted] / [NavigationManager.navigationEnded] at the + * correct lifecycle points (NF-4, NF-5). + * - Feeding [NavigationManager.updateTrip] on each state update. + * - Delegating [NavigationManagerCallback.onStopNavigation] to the provided callback. + * - Delegating [NavigationManagerCallback.onAutoDriveEnabled] for NF-7 simulation support. + * + * Auto-drive simulation is intentionally NOT included — apps should implement [onAutoDriveEnabled] + * themselves, as it requires direct access to [FerrostarCore] for location injection. + * + * @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. + * @param onStopNavigation Called when the head unit requests navigation stop. + * @param onAutoDriveEnabled Called when auto-drive simulation is requested (NF-7). Optional. + */ +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 var observationJob: Job? = null + private var wasNavigating = false + private var destination: Destination? = null + + /** + * Sets the destination shown on the head unit during navigation. + * + * May be called at any time. Takes effect on the next [NavigationManager.updateTrip] call. + */ + fun setDestination(destination: Destination?) { + this.destination = destination + } + + /** + * 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() + } + }) + + observationJob = + viewModel.navigationUiState + .onEach { state -> onNavigationStateUpdate(state) } + .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() { + observationJob?.cancel() + observationJob = null + + notificationManager?.clear() + + if (wasNavigating) { + navigationManager.navigationEnded() + wasNavigating = false + } + + navigationManager.clearNavigationManagerCallback() + } + + private fun onNavigationStateUpdate(uiState: NavigationUiState) { + val isNavigating = uiState.isNavigating() + + if (isNavigating && !wasNavigating) { + navigationManager.navigationStarted() + wasNavigating = true + } + + if (isNavigating) { + updateTrip(uiState) + } + + if (!isNavigating && wasNavigating) { + notificationManager?.clear() + navigationManager.navigationEnded() + wasNavigating = false + } + } + + private fun updateTrip(uiState: NavigationUiState) { + uiState.tripState?.let { + val trip = FerrostarTrip.Builder(context) + .setTripState(it) + .setBackupDrivingSide(backupDrivingSide) + .apply { + destination?.let { dest -> setDestination(dest) } + } + .build() + + try { + navigationManager.updateTrip(trip) + } catch (e: Exception) { + Log.w(TAG, "Failed to update trip", e) + } + } + + uiState.visualInstruction?.let { + notificationManager?.update(it) + } + } + + 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..eb46c7e23 --- /dev/null +++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/TurnByTurnNotificationManager.kt @@ -0,0 +1,72 @@ +package com.stadiamaps.ferrostar.car.app.navigation + +import android.annotation.SuppressLint +import android.app.NotificationChannel +import android.app.NotificationManager +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 uniffi.ferrostar.VisualInstruction + +/** + * 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. + */ +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 notificationManager = NotificationManagerCompat.from(context) + + init { + val channel = + NotificationChannel(channelId, "Navigation", NotificationManager.IMPORTANCE_HIGH).apply { + description = "Turn-by-turn navigation directions" + } + 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: VisualInstruction) { + val notification = + NotificationCompat.Builder(context, channelId) + .setSmallIcon(smallIconRes) + .setContentTitle(instruction.primaryContent.text) + .setContentText(instruction.secondaryContent?.text) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_NAVIGATION) + .extend( + CarAppExtender.Builder().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..9d19fa844 --- /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 info = FerrostarRoutingInfo.Builder(carContext) + .setTripState(state) + .build() + setNavigationInfo(info) + + 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..3f7c76b28 --- /dev/null +++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/RoutingInfoBuilder.kt @@ -0,0 +1,49 @@ +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() + val progress = tripState?.progress() + val currentStep = tripState?.currentStep() + + return RoutingInfo.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 distance = progress.toCarDistanceToNextManeuver() + + setCurrentStep(step, distance) + } + } + .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..20cc422fc --- /dev/null +++ b/android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/models/TripBuilder.kt @@ -0,0 +1,64 @@ +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: Destination): Builder { + this.destination = destination + 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..d56c2382b --- /dev/null +++ b/android/car-app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Stop + \ No newline at end of file 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/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt index e8b0ef857..2caaa07b5 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 @@ -24,6 +24,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 +38,8 @@ data class NavigationUiState( * in the `location` and `snappedLocation` properties. */ val heading: Float?, + /** 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. */ @@ -76,6 +79,7 @@ data class NavigationUiState( location = coreState.tripState.preferredUserLocation(), // TODO: Heading/course over ground heading = null, + tripState = coreState.tripState, routeGeometry = coreState.routeGeometry, visualInstruction = coreState.tripState.visualInstruction(), spokenInstruction = null, @@ -86,7 +90,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 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..eda31935b 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 @@ -87,6 +87,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/demo-app/build.gradle b/android/demo-app/build.gradle index 25cf3a921..ee9084b47 100644 --- a/android/demo-app/build.gradle +++ b/android/demo-app/build.gradle @@ -63,10 +63,14 @@ 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 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/DemoNavigationViewModel.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt index e1a36f52d..ff6c321b7 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 @@ -62,6 +62,7 @@ class DemoNavigationViewModel( null, null, null, + null, false, null, null, 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/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 8ed7bef04..8d6b288cc 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 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-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..36f582a94 --- /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 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 + } + 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..75a3f9d0a --- /dev/null +++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt @@ -0,0 +1,102 @@ +package com.stadiamaps.ferrostar.ui.maplibre.car.app + +import android.graphics.Rect +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.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(), + stableArea: Rect? = 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 gridPadding = surfaceStableFractionalPadding(stableArea) + + Box(modifier) { + NavigationMapView( + styleUrl, + camera, + uiState = uiState, + mapControls = mapControls, + locationRequestProperties = locationRequestProperties, + routeOverlayBuilder = routeOverlayBuilder, + onMapReadyCallback = { + // No definition + }, + content = mapContent + ) + + 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..86bb834fe --- /dev/null +++ b/android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/runtime/ScreenState.kt @@ -0,0 +1,89 @@ +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 and assign it to [ComposableScreen.surfaceGestureCallback]. + * 2. Inside a [MapLibreComposable] context, call [rememberSurfaceArea] to wire up map gestures + * and observe safe areas in a single call. + */ +class SurfaceAreaTracker : SurfaceGestureCallback { + val stableArea: MutableState = mutableStateOf(null) + val visibleArea: MutableState = mutableStateOf(null) + + @Volatile + var delegate: SurfaceGestureCallback? = null + + 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) and returns a [State] tracking the + * current [SurfaceArea]. Must be called within a [MapLibreComposable] context. + */ + @Composable + @MapLibreComposable + fun rememberSurfaceArea(): State { + rememberMapSurfaceGestureCallback { delegate = it } + 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/ui-maplibre-car-app/src/test/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleUnitTest.kt b/android/ui-maplibre-car-app/src/test/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleUnitTest.kt new file mode 100644 index 000000000..41ffea42a --- /dev/null +++ b/android/ui-maplibre-car-app/src/test/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/ExampleUnitTest.kt @@ -0,0 +1,16 @@ +package com.stadiamaps.ferrostar.ui.maplibre.car.app + +import org.junit.Assert.* +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} From 81dbbf875e025facb043a9e6c62a37282eb09eff Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sun, 15 Mar 2026 09:00:53 -0700 Subject: [PATCH 08/15] feat: initialize android auto/car app --- .../car/app/intent/NavigationDestination.kt | 15 +- .../ferrostar/core/NavigationViewModel.kt | 3 + android/demo-app/build.gradle | 4 +- .../ferrostar/DemoNavigationViewModel.kt | 70 ++++++- .../ferrostar/NotNavigatingOverlay.kt | 2 +- .../ferrostar/auto/DemoCarAppService.kt | 33 ++++ .../ferrostar/auto/DemoMapTemplateBuilder.kt | 12 ++ .../ferrostar/auto/DemoNavigationScreen.kt | 173 ++++++++++++++++++ .../ferrostar/auto/DemoNavigationView.kt | 72 ++++++++ .../src/main/res/drawable/ic_navigation.xml | 11 ++ android/gradle/libs.versions.toml | 1 + 11 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoCarAppService.kt create mode 100644 android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoMapTemplateBuilder.kt create mode 100644 android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt create mode 100644 android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt create mode 100644 android/demo-app/src/main/res/drawable/ic_navigation.xml 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 index 0c256e5e8..338689dff 100644 --- 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 @@ -1,5 +1,6 @@ package com.stadiamaps.ferrostar.car.app.intent +import android.location.Location import uniffi.ferrostar.GeographicCoordinate /** @@ -18,11 +19,17 @@ data class NavigationDestination( val longitude: Double?, val query: String? ) { - /** The destination as a [GeographicCoordinate], or null if only a query is available. */ - val coordinate: GeographicCoordinate? + /** The destination as a [Location], or null if only a query is available. */ + val location: Location? get() = - if (latitude != null && longitude != null) GeographicCoordinate(latitude, longitude) - else null + 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 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 b5f7d4d62..db4f5e505 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 @@ -1,5 +1,6 @@ package com.stadiamaps.ferrostar.core +import android.location.Location import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -27,6 +28,8 @@ import uniffi.ferrostar.TripProgress import uniffi.ferrostar.TripState import uniffi.ferrostar.UserLocation import uniffi.ferrostar.VisualInstruction +import uniffi.ferrostar.Waypoint +import uniffi.ferrostar.WaypointKind data class NavigationUiState( /** The user's location as reported by the location provider. */ diff --git a/android/demo-app/build.gradle b/android/demo-app/build.gradle index ee9084b47..49cec93a6 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" @@ -73,6 +74,7 @@ dependencies { 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 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 0b7ffc9ef..9aff2d775 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 @@ -2,12 +2,18 @@ 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.NavigationUiState import com.stadiamaps.ferrostar.core.annotation.AnnotationPublisher import com.stadiamaps.ferrostar.core.annotation.valhalla.valhallaExtendedOSRMAnnotationPublisher +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 @@ -24,6 +30,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.maplibre.android.geometry.LatLngBounds import uniffi.ferrostar.GeographicCoordinate import uniffi.ferrostar.UserLocation import uniffi.ferrostar.Waypoint @@ -101,6 +108,10 @@ class DemoNavigationViewModel( _simulated.value = !_simulated.value } + fun enableAutoDriveSimulation() { + _simulated.value = true + } + override fun toggleMute() { val spokenInstructionObserver = ferrostarCore.spokenInstructionObserver if (spokenInstructionObserver == null) { @@ -110,11 +121,14 @@ class DemoNavigationViewModel( spokenInstructionObserver.setMuted(!spokenInstructionObserver.isMuted) } - fun startNavigation(destination: Location) { + fun startNavigation(destination: Location, name: String?) { viewModelScope.launch(Dispatchers.IO) { // TODO: Fail gracefully val lastLocation = location.value ?: return@launch + // TODO: Add label to waypoint? + Log.d(TAG, "fetching route to $destination with name $name") + val routes = ferrostarCore.getRoutes( lastLocation, @@ -139,4 +153,58 @@ class DemoNavigationViewModel( 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 + ) + } + } + + private fun centerOnUser() { + mapViewCamera.value = navigationCamera.value + } + + companion object { + const val TAG = "DemoNavigationViewModel" + } } 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 index e2affeb74..c1a26c9a5 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/NotNavigatingOverlay.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/NotNavigatingOverlay.kt @@ -40,7 +40,7 @@ fun NotNavigatingOverlay( userLocation = location?.toAndroidLocation() ) { feature -> feature.center()?.let { center -> - viewModel.startNavigation(center) + viewModel.startNavigation(center, feature.properties.name) } } } 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..d1cc3e3c4 --- /dev/null +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoCarAppService.kt @@ -0,0 +1,33 @@ +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 { + 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..93f207696 --- /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.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.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() } + ) + + private var uiState: NavigationUiState? by mutableStateOf(null) + + private val surfaceAreaTracker = SurfaceAreaTracker() + + init { + // Attach pan gesture and screen area tracking to map view. + surfaceGestureCallback = surfaceAreaTracker + + 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 stableArea = surfaceAreaTracker.stableArea.value + val normalPaddingState = rememberUpdatedState(surfaceStableFractionalCameraPadding(stableArea)) + val trackingPaddingState = rememberUpdatedState(surfaceStableFractionalCameraPadding(stableArea, 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..649038875 --- /dev/null +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationView.kt @@ -0,0 +1,72 @@ +package com.stadiamaps.ferrostar.auto + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.ui.maplibre.car.app.runtime.screenSurfaceState +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, +) { + // Note: you can also request location permission when launching your car app. + // E.g. https://developer.android.com/training/cars/apps/library/request-permissions + + // screenSurfaceState is regular @Composable — observe area state outside the map context + // so we can pass compositeArea as the stableArea overlay inset. + val surfaceArea by surfaceAreaTracker + ?.let { screenSurfaceState(it) } + ?: remember { mutableStateOf(null) } + + CarAppNavigationView( + modifier = Modifier.fillMaxSize(), + styleUrl = AppModule.mapStyleUrl, + camera = camera, + viewModel = viewModel, + config = VisualNavigationViewConfig.Default() + .withSpeedLimitStyle(SignageStyle.MUTCD), + stableArea = surfaceArea?.compositeArea + ) { uiState -> + // Wire up map gestures (scroll/fling/scale) inside the @MapLibreComposable context. + surfaceAreaTracker?.also { it.rememberSurfaceArea() } + + // 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/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 8d6b288cc..9c21b5f19 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -62,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 From 7cdf3f4386012a723c7f935ca582092f45f9dc1e Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sun, 15 Mar 2026 11:41:50 -0700 Subject: [PATCH 09/15] feat: initialize android auto/car app --- Package.resolved | 13 +- android/car-app/build.gradle | 25 +- .../app/navigation/NavigationManagerBridge.kt | 113 ++--- .../TurnByTurnNotificationManager.kt | 33 +- .../car-app/src/main/res/values/strings.xml | 3 +- .../ferrostar/car/app/LaneBuilderTest.kt | 60 +++ .../ferrostar/car/app/ManeuverBuilderTest.kt | 385 ++++++++++++++++++ .../ferrostar/core/NavigationViewModel.kt | 4 +- .../core/extensions/TripStateExtensions.kt | 14 + .../ferrostar/DemoNavigationViewModel.kt | 1 + .../ferrostar/auto/DemoNavigationScreen.kt | 5 +- .../ferrostar/auto/DemoNavigationView.kt | 18 +- .../maplibre/car/app/CarAppNavigationView.kt | 22 +- android/ui-shared/build.gradle | 4 +- guide/src/android-auto-car-app.md | 83 ++++ 15 files changed, 687 insertions(+), 96 deletions(-) create mode 100644 android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/LaneBuilderTest.kt create mode 100644 android/car-app/src/test/java/com/stadiamaps/ferrostar/car/app/ManeuverBuilderTest.kt create mode 100644 guide/src/android-auto-car-app.md 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/build.gradle b/android/car-app/build.gradle index f60234499..1117a41d1 100644 --- a/android/car-app/build.gradle +++ b/android/car-app/build.gradle @@ -1,5 +1,9 @@ +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary + plugins { - alias(libs.plugins.androidLibrary) + alias libs.plugins.androidLibrary + alias libs.plugins.ktfmt + alias libs.plugins.mavenPublish } android { @@ -43,4 +47,23 @@ dependencies { 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/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 index a49ce33e4..6332b9fe2 100644 --- 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 @@ -5,12 +5,14 @@ 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.NavigationUiState 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 @@ -34,6 +36,9 @@ import uniffi.ferrostar.DrivingSide * @param backupDrivingSide Driving side for maneuver mapping. Defaults to RIGHT. * @param onStopNavigation Called when the head unit requests navigation stop. * @param onAutoDriveEnabled Called when auto-drive simulation is requested (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, @@ -42,10 +47,12 @@ class NavigationManagerBridge( private val viewModel: NavigationViewModel, private val backupDrivingSide: DrivingSide = DrivingSide.RIGHT, private val onStopNavigation: () -> Unit, - private val onAutoDriveEnabled: (() -> Unit)? = null + private val onAutoDriveEnabled: (() -> Unit)? = null, + private val isCarForeground: () -> Boolean = { false } ) { - private var observationJob: Job? = null + private var tripJob: Job? = null + private var notificationJob: Job? = null private var wasNavigating = false private var destination: Destination? = null @@ -76,9 +83,58 @@ class NavigationManagerBridge( } }) - observationJob = + // Trip lifecycle and updateTrip on every state change. + tripJob = viewModel.navigationUiState - .onEach { state -> onNavigationStateUpdate(state) } + .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 { 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) } @@ -88,8 +144,10 @@ class NavigationManagerBridge( * Call this when the navigation session ends or the Car App Session is destroyed. */ fun stop() { - observationJob?.cancel() - observationJob = null + tripJob?.cancel() + tripJob = null + notificationJob?.cancel() + notificationJob = null notificationManager?.clear() @@ -101,47 +159,6 @@ class NavigationManagerBridge( navigationManager.clearNavigationManagerCallback() } - private fun onNavigationStateUpdate(uiState: NavigationUiState) { - val isNavigating = uiState.isNavigating() - - if (isNavigating && !wasNavigating) { - navigationManager.navigationStarted() - wasNavigating = true - } - - if (isNavigating) { - updateTrip(uiState) - } - - if (!isNavigating && wasNavigating) { - notificationManager?.clear() - navigationManager.navigationEnded() - wasNavigating = false - } - } - - private fun updateTrip(uiState: NavigationUiState) { - uiState.tripState?.let { - val trip = FerrostarTrip.Builder(context) - .setTripState(it) - .setBackupDrivingSide(backupDrivingSide) - .apply { - destination?.let { dest -> setDestination(dest) } - } - .build() - - try { - navigationManager.updateTrip(trip) - } catch (e: Exception) { - Log.w(TAG, "Failed to update trip", e) - } - } - - uiState.visualInstruction?.let { - notificationManager?.update(it) - } - } - 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 index eb46c7e23..52bb068e0 100644 --- 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 @@ -3,12 +3,14 @@ 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 uniffi.ferrostar.VisualInstruction +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. @@ -20,37 +22,50 @@ import uniffi.ferrostar.VisualInstruction * @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 + @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 = "Turn-by-turn navigation directions" + 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: VisualInstruction) { + fun update(instruction: SpokenInstruction) { val notification = NotificationCompat.Builder(context, channelId) .setSmallIcon(smallIconRes) - .setContentTitle(instruction.primaryContent.text) - .setContentText(instruction.secondaryContent?.text) + .setContentTitle(instruction.text) .setOngoing(true) - .setOnlyAlertOnce(true) .setCategory(NotificationCompat.CATEGORY_NAVIGATION) .extend( - CarAppExtender.Builder().setImportance(NotificationManager.IMPORTANCE_HIGH).build()) + CarAppExtender.Builder() + .setContentTitle(instruction.text) + .apply { + contentIntent?.let { + setContentIntent(it) + } + } + .setImportance(NotificationManager.IMPORTANCE_HIGH) + .build() + ) .build() try { diff --git a/android/car-app/src/main/res/values/strings.xml b/android/car-app/src/main/res/values/strings.xml index d56c2382b..9ccfcc2b5 100644 --- a/android/car-app/src/main/res/values/strings.xml +++ b/android/car-app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Stop - \ No newline at end of file + Turn-by-turn navigation directions + 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/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt index db4f5e505..9da5a8415 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 @@ -1,6 +1,5 @@ package com.stadiamaps.ferrostar.core -import android.location.Location import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -13,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 @@ -85,7 +85,7 @@ data class NavigationUiState( 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(), 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 eda31935b..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. * 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 9aff2d775..ce2b629d4 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 @@ -127,6 +127,7 @@ class DemoNavigationViewModel( 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 = 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 index 93f207696..9425b586b 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -58,6 +59,7 @@ class DemoNavigationScreen( private val notificationManager = TurnByTurnNotificationManager(context = carContext, smallIconRes = R.drawable.ic_navigation) + private val navigationManagerBridge = NavigationManagerBridge( navigationManager = carContext.getCarService(NavigationManager::class.java), @@ -65,7 +67,8 @@ class DemoNavigationScreen( context = carContext, notificationManager = notificationManager, onStopNavigation = { viewModel.stopNavigation() }, - onAutoDriveEnabled = { viewModel.enableAutoDriveSimulation() } + onAutoDriveEnabled = { viewModel.enableAutoDriveSimulation() }, + isCarForeground = { lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) } ) private var uiState: NavigationUiState? by mutableStateOf(null) 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 index 649038875..eb84a0789 100644 --- 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 @@ -3,9 +3,6 @@ package com.stadiamaps.ferrostar.auto import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.maplibre.compose.camera.MapViewCamera import com.maplibre.compose.symbols.Circle @@ -13,7 +10,6 @@ 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.ui.maplibre.car.app.runtime.screenSurfaceState import com.stadiamaps.ferrostar.composeui.config.VisualNavigationViewConfig import com.stadiamaps.ferrostar.composeui.config.withSpeedLimitStyle import com.stadiamaps.ferrostar.composeui.views.components.speedlimit.SignageStyle @@ -26,15 +22,6 @@ fun DemoNavigationView( camera: MutableState, surfaceAreaTracker: SurfaceAreaTracker? = null, ) { - // Note: you can also request location permission when launching your car app. - // E.g. https://developer.android.com/training/cars/apps/library/request-permissions - - // screenSurfaceState is regular @Composable — observe area state outside the map context - // so we can pass compositeArea as the stableArea overlay inset. - val surfaceArea by surfaceAreaTracker - ?.let { screenSurfaceState(it) } - ?: remember { mutableStateOf(null) } - CarAppNavigationView( modifier = Modifier.fillMaxSize(), styleUrl = AppModule.mapStyleUrl, @@ -42,11 +29,8 @@ fun DemoNavigationView( viewModel = viewModel, config = VisualNavigationViewConfig.Default() .withSpeedLimitStyle(SignageStyle.MUTCD), - stableArea = surfaceArea?.compositeArea + surfaceAreaTracker = surfaceAreaTracker, ) { uiState -> - // Wire up map gestures (scroll/fling/scale) inside the @MapLibreComposable context. - surfaceAreaTracker?.also { it.rememberSurfaceArea() } - // 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/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 index 75a3f9d0a..5822a3e55 100644 --- 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 @@ -1,6 +1,5 @@ package com.stadiamaps.ferrostar.ui.maplibre.car.app -import android.graphics.Rect import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -20,6 +19,8 @@ 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 @@ -48,7 +49,7 @@ fun CarAppNavigationView( LocationRequestProperties.NavigationDefault(), config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), routeOverlayBuilder: RouteOverlayBuilder = RouteOverlayBuilder.Default(), - stableArea: Rect? = null, + surfaceAreaTracker: SurfaceAreaTracker? = null, mapContent: @Composable @MapLibreComposable() ((NavigationUiState) -> Unit)? = null, ) { val uiState by viewModel.navigationUiState.collectAsState() @@ -60,7 +61,20 @@ fun CarAppNavigationView( logo = LogoSettings(enabled = false))) } - val gridPadding = surfaceStableFractionalPadding(stableArea) + 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?.rememberSurfaceArea() + mapContent?.invoke(uiState) + } + } else null Box(modifier) { NavigationMapView( @@ -73,7 +87,7 @@ fun CarAppNavigationView( onMapReadyCallback = { // No definition }, - content = mapContent + content = wrappedContent ) Box( diff --git a/android/ui-shared/build.gradle b/android/ui-shared/build.gradle index c594790e6..ee4125aae 100644 --- a/android/ui-shared/build.gradle +++ b/android/ui-shared/build.gradle @@ -63,8 +63,8 @@ mavenPublishing { apply from: "${rootProject.projectDir}/common-pom.gradle" pom { - name = "Ferrostar Formatters" - description = "Distance, duration and arrival formatters for Ferrostar" + name = "Ferrostar Shared UI" + description = "Includes shared basics like the maneuver direction icons." commonPomConfig(it, true) } } \ No newline at end of file diff --git a/guide/src/android-auto-car-app.md b/guide/src/android-auto-car-app.md new file mode 100644 index 000000000..23c62fa39 --- /dev/null +++ b/guide/src/android-auto-car-app.md @@ -0,0 +1,83 @@ +# Implementing Android Auto (Car App) + +Ferrostar provides tooling to construct an Android Auto navigation app. The Demo App's +auto directory is a good place to review this. + +## Basic Setup + + +### Android Manifest & XML + +1. Add the navigation service to your apps 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). You can configure this based on your +specific needs. E.g. based on the features you use elsewhere in your car app +implementation. +3. Add the automotive app descriptor [`automotive_app_desc.xml`](android/demo-app/src/main/res/xml/automotive_app_desc.xml). Link this in your manifest [`AndroidManifest.xml#L32`](android/demo-app/src/main/AndroidManifest.xml#L32) + +### Car App Service + + + +### Car App Screen + + + +## 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. + +The Ferrostar `car.app` module provides the [`NavigationTemplateBuilder`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/NavigationTemplateBuilder.kt) +which translates ferrostar's active navigation state into a navigation template for +Android Auto. + +### 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. + +The [CarAppNavigationView](android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt) implements this. There are +multiple runtime tools that ensure the current road name and speed limit are displayed +within the safe area of the screen. + +### 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). + +Ferrostar includes [`TurnByTurnNotificationManager`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/TurnByTurnNotificationManager.kt) +for this purpose. + +### 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). + +TODO: Implement. + +### 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). + +TODO: Evaluate. + +### 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). + +### 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). + +App must simulate navigation when auto drive is called. + +```sh +adb shell dumpsys activity service com.stadiamaps.ferrostar.auto.DemoCarAppService AUTO_DRIVE +``` From 3b2b2ce98575eca1cbc8e1b87df570086c323a13 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sun, 15 Mar 2026 13:22:28 -0700 Subject: [PATCH 10/15] feat: default android auto implemtnation --- .../app/navigation/NavigationManagerBridge.kt | 12 +------ .../car/app/template/models/TripBuilder.kt | 7 ++-- .../ferrostar/core/NavigationViewModel.kt | 35 +++++++++++++++++-- .../core/mock/MockNavigationState.kt | 2 ++ .../ferrostar/DemoNavigationScene.kt | 20 ++--------- .../ferrostar/DemoNavigationViewModel.kt | 17 +-------- .../com/stadiamaps/ferrostar/MainActivity.kt | 2 +- .../ferrostar/auto/DemoNavigationScreen.kt | 13 +++---- .../maplibre/car/app/CarAppNavigationView.kt | 2 +- .../maplibre/car/app/runtime/ScreenState.kt | 30 +++++++++++++--- 10 files changed, 76 insertions(+), 64 deletions(-) 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 index 6332b9fe2..cf589cb9a 100644 --- 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 @@ -54,16 +54,6 @@ class NavigationManagerBridge( private var tripJob: Job? = null private var notificationJob: Job? = null private var wasNavigating = false - private var destination: Destination? = null - - /** - * Sets the destination shown on the head unit during navigation. - * - * May be called at any time. Takes effect on the next [NavigationManager.updateTrip] call. - */ - fun setDestination(destination: Destination?) { - this.destination = destination - } /** * Starts observing the view model's navigation state and driving the [NavigationManager]. @@ -100,7 +90,7 @@ class NavigationManagerBridge( FerrostarTrip.Builder(context) .setTripState(it) .setBackupDrivingSide(backupDrivingSide) - .apply { destination?.let { dest -> setDestination(dest) } } + .apply { state.destination?.let { dest -> setDestination(dest) } } .build() try { navigationManager.updateTrip(trip) 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 index 20cc422fc..5123edde9 100644 --- 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 @@ -22,8 +22,11 @@ class FerrostarTrip { return this } - fun setDestination(destination: Destination): Builder { - this.destination = destination + fun setDestination(destination: String): Builder { + this.destination = Destination.Builder() + .setName(destination) + .build() + return this } 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 9da5a8415..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 @@ -28,8 +28,6 @@ import uniffi.ferrostar.TripProgress import uniffi.ferrostar.TripState import uniffi.ferrostar.UserLocation import uniffi.ferrostar.VisualInstruction -import uniffi.ferrostar.Waypoint -import uniffi.ferrostar.WaypointKind data class NavigationUiState( /** The user's location as reported by the location provider. */ @@ -41,6 +39,8 @@ 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. */ @@ -73,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?, @@ -82,6 +101,7 @@ 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(), @@ -103,6 +123,8 @@ data class NavigationUiState( interface NavigationViewModel { val navigationUiState: StateFlow + fun setDestination(destination: String?) + fun toggleMute() fun stopNavigation() @@ -121,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) @@ -135,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(), @@ -144,6 +171,10 @@ open class DefaultNavigationViewModel( ferrostarCore.spokenInstructionObserver?.isMuted, null)) + override fun setDestination(destination: String?) { + this.destination.value = destination + } + override fun stopNavigation() { ferrostarCore.stopNavigation() } 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 b819e1070..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,6 +91,8 @@ fun NavigationUiState.Companion.pedestrianExample(): NavigationUiState = class MockNavigationViewModel(override val navigationUiState: StateFlow) : ViewModel(), NavigationViewModel { + override fun setDestination(destination: String?) {} + override fun toggleMute() {} override fun stopNavigation() {} 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 c5765928e..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,26 +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.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Button -import androidx.compose.material3.Text 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 @@ -35,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. @@ -59,16 +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 isSimulating by viewModel.simulated.collectAsState() - val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> when { permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) -> { - // viewModel.setLocationPermissions(it) + viewModel.setLocationPermissions(true) } permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> { // TODO: Probably alert the user that this is unusable for navigation @@ -80,8 +65,7 @@ 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.setLocationPermissions(true) 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 ce2b629d4..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 @@ -66,22 +66,7 @@ class DemoNavigationViewModel( .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), - initialValue = - NavigationUiState( - null, - null, - null, - null, - null, - null, - null, - false, - null, - null, - null, - null, - null, - null)) + initialValue = NavigationUiState.empty()) init { viewModelScope.launch { 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/auto/DemoNavigationScreen.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/auto/DemoNavigationScreen.kt index 9425b586b..2d2f2295e 100644 --- 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 @@ -20,6 +20,7 @@ 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 @@ -59,7 +60,6 @@ class DemoNavigationScreen( private val notificationManager = TurnByTurnNotificationManager(context = carContext, smallIconRes = R.drawable.ic_navigation) - private val navigationManagerBridge = NavigationManagerBridge( navigationManager = carContext.getCarService(NavigationManager::class.java), @@ -73,12 +73,9 @@ class DemoNavigationScreen( private var uiState: NavigationUiState? by mutableStateOf(null) - private val surfaceAreaTracker = SurfaceAreaTracker() + private val surfaceAreaTracker = SurfaceAreaTracker { surfaceGestureCallback = it } init { - // Attach pan gesture and screen area tracking to map view. - surfaceGestureCallback = surfaceAreaTracker - navigationManagerBridge.start(scope) observeJob = @@ -98,9 +95,9 @@ class DemoNavigationScreen( @Composable override fun content() { - val stableArea = surfaceAreaTracker.stableArea.value - val normalPaddingState = rememberUpdatedState(surfaceStableFractionalCameraPadding(stableArea)) - val trackingPaddingState = rememberUpdatedState(surfaceStableFractionalCameraPadding(stableArea, top = 0.5f)) + 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 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 index 5822a3e55..9c31300ef 100644 --- 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 @@ -71,7 +71,7 @@ fun CarAppNavigationView( val wrappedContent: (@Composable @MapLibreComposable (NavigationUiState) -> Unit)? = if (surfaceAreaTracker != null || mapContent != null) { { uiState -> - surfaceAreaTracker?.rememberSurfaceArea() + surfaceAreaTracker?.rememberGestureDelegate() mapContent?.invoke(uiState) } } else null 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 index 86bb834fe..eb1fa5690 100644 --- 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 @@ -16,17 +16,26 @@ import com.maplibre.compose.surface.rememberMapSurfaceGestureCallback * forwarding gesture events (scroll, fling, scale) to a map gesture delegate. * * Usage: - * 1. Create an instance and assign it to [ComposableScreen.surfaceGestureCallback]. - * 2. Inside a [MapLibreComposable] context, call [rememberSurfaceArea] to wire up map gestures - * and observe safe areas in a single call. + * 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 : SurfaceGestureCallback { +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 } @@ -47,6 +56,17 @@ class SurfaceAreaTracker : SurfaceGestureCallback { 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. @@ -54,7 +74,7 @@ class SurfaceAreaTracker : SurfaceGestureCallback { @Composable @MapLibreComposable fun rememberSurfaceArea(): State { - rememberMapSurfaceGestureCallback { delegate = it } + rememberGestureDelegate() return screenSurfaceState(stableArea, visibleArea) } } From 22d8e2835613a7c9a52579d563d585362fc12cd8 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sun, 15 Mar 2026 13:25:14 -0700 Subject: [PATCH 11/15] feat: default android auto implemtnation --- .../ferrostar/auto/DemoCarAppService.kt | 4 + guide/src/SUMMARY.md | 1 + guide/src/android-auto-car-app.md | 215 +++++++++++++++--- 3 files changed, 189 insertions(+), 31 deletions(-) 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 index d1cc3e3c4..5ac445519 100644 --- 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 @@ -16,6 +16,10 @@ class DemoCarAppService : CarAppService() { 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 } 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 index 23c62fa39..f4c90cba6 100644 --- a/guide/src/android-auto-car-app.md +++ b/guide/src/android-auto-car-app.md @@ -1,82 +1,235 @@ # Implementing Android Auto (Car App) Ferrostar provides tooling to construct an Android Auto navigation app. The Demo App's -auto directory is a good place to review this. +`auto` directory is a good reference implementation. ## Basic Setup - ### Android Manifest & XML -1. Add the navigation service to your apps 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). You can configure this based on your -specific needs. E.g. based on the features you use elsewhere in your car app -implementation. -3. Add the automotive app descriptor [`automotive_app_desc.xml`](android/demo-app/src/main/res/xml/automotive_app_desc.xml). Link this in your manifest [`AndroidManifest.xml#L32`](android/demo-app/src/main/AndroidManifest.xml#L32) +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). +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 +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. -The Ferrostar `car.app` module provides the [`NavigationTemplateBuilder`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/template/NavigationTemplateBuilder.kt) -which translates ferrostar's active navigation state into a navigation template for -Android Auto. +[`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. +> 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. -The [CarAppNavigationView](android/ui-maplibre-car-app/src/main/java/com/stadiamaps/ferrostar/ui/maplibre/car/app/CarAppNavigationView.kt) implements this. There are -multiple runtime tools that ensure the current road name and speed limit are displayed -within the safe area of the screen. +[`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). +> 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). -Ferrostar includes [`TurnByTurnNotificationManager`](android/car-app/src/main/java/com/stadiamaps/ferrostar/car/app/navigation/TurnByTurnNotificationManager.kt) -for this purpose. +[`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). +> 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). -TODO: Implement. +[`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). +> 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). -TODO: Evaluate. +[`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: -### NF-6 - App Must handle Navigation Intents +```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 handle navigation requests from other apps. For more information, see [Support navigation intents](https://developer.android.com/training/cars/apps/navigation#support-navigation-intents). +> 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). -### NF-7 - Test drive mode +Pass an `onAutoDriveEnabled` callback to `NavigationManagerBridge`. It is invoked when +the Car App host requests simulation (e.g. during review): -> 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). +```kotlin +NavigationManagerBridge( + ... + onAutoDriveEnabled = { viewModel.enableAutoDriveSimulation() }, +) +``` -App must simulate navigation when auto drive is called. +You can also trigger auto-drive manually via adb for testing: ```sh adb shell dumpsys activity service com.stadiamaps.ferrostar.auto.DemoCarAppService AUTO_DRIVE From a326973b5938446449ea927ef5772e2d8119b007 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Sun, 15 Mar 2026 13:57:03 -0700 Subject: [PATCH 12/15] feat: default android auto implemtnation --- android/car-app/build.gradle | 4 +++ .../app/template/NavigationTemplateBuilder.kt | 6 ++--- .../app/template/models/RoutingInfoBuilder.kt | 26 ++++++++----------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/android/car-app/build.gradle b/android/car-app/build.gradle index 1117a41d1..5ba1f83da 100644 --- a/android/car-app/build.gradle +++ b/android/car-app/build.gradle @@ -26,12 +26,16 @@ android { } } compileOptions { + coreLibraryDesugaringEnabled = true sourceCompatibility JavaVersion.VERSION_11 targetCompatibility JavaVersion.VERSION_11 } } 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 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 index 9d19fa844..bd7da4f90 100644 --- 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 @@ -80,10 +80,11 @@ class NavigationTemplateBuilder( .setMapActionStrip(buildMapActionStrip()) .apply { tripState?.let { state -> - val info = FerrostarRoutingInfo.Builder(carContext) + val routingInfo = FerrostarRoutingInfo.Builder(carContext) .setTripState(state) .build() - setNavigationInfo(info) + + routingInfo?.let { setNavigationInfo(it) } state.progress()?.let { setDestinationTravelEstimate(it.toCarTravelEstimate()) @@ -145,4 +146,3 @@ class NavigationTemplateBuilder( } .build() } - 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 index 3f7c76b28..9b92d6bbb 100644 --- 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 @@ -24,23 +24,19 @@ class FerrostarRoutingInfo { return this } - fun build(): RoutingInfo { - val instruction = tripState?.visualInstruction() - val progress = tripState?.progress() - val currentStep = tripState?.currentStep() + fun build(): RoutingInfo? { + val instruction = tripState?.visualInstruction() ?: return null + val progress = tripState?.progress() ?: return null + val currentStep = tripState?.currentStep() ?: return null - return RoutingInfo.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 distance = progress.toCarDistanceToNextManeuver() + val drivingSide = currentStep.drivingSide ?: backupDrivingSide + val roundaboutExitNumber = currentStep.roundaboutExitNumber?.toInt() - setCurrentStep(step, distance) - } - } + return RoutingInfo.Builder() + .setCurrentStep( + instruction.toCarStep(context, drivingSide, roundaboutExitNumber), + progress.toCarDistanceToNextManeuver() + ) .build() } } From 6dc0563b2d6c4c052f66e6318837b05a9d6543b6 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Mon, 16 Mar 2026 17:14:43 -0700 Subject: [PATCH 13/15] feat: simplified location providers --- .../core/SimulatedLocationProviderTest.kt | 112 ------------------ .../ferrostar/DemoNavigationScene.kt | 6 +- 2 files changed, 1 insertion(+), 117 deletions(-) delete mode 100644 android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt diff --git a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt deleted file mode 100644 index ea9155162..000000000 --- a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/SimulatedLocationProviderTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.stadiamaps.ferrostar.core - -import app.cash.turbine.test -import com.stadiamaps.ferrostar.core.location.SimulatedLocationProvider -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Test -import uniffi.ferrostar.RouteAdapter -import uniffi.ferrostar.WellKnownRouteProvider - -private const val valhallaEndpointUrl = "https://api.stadiamaps.com/navigate/v1" - -class SimulatedLocationProviderTest { - private fun parseRoute() = - RouteAdapter.fromWellKnownRouteProvider( - WellKnownRouteProvider.Valhalla(valhallaEndpointUrl, "auto")) - .parseResponse(simpleRoute.trimIndent().toByteArray()) - .first() - - @Test - fun locationUpdatesEmitsAfterSetRoute() = runTest { - val provider = SimulatedLocationProvider(scope = backgroundScope, warpFactor = 8u) - val route = parseRoute() - - provider.locationUpdates().test { - provider.setRoute(route) - - val first = awaitItem() - assertNotNull(first) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun lastLocationTracksRecentlyEmittedLocation() = runTest { - val provider = SimulatedLocationProvider(scope = backgroundScope, warpFactor = 8u) - val route = parseRoute() - - provider.locationUpdates().test { - provider.setRoute(route) - - awaitItem() // first - val second = awaitItem() - - assertEquals(second, provider.lastLocation()) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun setRouteRestartsSimulationFromBeginning() = runTest { - val provider = SimulatedLocationProvider(scope = backgroundScope, warpFactor = 8u) - val route = parseRoute() - val startCoord = route.geometry.first() - - provider.locationUpdates().test { - provider.setRoute(route) - - // Advance a few steps into the simulation - repeat(5) { awaitItem() } - - // Reset with the same route — simulation should restart from the beginning - provider.setRoute(route) - val restarted = awaitItem() - - assertEquals(startCoord.lat, restarted.latitude, 0.001) - assertEquals(startCoord.lng, restarted.longitude, 0.001) - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun multipleCollectorsReceiveSameLocationsViaSharedReplay() = runTest { - val provider = SimulatedLocationProvider(scope = backgroundScope, warpFactor = 8u) - val route = parseRoute() - - val collector1 = mutableListOf() - val collector2 = mutableListOf() - - provider.setRoute(route) - - // Collect 3 items on each subscriber concurrently - val job1 = launch { provider.locationUpdates().take(3).collect { collector1.add(it) } } - val job2 = launch { provider.locationUpdates().take(3).collect { collector2.add(it) } } - - job1.join() - job2.join() - - assertEquals(3, collector1.size) - assertEquals(3, collector2.size) - - // Both collectors should have started from the same replayed position - assertEquals(collector1.first().latitude, collector2.first().latitude, 0.0001) - assertEquals(collector1.first().longitude, collector2.first().longitude, 0.0001) - } - - @Test - fun locationUpdatesEmitsNothingBeforeSetRoute() = runTest { - val provider = SimulatedLocationProvider(scope = backgroundScope) - assertNull(provider.lastLocation()) - - provider.locationUpdates().test { - // No route set — should not emit anything - expectNoEvents() - cancel() - } - } -} 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 c5765928e..1a75a3995 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 @@ -59,16 +59,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 isSimulating by viewModel.simulated.collectAsState() - val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> when { permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) -> { - // viewModel.setLocationPermissions(it) + viewModel.setLocationPermissions(true) } permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> { // TODO: Probably alert the user that this is unusable for navigation From f2f3cd9cdfdb7c61533c82d10a22d577f56da5d6 Mon Sep 17 00:00:00 2001 From: Ian Wagner Date: Tue, 17 Mar 2026 10:40:37 +0900 Subject: [PATCH 14/15] Remove spurious test options Co-authored-by: Ian Wagner --- android/core/build.gradle | 1 - .../main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt | 1 - android/google-play-services/build.gradle | 3 --- 3 files changed, 5 deletions(-) diff --git a/android/core/build.gradle b/android/core/build.gradle index 73f81d1bd..08218524e 100644 --- a/android/core/build.gradle +++ b/android/core/build.gradle @@ -31,7 +31,6 @@ android { } testOptions { targetSdk 36 - unitTests.returnDefaultValues = true } lint { targetSdk 36 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 1a75a3995..ab0c4bbc7 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 @@ -42,7 +42,6 @@ fun DemoNavigationScene( 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. diff --git a/android/google-play-services/build.gradle b/android/google-play-services/build.gradle index e74d9fe49..fedf06654 100644 --- a/android/google-play-services/build.gradle +++ b/android/google-play-services/build.gradle @@ -22,9 +22,6 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } - testOptions { - unitTests.returnDefaultValues = true - } } dependencies { From 9dc6b7828ffe1bc8174c0629363fc1e4f7ad1fa8 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Mon, 16 Mar 2026 20:47:40 -0700 Subject: [PATCH 15/15] correcting mocks --- android/core/build.gradle | 3 --- .../core/location/AndroidLocationProviderTest.kt | 9 +++++++++ .../core/location/SimulatedLocationProviderTest.kt | 7 ++----- android/demo-app/build.gradle | 2 +- .../googleplayservices/FusedLocationProviderTest.kt | 7 +++++++ 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/android/core/build.gradle b/android/core/build.gradle index 08218524e..3c3f487ed 100644 --- a/android/core/build.gradle +++ b/android/core/build.gradle @@ -32,9 +32,6 @@ android { testOptions { targetSdk 36 } - lint { - targetSdk 36 - } } dependencies { 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 index fed579e4f..61234b70c 100644 --- 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 @@ -4,11 +4,14 @@ 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 @@ -26,6 +29,12 @@ class AndroidLocationProviderTest { @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 } 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 index a57c4cb83..445cb2903 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -15,11 +16,7 @@ class SimulatedLocationProviderTest { @Test fun `lastLocation returns initialLocation before any route is set`() = runTest { - val location = - Location("test").apply { - latitude = 60.534716 - longitude = -149.543469 - } + 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..559abcb4b 100644 --- a/android/demo-app/build.gradle +++ b/android/demo-app/build.gradle @@ -64,9 +64,9 @@ dependencies { implementation libs.androidx.compose.material3 implementation project(':core') + implementation project(':google-play-services') implementation project(':ui-compose') implementation project(':ui-maplibre') - implementation project(':google-play-services') implementation libs.maplibre.compose 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 index 9a38403d3..b79c59073 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -41,6 +42,12 @@ class FusedLocationProviderTest { 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