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 4819cef8..9f025595 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 @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import java.time.Duration import java.time.Instant import javax.inject.Inject import javax.inject.Singleton @@ -223,6 +224,8 @@ class PlayPause @Inject constructor( rawDecision = confirmation.decision, currentState = currState, autoPauseEnabled = reactions.autoPause, + now = current.ble?.seenLastAt, + generatedAtNanos = current.ble?.scanResult?.generatedAtNanos, ) pendingPauseDebounce = debounceResult.pending @@ -240,7 +243,7 @@ class PlayPause @Inject constructor( "rawShouldPlay=${confirmation.decision.shouldPlay}" } PauseDebounceEvent.COMMITTED -> log(TAG, DEBUG) { - "Pause debounce committed: source=$source confirmed pause" + "Pause debounce committed: source=$source, ${debounceResult.decision.reason}" } PauseDebounceEvent.NONE -> {} } @@ -438,12 +441,21 @@ class PlayPause @Inject constructor( } /** - * Sample-count debounce for pause decisions when the ear-detection source is an - * unauthenticated BLE advertisement. + * Hybrid sample-count + time-cap 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. + * 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 on the *count* path. + * + * On OEM stacks that deliver scan results in slow (~1.5-2s) batches, waiting for the full + * sample count would take ~4s. To cap that, the pause also commits early once the not-worn + * condition has persisted at least [PAUSE_DEBOUNCE_TIME_CAP] — but only when the samples are + * still strictly consecutive (no tolerated rebound) and the confirming sample is a *distinct* + * radio reception ([BleScanResult.generatedAtNanos] differs from the first). The time path + * therefore never commits on fewer than 2 consecutive, distinct not-worn receptions. Elapsed + * uses [now] (the sample's `ble.seenLastAt`, a callback-receive wall-clock) clamped to ≥ 0. + * When [now]/[generatedAtNanos] are null (no live BLE sample) the time path is inactive. * * The helper advances [pending] from [currentState], NOT from [rawDecision.shouldPause] * — subsequent samples after the initial detection are not-worn → not-worn, and @@ -462,6 +474,8 @@ class PlayPause @Inject constructor( rawDecision: PlayPauseDecision, currentState: EarDetectionState, autoPauseEnabled: Boolean, + now: Instant? = null, + generatedAtNanos: Long? = null, ): PauseDebounceResult { val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK || source == EarDetectionSource.BLE_ANONYMOUS @@ -525,6 +539,8 @@ class PlayPause @Inject constructor( profileId = profileId, initialPodCount = currentState.podCount, confirmationsRemaining = PAUSE_DEBOUNCE_SAMPLES, + startedAt = now, + startedGeneratedAtNanos = generatedAtNanos, ), event = PauseDebounceEvent.STARTED, ) @@ -545,7 +561,10 @@ class PlayPause @Inject constructor( shouldPause = false, reason = "Debouncing pause (rebound tolerated)", ), - pending = activePending.copy(resetTolerance = activePending.resetTolerance - 1), + pending = activePending.copy( + resetTolerance = activePending.resetTolerance - 1, + reboundTolerated = true, + ), event = PauseDebounceEvent.ADVANCED, ) } @@ -554,12 +573,41 @@ class PlayPause @Inject constructor( // Confirmation: count <= initialPodCount, decrement remaining. val remaining = activePending.confirmationsRemaining - 1 - if (remaining <= 0) { + val commitOnCount = remaining <= 0 + + // Time-cap early commit: once the not-worn condition has persisted at least + // PAUSE_DEBOUNCE_TIME_CAP, commit before the full sample count is reached — this caps the + // pause latency on OEM stacks that deliver scan results in slow batches. Three guards keep + // the "≥2 consecutive, distinct not-worn receptions" invariant: + // - reboundTolerated: a count-up rebound broke the consecutive not-worn run → the time + // path is disabled and we fall back to pure sample-count. + // - distinct generatedAtNanos: the confirming sample must be a different radio reception + // than the first, so a stack re-delivering one cached advert in a later batch (fresh + // seenLastAt, same reception) cannot early-commit on a single physical advert. A + // broken/constant OEM timebase just disables the time path (fail-safe to count). + // - elapsed clamped to >= 0: a backward seenLastAt jump (wall-clock step, or the backing + // snapshot switching to a different physical device under BLE_PROFILE_FALLBACK) can + // only delay, never prematurely fire. + val startedAt = activePending.startedAt + val startedNanos = activePending.startedGeneratedAtNanos + val elapsed = if (startedAt != null && now != null) { + Duration.between(startedAt, now).coerceAtLeast(Duration.ZERO) + } else { + null + } + val commitOnTime = !activePending.reboundTolerated && + elapsed != null && elapsed >= PAUSE_DEBOUNCE_TIME_CAP && + startedNanos != null && generatedAtNanos != null && + generatedAtNanos != startedNanos + + if (commitOnCount || commitOnTime) { + val mode = if (commitOnCount) "count" else "time(${elapsed?.toMillis()}ms)" return PauseDebounceResult( decision = PlayPauseDecision( shouldPlay = false, shouldPause = true, - reason = "Debounced pause confirmed (initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})", + reason = "Debounced pause confirmed via $mode " + + "(initial count: ${activePending.initialPodCount}, current: ${currentState.podCount})", ), pending = null, event = PauseDebounceEvent.COMMITTED, @@ -674,6 +722,18 @@ class PlayPause @Inject constructor( // shows a pod returning shouldn't kill the pending, since the next sample may // confirm the pods are still out. val resetTolerance: Int = 1, + // Observation time (ble.seenLastAt, a callback-receive wall-clock) of the first + // not-worn sample. null → the time-cap early commit is inactive for this pending. + val startedAt: Instant? = null, + // Hardware radio-reception timestamp (ScanResult.timestampNanos) of the first + // not-worn sample. The time-cap commit requires the confirming sample to be a + // DISTINCT reception (different generatedAtNanos), so a janky OEM re-delivering the + // same cached advert in a later batch cannot early-commit on one physical advert. + val startedGeneratedAtNanos: Long? = null, + // Set once a count-up rebound has been tolerated. Disables the time-cap early commit + // (the not-worn samples are no longer strictly consecutive), falling back to pure + // sample-count confirmation. + val reboundTolerated: Boolean = false, ) /** Discrete event produced by [applyPauseDebounce] for diagnostic logging. */ @@ -801,5 +861,19 @@ class PlayPause @Inject constructor( * decision is dispatched. With 2, a pause needs 3 consecutive not-worn samples total. */ internal const val PAUSE_DEBOUNCE_SAMPLES = 2 + + /** + * Upper bound on how long the sample-count debounce is allowed to stretch. Once the + * not-worn condition has persisted this long (measured from the first not-worn sample's + * observation time) with at least one further *distinct* not-worn reception and no + * tolerated rebound, the pause commits early — even if [PAUSE_DEBOUNCE_SAMPLES] hasn't + * been reached. This caps the delay on OEM BLE stacks (e.g. Samsung/OneUI) that deliver + * scan results in slow ~1.5-2s batches, where a pure sample count would take ~4s. + * + * Does not weaken corruption protection: an early commit still requires ≥2 consecutive, + * distinct not-worn receptions (see [applyPauseDebounce]). Cadences below ~2× this value + * see no change (count commits first). Tunable. + */ + internal val PAUSE_DEBOUNCE_TIME_CAP: Duration = Duration.ofMillis(1500) } } 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 661ad4ca..a25a371b 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,6 +1,7 @@ package eu.darken.capod.reaction.core.playpause import eu.darken.capod.common.MediaControl +import eu.darken.capod.common.bluetooth.BleScanResult import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.monitor.core.DeviceMonitor import eu.darken.capod.monitor.core.PodDevice @@ -1507,6 +1508,203 @@ class PlayPauseLogicTest : BaseTest() { result.decision shouldBe noopDecision result.pending shouldBe null } + + // --- Time-cap early commit (slow-scanner mitigation) --- + + private val t0 = Instant.parse("2026-01-01T00:00:00Z") + + @Test + fun `time-cap - slow cadence commits early on the first distinct confirmation past the cap`() { + // STARTED at t0 (reception nanos=100). The first confirmation arrives 2000ms later + // (> 1500ms cap) as a DISTINCT reception (nanos=200) → commit via time-cap on the + // first confirmation, before PAUSE_DEBOUNCE_SAMPLES would have fired. + val started = playPause.applyPauseDebounce( + pending = null, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, + now = t0, + generatedAtNanos = 100L, + ) + started.event shouldBe PlayPause.PauseDebounceEvent.STARTED + started.pending!!.startedAt shouldBe t0 + started.pending!!.startedGeneratedAtNanos shouldBe 100L + + val result = playPause.applyPauseDebounce( + pending = started.pending, + profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, + now = t0.plusMillis(2000), + generatedAtNanos = 200L, + ) + + result.decision.shouldPause shouldBe true + result.pending shouldBe null + result.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED + result.decision.reason.contains("via time") shouldBe true + } + + @Test + fun `time-cap - fast cadence still commits on sample count, not time`() { + // Per-advert cadence (batching disabled): confirmations at +150ms and +300ms, both + // under the cap → the pause commits on the 2nd confirmation via count, exactly as + // without the time-cap. Guards the recommended "Disable hardware batching" fast path. + val started = playPause.applyPauseDebounce( + pending = null, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0, generatedAtNanos = 1L, + ) + + val confirm1 = playPause.applyPauseDebounce( + pending = started.pending, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0.plusMillis(150), generatedAtNanos = 2L, + ) + confirm1.decision.shouldPause shouldBe false + confirm1.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED + // startedAt / startedGeneratedAtNanos are preserved across an ADVANCED. + confirm1.pending!!.startedAt shouldBe t0 + confirm1.pending!!.startedGeneratedAtNanos shouldBe 1L + + val confirm2 = playPause.applyPauseDebounce( + pending = confirm1.pending, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0.plusMillis(300), generatedAtNanos = 3L, + ) + confirm2.decision.shouldPause shouldBe true + confirm2.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED + confirm2.decision.reason.contains("via count") shouldBe true + } + + @Test + fun `time-cap - identical reception (same generatedAtNanos) does not time-commit`() { + // A janky OEM re-delivers the SAME cached advert in a later batch callback: fresh + // seenLastAt but identical generatedAtNanos. The distinct-reception guard must block + // the time path — one physical advert cannot early-commit a pause. + val started = playPause.applyPauseDebounce( + pending = null, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0, generatedAtNanos = 100L, + ) + + val result = playPause.applyPauseDebounce( + pending = started.pending, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0.plusMillis(2000), generatedAtNanos = 100L, + ) + + result.decision.shouldPause shouldBe false + result.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED + } + + @Test + fun `time-cap - boundary elapsed exactly equal to the cap commits`() { + // Pins the comparison to >= (not >). + val started = playPause.applyPauseDebounce( + pending = null, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0, generatedAtNanos = 1L, + ) + + val result = playPause.applyPauseDebounce( + pending = started.pending, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, + now = t0.plus(PlayPause.PAUSE_DEBOUNCE_TIME_CAP), + generatedAtNanos = 2L, + ) + + result.decision.shouldPause shouldBe true + result.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED + } + + @Test + fun `time-cap - backward wall-clock does not time-commit (elapsed clamped to zero)`() { + // seenLastAt regresses (wall-clock jump, or the backing snapshot switching to a + // different physical device under BLE_PROFILE_FALLBACK). Clamp to >=0 → elapsed 0 → + // no time commit; must ADVANCE and wait for the count. + val started = playPause.applyPauseDebounce( + pending = null, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0.plusMillis(5000), generatedAtNanos = 1L, + ) + + val result = playPause.applyPauseDebounce( + pending = started.pending, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0, generatedAtNanos = 2L, + ) + + result.decision.shouldPause shouldBe false + result.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED + } + + @Test + fun `time-cap - a tolerated rebound disables the time path but count still fires`() { + // STARTED t0; a count-up rebound at +1000 is tolerated (reboundTolerated=true). A + // not-worn at +2500 is past the cap but must NOT time-commit — the not-worn samples + // are no longer strictly consecutive. It ADVANCES; a further not-worn commits on count. + val started = playPause.applyPauseDebounce( + pending = null, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = pauseDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0, generatedAtNanos = 1L, + ) + + val rebound = playPause.applyPauseDebounce( + pending = started.pending, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(true, false), // pod returned (count up) + autoPauseEnabled = true, now = t0.plusMillis(1000), generatedAtNanos = 2L, + ) + rebound.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED + rebound.pending!!.reboundTolerated shouldBe true + + val pastCap = playPause.applyPauseDebounce( + pending = rebound.pending, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0.plusMillis(2500), generatedAtNanos = 3L, + ) + pastCap.decision.shouldPause shouldBe false + pastCap.event shouldBe PlayPause.PauseDebounceEvent.ADVANCED + + val committed = playPause.applyPauseDebounce( + pending = pastCap.pending, profileId = "profile", + source = PlayPause.EarDetectionSource.BLE_PROFILE_FALLBACK, + rawDecision = noopDecision, + currentState = EarDetectionState.fromDualPod(false, false), + autoPauseEnabled = true, now = t0.plusMillis(2700), generatedAtNanos = 4L, + ) + committed.decision.shouldPause shouldBe true + committed.event shouldBe PlayPause.PauseDebounceEvent.COMMITTED + } } @Nested @@ -1708,13 +1906,21 @@ class PlayPauseLogicTest : BaseTest() { @Nested inner class MonitorFlowTests { - private fun buildBle(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) = + private fun buildBle( + seenAt: Instant, + leftWorn: Boolean, + rightWorn: Boolean, + genNanos: Long = 0L, + ) = mockk(relaxed = true) { every { meta } returns ApplePods.AppleMeta( isIRKMatch = false, profile = mockk(relaxed = true), ) every { seenLastAt } returns seenAt + every { scanResult } returns mockk(relaxed = true) { + every { generatedAtNanos } returns genNanos + } every { isLeftPodInEar } returns leftWorn every { isRightPodInEar } returns rightWorn every { isBeingWorn } returns (leftWorn && rightWorn) @@ -1731,9 +1937,10 @@ class PlayPauseLogicTest : BaseTest() { leftWorn: Boolean, rightWorn: Boolean, startMusicOnWear: Boolean = true, + genNanos: Long = 0L, ) = PodDevice( profileId = "test-profile", - ble = buildBle(seenAt, leftWorn, rightWorn), + ble = buildBle(seenAt, leftWorn, rightWorn, genNanos), aap = null, profileModel = PodModel.AIRPODS_PRO3, reactions = ReactionConfig( @@ -1784,6 +1991,9 @@ class PlayPauseLogicTest : BaseTest() { @Test fun `flow - stable worn rebound resets stale pause debounce before a new removal sequence`() = runTest { + // NOTE: buildDevice defaults genNanos = 0L, so every sample here shares one + // generatedAtNanos. The time-cap's distinct-reception guard is therefore inert and + // this test exercises the pure sample-count path — independent of PAUSE_DEBOUNCE_TIME_CAP. val deviceFlow = MutableStateFlow>(emptyList()) val deviceMonitor: DeviceMonitor = mockk(relaxed = true) { every { devices } returns deviceFlow @@ -1834,6 +2044,82 @@ class PlayPauseLogicTest : BaseTest() { job.cancel() } + @Test + fun `flow - slow cadence commits pause via time-cap on the second distinct not-worn sample`() = runTest { + val deviceFlow = MutableStateFlow>(emptyList()) + val deviceMonitor: DeviceMonitor = mockk(relaxed = true) { + every { devices } returns deviceFlow + } + val bluetoothManager: BluetoothManager2 = mockk(relaxed = true) { + every { connectedDevices } returns flowOf(listOf(mockk(relaxed = true))) + } + val mediaControl: MediaControl = mockk(relaxed = true) { + every { isPlaying } returns true + every { wasRecentlyPausedByCap } returns false + coEvery { sendPause(rememberForResume = true) } returns true + } + val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl) + + val now = Instant.parse("2026-01-01T00:00:00Z") + val job = launch { flowPlayPause.monitor().collect {} } + + // T0: worn baseline. + deviceFlow.value = listOf(buildDevice(now, leftWorn = true, rightWorn = true, genNanos = 1L)) + advanceUntilIdle() + + // T1: first not-worn sample starts the debounce (seenLastAt = t0+1000). + deviceFlow.value = listOf(buildDevice(now.plusMillis(1000), leftWorn = false, rightWorn = false, genNanos = 2L)) + advanceUntilIdle() + + // T2: a second, DISTINCT not-worn reception 2000ms after the first (> 1500ms cap). + // Only two not-worn samples so far — the pure sample count would need a third — but + // on a slow (~2s) batch cadence the time-cap commits the pause here. + deviceFlow.value = listOf(buildDevice(now.plusMillis(3000), leftWorn = false, rightWorn = false, genNanos = 3L)) + advanceUntilIdle() + + coVerify(exactly = 1) { mediaControl.sendPause(rememberForResume = true) } + + job.cancel() + } + + @Test + fun `flow - two not-worn samples within the cap do not pause (elapsed anchored at first not-worn)`() = runTest { + val deviceFlow = MutableStateFlow>(emptyList()) + val deviceMonitor: DeviceMonitor = mockk(relaxed = true) { + every { devices } returns deviceFlow + } + val bluetoothManager: BluetoothManager2 = mockk(relaxed = true) { + every { connectedDevices } returns flowOf(listOf(mockk(relaxed = true))) + } + val mediaControl: MediaControl = mockk(relaxed = true) { + every { isPlaying } returns true + every { wasRecentlyPausedByCap } returns false + coEvery { sendPause(rememberForResume = true) } returns true + } + val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl) + + val now = Instant.parse("2026-01-01T00:00:00Z") + val job = launch { flowPlayPause.monitor().collect {} } + + // T0: worn baseline. + deviceFlow.value = listOf(buildDevice(now, leftWorn = true, rightWorn = true, genNanos = 1L)) + advanceUntilIdle() + + // T1: first not-worn at t0+1400 → debounce STARTED, elapsed anchored here. + deviceFlow.value = listOf(buildDevice(now.plusMillis(1400), leftWorn = false, rightWorn = false, genNanos = 2L)) + advanceUntilIdle() + + // T2: second not-worn at t0+2000. Elapsed from the FIRST not-worn is 600ms (< cap), + // and only two samples → no pause. A wrong anchor (baseline t0, or a device-level + // timestamp) would read 2000ms >= cap and wrongly pause here. + deviceFlow.value = listOf(buildDevice(now.plusMillis(2000), leftWorn = false, rightWorn = false, genNanos = 3L)) + advanceUntilIdle() + + coVerify(exactly = 0) { mediaControl.sendPause(rememberForResume = true) } + + job.cancel() + } + private fun buildIrkMatchedBle(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) = mockk(relaxed = true) { every { meta } returns ApplePods.AppleMeta(