fix(android): cancel in-flight talk playback on stop

This commit is contained in:
Ayaan Zaidi
2026-04-05 08:40:04 +05:30
parent f0c970fb43
commit 8d1f9ab5b8
2 changed files with 100 additions and 5 deletions

View File

@@ -266,7 +266,6 @@ class TalkModeManager(
if (playbackEnabled == enabled) return
playbackEnabled = enabled
if (!enabled) {
playbackGeneration.incrementAndGet()
stopSpeaking()
}
}
@@ -742,6 +741,7 @@ class TalkModeManager(
ttsJob
}
activeJob?.cancel()
talkAudioPlayer.stop()
stopTextToSpeechPlayback()
}
@@ -829,17 +829,16 @@ class TalkModeManager(
}
private fun stopSpeaking(resetInterrupt: Boolean = true) {
playbackGeneration.incrementAndGet()
if (!_isSpeaking.value) {
talkAudioPlayer.stop()
stopTextToSpeechPlayback()
cancelActivePlayback()
abandonAudioFocus()
return
}
if (resetInterrupt) {
lastInterruptedAtSeconds = null
}
talkAudioPlayer.stop()
stopTextToSpeechPlayback()
cancelActivePlayback()
_isSpeaking.value = false
abandonAudioFocus()
}

View File

@@ -0,0 +1,96 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.DeviceAuthEntry
import ai.openclaw.app.gateway.DeviceAuthTokenStore
import ai.openclaw.app.gateway.DeviceIdentityStore
import ai.openclaw.app.gateway.GatewaySession
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class TalkModeManagerTest {
@Test
fun stopTtsCancelsTrackedPlaybackJob() {
val manager = createManager()
val playbackJob = Job()
setPrivateField(manager, "ttsJob", playbackJob)
playbackGeneration(manager).set(7L)
manager.stopTts()
assertTrue(playbackJob.isCancelled)
assertEquals(8L, playbackGeneration(manager).get())
}
@Test
fun disablingPlaybackCancelsTrackedJobOnce() {
val manager = createManager()
val playbackJob = Job()
setPrivateField(manager, "ttsJob", playbackJob)
playbackGeneration(manager).set(11L)
manager.setPlaybackEnabled(false)
assertTrue(playbackJob.isCancelled)
assertEquals(12L, playbackGeneration(manager).get())
}
private fun createManager(): TalkModeManager {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = InMemoryDeviceAuthStore(),
onConnected = { _, _, _ -> },
onDisconnected = {},
onEvent = { _, _ -> },
)
return TalkModeManager(
context = app,
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
session = session,
supportsChatSubscribe = false,
isConnected = { true },
)
}
@Suppress("UNCHECKED_CAST")
private fun playbackGeneration(manager: TalkModeManager): AtomicLong {
return readPrivateField(manager, "playbackGeneration") as AtomicLong
}
private fun setPrivateField(target: Any, name: String, value: Any?) {
val field = target.javaClass.getDeclaredField(name)
field.isAccessible = true
field.set(target, value)
}
private fun readPrivateField(target: Any, name: String): Any? {
val field = target.javaClass.getDeclaredField(name)
field.isAccessible = true
return field.get(target)
}
}
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
override fun loadEntry(deviceId: String, role: String): DeviceAuthEntry? = null
override fun saveToken(deviceId: String, role: String, token: String, scopes: List<String>) = Unit
override fun clearToken(deviceId: String, role: String) = Unit
}