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.
This commit is contained in:
darken
2026-04-29 18:14:51 +02:00
committed by Matthias Urhahn
parent d0aab583d2
commit 67fead3589
2 changed files with 731 additions and 10 deletions
@@ -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
}
}
@@ -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<DualApplePods>(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<DualApplePods>(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<DualApplePods>(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<DualApplePods>(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<eu.darken.capod.profiles.core.AppleDeviceProfile>(relaxed = true)
val ble = mockk<DualApplePods>(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<DualApplePods>(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
}
}
}