From 660d8eca2f0e8b86dd53fb2219bb1a066128ca8d Mon Sep 17 00:00:00 2001 From: Dzina Dybouskaya Date: Wed, 2 Nov 2022 23:26:05 +0300 Subject: [PATCH] NAVAND-552: predownload voice instructions --- CHANGELOG.md | 6 + .../examples/core/MapboxNavigationActivity.kt | 1 + .../examples/core/MapboxVoiceActivity.kt | 1 + .../navigation/core/MapboxNavigation.kt | 10 +- .../core/MapboxNavigationProvider.kt | 18 +- .../LegacyMapboxNavigationInstanceHolder.kt | 46 ++ ...egacyMapboxNavigationInstanceHolderTest.kt | 128 ++++++ .../core/MapboxNavigationBaseTest.kt | 3 + .../navigation/core/MapboxNavigationTest.kt | 30 ++ .../mapbox/navigation/utils/internal/Time.kt | 5 + .../navigation/utils/internal/TimeTest.kt | 29 ++ libnavui-voice/api/current.txt | 1 + .../ui/voice/api/MapboxSpeechApi.kt | 8 + ...peechProvider.kt => MapboxSpeechLoader.kt} | 77 +++- .../navigation/ui/voice/api/MapboxVoiceApi.kt | 14 +- .../TimeBasedNextVoiceInstructionsProvider.kt | 53 +-- .../ui/voice/api/VoiceApiProvider.kt | 4 +- .../api/VoiceInstructionsDownloadTrigger.kt | 98 +++++ .../api/VoiceInstructionsPredownloadHub.kt | 50 +++ .../internal/MapboxAudioGuidanceVoice.kt | 4 + .../impl/MapboxAudioGuidanceServices.kt | 7 +- .../ui/voice/api/MapboxSpeechApiTest.kt | 13 + .../ui/voice/api/MapboxSpeechLoaderTest.kt | 375 +++++++++++++++++ .../ui/voice/api/MapboxSpeechProviderTest.kt | 191 --------- .../ui/voice/api/MapboxVoiceApiTest.kt | 68 ++- ...eBasedNextVoiceInstructionsProviderTest.kt | 90 +--- .../VoiceInstructionsDownloadTriggerTest.kt | 394 ++++++++++++++++++ .../VoiceInstructionsPredownloadHubTest.kt | 163 ++++++++ .../impl/MapboxAudioGuidanceServicesTest.kt | 30 ++ .../impl/MapboxAudioGuidanceVoiceTest.kt | 7 + 30 files changed, 1575 insertions(+), 349 deletions(-) create mode 100644 libnavigation-core/src/main/java/com/mapbox/navigation/core/internal/LegacyMapboxNavigationInstanceHolder.kt create mode 100644 libnavigation-core/src/test/java/com/mapbox/navigation/core/LegacyMapboxNavigationInstanceHolderTest.kt create mode 100644 libnavigation-util/src/test/java/com/mapbox/navigation/utils/internal/TimeTest.kt rename libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/{MapboxSpeechProvider.kt => MapboxSpeechLoader.kt} (52%) create mode 100644 libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTrigger.kt create mode 100644 libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPredownloadHub.kt create mode 100644 libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechLoaderTest.kt delete mode 100644 libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechProviderTest.kt create mode 100644 libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTriggerTest.kt create mode 100644 libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPredownloadHubTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 311df95ee80..d06b916de5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ Mapbox welcomes participation and contributions from everyone. .build() } ``` +- Optimized voice instructions downloading. [#6547](https://github.com/mapbox/mapbox-navigation-android/pull/6547) +- Fixed an issue where voice instruction was being played too late with low connectivity. [#6547](https://github.com/mapbox/mapbox-navigation-android/pull/6547) ## Mapbox Navigation SDK 2.10.0-rc.1 - 16 December, 2022 ### Changelog @@ -501,6 +503,10 @@ This release depends on, and has been tested with, the following Mapbox dependen - Mapbox Java `v6.9.0-beta.2` ([release notes](https://github.com/mapbox/mapbox-java/releases/tag/v6.9.0-beta.2)) - Mapbox Android Core `v5.0.2` ([release notes](https://github.com/mapbox/mapbox-events-android/releases/tag/core-5.0.2)) +- Fixed an issue with `NavigationView` that caused road label position to not update in some cases. [#6531](https://github.com/mapbox/mapbox-navigation-android/pull/6531) +- Fixed an issue where `DirectionsResponse#waypoints` list was cleared after a successful non-EV route refresh. [#6539](https://github.com/mapbox/mapbox-navigation-android/pull/6539) +- Fixed an issue with EV route refresh failing in cases where EV data updates are not provided. Now, the initial parameters from a route request will be used as a fallback. [#6534](https://github.com/mapbox/mapbox-navigation-android/pull/6534) + ## Mapbox Navigation SDK 2.10.0-alpha.1 - 28 October, 2022 ### Changelog [Changes between v2.9.0 and v2.10.0-alpha.1](https://github.com/mapbox/mapbox-navigation-android/compare/v2.9.0...v2.10.0-alpha.1) diff --git a/examples/src/main/java/com/mapbox/navigation/examples/core/MapboxNavigationActivity.kt b/examples/src/main/java/com/mapbox/navigation/examples/core/MapboxNavigationActivity.kt index 84ea81b9275..b5886c569b5 100644 --- a/examples/src/main/java/com/mapbox/navigation/examples/core/MapboxNavigationActivity.kt +++ b/examples/src/main/java/com/mapbox/navigation/examples/core/MapboxNavigationActivity.kt @@ -456,6 +456,7 @@ class MapboxNavigationActivity : AppCompatActivity() { mapboxNavigation.onDestroy() maneuverApi.cancel() speechAPI.cancel() + speechAPI.destroy() voiceInstructionsPlayer.shutdown() } diff --git a/examples/src/main/java/com/mapbox/navigation/examples/core/MapboxVoiceActivity.kt b/examples/src/main/java/com/mapbox/navigation/examples/core/MapboxVoiceActivity.kt index faba5fcf149..f32005f3963 100644 --- a/examples/src/main/java/com/mapbox/navigation/examples/core/MapboxVoiceActivity.kt +++ b/examples/src/main/java/com/mapbox/navigation/examples/core/MapboxVoiceActivity.kt @@ -413,6 +413,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener { mapboxReplayer.finish() mapboxNavigation.onDestroy() speechApi.cancel() + speechApi.destroy() voiceInstructionsPlayer.shutdown() } 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 938d940ea2e..a38994acb4d 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 @@ -54,6 +54,7 @@ import com.mapbox.navigation.core.directions.session.RoutesExtra import com.mapbox.navigation.core.directions.session.RoutesObserver import com.mapbox.navigation.core.history.MapboxHistoryReader import com.mapbox.navigation.core.history.MapboxHistoryRecorder +import com.mapbox.navigation.core.internal.LegacyMapboxNavigationInstanceHolder import com.mapbox.navigation.core.internal.ReachabilityService import com.mapbox.navigation.core.internal.telemetry.CustomEvent import com.mapbox.navigation.core.internal.telemetry.UserFeedbackCallback @@ -405,7 +406,7 @@ class MapboxNavigation @VisibleForTesting internal constructor( private set init { - if (hasInstance) { + if (LegacyMapboxNavigationInstanceHolder.peek() != null) { throw IllegalStateException( """ A different MapboxNavigation instance already exists. @@ -414,7 +415,6 @@ class MapboxNavigation @VisibleForTesting internal constructor( """.trimIndent() ) } - hasInstance = true val config = NavigatorLoader.createConfig( navigationOptions.deviceProfile, @@ -583,6 +583,7 @@ class MapboxNavigation @VisibleForTesting internal constructor( navigator.cache ) roadObjectMatcher = RoadObjectMatcher(navigator) + LegacyMapboxNavigationInstanceHolder.onCreated(this) } /** @@ -1212,7 +1213,7 @@ class MapboxNavigation @VisibleForTesting internal constructor( routesPreviewController.unregisterAllRoutesPreviewObservers() isDestroyed = true - hasInstance = false + LegacyMapboxNavigationInstanceHolder.onDestroyed() } /** @@ -2072,9 +2073,6 @@ class MapboxNavigation @VisibleForTesting internal constructor( private companion object { - @Volatile - private var hasInstance = false - private const val LOG_CATEGORY = "MapboxNavigation" private const val USER_AGENT: String = "MapboxNavigationNative" private const val THREADS_COUNT = 2 diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/MapboxNavigationProvider.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/MapboxNavigationProvider.kt index a2912c6b620..d88ab982611 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/MapboxNavigationProvider.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/MapboxNavigationProvider.kt @@ -2,6 +2,7 @@ package com.mapbox.navigation.core import androidx.annotation.UiThread import com.mapbox.navigation.base.options.NavigationOptions +import com.mapbox.navigation.core.internal.LegacyMapboxNavigationInstanceHolder /** * Singleton responsible for ensuring there is only one MapboxNavigation instance. @@ -11,8 +12,6 @@ import com.mapbox.navigation.base.options.NavigationOptions message = "Use MapboxNavigationApp to attach MapboxNavigation to lifecycles." ) object MapboxNavigationProvider { - @Volatile - private var mapboxNavigation: MapboxNavigation? = null /** * Create MapboxNavigation with provided options. @@ -25,12 +24,8 @@ object MapboxNavigationProvider { message = "Set the navigation options with MapboxNavigationApp.setup" ) fun create(navigationOptions: NavigationOptions): MapboxNavigation { - mapboxNavigation?.onDestroy() - mapboxNavigation = MapboxNavigation( - navigationOptions - ) - - return mapboxNavigation!! + LegacyMapboxNavigationInstanceHolder.peek()?.onDestroy() + return MapboxNavigation(navigationOptions) } /** @@ -48,7 +43,7 @@ object MapboxNavigationProvider { throw RuntimeException("Need to create MapboxNavigation before using it.") } - return mapboxNavigation!! + return LegacyMapboxNavigationInstanceHolder.peek()!! } /** @@ -59,8 +54,7 @@ object MapboxNavigationProvider { message = "MapboxNavigationApp will determine when to destroy MapboxNavigation instances" ) fun destroy() { - mapboxNavigation?.onDestroy() - mapboxNavigation = null + LegacyMapboxNavigationInstanceHolder.peek()?.onDestroy() } /** @@ -68,6 +62,6 @@ object MapboxNavigationProvider { */ @JvmStatic fun isCreated(): Boolean { - return mapboxNavigation?.isDestroyed == false + return LegacyMapboxNavigationInstanceHolder.peek()?.isDestroyed == false } } diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/internal/LegacyMapboxNavigationInstanceHolder.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/internal/LegacyMapboxNavigationInstanceHolder.kt new file mode 100644 index 00000000000..d0a5073a7fa --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/internal/LegacyMapboxNavigationInstanceHolder.kt @@ -0,0 +1,46 @@ +package com.mapbox.navigation.core.internal + +import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting +import com.mapbox.navigation.core.MapboxNavigation +import java.util.concurrent.CopyOnWriteArraySet + +fun interface MapboxNavigationCreateObserver { + + fun onCreated(mapboxNavigation: MapboxNavigation) +} + +@Deprecated("Used to keep track of MapboxNavigation instances created via deprecated methods") +@UiThread +object LegacyMapboxNavigationInstanceHolder { + + @Volatile + private var mapboxNavigation: MapboxNavigation? = null + + private val createObservers = CopyOnWriteArraySet() + + fun onCreated(instance: MapboxNavigation) { + mapboxNavigation = instance + createObservers.forEach { it.onCreated(instance) } + } + + fun onDestroyed() { + mapboxNavigation = null + } + + fun peek(): MapboxNavigation? = mapboxNavigation + + fun registerCreateObserver(observer: MapboxNavigationCreateObserver) { + createObservers.add(observer) + mapboxNavigation?.let { observer.onCreated(it) } + } + + fun unregisterCreateObserver(observer: MapboxNavigationCreateObserver) { + createObservers.remove(observer) + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun unregisterAllCreateObservers() { + createObservers.clear() + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/LegacyMapboxNavigationInstanceHolderTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/LegacyMapboxNavigationInstanceHolderTest.kt new file mode 100644 index 00000000000..45354ed4183 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/LegacyMapboxNavigationInstanceHolderTest.kt @@ -0,0 +1,128 @@ +package com.mapbox.navigation.core + +import com.mapbox.navigation.core.internal.LegacyMapboxNavigationInstanceHolder +import com.mapbox.navigation.core.internal.MapboxNavigationCreateObserver +import com.mapbox.navigation.testing.LoggingFrontendTestRule +import io.mockk.clearMocks +import io.mockk.mockk +import io.mockk.verify +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 +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class LegacyMapboxNavigationInstanceHolderTest { + + @get:Rule + val loggerRule = LoggingFrontendTestRule() + private val observer = mockk(relaxed = true) + private val mapboxNavigation = mockk(relaxed = true) + + @Before + fun setUp() { + LegacyMapboxNavigationInstanceHolder.unregisterAllCreateObservers() + LegacyMapboxNavigationInstanceHolder.onDestroyed() + } + + @After + fun tearDown() { + LegacyMapboxNavigationInstanceHolder.unregisterAllCreateObservers() + LegacyMapboxNavigationInstanceHolder.onDestroyed() + } + + @Test + fun `observer is invoked on registration if has mapboxNavigation`() { + LegacyMapboxNavigationInstanceHolder.onCreated(mapboxNavigation) + + LegacyMapboxNavigationInstanceHolder.registerCreateObserver(observer) + + verify(exactly = 1) { observer.onCreated(mapboxNavigation) } + } + + @Test + fun `observer is not invoked on registration if has no mapboxNavigation`() { + LegacyMapboxNavigationInstanceHolder.registerCreateObserver(observer) + + verify(exactly = 0) { observer.onCreated(mapboxNavigation) } + } + + @Test + fun `observer is not invoked on registration if has destroyed mapboxNavigation`() { + LegacyMapboxNavigationInstanceHolder.onCreated(mapboxNavigation) + LegacyMapboxNavigationInstanceHolder.onDestroyed() + + LegacyMapboxNavigationInstanceHolder.registerCreateObserver(observer) + + verify(exactly = 0) { observer.onCreated(mapboxNavigation) } + } + + @Test + fun `observer is invoked when mapboxNavigation is created`() { + LegacyMapboxNavigationInstanceHolder.registerCreateObserver(observer) + + LegacyMapboxNavigationInstanceHolder.onCreated(mapboxNavigation) + + verify(exactly = 1) { observer.onCreated(mapboxNavigation) } + } + + @Test + fun `observer is invoked when new mapboxNavigation is created`() { + LegacyMapboxNavigationInstanceHolder.registerCreateObserver(observer) + LegacyMapboxNavigationInstanceHolder.onCreated(mapboxNavigation) + clearMocks(observer, answers = false) + + val newNavigation = mockk(relaxed = true) + LegacyMapboxNavigationInstanceHolder.onCreated(newNavigation) + + verify(exactly = 1) { observer.onCreated(newNavigation) } + } + + @Test + fun `removed observer is not invoked when mapboxNavigation is created`() { + LegacyMapboxNavigationInstanceHolder.registerCreateObserver(observer) + LegacyMapboxNavigationInstanceHolder.unregisterCreateObserver(observer) + + LegacyMapboxNavigationInstanceHolder.onCreated(mapboxNavigation) + + verify(exactly = 0) { observer.onCreated(any()) } + } + + @Test + fun `multiple observers are invoked when new mapboxNavigation is created`() { + val secondObserver = mockk(relaxed = true) + LegacyMapboxNavigationInstanceHolder.registerCreateObserver(observer) + LegacyMapboxNavigationInstanceHolder.registerCreateObserver(secondObserver) + + LegacyMapboxNavigationInstanceHolder.onCreated(mapboxNavigation) + + verify(exactly = 1) { + observer.onCreated(mapboxNavigation) + secondObserver.onCreated(mapboxNavigation) + } + } + + @Test + fun `peek with no instance`() { + assertNull(LegacyMapboxNavigationInstanceHolder.peek()) + } + + @Test + fun `peek with active instance`() { + LegacyMapboxNavigationInstanceHolder.onCreated(mapboxNavigation) + + assertEquals(mapboxNavigation, LegacyMapboxNavigationInstanceHolder.peek()) + } + + @Test + fun `peek with destroyed instance`() { + LegacyMapboxNavigationInstanceHolder.onCreated(mapboxNavigation) + LegacyMapboxNavigationInstanceHolder.onDestroyed() + + assertNull(LegacyMapboxNavigationInstanceHolder.peek()) + } +} 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 5680be04c4b..7eae1e364c6 100644 --- a/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationBaseTest.kt +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationBaseTest.kt @@ -20,6 +20,7 @@ import com.mapbox.navigation.core.accounts.BillingController import com.mapbox.navigation.core.arrival.ArrivalProgressObserver import com.mapbox.navigation.core.directions.session.DirectionsSession import com.mapbox.navigation.core.ev.EVDynamicDataHolder +import com.mapbox.navigation.core.internal.LegacyMapboxNavigationInstanceHolder import com.mapbox.navigation.core.navigator.CacheHandleWrapper import com.mapbox.navigation.core.preview.RoutesPreviewController import com.mapbox.navigation.core.reroute.RerouteController @@ -129,6 +130,7 @@ internal open class MapboxNavigationBaseTest { @Before open fun setUp() { + LegacyMapboxNavigationInstanceHolder.onDestroyed() every { threadController.getMainScopeAndRootJob() } answers { JobControl(mockk(), coroutineRule.createTestScope()) } @@ -247,6 +249,7 @@ internal open class MapboxNavigationBaseTest { unmockkObject(EventsServiceProvider) unmockkObject(TelemetryServiceProvider) unmockkObject(TelemetryUtilsDelegate) + LegacyMapboxNavigationInstanceHolder.onDestroyed() } fun createMapboxNavigation() { 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 8c5177ab571..cfded56ebb3 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 @@ -17,6 +17,7 @@ import com.mapbox.navigation.core.directions.session.RoutesExtra import com.mapbox.navigation.core.directions.session.RoutesObserver import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult import com.mapbox.navigation.core.internal.HistoryRecordingStateChangeObserver +import com.mapbox.navigation.core.internal.LegacyMapboxNavigationInstanceHolder import com.mapbox.navigation.core.internal.extensions.registerHistoryRecordingStateChangeObserver import com.mapbox.navigation.core.internal.extensions.unregisterHistoryRecordingStateChangeObserver import com.mapbox.navigation.core.internal.telemetry.NavigationCustomEventType @@ -56,6 +57,7 @@ import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.slot import io.mockk.verify import io.mockk.verifyOrder @@ -1898,4 +1900,32 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { routesPreviewController.previewNavigationRoutes(emptyList()) } } + + @Test + fun mapboxNavigationCreationSetsInstanceToHolder() { + mockkObject(LegacyMapboxNavigationInstanceHolder) { + createMapboxNavigation() + verify { LegacyMapboxNavigationInstanceHolder.onCreated(mapboxNavigation) } + } + } + + @Test(expected = IllegalStateException::class) + fun mapboxNavigationIsNotCreatedIfHolderHasInstance() { + mockkObject(LegacyMapboxNavigationInstanceHolder) { + every { LegacyMapboxNavigationInstanceHolder.peek() } returns mockk(relaxed = true) + createMapboxNavigation() + verify(exactly = 0) { + LegacyMapboxNavigationInstanceHolder.onCreated(any()) + } + } + } + + @Test + fun mapboxNavigationDestructionRemovesInstanceFromHolder() { + mockkObject(LegacyMapboxNavigationInstanceHolder) { + createMapboxNavigation() + mapboxNavigation.onDestroy() + verify { LegacyMapboxNavigationInstanceHolder.onDestroyed() } + } + } } diff --git a/libnavigation-util/src/main/java/com/mapbox/navigation/utils/internal/Time.kt b/libnavigation-util/src/main/java/com/mapbox/navigation/utils/internal/Time.kt index e962427ff6c..a82ea6e2193 100644 --- a/libnavigation-util/src/main/java/com/mapbox/navigation/utils/internal/Time.kt +++ b/libnavigation-util/src/main/java/com/mapbox/navigation/utils/internal/Time.kt @@ -1,12 +1,17 @@ package com.mapbox.navigation.utils.internal +import java.util.concurrent.TimeUnit + interface Time { fun nanoTime(): Long fun millis(): Long + fun seconds(): Long object SystemImpl : Time { override fun nanoTime(): Long = System.nanoTime() override fun millis(): Long = System.currentTimeMillis() + + override fun seconds(): Long = TimeUnit.MILLISECONDS.toSeconds(millis()) } } diff --git a/libnavigation-util/src/test/java/com/mapbox/navigation/utils/internal/TimeTest.kt b/libnavigation-util/src/test/java/com/mapbox/navigation/utils/internal/TimeTest.kt new file mode 100644 index 00000000000..5d519b4a763 --- /dev/null +++ b/libnavigation-util/src/test/java/com/mapbox/navigation/utils/internal/TimeTest.kt @@ -0,0 +1,29 @@ +package com.mapbox.navigation.utils.internal + +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.math.abs + +class TimeTest { + + @Test + fun seconds() { + val tolerance = 1 + val diff = abs(System.currentTimeMillis() / 1000 - Time.SystemImpl.seconds()) + assertTrue(diff < tolerance) + } + + @Test + fun millis() { + val tolerance = 100 + val diff = abs(System.currentTimeMillis() - Time.SystemImpl.millis()) + assertTrue(diff < tolerance) + } + + @Test + fun nanoTime() { + val tolerance = 100000000 + val diff = abs(System.nanoTime() - Time.SystemImpl.nanoTime()) + assertTrue(diff < tolerance) + } +} diff --git a/libnavui-voice/api/current.txt b/libnavui-voice/api/current.txt index 0bb0e82f786..e1fb0ce3ab0 100644 --- a/libnavui-voice/api/current.txt +++ b/libnavui-voice/api/current.txt @@ -63,6 +63,7 @@ package com.mapbox.navigation.ui.voice.api { ctor public MapboxSpeechApi(android.content.Context context, String accessToken, String language); method public void cancel(); method public void clean(com.mapbox.navigation.ui.voice.model.SpeechAnnouncement announcement); + method public void destroy(); method public void generate(com.mapbox.api.directions.v5.models.VoiceInstructions voiceInstruction, com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer> consumer); } diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApi.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApi.kt index 63c42cc3f28..a18a5d6102d 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApi.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApi.kt @@ -75,6 +75,14 @@ class MapboxSpeechApi @JvmOverloads constructor( voiceAPI.clean(announcement) } + /** + * The method stops all work related to pre-downloading voice instructions and unregisters + * all related callbacks. It should be invoked from `Activity#onDestroy`. + */ + fun destroy() { + voiceAPI.destroy() + } + @Throws(IllegalStateException::class) private suspend fun retrieveVoiceFile( voiceInstruction: VoiceInstructions, diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechProvider.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechLoader.kt similarity index 52% rename from libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechProvider.kt rename to libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechLoader.kt index 06d04fe99fb..04e1a826049 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechProvider.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechLoader.kt @@ -5,10 +5,13 @@ package com.mapbox.navigation.ui.voice.api import android.net.Uri +import com.mapbox.api.directions.v5.models.VoiceInstructions import com.mapbox.bindgen.Expected import com.mapbox.bindgen.ExpectedFactory.createError import com.mapbox.bindgen.ExpectedFactory.createValue +import com.mapbox.common.NetworkRestriction import com.mapbox.common.ResourceLoadError +import com.mapbox.common.ResourceLoadFlags import com.mapbox.common.ResourceLoadResult import com.mapbox.common.ResourceLoadStatus import com.mapbox.navigation.base.internal.accounts.UrlSkuTokenProvider @@ -17,28 +20,88 @@ import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoader import com.mapbox.navigation.ui.utils.internal.resource.load import com.mapbox.navigation.ui.voice.model.TypeAndAnnouncement import com.mapbox.navigation.ui.voice.options.MapboxSpeechApiOptions +import com.mapbox.navigation.utils.internal.InternalJobControlFactory +import com.mapbox.navigation.utils.internal.logE +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch import java.net.MalformedURLException import java.net.URL -internal class MapboxSpeechProvider( +internal class MapboxSpeechLoader( private val accessToken: String, private val language: String, private val urlSkuTokenProvider: UrlSkuTokenProvider, private val options: MapboxSpeechApiOptions, - private val resourceLoader: ResourceLoader -) { + private val resourceLoader: ResourceLoader, +) : VoiceInstructionsDownloadTriggerObserver { - suspend fun load(typeAndAnnouncement: TypeAndAnnouncement): Expected { + private val currentRequests = mutableSetOf() + private val downloadedInstructions = mutableSetOf() + private val downloadedInstructionsLock = Any() + private val defaultScope = InternalJobControlFactory.createDefaultScopeJobControl().scope + + suspend fun load(voiceInstruction: VoiceInstructions): Expected { return runCatching { - val url = instructionUrl(typeAndAnnouncement.announcement, typeAndAnnouncement.type) - val response = resourceLoader.load(url) + val typeAndAnnouncement = VoiceInstructionsParser.parse(voiceInstruction) + .getValueOrElse { throw it } + val request = createRequest(typeAndAnnouncement).apply { + networkRestriction = NetworkRestriction.DISALLOW_ALL + } + val response = resourceLoader.load(request) return processResponse(response) }.getOrElse { createError(it) } } - private suspend fun ResourceLoader.load(url: String) = load(ResourceLoadRequest(url)) + override fun trigger(voiceInstructions: List) { + defaultScope.launch { + voiceInstructions.forEach { voiceInstruction -> + val typeAndAnnouncement = VoiceInstructionsParser.parse(voiceInstruction).value + if (typeAndAnnouncement != null && !hasTypeAndAnnouncement(typeAndAnnouncement)) { + predownload(typeAndAnnouncement) + } + } + } + } + + fun cancel() { + defaultScope.cancel() + currentRequests.forEach { resourceLoader.cancel(it) } + } + + private fun hasTypeAndAnnouncement(typeAndAnnouncement: TypeAndAnnouncement): Boolean { + synchronized(downloadedInstructionsLock) { + return typeAndAnnouncement in downloadedInstructions + } + } + + private fun predownload(typeAndAnnouncement: TypeAndAnnouncement) { + try { + val request = createRequest(typeAndAnnouncement) + var id: Long? = null + id = resourceLoader.load(request) { result -> + id?.let { currentRequests.remove(it) } + // tilestore thread + if (result.isValue) { + synchronized(downloadedInstructionsLock) { + downloadedInstructions.add(typeAndAnnouncement) + } + } + } + currentRequests.add(id) + } catch (ex: Throwable) { + logE("Failed to download instruction '$typeAndAnnouncement': ${ex.localizedMessage}") + } + } + + private fun createRequest(typeAndAnnouncement: TypeAndAnnouncement): ResourceLoadRequest { + val url = instructionUrl(typeAndAnnouncement.announcement, typeAndAnnouncement.type) + return ResourceLoadRequest(url).apply { + flags = ResourceLoadFlags.ACCEPT_EXPIRED + } + } private fun processResponse( response: Expected diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxVoiceApi.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxVoiceApi.kt index ff98a27c2b2..b79b9afedf6 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxVoiceApi.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxVoiceApi.kt @@ -12,18 +12,21 @@ import java.io.File * Implementation of [VoiceApi] allowing you to retrieve voice instructions. */ internal class MapboxVoiceApi( - private val speechProvider: MapboxSpeechProvider, + private val speechLoader: MapboxSpeechLoader, private val speechFileProvider: MapboxSpeechFileProvider ) : VoiceApi { + init { + VoiceInstructionsPredownloadHub.register(speechLoader) + } + /** * Given [VoiceInstructions] the method returns a [File] wrapped inside [VoiceState] * @param voiceInstruction VoiceInstructions object representing [VoiceInstructions] */ override suspend fun retrieveVoiceFile(voiceInstruction: VoiceInstructions): VoiceState { return runCatching { - val typeAndAnnouncement = VoiceInstructionsParser.parse(voiceInstruction).getOrThrow() - val blob = speechProvider.load(typeAndAnnouncement).getOrThrow() + val blob = speechLoader.load(voiceInstruction).getOrThrow() val file = speechFileProvider.generateVoiceFileFrom(blob.inputStream()) VoiceFile(file) }.getOrElse { @@ -48,6 +51,11 @@ internal class MapboxVoiceApi( speechFileProvider.cancel() } + fun destroy() { + VoiceInstructionsPredownloadHub.unregister(speechLoader) + speechLoader.cancel() + } + private fun genericError(voiceInstruction: VoiceInstructions) = "Cannot load voice instructions $voiceInstruction" diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/TimeBasedNextVoiceInstructionsProvider.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/TimeBasedNextVoiceInstructionsProvider.kt index 0cf8aa20fff..dadc08234cf 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/TimeBasedNextVoiceInstructionsProvider.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/TimeBasedNextVoiceInstructionsProvider.kt @@ -1,7 +1,5 @@ package com.mapbox.navigation.ui.voice.api -import com.mapbox.api.directions.v5.models.LegStep -import com.mapbox.api.directions.v5.models.RouteLeg import com.mapbox.api.directions.v5.models.VoiceInstructions import com.mapbox.navigation.utils.internal.logW @@ -27,22 +25,25 @@ internal class TimeBasedNextVoiceInstructionsProvider( } val voiceInstructions = mutableListOf() - fillCurrentStepVoiceInstructions( - currentStep, - progress.stepDistanceRemaining, - voiceInstructions - ) var cumulatedTime = progress.stepDurationRemaining + val currentStepInstructions = currentStep.voiceInstructions()?.filter { instruction -> + val distanceAlongGeometry = instruction.distanceAlongGeometry() + distanceAlongGeometry != null + && distanceAlongGeometry <= progress.stepDistanceRemaining + } + if (currentStepInstructions != null) { + voiceInstructions.addAll(currentStepInstructions) + } - // fill next steps var currentStepIndex = progress.stepIndex var currentLegIndex = progress.legIndex while (cumulatedTime < observableTimeSeconds) { - if (isLastStep(currentStepIndex, legSteps)) { + if (currentStepIndex + 1 < (legSteps?.size ?: 0)) { + currentStep = legSteps!![currentStepIndex + 1] + currentStepIndex++ + } else { currentStepIndex = 0 - if (isLastLeg(currentLegIndex, legs)) { - break - } else { + if (currentLegIndex + 1 < legs.size) { legSteps = legs[currentLegIndex + 1].steps() currentLegIndex++ if (legSteps.isNullOrEmpty()) { @@ -50,10 +51,9 @@ internal class TimeBasedNextVoiceInstructionsProvider( } else { currentStep = legSteps.first() } + } else { + break } - } else { - currentStep = legSteps!![currentStepIndex + 1] - currentStepIndex++ } currentStep.voiceInstructions()?.let { voiceInstructions.addAll(it) } cumulatedTime += currentStep.duration() @@ -61,29 +61,6 @@ internal class TimeBasedNextVoiceInstructionsProvider( return voiceInstructions } - private fun fillCurrentStepVoiceInstructions( - currentStep: LegStep, - stepDistanceRemaining: Double, - voiceInstructions: MutableList - ) { - val currentStepInstructions = currentStep.voiceInstructions()?.filter { instruction -> - val distanceAlongGeometry = instruction.distanceAlongGeometry() - distanceAlongGeometry != null && - distanceAlongGeometry <= stepDistanceRemaining - } - if (currentStepInstructions != null) { - voiceInstructions.addAll(currentStepInstructions) - } - } - - private fun isLastStep(currentStepIndex: Int, legSteps: List?): Boolean { - return currentStepIndex + 1 >= (legSteps?.size ?: 0) - } - - private fun isLastLeg(currentLegIndex: Int, legs: List): Boolean { - return currentLegIndex + 1 >= legs.size - } - private companion object { private const val LOG_CATEGORY = "TimeBasedNextVoiceInstructionsProvider" } diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceApiProvider.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceApiProvider.kt index 389491d008e..0ad660f9325 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceApiProvider.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceApiProvider.kt @@ -14,9 +14,9 @@ internal object VoiceApiProvider { context: Context, accessToken: String, language: String, - options: MapboxSpeechApiOptions + options: MapboxSpeechApiOptions, ): MapboxVoiceApi = MapboxVoiceApi( - MapboxSpeechProvider( + MapboxSpeechLoader( accessToken, language, MapboxNavigationAccounts, diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTrigger.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTrigger.kt new file mode 100644 index 00000000000..34c03b28d3f --- /dev/null +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTrigger.kt @@ -0,0 +1,98 @@ +package com.mapbox.navigation.ui.voice.api + +import androidx.annotation.VisibleForTesting +import com.mapbox.api.directions.v5.models.VoiceInstructions +import com.mapbox.navigation.base.trip.model.RouteProgress +import com.mapbox.navigation.core.directions.session.RoutesExtra +import com.mapbox.navigation.core.directions.session.RoutesObserver +import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult +import com.mapbox.navigation.core.trip.session.RouteProgressObserver +import com.mapbox.navigation.utils.internal.Time +import com.mapbox.navigation.utils.internal.ifNonNull +import java.util.concurrent.CopyOnWriteArraySet + +internal interface VoiceInstructionsDownloadTriggerObserver { + + fun trigger(voiceInstructions: List) +} + +internal class VoiceInstructionsDownloadTrigger( + private val observableTime: Int, + private val timePercentageToTriggerAfter: Double, + private val nextVoiceInstructionsProvider: NextVoiceInstructionsProvider + = TimeBasedNextVoiceInstructionsProvider(observableTime), + private val timeProvider: Time = Time.SystemImpl, +) : RoutesObserver, RouteProgressObserver { + + private val ignoredRouteUpdateReasons = setOf( + RoutesExtra.ROUTES_UPDATE_REASON_CLEAN_UP, + RoutesExtra.ROUTES_UPDATE_REASON_ALTERNATIVE, + RoutesExtra.ROUTES_UPDATE_REASON_REFRESH, + ) + private var lastDownloadTime: Long = 0 + @VisibleForTesting + internal val observers = CopyOnWriteArraySet() + + fun registerObserver(observer: VoiceInstructionsDownloadTriggerObserver) { + observers.add(observer) + } + + fun unregisterObserver(observer: VoiceInstructionsDownloadTriggerObserver) { + observers.remove(observer) + } + + override fun onRoutesChanged(result: RoutesUpdatedResult) { + if (result.reason in ignoredRouteUpdateReasons) { + return + } + result.navigationRoutes.firstOrNull()?.let { + val currentStep = it.directionsRoute.legs()?.firstOrNull()?.steps()?.firstOrNull() + if (currentStep != null) { + val progress = RouteProgressData( + it.directionsRoute, + 0, + 0, + currentStep.duration(), + currentStep.distance() + ) + triggerDownload(progress) + } + } + } + + override fun onRouteProgressChanged(routeProgress: RouteProgress) { + if (shouldDownloadBasedOnTime()) { + val legIndex = routeProgress.currentLegProgress?.legIndex + val currentStepProgress = routeProgress.currentLegProgress?.currentStepProgress + val stepIndex = currentStepProgress?.stepIndex + val durationRemaining = currentStepProgress?.durationRemaining + val distanceRemaining = currentStepProgress?.distanceRemaining + ifNonNull( + legIndex, + stepIndex, + durationRemaining, + distanceRemaining + ) { legIndex, stepIndex, durationRemaining, distanceRemaining -> + val progressData = RouteProgressData( + routeProgress.route, + legIndex, + stepIndex, + durationRemaining, + distanceRemaining.toDouble() + ) + triggerDownload(progressData) + } + } + } + + private fun shouldDownloadBasedOnTime(): Boolean { + return timeProvider.seconds() >= lastDownloadTime + observableTime * timePercentageToTriggerAfter + } + + private fun triggerDownload(progressData: RouteProgressData) { + lastDownloadTime = timeProvider.seconds() + val nextInstructionsToDownload = nextVoiceInstructionsProvider + .getNextVoiceInstructions(progressData) + observers.forEach { it.trigger(nextInstructionsToDownload) } + } +} diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPredownloadHub.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPredownloadHub.kt new file mode 100644 index 00000000000..ae5acbdc31e --- /dev/null +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPredownloadHub.kt @@ -0,0 +1,50 @@ +package com.mapbox.navigation.ui.voice.api + +import androidx.annotation.VisibleForTesting +import com.mapbox.navigation.core.internal.LegacyMapboxNavigationInstanceHolder +import com.mapbox.navigation.core.internal.MapboxNavigationCreateObserver + +private data class TriggerAndObserver( + val trigger: VoiceInstructionsDownloadTrigger, + val observer: MapboxNavigationCreateObserver, +) + +internal object VoiceInstructionsPredownloadHub { + + private const val DEFAULT_OBSERVABLE_TIME_SECONDS = 3 * 60 // 3 minutes + private const val DEFAULT_TIME_PERCENTAGE_TO_TRIGGER_AFTER = 0.5 + + private val registrants = hashMapOf() + + fun register(loader: MapboxSpeechLoader) { + if (loader !in registrants) { + val trigger = VoiceInstructionsDownloadTrigger( + DEFAULT_OBSERVABLE_TIME_SECONDS, + DEFAULT_TIME_PERCENTAGE_TO_TRIGGER_AFTER + ) + val observer = MapboxNavigationCreateObserver { mapboxNavigation -> + mapboxNavigation.registerRoutesObserver(trigger) + mapboxNavigation.registerRouteProgressObserver(trigger) + } + trigger.registerObserver(loader) + LegacyMapboxNavigationInstanceHolder.registerCreateObserver(observer) + registrants[loader] = TriggerAndObserver(trigger, observer) + } + } + + fun unregister(loader: MapboxSpeechLoader) { + registrants.remove(loader)?.let { (trigger, observer) -> + trigger.unregisterObserver(loader) + LegacyMapboxNavigationInstanceHolder.peek()?.let { + it.unregisterRoutesObserver(trigger) + it.unregisterRouteProgressObserver(trigger) + } + LegacyMapboxNavigationInstanceHolder.unregisterCreateObserver(observer) + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + fun unregisterAll() { + registrants.clear() + } +} diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/internal/MapboxAudioGuidanceVoice.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/internal/MapboxAudioGuidanceVoice.kt index 9bfa672ddbc..d1dfb97b67b 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/internal/MapboxAudioGuidanceVoice.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/internal/MapboxAudioGuidanceVoice.kt @@ -36,6 +36,10 @@ class MapboxAudioGuidanceVoice( } } + fun destroy() { + mapboxSpeechApi.destroy() + } + @OptIn(ExperimentalCoroutinesApi::class) private fun speechFlow(voiceInstructions: VoiceInstructions): Flow = callbackFlow { diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/internal/impl/MapboxAudioGuidanceServices.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/internal/impl/MapboxAudioGuidanceServices.kt index b44c8ff1161..52755282cc2 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/internal/impl/MapboxAudioGuidanceServices.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/internal/impl/MapboxAudioGuidanceServices.kt @@ -11,6 +11,8 @@ import com.mapbox.navigation.ui.voice.internal.MapboxVoiceInstructions class MapboxAudioGuidanceServices { + private var mapboxAudioGuidanceVoice: MapboxAudioGuidanceVoice? = null + var voiceInstructionsPlayer: MapboxVoiceInstructionsPlayer? = null private set @@ -18,13 +20,16 @@ class MapboxAudioGuidanceServices { mapboxNavigation: MapboxNavigation, language: String, ): MapboxAudioGuidanceVoice { + mapboxAudioGuidanceVoice?.destroy() val mapboxSpeechApi = mapboxSpeechApi(mapboxNavigation, language) val mapboxVoiceInstructionsPlayer = getOrUpdateMapboxVoiceInstructionsPlayer(mapboxNavigation, language) return MapboxAudioGuidanceVoice( mapboxSpeechApi, mapboxVoiceInstructionsPlayer - ) + ).also { + mapboxAudioGuidanceVoice = it + } } fun mapboxSpeechApi( diff --git a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApiTest.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApiTest.kt index 3da33ee9d21..5c9d1940587 100644 --- a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApiTest.kt +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechApiTest.kt @@ -191,4 +191,17 @@ class MapboxSpeechApiTest { mockedVoiceApi.clean(anyAnnouncement) } } + + @Test + fun destroy() { + val mockedVoiceApi = mockk(relaxed = true) + every { + VoiceApiProvider.retrieveMapboxVoiceApi(any(), any(), any(), any()) + } returns mockedVoiceApi + + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.destroy() + + verify { mockedVoiceApi.destroy() } + } } diff --git a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechLoaderTest.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechLoaderTest.kt new file mode 100644 index 00000000000..59e90870cef --- /dev/null +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechLoaderTest.kt @@ -0,0 +1,375 @@ +package com.mapbox.navigation.ui.voice.api + +import android.net.Uri +import androidx.core.net.toUri +import com.mapbox.api.directions.v5.models.VoiceInstructions +import com.mapbox.bindgen.Expected +import com.mapbox.bindgen.ExpectedFactory +import com.mapbox.common.NetworkRestriction +import com.mapbox.common.ResourceLoadError +import com.mapbox.common.ResourceLoadFlags +import com.mapbox.common.ResourceLoadResult +import com.mapbox.common.ResourceLoadStatus +import com.mapbox.navigation.base.internal.accounts.UrlSkuTokenProvider +import com.mapbox.navigation.testing.MainCoroutineRule +import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoadCallback +import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoadRequest +import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoader +import com.mapbox.navigation.ui.voice.options.MapboxSpeechApiOptions +import com.mapbox.navigation.ui.voice.testutils.Fixtures +import com.mapbox.navigation.utils.internal.InternalJobControlFactory +import com.mapbox.navigation.utils.internal.JobControl +import com.mapbox.navigation.utils.internal.ThreadController +import io.mockk.CapturingSlot +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.net.URL + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class MapboxSpeechLoaderTest { + + private lateinit var sut: MapboxSpeechLoader + + @get:Rule + val coroutineRule = MainCoroutineRule() + private var accessToken = "access_token" + private val language = "en" + private val apiOptions = MapboxSpeechApiOptions.Builder() + .baseUri("https://example.com") + .build() + private val sku = "SKU" + + private lateinit var mockResourceLoader: ResourceLoader + private var stubSkuTokenProvider: UrlSkuTokenProvider = UrlSkuTokenProvider { + URL("$it&sku=$sku") + } + + @Before + fun setUp() { + mockkObject(InternalJobControlFactory) + every { + InternalJobControlFactory.createDefaultScopeJobControl() + } returns JobControl(mockk(), coroutineRule.createTestScope()) + mockkObject(ThreadController) + + mockResourceLoader = mockk(relaxed = true) + + sut = MapboxSpeechLoader( + accessToken = accessToken, + language = language, + urlSkuTokenProvider = stubSkuTokenProvider, + options = apiOptions, + resourceLoader = mockResourceLoader + ) + } + + @After + fun tearDown() { + unmockkObject(ThreadController) + } + + @Test + fun `load should use ResourceLoader to load audio data`() = runBlocking { + val instructions = Fixtures.ssmlInstructions() + val requestCapture = slot() + val callbackCapture = slot() + + val loadResult = Fixtures.resourceLoadResult(null, ResourceLoadStatus.NOT_FOUND) + every { + mockResourceLoader.load(capture(requestCapture), capture(callbackCapture)) + } answers { + callbackCapture.captured.onFinish( + requestCapture.captured, + ExpectedFactory.createValue(loadResult) + ) + 1L + } + + sut.load(instructions) + + val loadRequest = requestCapture.captured + val loadRequestUri = loadRequest.url.toUri() + assertEquals("https", loadRequestUri.scheme) + assertEquals("example.com", loadRequestUri.authority) + assertEquals( + "/voice/v1/speak/${UrlUtils.encodePathSegment(instructions.ssmlAnnouncement()!!)}", + loadRequestUri.encodedPath + ) + assertEquals( + mapOf( + "textType" to "ssml", + "language" to language, + "access_token" to accessToken, + "sku" to sku + ), + loadRequestUri.getQueryParams() + ) + assertEquals( + NetworkRestriction.DISALLOW_ALL, + loadRequest.networkRestriction + ) + assertEquals( + ResourceLoadFlags.ACCEPT_EXPIRED, + loadRequest.flags + ) + } + + @Test + fun `load should return Expected with non empty audio BLOB on success`() = + runBlocking { + val instructions = Fixtures.textInstructions() + val blob = byteArrayOf(12, 23, 34) + val loadRequest = ResourceLoadRequest("https://some.url") + val loadResult = Fixtures.resourceLoadResult( + Fixtures.resourceData(blob), + ResourceLoadStatus.AVAILABLE + ) + givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) + + val result = sut.load(instructions) + + assertEquals(blob, result.value) + } + + @Test + fun `load should return Expected with an Error on AVAILABLE loader response with empty BLOB`() = + runBlocking { + val expectedError = "No data available." + val instructions = Fixtures.textInstructions() + val loadRequest = ResourceLoadRequest("https://some.url") + val loadResult = Fixtures.resourceLoadResult( + Fixtures.resourceData(byteArrayOf()), + ResourceLoadStatus.AVAILABLE + ) + givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) + + val result = sut.load(instructions) + + assertEquals(expectedError, result.error!!.localizedMessage) + } + + @Test + fun `load should return Expected with an Error on UNAUTHORIZED loader response`() = + runBlocking { + val expectedError = "Your token cannot access this resource." + val instructions = Fixtures.textInstructions() + val loadRequest = ResourceLoadRequest("https://some.url") + val loadResult = Fixtures.resourceLoadResult( + null, + ResourceLoadStatus.UNAUTHORIZED + ) + givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) + + val result = sut.load(instructions) + + assertEquals(expectedError, result.error!!.localizedMessage) + } + + @Test + fun `load should return Expected with an Error on NOT_FOUND loader response`() = + runBlocking { + val expectedError = "Resource is missing." + val instructions = Fixtures.textInstructions() + val loadRequest = ResourceLoadRequest("https://some.url") + val loadResult = Fixtures.resourceLoadResult( + null, + ResourceLoadStatus.NOT_FOUND + ) + givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) + + val result = sut.load(instructions) + + assertEquals(expectedError, result.error!!.localizedMessage) + } + + @Test + fun `trigger with empty list`() = coroutineRule.runBlockingTest { + sut.trigger(emptyList()) + + verify(exactly = 0) { mockResourceLoader.load(any(), any()) } + } + + @Test + fun `trigger with invalid instruction`() = coroutineRule.runBlockingTest { + sut.trigger(listOf(VoiceInstructions.builder().build())) + + verify(exactly = 0) { mockResourceLoader.load(any(), any()) } + } + + @Test + fun `trigger with new instruction`() = coroutineRule.runBlockingTest { + val announcement = "turn up and down" + sut.trigger( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement).build()) + ) + + verify(exactly = 1) { + mockResourceLoader.load( + match { it.url.contains(UrlUtils.encodePathSegment(announcement)) }, + any() + ) + } + } + + @Test + fun `trigger with same instruction of different type`() = coroutineRule.runBlockingTest { + val announcement = "turn up and down" + sut.trigger( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement).build()) + ) + captureRequestCallback().onFinish(mockk(), ExpectedFactory.createValue(mockk())) + clearMocks(mockResourceLoader, answers = false) + + sut.trigger( + listOf(VoiceInstructions.builder().announcement(announcement).build()) + ) + verify(exactly = 1) { mockResourceLoader.load(any(), any()) } + } + + @Test + fun `trigger with existing instruction`() = coroutineRule.runBlockingTest { + val announcement = "turn up and down" + sut.trigger( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement).build()) + ) + captureRequestCallback().onFinish(mockk(), ExpectedFactory.createValue(mockk())) + clearMocks(mockResourceLoader, answers = false) + + sut.trigger( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement).build()) + ) + verify(exactly = 0) { mockResourceLoader.load(any(), any()) } + } + + @Test + fun `trigger with multiple new instructions`() = coroutineRule.runBlockingTest { + val announcement1 = "turn up and down" + val announcement2 = "dance and jump" + sut.trigger( + listOf( + VoiceInstructions.builder().ssmlAnnouncement(announcement1).build(), + VoiceInstructions.builder().announcement(announcement2).build(), + ) + ) + + verify(exactly = 1) { + mockResourceLoader.load( + match { it.url.contains(UrlUtils.encodePathSegment(announcement1)) }, + any() + ) + mockResourceLoader.load( + match { it.url.contains(UrlUtils.encodePathSegment(announcement2)) }, + any() + ) + } + } + + @Test + fun `failed download does not save instruction`() = coroutineRule.runBlockingTest { + val announcement = "turn up and down" + sut.trigger( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement).build()) + ) + captureRequestCallback().onFinish(mockk(), ExpectedFactory.createError(mockk())) + clearMocks(mockResourceLoader, answers = false) + + sut.trigger( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement).build()) + ) + + verify(exactly = 1) { mockResourceLoader.load(any(), any()) } + } + + @Test + fun `cancel with no active downloads`() = coroutineRule.runBlockingTest { + sut.cancel() + + verify(exactly = 0) { mockResourceLoader.cancel(any()) } + } + + @Test + fun `cancel with active downloads`() = coroutineRule.runBlockingTest { + val id1 = 10L + val id2 = 15L + val id3 = 20L + every { mockResourceLoader.load(any(), any()) } returnsMany listOf(id1, id2, id3) + val announcement1 = "turn up and down" + val announcement2 = "dance and jump" + val announcement3 = "stop and watch" + sut.trigger( + listOf( + VoiceInstructions.builder().ssmlAnnouncement(announcement1).build(), + VoiceInstructions.builder().announcement(announcement2).build(), + ) + ) + sut.trigger(listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement3).build())) + + sut.cancel() + + verify(exactly = 1) { + mockResourceLoader.cancel(id1) + mockResourceLoader.cancel(id2) + mockResourceLoader.cancel(id3) + } + } + + @Test + fun `cancel with finished downloads`() = coroutineRule.runBlockingTest { + val id1 = 10L + val id2 = 15L + every { mockResourceLoader.load(any(), any()) } returnsMany listOf(id1, id2) + val announcement1 = "turn up and down" + val announcement2 = "dance and jump" + sut.trigger(listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement1).build())) + val callback = captureRequestCallback() + sut.trigger(listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement2).build())) + callback.onFinish(mockk(), ExpectedFactory.createError(mockk())) + + sut.cancel() + + verify(exactly = 1) { + mockResourceLoader.cancel(id2) + } + verify(exactly = 0) { + mockResourceLoader.cancel(id1) + } + } + + private fun captureRequestCallback(): ResourceLoadCallback { + val slot = CapturingSlot() + verify { mockResourceLoader.load(any(), capture(slot)) } + return slot.captured + } + + private fun givenResourceLoaderResponse( + request: ResourceLoadRequest, + response: Expected + ) { + val loadCallbackSlot = slot() + every { mockResourceLoader.load(any(), capture(loadCallbackSlot)) } answers { + loadCallbackSlot.captured.onFinish(request, response) + 0L + } + } + + private fun Uri.getQueryParams() = + queryParameterNames.fold(mutableMapOf()) { acc, key -> + acc[key] = getQueryParameter(key) + acc + } +} diff --git a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechProviderTest.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechProviderTest.kt deleted file mode 100644 index 6123527e796..00000000000 --- a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechProviderTest.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.mapbox.navigation.ui.voice.api - -import android.net.Uri -import androidx.core.net.toUri -import com.mapbox.bindgen.Expected -import com.mapbox.bindgen.ExpectedFactory -import com.mapbox.common.ResourceLoadError -import com.mapbox.common.ResourceLoadResult -import com.mapbox.common.ResourceLoadStatus -import com.mapbox.navigation.base.internal.accounts.UrlSkuTokenProvider -import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoadCallback -import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoadRequest -import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoader -import com.mapbox.navigation.ui.voice.options.MapboxSpeechApiOptions -import com.mapbox.navigation.ui.voice.testutils.Fixtures -import com.mapbox.navigation.utils.internal.ThreadController -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.slot -import io.mockk.unmockkObject -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import java.net.URL - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -internal class MapboxSpeechProviderTest { - - private lateinit var sut: MapboxSpeechProvider - - private var accessToken = "access_token" - private val language = "en" - private val apiOptions = MapboxSpeechApiOptions.Builder() - .baseUri("https://example.com") - .build() - private val sku = "SKU" - - private lateinit var mockResourceLoader: ResourceLoader - private var stubSkuTokenProvider: UrlSkuTokenProvider = UrlSkuTokenProvider { - URL("$it&sku=$sku") - } - - @Before - fun setUp() { - mockkObject(ThreadController) - - mockResourceLoader = mockk(relaxed = true) - - sut = MapboxSpeechProvider( - accessToken = accessToken, - language = language, - urlSkuTokenProvider = stubSkuTokenProvider, - options = apiOptions, - resourceLoader = mockResourceLoader - ) - } - - @After - fun tearDown() { - unmockkObject(ThreadController) - } - - @Test - fun `load should use ResourceLoader to load audio data`() = runBlocking { - val announcement = Fixtures.ssmlAnnouncement() - val requestCapture = slot() - val callbackCapture = slot() - - val loadResult = Fixtures.resourceLoadResult(null, ResourceLoadStatus.NOT_FOUND) - every { - mockResourceLoader.load(capture(requestCapture), capture(callbackCapture)) - } answers { - callbackCapture.captured.onFinish( - requestCapture.captured, - ExpectedFactory.createValue(loadResult) - ) - 1L - } - - sut.load(announcement) - - val loadRequestUri = requestCapture.captured.url.toUri() - assertEquals("https", loadRequestUri.scheme) - assertEquals("example.com", loadRequestUri.authority) - assertEquals( - "/voice/v1/speak/${UrlUtils.encodePathSegment(announcement.announcement)}", - loadRequestUri.encodedPath - ) - assertEquals( - mapOf( - "textType" to announcement.type, - "language" to language, - "access_token" to accessToken, - "sku" to sku - ), - loadRequestUri.getQueryParams() - ) - } - - @Test - fun `load should return Expected with non empty audio BLOB on success`() = - runBlocking { - val announcement = Fixtures.textAnnouncement() - val blob = byteArrayOf(12, 23, 34) - val loadRequest = ResourceLoadRequest("https://some.url") - val loadResult = Fixtures.resourceLoadResult( - Fixtures.resourceData(blob), - ResourceLoadStatus.AVAILABLE - ) - givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) - - val result = sut.load(announcement) - - assertEquals(blob, result.value) - } - - @Test - fun `load should return Expected with an Error on AVAILABLE loader response with empty BLOB`() = - runBlocking { - val expectedError = "No data available." - val announcement = Fixtures.textAnnouncement() - val loadRequest = ResourceLoadRequest("https://some.url") - val loadResult = Fixtures.resourceLoadResult( - Fixtures.resourceData(byteArrayOf()), - ResourceLoadStatus.AVAILABLE - ) - givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) - - val result = sut.load(announcement) - - assertEquals(expectedError, result.error!!.localizedMessage) - } - - @Test - fun `load should return Expected with an Error on UNAUTHORIZED loader response`() = - runBlocking { - val expectedError = "Your token cannot access this resource." - val announcement = Fixtures.textAnnouncement() - val loadRequest = ResourceLoadRequest("https://some.url") - val loadResult = Fixtures.resourceLoadResult( - null, - ResourceLoadStatus.UNAUTHORIZED - ) - givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) - - val result = sut.load(announcement) - - assertEquals(expectedError, result.error!!.localizedMessage) - } - - @Test - fun `load should return Expected with an Error on NOT_FOUND loader response`() = - runBlocking { - val expectedError = "Resource is missing." - val announcement = Fixtures.textAnnouncement() - val loadRequest = ResourceLoadRequest("https://some.url") - val loadResult = Fixtures.resourceLoadResult( - null, - ResourceLoadStatus.NOT_FOUND - ) - givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) - - val result = sut.load(announcement) - - assertEquals(expectedError, result.error!!.localizedMessage) - } - - private fun givenResourceLoaderResponse( - request: ResourceLoadRequest, - response: Expected - ) { - val loadCallbackSlot = slot() - every { mockResourceLoader.load(any(), capture(loadCallbackSlot)) } answers { - loadCallbackSlot.captured.onFinish(request, response) - 0L - } - } - - private fun Uri.getQueryParams() = - queryParameterNames.fold(mutableMapOf()) { acc, key -> - acc[key] = getQueryParameter(key) - acc - } -} diff --git a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxVoiceApiTest.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxVoiceApiTest.kt index e410a93c1ff..7914ff8cd7c 100644 --- a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxVoiceApiTest.kt +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxVoiceApiTest.kt @@ -2,16 +2,19 @@ package com.mapbox.navigation.ui.voice.api import com.mapbox.bindgen.ExpectedFactory import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement -import com.mapbox.navigation.ui.voice.model.TypeAndAnnouncement import com.mapbox.navigation.ui.voice.model.VoiceState import com.mapbox.navigation.ui.voice.testutils.Fixtures +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.slot +import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -22,26 +25,31 @@ import java.io.InputStream internal class MapboxVoiceApiTest { private lateinit var sut: MapboxVoiceApi - private lateinit var mockSpeechProvider: MapboxSpeechProvider + private lateinit var mockSpeechLoder: MapboxSpeechLoader private lateinit var mockSpeechFileProvider: MapboxSpeechFileProvider @Before fun setUp() { - mockSpeechProvider = mockk(relaxed = true) + mockkObject(VoiceInstructionsPredownloadHub) + mockSpeechLoder = mockk(relaxed = true) mockSpeechFileProvider = mockk(relaxed = true) - sut = MapboxVoiceApi(mockSpeechProvider, mockSpeechFileProvider) + sut = MapboxVoiceApi(mockSpeechLoder, mockSpeechFileProvider) + } + + @After + fun tearDown() { + unmockkObject(VoiceInstructionsPredownloadHub) } @Test fun `retrieveVoiceFile should download audio data using MapboxSpeechProvider`() = runBlocking { val voiceInstructions = Fixtures.ssmlInstructions() - coEvery { mockSpeechProvider.load(any()) } returns ExpectedFactory.createError(Error()) + coEvery { mockSpeechLoder.load(any()) } returns ExpectedFactory.createError(Error()) sut.retrieveVoiceFile(voiceInstructions) - val announcement = TypeAndAnnouncement("ssml", voiceInstructions.ssmlAnnouncement()!!) - coVerify { mockSpeechProvider.load(announcement) } + coVerify { mockSpeechLoder.load(voiceInstructions) } } @Test @@ -50,7 +58,7 @@ internal class MapboxVoiceApiTest { val voiceInstructions = Fixtures.ssmlInstructions() val blob = byteArrayOf(11, 22) val blobInputStream = slot() - coEvery { mockSpeechProvider.load(any()) } returns ExpectedFactory.createValue(blob) + coEvery { mockSpeechLoder.load(any()) } returns ExpectedFactory.createValue(blob) coEvery { mockSpeechFileProvider.generateVoiceFileFrom(capture(blobInputStream)) } returns File("ignored") @@ -66,7 +74,7 @@ internal class MapboxVoiceApiTest { val voiceInstructions = Fixtures.ssmlInstructions() val blob = byteArrayOf(11, 22) val file = File("saved-audio-file") - coEvery { mockSpeechProvider.load(any()) } returns ExpectedFactory.createValue(blob) + coEvery { mockSpeechLoder.load(any()) } returns ExpectedFactory.createValue(blob) coEvery { mockSpeechFileProvider.generateVoiceFileFrom(any()) } returns file val result = sut.retrieveVoiceFile(voiceInstructions) @@ -78,7 +86,7 @@ internal class MapboxVoiceApiTest { fun `retrieveVoiceFile should return VoiceError on any error`() = runBlocking { val voiceInstructions = Fixtures.emptyInstructions() - coEvery { mockSpeechProvider.load(any()) } returns ExpectedFactory.createError(Error()) + coEvery { mockSpeechLoder.load(any()) } returns ExpectedFactory.createError(Error()) coEvery { mockSpeechFileProvider.generateVoiceFileFrom(any()) } throws Error() val result = sut.retrieveVoiceFile(voiceInstructions) @@ -92,8 +100,8 @@ internal class MapboxVoiceApiTest { val mockedFile: File = mockk() every { mockedAnnouncement.file } returns mockedFile val fileProvider: MapboxSpeechFileProvider = mockk(relaxed = true) - val speechProvider: MapboxSpeechProvider = mockk() - val mapboxVoiceApi = MapboxVoiceApi(speechProvider, fileProvider) + val speechLoader: MapboxSpeechLoader = mockk() + val mapboxVoiceApi = MapboxVoiceApi(speechLoader, fileProvider) mapboxVoiceApi.clean(mockedAnnouncement) @@ -106,8 +114,8 @@ internal class MapboxVoiceApiTest { val nullFile: File? = null every { mockedAnnouncement.file } returns nullFile val fileProvider: MapboxSpeechFileProvider = mockk(relaxed = true) - val speechProvider: MapboxSpeechProvider = mockk() - val mapboxVoiceApi = MapboxVoiceApi(speechProvider, fileProvider) + val speechLoader: MapboxSpeechLoader = mockk() + val mapboxVoiceApi = MapboxVoiceApi(speechLoader, fileProvider) mapboxVoiceApi.clean(mockedAnnouncement) @@ -117,11 +125,39 @@ internal class MapboxVoiceApiTest { @Test fun cancel() { val fileProvider = mockk(relaxed = true) - val speechProvider = mockk() - val mapboxVoiceApi = MapboxVoiceApi(speechProvider, fileProvider) + val speechLoader = mockk() + val mapboxVoiceApi = MapboxVoiceApi(speechLoader, fileProvider) mapboxVoiceApi.cancel() verify(exactly = 1) { fileProvider.cancel() } } + + @Test + fun `destroy cancels speech loader`() { + sut.destroy() + + verify { mockSpeechLoder.cancel() } + } + + @Test + fun `predownload hub listener registration`() { + clearMocks(VoiceInstructionsPredownloadHub) + val fileProvider = mockk(relaxed = true) + val speechLoader = mockk(relaxed = true) + + val mapboxVoiceApi = MapboxVoiceApi(speechLoader, fileProvider) + + verify(exactly = 1) { VoiceInstructionsPredownloadHub.register(speechLoader) } + + mapboxVoiceApi.destroy() + + verify(exactly = 1) { VoiceInstructionsPredownloadHub.unregister(speechLoader) } + + clearMocks(VoiceInstructionsPredownloadHub) + + mapboxVoiceApi.destroy() + + verify(exactly = 1) { VoiceInstructionsPredownloadHub.unregister(speechLoader) } + } } diff --git a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/TimeBasedNextVoiceInstructionsProviderTest.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/TimeBasedNextVoiceInstructionsProviderTest.kt index 9a63ad0fae9..d6e3923fbc3 100644 --- a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/TimeBasedNextVoiceInstructionsProviderTest.kt +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/TimeBasedNextVoiceInstructionsProviderTest.kt @@ -4,14 +4,15 @@ import com.mapbox.api.directions.v5.models.DirectionsRoute import com.mapbox.api.directions.v5.models.LegStep import com.mapbox.api.directions.v5.models.RouteLeg import com.mapbox.api.directions.v5.models.VoiceInstructions -import com.mapbox.navigation.testing.FileUtils.loadJsonFixture import com.mapbox.navigation.testing.LoggingFrontendTestRule import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class TimeBasedNextVoiceInstructionsProviderTest { @get:Rule @@ -57,60 +58,42 @@ class TimeBasedNextVoiceInstructionsProviderTest { fun `getNextVoiceInstructions with null legs`() { every { currentRoute.legs() } returns null - assertEquals( - emptyList(), - sut.getNextVoiceInstructions(routeProgressData()) - ) + assertEquals(emptyList(), sut.getNextVoiceInstructions(routeProgressData())) } @Test fun `getNextVoiceInstructions with empty legs`() { every { currentRoute.legs() } returns emptyList() - assertEquals( - emptyList(), - sut.getNextVoiceInstructions(routeProgressData()) - ) + assertEquals(emptyList(), sut.getNextVoiceInstructions(routeProgressData())) } @Test fun `getNextVoiceInstructions with too large led index`() { every { currentRoute.legs() } returns listOf(mockk(), mockk()) - assertEquals( - emptyList(), - sut.getNextVoiceInstructions(routeProgressData(legIndex = 2)) - ) + assertEquals(emptyList(), sut.getNextVoiceInstructions(routeProgressData(legIndex = 2))) } @Test fun `getNextVoiceInstructions with null steps`() { every { currentLeg.steps() } returns null - assertEquals( - emptyList(), - sut.getNextVoiceInstructions(routeProgressData()) - ) + assertEquals(emptyList(), sut.getNextVoiceInstructions(routeProgressData())) } @Test fun `getNextVoiceInstructions with empty steps`() { every { currentLeg.steps() } returns emptyList() - assertEquals( - emptyList(), - sut.getNextVoiceInstructions(routeProgressData()) - ) + assertEquals(emptyList(), sut.getNextVoiceInstructions(routeProgressData())) } @Test fun `getNextVoiceInstructions with too large step index`() { every { currentLeg.steps() } returns listOf(mockk(), mockk()) - assertEquals( - emptyList(), - sut.getNextVoiceInstructions(routeProgressData(stepIndex = 2)) - ) + assertEquals(emptyList(), sut.getNextVoiceInstructions(routeProgressData(stepIndex = 2))) } @Test @@ -187,9 +170,7 @@ class TimeBasedNextVoiceInstructionsProviderTest { assertEquals( listOf(currentStepInstruction), - sut.getNextVoiceInstructions( - routeProgressData(stepIndex = 1, stepDurationRemaining = observableTime.toDouble()) - ) + sut.getNextVoiceInstructions(routeProgressData(stepIndex = 1, stepDurationRemaining = observableTime.toDouble())) ) } @@ -200,9 +181,7 @@ class TimeBasedNextVoiceInstructionsProviderTest { assertEquals( listOf(currentStepInstruction), - sut.getNextVoiceInstructions( - routeProgressData(stepIndex = 1, stepDurationRemaining = 0.0) - ) + sut.getNextVoiceInstructions(routeProgressData(stepIndex = 1, stepDurationRemaining = 0.0)) ) } @@ -212,9 +191,7 @@ class TimeBasedNextVoiceInstructionsProviderTest { assertEquals( listOf(currentStepInstruction), - sut.getNextVoiceInstructions( - routeProgressData(stepIndex = 1, stepDurationRemaining = observableTime + 50.0) - ) + sut.getNextVoiceInstructions(routeProgressData(stepIndex = 1, stepDurationRemaining = observableTime + 50.0)) ) } @@ -281,9 +258,7 @@ class TimeBasedNextVoiceInstructionsProviderTest { assertEquals( listOf(currentStepInstruction), - sut.getNextVoiceInstructions( - routeProgressData(stepIndex = 1, stepDurationRemaining = currentDurationRemaining) - ) + sut.getNextVoiceInstructions(routeProgressData(stepIndex = 1, stepDurationRemaining = currentDurationRemaining)) ) } @@ -348,9 +323,7 @@ class TimeBasedNextVoiceInstructionsProviderTest { assertEquals( listOf(currentStepInstruction), - sut.getNextVoiceInstructions( - routeProgressData(stepIndex = 1, stepDurationRemaining = currentDurationRemaining) - ) + sut.getNextVoiceInstructions(routeProgressData(stepIndex = 1, stepDurationRemaining = currentDurationRemaining)) ) } @@ -428,9 +401,7 @@ class TimeBasedNextVoiceInstructionsProviderTest { assertEquals( listOf(currentStepInstruction), - sut.getNextVoiceInstructions( - routeProgressData(legIndex = 1, stepDurationRemaining = currentDurationRemaining) - ) + sut.getNextVoiceInstructions(routeProgressData(legIndex = 1, stepDurationRemaining = currentDurationRemaining)) ) } @@ -483,9 +454,7 @@ class TimeBasedNextVoiceInstructionsProviderTest { assertEquals( listOf(currentStepInstruction), - sut.getNextVoiceInstructions( - routeProgressData(legIndex = 1, stepDurationRemaining = currentDurationRemaining) - ) + sut.getNextVoiceInstructions(routeProgressData(legIndex = 1, stepDurationRemaining = currentDurationRemaining)) ) } @@ -505,9 +474,7 @@ class TimeBasedNextVoiceInstructionsProviderTest { assertEquals( listOf(currentStepInstruction, instruction1), - sut.getNextVoiceInstructions( - routeProgressData(legIndex = 1, stepDurationRemaining = currentDurationRemaining) - ) + sut.getNextVoiceInstructions(routeProgressData(legIndex = 1, stepDurationRemaining = currentDurationRemaining)) ) } @@ -556,9 +523,7 @@ class TimeBasedNextVoiceInstructionsProviderTest { assertEquals( listOf(currentStepInstruction), - sut.getNextVoiceInstructions( - routeProgressData(legIndex = 1, stepDurationRemaining = currentDurationRemaining) - ) + sut.getNextVoiceInstructions(routeProgressData(legIndex = 1, stepDurationRemaining = currentDurationRemaining)) ) } @@ -719,27 +684,6 @@ class TimeBasedNextVoiceInstructionsProviderTest { ) } - @Test - fun `real data`() { - val route = DirectionsRoute.fromJson(loadJsonFixture("multileg_route.json")) - val nextInstructions = sut.getNextVoiceInstructions( - RouteProgressData(route, 0, 4, 18.0, 80.0) - ) - assertEquals( - listOf( - "Turn left onto Summerton Way", - "In 500 feet, turn right onto Forsythia Street", - "Turn right onto Forsythia Street, then you will arrive at your 1st destination", - "You have arrived at your 1st destination, on the left", - "Head northeast on Forsythia Street, then turn left onto Summerton Way", - "Turn left onto Summerton Way", - "In 500 feet, turn right onto Elder Avenue", - "Turn right onto Elder Avenue, then turn right onto Franconia Road (SR 644)", - ), - nextInstructions.map { it.announcement() } - ) - } - private fun setUpFor2ActiveSteps() { every { currentLeg.steps() } returns listOf( prevStep, diff --git a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTriggerTest.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTriggerTest.kt new file mode 100644 index 00000000000..deb53c6230c --- /dev/null +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTriggerTest.kt @@ -0,0 +1,394 @@ +package com.mapbox.navigation.ui.voice.api + +import com.mapbox.api.directions.v5.models.DirectionsRoute +import com.mapbox.api.directions.v5.models.LegStep +import com.mapbox.api.directions.v5.models.RouteLeg +import com.mapbox.api.directions.v5.models.StepManeuver +import com.mapbox.api.directions.v5.models.VoiceInstructions +import com.mapbox.navigation.base.route.NavigationRoute +import com.mapbox.navigation.base.trip.model.RouteLegProgress +import com.mapbox.navigation.base.trip.model.RouteProgress +import com.mapbox.navigation.base.trip.model.RouteStepProgress +import com.mapbox.navigation.core.directions.session.RoutesExtra +import com.mapbox.navigation.core.directions.session.RoutesUpdatedResult +import com.mapbox.navigation.utils.internal.Time +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.Before +import org.junit.Test + +class VoiceInstructionsDownloadTriggerTest { + + private val observableTime = 100 + private val timePercentageToTriggerAfter = 0.5 + private val nextVoiceInstructionsProvider = mockk(relaxed = true) + private val timeProvider = mockk