diff --git a/changelog/unreleased/bugfixes/6771.md b/changelog/unreleased/bugfixes/6771.md new file mode 100644 index 00000000000..b4e625d265b --- /dev/null +++ b/changelog/unreleased/bugfixes/6771.md @@ -0,0 +1,3 @@ +- Introduced `VoiceInstructionsPrefetcher` `MapboxSpeechAPI#generatePredownloaded` to use predownloaded voice instructions instead of downloading them on demand. Example usage can be found in the examples directory ( see `MapboxVoiceActivity`). +- Enabled voice instructions predownloading for those who use `MapboxAudioGuidance`. +- 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. \ No newline at end of file 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..c672ea8f0a4 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.VoiceInstructionsPrefetcher import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement import com.mapbox.navigation.ui.voice.model.SpeechError import com.mapbox.navigation.ui.voice.model.SpeechValue @@ -79,6 +81,7 @@ import java.util.Locale * attention to its usage. Long press anywhere on the map to set a destination and trigger * navigation. */ +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener { private var isMuted: Boolean = false @@ -201,9 +204,13 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener { } } + private val voiceInstructionsPrefetcher by lazy { + VoiceInstructionsPrefetcher(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 ) @@ -382,6 +389,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener { .build() ) init() + voiceInstructionsPrefetcher.onAttached(mapboxNavigation) } override fun onStart() { @@ -413,6 +421,7 @@ class MapboxVoiceActivity : AppCompatActivity(), OnMapLongClickListener { mapboxReplayer.finish() mapboxNavigation.onDestroy() speechApi.cancel() + voiceInstructionsPrefetcher.onDetached(mapboxNavigation) voiceInstructionsPlayer.shutdown() } 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..8c8cbbb23de 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,28 @@ package com.mapbox.navigation.utils.internal +import android.os.SystemClock +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()) + } + + object SystemClockImpl : Time { + override fun nanoTime(): Long = SystemClock.elapsedRealtimeNanos() + + override fun millis(): Long = SystemClock.elapsedRealtime() + + override fun seconds(): Long = TimeUnit.MILLISECONDS.toSeconds(millis()) } } diff --git a/libnavui-voice/api/current.txt b/libnavui-voice/api/current.txt index 0bb0e82f786..0ebed944d32 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 @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public void generatePredownloaded(com.mapbox.api.directions.v5.models.VoiceInstructions voiceInstruction, com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer> consumer); } @UiThread public final class MapboxVoiceInstructionsPlayer { @@ -92,6 +93,18 @@ package com.mapbox.navigation.ui.voice.api { property public abstract com.mapbox.navigation.ui.voice.options.VoiceInstructionsPlayerOptions options; } + @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class VoiceInstructionsPrefetcher implements com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver { + ctor public VoiceInstructionsPrefetcher(com.mapbox.navigation.ui.voice.api.MapboxSpeechApi speechApi); + method public void onAttached(com.mapbox.navigation.core.MapboxNavigation mapboxNavigation); + method public void onDetached(com.mapbox.navigation.core.MapboxNavigation mapboxNavigation); + field public static final com.mapbox.navigation.ui.voice.api.VoiceInstructionsPrefetcher.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 VoiceInstructionsPrefetcher.Companion { + } + } package com.mapbox.navigation.ui.voice.model { 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..06d7f8333d3 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,9 @@ internal constructor( private var dataStoreOwner: NavigationDataStoreOwner? = null private var configOwner: NavigationConfigOwner? = null + + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) + private var trigger: VoiceInstructionsPrefetcher? = null private var mutedStateFlow = MutableStateFlow(false) private val internalStateFlow = MutableStateFlow(MapboxAudioGuidanceState()) private val scope = CoroutineScope(SupervisorJob() + dispatcher) @@ -70,8 +74,10 @@ internal constructor( /** * @see [MapboxNavigationApp] */ + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) override fun onDetached(mapboxNavigation: MapboxNavigation) { mapboxVoiceInstructions.unregisterObservers(mapboxNavigation) + trigger?.onDetached(mapboxNavigation) job?.cancel() job = null } @@ -160,15 +166,22 @@ internal constructor( } } - private fun MapboxNavigation.audioGuidanceVoice(): Flow = - combine( + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) + private fun MapboxNavigation.audioGuidanceVoice(): Flow { + return combine( mapboxVoiceInstructions.voiceLanguage(), configOwner!!.language(), ) { voiceLanguage, deviceLanguage -> voiceLanguage ?: deviceLanguage } .distinctUntilChanged() .map { language -> - audioGuidanceServices.mapboxAudioGuidanceVoice(this, language) + trigger?.onDetached(this) + audioGuidanceServices.mapboxAudioGuidanceVoice(this, language).also { + trigger = VoiceInstructionsPrefetcher(it.mapboxSpeechApi).also { trigger -> + trigger.onAttached(this) + } + } } + } 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..f97960f367b 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 @@ -1,16 +1,21 @@ package com.mapbox.navigation.ui.voice.api import android.content.Context +import androidx.annotation.UiThread import com.mapbox.api.directions.v5.models.VoiceInstructions import com.mapbox.bindgen.Expected import com.mapbox.bindgen.ExpectedFactory +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +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 import com.mapbox.navigation.ui.voice.model.SpeechValue +import com.mapbox.navigation.ui.voice.model.TypeAndAnnouncement import com.mapbox.navigation.ui.voice.model.VoiceState import com.mapbox.navigation.ui.voice.options.MapboxSpeechApiOptions import com.mapbox.navigation.utils.internal.InternalJobControlFactory +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.util.Locale @@ -28,7 +33,11 @@ class MapboxSpeechApi @JvmOverloads constructor( private val options: MapboxSpeechApiOptions = MapboxSpeechApiOptions.Builder().build() ) { + private val cachedFiles = mutableMapOf() private val mainJobController by lazy { InternalJobControlFactory.createMainScopeJobControl() } + private val predownloadJobController by lazy { + InternalJobControlFactory.createDefaultScopeJobControl() + } private val voiceAPI = VoiceApiProvider.retrieveMapboxVoiceApi( context, accessToken, @@ -40,6 +49,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 [VoiceInstructionsPrefetcher]), 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 +63,42 @@ class MapboxSpeechApi @JvmOverloads constructor( consumer: MapboxNavigationConsumer> ) { mainJobController.scope.launch { - retrieveVoiceFile(voiceInstruction, consumer) + consumer.accept(retrieveVoiceFile(voiceInstruction)) + } + } + + /** + * 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 [VoiceInstructionsPrefetcher]), + * 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] + */ + @ExperimentalPreviewMapboxNavigationAPI + fun generatePredownloaded( + voiceInstruction: VoiceInstructions, + consumer: MapboxNavigationConsumer> + ) { + mainJobController.scope.launch { + val cachedValue = getFromCache(voiceInstruction) + if (cachedValue != null) { + consumer.accept(ExpectedFactory.createValue(cachedValue)) + } else { + val fallback = getFallbackAnnouncement(voiceInstruction) + val speechError = SpeechError( + "No predownloaded instruction for ${voiceInstruction.announcement()}", + null, + fallback + ) + consumer.accept(ExpectedFactory.createError(speechError)) + } } } @@ -73,33 +120,62 @@ class MapboxSpeechApi @JvmOverloads constructor( */ fun clean(announcement: SpeechAnnouncement) { voiceAPI.clean(announcement) + VoiceInstructionsParser.parse(announcement).onValue { + val value = cachedFiles[it] + // when we clear fallback announcement, there is a chance we will remove the key + // from map and not remove the file itself + // since for fallback SpeechAnnouncement file is null + if (value?.announcement == announcement) { + cachedFiles.remove(it) + } + } + } + + @UiThread + internal fun predownload(instructions: List) { + instructions.forEach { instruction -> + val typeAndAnnouncement = VoiceInstructionsParser.parse(instruction).value + if (typeAndAnnouncement != null && !hasTypeAndAnnouncement(typeAndAnnouncement)) { + predownloadJobController.scope.launch { + val voiceFile = retrieveVoiceFile(instruction) + mainJobController.scope.launch { + voiceFile.onValue { speechValue -> + cachedFiles[typeAndAnnouncement] = speechValue + } + } + } + } + } + } + + internal fun cancelPredownload() { + predownloadJobController.job.children.forEach { it.cancel() } + val announcements = cachedFiles.map { it.value.announcement } + announcements.forEach { clean(it) } } @Throws(IllegalStateException::class) private suspend fun retrieveVoiceFile( voiceInstruction: VoiceInstructions, - consumer: MapboxNavigationConsumer> - ) { + ): Expected { when (val result = voiceAPI.retrieveVoiceFile(voiceInstruction)) { is VoiceState.VoiceFile -> { val announcement = voiceInstruction.announcement() val ssmlAnnouncement = voiceInstruction.ssmlAnnouncement() - consumer.accept( - ExpectedFactory.createValue( - SpeechValue( - // Can't be null as it's checked in retrieveVoiceFile - SpeechAnnouncement.Builder(announcement!!) - .ssmlAnnouncement(ssmlAnnouncement) - .file(result.instructionFile) - .build() - ) + return ExpectedFactory.createValue( + SpeechValue( + // Can't be null as it's checked in retrieveVoiceFile + SpeechAnnouncement.Builder(announcement!!) + .ssmlAnnouncement(ssmlAnnouncement) + .file(result.instructionFile) + .build() ) ) } is VoiceState.VoiceError -> { val fallback = getFallbackAnnouncement(voiceInstruction) val speechError = SpeechError(result.exception, null, fallback) - consumer.accept(ExpectedFactory.createError(speechError)) + return ExpectedFactory.createError(speechError) } } } @@ -117,4 +193,13 @@ class MapboxSpeechApi @JvmOverloads constructor( .ssmlAnnouncement(ssmlAnnouncement) .build() } + + private fun hasTypeAndAnnouncement(typeAndAnnouncement: TypeAndAnnouncement): Boolean { + return typeAndAnnouncement in cachedFiles + } + + private fun getFromCache(voiceInstruction: VoiceInstructions): SpeechValue? { + val key = VoiceInstructionsParser.parse(voiceInstruction).value + return key?.let { cachedFiles[it] } + } } 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/MapboxSpeechProvider.kt index 06d04fe99fb..6ed0729817e 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/MapboxSpeechProvider.kt @@ -5,10 +5,12 @@ 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.ResourceLoadError +import com.mapbox.common.ResourceLoadFlags import com.mapbox.common.ResourceLoadResult import com.mapbox.common.ResourceLoadStatus import com.mapbox.navigation.base.internal.accounts.UrlSkuTokenProvider @@ -25,20 +27,27 @@ internal class MapboxSpeechProvider( 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 { + 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) + val response = resourceLoader.load(request) return processResponse(response) }.getOrElse { createError(it) } } - private suspend fun ResourceLoader.load(url: String) = load(ResourceLoadRequest(url)) + 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..d31d65e0a3a 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,7 +12,7 @@ import java.io.File * Implementation of [VoiceApi] allowing you to retrieve voice instructions. */ internal class MapboxVoiceApi( - private val speechProvider: MapboxSpeechProvider, + private val speechLoader: MapboxSpeechProvider, private val speechFileProvider: MapboxSpeechFileProvider ) : VoiceApi { @@ -22,8 +22,7 @@ internal class MapboxVoiceApi( */ 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 { 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..f9f0fe222aa 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 @@ -14,7 +14,9 @@ internal interface VoiceApi { * 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, + ): 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..7bf020cb900 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,7 +14,7 @@ internal object VoiceApiProvider { context: Context, accessToken: String, language: String, - options: MapboxSpeechApiOptions + options: MapboxSpeechApiOptions, ): MapboxVoiceApi = MapboxVoiceApi( MapboxSpeechProvider( accessToken, diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsParser.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsParser.kt index 072a862e310..404740f0cde 100644 --- a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsParser.kt +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsParser.kt @@ -4,6 +4,7 @@ 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.navigation.ui.voice.model.SpeechAnnouncement import com.mapbox.navigation.ui.voice.model.TypeAndAnnouncement internal object VoiceInstructionsParser { @@ -11,13 +12,22 @@ internal object VoiceInstructionsParser { private const val SSML_TYPE = "ssml" private const val TEXT_TYPE = "text" + fun parse(speechAnnouncement: SpeechAnnouncement): Expected { + return parse(speechAnnouncement.announcement, speechAnnouncement.ssmlAnnouncement) + } + fun parse(voiceInstructions: VoiceInstructions): Expected { - val announcement = voiceInstructions.announcement() - val ssmlAnnouncement = voiceInstructions.ssmlAnnouncement() + return parse(voiceInstructions.announcement(), voiceInstructions.ssmlAnnouncement()) + } + + private fun parse( + announcement: String?, + ssmlAnnouncement: String? + ): Expected { val (type, instruction) = - if (ssmlAnnouncement != null && !ssmlAnnouncement.isNullOrBlank()) { + if (!ssmlAnnouncement.isNullOrBlank()) { Pair(SSML_TYPE, ssmlAnnouncement) - } else if (announcement != null && !announcement.isNullOrBlank()) { + } else if (!announcement.isNullOrBlank()) { Pair(TEXT_TYPE, announcement) } else { return createError(invalidInstructionsError()) diff --git a/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPrefetcher.kt b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPrefetcher.kt new file mode 100644 index 00000000000..f774f4c5f9b --- /dev/null +++ b/libnavui-voice/src/main/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPrefetcher.kt @@ -0,0 +1,139 @@ +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.lifecycle.MapboxNavigationApp +import com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver +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 [MapboxNavigationApp.registerObserver] + * and [MapboxNavigationApp.unregisterObserver] + * or invoke [onAttached] and [onDetached] manually if you are not using [MapboxNavigationApp]. + */ +@ExperimentalPreviewMapboxNavigationAPI +class VoiceInstructionsPrefetcher internal constructor( + private val speechApi: MapboxSpeechApi, + private val observableTime: Int, + private val timePercentageToTriggerAfter: Double, + private val nextVoiceInstructionsProvider: NextVoiceInstructionsProvider = + TimeBasedNextVoiceInstructionsProvider(observableTime), + private val timeProvider: Time = Time.SystemClockImpl, +) : MapboxNavigationObserver { + + /** + * Creates [VoiceInstructionsPrefetcher. + * + * @param speechApi [MapboxSpeechApi] instances that's used to generate instructions + */ + constructor(speechApi: MapboxSpeechApi) : this( + speechApi, + DEFAULT_OBSERVABLE_TIME_SECONDS, + DEFAULT_TIME_PERCENTAGE_TO_TRIGGER_AFTER + ) + + private val routesObserver = RoutesObserver { onRoutesChanged(it) } + + private val routeProgressObserver = RouteProgressObserver { onRouteProgressChanged(it) } + + 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 [MapboxNavigationObserver.onAttached]. + */ + override fun onAttached(mapboxNavigation: MapboxNavigation) { + mapboxNavigation.registerRoutesObserver(routesObserver) + mapboxNavigation.registerRouteProgressObserver(routeProgressObserver) + } + + /** + * See [MapboxNavigationObserver.onDetached]. + */ + override fun onDetached(mapboxNavigation: MapboxNavigation) { + lastDownloadTime = 0 + mapboxNavigation.unregisterRoutesObserver(routesObserver) + mapboxNavigation.unregisterRouteProgressObserver(routeProgressObserver) + speechApi.cancelPredownload() + } + + private 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) + } + } + } + + private 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) + 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..0a70182f07c 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 @@ -1,6 +1,7 @@ package com.mapbox.navigation.ui.voice.internal import com.mapbox.api.directions.v5.models.VoiceInstructions +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.ui.voice.api.MapboxSpeechApi import com.mapbox.navigation.ui.voice.api.MapboxVoiceInstructionsPlayer import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement @@ -16,7 +17,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 +42,11 @@ class MapboxAudioGuidanceVoice( } } + @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) 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..4df1e98f418 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,7 +34,9 @@ class TestMapboxAudioGuidanceServices( every { voiceLanguage() } returns voiceLanguageFlow } - private val mapboxAudioGuidanceVoice = mockk { + private val mapboxSpeechApi = mockk(relaxed = true) + + val mapboxAudioGuidanceVoice = mockk(relaxed = true) { coEvery { speak(any()) } coAnswers { val voiceInstructions = firstArg() val speechAnnouncement: SpeechAnnouncement? = voiceInstructions?.let { @@ -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..21ea53a7d4b 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 @@ -3,13 +3,17 @@ package com.mapbox.navigation.ui.voice.api 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.directions.session.RoutesObserver +import com.mapbox.navigation.core.trip.session.RouteProgressObserver 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 +38,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 +278,56 @@ class MapboxAudioGuidanceTest { every { mapboxAudioGuidanceServices.voiceInstructionsPlayer } returns null assertNull(carAppAudioGuidance.getCurrentVoiceInstructionsPlayer()) } + + @Test + fun `triggers are registered and unregistered`() = coroutineRule.runBlockingTest { + val routesObserverSlot = mutableListOf() + val routeProgressObserverSlot = mutableListOf() + carAppAudioGuidance.onAttached(mapboxNavigation) + clearMocks(mapboxNavigation, answers = false) + + testMapboxAudioGuidanceServices.emitVoiceLanguage("ru") + delay(SPEECH_ANNOUNCEMENT_DELAY_MS) + + verify(exactly = 1) { + mapboxNavigation.registerRoutesObserver(capture(routesObserverSlot)) + mapboxNavigation.registerRouteProgressObserver(capture(routeProgressObserverSlot)) + } + + testMapboxAudioGuidanceServices.emitVoiceLanguage("fr") + delay(SPEECH_ANNOUNCEMENT_DELAY_MS) + + verify(exactly = 1) { + mapboxNavigation.unregisterRoutesObserver(routesObserverSlot.first()) + mapboxNavigation.registerRouteProgressObserver(routeProgressObserverSlot.first()) + } + verify(exactly = 2) { + mapboxNavigation.registerRoutesObserver(any()) + mapboxNavigation.registerRouteProgressObserver(any()) + } + + carAppAudioGuidance.onDetached(mapboxNavigation) + } + + @Test + fun `onDetached destroys trigger when has one`() = coroutineRule.runBlockingTest { + carAppAudioGuidance.onAttached(mapboxNavigation) + + testMapboxAudioGuidanceServices.emitVoiceLanguage("ru") + delay(SPEECH_ANNOUNCEMENT_DELAY_MS) + val speechApi = testMapboxAudioGuidanceServices.mapboxAudioGuidanceVoice.mapboxSpeechApi + clearMocks(speechApi, answers = false) + + carAppAudioGuidance.onDetached(mapboxNavigation) + + verify(exactly = 1) { + speechApi.cancelPredownload() + } + } + + @Test + fun `onDetached does not destroy voice guidance or trigger when does not have them`() { + 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..8e99e762c19 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 @@ -3,18 +3,20 @@ package com.mapbox.navigation.ui.voice.api import android.content.Context import com.mapbox.api.directions.v5.models.VoiceInstructions import com.mapbox.bindgen.Expected +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.testing.MainCoroutineRule import com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement import com.mapbox.navigation.ui.voice.model.SpeechError import com.mapbox.navigation.ui.voice.model.SpeechValue import com.mapbox.navigation.ui.voice.model.VoiceState -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 io.mockk.Runs +import io.mockk.clearMocks import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -36,12 +38,15 @@ import org.junit.Test import java.io.File import java.util.Locale +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) @ExperimentalCoroutinesApi class MapboxSpeechApiTest { @get:Rule var coroutineRule = MainCoroutineRule() + private val voiceAPI = mockk(relaxed = true) private val parentJob = SupervisorJob() + private val predownloadParentJob = SupervisorJob() private var exceptions: MutableList = mutableListOf() private val coroutineExceptionHandler: CoroutineExceptionHandler = CoroutineExceptionHandler { _, exception -> @@ -50,6 +55,9 @@ class MapboxSpeechApiTest { private val testScope = CoroutineScope( parentJob + coroutineRule.testDispatcher + coroutineExceptionHandler ) + private val predownloadScope = CoroutineScope( + predownloadParentJob + coroutineRule.testDispatcher + coroutineExceptionHandler + ) @Before fun setUp() { @@ -57,7 +65,13 @@ class MapboxSpeechApiTest { every { InternalJobControlFactory.createMainScopeJobControl() } returns JobControl(parentJob, testScope) + every { + InternalJobControlFactory.createDefaultScopeJobControl() + } returns JobControl(predownloadParentJob, predownloadScope) mockkObject(VoiceApiProvider) + every { + VoiceApiProvider.retrieveMapboxVoiceApi(any(), any(), any(), any()) + } returns voiceAPI mockkObject(VoiceInstructionsParser) } @@ -89,19 +103,9 @@ class MapboxSpeechApiTest { val speechValueSlot = slot>() every { speechConsumer.accept(capture(speechValueSlot)) } just Runs val mockedInstructionFile: File = mockk() - val mockedVoiceApi: MapboxVoiceApi = mockk() coEvery { - mockedVoiceApi.retrieveVoiceFile(any()) + voiceAPI.retrieveVoiceFile(any()) } 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.generate(mockedVoiceInstructions, speechConsumer) @@ -120,14 +124,9 @@ class MapboxSpeechApiTest { val speechConsumer = mockk>> { every { accept(capture(speechErrorCapture)) } just Runs } - val mockedVoiceApi = mockk { - coEvery { - retrieveVoiceFile(voiceInstructions) - } returns VoiceState.VoiceError("Some error message") - } - every { - VoiceApiProvider.retrieveMapboxVoiceApi(any(), any(), any(), any()) - } returns mockedVoiceApi + coEvery { + voiceAPI.retrieveVoiceFile(voiceInstructions) + } returns VoiceState.VoiceError("Some error message") val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) sut.generate(voiceInstructions, speechConsumer) @@ -151,14 +150,9 @@ class MapboxSpeechApiTest { val speechConsumer = mockk>> { every { accept(capture(speechErrorCapture)) } just Runs } - val mockedVoiceApi = mockk { - coEvery { - retrieveVoiceFile(voiceInstructions) - } returns VoiceState.VoiceError("Some error message") - } - every { - VoiceApiProvider.retrieveMapboxVoiceApi(any(), any(), any(), any()) - } returns mockedVoiceApi + coEvery { + voiceAPI.retrieveVoiceFile(voiceInstructions) + } returns VoiceState.VoiceError("Some error message") val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) sut.generate(voiceInstructions, speechConsumer) @@ -167,28 +161,380 @@ class MapboxSpeechApiTest { } @Test - fun clean() { + fun `generatePredownloaded for invalid instruction`() = coroutineRule.runBlockingTest { + val consumer: MapboxNavigationConsumer> = + mockk(relaxed = true) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.generatePredownloaded(VoiceInstructions.builder().build(), consumer) + + coVerify(exactly = 0) { + consumer.accept(any()) + } + assertTrue(exceptions[0] is java.lang.IllegalStateException) + } + + @Test + fun `generatePredownloaded no cached value`() = coroutineRule.runBlockingTest { + val consumer: MapboxNavigationConsumer> = + mockk(relaxed = true) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.generatePredownloaded( + VoiceInstructions.builder().announcement("turn up and down").build(), + consumer + ) + + coVerify(exactly = 1) { + consumer.accept(match { it.isError }) + } + } + + @Test + fun `generatePredownloaded has cached value`() = coroutineRule.runBlockingTest { + val consumer: MapboxNavigationConsumer> = + mockk(relaxed = true) + val instruction = VoiceInstructions.builder().announcement("turn up and down").build() + val file = mockk(relaxed = true) + val speechAnnouncement = SpeechAnnouncement.Builder("turn up and down").file(file).build() + coEvery { voiceAPI.retrieveVoiceFile(instruction) } returns VoiceState.VoiceFile(file) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction)) + + sut.generatePredownloaded(instruction, consumer) + + coVerify(exactly = 1) { + consumer.accept(match { it.value!!.announcement == speechAnnouncement }) + } + } + + @Test + fun `generatePredownloaded has removed via clean cached value`() = coroutineRule.runBlockingTest { + val consumer: MapboxNavigationConsumer> = + mockk(relaxed = true) + val instruction = VoiceInstructions.builder().announcement("turn up and down").build() + val file = mockk(relaxed = true) + val speechAnnouncement = SpeechAnnouncement.Builder("turn up and down").file(file).build() + coEvery { voiceAPI.retrieveVoiceFile(instruction) } returns VoiceState.VoiceFile(file) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction)) + sut.clean(speechAnnouncement) + + sut.generatePredownloaded(instruction, consumer) + + coVerify(exactly = 1) { + consumer.accept(match { it.isError }) + } + } + + @Test + fun `generatePredownloaded, another cached value is cleaned`() = coroutineRule.runBlockingTest { + val consumer: MapboxNavigationConsumer> = + mockk(relaxed = true) + val instruction = VoiceInstructions.builder().announcement("turn up and down").build() + val file = mockk(relaxed = true) + val speechAnnouncement = SpeechAnnouncement.Builder("turn up and down").file(file).build() + coEvery { voiceAPI.retrieveVoiceFile(instruction) } returns VoiceState.VoiceFile(file) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction)) + sut.clean(SpeechAnnouncement.Builder("announcement").build()) + + sut.generatePredownloaded(instruction, consumer) + + coVerify(exactly = 1) { + consumer.accept(match { it.value!!.announcement == speechAnnouncement }) + } + } + + @Test + fun `generatePredownloaded, fallback cached value is cleaned`() = coroutineRule.runBlockingTest { + val consumer: MapboxNavigationConsumer> = + mockk(relaxed = true) + val instruction = VoiceInstructions.builder().announcement("turn up and down").build() + val file = mockk(relaxed = true) + val speechAnnouncement = SpeechAnnouncement.Builder("turn up and down").file(file).build() + val fallbackAnnouncement = SpeechAnnouncement.Builder("turn up and down").build() + coEvery { voiceAPI.retrieveVoiceFile(instruction) } returns VoiceState.VoiceFile(file) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction)) + sut.clean(fallbackAnnouncement) + + sut.generatePredownloaded(instruction, consumer) + + coVerify(exactly = 1) { + consumer.accept(match { it.value!!.announcement == speechAnnouncement }) + } + } + + @Test + fun `generatePredownloaded has removed via destroy cached value`() = coroutineRule.runBlockingTest { + val consumer: MapboxNavigationConsumer> = + mockk(relaxed = true) + val instruction = VoiceInstructions.builder().announcement("turn up and down").build() + val file = mockk(relaxed = true) + coEvery { voiceAPI.retrieveVoiceFile(instruction) } returns VoiceState.VoiceFile(file) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction)) + sut.cancelPredownload() + + sut.generatePredownloaded(instruction, consumer) + + coVerify(exactly = 1) { + consumer.accept(match { it.isError }) + } + } + + @Test + fun `predownload with empty list`() = coroutineRule.runBlockingTest { + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.predownload(emptyList()) + + coVerify(exactly = 0) { voiceAPI.retrieveVoiceFile(any()) } + } + + @Test + fun `predownload with invalid instruction`() = coroutineRule.runBlockingTest { + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.predownload(listOf(VoiceInstructions.builder().build())) + + coVerify(exactly = 0) { voiceAPI.retrieveVoiceFile(any()) } + } + + @Test + fun `predownload with new instruction`() = coroutineRule.runBlockingTest { + val instruction = VoiceInstructions.builder().announcement("turn up and down").build() + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.predownload(listOf(instruction)) + + coVerify(exactly = 1) { voiceAPI.retrieveVoiceFile(instruction) } + } + + @Test + fun `predownload with same instruction of different type`() = coroutineRule.runBlockingTest { + val announcement = "turn up and down" + val instruction = VoiceInstructions.builder().announcement(announcement).build() + val newInstruction = VoiceInstructions.builder().ssmlAnnouncement(announcement).build() + coEvery { + voiceAPI.retrieveVoiceFile(any()) + } returns VoiceState.VoiceFile(mockk(relaxed = true)) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.predownload(listOf(instruction)) + clearMocks(voiceAPI, answers = false) + + sut.predownload(listOf(newInstruction)) + + coVerify(exactly = 1) { voiceAPI.retrieveVoiceFile(newInstruction) } + } + + @Test + fun `predownload with existing instruction`() = coroutineRule.runBlockingTest { + val announcement = "turn up and down" + val instruction = VoiceInstructions.builder().announcement(announcement).build() + coEvery { + voiceAPI.retrieveVoiceFile(any()) + } returns VoiceState.VoiceFile(mockk(relaxed = true)) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.predownload(listOf(instruction)) + clearMocks(voiceAPI, answers = false) + + sut.predownload(listOf(instruction)) + + coVerify(exactly = 0) { voiceAPI.retrieveVoiceFile(instruction) } + } + + @Test + fun `predownload with multiple new instructions`() = coroutineRule.runBlockingTest { + val instruction1 = VoiceInstructions.builder().announcement("turn up and down").build() + val instruction2 = VoiceInstructions.builder().announcement("dance and jump").build() + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.predownload(listOf(instruction1, instruction2)) + + coVerify(exactly = 1) { + voiceAPI.retrieveVoiceFile(instruction1) + voiceAPI.retrieveVoiceFile(instruction2) + } + } + + @Test + fun `failed download does not save instruction`() = coroutineRule.runBlockingTest { + val instruction = VoiceInstructions.builder().announcement("turn up and down").build() + coEvery { voiceAPI.retrieveVoiceFile(instruction) } returns VoiceState.VoiceError("") + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.predownload(listOf(instruction)) + clearMocks(voiceAPI, answers = false) + + sut.predownload(listOf(instruction)) + + coVerify(exactly = 1) { voiceAPI.retrieveVoiceFile(instruction) } + } + + @Test + fun `clean existing instruction with file`() = coroutineRule.runBlockingTest { + val instruction1 = VoiceInstructions.builder().announcement("turn up and down").build() + val instruction2 = VoiceInstructions.builder().announcement("dance and jump").build() + val file1 = mockk(relaxed = true) + val file2 = mockk(relaxed = true) + coEvery { + voiceAPI.retrieveVoiceFile(instruction1) + } returns VoiceState.VoiceFile(file1) + coEvery { + voiceAPI.retrieveVoiceFile(instruction2) + } returns VoiceState.VoiceFile(file2) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction1, instruction2)) + clearMocks(voiceAPI, answers = false) + + sut.clean(SpeechAnnouncement.Builder("turn up and down").file(file1).build()) + + sut.predownload(listOf(instruction1, instruction2)) + coVerify(exactly = 1) { + voiceAPI.retrieveVoiceFile(instruction1) + } + coVerify(exactly = 0) { + voiceAPI.retrieveVoiceFile(instruction2) + } + } + + @Test + fun `clean existing instruction with no file`() = coroutineRule.runBlockingTest { + val instruction1 = VoiceInstructions.builder().announcement("turn up and down").build() + val instruction2 = VoiceInstructions.builder().announcement("dance and jump").build() + coEvery { + voiceAPI.retrieveVoiceFile(any()) + } returns VoiceState.VoiceFile(mockk(relaxed = true)) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction1, instruction2)) + clearMocks(voiceAPI, answers = false) + + sut.clean(SpeechAnnouncement.Builder("turn up and down").build()) + + sut.predownload(listOf(instruction1, instruction2)) + coVerify(exactly = 0) { + voiceAPI.retrieveVoiceFile(instruction1) + voiceAPI.retrieveVoiceFile(instruction2) + } + } + + @Test + fun `clean existing instruction with different type`() = coroutineRule.runBlockingTest { + val instruction1 = VoiceInstructions.builder().announcement("turn up and down").build() + val instruction2 = VoiceInstructions.builder().announcement("dance and jump").build() + coEvery { + voiceAPI.retrieveVoiceFile(any()) + } returns VoiceState.VoiceFile(mockk(relaxed = true)) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction1, instruction2)) + clearMocks(voiceAPI, answers = false) + + sut.clean( + SpeechAnnouncement.Builder("new announcement") + .ssmlAnnouncement("turn up and down") + .build() + ) + + sut.predownload(listOf(instruction1, instruction2)) + coVerify(exactly = 0) { + voiceAPI.retrieveVoiceFile(instruction1) + voiceAPI.retrieveVoiceFile(instruction2) + } + } + + @Test + fun `clean unexisting instruction`() = coroutineRule.runBlockingTest { + val instruction1 = VoiceInstructions.builder().announcement("turn up and down").build() + val instruction2 = VoiceInstructions.builder().announcement("dance and jump").build() + coEvery { + voiceAPI.retrieveVoiceFile(any()) + } returns VoiceState.VoiceFile(mockk(relaxed = true)) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction1, instruction2)) + clearMocks(voiceAPI, answers = false) + + sut.clean(SpeechAnnouncement.Builder("new announcement").build()) + + sut.predownload(listOf(instruction1, instruction2)) + coVerify(exactly = 0) { + voiceAPI.retrieveVoiceFile(instruction1) + voiceAPI.retrieveVoiceFile(instruction2) + } + } + + @Test + fun `clean invalid instruction`() = coroutineRule.runBlockingTest { + val instruction1 = VoiceInstructions.builder().announcement("turn up and down").build() + val instruction2 = VoiceInstructions.builder().announcement("dance and jump").build() + coEvery { + voiceAPI.retrieveVoiceFile(any()) + } returns VoiceState.VoiceFile(mockk(relaxed = true)) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction1, instruction2)) + clearMocks(voiceAPI, answers = false) + + sut.clean(SpeechAnnouncement.Builder("").build()) + + sut.predownload(listOf(instruction1, instruction2)) + coVerify(exactly = 0) { + voiceAPI.retrieveVoiceFile(instruction1) + voiceAPI.retrieveVoiceFile(instruction2) + } + } + + @Test + fun `clean with no instructions`() { val aMockedContext: Context = mockk(relaxed = true) val anyAccessToken = "pk.123" val anyLanguage = Locale.US.language - val mockedVoiceApi: MapboxVoiceApi = mockk() - every { mockedVoiceApi.clean(any()) } just Runs - val options = MapboxSpeechApiOptions.Builder().build() - every { - VoiceApiProvider.retrieveMapboxVoiceApi( - aMockedContext, - anyAccessToken, - anyLanguage, - options - ) - } returns mockedVoiceApi val mapboxSpeechApi = MapboxSpeechApi(aMockedContext, anyAccessToken, anyLanguage) - val anyAnnouncement: SpeechAnnouncement = mockk() + val anyAnnouncement: SpeechAnnouncement = mockk(relaxed = true) mapboxSpeechApi.clean(anyAnnouncement) verify(exactly = 1) { - mockedVoiceApi.clean(anyAnnouncement) + voiceAPI.clean(anyAnnouncement) + } + } + + @Test + fun `destroy with no instructions`() { + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + + sut.cancelPredownload() + + coVerify(exactly = 0) { voiceAPI.clean(any()) } + } + + @Test + fun `destroy removes all instructions`() = coroutineRule.runBlockingTest { + val instruction1 = VoiceInstructions.builder().announcement("turn up and down").build() + val instruction2 = VoiceInstructions.builder().announcement("dance and jump").build() + val file1: File = mockk(relaxed = true) + val file2: File = mockk(relaxed = true) + val announcement1 = SpeechAnnouncement.Builder("turn up and down").file(file1).build() + val announcement2 = SpeechAnnouncement.Builder("dance and jump").file(file2).build() + coEvery { voiceAPI.retrieveVoiceFile(instruction1) } returns VoiceState.VoiceFile(file1) + coEvery { voiceAPI.retrieveVoiceFile(instruction2) } returns VoiceState.VoiceFile(file2) + val sut = MapboxSpeechApi(mockk(relaxed = true), "pk.123", Locale.US.language) + sut.predownload(listOf(instruction1, instruction2)) + clearMocks(voiceAPI, answers = false) + + sut.cancelPredownload() + + coVerify(exactly = 1) { + voiceAPI.clean(announcement1) + voiceAPI.clean(announcement2) + } + + sut.predownload(listOf(instruction1, instruction2)) + coVerify(exactly = 1) { + voiceAPI.retrieveVoiceFile(instruction1) + voiceAPI.retrieveVoiceFile(instruction2) } } } 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 index 6123527e796..90121dce26f 100644 --- 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 @@ -4,26 +4,35 @@ import android.net.Uri import androidx.core.net.toUri 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.LoggingFrontendTestRule +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.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.launch 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 @@ -35,6 +44,11 @@ internal class MapboxSpeechProviderTest { private lateinit var sut: MapboxSpeechProvider + @get:Rule + val coroutineRule = MainCoroutineRule() + + @get:Rule + val loggerRule = LoggingFrontendTestRule() private var accessToken = "access_token" private val language = "en" private val apiOptions = MapboxSpeechApiOptions.Builder() @@ -49,6 +63,10 @@ internal class MapboxSpeechProviderTest { @Before fun setUp() { + mockkObject(InternalJobControlFactory) + every { + InternalJobControlFactory.createDefaultScopeJobControl() + } returns JobControl(mockk(), coroutineRule.createTestScope()) mockkObject(ThreadController) mockResourceLoader = mockk(relaxed = true) @@ -69,7 +87,7 @@ internal class MapboxSpeechProviderTest { @Test fun `load should use ResourceLoader to load audio data`() = runBlocking { - val announcement = Fixtures.ssmlAnnouncement() + val instructions = Fixtures.ssmlInstructions() val requestCapture = slot() val callbackCapture = slot() @@ -84,30 +102,61 @@ internal class MapboxSpeechProviderTest { 1L } - sut.load(announcement) + sut.load(instructions) - val loadRequestUri = requestCapture.captured.url.toUri() + val loadRequest = requestCapture.captured + val loadRequestUri = loadRequest.url.toUri() assertEquals("https", loadRequestUri.scheme) assertEquals("example.com", loadRequestUri.authority) assertEquals( - "/voice/v1/speak/${UrlUtils.encodePathSegment(announcement.announcement)}", + "/voice/v1/speak/${UrlUtils.encodePathSegment(instructions.ssmlAnnouncement()!!)}", loadRequestUri.encodedPath ) assertEquals( mapOf( - "textType" to announcement.type, + "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 allow network`() = 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 + assertEquals( + NetworkRestriction.NONE, + loadRequest.networkRestriction + ) } @Test fun `load should return Expected with non empty audio BLOB on success`() = runBlocking { - val announcement = Fixtures.textAnnouncement() + val instructions = Fixtures.textInstructions() val blob = byteArrayOf(12, 23, 34) val loadRequest = ResourceLoadRequest("https://some.url") val loadResult = Fixtures.resourceLoadResult( @@ -116,7 +165,7 @@ internal class MapboxSpeechProviderTest { ) givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) - val result = sut.load(announcement) + val result = sut.load(instructions) assertEquals(blob, result.value) } @@ -125,7 +174,7 @@ internal class MapboxSpeechProviderTest { 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 instructions = Fixtures.textInstructions() val loadRequest = ResourceLoadRequest("https://some.url") val loadResult = Fixtures.resourceLoadResult( Fixtures.resourceData(byteArrayOf()), @@ -133,7 +182,7 @@ internal class MapboxSpeechProviderTest { ) givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) - val result = sut.load(announcement) + val result = sut.load(instructions) assertEquals(expectedError, result.error!!.localizedMessage) } @@ -142,7 +191,7 @@ internal class MapboxSpeechProviderTest { 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 instructions = Fixtures.textInstructions() val loadRequest = ResourceLoadRequest("https://some.url") val loadResult = Fixtures.resourceLoadResult( null, @@ -150,7 +199,7 @@ internal class MapboxSpeechProviderTest { ) givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) - val result = sut.load(announcement) + val result = sut.load(instructions) assertEquals(expectedError, result.error!!.localizedMessage) } @@ -159,7 +208,7 @@ internal class MapboxSpeechProviderTest { fun `load should return Expected with an Error on NOT_FOUND loader response`() = runBlocking { val expectedError = "Resource is missing." - val announcement = Fixtures.textAnnouncement() + val instructions = Fixtures.textInstructions() val loadRequest = ResourceLoadRequest("https://some.url") val loadResult = Fixtures.resourceLoadResult( null, @@ -167,11 +216,21 @@ internal class MapboxSpeechProviderTest { ) givenResourceLoaderResponse(loadRequest, ExpectedFactory.createValue(loadResult)) - val result = sut.load(announcement) + val result = sut.load(instructions) assertEquals(expectedError, result.error!!.localizedMessage) } + @Test + fun `request is cancelled when scope is cancelled`() = coroutineRule.runBlockingTest { + val instructions = Fixtures.textInstructions() + val job = launch { sut.load(instructions) } + + job.cancel() + + verify(exactly = 1) { mockResourceLoader.cancel(any()) } + } + private fun givenResourceLoaderResponse( request: ResourceLoadRequest, response: Expected 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..385e71a1ac0 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,7 +2,6 @@ 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.coEvery @@ -22,26 +21,25 @@ import java.io.InputStream internal class MapboxVoiceApiTest { private lateinit var sut: MapboxVoiceApi - private lateinit var mockSpeechProvider: MapboxSpeechProvider + private lateinit var mockSpeechLoader: MapboxSpeechProvider private lateinit var mockSpeechFileProvider: MapboxSpeechFileProvider @Before fun setUp() { - mockSpeechProvider = mockk(relaxed = true) + mockSpeechLoader = mockk(relaxed = true) mockSpeechFileProvider = mockk(relaxed = true) - sut = MapboxVoiceApi(mockSpeechProvider, mockSpeechFileProvider) + sut = MapboxVoiceApi(mockSpeechLoader, mockSpeechFileProvider) } @Test fun `retrieveVoiceFile should download audio data using MapboxSpeechProvider`() = runBlocking { val voiceInstructions = Fixtures.ssmlInstructions() - coEvery { mockSpeechProvider.load(any()) } returns ExpectedFactory.createError(Error()) + coEvery { mockSpeechLoader.load(any()) } returns ExpectedFactory.createError(Error()) sut.retrieveVoiceFile(voiceInstructions) - val announcement = TypeAndAnnouncement("ssml", voiceInstructions.ssmlAnnouncement()!!) - coVerify { mockSpeechProvider.load(announcement) } + coVerify { mockSpeechLoader.load(voiceInstructions) } } @Test @@ -50,7 +48,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 { mockSpeechLoader.load(any()) } returns ExpectedFactory.createValue(blob) coEvery { mockSpeechFileProvider.generateVoiceFileFrom(capture(blobInputStream)) } returns File("ignored") @@ -66,7 +64,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 { mockSpeechLoader.load(any()) } returns ExpectedFactory.createValue(blob) coEvery { mockSpeechFileProvider.generateVoiceFileFrom(any()) } returns file val result = sut.retrieveVoiceFile(voiceInstructions) @@ -78,7 +76,9 @@ 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 { + mockSpeechLoader.load(any()) + } returns ExpectedFactory.createError(Error()) coEvery { mockSpeechFileProvider.generateVoiceFileFrom(any()) } throws Error() val result = sut.retrieveVoiceFile(voiceInstructions) @@ -92,8 +92,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: MapboxSpeechProvider = mockk() + val mapboxVoiceApi = MapboxVoiceApi(speechLoader, fileProvider) mapboxVoiceApi.clean(mockedAnnouncement) @@ -106,8 +106,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: MapboxSpeechProvider = mockk() + val mapboxVoiceApi = MapboxVoiceApi(speechLoader, fileProvider) mapboxVoiceApi.clean(mockedAnnouncement) @@ -117,8 +117,8 @@ 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() 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/VoiceInstructionsParserTest.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsParserTest.kt index 356e704a59b..d8c208cfdec 100644 --- a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsParserTest.kt +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsParserTest.kt @@ -1,5 +1,6 @@ package com.mapbox.navigation.ui.voice.api +import com.mapbox.navigation.ui.voice.model.SpeechAnnouncement import com.mapbox.navigation.ui.voice.testutils.Fixtures import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -9,7 +10,7 @@ import org.junit.Test class VoiceInstructionsParserTest { @Test - fun `parse - should return SSML announcement`() { + fun `parse instruction - should return SSML announcement`() { val voiceInstructions = Fixtures.ssmlInstructions() val result = VoiceInstructionsParser.parse(voiceInstructions) @@ -20,7 +21,7 @@ class VoiceInstructionsParserTest { } @Test - fun `parse - should return TEXT announcement`() { + fun `parse instruction - should return TEXT announcement`() { val voiceInstructions = Fixtures.textInstructions() val result = VoiceInstructionsParser.parse(voiceInstructions) @@ -31,7 +32,7 @@ class VoiceInstructionsParserTest { } @Test - fun `parse - should return an error for empty announcement`() { + fun `parse instruction - should return an error for empty announcement`() { val voiceInstructions = Fixtures.emptyInstructions() val result = VoiceInstructionsParser.parse(voiceInstructions) @@ -41,7 +42,7 @@ class VoiceInstructionsParserTest { } @Test - fun `parse - should return an error for invalid announcement`() { + fun `parse instruction - should return an error for invalid announcement`() { val voiceInstructions = Fixtures.nullInstructions() val result = VoiceInstructionsParser.parse(voiceInstructions) @@ -49,4 +50,40 @@ class VoiceInstructionsParserTest { assertNull(result.value) assertNotNull(result.error) } + + @Test + fun `parse announcement - should return SSML announcement`() { + val ssmlAnnouncement = "Turn left" + val speechAnnouncement = SpeechAnnouncement.Builder("Turn right") + .ssmlAnnouncement(ssmlAnnouncement) + .build() + + val result = VoiceInstructionsParser.parse(speechAnnouncement) + + assertNotNull(result.value) + assertEquals(ssmlAnnouncement, result.value?.announcement) + assertEquals("ssml", result.value?.type) + } + + @Test + fun `parse announcement - should return TEXT announcement`() { + val announcement = "Turn left" + val speechAnnouncement = SpeechAnnouncement.Builder(announcement).build() + + val result = VoiceInstructionsParser.parse(speechAnnouncement) + + assertNotNull(result.value) + assertEquals(announcement, result.value?.announcement) + assertEquals("text", result.value?.type) + } + + @Test + fun `parse announcement - should return an error for empty announcement`() { + val announcement = SpeechAnnouncement.Builder("").build() + + val result = VoiceInstructionsParser.parse(announcement) + + assertNull(result.value) + assertNotNull(result.error) + } } diff --git a/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPrefetcherTest.kt b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPrefetcherTest.kt new file mode 100644 index 00000000000..dcbd355546b --- /dev/null +++ b/libnavui-voice/src/test/java/com/mapbox/navigation/ui/voice/api/VoiceInstructionsPrefetcherTest.kt @@ -0,0 +1,429 @@ +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.ExperimentalPreviewMapboxNavigationAPI +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.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 io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +class VoiceInstructionsPrefetcherTest { + + private val observableTime = 100 + private val timePercentageToTriggerAfter = 0.5 + private val nextVoiceInstructionsProvider = mockk(relaxed = true) + private val timeProvider = mockk