diff --git a/app/src/main/java/eu/darken/capod/common/MediaControl.kt b/app/src/main/java/eu/darken/capod/common/MediaControl.kt index 362e3c8e..cc5c0727 100644 --- a/app/src/main/java/eu/darken/capod/common/MediaControl.kt +++ b/app/src/main/java/eu/darken/capod/common/MediaControl.kt @@ -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) { + 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 } } 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 27a89c7f..ad7d09a0 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 @@ -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( diff --git a/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt b/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt index 47338687..1b9f786d 100644 --- a/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt +++ b/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt @@ -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 @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() + 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) + } } 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 e6a5edf6..f3cfabdd 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 @@ -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>(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() + } } }