From 8d1f9ab5b8be151d1fde4b0c313c6bc996171127 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 5 Apr 2026 08:40:04 +0530 Subject: [PATCH] fix(android): cancel in-flight talk playback on stop --- .../ai/openclaw/app/voice/TalkModeManager.kt | 9 +- .../openclaw/app/voice/TalkModeManagerTest.kt | 96 +++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt index 8f1e0bae3cf..59a1ebec50a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeManager.kt @@ -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() } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt new file mode 100644 index 00000000000..34fb432785b --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/voice/TalkModeManagerTest.kt @@ -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) = Unit + + override fun clearToken(deviceId: String, role: String) = Unit +}