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:
Matthias Urhahn
2026-05-07 14:08:22 +02:00
committed by Matthias Urhahn
parent 278257998b
commit 17663bc759
10 changed files with 273 additions and 359 deletions
@@ -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() }