From 100992379233ac5b7d8f51eaa5c8628d9ff6d830 Mon Sep 17 00:00:00 2001 From: Dzina Dybouskaya Date: Thu, 22 Dec 2022 12:44:37 +0300 Subject: [PATCH 01/13] NAVAND-552: predownload voice instructions --- CHANGELOG.md | 4 + .../examples/core/MapboxVoiceActivity.kt | 15 +- libnavigation-core/api/current.txt | 2 + .../navigation/core/MapboxNavigation.kt | 22 + .../navigation/core/MapboxNavigationTest.kt | 37 ++ .../mapbox/navigation/utils/internal/Time.kt | 5 + .../navigation/utils/internal/TimeTest.kt | 29 ++ libnavui-voice/api/current.txt | 15 + .../ui/voice/api/MapboxAudioGuidance.kt | 19 +- .../ui/voice/api/MapboxSpeechApi.kt | 44 +- ...peechProvider.kt => MapboxSpeechLoader.kt} | 79 +++- .../navigation/ui/voice/api/MapboxVoiceApi.kt | 18 +- .../TimeBasedNextVoiceInstructionsProvider.kt | 53 +-- .../navigation/ui/voice/api/VoiceApi.kt | 7 +- .../ui/voice/api/VoiceApiProvider.kt | 4 +- .../api/VoiceInstructionsDownloadTrigger.kt | 151 ++++++ .../internal/MapboxAudioGuidanceVoice.kt | 8 +- .../voice/TestMapboxAudioGuidanceServices.kt | 4 + .../ui/voice/api/MapboxAudioGuidanceTest.kt | 70 ++- .../ui/voice/api/MapboxSpeechApiTest.kt | 63 ++- .../ui/voice/api/MapboxSpeechLoaderTest.kt | 429 ++++++++++++++++++ .../ui/voice/api/MapboxSpeechProviderTest.kt | 191 -------- .../ui/voice/api/MapboxVoiceApiTest.kt | 62 ++- ...eBasedNextVoiceInstructionsProviderTest.kt | 22 - .../VoiceInstructionsDownloadTriggerTest.kt | 364 +++++++++++++++ .../impl/MapboxAudioGuidanceVoiceTest.kt | 11 +- 26 files changed, 1429 insertions(+), 299 deletions(-) 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/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 diff --git a/CHANGELOG.md b/CHANGELOG.md index becc6fc7d41..0ee922abee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,10 @@ This release depends on, and has been tested with, the following Mapbox dependen - Mapbox Java `v6.7.0` ([release notes](https://github.com/mapbox/mapbox-java/releases/tag/v6.7.0)) - Mapbox Android Core `v5.0.2` ([release notes](https://github.com/mapbox/mapbox-events-android/releases/tag/core-5.0.2)) +- Changed distance formatting: now all the imperial distances down to 0.1 miles will be represented in miles, while the smaller ones - in feet. [#6775](https://github.com/mapbox/mapbox-navigation-android/pull/6775) +- Introduced `VoiceInstructionsDownloadTrigger` `MapboxSpeechAPI#generatePredownloaded` to use predownloaded voice instructions instead of downloading them on demand. Example usage can be found in the examples directory ( see `MapboxVoiceActivity`). [#6771](https://github.com/mapbox/mapbox-navigation-android/pull/6771) +- Enabled voice instructions predownloading for those who use `MapboxAudioGuidance`. [#6771](https://github.com/mapbox/mapbox-navigation-android/pull/6771) +- Fixed an issue where with low connectivity voice instruction might have been played too late for those who use `MapboxAudioGuidance`. If you use `MapboxSpeechAPI` directly, switch to voice instructions predownloading as described above if you encounter said issue. [#6771](https://github.com/mapbox/mapbox-navigation-android/pull/6771) ## Mapbox Navigation SDK 2.10.0-rc.1 - 16 December, 2022 ### Changelog 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..4954e05500c 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 @@ -22,6 +22,7 @@ import com.mapbox.maps.plugin.gestures.OnMapLongClickListener import com.mapbox.maps.plugin.gestures.gestures import com.mapbox.maps.plugin.locationcomponent.LocationComponentPlugin import com.mapbox.maps.plugin.locationcomponent.location +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions import com.mapbox.navigation.base.extensions.applyLanguageAndVoiceUnitOptions import com.mapbox.navigation.base.options.NavigationOptions @@ -60,6 +61,7 @@ import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoadRequest import com.mapbox.navigation.ui.utils.internal.resource.ResourceLoaderFactory import com.mapbox.navigation.ui.voice.api.MapboxSpeechApi import com.mapbox.navigation.ui.voice.api.MapboxVoiceInstructionsPlayer +import com.mapbox.navigation.ui.voice.api.VoiceInstructionsDownloadTrigger import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement import com.mapbox.navigation.ui.voice.model.SpeechError import com.mapbox.navigation.ui.voice.model.SpeechValue @@ -201,9 +203,14 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener { } } + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) + private val voiceInstructionsDownloadTrigger by lazy { + VoiceInstructionsDownloadTrigger(speechApi) + } + private val voiceInstructionsObserver = VoiceInstructionsObserver { voiceInstructions -> // The data obtained must be used to generate the synthesized speech mp3 file. - speechApi.generate( + speechApi.generatePredownloaded( voiceInstructions, speechCallback ) @@ -384,11 +391,13 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener { init() } + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) override fun onStart() { super.onStart() if (::mapboxNavigation.isInitialized) { mapboxNavigation.registerRoutesObserver(routesObserver) mapboxNavigation.registerLocationObserver(locationObserver) + mapboxNavigation.registerVoiceInstructionsTriggerObserver(voiceInstructionsDownloadTrigger) mapboxNavigation.registerRouteProgressObserver(routeProgressObserver) mapboxNavigation.registerRouteProgressObserver(replayProgressObserver) mapboxNavigation.registerVoiceInstructionsObserver(voiceInstructionsObserver) @@ -396,16 +405,19 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener { ResourceLoaderFactory.getInstance().registerObserver(resourceLoadObserver) } + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) override fun onStop() { super.onStop() ResourceLoaderFactory.getInstance().unregisterObserver(resourceLoadObserver) mapboxNavigation.unregisterRoutesObserver(routesObserver) + mapboxNavigation.registerVoiceInstructionsTriggerObserver(voiceInstructionsDownloadTrigger) mapboxNavigation.unregisterLocationObserver(locationObserver) mapboxNavigation.unregisterRouteProgressObserver(routeProgressObserver) mapboxNavigation.unregisterRouteProgressObserver(replayProgressObserver) mapboxNavigation.unregisterVoiceInstructionsObserver(voiceInstructionsObserver) } + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) override fun onDestroy() { super.onDestroy() routeLineApi.cancel() @@ -413,6 +425,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener { mapboxReplayer.finish() mapboxNavigation.onDestroy() speechApi.cancel() + voiceInstructionsDownloadTrigger.destroy() voiceInstructionsPlayer.shutdown() } diff --git a/libnavigation-core/api/current.txt b/libnavigation-core/api/current.txt index a11b0c34ac1..a54b413bf8f 100644 --- a/libnavigation-core/api/current.txt +++ b/libnavigation-core/api/current.txt @@ -59,6 +59,7 @@ package com.mapbox.navigation.core { method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void registerRoutesPreviewObserver(com.mapbox.navigation.core.preview.RoutesPreviewObserver observer); method public void registerTripSessionStateObserver(com.mapbox.navigation.core.trip.session.TripSessionStateObserver tripSessionStateObserver); method public void registerVoiceInstructionsObserver(com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver voiceInstructionsObserver); + method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void registerVoiceInstructionsTriggerObserver(T observer); method public void requestAlternativeRoutes(); method public void requestAlternativeRoutes(com.mapbox.navigation.core.routealternatives.NavigationRouteAlternativesRequestCallback? callback = null); method public void requestAlternativeRoutes(com.mapbox.navigation.core.routealternatives.RouteAlternativesRequestCallback callback); @@ -102,6 +103,7 @@ package com.mapbox.navigation.core { method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void unregisterRoutesPreviewObserver(com.mapbox.navigation.core.preview.RoutesPreviewObserver observer); method public void unregisterTripSessionStateObserver(com.mapbox.navigation.core.trip.session.TripSessionStateObserver tripSessionStateObserver); method public void unregisterVoiceInstructionsObserver(com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver voiceInstructionsObserver); + method @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void unregisterVoiceInstructionsTriggerObserver(T observer); property public final com.mapbox.navigator.Experimental experimental; property public final com.mapbox.navigation.core.trip.session.eh.GraphAccessor graphAccessor; property public final com.mapbox.navigation.core.history.MapboxHistoryRecorder historyRecorder; 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 cd80d8a5ce9..ff4b53a50d4 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 @@ -1283,6 +1283,28 @@ class MapboxNavigation @VisibleForTesting internal constructor( } } + /** + * Subscribes voice instructions trigger observer for all the necessary updates. + */ + @ExperimentalPreviewMapboxNavigationAPI + fun registerVoiceInstructionsTriggerObserver( + observer: T + ) where T : RoutesObserver, T : RouteProgressObserver { + registerRoutesObserver(observer) + registerRouteProgressObserver(observer) + } + + /** + * Unsubscribes voice instructions trigger observer from all the updates it was subsribed for. + */ + @ExperimentalPreviewMapboxNavigationAPI + fun unregisterVoiceInstructionsTriggerObserver( + observer: T + ) where T : RoutesObserver, T : RouteProgressObserver { + unregisterRoutesObserver(observer) + unregisterRouteProgressObserver(observer) + } + /** * Unregisters [RoutesObserver]. */ diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/MapboxNavigationTest.kt index f61710d105e..6c86f1f5353 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 @@ -39,6 +39,7 @@ import com.mapbox.navigation.core.trip.session.NativeSetRouteValue import com.mapbox.navigation.core.trip.session.NavigationSession import com.mapbox.navigation.core.trip.session.OffRouteObserver import com.mapbox.navigation.core.trip.session.RoadObjectsOnRouteObserver +import com.mapbox.navigation.core.trip.session.RouteProgressObserver import com.mapbox.navigation.core.trip.session.TripSessionState import com.mapbox.navigation.core.trip.session.TripSessionStateObserver import com.mapbox.navigation.core.trip.session.createSetRouteResult @@ -2019,6 +2020,42 @@ internal class MapboxNavigationTest : MapboxNavigationBaseTest() { } } + @Test + fun registerVoiceInstructionsTriggerObserver() { + val observer = TestVoiceInstructionsTriggerObserver() + createMapboxNavigation() + + mapboxNavigation.registerVoiceInstructionsTriggerObserver(observer) + + verify(exactly = 1) { + directionsSession.registerRoutesObserver(observer) + tripSession.registerRouteProgressObserver(observer) + } + } + + @Test + fun unregisterVoiceInstructionsTriggerObserver() { + val observer = TestVoiceInstructionsTriggerObserver() + createMapboxNavigation() + mapboxNavigation.registerVoiceInstructionsTriggerObserver(observer) + + mapboxNavigation.unregisterVoiceInstructionsTriggerObserver(observer) + + verify(exactly = 1) { + directionsSession.unregisterRoutesObserver(observer) + tripSession.unregisterRouteProgressObserver(observer) + } + } + + private class TestVoiceInstructionsTriggerObserver : RoutesObserver, RouteProgressObserver { + + override fun onRouteProgressChanged(routeProgress: RouteProgress) { + } + + override fun onRoutesChanged(result: RoutesUpdatedResult) { + } + } + private fun alternativeWithId(mockId: String): RouteAlternative { val mockedRoute = mockk { every { routeId } returns mockId 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..0a40be7d1ac 100644 --- a/libnavui-voice/api/current.txt +++ b/libnavui-voice/api/current.txt @@ -64,6 +64,7 @@ package com.mapbox.navigation.ui.voice.api { method public void cancel(); method public void clean(com.mapbox.navigation.ui.voice.model.SpeechAnnouncement announcement); method public void generate(com.mapbox.api.directions.v5.models.VoiceInstructions voiceInstruction, com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer> consumer); + method public void generatePredownloaded(com.mapbox.api.directions.v5.models.VoiceInstructions voiceInstruction, com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer> consumer); } @UiThread public final class MapboxVoiceInstructionsPlayer { @@ -84,6 +85,20 @@ package com.mapbox.navigation.ui.voice.api { method @kotlin.jvm.Throws(exceptionClasses=IllegalArgumentException::class) public void volume(com.mapbox.navigation.ui.voice.model.SpeechVolume state) throws java.lang.IllegalArgumentException; } + @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class VoiceInstructionsDownloadTrigger implements com.mapbox.navigation.core.trip.session.RouteProgressObserver com.mapbox.navigation.core.directions.session.RoutesObserver { + ctor public VoiceInstructionsDownloadTrigger(com.mapbox.navigation.ui.voice.api.MapboxSpeechApi speechApi); + ctor public VoiceInstructionsDownloadTrigger(int observableTime, double timePercentageToTriggerAfter, com.mapbox.navigation.ui.voice.api.MapboxSpeechApi speechApi); + method public void destroy(); + method public void onRouteProgressChanged(com.mapbox.navigation.base.trip.model.RouteProgress routeProgress); + method public void onRoutesChanged(com.mapbox.navigation.core.directions.session.RoutesUpdatedResult result); + field public static final com.mapbox.navigation.ui.voice.api.VoiceInstructionsDownloadTrigger.Companion Companion; + field public static final int DEFAULT_OBSERVABLE_TIME_SECONDS = 180; // 0xb4 + field public static final double DEFAULT_TIME_PERCENTAGE_TO_TRIGGER_AFTER = 0.5; + } + + public static final class VoiceInstructionsDownloadTrigger.Companion { + } + public abstract sealed class VoiceInstructionsPlayerAttributes { method protected abstract kotlin.jvm.functions.Function1 configureAudioFocusRequestBuilder(com.mapbox.navigation.ui.voice.model.AudioFocusOwner owner); method protected abstract kotlin.jvm.functions.Function1 configureMediaPlayer(); diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxAudioGuidance.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxAudioGuidance.kt index b438d9ef4ad..7f3e500f72c 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxAudioGuidance.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/MapboxAudioGuidance.kt @@ -2,6 +2,7 @@ package com.mapbox.navigation.ui.voice.api import androidx.annotation.VisibleForTesting import com.mapbox.api.directions.v5.models.VoiceInstructions +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.core.MapboxNavigation import com.mapbox.navigation.core.lifecycle.MapboxNavigationApp import com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver @@ -41,6 +42,7 @@ internal constructor( private var dataStoreOwner: NavigationDataStoreOwner? = null private var configOwner: NavigationConfigOwner? = null + private var audioGuidanceVoice: MapboxAudioGuidanceVoice? = null private var mutedStateFlow = MutableStateFlow(false) private val internalStateFlow = MutableStateFlow(MapboxAudioGuidanceState()) private val scope = CoroutineScope(SupervisorJob() + dispatcher) @@ -72,6 +74,7 @@ internal constructor( */ override fun onDetached(mapboxNavigation: MapboxNavigation) { mapboxVoiceInstructions.unregisterObservers(mapboxNavigation) + audioGuidanceVoice?.destroy() job?.cancel() job = null } @@ -160,15 +163,25 @@ internal constructor( } } - private fun MapboxNavigation.audioGuidanceVoice(): Flow = - combine( + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) + private fun MapboxNavigation.audioGuidanceVoice(): Flow { + var trigger: VoiceInstructionsDownloadTrigger? = null + return combine( mapboxVoiceInstructions.voiceLanguage(), configOwner!!.language(), ) { voiceLanguage, deviceLanguage -> voiceLanguage ?: deviceLanguage } .distinctUntilChanged() .map { language -> - audioGuidanceServices.mapboxAudioGuidanceVoice(this, language) + audioGuidanceVoice?.destroy() + trigger?.let { unregisterVoiceInstructionsTriggerObserver(it) } + audioGuidanceServices.mapboxAudioGuidanceVoice(this, language).also { + audioGuidanceVoice = it + trigger = VoiceInstructionsDownloadTrigger(it.mapboxSpeechApi).also { + registerVoiceInstructionsTriggerObserver(it) + } + } } + } private suspend fun restoreMutedState() { dataStoreOwner?.apply { 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..d5f82df7b2c 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 @@ -4,6 +4,7 @@ import android.content.Context import com.mapbox.api.directions.v5.models.VoiceInstructions import com.mapbox.bindgen.Expected import com.mapbox.bindgen.ExpectedFactory +import com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver import com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement import com.mapbox.navigation.ui.voice.model.SpeechError @@ -40,6 +41,9 @@ class MapboxSpeechApi @JvmOverloads constructor( * Given [VoiceInstructions] the method will try to generate the * voice instruction [SpeechAnnouncement] including the synthesized speech mp3 file * from Mapbox's API Voice. + * NOTE: this method will try downloading an mp3 file from server. If you use voice instructions + * predownloading (see [VoiceInstructionsDownloadTrigger]), invoke [generatePredownloaded] + * instead of this method in your [VoiceInstructionsObserver]. * @param voiceInstruction VoiceInstructions object representing [VoiceInstructions] * @param consumer is a [SpeechValue] including the announcement to be played when the * announcement is ready or a [SpeechError] including the error information and a fallback @@ -51,7 +55,30 @@ class MapboxSpeechApi @JvmOverloads constructor( consumer: MapboxNavigationConsumer> ) { mainJobController.scope.launch { - retrieveVoiceFile(voiceInstruction, consumer) + retrieveVoiceFile(voiceInstruction, consumer, onlyCache = false) + } + } + + /** + * Given [VoiceInstructions] the method will try to generate the + * voice instruction [SpeechAnnouncement] including the synthesized speech mp3 file + * from Mapbox's API Voice. + * NOTE: this method will NOT try downloading an mp3 file from server. It will either use + * an already predownloaded file or an onboard speech synthesizer. Only invoke this method + * if you use voice instructions predownloading (see [VoiceInstructionsDownloadTrigger]), + * otherwise invoke [generatePredownloaded] in your [VoiceInstructionsObserver]. + * @param voiceInstruction VoiceInstructions object representing [VoiceInstructions] + * @param consumer is a [SpeechValue] including the announcement to be played when the + * announcement is ready or a [SpeechError] including the error information and a fallback + * with the raw announcement (without file) that can be played with a text-to-speech engine. + * @see [cancel] + */ + fun generatePredownloaded( + voiceInstruction: VoiceInstructions, + consumer: MapboxNavigationConsumer> + ) { + mainJobController.scope.launch { + retrieveVoiceFile(voiceInstruction, consumer, onlyCache = true) } } @@ -75,12 +102,23 @@ class MapboxSpeechApi @JvmOverloads constructor( voiceAPI.clean(announcement) } + internal fun predownload(instructions: List) { + mainJobController.scope.launch { + voiceAPI.predownload(instructions) + } + } + + internal fun destroy() { + voiceAPI.destroy() + } + @Throws(IllegalStateException::class) private suspend fun retrieveVoiceFile( voiceInstruction: VoiceInstructions, - consumer: MapboxNavigationConsumer> + consumer: MapboxNavigationConsumer>, + onlyCache: Boolean ) { - when (val result = voiceAPI.retrieveVoiceFile(voiceInstruction)) { + when (val result = voiceAPI.retrieveVoiceFile(voiceInstruction, onlyCache)) { is VoiceState.VoiceFile -> { val announcement = voiceInstruction.announcement() val ssmlAnnouncement = voiceInstruction.ssmlAnnouncement() 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..532a7f1d391 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,92 @@ 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.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, ) { - 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, + onlyCache: Boolean + ): 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 { + if (onlyCache) { + 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)) + fun triggerDownload(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..fd012ae9690 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,24 @@ 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 { + override fun predownload(instructions: List) { + speechLoader.triggerDownload(instructions) + } + /** * Given [VoiceInstructions] the method returns a [File] wrapped inside [VoiceState] * @param voiceInstruction VoiceInstructions object representing [VoiceInstructions] */ - override suspend fun retrieveVoiceFile(voiceInstruction: VoiceInstructions): VoiceState { + override suspend fun retrieveVoiceFile( + voiceInstruction: VoiceInstructions, + onlyCache: Boolean + ): VoiceState { return runCatching { - val typeAndAnnouncement = VoiceInstructionsParser.parse(voiceInstruction).getOrThrow() - val blob = speechProvider.load(typeAndAnnouncement).getOrThrow() + val blob = speechLoader.load(voiceInstruction, onlyCache).getOrThrow() val file = speechFileProvider.generateVoiceFileFrom(blob.inputStream()) VoiceFile(file) }.getOrElse { @@ -48,6 +54,10 @@ internal class MapboxVoiceApi( speechFileProvider.cancel() } + fun destroy() { + 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..281ac8dab04 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/VoiceApi.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceApi.kt index b45b564aa56..58c72f525b2 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceApi.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceApi.kt @@ -10,11 +10,16 @@ import java.io.File */ internal interface VoiceApi { + fun predownload(instructions: List) + /** * Given [VoiceInstructions] the method returns a [File] wrapped inside [VoiceState] * @param voiceInstruction VoiceInstructions object representing [VoiceInstructions] */ - suspend fun retrieveVoiceFile(voiceInstruction: VoiceInstructions): VoiceState + suspend fun retrieveVoiceFile( + voiceInstruction: VoiceInstructions, + onlyCache: Boolean + ): VoiceState /** * Given the [SpeechAnnouncement] the method may cleanup any associated files 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..0dd51e515d4 --- /dev/null +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTrigger.kt @@ -0,0 +1,151 @@ +package com.mapbox.navigation.ui.voice.api + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.trip.model.RouteProgress +import com.mapbox.navigation.core.MapboxNavigation +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 + +/** + * Class that is responsible for listening to relevant updates and triggering + * voice instructions predownloading when needed. + * Register and unregister with [MapboxNavigation.registerVoiceInstructionsTriggerObserver] and + * [MapboxNavigation.unregisterVoiceInstructionsTriggerObserver]. + */ +@ExperimentalPreviewMapboxNavigationAPI +class VoiceInstructionsDownloadTrigger internal constructor( + private val observableTime: Int, + private val timePercentageToTriggerAfter: Double, + private val speechApi: MapboxSpeechApi, + private val nextVoiceInstructionsProvider: NextVoiceInstructionsProvider, + private val timeProvider: Time, +) : RouteProgressObserver, RoutesObserver { + + /** + * Creates [VoiceInstructionsDownloadTrigger] with default + * observableTime and timePercentageToTriggerAfter. + * See [DEFAULT_OBSERVABLE_TIME_SECONDS] and [DEFAULT_TIME_PERCENTAGE_TO_TRIGGER_AFTER]. + * + * @param speechApi [MapboxSpeechApi] instances that's used to generate instructions + */ + constructor(speechApi: MapboxSpeechApi) : this( + DEFAULT_OBSERVABLE_TIME_SECONDS, + DEFAULT_TIME_PERCENTAGE_TO_TRIGGER_AFTER, + speechApi + ) + + /** + * Creates [VoiceInstructionsDownloadTrigger] with custom + * observableTime and timePercentageToTriggerAfter. + * + * @param observableTime voice instructions will be predownloaded for `observableTime` seconds + * of route ahead + * @param timePercentageToTriggerAfter voice instructions predownloading will not be triggered + * if `timePercentageToTriggerAfter` seconds did not pass since last predownloading session + * @param speechApi [MapboxSpeechApi] instances that's used to generate instructions + */ + constructor( + observableTime: Int, + timePercentageToTriggerAfter: Double, + speechApi: MapboxSpeechApi, + ) : this( + observableTime, + timePercentageToTriggerAfter, + speechApi, + TimeBasedNextVoiceInstructionsProvider(observableTime), + Time.SystemImpl + ) + + 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 + + /** + * See [RoutesObserver.onRoutesChanged]. + */ + 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) + } + } + } + + /** + * See [RouteProgressObserver.onRouteProgressChanged]. + */ + 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) + } + } + } + + /** + * 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() { + speechApi.destroy() + } + + private fun shouldDownloadBasedOnTime(): Boolean { + return timeProvider.seconds() >= + lastDownloadTime + observableTime * timePercentageToTriggerAfter + } + + private fun triggerDownload(progressData: RouteProgressData) { + lastDownloadTime = timeProvider.seconds() + val nextInstructionsToDownload = nextVoiceInstructionsProvider + .getNextVoiceInstructions(progressData) + speechApi.predownload(nextInstructionsToDownload) + } + + companion object { + /** + * Default value used for [observableTime], specified in seconds. + */ + const val DEFAULT_OBSERVABLE_TIME_SECONDS = 3 * 60 // 3 minutes + + /** + * Default value for [timePercentageToTriggerAfter]. + */ + const val DEFAULT_TIME_PERCENTAGE_TO_TRIGGER_AFTER = 0.5 + } +} 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 b6e13c1443e..72ddf3964ed 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 @@ -16,7 +16,7 @@ import kotlin.coroutines.resume * @param mapboxVoiceInstructionsPlayer stream of [VoiceInstructions]. */ class MapboxAudioGuidanceVoice( - private val mapboxSpeechApi: MapboxSpeechApi, + internal val mapboxSpeechApi: MapboxSpeechApi, private val mapboxVoiceInstructionsPlayer: MapboxVoiceInstructionsPlayer ) { /** @@ -41,10 +41,14 @@ class MapboxAudioGuidanceVoice( } } + fun destroy() { + mapboxSpeechApi.destroy() + } + private suspend fun MapboxSpeechApi.generate( instructions: VoiceInstructions ): SpeechAnnouncement = suspendCancellableCoroutine { cont -> - generate(instructions) { value -> + generatePredownloaded(instructions) { value -> val announcement = value.value?.announcement ?: value.error!!.fallback cont.resume(announcement) } diff --git a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/TestMapboxAudioGuidanceServices.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/TestMapboxAudioGuidanceServices.kt index 8f694ec2c70..96c428d4495 100644 --- a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/TestMapboxAudioGuidanceServices.kt +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/TestMapboxAudioGuidanceServices.kt @@ -2,6 +2,7 @@ package com.mapbox.navigation.ui.voice import com.mapbox.api.directions.v5.models.VoiceInstructions import com.mapbox.navigation.ui.utils.internal.configuration.NavigationConfigOwner +import com.mapbox.navigation.ui.voice.api.MapboxSpeechApi import com.mapbox.navigation.ui.voice.internal.MapboxAudioGuidanceVoice import com.mapbox.navigation.ui.voice.internal.MapboxVoiceInstructions import com.mapbox.navigation.ui.voice.internal.MapboxVoiceInstructionsState @@ -33,6 +34,8 @@ class TestMapboxAudioGuidanceServices( every { voiceLanguage() } returns voiceLanguageFlow } + private val mapboxSpeechApi = mockk(relaxed = true) + private val mapboxAudioGuidanceVoice = mockk { coEvery { speak(any()) } coAnswers { val voiceInstructions = firstArg() @@ -48,6 +51,7 @@ class TestMapboxAudioGuidanceServices( } speechAnnouncement } + every { mapboxSpeechApi } returns this@TestMapboxAudioGuidanceServices.mapboxSpeechApi } private val testCarAppDataStoreOwner = TestCarAppDataStoreOwner() diff --git a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxAudioGuidanceTest.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxAudioGuidanceTest.kt index b05da1a6292..f41412a5bc4 100644 --- a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxAudioGuidanceTest.kt +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxAudioGuidanceTest.kt @@ -7,9 +7,11 @@ import com.mapbox.navigation.testing.MainCoroutineRule import com.mapbox.navigation.ui.voice.TestMapboxAudioGuidanceServices import com.mapbox.navigation.ui.voice.TestMapboxAudioGuidanceServices.Companion.SPEECH_ANNOUNCEMENT_DELAY_MS import com.mapbox.navigation.ui.voice.internal.MapboxVoiceInstructionsState +import io.mockk.clearMocks import io.mockk.every import io.mockk.excludeRecords import io.mockk.mockk +import io.mockk.verify import io.mockk.verifySequence import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelAndJoin @@ -34,7 +36,7 @@ class MapboxAudioGuidanceTest { val coroutineRule = MainCoroutineRule() private val testMapboxAudioGuidanceServices = TestMapboxAudioGuidanceServices() - private val mapboxNavigation: MapboxNavigation = mockk { + private val mapboxNavigation: MapboxNavigation = mockk(relaxUnitFun = true) { every { navigationOptions } returns mockk { every { applicationContext } returns mockk() } @@ -274,4 +276,70 @@ class MapboxAudioGuidanceTest { every { mapboxAudioGuidanceServices.voiceInstructionsPlayer } returns null assertNull(carAppAudioGuidance.getCurrentVoiceInstructionsPlayer()) } + + @Test + fun `previous voice guidance is destroyed`() = coroutineRule.runBlockingTest { + carAppAudioGuidance.onAttached(mapboxNavigation) + + testMapboxAudioGuidanceServices.emitVoiceLanguage("ru") + delay(SPEECH_ANNOUNCEMENT_DELAY_MS) + clearMocks(testMapboxAudioGuidanceServices.mapboxAudioGuidanceVoice, answers = false) + + testMapboxAudioGuidanceServices.emitVoiceLanguage("fr") + delay(SPEECH_ANNOUNCEMENT_DELAY_MS) + + verify(exactly = 1) { + testMapboxAudioGuidanceServices.mapboxAudioGuidanceVoice.destroy() + } + carAppAudioGuidance.onDetached(mapboxNavigation) + } + + @Test + fun `triggers are registered and unregistered`() = coroutineRule.runBlockingTest { + val triggerSlot = mutableListOf() + carAppAudioGuidance.onAttached(mapboxNavigation) + clearMocks(mapboxNavigation, answers = false) + + testMapboxAudioGuidanceServices.emitVoiceLanguage("ru") + delay(SPEECH_ANNOUNCEMENT_DELAY_MS) + + verify(exactly = 1) { + mapboxNavigation.registerVoiceInstructionsTriggerObserver(capture(triggerSlot)) + } + + testMapboxAudioGuidanceServices.emitVoiceLanguage("fr") + delay(SPEECH_ANNOUNCEMENT_DELAY_MS) + + verify(exactly = 1) { + mapboxNavigation.unregisterVoiceInstructionsTriggerObserver(triggerSlot.first()) + } + verify(exactly = 2) { + mapboxNavigation.registerVoiceInstructionsTriggerObserver( + any() + ) + } + + carAppAudioGuidance.onDetached(mapboxNavigation) + } + + @Test + fun `onDetached destroys voice guidance when has one`() = coroutineRule.runBlockingTest { + carAppAudioGuidance.onAttached(mapboxNavigation) + + testMapboxAudioGuidanceServices.emitVoiceLanguage("ru") + delay(SPEECH_ANNOUNCEMENT_DELAY_MS) + clearMocks(testMapboxAudioGuidanceServices.mapboxAudioGuidanceVoice, answers = false) + + carAppAudioGuidance.onDetached(mapboxNavigation) + + verify(exactly = 1) { + testMapboxAudioGuidanceServices.mapboxAudioGuidanceVoice.destroy() + } + } + + @Test + fun `onDetached does not destroy voice guidance when does not have one`() { + carAppAudioGuidance.onDetached(mapboxNavigation) + // no crash + } } 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..9c514cdfb2b 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 @@ -91,7 +91,7 @@ class MapboxSpeechApiTest { val mockedInstructionFile: File = mockk() val mockedVoiceApi: MapboxVoiceApi = mockk() coEvery { - mockedVoiceApi.retrieveVoiceFile(any()) + mockedVoiceApi.retrieveVoiceFile(any(), false) } returns VoiceState.VoiceFile(mockedInstructionFile) val options = MapboxSpeechApiOptions.Builder().build() every { @@ -113,6 +113,50 @@ class MapboxSpeechApiTest { } } + @Test + fun `generate predownloaded voice file onAvailable`() = coroutineRule.runBlockingTest { + val aMockedContext: Context = mockk(relaxed = true) + val anyAccessToken = "pk.123" + val anyLanguage = Locale.US.language + val mockedVoiceInstructions: VoiceInstructions = mockk() + val anAnnouncement = "Turn right onto Frederick Road, Maryland 3 55." + val aSsmlAnnouncement = """ + + + Turn right onto Frederick Road, Maryland 3 55. + + + """.trimIndent() + every { mockedVoiceInstructions.announcement() } returns anAnnouncement + every { mockedVoiceInstructions.ssmlAnnouncement() } returns aSsmlAnnouncement + val speechConsumer: MapboxNavigationConsumer> = mockk() + val speechValueSlot = slot>() + every { speechConsumer.accept(capture(speechValueSlot)) } just Runs + val mockedInstructionFile: File = mockk() + val mockedVoiceApi: MapboxVoiceApi = mockk() + coEvery { + mockedVoiceApi.retrieveVoiceFile(any(), true) + } returns VoiceState.VoiceFile(mockedInstructionFile) + val options = MapboxSpeechApiOptions.Builder().build() + every { + VoiceApiProvider.retrieveMapboxVoiceApi( + aMockedContext, + anyAccessToken, + anyLanguage, + options + ) + } returns mockedVoiceApi + val mapboxSpeechApi = MapboxSpeechApi(aMockedContext, anyAccessToken, anyLanguage) + + mapboxSpeechApi.generatePredownloaded(mockedVoiceInstructions, speechConsumer) + + verify(exactly = 1) { + speechConsumer.accept( + speechValueSlot.captured + ) + } + } + @Test fun `generate voice file onError`() = coroutineRule.runBlockingTest { val voiceInstructions = Fixtures.ssmlInstructions() @@ -122,7 +166,7 @@ class MapboxSpeechApiTest { } val mockedVoiceApi = mockk { coEvery { - retrieveVoiceFile(voiceInstructions) + retrieveVoiceFile(voiceInstructions, false) } returns VoiceState.VoiceError("Some error message") } every { @@ -153,7 +197,7 @@ class MapboxSpeechApiTest { } val mockedVoiceApi = mockk { coEvery { - retrieveVoiceFile(voiceInstructions) + retrieveVoiceFile(voiceInstructions, false) } returns VoiceState.VoiceError("Some error message") } every { @@ -191,4 +235,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..498fa5ad4bc --- /dev/null +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/MapboxSpeechLoaderTest.kt @@ -0,0 +1,429 @@ +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, false) + + 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( + ResourceLoadFlags.ACCEPT_EXPIRED, + loadRequest.flags + ) + } + + @Test + fun `load should disallow network for onlyCache = true`() = 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, true) + + val loadRequest = requestCapture.captured + assertEquals( + NetworkRestriction.DISALLOW_ALL, + loadRequest.networkRestriction + ) + } + + @Test + fun `load should allow network for onlyCache = false`() = 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, false) + + val loadRequest = requestCapture.captured + assertEquals( + NetworkRestriction.NONE, + loadRequest.networkRestriction + ) + } + + @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, false) + + 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, false) + + 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, false) + + 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, false) + + assertEquals(expectedError, result.error!!.localizedMessage) + } + + @Test + fun `trigger with empty list`() = coroutineRule.runBlockingTest { + sut.triggerDownload(emptyList()) + + verify(exactly = 0) { mockResourceLoader.load(any(), any()) } + } + + @Test + fun `trigger with invalid instruction`() = coroutineRule.runBlockingTest { + sut.triggerDownload(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.triggerDownload( + 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.triggerDownload( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement).build()) + ) + captureRequestCallback().onFinish(mockk(), ExpectedFactory.createValue(mockk())) + clearMocks(mockResourceLoader, answers = false) + + sut.triggerDownload( + 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.triggerDownload( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement).build()) + ) + captureRequestCallback().onFinish(mockk(), ExpectedFactory.createValue(mockk())) + clearMocks(mockResourceLoader, answers = false) + + sut.triggerDownload( + 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.triggerDownload( + 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.triggerDownload( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement).build()) + ) + captureRequestCallback().onFinish(mockk(), ExpectedFactory.createError(mockk())) + clearMocks(mockResourceLoader, answers = false) + + sut.triggerDownload( + 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.triggerDownload( + listOf( + VoiceInstructions.builder().ssmlAnnouncement(announcement1).build(), + VoiceInstructions.builder().announcement(announcement2).build(), + ) + ) + sut.triggerDownload( + 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.triggerDownload( + listOf(VoiceInstructions.builder().ssmlAnnouncement(announcement1).build()) + ) + val callback = captureRequestCallback() + sut.triggerDownload( + 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..d368f65826f 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 @@ -1,8 +1,8 @@ package com.mapbox.navigation.ui.voice.api +import com.mapbox.api.directions.v5.models.VoiceInstructions 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.coEvery @@ -22,26 +22,25 @@ 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) + mockSpeechLoder = mockk(relaxed = true) mockSpeechFileProvider = mockk(relaxed = true) - sut = MapboxVoiceApi(mockSpeechProvider, mockSpeechFileProvider) + sut = MapboxVoiceApi(mockSpeechLoder, mockSpeechFileProvider) } @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(), any()) } returns ExpectedFactory.createError(Error()) - sut.retrieveVoiceFile(voiceInstructions) + sut.retrieveVoiceFile(voiceInstructions, true) - val announcement = TypeAndAnnouncement("ssml", voiceInstructions.ssmlAnnouncement()!!) - coVerify { mockSpeechProvider.load(announcement) } + coVerify { mockSpeechLoder.load(voiceInstructions, true) } } @Test @@ -50,12 +49,12 @@ 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(), false) } returns ExpectedFactory.createValue(blob) coEvery { mockSpeechFileProvider.generateVoiceFileFrom(capture(blobInputStream)) } returns File("ignored") - sut.retrieveVoiceFile(voiceInstructions) + sut.retrieveVoiceFile(voiceInstructions, false) assertEquals(blob.count(), blobInputStream.captured.available()) } @@ -66,10 +65,10 @@ 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(), true) } returns ExpectedFactory.createValue(blob) coEvery { mockSpeechFileProvider.generateVoiceFileFrom(any()) } returns file - val result = sut.retrieveVoiceFile(voiceInstructions) + val result = sut.retrieveVoiceFile(voiceInstructions, true) assertEquals(VoiceState.VoiceFile(file), result) } @@ -78,10 +77,12 @@ 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(), false) + } returns ExpectedFactory.createError(Error()) coEvery { mockSpeechFileProvider.generateVoiceFileFrom(any()) } throws Error() - val result = sut.retrieveVoiceFile(voiceInstructions) + val result = sut.retrieveVoiceFile(voiceInstructions, false) assertTrue(result is VoiceState.VoiceError) } @@ -92,22 +93,36 @@ 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) verify(exactly = 1) { fileProvider.delete(mockedFile) } } + @Test + fun predownload() { + val fileProvider = mockk(relaxed = true) + val speechLoader = mockk(relaxed = true) + val mapboxVoiceApi = MapboxVoiceApi(speechLoader, fileProvider) + val instructions = listOf(mockk()) + + mapboxVoiceApi.predownload(instructions) + + verify(exactly = 1) { + speechLoader.triggerDownload(instructions) + } + } + @Test fun `clean no file`() = runBlocking { val mockedAnnouncement: SpeechAnnouncement = mockk() 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 +132,18 @@ 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() } + } } 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..1193235888d 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,7 +4,6 @@ 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 @@ -719,27 +718,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..9722ba46be4 --- /dev/null +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsDownloadTriggerTest.kt @@ -0,0 +1,364 @@ +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 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