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 cc5c0727..24a8893a 100644 --- a/app/src/main/java/eu/darken/capod/common/MediaControl.kt +++ b/app/src/main/java/eu/darken/capod/common/MediaControl.kt @@ -15,23 +15,34 @@ class MediaControl @Inject constructor( private val audioManager: AudioManager, private val timeSource: TimeSource, ) { - private var capPauseExpiryElapsedRealtime: Long = 0L - - private val transitionLock = Any() - + /** + * Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay] + * dispatches a resume or when music transitions inactive→active from any source. Read by + * [PlayPause] to gate auto-resume on pod-in: only resume if we're the ones who paused. + * + * "Sticky" — no time-based expiry. The original 15-second window was too short for typical + * pod-out conversations and had a known race where music starting again via another source + * (e.g. user manually resumed) could trigger a stray play-key dispatch on the next pod-in. + */ + @Volatile private var capPaused: Boolean = false @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) + val nowActive = audioManager.isMusicActive + if (!lastKnownMusicActive && nowActive) { + // Music started by some source (could be us via sendPlay or someone else). + // Either way, our pause memory is stale — drop it so a future pod-in doesn't + // re-fire a play key over already-active music. + capPaused = false + } + lastKnownMusicActive = nowActive } } init { - // Seed from current state so we can't miss a true→false transition when - // MediaControl is constructed while music is already active. + // Seed the active flag from current state so we won't miss the next inactive→active + // transition if music is already playing when MediaControl is constructed. lastKnownMusicActive = audioManager.isMusicActive audioManager.registerAudioPlaybackCallback(playbackCallback, null) } @@ -40,74 +51,27 @@ class MediaControl @Inject constructor( 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 - } + get() = capPaused suspend fun sendPlay() { log(TAG, INFO) { "sendPlay()" } - if (audioManager.isMusicActive && !wasRecentlyPausedByCap) { + if (audioManager.isMusicActive && !capPaused) { log(TAG, INFO) { "Music is already playing, not sending play" } return } sendKey(KeyEvent.KEYCODE_MEDIA_PLAY) - clearRecentCapPause() + capPaused = false } /** * Dispatches a MEDIA_PAUSE key event if music is currently playing. * - * Returns `true` when a key event was actually dispatched (and the 15-second - * [wasRecentlyPausedByCap] window was set), `false` when the call was a no-op because - * nothing was playing. Callers that need to distinguish "we actually paused" from "there - * was nothing to pause" — e.g. the sleep reaction, which gates its notification and - * cooldown on a real pause — should branch on the return value rather than checking - * [isPlaying] themselves to avoid a check-then-act race with the audio system. + * Returns `true` when a key event was actually dispatched (and the [wasRecentlyPausedByCap] + * flag was set), `false` when the call was a no-op because nothing was playing. Callers that + * need to distinguish "we actually paused" from "there was nothing to pause" — e.g. the sleep + * reaction, which gates its notification and cooldown on a real pause — should branch on the + * return value rather than checking [isPlaying] themselves to avoid a check-then-act race + * with the audio system. */ suspend fun sendPause(): Boolean { log(TAG, INFO) { "sendPause()" } @@ -115,20 +79,19 @@ 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() - } + // Set BEFORE the suspending sendKey() call. If we set after, an inactive→active + // playback callback that fires during the dispatch (e.g. a fast user resume on the + // phone, or another app grabbing audio focus and immediately starting) could clear + // capPaused, and then we'd overwrite it back to true on a stale pause — leaving the + // sticky flag set while music is genuinely playing. + capPaused = true sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE) - markRecentCapPause() return true } suspend fun sendPlayPause() { log(TAG) { "sendPlayPause()" } - if (wasRecentlyPausedByCap) { + if (capPaused) { sendPlay() return } @@ -174,23 +137,7 @@ class MediaControl @Inject constructor( ) } - private fun markRecentCapPause() { - capPauseExpiryElapsedRealtime = timeSource.elapsedRealtime() + RECENT_CAP_PAUSE_WINDOW_MS - } - - private fun clearRecentCapPause() { - capPauseExpiryElapsedRealtime = 0L - } - 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/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index e1b0c89a..119c913c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -167,6 +167,7 @@ fun DeviceSettingsScreenHost( onOnePodModeChange = { vm.setOnePodMode(it) }, onAutoPlayChange = { vm.setAutoPlay(it) }, onAutoPauseChange = { vm.setAutoPause(it) }, + onStartMusicOnWearChange = { vm.setStartMusicOnWear(it) }, onAutoConnectChange = { vm.setAutoConnect(it) }, onAutoConnectConditionChange = { vm.setAutoConnectCondition(it) }, onShowPopUpOnCaseOpenChange = { vm.setShowPopUpOnCaseOpen(it) }, @@ -205,6 +206,7 @@ fun DeviceSettingsScreen( onOnePodModeChange: (Boolean) -> Unit = {}, onAutoPlayChange: (Boolean) -> Unit = {}, onAutoPauseChange: (Boolean) -> Unit = {}, + onStartMusicOnWearChange: (Boolean) -> Unit = {}, onAutoConnectChange: (Boolean) -> Unit = {}, onAutoConnectConditionChange: (AutoConnectCondition) -> Unit = {}, onShowPopUpOnCaseOpenChange: (Boolean) -> Unit = {}, @@ -342,6 +344,7 @@ fun DeviceSettingsScreen( monitorMode = state.monitorMode, onAutoPlayChange = onAutoPlayChange, onAutoPauseChange = onAutoPauseChange, + onStartMusicOnWearChange = onStartMusicOnWearChange, onOnePodModeChange = onOnePodModeChange, onConversationalAwarenessChange = onConversationalAwarenessChange, onSleepDetectionChange = onSleepDetectionChange, diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 69e50748..e92a0fc1 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -382,6 +382,15 @@ class DeviceSettingsViewModel @Inject constructor( syncEarDetection(autoPause = enabled) } + fun setStartMusicOnWear(enabled: Boolean) = launch { + log(TAG, INFO) { "setStartMusicOnWear($enabled)" } + if (enabled && !upgradeRepo.isPro()) { + navTo(Nav.Main.Upgrade) + return@launch + } + updateProfileNow { it.copy(startMusicOnWear = enabled) } + } + /** * Keeps the device-side Automatic Ear Detection setting in sync with the * auto-play / auto-pause reaction toggles. When either reaction is active diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt index 5de46790..154fc01e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/ReactionsCard.kt @@ -48,6 +48,7 @@ internal fun ReactionsCard( monitorMode: MonitorMode, onAutoPlayChange: (Boolean) -> Unit = {}, onAutoPauseChange: (Boolean) -> Unit = {}, + onStartMusicOnWearChange: (Boolean) -> Unit = {}, onOnePodModeChange: (Boolean) -> Unit = {}, onConversationalAwarenessChange: (Boolean) -> Unit = {}, onSleepDetectionChange: (Boolean) -> Unit = {}, @@ -73,6 +74,16 @@ internal fun ReactionsCard( onCheckedChange = onAutoPlayChange, requiresUpgrade = !isPro, ) + if (reactions.autoPlay) { + SettingsSwitchItem( + icon = Icons.TwoTone.PlayCircle, + title = stringResource(R.string.settings_start_music_on_wear_label), + subtitle = stringResource(R.string.settings_start_music_on_wear_description), + checked = reactions.startMusicOnWear, + onCheckedChange = onStartMusicOnWearChange, + requiresUpgrade = !isPro, + ) + } SettingsSwitchItem( icon = Icons.TwoTone.PauseCircle, title = stringResource(R.string.settings_autopause_label), diff --git a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt index 9405abc4..62577fbe 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/AppleDeviceProfile.kt @@ -25,6 +25,7 @@ data class AppleDeviceProfile( @SerialName("address") override val address: String? = null, @SerialName("reactionAutoPause") val autoPause: Boolean = false, @SerialName("reactionAutoPlay") val autoPlay: Boolean = false, + @SerialName("reactionStartMusicOnWear") val startMusicOnWear: Boolean = false, @SerialName("reactionOnePodMode") val onePodMode: Boolean = false, @SerialName("reactionAutoConnect") val autoConnect: Boolean = false, @SerialName("reactionAutoConnectCondition") val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN, @@ -49,6 +50,7 @@ data class AppleDeviceProfile( get() = ReactionConfig( autoPause = autoPause, autoPlay = autoPlay, + startMusicOnWear = startMusicOnWear, onePodMode = onePodMode, autoConnect = autoConnect, autoConnectCondition = autoConnectCondition, @@ -62,6 +64,7 @@ data class AppleDeviceProfile( "identityKey=${if (identityKey == null) "null" else ""}, " + "encryptionKey=${if (encryptionKey == null) "null" else ""}, " + "address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " + + "startMusicOnWear=$startMusicOnWear, " + "onePodMode=$onePodMode, autoConnect=$autoConnect, " + "autoConnectCondition=$autoConnectCondition, " + "showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " + diff --git a/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt b/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt index 204bad35..c380fe9a 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/ReactionConfig.kt @@ -5,6 +5,7 @@ import eu.darken.capod.reaction.core.autoconnect.AutoConnectCondition data class ReactionConfig( val autoPause: Boolean = false, val autoPlay: Boolean = false, + val startMusicOnWear: Boolean = false, val onePodMode: Boolean = false, val autoConnect: Boolean = false, val autoConnectCondition: AutoConnectCondition = AutoConnectCondition.WHEN_SEEN, 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 ad7d09a0..8334c5e2 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,7 +169,6 @@ class PlayPause @Inject constructor( val isCurrentlyPlaying = mediaControl.isPlaying val wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap - val wasMusicExternallyStoppedRecently = mediaControl.wasMusicExternallyStoppedRecently val source = current.earDetectionSource() @@ -180,7 +179,7 @@ class PlayPause @Inject constructor( onePodMode = reactions.onePodMode, isCurrentlyPlaying = isCurrentlyPlaying, wasRecentlyPausedByUs = wasRecentlyPausedByUs, - wasMusicExternallyStoppedRecently = wasMusicExternallyStoppedRecently, + startMusicOnWear = reactions.startMusicOnWear, ) // BLE-only autoplay confirmation only applies to UNAUTHENTICATED sources. @@ -206,7 +205,7 @@ class PlayPause @Inject constructor( shouldStageBleOnlyPlay = shouldStageBleOnlyPlay, isCurrentlyPlaying = isCurrentlyPlaying, wasRecentlyPausedByUs = wasRecentlyPausedByUs, - wasMusicExternallyStoppedRecently = wasMusicExternallyStoppedRecently, + startMusicOnWear = reactions.startMusicOnWear, ) pendingPlayConfirmation = confirmation.pending @@ -291,11 +290,11 @@ class PlayPause @Inject constructor( onePodMode: Boolean, isCurrentlyPlaying: Boolean, wasRecentlyPausedByUs: Boolean = false, - wasMusicExternallyStoppedRecently: Boolean = false, + startMusicOnWear: Boolean = false, ): PlayPauseDecision = if (onePodMode) { - evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, wasMusicExternallyStoppedRecently) + evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear) } else { - evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, wasMusicExternallyStoppedRecently) + evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs, startMusicOnWear) } private fun evaluateOnePodMode( @@ -303,7 +302,7 @@ class PlayPause @Inject constructor( current: EarDetectionState, isCurrentlyPlaying: Boolean, wasRecentlyPausedByUs: Boolean, - wasMusicExternallyStoppedRecently: Boolean, + startMusicOnWear: Boolean, ): PlayPauseDecision { val netChange = current.podCount - previous.podCount @@ -315,13 +314,12 @@ class PlayPause @Inject constructor( reason = "One-pod mode: pod(s) removed (net change: $netChange)" ) - // 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. + // Net increase: pod(s) inserted → play, but only when we paused recently OR the user + // opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay + // so we never override a user-initiated pause or fire over silence. netChange > 0 && ( wasRecentlyPausedByUs || - (!isCurrentlyPlaying && !wasMusicExternallyStoppedRecently) + (startMusicOnWear && !isCurrentlyPlaying) ) -> PlayPauseDecision( shouldPlay = true, shouldPause = false, @@ -343,19 +341,18 @@ class PlayPause @Inject constructor( current: EarDetectionState, isCurrentlyPlaying: Boolean, wasRecentlyPausedByCap: Boolean, - wasMusicExternallyStoppedRecently: Boolean, + startMusicOnWear: Boolean, ): PlayPauseDecision { val wasWorn = previous.bothInEar val isWorn = current.bothInEar return when { - // 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. + // Transition: not worn → worn → play, but only when we paused recently OR the user + // opted in to firing a play key on cold wear. Without an opt-in, suppress autoplay + // so we never override a user-initiated pause or fire over silence. !wasWorn && isWorn && ( wasRecentlyPausedByCap || - (!isCurrentlyPlaying && !wasMusicExternallyStoppedRecently) + (startMusicOnWear && !isCurrentlyPlaying) ) -> PlayPauseDecision( shouldPlay = true, shouldPause = false, @@ -389,7 +386,7 @@ class PlayPause @Inject constructor( shouldStageBleOnlyPlay: Boolean, isCurrentlyPlaying: Boolean, wasRecentlyPausedByUs: Boolean, - wasMusicExternallyStoppedRecently: Boolean = false, + startMusicOnWear: Boolean = false, ): PlayConfirmationResult { val activePending = pending?.takeIf { it.profileId == profileId && it.onePodMode == onePodMode && autoPlayEnabled @@ -399,7 +396,7 @@ class PlayPause @Inject constructor( currentState == activePending.targetState && ( wasRecentlyPausedByUs || - (!isCurrentlyPlaying && !wasMusicExternallyStoppedRecently) + (startMusicOnWear && !isCurrentlyPlaying) ) ) { return PlayConfirmationResult( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 62361ecc..87ffaf48 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -53,7 +53,9 @@ Auto pause Pause audio when removing the device from your ear. Auto play - Start audio playback when device is worn. + Resume music when wearing your pods again, if Auto pause stopped it. Music you paused yourself stays paused. + Start music on wear + Always tries to play music when wearing your pods. May also restart music you paused yourself. Ear detection note If ear detection only works for one pod, this is an Apple limitation. Only the \"primary pod\" (used for microphone) is detected. Configure on Apple devices: Settings → Bluetooth → AirPods → Microphone. Fake data 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 1b9f786d..a36f93f8 100644 --- a/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt +++ b/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt @@ -16,7 +16,6 @@ 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() { @@ -89,199 +88,75 @@ class MediaControlTest : BaseTest() { val dispatched = mediaControl.sendPause() assertFalse(dispatched) - // Critical: the 15-second cap-pause window must NOT open for a no-op pause, otherwise - // an unrelated sendPlay would treat it as "we just paused, resume from it". + // Critical: the cap-pause flag must NOT be set for a no-op pause, otherwise an + // unrelated sendPlay would treat it as "we just paused, resume from it". 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`() { + fun `wasRecentlyPausedByCap is sticky and does not expire on its own`() = runTest { every { audioManager.isMusicActive } returns true - assertFalse(mediaControl.wasMusicExternallyStoppedRecently) + mediaControl.sendPause() + assertTrue(mediaControl.wasRecentlyPausedByCap) + + // Wait an arbitrarily long time. With the previous timer-based design this would have + // expired after 15 seconds; the sticky flag must remain true until cleared by an event. + timeSource.advanceBy(java.time.Duration.ofMinutes(30)) + + assertTrue(mediaControl.wasRecentlyPausedByCap) } @Test - fun `external stop is recorded when music goes inactive without a preceding sendPause`() { + fun `wasRecentlyPausedByCap clears when music transitions inactive to active from any source`() = runTest { 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 + // First the seed transition active→active so lastKnownMusicActive is true. fireCallback() mediaControl.sendPause() + assertTrue(mediaControl.wasRecentlyPausedByCap) + + // Music goes inactive (CAP's pause took effect). every { audioManager.isMusicActive } returns false - timeSource.advanceBy(Duration.ofSeconds(10)) // realistic-but-late callback - fireCallback() + assertTrue(mediaControl.wasRecentlyPausedByCap) - assertFalse(mediaControl.wasMusicExternallyStoppedRecently) + // Music starts again (e.g. user manually resumed via phone). Sticky flag must clear so + // a later pod-in doesn't fire a stray play key on top of already-playing music. + every { audioManager.isMusicActive } returns true + fireCallback() + assertFalse(mediaControl.wasRecentlyPausedByCap) } @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. + fun `sendPause sets capPaused before dispatching so a racing inactive-active callback cannot leave a stale true`() = runTest { + // Repro for a race where the playback config callback fires during sendKey()'s + // suspension. If capPaused were set after dispatch, an interleaved inactive→active + // callback would clear it, then sendPause's post-dispatch line would put it back to + // true while music is genuinely playing again — wrongly arming a future pod-in resume. every { audioManager.isMusicActive } returns true - fireCallback() + fireCallback() // seed lastKnownMusicActive=true + + // sendKey is implemented with an internal delay(100). Drive a callback during that + // window by sending a single coalesced inactive→active sequence right after kicking + // off sendPause; with the fix in place the sequence's effect on capPaused is the + // intended one (cleared on inactive→active, but only AFTER capPaused was set). + every { audioManager.dispatchMediaKeyEvent(any()) } answers { + // First DOWN dispatch: pretend music briefly went inactive then active mid-pause. + every { audioManager.isMusicActive } returns false + fireCallback() + 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) + // After the suspended dispatch returns, capPaused should be in a coherent state with + // the live callback observations. Music is currently active (per the racing callback) + // so the inactive→active reset clears the sticky flag — that's the correct outcome: + // we don't want to claim our pause "stuck" when audio is playing. + assertFalse(mediaControl.wasRecentlyPausedByCap) } } 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 f3cfabdd..a26d419d 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 @@ -79,7 +79,7 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `one in to both in - should play if not playing`() { + fun `one in to both in - should play if not playing (cold-wear opt-in)`() { val previous = EarDetectionState.fromDualPod(left = true, right = false) val current = EarDetectionState.fromDualPod(left = true, right = true) @@ -87,7 +87,8 @@ class PlayPauseLogicTest : BaseTest() { previous = previous, current = current, onePodMode = false, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + startMusicOnWear = true, ) decision.shouldPlay shouldBe true @@ -145,7 +146,7 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `none in to both in - should play if not playing`() { + fun `none in to both in - should play if not playing (cold-wear opt-in)`() { val previous = EarDetectionState.fromDualPod(left = false, right = false) val current = EarDetectionState.fromDualPod(left = true, right = true) @@ -153,7 +154,8 @@ class PlayPauseLogicTest : BaseTest() { previous = previous, current = current, onePodMode = false, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + startMusicOnWear = true, ) decision.shouldPlay shouldBe true @@ -225,10 +227,9 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `one in to both in - user paused externally, do not auto-play`() { + fun `one in to both in - default suppresses autoplay when not we-paused`() { // 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. + // We didn't pause and the user hasn't opted into cold-wear; suppress autoplay. val previous = EarDetectionState.fromDualPod(left = true, right = false) val current = EarDetectionState.fromDualPod(left = true, right = true) @@ -238,7 +239,7 @@ class PlayPauseLogicTest : BaseTest() { onePodMode = false, isCurrentlyPlaying = false, wasRecentlyPausedByUs = false, - wasMusicExternallyStoppedRecently = true, + startMusicOnWear = false, ) decision.shouldPlay shouldBe false @@ -246,45 +247,59 @@ class PlayPauseLogicTest : BaseTest() { } @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. + fun `one in to both in - we-paused resumes regardless of startMusicOnWear`() { + // wasRecentlyPausedByUs=true must take precedence over the cold-wear setting: + // resume our pause whether or not the user opted into cold-wear. val previous = EarDetectionState.fromDualPod(left = true, right = false) val current = EarDetectionState.fromDualPod(left = true, right = true) - val decision = playPause.evaluatePlayPauseAction( + val decisionWearOff = playPause.evaluatePlayPauseAction( previous = previous, current = current, onePodMode = false, isCurrentlyPlaying = false, wasRecentlyPausedByUs = true, - wasMusicExternallyStoppedRecently = true, + startMusicOnWear = false, ) + decisionWearOff.shouldPlay shouldBe true - decision.shouldPlay shouldBe true - decision.shouldPause shouldBe false + val decisionWearOn = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false, + wasRecentlyPausedByUs = true, + startMusicOnWear = true, + ) + decisionWearOn.shouldPlay shouldBe true } @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. + fun `none in to both in - cold start fires play only when startMusicOnWear is opted in`() { + // Default OFF: putting pods on with no music history does nothing. + // Opted in: same transition fires the cold-wear play key. val previous = EarDetectionState.fromDualPod(left = false, right = false) val current = EarDetectionState.fromDualPod(left = true, right = true) - val decision = playPause.evaluatePlayPauseAction( + val decisionDefault = playPause.evaluatePlayPauseAction( previous = previous, current = current, onePodMode = false, isCurrentlyPlaying = false, wasRecentlyPausedByUs = false, - wasMusicExternallyStoppedRecently = false, + startMusicOnWear = false, ) + decisionDefault.shouldPlay shouldBe false - decision.shouldPlay shouldBe true - decision.shouldPause shouldBe false + val decisionOptedIn = playPause.evaluatePlayPauseAction( + previous = previous, + current = current, + onePodMode = false, + isCurrentlyPlaying = false, + wasRecentlyPausedByUs = false, + startMusicOnWear = true, + ) + decisionOptedIn.shouldPlay shouldBe true } } @@ -324,7 +339,7 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `none in to one in - should play if not playing`() { + fun `none in to one in - should play if not playing (cold-wear opt-in)`() { val previous = EarDetectionState.fromDualPod(left = false, right = false) val current = EarDetectionState.fromDualPod(left = true, right = false) @@ -332,7 +347,8 @@ class PlayPauseLogicTest : BaseTest() { previous = previous, current = current, onePodMode = true, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + startMusicOnWear = true, ) decision.shouldPlay shouldBe true @@ -356,7 +372,7 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `one in to both in - should play if paused`() { + fun `one in to both in - should play if paused (cold-wear opt-in)`() { val previous = EarDetectionState.fromDualPod(left = true, right = false) val current = EarDetectionState.fromDualPod(left = true, right = true) @@ -364,7 +380,8 @@ class PlayPauseLogicTest : BaseTest() { previous = previous, current = current, onePodMode = true, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + startMusicOnWear = true, ) // NEW BEHAVIOR: In one-pod mode, inserting a pod triggers play @@ -478,7 +495,7 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `none in to both in - should play if not playing`() { + fun `none in to both in - should play if not playing (cold-wear opt-in)`() { val previous = EarDetectionState.fromDualPod(left = false, right = false) val current = EarDetectionState.fromDualPod(left = true, right = true) @@ -486,7 +503,8 @@ class PlayPauseLogicTest : BaseTest() { previous = previous, current = current, onePodMode = true, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + startMusicOnWear = true, ) decision.shouldPlay shouldBe true @@ -515,14 +533,18 @@ class PlayPauseLogicTest : BaseTest() { decision1to2.shouldPlay shouldBe false decision1to2.shouldPause shouldBe true - // Step 2→3: Right reinserted (should play) + // Step 2→3: Right reinserted (should play). The original pause came from CAP in + // step 1→2, so wasRecentlyPausedByUs would be true in real flow — pass that here + // to keep the test focused on the one-pod-mode transition logic without depending + // on the cold-wear setting. val step3 = EarDetectionState.fromDualPod(left = true, right = true) val decision2to3 = playPause.evaluatePlayPauseAction( previous = step2, current = step3, onePodMode = true, - isCurrentlyPlaying = false // Music is now paused from step 2 + isCurrentlyPlaying = false, // Music is now paused from step 2 + wasRecentlyPausedByUs = true, ) // Expected: should play when a pod is reinserted @@ -531,7 +553,7 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `rapid transitions - none to one to none - should play then pause`() { + fun `rapid transitions - none to one to none - should play then pause (cold-wear opt-in)`() { // Step 1: None → One in (should play) val step1 = EarDetectionState.fromDualPod(left = false, right = false) val step2 = EarDetectionState.fromDualPod(left = true, right = false) @@ -540,7 +562,8 @@ class PlayPauseLogicTest : BaseTest() { previous = step1, current = step2, onePodMode = true, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + startMusicOnWear = true, ) decision1to2.shouldPlay shouldBe true @@ -561,8 +584,9 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `one-pod mode pod insertion - external stop suppresses autoplay`() { + fun `one-pod mode pod insertion - default suppresses autoplay`() { // Bug repro in one-pod mode: user manually paused, removed one pod, put it back. + // Default startMusicOnWear=false means cold-wear path is gated; suppress. val previous = EarDetectionState.fromDualPod(left = true, right = false) val current = EarDetectionState.fromDualPod(left = true, right = true) @@ -572,7 +596,7 @@ class PlayPauseLogicTest : BaseTest() { onePodMode = true, isCurrentlyPlaying = false, wasRecentlyPausedByUs = false, - wasMusicExternallyStoppedRecently = true, + startMusicOnWear = false, ) decision.shouldPlay shouldBe false @@ -580,7 +604,7 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `one-pod mode pod insertion - cap-paused still resumes despite external stop flag`() { + fun `one-pod mode pod insertion - cap-paused still resumes regardless of startMusicOnWear`() { val previous = EarDetectionState.fromDualPod(left = true, right = false) val current = EarDetectionState.fromDualPod(left = true, right = true) @@ -590,7 +614,7 @@ class PlayPauseLogicTest : BaseTest() { onePodMode = true, isCurrentlyPlaying = false, wasRecentlyPausedByUs = true, - wasMusicExternallyStoppedRecently = true, + startMusicOnWear = false, ) decision.shouldPlay shouldBe true @@ -602,12 +626,13 @@ class PlayPauseLogicTest : BaseTest() { inner class BleConfirmationTests { @Test - fun `ble-only normal mode autoplay waits for confirmation`() { + fun `ble-only normal mode autoplay waits for confirmation (cold-wear opt-in)`() { val rawDecision = playPause.evaluatePlayPauseAction( previous = EarDetectionState.fromDualPod(left = true, right = false), current = EarDetectionState.fromDualPod(left = true, right = true), onePodMode = false, isCurrentlyPlaying = false, + startMusicOnWear = true, ) val result = playPause.applyBleOnlyPlayConfirmation( @@ -620,6 +645,7 @@ class PlayPauseLogicTest : BaseTest() { shouldStageBleOnlyPlay = true, isCurrentlyPlaying = false, wasRecentlyPausedByUs = false, + startMusicOnWear = true, ) result.decision.shouldPlay shouldBe false @@ -635,7 +661,7 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `ble-only normal mode autoplay confirms on stable follow-up state`() { + fun `ble-only normal mode autoplay confirms on stable follow-up state (cold-wear opt-in)`() { val pending = PlayPause.PendingPlayConfirmation( profileId = "profile", onePodMode = false, @@ -648,6 +674,7 @@ class PlayPauseLogicTest : BaseTest() { current = EarDetectionState.fromDualPod(left = true, right = true), onePodMode = false, isCurrentlyPlaying = false, + startMusicOnWear = true, ) val result = playPause.applyBleOnlyPlayConfirmation( @@ -660,6 +687,7 @@ class PlayPauseLogicTest : BaseTest() { shouldStageBleOnlyPlay = false, isCurrentlyPlaying = false, wasRecentlyPausedByUs = false, + startMusicOnWear = true, ) result.decision.shouldPlay shouldBe true @@ -670,12 +698,13 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `aap-backed autoplay bypasses confirmation`() { + fun `aap-backed autoplay bypasses confirmation (cold-wear opt-in)`() { val rawDecision = playPause.evaluatePlayPauseAction( previous = EarDetectionState.fromDualPod(left = true, right = false), current = EarDetectionState.fromDualPod(left = true, right = true), onePodMode = false, isCurrentlyPlaying = false, + startMusicOnWear = true, ) val result = playPause.applyBleOnlyPlayConfirmation( @@ -688,6 +717,7 @@ class PlayPauseLogicTest : BaseTest() { shouldStageBleOnlyPlay = false, isCurrentlyPlaying = false, wasRecentlyPausedByUs = false, + startMusicOnWear = true, ) result.decision.shouldPlay shouldBe true @@ -731,9 +761,9 @@ class PlayPauseLogicTest : BaseTest() { } @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. + fun `ble-only confirmation suppressed by default when not we-paused`() { + // A staged BLE-only autoplay must not confirm under default settings (cold-wear + // gated) when CAP didn't pause — even if the second worn sample matches. val pending = PlayPause.PendingPlayConfirmation( profileId = "profile", onePodMode = false, @@ -747,7 +777,7 @@ class PlayPauseLogicTest : BaseTest() { onePodMode = false, isCurrentlyPlaying = false, wasRecentlyPausedByUs = false, - wasMusicExternallyStoppedRecently = true, + startMusicOnWear = false, ) val result = playPause.applyBleOnlyPlayConfirmation( @@ -760,7 +790,7 @@ class PlayPauseLogicTest : BaseTest() { shouldStageBleOnlyPlay = false, isCurrentlyPlaying = false, wasRecentlyPausedByUs = false, - wasMusicExternallyStoppedRecently = true, + startMusicOnWear = false, ) result.decision.shouldPlay shouldBe false @@ -808,13 +838,14 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `aggregate normal mode - none to both - should play`() { + fun `aggregate normal mode - none to both - should play (cold-wear opt-in)`() { val previous = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = false) val current = EarDetectionState.fromAapAggregate(isBeingWorn = true, isEitherPodInEar = true) val decision = playPause.evaluatePlayPauseAction( previous = previous, current = current, onePodMode = false, isCurrentlyPlaying = false, + startMusicOnWear = true, ) decision.shouldPlay shouldBe true } @@ -832,13 +863,14 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `aggregate one-pod mode - none to one - should play`() { + fun `aggregate one-pod mode - none to one - should play (cold-wear opt-in)`() { val previous = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = false) val current = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = true) val decision = playPause.evaluatePlayPauseAction( previous = previous, current = current, onePodMode = true, isCurrentlyPlaying = false, + startMusicOnWear = true, ) decision.shouldPlay shouldBe true } @@ -856,13 +888,14 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `aggregate one-pod mode - one to both - should play`() { + fun `aggregate one-pod mode - one to both - should play (cold-wear opt-in)`() { val previous = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = true) val current = EarDetectionState.fromAapAggregate(isBeingWorn = true, isEitherPodInEar = true) val decision = playPause.evaluatePlayPauseAction( previous = previous, current = current, onePodMode = true, isCurrentlyPlaying = false, + startMusicOnWear = true, ) decision.shouldPlay shouldBe true } @@ -959,7 +992,7 @@ class PlayPauseLogicTest : BaseTest() { inner class SinglePodDeviceTests { @Test - fun `single pod - not worn to worn - should play if not playing`() { + fun `single pod - not worn to worn - should play if not playing (cold-wear opt-in)`() { val previous = EarDetectionState.fromSinglePod(worn = false) val current = EarDetectionState.fromSinglePod(worn = true) @@ -967,7 +1000,8 @@ class PlayPauseLogicTest : BaseTest() { previous = previous, current = current, onePodMode = false, // Irrelevant for single pods - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + startMusicOnWear = true, ) decision.shouldPlay shouldBe true @@ -1055,7 +1089,7 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `single pod - one-pod mode enabled - play behaves same as normal mode`() { + fun `single pod - one-pod mode enabled - play behaves same as normal mode (cold-wear opt-in)`() { val previous = EarDetectionState.fromSinglePod(worn = false) val current = EarDetectionState.fromSinglePod(worn = true) @@ -1063,14 +1097,16 @@ class PlayPauseLogicTest : BaseTest() { previous = previous, current = current, onePodMode = false, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + startMusicOnWear = true, ) val decisionOnePod = playPause.evaluatePlayPauseAction( previous = previous, current = current, onePodMode = true, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + startMusicOnWear = true, ) decisionNormal.shouldPlay shouldBe true @@ -1687,14 +1723,25 @@ class PlayPauseLogicTest : BaseTest() { every { model } returns PodModel.AIRPODS_PRO3 } - private fun buildDevice(seenAt: Instant, leftWorn: Boolean, rightWorn: Boolean) = - PodDevice( - profileId = "test-profile", - ble = buildBle(seenAt, leftWorn, rightWorn), - aap = null, - profileModel = PodModel.AIRPODS_PRO3, - reactions = ReactionConfig(autoPlay = true, autoPause = true), - ) + // Default `startMusicOnWear = true` preserves the cold-wear semantics most existing + // flow tests describe (autoplay fires on pod-in even without a CAP-pause history). + // The bug-repro test that asserts the new default behavior overrides with `false`. + private fun buildDevice( + seenAt: Instant, + leftWorn: Boolean, + rightWorn: Boolean, + startMusicOnWear: Boolean = true, + ) = PodDevice( + profileId = "test-profile", + ble = buildBle(seenAt, leftWorn, rightWorn), + aap = null, + profileModel = PodModel.AIRPODS_PRO3, + reactions = ReactionConfig( + autoPlay = true, + autoPause = true, + startMusicOnWear = startMusicOnWear, + ), + ) @Test fun `flow - 3 consecutive not-worn unauthenticated samples fire pause exactly once`() = runTest { @@ -1802,22 +1849,34 @@ class PlayPauseLogicTest : BaseTest() { 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 buildIrkMatchedDevice( + seenAt: Instant, + leftWorn: Boolean, + rightWorn: Boolean, + startMusicOnWear: Boolean = true, + ) = PodDevice( + profileId = "test-profile", + ble = buildIrkMatchedBle(seenAt, leftWorn, rightWorn), + aap = null, + profileModel = PodModel.AIRPODS_PRO3, + reactions = ReactionConfig( + autoPlay = true, + autoPause = true, + startMusicOnWear = startMusicOnWear, + ), + ) - private fun buildNoLiveBleDevice() = + private fun buildNoLiveBleDevice(startMusicOnWear: Boolean = true) = PodDevice( profileId = "test-profile", ble = null, aap = null, profileModel = PodModel.AIRPODS_PRO3, - reactions = ReactionConfig(autoPlay = true, autoPause = true), + reactions = ReactionConfig( + autoPlay = true, + autoPause = true, + startMusicOnWear = startMusicOnWear, + ), ) @Test @@ -2026,10 +2085,11 @@ class PlayPauseLogicTest : BaseTest() { } @Test - fun `flow - user-paused before pod cycle does not auto-resume`() = runTest { + fun `flow - default suppresses autoplay when CAP did not pause`() = 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 + // (not CAP) is the one who stopped playback. Uses default ReactionConfig with + // startMusicOnWear=false (the new default) and 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()) @@ -2042,7 +2102,6 @@ class PlayPauseLogicTest : BaseTest() { val mediaControl: MediaControl = mockk(relaxed = true) { every { isPlaying } returns false every { wasRecentlyPausedByCap } returns false - every { wasMusicExternallyStoppedRecently } returns true } val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl) @@ -2050,17 +2109,24 @@ class PlayPauseLogicTest : BaseTest() { val job = launch { flowPlayPause.monitor().collect {} } // T0: worn baseline. - deviceFlow.value = listOf(buildIrkMatchedDevice(now, leftWorn = true, rightWorn = true)) + deviceFlow.value = listOf( + buildIrkMatchedDevice(now, leftWorn = true, rightWorn = true, startMusicOnWear = false), + ) advanceUntilIdle() // T1: one pod removed. Music is already paused, so no autoPause fires. - deviceFlow.value = listOf(buildIrkMatchedDevice(now.plusMillis(1000), leftWorn = true, rightWorn = false)) + deviceFlow.value = listOf( + buildIrkMatchedDevice(now.plusMillis(1000), leftWorn = true, rightWorn = false, startMusicOnWear = 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)) + // T2: pod reinserted. Auto-play must NOT fire — the user paused, not CAP, and the + // user hasn't opted into cold-wear. + deviceFlow.value = listOf( + buildIrkMatchedDevice(now.plusMillis(2000), leftWorn = true, rightWorn = true, startMusicOnWear = false), + ) advanceUntilIdle() coVerify(exactly = 0) { mediaControl.sendPlay() }