diff --git a/android/core/build.gradle b/android/core/build.gradle index 5d771e7c3..3c3f487ed 100644 --- a/android/core/build.gradle +++ b/android/core/build.gradle @@ -32,12 +32,6 @@ android { testOptions { targetSdk 36 } - lint { - targetSdk 36 - } - testOptions { - targetSdk 36 - } } dependencies { @@ -69,6 +63,7 @@ dependencies { // ValhallaCoreTest.kt androidTestImplementation libs.okhttp.mock androidTestImplementation libs.kotlinx.coroutines.test + androidTestImplementation libs.turbine androidTestImplementation libs.androidx.test.junit androidTestImplementation libs.androidx.test.espresso } diff --git a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/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/ValhallaCoreTest.kt b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ValhallaCoreTest.kt index ed298afa2..e6600ed0d 100644 --- a/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ValhallaCoreTest.kt +++ b/android/core/src/androidTest/java/com/stadiamaps/ferrostar/core/ValhallaCoreTest.kt @@ -9,6 +9,7 @@ package com.stadiamaps.ferrostar.core import com.stadiamaps.ferrostar.core.http.OkHttpClientProvider.Companion.toOkHttpClientProvider +import com.stadiamaps.ferrostar.core.location.SimulatedLocationProvider import java.time.Instant import kotlinx.coroutines.test.TestResult import kotlinx.coroutines.test.runTest diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt index ce7f10acd..a00450943 100644 --- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/FerrostarCore.kt @@ -2,20 +2,22 @@ package com.stadiamaps.ferrostar.core import androidx.annotation.VisibleForTesting import com.stadiamaps.ferrostar.core.http.HttpClientProvider +import com.stadiamaps.ferrostar.core.location.NavigationLocationProviding +import com.stadiamaps.ferrostar.core.location.toAndroidLocation +import com.stadiamaps.ferrostar.core.location.toUserLocation import com.stadiamaps.ferrostar.core.service.ForegroundServiceManager import java.time.Instant -import java.util.concurrent.Executors import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import uniffi.ferrostar.GeographicCoordinate -import uniffi.ferrostar.Heading import uniffi.ferrostar.NavState import uniffi.ferrostar.NavigationControllerConfig import uniffi.ferrostar.NavigationSession @@ -62,12 +64,12 @@ fun NavigationState.isNavigating(): Boolean = class FerrostarCore( val routeProvider: RouteProvider, val httpClient: HttpClientProvider, - val locationProvider: LocationProvider, + val locationProvider: NavigationLocationProviding, val foregroundServiceManager: ForegroundServiceManager? = null, navigationControllerConfig: NavigationControllerConfig, val sessionBuilder: FerrostarSessionBuilder = FerrostarSessionBuilder(navigationControllerConfig), -) : LocationUpdateListener { +) { companion object { private const val TAG = "FerrostarCore" } @@ -128,8 +130,8 @@ class FerrostarCore( var isCalculatingNewRoute: Boolean = false private set - private val _executor = Executors.newSingleThreadScheduledExecutor() private val _scope = CoroutineScope(Dispatchers.IO) + private var _locationJob: Job? = null private var _navigationSession: NavigationSession? = null private val _navState: MutableStateFlow = MutableStateFlow(null) @@ -155,7 +157,7 @@ class FerrostarCore( constructor( wellKnownRouteProvider: WellKnownRouteProvider, httpClient: HttpClientProvider, - locationProvider: LocationProvider, + locationProvider: NavigationLocationProviding, navigationControllerConfig: NavigationControllerConfig, foregroundServiceManager: ForegroundServiceManager? = null, ) : this( @@ -168,7 +170,7 @@ class FerrostarCore( constructor( routeAdapter: RouteAdapter, httpClient: HttpClientProvider, - locationProvider: LocationProvider, + locationProvider: NavigationLocationProviding, navigationControllerConfig: NavigationControllerConfig, foregroundServiceManager: ForegroundServiceManager? = null, ) : this( @@ -181,7 +183,7 @@ class FerrostarCore( constructor( customRouteProvider: CustomRouteProvider, httpClient: HttpClientProvider, - locationProvider: LocationProvider, + locationProvider: NavigationLocationProviding, navigationControllerConfig: NavigationControllerConfig, foregroundServiceManager: ForegroundServiceManager? = null, ) : this( @@ -248,8 +250,7 @@ class FerrostarCore( _navigationSession = navigationSession val startingLocation = - locationProvider.lastLocation - ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null) + _lastLocation ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null) val initialNavState = navigationSession.getInitialState(startingLocation) val newState = NavigationState(tripState = initialNavState.tripState, route.geometry, false) @@ -258,7 +259,9 @@ class FerrostarCore( _navState.value = initialNavState _state.value = newState - locationProvider.addListener(this, _executor) + _locationJob = _scope.launch { + locationProvider.locationUpdates().collect { location -> onLocationUpdated(location.toUserLocation()) } + } } /** @@ -280,8 +283,7 @@ class FerrostarCore( _navigationSession = navigationSession val startingLocation = - locationProvider.lastLocation - ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null) + _lastLocation ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null) val newState = NavigationState(tripState = navState.tripState, route.geometry, false) handleStateUpdate(navState, startingLocation) @@ -289,7 +291,9 @@ class FerrostarCore( _navState.value = navState _state.value = newState - locationProvider.addListener(this, _executor) + _locationJob = _scope.launch { + locationProvider.locationUpdates().collect { location -> onLocationUpdated(location.toUserLocation()) } + } } /** @@ -310,8 +314,7 @@ class FerrostarCore( _navigationSession = navigationSession val startingLocation = - locationProvider.lastLocation - ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null) + _lastLocation ?: UserLocation(route.geometry.first(), 0.0, null, Instant.now(), null) _queuedUtteranceIds.clear() spokenInstructionObserver?.stopAndClearQueue() @@ -342,11 +345,10 @@ class FerrostarCore( } } - fun stopNavigation(stopLocationUpdates: Boolean = true) { + fun stopNavigation() { foregroundServiceManager?.stopService() - if (stopLocationUpdates) { - locationProvider.removeListener(this) - } + _locationJob?.cancel() + _locationJob = null _navigationSession?.destroy() _navigationSession = null _state.value = NavigationState() @@ -435,7 +437,7 @@ class FerrostarCore( } ?: true // Default to true if no prior automatic recalculation } - override fun onLocationUpdated(location: UserLocation) { + private fun onLocationUpdated(location: UserLocation) { _lastLocation = location val session = _navigationSession @@ -453,9 +455,6 @@ class FerrostarCore( } } - override fun onHeadingUpdated(heading: Heading) { - // TODO: Publish new heading to flow - } } /** diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt deleted file mode 100644 index d92bfc1a3..000000000 --- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt +++ /dev/null @@ -1,297 +0,0 @@ -package com.stadiamaps.ferrostar.core - -import android.annotation.SuppressLint -import android.content.Context -import android.location.LocationListener -import android.location.LocationManager -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.os.SystemClock -import java.time.Instant -import java.util.concurrent.Executor -import kotlin.time.DurationUnit -import kotlin.time.toDuration -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import uniffi.ferrostar.CourseOverGround -import uniffi.ferrostar.GeographicCoordinate -import uniffi.ferrostar.Heading -import uniffi.ferrostar.LocationBias -import uniffi.ferrostar.LocationSimulationState -import uniffi.ferrostar.Route -import uniffi.ferrostar.Speed -import uniffi.ferrostar.UserLocation -import uniffi.ferrostar.advanceLocationSimulation -import uniffi.ferrostar.locationSimulationFromRoute - -interface LocationProvider { - val lastLocation: UserLocation? - - val lastHeading: Heading? - - fun addListener(listener: LocationUpdateListener, executor: Executor) - - fun removeListener(listener: LocationUpdateListener) -} - -interface LocationUpdateListener { - fun onLocationUpdated(location: UserLocation) - - fun onHeadingUpdated(heading: Heading) -} - -/** - * A location provider that uses the Android system location services. - * - * NOTE: This does NOT attempt to check permissions. The caller is responsible for ensuring that - * permissions are granted. - */ -class AndroidSystemLocationProvider(context: Context) : LocationProvider { - companion object { - private const val TAG = "AndroidLocationProvider" - } - - override var lastLocation: UserLocation? = null - private set - - override var lastHeading: Heading? = null - private set - - val locationManager: LocationManager = - context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - private val listeners: MutableMap = mutableMapOf() - - /** - * Adds a location update listener. - * - * NOTE: This does NOT attempt to check permissions. The caller is responsible for ensuring that - * permissions are enabled before calling this. - */ - // TODO: This SuppressLint feels wrong; can't we push this "taint" up? - @SuppressLint("MissingPermission") - override fun addListener(listener: LocationUpdateListener, executor: Executor) { - android.util.Log.d(TAG, "Add location listener") - if (listeners.contains(listener)) { - android.util.Log.d(TAG, "Already registered; skipping") - return - } - val androidListener = LocationListener { - val userLocation = it.toUserLocation() - lastLocation = userLocation - listener.onLocationUpdated(userLocation) - } - listeners[listener] = androidListener - - val handler = Handler(Looper.getMainLooper()) - - executor.execute { - handler.post { - val last = locationManager.getLastKnownLocation(getBestProvider()) - last?.let { androidListener.onLocationChanged(last) } - locationManager.requestLocationUpdates(getBestProvider(), 100L, 5.0f, androidListener) - } - } - } - - override fun removeListener(listener: LocationUpdateListener) { - android.util.Log.d(TAG, "Remove location listener") - val androidListener = listeners.remove(listener) - - if (androidListener != null) { - locationManager.removeUpdates(androidListener) - } - } - - private fun getBestProvider(): String { - val providers = locationManager.getProviders(true).toSet() - // Oh, how we love Android... Fused provider is brand new, - // and we can't express this any other way than with duplicate clauses. - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - when { - providers.contains(LocationManager.FUSED_PROVIDER) -> LocationManager.FUSED_PROVIDER - providers.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER - providers.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER - else -> LocationManager.PASSIVE_PROVIDER - } - } else { - when { - providers.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER - providers.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER - else -> LocationManager.PASSIVE_PROVIDER - } - } - } -} - -/** - * Location provider for testing without relying on simulator location spoofing. - * - * This allows for more granular unit tests. - */ -class SimulatedLocationProvider : LocationProvider { - private var simulationState: LocationSimulationState? = null - private val scope = CoroutineScope(Dispatchers.Default) - private var simulationJob: Job? = null - private var listeners: MutableList> = mutableListOf() - - override var lastLocation: UserLocation? = null - set(value) { - field = value - onLocationUpdated() - } - - override var lastHeading: Heading? = null - set(value) { - field = value - onHeadingUpdated() - } - - /** A factor by which simulated route playback speed is multiplied. */ - var warpFactor: UInt = 1u - - override fun addListener(listener: LocationUpdateListener, executor: Executor) { - listeners.add(listener to executor) - - if (simulationJob?.isActive != true) { - simulationJob = scope.launch { startSimulation() } - } - } - - override fun removeListener(listener: LocationUpdateListener) { - listeners.removeIf { it.first == listener } - - if (listeners.isEmpty()) { - simulationJob?.cancel() - } - } - - fun setSimulatedRoute(route: Route, bias: LocationBias = LocationBias.None) { - simulationState = locationSimulationFromRoute(route, resampleDistance = 10.0, bias) - lastLocation = simulationState?.currentLocation - - if (listeners.isNotEmpty() && simulationJob?.isActive != true) { - simulationJob = scope.launch { startSimulation() } - } - } - - private suspend fun startSimulation() { - var pendingCompletion = false - - while (true) { - delay((1.0 / warpFactor.toFloat()).toDuration(DurationUnit.SECONDS)) - val initialState = simulationState ?: return - val updatedState = advanceLocationSimulation(initialState) - - // Stop if the route has been fully simulated (no state change). - if (updatedState == initialState) { - if (pendingCompletion) { - return - } else { - pendingCompletion = true - } - } - - simulationState = updatedState - lastLocation = updatedState.currentLocation - } - } - - private fun onLocationUpdated() { - val location = lastLocation - if (location != null) { - for ((listener, executor) in listeners) { - executor.execute { listener.onLocationUpdated(location) } - } - } - } - - private fun onHeadingUpdated() { - val heading = lastHeading - if (heading != null) { - for ((listener, executor) in listeners) { - executor.execute { listener.onHeadingUpdated(heading) } - } - } - } -} - -fun UserLocation.toAndroidLocation(): android.location.Location { - val location = android.location.Location("FerrostarCore") - - location.latitude = this.coordinates.lat - location.longitude = this.coordinates.lng - location.accuracy = this.horizontalAccuracy.toFloat() - - // NOTE: We have a lot of checks in place which we could remove (+ improve correctness) - // if we supported API 26. - val course = this.courseOverGround - if (course != null) { - location.bearing = course.degrees.toFloat() - - val accuracy = course.accuracy - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && accuracy != null) { - // NOTE: Course accuracy information is not available until API 26 - location.bearingAccuracyDegrees = accuracy.toFloat() - } - } - - location.time = this.timestamp.toEpochMilli() - - // FIXME: This is not entirely correct, but might be an acceptable approximation. - // Feedback welcome as the purpose is not really documented. - location.elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos() - - return location -} - -fun android.location.Location.toUserLocation(): UserLocation { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - UserLocation( - GeographicCoordinate(latitude, longitude), - if (hasAccuracy()) { - accuracy.toDouble() - } else { - Double.MAX_VALUE - }, - if (hasBearing()) { - CourseOverGround( - bearing.toUInt().toUShort(), - if (hasBearingAccuracy()) { - bearingAccuracyDegrees.toUInt().toUShort() - } else { - null - }) - } else { - null - }, - Instant.ofEpochMilli(time), - if (hasSpeed() && hasSpeedAccuracy()) { - Speed(speed.toDouble(), speedAccuracyMetersPerSecond.toDouble()) - } else { - null - }) - } else { - UserLocation( - GeographicCoordinate(latitude, longitude), - if (hasAccuracy()) { - accuracy.toDouble() - } else { - Double.MAX_VALUE - }, - if (hasBearing()) { - CourseOverGround(bearing.toUInt().toUShort(), null) - } else { - null - }, - Instant.ofEpochMilli(time), - if (hasSpeed()) { - Speed(speed.toDouble(), null) - } else { - null - }) - } -} diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/NavigationViewModel.kt index e8b0ef857..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..db59db698 --- /dev/null +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/AndroidLocationProvider.kt @@ -0,0 +1,69 @@ +package com.stadiamaps.ferrostar.core.location + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Build +import android.os.Looper +import android.util.Log +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * A location provider that uses the Android system location services (no Google Play Services + * dependency). + * + * NOTE: This does NOT attempt to check permissions. The caller is responsible for ensuring that + * location permissions are granted before use. + */ +class AndroidLocationProvider(context: Context) : NavigationLocationProviding { + + private val locationManager = + context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + @SuppressLint("MissingPermission") + override suspend fun lastLocation(): Location? = + locationManager.getLastKnownLocation(getBestProvider()) + + @SuppressLint("MissingPermission") + override fun locationUpdates(intervalMillis: Long): Flow = callbackFlow { + val provider = getBestProvider() + val listener = LocationListener { location -> trySend(location) } + + // Emit last known location immediately so the first update isn't delayed by the interval. + locationManager.getLastKnownLocation(provider)?.let { trySend(it) } + + Log.d(TAG, "Requesting location updates from provider: $provider") + locationManager.requestLocationUpdates(provider, intervalMillis, 0f, listener, Looper.getMainLooper()) + + awaitClose { + Log.d(TAG, "Removing location updates from provider: $provider") + locationManager.removeUpdates(listener) + } + } + + private fun getBestProvider(): String { + val providers = locationManager.getProviders(true).toSet() + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + when { + providers.contains(LocationManager.FUSED_PROVIDER) -> LocationManager.FUSED_PROVIDER + providers.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER + providers.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER + else -> LocationManager.PASSIVE_PROVIDER + } + } else { + when { + providers.contains(LocationManager.GPS_PROVIDER) -> LocationManager.GPS_PROVIDER + providers.contains(LocationManager.NETWORK_PROVIDER) -> LocationManager.NETWORK_PROVIDER + else -> LocationManager.PASSIVE_PROVIDER + } + } + } + + companion object { + private const val TAG = "AndroidLocationProvider" + } +} diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProvider.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProvider.kt new file mode 100644 index 000000000..ba9eba703 --- /dev/null +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProvider.kt @@ -0,0 +1,47 @@ +package com.stadiamaps.ferrostar.core.location + +import android.location.Location +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import uniffi.ferrostar.LocationBias +import uniffi.ferrostar.Route + +class NavigationLocationProvider( + private val liveProviding: NavigationLocationProviding, + private val simulatedProvider: SimulatedLocationProvider +): NavigationLocationProviding { + private val _isSimulating = MutableStateFlow(false) + val isSimulating: StateFlow + get() = _isSimulating.asStateFlow() + + fun enableSimulationOn(route: Route, bias: LocationBias = LocationBias.None) { + simulatedProvider.setRoute(route, bias) + _isSimulating.value = true + } + + fun disableSimulation() { + _isSimulating.value = false + } + + override suspend fun lastLocation(): Location? = + if (isSimulating.value) { + simulatedProvider.lastLocation() + } else { + liveProviding.lastLocation() + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun locationUpdates(intervalMillis: Long): Flow = + isSimulating + .flatMapLatest { isSimulating -> + if (isSimulating) { + simulatedProvider.locationUpdates(intervalMillis) + } else { + liveProviding.locationUpdates(intervalMillis) + } + } +} diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProviding.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProviding.kt new file mode 100644 index 000000000..5888223d7 --- /dev/null +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/NavigationLocationProviding.kt @@ -0,0 +1,11 @@ +package com.stadiamaps.ferrostar.core.location + +import android.location.Location +import kotlinx.coroutines.flow.Flow + +interface NavigationLocationProviding { + suspend fun lastLocation(): Location? + + fun locationUpdates(intervalMillis: Long = 1000): Flow +} + diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProvider.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProvider.kt new file mode 100644 index 000000000..cb43af66d --- /dev/null +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProvider.kt @@ -0,0 +1,78 @@ +package com.stadiamaps.ferrostar.core.location + +import android.location.Location +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.shareIn +import uniffi.ferrostar.LocationBias +import uniffi.ferrostar.LocationSimulationState +import uniffi.ferrostar.Route +import uniffi.ferrostar.advanceLocationSimulation +import uniffi.ferrostar.locationSimulationFromRoute + +class SimulatedLocationProvider( + scope: CoroutineScope = CoroutineScope(Dispatchers.Default), + /** A factor by which simulated route playback speed is multiplied. */ + var warpFactor: UInt = 1u, + initialLocation: Location? = null +) : NavigationLocationProviding { + + // Emitting a new value here restarts the simulation from the beginning of the new route. + private val _routeFlow = MutableStateFlow(null) + + // Tracks current position within the active simulation run, seeded with initialLocation + // so lastLocation() returns a sensible value before any route has been simulated. + private var _lastLocation: Location? = initialLocation + + @OptIn(ExperimentalCoroutinesApi::class) + private val sharedUpdates: Flow = + _routeFlow + .flatMapLatest { initialState -> + // Capture into a local val so the non-null smart cast carries into the nested + // flow lambda — parameter smart casts don't cross lambda boundaries. + val startState: LocationSimulationState = initialState ?: return@flatMapLatest emptyFlow() + flow { + var state = startState + var pendingCompletion = false + + while (true) { + delay((1.0 / warpFactor.toFloat()).toDuration(DurationUnit.SECONDS)) + val updatedState = advanceLocationSimulation(state) + + // Stop if the route has been fully simulated (no state change). + if (updatedState == state) { + if (pendingCompletion) { + return@flow + } else { + pendingCompletion = true + } + } + + state = updatedState + val loc = updatedState.currentLocation.toAndroidLocation() + _lastLocation = loc + emit(loc) + } + } + } + .shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1) + + fun setRoute(route: Route, bias: LocationBias = LocationBias.None) { + _routeFlow.value = locationSimulationFromRoute(route, resampleDistance = 10.0, bias) + } + + override suspend fun lastLocation(): Location? = + _lastLocation ?: _routeFlow.value?.currentLocation?.toAndroidLocation() + + override fun locationUpdates(intervalMillis: Long): Flow = sharedUpdates +} diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/UserLocation.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/UserLocation.kt new file mode 100644 index 000000000..e64a2326c --- /dev/null +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/location/UserLocation.kt @@ -0,0 +1,87 @@ +package com.stadiamaps.ferrostar.core.location + +import android.location.Location +import android.os.Build +import android.os.SystemClock +import java.time.Instant +import uniffi.ferrostar.CourseOverGround +import uniffi.ferrostar.GeographicCoordinate +import uniffi.ferrostar.Speed +import uniffi.ferrostar.UserLocation + +fun Location.toUserLocation(): UserLocation { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + UserLocation( + GeographicCoordinate(latitude, longitude), + if (hasAccuracy()) { + accuracy.toDouble() + } else { + Double.MAX_VALUE + }, + if (hasBearing()) { + CourseOverGround( + bearing.toUInt().toUShort(), + if (hasBearingAccuracy()) { + bearingAccuracyDegrees.toUInt().toUShort() + } else { + null + }) + } else { + null + }, + Instant.ofEpochMilli(time), + if (hasSpeed() && hasSpeedAccuracy()) { + Speed(speed.toDouble(), speedAccuracyMetersPerSecond.toDouble()) + } else { + null + }) + } else { + UserLocation( + GeographicCoordinate(latitude, longitude), + if (hasAccuracy()) { + accuracy.toDouble() + } else { + Double.MAX_VALUE + }, + if (hasBearing()) { + CourseOverGround(bearing.toUInt().toUShort(), null) + } else { + null + }, + Instant.ofEpochMilli(time), + if (hasSpeed()) { + Speed(speed.toDouble(), null) + } else { + null + }) + } +} + +fun UserLocation.toAndroidLocation(): Location { + val location = Location("FerrostarCore") + + location.latitude = this.coordinates.lat + location.longitude = this.coordinates.lng + location.accuracy = this.horizontalAccuracy.toFloat() + + // NOTE: We have a lot of checks in place which we could remove (+ improve correctness) + // if we supported API 26. + val course = this.courseOverGround + if (course != null) { + location.bearing = course.degrees.toFloat() + + val accuracy = course.accuracy + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && accuracy != null) { + // NOTE: Course accuracy information is not available until API 26 + location.bearingAccuracyDegrees = accuracy.toFloat() + } + } + + location.time = this.timestamp.toEpochMilli() + + // FIXME: This is not entirely correct, but might be an acceptable approximation. + // Feedback welcome as the purpose is not really documented. + location.elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos() + + return location +} diff --git a/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt b/android/core/src/main/java/com/stadiamaps/ferrostar/core/mock/MockNavigationState.kt index 94765a9fd..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() + private val mockLocationManager = mockk() + private val mockLocation = mockk() + + @Before + fun setup() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + + mockkStatic(Looper::class) + every { Looper.getMainLooper() } returns mockk(relaxed = true) + + every { mockContext.getSystemService(Context.LOCATION_SERVICE) } returns mockLocationManager + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `lastLocation returns null when no last known location exists`() = runTest { + every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER) + every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns null + + val provider = AndroidLocationProvider(mockContext) + assertNull(provider.lastLocation()) + } + + @Test + fun `lastLocation returns location from location manager`() = runTest { + every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER) + every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns + mockLocation + + val provider = AndroidLocationProvider(mockContext) + assertSame(mockLocation, provider.lastLocation()) + } + + @Test + fun `getBestProvider prefers GPS over network`() = runTest { + val networkLocation = mockk() + every { mockLocationManager.getProviders(true) } returns + listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns + mockLocation + every { mockLocationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) } returns + networkLocation + + val provider = AndroidLocationProvider(mockContext) + assertSame(mockLocation, provider.lastLocation()) + } + + @Test + fun `getBestProvider falls back to network when GPS unavailable`() = runTest { + val networkLocation = mockk() + every { mockLocationManager.getProviders(true) } returns + listOf(LocationManager.NETWORK_PROVIDER) + every { mockLocationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER) } returns + networkLocation + + val provider = AndroidLocationProvider(mockContext) + assertSame(networkLocation, provider.lastLocation()) + } + + @Test + fun `getBestProvider falls back to passive when no other provider is available`() = runTest { + every { mockLocationManager.getProviders(true) } returns emptyList() + every { mockLocationManager.getLastKnownLocation(LocationManager.PASSIVE_PROVIDER) } returns + null + + val provider = AndroidLocationProvider(mockContext) + assertNull(provider.lastLocation()) + } + + @Test + fun `locationUpdates emits last known location immediately on subscribe`() = runTest { + val listenerSlot = slot() + every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER) + every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns + mockLocation + every { + mockLocationManager.requestLocationUpdates( + any(), any(), any(), capture(listenerSlot), any()) + } just Runs + every { mockLocationManager.removeUpdates(any()) } just Runs + + val provider = AndroidLocationProvider(mockContext) + provider.locationUpdates().test { + assertSame(mockLocation, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `locationUpdates emits when the location listener fires`() = runTest { + val listenerSlot = slot() + val newLocation = mockk() + every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER) + every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns null + every { + mockLocationManager.requestLocationUpdates( + any(), any(), any(), capture(listenerSlot), any()) + } just Runs + every { mockLocationManager.removeUpdates(any()) } just Runs + + val provider = AndroidLocationProvider(mockContext) + provider.locationUpdates().test { + listenerSlot.captured.onLocationChanged(newLocation) + assertSame(newLocation, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `locationUpdates unregisters listener when flow is cancelled`() = runTest { + val listenerSlot = slot() + every { mockLocationManager.getProviders(true) } returns listOf(LocationManager.GPS_PROVIDER) + every { mockLocationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER) } returns null + every { + mockLocationManager.requestLocationUpdates( + any(), any(), any(), capture(listenerSlot), any()) + } just Runs + every { mockLocationManager.removeUpdates(any()) } just Runs + + val provider = AndroidLocationProvider(mockContext) + provider.locationUpdates().test { cancelAndIgnoreRemainingEvents() } + + verify { mockLocationManager.removeUpdates(any()) } + } +} diff --git a/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProviderTest.kt b/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProviderTest.kt new file mode 100644 index 000000000..445cb2903 --- /dev/null +++ b/android/core/src/test/java/com/stadiamaps/ferrostar/core/location/SimulatedLocationProviderTest.kt @@ -0,0 +1,23 @@ +package com.stadiamaps.ferrostar.core.location + +import android.location.Location +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class SimulatedLocationProviderTest { + @Test + fun `lastLocation is null when no route or initialLocation is set`() = runTest { + val provider = SimulatedLocationProvider() + assertNull(provider.lastLocation()) + } + + @Test + fun `lastLocation returns initialLocation before any route is set`() = runTest { + val location = mockk(relaxed = true) + val provider = SimulatedLocationProvider(initialLocation = location) + assertSame(location, provider.lastLocation()) + } +} diff --git a/android/demo-app/build.gradle b/android/demo-app/build.gradle index 25cf3a921..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/demo-app/src/main/java/com/stadiamaps/ferrostar/AppModule.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AppModule.kt index 8534b4928..94582e3a7 100644 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AppModule.kt +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AppModule.kt @@ -7,22 +7,21 @@ import com.stadiamaps.ferrostar.core.AlternativeRouteProcessor import com.stadiamaps.ferrostar.core.AndroidTtsObserver import com.stadiamaps.ferrostar.core.CorrectiveAction import com.stadiamaps.ferrostar.core.FerrostarCore -import com.stadiamaps.ferrostar.core.LocationProvider import com.stadiamaps.ferrostar.core.RouteDeviationHandler -import com.stadiamaps.ferrostar.core.SimulatedLocationProvider import com.stadiamaps.ferrostar.core.http.HttpClientProvider import com.stadiamaps.ferrostar.core.http.OkHttpClientProvider.Companion.toOkHttpClientProvider +import com.stadiamaps.ferrostar.core.location.NavigationLocationProvider +import com.stadiamaps.ferrostar.core.location.SimulatedLocationProvider +import com.stadiamaps.ferrostar.core.location.toAndroidLocation import com.stadiamaps.ferrostar.core.service.FerrostarForegroundServiceManager import com.stadiamaps.ferrostar.core.service.ForegroundServiceManager import com.stadiamaps.ferrostar.core.withJsonOptions -import com.stadiamaps.ferrostar.googleplayservices.FusedLocationProvider +import com.stadiamaps.ferrostar.googleplayservices.FusedNavigationLocationProvider +import com.stadiamaps.ferrostar.support.initialSimulatedLocation import java.time.Duration -import java.time.Instant import okhttp3.OkHttpClient -import uniffi.ferrostar.GeographicCoordinate import uniffi.ferrostar.GraphHopperVoiceUnits import uniffi.ferrostar.NavigationControllerConfig -import uniffi.ferrostar.UserLocation import uniffi.ferrostar.WellKnownRouteProvider /** @@ -77,18 +76,14 @@ object AppModule { appContext = context } - // TODO: Make this configurable in the UI. - val simulation = false - val locationProvider: LocationProvider by lazy { - if (simulation) { - SimulatedLocationProvider().apply { - warpFactor = 2u - lastLocation = - UserLocation(GeographicCoordinate(51.049315, 13.73552), 1.0, null, Instant.now(), null) - } - } else { - FusedLocationProvider(appContext) - } + val locationProvider: NavigationLocationProvider by lazy { + NavigationLocationProvider( + liveProviding = FusedNavigationLocationProvider(appContext), + simulatedProvider = SimulatedLocationProvider( + warpFactor = 2u, + initialLocation = initialSimulatedLocation.toAndroidLocation() + ) + ) } private val httpClient: HttpClientProvider by lazy { OkHttpClient.Builder().callTimeout(Duration.ofSeconds(15)).build().toOkHttpClientProvider() diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AutocompleteOverlay.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AutocompleteOverlay.kt deleted file mode 100644 index bcd4dce4b..000000000 --- a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/AutocompleteOverlay.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.stadiamaps.ferrostar - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.stadiamaps.autocomplete.AutocompleteSearch -import com.stadiamaps.autocomplete.center -import com.stadiamaps.ferrostar.composeui.views.components.gridviews.InnerGridView -import com.stadiamaps.ferrostar.core.LocationProvider -import com.stadiamaps.ferrostar.core.SimulatedLocationProvider -import com.stadiamaps.ferrostar.core.toAndroidLocation -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import uniffi.ferrostar.GeographicCoordinate -import uniffi.ferrostar.UserLocation -import uniffi.ferrostar.Waypoint -import uniffi.ferrostar.WaypointKind - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AutocompleteOverlay( - modifier: Modifier = Modifier, - scope: CoroutineScope, - isNavigating: Boolean, - locationProvider: LocationProvider, - loc: UserLocation -) { - if (!isNavigating) { - InnerGridView( - modifier = modifier.fillMaxSize().padding(bottom = 16.dp, top = 16.dp), - topCenter = { - AppModule.stadiaApiKey?.let { apiKey -> - AutocompleteSearch(apiKey = apiKey, userLocation = loc.toAndroidLocation()) { feature -> - feature.center()?.let { center -> - // Fetch a route in the background - scope.launch(Dispatchers.IO) { - // TODO: Fail gracefully - val routes = - AppModule.ferrostarCore.getRoutes( - loc, - listOf( - Waypoint( - coordinate = - GeographicCoordinate(center.latitude, center.longitude), - kind = WaypointKind.BREAK), - )) - - val route = routes.first() - AppModule.ferrostarCore.startNavigation(route = route) - - if (locationProvider is SimulatedLocationProvider) { - locationProvider.setSimulatedRoute(route) - } - } - } - } - } - }) - } -} diff --git a/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationScene.kt index a60c08408..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 @@ -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,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. @@ -56,15 +58,12 @@ fun DemoNavigationScene( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) } - val navigationUiState by viewModel.navigationUiState.collectAsState(scope.coroutineContext) - val location by viewModel.location.collectAsState() - val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> when { permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) -> { - viewModel.startLocationUpdates() + viewModel.setLocationPermissions(true) } permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> { // TODO: Probably alert the user that this is unusable for navigation @@ -80,7 +79,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 +87,7 @@ fun DemoNavigationScene( // Set up the map! val camera = rememberSaveableMapViewCamera(MapViewCamera.TrackingUserLocation()) + DynamicallyOrientingNavigationView( modifier = Modifier.fillMaxSize(), styleUrl = AppModule.mapStyleUrl, @@ -99,16 +99,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/build.gradle b/android/google-play-services/build.gradle index 0ad880810..fedf06654 100644 --- a/android/google-play-services/build.gradle +++ b/android/google-play-services/build.gradle @@ -36,6 +36,9 @@ dependencies { implementation libs.play.services.location testImplementation libs.junit + testImplementation libs.kotlinx.coroutines.test + testImplementation libs.mockk + testImplementation libs.turbine androidTestImplementation libs.androidx.test.junit androidTestImplementation libs.androidx.test.espresso } diff --git a/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProvider.kt b/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProvider.kt index 654161cbb..d9508748f 100644 --- a/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProvider.kt +++ b/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProvider.kt @@ -2,74 +2,109 @@ package com.stadiamaps.ferrostar.googleplayservices import android.annotation.SuppressLint import android.content.Context +import android.location.Location +import android.os.Looper import android.util.Log -import com.google.android.gms.location.FusedLocationProviderClient -import com.google.android.gms.location.LocationListener +import com.google.android.gms.location.CurrentLocationRequest +import com.google.android.gms.location.LastLocationRequest +import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority -import com.stadiamaps.ferrostar.core.LocationProvider -import com.stadiamaps.ferrostar.core.LocationUpdateListener -import com.stadiamaps.ferrostar.core.toUserLocation -import java.util.concurrent.Executor -import uniffi.ferrostar.Heading -import uniffi.ferrostar.UserLocation - -class FusedLocationProvider( - context: Context, - private val fusedLocationProviderClient: FusedLocationProviderClient = - LocationServices.getFusedLocationProviderClient(context), - private val priority: Int = Priority.PRIORITY_HIGH_ACCURACY -) : LocationProvider { +import com.google.android.gms.tasks.CancellationTokenSource +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine - companion object { - private const val TAG = "FusedLocationProvider" - } +interface LocationProviding { + suspend fun getLastLocation( + priority: Int = Priority.PRIORITY_HIGH_ACCURACY, + ): Location? - override var lastLocation: UserLocation? = null - private set + suspend fun getNextLocation( + priority: Int = Priority.PRIORITY_HIGH_ACCURACY, + timeoutMillis: Long = 60000 + ): Location? - override var lastHeading: Heading? = null - private set + fun locationUpdates( + priority: Int = Priority.PRIORITY_HIGH_ACCURACY, + intervalMillis: Long = 1000 + ): Flow +} - private val listeners: MutableMap = mutableMapOf() +class FusedLocationProvider(context: Context) : LocationProviding { + private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context) @SuppressLint("MissingPermission") - override fun addListener(listener: LocationUpdateListener, executor: Executor) { - Log.d(TAG, "Adding listener") - if (listeners.contains(listener)) { - Log.d(TAG, "Listener already added") - return - } - - val androidListener = LocationListener { - val userLocation = it.toUserLocation() - lastLocation = userLocation - listener.onLocationUpdated(userLocation) - } - listeners[listener] = androidListener - - val locationRequest = - LocationRequest.Builder(priority, 1000L) - .setMinUpdateDistanceMeters(5.0f) - .setWaitForAccurateLocation(false) - .build() - - if (lastLocation == null) { - fusedLocationProviderClient.lastLocation.addOnSuccessListener { location -> - if (location != null) { - androidListener.onLocationChanged(location) + override suspend fun getLastLocation(priority: Int): Location? = + suspendCoroutine { continuation -> + Log.d(TAG, "Requesting last location") + val requestStart = System.currentTimeMillis() + + fusedLocationClient + .getLastLocation(LastLocationRequest.Builder().build()) + .addOnSuccessListener { location -> + val durationSeconds = (System.currentTimeMillis() - requestStart) / 1000.0 + Log.d(TAG, "Obtained last location in $durationSeconds s") + continuation.resume(location) + } + .addOnFailureListener { exception -> continuation.resumeWithException(exception) } + } + + // Get the next fresh location update with timeout + @SuppressLint("MissingPermission") + override suspend fun getNextLocation(priority: Int, timeoutMillis: Long): Location? = + suspendCancellableCoroutine { continuation -> + val requestStart = System.currentTimeMillis() + Log.d(TAG, "Requesting next location with priority: $priority") + val cancellationTokenSource = CancellationTokenSource() + + // https://developers.google.com/android/reference/com/google/android/gms/location/CurrentLocationRequest.Builder + val request = + CurrentLocationRequest.Builder() + .setDurationMillis(timeoutMillis) + .setPriority(priority) + .build() + + fusedLocationClient + .getCurrentLocation(request, cancellationTokenSource.token) + .addOnSuccessListener { location -> + val durationSeconds = (System.currentTimeMillis() - requestStart) / 1000.0 + Log.d(TAG,"Obtained next location in $durationSeconds s") + continuation.resume(location) + } + .addOnFailureListener { exception -> continuation.resumeWithException(exception) } + + continuation.invokeOnCancellation { + val durationSeconds = (System.currentTimeMillis() - requestStart) / 1000.0 + Log.d(TAG,"Next location cancelled after $durationSeconds s") + cancellationTokenSource.cancel() } } - } - fusedLocationProviderClient.requestLocationUpdates(locationRequest, executor, androidListener) - } - override fun removeListener(listener: LocationUpdateListener) { - val activeListener = listeners.remove(listener) + // Continuous location updates as Flow + @SuppressLint("MissingPermission") + override fun locationUpdates(priority: Int, intervalMillis: Long): Flow = callbackFlow { + val request = LocationRequest.Builder(priority, intervalMillis).build() + + val callback = + object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + result.lastLocation?.let { trySend(it) } + } + } - if (activeListener != null) { - fusedLocationProviderClient.removeLocationUpdates(activeListener) - } + fusedLocationClient.requestLocationUpdates(request, callback, Looper.getMainLooper()) + + awaitClose { fusedLocationClient.removeLocationUpdates(callback) } + } + + companion object { + private const val TAG = "LocationProvider" } } diff --git a/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProvider.kt b/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProvider.kt new file mode 100644 index 000000000..bf7a8048d --- /dev/null +++ b/android/google-play-services/src/main/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProvider.kt @@ -0,0 +1,18 @@ +package com.stadiamaps.ferrostar.googleplayservices + +import android.content.Context +import android.location.Location +import com.google.android.gms.location.Priority +import com.stadiamaps.ferrostar.core.location.NavigationLocationProviding +import kotlinx.coroutines.flow.Flow + +class FusedNavigationLocationProvider( + context: Context, + private val locationProvider: FusedLocationProvider = FusedLocationProvider(context) +): NavigationLocationProviding { + override suspend fun lastLocation(): Location? = + locationProvider.getLastLocation(Priority.PRIORITY_HIGH_ACCURACY) + + override fun locationUpdates(intervalMillis: Long): Flow = + locationProvider.locationUpdates(Priority.PRIORITY_HIGH_ACCURACY, intervalMillis) +} diff --git a/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/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..b79c59073 --- /dev/null +++ b/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedLocationProviderTest.kt @@ -0,0 +1,202 @@ +package com.stadiamaps.ferrostar.googleplayservices + +import android.content.Context +import android.location.Location +import android.os.Looper +import android.util.Log +import app.cash.turbine.test +import com.google.android.gms.location.CurrentLocationRequest +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.tasks.CancellationToken +import com.google.android.gms.tasks.CancellationTokenSource +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Task +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test + +class FusedLocationProviderTest { + private val mockContext = mockk() + private val mockFusedClient = mockk() + private val mockLocation = mockk() + + @Before + fun setup() { + mockkStatic(LocationServices::class) + every { LocationServices.getFusedLocationProviderClient(mockContext) } returns mockFusedClient + + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + + mockkStatic(Looper::class) + every { Looper.getMainLooper() } returns mockk(relaxed = true) + } + + @After + fun teardown() { + unmockkAll() + } + + // --- Helpers --- + + private fun successTask(location: Location?): Task { + val task = mockk>() + every { task.addOnSuccessListener(any>()) } answers { + firstArg>().onSuccess(location) + task + } + every { task.addOnFailureListener(any()) } returns task + return task + } + + private fun failureTask(exception: Exception): Task { + val task = mockk>() + every { task.addOnSuccessListener(any>()) } returns task + every { task.addOnFailureListener(any()) } answers { + firstArg().onFailure(exception) + task + } + return task + } + + private fun pendingTask(): Task { + val task = mockk>() + every { task.addOnSuccessListener(any>()) } returns task + every { task.addOnFailureListener(any()) } returns task + return task + } + + // --- getLastLocation --- + + @Test + fun `getLastLocation returns location on success`() = runTest { + every { mockFusedClient.getLastLocation(any()) } returns successTask(mockLocation) + + val provider = FusedLocationProvider(mockContext) + assertSame(mockLocation, provider.getLastLocation()) + } + + @Test + fun `getLastLocation returns null when no location is cached`() = runTest { + every { mockFusedClient.getLastLocation(any()) } returns successTask(null) + + val provider = FusedLocationProvider(mockContext) + assertNull(provider.getLastLocation()) + } + + @Test(expected = RuntimeException::class) + fun `getLastLocation throws on failure`() = runTest { + every { mockFusedClient.getLastLocation(any()) } returns + failureTask(RuntimeException("Location unavailable")) + + val provider = FusedLocationProvider(mockContext) + provider.getLastLocation() + } + + // --- getNextLocation --- + + @Test + fun `getNextLocation returns location on success`() = runTest { + every { + mockFusedClient.getCurrentLocation(any(), any()) + } returns successTask(mockLocation) + + val provider = FusedLocationProvider(mockContext) + assertSame(mockLocation, provider.getNextLocation()) + } + + @Test + fun `getNextLocation cancels the token when the coroutine is cancelled`() = runTest { + mockkConstructor(CancellationTokenSource::class) + val mockToken = mockk() + every { anyConstructed().token } returns mockToken + every { anyConstructed().cancel() } just Runs + every { + mockFusedClient.getCurrentLocation(any(), any()) + } returns pendingTask() + + val provider = FusedLocationProvider(mockContext) + val job = launch { provider.getNextLocation() } + runCurrent() // let the coroutine reach the suspension point before cancelling + job.cancel() + job.join() + + verify { anyConstructed().cancel() } + } + + // --- locationUpdates --- + + @Test + fun `locationUpdates emits when the location callback fires`() = runTest { + val callbackSlot = slot() + every { + mockFusedClient.requestLocationUpdates(any(), capture(callbackSlot), any()) + } returns mockk() + every { mockFusedClient.removeLocationUpdates(any()) } returns mockk() + + val mockResult = mockk() + every { mockResult.lastLocation } returns mockLocation + + val provider = FusedLocationProvider(mockContext) + provider.locationUpdates().test { + callbackSlot.captured.onLocationResult(mockResult) + assertSame(mockLocation, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `locationUpdates skips null locations from callback`() = runTest { + val callbackSlot = slot() + every { + mockFusedClient.requestLocationUpdates(any(), capture(callbackSlot), any()) + } returns mockk() + every { mockFusedClient.removeLocationUpdates(any()) } returns mockk() + + val nullResult = mockk() + every { nullResult.lastLocation } returns null + + val validResult = mockk() + every { validResult.lastLocation } returns mockLocation + + val provider = FusedLocationProvider(mockContext) + provider.locationUpdates().test { + callbackSlot.captured.onLocationResult(nullResult) // should be dropped + callbackSlot.captured.onLocationResult(validResult) + assertSame(mockLocation, awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `locationUpdates unregisters callback when flow is cancelled`() = runTest { + val callbackSlot = slot() + every { + mockFusedClient.requestLocationUpdates(any(), capture(callbackSlot), any()) + } returns mockk() + every { mockFusedClient.removeLocationUpdates(any()) } returns mockk() + + val provider = FusedLocationProvider(mockContext) + provider.locationUpdates().test { cancelAndIgnoreRemainingEvents() } + + verify { mockFusedClient.removeLocationUpdates(any()) } + } +} diff --git a/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProviderTest.kt b/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProviderTest.kt new file mode 100644 index 000000000..f4d493a78 --- /dev/null +++ b/android/google-play-services/src/test/java/com/stadiamaps/ferrostar/googleplayservices/FusedNavigationLocationProviderTest.kt @@ -0,0 +1,60 @@ +package com.stadiamaps.ferrostar.googleplayservices + +import android.content.Context +import android.location.Location +import com.google.android.gms.location.Priority +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class FusedNavigationLocationProviderTest { + private val mockContext = mockk() + private val mockLocationProvider = mockk() + private val mockLocation = mockk() + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `lastLocation delegates to getLastLocation with high accuracy priority`() = runTest { + coEvery { mockLocationProvider.getLastLocation(Priority.PRIORITY_HIGH_ACCURACY) } returns + mockLocation + + val provider = FusedNavigationLocationProvider(mockContext, mockLocationProvider) + assertSame(mockLocation, provider.lastLocation()) + } + + @Test + fun `lastLocation returns null when delegate returns null`() = runTest { + coEvery { mockLocationProvider.getLastLocation(Priority.PRIORITY_HIGH_ACCURACY) } returns null + + val provider = FusedNavigationLocationProvider(mockContext, mockLocationProvider) + assertNull(provider.lastLocation()) + } + + @Test + fun `locationUpdates delegates to provider with high accuracy priority`() = runTest { + val intervalMillis = 1000L + val locationFlow = flowOf(mockLocation) + every { + mockLocationProvider.locationUpdates(Priority.PRIORITY_HIGH_ACCURACY, intervalMillis) + } returns locationFlow + + val provider = FusedNavigationLocationProvider(mockContext, mockLocationProvider) + val result = provider.locationUpdates(intervalMillis) + + // Verify delegation to the underlying provider with the correct arguments + verify { mockLocationProvider.locationUpdates(Priority.PRIORITY_HIGH_ACCURACY, intervalMillis) } + assertSame(locationFlow, result) + } +} diff --git a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt index cd31ba813..c6b0901b1 100644 --- a/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt +++ b/android/ui-maplibre/src/main/java/com/stadiamaps/ferrostar/maplibreui/NavigationMapView.kt @@ -16,7 +16,7 @@ import com.maplibre.compose.ramani.LocationRequestProperties import com.maplibre.compose.ramani.MapLibreComposable import com.maplibre.compose.settings.MapControls import com.stadiamaps.ferrostar.core.NavigationUiState -import com.stadiamaps.ferrostar.core.toAndroidLocation +import com.stadiamaps.ferrostar.core.location.toAndroidLocation import com.stadiamaps.ferrostar.maplibreui.extensions.NavigationDefault import com.stadiamaps.ferrostar.maplibreui.routeline.RouteOverlayBuilder import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera