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 9d47b7491b7e8438984bf13d43da381771053aea..b38c8bdca5b91af9c08765806065945cd804abba 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 0000000000000000000000000000000000000000..9ac2acb7511e8dc11b9d889dd729e8b45d9b75f0
--- /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 0000000000000000000000000000000000000000..8806b225395981435d698e9611271aedffd60b15
--- /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 0000000000000000000000000000000000000000..66dc2c174b7bd30885da59c245f5d6a4bb005e4d
--- /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_ADD,
+ 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 0000000000000000000000000000000000000000..fceb2fd63f9342448d706d3bcb5161ac9c3746e4
--- /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 0000000000000000000000000000000000000000..7c6d4084e85e691c71e3433977bd6548a6b8c533
--- /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 be479d000cd9f80bcb61fc5b59839a7e62c4bfee..ae1b0010f1e513cfd66f297d31c975ce2d3414e2 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 36fa3bd91f74807021f3f6902e21be2428932b3e..d1c4c49e0f1494520920f03c00d981afff3be193 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 02b1ec0027368cf0daabf388c2af2a60f26609ee..a1b57e2b36600947d2fb62a8bf91b58d654d86fa 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 0000000000000000000000000000000000000000..dff9dddd97bf6f37e2c9620df0e0c88ebb28d4b6
--- /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 5cb8f7b653e7b07d6cc903f064ff6177e8d59265..e27d99f374d90acadc64f34ed4683dc67ed65963 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 0000000000000000000000000000000000000000..7e90f11429724e0e614aa54ed09b72422356d38b
--- /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 0000000000000000000000000000000000000000..b0d61bdca902c8e36e7a1fafbc39f6eb14681908
--- /dev/null
+++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/ui/navigation/TurnByTurnNavigationViewModelTest.kt
@@ -0,0 +1,81 @@
+/*
+ * 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
+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