diff --git a/CHANGELOG.md b/CHANGELOG.md index b54e049f7c5..44d292aafa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Mapbox welcomes participation and contributions from everyone. ## Unreleased #### Features +- Added experimental routes preview state, see `MapboxNavigaton#setRoutesPreview`, `MapboxNavigaton#changeRoutesPreviewPrimaryRoute`, `MapboxNavigaton#registerRoutesPreviewObserver`, `MapboxNavigaton#getRoutesPreview`. [#6495](https://github.com/mapbox/mapbox-navigation-android/pull/6495) #### Bug fixes and improvements ## Mapbox Navigation SDK 2.10.0-alpha.2 - 04 November, 2022 diff --git a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RoutesPreviewTest.kt b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RoutesPreviewTest.kt new file mode 100644 index 00000000000..7d09dbdb089 --- /dev/null +++ b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RoutesPreviewTest.kt @@ -0,0 +1,196 @@ +package com.mapbox.navigation.instrumentation_tests.core + +import android.location.Location +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.options.NavigationOptions +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.base.trip.model.RouteProgressState +import com.mapbox.navigation.core.MapboxNavigation +import com.mapbox.navigation.core.MapboxNavigationProvider +import com.mapbox.navigation.core.directions.session.RoutesExtra +import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult +import com.mapbox.navigation.core.preview.RoutesPreviewExtra +import com.mapbox.navigation.core.preview.RoutesPreviewUpdate +import com.mapbox.navigation.instrumentation_tests.activity.EmptyTestActivity +import com.mapbox.navigation.instrumentation_tests.utils.MapboxNavigationRule +import com.mapbox.navigation.instrumentation_tests.utils.coroutines.routeProgressUpdates +import com.mapbox.navigation.instrumentation_tests.utils.coroutines.routesPreviewUpdates +import com.mapbox.navigation.instrumentation_tests.utils.coroutines.routesUpdates +import com.mapbox.navigation.instrumentation_tests.utils.coroutines.sdkTest +import com.mapbox.navigation.instrumentation_tests.utils.routes.RoutesProvider +import com.mapbox.navigation.instrumentation_tests.utils.routes.RoutesProvider.toNavigationRoutes +import com.mapbox.navigation.testing.ui.BaseTest +import com.mapbox.navigation.testing.ui.utils.getMapboxAccessTokenFromResources +import com.mapbox.navigation.testing.ui.utils.runOnMainSync +import kotlinx.coroutines.flow.first +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +class RoutesPreviewTest : BaseTest(EmptyTestActivity::class.java) { + + override fun setupMockLocation(): Location = mockLocationUpdatesRule.generateLocationUpdate { + latitude = 38.894721 + longitude = -77.031991 + } + + @get:Rule + val mapboxNavigationRule = MapboxNavigationRule() + private lateinit var mapboxNavigation: MapboxNavigation + + @Before + fun setUp() { + runOnMainSync { + mapboxNavigation = MapboxNavigationProvider.create( + NavigationOptions.Builder(activity) + .accessToken(getMapboxAccessTokenFromResources(activity)) + .build() + ) + } + } + + @Test + fun transitions_free_drive_to_preview_to_active_guidance_to_free_drive() = sdkTest { + var currentRoutesPreview: RoutesPreviewUpdate? = null + mapboxNavigation.registerRoutesPreviewObserver { update -> + currentRoutesPreview = update + } + var currentRoutes: RoutesUpdatedResult? = null + mapboxNavigation.registerRoutesObserver { update -> + currentRoutes = update + } + // initial free drive + mapboxNavigation.startTripSession() + assertNull(currentRoutesPreview) + assertNull(currentRoutes) + // set routes preview + val routes = RoutesProvider.dc_very_short(activity).toNavigationRoutes() + mapboxNavigation.setRoutesPreview(routes) + mapboxNavigation.routesPreviewUpdates() + .first { it.reason == RoutesPreviewExtra.PREVIEW_NEW } + assertEquals(RoutesPreviewExtra.PREVIEW_NEW, currentRoutesPreview?.reason) + assertEquals(routes, currentRoutesPreview!!.routesPreview!!.routesList) + assertNull(currentRoutes) + // start active guidance + mapboxNavigation.setNavigationRoutes(currentRoutesPreview!!.routesPreview!!.routesList) + mapboxNavigation.setRoutesPreview(emptyList()) + mapboxNavigation.routesUpdates() + .first { it.reason == RoutesExtra.ROUTES_UPDATE_REASON_NEW } + mapboxNavigation.routeProgressUpdates() + .first { it.currentState == RouteProgressState.TRACKING } + mapboxNavigation.routesPreviewUpdates() + .first { it.reason == RoutesPreviewExtra.PREVIEW_CLEAN_UP } + assertEquals(RoutesPreviewExtra.PREVIEW_CLEAN_UP, currentRoutesPreview?.reason) + assertNull(currentRoutesPreview!!.routesPreview) + assertEquals(RoutesExtra.ROUTES_UPDATE_REASON_NEW, currentRoutes!!.reason) + assertEquals(routes, currentRoutes!!.navigationRoutes) + // back to free drive + mapboxNavigation.setNavigationRoutes(emptyList()) + mapboxNavigation.routesUpdates() + .first { it.reason == RoutesExtra.ROUTES_UPDATE_REASON_CLEAN_UP } + assertEquals(RoutesPreviewExtra.PREVIEW_CLEAN_UP, currentRoutesPreview?.reason) + assertNull(currentRoutesPreview!!.routesPreview) + assertEquals(RoutesExtra.ROUTES_UPDATE_REASON_CLEAN_UP, currentRoutes!!.reason) + assertEquals(emptyList(), currentRoutes!!.navigationRoutes) + } + + @Test + fun route_preview_in_parallel_to_active_guidance() = sdkTest { + var currentRoutesPreview: RoutesPreviewUpdate? = null + mapboxNavigation.registerRoutesPreviewObserver { update -> + currentRoutesPreview = update + } + var currentRoutes: RoutesUpdatedResult? = null + mapboxNavigation.registerRoutesObserver { update -> + currentRoutes = update + } + // initial free drive + mapboxNavigation.startTripSession() + assertNull(currentRoutesPreview) + assertNull(currentRoutes) + // set routes preview + val initialRoutes = RoutesProvider.dc_very_short(activity).toNavigationRoutes() + mapboxNavigation.setRoutesPreview(initialRoutes) + mapboxNavigation.routesPreviewUpdates() + .first { it.reason == RoutesPreviewExtra.PREVIEW_NEW } + // start active guidance + mapboxNavigation.setNavigationRoutes(currentRoutesPreview!!.routesPreview!!.routesList) + mapboxNavigation.setRoutesPreview(emptyList()) + mapboxNavigation.routeProgressUpdates() + .first { it.currentState == RouteProgressState.TRACKING } + // preview a different route not leaving action guidance + val updatedRoutes = RoutesProvider.dc_very_short_two_legs(activity).toNavigationRoutes() + mapboxNavigation.setRoutesPreview(updatedRoutes) + mapboxNavigation.routesPreviewUpdates() + .first { it.routesPreview?.routesList == updatedRoutes } + assertEquals( + "active guidance should track initial routes", + initialRoutes, + currentRoutes?.navigationRoutes + ) + // user decided to switch to previewed routes + mapboxNavigation.setNavigationRoutes(currentRoutesPreview!!.routesPreview!!.routesList) + mapboxNavigation.setRoutesPreview(emptyList()) + mapboxNavigation.routeProgressUpdates().first { + it.navigationRoute == updatedRoutes[0] + } + } + + @Test + fun start_active_guidance_from_previewed_alternative_route() = sdkTest { + // set routes preview + val routes = RoutesProvider.dc_short_with_alternative(activity).toNavigationRoutes() + mapboxNavigation.setRoutesPreview(routes) + val preview = mapboxNavigation.routesPreviewUpdates() + .first { it.reason == RoutesPreviewExtra.PREVIEW_NEW } + // switch to alternative route + mapboxNavigation.changeRoutesPreviewPrimaryRoute( + preview.routesPreview!!.originalRoutesList[1] + ) + val updatedPreview = mapboxNavigation.routesPreviewUpdates() + .first { it != preview } + // start active guidance + mapboxNavigation.setNavigationRoutes(updatedPreview.routesPreview!!.routesList) + mapboxNavigation.setRoutesPreview(emptyList()) + val routesUpdate = mapboxNavigation.routesUpdates() + .first { it.reason == RoutesExtra.ROUTES_UPDATE_REASON_NEW } + assertEquals( + listOf( + routes[1], + routes[0] + ), + routesUpdate.navigationRoutes + ) + val previewAlternativeMetadata = updatedPreview.routesPreview!!.alternativesMetadata.first() + val activeGuidanceAlternativeMetadata = mapboxNavigation + .getAlternativeMetadataFor(routes[0])!! + assertEquals( + 0, + previewAlternativeMetadata.alternativeId + ) + assertNotEquals( + previewAlternativeMetadata.alternativeId, + activeGuidanceAlternativeMetadata.alternativeId + ) + assertEquals( + previewAlternativeMetadata.infoFromStartOfPrimary, + activeGuidanceAlternativeMetadata.infoFromStartOfPrimary + ) + assertEquals( + previewAlternativeMetadata.forkIntersectionOfAlternativeRoute, + activeGuidanceAlternativeMetadata.forkIntersectionOfAlternativeRoute + ) + assertEquals( + previewAlternativeMetadata.infoFromFork, + activeGuidanceAlternativeMetadata.infoFromFork + ) + assertEquals( + previewAlternativeMetadata.forkIntersectionOfPrimaryRoute, + activeGuidanceAlternativeMetadata.forkIntersectionOfPrimaryRoute + ) + } +} diff --git a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/coroutines/Adapters.kt b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/coroutines/Adapters.kt index c60800b143d..cee8b4d27ff 100644 --- a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/coroutines/Adapters.kt +++ b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/coroutines/Adapters.kt @@ -6,6 +6,7 @@ import com.mapbox.api.directions.v5.models.BannerInstructions import com.mapbox.api.directions.v5.models.RouteOptions import com.mapbox.api.directions.v5.models.VoiceInstructions import com.mapbox.bindgen.Expected +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.base.route.NavigationRoute import com.mapbox.navigation.base.route.NavigationRouterCallback import com.mapbox.navigation.base.route.RouterFailure @@ -18,6 +19,8 @@ import com.mapbox.navigation.core.RoutesSetError import com.mapbox.navigation.core.RoutesSetSuccess import com.mapbox.navigation.core.directions.session.RoutesObserver import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult +import com.mapbox.navigation.core.preview.RoutesPreviewObserver +import com.mapbox.navigation.core.preview.RoutesPreviewUpdate import com.mapbox.navigation.core.trip.session.BannerInstructionsObserver import com.mapbox.navigation.core.trip.session.RoadObjectsOnRouteObserver import com.mapbox.navigation.core.trip.session.RouteProgressObserver @@ -43,6 +46,20 @@ fun MapboxNavigation.routesUpdates(): Flow { } } +@ExperimentalPreviewMapboxNavigationAPI +fun MapboxNavigation.routesPreviewUpdates(): Flow { + val navigation = this + return callbackFlow { + val observer = RoutesPreviewObserver { + trySend(it) + } + navigation.registerRoutesPreviewObserver(observer) + awaitClose { + navigation.unregisterRoutesPreviewObserver(observer) + } + } +} + fun MapboxNavigation.routeProgressUpdates(): Flow { val navigation = this return callbackFlow { diff --git a/libnavigation-core/api/current.txt b/libnavigation-core/api/current.txt index b540b76eaea..74d3d1dddb2 100644 --- a/libnavigation-core/api/current.txt +++ b/libnavigation-core/api/current.txt @@ -13,6 +13,7 @@ package com.mapbox.navigation.core { @UiThread public final class MapboxNavigation { ctor public MapboxNavigation(com.mapbox.navigation.base.options.NavigationOptions navigationOptions); method public void cancelRouteRequest(long requestId); + method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI @kotlin.jvm.Throws(exceptionClasses=IllegalArgumentException::class) public void changeRoutesPreviewPrimaryRoute(com.mapbox.navigation.base.route.NavigationRoute newPrimaryRoute) throws java.lang.IllegalArgumentException; method public com.mapbox.navigation.core.routealternatives.AlternativeRouteMetadata? getAlternativeMetadataFor(com.mapbox.navigation.base.route.NavigationRoute navigationRoute); method public java.util.List getAlternativeMetadataFor(java.util.List navigationRoutes); method public com.mapbox.navigator.Experimental getExperimental(); @@ -26,6 +27,7 @@ package com.mapbox.navigation.core { method public com.mapbox.navigation.core.trip.session.eh.RoadObjectMatcher getRoadObjectMatcher(); method public com.mapbox.navigation.core.trip.session.eh.RoadObjectsStore getRoadObjectsStore(); method @Deprecated public java.util.List getRoutes(); + method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public com.mapbox.navigation.core.preview.RoutesPreview? getRoutesPreview(); method public com.mapbox.navigation.core.navigator.TilesetDescriptorFactory getTilesetDescriptorFactory(); method public com.mapbox.navigation.core.trip.session.TripSessionState getTripSessionState(); method public Integer? getZLevel(); @@ -53,6 +55,7 @@ package com.mapbox.navigation.core { method public void registerRouteProgressObserver(com.mapbox.navigation.core.trip.session.RouteProgressObserver routeProgressObserver); method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void registerRouteRefreshStateObserver(com.mapbox.navigation.core.routerefresh.RouteRefreshStatesObserver routeRefreshStatesObserver); method public void registerRoutesObserver(com.mapbox.navigation.core.directions.session.RoutesObserver routesObserver); + method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void registerRoutesPreviewObserver(com.mapbox.navigation.core.preview.RoutesPreviewObserver observer); method public void registerTripSessionStateObserver(com.mapbox.navigation.core.trip.session.TripSessionStateObserver tripSessionStateObserver); method public void registerVoiceInstructionsObserver(com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver voiceInstructionsObserver); method public void requestAlternativeRoutes(); @@ -73,6 +76,8 @@ package com.mapbox.navigation.core { method public void setRerouteOptionsAdapter(com.mapbox.navigation.core.reroute.RerouteOptionsAdapter? rerouteOptionsAdapter); method @Deprecated public void setRoutes(java.util.List routes, int initialLegIndex = 0); method @Deprecated public void setRoutes(java.util.List routes); + method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void setRoutesPreview(java.util.List routes, int primaryRouteIndex = 0); + method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void setRoutesPreview(java.util.List routes); method public void setTripNotificationInterceptor(com.mapbox.navigation.base.trip.notification.TripNotificationInterceptor? interceptor); method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void startReplayTripSession(boolean withForegroundService = true); method @RequiresPermission(anyOf={android.Manifest.permission.ACCESS_COARSE_LOCATION, android.Manifest.permission.ACCESS_FINE_LOCATION}) public void startTripSession(boolean withForegroundService = true); @@ -92,6 +97,7 @@ package com.mapbox.navigation.core { method public void unregisterRouteProgressObserver(com.mapbox.navigation.core.trip.session.RouteProgressObserver routeProgressObserver); method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void unregisterRouteRefreshStateObserver(com.mapbox.navigation.core.routerefresh.RouteRefreshStatesObserver routeRefreshStatesObserver); method public void unregisterRoutesObserver(com.mapbox.navigation.core.directions.session.RoutesObserver routesObserver); + method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void unregisterRoutesPreviewObserver(com.mapbox.navigation.core.preview.RoutesPreviewObserver observer); method public void unregisterTripSessionStateObserver(com.mapbox.navigation.core.trip.session.TripSessionStateObserver tripSessionStateObserver); method public void unregisterVoiceInstructionsObserver(com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver voiceInstructionsObserver); property public final com.mapbox.navigator.Experimental experimental; @@ -384,6 +390,43 @@ package com.mapbox.navigation.core.navigator { } +package com.mapbox.navigation.core.preview { + + @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class RoutesPreview { + method public java.util.List getAlternativesMetadata(); + method public java.util.List getOriginalRoutesList(); + method public com.mapbox.navigation.base.route.NavigationRoute getPrimaryRoute(); + method public int getPrimaryRouteIndex(); + method public java.util.List getRoutesList(); + property public final java.util.List alternativesMetadata; + property public final java.util.List originalRoutesList; + property public final com.mapbox.navigation.base.route.NavigationRoute primaryRoute; + property public final int primaryRouteIndex; + property public final java.util.List routesList; + } + + public final class RoutesPreviewExtra { + field public static final com.mapbox.navigation.core.preview.RoutesPreviewExtra INSTANCE; + field public static final String PREVIEW_CLEAN_UP = "PREVIEW_CLEAN_UP"; + field public static final String PREVIEW_NEW = "PREVIEW_NEW"; + } + + @StringDef({com.mapbox.navigation.core.preview.RoutesPreviewExtra.PREVIEW_NEW, com.mapbox.navigation.core.preview.RoutesPreviewExtra.PREVIEW_CLEAN_UP}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public static @interface RoutesPreviewExtra.RoutePreviewUpdateReason { + } + + @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public fun interface RoutesPreviewObserver { + method public void routesPreviewUpdated(com.mapbox.navigation.core.preview.RoutesPreviewUpdate update); + } + + @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class RoutesPreviewUpdate { + method public String getReason(); + method public com.mapbox.navigation.core.preview.RoutesPreview? getRoutesPreview(); + property public final String reason; + property public final com.mapbox.navigation.core.preview.RoutesPreview? routesPreview; + } + +} + package com.mapbox.navigation.core.replay { @UiThread public final class MapboxReplayer { diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/MapboxNavigation.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/MapboxNavigation.kt index 61cfa18a61e..fe719863bc6 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/MapboxNavigation.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/MapboxNavigation.kt @@ -64,6 +64,8 @@ import com.mapbox.navigation.core.internal.utils.paramsProvider import com.mapbox.navigation.core.lifecycle.MapboxNavigationApp import com.mapbox.navigation.core.navigator.CacheHandleWrapper import com.mapbox.navigation.core.navigator.TilesetDescriptorFactory +import com.mapbox.navigation.core.preview.RoutesPreview +import com.mapbox.navigation.core.preview.RoutesPreviewObserver import com.mapbox.navigation.core.replay.MapboxReplayer import com.mapbox.navigation.core.reroute.LegacyRerouteControllerAdapter import com.mapbox.navigation.core.reroute.MapboxRerouteController @@ -275,6 +277,10 @@ class MapboxNavigation @VisibleForTesting internal constructor( ), ) + private val routesPreviewController = NavigationComponentProvider.createRoutesPreviewController( + threadController.getMainScopeAndRootJob().scope + ) + private val routeUpdateMutex = Mutex() // native Router Interface @@ -847,6 +853,75 @@ class MapboxNavigation @VisibleForTesting internal constructor( ) } + /*** + * Sets routes to preview. + * Triggers an update in [RoutesPreviewObserver] and changes [MapboxNavigation.getRoutesPreview]. + * Preview state is updated asynchronously as it requires the SDK to process routes and compute alternative metadata. Subscribe for updates using [MapboxNavigation.registerRoutesPreviewObserver] to receive new routes preview state when the processing will be completed. + * + * If [routes] isn't empty, the route with [primaryRouteIndex] is considered as primary, the others as alternatives. + * To cleanup routes preview state pass an empty list as [routes]. + * + * Use [RoutesPreview.routesList] to start Active Guidance after route's preview: + * ``` + * mapboxNavigation.getRoutesPreview()?.routesList?.let{ routesList -> + * mapboxNavigation.setNavigationRoutes(routesList) + * } + * ``` + * Routes preview state is controlled by the SDK's user. If you want to stop routes preview when you start active guidance, do it manually: + * ``` + * mapboxNavigation.setRoutesPreview(emptyList()) + * ``` + * + * @param routes to preview + * @param primaryRouteIndex index of primary route from [routes] + */ + @ExperimentalPreviewMapboxNavigationAPI + @JvmOverloads + fun setRoutesPreview(routes: List, primaryRouteIndex: Int = 0) { + routesPreviewController.previewNavigationRoutes(routes, primaryRouteIndex) + } + + /*** + * Changes primary route for current preview state without changing order of [RoutesPreview.originalRoutesList]. + * Order is important for a case when routes are displayed as a list on UI, the list shouldn't change order when a user choose different primary route. + * + * In case [changeRoutesPreviewPrimaryRoute] is called while the the other set of routes are being processed, the current state with a new routes will be reapplied after the current processing. + * + * @param newPrimaryRoute is a new primary route + * @throws [IllegalArgumentException] if [newPrimaryRoute] isn't found in the previewed routes list + */ + @ExperimentalPreviewMapboxNavigationAPI + @Throws(IllegalArgumentException::class) + fun changeRoutesPreviewPrimaryRoute(newPrimaryRoute: NavigationRoute) { + routesPreviewController.changeRoutesPreviewPrimaryRoute(newPrimaryRoute) + } + + /*** + * Registers [RoutesPreviewObserver] to be notified when routes preview state changes. + * [observer] is immediately called with current preview state + * + * @param observer to be called on routes preview state changes + */ + @ExperimentalPreviewMapboxNavigationAPI + fun registerRoutesPreviewObserver(observer: RoutesPreviewObserver) { + routesPreviewController.registerRoutesPreviewObserver(observer) + } + + /*** + * Unregisters observer which were registered using [registerRoutesPreviewObserver] + * @param observer which stops receiving updates when routes preview changes + */ + @ExperimentalPreviewMapboxNavigationAPI + fun unregisterRoutesPreviewObserver(observer: RoutesPreviewObserver) { + routesPreviewController.unregisterRoutesPreviewObserver(observer) + } + + /*** + * Returns current state of routes preview + */ + @ExperimentalPreviewMapboxNavigationAPI + fun getRoutesPreview(): RoutesPreview? = routesPreviewController.getRoutesPreview() + /** * Requests road graph data update and invokes the callback on result. * Use this method if the frequency of application relaunch is too low @@ -1065,6 +1140,7 @@ class MapboxNavigation @VisibleForTesting internal constructor( reachabilityObserverId = null } routeRefreshController.unregisterAllRouteRefreshStateObservers() + routesPreviewController.unregisterAllRoutesPreviewObservers() isDestroyed = true hasInstance = false diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/NavigationComponentProvider.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/NavigationComponentProvider.kt index cba4fe28621..d3676d1f02a 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/NavigationComponentProvider.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/NavigationComponentProvider.kt @@ -8,6 +8,8 @@ import com.mapbox.navigation.core.accounts.BillingController import com.mapbox.navigation.core.arrival.ArrivalProgressObserver import com.mapbox.navigation.core.directions.session.DirectionsSession import com.mapbox.navigation.core.directions.session.MapboxDirectionsSession +import com.mapbox.navigation.core.preview.NativeRoutesDataParser +import com.mapbox.navigation.core.preview.RoutesPreviewController import com.mapbox.navigation.core.routerefresh.EVDataHolder import com.mapbox.navigation.core.trip.service.MapboxTripService import com.mapbox.navigation.core.trip.service.TripService @@ -23,6 +25,7 @@ import com.mapbox.navigator.ConfigHandle import com.mapbox.navigator.HistoryRecorderHandle import com.mapbox.navigator.RouterInterface import com.mapbox.navigator.TilesConfig +import kotlinx.coroutines.CoroutineScope internal object NavigationComponentProvider { @@ -100,6 +103,13 @@ internal object NavigationComponentProvider { historyRecordingStateHandler.registerCopilotSessionObserver(it) } + fun createRoutesPreviewController( + scope: CoroutineScope + ) = RoutesPreviewController( + routesDataParser = NativeRoutesDataParser(), + scope = scope + ) + fun createRouteRefreshRequestDataProvider(): RouteProgressDataProvider = RouteProgressDataProvider() diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/NativeRoutesDataParser.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/NativeRoutesDataParser.kt new file mode 100644 index 00000000000..24fda8ef9d8 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/NativeRoutesDataParser.kt @@ -0,0 +1,18 @@ +package com.mapbox.navigation.core.preview + +import com.mapbox.navigation.base.internal.route.nativeRoute +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.utils.internal.ThreadController +import com.mapbox.navigator.RouteParser +import com.mapbox.navigator.RoutesData +import kotlinx.coroutines.withContext + +internal class NativeRoutesDataParser : RoutesDataParser { + override suspend fun parse(routes: List): RoutesData = + withContext(ThreadController.DefaultDispatcher) { + RouteParser.createRoutesData( + routes.first().nativeRoute(), + routes.drop(1).map { it.nativeRoute() } + ) + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreview.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreview.kt new file mode 100644 index 00000000000..4c14dec7209 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreview.kt @@ -0,0 +1,74 @@ +package com.mapbox.navigation.core.preview + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.core.MapboxNavigation +import com.mapbox.navigation.core.routealternatives.AlternativeRouteMetadata + +/*** + * @param routesList - previewed routes ordered in way specific for mapbox navigation: [primary, alternative1, alternative2]. + * @param alternativesMetadata - alternative metadata for valid alternatives from [routesList]. [AlternativeRouteMetadata.alternativeId] is always 0 in preview. + * @param originalRoutesList - original routes list which doesn't change order no matter which primary route is selected. + * @param primaryRouteIndex - index of primary route from the [originalRoutesList]. + * + * Use [routesList] when you want to pass routes to other Navigation SDK components, + * for example start active guidance calling [MapboxNavigation.setNavigationRoutes] or + * display a route using route line API. The majority of the Navigation SDK's API accepts routes in this format. + * + * Use [originalRoutesList] and [primaryRouteIndex] if you want to display routes as a list on UI. + * In this case routes' order shouldn't change on UI when users pick different routes as primary. + * [MapboxNavigation.changeRoutesPreviewPrimaryRoute] is designed to select a new primary route without + * changing the order of the [originalRoutesList]. + */ +@ExperimentalPreviewMapboxNavigationAPI +class RoutesPreview internal constructor( + val routesList: List, + val alternativesMetadata: List, + val originalRoutesList: List, + val primaryRouteIndex: Int, +) { + /*** + * Primary route used for preview + */ + val primaryRoute = originalRoutesList[primaryRouteIndex] + + /** + * Regenerate whenever a change is made + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RoutesPreview + + if (routesList != other.routesList) return false + if (alternativesMetadata != other.alternativesMetadata) return false + if (originalRoutesList != other.originalRoutesList) return false + if (primaryRouteIndex != other.primaryRouteIndex) return false + + return true + } + + /** + * Regenerate whenever a change is made + */ + override fun hashCode(): Int { + var result = routesList.hashCode() + result = 31 * result + alternativesMetadata.hashCode() + result = 31 * result + originalRoutesList.hashCode() + result = 31 * result + primaryRouteIndex.hashCode() + return result + } + + /** + * Regenerate whenever a change is made + */ + override fun toString(): String { + return "RoutesPreview(" + + "routesList=$routesList, " + + "alternativesMetadata=$alternativesMetadata, " + + "originalRoutesList=$originalRoutesList, " + + "primaryRouteIndex=$primaryRouteIndex" + + ")" + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreviewController.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreviewController.kt new file mode 100644 index 00000000000..eeec261eaf1 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreviewController.kt @@ -0,0 +1,148 @@ +package com.mapbox.navigation.core.preview + +import androidx.annotation.UiThread +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.core.routealternatives.mapToMetadata +import com.mapbox.navigator.RoutesData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.CopyOnWriteArrayList + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +@UiThread +internal class RoutesPreviewController( + private val routesDataParser: RoutesDataParser, + private val scope: CoroutineScope +) { + + private val mutex = Mutex() + + private var lastUpdate: RoutesPreviewUpdate? = null + set(value) { + if (value != field) { + field = value + if (value != null) { + observers.forEach { + it.routesPreviewUpdated(value) + } + } + } + } + + private val observers = CopyOnWriteArrayList() + + fun registerRoutesPreviewObserver(observer: RoutesPreviewObserver) { + observers.add(observer) + lastUpdate?.let { observer.routesPreviewUpdated(it) } + } + + fun unregisterRoutesPreviewObserver(observer: RoutesPreviewObserver) { + observers.remove(observer) + } + + fun unregisterAllRoutesPreviewObservers() { + observers.clear() + } + + fun getRoutesPreview(): RoutesPreview? = lastUpdate?.routesPreview + + fun previewNavigationRoutes( + routesToPreview: List, + primaryRouteIndex: Int = 0 + ) { + scope.launch { + mutex.withLock { + previewRoutesInternal(routesToPreview, primaryRouteIndex) + } + } + } + + @Throws(IllegalArgumentException::class) + fun changeRoutesPreviewPrimaryRoute(route: NavigationRoute) { + val originalRoutes = lastUpdate?.routesPreview?.originalRoutesList + require(originalRoutes != null) { + "no previewed routes are set" + } + val routes = originalRoutes.toMutableList() + require(routes.remove(route)) { + "route ${route.id} isn't found among the list of previewed routes" + } + routes.add(0, route) + + scope.launch { + mutex.withLock { + val routesData = routesDataParser.parse(routes) + val preview = createRoutesPreview(routes, routesData, originalRoutes) + setNewRoutesPreview(preview) + } + } + } + + private fun setNewRoutesPreview(preview: RoutesPreview) { + lastUpdate = RoutesPreviewUpdate( + reason = RoutesPreviewExtra.PREVIEW_NEW, + routesPreview = preview + ) + } + + private suspend fun previewRoutesInternal( + routesToPreview: List, + primaryRouteIndex: Int + ) { + if (routesToPreview.isEmpty()) { + lastUpdate = RoutesPreviewUpdate( + RoutesPreviewExtra.PREVIEW_CLEAN_UP, + null + ) + return + } + val previewedRoutes = movePrimaryRouteToTheBeginning(primaryRouteIndex, routesToPreview) + val preview = + createRoutesPreview( + previewedRoutes, + routesDataParser.parse(previewedRoutes), + routesToPreview + ) + setNewRoutesPreview(preview) + } + + private fun movePrimaryRouteToTheBeginning( + primaryRouteIndex: Int, + routesToPreview: List + ): List { + val previewedRoutes = if (primaryRouteIndex == 0) { + routesToPreview + } else { + routesToPreview.toMutableList().apply { + val primaryRoute = removeAt(primaryRouteIndex) + add(0, primaryRoute) + } + } + return previewedRoutes + } + + private fun createRoutesPreview( + routes: List, + routesData: RoutesData, + originalRoutes: List + ): RoutesPreview { + val preview = RoutesPreview( + alternativesMetadata = routesData.alternativeRoutes().map { nativeAlternative -> + val alternative = + originalRoutes.first { it.id == nativeAlternative.route.routeId } + nativeAlternative.mapToMetadata(alternative) + }, + routesList = routes, + originalRoutesList = originalRoutes, + primaryRouteIndex = originalRoutes.indexOf(routes.first()) + ) + return preview + } +} + +internal fun interface RoutesDataParser { + suspend fun parse(routes: List): RoutesData +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreviewExtra.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreviewExtra.kt new file mode 100644 index 00000000000..dad6aaa158d --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreviewExtra.kt @@ -0,0 +1,34 @@ +package com.mapbox.navigation.core.preview + +import androidx.annotation.StringDef + +/** + * All reasons which can cause routes preview update. + * @see [RoutesPreviewObserver] + */ +object RoutesPreviewExtra { + /** + * Routes for preview were set by user. + * [RoutesPreviewUpdate.routesPreview] always has value for this reason. + * @see [RoutesPreviewObserver] + */ + const val PREVIEW_NEW = "PREVIEW_NEW" + + /*** + * Routes preview were cleanup by user. + * [RoutesPreviewUpdate.routesPreview] is always null for this reason. + * @see [RoutesPreviewObserver] + */ + const val PREVIEW_CLEAN_UP = "PREVIEW_CLEAN_UP" + + /** + * Reason of Routes Preview update. + * See [RoutesPreviewObserver] + */ + @Retention(AnnotationRetention.BINARY) + @StringDef( + PREVIEW_NEW, + PREVIEW_CLEAN_UP + ) + annotation class RoutePreviewUpdateReason +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreviewObserver.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreviewObserver.kt new file mode 100644 index 00000000000..a2b20468400 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/preview/RoutesPreviewObserver.kt @@ -0,0 +1,64 @@ +package com.mapbox.navigation.core.preview + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.core.MapboxNavigation + +/** + * Interface definition for an observer that gets notified whenever route preview state changes. + */ +@ExperimentalPreviewMapboxNavigationAPI +fun interface RoutesPreviewObserver { + /*** + * Called when route preview state changes. Emits current state on subscription. + * + * Register the observer using [MapboxNavigation.registerRoutesPreviewObserver]. + */ + fun routesPreviewUpdated(update: RoutesPreviewUpdate) +} + +/** + * Routes preview update is provided via [RoutesPreviewObserver] whenever route + * preview changes state. + * + * @param reason why route preview has been updated + * @param routesPreview current state of route preview, null if routes preview isn't set + */ +@ExperimentalPreviewMapboxNavigationAPI +class RoutesPreviewUpdate internal constructor( + @RoutesPreviewExtra.RoutePreviewUpdateReason val reason: String, + val routesPreview: RoutesPreview? +) { + + /** + * Regenerate whenever a change is made + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RoutesPreviewUpdate + if (reason != other.reason) return false + if (routesPreview != other.routesPreview) return false + + return true + } + + /** + * Regenerate whenever a change is made + */ + override fun hashCode(): Int { + var result = reason.hashCode() + result = 31 * result + routesPreview.hashCode() + return result + } + + /** + * Regenerate whenever a change is made + */ + override fun toString(): String { + return "RoutesPreviewUpdate(" + + "reason='$reason', " + + "routesPreview=$routesPreview" + + ")" + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routealternatives/RouteAlternativesController.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routealternatives/RouteAlternativesController.kt index 87f3c40123e..63380bf9f11 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routealternatives/RouteAlternativesController.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routealternatives/RouteAlternativesController.kt @@ -240,7 +240,7 @@ internal class RouteAlternativesController constructor( } } -private fun RouteAlternative.mapToMetadata( +internal fun RouteAlternative.mapToMetadata( navigationRoute: NavigationRoute ): AlternativeRouteMetadata { return AlternativeRouteMetadata( diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationBaseTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationBaseTest.kt index aa010a31575..d46391cedfb 100644 --- a/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationBaseTest.kt +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationBaseTest.kt @@ -20,6 +20,7 @@ import com.mapbox.navigation.core.accounts.BillingController import com.mapbox.navigation.core.arrival.ArrivalProgressObserver import com.mapbox.navigation.core.directions.session.DirectionsSession import com.mapbox.navigation.core.navigator.CacheHandleWrapper +import com.mapbox.navigation.core.preview.RoutesPreviewController import com.mapbox.navigation.core.reroute.RerouteController import com.mapbox.navigation.core.reroute.RerouteState import com.mapbox.navigation.core.routealternatives.RouteAlternativesController @@ -100,6 +101,7 @@ internal open class MapboxNavigationBaseTest { val developerMetadataAggregator: DeveloperMetadataAggregator = mockk(relaxUnitFun = true) val threadController = mockk(relaxed = true) val routeProgressDataProvider = mockk(relaxed = true) + val routesPreviewController = mockk(relaxed = true) val applicationContext: Context = mockk(relaxed = true) { every { inferDeviceLocale() } returns Locale.US @@ -206,6 +208,9 @@ internal open class MapboxNavigationBaseTest { every { NavigationComponentProvider.createRouteRefreshRequestDataProvider() } returns routeProgressDataProvider + every { + NavigationComponentProvider.createRoutesPreviewController(any()) + } returns routesPreviewController every { navigator.create( diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationTest.kt index 75f0702570c..23a1ed4e8dd 100644 --- a/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationTest.kt +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationTest.kt @@ -156,6 +156,14 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { verify(exactly = 1) { tripSession.unregisterAllOffRouteObservers() } } + @Test + fun destroy_unregisterAllRoutesPreviewObservers() { + createMapboxNavigation() + mapboxNavigation.onDestroy() + + verify(exactly = 1) { routesPreviewController.unregisterAllRoutesPreviewObservers() } + } + @Test fun init_registerOffRouteObserver_MapboxNavigation_recreated() { createMapboxNavigation() diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/preview/RouteDataParserStub.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/preview/RouteDataParserStub.kt new file mode 100644 index 00000000000..df89ac36979 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/preview/RouteDataParserStub.kt @@ -0,0 +1,28 @@ +package com.mapbox.navigation.core.preview + +import com.mapbox.navigation.base.internal.route.nativeRoute +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigator.RouteAlternative +import com.mapbox.navigator.RoutesData +import io.mockk.every +import io.mockk.mockk + +internal class RouteDataParserStub : RoutesDataParser { + override suspend fun parse(routes: List): RoutesData { + return object : RoutesData { + + private var alternativeIdCounter = 1 + + override fun primaryRoute() = routes.first().nativeRoute() + + override fun alternativeRoutes(): MutableList = + routes.drop(1).map { navigationRoute -> + val nextId = alternativeIdCounter++ + mockk(relaxed = true) { + every { id } returns nextId + every { route } returns navigationRoute.nativeRoute() + } + }.toMutableList() + } + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/preview/RoutePreviewControllerFactory.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/preview/RoutePreviewControllerFactory.kt new file mode 100644 index 00000000000..7b4161d2cef --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/preview/RoutePreviewControllerFactory.kt @@ -0,0 +1,18 @@ +package com.mapbox.navigation.core.preview + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope + +@ExperimentalPreviewMapboxNavigationAPI +internal fun createRoutePreviewController( + parentScope: CoroutineScope = TestCoroutineScope(SupervisorJob() + TestCoroutineDispatcher()), + routesDataParser: RoutesDataParser = RouteDataParserStub() +): RoutesPreviewController { + return RoutesPreviewController( + routesDataParser = routesDataParser, + scope = parentScope + ) +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/preview/RoutesPreviewControllerTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/preview/RoutesPreviewControllerTest.kt new file mode 100644 index 00000000000..4bd906f7b13 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/preview/RoutesPreviewControllerTest.kt @@ -0,0 +1,456 @@ +package com.mapbox.navigation.core.preview + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.internal.route.nativeRoute +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.testing.factories.createDirectionsResponse +import com.mapbox.navigation.testing.factories.createDirectionsRoute +import com.mapbox.navigation.testing.factories.createNavigationRoutes +import com.mapbox.navigator.RouteAlternative +import com.mapbox.navigator.RouteInterface +import com.mapbox.navigator.RoutesData +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +class RoutesPreviewControllerTest { + + @Test + fun `default state`() { + val routesPreviewController = createRoutePreviewController() + + var previewUpdate: RoutesPreviewUpdate? = null + routesPreviewController.registerRoutesPreviewObserver { + previewUpdate = it + } + val preview = routesPreviewController.getRoutesPreview() + + assertNull(previewUpdate) + assertNull(preview) + } + + @Test + fun `set previewed routes`() { + val routesPreviewController = createRoutePreviewController() + var previewUpdate: RoutesPreviewUpdate? = null + routesPreviewController.registerRoutesPreviewObserver { + previewUpdate = it + } + + val testRoutes = createNavigationRoutes( + response = createDirectionsResponse( + uuid = "test", + routes = listOf( + createDirectionsRoute(), + createDirectionsRoute() + ) + ) + ) + routesPreviewController.previewNavigationRoutes(testRoutes) + + assertNotNull(previewUpdate) + assertEquals(previewUpdate!!.routesPreview, routesPreviewController.getRoutesPreview()) + assertEquals(RoutesPreviewExtra.PREVIEW_NEW, previewUpdate!!.reason) + assertNotNull(previewUpdate!!.routesPreview) + val preview = previewUpdate!!.routesPreview!! + assertEquals(testRoutes, preview.originalRoutesList) + assertEquals(testRoutes, preview.routesList) + assertEquals(testRoutes.first(), preview.primaryRoute) + assertEquals(testRoutes[1], preview.alternativesMetadata.first().navigationRoute) + assertEquals(0, preview.primaryRouteIndex) + } + + @Test + fun `register observer when preview is active`() { + val routesPreviewController = createRoutePreviewController() + val testRoutes = createNavigationRoutes() + routesPreviewController.previewNavigationRoutes(testRoutes) + + var previewUpdate: RoutesPreviewUpdate? = null + routesPreviewController.registerRoutesPreviewObserver { + previewUpdate = it + } + + assertNotNull(previewUpdate) + assertEquals(previewUpdate!!.routesPreview, routesPreviewController.getRoutesPreview()) + } + + @Test + fun `set previewed routes with the second route as a primary`() { + val routesPreviewController = createRoutePreviewController() + var previewUpdate: RoutesPreviewUpdate? = null + routesPreviewController.registerRoutesPreviewObserver { + previewUpdate = it + } + + val testRoutes = createNavigationRoutes( + response = createDirectionsResponse( + uuid = "test", + routes = listOf( + createDirectionsRoute(), + createDirectionsRoute() + ) + ) + ) + routesPreviewController.previewNavigationRoutes(testRoutes, primaryRouteIndex = 1) + + assertNotNull(previewUpdate) + assertNotNull(previewUpdate!!.routesPreview) + val preview = previewUpdate!!.routesPreview!! + assertEquals(testRoutes, preview.originalRoutesList) + assertEquals(listOf("test#1", "test#0"), preview.routesList.map { it.id }) + assertEquals(testRoutes[1], preview.primaryRoute) + assertEquals(testRoutes[0], preview.alternativesMetadata.first().navigationRoute) + assertEquals(1, preview.primaryRouteIndex) + } + + @Test + fun `preview the same set of routes a few times`() { + val routesPreviewController = createRoutePreviewController() + var eventCount = 0 + routesPreviewController.registerRoutesPreviewObserver { + eventCount++ + } + + val testRoutes = createNavigationRoutes() + routesPreviewController.previewNavigationRoutes(testRoutes) + routesPreviewController.previewNavigationRoutes(testRoutes) + routesPreviewController.previewNavigationRoutes(testRoutes) + + assertEquals(1, eventCount) + } + + @Test + fun `cleanup routes a few times`() { + val routesPreviewController = createRoutePreviewController() + var eventCount = 0 + routesPreviewController.registerRoutesPreviewObserver { + eventCount++ + } + + routesPreviewController.previewNavigationRoutes(createNavigationRoutes()) + routesPreviewController.previewNavigationRoutes(emptyList()) + routesPreviewController.previewNavigationRoutes(emptyList()) + routesPreviewController.previewNavigationRoutes(emptyList()) + + assertEquals( + 2, // one for set and one for cleanup + eventCount + ) + } + + @Test + fun `select different primary route`() { + val routesPreviewController = createRoutePreviewController() + var previewUpdate: RoutesPreviewUpdate? = null + routesPreviewController.registerRoutesPreviewObserver { + previewUpdate = it + } + val testRoutes = createNavigationRoutes( + response = createDirectionsResponse( + uuid = "test", + routes = listOf( + createDirectionsRoute(), + createDirectionsRoute() + ) + ) + ) + routesPreviewController.previewNavigationRoutes(testRoutes) + + routesPreviewController.changeRoutesPreviewPrimaryRoute( + previewUpdate!!.routesPreview!!.alternativesMetadata.first().navigationRoute + ) + + val result = previewUpdate!!.routesPreview + assertNotNull(result) + result!! + assertEquals(testRoutes[1], result.primaryRoute) + assertEquals(testRoutes[0], result.alternativesMetadata.first().navigationRoute) + assertEquals(testRoutes, result.originalRoutesList) + assertEquals( + listOf( + testRoutes[1], + testRoutes[0] + ), + result.routesList + ) + assertEquals(1, result.primaryRouteIndex) + } + + @Test + fun `switch between previewed routes quicker then internal processing`() { + val testRoutes = createNavigationRoutes( + response = createDirectionsResponse( + uuid = "test", + routes = listOf( + createDirectionsRoute(), + createDirectionsRoute(), + createDirectionsRoute(), + ) + ) + ) + val waitHandle = CompletableDeferred() + val routesPreviewController = createRoutePreviewController( + routesDataParser = { + if (it.first().id == "test#1") { + waitHandle.await() + } + RouteDataParserStub().parse(it) + } + ) + val previewedRoutesIds = mutableListOf?>() + routesPreviewController.registerRoutesPreviewObserver { + previewedRoutesIds.add(it.routesPreview?.routesList?.map { it.id }) + } + routesPreviewController.previewNavigationRoutes(testRoutes) + + routesPreviewController.changeRoutesPreviewPrimaryRoute(testRoutes[1]) + routesPreviewController.changeRoutesPreviewPrimaryRoute(testRoutes[2]) + waitHandle.complete(Unit) + + assertEquals( + listOf( + listOf("test#0", "test#1", "test#2"), + listOf("test#1", "test#0", "test#2"), + listOf("test#2", "test#0", "test#1") + ), + previewedRoutesIds + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `select wrong primary route`() { + val routesPreviewController = createRoutePreviewController() + routesPreviewController.previewNavigationRoutes( + createNavigationRoutes( + response = createDirectionsResponse( + uuid = "test", + routes = listOf( + createDirectionsRoute(), + createDirectionsRoute() + ) + ) + ) + ) + + routesPreviewController.changeRoutesPreviewPrimaryRoute( + createNavigationRoutes().first() + ) + } + + @Test(expected = IllegalArgumentException::class) + fun `select primary route without previewing any routes`() { + val routesPreviewController = createRoutePreviewController() + + routesPreviewController.changeRoutesPreviewPrimaryRoute( + createNavigationRoutes().first() + ) + } + + @Test + fun `unsubscribe from preview updates`() { + val routesPreviewController = createRoutePreviewController() + var eventsCount = 0 + val observer = RoutesPreviewObserver { eventsCount++ } + + routesPreviewController.registerRoutesPreviewObserver(observer) + routesPreviewController.unregisterRoutesPreviewObserver(observer) + routesPreviewController.previewNavigationRoutes( + createNavigationRoutes( + createDirectionsResponse(uuid = "test1") + ) + ) + routesPreviewController.previewNavigationRoutes( + createNavigationRoutes( + createDirectionsResponse(uuid = "test2") + ) + ) + + assertEquals(0, eventsCount) + } + + @Test + fun `remove all observers`() { + val routesPreviewController = createRoutePreviewController() + var eventsCount = 0 + routesPreviewController.registerRoutesPreviewObserver { + eventsCount++ + } + routesPreviewController.registerRoutesPreviewObserver { + eventsCount++ + } + + routesPreviewController.unregisterAllRoutesPreviewObservers() + routesPreviewController.previewNavigationRoutes( + createNavigationRoutes( + createDirectionsResponse(uuid = "test1") + ) + ) + routesPreviewController.previewNavigationRoutes( + createNavigationRoutes( + createDirectionsResponse(uuid = "test2") + ) + ) + + assertEquals(0, eventsCount) + } + + @Test + fun `clean-up routes preview`() { + val routesPreviewController = createRoutePreviewController() + var previewUpdate: RoutesPreviewUpdate? = null + routesPreviewController.registerRoutesPreviewObserver { + previewUpdate = it + } + val testRoutes = createNavigationRoutes( + response = createDirectionsResponse( + uuid = "test", + routes = listOf( + createDirectionsRoute(), + createDirectionsRoute() + ) + ) + ) + routesPreviewController.previewNavigationRoutes(testRoutes) + + routesPreviewController.previewNavigationRoutes(emptyList()) + + assertNotNull(previewUpdate) + val result = previewUpdate!! + assertEquals(RoutesPreviewExtra.PREVIEW_CLEAN_UP, result.reason) + assertNull(result.routesPreview) + } + + @Test + fun `one of alternatives are invalid`() { + val testRoutes = listOf( + createNavigationRoutes(createDirectionsResponse(uuid = "primary")).first(), + createNavigationRoutes(createDirectionsResponse(uuid = "valid")).first(), + createNavigationRoutes(createDirectionsResponse(uuid = "invalid")).first(), + ) + val routesPreviewController = createRoutePreviewController( + routesDataParser = { + object : RoutesData { + override fun primaryRoute(): RouteInterface = testRoutes.first().nativeRoute() + + // data for the invalid alternative isn't returned + override fun alternativeRoutes(): MutableList { + return mutableListOf( + mockk(relaxed = true) { + every { route } returns testRoutes[1].nativeRoute() + } + ) + } + } + } + ) + + routesPreviewController.previewNavigationRoutes(testRoutes) + val routesPreview = routesPreviewController.getRoutesPreview() + + assertNotNull(routesPreview) + routesPreview!! + assertEquals( + listOf("valid#0"), + routesPreview.alternativesMetadata.map { it.navigationRoute.id } + ) + assertEquals(testRoutes, routesPreview.routesList) + assertEquals(testRoutes, routesPreview.originalRoutesList) + } + + @Test + fun `new routes are set faster then processing`() { + val slow = createNavigationRoutes(createDirectionsResponse(uuid = "slow")) + val slowWaitHandle = CompletableDeferred() + val fast = createNavigationRoutes( + createDirectionsResponse( + uuid = "fast", + routes = listOf( + createDirectionsRoute(), + createDirectionsRoute() + ) + ) + ) + val routesPreviewController = createRoutePreviewController( + routesDataParser = { + if (it == slow) { + slowWaitHandle.await() + } + RouteDataParserStub().parse(it) + } + ) + val previewedRoutes = mutableListOf?>() + routesPreviewController.registerRoutesPreviewObserver { + previewedRoutes.add(it.routesPreview?.routesList?.map(NavigationRoute::id)) + } + + routesPreviewController.previewNavigationRoutes(slow) + routesPreviewController.previewNavigationRoutes(fast) + routesPreviewController.previewNavigationRoutes(emptyList()) + slowWaitHandle.complete(Unit) + + assertEquals( + listOf( + listOf("slow#0"), + listOf("fast#0", "fast#1"), + null + ), + previewedRoutes + ) + } + + @Test + fun `select primary route while new route is processing`() { + val slow = createNavigationRoutes( + createDirectionsResponse( + uuid = "slow", + routes = listOf( + createDirectionsRoute(), + createDirectionsRoute() + ) + ) + ) + val slowWaitHandle = CompletableDeferred() + val fast = createNavigationRoutes( + createDirectionsResponse( + uuid = "fast", + routes = listOf( + createDirectionsRoute(), + createDirectionsRoute() + ) + ) + ) + val routesPreviewController = createRoutePreviewController( + routesDataParser = { + if (it == slow) { + slowWaitHandle.await() + } + RouteDataParserStub().parse(it) + } + ) + val previewedRoutes = mutableListOf?>() + routesPreviewController.registerRoutesPreviewObserver { + previewedRoutes.add(it.routesPreview?.routesList?.map(NavigationRoute::id)) + } + + routesPreviewController.previewNavigationRoutes(fast) + routesPreviewController.previewNavigationRoutes(slow) + // user clicks on UI with old routes + routesPreviewController.changeRoutesPreviewPrimaryRoute(fast[1]) + slowWaitHandle.complete(Unit) + + assertEquals( + listOf( + listOf("fast#0", "fast#1"), + listOf("slow#0", "slow#1"), + listOf("fast#1", "fast#0"), + ), + previewedRoutes + ) + } +} diff --git a/qa-test-app/src/main/AndroidManifest.xml b/qa-test-app/src/main/AndroidManifest.xml index d79ed5d2c86..18ce384acd9 100644 --- a/qa-test-app/src/main/AndroidManifest.xml +++ b/qa-test-app/src/main/AndroidManifest.xml @@ -73,6 +73,8 @@ + + activity.startActivity() }, + TestActivityDescription( + "Route preview", + R.string.drop_in_buttons_activity_description + ) { activity -> + activity.startActivity() + }, ) } diff --git a/qa-test-app/src/main/java/com/mapbox/navigation/qa_test_app/view/RoutesPreviewActivity.kt b/qa-test-app/src/main/java/com/mapbox/navigation/qa_test_app/view/RoutesPreviewActivity.kt new file mode 100644 index 00000000000..d2f88d8ff2c --- /dev/null +++ b/qa-test-app/src/main/java/com/mapbox/navigation/qa_test_app/view/RoutesPreviewActivity.kt @@ -0,0 +1,524 @@ +package com.mapbox.navigation.qa_test_app.view + +import android.annotation.SuppressLint +import android.content.res.Resources +import android.location.Location +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import com.mapbox.api.directions.v5.models.RouteOptions +import com.mapbox.bindgen.Expected +import com.mapbox.geojson.Point +import com.mapbox.maps.CameraOptions +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.MapboxMap +import com.mapbox.maps.Style +import com.mapbox.maps.plugin.LocationPuck2D +import com.mapbox.maps.plugin.animation.camera +import com.mapbox.maps.plugin.gestures.OnMapClickListener +import com.mapbox.maps.plugin.gestures.gestures +import com.mapbox.maps.plugin.locationcomponent.location +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.TimeFormat +import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions +import com.mapbox.navigation.base.extensions.applyLanguageAndVoiceUnitOptions +import com.mapbox.navigation.base.formatter.DistanceFormatterOptions +import com.mapbox.navigation.base.options.NavigationOptions +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.base.route.NavigationRouterCallback +import com.mapbox.navigation.base.route.RouterFailure +import com.mapbox.navigation.base.route.RouterOrigin +import com.mapbox.navigation.core.MapboxNavigation +import com.mapbox.navigation.core.MapboxNavigationProvider +import com.mapbox.navigation.core.directions.session.RoutesObserver +import com.mapbox.navigation.core.formatter.MapboxDistanceFormatter +import com.mapbox.navigation.core.preview.RoutesPreviewObserver +import com.mapbox.navigation.core.trip.session.LocationMatcherResult +import com.mapbox.navigation.core.trip.session.LocationObserver +import com.mapbox.navigation.core.trip.session.RouteProgressObserver +import com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver +import com.mapbox.navigation.qa_test_app.R +import com.mapbox.navigation.qa_test_app.databinding.LayoutActivityRoutePreviewBinding +import com.mapbox.navigation.qa_test_app.utils.Utils +import com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer +import com.mapbox.navigation.ui.maneuver.api.MapboxManeuverApi +import com.mapbox.navigation.ui.maps.camera.NavigationCamera +import com.mapbox.navigation.ui.maps.camera.data.MapboxNavigationViewportDataSource +import com.mapbox.navigation.ui.maps.camera.lifecycle.NavigationBasicGesturesHandler +import com.mapbox.navigation.ui.maps.camera.state.NavigationCameraState +import com.mapbox.navigation.ui.maps.location.NavigationLocationProvider +import com.mapbox.navigation.ui.maps.route.line.MapboxRouteLineApiExtensions.findClosestRoute +import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineApi +import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineView +import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineOptions +import com.mapbox.navigation.ui.tripprogress.api.MapboxTripProgressApi +import com.mapbox.navigation.ui.tripprogress.model.DistanceRemainingFormatter +import com.mapbox.navigation.ui.tripprogress.model.EstimatedTimeToArrivalFormatter +import com.mapbox.navigation.ui.tripprogress.model.PercentDistanceTraveledFormatter +import com.mapbox.navigation.ui.tripprogress.model.TimeRemainingFormatter +import com.mapbox.navigation.ui.tripprogress.model.TripProgressUpdateFormatter +import com.mapbox.navigation.ui.voice.api.MapboxSpeechApi +import com.mapbox.navigation.ui.voice.api.MapboxVoiceInstructionsPlayer +import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement +import com.mapbox.navigation.ui.voice.model.SpeechError +import com.mapbox.navigation.ui.voice.model.SpeechValue +import com.mapbox.navigation.ui.voice.model.SpeechVolume +import kotlinx.coroutines.launch +import java.util.Locale + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +class RoutesPreviewActivity : AppCompatActivity() { + + /* ----- Layout binding reference ----- */ + private lateinit var binding: LayoutActivityRoutePreviewBinding + + /* ----- Mapbox Maps components ----- */ + private lateinit var mapboxMap: MapboxMap + + /* ----- Mapbox Navigation components ----- */ + private lateinit var mapboxNavigation: MapboxNavigation + + // location puck integration + private val navigationLocationProvider = NavigationLocationProvider() + + // camera + private lateinit var navigationCamera: NavigationCamera + private lateinit var viewportDataSource: MapboxNavigationViewportDataSource + private val pixelDensity = Resources.getSystem().displayMetrics.density + private val overviewPadding: EdgeInsets by lazy { + EdgeInsets( + 140.0 * pixelDensity, + 40.0 * pixelDensity, + 120.0 * pixelDensity, + 40.0 * pixelDensity + ) + } + private val followingPadding: EdgeInsets by lazy { + EdgeInsets( + 180.0 * pixelDensity, + 40.0 * pixelDensity, + 150.0 * pixelDensity, + 40.0 * pixelDensity + ) + } + + // trip progress bottom view + private lateinit var tripProgressApi: MapboxTripProgressApi + + // voice instructions + private var isVoiceInstructionsMuted = false + private lateinit var maneuverApi: MapboxManeuverApi + private lateinit var speechAPI: MapboxSpeechApi + private lateinit var voiceInstructionsPlayer: MapboxVoiceInstructionsPlayer + + // route line + private lateinit var routeLineAPI: MapboxRouteLineApi + private lateinit var routeLineView: MapboxRouteLineView + + /* ----- Voice instruction callbacks ----- */ + private val voiceInstructionsObserver = + VoiceInstructionsObserver { voiceInstructions -> + speechAPI.generate( + voiceInstructions, + speechCallback + ) + } + + private val voiceInstructionsPlayerCallback = + MapboxNavigationConsumer { value -> + // remove already consumed file to free-up space + speechAPI.clean(value) + } + + private val speechCallback = + MapboxNavigationConsumer> { expected -> + expected.fold( + { error -> + // play the instruction via fallback text-to-speech engine + voiceInstructionsPlayer.play( + error.fallback, + voiceInstructionsPlayerCallback + ) + }, + { value -> + // play the sound file from the external generator + voiceInstructionsPlayer.play( + value.announcement, + voiceInstructionsPlayerCallback + ) + } + ) + } + + /* ----- Location and route progress callbacks ----- */ + private val locationObserver = object : LocationObserver { + override fun onNewRawLocation(rawLocation: Location) { + // not handled + } + + override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) { + // update location puck's position on the map + navigationLocationProvider.changePosition( + location = locationMatcherResult.enhancedLocation, + keyPoints = locationMatcherResult.keyPoints, + ) + + // update camera position to account for new location + viewportDataSource.onLocationChanged(locationMatcherResult.enhancedLocation) + viewportDataSource.evaluate() + } + } + + private val routeProgressObserver = + RouteProgressObserver { routeProgress -> + // update the camera position to account for the progressed fragment of the route + viewportDataSource.onRouteProgressChanged(routeProgress) + viewportDataSource.evaluate() + + // update top maneuver instructions + val maneuvers = maneuverApi.getManeuvers(routeProgress) + maneuvers.fold( + { error -> + Toast.makeText( + this@RoutesPreviewActivity, + error.errorMessage, + Toast.LENGTH_SHORT + ).show() + }, + { + binding.maneuverView.visibility = View.VISIBLE + binding.maneuverView.renderManeuvers(maneuvers) + } + ) + + // update bottom trip progress summary + binding.tripProgressView.render(tripProgressApi.getTripProgress(routeProgress)) + } + + private val routesObserver = RoutesObserver { result -> + if (result.navigationRoutes.isNotEmpty()) { + // generate route geometries asynchronously and render them + routeLineAPI.setNavigationRoutes( + result.navigationRoutes, + mapboxNavigation.getAlternativeMetadataFor(result.navigationRoutes) + ) { + val style = mapboxMap.getStyle() + if (style != null) { + routeLineView.renderRouteDrawData(style, it) + } + } + // update the camera position to account for the new route + viewportDataSource.onRouteChanged(result.routes.first()) + viewportDataSource.evaluate() + } else { + // remove the route line and route arrow from the map + val style = mapboxMap.getStyle() + if (style != null) { + routeLineAPI.clearRouteLine { value -> + routeLineView.renderClearRouteLineValue( + style, + value + ) + } + } + + // remove the route reference to change camera position + viewportDataSource.clearRouteData() + viewportDataSource.evaluate() + } + } + + private val routesPreviewObserver = RoutesPreviewObserver { update -> + val routePreview = update.routesPreview + if (routePreview != null) { + routeLineAPI.setNavigationRoutes( + routePreview.routesList, + routePreview.alternativesMetadata + ) { + val style = mapboxMap.getStyle() + if (style != null) { + routeLineView.renderRouteDrawData(style, it) + } + } + // update the camera position to account for the new route + viewportDataSource.onRouteChanged(routePreview.primaryRoute) + viewportDataSource.evaluate() + } + } + + private val routeClickPadding = com.mapbox.android.gestures.Utils.dpToPx(30f) + + private val previewMapClickListener = OnMapClickListener { + lifecycleScope.launch { + mapboxNavigation.getRoutesPreview() ?: return@launch + val result = routeLineAPI.findClosestRoute( + it, + binding.mapView.getMapboxMap(), + routeClickPadding + ) + + val routeFound = result.value?.navigationRoute + if (routeFound != null && routeFound != routeLineAPI.getPrimaryNavigationRoute()) { + mapboxNavigation.changeRoutesPreviewPrimaryRoute(routeFound) + } + } + false + } + + @SuppressLint("MissingPermission") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = LayoutActivityRoutePreviewBinding.inflate(layoutInflater) + setContentView(binding.root) + mapboxMap = binding.mapView.getMapboxMap() + + // initialize the location puck + binding.mapView.location.apply { + this.locationPuck = LocationPuck2D( + bearingImage = ContextCompat.getDrawable( + this@RoutesPreviewActivity, + R.drawable.mapbox_navigation_puck_icon + ) + ) + setLocationProvider(navigationLocationProvider) + enabled = true + } + + // initialize Mapbox Navigation + mapboxNavigation = MapboxNavigationProvider.create( + NavigationOptions.Builder(this) + .accessToken(getMapboxAccessTokenFromResources()) + .build() + ) + // move the camera to current location on the first update + mapboxNavigation.registerLocationObserver(object : LocationObserver { + override fun onNewRawLocation(rawLocation: Location) { + val point = Point.fromLngLat(rawLocation.longitude, rawLocation.latitude) + val cameraOptions = CameraOptions.Builder() + .center(point) + .zoom(13.0) + .build() + mapboxMap.setCamera(cameraOptions) + mapboxNavigation.unregisterLocationObserver(this) + } + + override fun onNewLocationMatcherResult( + locationMatcherResult: LocationMatcherResult, + ) { + // not handled + } + }) + + // initialize Navigation Camera + viewportDataSource = MapboxNavigationViewportDataSource( + binding.mapView.getMapboxMap() + ) + navigationCamera = NavigationCamera( + binding.mapView.getMapboxMap(), + binding.mapView.camera, + viewportDataSource + ) + binding.mapView.camera.addCameraAnimationsLifecycleListener( + NavigationBasicGesturesHandler(navigationCamera) + ) + navigationCamera.registerNavigationCameraStateChangeObserver { navigationCameraState -> + // shows/hide the recenter button depending on the camera state + when (navigationCameraState) { + NavigationCameraState.TRANSITION_TO_FOLLOWING, + NavigationCameraState.FOLLOWING -> binding.recenter.visibility = View.INVISIBLE + + NavigationCameraState.TRANSITION_TO_OVERVIEW, + NavigationCameraState.OVERVIEW, + NavigationCameraState.IDLE -> binding.recenter.visibility = View.VISIBLE + } + } + + viewportDataSource.overviewPadding = overviewPadding + viewportDataSource.followingPadding = followingPadding + + // initialize top maneuver view + maneuverApi = MapboxManeuverApi( + MapboxDistanceFormatter(DistanceFormatterOptions.Builder(this).build()) + ) + + // initialize bottom progress view + tripProgressApi = MapboxTripProgressApi( + TripProgressUpdateFormatter.Builder(this) + .distanceRemainingFormatter( + DistanceRemainingFormatter( + mapboxNavigation.navigationOptions.distanceFormatterOptions + ) + ) + .timeRemainingFormatter(TimeRemainingFormatter(this)) + .percentRouteTraveledFormatter(PercentDistanceTraveledFormatter()) + .estimatedTimeToArrivalFormatter( + EstimatedTimeToArrivalFormatter(this, TimeFormat.NONE_SPECIFIED) + ) + .build() + ) + + // initialize voice instructions + speechAPI = MapboxSpeechApi( + this, + getMapboxAccessTokenFromResources(), + Locale.US.language + ) + voiceInstructionsPlayer = MapboxVoiceInstructionsPlayer( + this, + Locale.US.language + ) + + // initialize route line + val mapboxRouteLineOptions = MapboxRouteLineOptions.Builder(this) + .withRouteLineBelowLayerId("road-label") + .build() + routeLineAPI = MapboxRouteLineApi(mapboxRouteLineOptions) + routeLineView = MapboxRouteLineView(mapboxRouteLineOptions) + + // load map style + mapboxMap.loadStyleUri(Style.MAPBOX_STREETS) { style -> + routeLineView.initializeLayers(style) + // add long click listener that search for a route to the clicked destination + binding.mapView.gestures.addOnMapLongClickListener { point -> + findRoute(point) + true + } + binding.mapView.gestures.addOnMapClickListener(previewMapClickListener) + } + + // initialize view interactions + binding.stop.setOnClickListener { + clearRouteAndStopNavigation() + } + binding.recenter.setOnClickListener { + navigationCamera.requestNavigationCameraToFollowing() + } + binding.routeOverview.setOnClickListener { + navigationCamera.requestNavigationCameraToOverview() + binding.recenter.showTextAndExtend(2000L) + } + binding.soundButton.setOnClickListener { + // mute/unmute voice instructions + isVoiceInstructionsMuted = !isVoiceInstructionsMuted + if (isVoiceInstructionsMuted) { + binding.soundButton.muteAndExtend(2000L) + voiceInstructionsPlayer.volume(SpeechVolume(0f)) + } else { + binding.soundButton.unmuteAndExtend(2000L) + voiceInstructionsPlayer.volume(SpeechVolume(1f)) + } + } + + // start the trip session to being receiving location updates in free drive + // and later when a route is set, also receiving route progress updates + mapboxNavigation.startTripSession() + } + + override fun onStart() { + super.onStart() + mapboxNavigation.registerRoutesObserver(routesObserver) + mapboxNavigation.registerRouteProgressObserver(routeProgressObserver) + mapboxNavigation.registerLocationObserver(locationObserver) + mapboxNavigation.registerVoiceInstructionsObserver(voiceInstructionsObserver) + mapboxNavigation.registerRoutesPreviewObserver(routesPreviewObserver) + } + + override fun onStop() { + super.onStop() + mapboxNavigation.unregisterRoutesObserver(routesObserver) + mapboxNavigation.unregisterRouteProgressObserver(routeProgressObserver) + mapboxNavigation.unregisterLocationObserver(locationObserver) + mapboxNavigation.unregisterVoiceInstructionsObserver(voiceInstructionsObserver) + mapboxNavigation.unregisterRoutesPreviewObserver(routesPreviewObserver) + } + + override fun onDestroy() { + super.onDestroy() + routeLineAPI.cancel() + routeLineView.cancel() + mapboxNavigation.onDestroy() + maneuverApi.cancel() + speechAPI.cancel() + voiceInstructionsPlayer.shutdown() + } + + private fun findRoute(destination: Point) { + val origin = navigationLocationProvider.lastLocation?.let { + Point.fromLngLat(it.longitude, it.latitude) + } ?: return + + mapboxNavigation.requestRoutes( + RouteOptions.builder() + .applyDefaultNavigationOptions() + .applyLanguageAndVoiceUnitOptions(this) + .alternatives(true) + .coordinatesList(listOf(origin, destination)) + .layersList(listOf(mapboxNavigation.getZLevel(), null)) + .build(), + object : NavigationRouterCallback { + override fun onRoutesReady( + routes: List, + routerOrigin: RouterOrigin + ) { + setRoutesPreview(routes) + } + + override fun onFailure( + reasons: List, + routeOptions: RouteOptions + ) { + // no impl + } + + override fun onCanceled(routeOptions: RouteOptions, routerOrigin: RouterOrigin) { + // no impl + } + } + ) + } + + private fun setRoutesPreview(routes: List) { + binding.navigateButton.apply { + visibility = View.VISIBLE + setOnClickListener { + visibility = View.GONE + setRouteAndStartNavigation(mapboxNavigation.getRoutesPreview()!!.routesList) + mapboxNavigation.setRoutesPreview(emptyList()) + } + } + mapboxNavigation.setRoutesPreview(routes) + } + + private fun setRouteAndStartNavigation(route: List) { + // set route + mapboxNavigation.setNavigationRoutes(route) + + // show UI elements + binding.soundButton.visibility = View.VISIBLE + binding.routeOverview.visibility = View.VISIBLE + binding.tripProgressCard.visibility = View.VISIBLE + binding.routeOverview.showTextAndExtend(2000L) + binding.soundButton.unmuteAndExtend(2000L) + + // move the camera to overview when new route is available + navigationCamera.requestNavigationCameraToOverview() + } + + private fun clearRouteAndStopNavigation() { + // clear + mapboxNavigation.setNavigationRoutes(listOf()) + + // hide UI elements + binding.soundButton.visibility = View.INVISIBLE + binding.maneuverView.visibility = View.INVISIBLE + binding.routeOverview.visibility = View.INVISIBLE + binding.tripProgressCard.visibility = View.INVISIBLE + } + + private fun getMapboxAccessTokenFromResources(): String { + return Utils.getMapboxAccessToken(this) + } +} diff --git a/qa-test-app/src/main/res/layout/layout_activity_route_preview.xml b/qa-test-app/src/main/res/layout/layout_activity_route_preview.xml new file mode 100644 index 00000000000..2bc93a199a3 --- /dev/null +++ b/qa-test-app/src/main/res/layout/layout_activity_route_preview.xml @@ -0,0 +1,93 @@ + + + + + + + + + +