diff --git a/changelog/unreleased/features/6610.md b/changelog/unreleased/features/6610.md new file mode 100644 index 00000000000..a30f09876d7 --- /dev/null +++ b/changelog/unreleased/features/6610.md @@ -0,0 +1,18 @@ +- Added `RouteRefreshController` interface to manage route refreshes. Retrieve it via `MapboxNavigation#routeRefreshController`. +- Added `RouteRefreshController#requestImmediateRouteRefresh` to trigger route refresh request immediately. +- Moved `MapboxNavigation#registerRouteRefreshStateObserver` to `RouteRefreshController#registerRouteRefreshStateObserver`. To migrate, change: + ```kotlin + mapboxNavigation.registerRouteRefreshStateObserver(observer) + ``` + to + ```kotlin + mapboxNavigation.routeRefreshController.registerRouteRefreshStateObserver(observer) + ``` +- Moved `MapboxNavigation#unregisterRouteRefreshStateObserver` to `RouteRefreshController#unregisterRouteRefreshStateObserver`. To migrate, change: + ```kotlin + mapboxNavigation.unregisterRouteRefreshStateObserver(observer) + ``` + to + ```kotlin + mapboxNavigation.routeRefreshController.unregisterRouteRefreshStateObserver(observer) + ``` \ No newline at end of file diff --git a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/EVRouteRefreshTest.kt b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/EVRouteRefreshTest.kt index 0a7fdf69f54..a15e1031b29 100644 --- a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/EVRouteRefreshTest.kt +++ b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/EVRouteRefreshTest.kt @@ -5,7 +5,6 @@ import androidx.annotation.IdRes import com.mapbox.api.directions.v5.DirectionsCriteria import com.mapbox.api.directions.v5.models.DirectionsWaypoint import com.mapbox.api.directions.v5.models.RouteOptions -import com.mapbox.api.directionsrefresh.v1.models.DirectionsRefreshResponse import com.mapbox.geojson.Point import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions @@ -20,6 +19,7 @@ import com.mapbox.navigation.core.directions.session.RoutesExtra import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult import com.mapbox.navigation.instrumentation_tests.R import com.mapbox.navigation.instrumentation_tests.activity.EmptyTestActivity +import com.mapbox.navigation.instrumentation_tests.utils.DynamicResponseModifier import com.mapbox.navigation.instrumentation_tests.utils.MapboxNavigationRule import com.mapbox.navigation.instrumentation_tests.utils.coroutines.getSuccessfulResultOrThrowException import com.mapbox.navigation.instrumentation_tests.utils.coroutines.requestRoutes @@ -715,39 +715,3 @@ class EVRouteRefreshTest : BaseTest(EmptyTestActivity::class. mockWebServerRule.requestHandlers.add(0, routeHandler) } } - -private class DynamicResponseModifier : (String) -> String { - - var numberOfInvocations = 0 - - override fun invoke(p1: String): String { - numberOfInvocations++ - val originalResponse = DirectionsRefreshResponse.fromJson(p1) - val newRoute = originalResponse.route()!! - .toBuilder() - .legs( - originalResponse.route()!!.legs()!!.map { - it - .toBuilder() - .annotation( - it.annotation()!! - .toBuilder() - .speed( - it.annotation()!!.speed()!!.map { - it + numberOfInvocations * 0.1 - } - ) - .build() - ) - .build() - } - ) - .build() - return DirectionsRefreshResponse.builder() - .route(newRoute) - .code(originalResponse.code()) - .message(originalResponse.message()) - .build() - .toJson() - } -} diff --git a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RouteRefreshOnDemandTest.kt b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RouteRefreshOnDemandTest.kt new file mode 100644 index 00000000000..8c547a90799 --- /dev/null +++ b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RouteRefreshOnDemandTest.kt @@ -0,0 +1,245 @@ +package com.mapbox.navigation.instrumentation_tests.core + +import android.location.Location +import com.mapbox.api.directions.v5.DirectionsCriteria +import com.mapbox.api.directions.v5.models.RouteOptions +import com.mapbox.geojson.Point +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions +import com.mapbox.navigation.base.options.NavigationOptions +import com.mapbox.navigation.base.options.RoutingTilesOptions +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.base.route.RouteRefreshOptions +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.routerefresh.RouteRefreshExtra +import com.mapbox.navigation.instrumentation_tests.R +import com.mapbox.navigation.instrumentation_tests.activity.EmptyTestActivity +import com.mapbox.navigation.instrumentation_tests.utils.DynamicResponseModifier +import com.mapbox.navigation.instrumentation_tests.utils.MapboxNavigationRule +import com.mapbox.navigation.instrumentation_tests.utils.coroutines.getSuccessfulResultOrThrowException +import com.mapbox.navigation.instrumentation_tests.utils.coroutines.requestRoutes +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.coroutines.setNavigationRoutesAndWaitForUpdate +import com.mapbox.navigation.instrumentation_tests.utils.http.MockDirectionsRefreshHandler +import com.mapbox.navigation.instrumentation_tests.utils.http.MockDirectionsRequestHandler +import com.mapbox.navigation.instrumentation_tests.utils.http.MockRoutingTileEndpointErrorRequestHandler +import com.mapbox.navigation.instrumentation_tests.utils.http.NthAttemptHandler +import com.mapbox.navigation.instrumentation_tests.utils.location.MockLocationReplayerRule +import com.mapbox.navigation.instrumentation_tests.utils.readRawFileText +import com.mapbox.navigation.testing.ui.BaseTest +import com.mapbox.navigation.testing.ui.http.MockRequestHandler +import com.mapbox.navigation.testing.ui.utils.getMapboxAccessTokenFromResources +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.net.URI +import java.util.concurrent.TimeUnit + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +class RouteRefreshOnDemandTest : BaseTest(EmptyTestActivity::class.java) { + + @get:Rule + val mapboxNavigationRule = MapboxNavigationRule() + + @get:Rule + val mockLocationReplayerRule = MockLocationReplayerRule(mockLocationUpdatesRule) + + private lateinit var baseRefreshHandler: MockDirectionsRefreshHandler + private lateinit var mapboxNavigation: MapboxNavigation + private val twoCoordinates = listOf( + Point.fromLngLat(-121.496066, 38.577764), + Point.fromLngLat(-121.480279, 38.57674) + ) + + @Before + fun setUp() { + baseRefreshHandler = MockDirectionsRefreshHandler( + "route_response_single_route_refresh", + readRawFileText(activity, R.raw.route_response_route_refresh_annotations), + ) + } + + override fun setupMockLocation(): Location = mockLocationUpdatesRule.generateLocationUpdate { + latitude = twoCoordinates[0].latitude() + longitude = twoCoordinates[0].longitude() + bearing = 190f + } + + @Test + fun immediate_route_refresh_before_planned() = sdkTest { + val observer = TestObserver() + val routeRefreshes = mutableListOf() + setupMockRequestHandlers(baseRefreshHandler) + baseRefreshHandler.jsonResponseModifier = DynamicResponseModifier() + createMapboxNavigation(createRouteRefreshOptionsWithInvalidInterval(5000)) + val routeOptions = generateRouteOptions(twoCoordinates) + val requestedRoutes = mapboxNavigation.requestRoutes(routeOptions) + .getSuccessfulResultOrThrowException() + .routes + mapboxNavigation.routeRefreshController.registerRouteRefreshStateObserver(observer) + mapboxNavigation.startTripSession() + stayOnInitialPosition() + mapboxNavigation.registerRoutesObserver { + if (it.reason == RoutesExtra.ROUTES_UPDATE_REASON_REFRESH) { + routeRefreshes.add(it) + } + } + mapboxNavigation.setNavigationRoutesAndWaitForUpdate(requestedRoutes) + delay(2500) + + mapboxNavigation.routeRefreshController.requestImmediateRouteRefresh() + val refreshedRoutes = mapboxNavigation.routesUpdates() + .filter { it.reason == RoutesExtra.ROUTES_UPDATE_REASON_REFRESH } + .first() + assertEquals(1, routeRefreshes.size) + assertEquals( + 224.2239, + requestedRoutes[0].getSumOfDurationAnnotationsFromLeg(0), + 0.0001 + ) + assertEquals( + 258.767, + refreshedRoutes.navigationRoutes[0].getSumOfDurationAnnotationsFromLeg(0), + 0.0001 + ) + + // no route refresh 4 seconds after refresh on demand + delay(4000) + assertEquals(1, routeRefreshes.size) + + delay(1000) + // has new refresh 5 seconds after refresh on demand + mapboxNavigation.routesUpdates() + .filter { it.reason == RoutesExtra.ROUTES_UPDATE_REASON_REFRESH } + .take(2) + .toList() + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, + ), + observer.getStatesSnapshot() + ) + } + + @Test + fun route_refresh_on_demand_between_planned_attempts() = sdkTest { + val observer = TestObserver() + baseRefreshHandler.jsonResponseModifier = DynamicResponseModifier() + setupMockRequestHandlers( + NthAttemptHandler(baseRefreshHandler, 1) + ) + + createMapboxNavigation(createRouteRefreshOptionsWithInvalidInterval(5_000)) + mapboxNavigation.routeRefreshController.registerRouteRefreshStateObserver(observer) + mapboxNavigation.startTripSession() + val routeOptions = generateRouteOptions(twoCoordinates) + val requestedRoutes = mapboxNavigation.requestRoutes(routeOptions) + .getSuccessfulResultOrThrowException() + .routes + mapboxNavigation.setNavigationRoutesAndWaitForUpdate(requestedRoutes) + delay(8000) // refresh interval + accuracy + + mapboxNavigation.routeRefreshController.requestImmediateRouteRefresh() + + // one from immediate and the next planned + mapboxNavigation.routesUpdates() + .filter { it.reason == RoutesExtra.ROUTES_UPDATE_REASON_REFRESH } + .take(2) + .toList() + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_CANCELED, + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, + ), + observer.getStatesSnapshot() + ) + } + + private fun createMapboxNavigation(routeRefreshOptions: RouteRefreshOptions) { + mapboxNavigation = MapboxNavigationProvider.create( + NavigationOptions.Builder(activity) + .accessToken(getMapboxAccessTokenFromResources(activity)) + .routeRefreshOptions(routeRefreshOptions) + .routingTilesOptions( + RoutingTilesOptions.Builder() + .tilesBaseUri(URI(mockWebServerRule.baseUrl)) + .build() + ) + .navigatorPredictionMillis(0L) + .build() + ) + } + + private fun stayOnInitialPosition() { + mockLocationReplayerRule.loopUpdate( + mockLocationUpdatesRule.generateLocationUpdate { + latitude = twoCoordinates[0].latitude() + longitude = twoCoordinates[0].longitude() + bearing = 190f + }, + times = 120 + ) + } + + private fun generateRouteOptions(coordinates: List): RouteOptions { + return RouteOptions.builder().applyDefaultNavigationOptions() + .profile(DirectionsCriteria.PROFILE_DRIVING_TRAFFIC) + .alternatives(true) + .coordinatesList(coordinates) + .baseUrl(mockWebServerRule.baseUrl) // Comment out to test a real server + .build() + } + + private fun setupMockRequestHandlers( + refreshHandler: MockRequestHandler, + ) { + mockWebServerRule.requestHandlers.clear() + mockWebServerRule.requestHandlers.add( + MockDirectionsRequestHandler( + "driving-traffic", + readRawFileText(activity, R.raw.route_response_single_route_refresh), + twoCoordinates + ) + ) + mockWebServerRule.requestHandlers.add(refreshHandler) + mockWebServerRule.requestHandlers.add(MockRoutingTileEndpointErrorRequestHandler()) + } + + private fun NavigationRoute.getSumOfDurationAnnotationsFromLeg(legIndex: Int): Double = + directionsRoute.legs()?.get(legIndex) + ?.annotation() + ?.duration() + ?.sum()!! + + private fun createRouteRefreshOptionsWithInvalidInterval( + intervalMillis: Long + ): RouteRefreshOptions { + val routeRefreshOptions = RouteRefreshOptions.Builder() + .intervalMillis(TimeUnit.SECONDS.toMillis(30)) + .build() + RouteRefreshOptions::class.java.getDeclaredField("intervalMillis").apply { + isAccessible = true + set(routeRefreshOptions, intervalMillis) + } + return routeRefreshOptions + } +} diff --git a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RouteRefreshTest.kt b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RouteRefreshTest.kt index 033ef0dd3fc..f526540ee9b 100644 --- a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RouteRefreshTest.kt +++ b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/core/RouteRefreshTest.kt @@ -19,6 +19,9 @@ import com.mapbox.navigation.base.trip.model.RouteProgress import com.mapbox.navigation.core.MapboxNavigation import com.mapbox.navigation.core.MapboxNavigationProvider import com.mapbox.navigation.core.directions.session.RoutesExtra.ROUTES_UPDATE_REASON_REFRESH +import com.mapbox.navigation.core.routerefresh.RouteRefreshExtra +import com.mapbox.navigation.core.routerefresh.RouteRefreshStateResult +import com.mapbox.navigation.core.routerefresh.RouteRefreshStatesObserver import com.mapbox.navigation.instrumentation_tests.R import com.mapbox.navigation.instrumentation_tests.activity.EmptyTestActivity import com.mapbox.navigation.instrumentation_tests.utils.MapboxNavigationRule @@ -60,7 +63,6 @@ import java.net.URI import java.util.concurrent.TimeUnit import kotlin.math.absoluteValue -@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) class RouteRefreshTest : BaseTest(EmptyTestActivity::class.java) { @get:Rule @@ -303,6 +305,7 @@ class RouteRefreshTest : BaseTest(EmptyTestActivity::class.ja waitForRouteToSuccessfullyRefresh() } + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) @Test fun routeSuccessfullyRefreshesAfterInvalidationOfExpiringData() = sdkTest { val routeOptions = generateRouteOptions(twoCoordinates) @@ -313,6 +316,8 @@ class RouteRefreshTest : BaseTest(EmptyTestActivity::class.ja mapboxNavigation.setNavigationRoutesAndWaitForUpdate(routes) mapboxNavigation.startTripSession() stayOnInitialPosition() + val observer = TestObserver() + mapboxNavigation.routeRefreshController.registerRouteRefreshStateObserver(observer) // act val refreshedRoutes = mapboxNavigation.routesUpdates() .filter { it.reason == ROUTES_UPDATE_REASON_REFRESH } @@ -331,6 +336,16 @@ class RouteRefreshTest : BaseTest(EmptyTestActivity::class.ja ) failByRequestRouteRefreshResponse.failResponse = false waitForRouteToSuccessfullyRefresh() + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, + RouteRefreshExtra.REFRESH_STATE_CLEARED_EXPIRED, + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS + ), + observer.getStatesSnapshot() + ) } @Test @@ -906,3 +921,15 @@ private fun NavigationRoute.getIncidentsIdFromTheRoute(legIndex: Int): List() + + override fun onNewState(result: RouteRefreshStateResult) { + states.add(result) + } + + fun getStatesSnapshot(): List = states.map { it.state } +} diff --git a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/DynamicResponseModifier.kt b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/DynamicResponseModifier.kt new file mode 100644 index 00000000000..8e9c62f6d7e --- /dev/null +++ b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/DynamicResponseModifier.kt @@ -0,0 +1,39 @@ +package com.mapbox.navigation.instrumentation_tests.utils + +import com.mapbox.api.directionsrefresh.v1.models.DirectionsRefreshResponse + +class DynamicResponseModifier : (String) -> String { + + var numberOfInvocations = 0 + + override fun invoke(p1: String): String { + numberOfInvocations++ + val originalResponse = DirectionsRefreshResponse.fromJson(p1) + val newRoute = originalResponse.route()!! + .toBuilder() + .legs( + originalResponse.route()!!.legs()!!.map { + it + .toBuilder() + .annotation( + it.annotation()!! + .toBuilder() + .speed( + it.annotation()!!.speed()!!.map { + it + numberOfInvocations * 0.1 + } + ) + .build() + ) + .build() + } + ) + .build() + return DirectionsRefreshResponse.builder() + .route(newRoute) + .code(originalResponse.code()) + .message(originalResponse.message()) + .build() + .toJson() + } +} diff --git a/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/http/NthAttemptHandler.kt b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/http/NthAttemptHandler.kt new file mode 100644 index 00000000000..13879b08538 --- /dev/null +++ b/instrumentation-tests/src/androidTest/java/com/mapbox/navigation/instrumentation_tests/utils/http/NthAttemptHandler.kt @@ -0,0 +1,24 @@ +package com.mapbox.navigation.instrumentation_tests.utils.http + +import com.mapbox.navigation.testing.ui.http.MockRequestHandler +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.RecordedRequest + +class NthAttemptHandler( + private val originalHandler: MockRequestHandler, + private val successfulAttemptNumber: Int, +) : MockRequestHandler { + + private var attemptsCount = 0 + + override fun handle(request: RecordedRequest): MockResponse? { + val response = originalHandler.handle(request) + val result = if (attemptsCount < successfulAttemptNumber) { + null + } else { + response + } + if (response != null) attemptsCount++ + return result + } +} diff --git a/instrumentation-tests/src/main/res/raw/route_response_route_refresh_annotations_without_traffic.json b/instrumentation-tests/src/main/res/raw/route_response_route_refresh_annotations_without_traffic.json new file mode 100644 index 00000000000..da644afc3f4 --- /dev/null +++ b/instrumentation-tests/src/main/res/raw/route_response_route_refresh_annotations_without_traffic.json @@ -0,0 +1,397 @@ +{ + "code":"Ok", + "route": { + "legs": [ + { + "closures": [ + { + "geometry_index_start": 1, + "geometry_index_end": 3 + } + ], + "incidents": [ + { + "length": 20, + "affected_road_names": [ + "9th Street" + ], + "id": "14158569638505033", + "type": "lane_restriction", + "congestion": { + "value": 101 + }, + "description": "Möllendorffstrasse: Bauarbeiten zwischen Storkower Strasse und Scheffelstrasse", + "long_description": "Fahrbahnverengung von auf eine Fahrspur wegen Bauarbeiten auf der Möllendorffstrasse in Richtung Süden zwischen Storkower Strasse und Scheffelstrasse.", + "impact": "low", + "alertc_codes": [ + 743 + ], + "geometry_index_start": 10, + "geometry_index_end": 15, + "creation_time": "2022-05-11T14:10:36Z", + "start_time": "2022-04-19T05:00:00Z", + "end_time": "2022-06-30T21:59:00Z", + "iso_3166_1_alpha2": "DE", + "iso_3166_1_alpha3": "DEU", + "lanes_blocked": [] + }, + { + "length": 30, + "affected_road_names": [ + "9th Street" + ], + "id": "11589180127444257", + "type": "lane_restriction", + "congestion": { + "value": 101 + }, + "description": "Rummelsburger Landstrasse: Bauarbeiten um Fritz-König-Weg", + "long_description": "Fahrbahnverengung von auf eine Fahrspur wegen Bauarbeiten auf der Rummelsburger Landstrasse in Richtung Süden zwischen Rummelsburger Landstrasse und Fritz-König-Weg.", + "impact": "low", + "alertc_codes": [ + 743 + ], + "geometry_index_start": 30, + "geometry_index_end": 38, + "creation_time": "2022-05-11T14:10:36Z", + "start_time": "2022-04-29T13:08:25Z", + "end_time": "2022-05-30T21:59:00Z", + "iso_3166_1_alpha2": "DE", + "iso_3166_1_alpha3": "DEU", + "lanes_blocked": [] + } + ], + "annotation": { + "maxspeed": [ + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 40, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + }, + { + "speed": 48, + "unit": "km/h" + } + ], + "speed": [ + 5, + 5, + 12.2, + 12.2, + 6.1, + 6.1, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 5.8, + 1.7, + 13.3, + 9.2, + 9.4, + 11.1, + 11.1, + 5.6, + 5.6, + 8.3, + 8.3, + 2.5, + 12.2, + 12.2, + 9.7, + 9.7, + 9.7, + 9.7, + 9.7, + 11.7, + 8.6, + 9.4, + 11.9, + 8.3, + 12.2, + 12.2, + 12.2, + 12.2 + ], + "distance": [ + 26.6, + 8.9, + 13.3, + 11.4, + 11.7, + 24.5, + 4, + 4.6, + 4.9, + 4.9, + 4.8, + 4.8, + 4.7, + 4, + 5.7, + 4.7, + 4.7, + 4.8, + 4.7, + 4.9, + 4.7, + 4.8, + 34.7, + 11.4, + 126.6, + 30, + 10.4, + 23.4, + 62, + 2.1, + 2.1, + 62, + 65.2, + 4.9, + 116.3, + 35.9, + 83.8, + 3.4, + 3.7, + 41.6, + 101, + 122.1, + 123.4, + 74.4, + 46, + 48.6, + 31.5, + 9.7, + 30.5 + ], + "duration": [ + 5.323, + 10.787, + 1.087, + 0.93, + 1.918, + 4.003, + 0.687, + 0.787, + 0.837, + 0.832, + 0.83, + 0.816, + 0.813, + 0.689, + 0.972, + 0.808, + 0.811, + 0.823, + 0.808, + 0.835, + 0.806, + 0.828, + 20.809, + 0.855, + 13.816, + 3.175, + 0.94, + 2.107, + 11.166, + 0.382, + 0.255, + 7.436, + 26.078, + 0.398, + 9.519, + 3.688, + 8.62, + 0.351, + 0.376, + 4.278, + 8.661, + 14.175, + 13.062, + 6.225, + 5.52, + 3.976, + 2.58, + 0.79, + 52.499 + ] + } + } + ] + } +} \ No newline at end of file diff --git a/instrumentation-tests/src/main/res/raw/route_response_single_route_refresh.json b/instrumentation-tests/src/main/res/raw/route_response_single_route_refresh.json new file mode 100644 index 00000000000..6c273810e6c --- /dev/null +++ b/instrumentation-tests/src/main/res/raw/route_response_single_route_refresh.json @@ -0,0 +1,1899 @@ +{ + "routes": [ + { + "weight_typical": 366.114, + "duration_typical": 260.004, + "weight_name": "auto", + "weight": 358.967, + "duration": 254.169, + "distance": 2045.35, + "legs": [ + { + "admins": [ + { + "iso_3166_1_alpha3": "USA", + "iso_3166_1": "US" + } + ], + "incidents": [ + { + "length": 50, + "affected_road_names": [ + "9th Street" + ], + "id": "11589180127444257", + "type": "lane_restriction", + "congestion": { + "value": 20 + }, + "description": "Rummelsburger Landstrasse: Bauarbeiten um Fritz-K\u00f6nig-Weg", + "long_description": "Fahrbahnverengung von auf eine Fahrspur wegen Bauarbeiten auf der Rummelsburger Landstrasse in Richtung S\u00fcden zwischen Rummelsburger Landstrasse und Fritz-K\u00f6nig-Weg.", + "impact": "low", + "alertc_codes": [ + 743 + ], + "geometry_index_start": 3, + "geometry_index_end": 8, + "creation_time": "2022-05-11T14:10:36Z", + "start_time": "2022-04-29T13:08:25Z", + "end_time": "3022-05-30T21:59:00Z", + "iso_3166_1_alpha2": "US", + "iso_3166_1_alpha3": "USA", + "lanes_blocked": [] + } + ], + "annotation": { + "maxspeed": [ + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + } + ], + "congestion": [ + "unknown", + "unknown", + "unknown", + "unknown", + "moderate", + "moderate", + "unknown", + "unknown", + "unknown", + "unknown", + "unknown", + "low", + "low", + "low", + "unknown", + "unknown", + "unknown", + "low", + "low", + "low", + "unknown", + "unknown", + "low", + "low", + "low", + "unknown", + "unknown", + "unknown", + "unknown", + "unknown", + "low", + "low", + "low", + "low", + "low", + "low", + "unknown", + "unknown", + "unknown", + "unknown", + "low", + "low", + "low", + "low", + "unknown", + "unknown", + "unknown", + "unknown", + "low" + ], + "speed": [ + 4.2, + 4.2, + 10.6, + 10.6, + 9.4, + 9.4, + 6.1, + 9.7, + 9.7, + 9.7, + 9.7, + 7.5, + 7.5, + 7.5, + 11.4, + 11.4, + 11.4, + 12.8, + 12.8, + 12.8, + 6.9, + 6.9, + 10.3, + 5.8, + 10, + 10.8, + 11.7, + 11.7, + 11.7, + 8.9, + 10.3, + 10.3, + 9.7, + 11.1, + 11.1, + 5.6, + 11.9, + 11.9, + 13.9, + 13.9, + 13.6, + 13.6, + 16.1, + 11.7, + 9.7, + 9.4, + 9.7, + 9.7, + 9.4 + ], + "distance": [ + 27.2, + 8.9, + 13.3, + 11.4, + 10.7, + 109.9, + 121.8, + 6.9, + 4, + 100.3, + 10.6, + 11.2, + 101.6, + 10.2, + 10.1, + 126.8, + 11, + 9.3, + 102, + 11.1, + 11.5, + 110.5, + 63.9, + 58.7, + 64.3, + 48.2, + 18.8, + 3.7, + 14.6, + 113.9, + 19.6, + 45.6, + 64.3, + 26.4, + 38.4, + 64.4, + 48.6, + 31.5, + 9.7, + 30.5, + 50.8, + 13.8, + 61.4, + 122.1, + 34.1, + 29.9, + 7.8, + 8, + 9.2 + ], + "duration": [ + 6.518, + 2.144, + 1.258, + 1.077, + 1.134, + 11.638, + 19.931, + 0.709, + 0.409, + 10.317, + 1.093, + 1.5, + 13.541, + 1.362, + 0.89, + 11.132, + 0.969, + 0.727, + 7.985, + 0.87, + 1.654, + 15.913, + 6.217, + 10.058, + 6.432, + 4.449, + 1.614, + 0.32, + 1.252, + 12.814, + 1.903, + 4.442, + 6.616, + 2.379, + 3.459, + 11.6, + 4.068, + 2.64, + 0.695, + 2.199, + 3.735, + 1.013, + 3.81, + 10.466, + 3.505, + 3.165, + 0.801, + 0.826, + 0.975 + ] + }, + "weight_typical": 366.114, + "duration_typical": 260.004, + "weight": 358.967, + "duration": 254.169, + "steps": [ + { + "voiceInstructions": [ + { + "ssmlAnnouncement": "Drive south on 9th Street. Then, in 600 feet, Turn left onto N Street.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Drive south on 9th Street. Then, in 600 feet, Turn left onto N Street.", + "distanceAlongGeometry": 182.142 + }, + { + "ssmlAnnouncement": "Turn left onto N Street, U.S. 40 Historic.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Turn left onto N Street, U.S. 40 Historic.", + "distanceAlongGeometry": 63.333 + } + ], + "intersections": [ + { + "entry": [ + true + ], + "bearings": [ + 199 + ], + "duration": 8.674, + "mapbox_streets_v8": { + "class": "street" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 10.626, + "geometry_index": 0, + "location": [ + -121.496066, + 38.577764 + ] + }, + { + "entry": [ + false, + true + ], + "in": 0, + "bearings": [ + 19, + 199 + ], + "duration": 4.376, + "turn_weight": 2, + "turn_duration": 2.007, + "mapbox_streets_v8": { + "class": "street" + }, + "is_urban": true, + "admin_index": 0, + "out": 1, + "weight": 4.901, + "geometry_index": 2, + "location": [ + -121.496199, + 38.577457 + ] + }, + { + "bearings": [ + 18, + 199 + ], + "entry": [ + false, + true + ], + "in": 0, + "turn_weight": 2, + "turn_duration": 2.007, + "mapbox_streets_v8": { + "class": "street" + }, + "is_urban": true, + "admin_index": 0, + "out": 1, + "geometry_index": 4, + "location": [ + -121.496289, + 38.577247 + ] + } + ], + "bannerInstructions": [ + { + "primary": { + "components": [ + { + "type": "text", + "text": "N Street" + }, + { + "type": "delimiter", + "text": "\/" + }, + { + "type": "icon", + "text": "US 40 Historic" + } + ], + "type": "turn", + "modifier": "left", + "text": "N Street \/ US 40 Historic" + }, + "distanceAlongGeometry": 182.142 + } + ], + "speedLimitUnit": "mph", + "maneuver": { + "type": "depart", + "instruction": "Drive south on 9th Street.", + "bearing_after": 199, + "bearing_before": 0, + "location": [ + -121.496066, + 38.577764 + ] + }, + "speedLimitSign": "mutcd", + "name": "9th Street", + "weight_typical": 33.221, + "duration_typical": 27.869, + "duration": 27.869, + "distance": 182.142, + "driving_side": "right", + "weight": 33.221, + "mode": "driving", + "geometry": "gerqhAb_pvfFlMfEvC`A`F`B`EpAtDnAny@bX" + }, + { + "voiceInstructions": [ + { + "ssmlAnnouncement": "Continue for a half mile.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Continue for a half mile.", + "distanceAlongGeometry": 868.667 + }, + { + "ssmlAnnouncement": "In a quarter mile, Turn left onto 16th Street.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "In a quarter mile, Turn left onto 16th Street.", + "distanceAlongGeometry": 402.336 + }, + { + "ssmlAnnouncement": "Turn left onto 16th Street, California 1 60.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Turn left onto 16th Street, California 1 60.", + "distanceAlongGeometry": 80 + } + ], + "intersections": [ + { + "entry": [ + false, + true + ], + "in": 0, + "bearings": [ + 19, + 109 + ], + "duration": 25.358, + "turn_weight": 20, + "turn_duration": 5.395, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 1, + "weight": 44.455, + "geometry_index": 6, + "location": [ + -121.496731, + 38.57622 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 0.739, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.882, + "geometry_index": 7, + "location": [ + -121.495405, + 38.57587 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 110, + 289 + ], + "duration": 0.419, + "turn_weight": 2, + "turn_duration": 0.007, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.504, + "geometry_index": 8, + "location": [ + -121.49533, + 38.57585 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 290 + ], + "duration": 10.306, + "turn_weight": 0.5, + "turn_duration": 0.021, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 13.1, + "geometry_index": 9, + "location": [ + -121.495287, + 38.575838 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 110, + 289 + ], + "duration": 1.139, + "turn_weight": 0.5, + "turn_duration": 0.007, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 1.886, + "geometry_index": 10, + "location": [ + -121.494198, + 38.575543 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 108, + 290 + ], + "duration": 1.487, + "turn_weight": 0.5, + "turn_duration": 0.021, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.297, + "geometry_index": 11, + "location": [ + -121.494083, + 38.575511 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 288 + ], + "duration": 13.619, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 17.16, + "geometry_index": 12, + "location": [ + -121.49396, + 38.57548 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 112, + 289 + ], + "duration": 1.342, + "turn_weight": 0.5, + "turn_duration": 0.009, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.133, + "geometry_index": 13, + "location": [ + -121.492854, + 38.575189 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 106, + 292 + ], + "duration": 0.904, + "turn_weight": 2, + "turn_duration": 0.026, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 3.076, + "geometry_index": 14, + "location": [ + -121.492745, + 38.575155 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 286 + ], + "duration": 11.159, + "turn_weight": 0.5, + "turn_duration": 0.008, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 14.16, + "geometry_index": 15, + "location": [ + -121.492633, + 38.57513 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 0.985, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 1.683, + "geometry_index": 16, + "location": [ + -121.491254, + 38.574763 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 0.723, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.863, + "geometry_index": 17, + "location": [ + -121.491134, + 38.574731 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 8.002, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 10.279, + "geometry_index": 18, + "location": [ + -121.491033, + 38.574704 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 0.88, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 3.055, + "geometry_index": 19, + "location": [ + -121.489923, + 38.574409 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 1.603, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.44, + "geometry_index": 20, + "location": [ + -121.489802, + 38.574377 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 16.003, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 21.58, + "geometry_index": 21, + "location": [ + -121.489677, + 38.574344 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 108, + 289 + ], + "duration": 6.246, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 9.628, + "geometry_index": 22, + "location": [ + -121.488475, + 38.574024 + ] + }, + { + "bearings": [ + 110, + 288 + ], + "entry": [ + true, + false + ], + "in": 1, + "turn_weight": 0.5, + "turn_duration": 0.007, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "geometry_index": 23, + "location": [ + -121.487777, + 38.573846 + ] + } + ], + "bannerInstructions": [ + { + "primary": { + "components": [ + { + "type": "text", + "text": "16th Street" + }, + { + "type": "delimiter", + "text": "\/" + }, + { + "imageBaseURL": "https:\/\/mapbox-navigation-shields.s3.amazonaws.com\/public\/shields\/v4\/US\/ca-160", + "type": "icon", + "text": "CA 160" + } + ], + "type": "turn", + "modifier": "left", + "text": "16th Street \/ CA 160" + }, + "distanceAlongGeometry": 882 + } + ], + "speedLimitUnit": "mph", + "maneuver": { + "type": "turn", + "instruction": "Turn left onto N Street\/US 40 Historic.", + "modifier": "left", + "bearing_after": 109, + "bearing_before": 199, + "location": [ + -121.496731, + 38.57622 + ] + }, + "speedLimitSign": "mutcd", + "name": "N Street", + "weight_typical": 168.071, + "duration_typical": 111.038, + "duration": 111.038, + "distance": 882, + "driving_side": "right", + "weight": 168.071, + "mode": "driving", + "ref": "US 40 Historic", + "geometry": "wdoqhAthqvfFzT{qAf@uCVuAlQacA~@eF|@uFdQcdAbAyEp@_F|UeuA~@oFt@iElQkdA~@qF`AyF~RcjAbJsj@`Juf@" + }, + { + "voiceInstructions": [ + { + "ssmlAnnouncement": "In a quarter mile, Turn right onto J Street.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "In a quarter mile, Turn right onto J Street.", + "distanceAlongGeometry": 508.666 + }, + { + "ssmlAnnouncement": "Turn right onto J Street.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Turn right onto J Street.", + "distanceAlongGeometry": 66.667 + } + ], + "intersections": [ + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 290 + ], + "duration": 12.022, + "turn_weight": 7.5, + "turn_duration": 5.622, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 15.34, + "geometry_index": 24, + "location": [ + -121.487142, + 38.573669 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 199 + ], + "duration": 4.45, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 7.428, + "geometry_index": 25, + "location": [ + -121.486904, + 38.574216 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 199 + ], + "duration": 1.991, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.415, + "geometry_index": 26, + "location": [ + -121.486726, + 38.574626 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 199 + ], + "duration": 1.305, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.075, + "geometry_index": 28, + "location": [ + -121.486643, + 38.574818 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 18, + 199 + ], + "duration": 12.844, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 17.711, + "geometry_index": 29, + "location": [ + -121.486588, + 38.574942 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 18, + 198 + ], + "duration": 3.965, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.384, + "geometry_index": 30, + "location": [ + -121.486187, + 38.575916 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 18, + 198 + ], + "duration": 4.495, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 5.983, + "geometry_index": 31, + "location": [ + -121.486117, + 38.576083 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 198 + ], + "duration": 6.602, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 10.064, + "geometry_index": 32, + "location": [ + -121.485951, + 38.576472 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 199 + ], + "duration": 4.359, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.866, + "geometry_index": 33, + "location": [ + -121.485713, + 38.577019 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 20, + 199 + ], + "duration": 3.427, + "turn_weight": 0.5, + "turn_duration": 0.007, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.69, + "geometry_index": 34, + "location": [ + -121.485616, + 38.577244 + ] + }, + { + "bearings": [ + 19, + 200 + ], + "entry": [ + true, + false + ], + "in": 1, + "turn_weight": 2, + "turn_duration": 0.021, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "geometry_index": 35, + "location": [ + -121.485467, + 38.577569 + ] + } + ], + "bannerInstructions": [ + { + "primary": { + "components": [ + { + "type": "text", + "text": "J Street" + } + ], + "type": "turn", + "modifier": "right", + "text": "J Street" + }, + "distanceAlongGeometry": 522 + } + ], + "speedLimitUnit": "mph", + "maneuver": { + "type": "turn", + "instruction": "Turn left onto 16th Street\/CA 160\/US 40 Historic.", + "modifier": "left", + "bearing_after": 19, + "bearing_before": 110, + "location": [ + -121.487142, + 38.573669 + ] + }, + "speedLimitSign": "mutcd", + "name": "16th Street", + "weight_typical": 93.067, + "duration_typical": 67, + "duration": 67, + "distance": 522, + "driving_side": "right", + "weight": 93.067, + "mode": "driving", + "ref": "CA 160; US 40 Historic", + "geometry": "iejqhAjq~ufFea@{MsXcJ_IkC_AYwFmB{{@aXmIkCiWkIea@{MaMaEiSiHia@uM" + }, + { + "voiceInstructions": [ + { + "ssmlAnnouncement": "In a quarter mile, Your destination will be on the right.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "In a quarter mile, Your destination will be on the right.", + "distanceAlongGeometry": 443.209 + }, + { + "ssmlAnnouncement": "Your destination is on the right.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Your destination is on the right.", + "distanceAlongGeometry": 68.056 + } + ], + "intersections": [ + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 199 + ], + "duration": 8.107, + "turn_weight": 8, + "turn_duration": 4.005, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 13.025, + "geometry_index": 36, + "location": [ + -121.485232, + 38.578118 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 108, + 289 + ], + "duration": 2.698, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 3.782, + "geometry_index": 37, + "location": [ + -121.484704, + 38.577976 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 288 + ], + "duration": 0.739, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 1.382, + "geometry_index": 38, + "location": [ + -121.48436, + 38.577887 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 2.251, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 3.234, + "geometry_index": 39, + "location": [ + -121.484255, + 38.577859 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 5.766, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 6.59, + "geometry_index": 40, + "location": [ + -121.483923, + 38.57777 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 1.048, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 1.76, + "geometry_index": 41, + "location": [ + -121.48337, + 38.577623 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 3.805, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 5.138, + "geometry_index": 42, + "location": [ + -121.48322, + 38.577583 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 12.476, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 14.81, + "geometry_index": 43, + "location": [ + -121.482552, + 38.577406 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 5.516, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 6.284, + "geometry_index": 44, + "location": [ + -121.481224, + 38.577052 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 3.196, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.391, + "geometry_index": 45, + "location": [ + -121.480853, + 38.576954 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 1.665, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.516, + "geometry_index": 46, + "location": [ + -121.480528, + 38.576867 + ] + }, + { + "bearings": [ + 108, + 289 + ], + "entry": [ + true, + false + ], + "in": 1, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "geometry_index": 48, + "location": [ + -121.480356, + 38.576821 + ] + } + ], + "bannerInstructions": [ + { + "primary": { + "components": [ + { + "type": "text", + "text": "Your destination will be on the right" + } + ], + "type": "arrive", + "modifier": "right", + "text": "Your destination will be on the right" + }, + "distanceAlongGeometry": 459.209 + }, + { + "primary": { + "components": [ + { + "type": "text", + "text": "Your destination is on the right" + } + ], + "type": "arrive", + "modifier": "right", + "text": "Your destination is on the right" + }, + "distanceAlongGeometry": 68.056 + } + ], + "speedLimitUnit": "mph", + "maneuver": { + "type": "turn", + "instruction": "Turn right onto J Street.", + "modifier": "right", + "bearing_after": 109, + "bearing_before": 19, + "location": [ + -121.485232, + 38.578118 + ] + }, + "speedLimitSign": "mutcd", + "name": "J Street", + "weight_typical": 71.754, + "duration_typical": 54.096, + "duration": 48.262, + "distance": 459.209, + "driving_side": "right", + "weight": 64.607, + "mode": "driving", + "geometry": "k{rqhA~yzufFzG_`@pDoTv@qEpDwSdHqa@nAkH`Jwh@bU_rAbEeVlDiSj@iDn@mDr@gE" + }, + { + "voiceInstructions": [], + "intersections": [ + { + "bearings": [ + 288 + ], + "entry": [ + true + ], + "in": 0, + "admin_index": 0, + "geometry_index": 49, + "location": [ + -121.480256, + 38.576795 + ] + } + ], + "bannerInstructions": [], + "speedLimitUnit": "mph", + "maneuver": { + "type": "arrive", + "instruction": "Your destination is on the right.", + "modifier": "right", + "bearing_after": 0, + "bearing_before": 108, + "location": [ + -121.480256, + 38.576795 + ] + }, + "speedLimitSign": "mutcd", + "name": "J Street", + "weight_typical": 0, + "duration_typical": 0, + "duration": 0, + "distance": 0, + "driving_side": "right", + "weight": 0, + "mode": "driving", + "geometry": "uhpqhA~bqufF??" + } + ], + "distance": 2045.35, + "summary": "N Street, 16th Street" + } + ], + "geometry": "gerqhAb_pvfFlMfEvC`A`F`B`EpAtDnAny@bXzT{qAf@uCVuAlQacA~@eF|@uFdQcdAbAyEp@_F|UeuA~@oFt@iElQkdA~@qF`AyF~RcjAbJsj@`Juf@ea@{MsXcJ_IkC_AYwFmB{{@aXmIkCiWkIea@{MaMaEiSiHia@uMzG_`@pDoTv@qEpDwSdHqa@nAkH`Jwh@bU_rAbEeVlDiSj@iDn@mDr@gE", + "voiceLocale": "en-US" + } + ], + "waypoints": [ + { + "distance": 8.347, + "name": "9th Street", + "location": [ + -121.496066, + 38.577764 + ] + }, + { + "distance": 6.435, + "name": "J Street", + "location": [ + -121.480256, + 38.576795 + ] + } + ], + "code": "Ok", + "uuid": "route_response_single_route_refresh" +} \ No newline at end of file diff --git a/instrumentation-tests/src/main/res/raw/route_response_single_route_refresh_without_traffic.json b/instrumentation-tests/src/main/res/raw/route_response_single_route_refresh_without_traffic.json new file mode 100644 index 00000000000..dd3b7ae5a2d --- /dev/null +++ b/instrumentation-tests/src/main/res/raw/route_response_single_route_refresh_without_traffic.json @@ -0,0 +1,1848 @@ +{ + "routes": [ + { + "weight_typical": 366.114, + "duration_typical": 260.004, + "weight_name": "auto", + "weight": 358.967, + "duration": 254.169, + "distance": 2045.35, + "legs": [ + { + "admins": [ + { + "iso_3166_1_alpha3": "USA", + "iso_3166_1": "US" + } + ], + "incidents": [ + { + "length": 50, + "affected_road_names": [ + "9th Street" + ], + "id": "11589180127444257", + "type": "lane_restriction", + "congestion": { + "value": 20 + }, + "description": "Rummelsburger Landstrasse: Bauarbeiten um Fritz-K\u00f6nig-Weg", + "long_description": "Fahrbahnverengung von auf eine Fahrspur wegen Bauarbeiten auf der Rummelsburger Landstrasse in Richtung S\u00fcden zwischen Rummelsburger Landstrasse und Fritz-K\u00f6nig-Weg.", + "impact": "low", + "alertc_codes": [ + 743 + ], + "geometry_index_start": 3, + "geometry_index_end": 8, + "creation_time": "2022-05-11T14:10:36Z", + "start_time": "2022-04-29T13:08:25Z", + "end_time": "3022-05-30T21:59:00Z", + "iso_3166_1_alpha2": "US", + "iso_3166_1_alpha3": "USA", + "lanes_blocked": [] + } + ], + "annotation": { + "maxspeed": [ + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 40, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "speed": 48, + "unit": "km\/h" + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + }, + { + "unknown": true + } + ], + "speed": [ + 4.2, + 4.2, + 10.6, + 10.6, + 9.4, + 9.4, + 6.1, + 9.7, + 9.7, + 9.7, + 9.7, + 7.5, + 7.5, + 7.5, + 11.4, + 11.4, + 11.4, + 12.8, + 12.8, + 12.8, + 6.9, + 6.9, + 10.3, + 5.8, + 10, + 10.8, + 11.7, + 11.7, + 11.7, + 8.9, + 10.3, + 10.3, + 9.7, + 11.1, + 11.1, + 5.6, + 11.9, + 11.9, + 13.9, + 13.9, + 13.6, + 13.6, + 16.1, + 11.7, + 9.7, + 9.4, + 9.7, + 9.7, + 9.4 + ], + "distance": [ + 27.2, + 8.9, + 13.3, + 11.4, + 10.7, + 109.9, + 121.8, + 6.9, + 4, + 100.3, + 10.6, + 11.2, + 101.6, + 10.2, + 10.1, + 126.8, + 11, + 9.3, + 102, + 11.1, + 11.5, + 110.5, + 63.9, + 58.7, + 64.3, + 48.2, + 18.8, + 3.7, + 14.6, + 113.9, + 19.6, + 45.6, + 64.3, + 26.4, + 38.4, + 64.4, + 48.6, + 31.5, + 9.7, + 30.5, + 50.8, + 13.8, + 61.4, + 122.1, + 34.1, + 29.9, + 7.8, + 8, + 9.2 + ], + "duration": [ + 6.518, + 2.144, + 1.258, + 1.077, + 1.134, + 11.638, + 19.931, + 0.709, + 0.409, + 10.317, + 1.093, + 1.5, + 13.541, + 1.362, + 0.89, + 11.132, + 0.969, + 0.727, + 7.985, + 0.87, + 1.654, + 15.913, + 6.217, + 10.058, + 6.432, + 4.449, + 1.614, + 0.32, + 1.252, + 12.814, + 1.903, + 4.442, + 6.616, + 2.379, + 3.459, + 11.6, + 4.068, + 2.64, + 0.695, + 2.199, + 3.735, + 1.013, + 3.81, + 10.466, + 3.505, + 3.165, + 0.801, + 0.826, + 0.975 + ] + }, + "weight_typical": 366.114, + "duration_typical": 260.004, + "weight": 358.967, + "duration": 254.169, + "steps": [ + { + "voiceInstructions": [ + { + "ssmlAnnouncement": "Drive south on 9th Street. Then, in 600 feet, Turn left onto N Street.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Drive south on 9th Street. Then, in 600 feet, Turn left onto N Street.", + "distanceAlongGeometry": 182.142 + }, + { + "ssmlAnnouncement": "Turn left onto N Street, U.S. 40 Historic.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Turn left onto N Street, U.S. 40 Historic.", + "distanceAlongGeometry": 63.333 + } + ], + "intersections": [ + { + "entry": [ + true + ], + "bearings": [ + 199 + ], + "duration": 8.674, + "mapbox_streets_v8": { + "class": "street" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 10.626, + "geometry_index": 0, + "location": [ + -121.496066, + 38.577764 + ] + }, + { + "entry": [ + false, + true + ], + "in": 0, + "bearings": [ + 19, + 199 + ], + "duration": 4.376, + "turn_weight": 2, + "turn_duration": 2.007, + "mapbox_streets_v8": { + "class": "street" + }, + "is_urban": true, + "admin_index": 0, + "out": 1, + "weight": 4.901, + "geometry_index": 2, + "location": [ + -121.496199, + 38.577457 + ] + }, + { + "bearings": [ + 18, + 199 + ], + "entry": [ + false, + true + ], + "in": 0, + "turn_weight": 2, + "turn_duration": 2.007, + "mapbox_streets_v8": { + "class": "street" + }, + "is_urban": true, + "admin_index": 0, + "out": 1, + "geometry_index": 4, + "location": [ + -121.496289, + 38.577247 + ] + } + ], + "bannerInstructions": [ + { + "primary": { + "components": [ + { + "type": "text", + "text": "N Street" + }, + { + "type": "delimiter", + "text": "\/" + }, + { + "type": "icon", + "text": "US 40 Historic" + } + ], + "type": "turn", + "modifier": "left", + "text": "N Street \/ US 40 Historic" + }, + "distanceAlongGeometry": 182.142 + } + ], + "speedLimitUnit": "mph", + "maneuver": { + "type": "depart", + "instruction": "Drive south on 9th Street.", + "bearing_after": 199, + "bearing_before": 0, + "location": [ + -121.496066, + 38.577764 + ] + }, + "speedLimitSign": "mutcd", + "name": "9th Street", + "weight_typical": 33.221, + "duration_typical": 27.869, + "duration": 27.869, + "distance": 182.142, + "driving_side": "right", + "weight": 33.221, + "mode": "driving", + "geometry": "gerqhAb_pvfFlMfEvC`A`F`B`EpAtDnAny@bX" + }, + { + "voiceInstructions": [ + { + "ssmlAnnouncement": "Continue for a half mile.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Continue for a half mile.", + "distanceAlongGeometry": 868.667 + }, + { + "ssmlAnnouncement": "In a quarter mile, Turn left onto 16th Street.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "In a quarter mile, Turn left onto 16th Street.", + "distanceAlongGeometry": 402.336 + }, + { + "ssmlAnnouncement": "Turn left onto 16th Street, California 1 60.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Turn left onto 16th Street, California 1 60.", + "distanceAlongGeometry": 80 + } + ], + "intersections": [ + { + "entry": [ + false, + true + ], + "in": 0, + "bearings": [ + 19, + 109 + ], + "duration": 25.358, + "turn_weight": 20, + "turn_duration": 5.395, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 1, + "weight": 44.455, + "geometry_index": 6, + "location": [ + -121.496731, + 38.57622 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 0.739, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.882, + "geometry_index": 7, + "location": [ + -121.495405, + 38.57587 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 110, + 289 + ], + "duration": 0.419, + "turn_weight": 2, + "turn_duration": 0.007, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.504, + "geometry_index": 8, + "location": [ + -121.49533, + 38.57585 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 290 + ], + "duration": 10.306, + "turn_weight": 0.5, + "turn_duration": 0.021, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 13.1, + "geometry_index": 9, + "location": [ + -121.495287, + 38.575838 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 110, + 289 + ], + "duration": 1.139, + "turn_weight": 0.5, + "turn_duration": 0.007, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 1.886, + "geometry_index": 10, + "location": [ + -121.494198, + 38.575543 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 108, + 290 + ], + "duration": 1.487, + "turn_weight": 0.5, + "turn_duration": 0.021, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.297, + "geometry_index": 11, + "location": [ + -121.494083, + 38.575511 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 288 + ], + "duration": 13.619, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 17.16, + "geometry_index": 12, + "location": [ + -121.49396, + 38.57548 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 112, + 289 + ], + "duration": 1.342, + "turn_weight": 0.5, + "turn_duration": 0.009, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.133, + "geometry_index": 13, + "location": [ + -121.492854, + 38.575189 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 106, + 292 + ], + "duration": 0.904, + "turn_weight": 2, + "turn_duration": 0.026, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 3.076, + "geometry_index": 14, + "location": [ + -121.492745, + 38.575155 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 286 + ], + "duration": 11.159, + "turn_weight": 0.5, + "turn_duration": 0.008, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 14.16, + "geometry_index": 15, + "location": [ + -121.492633, + 38.57513 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 0.985, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 1.683, + "geometry_index": 16, + "location": [ + -121.491254, + 38.574763 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 0.723, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.863, + "geometry_index": 17, + "location": [ + -121.491134, + 38.574731 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 8.002, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 10.279, + "geometry_index": 18, + "location": [ + -121.491033, + 38.574704 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 0.88, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 3.055, + "geometry_index": 19, + "location": [ + -121.489923, + 38.574409 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 1.603, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.44, + "geometry_index": 20, + "location": [ + -121.489802, + 38.574377 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 16.003, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 21.58, + "geometry_index": 21, + "location": [ + -121.489677, + 38.574344 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 108, + 289 + ], + "duration": 6.246, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 9.628, + "geometry_index": 22, + "location": [ + -121.488475, + 38.574024 + ] + }, + { + "bearings": [ + 110, + 288 + ], + "entry": [ + true, + false + ], + "in": 1, + "turn_weight": 0.5, + "turn_duration": 0.007, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "geometry_index": 23, + "location": [ + -121.487777, + 38.573846 + ] + } + ], + "bannerInstructions": [ + { + "primary": { + "components": [ + { + "type": "text", + "text": "16th Street" + }, + { + "type": "delimiter", + "text": "\/" + }, + { + "imageBaseURL": "https:\/\/mapbox-navigation-shields.s3.amazonaws.com\/public\/shields\/v4\/US\/ca-160", + "type": "icon", + "text": "CA 160" + } + ], + "type": "turn", + "modifier": "left", + "text": "16th Street \/ CA 160" + }, + "distanceAlongGeometry": 882 + } + ], + "speedLimitUnit": "mph", + "maneuver": { + "type": "turn", + "instruction": "Turn left onto N Street\/US 40 Historic.", + "modifier": "left", + "bearing_after": 109, + "bearing_before": 199, + "location": [ + -121.496731, + 38.57622 + ] + }, + "speedLimitSign": "mutcd", + "name": "N Street", + "weight_typical": 168.071, + "duration_typical": 111.038, + "duration": 111.038, + "distance": 882, + "driving_side": "right", + "weight": 168.071, + "mode": "driving", + "ref": "US 40 Historic", + "geometry": "wdoqhAthqvfFzT{qAf@uCVuAlQacA~@eF|@uFdQcdAbAyEp@_F|UeuA~@oFt@iElQkdA~@qF`AyF~RcjAbJsj@`Juf@" + }, + { + "voiceInstructions": [ + { + "ssmlAnnouncement": "In a quarter mile, Turn right onto J Street.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "In a quarter mile, Turn right onto J Street.", + "distanceAlongGeometry": 508.666 + }, + { + "ssmlAnnouncement": "Turn right onto J Street.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Turn right onto J Street.", + "distanceAlongGeometry": 66.667 + } + ], + "intersections": [ + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 290 + ], + "duration": 12.022, + "turn_weight": 7.5, + "turn_duration": 5.622, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 15.34, + "geometry_index": 24, + "location": [ + -121.487142, + 38.573669 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 199 + ], + "duration": 4.45, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 7.428, + "geometry_index": 25, + "location": [ + -121.486904, + 38.574216 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 199 + ], + "duration": 1.991, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.415, + "geometry_index": 26, + "location": [ + -121.486726, + 38.574626 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 199 + ], + "duration": 1.305, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.075, + "geometry_index": 28, + "location": [ + -121.486643, + 38.574818 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 18, + 199 + ], + "duration": 12.844, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 17.711, + "geometry_index": 29, + "location": [ + -121.486588, + 38.574942 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 18, + 198 + ], + "duration": 3.965, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.384, + "geometry_index": 30, + "location": [ + -121.486187, + 38.575916 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 18, + 198 + ], + "duration": 4.495, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 5.983, + "geometry_index": 31, + "location": [ + -121.486117, + 38.576083 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 198 + ], + "duration": 6.602, + "turn_weight": 2, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 10.064, + "geometry_index": 32, + "location": [ + -121.485951, + 38.576472 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 19, + 199 + ], + "duration": 4.359, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.866, + "geometry_index": 33, + "location": [ + -121.485713, + 38.577019 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 20, + 199 + ], + "duration": 3.427, + "turn_weight": 0.5, + "turn_duration": 0.007, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.69, + "geometry_index": 34, + "location": [ + -121.485616, + 38.577244 + ] + }, + { + "bearings": [ + 19, + 200 + ], + "entry": [ + true, + false + ], + "in": 1, + "turn_weight": 2, + "turn_duration": 0.021, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "geometry_index": 35, + "location": [ + -121.485467, + 38.577569 + ] + } + ], + "bannerInstructions": [ + { + "primary": { + "components": [ + { + "type": "text", + "text": "J Street" + } + ], + "type": "turn", + "modifier": "right", + "text": "J Street" + }, + "distanceAlongGeometry": 522 + } + ], + "speedLimitUnit": "mph", + "maneuver": { + "type": "turn", + "instruction": "Turn left onto 16th Street\/CA 160\/US 40 Historic.", + "modifier": "left", + "bearing_after": 19, + "bearing_before": 110, + "location": [ + -121.487142, + 38.573669 + ] + }, + "speedLimitSign": "mutcd", + "name": "16th Street", + "weight_typical": 93.067, + "duration_typical": 67, + "duration": 67, + "distance": 522, + "driving_side": "right", + "weight": 93.067, + "mode": "driving", + "ref": "CA 160; US 40 Historic", + "geometry": "iejqhAjq~ufFea@{MsXcJ_IkC_AYwFmB{{@aXmIkCiWkIea@{MaMaEiSiHia@uM" + }, + { + "voiceInstructions": [ + { + "ssmlAnnouncement": "In a quarter mile, Your destination will be on the right.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "In a quarter mile, Your destination will be on the right.", + "distanceAlongGeometry": 443.209 + }, + { + "ssmlAnnouncement": "Your destination is on the right.<\/prosody><\/amazon:effect><\/speak>", + "announcement": "Your destination is on the right.", + "distanceAlongGeometry": 68.056 + } + ], + "intersections": [ + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 199 + ], + "duration": 8.107, + "turn_weight": 8, + "turn_duration": 4.005, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 13.025, + "geometry_index": 36, + "location": [ + -121.485232, + 38.578118 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 108, + 289 + ], + "duration": 2.698, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 3.782, + "geometry_index": 37, + "location": [ + -121.484704, + 38.577976 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 288 + ], + "duration": 0.739, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 1.382, + "geometry_index": 38, + "location": [ + -121.48436, + 38.577887 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 2.251, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 3.234, + "geometry_index": 39, + "location": [ + -121.484255, + 38.577859 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 5.766, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 6.59, + "geometry_index": 40, + "location": [ + -121.483923, + 38.57777 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 1.048, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 1.76, + "geometry_index": 41, + "location": [ + -121.48337, + 38.577623 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 3.805, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 5.138, + "geometry_index": 42, + "location": [ + -121.48322, + 38.577583 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 12.476, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 14.81, + "geometry_index": 43, + "location": [ + -121.482552, + 38.577406 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 5.516, + "turn_weight": 2, + "turn_duration": 2.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 6.284, + "geometry_index": 44, + "location": [ + -121.481224, + 38.577052 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 3.196, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 4.391, + "geometry_index": 45, + "location": [ + -121.480853, + 38.576954 + ] + }, + { + "entry": [ + true, + false + ], + "in": 1, + "bearings": [ + 109, + 289 + ], + "duration": 1.665, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "weight": 2.516, + "geometry_index": 46, + "location": [ + -121.480528, + 38.576867 + ] + }, + { + "bearings": [ + 108, + 289 + ], + "entry": [ + true, + false + ], + "in": 1, + "turn_weight": 0.5, + "turn_duration": 0.019, + "mapbox_streets_v8": { + "class": "secondary" + }, + "is_urban": true, + "admin_index": 0, + "out": 0, + "geometry_index": 48, + "location": [ + -121.480356, + 38.576821 + ] + } + ], + "bannerInstructions": [ + { + "primary": { + "components": [ + { + "type": "text", + "text": "Your destination will be on the right" + } + ], + "type": "arrive", + "modifier": "right", + "text": "Your destination will be on the right" + }, + "distanceAlongGeometry": 459.209 + }, + { + "primary": { + "components": [ + { + "type": "text", + "text": "Your destination is on the right" + } + ], + "type": "arrive", + "modifier": "right", + "text": "Your destination is on the right" + }, + "distanceAlongGeometry": 68.056 + } + ], + "speedLimitUnit": "mph", + "maneuver": { + "type": "turn", + "instruction": "Turn right onto J Street.", + "modifier": "right", + "bearing_after": 109, + "bearing_before": 19, + "location": [ + -121.485232, + 38.578118 + ] + }, + "speedLimitSign": "mutcd", + "name": "J Street", + "weight_typical": 71.754, + "duration_typical": 54.096, + "duration": 48.262, + "distance": 459.209, + "driving_side": "right", + "weight": 64.607, + "mode": "driving", + "geometry": "k{rqhA~yzufFzG_`@pDoTv@qEpDwSdHqa@nAkH`Jwh@bU_rAbEeVlDiSj@iDn@mDr@gE" + }, + { + "voiceInstructions": [], + "intersections": [ + { + "bearings": [ + 288 + ], + "entry": [ + true + ], + "in": 0, + "admin_index": 0, + "geometry_index": 49, + "location": [ + -121.480256, + 38.576795 + ] + } + ], + "bannerInstructions": [], + "speedLimitUnit": "mph", + "maneuver": { + "type": "arrive", + "instruction": "Your destination is on the right.", + "modifier": "right", + "bearing_after": 0, + "bearing_before": 108, + "location": [ + -121.480256, + 38.576795 + ] + }, + "speedLimitSign": "mutcd", + "name": "J Street", + "weight_typical": 0, + "duration_typical": 0, + "duration": 0, + "distance": 0, + "driving_side": "right", + "weight": 0, + "mode": "driving", + "geometry": "uhpqhA~bqufF??" + } + ], + "distance": 2045.35, + "summary": "N Street, 16th Street" + } + ], + "geometry": "gerqhAb_pvfFlMfEvC`A`F`B`EpAtDnAny@bXzT{qAf@uCVuAlQacA~@eF|@uFdQcdAbAyEp@_F|UeuA~@oFt@iElQkdA~@qF`AyF~RcjAbJsj@`Juf@ea@{MsXcJ_IkC_AYwFmB{{@aXmIkCiWkIea@{MaMaEiSiHia@uMzG_`@pDoTv@qEpDwSdHqa@nAkH`Jwh@bU_rAbEeVlDiSj@iDn@mDr@gE", + "voiceLocale": "en-US" + } + ], + "waypoints": [ + { + "distance": 8.347, + "name": "9th Street", + "location": [ + -121.496066, + 38.577764 + ] + }, + { + "distance": 6.435, + "name": "J Street", + "location": [ + -121.480256, + 38.576795 + ] + } + ], + "code": "Ok", + "uuid": "route_response_single_route_refresh_without_traffic" +} \ No newline at end of file diff --git a/libnavigation-core/api/current.txt b/libnavigation-core/api/current.txt index a44335082fc..4476de0207a 100644 --- a/libnavigation-core/api/current.txt +++ b/libnavigation-core/api/current.txt @@ -26,6 +26,7 @@ package com.mapbox.navigation.core { method public com.mapbox.navigation.core.reroute.NavigationRerouteController? getRerouteController(); method public com.mapbox.navigation.core.trip.session.eh.RoadObjectMatcher getRoadObjectMatcher(); method public com.mapbox.navigation.core.trip.session.eh.RoadObjectsStore getRoadObjectsStore(); + method public com.mapbox.navigation.core.routerefresh.RouteRefreshController getRouteRefreshController(); 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(); @@ -55,7 +56,6 @@ package com.mapbox.navigation.core { method public void registerRouteAlternativesObserver(com.mapbox.navigation.core.routealternatives.RouteAlternativesObserver routeAlternativesObserver); method public void registerRouteAlternativesObserver(com.mapbox.navigation.core.routealternatives.NavigationRouteAlternativesObserver routeAlternativesObserver); 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); @@ -98,7 +98,6 @@ package com.mapbox.navigation.core { method public void unregisterRouteAlternativesObserver(com.mapbox.navigation.core.routealternatives.RouteAlternativesObserver routeAlternativesObserver); method public void unregisterRouteAlternativesObserver(com.mapbox.navigation.core.routealternatives.NavigationRouteAlternativesObserver routeAlternativesObserver); 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); @@ -111,6 +110,7 @@ package com.mapbox.navigation.core { property public final com.mapbox.navigation.base.options.NavigationOptions navigationOptions; property public final com.mapbox.navigation.core.trip.session.eh.RoadObjectMatcher roadObjectMatcher; property public final com.mapbox.navigation.core.trip.session.eh.RoadObjectsStore roadObjectsStore; + property public final com.mapbox.navigation.core.routerefresh.RouteRefreshController routeRefreshController; property public final com.mapbox.navigation.core.navigator.TilesetDescriptorFactory tilesetDescriptorFactory; } @@ -529,6 +529,9 @@ package com.mapbox.navigation.core.replay.history { property public final Double? time; } + public final class ReplayEventLocationMapperKt { + } + public final class ReplayEventUpdateLocation implements com.mapbox.navigation.core.replay.history.ReplayEventBase { ctor public ReplayEventUpdateLocation(@com.google.gson.annotations.SerializedName("event_timestamp") double eventTimestamp, @com.google.gson.annotations.SerializedName("location") com.mapbox.navigation.core.replay.history.ReplayEventLocation location); method public double component1(); @@ -879,15 +882,22 @@ package com.mapbox.navigation.core.routeoptions { package com.mapbox.navigation.core.routerefresh { + public final class RouteRefreshController { + method public void registerRouteRefreshStateObserver(com.mapbox.navigation.core.routerefresh.RouteRefreshStatesObserver routeRefreshStatesObserver); + method public void requestImmediateRouteRefresh(); + method public void unregisterRouteRefreshStateObserver(com.mapbox.navigation.core.routerefresh.RouteRefreshStatesObserver routeRefreshStatesObserver); + } + @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class RouteRefreshExtra { field public static final com.mapbox.navigation.core.routerefresh.RouteRefreshExtra INSTANCE; field public static final String REFRESH_STATE_CANCELED = "CANCELED"; + field public static final String REFRESH_STATE_CLEARED_EXPIRED = "CLEARED_EXPIRED"; field public static final String REFRESH_STATE_FINISHED_FAILED = "FINISHED_FAILED"; field public static final String REFRESH_STATE_FINISHED_SUCCESS = "FINISHED_SUCCESS"; field public static final String REFRESH_STATE_STARTED = "STARTED"; } - @StringDef({com.mapbox.navigation.core.routerefresh.RouteRefreshExtra.REFRESH_STATE_STARTED, com.mapbox.navigation.core.routerefresh.RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, com.mapbox.navigation.core.routerefresh.RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, com.mapbox.navigation.core.routerefresh.RouteRefreshExtra.REFRESH_STATE_CANCELED}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public static @interface RouteRefreshExtra.RouteRefreshState { + @StringDef({com.mapbox.navigation.core.routerefresh.RouteRefreshExtra.REFRESH_STATE_STARTED, com.mapbox.navigation.core.routerefresh.RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, com.mapbox.navigation.core.routerefresh.RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, com.mapbox.navigation.core.routerefresh.RouteRefreshExtra.REFRESH_STATE_CLEARED_EXPIRED, com.mapbox.navigation.core.routerefresh.RouteRefreshExtra.REFRESH_STATE_CANCELED}) @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public static @interface RouteRefreshExtra.RouteRefreshState { } @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class RouteRefreshStateResult { 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 58655e55bde..8e492dc23f4 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 @@ -86,7 +86,6 @@ import com.mapbox.navigation.core.routealternatives.RouteAlternativesRequestCall import com.mapbox.navigation.core.routeoptions.RouteOptionsUpdater import com.mapbox.navigation.core.routerefresh.RouteRefreshController import com.mapbox.navigation.core.routerefresh.RouteRefreshControllerProvider -import com.mapbox.navigation.core.routerefresh.RouteRefreshStatesObserver import com.mapbox.navigation.core.telemetry.MapboxNavigationTelemetry import com.mapbox.navigation.core.telemetry.events.FeedbackEvent import com.mapbox.navigation.core.telemetry.events.FeedbackHelper @@ -124,6 +123,7 @@ import com.mapbox.navigation.navigator.internal.NavigatorLoader import com.mapbox.navigation.navigator.internal.router.RouterInterfaceAdapter import com.mapbox.navigation.utils.internal.ConnectivityHandler import com.mapbox.navigation.utils.internal.ThreadController +import com.mapbox.navigation.utils.internal.Time import com.mapbox.navigation.utils.internal.ifNonNull import com.mapbox.navigation.utils.internal.logD import com.mapbox.navigation.utils.internal.logE @@ -141,7 +141,6 @@ import com.mapbox.navigator.SetRoutesReason import com.mapbox.navigator.TileEndpointConfiguration import com.mapbox.navigator.TilesConfig import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.launch @@ -267,8 +266,6 @@ class MapboxNavigation @VisibleForTesting internal constructor( private val internalOffRouteObserver: OffRouteObserver private val internalFallbackVersionsObserver: FallbackVersionsObserver private val routeAlternativesController: RouteAlternativesController - private val routeRefreshController: RouteRefreshController - private var routeRefreshScope = createChildScope() private val arrivalProgressObserver: ArrivalProgressObserver private val electronicHorizonOptions: ElectronicHorizonOptions = ElectronicHorizonOptions( navigationOptions.eHorizonOptions.length, @@ -378,6 +375,13 @@ class MapboxNavigation @VisibleForTesting internal constructor( */ val historyRecorder = MapboxHistoryRecorder(navigationOptions) + /** + * Use route refresh controller to handle route refreshes. + * @see [RouteRefreshController] for more details. + */ + @ExperimentalPreviewMapboxNavigationAPI + val routeRefreshController: RouteRefreshController + internal val copilotHistoryRecorder = MapboxHistoryRecorder(navigationOptions) /** @@ -552,13 +556,24 @@ class MapboxNavigation @VisibleForTesting internal constructor( tripSession, threadController, ) + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) routeRefreshController = RouteRefreshControllerProvider.createRouteRefreshController( + Dispatchers.Main, + Dispatchers.Main.immediate, navigationOptions.routeRefreshOptions, directionsSession, routeRefreshRequestDataProvider, routeAlternativesController, - evDynamicDataHolder + evDynamicDataHolder, + Time.SystemImpl ) + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) + routeRefreshController.registerRouteRefreshObserver { + internalSetNavigationRoutes( + it.allRoutesProgressData.map { pair -> pair.first }, + SetRoutes.RefreshRoutes(it.primaryRouteProgressData) + ) + } defaultRerouteController = MapboxRerouteController( directionsSession, @@ -1041,7 +1056,6 @@ class MapboxNavigation @VisibleForTesting internal constructor( // do not interrupt reroute when primary route has not changed } } - restartRouteRefreshScope() threadController.getMainScopeAndRootJob().scope.launch(Dispatchers.Main.immediate) { routeUpdateMutex.withLock { historyRecordingStateHandler.setRoutes(routes) @@ -1208,6 +1222,9 @@ class MapboxNavigation @VisibleForTesting internal constructor( historyRecordingStateHandler.unregisterAllStateChangeObservers() historyRecordingStateHandler.unregisterAllCopilotSessionObservers() developerMetadataAggregator.unregisterAllObservers() + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) + routeRefreshController.destroy() + routesPreviewController.unregisterAllRoutesPreviewObservers() runInTelemetryContext { telemetry -> telemetry.destroy(this@MapboxNavigation) } @@ -1217,8 +1234,6 @@ class MapboxNavigation @VisibleForTesting internal constructor( ReachabilityService.removeReachabilityObserver(it) reachabilityObserverId = null } - routeRefreshController.unregisterAllRouteRefreshStateObservers() - routesPreviewController.unregisterAllRoutesPreviewObservers() isDestroyed = true hasInstance = false @@ -1781,28 +1796,6 @@ class MapboxNavigation @VisibleForTesting internal constructor( navigationSession.unregisterNavigationSessionStateObserver(navigationSessionStateObserver) } - /** - * Register a [RouteRefreshStatesObserver] to be notified of Route refresh state changes. - * - * @param routeRefreshStatesObserver RouteRefreshStatesObserver - */ - @ExperimentalPreviewMapboxNavigationAPI - fun registerRouteRefreshStateObserver( - routeRefreshStatesObserver: RouteRefreshStatesObserver - ) { - routeRefreshController.registerRouteRefreshStateObserver(routeRefreshStatesObserver) - } - - /** - * Unregisters a [RouteRefreshStatesObserver]. - */ - @ExperimentalPreviewMapboxNavigationAPI - fun unregisterRouteRefreshStateObserver( - routeRefreshStatesObserver: RouteRefreshStatesObserver - ) { - routeRefreshController.unregisterRouteRefreshStateObserver(routeRefreshStatesObserver) - } - /** * Registers a [DeveloperMetadataObserver] to be notified of [DeveloperMetadata] changes. * @@ -1892,17 +1885,8 @@ class MapboxNavigation @VisibleForTesting internal constructor( private fun createInternalRoutesObserver() = RoutesObserver { result -> latestLegIndex = null routeRefreshRequestDataProvider.onNewRoutes() - if (result.navigationRoutes.isNotEmpty()) { - routeRefreshScope.launch { - val refreshed = routeRefreshController.refresh( - result.navigationRoutes - ) - internalSetNavigationRoutes( - refreshed.routes, - SetRoutes.RefreshRoutes(refreshed.routeProgressData), - ) - } - } + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) + routeRefreshController.requestPlannedRouteRefresh(result.navigationRoutes) } private fun createInternalOffRouteObserver() = OffRouteObserver { offRoute -> @@ -2066,13 +2050,6 @@ class MapboxNavigation @VisibleForTesting internal constructor( } } - private fun createChildScope() = threadController.getMainScopeAndRootJob().scope - - private fun restartRouteRefreshScope() { - routeRefreshScope.cancel() - routeRefreshScope = createChildScope() - } - @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) private fun setUpRouteCacheClearer() { registerRoutesObserver(routesCacheClearer) diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/RoutesProgressDataProvider.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/RoutesProgressDataProvider.kt index 546beaf36ae..26f6dc5a01d 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/RoutesProgressDataProvider.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/RoutesProgressDataProvider.kt @@ -18,6 +18,12 @@ internal class RoutesProgressDataProvider( private val alternativeMetadataProvider: AlternativeMetadataProvider, ) { + /** + * Retrieved progress data for passed routes. + * + * @throws IllegalArgumentException if routes are empty + */ + @Throws(IllegalArgumentException::class) suspend fun getRoutesProgressData( routes: List ): RoutesProgressData { diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/internal/utils/CoroutineUtils.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/internal/utils/CoroutineUtils.kt new file mode 100644 index 00000000000..ff846a5abf2 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/internal/utils/CoroutineUtils.kt @@ -0,0 +1,23 @@ +package com.mapbox.navigation.core.internal.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.job +import kotlin.coroutines.CoroutineContext + +internal object CoroutineUtils { + + fun createScope( + parentJob: Job, + additionalContext: CoroutineContext + ): CoroutineScope { + return CoroutineScope(SupervisorJob(parentJob) + additionalContext) + } + + fun createChildScope( + parentScope: CoroutineScope + ): CoroutineScope = CoroutineScope( + SupervisorJob(parentScope.coroutineContext.job) + parentScope.coroutineContext.minusKey(Job) + ) +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/ExpiringDataRemover.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/ExpiringDataRemover.kt new file mode 100644 index 00000000000..afb3d2f2f94 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/ExpiringDataRemover.kt @@ -0,0 +1,76 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.api.directions.v5.models.DirectionsRoute +import com.mapbox.api.directions.v5.models.RouteLeg +import com.mapbox.navigation.base.internal.route.update +import com.mapbox.navigation.base.internal.time.parseISO8601DateToLocalTimeOrNull +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.core.RoutesProgressData +import java.util.Date + +internal class ExpiringDataRemover( + private val localDateProvider: () -> Date, +) { + + fun removeExpiringDataFromRoutesProgressData( + routesProgressData: RoutesProgressData, + ): RoutesProgressData { + val primaryRoute = removeExpiringDataFromRoute( + routesProgressData.primaryRoute, + routesProgressData.primaryRouteProgressData.legIndex + ) + val alternativeRoutesData = routesProgressData.alternativeRoutesProgressData.map { + removeExpiringDataFromRoute(it.first, it.second?.legIndex ?: 0) to it.second + } + return RoutesProgressData( + primaryRoute, + routesProgressData.primaryRouteProgressData, + alternativeRoutesData + ) + } + + private fun removeExpiringDataFromRoute( + route: NavigationRoute, + currentLegIndex: Int, + ): NavigationRoute { + val routeLegs = route.directionsRoute.legs() + val directionsRouteBlock: DirectionsRoute.() -> DirectionsRoute = { + toBuilder().legs( + routeLegs?.mapIndexed { legIndex, leg -> + val legHasAlreadyBeenPassed = legIndex < currentLegIndex + if (legHasAlreadyBeenPassed) { + leg + } else { + removeExpiredDataFromLeg(leg) + } + } + ).build() + } + return route.update( + directionsRouteBlock = directionsRouteBlock, + directionsResponseBlock = { this } + ) + } + + private fun removeExpiredDataFromLeg(leg: RouteLeg): RouteLeg { + val oldAnnotation = leg.annotation() + return leg.toBuilder() + .annotation( + oldAnnotation?.let { nonNullOldAnnotation -> + nonNullOldAnnotation.toBuilder() + .congestion(nonNullOldAnnotation.congestion()?.map { "unknown" }) + .congestionNumeric(nonNullOldAnnotation.congestionNumeric()?.map { null }) + .build() + } + ) + .incidents( + leg.incidents()?.filter { + val parsed = parseISO8601DateToLocalTimeOrNull(it.endTime()) + ?: return@filter true + val currentDate = localDateProvider() + parsed > currentDate + } + ) + .build() + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/ImmediateRouteRefreshController.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/ImmediateRouteRefreshController.kt new file mode 100644 index 00000000000..ad045ddb590 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/ImmediateRouteRefreshController.kt @@ -0,0 +1,45 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.bindgen.Expected +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.utils.internal.logW +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +internal class ImmediateRouteRefreshController( + private val routeRefresherExecutor: RouteRefresherExecutor, + private val stateHolder: RouteRefreshStateHolder, + private val scope: CoroutineScope, + private val listener: RouteRefresherListener +) { + + @Throws(IllegalArgumentException::class) + fun requestRoutesRefresh( + routes: List, + callback: (Expected) -> Unit + ) { + if (routes.isEmpty()) { + throw IllegalArgumentException("Routes to refresh should not be empty") + } + scope.launch { + val result = routeRefresherExecutor.executeRoutesRefresh( + routes, + startCallback = { stateHolder.onStarted() } + ) + callback(result) + result.fold( + { logW("Route refresh on-demand error: $it", RouteRefreshLog.LOG_CATEGORY) }, + { + if (it.success) { + stateHolder.onSuccess() + } else { + stateHolder.onFailure(null) + } + listener.onRoutesRefreshed(it) + } + ) + } + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/PlannedRouteRefreshController.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/PlannedRouteRefreshController.kt new file mode 100644 index 00000000000..a44073836bf --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/PlannedRouteRefreshController.kt @@ -0,0 +1,159 @@ +package com.mapbox.navigation.core.routerefresh + +import androidx.annotation.VisibleForTesting +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.base.route.RouteRefreshOptions +import com.mapbox.navigation.core.internal.utils.CoroutineUtils +import com.mapbox.navigation.utils.internal.logI +import com.mapbox.navigation.utils.internal.logW +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +internal class PlannedRouteRefreshController @VisibleForTesting constructor( + private val routeRefresherExecutor: RouteRefresherExecutor, + private val routeRefreshOptions: RouteRefreshOptions, + private val stateHolder: RouteRefreshStateHolder, + private val listener: RouteRefresherListener, + private val parentScope: CoroutineScope, + private val retryStrategy: RetryRouteRefreshStrategy, +) { + + constructor( + routeRefresherExecutor: RouteRefresherExecutor, + routeRefreshOptions: RouteRefreshOptions, + stateHolder: RouteRefreshStateHolder, + parentScope: CoroutineScope, + listener: RouteRefresherListener, + ) : this( + routeRefresherExecutor, + routeRefreshOptions, + stateHolder, + listener, + parentScope, + RetryRouteRefreshStrategy(maxAttemptsCount = MAX_RETRY_COUNT) + ) + + private var plannedRefreshScope = CoroutineUtils.createChildScope(parentScope) + private var paused = false + var routesToRefresh: List? = null + private set + + fun startRoutesRefreshing(routes: List) { + recreateScope() + routesToRefresh = null + if (routes.isEmpty()) { + logI("Routes are empty, nothing to refresh", RouteRefreshLog.LOG_CATEGORY) + stateHolder.reset() + return + } + val routesValidationResults = routes.map { RouteRefreshValidator.validateRoute(it) } + if ( + routesValidationResults.any { it is RouteRefreshValidator.RouteValidationResult.Valid } + ) { + routesToRefresh = routes + scheduleNewUpdate(routes) + } else { + val message = + RouteRefreshValidator.joinValidationErrorMessages( + routesValidationResults.mapIndexed { index, routeValidationResult -> + routeValidationResult to routes[index] + } + ) + val logMessage = "No routes which could be refreshed. $message" + logI(logMessage, RouteRefreshLog.LOG_CATEGORY) + stateHolder.onStarted() + stateHolder.onFailure(logMessage) + stateHolder.reset() + } + } + + fun pause() { + if (!paused) { + paused = true + recreateScope() + } + } + + fun resume() { + if (paused) { + paused = false + routesToRefresh?.let { + if (retryStrategy.shouldRetry()) { + scheduleUpdateRetry(it, shouldNotifyOnStart = true) + } + } + } + } + + private fun scheduleNewUpdate(routes: List) { + retryStrategy.reset() + postAttempt { + executePlannedRefresh(routes, shouldNotifyOnStart = true) + } + } + + private fun scheduleUpdateRetry(routes: List, shouldNotifyOnStart: Boolean) { + postAttempt { + retryStrategy.onNextAttempt() + executePlannedRefresh(routes, shouldNotifyOnStart = shouldNotifyOnStart) + } + } + + private fun postAttempt(attemptBlock: suspend () -> Unit) { + plannedRefreshScope.launch { + try { + delay(routeRefreshOptions.intervalMillis) + attemptBlock() + } catch (ex: CancellationException) { + stateHolder.onCancel() + throw ex + } + } + } + + private suspend fun executePlannedRefresh( + routes: List, + shouldNotifyOnStart: Boolean + ) { + val routeRefresherResult = routeRefresherExecutor.executeRoutesRefresh( + routes, + startCallback = { + if (shouldNotifyOnStart) { + stateHolder.onStarted() + } + } + ) + routeRefresherResult.fold( + { logW("Planned route refresh error: $it", RouteRefreshLog.LOG_CATEGORY) }, + { + if (it.success) { + stateHolder.onSuccess() + listener.onRoutesRefreshed(it) + } else { + if (retryStrategy.shouldRetry()) { + scheduleUpdateRetry(routes, shouldNotifyOnStart = false) + } else { + stateHolder.onFailure(null) + listener.onRoutesRefreshed(it) + scheduleNewUpdate(routes) + } + } + } + ) + } + + private fun recreateScope() { + plannedRefreshScope.cancel() + plannedRefreshScope = CoroutineUtils.createChildScope(parentScope) + } + + companion object { + + const val MAX_RETRY_COUNT = 2 + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RefreshObserversManager.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RefreshObserversManager.kt new file mode 100644 index 00000000000..e71473cc90d --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RefreshObserversManager.kt @@ -0,0 +1,32 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.core.RoutesProgressData +import java.util.concurrent.CopyOnWriteArraySet + +internal fun interface RouteRefreshObserver { + + fun onRoutesRefreshed(routeInfo: RoutesProgressData) +} + +internal class RefreshObserversManager { + + private val refreshObservers = CopyOnWriteArraySet() + + fun registerObserver(observer: RouteRefreshObserver) { + refreshObservers.add(observer) + } + + fun unregisterObserver(observer: RouteRefreshObserver) { + refreshObservers.remove(observer) + } + + fun unregisterAllObservers() { + refreshObservers.clear() + } + + fun onRoutesRefreshed(result: RouteRefresherResult) { + refreshObservers.forEach { observer -> + observer.onRoutesRefreshed(result.refreshedRoutesData) + } + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RefreshedRouteInfo.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RefreshedRouteInfo.kt deleted file mode 100644 index 8a6394f2e84..00000000000 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RefreshedRouteInfo.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.mapbox.navigation.core.routerefresh - -import com.mapbox.navigation.base.route.NavigationRoute -import com.mapbox.navigation.core.RouteProgressData - -internal data class RefreshedRouteInfo( - val routes: List, - val routeProgressData: RouteProgressData, -) diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RetryRouteRefreshStrategy.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RetryRouteRefreshStrategy.kt new file mode 100644 index 00000000000..46dc35a731b --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RetryRouteRefreshStrategy.kt @@ -0,0 +1,20 @@ +package com.mapbox.navigation.core.routerefresh + +internal class RetryRouteRefreshStrategy( + private val maxAttemptsCount: Int +) { + + private var attemptNumber = 0 + + fun reset() { + attemptNumber = 0 + } + + fun shouldRetry(): Boolean { + return attemptNumber < maxAttemptsCount + } + + fun onNextAttempt() { + attemptNumber++ + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt index 048ab6627b9..263023ab66c 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt @@ -1,360 +1,88 @@ package com.mapbox.navigation.core.routerefresh -import android.util.Log -import androidx.annotation.VisibleForTesting -import com.mapbox.api.directions.v5.models.DirectionsRoute -import com.mapbox.api.directions.v5.models.RouteLeg import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI -import com.mapbox.navigation.base.internal.RouteRefreshRequestData -import com.mapbox.navigation.base.internal.route.update -import com.mapbox.navigation.base.internal.time.parseISO8601DateToLocalTimeOrNull import com.mapbox.navigation.base.route.NavigationRoute -import com.mapbox.navigation.base.route.NavigationRouterRefreshCallback -import com.mapbox.navigation.base.route.NavigationRouterRefreshError -import com.mapbox.navigation.base.route.RouteRefreshOptions -import com.mapbox.navigation.core.RouteProgressData -import com.mapbox.navigation.core.RoutesProgressData -import com.mapbox.navigation.core.RoutesProgressDataProvider -import com.mapbox.navigation.core.directions.session.RouteRefresh -import com.mapbox.navigation.core.ev.EVRefreshDataProvider -import com.mapbox.navigation.utils.internal.logE +import com.mapbox.navigation.core.directions.session.RoutesObserver import com.mapbox.navigation.utils.internal.logI -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeoutOrNull -import java.util.Date -import java.util.concurrent.CopyOnWriteArraySet -import kotlin.coroutines.resume +import kotlinx.coroutines.Job /** - * This class is responsible for refreshing the current direction route's traffic. - * This does not support alternative routes. + * This class lets you manage route refreshes. */ -@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) -internal class RouteRefreshController( - private val routeRefreshOptions: RouteRefreshOptions, - private val routeRefresh: RouteRefresh, - private val routesProgressDataProvider: RoutesProgressDataProvider, - private val evRefreshDataProvider: EVRefreshDataProvider, - private val routeDiffProvider: DirectionsRouteDiffProvider = DirectionsRouteDiffProvider(), - private val localDateProvider: () -> Date, +@ExperimentalPreviewMapboxNavigationAPI +class RouteRefreshController internal constructor( + private val routeRefreshParentJob: Job, + private val plannedRouteRefreshController: PlannedRouteRefreshController, + private val immediateRouteRefreshController: ImmediateRouteRefreshController, + private val stateHolder: RouteRefreshStateHolder, + private val refreshObserversManager: RefreshObserversManager, + private val routeRefresherResultProcessor: RouteRefresherResultProcessor, ) { - private var state: RouteRefreshStateResult? = null - set(value) { - if (field == value) return - field = value - value?.let { nonNullValue -> - observers.forEach { - it.onNewState(nonNullValue) - } - } - } - - private val observers = CopyOnWriteArraySet() - - internal companion object { - @VisibleForTesting - internal const val LOG_CATEGORY = "RouteRefreshController" - - @VisibleForTesting - internal const val FAILED_ATTEMPTS_TO_INVALIDATE_EXPIRING_DATA = 3 - } - - suspend fun refresh(routes: List): RefreshedRouteInfo { - try { - return if (routes.isNotEmpty()) { - val routesValidationResults = routes.map { validateRoute(it) } - if (routesValidationResults.any { it is RouteValidationResult.Valid }) { - tryRefreshingRoutesUntilRouteChanges(routes) - } else { - val message = joinValidationErrorMessages(routesValidationResults, routes) - onNewState( - RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, - "No routes which could be refreshed. $message" - ) - waitForever("No routes which could be refreshed. $message") - } - } else { - resetState() - waitForever("routes are empty") - } - } catch (e: CancellationException) { - onNewStateIfCurrentIs( - RouteRefreshExtra.REFRESH_STATE_CANCELED, - current = RouteRefreshExtra.REFRESH_STATE_STARTED, - ) - resetState() - throw e - } - } - - fun registerRouteRefreshStateObserver(observer: RouteRefreshStatesObserver) { - observers.add(observer) - state?.let { observer.onNewState(it) } - } - - fun unregisterRouteRefreshStateObserver(observer: RouteRefreshStatesObserver) { - observers.remove(observer) - } - - fun unregisterAllRouteRefreshStateObservers() { - observers.clear() - } - - private fun onNewState( - @RouteRefreshExtra.RouteRefreshState state: String, - message: String? = null + /** + * Register a [RouteRefreshStatesObserver] to be notified of Route refresh state changes. + * + * @param routeRefreshStatesObserver RouteRefreshStatesObserver + */ + fun registerRouteRefreshStateObserver( + routeRefreshStatesObserver: RouteRefreshStatesObserver ) { - this.state = RouteRefreshStateResult(state, message) + stateHolder.registerRouteRefreshStateObserver(routeRefreshStatesObserver) } - private fun onNewStateIfCurrentIs( - @RouteRefreshExtra.RouteRefreshState state: String, - message: String? = null, - @RouteRefreshExtra.RouteRefreshState current: String, + /** + * Unregisters a [RouteRefreshStatesObserver]. + * + * @param routeRefreshStatesObserver RouteRefreshStatesObserver + */ + fun unregisterRouteRefreshStateObserver( + routeRefreshStatesObserver: RouteRefreshStatesObserver ) { - if (current == this.state?.state) { - onNewState(state, message) - } - } - - private fun resetState() { - this.state = null - } - - private fun joinValidationErrorMessages( - routeValidation: List, - routes: List - ): String = routeValidation.filterIsInstance() - .mapIndexed { index, validation -> "${routes[index].id} ${validation.reason}" } - .joinToString(separator = ". ") - - private suspend fun tryRefreshingRoutesUntilRouteChanges( - initialRoutes: List - ): RefreshedRouteInfo { - while (true) { - val refreshed = refreshRoutesWithRetry(initialRoutes) - if (refreshed.routes != initialRoutes) { - return refreshed - } + stateHolder.unregisterRouteRefreshStateObserver(routeRefreshStatesObserver) + } + + /** + * Immediately refresh current navigation routes. + * Listen for refreshed routes using [RoutesObserver]. + * + * The on-demand refresh request is not guaranteed to succeed and call the [RoutesObserver], + * [requestImmediateRouteRefresh] invocations cannot be coupled with + * [RoutesObserver.onRoutesChanged] callbacks for state management. + * You can use [registerRouteRefreshStateObserver] to monitor refresh statuses independently. + */ + fun requestImmediateRouteRefresh() { + val routes = plannedRouteRefreshController.routesToRefresh + if (routes.isNullOrEmpty()) { + logI("No routes to refresh", RouteRefreshLog.LOG_CATEGORY) + stateHolder.onStarted() + stateHolder.onFailure("No routes to refresh") + return } - } - - private suspend fun refreshRoutesWithRetry( - routes: List // non-empty - ): RefreshedRouteInfo = coroutineScope { - var timeUntilNextAttempt = async { delay(routeRefreshOptions.intervalMillis) } - try { - repeat(FAILED_ATTEMPTS_TO_INVALIDATE_EXPIRING_DATA) { - timeUntilNextAttempt.await() - if (it == 0) { - onNewState(RouteRefreshExtra.REFRESH_STATE_STARTED) - } - timeUntilNextAttempt = async { delay(routeRefreshOptions.intervalMillis) } - val routesProgressData = routesProgressDataProvider - .getRoutesProgressData(routes) - val refreshedRoutes = refreshRoutesOrNull(routesProgressData) - if (refreshedRoutes.any { it != null }) { - onNewState(RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS) - return@coroutineScope RefreshedRouteInfo( - refreshedRoutes.mapIndexed { index, navigationRoute -> - navigationRoute ?: routes[index] - }, - routesProgressData.primaryRouteProgressData - ) - } - } - } finally { - timeUntilNextAttempt.cancel() // otherwise current coroutine will wait for its child - } - onNewState(RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED) - val routesProgressData = routesProgressDataProvider.getRoutesProgressData(routes) - RefreshedRouteInfo( - routesProgressData.allRoutesProgressData.map { - removeExpiringDataFromRoute(it.first, it.second?.legIndex ?: 0) - }, - routesProgressData.primaryRouteProgressData - ) - } - - private fun removeExpiringDataFromRoute( - route: NavigationRoute, - currentLegIndex: Int, - ): NavigationRoute { - val routeLegs = route.directionsRoute.legs() - val directionsRouteBlock: DirectionsRoute.() -> DirectionsRoute = { - toBuilder().legs( - routeLegs?.mapIndexed { legIndex, leg -> - val legHasAlreadyBeenPassed = legIndex < currentLegIndex - if (legHasAlreadyBeenPassed) { - leg - } else { - removeExpiredDataFromLeg(leg) - } - } - ).build() - } - return route.update( - directionsRouteBlock = directionsRouteBlock, - directionsResponseBlock = { this } - ) - } - - private fun removeExpiredDataFromLeg(leg: RouteLeg) = - leg.toBuilder() - .annotation( - leg.annotation()?.toBuilder() - ?.congestion(leg.annotation()?.congestion()?.map { "unknown" }) - ?.congestionNumeric( - leg.annotation()?.congestionNumeric()?.map { null } - ) - ?.build() - ) - .incidents( - leg.incidents()?.filter { - val parsed = parseISO8601DateToLocalTimeOrNull(it.endTime()) - ?: return@filter true - val currentDate = localDateProvider() - parsed > currentDate - } - ) - .build() - - private suspend fun refreshRouteOrNull( - route: NavigationRoute, - routeProgressData: RouteProgressData, - ): NavigationRoute? { - val validationResult = validateRoute(route) - if (validationResult is RouteValidationResult.Invalid) { - logI("route ${route.id} can't be refreshed because ${validationResult.reason}") - return null - } - val routeRefreshRequestData = RouteRefreshRequestData( - routeProgressData.legIndex, - routeProgressData.routeGeometryIndex, - routeProgressData.legGeometryIndex, - HashMap(evRefreshDataProvider.get(route.routeOptions)) - ) - return when (val result = requestRouteRefresh(route, routeRefreshRequestData)) { - is RouteRefreshResult.Fail -> { - logE( - "Route refresh error: ${result.error.message} " + - "throwable=${result.error.throwable}", - LOG_CATEGORY - ) - null - } - is RouteRefreshResult.Success -> { - logI("Received refreshed route ${result.route.id}", LOG_CATEGORY) - logRoutesDiff( - newRoute = result.route, - oldRoute = route, - currentLegIndex = routeRefreshRequestData.legIndex - ) - result.route + plannedRouteRefreshController.pause() + immediateRouteRefreshController.requestRoutesRefresh(routes) { + if (it.value?.success == false) { + plannedRouteRefreshController.resume() } } } - private suspend fun refreshRoutesOrNull( - routesData: RoutesProgressData, - ): List { - return coroutineScope { - routesData.allRoutesProgressData.map { routeData -> - async { - withTimeoutOrNull(routeRefreshOptions.intervalMillis) { - val routeProgressData = routeData.second - if (routeProgressData != null) { - refreshRouteOrNull(routeData.first, routeProgressData) - } else { - // No RouteProgressData - no refresh. Should not happen in production. - Log.w( - LOG_CATEGORY, - "Can't refresh route ${routeData.first.id}: " + - "no route progress data for it" - ) - null - } - } - } - }.awaitAll() - } - } - - private fun logRoutesDiff( - newRoute: NavigationRoute, - oldRoute: NavigationRoute, - currentLegIndex: Int, - ) { - val routeDiffs = routeDiffProvider.buildRouteDiffs( - oldRoute, - newRoute, - currentLegIndex, - ) - if (routeDiffs.isEmpty()) { - logI("No changes in annotations for route ${newRoute.id}", LOG_CATEGORY) - } else { - for (diff in routeDiffs) { - logI(diff, LOG_CATEGORY) - } - } - } - - private suspend fun requestRouteRefresh( - route: NavigationRoute, - routeRefreshRequestData: RouteRefreshRequestData - ): RouteRefreshResult = - suspendCancellableCoroutine { continuation -> - val requestId = routeRefresh.requestRouteRefresh( - route, - routeRefreshRequestData, - object : NavigationRouterRefreshCallback { - override fun onRefreshReady(route: NavigationRoute) { - continuation.resume(RouteRefreshResult.Success(route)) - } - - override fun onFailure(error: NavigationRouterRefreshError) { - continuation.resume(RouteRefreshResult.Fail(error)) - } - } - ) - continuation.invokeOnCancellation { - routeRefresh.cancelRouteRefreshRequest(requestId) - } - } - - private suspend fun waitForever(message: String): T { - logI("Route won't be refreshed because $message", LOG_CATEGORY) - return CompletableDeferred().await() + internal fun registerRouteRefreshObserver(observer: RouteRefreshObserver) { + refreshObserversManager.registerObserver(observer) } - private fun validateRoute(route: NavigationRoute): RouteValidationResult = when { - route.routeOptions.enableRefresh() != true -> - RouteValidationResult.Invalid("RouteOptions#enableRefresh is false") - route.directionsRoute.requestUuid()?.isNotBlank() != true -> - RouteValidationResult.Invalid( - "DirectionsRoute#requestUuid is blank. " + - "This can be caused by a route being generated by " + - "an Onboard router (in offline mode). " + - "Make sure to switch to an Offboard route when possible, " + - "only Offboard routes support the refresh feature." - ) - else -> RouteValidationResult.Valid + internal fun unregisterRouteRefreshObserver(observer: RouteRefreshObserver) { + refreshObserversManager.unregisterObserver(observer) } - private sealed class RouteValidationResult { - object Valid : RouteValidationResult() - data class Invalid(val reason: String) : RouteValidationResult() + internal fun destroy() { + refreshObserversManager.unregisterAllObservers() + stateHolder.unregisterAllRouteRefreshStateObservers() + // first unregister observers, then cancel scope - otherwise we dispatch CANCELLED state from onDestroy + routeRefreshParentJob.cancel() } - private sealed class RouteRefreshResult { - data class Success(val route: NavigationRoute) : RouteRefreshResult() - data class Fail(val error: NavigationRouterRefreshError) : RouteRefreshResult() + internal fun requestPlannedRouteRefresh(routes: List) { + routeRefresherResultProcessor.reset() + plannedRouteRefreshController.startRoutesRefreshing(routes) } } diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerProvider.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerProvider.kt index 4f7907911ee..159a33b1ee1 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerProvider.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerProvider.kt @@ -1,28 +1,76 @@ package com.mapbox.navigation.core.routerefresh +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.base.route.RouteRefreshOptions import com.mapbox.navigation.core.PrimaryRouteProgressDataProvider import com.mapbox.navigation.core.RoutesProgressDataProvider import com.mapbox.navigation.core.directions.session.DirectionsSession import com.mapbox.navigation.core.ev.EVDynamicDataHolder import com.mapbox.navigation.core.ev.EVRefreshDataProvider +import com.mapbox.navigation.core.internal.utils.CoroutineUtils import com.mapbox.navigation.core.routealternatives.AlternativeMetadataProvider +import com.mapbox.navigation.utils.internal.Time +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.SupervisorJob import java.util.Date internal object RouteRefreshControllerProvider { + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) fun createRouteRefreshController( + dispatcher: CoroutineDispatcher, + immediateDispatcher: CoroutineDispatcher, routeRefreshOptions: RouteRefreshOptions, directionsSession: DirectionsSession, primaryRouteProgressDataProvider: PrimaryRouteProgressDataProvider, alternativeMetadataProvider: AlternativeMetadataProvider, evDynamicDataHolder: EVDynamicDataHolder, - ) = RouteRefreshController( - routeRefreshOptions, - directionsSession, - RoutesProgressDataProvider(primaryRouteProgressDataProvider, alternativeMetadataProvider), - EVRefreshDataProvider(evDynamicDataHolder), - DirectionsRouteDiffProvider(), - { Date() }, - ) + timeProvider: Time + ): RouteRefreshController { + val routeRefresher = RouteRefresher( + RoutesProgressDataProvider( + primaryRouteProgressDataProvider, + alternativeMetadataProvider + ), + EVRefreshDataProvider(evDynamicDataHolder), + DirectionsRouteDiffProvider(), + directionsSession, + ) + val routeRefresherExecutor = RouteRefresherExecutor( + routeRefresher, + routeRefreshOptions.intervalMillis + ) + val stateHolder = RouteRefreshStateHolder() + val refreshObserversManager = RefreshObserversManager() + val routeRefresherResultProcessor = RouteRefresherResultProcessor( + stateHolder, + refreshObserversManager, + ExpiringDataRemover { Date() }, + timeProvider, + routeRefreshOptions.intervalMillis * 3 + ) + + val routeRefreshParentJob = SupervisorJob() + val plannedRouteRefreshController = PlannedRouteRefreshController( + routeRefresherExecutor, + routeRefreshOptions, + stateHolder, + CoroutineUtils.createScope(routeRefreshParentJob, immediateDispatcher), + routeRefresherResultProcessor + ) + val immediateRouteRefreshController = ImmediateRouteRefreshController( + routeRefresherExecutor, + stateHolder, + CoroutineUtils.createScope(routeRefreshParentJob, dispatcher), + routeRefresherResultProcessor + ) + return RouteRefreshController( + routeRefreshParentJob, + plannedRouteRefreshController, + immediateRouteRefreshController, + stateHolder, + refreshObserversManager, + routeRefresherResultProcessor + ) + } } diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshExtra.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshExtra.kt index 396cd9fc6af..5ef8580c667 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshExtra.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshExtra.kt @@ -2,7 +2,6 @@ package com.mapbox.navigation.core.routerefresh import androidx.annotation.StringDef import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI -import com.mapbox.navigation.base.route.RouteRefreshOptions /** * Extra data of route refresh @@ -22,12 +21,18 @@ object RouteRefreshExtra { const val REFRESH_STATE_FINISHED_SUCCESS = "FINISHED_SUCCESS" /** - * The state becomes [REFRESH_STATE_FINISHED_FAILED] when a route refresh failed and the route - * is cleaned up of expired data, see [RouteRefreshOptions] for details. - * The state is triggered in case if every single route refresh of a set of the routes is failed. + * The state becomes [REFRESH_STATE_FINISHED_FAILED] when a route refresh failed after all + * the retry attempts (whose number may differ depending on refresh mode: planned or on-demand). + * The state is triggered in case refresh of every route from routes failed. */ const val REFRESH_STATE_FINISHED_FAILED = "FINISHED_FAILED" + /** + * The state becomes [REFRESH_STATE_CLEARED_EXPIRED] when expired incidents and congestion + * annotations are removed from the route due to failure of a route refresh request. + */ + const val REFRESH_STATE_CLEARED_EXPIRED = "CLEARED_EXPIRED" + /** * The state becomes [REFRESH_STATE_CANCELED] when a route refresh canceled. It occurs * when a new set of routes are set, that leads to interrupt route refresh process. @@ -42,6 +47,7 @@ object RouteRefreshExtra { REFRESH_STATE_STARTED, REFRESH_STATE_FINISHED_SUCCESS, REFRESH_STATE_FINISHED_FAILED, + REFRESH_STATE_CLEARED_EXPIRED, REFRESH_STATE_CANCELED, ) annotation class RouteRefreshState diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshLog.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshLog.kt new file mode 100644 index 00000000000..ed99ef19607 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshLog.kt @@ -0,0 +1,6 @@ +package com.mapbox.navigation.core.routerefresh + +internal object RouteRefreshLog { + + const val LOG_CATEGORY = "RouteRefreshController" +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshProgressObserver.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshProgressObserver.kt new file mode 100644 index 00000000000..f9edecf935a --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshProgressObserver.kt @@ -0,0 +1,14 @@ +package com.mapbox.navigation.core.routerefresh + +internal interface RouteRefreshProgressObserver { + + fun onStarted() + + fun onSuccess() + + fun onFailure(message: String?) + + fun onClearedExpired() + + fun onCancel() +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshStateHolder.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshStateHolder.kt new file mode 100644 index 00000000000..052537cc76d --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshStateHolder.kt @@ -0,0 +1,68 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import java.util.concurrent.CopyOnWriteArraySet + +@ExperimentalPreviewMapboxNavigationAPI +internal class RouteRefreshStateHolder : RouteRefreshProgressObserver { + + private val observers = CopyOnWriteArraySet() + + private var state: RouteRefreshStateResult? = null + + override fun onStarted() { + onNewState(RouteRefreshExtra.REFRESH_STATE_STARTED) + } + + override fun onSuccess() { + onNewState(RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS) + } + + override fun onFailure(message: String?) { + onNewState(RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, message) + } + + override fun onClearedExpired() { + onNewState(RouteRefreshExtra.REFRESH_STATE_CLEARED_EXPIRED) + } + + override fun onCancel() { + // cancel can be invoked at any time because of coroutine cancellation: make sure the transition is valid + if (state?.state == RouteRefreshExtra.REFRESH_STATE_STARTED) { + onNewState(RouteRefreshExtra.REFRESH_STATE_CANCELED) + } + } + + fun reset() { + onNewState(null) + } + + fun registerRouteRefreshStateObserver(observer: RouteRefreshStatesObserver) { + observers.add(observer) + state?.let { observer.onNewState(it) } + } + + fun unregisterRouteRefreshStateObserver( + observer: RouteRefreshStatesObserver + ) { + observers.remove(observer) + } + + fun unregisterAllRouteRefreshStateObservers() { + observers.clear() + } + + private fun onNewState( + @RouteRefreshExtra.RouteRefreshState state: String?, + message: String? = null + ) { + val oldState = this.state?.state + if (oldState != state) { + val newState = state?.let { RouteRefreshStateResult(it, message) } + this.state = newState + if (newState != null) { + observers.forEach { it.onNewState(newState) } + } + } + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshValidator.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshValidator.kt new file mode 100644 index 00000000000..2e1d6e07415 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshValidator.kt @@ -0,0 +1,33 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.base.route.NavigationRoute + +internal object RouteRefreshValidator { + + fun joinValidationErrorMessages( + validations: List> + ): String = validations.mapNotNull { pair -> + (pair.first as? RouteValidationResult.Invalid)?.let { invalidResult -> + "${pair.second.id} ${invalidResult.reason}" + } + }.joinToString(separator = ". ") + + fun validateRoute(route: NavigationRoute): RouteValidationResult = when { + route.routeOptions.enableRefresh() != true -> + RouteValidationResult.Invalid("RouteOptions#enableRefresh is false") + route.directionsRoute.requestUuid()?.isNotBlank() != true -> + RouteValidationResult.Invalid( + "DirectionsRoute#requestUuid is blank. " + + "This can be caused by a route being generated by " + + "an Onboard router (in offline mode). " + + "Make sure to switch to an Offboard route when possible, " + + "only Offboard routes support the refresh feature." + ) + else -> RouteValidationResult.Valid + } + + sealed class RouteValidationResult { + object Valid : RouteValidationResult() + data class Invalid(val reason: String) : RouteValidationResult() + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresher.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresher.kt new file mode 100644 index 00000000000..753b81a3533 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresher.kt @@ -0,0 +1,189 @@ +package com.mapbox.navigation.core.routerefresh + +import android.util.Log +import com.mapbox.navigation.base.internal.RouteRefreshRequestData +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.base.route.NavigationRouterRefreshCallback +import com.mapbox.navigation.base.route.NavigationRouterRefreshError +import com.mapbox.navigation.core.RouteProgressData +import com.mapbox.navigation.core.RoutesProgressData +import com.mapbox.navigation.core.RoutesProgressDataProvider +import com.mapbox.navigation.core.directions.session.RouteRefresh +import com.mapbox.navigation.core.ev.EVRefreshDataProvider +import com.mapbox.navigation.utils.internal.logE +import com.mapbox.navigation.utils.internal.logI +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.coroutines.resume + +internal data class RouteRefresherResult( + val success: Boolean, + val refreshedRoutesData: RoutesProgressData, +) + +internal class RouteRefresher( + private val routesProgressDataProvider: RoutesProgressDataProvider, + private val evRefreshDataProvider: EVRefreshDataProvider, + private val routeDiffProvider: DirectionsRouteDiffProvider, + private val routeRefresh: RouteRefresh, +) { + + /** + * Refreshes routes. + * + * @throws IllegalArgumentException when routes are empty + */ + @Throws(IllegalArgumentException::class) + suspend fun refresh( + routes: List, + routeRefreshTimeout: Long + ): RouteRefresherResult { + val routesProgressData = routesProgressDataProvider.getRoutesProgressData(routes) + val refreshedRoutes = refreshRoutesOrNull(routesProgressData, routeRefreshTimeout) + return if (refreshedRoutes.any { it != null }) { + val primaryRoute = refreshedRoutes.first() ?: routes.first() + val alternativeRoutesProgressData = + routesProgressData.alternativeRoutesProgressData.mapIndexed { index, pair -> + (refreshedRoutes[index + 1] ?: routes[index + 1]) to pair.second + } + RouteRefresherResult( + success = true, + RoutesProgressData( + primaryRoute, + routesProgressData.primaryRouteProgressData, + alternativeRoutesProgressData + ) + ) + } else { + RouteRefresherResult( + success = false, + routesProgressData + ) + } + } + + private suspend fun refreshRoutesOrNull( + routesData: RoutesProgressData, + timeout: Long + ): List { + return coroutineScope { + routesData.allRoutesProgressData.map { routeData -> + async { + withTimeoutOrNull(timeout) { + val routeProgressData = routeData.second + if (routeProgressData != null) { + refreshRouteOrNull(routeData.first, routeProgressData) + } else { + // No RouteProgressData - no refresh. Should not happen in production. + Log.w( + RouteRefreshLog.LOG_CATEGORY, + "Can't refresh route ${routeData.first.id}: " + + "no route progress data for it" + ) + null + } + } + } + } + }.awaitAll() + } + + private suspend fun refreshRouteOrNull( + route: NavigationRoute, + routeProgressData: RouteProgressData, + ): NavigationRoute? { + val validationResult = RouteRefreshValidator.validateRoute(route) + if (validationResult is RouteRefreshValidator.RouteValidationResult.Invalid) { + logI( + "route ${route.id} can't be refreshed because ${validationResult.reason}", + RouteRefreshLog.LOG_CATEGORY + ) + return null + } + val routeRefreshRequestData = RouteRefreshRequestData( + routeProgressData.legIndex, + routeProgressData.routeGeometryIndex, + routeProgressData.legGeometryIndex, + evRefreshDataProvider.get(route.routeOptions) + ) + return when (val result = requestRouteRefresh(route, routeRefreshRequestData)) { + is RouteRefreshResult.Fail -> { + logE( + "Route refresh error: ${result.error.message} " + + "throwable=${result.error.throwable}", + RouteRefreshLog.LOG_CATEGORY + ) + null + } + is RouteRefreshResult.Success -> { + logI( + "Received refreshed route ${result.route.id}", + RouteRefreshLog.LOG_CATEGORY + ) + logRoutesDiff( + newRoute = result.route, + oldRoute = route, + currentLegIndex = routeRefreshRequestData.legIndex + ) + result.route + } + } + } + + private suspend fun requestRouteRefresh( + route: NavigationRoute, + routeRefreshRequestData: RouteRefreshRequestData + ): RouteRefreshResult = + suspendCancellableCoroutine { continuation -> + val requestId = routeRefresh.requestRouteRefresh( + route, + routeRefreshRequestData, + object : NavigationRouterRefreshCallback { + override fun onRefreshReady(route: NavigationRoute) { + continuation.resume(RouteRefreshResult.Success(route)) + } + + override fun onFailure(error: NavigationRouterRefreshError) { + continuation.resume(RouteRefreshResult.Fail(error)) + } + } + ) + continuation.invokeOnCancellation { + logI( + "Route refresh for route ${route.id} was cancelled after timeout", + RouteRefreshLog.LOG_CATEGORY + ) + routeRefresh.cancelRouteRefreshRequest(requestId) + } + } + + private fun logRoutesDiff( + newRoute: NavigationRoute, + oldRoute: NavigationRoute, + currentLegIndex: Int, + ) { + val routeDiffs = routeDiffProvider.buildRouteDiffs( + oldRoute, + newRoute, + currentLegIndex, + ) + if (routeDiffs.isEmpty()) { + logI( + "No changes in annotations for route ${newRoute.id}", + RouteRefreshLog.LOG_CATEGORY + ) + } else { + for (diff in routeDiffs) { + logI(diff, RouteRefreshLog.LOG_CATEGORY) + } + } + } + + private sealed class RouteRefreshResult { + data class Success(val route: NavigationRoute) : RouteRefreshResult() + data class Fail(val error: NavigationRouterRefreshError) : RouteRefreshResult() + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresherExecutor.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresherExecutor.kt new file mode 100644 index 00000000000..a5a5e380784 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresherExecutor.kt @@ -0,0 +1,30 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.ExpectedFactory +import com.mapbox.navigation.base.route.NavigationRoute + +internal class RouteRefresherExecutor( + private val routeRefresher: RouteRefresher, + private val timeout: Long, +) { + + private var hasCurrentRequest = false + + suspend fun executeRoutesRefresh( + routes: List, + startCallback: () -> Unit + ): Expected { + if (!hasCurrentRequest) { + hasCurrentRequest = true + startCallback() + try { + return ExpectedFactory.createValue(routeRefresher.refresh(routes, timeout)) + } finally { + hasCurrentRequest = false + } + } else { + return ExpectedFactory.createError("Skipping request as another one is in progress.") + } + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresherListener.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresherListener.kt new file mode 100644 index 00000000000..4b967c2f126 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresherListener.kt @@ -0,0 +1,5 @@ +package com.mapbox.navigation.core.routerefresh + +internal fun interface RouteRefresherListener { + fun onRoutesRefreshed(result: RouteRefresherResult) +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresherResultProcessor.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresherResultProcessor.kt new file mode 100644 index 00000000000..622a72ef601 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefresherResultProcessor.kt @@ -0,0 +1,43 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.utils.internal.Time + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +internal class RouteRefresherResultProcessor( + private val stateHolder: RouteRefreshStateHolder, + private val observersManager: RefreshObserversManager, + private val expiringDataRemover: ExpiringDataRemover, + private val timeProvider: Time, + private val staleDataTimeoutMillis: Long, +) : RouteRefresherListener { + + private var lastRefreshTimeMillis: Long = 0 + + fun reset() { + lastRefreshTimeMillis = timeProvider.millis() + } + + override fun onRoutesRefreshed(result: RouteRefresherResult) { + val currentTime = timeProvider.millis() + if (result.success) { + lastRefreshTimeMillis = currentTime + observersManager.onRoutesRefreshed(result) + } else { + if (currentTime >= lastRefreshTimeMillis + staleDataTimeoutMillis) { + lastRefreshTimeMillis = currentTime + val newRoutesData = expiringDataRemover.removeExpiringDataFromRoutesProgressData( + result.refreshedRoutesData + ) + stateHolder.onClearedExpired() + if (result.refreshedRoutesData != newRoutesData) { + val processedResult = RouteRefresherResult( + result.success, + newRoutesData + ) + observersManager.onRoutesRefreshed(processedResult) + } + } + } + } +} 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 1fd758e14cf..702afb819d5 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 @@ -8,6 +8,7 @@ import com.mapbox.android.core.location.LocationEngine import com.mapbox.annotation.module.MapboxModuleType import com.mapbox.common.MapboxSDKCommon import com.mapbox.common.module.provider.MapboxModuleProvider +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.base.TimeFormat import com.mapbox.navigation.base.formatter.DistanceFormatterOptions import com.mapbox.navigation.base.internal.extensions.inferDeviceLocale @@ -66,7 +67,7 @@ import org.junit.Rule import java.io.File import java.util.Locale -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalPreviewMapboxNavigationAPI::class) internal open class MapboxNavigationBaseTest { @get:Rule @@ -174,6 +175,9 @@ internal open class MapboxNavigationBaseTest { mockkObject(RouteRefreshControllerProvider) every { RouteRefreshControllerProvider.createRouteRefreshController( + any(), + any(), + any(), any(), any(), any(), 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 f61710d105e..bb49f10f6ae 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 @@ -16,7 +16,6 @@ import com.mapbox.navigation.core.arrival.ArrivalController import com.mapbox.navigation.core.arrival.ArrivalProgressObserver import com.mapbox.navigation.core.directions.session.DirectionsSessionRoutes import com.mapbox.navigation.core.directions.session.IgnoredRoute -import com.mapbox.navigation.core.directions.session.MapboxDirectionsSession import com.mapbox.navigation.core.directions.session.RoutesExtra import com.mapbox.navigation.core.directions.session.RoutesObserver import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult @@ -29,8 +28,7 @@ import com.mapbox.navigation.core.preview.RoutesPreview import com.mapbox.navigation.core.reroute.NavigationRerouteController import com.mapbox.navigation.core.reroute.RerouteController import com.mapbox.navigation.core.reroute.RerouteState -import com.mapbox.navigation.core.routerefresh.RefreshedRouteInfo -import com.mapbox.navigation.core.routerefresh.RouteRefreshStatesObserver +import com.mapbox.navigation.core.routerefresh.RouteRefreshObserver import com.mapbox.navigation.core.telemetry.MapboxNavigationTelemetry import com.mapbox.navigation.core.testutil.createRoutesUpdatedResult import com.mapbox.navigation.core.trip.session.LocationObserver @@ -65,7 +63,6 @@ import io.mockk.mockk import io.mockk.slot import io.mockk.verify import io.mockk.verifyOrder -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.delay @@ -723,7 +720,7 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { coVerifyOrder { routeProgressDataProvider.onNewRoutes() - routeRefreshController.refresh(routes) + routeRefreshController.requestPlannedRouteRefresh(routes) } } @@ -744,7 +741,7 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { coVerify(exactly = 1) { routeProgressDataProvider.onNewRoutes() } - coVerify(exactly = 0) { routeRefreshController.refresh(any()) } + coVerify(exactly = 1) { routeRefreshController.requestPlannedRouteRefresh(emptyList()) } } @Test @@ -1442,49 +1439,6 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { } } - @Test - fun `route refresh of previous route completes after new route is set`() = - coroutineRule.runBlockingTest { - every { NavigationComponentProvider.createDirectionsSession(any()) } answers { - MapboxDirectionsSession(mockk(relaxed = true)) - } - createMapboxNavigation() - val first = listOf(createNavigationRoute(createDirectionsRoute(requestUuid = "test1"))) - val second = listOf(createNavigationRoute(createDirectionsRoute(requestUuid = "test2"))) - val refreshOrFirstRoute = CompletableDeferred() - coEvery { routeRefreshController.refresh(any()) } coAnswers { - CompletableDeferred().await() // never completes - firstArg() - } - coEvery { routeRefreshController.refresh(first) } coAnswers { - refreshOrFirstRoute.await() - RefreshedRouteInfo( - listOf(createNavigationRoute(createDirectionsRoute(requestUuid = "test1.1"))), - RouteProgressData(1, 2, 3) - ) - } - coEvery { tripSession.setRoutes(second, any()) } coAnswers { - NativeSetRouteValue(second, emptyList()) - } - - val routesUpdates = mutableListOf() - mapboxNavigation.registerRoutesObserver { - routesUpdates.add(it) - } - mapboxNavigation.setNavigationRoutes(first) - mapboxNavigation.setNavigationRoutes(second) - refreshOrFirstRoute.complete(Unit) - - assertEquals( - listOf(first, second), - routesUpdates.map { it.navigationRoutes } - ) - assertEquals( - listOf(RoutesExtra.ROUTES_UPDATE_REASON_NEW, RoutesExtra.ROUTES_UPDATE_REASON_NEW), - routesUpdates.map { it.reason } - ) - } - @Test fun `set route - new routes immediately interrupts reroute`() = coroutineRule.runBlockingTest { createMapboxNavigation() @@ -1577,22 +1531,27 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { createMapboxNavigation() mapboxNavigation.setRerouteController(rerouteController) val initialRoutes = listOf(mockk(relaxed = true)) - val routeProgressData = RouteProgressData(5, 12, 43) - val refreshedRoutes = listOf(mockk(relaxed = true)) - coEvery { - routeRefreshController.refresh(initialRoutes) - } returns RefreshedRouteInfo(refreshedRoutes, routeProgressData) + val primaryRoute = mockk(relaxed = true) + val alternativeRoute = mockk(relaxed = true) + val primaryRouteProgressData = RouteProgressData(5, 12, 43) + val alternativeRouteProgressData = RouteProgressData(1, 2, 3) + val routesProgressData = RoutesProgressData( + primaryRoute, + primaryRouteProgressData, + listOf(alternativeRoute to alternativeRouteProgressData) + ) routeObserversSlot.forEach { it.onRoutesChanged( createRoutesUpdatedResult(initialRoutes, RoutesExtra.ROUTES_UPDATE_REASON_NEW) ) } + interceptRefreshObserver().onRoutesRefreshed(routesProgressData) coVerify(exactly = 1) { tripSession.setRoutes( - refreshedRoutes, - SetRoutes.RefreshRoutes(routeProgressData) + listOf(primaryRoute, alternativeRoute), + SetRoutes.RefreshRoutes(primaryRouteProgressData) ) } verify(exactly = 0) { rerouteController.interrupt() } @@ -1656,9 +1615,6 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { SetRoutes.RefreshRoutes(routeProgressData) ) } returns NativeSetRouteError("some error") - coEvery { - routeRefreshController.refresh(routes) - } returns RefreshedRouteInfo(refreshedRoutes, routeProgressData) verify { directionsSession.registerRoutesObserver(capture(routeObserversSlot)) } routeObserversSlot.forEach { @@ -1750,34 +1706,41 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { fun `refreshed route is set to trip session and directions session`() = coroutineRule.runBlockingTest { createMapboxNavigation() - val primary: NavigationRoute = mockk { - every { directionsRoute } returns mockk() - } - val routes = listOf(primary) + val primaryRoute = routeWithId("id#0") + val routes = listOf(primaryRoute) val reason = RoutesExtra.ROUTES_UPDATE_REASON_NEW - val routeProgressData = RouteProgressData(5, 12, 43) val routeObserversSlot = mutableListOf() every { tripSession.getState() } returns TripSessionState.STARTED verify { directionsSession.registerRoutesObserver(capture(routeObserversSlot)) } - val refreshedRoutes = - listOf(routeWithId("id#0"), routeWithId("id#1"), routeWithId("id#2")) - val acceptedRefreshRoutes = listOf(refreshedRoutes[0], refreshedRoutes[2]) - val ignoredRefreshRoutes = listOf(IgnoredRoute(refreshedRoutes[1], invalidRouteReason)) - coEvery { - routeRefreshController.refresh(routes) - } returns RefreshedRouteInfo(refreshedRoutes, routeProgressData) + val alternativeRoute1 = routeWithId("id#1") + val alternativeRoute2 = routeWithId("id#2") + val primaryRouteProgressData = RouteProgressData(5, 12, 43) + val alternativeRoute1ProgressData = RouteProgressData(1, 2, 3) + val alternativeRoute2ProgressData = RouteProgressData(4, 5, 6) + val refreshedRoutes = listOf(primaryRoute, alternativeRoute1, alternativeRoute2) + val acceptedRefreshRoutes = listOf(primaryRoute, alternativeRoute2) + val ignoredRefreshRoutes = listOf(IgnoredRoute(alternativeRoute1, invalidRouteReason)) coEvery { tripSession.setRoutes(refreshedRoutes, ofType(SetRoutes.RefreshRoutes::class)) } returns NativeSetRouteValue(refreshedRoutes, listOf(alternativeWithId("id#2"))) routeObserversSlot.forEach { it.onRoutesChanged(RoutesUpdatedResult(routes, emptyList(), reason)) } + val routesProgressData = RoutesProgressData( + primaryRoute, + primaryRouteProgressData, + listOf( + alternativeRoute1 to alternativeRoute1ProgressData, + alternativeRoute2 to alternativeRoute2ProgressData + ) + ) + interceptRefreshObserver().onRoutesRefreshed(routesProgressData) coVerify(exactly = 1) { tripSession.setRoutes( - refreshedRoutes, - SetRoutes.RefreshRoutes(routeProgressData) + listOf(primaryRoute, alternativeRoute1, alternativeRoute2), + SetRoutes.RefreshRoutes(primaryRouteProgressData) ) } verify(exactly = 1) { @@ -1785,7 +1748,7 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { DirectionsSessionRoutes( acceptedRefreshRoutes, ignoredRefreshRoutes, - SetRoutes.RefreshRoutes(routeProgressData) + SetRoutes.RefreshRoutes(primaryRouteProgressData) ) ) } @@ -1801,26 +1764,32 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { val routes = listOf(primary) val ignoredRoutes = listOf(mockk(relaxed = true)) val reason = RoutesExtra.ROUTES_UPDATE_REASON_NEW - val routeProgressData = RouteProgressData(4, 13, 42) val routeObserversSlot = mutableListOf() every { tripSession.getState() } returns TripSessionState.STARTED verify { directionsSession.registerRoutesObserver(capture(routeObserversSlot)) } - val refreshedRoutes = listOf(mockk(relaxed = true)) - coEvery { - routeRefreshController.refresh(routes) - } returns RefreshedRouteInfo(refreshedRoutes, routeProgressData) + val primaryRoute = routeWithId("id#0") + val alternativeRoute = routeWithId("id#1") + val primaryRouteProgressData = RouteProgressData(5, 12, 43) + val alternativeRouteProgressData = RouteProgressData(1, 2, 3) + val routesProgressData = RoutesProgressData( + primaryRoute, + primaryRouteProgressData, + listOf(alternativeRoute to alternativeRouteProgressData) + ) + coEvery { tripSession.setRoutes(any(), any()) } returns NativeSetRouteError("some error") routeObserversSlot.forEach { it.onRoutesChanged(RoutesUpdatedResult(routes, ignoredRoutes, reason)) } + interceptRefreshObserver().onRoutesRefreshed(routesProgressData) coVerify(exactly = 1) { tripSession.setRoutes( - refreshedRoutes, - SetRoutes.RefreshRoutes(routeProgressData) + listOf(primaryRoute, alternativeRoute), + SetRoutes.RefreshRoutes(primaryRouteProgressData) ) } verify(exactly = 0) { @@ -1949,37 +1918,20 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { } @Test - fun registerRouteRefreshStateObserver() { - val observer = mockk() + fun routeRefreshController() { createMapboxNavigation() - mapboxNavigation.registerRouteRefreshStateObserver(observer) - - verify(exactly = 1) { - routeRefreshController.registerRouteRefreshStateObserver(observer) - } + assertEquals(routeRefreshController, mapboxNavigation.routeRefreshController) } @Test - fun unregisterRouteRefreshStateObserver() { - val observer = mockk() - createMapboxNavigation() - - mapboxNavigation.unregisterRouteRefreshStateObserver(observer) - - verify(exactly = 1) { - routeRefreshController.unregisterRouteRefreshStateObserver(observer) - } - } - - @Test - fun onDestroyUnregisterAllRouteRefreshStateObserver() { + fun onDestroyDestroysRouteRefreshController() { createMapboxNavigation() mapboxNavigation.onDestroy() verify(exactly = 1) { - routeRefreshController.unregisterAllRouteRefreshStateObservers() + routeRefreshController.destroy() } } @@ -2019,6 +1971,12 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { } } + private fun interceptRefreshObserver(): RouteRefreshObserver { + val observers = mutableListOf() + verify { routeRefreshController.registerRouteRefreshObserver(capture(observers)) } + return observers.last() + } + private fun alternativeWithId(mockId: String): RouteAlternative { val mockedRoute = mockk { every { routeId } returns mockId diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/internal/utils/CoroutineUtilsTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/internal/utils/CoroutineUtilsTest.kt new file mode 100644 index 00000000000..ae7d467b9fe --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/internal/utils/CoroutineUtilsTest.kt @@ -0,0 +1,184 @@ +package com.mapbox.navigation.core.internal.utils + +import android.os.Handler +import android.os.HandlerThread +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.android.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.coroutines.EmptyCoroutineContext + +@RunWith(RobolectricTestRunner::class) +class CoroutineUtilsTest { + + private lateinit var parentThread: HandlerThread + private lateinit var parentScope: CoroutineScope + private lateinit var parentJob: Job + + @Before + fun setUp() { + parentThread = HandlerThread("parent thread").also { it.start() } + parentJob = SupervisorJob() + parentScope = CoroutineScope( + SupervisorJob() + Handler(parentThread.looper).asCoroutineDispatcher() + ) + } + + @After + fun tearDown() { + parentScope.cancel() + parentJob.cancel() + parentThread.quit() + } + + @Test + fun createChildScope_parentCancellationCancelsChildren() { + val scope1 = CoroutineUtils.createChildScope(parentScope) + val scope2 = CoroutineUtils.createChildScope(parentScope) + + val job1 = scope1.launch { + delay(5000) + } + val job2 = scope2.launch { + delay(5000) + } + + assertFalse(job1.isCancelled) + assertFalse(job2.isCancelled) + + parentScope.cancel() + + assertTrue(job1.isCancelled) + assertTrue(job2.isCancelled) + } + + @Test + fun createChildScope_childDoesNotCancelOtherChildren() { + val scope1 = CoroutineUtils.createChildScope(parentScope) + val scope2 = CoroutineUtils.createChildScope(parentScope) + + val job1 = scope1.launch { + delay(5000) + } + val job2 = scope2.launch { + delay(5000) + } + + assertFalse(job1.isCancelled) + assertFalse(job2.isCancelled) + + scope1.cancel() + + assertTrue(job1.isCancelled) + assertFalse(job2.isCancelled) + } + + @Test + fun createChildScope_childrenUseParentDispatcher() = runBlocking { + val childScope = CoroutineUtils.createChildScope(parentScope) + + var childThread: Long? = null + var parentThread: Long? = null + val childJob = childScope.launch { + childThread = Thread.currentThread().id + } + val parentJob = parentScope.launch { + parentThread = Thread.currentThread().id + } + + parentJob.join() + childJob.join() + + assertNotNull(childThread) + assertEquals(parentThread, childThread) + } + + @Test + fun createScope_parentCancellationCancelsChildren() { + val scope1 = CoroutineUtils.createScope(parentJob, EmptyCoroutineContext) + val scope2 = CoroutineUtils.createScope(parentJob, EmptyCoroutineContext) + + val job1 = scope1.launch { + delay(5000) + } + val job2 = scope2.launch { + delay(5000) + } + + assertFalse(job1.isCancelled) + assertFalse(job2.isCancelled) + + parentJob.cancel() + + assertTrue(job1.isCancelled) + assertTrue(job2.isCancelled) + } + + @Test + fun createScope_childDoesNotCancelOtherChildren() { + val scope1 = CoroutineUtils.createScope(parentJob, EmptyCoroutineContext) + val scope2 = CoroutineUtils.createScope(parentJob, EmptyCoroutineContext) + + val job1 = scope1.launch { + delay(5000) + } + val job2 = scope2.launch { + delay(5000) + } + + assertFalse(job1.isCancelled) + assertFalse(job2.isCancelled) + + scope1.cancel() + + assertTrue(job1.isCancelled) + assertFalse(job2.isCancelled) + } + + @Test + fun createScope_childrenUseOwnDispatcher() = runBlocking { + val thread1 = HandlerThread("thread 1").also { it.start() } + val thread2 = HandlerThread("thread 2").also { it.start() } + try { + val childScope1 = CoroutineUtils.createScope( + parentJob, + Handler(thread1.looper).asCoroutineDispatcher() + ) + val childScope2 = CoroutineUtils.createScope( + parentJob, + Handler(thread2.looper).asCoroutineDispatcher() + ) + + var childThread1: Thread? = null + var childThread2: Thread? = null + val childJob1 = childScope1.launch { + childThread1 = Thread.currentThread() + } + val childJob2 = childScope2.launch { + childThread2 = Thread.currentThread() + } + + childJob1.join() + childJob2.join() + + assertEquals(thread1, childThread1) + assertEquals(thread2, childThread2) + } finally { + thread1.quit() + thread2.quit() + } + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/ExpiringDataRemoverTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/ExpiringDataRemoverTest.kt new file mode 100644 index 00000000000..1e1e71bfb8f --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/ExpiringDataRemoverTest.kt @@ -0,0 +1,192 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.api.directions.v5.models.LegAnnotation +import com.mapbox.navigation.base.internal.time.parseISO8601DateToLocalTimeOrNull +import com.mapbox.navigation.core.RouteProgressData +import com.mapbox.navigation.core.RoutesProgressData +import com.mapbox.navigation.testing.factories.createDirectionsRoute +import com.mapbox.navigation.testing.factories.createIncident +import com.mapbox.navigation.testing.factories.createNavigationRoute +import com.mapbox.navigation.testing.factories.createRouteLeg +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Date + +class ExpiringDataRemoverTest { + + private val localDateProvider = mockk<() -> Date>(relaxed = true) + private val sut = ExpiringDataRemover(localDateProvider) + + @Test + fun removeExpiringDataFromRoutes() { + every { + localDateProvider() + } returns parseISO8601DateToLocalTimeOrNull("2022-06-30T20:00:00Z")!! + val route1 = createNavigationRoute( + directionsRoute = createDirectionsRoute( + legs = listOf( + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("moderate", "moderate")) + .congestionNumeric(listOf(80, 80)) + .build(), + incidents = listOf( + createIncident(endTime = "2022-06-30T21:59:00Z"), + createIncident(endTime = "2022-06-31T21:59:00Z"), + ) + ), + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("heavy", "heavy")) + .congestionNumeric(listOf(90, 90)) + .build(), + incidents = listOf( + createIncident(endTime = "2022-06-30T20:59:00Z"), + createIncident(endTime = "bad time"), + createIncident(endTime = "2022-06-30T19:59:00Z"), + ) + ), + ) + ) + ) + val expectedNewRoute1 = createNavigationRoute( + directionsRoute = createDirectionsRoute( + legs = listOf( + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("moderate", "moderate")) + .congestionNumeric(listOf(80, 80)) + .build(), + incidents = listOf( + createIncident(endTime = "2022-06-30T21:59:00Z"), + createIncident(endTime = "2022-06-31T21:59:00Z"), + ) + ), + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("unknown", "unknown")) + .congestionNumeric(listOf(null, null)) + .build(), + incidents = listOf( + createIncident(endTime = "2022-06-30T20:59:00Z"), + createIncident(endTime = "bad time"), + ) + ), + ) + ) + ) + val route2 = createNavigationRoute( + directionsRoute = createDirectionsRoute( + legs = listOf( + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("moderate", "heavy")) + .congestionNumeric(listOf(80, 90)) + .build(), + incidents = listOf( + createIncident(endTime = "2022-06-31T10:59:00Z"), + createIncident(endTime = "2022-06-21T10:59:00Z"), + ) + ), + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("heavy", "moderate")) + .congestionNumeric(listOf(90, 80)) + .build(), + incidents = null + ), + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("severe", "low")) + .congestionNumeric(listOf(120, 20)) + .build(), + incidents = null + ), + createRouteLeg( + annotation = null, + incidents = listOf( + createIncident(endTime = "2022-06-31T22:59:00Z"), + ) + ), + createRouteLeg( + annotation = LegAnnotation.builder().build(), + incidents = listOf( + createIncident(endTime = "2022-06-31T22:50:00Z"), + ) + ), + ) + ) + ) + val expectedNewRoute2 = createNavigationRoute( + directionsRoute = createDirectionsRoute( + legs = listOf( + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("moderate", "heavy")) + .congestionNumeric(listOf(80, 90)) + .build(), + incidents = listOf( + createIncident(endTime = "2022-06-31T10:59:00Z"), + createIncident(endTime = "2022-06-21T10:59:00Z"), + ) + ), + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("heavy", "moderate")) + .congestionNumeric(listOf(90, 80)) + .build(), + incidents = null + ), + createRouteLeg( + annotation = LegAnnotation.builder() + .congestion(listOf("unknown", "unknown")) + .congestionNumeric(listOf(null, null)) + .build(), + incidents = null + ), + createRouteLeg( + annotation = null, + incidents = listOf( + createIncident(endTime = "2022-06-31T22:59:00Z"), + ) + ), + createRouteLeg( + annotation = LegAnnotation.builder().build(), + incidents = listOf( + createIncident(endTime = "2022-06-31T22:50:00Z"), + ) + ), + ) + ) + ) + val route3 = createNavigationRoute(directionsRoute = createDirectionsRoute(legs = null)) + val expectedNewRoute3 = createNavigationRoute( + directionsRoute = createDirectionsRoute(legs = null) + ) + val route1RouteProgressData = RouteProgressData(1, 2, 3) + val route2RouteProgressData = RouteProgressData(2, 5, 6) + val route3RouteProgressData = RouteProgressData(0, 5, 7) + val input = RoutesProgressData( + route1, + route1RouteProgressData, + listOf( + route2 to route2RouteProgressData, + route3 to route3RouteProgressData + ) + ) + val expected = RoutesProgressData( + expectedNewRoute1, + route1RouteProgressData, + listOf( + expectedNewRoute2 to route2RouteProgressData, + expectedNewRoute3 to route3RouteProgressData + ) + ) + + val actual = sut.removeExpiringDataFromRoutesProgressData(input) + + assertEquals(expected, actual) + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/ImmediateRouteRefreshControllerTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/ImmediateRouteRefreshControllerTest.kt new file mode 100644 index 00000000000..4de0d35cc4a --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/ImmediateRouteRefreshControllerTest.kt @@ -0,0 +1,128 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.ExpectedFactory +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.testing.LoggingFrontendTestRule +import com.mapbox.navigation.testing.MainCoroutineRule +import com.mapbox.navigation.utils.internal.LoggerFrontend +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class, ExperimentalCoroutinesApi::class) +class ImmediateRouteRefreshControllerTest { + + private val logger = mockk(relaxed = true) + + @get:Rule + val coroutineRule = MainCoroutineRule() + + @get:Rule + val loggerRule = LoggingFrontendTestRule(logger) + + private val routeRefresherExecutor = mockk(relaxed = true) + private val stateHolder = mockk(relaxed = true) + private val listener = mockk(relaxed = true) + private val clientCallback = + mockk<(Expected) -> Unit>(relaxed = true) + private val routes = listOf(mockk()) + + private val sut = ImmediateRouteRefreshController( + routeRefresherExecutor, + stateHolder, + coroutineRule.coroutineScope, + listener + ) + + @Test(expected = IllegalArgumentException::class) + fun requestRoutesRefreshWithEmptyRoutes() = coroutineRule.runBlockingTest { + sut.requestRoutesRefresh(emptyList(), clientCallback) + } + + @Test + fun requestRoutesRefreshPostsRefreshRequest() = coroutineRule.runBlockingTest { + sut.requestRoutesRefresh(routes, clientCallback) + + coVerify(exactly = 1) { routeRefresherExecutor.executeRoutesRefresh(routes, any()) } + } + + @Test + fun routesRefreshStarted() = coroutineRule.runBlockingTest { + sut.requestRoutesRefresh(routes, clientCallback) + val startCallback = interceptStartCallback() + + startCallback() + + verify(exactly = 1) { stateHolder.onStarted() } + } + + @Test + fun routesRefreshFinishedSuccessfully() = coroutineRule.runBlockingTest { + val result = RouteRefresherResult( + true, + mockk() + ) + coEvery { + routeRefresherExecutor.executeRoutesRefresh(any(), any()) + } returns ExpectedFactory.createValue(result) + + sut.requestRoutesRefresh(routes, clientCallback) + + verify(exactly = 1) { stateHolder.onSuccess() } + verify(exactly = 1) { listener.onRoutesRefreshed(result) } + verify(exactly = 1) { clientCallback(match { it.value == result }) } + } + + @Test + fun routesRefreshFinishedWithFailure() = coroutineRule.runBlockingTest { + val result = RouteRefresherResult( + false, + mockk() + ) + coEvery { + routeRefresherExecutor.executeRoutesRefresh(any(), any()) + } returns ExpectedFactory.createValue(result) + + sut.requestRoutesRefresh(routes, clientCallback) + + verify(exactly = 1) { stateHolder.onFailure(null) } + verify(exactly = 1) { clientCallback(match { it.value == result }) } + verify(exactly = 1) { listener.onRoutesRefreshed(result) } + } + + @Test + fun routesRefreshFinishedWithError() = coroutineRule.runBlockingTest { + val error: Expected = + ExpectedFactory.createError("Some error") + coEvery { + routeRefresherExecutor.executeRoutesRefresh(any(), any()) + } returns error + + sut.requestRoutesRefresh(routes, clientCallback) + + verify(exactly = 0) { + stateHolder.onFailure(any()) + stateHolder.onSuccess() + listener.onRoutesRefreshed(any()) + } + verify(exactly = 1) { clientCallback.invoke(error) } + verify(exactly = 1) { + logger.logW( + "Route refresh on-demand error: Some error", + "RouteRefreshController" + ) + } + } + + private fun interceptStartCallback(): () -> Unit { + val callbacks = mutableListOf<() -> Unit>() + coVerify { routeRefresherExecutor.executeRoutesRefresh(any(), capture(callbacks)) } + return callbacks.last() + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/PlannedRouteRefreshControllerTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/PlannedRouteRefreshControllerTest.kt new file mode 100644 index 00000000000..9f598629e5f --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/PlannedRouteRefreshControllerTest.kt @@ -0,0 +1,771 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.ExpectedFactory +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.base.route.RouteRefreshOptions +import com.mapbox.navigation.core.internal.utils.CoroutineUtils +import com.mapbox.navigation.testing.LoggingFrontendTestRule +import com.mapbox.navigation.testing.MainCoroutineRule +import com.mapbox.navigation.utils.internal.LoggerFrontend +import io.mockk.clearAllMocks +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class, ExperimentalCoroutinesApi::class) +class PlannedRouteRefreshControllerTest { + + private val logger = mockk(relaxed = true) + + @get:Rule + val coroutineRule = MainCoroutineRule() + + @get:Rule + val loggerRule = LoggingFrontendTestRule(logger) + + private val executor = mockk(relaxed = true) + private val stateHolder = mockk(relaxed = true) + private val listener = mockk(relaxed = true) + private val retryStrategy = mockk(relaxed = true) + private val interval = 40000L + private val routeRefreshOptions = RouteRefreshOptions.Builder().intervalMillis(interval).build() + private val childScopeDispatcher = TestCoroutineDispatcher() + private var childScope: CoroutineScope? = null + private val parentScope = coroutineRule.createTestScope() + private lateinit var sut: PlannedRouteRefreshController + + @Before + fun setUp() { + mockkObject(RouteRefreshValidator) + mockkObject(CoroutineUtils) + every { CoroutineUtils.createChildScope(parentScope) } answers { + TestCoroutineScope(SupervisorJob() + childScopeDispatcher).also { childScope = it } + } + sut = PlannedRouteRefreshController( + executor, + routeRefreshOptions, + stateHolder, + listener, + parentScope, + retryStrategy + ) + } + + @After + fun tearDown() { + parentScope.cancel() + unmockkObject(RouteRefreshValidator) + unmockkObject(CoroutineUtils) + } + + @Test + fun startRoutesRefreshing_emptyRoutes() = coroutineRule.runBlockingTest { + sut.startRoutesRefreshing(emptyList()) + + verify(exactly = 1) { + stateHolder.reset() + logger.logI("Routes are empty, nothing to refresh", "RouteRefreshController") + } + verify(exactly = 0) { + stateHolder.onFailure(any()) + RouteRefreshValidator.validateRoute(any()) + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + assertNull(sut.routesToRefresh) + } + + @Test + fun startRoutesRefreshing_allRoutesAreInvalid() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val validation1 = RouteRefreshValidator.RouteValidationResult.Invalid("some reason 1") + val validation2 = RouteRefreshValidator.RouteValidationResult.Invalid("some reason 2") + val message = "some message" + val expectedLogMessage = "No routes which could be refreshed. $message" + every { + RouteRefreshValidator.validateRoute(route1) + } returns validation1 + every { + RouteRefreshValidator.validateRoute(route2) + } returns validation2 + every { + RouteRefreshValidator.joinValidationErrorMessages( + listOf(validation1 to route1, validation2 to route2) + ) + } returns message + + sut.startRoutesRefreshing(listOf(route1, route2)) + + verify(exactly = 1) { + logger.logI(expectedLogMessage, "RouteRefreshController") + } + verifyOrder { + stateHolder.onStarted() + stateHolder.onFailure(expectedLogMessage) + stateHolder.reset() + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + assertNull(sut.routesToRefresh) + } + + @Test + fun startRoutesRefreshing_someRoutesAreInvalid() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + val validation1 = RouteRefreshValidator.RouteValidationResult.Invalid("some reason 1") + every { + RouteRefreshValidator.validateRoute(route1) + } returns validation1 + every { + RouteRefreshValidator.validateRoute(route2) + } returns RouteRefreshValidator.RouteValidationResult.Valid + + sut.startRoutesRefreshing(routes) + + verify(exactly = 1) { + retryStrategy.reset() + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { + executor.executeRoutesRefresh(any(), any()) + } + assertEquals(routes, sut.routesToRefresh) + } + + @Test + fun startRoutesRefreshing_allRoutesAreValid() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + + sut.startRoutesRefreshing(routes) + + verify(exactly = 1) { + retryStrategy.reset() + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { + executor.executeRoutesRefresh(any(), any()) + } + assertEquals(routes, sut.routesToRefresh) + } + + @Test + fun startRoutesRefreshing_resetsRetryStrategy() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + + sut.startRoutesRefreshing(routes) + + verify(exactly = 1) { retryStrategy.reset() } + } + + @Test + fun startRoutesRefreshing_postsCancellableTask() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + + sut.startRoutesRefreshing(routes) + + childScopeDispatcher.advanceTimeBy(interval - 1) + childScope?.cancel() + verify { stateHolder.onCancel() } + } + + @Test + fun startRoutesRefreshing_notifiesOnStart() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + + sut.startRoutesRefreshing(routes) + + startRequest() + verify(exactly = 1) { + stateHolder.onStarted() + } + } + + @Test + fun retryIncrementsAttempt() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { retryStrategy.shouldRetry() } returns true + + sut.startRoutesRefreshing(routes) + finishRequest( + RouteRefresherResult( + false, + mockk() + ) + ) + + startRequest() + verify(exactly = 1) { retryStrategy.onNextAttempt() } + } + + @Test + fun finishRequestSuccessfully() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + + sut.startRoutesRefreshing(routes) + val result = RouteRefresherResult(true, mockk()) + finishRequest(result) + + verify(exactly = 1) { + stateHolder.onSuccess() + listener.onRoutesRefreshed(result) + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + } + + @Test + fun finishRequestUnsuccessfullyShouldRetry() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { retryStrategy.shouldRetry() } returns true + + sut.startRoutesRefreshing(routes) + clearMocks(retryStrategy, answers = false) + val result = RouteRefresherResult(false, mockk()) + finishRequest(result) + + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { + executor.executeRoutesRefresh(any(), any()) + } + verify(exactly = 0) { + stateHolder.onFailure(any()) + listener.onRoutesRefreshed(any()) + retryStrategy.reset() + } + } + + @Test + fun finishRequestUnsuccessfullyShouldRetryDoesNotNotifyOnStart() = + coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { retryStrategy.shouldRetry() } returns true + + sut.startRoutesRefreshing(routes) + val result = RouteRefresherResult(false, mockk()) + finishRequest(result) + startRequest() + + verify(exactly = 0) { stateHolder.onStarted() } + } + + @Test + fun finishRequestUnsuccessfullyShouldNotRetry() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { retryStrategy.shouldRetry() } returns false + + sut.startRoutesRefreshing(routes) + clearMocks(retryStrategy, answers = false) + val result = RouteRefresherResult(false, mockk()) + finishRequest(result) + + verifyOrder { + stateHolder.onFailure(null) + retryStrategy.reset() + } + verify(exactly = 1) { + listener.onRoutesRefreshed(any()) + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { + executor.executeRoutesRefresh(any(), any()) + } + } + + @Test + fun finishRequestUnsuccessfullyShouldNotRetryShouldNotifyOnStart() = + coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { retryStrategy.shouldRetry() } returns false + + sut.startRoutesRefreshing(routes) + val result = RouteRefresherResult(false, mockk()) + finishRequest(result) + startRequest() + + verify(exactly = 1) { stateHolder.onStarted() } + } + + @Test + fun finishRequestWithErrorIsIgnored() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { retryStrategy.shouldRetry() } returns false + + sut.startRoutesRefreshing(routes) + clearMocks(retryStrategy, answers = false) + finishRequest(ExpectedFactory.createError("Some error")) + + verify(exactly = 0) { + stateHolder.onSuccess() + stateHolder.onFailure(any()) + listener.onRoutesRefreshed(any()) + retryStrategy.shouldRetry() + retryStrategy.reset() + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + verify(exactly = 1) { + logger.logW("Planned route refresh error: Some error", "RouteRefreshController") + } + } + + private suspend fun startRequest() { + childScopeDispatcher.advanceTimeBy(interval) + val startCallbacks = mutableListOf<() -> Unit>() + coVerify(exactly = 1) { executor.executeRoutesRefresh(any(), capture(startCallbacks)) } + startCallbacks.last().invoke() + } + + private suspend fun finishRequest(result: RouteRefresherResult) { + finishRequest(ExpectedFactory.createValue(result)) + } + + private suspend fun finishRequest(result: Expected) { + coEvery { + executor.executeRoutesRefresh(any(), any()) + } returns result + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { + executor.executeRoutesRefresh(any(), any()) + } + clearMocks(executor, answers = false) + } + + @Test + fun pauseNotPaused() = coroutineRule.runBlockingTest { + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(listOf(mockk(relaxed = true))) + + sut.pause() + + verify(exactly = 1) { stateHolder.onCancel() } + } + + @Test + fun pausePaused() = coroutineRule.runBlockingTest { + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { retryStrategy.shouldRetry() } returns true + sut.startRoutesRefreshing(listOf(mockk(relaxed = true))) + sut.pause() + clearAllMocks(answers = false) + + sut.pause() + + verify(exactly = 0) { stateHolder.onCancel() } + } + + @Test + fun pauseResumed() = coroutineRule.runBlockingTest { + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { retryStrategy.shouldRetry() } returns true + sut.startRoutesRefreshing(listOf(mockk(relaxed = true))) + sut.pause() + sut.resume() + clearAllMocks(answers = false) + + sut.pause() + + verify(exactly = 1) { stateHolder.onCancel() } + } + + @Test + fun resumePausedNoRoutes() = coroutineRule.runBlockingTest { + sut.pause() + clearAllMocks(answers = false) + + sut.resume() + + verify(exactly = 0) { + retryStrategy.shouldRetry() + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + } + + @Test + fun resumePausedHasRoutesShouldNotRetry() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(routes) + sut.pause() + clearAllMocks(answers = false) + every { retryStrategy.shouldRetry() } returns false + + sut.resume() + + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + } + + @Test + fun resumePausedHasRoutesShouldRetry() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(routes) + sut.pause() + clearAllMocks(answers = false) + every { retryStrategy.shouldRetry() } returns true + + sut.resume() + + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { + executor.executeRoutesRefresh(any(), any()) + } + } + + @Test + fun resumeNotPausedHasRoutesShouldRetry() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(routes) + clearAllMocks(answers = false) + every { retryStrategy.shouldRetry() } returns true + + sut.resume() + + verify(exactly = 0) { retryStrategy.shouldRetry() } + } + + @Test + fun resumeResumedHasRoutesShouldRetry() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(routes) + sut.pause() + sut.resume() + clearAllMocks(answers = false) + every { retryStrategy.shouldRetry() } returns true + + sut.resume() + + verify(exactly = 0) { retryStrategy.shouldRetry() } + } + + @Test + fun resumePausedHasRoutesShouldRetryNotifiesOnStart() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(routes) + sut.pause() + clearAllMocks(answers = false) + every { retryStrategy.shouldRetry() } returns true + + sut.resume() + startRequest() + + verify(exactly = 1) { stateHolder.onStarted() } + } + + @Test + fun emptyRoutesAreNotRemembered() = coroutineRule.runBlockingTest { + sut.startRoutesRefreshing(emptyList()) + sut.pause() + clearAllMocks(answers = false) + + sut.resume() + + verify(exactly = 0) { + retryStrategy.shouldRetry() + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + } + + @Test + fun invalidRoutesAreNotRemembered() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val validation1 = RouteRefreshValidator.RouteValidationResult.Invalid("some reason 1") + val validation2 = RouteRefreshValidator.RouteValidationResult.Invalid("some reason 2") + val message = "some message" + every { + RouteRefreshValidator.validateRoute(route1) + } returns validation1 + every { + RouteRefreshValidator.validateRoute(route2) + } returns validation2 + every { + RouteRefreshValidator.joinValidationErrorMessages( + listOf(validation1 to route1, validation2 to route2) + ) + } returns message + sut.startRoutesRefreshing(listOf(route1, route2)) + sut.pause() + clearAllMocks(answers = false) + + sut.resume() + + verify(exactly = 0) { + retryStrategy.shouldRetry() + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + } + + @Test + fun partiallyInvalidRoutesAreRemembered() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + val validation1 = RouteRefreshValidator.RouteValidationResult.Invalid("some reason 1") + every { + RouteRefreshValidator.validateRoute(route1) + } returns validation1 + every { + RouteRefreshValidator.validateRoute(route2) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(routes) + sut.pause() + clearAllMocks(answers = false) + every { retryStrategy.shouldRetry() } returns true + + sut.resume() + + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { executor.executeRoutesRefresh(routes, any()) } + } + + @Test + fun validRoutesAreRemembered() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(routes) + sut.pause() + clearAllMocks(answers = false) + every { retryStrategy.shouldRetry() } returns true + + sut.resume() + + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { executor.executeRoutesRefresh(routes, any()) } + } + + @Test + fun emptyRoutesResetOldValidRoutes() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val routes = listOf(route1, route2) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(routes) + sut.startRoutesRefreshing(emptyList()) + sut.pause() + clearAllMocks(answers = false) + + sut.resume() + + verify(exactly = 0) { + retryStrategy.shouldRetry() + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + assertNull(sut.routesToRefresh) + } + + @Test + fun invalidRoutesResetOldValidRoutes() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + every { + RouteRefreshValidator.validateRoute(route1) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { + RouteRefreshValidator.validateRoute(route2) + } returns RouteRefreshValidator.RouteValidationResult.Invalid("") + sut.startRoutesRefreshing(listOf(route1)) + sut.startRoutesRefreshing(listOf(route2)) + sut.pause() + clearAllMocks(answers = false) + + sut.resume() + + verify(exactly = 0) { + retryStrategy.shouldRetry() + } + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 0) { + executor.executeRoutesRefresh(any(), any()) + } + assertNull(sut.routesToRefresh) + } + + @Test + fun partiallyValidRoutesResetOldValidRoutes() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val route3 = mockk(relaxed = true) + val route4 = mockk(relaxed = true) + every { + RouteRefreshValidator.validateRoute(route1) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { + RouteRefreshValidator.validateRoute(route2) + } returns RouteRefreshValidator.RouteValidationResult.Valid + every { + RouteRefreshValidator.validateRoute(route3) + } returns RouteRefreshValidator.RouteValidationResult.Invalid("") + every { + RouteRefreshValidator.validateRoute(route4) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(listOf(route1, route2)) + sut.startRoutesRefreshing(listOf(route3, route4)) + sut.pause() + clearAllMocks(answers = false) + every { retryStrategy.shouldRetry() } returns true + + sut.resume() + + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { executor.executeRoutesRefresh(listOf(route3, route4), any()) } + assertEquals(listOf(route3, route4), sut.routesToRefresh) + } + + @Test + fun validRoutesResetOldValidRoutes() = coroutineRule.runBlockingTest { + val route1 = mockk(relaxed = true) + val route2 = mockk(relaxed = true) + val route3 = mockk(relaxed = true) + val route4 = mockk(relaxed = true) + every { + RouteRefreshValidator.validateRoute(any()) + } returns RouteRefreshValidator.RouteValidationResult.Valid + sut.startRoutesRefreshing(listOf(route1, route2)) + sut.startRoutesRefreshing(listOf(route3, route4)) + sut.pause() + clearAllMocks(answers = false) + every { retryStrategy.shouldRetry() } returns true + + sut.resume() + + childScopeDispatcher.advanceTimeBy(interval) + coVerify(exactly = 1) { executor.executeRoutesRefresh(listOf(route3, route4), any()) } + assertEquals(listOf(route3, route4), sut.routesToRefresh) + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RefreshObserversManagerTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RefreshObserversManagerTest.kt new file mode 100644 index 00000000000..851144d6d59 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RefreshObserversManagerTest.kt @@ -0,0 +1,128 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.core.RoutesProgressData +import io.mockk.clearAllMocks +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class RefreshObserversManagerTest { + + private val sut = RefreshObserversManager() + private val observer = mockk(relaxed = true) + private val routesProgressData = mockk() + private val inputResult = RouteRefresherResult(true, routesProgressData) + private val outputResult = routesProgressData + + @Test + fun registerObserverThenReceiveUpdate() { + sut.registerObserver(observer) + + sut.onRoutesRefreshed(inputResult) + + verify(exactly = 1) { observer.onRoutesRefreshed(outputResult) } + } + + @Test + fun registerUnregisteredObserver() { + sut.registerObserver(observer) + sut.unregisterObserver(observer) + sut.registerObserver(observer) + + sut.onRoutesRefreshed(inputResult) + + verify(exactly = 1) { observer.onRoutesRefreshed(outputResult) } + } + + @Test + fun receiveUpdateThenRegisterObserver() { + sut.onRoutesRefreshed(inputResult) + + sut.registerObserver(observer) + + verify(exactly = 0) { observer.onRoutesRefreshed(any()) } + } + + @Test + fun receiveUpdateAfterUnregisterObserver() { + sut.registerObserver(observer) + sut.unregisterObserver(observer) + + sut.onRoutesRefreshed(inputResult) + + verify(exactly = 0) { observer.onRoutesRefreshed(any()) } + } + + @Test + fun receiveUpdateAfterUnregisterAllObservers() { + val observer2 = mockk(relaxed = true) + sut.registerObserver(observer) + sut.registerObserver(observer2) + sut.unregisterAllObservers() + + sut.onRoutesRefreshed(inputResult) + + verify(exactly = 0) { + observer.onRoutesRefreshed(any()) + observer2.onRoutesRefreshed(any()) + } + } + + @Test + fun receiveUpdateToNotifyMultipleObservers() { + val observer2 = mockk(relaxed = true) + sut.registerObserver(observer) + sut.registerObserver(observer2) + + sut.onRoutesRefreshed(inputResult) + + verify(exactly = 1) { + observer.onRoutesRefreshed(outputResult) + observer2.onRoutesRefreshed(outputResult) + } + } + + @Test + fun receiveUpdateWithOneRegisteredAndOneUnregisteredObserver() { + val observer2 = mockk(relaxed = true) + sut.registerObserver(observer) + sut.registerObserver(observer2) + sut.unregisterObserver(observer2) + + sut.onRoutesRefreshed(inputResult) + + verify(exactly = 1) { + observer.onRoutesRefreshed(outputResult) + } + verify(exactly = 0) { + observer2.onRoutesRefreshed(any()) + } + } + + @Test + fun receiveMultipleUpdates() { + val routesProgressData2 = mockk() + val inputResult2 = RouteRefresherResult( + true, + routesProgressData2 + ) + + sut.registerObserver(observer) + sut.onRoutesRefreshed(inputResult) + clearAllMocks(answers = false) + + sut.onRoutesRefreshed(inputResult2) + + verify { observer.onRoutesRefreshed(routesProgressData2) } + } + + @Test + fun unregisterUnknownObserver() { + sut.unregisterObserver(observer) + } + + @Test + fun unregisterAllObserversWhenNoneRegistered() { + sut.unregisterAllObservers() + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RetryRouteRefreshStrategyTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RetryRouteRefreshStrategyTest.kt new file mode 100644 index 00000000000..bd58ffbb458 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RetryRouteRefreshStrategyTest.kt @@ -0,0 +1,62 @@ +package com.mapbox.navigation.core.routerefresh + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RetryRouteRefreshStrategyTest { + + @Test + fun maxRetryCountIsZero() { + val sut = RetryRouteRefreshStrategy(0) + + assertFalse(sut.shouldRetry()) + + sut.onNextAttempt() + assertFalse(sut.shouldRetry()) + } + + @Test + fun maxRetryCountIsThree() { + val sut = RetryRouteRefreshStrategy(3) + + assertTrue(sut.shouldRetry()) + + sut.onNextAttempt() + assertTrue(sut.shouldRetry()) + + sut.onNextAttempt() + assertTrue(sut.shouldRetry()) + + sut.onNextAttempt() + assertFalse(sut.shouldRetry()) + + sut.reset() + + assertTrue(sut.shouldRetry()) + } + + @Test + fun shouldRetryDoesNotChangeState() { + val sut = RetryRouteRefreshStrategy(1) + + assertTrue(sut.shouldRetry()) + assertTrue(sut.shouldRetry()) + } + + @Test + fun resetWhenMaxAttemptsCountIsNotReached() { + val sut = RetryRouteRefreshStrategy(2) + sut.onNextAttempt() + + sut.reset() + + assertTrue(sut.shouldRetry()) + + sut.onNextAttempt() + assertTrue(sut.shouldRetry()) + + sut.onNextAttempt() + assertFalse(sut.shouldRetry()) + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerTest.kt index 9be328eb979..0f6263aeeec 100644 --- a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerTest.kt +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshControllerTest.kt @@ -1,61 +1,21 @@ package com.mapbox.navigation.core.routerefresh -import com.google.gson.JsonPrimitive -import com.mapbox.api.directions.v5.models.Closure -import com.mapbox.api.directions.v5.models.Incident -import com.mapbox.api.directions.v5.models.LegAnnotation -import com.mapbox.navigation.base.ExperimentalMapboxNavigationAPI +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.ExpectedFactory import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI -import com.mapbox.navigation.base.internal.RouteRefreshRequestData import com.mapbox.navigation.base.route.NavigationRoute -import com.mapbox.navigation.base.route.NavigationRouterRefreshCallback -import com.mapbox.navigation.base.route.RouteRefreshOptions -import com.mapbox.navigation.base.route.RouterFactory -import com.mapbox.navigation.core.RouteProgressData -import com.mapbox.navigation.core.RoutesProgressData -import com.mapbox.navigation.core.RoutesProgressDataProvider -import com.mapbox.navigation.core.directions.session.DirectionsSession -import com.mapbox.navigation.core.directions.session.RouteRefresh -import com.mapbox.navigation.core.ev.EVRefreshDataProvider import com.mapbox.navigation.testing.LoggingFrontendTestRule -import com.mapbox.navigation.testing.add -import com.mapbox.navigation.testing.factories.createClosure -import com.mapbox.navigation.testing.factories.createCoordinatesList -import com.mapbox.navigation.testing.factories.createDirectionsResponse -import com.mapbox.navigation.testing.factories.createDirectionsRoute -import com.mapbox.navigation.testing.factories.createIncident -import com.mapbox.navigation.testing.factories.createNavigationRoute -import com.mapbox.navigation.testing.factories.createNavigationRoutes -import com.mapbox.navigation.testing.factories.createRouteLeg -import com.mapbox.navigation.testing.factories.createRouteLegAnnotation -import com.mapbox.navigation.testing.factories.createRouteOptions -import com.mapbox.navigation.testing.utcToLocalTime import com.mapbox.navigation.utils.internal.LoggerFrontend -import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import io.mockk.spyk +import io.mockk.slot import io.mockk.verify -import io.mockk.verifySequence -import junit.framework.Assert.assertEquals -import junit.framework.Assert.assertFalse -import junit.framework.Assert.assertTrue -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runBlockingTest +import io.mockk.verifyOrder +import kotlinx.coroutines.Job import org.junit.Rule import org.junit.Test -import java.time.Month -import java.util.Date -import java.util.concurrent.TimeUnit -@OptIn( - ExperimentalMapboxNavigationAPI::class, - ExperimentalPreviewMapboxNavigationAPI::class, - ExperimentalCoroutinesApi::class, -) +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) class RouteRefreshControllerTest { private val logger = mockk(relaxed = true) @@ -63,1642 +23,193 @@ class RouteRefreshControllerTest { @get:Rule val loggerRule = LoggingFrontendTestRule(logger) - private val routeProgressData = RouteProgressData(0, 1, 2) - private val routeProgressDataProvider = - mockk(relaxed = true) { - coEvery { getRoutesProgressData(any()) } answers { - val routes = firstArg() as List - RoutesProgressData( - routes.first(), - routeProgressData, - routes.drop(1).map { it to routeProgressData } - ) - } - } - private val evRefreshDataProvider = mockk(relaxed = true) - private val mockStatesObserver = mockk(relaxUnitFun = true) - - @Test - fun `route with disabled refresh never refreshes and observer is failed`() = - runBlockingTest { - val testRoute = createNavigationRoute( - createDirectionsRoute( - routeOptions = createRouteOptions( - enableRefresh = false - ) - ) - ) - val routeRefreshController = createRouteRefreshController() - - val refreshJob = async { routeRefreshController.refresh(listOf(testRoute)) } - advanceTimeBy(TimeUnit.HOURS.toMillis(3)) - - assertTrue(refreshJob.isActive) - refreshJob.cancel() - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult( - RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, - "No routes which could be refreshed. " + - "testUUID#0 RouteOptions#enableRefresh is false" - ) - ) - } - } - - @Test - fun `route refreshes and observer is triggered with valid states`() = - runBlockingTest { - val (initialRoute, refreshedRoute) = createTestInitialAndRefreshedTestRoutes() - val routeRefreshStub = RouteRefreshStub().apply { - setRefreshedRoute(refreshedRoute) - } - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefreshStub, - routeRefreshOptions = RouteRefreshOptions.Builder() - .intervalMillis(30_000) - .build(), - ) - - val refreshJob = async { routeRefreshController.refresh(listOf(initialRoute)) } - advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) - - assertEquals( - RefreshedRouteInfo(listOf(refreshedRoute), routeProgressData), - refreshJob.getCompletedTest() - ) - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS) - ) - } - } + private val plannedRouteRefreshController = mockk(relaxed = true) + private val immediateRouteRefreshController = + mockk(relaxed = true) + private val stateHolder = mockk(relaxed = true) + private val refreshObserversManager = mockk(relaxed = true) + private val resultProcessor = mockk(relaxed = true) + private val job = mockk(relaxed = true) + private val sut = RouteRefreshController( + job, + plannedRouteRefreshController, + immediateRouteRefreshController, + stateHolder, + refreshObserversManager, + resultProcessor + ) @Test - fun `should refresh route with any annotation`() = runBlockingTest { - val routeWithoutAnnotations = createNavigationRoute( - createTestTwoLegRoute( - firstLegAnnotations = null, - secondLegAnnotations = null - ) - ) - val refreshedRoute = createNavigationRoute() - val routeRefreshStub = RouteRefreshStub().apply { - setRefreshedRoute(refreshedRoute) - } - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefreshStub - ) + fun registerRouteRefreshObserver() { + val observer = mockk() - val refreshedRouteDeferred = - async { routeRefreshController.refresh(listOf(routeWithoutAnnotations)) } - advanceTimeBy(TimeUnit.MINUTES.toMillis(6)) + sut.registerRouteRefreshObserver(observer) - assertEquals( - RefreshedRouteInfo(listOf(refreshedRoute), routeProgressData), - refreshedRouteDeferred.getCompletedTest() - ) + verify(exactly = 1) { refreshObserversManager.registerObserver(observer) } } @Test - fun `should log warning when the only route is not supported, observer is failed`() = - runBlockingTest { - val primaryRoute = createNavigationRoute(createTestTwoLegRoute(requestUuid = null)) - val routeRefreshController = createRouteRefreshController() + fun unregisterRouteRefreshObserver() { + val observer = mockk() - val refreshedDeferred = async { routeRefreshController.refresh(listOf(primaryRoute)) } - advanceTimeBy(TimeUnit.HOURS.toMillis(6)) + sut.unregisterRouteRefreshObserver(observer) - assertTrue(refreshedDeferred.isActive) - verify(exactly = 1) { - logger.logI( - withArg { - assertTrue( - "message doesn't mention the reason of failure - empty uuid: $it", - it.contains("uuid", ignoreCase = true) - ) - }, - any() - ) - } - refreshedDeferred.cancel() - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult( - RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, - "No routes which could be refreshed. null#0 DirectionsRoute#requestUuid " + - "is blank. This can be caused by a route being generated by an " + - "Onboard router (in offline mode). Make sure to switch to an " + - "Offboard route when possible, only Offboard routes support " + - "the refresh feature." - ) - ) - } - } - - @Test - fun `refreshing of empty routes, observer is not triggered`() = runBlockingTest { - val routeRefreshController = createRouteRefreshController() - - val refreshedDeferred = async { - routeRefreshController.refresh( - listOf() - ) - } - advanceTimeBy(TimeUnit.HOURS.toMillis(6)) - - assertTrue(refreshedDeferred.isActive) - refreshedDeferred.cancel() - - verify(exactly = 0) { - mockStatesObserver.onNewState(any()) - } + verify(exactly = 1) { refreshObserversManager.unregisterObserver(observer) } } @Test - fun `refresh canceled, observer is not triggered`() = runBlockingTest { - val routeRefreshController = createRouteRefreshController( - routeRefreshOptions = RouteRefreshOptions.Builder() - .intervalMillis(30_000) - .build() - ) + fun registerRouteRefreshStatesObserver() { + val observer = mockk() - val refreshedDeferred = async { - routeRefreshController.refresh( - listOf( - createNavigationRoute(createTestTwoLegRoute()) - ) - ) - } - advanceTimeBy(TimeUnit.SECONDS.toMillis(29)) - refreshedDeferred.cancel() + sut.registerRouteRefreshStateObserver(observer) - assertFalse(refreshedDeferred.isActive) - verify(exactly = 0) { - mockStatesObserver.onNewState(any()) - } + verify(exactly = 1) { stateHolder.registerRouteRefreshStateObserver(observer) } } @Test - fun `refresh canceled when route refresh started, observer stared, canceled`() = - runBlockingTest { - val routeRefreshController = createRouteRefreshController( - routeRefreshOptions = RouteRefreshOptions.Builder() - .intervalMillis(30_000) - .build() - ) - - val refreshedDeferred = async { - routeRefreshController.refresh( - listOf( - createNavigationRoute(createTestTwoLegRoute()) - ) - ) - } - advanceTimeBy(TimeUnit.SECONDS.toMillis(30)) - refreshedDeferred.cancel() + fun unregisterRouteRefreshStatesObserver() { + val observer = mockk() - assertFalse(refreshedDeferred.isActive) - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_CANCELED) - ) - } - } + sut.unregisterRouteRefreshStateObserver(observer) - @Test - fun `cancel request when stopped`() = runBlockingTest { - val routeRefresh = mockk { - every { requestRouteRefresh(any(), any(), any()) } returns 8 - every { cancelRouteRefreshRequest(any()) } returns Unit - } - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefresh - ) - - val refreshJob = - async { - routeRefreshController.refresh( - listOf( - createNavigationRoute( - createTestTwoLegRoute() - ) - ) - ) - } - advanceTimeBy(TimeUnit.MINUTES.toMillis(6)) - refreshJob.cancel() - - verify(exactly = 1) { routeRefresh.requestRouteRefresh(any(), any(), any()) } - verify(exactly = 1) { routeRefresh.cancelRouteRefreshRequest(8) } + verify(exactly = 1) { stateHolder.unregisterRouteRefreshStateObserver(observer) } } @Test - fun `when uuid is empty, request is not sent, observer is in failed`() = - runBlockingTest { - val routeRefresh = mockk(relaxed = true) - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefresh - ) - val route = createNavigationRoute(createTestTwoLegRoute(requestUuid = "")) + fun destroy() { + sut.destroy() - val refreshDeferred = launch { routeRefreshController.refresh(listOf(route)) } - advanceTimeBy(TimeUnit.MINUTES.toMillis(6)) - - assertTrue(refreshDeferred.isActive) - verify(exactly = 0) { routeRefresh.requestRouteRefresh(any(), any(), any()) } - refreshDeferred.cancel() - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult( - RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, - "No routes which could be refreshed. #0 DirectionsRoute#requestUuid " + - "is blank. This can be caused by a route being generated by an " + - "Onboard router (in offline mode). Make sure to switch to an " + - "Offboard route when possible, only Offboard routes support " + - "the refresh feature." - ) - ) - } + verifyOrder { + refreshObserversManager.unregisterAllObservers() + stateHolder.unregisterAllRouteRefreshStateObservers() + job.cancel() } - - @Test - fun `clean up of a route without legs never returns`() = runBlockingTest { - val initialRoute = createNavigationRoute( - createDirectionsRoute( - legs = null, - createRouteOptions(enableRefresh = true) - ) - ) - val routeRefresh = RouteRefreshStub().apply { - failRouteRefresh(initialRoute.id) - } - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefresh - ) - - val result = async { routeRefreshController.refresh(listOf(initialRoute)) } - advanceTimeBy(TimeUnit.HOURS.toMillis(6)) - - assertTrue("route refresh has finished $result", result.isActive) - result.cancel() } @Test - fun `refresh several routes uses first route's options`() = runBlockingTest { - val firstOptions = createRouteOptions( - unrecognizedProperties = mapOf("aaa" to JsonPrimitive("bbb")), - enableRefresh = true - ) - val secondOptions = createRouteOptions( - unrecognizedProperties = mapOf("ccc" to JsonPrimitive("ddd")), - enableRefresh = true - ) - val firstEvData = mapOf("eee" to "fff") - val secondEvData = mapOf("ggg" to "hhh") - val firstRefreshRequestData = RouteRefreshRequestData( - routeProgressData.legIndex, - routeProgressData.routeGeometryIndex, - routeProgressData.legGeometryIndex, - firstEvData - ) - val secondRefreshRequestData = RouteRefreshRequestData( - routeProgressData.legIndex, - routeProgressData.routeGeometryIndex, - routeProgressData.legGeometryIndex, - secondEvData - ) - every { evRefreshDataProvider.get(firstOptions) } returns firstEvData - every { evRefreshDataProvider.get(secondOptions) } returns secondEvData - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - ) - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - ) - ) - ) - ) - ) - val inputRoutes = listOf( - spyk(initialRoutes[0]) { - every { routeOptions } returns firstOptions - }, - spyk(initialRoutes[1]) { - every { routeOptions } returns secondOptions - }, - ) - val refreshedRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - ) - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - ) - ) - ) - ) - ) - val routeRefreshStub = RouteRefreshStub().apply { - setRefreshedRoute(refreshedRoutes[0], firstRefreshRequestData) - setRefreshedRoute(refreshedRoutes[1], secondRefreshRequestData) - } - val refreshOptions = RouteRefreshOptions.Builder().build() - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefreshStub, - routeRefreshOptions = refreshOptions - ) - - val refreshedRoutesDeferred = async { - routeRefreshController.refresh(inputRoutes) - } - advanceTimeBy(refreshOptions.intervalMillis) + fun requestPlannedRouteRefreshWithNonEmptyRoutes() { + val routes = listOf(mockk()) - refreshedRoutesDeferred.getCompletedTest() - } + sut.requestPlannedRouteRefresh(routes) - @Test - fun `refresh alternative route without legs`() = runBlockingTest { - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute(requestUuid = "test1"), - createTestTwoLegRoute(requestUuid = "test2") - .toBuilder().legs(emptyList()).build() - ) - ) - ) - val refreshedRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - initialRoutes.first().directionsRoute, - createTestTwoLegRoute(requestUuid = "test2") - ) - ) - ) - val routeRefresh = RouteRefreshStub().apply { - setRefreshedRoute(refreshedRoutes[0]) - setRefreshedRoute(refreshedRoutes[1]) + verify(exactly = 1) { + resultProcessor.reset() + plannedRouteRefreshController.startRoutesRefreshing(routes) } - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefresh - ) - - val result = routeRefreshController.refresh(initialRoutes) - - assertEquals(RefreshedRouteInfo(refreshedRoutes, routeProgressData), result) } @Test - fun `should log route diffs when there is a successful response`() = - runBlockingTest { - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - uuid = "test", - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("moderate", "heavy"), - congestionNumeric = listOf(50, 94) - ), - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("moderate", "heavy"), - congestionNumeric = listOf(50, 94) - ), - ) - ) - ) - ) - val refreshedRoutes = createNavigationRoutes( - createDirectionsResponse( - uuid = "test", - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("heavy", "heavy"), - congestionNumeric = listOf(93, 94), - ), - firstLegIncidents = listOf( - createIncident(id = "1") - ), - firstLegClosures = listOf( - createClosure() - ), - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("moderate", "heavy"), - congestionNumeric = listOf(50, 94) - ), - ) - ) - ) - ) - val routeRefresh = RouteRefreshStub().apply { - setRefreshedRoute(refreshedRoutes[0]) - setRefreshedRoute(refreshedRoutes[1]) - } - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefresh, - ) + fun requestPlannedRouteRefreshWithEmptyRoutes() { + val routes = emptyList() - routeRefreshController.refresh(initialRoutes) + sut.requestPlannedRouteRefresh(routes) - verify { - logger.logI( - "Updated congestion, congestionNumeric, incidents, closures at route " + - "test#0 leg 0", - RouteRefreshController.LOG_CATEGORY - ) - } - } - - @Test - fun `should log message when there is a successful response without route diffs`() = - runBlockingTest { - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf(createTestTwoLegRoute()), - uuid = "testNoDiff" - ) - ) - val refreshedRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf(createTestTwoLegRoute()), - uuid = "testNoDiff" - ) - ) - val routeRefreshStub = RouteRefreshStub().apply { - setRefreshedRoute(refreshedRoutes[0]) - } - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefreshStub, - ) - - val refreshJob = launch { routeRefreshController.refresh(initialRoutes) } - advanceTimeBy(TimeUnit.MINUTES.toMillis(6)) - refreshJob.cancel() - - verify { - logger.logI( - withArg { - it.contains("no changes", ignoreCase = true) && - it.contains("testNoDiff#0") - }, - RouteRefreshController.LOG_CATEGORY - ) - } + verify(exactly = 1) { + resultProcessor.reset() + plannedRouteRefreshController.startRoutesRefreshing(routes) } + } @Test - fun `traffic annotations and incidents on all legs starting with the current(first) disappears if refresh fails`() = - runBlockingTest { - val currentTime = utcToLocalTime( - year = 2022, - month = Month.MAY, - date = 22, - hourOfDay = 12, - minute = 30, - second = 0 - ) - val primaryRoute = createNavigationRoute( - createTestTwoLegRoute( - firstLegIncidents = listOf( - createIncident( - id = "1", - endTime = "2022-05-22T14:00:00Z", - ), - createIncident( - id = "2", - endTime = "2022-05-22T12:00:00Z", // expired - ), - createIncident( - id = "3", - endTime = "2022-05-22T12:00:00-01", - ), - createIncident( - id = "4", - endTime = null, - ) - ), - secondLegIncidents = listOf( - createIncident( - id = "5", - endTime = "2022-05-23T10:00:00Z", - ), - createIncident( - id = "6", - endTime = "2022-05-22T12:29:00Z", // expired - ), - createIncident( - id = "7", - endTime = "wrong date format", - ) - ) - ) - ) - val routeRefreshStub = RouteRefreshStub().apply { - failRouteRefresh(primaryRoute.id) - } - val routeRefreshOptions = RouteRefreshOptions.Builder() - .intervalMillis(30_000) - .build() - val routeRefreshController = createRouteRefreshController( - routeRefreshOptions = routeRefreshOptions, - localDateProvider = { currentTime }, - routeRefresh = routeRefreshStub, - ) - // act - val refreshedRoutesDeffer = async { - routeRefreshController.refresh(listOf(primaryRoute)) - } - advanceTimeBy( - expectedTimeToInvalidateCongestions(routeRefreshOptions.intervalMillis) - ) - // assert - val refreshedRoute = refreshedRoutesDeffer.getCompletedTest().routes.first() - refreshedRoute.assertCongestionExpiredForLeg(0) - refreshedRoute.assertCongestionExpiredForLeg(1) - assertEquals( - listOf("1", "3", "4"), - refreshedRoute.directionsRoute.legs()!![0].incidents()?.map { it.id() } - ) - assertEquals( - listOf("5", "7"), - refreshedRoute.directionsRoute.legs()!![1].incidents()?.map { it.id() } - ) - } + fun requestImmediateRouteRefreshWithNonEmptyRoutes() { + val routes = listOf(mockk()) + every { plannedRouteRefreshController.routesToRefresh } returns routes - @Test - fun `remove expired data for alternative route`() = - runBlockingTest { - val currentTime = utcToLocalTime( - year = 2022, - month = Month.MAY, - date = 22, - hourOfDay = 12, - minute = 30, - second = 0 - ) - val primaryRoute = createNavigationRoute( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - ) - ) - ) - val alternativeRoute = createNavigationRoute( - createTestTwoLegRoute( - firstLegIncidents = listOf( - createIncident( - id = "1", - endTime = "2022-05-22T14:00:00Z", - ), - createIncident( - id = "2", - endTime = "2022-05-22T12:00:00Z", // expired - ), - createIncident( - id = "3", - endTime = "2022-05-22T12:00:00-01", - ), - createIncident( - id = "4", - endTime = null, - ) - ), - secondLegIncidents = listOf( - createIncident( - id = "5", - endTime = "2022-05-23T10:00:00Z", - ), - createIncident( - id = "6", - endTime = "2022-05-22T12:29:00Z", // expired - ), - createIncident( - id = "7", - endTime = "wrong date format", - ) - ) - ) - ) - val routeRefreshStub = RouteRefreshStub().apply { - setRefreshedRoute(primaryRoute) - failRouteRefresh(alternativeRoute.id) - } - coEvery { - routeProgressDataProvider.getRoutesProgressData(any()) - } returns RoutesProgressData( - primaryRoute, - RouteProgressData(1, 2, 3), - listOf(alternativeRoute to routeProgressData) - ) - val routeRefreshOptions = RouteRefreshOptions.Builder() - .intervalMillis(30_000) - .build() - val routeRefreshController = createRouteRefreshController( - routeRefreshOptions = routeRefreshOptions, - localDateProvider = { currentTime }, - routeRefresh = routeRefreshStub, - ) - // act - val refreshedRoutesDeffer = async { - routeRefreshController.refresh(listOf(primaryRoute, alternativeRoute)) - } - advanceTimeBy( - expectedTimeToInvalidateCongestions(routeRefreshOptions.intervalMillis) - ) - // assert - val refreshedRoute = refreshedRoutesDeffer.getCompletedTest().routes[1] - refreshedRoute.assertCongestionExpiredForLeg(0) - refreshedRoute.assertCongestionExpiredForLeg(1) - assertEquals( - listOf("1", "3", "4"), - refreshedRoute.directionsRoute.legs()!![0].incidents()?.map { it.id() } - ) - assertEquals( - listOf("5", "7"), - refreshedRoute.directionsRoute.legs()!![1].incidents()?.map { it.id() } - ) - } + sut.requestImmediateRouteRefresh() - @Test(timeout = 2_000_000) - fun `after invalidation route isn't updated until successful refresh`() = runBlockingTest { - val initialRoute = createNavigationRoute(createTestTwoLegRoute()) - val routeRefreshStub = RouteRefreshStub().apply { - failRouteRefresh(initialRoute.id) + verify(exactly = 1) { + plannedRouteRefreshController.pause() } - val routeRefreshOptions = RouteRefreshOptions.Builder().build() - val routeRefreshController = createRouteRefreshController( - routeRefreshOptions = routeRefreshOptions, - routeRefresh = routeRefreshStub - ) - val invalidatedRouteDeffer = async { - routeRefreshController.refresh(listOf(initialRoute)) - } - advanceTimeBy( - expectedTimeToInvalidateCongestions(routeRefreshOptions.intervalMillis) - ) - val invalidatedRoute = invalidatedRouteDeffer.getCompletedTest().routes.first() - // act - val refreshedRoute = async { - routeRefreshController.refresh(listOf(invalidatedRoute)) + verify(exactly = 0) { + plannedRouteRefreshController.resume() } - advanceTimeBy( - expectedTimeToInvalidateCongestions(routeRefreshOptions.intervalMillis) * 2 - ) - assertFalse(refreshedRoute.isCompleted) - routeRefreshStub.setRefreshedRoute(initialRoute) - advanceTimeBy(routeRefreshOptions.intervalMillis) - // assert - assertEquals( - RefreshedRouteInfo(listOf(initialRoute), routeProgressData), - refreshedRoute.getCompletedTest() - ) - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS) - ) + verify(exactly = 1) { + immediateRouteRefreshController.requestRoutesRefresh(routes, any()) } + verify(exactly = 0) { resultProcessor.reset() } } @Test - fun `after invalidation route isn't updated until incident expiration`() = - runBlockingTest { - var currentTime = utcToLocalTime( - year = 2022, - month = Month.MAY, - date = 22, - hourOfDay = 9, - minute = 0, - second = 0 - ) - val initialRoute = createNavigationRoute( - createTestTwoLegRoute( - firstLegIncidents = listOf( - createIncident( - id = "1", - endTime = "2022-05-22T12:00:00Z" // expires in 3 hours - ) - ) - ) - ) - val routeRefreshStub = RouteRefreshStub().apply { - failRouteRefresh(initialRoute.id) - } - val routeRefreshOptions = RouteRefreshOptions.Builder().build() - val routeRefreshController = createRouteRefreshController( - routeRefreshOptions = routeRefreshOptions, - routeRefresh = routeRefreshStub, - localDateProvider = { currentTime } - ) - val invalidatedRouteDeffer = async { - routeRefreshController.refresh(listOf(initialRoute)) - } - advanceTimeBy( - expectedTimeToInvalidateCongestions(routeRefreshOptions.intervalMillis) - ) - val invalidatedRoute = invalidatedRouteDeffer.getCompletedTest().routes.first() - // act - val refreshedRoute = async { - routeRefreshController.refresh(listOf(invalidatedRoute)) - } - val twoHours = TimeUnit.HOURS.toMillis(2) - currentTime = currentTime.add(milliseconds = twoHours) - advanceTimeBy(twoHours) - assertFalse("incident should not expire in 2 hours", refreshedRoute.isCompleted) - val oneHour = TimeUnit.HOURS.toMillis(1) - currentTime = currentTime.add(milliseconds = oneHour) - advanceTimeBy(oneHour) - // assert - assertEquals( - emptyList(), - refreshedRoute.getCompletedTest().routes.first() - .directionsResponse.routes().first().legs()?.first()?.incidents() - ) - } - - @Test - fun `traffic annotations and expired incidents disappear, but closures are kept on if refresh doesn't respond`() = - runBlockingTest { - val currentTime = utcToLocalTime( - year = 2022, - month = Month.MAY, - date = 22, - hourOfDay = 10, - minute = 0, - second = 0 - ) - val currentRoute = createNavigationRoute( - createTestTwoLegRoute( - firstLegIncidents = listOf( - createIncident( - id = "1", - endTime = "2022-05-22T09:00:00Z", - ), - ), - secondLegIncidents = listOf( - createIncident( - id = "2", - endTime = "2022-05-22T09:59:00Z" - ), - createIncident( - id = "3", - endTime = "2022-05-22T10:00:01Z" - ), - ), - firstLegClosures = listOf( - createClosure( - geometryIndexStart = 5, - geometryIndexEnd = 14, - ), - ), - secondLegClosures = listOf( - createClosure( - geometryIndexStart = 1, - geometryIndexEnd = 6, - ), - ) - ) - ) - val routeRefreshStub = RouteRefreshStub().apply { - doNotRespondForRouteRefresh(currentRoute.id) - } - val routeRefreshOptions = RouteRefreshOptions.Builder() - .intervalMillis(30_000L) - .build() - coEvery { - routeProgressDataProvider.getRoutesProgressData(listOf(currentRoute)) - } returns RoutesProgressData(currentRoute, RouteProgressData(1, 0, null), emptyList()) - val routeRefreshController = createRouteRefreshController( - routeRefreshOptions = routeRefreshOptions, - routeDiffProvider = DirectionsRouteDiffProvider(), - localDateProvider = { currentTime }, - routeRefresh = routeRefreshStub - ) - // act - val refreshedRoutesDeferred = async { - routeRefreshController.refresh(listOf(currentRoute)) - } - advanceTimeBy( - expectedTimeToInvalidateCongestionsInCaseOfTimeout( - routeRefreshOptions.intervalMillis - ) - ) - // assert - val refreshedRoute = refreshedRoutesDeferred.getCompletedTest().routes.first() - refreshedRoute.assertCongestionExpiredForLeg(1) - assertEquals( - listOf("3"), - refreshedRoute.directionsRoute.legs()!![1].incidents()?.map { it.id() } - ) - assertEquals( - "annotations on passed legs should not be refreshed", - currentRoute.directionsRoute.legs()!![0].annotation(), - refreshedRoute.directionsRoute.legs()!![0].annotation()!! - ) - assertEquals( - "incidents on passed legs should not be refreshed", - currentRoute.directionsRoute.legs()!![0].incidents(), - refreshedRoute.directionsRoute.legs()!![0].incidents()!! - ) - assertEquals( - "closures on the route should not be refreshed", - currentRoute.directionsRoute.legs()!!.map { it.closures() }, - refreshedRoute.directionsRoute.legs()!!.map { it.closures() } - ) - } - - @Test - fun `route successfully refreshes on time if first try doesn't respond`() = - runBlockingTest { - val initialRoute = createNavigationRoute( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ) - ) - val refreshed = createNavigationRoute( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(90, 90), - ) - ) - ) - var currentRoute = initialRoute - val directionsSession = mockk(relaxed = true) - .onRefresh { refreshAttempt, _, _, callback -> - if (refreshAttempt >= 1) { - callback.onRefreshReady(currentRoute) - } - } - val refreshInterval = 30_000L - val refreshController = createRouteRefreshController( - routeRefresh = directionsSession, - routeRefreshOptions = RouteRefreshOptions.Builder() - .intervalMillis(refreshInterval) - .build() - ) + fun requestImmediateRouteRefreshWithNonEmptySuccess() { + val routes = listOf(mockk()) + val callback = slot<(Expected) -> Unit>() + every { plannedRouteRefreshController.routesToRefresh } returns routes - val refreshedDeferred = async { refreshController.refresh(listOf(initialRoute)) } - advanceTimeBy(refreshInterval) - assertFalse(refreshedDeferred.isCompleted) - currentRoute = refreshed - advanceTimeBy(refreshInterval) + sut.requestImmediateRouteRefresh() - assertEquals( - RefreshedRouteInfo(listOf(refreshed), routeProgressData), - refreshedDeferred.getCompletedTest() - ) + verify(exactly = 1) { + immediateRouteRefreshController.requestRoutesRefresh(routes, capture(callback)) } - - @Test - fun `route successfully refreshes on time if first try failed, observer states are started, success`() = - runBlockingTest { - val initialRoute = createNavigationRoute( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ) - ) - val refreshed = createNavigationRoute( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(90, 90), - ) - ) - ) - var currentRoute = initialRoute - - val directionsSession = mockk(relaxed = true) - .onRefresh { refreshAttempt, _, _, callback -> - if (refreshAttempt >= 1) { - callback.onRefreshReady(currentRoute) - } else { - callback.onFailure(RouterFactory.buildNavigationRouterRefreshError()) - } - } - val refreshInterval = 30_000L - val refreshController = createRouteRefreshController( - routeRefresh = directionsSession, - routeRefreshOptions = RouteRefreshOptions.Builder() - .intervalMillis(refreshInterval) - .build() - ) - - val refreshedDeferred = async { refreshController.refresh(listOf(initialRoute)) } - advanceTimeBy(refreshInterval) - assertFalse(refreshedDeferred.isCompleted) - currentRoute = refreshed - advanceTimeBy(refreshInterval) - - assertEquals( - RefreshedRouteInfo(listOf(refreshed), routeProgressData), - refreshedDeferred.getCompletedTest() - ) - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS) - ) - } + callback.captured(ExpectedFactory.createValue(RouteRefresherResult(true, mockk()))) + verify(exactly = 0) { + plannedRouteRefreshController.resume() } + } @Test - fun `successful refresh of two routes, observer states are success, start`() = - runBlockingTest { - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ) - ) - ) - ) - val refreshedRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(90, 90), - ) - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(90, 90), - ) - ) - ) - ) - ) - val routeRefreshStub = RouteRefreshStub().apply { - setRefreshedRoute(refreshedRoutes[0]) - setRefreshedRoute(refreshedRoutes[1]) - } - val refreshOptions = RouteRefreshOptions.Builder().build() - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefreshStub, - routeRefreshOptions = refreshOptions - ) - - val refreshedRoutesDeferred = async { - routeRefreshController.refresh(initialRoutes) - } - advanceTimeBy(refreshOptions.intervalMillis) + fun requestImmediateRouteRefreshWithNonEmptyFailure() { + val routes = listOf(mockk()) + val callback = slot<(Expected) -> Unit>() + every { plannedRouteRefreshController.routesToRefresh } returns routes - val result = refreshedRoutesDeferred.getCompletedTest() + sut.requestImmediateRouteRefresh() - assertEquals( - RefreshedRouteInfo(refreshedRoutes, routeProgressData), - result - ) - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS) - ) - } + verify(exactly = 1) { + immediateRouteRefreshController.requestRoutesRefresh(routes, capture(callback)) } - - @Test - fun `successful refresh of two routes starting at different locations`() = - runBlockingTest { - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ) - ) - ) - ) - val refreshedRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(90, 90), - ) - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(90, 90), - ) - ) - ) - ) - ) - val primaryRouteProgressData = RouteProgressData(3, 4, 5) - val alternativeRouteProgressData = RouteProgressData(1, 2, 3) - coEvery { - routeProgressDataProvider.getRoutesProgressData(any()) - } returns RoutesProgressData( - initialRoutes.first(), - primaryRouteProgressData, - listOf(initialRoutes[1] to alternativeRouteProgressData) - ) - val routeRefreshStub = RouteRefreshStub().apply { - setRefreshedRoute(refreshedRoutes[0], RouteRefreshRequestData(3, 4, 5, emptyMap())) - setRefreshedRoute(refreshedRoutes[1], RouteRefreshRequestData(1, 2, 3, emptyMap())) - } - val refreshOptions = RouteRefreshOptions.Builder().build() - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefreshStub, - routeRefreshOptions = refreshOptions - ) - - val refreshedRoutesDeferred = async { - routeRefreshController.refresh(initialRoutes) - } - advanceTimeBy(refreshOptions.intervalMillis) - - val result = refreshedRoutesDeferred.getCompletedTest() - - assertEquals( - RefreshedRouteInfo(refreshedRoutes, primaryRouteProgressData), - result - ) + callback.captured(ExpectedFactory.createValue(RouteRefresherResult(false, mockk()))) + verify(exactly = 1) { + plannedRouteRefreshController.resume() } + } @Test - fun `primary route refresh failed, alternative route refreshed successfully, observer started, success`() = - runBlockingTest { - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ) - ) - ) - ) - val expectedRefreshedRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - initialRoutes[0].directionsRoute, - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(90, 90), - ) - ) - ) - ) - ) - val routeRefreshStub = RouteRefreshStub().apply { - failRouteRefresh(expectedRefreshedRoutes[0].id) - setRefreshedRoute(expectedRefreshedRoutes[1]) - } - val refreshOptions = RouteRefreshOptions.Builder().build() - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefreshStub, - routeRefreshOptions = refreshOptions - ) + fun requestImmediateRouteRefreshWithNonEmptyError() { + val routes = listOf(mockk()) + val callback = slot<(Expected) -> Unit>() + every { plannedRouteRefreshController.routesToRefresh } returns routes - val refreshedRoutesDeferred = async { - routeRefreshController.refresh(initialRoutes) - } - advanceTimeBy(refreshOptions.intervalMillis) - val result = refreshedRoutesDeferred.getCompletedTest() + sut.requestImmediateRouteRefresh() - assertEquals( - RefreshedRouteInfo(expectedRefreshedRoutes, routeProgressData), - result - ) - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS) - ) - } + verify(exactly = 1) { + immediateRouteRefreshController.requestRoutesRefresh(routes, capture(callback)) } - - @Test - fun `routes won't refresh until one of them(second) changes`() = runBlockingTest { - val routeRefreshStub = RouteRefreshStub() - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ), - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "moderate"), - congestionNumeric = listOf(90, 50), - ) - ) - ) - ) - ) - val refreshedRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - initialRoutes[0].directionsRoute, - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(90, 90), - ) - ) - ) - ) - ) - val refreshOptions = RouteRefreshOptions.Builder().build() - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefreshStub, - routeRefreshOptions = refreshOptions - ) - - val refreshedRoutesDeferred = async { - routeRefreshController.refresh(initialRoutes) + callback.captured(ExpectedFactory.createError("error")) + verify(exactly = 0) { + plannedRouteRefreshController.resume() } - routeRefreshStub.setRefreshedRoute(initialRoutes[0]) - routeRefreshStub.setRefreshedRoute(initialRoutes[1]) - advanceTimeBy(TimeUnit.HOURS.toMillis(7)) - assertFalse(refreshedRoutesDeferred.isCompleted) - routeRefreshStub.setRefreshedRoute(refreshedRoutes[1]) - advanceTimeBy(refreshOptions.intervalMillis) - - val result = refreshedRoutesDeferred.getCompletedTest() - - assertEquals( - RefreshedRouteInfo(refreshedRoutes, routeProgressData), - result - ) } @Test - fun `should updated primary and log warning when only alternative route is not supported`() = - runBlockingTest { - val primaryRoute = createNavigationRoute(createTestTwoLegRoute(requestUuid = "testid")) - val alternativeRoute = createNavigationRoute(createTestTwoLegRoute(requestUuid = null)) - val updatedPrimary = createNavigationRoute( - createTestTwoLegRoute( - requestUuid = "testid", - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(95, 93), - ) - ) - ) - val routeRefresh = RouteRefreshStub().apply { - setRefreshedRoute(updatedPrimary) - } - val refreshOptions = RouteRefreshOptions.Builder().build() - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefresh, - routeRefreshOptions = refreshOptions - ) + fun requestImmediateRouteRefreshWithEmptyRoutes() { + every { plannedRouteRefreshController.routesToRefresh } returns emptyList() - val refreshedDeferred = async { - routeRefreshController.refresh(listOf(primaryRoute, alternativeRoute)) - } - advanceTimeBy(refreshOptions.intervalMillis) - val result = refreshedDeferred.getCompletedTest() + sut.requestImmediateRouteRefresh() - assertEquals( - RefreshedRouteInfo( - listOf(updatedPrimary, alternativeRoute), - routeProgressData - ), - result - ) - verify(exactly = 1) { - logger.logI( - withArg { - assertTrue( - "message doesn't mention the reason of failure - empty uuid: $it", - it.contains("uuid", ignoreCase = true) - ) - assertTrue( - "message doesn't mention the route index", - it.contains("0", ignoreCase = true) - ) - }, - any() - ) - } + verify(exactly = 0) { + plannedRouteRefreshController.pause() + immediateRouteRefreshController.requestRoutesRefresh(any(), any()) + resultProcessor.reset() } - - @Test - fun `no refreshes when all routes disable refresh, observer is started, failed`() = - runBlockingTest { - val routes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createDirectionsRoute( - routeOptions = createRouteOptions(enableRefresh = false) - ), - createDirectionsRoute( - routeOptions = createRouteOptions(enableRefresh = false) - ), - ) - ) - ) - val routeRefresh = RouteRefreshStub() - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefresh - ) - - val refreshedDeferred = async { routeRefreshController.refresh(routes) } - advanceTimeBy(TimeUnit.HOURS.toMillis(8)) - assertFalse(refreshedDeferred.isCompleted) - refreshedDeferred.cancel() - verify(exactly = 1) { - logger.logI( - withArg { - assertTrue( - "message doesn't mention the reason of failure - " + - "enableRefresh=false: $it", - it.contains("enableRefresh", ignoreCase = true) - ) - assertTrue( - "message doesn't mention the route index", - it.contains("0", ignoreCase = true) - ) - }, - any() - ) - logger.logI( - withArg { - assertTrue( - "message doesn't mention the reason of failure - " + - "enableRefresh=false: $it", - it.contains("enableRefresh", ignoreCase = true) - ) - assertTrue( - "message doesn't mention the route index", - it.contains("1", ignoreCase = true) - ) - }, - any() - ) - } - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult( - RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, - "No routes which could be refreshed. testUUID#0 " + - "RouteOptions#enableRefresh is false. testUUID#1 " + - "RouteOptions#enableRefresh is false" - ) - ) - } + verifyOrder { + stateHolder.onStarted() + stateHolder.onFailure("No routes to refresh") } - - @Test - fun `traffic annotations are cleaned up if all routes refresh fails, observer states are started, failed`() = - runBlockingTest { - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute(), - createTestTwoLegRoute() - ) - ) - ) - val routeRefresh = RouteRefreshStub().apply { - failRouteRefresh(initialRoutes[0].id) - failRouteRefresh(initialRoutes[1].id) - } - val routeRefreshOptions = RouteRefreshOptions.Builder().build() - coEvery { - routeProgressDataProvider.getRoutesProgressData(initialRoutes) - } returns RoutesProgressData( - initialRoutes.first(), - RouteProgressData(1, 0, null), - listOf(initialRoutes[1] to RouteProgressData(1, 0, null)) - ) - val routeRefreshController = createRouteRefreshController( - routeRefreshOptions = routeRefreshOptions, - routeDiffProvider = DirectionsRouteDiffProvider(), - routeRefresh = routeRefresh - ) - // act - val refreshedRoutesDeferred = async { - routeRefreshController.refresh(initialRoutes) - } - advanceTimeBy( - expectedTimeToInvalidateCongestions(routeRefreshOptions.intervalMillis) - ) - // assert - val refreshedRoutes = refreshedRoutesDeferred.getCompletedTest().routes - refreshedRoutes[0].assertCongestionExpiredForLeg(1) - refreshedRoutes[1].assertCongestionExpiredForLeg(1) - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED) - ) - } + verify(exactly = 1) { + logger.logI("No routes to refresh", "RouteRefreshController") } + } @Test - fun `if route refresh works only for one route, the controller updates only one route, observer states are started, success`() = - runBlockingTest { - val currentTime = utcToLocalTime( - year = 2022, - month = Month.MAY, - date = 22, - hourOfDay = 12, - minute = 30, - second = 0 - ) - val routeRefreshStub = RouteRefreshStub() - val initialRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute(), - createTestTwoLegRoute( - secondLegIncidents = listOf( - createIncident( - id = "1", - endTime = "2022-05-21T14:00:00Z", // expired - ), - createIncident( - id = "2", - endTime = "2022-05-22T14:00:00Z", - ), - ), - secondLegClosures = listOf( - createClosure( - geometryIndexStart = 0, - geometryIndexEnd = 10, - ), - createClosure( - geometryIndexStart = 40, - geometryIndexEnd = 46, - ), - ), - ) - ) - ) - ) - val refreshedRoutes = createNavigationRoutes( - createDirectionsResponse( - routes = listOf( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("severe", "severe"), - congestionNumeric = listOf(90, 99), - ) - ), - createTestTwoLegRoute() - ) - ) - ) - routeRefreshStub.setRefreshedRoute(refreshedRoutes[0]) - routeRefreshStub.doNotRespondForRouteRefresh(refreshedRoutes[1].id) - val refreshOptions = RouteRefreshOptions.Builder().build() - coEvery { - routeProgressDataProvider.getRoutesProgressData(initialRoutes) - } returns RoutesProgressData( - initialRoutes.first(), - RouteProgressData(1, 0, null), - listOf(initialRoutes[1] to RouteProgressData(1, 0, null)) - ) - val routeRefreshController = createRouteRefreshController( - routeRefresh = routeRefreshStub, - routeRefreshOptions = refreshOptions, - localDateProvider = { currentTime }, - ) - - val refreshedRoutesDeferred = async { - routeRefreshController.refresh(initialRoutes) - } - advanceTimeBy( - expectedTimeToInvalidateCongestionsInCaseOfTimeout(refreshOptions.intervalMillis) - ) + fun requestImmediateRouteRefreshWithNullRoutes() { + every { plannedRouteRefreshController.routesToRefresh } returns null - val result = refreshedRoutesDeferred.getCompletedTest().routes + sut.requestImmediateRouteRefresh() - assertEquals( - refreshedRoutes[0], - result[0] - ) - assertEquals( - initialRoutes[1], // no cleanup happens - result[1] - ) - verifySequence { - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED) - ) - mockStatesObserver.onNewState( - RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS) - ) - } + verify(exactly = 0) { + plannedRouteRefreshController.pause() + immediateRouteRefreshController.requestRoutesRefresh(any(), any()) + resultProcessor.reset() } - - private fun createRouteRefreshController( - routeRefreshOptions: RouteRefreshOptions = RouteRefreshOptions.Builder().build(), - routeRefresh: RouteRefresh = RouteRefreshStub(), - routeDiffProvider: DirectionsRouteDiffProvider = DirectionsRouteDiffProvider(), - localDateProvider: () -> Date = { Date(1653493148247) }, - routeRefreshStatesObserver: RouteRefreshStatesObserver = mockStatesObserver, - ) = RouteRefreshController( - routeRefreshOptions, - routeRefresh, - routeProgressDataProvider, - evRefreshDataProvider, - routeDiffProvider, - localDateProvider, - ).also { - it.registerRouteRefreshStateObserver(routeRefreshStatesObserver) - } -} - -private fun createTestTwoLegRoute( - firstLegIncidents: List? = null, - secondLegIncidents: List? = null, - firstLegAnnotations: LegAnnotation? = createRouteLegAnnotation( - congestion = listOf("heavy", "heavy"), - congestionNumeric = listOf(93, 94), - distance = listOf(23.0, 24.0), - ), - secondLegAnnotations: LegAnnotation? = createRouteLegAnnotation( - congestion = listOf("heavy", "heavy"), - congestionNumeric = listOf(95, 96), - distance = listOf(28.0, 29.0) - ), - firstLegClosures: List? = null, - secondLegClosures: List? = null, - requestUuid: String? = "testUUID" -) = - createDirectionsRoute( - legs = listOf( - createRouteLeg( - annotation = firstLegAnnotations, - incidents = firstLegIncidents, - closures = firstLegClosures, - ), - createRouteLeg( - annotation = secondLegAnnotations, - incidents = secondLegIncidents, - closures = secondLegClosures, - ) - ), - routeOptions = createRouteOptions( - enableRefresh = true, - coordinatesList = createCoordinatesList(waypointCount = 3) - ), - requestUuid = requestUuid - ) - -private fun NavigationRoute.assertCongestionExpiredForLeg(legIndex: Int) { - val legToCheck = this.directionsRoute.legs()!![legIndex].annotation()!! - assertTrue( - "Expected unknown congestion after expiration " + - "but they are ${legToCheck.congestion()}", - legToCheck.congestion()?.all { it == "unknown" } ?: false - ) - assertTrue( - "Expected null congestion numeric after expiration " + - "but they are ${legToCheck.congestionNumeric()}", - legToCheck.congestionNumeric()?.all { it == null } ?: false - ) -} - -private fun DirectionsSession.onRefresh( - body: ( - refreshAttempt: Int, - route: NavigationRoute, - requestData: RouteRefreshRequestData, - callback: NavigationRouterRefreshCallback - ) -> Unit -): DirectionsSession { - var refreshAttempt = 0 - every { this@onRefresh.requestRouteRefresh(any(), any(), any()) } answers { - body(refreshAttempt, firstArg(), secondArg(), thirdArg()) - refreshAttempt++ - refreshAttempt.toLong() } - return this -} - -private fun expectedTimeToInvalidateCongestions(refreshInterval: Long): Long = - refreshInterval * RouteRefreshController.FAILED_ATTEMPTS_TO_INVALIDATE_EXPIRING_DATA - -// in case of timeout controller will wait for the one more response -private fun expectedTimeToInvalidateCongestionsInCaseOfTimeout(refreshInterval: Long) = - expectedTimeToInvalidateCongestions(refreshInterval) + - refreshInterval - -private fun createTestInitialAndRefreshedTestRoutes(): Pair = - Pair( - createNavigationRoute( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("moderate", "heavy"), - congestionNumeric = listOf(50, 94), - ), - ) - ), - createNavigationRoute( - createTestTwoLegRoute( - firstLegAnnotations = createRouteLegAnnotation( - congestion = listOf("heavy", "heavy"), - congestionNumeric = listOf(93, 94), - ) - ) - ) - ) - -@OptIn(ExperimentalCoroutinesApi::class) -private fun Deferred.getCompletedTest(): T = if (isActive) { - cancel() - error("can't get result from a Deferred, coroutine is still active") -} else { - getCompleted() } diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshIntegrationTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshIntegrationTest.kt new file mode 100644 index 00000000000..44ee9186cb3 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshIntegrationTest.kt @@ -0,0 +1,213 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.api.directions.v5.DirectionsCriteria +import com.mapbox.api.directions.v5.models.DirectionsResponse +import com.mapbox.api.directions.v5.models.RouteOptions +import com.mapbox.bindgen.ExpectedFactory +import com.mapbox.geojson.Point +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.internal.NativeRouteParserWrapper +import com.mapbox.navigation.base.internal.NavigationRouterV2 +import com.mapbox.navigation.base.internal.RouteRefreshRequestData +import com.mapbox.navigation.base.internal.route.update +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.base.route.NavigationRouterRefreshCallback +import com.mapbox.navigation.base.route.RouteAlternativesOptions +import com.mapbox.navigation.base.route.RouteRefreshOptions +import com.mapbox.navigation.base.route.RouterOrigin +import com.mapbox.navigation.core.NavigationComponentProvider +import com.mapbox.navigation.core.PrimaryRouteProgressDataProvider +import com.mapbox.navigation.core.RoutesProgressData +import com.mapbox.navigation.core.ev.EVDynamicDataHolder +import com.mapbox.navigation.core.internal.utils.CoroutineUtils +import com.mapbox.navigation.core.replay.MapboxReplayer +import com.mapbox.navigation.core.replay.ReplayLocationEngine +import com.mapbox.navigation.core.routealternatives.RouteAlternativesControllerProvider +import com.mapbox.navigation.core.trip.session.TripSessionLocationEngine +import com.mapbox.navigation.testing.FileUtils +import com.mapbox.navigation.testing.LoggingFrontendTestRule +import com.mapbox.navigation.testing.MainCoroutineRule +import com.mapbox.navigation.utils.internal.ThreadController +import com.mapbox.navigation.utils.internal.Time +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.junit.After +import org.junit.Before +import org.junit.Rule + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class, ExperimentalCoroutinesApi::class) +internal open class RouteRefreshIntegrationTest { + + @get:Rule + val loggerRule = LoggingFrontendTestRule() + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private val mapboxReplayer = MapboxReplayer() + private val threadController = ThreadController() + val router = mockk(relaxed = true) + private val primaryRouteProgressDataProvider = PrimaryRouteProgressDataProvider() + private val tripSession = NavigationComponentProvider.createTripSession( + tripService = mockk(relaxed = true), + TripSessionLocationEngine(mockk()) { ReplayLocationEngine(mapboxReplayer) }, + mockk(relaxed = true), + threadController + ) + private val directionsSession = NavigationComponentProvider.createDirectionsSession(router) + private val routesAlternativeController = RouteAlternativesControllerProvider.create( + RouteAlternativesOptions.Builder().build(), + mockk(relaxed = true), + tripSession, + threadController + ) + lateinit var routeRefreshController: RouteRefreshController + val stateObserver = TestStateObserver() + val refreshObserver = TestRefreshObserver() + val testDispatcher = coroutineRule.testDispatcher + val testScope = coroutineRule.createTestScope() + + class TestRefreshObserver : RouteRefreshObserver { + + val refreshes = mutableListOf() + + override fun onRoutesRefreshed(routeInfo: RoutesProgressData) { + refreshes.add(routeInfo) + } + } + + class TestStateObserver : RouteRefreshStatesObserver { + + private val states = mutableListOf() + + override fun onNewState(result: RouteRefreshStateResult) { + states.add(result) + } + + fun getStatesSnapshot(): List = states.map { it.state } + } + + @Before + fun setUp() { + mockkObject(NativeRouteParserWrapper) + every { + NativeRouteParserWrapper.parseDirectionsResponse(any(), any(), any()) + } returns ExpectedFactory.createValue(listOf(mockk(relaxed = true))) + mockkObject(CoroutineUtils) + every { + CoroutineUtils.createScope(any(), any()) + } answers { coroutineRule.createTestScope() } + + primaryRouteProgressDataProvider.onRouteProgressChanged( + mockk { + every { currentLegProgress } returns mockk { + every { legIndex } returns 0 + every { geometryIndex } returns 0 + } + every { currentRouteGeometryIndex } returns 0 + } + ) + } + + @After + fun tearDown() { + unmockkObject(NativeRouteParserWrapper) + unmockkObject(CoroutineUtils) + } + + fun createRefreshController(refreshInternal: Long): RouteRefreshController { + val options = RouteRefreshOptions.Builder().intervalMillis(refreshInternal).build() + return RouteRefreshControllerProvider.createRouteRefreshController( + testDispatcher, + testDispatcher, + options, + directionsSession, + primaryRouteProgressDataProvider, + routesAlternativeController, + EVDynamicDataHolder(), + object : Time { + override fun nanoTime() = System.nanoTime() + + override fun millis() = testDispatcher.currentTime + } + ) + } + + fun setUpRoutes( + fileName: String, + enableRefresh: Boolean = true, + successfulAttemptNumber: Int = 0, + responseDelay: Long = 0 + ): List { + val routes = NavigationRoute.create( + DirectionsResponse.fromJson(FileUtils.loadJsonFixture(fileName)), + RouteOptions.builder() + .coordinatesList( + listOf( + Point.fromLngLat(-121.496066, 38.577764), + Point.fromLngLat(-121.480256, 38.576795) + ) + ) + .profile(DirectionsCriteria.PROFILE_DRIVING_TRAFFIC) + .enableRefresh(enableRefresh) + .build(), + RouterOrigin.Custom() + ) + val refreshedRoute = NavigationRoute.create( + DirectionsResponse.fromJson(FileUtils.loadJsonFixture(fileName)), + RouteOptions.builder() + .coordinatesList( + listOf( + Point.fromLngLat(-121.496066, 38.577764), + Point.fromLngLat(-121.480256, 38.576795) + ) + ) + .profile(DirectionsCriteria.PROFILE_DRIVING_TRAFFIC) + .enableRefresh(enableRefresh) + .build(), + RouterOrigin.Custom() + ).first() + var invocationNumber = 0 + every { router.getRouteRefresh(any(), any(), any()) } answers { + val callback = thirdArg() as NavigationRouterRefreshCallback + refreshedRoute.update( + { + toBuilder() + .legs( + this.legs()!!.map { leg -> + leg.toBuilder() + .annotation( + leg.annotation()!!.toBuilder() + .duration( + leg.annotation()!!.duration()!!.map { + it + (invocationNumber + 1) * 0.1 + } + ) + .build() + ) + .build() + } + ) + .build() + }, + { this } + ) + testScope.launch { + delay(responseDelay) + if (invocationNumber >= successfulAttemptNumber) { + callback.onRefreshReady(refreshedRoute) + } else { + callback.onFailure(mockk(relaxed = true)) + } + } + invocationNumber++ + 0 + } + return routes + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshOnDemandIntegrationTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshOnDemandIntegrationTest.kt new file mode 100644 index 00000000000..651d85debbd --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshOnDemandIntegrationTest.kt @@ -0,0 +1,42 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalPreviewMapboxNavigationAPI::class) +internal class RouteRefreshOnDemandIntegrationTest : RouteRefreshIntegrationTest() { + + @Test + fun routeRefreshOnDemandDoesNotNotifyObserverBeforeTimeout() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + successfulAttemptNumber = 100 + ) + routeRefreshController = createRefreshController(60_000) + routeRefreshController.registerRouteRefreshObserver(refreshObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + // should notify after 60_000 * 3 + testDispatcher.advanceTimeBy(179_000) + routeRefreshController.requestImmediateRouteRefresh() + + assertEquals(0, refreshObserver.refreshes.size) + } + + @Test + fun routeRefreshOnDemandDoesNotNotifyObserverAfterTimeout() = coroutineRule.runBlockingTest { + val routes = setUpRoutes("route_response_single_route_refresh.json", responseDelay = 30_000) + routeRefreshController = createRefreshController(60_000) + routeRefreshController.registerRouteRefreshObserver(refreshObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + testDispatcher.advanceTimeBy(80_000) + routeRefreshController.requestImmediateRouteRefresh() + testDispatcher.advanceTimeBy(10_000) + assertEquals(0, refreshObserver.refreshes.size) + testDispatcher.advanceTimeBy(20_000) + assertEquals(1, refreshObserver.refreshes.size) + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshStateHolderTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshStateHolderTest.kt new file mode 100644 index 00000000000..6f9ca4f2565 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshStateHolderTest.kt @@ -0,0 +1,284 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import io.mockk.clearAllMocks +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +class RouteRefreshStateHolderTest { + + private val observer = mockk(relaxed = true) + private val sut = RouteRefreshStateHolder() + + @Before + fun setUp() { + sut.registerRouteRefreshStateObserver(observer) + } + + @Test + fun `null to started`() { + sut.onStarted() + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED, null) + ) + } + } + + @Test + fun `failed to started`() { + sut.onFailure(null) + clearAllMocks(answers = false) + + sut.onStarted() + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult( + RouteRefreshExtra.REFRESH_STATE_STARTED, + null + ) + ) + } + } + + @Test + fun `started to started`() { + sut.onStarted() + clearAllMocks(answers = false) + + sut.onStarted() + + verify(exactly = 0) { observer.onNewState(any()) } + } + + @Test + fun `null to success`() { + sut.onSuccess() + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult( + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, + null + ) + ) + } + } + + @Test + fun `failed to success`() { + sut.onFailure(null) + clearAllMocks(answers = false) + + sut.onSuccess() + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult( + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, + null + ) + ) + } + } + + @Test + fun `success to success`() { + sut.onSuccess() + clearAllMocks(answers = false) + + sut.onSuccess() + + verify(exactly = 0) { observer.onNewState(any()) } + } + + @Test + fun `null to failed`() { + val message = "some message" + sut.onFailure(message) + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult( + RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, + message + ) + ) + } + } + + @Test + fun `started to failed `() { + val message = "some message" + sut.onStarted() + clearAllMocks(answers = false) + + sut.onFailure(message) + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, message) + ) + } + } + + @Test + fun `failed to failed`() { + val message = "come message" + sut.onFailure(message) + clearAllMocks(answers = false) + + sut.onFailure(message) + + verify(exactly = 0) { observer.onNewState(any()) } + } + + @Test + fun `null to cleared_expired`() { + sut.onClearedExpired() + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_CLEARED_EXPIRED) + ) + } + } + + @Test + fun `started to cleared_expired`() { + sut.onStarted() + clearAllMocks(answers = false) + + sut.onClearedExpired() + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_CLEARED_EXPIRED) + ) + } + } + + @Test + fun `cleared_expired to cleared_expired`() { + sut.onClearedExpired() + clearAllMocks(answers = false) + + sut.onClearedExpired() + + verify(exactly = 0) { observer.onNewState(any()) } + } + + @Test + fun `null to cancelled`() { + sut.onCancel() + + verify(exactly = 0) { + observer.onNewState(any()) + } + } + + @Test + fun `started to cancelled can change`() { + sut.onStarted() + clearAllMocks(answers = false) + + sut.onCancel() + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_CANCELED, null) + ) + } + } + + @Test + fun `success to cancelled cannot change`() { + sut.onStarted() + sut.onSuccess() + clearAllMocks(answers = false) + + sut.onCancel() + + verify(exactly = 0) { observer.onNewState(any()) } + } + + @Test + fun `cancelled to cancelled`() { + sut.onCancel() + clearAllMocks(answers = false) + + sut.onCancel() + + verify(exactly = 0) { observer.onNewState(any()) } + } + + @Test + fun `null to null`() { + sut.reset() + + verify(exactly = 0) { observer.onNewState(any()) } + } + + @Test + fun `started to null`() { + sut.onStarted() + clearAllMocks(answers = false) + + sut.reset() + + verify(exactly = 0) { observer.onNewState(any()) } + } + + @Test + fun observersNotification() { + val secondObserver = mockk(relaxed = true) + sut.onStarted() + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_STARTED, null) + ) + } + clearAllMocks(answers = false) + + sut.registerRouteRefreshStateObserver(secondObserver) + + sut.onCancel() + + verify(exactly = 1) { + observer.onNewState( + RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_CANCELED, null) + ) + secondObserver.onNewState( + RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_CANCELED, null) + ) + } + clearAllMocks(answers = false) + + sut.unregisterRouteRefreshStateObserver(observer) + + sut.onFailure(null) + + verify(exactly = 1) { + secondObserver.onNewState( + RouteRefreshStateResult(RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, null) + ) + } + verify(exactly = 0) { observer.onNewState(any()) } + clearAllMocks(answers = false) + + sut.unregisterAllRouteRefreshStateObservers() + + sut.onStarted() + + verify(exactly = 0) { + observer.onNewState(any()) + secondObserver.onNewState(any()) + } + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshStateObserverIntegrationTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshStateObserverIntegrationTest.kt new file mode 100644 index 00000000000..55768667438 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshStateObserverIntegrationTest.kt @@ -0,0 +1,311 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.internal.RouteRefreshRequestData +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class, ExperimentalCoroutinesApi::class) +internal class RouteRefreshStateObserverIntegrationTest : RouteRefreshIntegrationTest() { + + @Test + fun emptyRoutesOnDemandRefreshTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes("route_response_single_route_refresh.json") + routeRefreshController = createRefreshController(100000) + + routeRefreshController.requestPlannedRouteRefresh(routes) + routeRefreshController.requestPlannedRouteRefresh(emptyList()) + + testDispatcher.advanceTimeBy(100000) + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + routeRefreshController.requestImmediateRouteRefresh() + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun routeRefreshOnDemandForInvalidRoutes() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + enableRefresh = false + ) + routeRefreshController = createRefreshController(50000) + + routeRefreshController.requestPlannedRouteRefresh(routes) + + testDispatcher.advanceTimeBy(50000) + + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + routeRefreshController.requestImmediateRouteRefresh() + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun invalidRoutesRefreshTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + enableRefresh = false + ) + routeRefreshController = createRefreshController(100000) + + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + + testDispatcher.advanceTimeBy(100000) + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun successfulRefreshTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes("route_response_single_route_refresh.json") + routeRefreshController = createRefreshController(100000) + + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + testDispatcher.advanceTimeBy(100000) + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun notStartedUntilTimeElapses() = coroutineRule.runBlockingTest { + val routes = setUpRoutes("route_response_single_route_refresh.json") + routeRefreshController = createRefreshController(50000) + + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + testDispatcher.advanceTimeBy(49999) + + assertEquals( + emptyList(), + stateObserver.getStatesSnapshot() + ) + + testDispatcher.advanceTimeBy(1) + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun successfulFromSecondAttemptRefreshTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + successfulAttemptNumber = 1 + ) + routeRefreshController = createRefreshController(50000) + + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + testDispatcher.advanceTimeBy(100000) + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun threeFailedAttemptsThenSuccessfulRefreshTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + successfulAttemptNumber = 3 + ) + routeRefreshController = createRefreshController(40_000) + + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + testDispatcher.advanceTimeBy(120_000) + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, + RouteRefreshExtra.REFRESH_STATE_CLEARED_EXPIRED, + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun successfulRouteRefreshOnDemandTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes("route_response_single_route_refresh.json") + routeRefreshController = createRefreshController(100_000) + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + routeRefreshController.requestImmediateRouteRefresh() + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun failedRouteRefreshOnDemandTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + successfulAttemptNumber = 1 + ) + routeRefreshController = createRefreshController(100_000) + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + routeRefreshController.requestImmediateRouteRefresh() + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun multipleRouteRefreshesOnDemandTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + responseDelay = 4000 + ) + routeRefreshController = createRefreshController(200_000) + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + routeRefreshController.requestImmediateRouteRefresh() + testDispatcher.advanceTimeBy(2_000) + routeRefreshController.requestImmediateRouteRefresh() + routeRefreshController.requestImmediateRouteRefresh() + routeRefreshController.requestImmediateRouteRefresh() + testDispatcher.advanceTimeBy(2_000) + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, + ), + stateObserver.getStatesSnapshot() + ) + verify(exactly = 1) { router.getRouteRefresh(any(), any(), any()) } + } + + @Test + fun routeRefreshOnDemandFailsThenPlannedTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + successfulAttemptNumber = 1 + ) + routeRefreshController = createRefreshController(50_000) + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + routeRefreshController.requestImmediateRouteRefresh() + + testDispatcher.advanceTimeBy(50_000) + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun routeRefreshOnDemandFailsBetweenPlannedAttemptsTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + successfulAttemptNumber = 2 + ) + routeRefreshController = createRefreshController(50_000) + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + testDispatcher.advanceTimeBy(50_000) + routeRefreshController.requestImmediateRouteRefresh() + testDispatcher.advanceTimeBy(50_000) + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_CANCELED, + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_FAILED, + RouteRefreshExtra.REFRESH_STATE_STARTED, + RouteRefreshExtra.REFRESH_STATE_FINISHED_SUCCESS, + ), + stateObserver.getStatesSnapshot() + ) + } + + @Test + fun routeRefreshDoesNotDispatchCancelledStateOnDestroyTest() = coroutineRule.runBlockingTest { + val routes = setUpRoutes( + "route_response_single_route_refresh.json", + successfulAttemptNumber = 2 + ) + routeRefreshController = createRefreshController(50_000) + routeRefreshController.registerRouteRefreshStateObserver(stateObserver) + + routeRefreshController.requestPlannedRouteRefresh(routes) + testDispatcher.advanceTimeBy(80_000) + + routeRefreshController.destroy() + + assertEquals( + listOf( + RouteRefreshExtra.REFRESH_STATE_STARTED, + ), + stateObserver.getStatesSnapshot() + ) + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshValidatorTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshValidatorTest.kt new file mode 100644 index 00000000000..1f98d0e1ecc --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshValidatorTest.kt @@ -0,0 +1,130 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.testing.factories.createDirectionsRoute +import com.mapbox.navigation.testing.factories.createRouteOptions +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Test + +class RouteRefreshValidatorTest { + + private val noUuidMessage = "DirectionsRoute#requestUuid is blank. " + + "This can be caused by a route being generated by " + + "an Onboard router (in offline mode). " + + "Make sure to switch to an Offboard route when possible, " + + "only Offboard routes support the refresh feature." + + private val route = mockk(relaxed = true) + + @Test + fun `validateRoute valid`() { + every { route.routeOptions } returns createRouteOptions(enableRefresh = true) + every { route.directionsRoute } returns createDirectionsRoute(requestUuid = "uuid") + + assertEquals( + RouteRefreshValidator.RouteValidationResult.Valid, + RouteRefreshValidator.validateRoute(route) + ) + } + + @Test + fun `validateRoute enableRefresh is null`() { + every { route.routeOptions } returns createRouteOptions(enableRefresh = null) + every { route.directionsRoute } returns createDirectionsRoute(requestUuid = "uuid") + + assertEquals( + RouteRefreshValidator.RouteValidationResult + .Invalid("RouteOptions#enableRefresh is false"), + RouteRefreshValidator.validateRoute(route) + ) + } + + @Test + fun `validateRoute enableRefresh is false`() { + every { route.routeOptions } returns createRouteOptions(enableRefresh = false) + every { route.directionsRoute } returns createDirectionsRoute(requestUuid = "uuid") + + assertEquals( + RouteRefreshValidator.RouteValidationResult + .Invalid("RouteOptions#enableRefresh is false"), + RouteRefreshValidator.validateRoute(route) + ) + } + + @Test + fun `validateRoute requestUuid is null`() { + every { route.routeOptions } returns createRouteOptions(enableRefresh = true) + every { route.directionsRoute } returns createDirectionsRoute(requestUuid = null) + + assertEquals( + RouteRefreshValidator.RouteValidationResult.Invalid(noUuidMessage), + RouteRefreshValidator.validateRoute(route) + ) + } + + @Test + fun `validateRoute requestUuid is empty`() { + every { route.routeOptions } returns createRouteOptions(enableRefresh = true) + every { route.directionsRoute } returns createDirectionsRoute(requestUuid = "") + + assertEquals( + RouteRefreshValidator.RouteValidationResult.Invalid(noUuidMessage), + RouteRefreshValidator.validateRoute(route) + ) + } + + @Test + fun `validateRoute requestUuid is blank`() { + every { route.routeOptions } returns createRouteOptions(enableRefresh = true) + every { route.directionsRoute } returns createDirectionsRoute(requestUuid = " ") + + assertEquals( + RouteRefreshValidator.RouteValidationResult.Invalid(noUuidMessage), + RouteRefreshValidator.validateRoute(route) + ) + } + + @Test + fun `joinValidationErrorMessages empty list`() { + assertEquals("", RouteRefreshValidator.joinValidationErrorMessages(emptyList())) + } + + @Test + fun `joinValidationErrorMessages all valid`() { + val list = listOf>( + RouteRefreshValidator.RouteValidationResult.Valid to mockk(relaxed = true), + RouteRefreshValidator.RouteValidationResult.Valid to mockk(relaxed = true), + RouteRefreshValidator.RouteValidationResult.Valid to mockk(relaxed = true), + ) + assertEquals("", RouteRefreshValidator.joinValidationErrorMessages(list)) + } + + @Test + fun `joinValidationErrorMessages one invalid`() { + val list = listOf>( + RouteRefreshValidator.RouteValidationResult.Valid to mockk(relaxed = true), + RouteRefreshValidator.RouteValidationResult.Valid to mockk(relaxed = true), + RouteRefreshValidator.RouteValidationResult.Invalid("some reason") to + mockk(relaxed = true) { every { id } returns "id#0" } + ) + assertEquals("id#0 some reason", RouteRefreshValidator.joinValidationErrorMessages(list)) + } + + @Test + fun `joinValidationErrorMessages all invalid`() { + val list = listOf>( + RouteRefreshValidator.RouteValidationResult.Invalid("reason 1") to + mockk(relaxed = true) { every { id } returns "id#0" }, + RouteRefreshValidator.RouteValidationResult.Invalid("reason 2") to + mockk(relaxed = true) { every { id } returns "id#1" }, + RouteRefreshValidator.RouteValidationResult.Invalid("reason 3") to + mockk(relaxed = true) { every { id } returns "id#2" } + ) + assertEquals( + "id#0 reason 1. id#1 reason 2. id#2 reason 3", + RouteRefreshValidator.joinValidationErrorMessages(list) + ) + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefresherExecutorTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefresherExecutorTest.kt new file mode 100644 index 00000000000..f6059500f02 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefresherExecutorTest.kt @@ -0,0 +1,84 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.testing.MainCoroutineRule +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RouteRefresherExecutorTest { + + @get:Rule + val coroutineRule = MainCoroutineRule() + private val routeRefresherResult = RouteRefresherResult(true, mockk()) + private val routeRefresher = mockk(relaxed = true) { + coEvery { refresh(any(), any()) } returns routeRefresherResult + } + private val timeout = 100L + private val sut = RouteRefresherExecutor(routeRefresher, timeout) + private val routes = listOf(mockk(), mockk()) + private val startCallback = mockk<() -> Unit>(relaxed = true) + + @Test + fun executeRoutesRefresh() = coroutineRule.runBlockingTest { + val actual = sut.executeRoutesRefresh(routes, startCallback) + + coVerify(exactly = 1) { + startCallback.invoke() + routeRefresher.refresh(routes, timeout) + } + assertEquals(routeRefresherResult, actual.value) + } + + @Test + fun twoRequestsAreNotExecutedSimultaneously() = coroutineRule.runBlockingTest { + val routes2 = listOf(mockk(), mockk(), mockk()) + val startCallback2 = mockk<() -> Unit>(relaxed = true) + val routeRefresherResult2 = RouteRefresherResult(false, mockk()) + + coEvery { routeRefresher.refresh(routes, any()) } coAnswers { + delay(10000) + routeRefresherResult + } + coEvery { routeRefresher.refresh(routes2, any()) } returns routeRefresherResult2 + + val result1 = async { + sut.executeRoutesRefresh(routes, startCallback) + } + + coVerify(exactly = 1) { startCallback() } + clearAllMocks(answers = false) + + val result2 = async { + sut.executeRoutesRefresh(routes2, startCallback2) + } + assertEquals("Skipping request as another one is in progress.", result2.await().error) + + assertEquals(routeRefresherResult, result1.await().value) + } + + @Test + fun secondRequestIsExecutedWhenTheFirstOneIsCancelled() = coroutineRule.runBlockingTest { + coEvery { routeRefresher.refresh(routes, any()) } coAnswers { + delay(1000) + routeRefresherResult + } + val job1 = launch { + sut.executeRoutesRefresh(routes, startCallback) + } + job1.cancel() + + val actual = sut.executeRoutesRefresh(routes, startCallback) + + assertEquals(routeRefresherResult, actual.value) + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefresherResultProcessorTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefresherResultProcessorTest.kt new file mode 100644 index 00000000000..c951f5d86e6 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefresherResultProcessorTest.kt @@ -0,0 +1,212 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.core.RouteProgressData +import com.mapbox.navigation.core.RoutesProgressData +import com.mapbox.navigation.utils.internal.Time +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +class RouteRefresherResultProcessorTest { + + private val stateHolder = mockk(relaxed = true) + private val observersManager = mockk(relaxed = true) + private val expiringDataRemover = mockk(relaxed = true) + private val timeProvider = mockk