From 67fead358973078dda72d92a37139760aadbd0cd Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 29 Apr 2026 11:03:54 +0200 Subject: [PATCH] fix(reaction): Debounce auto-pause for unauthenticated BLE sources Classifies the ear-detection source (AAP / BLE_IRK_MATCH / BLE_PROFILE_FALLBACK / BLE_ANONYMOUS / NO_LIVE_BLE) and applies a 3-sample debounce only to unauthenticated BLE paths. AAP and IRK-authenticated BLE pass through unchanged. Also tightens toEarDetectionState() to prefer AAP aggregate over BLE per-side bits whenever AAP EarDetection is present, and suppresses pause on NO_LIVE_BLE (cache-only state) to avoid firing without live evidence. --- .../reaction/core/playpause/PlayPause.kt | 260 +++++++++- .../core/playpause/PlayPauseLogicTest.kt | 481 ++++++++++++++++++ 2 files changed, 731 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt index 8816333a..4db379c3 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt @@ -2,6 +2,8 @@ package eu.darken.capod.reaction.core.playpause import eu.darken.capod.common.MediaControl import eu.darken.capod.common.bluetooth.BluetoothManager2 +import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG +import eu.darken.capod.common.debug.logging.Logging.Priority.INFO import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.WARN import eu.darken.capod.common.debug.logging.log @@ -11,6 +13,8 @@ import eu.darken.capod.common.flow.withPrevious import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.primaryDevice +import eu.darken.capod.pods.core.apple.ble.devices.ApplePods +import eu.darken.capod.reaction.core.playpause.PlayPause.Companion.PAUSE_DEBOUNCE_SAMPLES import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.emptyFlow @@ -18,6 +22,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import java.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -30,6 +35,7 @@ class PlayPause @Inject constructor( fun monitor() = run { var pendingPlayConfirmation: PendingPlayConfirmation? = null + var pendingPauseDebounce: PendingPauseDebounce? = null deviceMonitor.primaryDevice() .map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true } @@ -39,12 +45,14 @@ class PlayPause @Inject constructor( bluetoothManager.connectedDevices } else { pendingPlayConfirmation = null + pendingPauseDebounce = null emptyFlow() } } .flatMapLatest { connected -> if (connected.isEmpty()) { pendingPlayConfirmation = null + pendingPauseDebounce = null log(TAG) { "No known devices connected." } emptyFlow() } else { @@ -63,7 +71,13 @@ class PlayPause @Inject constructor( // Use profileId (stable across BLE address rotations) rather than BLE identifier. // Only profiled devices reach this point (outer gate requires profile.autoPlay/autoPause). val match = previous.profileId != null && previous.profileId == current.profileId - if (!match) log(TAG, WARN) { "Main device switched, skipping reaction." } + if (!match) { + log(TAG, WARN) { "Main device switched, skipping reaction." } + if (pendingPauseDebounce != null) { + log(TAG, DEBUG) { "Pause debounce reset: profile change" } + pendingPauseDebounce = null + } + } match } .onEach { (previous, current) -> @@ -76,6 +90,10 @@ class PlayPause @Inject constructor( if (reactions == null) { log(TAG, VERBOSE) { "No reactions on current device, skipping reaction" } pendingPlayConfirmation = null + if (pendingPauseDebounce != null) { + log(TAG, DEBUG) { "Pause debounce reset: no reactions on device" } + pendingPauseDebounce = null + } return@onEach } @@ -109,6 +127,10 @@ class PlayPause @Inject constructor( else -> { log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" } pendingPlayConfirmation = null + if (pendingPauseDebounce != null) { + log(TAG, DEBUG) { "Pause debounce reset: device lost ear detection" } + pendingPauseDebounce = null + } return@onEach } } @@ -151,7 +173,37 @@ class PlayPause @Inject constructor( log(TAG, VERBOSE) { "BLE-only autoplay confirmed by a follow-up state update" } } - val decision = confirmation.decision + val source = current.earDetectionSource() + val debounceResult = applyPauseDebounce( + pending = pendingPauseDebounce, + profileId = current.profileId, + source = source, + rawDecision = confirmation.decision, + currentState = currState, + autoPauseEnabled = reactions.autoPause, + ) + pendingPauseDebounce = debounceResult.pending + + when (debounceResult.event) { + PauseDebounceEvent.STARTED -> log(TAG, DEBUG) { + "Pause debounce started: source=$source, initialPodCount=${debounceResult.pending?.initialPodCount}, " + + "remaining=${debounceResult.pending?.confirmationsRemaining}" + } + PauseDebounceEvent.ADVANCED -> log(TAG, DEBUG) { + "Pause debounce advanced: remaining=${debounceResult.pending?.confirmationsRemaining}, " + + "currentPodCount=${currState.podCount}" + } + PauseDebounceEvent.RESET -> log(TAG, DEBUG) { + "Pause debounce reset: source=$source, currentPodCount=${currState.podCount}, " + + "rawShouldPlay=${confirmation.decision.shouldPlay}" + } + PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) { + "Pause debounce committed: source=$source confirmed pause" + } + PauseDebounceEvent.NONE -> {} + } + + val decision = debounceResult.decision if (decision.usedRecentCapPauseOverride) { log(TAG, VERBOSE) { "Resume override: recent CAP pause window is active, allowing play despite playing=true" @@ -171,8 +223,16 @@ class PlayPause @Inject constructor( } decision.shouldPause && reactions.autoPause -> { - log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" } - mediaControl.sendPause() + val pauseSent = mediaControl.sendPause() + log(TAG, INFO) { + "autoPause triggered: source=$source, " + + "wasWorn=${prevState.bothInEar}, isWorn=${currState.bothInEar}, " + + "podCount=${prevState.podCount}->${currState.podCount}, " + + "bleKeyState=${current.bleKeyState}, " + + "aapEar=${current.aap?.aapEarDetection != null}, " + + "aapConn=${current.aap?.connectionState}, " + + "pauseSent=$pauseSent, reason=${decision.reason}" + } } decision.shouldPause && !reactions.autoPause -> { @@ -319,6 +379,111 @@ class PlayPause @Inject constructor( ) } + /** + * Sample-count debounce for pause decisions when the ear-detection source is an + * unauthenticated BLE advertisement. + * + * RF interference can produce a single corrupt advert that decodes as not-worn, + * triggering a false pause. With [PAUSE_DEBOUNCE_SAMPLES] = 2, a pause requires + * 3 consecutive not-worn samples before firing. + * + * The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause] + * — subsequent samples after the initial detection are not-worn → not-worn, and + * [evaluateNormalMode] returns no action for those. + * + * Confirmation rule: a sample with `currentState.podCount <= pending.initialPodCount` + * counts as a confirmation. An *increase* (pod returned) clears pending. + * + * Trusted sources ([EarDetectionSource.AAP], [EarDetectionSource.BLE_IRK_MATCH]) skip + * debounce and pass through [rawDecision] unchanged; any active [pending] is cleared. + */ + internal fun applyPauseDebounce( + pending: PendingPauseDebounce?, + profileId: String?, + source: EarDetectionSource, + rawDecision: PlayPauseDecision, + currentState: EarDetectionState, + autoPauseEnabled: Boolean, + ): PauseDebounceResult { + val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK || + source == EarDetectionSource.BLE_ANONYMOUS + + val activePending = pending?.takeIf { it.profileId == profileId } + + // NO_LIVE_BLE: no fresh evidence anywhere (ble == null, aap absent or no EarDetection). + // Suppress shouldPause — a worn → not-worn transition derived from null/cached state is + // not real removal evidence. Also clears any active pending since stale samples must not + // advance confirmations. + if (source == EarDetectionSource.NO_LIVE_BLE) { + val event = if (activePending != null) PauseDebounceEvent.RESET else PauseDebounceEvent.NONE + return PauseDebounceResult( + decision = rawDecision.copy( + shouldPause = false, + reason = "${rawDecision.reason} (suppressed: no live BLE evidence)", + ), + pending = null, + event = event, + ) + } + + // Non-debounce-eligible trusted sources (AAP / BLE_IRK_MATCH) and disabled debounce: + // pass raw decision through and clear any active pending. + if (!needsDebounce || !autoPauseEnabled || profileId == null) { + val event = if (activePending != null) PauseDebounceEvent.RESET else PauseDebounceEvent.NONE + return PauseDebounceResult(decision = rawDecision, pending = null, event = event) + } + + // First detection: no active pending, raw decision wants to pause → start debounce. + if (activePending == null) { + if (!rawDecision.shouldPause) { + return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.NONE) + } + if (PAUSE_DEBOUNCE_SAMPLES <= 0) { + return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.NONE) + } + return PauseDebounceResult( + decision = rawDecision.copy( + shouldPause = false, + reason = "${rawDecision.reason} (debouncing, $PAUSE_DEBOUNCE_SAMPLES confirmation(s) needed)", + ), + pending = PendingPauseDebounce( + profileId = profileId, + initialPodCount = currentState.podCount, + confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES, + ), + event = PauseDebounceEvent.STARTED, + ) + } + + // Confirmation phase: pending exists. + // Reset cases: pod returned (count went up) or raw decision wants to play. + if (currentState.podCount > activePending.initialPodCount || rawDecision.shouldPlay) { + return PauseDebounceResult(decision = rawDecision, pending = null, event = PauseDebounceEvent.RESET) + } + + // Confirmation: count <= initialPodCount, decrement remaining. + val remaining = activePending.confirmationsRemaining - 1 + if (remaining <= 0) { + return PauseDebounceResult( + decision = PlayPauseDecision( + shouldPlay = false, + shouldPause = true, + reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})", + ), + pending = null, + event = PauseDebounceEvent.COMMITTED, + ) + } + return PauseDebounceResult( + decision = rawDecision.copy( + shouldPause = false, + reason = "Debouncing pause ($remaining confirmation(s) remaining)", + ), + pending = activePending.copy(confirmationsRemaining = remaining), + event = PauseDebounceEvent.ADVANCED, + ) + } + data class EarDetectionState( val leftInEar: Boolean?, // null for single pod devices val rightInEar: Boolean?, // null for single pod devices @@ -389,6 +554,37 @@ class PlayPause @Inject constructor( val stagedConfirmation: Boolean, ) + /** + * Trust classification for the source of the current ear-detection reading. + * + * - [AAP]: EarDetection setting from an active AAP (L2CAP) session — error-corrected, + * identity-authenticated. Trusted; debounce skipped. + * - [BLE_IRK_MATCH]: BLE advertisement whose RPA was verified against this profile's + * identity key. Identity-authenticated; debounce skipped. + * - [BLE_PROFILE_FALLBACK]: BLE advertisement assigned to a profile via signal-quality + * fallback (no IRK match). Could be a stray advert from a nearby pair. Debounced. + * - [BLE_ANONYMOUS]: BLE advertisement with no profile match. Debounced. + * - [NO_LIVE_BLE]: No live BLE snapshot at all (cache-only or empty). Not debounced — + * the cached state is not "fresh evidence" so it must not advance the debounce counter. + * Any active pending is cleared. + */ + enum class EarDetectionSource { AAP, BLE_IRK_MATCH, BLE_PROFILE_FALLBACK, BLE_ANONYMOUS, NO_LIVE_BLE } + + data class PendingPauseDebounce( + val profileId: String, + val initialPodCount: Int, + val confirmationsRemaining: Int, + ) + + /** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */ + enum class PauseDebounceEvent { NONE, STARTED, ADVANCED, RESET, COMMITTED } + + data class PauseDebounceResult( + val decision: PlayPauseDecision, + val pending: PendingPauseDebounce?, + val event: PauseDebounceEvent = PauseDebounceEvent.NONE, + ) + internal data class PlayPauseMonitorKey( val profileId: String?, val autoPlay: Boolean, @@ -402,10 +598,34 @@ class PlayPause @Inject constructor( val isEitherPodInEar: Boolean?, val hasAapEarDetection: Boolean, val hasBleSnapshot: Boolean, + val source: EarDetectionSource, + // Set only for debounce-eligible sources (BLE_PROFILE_FALLBACK / BLE_ANONYMOUS) so + // that distinctUntilChangedBy doesn't collapse repeated identical not-worn samples + // and the debounce counter can advance. + // + // Caveat: BlePodMonitor.preferCaseContextPod can keep an existing case-context + // snapshot in place over an incoming non-case-context snapshot, preserving the old + // seenLastAt. This is fail-closed (delays a legitimate unauthenticated-BLE pause + // rather than firing a false one), so accepted as a trade-off. + val debounceFreshness: Instant?, ) - internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey = - PlayPauseMonitorKey( + internal fun PodDevice.earDetectionSource(): EarDetectionSource { + if (aap?.aapEarDetection != null) return EarDetectionSource.AAP + if (ble == null) return EarDetectionSource.NO_LIVE_BLE + val applePod = ble as? ApplePods + return when { + applePod?.meta?.isIRKMatch == true -> EarDetectionSource.BLE_IRK_MATCH + ble.meta.profile != null -> EarDetectionSource.BLE_PROFILE_FALLBACK + else -> EarDetectionSource.BLE_ANONYMOUS + } + } + + internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey { + val source = earDetectionSource() + val needsFreshness = source == EarDetectionSource.BLE_PROFILE_FALLBACK || + source == EarDetectionSource.BLE_ANONYMOUS + return PlayPauseMonitorKey( profileId = profileId, autoPlay = reactions.autoPlay, autoPause = reactions.autoPause, @@ -418,20 +638,34 @@ class PlayPause @Inject constructor( isEitherPodInEar = isEitherPodInEar, hasAapEarDetection = aap?.aapEarDetection != null, hasBleSnapshot = ble != null, + source = source, + debounceFreshness = if (needsFreshness) ble?.seenLastAt else null, ) + } /** * Converts a dual-pod [PodDevice] to [EarDetectionState] for reaction evaluation. - * Prefers per-side values (left/right) when available, falls back to AAP aggregate - * state (isBeingWorn/isEitherPodInEar) when per-side mapping is unknown. + * + * When AAP EarDetection is present, prefers AAP aggregate state — even if per-side + * (left/right) values are non-null via BLE fallback. This avoids letting BLE per-side + * bits drive the decision when AAP has authoritative aggregate state but + * resolvedPrimaryPod is unknown (cmd 0x0008 not received or cleared by role swap). + * + * When AAP is absent, prefers per-side values (BLE) when available, falling back to + * AAP aggregate as a last resort (which is identical to BLE aggregate in this case). */ - private fun PodDevice.toEarDetectionState(): EarDetectionState { + internal fun PodDevice.toEarDetectionState(): EarDetectionState { + if (aap?.aapEarDetection != null) { + return EarDetectionState.fromAapAggregate( + isBeingWorn = isBeingWorn ?: false, + isEitherPodInEar = isEitherPodInEar ?: false, + ) + } val left = isLeftInEar val right = isRightInEar if (left != null && right != null) { return EarDetectionState.fromDualPod(left = left, right = right) } - // Per-side unavailable (resolvedPrimaryPod is null) — use aggregate AAP state. return EarDetectionState.fromAapAggregate( isBeingWorn = isBeingWorn ?: false, isEitherPodInEar = isEitherPodInEar ?: false, @@ -440,5 +674,11 @@ class PlayPause @Inject constructor( companion object { private val TAG = logTag("Reaction", "PlayPause") + + /** + * Number of additional confirmations required before an unauthenticated-BLE pause + * decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total. + */ + internal const val PAUSE_DEBOUNCE_SAMPLES = 2 } } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt index fcdb740e..0e3beedc 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt @@ -1,7 +1,14 @@ package eu.darken.capod.reaction.core.playpause +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.reaction.core.playpause.PlayPause.EarDetectionState import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested @@ -969,4 +976,478 @@ class PlayPauseLogicTest : BaseTest() { state.rightInEar shouldBe null } } + + @Nested + inner class PauseDebounceTests { + + private val pauseDecision = PlayPause.PlayPauseDecision( + shouldPlay = false, + shouldPause = true, + reason = "test pause", + ) + + private val playDecision = PlayPause.PlayPauseDecision( + shouldPlay = true, + shouldPause = false, + reason = "test play", + ) + + private val noopDecision = PlayPause.PlayPauseDecision( + shouldPlay = false, + shouldPause = false, + reason = "test no-op", + ) + + @Test + fun `AAP source - debounce is skipped`() { + val result = playPause.applyPauseDebounce( + pending = null, + profileId = "profile", + source = PlayPause.EarDetectionSource.AAP, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, + ) + + result.decision shouldBe pauseDecision + result.pending shouldBe null + } + + @Test + fun `BLE_IRK_MATCH source - debounce is skipped`() { + val result = playPause.applyPauseDebounce( + pending = null, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_IRK_MATCH, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, + ) + + result.decision shouldBe pauseDecision + result.pending shouldBe null + } + + @Test + fun `NO_LIVE_BLE source - clears existing pending without advancing it`() { + // Stale-cache scenario: pending was started by a prior fresh BLE sample, then BLE + // dropped (ble == null). The cached state must NOT count as a confirmation. + val pending = PlayPause.PendingPauseDebounce( + profileId = "profile", + initialPodCount = 0, + confirmationsRemaining = 1, + ) + val result = playPause.applyPauseDebounce( + pending = pending, + profileId = "profile", + source = PlayPause.EarDetectionSource.NO_LIVE_BLE, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, + ) + + result.pending shouldBe null + result.decision.shouldPause shouldBe false + result.event shouldBe PlayPause.PauseDebounceEvent.RESET + } + + @Test + fun `NO_LIVE_BLE source - suppresses raw shouldPause to prevent stale-cache pause`() { + // Without live BLE evidence, a cached worn → not-worn transition could otherwise + // fire shouldPause. Verify the helper suppresses that to zero. + val result = playPause.applyPauseDebounce( + pending = null, + profileId = "profile", + source = PlayPause.EarDetectionSource.NO_LIVE_BLE, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, + ) + + result.decision.shouldPause shouldBe false + result.decision.reason shouldBe "test pause (suppressed: no live BLE evidence)" + result.pending shouldBe null + result.event shouldBe PlayPause.PauseDebounceEvent.NONE + } + + @Test + fun `BLE_PROFILE_FALLBACK first not-worn sample is suppressed and pending created`() { + val current = EarDetectionState.fromDualPod(false, false) + val result = playPause.applyPauseDebounce( + pending = null, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = current, + autoPauseEnabled = true, + ) + + result.decision.shouldPause shouldBe false + result.pending shouldNotBe null + result.pending!!.profileId shouldBe "profile" + result.pending!!.initialPodCount shouldBe 0 + result.pending!!.confirmationsRemaining shouldBe PlayPause.PAUSE_DEBOUNCE_SAMPLES + } + + @Test + fun `BLE_PROFILE_FALLBACK three consecutive not-worn samples fires pause on third`() { + val current = EarDetectionState.fromDualPod(false, false) + + // Sample 1 — first detection, suppressed, pending created + val result1 = playPause.applyPauseDebounce( + pending = null, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = current, + autoPauseEnabled = true, + ) + result1.decision.shouldPause shouldBe false + result1.pending shouldNotBe null + + // Sample 2 — confirmation 1/2, still suppressed (raw is now no-op since not-worn -> not-worn) + val result2 = playPause.applyPauseDebounce( + pending = result1.pending, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = current, + autoPauseEnabled = true, + ) + result2.decision.shouldPause shouldBe false + result2.pending shouldNotBe null + result2.pending!!.confirmationsRemaining shouldBe (PlayPause.PAUSE_DEBOUNCE_SAMPLES - 1) + + // Sample 3 — confirmation 2/2, pause fires + val result3 = playPause.applyPauseDebounce( + pending = result2.pending, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = current, + autoPauseEnabled = true, + ) + result3.decision.shouldPause shouldBe true + result3.pending shouldBe null + } + + @Test + fun `BLE_PROFILE_FALLBACK pod returns mid-debounce - pending cleared`() { + // First detection: both pods removed + val pending = PlayPause.PendingPauseDebounce( + profileId = "profile", + initialPodCount = 0, + confirmationsRemaining = 1, + ) + // Pod returned: count went from 0 -> 1 (worn back) + val current = EarDetectionState.fromDualPod(true, false) + + val result = playPause.applyPauseDebounce( + pending = pending, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = current, + autoPauseEnabled = true, + ) + + result.decision.shouldPause shouldBe false + result.pending shouldBe null + } + + @Test + fun `BLE_PROFILE_FALLBACK raw shouldPlay clears pending`() { + val pending = PlayPause.PendingPauseDebounce( + profileId = "profile", + initialPodCount = 0, + confirmationsRemaining = 1, + ) + val current = EarDetectionState.fromDualPod(true, true) + + val result = playPause.applyPauseDebounce( + pending = pending, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = playDecision, + currentState = current, + autoPauseEnabled = true, + ) + + result.decision shouldBe playDecision + result.pending shouldBe null + } + + @Test + fun `BLE_ANONYMOUS one-pod mode - both to one to none confirms across decreasing counts`() { + // Sample 1: both -> one (initial pause request, podCount=1) + val sample1Current = EarDetectionState.fromDualPod(true, false) + val result1 = playPause.applyPauseDebounce( + pending = null, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_ANONYMOUS, + rawDecision = pauseDecision, + currentState = sample1Current, + autoPauseEnabled = true, + ) + result1.decision.shouldPause shouldBe false + result1.pending shouldNotBe null + result1.pending!!.initialPodCount shouldBe 1 + + // Sample 2: one -> none (count=0, still <= initial 1, confirms) + val sample2Current = EarDetectionState.fromDualPod(false, false) + val result2 = playPause.applyPauseDebounce( + pending = result1.pending, + profileId = "profile", + // In one-pod mode with prev=1 curr=0, evaluateOnePodMode returns shouldPause=true + // again — but the helper should still treat this as a confirmation, not restart. + source = PlayPause.EarDetectionSource.BLE_ANONYMOUS, + rawDecision = pauseDecision, + currentState = sample2Current, + autoPauseEnabled = true, + ) + result2.decision.shouldPause shouldBe false + result2.pending shouldNotBe null + result2.pending!!.confirmationsRemaining shouldBe (PlayPause.PAUSE_DEBOUNCE_SAMPLES - 1) + + // Sample 3: still none, final confirmation + val result3 = playPause.applyPauseDebounce( + pending = result2.pending, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_ANONYMOUS, + rawDecision = noopDecision, + currentState = sample2Current, + autoPauseEnabled = true, + ) + result3.decision.shouldPause shouldBe true + result3.pending shouldBe null + } + + @Test + fun `profile change - pending from different profile is ignored`() { + val pending = PlayPause.PendingPauseDebounce( + profileId = "old-profile", + initialPodCount = 0, + confirmationsRemaining = 1, + ) + + val result = playPause.applyPauseDebounce( + pending = pending, + profileId = "new-profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, + ) + + // Old pending is dropped (different profileId), new pending is started for new profile + result.decision.shouldPause shouldBe false + result.pending shouldNotBe null + result.pending!!.profileId shouldBe "new-profile" + result.pending!!.confirmationsRemaining shouldBe PlayPause.PAUSE_DEBOUNCE_SAMPLES + } + + @Test + fun `autoPause disabled - debounce skipped, raw decision passes through`() { + val result = playPause.applyPauseDebounce( + pending = null, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = false, + ) + + result.decision shouldBe pauseDecision + result.pending shouldBe null + } + + @Test + fun `null profileId - debounce skipped`() { + val result = playPause.applyPauseDebounce( + pending = null, + profileId = null, + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, + ) + + result.decision shouldBe pauseDecision + result.pending shouldBe null + } + + @Test + fun `no pause request - raw decision passes through unchanged`() { + val result = playPause.applyPauseDebounce( + pending = null, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(true, true), + autoPauseEnabled = true, + ) + + result.decision shouldBe noopDecision + result.pending shouldBe null + } + } + + @Nested + inner class ToEarDetectionStateTests { + + @Test + fun `AAP EarDetection present - returns aggregate state`() { + // With AAP EarDetection saying NOT_IN_EAR for both, the helper should return + // not-worn aggregate regardless of any conflicting BLE per-side bits. + val ble = mockk(relaxed = true) { + every { isLeftPodInEar } returns true // BLE says worn (potentially corrupt) + every { isRightPodInEar } returns true + every { isBeingWorn } returns true + every { isEitherPodInEar } returns true + } + val aapEarDetection = AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ) + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf(AapSetting.EarDetection::class to aapEarDetection), + ) + val device = PodDevice( + profileId = "test", + ble = ble, + aap = aap, + ) + + val state = with(playPause) { device.toEarDetectionState() } + + // AAP says not worn → aggregate should be not-worn. + state.bothInEar shouldBe false + state.podCount shouldBe 0 + } + + @Test + fun `AAP absent - falls back to BLE per-side`() { + val ble = mockk(relaxed = true) { + every { isLeftPodInEar } returns true + every { isRightPodInEar } returns false + every { isBeingWorn } returns false + every { isEitherPodInEar } returns true + every { primaryPod } returns DualBlePodSnapshot.Pod.LEFT + } + val device = PodDevice( + profileId = "test", + ble = ble, + aap = null, + ) + + val state = with(playPause) { device.toEarDetectionState() } + + // BLE per-side: left=true, right=false → podCount=1 + state.leftInEar shouldBe true + state.rightInEar shouldBe false + state.podCount shouldBe 1 + } + + @Test + fun `monitor key freshness field differs across BLE scans for unauthenticated source`() { + // Repeated identical not-worn samples must produce distinct monitor keys when + // bleKeyState is NONE / profile-fallback, so distinctUntilChangedBy doesn't + // collapse them and the debounce counter can advance. + val now = java.time.Instant.parse("2026-01-01T00:00:00Z") + val ble1 = mockk(relaxed = true) { + every { meta } returns eu.darken.capod.pods.core.apple.ble.devices.ApplePods.AppleMeta( + isIRKMatch = false, + profile = null, + ) + every { seenLastAt } returns now + every { isLeftPodInEar } returns false + every { isRightPodInEar } returns false + every { isBeingWorn } returns false + every { isEitherPodInEar } returns false + } + val ble2 = mockk(relaxed = true) { + every { meta } returns eu.darken.capod.pods.core.apple.ble.devices.ApplePods.AppleMeta( + isIRKMatch = false, + profile = null, + ) + every { seenLastAt } returns now.plusMillis(1000) // 1 second later, identical state + every { isLeftPodInEar } returns false + every { isRightPodInEar } returns false + every { isBeingWorn } returns false + every { isEitherPodInEar } returns false + } + val device1 = PodDevice(profileId = "test", ble = ble1, aap = null) + val device2 = PodDevice(profileId = "test", ble = ble2, aap = null) + + val key1 = with(playPause) { device1.toPlayPauseMonitorKey() } + val key2 = with(playPause) { device2.toPlayPauseMonitorKey() } + + // Same wear state but different seenLastAt → distinct keys (debounce can advance) + (key1 == key2) shouldBe false + key1.source shouldBe PlayPause.EarDetectionSource.BLE_ANONYMOUS + key2.source shouldBe PlayPause.EarDetectionSource.BLE_ANONYMOUS + } + + @Test + fun `monitor key freshness field is null for IRK-authenticated source`() { + // For IRK_MATCH source, debounceFreshness should be null so that battery-only + // updates with identical wear state still collapse via distinctUntilChangedBy. + val now = java.time.Instant.parse("2026-01-01T00:00:00Z") + val profile = mockk(relaxed = true) + val ble = mockk(relaxed = true) { + every { meta } returns eu.darken.capod.pods.core.apple.ble.devices.ApplePods.AppleMeta( + isIRKMatch = true, + profile = profile, + ) + every { seenLastAt } returns now + every { isLeftPodInEar } returns false + every { isRightPodInEar } returns false + every { isBeingWorn } returns false + every { isEitherPodInEar } returns false + } + val device = PodDevice(profileId = "test", ble = ble, aap = null) + + val key = with(playPause) { device.toPlayPauseMonitorKey() } + + key.source shouldBe PlayPause.EarDetectionSource.BLE_IRK_MATCH + key.debounceFreshness shouldBe null + } + + @Test + fun `AAP EarDetection present and primary pod resolved - aggregate matches per-side`() { + // When AAP has both EarDetection AND primary pod resolved, aggregate and per-side + // should agree. Verify the helper still returns aggregate (not per-side) since + // both are equivalent. + val aapEarDetection = AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + ) + val aapPrimary = AapSetting.PrimaryPod(pod = AapSetting.PrimaryPod.Pod.LEFT) + val aap = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to aapEarDetection, + AapSetting.PrimaryPod::class to aapPrimary, + ), + ) + val ble = mockk(relaxed = true) { + every { isLeftPodInEar } returns true + every { isRightPodInEar } returns true + every { isBeingWorn } returns true + every { isEitherPodInEar } returns true + every { primaryPod } returns DualBlePodSnapshot.Pod.LEFT + } + val device = PodDevice( + profileId = "test", + ble = ble, + aap = aap, + ) + + val state = with(playPause) { device.toEarDetectionState() } + + state.bothInEar shouldBe true + state.podCount shouldBe 2 + } + } }