mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
refactor(reaction): Switch to sticky resume flag with opt-in cold-wear
Replaces the origin-tracking machinery with a simpler model that matches Apple's iOS/macOS behavior: auto-play strictly resumes a CAP-dispatched pause, gated by a sticky boolean cleared on inactive→active transitions. The original fire-on-cold-wear behavior is preserved as a per-device opt-in 'Start music on wear' setting.
This commit is contained in:
committed by
Matthias Urhahn
parent
278257998b
commit
17663bc759
@@ -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<AudioPlaybackConfiguration>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 "<redacted>"}, " +
|
||||
"encryptionKey=${if (encryptionKey == null) "null" else "<redacted>"}, " +
|
||||
"address=$address, autoPause=$autoPause, autoPlay=$autoPlay, " +
|
||||
"startMusicOnWear=$startMusicOnWear, " +
|
||||
"onePodMode=$onePodMode, autoConnect=$autoConnect, " +
|
||||
"autoConnectCondition=$autoConnectCondition, " +
|
||||
"showPopUpOnCaseOpen=$showPopUpOnCaseOpen, " +
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -53,7 +53,9 @@
|
||||
<string name="settings_autopause_label">Auto pause</string>
|
||||
<string name="settings_autopause_description">Pause audio when removing the device from your ear.</string>
|
||||
<string name="settings_autopplay_label">Auto play</string>
|
||||
<string name="settings_autoplay_description">Start audio playback when device is worn.</string>
|
||||
<string name="settings_autoplay_description">Resume music when wearing your pods again, if Auto pause stopped it. Music you paused yourself stays paused.</string>
|
||||
<string name="settings_start_music_on_wear_label">Start music on wear</string>
|
||||
<string name="settings_start_music_on_wear_description">Always tries to play music when wearing your pods. May also restart music you paused yourself.</string>
|
||||
<string name="settings_eardetection_info_label">Ear detection note</string>
|
||||
<string name="settings_eardetection_info_description">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.</string>
|
||||
<string name="settings_fake_data_label">Fake data</string>
|
||||
|
||||
@@ -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<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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<PodDevice>>(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() }
|
||||
|
||||
Reference in New Issue
Block a user