fix(reaction): Don't auto-resume music after a user-initiated pause

This commit is contained in:
Matthias Urhahn
2026-05-07 14:08:22 +02:00
committed by Matthias Urhahn
parent 9d41979499
commit 278257998b
4 changed files with 490 additions and 7 deletions
@@ -1,6 +1,7 @@
package eu.darken.capod.common
import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.view.KeyEvent
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.log
@@ -16,12 +17,78 @@ class MediaControl @Inject constructor(
) {
private var capPauseExpiryElapsedRealtime: Long = 0L
private val transitionLock = Any()
@Volatile private var lastKnownMusicActive: Boolean = false
@Volatile private var externalStopAt: Long = NO_TIMESTAMP
@Volatile private var capPauseDispatchedAt: Long = NO_TIMESTAMP
private val playbackCallback = object : AudioManager.AudioPlaybackCallback() {
override fun onPlaybackConfigChanged(configs: List<AudioPlaybackConfiguration>) {
recordTransition(audioManager.isMusicActive)
}
}
init {
// Seed from current state so we can't miss a true→false transition when
// MediaControl is constructed while music is already active.
lastKnownMusicActive = audioManager.isMusicActive
audioManager.registerAudioPlaybackCallback(playbackCallback, null)
}
val isPlaying: Boolean
get() = audioManager.isMusicActive
val wasRecentlyPausedByCap: Boolean
get() = capPauseExpiryElapsedRealtime > timeSource.elapsedRealtime()
/**
* `true` when music has been stopped recently by something *other* than CAP — i.e. the
* user paused via the phone, the playing app stopped on its own, or playback ended.
*
* Used by [PlayPause] to suppress auto-play on pod re-insertion when the user clearly
* wanted music to stay stopped. Stays `false` for stops attributed to CAP — those are
* detected by [recordTransition] from the pending [capPauseDispatchedAt] set by
* [sendPause].
*/
val wasMusicExternallyStoppedRecently: Boolean
get() {
val nowActive = audioManager.isMusicActive
// Defense in depth: if the callback was missed (race, re-register, etc.), record
// the transition on read.
if (lastKnownMusicActive != nowActive) recordTransition(nowActive)
if (nowActive) return false
val stoppedAt = externalStopAt
return stoppedAt != NO_TIMESTAMP &&
timeSource.elapsedRealtime() - stoppedAt < EXTERNAL_STOP_WINDOW_MS
}
private fun recordTransition(nowActive: Boolean) = synchronized(transitionLock) {
if (lastKnownMusicActive && !nowActive) {
val now = timeSource.elapsedRealtime()
val pendingAt = capPauseDispatchedAt
val byCap = pendingAt != NO_TIMESTAMP &&
(now - pendingAt) < CAP_PAUSE_ATTRIBUTION_TTL_MS
if (byCap) {
// Consume the pending CAP attribution; clear any stale prior external stop
// since we're attributing the *current* state-of-music to CAP.
capPauseDispatchedAt = NO_TIMESTAMP
externalStopAt = NO_TIMESTAMP
} else {
externalStopAt = now
// Stale pending dispatch (TTL exceeded — pause was probably ignored). Drop it
// so a future stop isn't misattributed.
capPauseDispatchedAt = NO_TIMESTAMP
}
} else if (!lastKnownMusicActive && nowActive) {
// Music is active again — any pending CAP attribution is stale, and any prior
// external stop is no longer "recent" (music has been resumed since).
capPauseDispatchedAt = NO_TIMESTAMP
externalStopAt = NO_TIMESTAMP
}
lastKnownMusicActive = nowActive
}
suspend fun sendPlay() {
log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
@@ -48,6 +115,12 @@ class MediaControl @Inject constructor(
log(TAG, INFO) { "Music is not playing, not sending pause" }
return false
}
// Set BEFORE dispatch so the resulting active→inactive transition (whether observed
// by the playback callback or detected by the getter's read-time fallback) attributes
// the stop to CAP. Held under transitionLock for memory ordering against recordTransition.
synchronized(transitionLock) {
capPauseDispatchedAt = timeSource.elapsedRealtime()
}
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
markRecentCapPause()
return true
@@ -112,5 +185,12 @@ class MediaControl @Inject constructor(
companion object {
private val TAG = logTag("MediaControl")
private const val RECENT_CAP_PAUSE_WINDOW_MS = 15_000L
private const val EXTERNAL_STOP_WINDOW_MS = 60_000L
// TTL for a pending CAP-pause attribution. Long enough to cover delayed/missed
// playback-config callbacks (the read-time fallback may fire many seconds later);
// short enough that an "ignored pause" doesn't wrongly claim a much later external
// stop as CAP-attributed.
private const val CAP_PAUSE_ATTRIBUTION_TTL_MS = 30_000L
private const val NO_TIMESTAMP = -1L
}
}
@@ -169,6 +169,7 @@ class PlayPause @Inject constructor(
val isCurrentlyPlaying = mediaControl.isPlaying
val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap
val wasMusicExternallyStoppedRecently = mediaControl.wasMusicExternallyStoppedRecently
val source = current.earDetectionSource()
@@ -179,6 +180,7 @@ class PlayPause @Inject constructor(
onePodMode = reactions.onePodMode,
isCurrentlyPlaying = isCurrentlyPlaying,
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
wasMusicExternallyStoppedRecently = wasMusicExternallyStoppedRecently,
)
// BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources.
@@ -204,6 +206,7 @@ class PlayPause @Inject constructor(
shouldStageBleOnlyPlay = shouldStageBleOnlyPlay,
isCurrentlyPlaying = isCurrentlyPlaying,
wasRecentlyPausedByUs = wasRecentlyPausedByUs,
wasMusicExternallyStoppedRecently = wasMusicExternallyStoppedRecently,
)
pendingPlayConfirmation = confirmation.pending
@@ -288,10 +291,11 @@ class PlayPause @Inject constructor(
onePodMode: Boolean,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean = false,
wasMusicExternallyStoppedRecently: Boolean = false,
): PlayPauseDecision = if (onePodMode) {
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, wasMusicExternallyStoppedRecently)
} else {
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, wasMusicExternallyStoppedRecently)
}
private fun evaluateOnePodMode(
@@ -299,6 +303,7 @@ class PlayPause @Inject constructor(
current: EarDetectionState,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean,
wasMusicExternallyStoppedRecently: Boolean,
): PlayPauseDecision {
val netChange = current.podCount - previous.podCount
@@ -310,8 +315,14 @@ class PlayPause @Inject constructor(
reason = "One-pod mode: pod(s) removed (net change: $netChange)"
)
// Net increase: pod(s) inserted → play
netChange > 0 && (!isCurrentlyPlaying || wasRecentlyPausedByUs) -> PlayPauseDecision(
// Net increase: pod(s) inserted → play, but only when either we paused recently
// (resume our pause / handle the AudioManager.isMusicActive race) or there is no
// sign of a recent external stop (cold start). Suppress autoplay otherwise so
// CAP doesn't override a user-initiated pause.
netChange > 0 && (
wasRecentlyPausedByUs ||
(!isCurrentlyPlaying && !wasMusicExternallyStoppedRecently)
) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)",
@@ -332,13 +343,20 @@ class PlayPause @Inject constructor(
current: EarDetectionState,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByCap: Boolean,
wasMusicExternallyStoppedRecently: Boolean,
): PlayPauseDecision {
val wasWorn = previous.bothInEar
val isWorn = current.bothInEar
return when {
// Transition: not worn → worn, and not playing → play
!wasWorn && isWorn && (!isCurrentlyPlaying || wasRecentlyPausedByCap) -> PlayPauseDecision(
// Transition: not worn → worn → play, but only when either we paused recently
// (resume our pause / handle the AudioManager.isMusicActive race) or there is no
// sign of a recent external stop (cold start). Suppress autoplay otherwise so
// CAP doesn't override a user-initiated pause.
!wasWorn && isWorn && (
wasRecentlyPausedByCap ||
(!isCurrentlyPlaying && !wasMusicExternallyStoppedRecently)
) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "Normal mode: both pods in ear",
@@ -371,6 +389,7 @@ class PlayPause @Inject constructor(
shouldStageBleOnlyPlay: Boolean,
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean,
wasMusicExternallyStoppedRecently: Boolean = false,
): PlayConfirmationResult {
val activePending = pending?.takeIf {
it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled
@@ -378,7 +397,10 @@ class PlayPause @Inject constructor(
if (activePending != null &&
currentState == activePending.targetState &&
(!isCurrentlyPlaying || wasRecentlyPausedByUs)
(
wasRecentlyPausedByUs ||
(!isCurrentlyPlaying && !wasMusicExternallyStoppedRecently)
)
) {
return PlayConfirmationResult(
decision = PlayPauseDecision(
@@ -1,11 +1,13 @@
package eu.darken.capod.common
import android.media.AudioManager
import io.mockk.CapturingSlot
import io.mockk.Runs
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertFalse
@@ -14,12 +16,14 @@ import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.TestTimeSource
import java.time.Duration
class MediaControlTest : BaseTest() {
private lateinit var audioManager: AudioManager
private lateinit var mediaControl: MediaControl
private lateinit var timeSource: TestTimeSource
private lateinit var playbackCallbackSlot: CapturingSlot<AudioManager.AudioPlaybackCallback>
@BeforeEach
fun setup() {
@@ -29,9 +33,16 @@ class MediaControlTest : BaseTest() {
)
audioManager = mockk(relaxed = true)
every { audioManager.dispatchMediaKeyEvent(any()) } just Runs
every { audioManager.isMusicActive } returns false
playbackCallbackSlot = slot()
every { audioManager.registerAudioPlaybackCallback(capture(playbackCallbackSlot), any()) } just Runs
mediaControl = MediaControl(audioManager, timeSource)
}
private fun fireCallback() {
playbackCallbackSlot.captured.onPlaybackConfigChanged(emptyList())
}
@Test
fun `sendPlay ignores stale active state after cap pause`() = runTest {
every { audioManager.isMusicActive } returns true
@@ -83,4 +94,194 @@ class MediaControlTest : BaseTest() {
assertFalse(mediaControl.wasRecentlyPausedByCap)
verify(exactly = 0) { audioManager.dispatchMediaKeyEvent(any()) }
}
@Test
fun `wasMusicExternallyStoppedRecently is false on cold start`() {
// No music ever observed.
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `wasMusicExternallyStoppedRecently is false while music is currently active`() {
every { audioManager.isMusicActive } returns true
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `external stop is recorded when music goes inactive without a preceding sendPause`() {
every { audioManager.isMusicActive } returns true
fireCallback() // seeds lastKnownMusicActive=true
every { audioManager.isMusicActive } returns false
fireCallback() // active->inactive transition with no recent CAP pause
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `cap stop is NOT classified as external`() = runTest {
every { audioManager.isMusicActive } returns true
fireCallback() // seeds lastKnownMusicActive=true
mediaControl.sendPause() // sets lastCapPauseDispatchAt synchronously
every { audioManager.isMusicActive } returns false
fireCallback() // active->inactive immediately after our pause
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `external-stop window expires after 60 seconds`() {
every { audioManager.isMusicActive } returns true
fireCallback()
every { audioManager.isMusicActive } returns false
fireCallback()
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
timeSource.advanceBy(Duration.ofSeconds(61))
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `external-stop window boundary - 59s in, 60s out`() {
every { audioManager.isMusicActive } returns true
fireCallback()
every { audioManager.isMusicActive } returns false
fireCallback()
timeSource.advanceBy(Duration.ofMillis(59_999))
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
timeSource.advanceBy(Duration.ofMillis(1)) // now exactly 60_000ms after stop
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `init seeds lastKnownMusicActive from current state`() {
// Construct a fresh MediaControl with isMusicActive=true at construction. The first
// active->inactive callback must fire the transition logic — without the seed it
// would incorrectly believe the previous state was inactive and miss the stop.
val freshAudioManager: AudioManager = mockk(relaxed = true)
every { freshAudioManager.dispatchMediaKeyEvent(any()) } just Runs
every { freshAudioManager.isMusicActive } returns true
val freshSlot = slot<AudioManager.AudioPlaybackCallback>()
every { freshAudioManager.registerAudioPlaybackCallback(capture(freshSlot), any()) } just Runs
val freshControl = MediaControl(freshAudioManager, timeSource)
// Music goes off (e.g. user pause) — this is the first transition we observe.
every { freshAudioManager.isMusicActive } returns false
freshSlot.captured.onPlaybackConfigChanged(emptyList())
assertTrue(freshControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `getter self-heals when callback was missed`() {
// Seed: callback fired with active=true. Then the active->inactive transition happens
// but the callback never fires (race / missed event). The getter should detect the
// mismatch on read and record the stop itself.
every { audioManager.isMusicActive } returns true
fireCallback()
every { audioManager.isMusicActive } returns false
// No fireCallback() — simulate missed event.
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `delayed cap callback within TTL is still attributed to cap`() = runTest {
every { audioManager.isMusicActive } returns true
fireCallback()
mediaControl.sendPause()
every { audioManager.isMusicActive } returns false
timeSource.advanceBy(Duration.ofSeconds(10)) // realistic-but-late callback
fireCallback()
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `cap stop self-heals via getter when callback was missed past the old short window`() = runTest {
// Codex review scenario: CAP pauses, the playback callback never fires, the user
// reinserts a pod some seconds later. The getter must still attribute the stop to
// CAP — not regress to recreating the 16-60s "dead zone" that the original fix had.
every { audioManager.isMusicActive } returns true
fireCallback()
mediaControl.sendPause()
every { audioManager.isMusicActive } returns false
timeSource.advanceBy(Duration.ofSeconds(16))
// No fireCallback() — the callback was missed.
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `pending cap dispatch is dropped after TTL so an unrelated later stop is external`() = runTest {
// Pause was dispatched but ignored (music kept playing). After TTL, the next genuine
// active→inactive transition must NOT be misattributed to CAP.
every { audioManager.isMusicActive } returns true
fireCallback()
mediaControl.sendPause()
// Music ignored the key — still active. Time passes.
timeSource.advanceBy(Duration.ofSeconds(31)) // > CAP_PAUSE_ATTRIBUTION_TTL_MS
every { audioManager.isMusicActive } returns false
fireCallback() // unrelated stop
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `prior external stop is cleared when music resumes so it does not suppress a later cap pause cycle`() = runTest {
// T=0: external stop.
every { audioManager.isMusicActive } returns true
fireCallback()
every { audioManager.isMusicActive } returns false
fireCallback()
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
// Music resumes (e.g. user starts a new song manually) — must clear externalStopAt.
timeSource.advanceBy(Duration.ofSeconds(5))
every { audioManager.isMusicActive } returns true
fireCallback()
// CAP pauses fresh. The earlier external stop must not bleed through.
timeSource.advanceBy(Duration.ofSeconds(5))
mediaControl.sendPause()
every { audioManager.isMusicActive } returns false
fireCallback()
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
}
@Test
fun `pending cap dispatch is dropped when music transitions to active before being consumed`() = runTest {
// sendPause was dispatched but the pause was effectively ignored — observable as a
// sustained inactive→active transition without ever going inactive. The next external
// stop must still be classified as external.
every { audioManager.isMusicActive } returns false
fireCallback() // seed lastKnownMusicActive=false
every { audioManager.isMusicActive } returns true
mediaControl.sendPause()
// sendPause sets pending; but isMusicActive is now true (not actually paused).
fireCallback() // observe inactive→active; clears pending
// Some time later, a genuine external stop happens.
timeSource.advanceBy(Duration.ofSeconds(2))
every { audioManager.isMusicActive } returns false
fireCallback()
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
}
}
@@ -223,6 +223,69 @@ class PlayPauseLogicTest : BaseTest() {
decision.shouldPlay shouldBe false
decision.shouldPause shouldBe false
}
@Test
fun `one in to both in - user paused externally, do not auto-play`() {
// Bug repro: user manually paused, then took one pod out and put it back.
// Music isn't playing and we didn't pause it — but it was stopped recently
// by something external (user). Suppress autoplay.
val previous = EarDetectionState.fromDualPod(left = true, right = false)
val current = EarDetectionState.fromDualPod(left = true, right = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous,
current = current,
onePodMode = false,
isCurrentlyPlaying = false,
wasRecentlyPausedByUs = false,
wasMusicExternallyStoppedRecently = true,
)
decision.shouldPlay shouldBe false
decision.shouldPause shouldBe false
}
@Test
fun `one in to both in - cap-paused wins over external-stop suppression`() {
// Even if `wasMusicExternallyStoppedRecently` is also true (e.g. CAP attribution
// window expired and another non-CAP stop happened), `wasRecentlyPausedByUs`
// must still take precedence and resume.
val previous = EarDetectionState.fromDualPod(left = true, right = false)
val current = EarDetectionState.fromDualPod(left = true, right = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous,
current = current,
onePodMode = false,
isCurrentlyPlaying = false,
wasRecentlyPausedByUs = true,
wasMusicExternallyStoppedRecently = true,
)
decision.shouldPlay shouldBe true
decision.shouldPause shouldBe false
}
@Test
fun `none in to both in - cold start with no history fires play`() {
// No external stop recorded, music not playing, we didn't pause — pure
// cold start. Auto-play should still fire so the existing "put pods on
// to wake the media app" behavior is preserved.
val previous = EarDetectionState.fromDualPod(left = false, right = false)
val current = EarDetectionState.fromDualPod(left = true, right = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous,
current = current,
onePodMode = false,
isCurrentlyPlaying = false,
wasRecentlyPausedByUs = false,
wasMusicExternallyStoppedRecently = false,
)
decision.shouldPlay shouldBe true
decision.shouldPause shouldBe false
}
}
@Nested
@@ -496,6 +559,43 @@ class PlayPauseLogicTest : BaseTest() {
decision2to3.shouldPlay shouldBe false
decision2to3.shouldPause shouldBe true
}
@Test
fun `one-pod mode pod insertion - external stop suppresses autoplay`() {
// Bug repro in one-pod mode: user manually paused, removed one pod, put it back.
val previous = EarDetectionState.fromDualPod(left = true, right = false)
val current = EarDetectionState.fromDualPod(left = true, right = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous,
current = current,
onePodMode = true,
isCurrentlyPlaying = false,
wasRecentlyPausedByUs = false,
wasMusicExternallyStoppedRecently = true,
)
decision.shouldPlay shouldBe false
decision.shouldPause shouldBe false
}
@Test
fun `one-pod mode pod insertion - cap-paused still resumes despite external stop flag`() {
val previous = EarDetectionState.fromDualPod(left = true, right = false)
val current = EarDetectionState.fromDualPod(left = true, right = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous,
current = current,
onePodMode = true,
isCurrentlyPlaying = false,
wasRecentlyPausedByUs = true,
wasMusicExternallyStoppedRecently = true,
)
decision.shouldPlay shouldBe true
decision.shouldPause shouldBe false
}
}
@Nested
@@ -629,6 +729,43 @@ class PlayPauseLogicTest : BaseTest() {
result.stagedConfirmation shouldBe false
result.confirmedPendingPlay shouldBe false
}
@Test
fun `ble-only confirmation suppressed when music was externally stopped recently`() {
// A staged BLE-only autoplay must not confirm if an external stop happened in
// the meantime — even if the second worn sample matches.
val pending = PlayPause.PendingPlayConfirmation(
profileId = "profile",
onePodMode = false,
targetState = EarDetectionState.fromDualPod(left = true, right = true),
reason = "Normal mode: both pods in ear",
)
val rawDecision = playPause.evaluatePlayPauseAction(
previous = EarDetectionState.fromDualPod(left = true, right = true),
current = EarDetectionState.fromDualPod(left = true, right = true),
onePodMode = false,
isCurrentlyPlaying = false,
wasRecentlyPausedByUs = false,
wasMusicExternallyStoppedRecently = true,
)
val result = playPause.applyBleOnlyPlayConfirmation(
pending = pending,
profileId = "profile",
onePodMode = false,
autoPlayEnabled = true,
rawDecision = rawDecision,
currentState = EarDetectionState.fromDualPod(left = true, right = true),
shouldStageBleOnlyPlay = false,
isCurrentlyPlaying = false,
wasRecentlyPausedByUs = false,
wasMusicExternallyStoppedRecently = true,
)
result.decision.shouldPlay shouldBe false
result.confirmedPendingPlay shouldBe false
}
}
@Nested
@@ -1887,5 +2024,48 @@ class PlayPauseLogicTest : BaseTest() {
job.cancel()
}
@Test
fun `flow - user-paused before pod cycle does not auto-resume`() = runTest {
// Bug repro: while wearing pods, user manually pauses music via the phone, then
// takes one pod out and puts it back. Auto-play must NOT fire because the user
// (not CAP) is the one who stopped playback. Uses the IRK-matched source so the
// BLE-only autoplay confirmation step is bypassed and the decision lands on
// a single transition.
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
every { wasMusicExternallyStoppedRecently } 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(buildIrkMatchedDevice(now, leftWorn = true, rightWorn = true))
advanceUntilIdle()
// T1: one pod removed. Music is already paused, so no autoPause fires.
deviceFlow.value = listOf(buildIrkMatchedDevice(now.plusMillis(1000), leftWorn = true, rightWorn = false))
advanceUntilIdle()
coVerify(exactly = 0) { mediaControl.sendPause() }
// T2: pod reinserted. Auto-play must NOT fire — the user paused, not CAP.
deviceFlow.value = listOf(buildIrkMatchedDevice(now.plusMillis(2000), leftWorn = true, rightWorn = true))
advanceUntilIdle()
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
}
}