mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 11:16:12 -04:00
fix(reaction): Refresh CA keep-alive timestamp so long conversations resume
This commit is contained in:
+17
-3
@@ -121,7 +121,7 @@ class ConversationReaction @Inject constructor(
|
|||||||
if (current != null && current.owner == address) {
|
if (current != null && current.owner == address) {
|
||||||
// Duplicate START for the same speaker — don't re-act, just keep the session alive.
|
// 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.
|
// 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" }
|
log(TAG) { "START from $address — already active ($action), keep-alive" }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ class ConversationReaction @Inject constructor(
|
|||||||
private suspend fun onSpeakingResume(address: BluetoothAddress) = mutex.withLock {
|
private suspend fun onSpeakingResume(address: BluetoothAddress) = mutex.withLock {
|
||||||
val current = active ?: return
|
val current = active ?: return
|
||||||
if (current.owner != address) return
|
if (current.owner != address) return
|
||||||
armDisengageTimer(current, STALE_TIMEOUT)
|
keepAlive(current, STALE_TIMEOUT)
|
||||||
log(TAG) { "RESUME from $address — speech resumed, keep-alive" }
|
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 {
|
private suspend fun onSpeakingHold(address: BluetoothAddress) = mutex.withLock {
|
||||||
val current = active ?: return
|
val current = active ?: return
|
||||||
if (current.owner != address) return
|
if (current.owner != address) return
|
||||||
armDisengageTimer(current, WIND_DOWN_TIMEOUT)
|
keepAlive(current, WIND_DOWN_TIMEOUT)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun onSpeakingStop(address: BluetoothAddress) {
|
private suspend fun onSpeakingStop(address: BluetoothAddress) {
|
||||||
@@ -278,6 +278,20 @@ class ConversationReaction @Inject constructor(
|
|||||||
active = null
|
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
|
* 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
|
* the fuse matching its meaning: START / RESUME → [STALE_TIMEOUT] (active speech, frames cease
|
||||||
|
|||||||
+26
@@ -448,6 +448,32 @@ class ConversationReactionTest : BaseTest() {
|
|||||||
job.cancel()
|
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
|
@Test
|
||||||
fun `RESUME without a prior start is a no-op`() = runTest(UnconfinedTestDispatcher()) {
|
fun `RESUME without a prior start is a no-op`() = runTest(UnconfinedTestDispatcher()) {
|
||||||
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
|
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
|
||||||
|
|||||||
Reference in New Issue
Block a user