mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
fix(reaction): Don't auto-resume music after a user-initiated pause
This commit is contained in:
committed by
Matthias Urhahn
parent
9d41979499
commit
278257998b
@@ -1,11 +1,13 @@
|
||||
package eu.darken.capod.common
|
||||
|
||||
import android.media.AudioManager
|
||||
import io.mockk.CapturingSlot
|
||||
import io.mockk.Runs
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -14,12 +16,14 @@ import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import testhelpers.TestTimeSource
|
||||
import java.time.Duration
|
||||
|
||||
class MediaControlTest : BaseTest() {
|
||||
|
||||
private lateinit var audioManager: AudioManager
|
||||
private lateinit var mediaControl: MediaControl
|
||||
private lateinit var timeSource: TestTimeSource
|
||||
private lateinit var playbackCallbackSlot: CapturingSlot<AudioManager.AudioPlaybackCallback>
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
@@ -29,9 +33,16 @@ class MediaControlTest : BaseTest() {
|
||||
)
|
||||
audioManager = mockk(relaxed = true)
|
||||
every { audioManager.dispatchMediaKeyEvent(any()) } just Runs
|
||||
every { audioManager.isMusicActive } returns false
|
||||
playbackCallbackSlot = slot()
|
||||
every { audioManager.registerAudioPlaybackCallback(capture(playbackCallbackSlot), any()) } just Runs
|
||||
mediaControl = MediaControl(audioManager, timeSource)
|
||||
}
|
||||
|
||||
private fun fireCallback() {
|
||||
playbackCallbackSlot.captured.onPlaybackConfigChanged(emptyList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendPlay ignores stale active state after cap pause`() = runTest {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
@@ -83,4 +94,194 @@ class MediaControlTest : BaseTest() {
|
||||
assertFalse(mediaControl.wasRecentlyPausedByCap)
|
||||
verify(exactly = 0) { audioManager.dispatchMediaKeyEvent(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wasMusicExternallyStoppedRecently is false on cold start`() {
|
||||
// No music ever observed.
|
||||
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wasMusicExternallyStoppedRecently is false while music is currently active`() {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
|
||||
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external stop is recorded when music goes inactive without a preceding sendPause`() {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback() // seeds lastKnownMusicActive=true
|
||||
|
||||
every { audioManager.isMusicActive } returns false
|
||||
fireCallback() // active->inactive transition with no recent CAP pause
|
||||
|
||||
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cap stop is NOT classified as external`() = runTest {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback() // seeds lastKnownMusicActive=true
|
||||
|
||||
mediaControl.sendPause() // sets lastCapPauseDispatchAt synchronously
|
||||
|
||||
every { audioManager.isMusicActive } returns false
|
||||
fireCallback() // active->inactive immediately after our pause
|
||||
|
||||
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external-stop window expires after 60 seconds`() {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback()
|
||||
every { audioManager.isMusicActive } returns false
|
||||
fireCallback()
|
||||
|
||||
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
|
||||
timeSource.advanceBy(Duration.ofSeconds(61))
|
||||
|
||||
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external-stop window boundary - 59s in, 60s out`() {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback()
|
||||
every { audioManager.isMusicActive } returns false
|
||||
fireCallback()
|
||||
|
||||
timeSource.advanceBy(Duration.ofMillis(59_999))
|
||||
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
|
||||
timeSource.advanceBy(Duration.ofMillis(1)) // now exactly 60_000ms after stop
|
||||
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init seeds lastKnownMusicActive from current state`() {
|
||||
// Construct a fresh MediaControl with isMusicActive=true at construction. The first
|
||||
// active->inactive callback must fire the transition logic — without the seed it
|
||||
// would incorrectly believe the previous state was inactive and miss the stop.
|
||||
val freshAudioManager: AudioManager = mockk(relaxed = true)
|
||||
every { freshAudioManager.dispatchMediaKeyEvent(any()) } just Runs
|
||||
every { freshAudioManager.isMusicActive } returns true
|
||||
val freshSlot = slot<AudioManager.AudioPlaybackCallback>()
|
||||
every { freshAudioManager.registerAudioPlaybackCallback(capture(freshSlot), any()) } just Runs
|
||||
|
||||
val freshControl = MediaControl(freshAudioManager, timeSource)
|
||||
|
||||
// Music goes off (e.g. user pause) — this is the first transition we observe.
|
||||
every { freshAudioManager.isMusicActive } returns false
|
||||
freshSlot.captured.onPlaybackConfigChanged(emptyList())
|
||||
|
||||
assertTrue(freshControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getter self-heals when callback was missed`() {
|
||||
// Seed: callback fired with active=true. Then the active->inactive transition happens
|
||||
// but the callback never fires (race / missed event). The getter should detect the
|
||||
// mismatch on read and record the stop itself.
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback()
|
||||
|
||||
every { audioManager.isMusicActive } returns false
|
||||
// No fireCallback() — simulate missed event.
|
||||
|
||||
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delayed cap callback within TTL is still attributed to cap`() = runTest {
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback()
|
||||
|
||||
mediaControl.sendPause()
|
||||
every { audioManager.isMusicActive } returns false
|
||||
timeSource.advanceBy(Duration.ofSeconds(10)) // realistic-but-late callback
|
||||
|
||||
fireCallback()
|
||||
|
||||
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cap stop self-heals via getter when callback was missed past the old short window`() = runTest {
|
||||
// Codex review scenario: CAP pauses, the playback callback never fires, the user
|
||||
// reinserts a pod some seconds later. The getter must still attribute the stop to
|
||||
// CAP — not regress to recreating the 16-60s "dead zone" that the original fix had.
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback()
|
||||
|
||||
mediaControl.sendPause()
|
||||
every { audioManager.isMusicActive } returns false
|
||||
timeSource.advanceBy(Duration.ofSeconds(16))
|
||||
|
||||
// No fireCallback() — the callback was missed.
|
||||
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending cap dispatch is dropped after TTL so an unrelated later stop is external`() = runTest {
|
||||
// Pause was dispatched but ignored (music kept playing). After TTL, the next genuine
|
||||
// active→inactive transition must NOT be misattributed to CAP.
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback()
|
||||
|
||||
mediaControl.sendPause()
|
||||
// Music ignored the key — still active. Time passes.
|
||||
timeSource.advanceBy(Duration.ofSeconds(31)) // > CAP_PAUSE_ATTRIBUTION_TTL_MS
|
||||
|
||||
every { audioManager.isMusicActive } returns false
|
||||
fireCallback() // unrelated stop
|
||||
|
||||
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prior external stop is cleared when music resumes so it does not suppress a later cap pause cycle`() = runTest {
|
||||
// T=0: external stop.
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback()
|
||||
every { audioManager.isMusicActive } returns false
|
||||
fireCallback()
|
||||
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
|
||||
// Music resumes (e.g. user starts a new song manually) — must clear externalStopAt.
|
||||
timeSource.advanceBy(Duration.ofSeconds(5))
|
||||
every { audioManager.isMusicActive } returns true
|
||||
fireCallback()
|
||||
|
||||
// CAP pauses fresh. The earlier external stop must not bleed through.
|
||||
timeSource.advanceBy(Duration.ofSeconds(5))
|
||||
mediaControl.sendPause()
|
||||
every { audioManager.isMusicActive } returns false
|
||||
fireCallback()
|
||||
|
||||
assertFalse(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending cap dispatch is dropped when music transitions to active before being consumed`() = runTest {
|
||||
// sendPause was dispatched but the pause was effectively ignored — observable as a
|
||||
// sustained inactive→active transition without ever going inactive. The next external
|
||||
// stop must still be classified as external.
|
||||
every { audioManager.isMusicActive } returns false
|
||||
fireCallback() // seed lastKnownMusicActive=false
|
||||
|
||||
every { audioManager.isMusicActive } returns true
|
||||
mediaControl.sendPause()
|
||||
// sendPause sets pending; but isMusicActive is now true (not actually paused).
|
||||
fireCallback() // observe inactive→active; clears pending
|
||||
|
||||
// Some time later, a genuine external stop happens.
|
||||
timeSource.advanceBy(Duration.ofSeconds(2))
|
||||
every { audioManager.isMusicActive } returns false
|
||||
fireCallback()
|
||||
|
||||
assertTrue(mediaControl.wasMusicExternallyStoppedRecently)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,69 @@ class PlayPauseLogicTest : BaseTest() {
|
||||
decision.shouldPlay shouldBe false
|
||||
decision.shouldPause shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one in to both in - user paused externally, do not auto-play`() {
|
||||
// Bug repro: user manually paused, then took one pod out and put it back.
|
||||
// Music isn't playing and we didn't pause it — but it was stopped recently
|
||||
// by something external (user). Suppress autoplay.
|
||||
val previous = EarDetectionState.fromDualPod(left = true, right = false)
|
||||
val current = EarDetectionState.fromDualPod(left = true, right = true)
|
||||
|
||||
val decision = playPause.evaluatePlayPauseAction(
|
||||
previous = previous,
|
||||
current = current,
|
||||
onePodMode = false,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = false,
|
||||
wasMusicExternallyStoppedRecently = true,
|
||||
)
|
||||
|
||||
decision.shouldPlay shouldBe false
|
||||
decision.shouldPause shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one in to both in - cap-paused wins over external-stop suppression`() {
|
||||
// Even if `wasMusicExternallyStoppedRecently` is also true (e.g. CAP attribution
|
||||
// window expired and another non-CAP stop happened), `wasRecentlyPausedByUs`
|
||||
// must still take precedence and resume.
|
||||
val previous = EarDetectionState.fromDualPod(left = true, right = false)
|
||||
val current = EarDetectionState.fromDualPod(left = true, right = true)
|
||||
|
||||
val decision = playPause.evaluatePlayPauseAction(
|
||||
previous = previous,
|
||||
current = current,
|
||||
onePodMode = false,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = true,
|
||||
wasMusicExternallyStoppedRecently = true,
|
||||
)
|
||||
|
||||
decision.shouldPlay shouldBe true
|
||||
decision.shouldPause shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `none in to both in - cold start with no history fires play`() {
|
||||
// No external stop recorded, music not playing, we didn't pause — pure
|
||||
// cold start. Auto-play should still fire so the existing "put pods on
|
||||
// to wake the media app" behavior is preserved.
|
||||
val previous = EarDetectionState.fromDualPod(left = false, right = false)
|
||||
val current = EarDetectionState.fromDualPod(left = true, right = true)
|
||||
|
||||
val decision = playPause.evaluatePlayPauseAction(
|
||||
previous = previous,
|
||||
current = current,
|
||||
onePodMode = false,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = false,
|
||||
wasMusicExternallyStoppedRecently = false,
|
||||
)
|
||||
|
||||
decision.shouldPlay shouldBe true
|
||||
decision.shouldPause shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -496,6 +559,43 @@ class PlayPauseLogicTest : BaseTest() {
|
||||
decision2to3.shouldPlay shouldBe false
|
||||
decision2to3.shouldPause shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one-pod mode pod insertion - external stop suppresses autoplay`() {
|
||||
// Bug repro in one-pod mode: user manually paused, removed one pod, put it back.
|
||||
val previous = EarDetectionState.fromDualPod(left = true, right = false)
|
||||
val current = EarDetectionState.fromDualPod(left = true, right = true)
|
||||
|
||||
val decision = playPause.evaluatePlayPauseAction(
|
||||
previous = previous,
|
||||
current = current,
|
||||
onePodMode = true,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = false,
|
||||
wasMusicExternallyStoppedRecently = true,
|
||||
)
|
||||
|
||||
decision.shouldPlay shouldBe false
|
||||
decision.shouldPause shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one-pod mode pod insertion - cap-paused still resumes despite external stop flag`() {
|
||||
val previous = EarDetectionState.fromDualPod(left = true, right = false)
|
||||
val current = EarDetectionState.fromDualPod(left = true, right = true)
|
||||
|
||||
val decision = playPause.evaluatePlayPauseAction(
|
||||
previous = previous,
|
||||
current = current,
|
||||
onePodMode = true,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = true,
|
||||
wasMusicExternallyStoppedRecently = true,
|
||||
)
|
||||
|
||||
decision.shouldPlay shouldBe true
|
||||
decision.shouldPause shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -629,6 +729,43 @@ class PlayPauseLogicTest : BaseTest() {
|
||||
result.stagedConfirmation shouldBe false
|
||||
result.confirmedPendingPlay shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ble-only confirmation suppressed when music was externally stopped recently`() {
|
||||
// A staged BLE-only autoplay must not confirm if an external stop happened in
|
||||
// the meantime — even if the second worn sample matches.
|
||||
val pending = PlayPause.PendingPlayConfirmation(
|
||||
profileId = "profile",
|
||||
onePodMode = false,
|
||||
targetState = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
reason = "Normal mode: both pods in ear",
|
||||
)
|
||||
|
||||
val rawDecision = playPause.evaluatePlayPauseAction(
|
||||
previous = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
current = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
onePodMode = false,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = false,
|
||||
wasMusicExternallyStoppedRecently = true,
|
||||
)
|
||||
|
||||
val result = playPause.applyBleOnlyPlayConfirmation(
|
||||
pending = pending,
|
||||
profileId = "profile",
|
||||
onePodMode = false,
|
||||
autoPlayEnabled = true,
|
||||
rawDecision = rawDecision,
|
||||
currentState = EarDetectionState.fromDualPod(left = true, right = true),
|
||||
shouldStageBleOnlyPlay = false,
|
||||
isCurrentlyPlaying = false,
|
||||
wasRecentlyPausedByUs = false,
|
||||
wasMusicExternallyStoppedRecently = true,
|
||||
)
|
||||
|
||||
result.decision.shouldPlay shouldBe false
|
||||
result.confirmedPendingPlay shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -1887,5 +2024,48 @@ class PlayPauseLogicTest : BaseTest() {
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flow - user-paused before pod cycle does not auto-resume`() = runTest {
|
||||
// Bug repro: while wearing pods, user manually pauses music via the phone, then
|
||||
// takes one pod out and puts it back. Auto-play must NOT fire because the user
|
||||
// (not CAP) is the one who stopped playback. Uses the IRK-matched source so the
|
||||
// BLE-only autoplay confirmation step is bypassed and the decision lands on
|
||||
// a single transition.
|
||||
val deviceFlow = MutableStateFlow<List<PodDevice>>(emptyList())
|
||||
val deviceMonitor: DeviceMonitor = mockk(relaxed = true) {
|
||||
every { devices } returns deviceFlow
|
||||
}
|
||||
val bluetoothManager: BluetoothManager2 = mockk(relaxed = true) {
|
||||
every { connectedDevices } returns flowOf(listOf(mockk(relaxed = true)))
|
||||
}
|
||||
val mediaControl: MediaControl = mockk(relaxed = true) {
|
||||
every { isPlaying } returns false
|
||||
every { wasRecentlyPausedByCap } returns false
|
||||
every { wasMusicExternallyStoppedRecently } returns true
|
||||
}
|
||||
val flowPlayPause = PlayPause(deviceMonitor, bluetoothManager, mediaControl)
|
||||
|
||||
val now = Instant.parse("2026-01-01T00:00:00Z")
|
||||
val job = launch { flowPlayPause.monitor().collect {} }
|
||||
|
||||
// T0: worn baseline.
|
||||
deviceFlow.value = listOf(buildIrkMatchedDevice(now, leftWorn = true, rightWorn = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
// T1: one pod removed. Music is already paused, so no autoPause fires.
|
||||
deviceFlow.value = listOf(buildIrkMatchedDevice(now.plusMillis(1000), leftWorn = true, rightWorn = false))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { mediaControl.sendPause() }
|
||||
|
||||
// T2: pod reinserted. Auto-play must NOT fire — the user paused, not CAP.
|
||||
deviceFlow.value = listOf(buildIrkMatchedDevice(now.plusMillis(2000), leftWorn = true, rightWorn = true))
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { mediaControl.sendPlay() }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user