diff --git a/LICENSE.md b/LICENSE.md index fcfb2d71a05..75773d212ee 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1001,6 +1001,11 @@ License: [The Apache Software License, Version 2.0](http://www.apache.org/licens =========================================================================== +Mapbox Navigation uses portions of the Converter: Gson. +License: [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt) + +=========================================================================== + Mapbox Navigation uses portions of the Gson. License: [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) @@ -1048,6 +1053,21 @@ License: [Mapbox Terms of Service](https://www.mapbox.com/tos/) =========================================================================== +Mapbox Navigation uses portions of the OkHttp. +License: [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) + +=========================================================================== + +Mapbox Navigation uses portions of the OkHttp Logging Interceptor. +License: [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) + +=========================================================================== + +Mapbox Navigation uses portions of the Okio. +License: [Apache 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) + +=========================================================================== + Mapbox Navigation uses portions of the org.jetbrains.kotlin:kotlin-stdlib (Kotlin Standard Library for JVM). URL: [https://kotlinlang.org/](https://kotlinlang.org/) License: [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0.txt) @@ -1066,6 +1086,11 @@ License: [The Apache License, Version 2.0](http://www.apache.org/licenses/LICENS =========================================================================== +Mapbox Navigation uses portions of the Retrofit. +License: [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.txt) + +=========================================================================== + diff --git a/libdirections-offboard/src/main/java/com/mapbox/navigation/route/offboard/MapboxOffboardRouter.kt b/libdirections-offboard/src/main/java/com/mapbox/navigation/route/offboard/MapboxOffboardRouter.kt index 1ba1aabc4a7..f805cafcd37 100644 --- a/libdirections-offboard/src/main/java/com/mapbox/navigation/route/offboard/MapboxOffboardRouter.kt +++ b/libdirections-offboard/src/main/java/com/mapbox/navigation/route/offboard/MapboxOffboardRouter.kt @@ -39,6 +39,7 @@ class MapboxOffboardRouter( ) { mapboxDirections = RouteBuilderProvider.getBuilder(accessToken, context, skuTokenProvider) .routeOptions(routeOptions) + .enableRefresh(routeOptions.isRefreshEnabled()) .build() mapboxDirections?.enqueueCall(object : Callback { @@ -69,3 +70,7 @@ class MapboxOffboardRouter( mapboxDirections = null } } + +private fun RouteOptions.isRefreshEnabled(): Boolean { + return profile().contains(other = "traffic", ignoreCase = true) +} diff --git a/libdirections-offboard/src/main/java/com/mapbox/navigation/route/offboard/RouteBuilderProvider.kt b/libdirections-offboard/src/main/java/com/mapbox/navigation/route/offboard/RouteBuilderProvider.kt index 7e4d658c340..01a47f111f1 100644 --- a/libdirections-offboard/src/main/java/com/mapbox/navigation/route/offboard/RouteBuilderProvider.kt +++ b/libdirections-offboard/src/main/java/com/mapbox/navigation/route/offboard/RouteBuilderProvider.kt @@ -27,7 +27,6 @@ internal object RouteBuilderProvider { .accessToken(accessToken) .voiceInstructions(true) .bannerInstructions(true) - .enableRefresh(false) .voiceUnits(context.inferDeviceLocale().getUnitTypeForLocale()) .interceptor { val httpUrl = it.request().url() diff --git a/libdirections-offboard/src/test/java/com/mapbox/navigation/route/offboard/MapboxOffboardRouterTest.kt b/libdirections-offboard/src/test/java/com/mapbox/navigation/route/offboard/MapboxOffboardRouterTest.kt index 0e0ff9fe90d..e6b1c987461 100644 --- a/libdirections-offboard/src/test/java/com/mapbox/navigation/route/offboard/MapboxOffboardRouterTest.kt +++ b/libdirections-offboard/src/test/java/com/mapbox/navigation/route/offboard/MapboxOffboardRouterTest.kt @@ -40,6 +40,7 @@ class MapboxOffboardRouterTest : BaseTest() { every { mockSkuTokenProvider.obtainUrlWithSkuToken("/mock", 1) } returns ("/mock&sku=102jaksdhfj") every { RouteBuilderProvider.getBuilder(accessToken, context, mockSkuTokenProvider) } returns mapboxDirectionsBuilder every { mapboxDirectionsBuilder.interceptor(any()) } returns mapboxDirectionsBuilder + every { mapboxDirectionsBuilder.enableRefresh(any()) } returns mapboxDirectionsBuilder every { mapboxDirectionsBuilder.build() } returns mapboxDirections every { mapboxDirections.enqueueCall(capture(listener)) } answers { callback = listener.captured diff --git a/libnavigation-core/build.gradle b/libnavigation-core/build.gradle index 61b677c94de..7ebb06fec6f 100644 --- a/libnavigation-core/build.gradle +++ b/libnavigation-core/build.gradle @@ -43,6 +43,7 @@ dependencies { implementation project(':libnavigation-metrics') implementation dependenciesList.mapboxAndroidAccounts implementation dependenciesList.mapboxSdkTurf + implementation dependenciesList.mapboxSdkServices //ktlint ktlint dependenciesList.ktlint 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 cddfa5ef37c..addb8992de5 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 @@ -32,6 +32,7 @@ import com.mapbox.navigation.core.directions.session.RoutesRequestCallback import com.mapbox.navigation.core.fasterroute.FasterRouteController import com.mapbox.navigation.core.fasterroute.FasterRouteObserver import com.mapbox.navigation.core.module.NavigationModuleProvider +import com.mapbox.navigation.core.routerefresh.RouteRefreshController import com.mapbox.navigation.core.telemetry.MapboxNavigationTelemetry import com.mapbox.navigation.core.telemetry.MapboxNavigationTelemetry.TAG import com.mapbox.navigation.core.telemetry.events.TelemetryUserFeedback @@ -136,6 +137,7 @@ constructor( private val internalRoutesObserver = createInternalRoutesObserver() private val internalOffRouteObserver = createInternalOffRouteObserver() private val fasterRouteController: FasterRouteController + private val routeRefreshController: RouteRefreshController private var notificationChannelField: Field? = null private val MAPBOX_NAVIGATION_NOTIFICATION_PACKAGE_NAME = @@ -192,6 +194,8 @@ constructor( } fasterRouteController = FasterRouteController(directionsSession, tripSession) + routeRefreshController = RouteRefreshController(accessToken ?: "", tripSession) + routeRefreshController.start() } /** @@ -289,6 +293,7 @@ constructor( tripSession.unregisterAllBannerInstructionsObservers() tripSession.unregisterAllVoiceInstructionsObservers() fasterRouteController.stop() + routeRefreshController.stop() } } diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshApi.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshApi.kt new file mode 100644 index 00000000000..ca762417080 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshApi.kt @@ -0,0 +1,54 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.api.directions.v5.models.DirectionsRoute +import com.mapbox.api.directionsrefresh.v1.MapboxDirectionsRefresh +import com.mapbox.navigation.base.trip.model.RouteProgress + +internal class RouteRefreshApi( + private val routeRefreshRetrofit: RouteRefreshRetrofit +) { + fun supportsRefresh(route: DirectionsRoute?): Boolean { + val isTrafficProfile = route?.routeOptions() + ?.profile()?.contains(other = "traffic", ignoreCase = true) + return isTrafficProfile == true + } + + fun refreshRoute( + accessToken: String, + route: DirectionsRoute?, + routeProgress: RouteProgress?, + callback: RouteRefreshCallback + ) { + val refreshBuilder = MapboxDirectionsRefresh.builder() + if (accessToken.isNotEmpty()) { + refreshBuilder.accessToken(accessToken) + } + + val originalRoute: DirectionsRoute = route ?: run { + callback.onError(RouteRefreshError("No DirectionsRoute to refresh")) + return + } + + if (!supportsRefresh(originalRoute)) { + callback.onError(RouteRefreshError("Unsupported profile ${originalRoute.routeOptions()?.profile()}")) + return + } + + originalRoute.routeOptions()?.requestUuid()?.let { + refreshBuilder.requestId(it) + } + + val legIndex = routeProgress?.currentLegProgress()?.legIndex() ?: 0 + refreshBuilder.legIndex(legIndex) + + return try { + val mapboxDirectionsRefresh = refreshBuilder.build() + val callbackMapper = RouteRefreshCallbackMapper(originalRoute, legIndex, callback) + routeRefreshRetrofit.enqueueCall(mapboxDirectionsRefresh, callbackMapper) + } catch (throwable: Throwable) { + callback.onError(RouteRefreshError( + message = "Route refresh call failed", + throwable = throwable)) + } + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshCallback.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshCallback.kt new file mode 100644 index 00000000000..54f238cb402 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshCallback.kt @@ -0,0 +1,14 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.api.directions.v5.models.DirectionsRoute + +interface RouteRefreshCallback { + fun onRefresh(directionsRoute: DirectionsRoute) + + fun onError(error: RouteRefreshError) +} + +data class RouteRefreshError( + val message: String? = null, + val throwable: Throwable? = null +) diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshCallbackMapper.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshCallbackMapper.kt new file mode 100644 index 00000000000..78460673914 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshCallbackMapper.kt @@ -0,0 +1,51 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.api.directions.v5.models.DirectionsRoute +import com.mapbox.api.directionsrefresh.v1.models.DirectionsRefreshResponse +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +internal class RouteRefreshCallbackMapper( + private val originalRoute: DirectionsRoute, + private val currentLegIndex: Int, + private val callback: RouteRefreshCallback +) : Callback { + + override fun onResponse(call: Call, response: Response) { + val routeAnnotations = response.body()?.route() + var errorThrowable: Throwable? = null + val refreshedDirectionsRoute = try { + mapToDirectionsRoute(routeAnnotations) + } catch (t: Throwable) { + errorThrowable = t + null + } + if (refreshedDirectionsRoute != null) { + callback.onRefresh(refreshedDirectionsRoute) + } else { + callback.onError(RouteRefreshError( + message = "Failed to read refresh response", + throwable = errorThrowable)) + } + } + + override fun onFailure(call: Call, t: Throwable) { + callback.onError(RouteRefreshError(throwable = t)) + } + + private fun mapToDirectionsRoute(routeAnnotations: DirectionsRoute?): DirectionsRoute? { + val validRouteAnnotations = routeAnnotations ?: return null + val refreshedRouteLegs = originalRoute.legs()?.let { oldRouteLegsList -> + val legs = oldRouteLegsList.toMutableList() + for (i in currentLegIndex until legs.size) { + validRouteAnnotations.legs()?.let { annotationHolderRouteLegsList -> + val updatedAnnotation = annotationHolderRouteLegsList[i - currentLegIndex].annotation() + legs[i] = legs[i].toBuilder().annotation(updatedAnnotation).build() + } + } + legs.toList() + } + return originalRoute.toBuilder().legs(refreshedRouteLegs).build() + } +} 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 new file mode 100644 index 00000000000..f938f45f43d --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshController.kt @@ -0,0 +1,65 @@ +package com.mapbox.navigation.core.routerefresh + +import android.util.Log +import com.mapbox.api.directions.v5.models.DirectionsRoute +import com.mapbox.navigation.core.trip.session.TripSession +import com.mapbox.navigation.utils.timer.MapboxTimer +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Job + +/** + * This class is responsible for refreshing the current direction route's traffic at a + * specified [intervalSeconds]. This does not support alternative routes. + * + * If the route is route is successfully refreshed, this class will update the [TripSession.route] + * + * [start] and [stop] are attached to the application lifecycle. Observing routes that + * can be refreshed are handled by this class. Calling [start] will restart the refresh timer. + */ +internal class RouteRefreshController( + private var accessToken: String, + private val tripSession: TripSession +) { + private val routerRefreshTimer = MapboxTimer() + private val routeRefreshRetrofit = RouteRefreshRetrofit() + private val routeRefreshApi = RouteRefreshApi(routeRefreshRetrofit) + + var intervalSeconds: Long = TimeUnit.MILLISECONDS.toSeconds(routerRefreshTimer.restartAfterMillis) + set(value) { + routerRefreshTimer.restartAfterMillis = TimeUnit.SECONDS.toMillis(value) + field = value + } + + fun start(): Job { + stop() + return routerRefreshTimer.startTimer { + if (routeRefreshApi.supportsRefresh(tripSession.route)) { + routeRefreshApi.refreshRoute( + accessToken, + tripSession.route, + tripSession.getRouteProgress(), + routeRefreshCallback) + } + } + } + + fun stop() { + routerRefreshTimer.stopJobs() + } + + private val routeRefreshCallback = object : RouteRefreshCallback { + + override fun onRefresh(directionsRoute: DirectionsRoute) { + Log.i("RouteRefresh", "Successful refresh") + tripSession.route = directionsRoute + } + + override fun onError(error: RouteRefreshError) { + if (error.throwable != null) { + Log.e("RouteRefresh", error.message, error.throwable) + } else { + Log.e("RouteRefresh", error.message) + } + } + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshRetrofit.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshRetrofit.kt new file mode 100644 index 00000000000..68a98b75e99 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/routerefresh/RouteRefreshRetrofit.kt @@ -0,0 +1,18 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.api.directionsrefresh.v1.MapboxDirectionsRefresh +import com.mapbox.api.directionsrefresh.v1.models.DirectionsRefreshResponse +import retrofit2.Callback + +/** + * This class is used for adding unit tests to [RouteRefreshApi] + */ +internal class RouteRefreshRetrofit { + + internal fun enqueueCall( + mapboxDirectionsRefresh: MapboxDirectionsRefresh, + callback: Callback + ) { + mapboxDirectionsRefresh.enqueueCall(callback) + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshApiTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshApiTest.kt new file mode 100644 index 00000000000..e8204b4c343 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/routerefresh/RouteRefreshApiTest.kt @@ -0,0 +1,112 @@ +package com.mapbox.navigation.core.routerefresh + +import com.mapbox.api.directions.v5.DirectionsCriteria +import com.mapbox.api.directions.v5.models.DirectionsRoute +import com.mapbox.navigation.base.trip.model.RouteProgress +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +/** + * These tests should fail if MapboxDirectionsRefresh api parameters change + */ +class RouteRefreshApiTest { + + private val routeRefreshRetrofit: RouteRefreshRetrofit = mockk() + private val routeRefreshApi: RouteRefreshApi = RouteRefreshApi(routeRefreshRetrofit) + + @Test + fun `should call route refresh with parameters`() { + val accessToken = "pk.123" + val directionsRoute: DirectionsRoute? = mockk { + every { routeOptions() } returns mockk { + every { requestUuid() } returns "test_request_id" + every { profile() } returns DirectionsCriteria.PROFILE_DRIVING_TRAFFIC + } + every { routeIndex() } returns "2" + } + val routeProgress: RouteProgress = mockk(relaxed = true) + val callback: RouteRefreshCallback = mockk(relaxed = true) { + every { onError(any()) } returns Unit + } + every { routeRefreshRetrofit.enqueueCall(any(), any()) } returns Unit + + routeRefreshApi.refreshRoute(accessToken, directionsRoute, routeProgress, callback) + + verify(exactly = 1) { routeRefreshRetrofit.enqueueCall(any(), any()) } + verify(exactly = 0) { callback.onError(any()) } + } + + @Test + fun `should error with empty access token`() { + val accessToken = "" + val directionsRoute: DirectionsRoute = mockk(relaxed = true) + val routeProgress: RouteProgress = mockk(relaxed = true) + val callback: RouteRefreshCallback = mockk(relaxed = true) { + every { onError(any()) } returns Unit + } + + routeRefreshApi.refreshRoute(accessToken, directionsRoute, routeProgress, callback) + + verify(exactly = 0) { callback.onRefresh(any()) } + verify(exactly = 1) { callback.onError(any()) } + } + + @Test + fun `should error with null directions route`() { + val accessToken = "pk.123" + val directionsRoute: DirectionsRoute? = null + val routeProgress: RouteProgress = mockk(relaxed = true) + val callback: RouteRefreshCallback = mockk(relaxed = true) { + every { onError(any()) } returns Unit + } + + routeRefreshApi.refreshRoute(accessToken, directionsRoute, routeProgress, callback) + + verify(exactly = 0) { callback.onRefresh(any()) } + verify(exactly = 1) { callback.onError(any()) } + } + + @Test + fun `should error with empty request uuid`() { + val accessToken = "pk.123" + val directionsRoute: DirectionsRoute? = mockk { + every { routeOptions() } returns mockk { + every { requestUuid() } returns "" + every { profile() } returns DirectionsCriteria.PROFILE_DRIVING_TRAFFIC + } + } + val routeProgress: RouteProgress = mockk(relaxed = true) + val callback: RouteRefreshCallback = mockk(relaxed = true) { + every { onError(any()) } returns Unit + } + + routeRefreshApi.refreshRoute(accessToken, directionsRoute, routeProgress, callback) + + verify(exactly = 0) { callback.onRefresh(any()) } + verify(exactly = 1) { callback.onError(any()) } + } + + @Test + fun `should error with non traffic profiles`() { + val accessToken = "pk.123" + val directionsRoute: DirectionsRoute? = mockk { + every { routeOptions() } returns mockk { + every { requestUuid() } returns "test_request_id" + every { profile() } returns DirectionsCriteria.PROFILE_DRIVING + } + every { routeIndex() } returns "2" + } + val routeProgress: RouteProgress = mockk(relaxed = true) + val callback: RouteRefreshCallback = mockk(relaxed = true) { + every { onError(any()) } returns Unit + } + every { routeRefreshRetrofit.enqueueCall(any(), any()) } returns Unit + + routeRefreshApi.refreshRoute(accessToken, directionsRoute, routeProgress, callback) + + verify(exactly = 0) { routeRefreshRetrofit.enqueueCall(any(), any()) } + verify(exactly = 1) { callback.onError(any()) } + } +}