mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(reaction): Honor One-Pod Mode in CA resume worn-guard
This commit is contained in:
+12
-1
@@ -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
|
||||
|
||||
+79
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user