diff --git a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt index ff58aa0f..56c09cf7 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt @@ -121,7 +121,7 @@ class ConversationReaction @Inject constructor( if (current != null && current.owner == address) { // Duplicate START for the same speaker — don't re-act, just keep the session alive. // A START also cancels a pending wind-down fuse: the wearer is speaking again. - armDisengageTimer(current, STALE_TIMEOUT) + keepAlive(current, STALE_TIMEOUT) log(TAG) { "START from $address — already active ($action), keep-alive" } return } @@ -180,7 +180,7 @@ class ConversationReaction @Inject constructor( private suspend fun onSpeakingResume(address: BluetoothAddress) = mutex.withLock { val current = active ?: return if (current.owner != address) return - armDisengageTimer(current, STALE_TIMEOUT) + keepAlive(current, STALE_TIMEOUT) log(TAG) { "RESUME from $address — speech resumed, keep-alive" } } @@ -193,7 +193,7 @@ class ConversationReaction @Inject constructor( private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock { val current = active ?: return if (current.owner != address) return - armDisengageTimer(current, WIND_DOWN_TIMEOUT) + keepAlive(current, WIND_DOWN_TIMEOUT) } private suspend fun onSpeakingStop(address: BluetoothAddress) { @@ -278,6 +278,20 @@ class ConversationReaction @Inject constructor( active = null } + /** + * Must be called under [mutex]. Keep-alive for the ongoing session [current]: refresh its + * activity timestamp and (re)arm the disengage timer. The timestamp refresh makes + * [PAUSE_RESUME_WINDOW] measure "time since the last frame" rather than "since engage" — without + * it a conversation longer than the window would fail the resume guard and strand media paused + * when its terminal finally arrives. A genuinely back-stopped session (no frames for the whole + * [STALE_TIMEOUT]) is unaffected: its timestamp is never refreshed, so it still won't auto-resume. + */ + private fun keepAlive(current: Active, timeout: Duration) { + val refreshed = current.copy(at = timeSource.elapsedRealtime()) + active = refreshed + armDisengageTimer(refreshed, timeout) + } + /** * Must be called under [mutex]. (Re)arms the disengage timer for [record] — every frame picks * the fuse matching its meaning: START / RESUME → [STALE_TIMEOUT] (active speech, frames cease diff --git a/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt index 827d4ac2..b6b11704 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt @@ -448,6 +448,32 @@ class ConversationReactionTest : BaseTest() { job.cancel() } + @Test + fun `PAUSE resumes on the terminal even after a conversation longer than the resume window`() = + runTest(UnconfinedTestDispatcher()) { + // Each keep-alive frame refreshes the activity timestamp, so PAUSE_RESUME_WINDOW is + // measured from the last frame — not from engage. Without that refresh a 3-minute + // conversation would fail the age guard and strand media paused on its real terminal. + // Advance BOTH clocks so the window is genuinely exercised (the TestTimeSource drives age). + devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE)) + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + coVerify(exactly = 1) { mediaControl.sendPause(false) } + + repeat(3) { + timeSource.advanceBy(java.time.Duration.ofSeconds(60)) + advanceTimeBy(60_000) // < STALE_TIMEOUT, so the backstop never fires + runCurrent() + emit(primaryAddress, ConversationAwarenessEvent.RESUME) // keep-alive, refreshes `at` + } + coVerify(exactly = 0) { mediaControl.sendPlay() } // 3 min in, still paused + + emit(primaryAddress, ConversationAwarenessEvent.STOP) // terminal right after last activity + coVerify(exactly = 1) { mediaControl.sendPlay() } + job.cancel() + } + @Test fun `RESUME without a prior start is a no-op`() = runTest(UnconfinedTestDispatcher()) { devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))