fix(reaction): Restore BLE-only autoplay and stop false reactions on app start

- Apply seenLastAt freshness to all unauthenticated BLE samples (worn and not-worn). The earlier scoping to not-worn-only collapsed the second worn sample for BLE-only autoplay confirmation, so the staged play never fired.

- Replace distinctUntilChangedBy with a manual filter so worn samples that need to reset an active pause debounce (count went up) can pass through even when the monitor key is otherwise identical.

- Skip BLE-only autoplay confirmation for trusted sources. With BLE_IRK_MATCH and AAP, autoplay now fires on the first not-worn -> worn transition, mirroring the pause-debounce skip on the same sources.

- Skip the reaction entirely when the previous emission had no live evidence (NO_LIVE_BLE). Prevents app-process-start from synthesising a fake not-worn -> worn transition and firing autoplay while the user is already wearing the pods. Same guard handles mid-session BLE gap recoveries.

- Add MonitorFlowTests covering process-start-worn, genuine-insertion-after-startup, mid-session BLE-gap recovery, IRK-matched immediate autoplay, BLE-only autoplay confirmation, 3-sample pause debounce, and rebound-tolerated debounce reset.
This commit is contained in:
darken
2026-04-29 18:14:51 +02:00
committed by Matthias Urhahn
parent 228d7d1f0c
commit e6dbd2d660
2 changed files with 362 additions and 16 deletions
@@ -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.
*
@@ -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<DualApplePods>(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<eu.darken.capod.profiles.core.AppleDeviceProfile>(relaxed = true)
val ble = mockk<DualApplePods>(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<List<PodDevice>>(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<DualApplePods>(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<List<PodDevice>>(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<List<PodDevice>>(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<List<PodDevice>>(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<List<PodDevice>>(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<List<PodDevice>>(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()
}
}
}