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.
*