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 09c50524..27a89c7f 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 @@ -16,7 +16,6 @@ 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 import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest @@ -36,6 +35,8 @@ class PlayPause @Inject constructor( fun monitor() = run { var pendingPlayConfirmation: PendingPlayConfirmation? = null var pendingPauseDebounce: PendingPauseDebounce? = null + var hasLastMonitorKey = false + var lastMonitorKey: PlayPauseMonitorKey? = null deviceMonitor.primaryDevice() .map { device -> device?.reactions?.let { it.autoPlay || it.autoPause } == true } @@ -46,6 +47,8 @@ class PlayPause @Inject constructor( } else { pendingPlayConfirmation = null pendingPauseDebounce = null + hasLastMonitorKey = false + lastMonitorKey = null emptyFlow() } } @@ -53,6 +56,8 @@ class PlayPause @Inject constructor( if (connected.isEmpty()) { pendingPlayConfirmation = null pendingPauseDebounce = null + hasLastMonitorKey = false + lastMonitorKey = null log(TAG) { "No known devices connected." } emptyFlow() } else { @@ -61,9 +66,20 @@ class PlayPause @Inject constructor( } } // Cache persistence can update battery timestamps without changing any reaction-relevant state. - .distinctUntilChangedBy { it?.toPlayPauseMonitorKey() } + .filter { device -> + val key = device?.toPlayPauseMonitorKey() + val shouldEmit = !hasLastMonitorKey || + key != lastMonitorKey || + device?.isPauseDebounceResetCandidate(pendingPauseDebounce) == true + + if (shouldEmit) { + hasLastMonitorKey = true + lastMonitorKey = key + } + shouldEmit + } .onEach { device -> - log(TAG, VERBOSE) { "Post-distinct: profileId=${device?.profileId}" } + log(TAG, VERBOSE) { "Post-monitor-filter: profileId=${device?.profileId}" } } .withPrevious() .filter { (previous, current) -> @@ -97,6 +113,22 @@ class PlayPause @Inject constructor( return@onEach } + // Skip the reaction when previous was a no-live-evidence emission (cache-only + // baseline emitted at process start, or after a >20s BLE gap that evicted the + // device from the live cache). PodDevice.isBeingWorn returns null for those, + // and toEarDetectionState() coerces null -> false, which would produce a + // fake "not-worn -> worn" transition the moment live BLE arrives — firing + // an unwanted autoPlay on app start while the user is wearing the pods. + if (previous?.earDetectionSource() == EarDetectionSource.NO_LIVE_BLE) { + log(TAG, VERBOSE) { "Previous emission has no live evidence; skipping reaction." } + pendingPlayConfirmation = null + if (pendingPauseDebounce != null) { + log(TAG, DEBUG) { "Pause debounce reset: previous emission lacked live evidence" } + pendingPauseDebounce = null + } + return@onEach + } + // Convert to EarDetectionState based on device capabilities val prevState: EarDetectionState val currState: EarDetectionState @@ -138,6 +170,8 @@ class PlayPause @Inject constructor( val isCurrentlyPlaying = mediaControl.isPlaying val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap + val source = current.earDetectionSource() + // Evaluate what action to take val rawDecision = evaluatePlayPauseAction( previous = prevState, @@ -147,11 +181,18 @@ class PlayPause @Inject constructor( wasRecentlyPausedByUs = wasRecentlyPausedByUs, ) + // BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources. + // Trusted sources (AAP, BLE_IRK_MATCH) skip staging — symmetric to the + // pause debounce, which also skips for these sources. Without this gate, + // an IRK-matched device with no live AAP would stage a BLE-only confirmation + // that never confirms (the second worn sample has no freshness on the + // monitor key for IRK_MATCH so it gets collapsed). val shouldStageBleOnlyPlay = rawDecision.shouldPlay && reactions.autoPlay && !reactions.onePodMode && current.hasDualPods && - current.aap?.aapEarDetection == null + (source == EarDetectionSource.BLE_PROFILE_FALLBACK || + source == EarDetectionSource.BLE_ANONYMOUS) val confirmation = applyBleOnlyPlayConfirmation( pending = pendingPlayConfirmation, @@ -173,7 +214,6 @@ class PlayPause @Inject constructor( log(TAG, VERBOSE) { "BLE-only autoplay confirmed by a follow-up state update" } } - val source = current.earDetectionSource() val debounceResult = applyPauseDebounce( pending = pendingPauseDebounce, profileId = current.profileId, @@ -641,7 +681,7 @@ class PlayPause @Inject constructor( 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 + // that monitor distinct filtering doesn't collapse repeated identical not-worn samples // and the debounce counter can advance. // // Caveat: BlePodMonitor.preferCaseContextPod can keep an existing case-context @@ -664,13 +704,18 @@ class PlayPause @Inject constructor( internal fun PodDevice.toPlayPauseMonitorKey(): PlayPauseMonitorKey { val source = earDetectionSource() - // Freshness is needed only on debounce-eligible NOT-WORN samples. Including - // worn samples would also break distinctUntilChangedBy for identical both-in - // samples, accidentally enabling BLE-only auto-play confirmation to fire on - // repeated unauthenticated adverts. - val needsFreshness = (source == EarDetectionSource.BLE_PROFILE_FALLBACK || - source == EarDetectionSource.BLE_ANONYMOUS) && - isBeingWorn != true + // Freshness applies to all unauthenticated samples (both worn and not-worn) so + // that monitor distinct filtering doesn't collapse repeated identical samples: + // - not-worn samples must pass through to advance the pause debounce counter + // - worn samples must pass through to satisfy applyBleOnlyPlayConfirmation, + // which requires a 2nd identical worn sample to confirm a staged play + // (see commit 6825abaa "Guard BLE-only autoplay") + // The 2-sample autoplay confirmation IS the debounce on the play side, mirroring + // the pause debounce. isPauseDebounceResetCandidate() remains as a defense-in-depth + // backstop for the rare case where seenLastAt doesn't advance between samples + // (e.g. BlePodMonitor.preferCaseContextPod preserves the prior snapshot). + val needsFreshness = source == EarDetectionSource.BLE_PROFILE_FALLBACK || + source == EarDetectionSource.BLE_ANONYMOUS return PlayPauseMonitorKey( profileId = profileId, autoPlay = reactions.autoPlay, @@ -689,6 +734,17 @@ class PlayPause @Inject constructor( ) } + private fun PodDevice.isPauseDebounceResetCandidate(pending: PendingPauseDebounce?): Boolean { + val activePending = pending?.takeIf { it.profileId == profileId } ?: return false + val source = earDetectionSource() + val needsDebounce = source == EarDetectionSource.BLE_PROFILE_FALLBACK || + source == EarDetectionSource.BLE_ANONYMOUS + + if (!needsDebounce || !hasEarDetection) return false + + return toEarDetectionState().podCount > activePending.initialPodCount + } + /** * Converts a dual-pod [PodDevice] to [EarDetectionState] for reaction evaluation. * 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 9979eafa..e6a5edf6 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 @@ -1433,7 +1433,7 @@ class PlayPauseLogicTest : BaseTest() { @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 + // bleKeyState is NONE / profile-fallback, so monitor distinct filtering 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) { @@ -1473,7 +1473,7 @@ class PlayPauseLogicTest : BaseTest() { @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. + // updates with identical wear state still collapse via monitor distinct filtering. val now = java.time.Instant.parse("2026-01-01T00:00:00Z") val profile = mockk(relaxed = true) val ble = mockk(relaxed = true) { @@ -1583,7 +1583,7 @@ class PlayPauseLogicTest : BaseTest() { advanceUntilIdle() // T1, T2, T3: identical not-worn samples with incrementing seenLastAt. - // Without the freshness fix, distinctUntilChangedBy would collapse T2 and T3 + // Without the freshness fix, monitor distinct filtering would collapse T2 and T3 // and the debounce counter could never advance. With it, the helper sees // 3 samples and commits the pause on the third. deviceFlow.value = listOf(buildDevice(now.plusMillis(1000), leftWorn = false, rightWorn = false)) @@ -1597,5 +1597,295 @@ class PlayPauseLogicTest : BaseTest() { job.cancel() } + + @Test + fun `flow - stable worn rebound resets stale pause debounce before a new removal sequence`() = 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() } 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)) + advanceUntilIdle() + + // T1: corrupt not-worn sample starts pause debounce. + deviceFlow.value = listOf(buildDevice(now.plusMillis(1000), leftWorn = false, rightWorn = false)) + advanceUntilIdle() + + // T2/T3: the pods are stably worn again. T2 consumes resetTolerance, and T3 + // must still reach applyPauseDebounce so the stale pending state is cleared. + deviceFlow.value = listOf(buildDevice(now.plusMillis(2000), leftWorn = true, rightWorn = true)) + advanceUntilIdle() + deviceFlow.value = listOf(buildDevice(now.plusMillis(3000), leftWorn = true, rightWorn = true)) + advanceUntilIdle() + + // T4/T5: a later real removal has only two not-worn samples so far. If T3 was + // collapsed, stale pending from T1 would incorrectly commit a pause here. + deviceFlow.value = listOf(buildDevice(now.plusMillis(4000), leftWorn = false, rightWorn = false)) + advanceUntilIdle() + deviceFlow.value = listOf(buildDevice(now.plusMillis(5000), leftWorn = false, rightWorn = false)) + advanceUntilIdle() + + coVerify(exactly = 0) { mediaControl.sendPause() } + + // T6: the new removal sequence reaches three consecutive not-worn samples. + deviceFlow.value = listOf(buildDevice(now.plusMillis(6000), leftWorn = false, rightWorn = false)) + advanceUntilIdle() + + coVerify(exactly = 1) { mediaControl.sendPause() } + + job.cancel() + } + + private fun buildIrkMatchedBle(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) = + mockk(relaxed = true) { + every { meta } returns ApplePods.AppleMeta( + isIRKMatch = true, + profile = mockk(relaxed = true), + ) + every { seenLastAt } returns seenAt + every { isLeftPodInEar } returns leftWorn + every { isRightPodInEar } returns rightWorn + every { isBeingWorn } returns (leftWorn && rightWorn) + every { isEitherPodInEar } returns (leftWorn || rightWorn) + every { primaryPod } returns DualBlePodSnapshot.Pod.LEFT + every { model } returns PodModel.AIRPODS_PRO3 + } + + private fun buildIrkMatchedDevice(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) = + PodDevice( + profileId = "test-profile", + ble = buildIrkMatchedBle(seenAt, leftWorn, rightWorn), + aap = null, + profileModel = PodModel.AIRPODS_PRO3, + reactions = ReactionConfig(autoPlay = true, autoPause = true), + ) + + private fun buildNoLiveBleDevice() = + PodDevice( + profileId = "test-profile", + ble = null, + aap = null, + profileModel = PodModel.AIRPODS_PRO3, + reactions = ReactionConfig(autoPlay = true, autoPause = true), + ) + + @Test + fun `flow - process start with pods worn does not fire autoplay`() = runTest { + // App process starts while user is already wearing pods. The first emission has + // no live BLE/AAP yet (cache-only). When live BLE arrives showing both worn, + // the synthetic null -> false coercion in toEarDetectionState would otherwise + // produce a fake not-worn -> worn transition and fire autoplay. With the + // NO_LIVE_BLE-previous guard, the reaction must be suppressed. + 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 false + every { wasRecentlyPausedByCap } returns false + } + val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl) + + val now = Instant.parse("2026-01-01T00:00:00Z") + val job = launch { flowPlayPause.monitor().collect {} } + + // T0: process-start emission — no live BLE, only cached profile state. + deviceFlow.value = listOf(buildNoLiveBleDevice()) + advanceUntilIdle() + + // T1: live IRK-matched BLE arrives showing both pods worn. Previous emission + // had no live evidence, so the transition must NOT fire autoplay. + deviceFlow.value = listOf(buildIrkMatchedDevice(now, leftWorn = true, rightWorn = true)) + advanceUntilIdle() + + coVerify(exactly = 0) { mediaControl.sendPlay() } + + // T2: another live worn sample. Previous is now live worn — same wear state, + // no transition, no action. autoplay still must not fire. + deviceFlow.value = listOf(buildIrkMatchedDevice(now.plusMillis(1500), leftWorn = true, rightWorn = true)) + advanceUntilIdle() + + coVerify(exactly = 0) { mediaControl.sendPlay() } + + job.cancel() + } + + @Test + fun `flow - genuine pod insertion after process start fires autoplay normally`() = runTest { + // After the no-live-evidence baseline is replaced by a stable live not-worn + // state, a real not-worn -> worn transition must still fire autoplay. + 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 false + every { wasRecentlyPausedByCap } returns false + } + val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl) + + val now = Instant.parse("2026-01-01T00:00:00Z") + val job = launch { flowPlayPause.monitor().collect {} } + + // T0: cache-only baseline. + deviceFlow.value = listOf(buildNoLiveBleDevice()) + advanceUntilIdle() + + // T1: live IRK-matched BLE arrives, pods NOT worn. + deviceFlow.value = listOf(buildIrkMatchedDevice(now, leftWorn = false, rightWorn = false)) + advanceUntilIdle() + + coVerify(exactly = 0) { mediaControl.sendPlay() } + + // T2: user inserts pods. Genuine not-worn -> worn transition with both + // previous and current carrying live evidence. autoplay fires. + deviceFlow.value = listOf(buildIrkMatchedDevice(now.plusMillis(1500), leftWorn = true, rightWorn = true)) + advanceUntilIdle() + + coVerify(exactly = 1) { mediaControl.sendPlay() } + + job.cancel() + } + + @Test + fun `flow - mid-session BLE gap does not fire false reactions on resume`() = runTest { + // Existing live evidence -> BLE drops out (NO_LIVE_BLE) -> BLE comes back. + // The recovery sample's previous is NO_LIVE_BLE, so no reaction must fire + // even if the wear state happens to differ from the synthetic baseline. + 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() } returns true + } + val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl) + + val now = Instant.parse("2026-01-01T00:00:00Z") + val job = launch { flowPlayPause.monitor().collect {} } + + // T0: stable live worn baseline. + deviceFlow.value = listOf(buildIrkMatchedDevice(now, leftWorn = true, rightWorn = true)) + advanceUntilIdle() + + // T1: BLE gap — device evicted from live cache, only profile state remains. + deviceFlow.value = listOf(buildNoLiveBleDevice()) + advanceUntilIdle() + + // T2: BLE resumes with worn=true. Previous is NO_LIVE_BLE, so the recovery + // sample must not be treated as a fresh transition. + deviceFlow.value = listOf(buildIrkMatchedDevice(now.plusMillis(2000), leftWorn = true, rightWorn = true)) + advanceUntilIdle() + + coVerify(exactly = 0) { mediaControl.sendPause() } + coVerify(exactly = 0) { mediaControl.sendPlay() } + + job.cancel() + } + + @Test + fun `flow - IRK-matched source fires autoplay on first worn sample without confirmation`() = runTest { + // For BLE_IRK_MATCH source, BLE-only autoplay confirmation should be skipped + // (mirroring the pause-debounce skip). Otherwise an IRK-matched device with no + // live AAP would stage a confirmation that never fires — the 2nd worn sample + // has no freshness on the monitor key for IRK_MATCH and gets collapsed. + 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 false + every { wasRecentlyPausedByCap } returns false + } + val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl) + + val now = Instant.parse("2026-01-01T00:00:00Z") + val job = launch { flowPlayPause.monitor().collect {} } + + // T0: not-worn baseline + deviceFlow.value = listOf(buildIrkMatchedDevice(now, leftWorn = false, rightWorn = false)) + advanceUntilIdle() + + // T1: pods inserted. With IRK_MATCH source, autoplay must fire immediately + // (no waiting for a 2nd sample). + deviceFlow.value = listOf(buildIrkMatchedDevice(now.plusMillis(1000), leftWorn = true, rightWorn = true)) + advanceUntilIdle() + + coVerify(exactly = 1) { mediaControl.sendPlay() } + + job.cancel() + } + + @Test + fun `flow - BLE-only autoplay confirmation fires after second worn sample`() = runTest { + // Verifies that the freshness field passes a 2nd identical worn sample through + // monitor distinct filtering, so applyBleOnlyPlayConfirmation can confirm a + // staged play. This is the inverse of the pause-debounce flow test: the same + // mechanism that lets repeated not-worn samples advance the pause counter must + // also let repeated worn samples confirm a staged BLE-only autoplay. + 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 false + every { wasRecentlyPausedByCap } returns false + } + val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl) + + val now = Instant.parse("2026-01-01T00:00:00Z") + val job = launch { flowPlayPause.monitor().collect {} } + + // T0: not-worn baseline + deviceFlow.value = listOf(buildDevice(now, leftWorn = false, rightWorn = false)) + advanceUntilIdle() + + // T1: pods inserted (transition not-worn -> worn). BLE-only path stages an + // autoplay confirmation; sendPlay is suppressed pending a second worn sample. + deviceFlow.value = listOf(buildDevice(now.plusMillis(1000), leftWorn = true, rightWorn = true)) + advanceUntilIdle() + + coVerify(exactly = 0) { mediaControl.sendPlay() } + + // T2: identical worn sample with a fresher seenLastAt. The freshness field + // must let it through monitor distinct filtering so the staged play confirms. + deviceFlow.value = listOf(buildDevice(now.plusMillis(2000), leftWorn = true, rightWorn = true)) + advanceUntilIdle() + + coVerify(exactly = 1) { mediaControl.sendPlay() } + + job.cancel() + } } }