diff --git a/.github/workflows/gradle-publish.yml b/.github/workflows/gradle-publish.yml index dac95e6b..0f6f2bd8 100644 --- a/.github/workflows/gradle-publish.yml +++ b/.github/workflows/gradle-publish.yml @@ -32,7 +32,7 @@ jobs: - name: Install cargo-ndk run: cargo install cargo-ndk - - name: Creat local.properties (required for cargo-ndk and the demo app) + - name: Create local.properties (required for cargo-ndk and the demo app) run: echo 'stadiaApiKey=' > local.properties working-directory: android diff --git a/Package.resolved b/Package.resolved index 74475d8e..b44164b2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git", "state" : { - "revision" : "e409318144091c3ee9ad551b202e1c36695f8086", - "version" : "6.7.0" + "revision" : "f23db791d7b6f0329e3c6788d8e4152c24c52b6b", + "version" : "6.7.1" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stadiamaps/maplibre-swift-macros.git", "state" : { - "revision" : "236215c13bff962009e0f0257d6d8349be33442f", - "version" : "0.0.4" + "revision" : "9e27e62dff7fd727aebd0a7c8aa74e7635a5583e", + "version" : "0.0.5" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax.git", "state" : { - "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", - "version" : "509.1.1" + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/swiftui-dsl", "state" : { - "revision" : "5ba75ef1e4382fcc7ee71e274eb9c2a50906b14e", - "version" : "0.1.0" + "revision" : "c39688db3aac50b523ea062b286c584123022093", + "version" : "0.3.0" } } ], diff --git a/android/README.md b/android/README.md index 94aaff41..356bb4ce 100644 --- a/android/README.md +++ b/android/README.md @@ -5,7 +5,7 @@ This directory tree contains the Gradle workspace for Ferrostar on Android. * `composeui` - Jetpack Compose UI elements which are not tightly coupled to any particular map renderer. * `core` - The core module is where all the "business logic", location management, and other core functionality lives. * `demo-app` - A minimal demonstration app. -* `google-play-services` - Optional functionality that depends on Google Play Services (like a fused location client wrapper). This is a separate module so that apps are able to "de-Google" if necessary. +* `google-play-services` - Optional functionality that depends on Google Play Services (like the fused location client wrapper). This is a separate module so that apps are able to "de-Google" if necessary. * `maplibreui` - Map-related user interface components built with MapLibre. ## Running the demo app diff --git a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/maneuver/ManeuverImage.kt b/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/maneuver/ManeuverImage.kt index 56598031..0de34f7b 100644 --- a/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/maneuver/ManeuverImage.kt +++ b/android/composeui/src/main/java/com/stadiamaps/ferrostar/composeui/views/maneuver/ManeuverImage.kt @@ -47,7 +47,7 @@ fun ManeuverImage(content: VisualInstructionContent, tint: Color = LocalContentC @Preview @Composable -fun ManeuverImagePreview() { +fun ManeuverImageLeftTurnPreview() { ManeuverImage( VisualInstructionContent( text = "", @@ -56,3 +56,14 @@ fun ManeuverImagePreview() { roundaboutExitDegrees = null, laneInfo = null)) } + +@Preview +@Composable +fun ManeuverImageContinueUturnPreview() { + ManeuverImage( + VisualInstructionContent( + text = "", + maneuverType = ManeuverType.CONTINUE, + maneuverModifier = ManeuverModifier.U_TURN, + roundaboutExitDegrees = null)) +} diff --git a/android/composeui/src/main/res/drawable/direction_continue_uturn.xml b/android/composeui/src/main/res/drawable/direction_continue_u_turn.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_continue_uturn.xml rename to android/composeui/src/main/res/drawable/direction_continue_u_turn.xml diff --git a/android/composeui/src/main/res/drawable/direction_invalid_uturn.xml b/android/composeui/src/main/res/drawable/direction_invalid_u_turn.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_invalid_uturn.xml rename to android/composeui/src/main/res/drawable/direction_invalid_u_turn.xml diff --git a/android/composeui/src/main/res/drawable/direction_uturn.xml b/android/composeui/src/main/res/drawable/direction_u_turn.xml similarity index 100% rename from android/composeui/src/main/res/drawable/direction_uturn.xml rename to android/composeui/src/main/res/drawable/direction_u_turn.xml 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 index 2f98cf34..827eaa97 100644 --- a/android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt +++ b/android/core/src/main/java/com/stadiamaps/ferrostar/core/Location.kt @@ -78,13 +78,19 @@ class AndroidSystemLocationProvider(context: Context) : LocationProvider { android.util.Log.d(TAG, "Already registered; skipping") return } - val androidListener = LocationListener { listener.onLocationUpdated(it.toUserLocation()) } + 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) } } 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 7759356d..25c7883a 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 @@ -31,7 +31,7 @@ data class NavigationUiState( */ val heading: Float?, /** The geometry of the full route. */ - val routeGeometry: List, + val routeGeometry: List?, /** Visual instructions which should be displayed based on the user's current progress. */ val visualInstruction: VisualInstruction?, /** @@ -76,6 +76,11 @@ interface NavigationViewModel { fun toggleMute() fun stopNavigation() + + fun isNavigating(): Boolean = uiState.value.progress != null + + // 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) } class DefaultNavigationViewModel( diff --git a/android/demo-app/build.gradle b/android/demo-app/build.gradle index 230da289..eb588330 100644 --- a/android/demo-app/build.gradle +++ b/android/demo-app/build.gradle @@ -13,7 +13,7 @@ android { defaultConfig { applicationId "com.stadiamaps.ferrostar.demo" minSdk 26 - targetSdk 34 + targetSdk 35 versionCode 1 versionName "1.0" @@ -64,11 +64,16 @@ dependencies { implementation project(':core') implementation project(':composeui') implementation project(':maplibreui') + implementation project(':google-play-services') implementation libs.maplibre.compose - implementation(platform(libs.okhttp.bom)) - implementation(libs.okhttp.core) + implementation platform(libs.okhttp.bom) + implementation libs.okhttp.core + + implementation libs.play.services.location + + implementation libs.stadiamaps.autocomplete.search testImplementation libs.junit androidTestImplementation libs.androidx.test.junit 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 1c7a4dc4..2bcd2ded 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 @@ -8,10 +8,11 @@ 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.service.FerrostarForegroundServiceManager import com.stadiamaps.ferrostar.core.service.ForegroundServiceManager +import com.stadiamaps.ferrostar.googleplayservices.FusedLocationProvider import java.net.URL import java.time.Duration import okhttp3.OkHttpClient @@ -56,7 +57,10 @@ object AppModule { appContext = context } - val locationProvider: SimulatedLocationProvider by lazy { SimulatedLocationProvider() } + val locationProvider: LocationProvider by lazy { + // TODO: Make this configurable? + FusedLocationProvider(appContext) + } private val httpClient: OkHttpClient by lazy { OkHttpClient.Builder().callTimeout(Duration.ofSeconds(15)).build() } @@ -69,7 +73,7 @@ object AppModule { val core = FerrostarCore( valhallaEndpointURL = valhallaEndpointUrl, - profile = "bicycle", + profile = "auto", httpClient = httpClient, locationProvider = locationProvider, foregroundServiceManager = foregroundServiceManager, @@ -79,7 +83,20 @@ object AppModule { minimumHorizontalAccuracy = 25U, automaticAdvanceDistance = 10U), RouteDeviationTracking.StaticThreshold(15U, 25.0), CourseFiltering.SNAP_TO_ROUTE), - options = mapOf("costingOptions" to mapOf("bicycle" to mapOf("use_roads" to 0.2)))) + options = + mapOf( + "costingOptions" to + // Just an example... You can set multiple costing options for any profile + // in Valhalla. + // If your app uses multiple routing modes, you can have a master settings + // map, or construct a new one each time. + mapOf( + "low_speed_vehicle" to + mapOf( + "vehicle_type" to "golf_cart", + "top_speed" to 32 // 24kph ~= 15mph + )), + "units" to "miles")) // Not all navigation apps will require this sort of extra configuration. // In fact, we hope that most don't! @@ -93,13 +110,7 @@ object AppModule { Log.i(TAG, "Received alternate route(s): $routes") if (routes.isNotEmpty()) { // NB: Use `replaceRoute` for cases like this! - it.replaceRoute( - routes.first(), - NavigationControllerConfig( - StepAdvanceMode.RelativeLineStringDistance( - minimumHorizontalAccuracy = 25U, automaticAdvanceDistance = 10U), - RouteDeviationTracking.StaticThreshold(25U, 10.0), - CourseFiltering.SNAP_TO_ROUTE)) + it.replaceRoute(routes.first()) } } 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 b089e6d2..cfa85735 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 @@ -5,29 +5,37 @@ import android.os.Build import android.os.Bundle import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.width -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold 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.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.mapbox.mapboxsdk.geometry.LatLng +import com.maplibre.compose.camera.MapViewCamera +import com.maplibre.compose.rememberSaveableMapViewCamera import com.maplibre.compose.symbols.Circle +import com.stadiamaps.autocomplete.AutocompleteSearch +import com.stadiamaps.autocomplete.center import com.stadiamaps.ferrostar.composeui.runtime.KeepScreenOnDisposableEffect -import com.stadiamaps.ferrostar.core.FerrostarCore +import com.stadiamaps.ferrostar.composeui.views.gridviews.InnerGridView +import com.stadiamaps.ferrostar.core.AndroidSystemLocationProvider +import com.stadiamaps.ferrostar.core.LocationProvider import com.stadiamaps.ferrostar.core.NavigationViewModel import com.stadiamaps.ferrostar.core.SimulatedLocationProvider +import com.stadiamaps.ferrostar.core.toAndroidLocation +import com.stadiamaps.ferrostar.googleplayservices.FusedLocationProvider import com.stadiamaps.ferrostar.maplibreui.views.DynamicallyOrientingNavigationView -import com.stadiamaps.ferrostar.support.initialSimulatedLocation +import java.util.concurrent.Executors +import kotlin.math.min import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import uniffi.ferrostar.GeographicCoordinate @@ -37,13 +45,18 @@ import uniffi.ferrostar.WaypointKind @Composable fun DemoNavigationScene( savedInstanceState: Bundle?, - locationProvider: SimulatedLocationProvider = AppModule.locationProvider, - core: FerrostarCore = AppModule.ferrostarCore + locationProvider: LocationProvider = AppModule.locationProvider, ) { + val executor = remember { Executors.newSingleThreadScheduledExecutor() } + // Keeps the screen on at consistent brightness while this Composable is in the view hierarchy. KeepScreenOnDisposableEffect() - var viewModel by remember { mutableStateOf(null) } + // NOTE: We are aware that this is not a particularly great pattern. + // We are working on improving this. See the discussion on + // https://github.com/stadiamaps/ferrostar/pull/295. + var viewModel by remember { mutableStateOf(DemoNavigationViewModel()) } + val scope = rememberCoroutineScope() // Get location permissions. // NOTE: This is NOT a robust suggestion for how to get permissions in a production app. @@ -60,21 +73,26 @@ fun DemoNavigationScene( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) } + val vmState = viewModel.uiState.collectAsState(scope.coroutineContext) + val permissionsLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> when { permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) -> { - // TODO - // onAccess() + val vm = viewModel + if ((locationProvider is AndroidSystemLocationProvider || + locationProvider is FusedLocationProvider) && vm is DemoNavigationViewModel) { + // Activate location updates in the view model + vm.startLocationUpdates(locationProvider) + } } permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> { - // TODO - // onAccess() + // TODO: Probably alert the user that this is unusable for navigation } + // TODO: Foreground service permissions; we should block access until approved on API 34+ else -> { // TODO - // onFailed() } } } @@ -83,62 +101,89 @@ fun DemoNavigationScene( LaunchedEffect(savedInstanceState) { // Request all permissions permissionsLauncher.launch(allPermissions) + } - // Fetch a route in the background - launch(Dispatchers.IO) { - val routes = - core.getRoutes( - initialSimulatedLocation, - listOf( - Waypoint( - coordinate = GeographicCoordinate(37.807587, -122.428411), - kind = WaypointKind.BREAK), - )) - - val route = routes.first() - viewModel = core.startNavigation(route = route) - - locationProvider.setSimulatedRoute(route) + // For smart casting + val loc = vmState.value.location + if (loc == null) { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Text("Waiting to acquire your GPS location...", modifier = Modifier.padding(innerPadding)) } + return } - if (viewModel != null) { - // Demo tiles illustrate a basic integration without any API key required, - // but you can replace the styleURL with any valid MapLibre style URL. - // See https://stadiamaps.github.io/ferrostar/vendors.html for some vendors. - // Most vendors offer free API keys for development use. - DynamicallyOrientingNavigationView( - modifier = Modifier.fillMaxSize(), - // These are demo tiles and not very useful. - // Check https://stadiamaps.github.io/ferrostar/vendors.html for some vendors of vector - // tiles. - // Most vendors offer free API keys for development use. - styleUrl = "https://demotiles.maplibre.org/style.json", - viewModel = viewModel!!, - // This is the default value, which works well for motor vehicle navigation. - // Other travel modes though, such as walking, may not want snapping. - snapUserLocationToRoute = true, - 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.) - uiState.value.snappedLocation?.let { + // Set up the map! + val camera = rememberSaveableMapViewCamera(MapViewCamera.TrackingUserLocation()) + DynamicallyOrientingNavigationView( + modifier = Modifier.fillMaxSize(), + styleUrl = AppModule.mapStyleUrl, + camera = camera, + viewModel = viewModel, + // Snapping works well for most motor vehicle navigation. + // Other travel modes though, such as walking, may not want snapping. + snapUserLocationToRoute = false, + onTapExit = { + viewModel.stopNavigation() + val vm = DemoNavigationViewModel() + viewModel = vm + + vm.startLocationUpdates(locationProvider) + }, + userContent = { modifier -> + if (!viewModel.isNavigating()) { + InnerGridView( + modifier = modifier.fillMaxSize().padding(bottom = 16.dp, top = 16.dp), + topCenter = { + AutocompleteSearch( + apiKey = AppModule.stadiaApiKey, 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() + viewModel = AppModule.ferrostarCore.startNavigation(route = route) + + if (locationProvider is SimulatedLocationProvider) { + locationProvider.setSimulatedRoute(route) + } + } + } + } + }) + } + }) { uiState -> + // Trivial, if silly example of how to add your own overlay layers. + // (Also incidentally highlights the lag inherent in MapLibre location tracking + // as-is.) + uiState.value.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(it.coordinates.lat, it.coordinates.lng), - radius = 10f, + center = LatLng(location.coordinates.lat, location.coordinates.lng), + radius = min(location.horizontalAccuracy.toFloat(), 150f), color = "Blue", + opacity = 0.2f, zIndex = 2, ) } } - } else { - // Loading indicator - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = "Calculating route...") - CircularProgressIndicator(modifier = Modifier.width(64.dp)) - } - } + } } 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 new file mode 100644 index 00000000..7d10ac2a --- /dev/null +++ b/android/demo-app/src/main/java/com/stadiamaps/ferrostar/DemoNavigationViewModel.kt @@ -0,0 +1,60 @@ +package com.stadiamaps.ferrostar + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.stadiamaps.ferrostar.core.LocationProvider +import com.stadiamaps.ferrostar.core.LocationUpdateListener +import com.stadiamaps.ferrostar.core.NavigationUiState +import com.stadiamaps.ferrostar.core.NavigationViewModel +import java.util.concurrent.Executors +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import uniffi.ferrostar.Heading +import uniffi.ferrostar.UserLocation + +// NOTE: We are aware that this is not a particularly great ViewModel. +// We are working on improving this. See the discussion on +// https://github.com/stadiamaps/ferrostar/pull/295. +class DemoNavigationViewModel : ViewModel(), NavigationViewModel { + private val locationStateFlow = MutableStateFlow(null) + private val executor = Executors.newSingleThreadScheduledExecutor() + + fun startLocationUpdates(locationProvider: LocationProvider) { + locationStateFlow.update { locationProvider.lastLocation } + locationProvider.addListener( + object : LocationUpdateListener { + override fun onLocationUpdated(location: UserLocation) { + locationStateFlow.update { location } + } + + override fun onHeadingUpdated(heading: Heading) { + // TODO: Heading + } + }, + executor) + } + + override val uiState = + locationStateFlow + .map { userLocation -> + // TODO: Heading + NavigationUiState(userLocation, null, null, null, null, null, null, false, null, null) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + // TODO: Heading + initialValue = + NavigationUiState(null, null, null, null, null, null, null, false, null, null)) + + override fun toggleMute() { + // Do nothing + } + + override fun stopNavigation() { + // Do nothing + } +} 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 24dc163a..1d039aa4 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 @@ -10,7 +10,6 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import com.stadiamaps.ferrostar.core.AndroidTtsStatusListener -import com.stadiamaps.ferrostar.support.initialSimulatedLocation import com.stadiamaps.ferrostar.ui.theme.FerrostarTheme import java.util.Locale @@ -44,8 +43,8 @@ class MainActivity : ComponentActivity(), AndroidTtsStatusListener { AppModule.ferrostarCore.spokenInstructionObserver = AppModule.ttsObserver // Set up the location provider - AppModule.locationProvider.lastLocation = initialSimulatedLocation - AppModule.locationProvider.warpFactor = 2u + // AppModule.locationProvider.lastLocation = initialSimulatedLocation + // AppModule.locationProvider.warpFactor = 2u // Edge to edge (this will be default in Android 15) // See https://developer.android.com/codelabs/edge-to-edge#0 @@ -67,6 +66,7 @@ class MainActivity : ComponentActivity(), AndroidTtsStatusListener { // TTS listener methods override fun onTtsInitialized(tts: TextToSpeech?, status: Int) { + // Set this up as appropriate for your app if (tts != null) { tts.setLanguage(Locale.US) android.util.Log.i(TAG, "setLanguage status: $status") 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 566f9212..654161cb 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,7 +2,6 @@ package com.stadiamaps.ferrostar.googleplayservices import android.annotation.SuppressLint import android.content.Context -import android.os.Looper import android.util.Log import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationListener @@ -19,7 +18,8 @@ import uniffi.ferrostar.UserLocation class FusedLocationProvider( context: Context, private val fusedLocationProviderClient: FusedLocationProviderClient = - LocationServices.getFusedLocationProviderClient(context) + LocationServices.getFusedLocationProviderClient(context), + private val priority: Int = Priority.PRIORITY_HIGH_ACCURACY ) : LocationProvider { companion object { @@ -42,16 +42,27 @@ class FusedLocationProvider( return } - val locationListener = LocationListener { newLocation -> - listener.onLocationUpdated(newLocation.toUserLocation()) + val androidListener = LocationListener { + val userLocation = it.toUserLocation() + lastLocation = userLocation + listener.onLocationUpdated(userLocation) } - listeners[listener] = locationListener + listeners[listener] = androidListener val locationRequest = - LocationRequest.Builder(1000L).setPriority(Priority.PRIORITY_HIGH_ACCURACY).build() + LocationRequest.Builder(priority, 1000L) + .setMinUpdateDistanceMeters(5.0f) + .setWaitForAccurateLocation(false) + .build() - fusedLocationProviderClient.requestLocationUpdates( - locationRequest, locationListener, Looper.getMainLooper()) + if (lastLocation == null) { + fusedLocationProviderClient.lastLocation.addOnSuccessListener { location -> + if (location != null) { + androidListener.onLocationChanged(location) + } + } + } + fusedLocationProviderClient.requestLocationUpdates(locationRequest, executor, androidListener) } override fun removeListener(listener: LocationUpdateListener) { diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 34e86426..4aacc790 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -11,19 +11,20 @@ kotlinx-coroutines = "1.9.0" kotlinx-datetime = "0.6.1" androidx-appcompat = "1.7.0" androidx-activity-compose = "1.9.2" -compose = "2024.09.02" +compose = "2024.09.03" okhttp = "4.12.0" moshi = "1.15.1" maplibre-compose = "0.2.0" playServicesLocation = "21.3.0" junit = "4.13.2" junitVersion = "1.2.1" -junitCompose = "1.7.2" +junitCompose = "1.7.3" espressoCore = "3.6.1" okhttp-mock = "2.0.0" mavenPublish = "0.29.0" secretsGradlePlugin = "2.0.1" material = "1.12.0" +stadiaAutocompleteSearch = "1.0.0" [libraries] desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } @@ -65,6 +66,7 @@ androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref androidx-test-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "junitCompose" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +stadiamaps-autocomplete-search = { group = "com.stadiamaps", name = "jetpack-compose-autocomplete", version.ref = "stadiaAutocompleteSearch" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } 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 b258aa0e..9954d4cd 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 @@ -25,7 +25,11 @@ import com.stadiamaps.ferrostar.maplibreui.runtime.navigationMapViewCamera * The base MapLibre MapView configured for navigation with a polyline representing the route. * * @param styleUrl The MapLibre style URL to use for the map. - * @param camera The bi-directional camera state to use for the map. + * @param camera The bi-directional camera state to use for the map. Note: this is a bit + * non-standard as far as normal compose patterns go, but we independently came up with this + * approach and later verified that Google Maps does the same thing in their compose SDK. + * @param navigationCamera The default camera settings to use when navigation starts. This will be + * re-applied to the camera any time that navigation is started. * @param viewModel The navigation view model provided by Ferrostar Core. * @param locationRequestProperties The location request properties to use for the map's location * engine. @@ -45,17 +49,32 @@ fun NavigationMapView( locationRequestProperties: LocationRequestProperties = LocationRequestProperties.NavigationDefault(), snapUserLocationToRoute: Boolean = true, - onMapReadyCallback: (Style) -> Unit = { camera.value = navigationCamera }, - content: @Composable @MapLibreComposable() ((State) -> Unit)? = null + onMapReadyCallback: (Style) -> Unit = { + if (viewModel.isNavigating()) camera.value = navigationCamera + }, + content: @Composable @MapLibreComposable ((State) -> Unit)? = null ) { val uiState = viewModel.uiState.collectAsState() + // TODO: This works for now, but in the end, the view model may need to "own" the camera. + // We can move this code if we do such a refactor. + var isNavigating = remember { viewModel.isNavigating() } + if (viewModel.isNavigating() != isNavigating) { + isNavigating = viewModel.isNavigating() + + if (isNavigating) { + camera.value = navigationCamera + } + } + val locationEngine = remember { StaticLocationEngine() } locationEngine.lastLocation = - if (snapUserLocationToRoute) { - uiState.value.snappedLocation?.toAndroidLocation() - } else { - uiState.value.location?.toAndroidLocation() + uiState.value.let { state -> + if (snapUserLocationToRoute) { + state.snappedLocation?.toAndroidLocation() + } else { + state.location?.toAndroidLocation() + } } MapView( @@ -67,8 +86,9 @@ fun NavigationMapView( locationEngine = locationEngine, onMapReadyCallback = onMapReadyCallback, ) { - BorderedPolyline( - points = uiState.value.routeGeometry.map { LatLng(it.lat, it.lng) }, zIndex = 0) + val geometry = uiState.value.routeGeometry + if (geometry != null) + BorderedPolyline(points = geometry.map { LatLng(it.lat, it.lng) }, zIndex = 0) if (content != null) { content(uiState) diff --git a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt index 9ea20dd1..9beb41b2 100644 --- a/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt +++ b/android/maplibreui/src/main/java/com/stadiamaps/ferrostar/maplibreui/views/DynamicallyOrientingNavigationView.kt @@ -2,6 +2,7 @@ package com.stadiamaps.ferrostar.maplibreui.views import android.content.res.Configuration import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars @@ -46,7 +47,10 @@ import com.stadiamaps.ferrostar.maplibreui.views.overlays.PortraitNavigationOver * route line. * @param config The configuration for the navigation view. * @param onTapExit The callback to invoke when the exit button is tapped. - * @param content Any additional composable map symbol content to render. + * @param userContent Any composable with additional content to render. The most common use of this + * parameter is to display custom UI when there is no navigation in progress. See the demo app for + * an example that adds a search box. + * @param mapContent Any additional composable map symbol content to render. */ @Composable fun DynamicallyOrientingNavigationView( @@ -60,7 +64,8 @@ fun DynamicallyOrientingNavigationView( snapUserLocationToRoute: Boolean = true, config: VisualNavigationViewConfig = VisualNavigationViewConfig.Default(), onTapExit: (() -> Unit)? = null, - content: @Composable @MapLibreComposable() ((State) -> Unit)? = null, + userContent: @Composable (BoxScope.(Modifier) -> Unit)? = null, + mapContent: @Composable @MapLibreComposable ((State) -> Unit)? = null, ) { val orientation = LocalConfiguration.current.orientation @@ -83,27 +88,38 @@ fun DynamicallyOrientingNavigationView( mapControls, locationRequestProperties, snapUserLocationToRoute, - onMapReadyCallback = { camera.value = navigationCamera }, - content) + onMapReadyCallback = { + if (viewModel.isNavigating()) { + camera.value = navigationCamera + } + }, + mapContent) - when (orientation) { - Configuration.ORIENTATION_LANDSCAPE -> { - LandscapeNavigationOverlayView( - modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding), - camera = camera, - viewModel = viewModel, - config = config, - onTapExit = onTapExit) - } - else -> { - PortraitNavigationOverlayView( - modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding), - camera = camera, - viewModel = viewModel, - config = config, - arrivalViewSize = rememberArrivalViewSize, - onTapExit = onTapExit) + if (viewModel.isNavigating()) { + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + LandscapeNavigationOverlayView( + modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding), + camera = camera, + viewModel = viewModel, + config = config, + onTapExit = onTapExit) + } + + else -> { + PortraitNavigationOverlayView( + modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding), + camera = camera, + viewModel = viewModel, + config = config, + arrivalViewSize = rememberArrivalViewSize, + onTapExit = onTapExit) + } } } + + if (userContent != null) { + userContent(Modifier.windowInsetsPadding(WindowInsets.systemBars).padding(gridPadding)) + } } } diff --git a/apple/Sources/FerrostarSwiftUI/Resources/Directions.xcassets/uturn.imageset/Contents.json b/apple/Sources/FerrostarSwiftUI/Resources/Directions.xcassets/turn_uturn.imageset/Contents.json similarity index 100% rename from apple/Sources/FerrostarSwiftUI/Resources/Directions.xcassets/uturn.imageset/Contents.json rename to apple/Sources/FerrostarSwiftUI/Resources/Directions.xcassets/turn_uturn.imageset/Contents.json diff --git a/apple/Sources/FerrostarSwiftUI/Resources/Directions.xcassets/uturn.imageset/uturn.svg b/apple/Sources/FerrostarSwiftUI/Resources/Directions.xcassets/turn_uturn.imageset/uturn.svg similarity index 100% rename from apple/Sources/FerrostarSwiftUI/Resources/Directions.xcassets/uturn.imageset/uturn.svg rename to apple/Sources/FerrostarSwiftUI/Resources/Directions.xcassets/turn_uturn.imageset/uturn.svg diff --git a/apple/Sources/FerrostarSwiftUI/Views/Maneuver/ManeuverInstructionView.swift b/apple/Sources/FerrostarSwiftUI/Views/Maneuver/ManeuverInstructionView.swift index 69c541cf..8389413e 100644 --- a/apple/Sources/FerrostarSwiftUI/Views/Maneuver/ManeuverInstructionView.swift +++ b/apple/Sources/FerrostarSwiftUI/Views/Maneuver/ManeuverInstructionView.swift @@ -94,6 +94,17 @@ public struct ManeuverInstructionView: View { .font(.body) .foregroundColor(.blue) + ManeuverInstructionView( + text: "Make a legal u-turn", + distanceFormatter: MKDistanceFormatter(), + distanceToNextManeuver: 152.4 + ) { + ManeuverImage(maneuverType: .turn, maneuverModifier: .uTurn) + .frame(width: 24) + } + .font(.body) + .foregroundColor(.blue) + // Demonstrate a Right to Left ManeuverInstructionView( text: "ادمج يسارًا", diff --git a/apple/Tests/FerrostarSwiftUITests/Views/ManeuverImageTests.swift b/apple/Tests/FerrostarSwiftUITests/Views/ManeuverImageTests.swift index b3dcbbbf..6efda9f6 100644 --- a/apple/Tests/FerrostarSwiftUITests/Views/ManeuverImageTests.swift +++ b/apple/Tests/FerrostarSwiftUITests/Views/ManeuverImageTests.swift @@ -14,6 +14,16 @@ final class ManeuverImageTests: XCTestCase { ManeuverImage(maneuverType: .fork, maneuverModifier: .left) .frame(width: 32) } + + assertView { + ManeuverImage(maneuverType: .turn, maneuverModifier: .uTurn) + .frame(width: 32) + } + + assertView { + ManeuverImage(maneuverType: .continue, maneuverModifier: .uTurn) + .frame(width: 32) + } } func testManeuverImageLarge() { diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/ManeuverImageTests/testManeuverImageDefaultTheme.3.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/ManeuverImageTests/testManeuverImageDefaultTheme.3.png new file mode 100644 index 00000000..af60e696 Binary files /dev/null and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/ManeuverImageTests/testManeuverImageDefaultTheme.3.png differ diff --git a/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/ManeuverImageTests/testManeuverImageDefaultTheme.4.png b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/ManeuverImageTests/testManeuverImageDefaultTheme.4.png new file mode 100644 index 00000000..a16ebace Binary files /dev/null and b/apple/Tests/FerrostarSwiftUITests/Views/__Snapshots__/ManeuverImageTests/testManeuverImageDefaultTheme.4.png differ diff --git a/common/build-ios.sh b/common/build-ios.sh index df135654..8c6d53dd 100755 --- a/common/build-ios.sh +++ b/common/build-ios.sh @@ -57,7 +57,7 @@ build_xcframework() { echo "Building xcframework archive" ditto -c -k --sequesterRsrc --keepParent target/ios/lib$1-rs.xcframework target/ios/lib$1-rs.xcframework.zip checksum=$(swift package compute-checksum target/ios/lib$1-rs.xcframework.zip) - version=$(cargo metadata --format-version 1 | jq -r '.packages[] | select(.name=="ferrostar") .version') + version=$(cargo metadata --format-version 1 | jq -r --arg pkg_name "$1" '.packages[] | select(.name==$pkg_name) .version') sed -i "" -E "s/(let releaseTag = \")[^\"]+(\")/\1$version\2/g" ../Package.swift sed -i "" -E "s/(let releaseChecksum = \")[^\"]+(\")/\1$checksum\2/g" ../Package.swift fi diff --git a/common/ferrostar/src/models.rs b/common/ferrostar/src/models.rs index 6380c4dc..694f6ff2 100644 --- a/common/ferrostar/src/models.rs +++ b/common/ferrostar/src/models.rs @@ -437,6 +437,7 @@ pub enum ManeuverType { #[cfg_attr(feature = "wasm-bindgen", tsify(into_wasm_abi, from_wasm_abi))] #[serde(rename_all = "lowercase")] pub enum ManeuverModifier { + #[serde(rename = "uturn")] UTurn, #[serde(rename = "sharp right")] SharpRight, diff --git a/common/ferrostar/src/routing_adapters/valhalla.rs b/common/ferrostar/src/routing_adapters/valhalla.rs index 4a9905ec..9e95131d 100644 --- a/common/ferrostar/src/routing_adapters/valhalla.rs +++ b/common/ferrostar/src/routing_adapters/valhalla.rs @@ -21,6 +21,19 @@ use alloc::{ /// Valhalla supports the [`WaypointKind`] field of [`Waypoint`]s. Variants have the same meaning as their /// [`type` strings in Valhalla API](https://valhalla.github.io/valhalla/api/turn-by-turn/api-reference/#locations) /// having the same name. +/// +/// ``` +/// use serde_json::{json, Map, Value}; +/// use ferrostar::routing_adapters::valhalla::ValhallaHttpRequestGenerator; +/// let options: Map = json!({ +/// "costing_options": { +/// "low_speed_vehicle": { +/// "vehicle_type": "golf_cart" +/// } +/// } +/// }).as_object().unwrap().to_owned();; +/// let request_generator = ValhallaHttpRequestGenerator::new("https://api.stadiamaps.com/route/v1?api_key=YOUR-API-KEY".to_string(), "low_speed_vehicle".to_string(), options); +/// ``` #[derive(Debug)] pub struct ValhallaHttpRequestGenerator { /// The full URL of the Valhalla endpoint to access. This will normally be the route endpoint, diff --git a/guide/src/location-providers.md b/guide/src/location-providers.md index f4730c81..12d02e3f 100644 --- a/guide/src/location-providers.md +++ b/guide/src/location-providers.md @@ -44,7 +44,21 @@ and unsubscribes itself (Android) or stops location updates automatically (iOS) Ferrostar includes the following live location providers: * iOS - - `CoreLocationProvider` - Location backed by a `CLLocationManager`. See the [iOS tutorial](./ios-getting-started.md) for a usage example. + - `CoreLocationProvider` - Location backed by a `CLLocationManager`. See the [iOS tutorial](./ios-getting-started.md#corelocationprovider) for a usage example. * Android - - [`AndroidSystemLocationProvider`] - Location backed by a android.location.LocationManger` (the class that is included in AOSP). See the [Android tutorial](./android-getting-started.md) for a usage example. - - TODO: Provider backed by the Google Fused Location Client + - [`AndroidSystemLocationProvider`] - Location backed by an `android.location.LocationManger` (the class that is included in AOSP). See the [Android tutorial](./android-getting-started.md#androidsystemlocationprovider) for a usage example. + - [`FusedLocationProvider`] - Location backed by a Google Play Services `FusedLocationClient`, which is proprietary but often provides better location updates. See the [Android tutorial](./android-getting-started.md#google-play-fused-location-client) for a usage example. + +## Implementation note: `StaticLocationEngine` + +If you dig around the FerrostarMapLibreUI modules, you may come across as `StaticLocationEngine`. + +The static location engine exists to bridge between Ferrostar location providers and MapLibre. +MapLibre uses `LocationEngine` objects, not platform-native location clients, as its first line. +This is smart, since it makes MapLibre generic enough to support location from other sources. +For Ferrostar, it enables us to account for things like snapping, simulated routes, etc. +The easiest way to hide all that complexity from `LocationProvider` implementors +is to introduce the `StaticLocationEngine` with a simple interface to set location. + +This is mostly transparent to developers using Ferrostar, +but in case you come across it, hopefully this note explains the purpose.