mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user