fix(talk): require Android output identity for cancellation

Co-authored-by: Zhilong Zheng <zhengzhilong1115@gmail.com>
This commit is contained in:
Vincent Koc
2026-08-23 13:16:51 +08:00
parent d3edc431fe
commit 3bb4670637
2 changed files with 21 additions and 14 deletions
@@ -2927,7 +2927,7 @@ class TalkModeManager internal constructor(
private suspend fun cancelRealtimeOutput(reason: String): Boolean =
realtimeOutputCancellationMutex.withLock {
val sessionId = realtimeSessionId ?: return@withLock true
val turnId = realtimeOutputTurnId
val turnId = realtimeOutputTurnId?.trim()?.takeIf(String::isNotEmpty) ?: return@withLock false
val clear = CompletableDeferred<String?>()
pendingRealtimeOutputClear = clear
try {
@@ -2935,7 +2935,7 @@ class TalkModeManager internal constructor(
buildJsonObject {
put("sessionId", JsonPrimitive(sessionId))
put("reason", JsonPrimitive(reason))
if (turnId != null) put("turnId", JsonPrimitive(turnId))
put("turnId", JsonPrimitive(turnId))
}
val response = requestGateway("talk.session.cancelOutput", params.toString(), timeoutMs = 5_000)
val result = requireAcceptedRealtimeOutputCancellation(response, turnId)
@@ -2943,8 +2943,7 @@ class TalkModeManager internal constructor(
// The response confirms provider cancellation; clear confirms that the
// old playback boundary reached Android before capture can resume.
val clearedTurnId = withTimeout(2_000) { clear.await() }
val expectedTurnId = turnId ?: result.turnId
check(expectedTurnId == null || clearedTurnId == expectedTurnId) {
check(clearedTurnId == turnId) {
"talk.session.cancelOutput clear turnId did not match"
}
true
@@ -100,18 +100,25 @@ class TalkModeManagerTest {
}
@Test
fun stopTtsCancelsTrackedPlaybackJob() {
val manager = createManager()
val playbackJob = Job()
fun stopTtsWithoutOutputIdentityCancelsPlaybackWithoutReplacingCancellationWaiter() =
runTest {
val manager = createManager(scope = this)
val playbackJob = Job()
val pendingClear = CompletableDeferred<String?>()
setPrivateField(manager, "ttsJob", playbackJob)
playbackGeneration(manager).set(7L)
setPrivateField(manager, "ttsJob", playbackJob)
setPrivateField(manager, "realtimeSessionId", "relay-1")
setPrivateField(manager, "realtimeOutputTurnId", " ")
setPrivateField(manager, "pendingRealtimeOutputClear", pendingClear)
playbackGeneration(manager).set(7L)
manager.stopTts()
manager.stopTts()
runCurrent()
assertTrue(playbackJob.isCancelled)
assertEquals(8L, playbackGeneration(manager).get())
}
assertTrue(playbackJob.isCancelled)
assertEquals(8L, playbackGeneration(manager).get())
assertTrue(readPrivateField(manager, "pendingRealtimeOutputClear") === pendingClear)
}
@Test
fun disablingPlaybackCancelsTrackedJobOnce() {
@@ -829,7 +836,7 @@ class TalkModeManagerTest {
}
@Test
fun unconfirmedOutputCancellationClosesRealtimeRelay() =
fun pushToTalkWithoutOutputIdentityClosesRealtimeRelayWithoutWaitingForClear() =
runTest {
var stoppedByRelay = false
val manager =
@@ -843,6 +850,7 @@ class TalkModeManagerTest {
manager.pauseRealtimeCaptureForPushToTalk("capture-1")
assertNull(readPrivateField(manager, "realtimeSessionId"))
assertNull(readPrivateField(manager, "pendingRealtimeOutputClear"))
val pause = readPrivateField(manager, "realtimeCapturePause")!!
assertEquals("capture-1", readPrivateField(pause, "pttCaptureId"))
assertTrue(readPrivateField(pause, "restartRelay") as Boolean)