fix(reaction): Honor One-Pod Mode in CA resume worn-guard

This commit is contained in:
darken
2026-06-18 12:07:19 +02:00
committed by Matthias Urhahn
parent 49bbf1bc85
commit e9b0694b67
2 changed files with 91 additions and 1 deletions
@@ -284,7 +284,7 @@ class ConversationReaction @Inject constructor(
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
primary?.address != record.owner ->
log(TAG) { "$reason — resume skipped (primary switched)" }
primary.isBeingWorn == false ->
wornForResume(primary) == false ->
log(TAG) { "$reason — resume skipped (not worn)" }
mediaControl.isPlaying ->
log(TAG) { "$reason — resume skipped (already playing)" }
@@ -409,6 +409,17 @@ class ConversationReaction @Inject constructor(
disengage(record, primary, reason, applyAgeGuard = phase == TimerPhase.STALE_BACKSTOP)
}
/**
* Whether the pod is worn enough to resume into, honoring One-Pod Mode (mirrors
* [eu.darken.capod.reaction.core.autoconnect.AutoConnect]): with One-Pod Mode on, a single pod
* in ear counts as worn, falling back to the both-pods reading only when the single-pod signal is
* unknown. Returns `null` when ear state is genuinely unknown — the caller treats only an explicit
* `false` as not-worn, so an unknown stays lenient (resumes).
*/
private fun wornForResume(device: PodDevice): Boolean? =
if (device.reactions.onePodMode) device.isEitherPodInEar ?: device.isBeingWorn
else device.isBeingWorn
/** Must be called under [mutex]. True if [address] had an AAP ear-detection change very recently. */
private fun isRecentEarTransition(address: BluetoothAddress): Boolean {
val at = earTransitionAt[address] ?: return false
@@ -56,14 +56,18 @@ class ConversationReactionTest : BaseTest() {
action: ConversationAction,
reduction: Int = 50,
worn: Boolean = true,
onePodMode: Boolean = false,
eitherInEar: Boolean? = worn,
): PodDevice = mockk(relaxed = true) {
every { profileId } returns address
every { this@mockk.address } returns address
every { reactions } returns ReactionConfig(
conversationAction = action,
conversationVolumeReduction = reduction,
onePodMode = onePodMode,
)
every { isBeingWorn } returns worn
every { isEitherPodInEar } returns eitherInEar
}
@BeforeEach
@@ -286,6 +290,81 @@ class ConversationReactionTest : BaseTest() {
job.cancel()
}
@Test
fun `PAUSE one-pod mode resumes via wind-down fuse when the single worn pod drops the terminal`() =
runTest(UnconfinedTestDispatcher()) {
// #608 single-pod wear: both-pods isBeingWorn is false, but One-Pod Mode means the single
// in-ear pod counts as worn, so the fuse-driven resume must fire. Was skipped as "not worn"
// because the guard read isBeingWorn (both pods) instead of honoring One-Pod Mode.
devicesFlow.value = listOf(
mockPodDevice(
primaryAddress,
ConversationAction.PAUSE,
worn = false,
onePodMode = true,
eitherInEar = true,
),
)
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
coVerify(exactly = 1) { mediaControl.sendPause(false) }
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // wind-down begins, terminal dropped
advanceTimeBy(windDownTimeoutMs + 500)
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `PAUSE one-pod mode stays paused via fuse when no pod is worn`() = runTest(UnconfinedTestDispatcher()) {
// One-Pod Mode on, but neither pod in ear (both isBeingWorn and isEitherPodInEar false) — the
// single-pod leniency must NOT resume into pods that are out.
devicesFlow.value = listOf(
mockPodDevice(
primaryAddress,
ConversationAction.PAUSE,
worn = false,
onePodMode = true,
eitherInEar = false,
),
)
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.HOLD)
advanceTimeBy(windDownTimeoutMs + 500)
runCurrent()
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `PAUSE one-pod mode resumes on terminal after wind-down with a single worn pod`() =
runTest(UnconfinedTestDispatcher()) {
// The timerPhase==WIND_DOWN_FUSE branch disengages immediately on the explicit terminal
// (no STOP_SETTLE). Verify the One-Pod Mode worn check applies on that shared path too.
devicesFlow.value = listOf(
mockPodDevice(
primaryAddress,
ConversationAction.PAUSE,
worn = false,
onePodMode = true,
eitherInEar = true,
),
)
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // wind-down → fuse armed
emit(primaryAddress, ConversationAwarenessEvent.STOP) // real terminal → immediate disengage
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `PAUSE stop does not resume when something is already playing`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))