Skip to content

Commit

Permalink
Fix/android/tts reinit for onstart (#411)
Browse files Browse the repository at this point in the history
* fix: corrects abandoned null android tts observer

* fix: corrects null android TTS after destroy

* Update guide/src/android-getting-started.md

---------

Co-authored-by: Ian Wagner <[email protected]>
  • Loading branch information
Archdoog and ianthetechie authored Jan 7, 2025
1 parent 3a27078 commit 772d778
Show file tree
Hide file tree
Showing 6 changed files with 269 additions and 12 deletions.
3 changes: 3 additions & 0 deletions android/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ dependencies {
implementation 'net.java.dev.jna:jna:5.15.0@aar'

testImplementation libs.junit
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.mockk
testImplementation libs.turbine

// These probably shouldn't have to be androidTestImplementation... see rant in
// ValhallaCoreTest.kt
Expand Down
52 changes: 44 additions & 8 deletions android/core/src/main/java/com/stadiamaps/ferrostar/core/Speech.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ package com.stadiamaps.ferrostar.core
import android.content.Context
import android.speech.tts.TextToSpeech
import android.speech.tts.TextToSpeech.OnInitListener
import androidx.annotation.VisibleForTesting
import java.lang.ref.WeakReference
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import uniffi.ferrostar.SpokenInstruction

interface SpokenInstructionObserver {

/**
* Handles spoken instructions as they are triggered.
*
Expand Down Expand Up @@ -39,6 +42,14 @@ interface AndroidTtsStatusListener {
*/
fun onTtsInitialized(tts: TextToSpeech?, status: Int)

/**
* Invoked when the [TextToSpeech] instance is shut down and released to nil.
*
* After this point you must initialize a new instance to use TTS by calling
* [AndroidTtsObserver.start].
*/
fun onTtsShutdownAndRelease()

/**
* Invoked whenever [TextToSpeech.speak] returns a status code other than [TextToSpeech.SUCCESS].
*/
Expand All @@ -64,7 +75,8 @@ interface AndroidTtsStatusListener {
*/
class AndroidTtsObserver(
context: Context,
engine: String? = null,
private val weakContext: WeakReference<Context> = WeakReference(context),
private val engine: String? = null,
var statusObserver: AndroidTtsStatusListener? = null,
) : SpokenInstructionObserver, OnInitListener {
companion object {
Expand All @@ -73,6 +85,9 @@ class AndroidTtsObserver(

private var _muteState: MutableStateFlow<Boolean> = MutableStateFlow(false)

private val context: Context?
get() = weakContext.get()

override fun setMuted(isMuted: Boolean) {
_muteState.update { _ ->
if (isMuted && tts?.isSpeaking == true) {
Expand All @@ -84,7 +99,7 @@ class AndroidTtsObserver(

override val muteState: StateFlow<Boolean> = _muteState.asStateFlow()

var tts: TextToSpeech?
var tts: TextToSpeech? = null
private set

/**
Expand All @@ -104,8 +119,23 @@ class AndroidTtsObserver(
val isInitializedSuccessfully: Boolean
get() = initStatus == TextToSpeech.SUCCESS

init {
tts = TextToSpeech(context, this, engine)
/**
* Starts a new [TextToSpeech] instance.
*
* Except [onInit] to fire when the engine is ready.
*/
fun start(@VisibleForTesting injectedTts: TextToSpeech? = null) {
if (context == null) {
android.util.Log.e(TAG, "Context is null. Unable to start TTS.")
return
}

if (tts != null) {
android.util.Log.e(TAG, "TTS engine is already initialized.")
return
}

tts = injectedTts ?: TextToSpeech(context, this, engine)
}

/**
Expand All @@ -114,8 +144,13 @@ class AndroidTtsObserver(
* Fails silently if TTS is unavailable.
*/
override fun onSpokenInstructionTrigger(spokenInstruction: SpokenInstruction) {
val tts = tts
if (tts != null && isInitializedSuccessfully && !isMuted) {
if (tts == null) {
android.util.Log.e(TAG, "TTS engine is not initialized.")
return
}
val tts = tts ?: return

if (isInitializedSuccessfully && !isMuted) {
// In the future, someone may wish to parse SSML to get more natural utterances into TtsSpans.
// Amazon Polly is generally the intended target for SSML on Android though.
val status =
Expand Down Expand Up @@ -148,11 +183,12 @@ class AndroidTtsObserver(
/**
* Shuts down the underlying [TextToSpeech] engine.
*
* The instance will no longer be usable after this. This method should usually be called from an
* activity's onDestroy method.
* The instance will be shut down and released after this call. You must call [start] to use TTS
* again. If you call this method, ensure that you've handled an start again.
*/
fun shutdown() {
tts?.shutdown()
tts = null
statusObserver?.onTtsShutdownAndRelease()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package com.stadiamaps.ferrostar.core

import android.content.Context
import android.speech.tts.TextToSpeech
import android.util.Log
import app.cash.turbine.test
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import java.util.UUID
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertNull
import junit.framework.TestCase.assertTrue
import kotlin.uuid.ExperimentalUuidApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import uniffi.ferrostar.SpokenInstruction

class AndroidTextToSpeechTest {

private val engine: String = "ferrostar.test"

private lateinit var context: Context
private lateinit var androidTts: AndroidTtsObserver

@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.e(any(), any()) } returns 0

context = mockk(relaxed = true)
androidTts = AndroidTtsObserver(context = context, engine = engine)
}

@Test
fun testStart() {
val observer = mockk<AndroidTtsStatusListener>()
androidTts.statusObserver = observer
androidTts.start()

assertNotNull(androidTts.tts)
}

@Test
fun `test shutdown`() {
val tts = mockk<TextToSpeech>(relaxed = true)
androidTts.start(tts)
androidTts.shutdown()

verify { tts.shutdown() }
assertNull(androidTts.tts)
}

@Test
fun `test setMuted while speaking`() = runTest {
val tts = mockk<TextToSpeech>(relaxed = true) { every { isSpeaking } returns true }
androidTts.start(tts)
androidTts.setMuted(true)

verify { tts.stop() }

androidTts.muteState.test { assertTrue(awaitItem()) }

androidTts.setMuted(false)

androidTts.muteState.test { assertFalse(awaitItem()) }
}

@Test
fun `test setMuted without speaking`() {
val tts = mockk<TextToSpeech>(relaxed = true) { every { isSpeaking } returns false }
androidTts.start(tts)
androidTts.setMuted(true)

verify(exactly = 0) { tts.stop() }
}

@Test
fun `test stop and clear`() {
val tts = mockk<TextToSpeech>(relaxed = true)
androidTts.start(tts)
androidTts.stopAndClearQueue()

verify { tts.stop() }
}

@OptIn(ExperimentalUuidApi::class)
@Test
fun `test on speak`() {
val tts = mockk<TextToSpeech>(relaxed = true)
androidTts.start(tts)
androidTts.setMuted(false)
androidTts.onInit(TextToSpeech.SUCCESS)

val uuid = UUID.randomUUID()
val instruction =
mockk<SpokenInstruction> {
every { text } returns "Hello, World!"
every { utteranceId } returns uuid
}

androidTts.onSpokenInstructionTrigger(instruction)

verify { tts.speak("Hello, World!", TextToSpeech.QUEUE_ADD, any(), uuid.toString()) }
}

@OptIn(ExperimentalUuidApi::class)
@Test
fun `test on speak error`() {
val tts = mockk<TextToSpeech>(relaxed = true)
every { tts.speak(any(), any(), any(), any()) } returns TextToSpeech.ERROR

androidTts.start(tts)
androidTts.setMuted(false)
androidTts.onInit(TextToSpeech.SUCCESS)

val observer = mockk<AndroidTtsStatusListener>()
every { observer.onTtsSpeakError(any(), any()) } returns Unit

androidTts.statusObserver = observer

val uuid = UUID.randomUUID()
val instruction =
mockk<SpokenInstruction> {
every { text } returns "Hello, World!"
every { utteranceId } returns uuid
}

androidTts.onSpokenInstructionTrigger(instruction)

verify { Log.e(any(), any()) }
verify { observer.onTtsSpeakError(uuid.toString(), TextToSpeech.ERROR) }
}

@OptIn(ExperimentalUuidApi::class)
@Test
fun `test on speech while muted`() {
val tts = mockk<TextToSpeech>(relaxed = true)
androidTts.start(tts)
androidTts.setMuted(true)
androidTts.onInit(TextToSpeech.SUCCESS)

val uuid = UUID.randomUUID()
val instruction =
mockk<SpokenInstruction> {
every { text } returns "Hello, World!"
every { utteranceId } returns uuid
}

androidTts.onSpokenInstructionTrigger(instruction)

verify(exactly = 0) {
tts.speak("Hello, World!", TextToSpeech.QUEUE_ADD, any(), uuid.toString())
}
}

@OptIn(ExperimentalUuidApi::class)
@Test
fun `test on speech when not successful`() {
val tts = mockk<TextToSpeech>(relaxed = true)
androidTts.start(tts)
androidTts.setMuted(false)
androidTts.onInit(TextToSpeech.ERROR)

val uuid = UUID.randomUUID()
val instruction =
mockk<SpokenInstruction> {
every { text } returns "Hello, World!"
every { utteranceId } returns uuid
}

androidTts.onSpokenInstructionTrigger(instruction)

verify { Log.e(any(), any()) }
verify(exactly = 0) {
tts.speak("Hello, World!", TextToSpeech.QUEUE_ADD, any(), uuid.toString())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ class MainActivity : ComponentActivity(), AndroidTtsStatusListener {
AppModule.ttsObserver.shutdown()
}

override fun onStart() {
super.onStart()

// Start the TTS engine. This ensures that after onDestroy, a new instance is created.
AppModule.ttsObserver.start()
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

Expand Down Expand Up @@ -70,7 +77,7 @@ class MainActivity : ComponentActivity(), AndroidTtsStatusListener {
override fun onTtsInitialized(tts: TextToSpeech?, status: Int) {
// Set this up as appropriate for your app
if (tts != null) {
tts.setLanguage(Locale.US)
tts.language = Locale.US
android.util.Log.i(TAG, "setLanguage status: $status")
} else {
android.util.Log.e(TAG, "TTS setup failed! $status")
Expand All @@ -81,4 +88,9 @@ class MainActivity : ComponentActivity(), AndroidTtsStatusListener {
android.util.Log.e(
TAG, "Something went wrong synthesizing utterance $utteranceId. Status code: $status.")
}

override fun onTtsShutdownAndRelease() {
android.util.Log.i(
TAG, "TTS shutdown and release. After this point you must call start() again.")
}
}
11 changes: 9 additions & 2 deletions android/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ junitVersion = "1.2.1"
junitCompose = "1.7.5"
espressoCore = "3.6.1"
okhttp-mock = "2.0.0"
mockk = "1.13.14"
turbine = "1.2.0"
mavenPublish = "0.30.0"
material = "1.12.0"
stadiaAutocompleteSearch = "1.0.0"
Expand All @@ -30,7 +32,6 @@ desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref
# Kotlin & KotlinX
kotlin-bom = { group = "org.jetbrains.kotlin", name = "kotlin-bom", version.ref = "kotlin" }
kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinx-datetime" }
# AndroidX
androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" }
Expand All @@ -49,6 +50,8 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icon-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
# Material
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
# OkHttp & Moshi
okhttp-bom = { group = "com.squareup.okhttp3", name = "okhttp-bom", version.ref = "okhttp" }
okhttp-core = { group = "com.squareup.okhttp3", name = "okhttp" }
Expand All @@ -64,7 +67,11 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-test-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "junitCompose" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }

# Demo App
stadiamaps-autocomplete-search = { group = "com.stadiamaps", name = "jetpack-compose-autocomplete", version.ref = "stadiaAutocompleteSearch" }

[plugins]
Expand Down
Loading

0 comments on commit 772d778

Please sign in to comment.