From 7e5e823516663ebb2711bf83fc6c03e9ae0cd745 Mon Sep 17 00:00:00 2001 From: mitulsheth Date: Tue, 7 Apr 2026 11:46:55 +0530 Subject: [PATCH 1/3] fix(audio): allow other apps to resume playback after navigation TTS - Fix audio focus handling for navigation announcements - Ensure proper release of audio focus after TTS --- .../java/earth/maps/cardinal/MainActivity.kt | 7 - .../data/audio/MapsAudioFocusController.kt | 64 ++++++++ .../cardinal/data/tts/AndroidTtsFactory.kt | 33 ++++ .../maps/cardinal/data/tts/MapsTtsObserver.kt | 133 ++++++++++++++++ .../maps/cardinal/data/tts/TtsFactory.kt | 25 +++ .../java/earth/maps/cardinal/di/TtsModule.kt | 60 +++++++ .../maps/cardinal/routing/FerrostarWrapper.kt | 4 +- .../routing/FerrostarWrapperRepository.kt | 16 +- .../navigation/TurnByTurnNavigationScreen.kt | 6 +- .../navigation/TurnByTurnNavigationUiEvent.kt | 24 +++ .../TurnByTurnNavigationViewModel.kt | 12 +- .../cardinal/data/tts/MapsTtsObserverTest.kt | 149 ++++++++++++++++++ .../TurnByTurnNavigationViewModelTest.kt | 63 ++++++++ 13 files changed, 579 insertions(+), 17 deletions(-) create mode 100644 cardinal-android/app/src/main/java/earth/maps/cardinal/data/audio/MapsAudioFocusController.kt create mode 100644 cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/AndroidTtsFactory.kt create mode 100644 cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/MapsTtsObserver.kt create mode 100644 cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/TtsFactory.kt create mode 100644 cardinal-android/app/src/main/java/earth/maps/cardinal/di/TtsModule.kt create mode 100644 cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationUiEvent.kt create mode 100644 cardinal-android/app/src/test/java/earth/maps/cardinal/data/tts/MapsTtsObserverTest.kt create mode 100644 cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModelTest.kt diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt index 9d47b74..b38c8bd 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/MainActivity.kt @@ -289,7 +289,6 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() - ferrostarWrapperRepository.androidTtsObserver.start() // Observe permission requests from services lifecycleScope.launch { @@ -316,14 +315,8 @@ class MainActivity : ComponentActivity() { bindService(serviceIntent, connection, BIND_AUTO_CREATE) } - override fun onStop() { - super.onStop() - ferrostarWrapperRepository.androidTtsObserver.stopAndClearQueue() - } - override fun onDestroy() { super.onDestroy() - ferrostarWrapperRepository.androidTtsObserver.shutdown() if (bound) { unbindService(connection) bound = false diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/audio/MapsAudioFocusController.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/audio/MapsAudioFocusController.kt new file mode 100644 index 0000000..9ac2acb --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/audio/MapsAudioFocusController.kt @@ -0,0 +1,64 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.data.audio + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest +import android.media.AudioManager +import dagger.hilt.android.qualifiers.ApplicationContext + +class MapsAudioFocusController( + @param:ApplicationContext private val context: Context +) { + + private val audioManager = + context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + + private val focusRequest: AudioFocusRequest by lazy { + val attributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + + AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setAudioAttributes(attributes) + .setWillPauseWhenDucked(false) + .build() + } + + private var hasFocus = false + + fun requestFocus(): Boolean { + if (hasFocus) return true // already holding focus + + val granted = audioManager.requestAudioFocus(focusRequest) == + AudioManager.AUDIOFOCUS_REQUEST_GRANTED + + hasFocus = granted + return granted + } + + fun abandonFocus() { + if (!hasFocus) return + + audioManager.abandonAudioFocusRequest(focusRequest) + hasFocus = false + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/AndroidTtsFactory.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/AndroidTtsFactory.kt new file mode 100644 index 0000000..8806b22 --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/AndroidTtsFactory.kt @@ -0,0 +1,33 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.data.tts + +import android.content.Context +import android.speech.tts.TextToSpeech +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class AndroidTtsFactory @Inject constructor( + @param:ApplicationContext private val context: Context +) : TtsFactory { + + override fun create(onInitListener: TextToSpeech.OnInitListener, engine: String?): TextToSpeech { + return TextToSpeech(context, onInitListener, engine) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/MapsTtsObserver.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/MapsTtsObserver.kt new file mode 100644 index 0000000..9d511f7 --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/MapsTtsObserver.kt @@ -0,0 +1,133 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.data.tts + +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import com.stadiamaps.ferrostar.core.AndroidTtsStatusListener +import com.stadiamaps.ferrostar.core.SpokenInstructionObserver +import earth.maps.cardinal.data.audio.MapsAudioFocusController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import uniffi.ferrostar.SpokenInstruction + +class MapsTtsObserver( + private val ttsFactory: TtsFactory, + private val audioFocusController: MapsAudioFocusController, + private val engine: String? = null, + private val statusObserver: AndroidTtsStatusListener? = null +) : SpokenInstructionObserver, TextToSpeech.OnInitListener { + + private val pendingInstructions = ArrayDeque() + private val _muteState = MutableStateFlow(false) + override val muteState: StateFlow = _muteState.asStateFlow() + private var tts: TextToSpeech? = null + private var initStatus: Int? = null + val isInitializedSuccessfully: Boolean + get() = initStatus == TextToSpeech.SUCCESS + + fun start() { + if (tts != null) return + tts = ttsFactory.create(this, engine) + } + + override fun onInit(status: Int) { + initStatus = status + + if (status != TextToSpeech.SUCCESS) { + statusObserver?.onTtsInitialized(null, status) + shutdown() + return + } + + tts?.setOnUtteranceProgressListener(progressListener) + statusObserver?.onTtsInitialized(tts, status) + flushPendingInstructions() + } + + override fun setMuted(isMuted: Boolean) { + _muteState.value = isMuted + if (isMuted) stopAndClearQueue() + } + + override fun onSpokenInstructionTrigger(spokenInstruction: SpokenInstruction) { + val tts = tts ?: return + if (!isInitializedSuccessfully) { + if (pendingInstructions.isNotEmpty()) { + pendingInstructions.clear() + } + pendingInstructions.add(spokenInstruction) + return + } + + if (isMuted) return + + if (!audioFocusController.requestFocus()) return + + val utteranceId = spokenInstruction.utteranceId.toString() + + val result = tts.speak( + spokenInstruction.text, + TextToSpeech.QUEUE_FLUSH, + null, + utteranceId + ) + + if (result != TextToSpeech.SUCCESS) { + audioFocusController.abandonFocus() + statusObserver?.onTtsSpeakError(utteranceId, result) + } + } + + private fun flushPendingInstructions() { + while (pendingInstructions.isNotEmpty()) { + val instruction = pendingInstructions.removeFirst() + onSpokenInstructionTrigger(instruction) + } + } + + override fun stopAndClearQueue() { + tts?.stop() + audioFocusController.abandonFocus() + } + + fun shutdown() { + stopAndClearQueue() + tts?.shutdown() + tts = null + statusObserver?.onTtsShutdownAndRelease() + } + + private val progressListener = object : UtteranceProgressListener() { + + override fun onStart(utteranceId: String?) { + // No-op + } + + override fun onDone(utteranceId: String?) { + audioFocusController.abandonFocus() + } + + @Deprecated("Deprecated in Java") + override fun onError(utteranceId: String?) { + audioFocusController.abandonFocus() + } + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/TtsFactory.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/TtsFactory.kt new file mode 100644 index 0000000..fceb2fd --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/TtsFactory.kt @@ -0,0 +1,25 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.data.tts + +import android.speech.tts.TextToSpeech + +interface TtsFactory { + fun create(onInitListener: TextToSpeech.OnInitListener, engine: String? = null): TextToSpeech +} \ No newline at end of file diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/di/TtsModule.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/TtsModule.kt new file mode 100644 index 0000000..7c6d408 --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/di/TtsModule.kt @@ -0,0 +1,60 @@ +/* + * Cardinal Maps + * Copyright (C) 2025 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.di + +import android.content.Context +import com.stadiamaps.ferrostar.core.SpokenInstructionObserver +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import earth.maps.cardinal.data.audio.MapsAudioFocusController +import earth.maps.cardinal.data.tts.AndroidTtsFactory +import earth.maps.cardinal.data.tts.MapsTtsObserver +import earth.maps.cardinal.data.tts.TtsFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object TtsModule { + + @Provides + fun provideTtsFactory( + @ApplicationContext context: Context + ): TtsFactory { + return AndroidTtsFactory(context) + } + + @Provides + fun provideMapsAudioFocusController( + @ApplicationContext context: Context + ): MapsAudioFocusController { + return MapsAudioFocusController(context) + } + + @Provides + @Singleton + fun provideSpokenInstructionObserver( + ttsFactory: TtsFactory, + mapsAudioFocusController: MapsAudioFocusController + ): SpokenInstructionObserver { + return MapsTtsObserver(ttsFactory = ttsFactory, audioFocusController = mapsAudioFocusController) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapper.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapper.kt index be479d0..ae1b001 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapper.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapper.kt @@ -20,10 +20,10 @@ package earth.maps.cardinal.routing import android.content.Context import com.stadiamaps.ferrostar.composeui.notification.DefaultForegroundNotificationBuilder -import com.stadiamaps.ferrostar.core.AndroidTtsObserver import com.stadiamaps.ferrostar.core.FerrostarCore import com.stadiamaps.ferrostar.core.LocationProvider import com.stadiamaps.ferrostar.core.LocationUpdateListener +import com.stadiamaps.ferrostar.core.SpokenInstructionObserver import com.stadiamaps.ferrostar.core.http.OkHttpClientProvider import com.stadiamaps.ferrostar.core.service.FerrostarForegroundServiceManager import com.stadiamaps.ferrostar.core.service.ForegroundServiceManager @@ -57,7 +57,7 @@ class FerrostarWrapper( private val orientationRepository: OrientationRepository, private val mode: RoutingMode, private val localValhallaEndpoint: String, - private val androidTtsObserver: AndroidTtsObserver, + private val androidTtsObserver: SpokenInstructionObserver, routingProfileRepository: RoutingProfileRepository, routingOptions: RoutingOptions? = null ) { diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapperRepository.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapperRepository.kt index 36fa3bd..d1c4c49 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapperRepository.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/routing/FerrostarWrapperRepository.kt @@ -19,12 +19,13 @@ package earth.maps.cardinal.routing import android.content.Context -import com.stadiamaps.ferrostar.core.AndroidTtsObserver +import com.stadiamaps.ferrostar.core.SpokenInstructionObserver import dagger.hilt.android.qualifiers.ApplicationContext import earth.maps.cardinal.data.LocationRepository import earth.maps.cardinal.data.OrientationRepository import earth.maps.cardinal.data.RoutingMode import earth.maps.cardinal.data.room.RoutingProfileRepository +import earth.maps.cardinal.data.tts.MapsTtsObserver import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter @@ -38,7 +39,8 @@ class FerrostarWrapperRepository @Inject constructor( private val context: Context, private val locationRepository: LocationRepository, private val orientationRepository: OrientationRepository, - private val routingProfileRepository: RoutingProfileRepository + private val routingProfileRepository: RoutingProfileRepository, + private val androidTtsObserver: SpokenInstructionObserver ) { private val _isInitialized = MutableStateFlow(false) val isInitialized = _isInitialized.asStateFlow() @@ -59,8 +61,6 @@ class FerrostarWrapperRepository @Inject constructor( private val pendingOptions = mutableMapOf() - val androidTtsObserver = AndroidTtsObserver(context) - /** * Suspends the caller until the repository has been initialized with a Valhalla endpoint. * @@ -71,6 +71,14 @@ class FerrostarWrapperRepository @Inject constructor( _isInitialized.filter { it }.first() } + fun onStartNavigation() { + (androidTtsObserver as? MapsTtsObserver)?.start() + } + + fun onStopNavigation() { + (androidTtsObserver as? MapsTtsObserver)?.shutdown() + } + fun setValhallaEndpoint(endpoint: String) { _walking = FerrostarWrapper( context, diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationScreen.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationScreen.kt index 02b1ec0..a1b57e2 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationScreen.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationScreen.kt @@ -1,6 +1,6 @@ /* * Cardinal Maps - * Copyright (C) 2025 Cardinal Maps Authors + * Copyright (C) 2026 Cardinal Maps Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -29,7 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.stadiamaps.ferrostar.core.DefaultNavigationViewModel import com.stadiamaps.ferrostar.maplibreui.views.DynamicallyOrientingNavigationView import earth.maps.cardinal.data.RoutingMode @@ -85,10 +85,12 @@ fun TurnByTurnNavigationScreen( // Start navigation when a route is provided DisposableEffect(route) { route?.let { + turnByTurnViewModel.onEvent(TurnByTurnNavigationUiEvent.OnStartNavigation) ferrostarCore.startNavigation(route = it) } onDispose { route?.let { + turnByTurnViewModel.onEvent(TurnByTurnNavigationUiEvent.OnStopNavigation) ferrostarCore.stopNavigation() } } diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationUiEvent.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationUiEvent.kt new file mode 100644 index 0000000..dff9ddd --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationUiEvent.kt @@ -0,0 +1,24 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.ui.navigation + +sealed interface TurnByTurnNavigationUiEvent { + object OnStartNavigation : TurnByTurnNavigationUiEvent + object OnStopNavigation : TurnByTurnNavigationUiEvent +} \ No newline at end of file diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModel.kt index 5cb8f7b..e27d99f 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModel.kt @@ -1,6 +1,6 @@ /* * Cardinal Maps - * Copyright (C) 2025 Cardinal Maps Authors + * Copyright (C) 2026 Cardinal Maps Authors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -26,4 +26,12 @@ import javax.inject.Inject @HiltViewModel class TurnByTurnNavigationViewModel @Inject constructor( val ferrostarWrapperRepository: FerrostarWrapperRepository -) : ViewModel() \ No newline at end of file +) : ViewModel() { + + fun onEvent(uiEvent: TurnByTurnNavigationUiEvent) { + when (uiEvent) { + TurnByTurnNavigationUiEvent.OnStartNavigation -> ferrostarWrapperRepository.onStartNavigation() + TurnByTurnNavigationUiEvent.OnStopNavigation -> ferrostarWrapperRepository.onStopNavigation() + } + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/data/tts/MapsTtsObserverTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/tts/MapsTtsObserverTest.kt new file mode 100644 index 0000000..7e90f11 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/data/tts/MapsTtsObserverTest.kt @@ -0,0 +1,149 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.data.tts + +import android.speech.tts.TextToSpeech +import com.stadiamaps.ferrostar.core.AndroidTtsStatusListener +import earth.maps.cardinal.data.audio.MapsAudioFocusController +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import uniffi.ferrostar.SpokenInstruction +import java.util.UUID + +class MapsTtsObserverTest { + + private val ttsFactory = mockk() + private val audioFocus = mockk(relaxed = true) + private val tts = mockk(relaxed = true) + private val statusObserver = mockk(relaxed = true) + + private lateinit var observer: MapsTtsObserver + + @Before + fun setup() { + every { ttsFactory.create(any(), any()) } returns tts + + observer = MapsTtsObserver( + ttsFactory = ttsFactory, + audioFocusController = audioFocus, + statusObserver = statusObserver + ) + } + + @Test + fun `start should create TTS instance`() { + observer.start() + + verify { ttsFactory.create(observer, null) } + } + + @Test + fun `should buffer instruction when TTS not initialized`() { + observer.start() + + val instruction = instruction(text = "Turn left") + + observer.onSpokenInstructionTrigger(instruction) + + verify(exactly = 0) { tts.speak(any(), any(), any(), any()) } + verify(exactly = 0) { audioFocus.requestFocus() } + } + + @Test + fun `should flush buffered instruction after init`() { + observer.start() + + val id = UUID.randomUUID() + val inst = instruction("Turn right", id) + + observer.onSpokenInstructionTrigger(inst) + + every { audioFocus.requestFocus() } returns true + every { tts.speak(any(), any(), any(), any()) } returns TextToSpeech.SUCCESS + + observer.onInit(TextToSpeech.SUCCESS) + + verify { audioFocus.requestFocus() } + verify { + tts.speak( + "Turn right", + TextToSpeech.QUEUE_ADD, + null, + id.toString() + ) + } + } + + @Test + fun `should request focus and speak`() { + observer.start() + observer.onInit(TextToSpeech.SUCCESS) + + val id = UUID.randomUUID() + val inst = instruction("Go straight", id) + + every { audioFocus.requestFocus() } returns true + every { tts.speak(any(), any(), any(), any()) } returns TextToSpeech.SUCCESS + + observer.onSpokenInstructionTrigger(inst) + + verify { audioFocus.requestFocus() } + + verify { + tts.speak( + "Go straight", + TextToSpeech.QUEUE_ADD, + null, + id.toString() + ) + } + } + + @Test + fun `should abandon focus on speak error`() { + observer.start() + observer.onInit(TextToSpeech.SUCCESS) + + val id = UUID.randomUUID() + val inst = instruction("Error test", id) + + every { audioFocus.requestFocus() } returns true + every { tts.speak(any(), any(), any(), any()) } returns TextToSpeech.ERROR + + observer.onSpokenInstructionTrigger(inst) + + verify { audioFocus.abandonFocus() } + verify { statusObserver.onTtsSpeakError(id.toString(), TextToSpeech.ERROR) } + } + + private fun instruction( + text: String, + id: UUID = UUID.randomUUID() + ): SpokenInstruction { + return SpokenInstruction( + text = text, + ssml = null, + triggerDistanceBeforeManeuver = 10.0, + utteranceId = id + ) + } +} \ No newline at end of file diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModelTest.kt new file mode 100644 index 0000000..192309f --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModelTest.kt @@ -0,0 +1,63 @@ +package earth.maps.cardinal.ui.navigation + +import earth.maps.cardinal.routing.FerrostarWrapperRepository +import io.mockk.confirmVerified +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.Before +import org.junit.Test + +class TurnByTurnNavigationViewModelTest { + + private val repository = mockk(relaxed = true) + + private lateinit var viewModel: TurnByTurnNavigationViewModel + + @Before + fun setup() { + viewModel = TurnByTurnNavigationViewModel(repository) + } + + @Test + fun `onEvent OnStartNavigation should call repository start`() { + viewModel.onEvent(TurnByTurnNavigationUiEvent.OnStartNavigation) + + verify(exactly = 1) { + repository.onStartNavigation() + } + + verify(exactly = 0) { + repository.onStopNavigation() + } + } + + @Test + fun `onEvent OnStopNavigation should call repository stop`() { + viewModel.onEvent(TurnByTurnNavigationUiEvent.OnStopNavigation) + + verify(exactly = 1) { + repository.onStopNavigation() + } + + verify(exactly = 0) { + repository.onStartNavigation() + } + } + + @Test + fun `should handle multiple events correctly`() { + viewModel.onEvent(TurnByTurnNavigationUiEvent.OnStartNavigation) + viewModel.onEvent(TurnByTurnNavigationUiEvent.OnStopNavigation) + + verifyOrder { + repository.onStartNavigation() + repository.onStopNavigation() + } + } + + @Test + fun `should not call any repository method without events`() { + confirmVerified(repository) + } +} \ No newline at end of file -- GitLab From 04f91521a862d46904ef1cead490b0da1b85468d Mon Sep 17 00:00:00 2001 From: mitulsheth Date: Wed, 8 Apr 2026 15:52:29 +0530 Subject: [PATCH 2/3] fix(bug): Fix the audio queue issue --- .../main/java/earth/maps/cardinal/data/tts/MapsTtsObserver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/MapsTtsObserver.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/MapsTtsObserver.kt index 9d511f7..66dc2c1 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/MapsTtsObserver.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/data/tts/MapsTtsObserver.kt @@ -85,7 +85,7 @@ class MapsTtsObserver( val result = tts.speak( spokenInstruction.text, - TextToSpeech.QUEUE_FLUSH, + TextToSpeech.QUEUE_ADD, null, utteranceId ) -- GitLab From ff8d03eede170985c558495f759c1afd39e1f189 Mon Sep 17 00:00:00 2001 From: mitulsheth Date: Wed, 8 Apr 2026 17:42:35 +0530 Subject: [PATCH 3/3] fix(bug): Fix missing License comment --- .../TurnByTurnNavigationViewModelTest.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModelTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModelTest.kt index 192309f..b0d61bd 100644 --- a/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModelTest.kt +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModelTest.kt @@ -1,3 +1,21 @@ +/* + * Cardinal Maps + * Copyright (C) 2026 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package earth.maps.cardinal.ui.navigation import earth.maps.cardinal.routing.FerrostarWrapperRepository -- GitLab