Fix auto-resume after CAP pause

This commit is contained in:
darken
2026-04-14 11:28:41 +02:00
committed by Matthias Urhahn
parent b8f342f096
commit c1601c105c
5 changed files with 155 additions and 13 deletions
@@ -14,17 +14,22 @@ import javax.inject.Singleton
class MediaControl @Inject constructor(
private val audioManager: AudioManager,
) {
private var capPauseExpiryElapsedRealtime: Long = 0L
val isPlaying: Boolean
get() = audioManager.isMusicActive
val wasRecentlyPausedByCap: Boolean
get() = capPauseExpiryElapsedRealtime > SystemClock.elapsedRealtime()
suspend fun sendPlay() {
log(TAG, INFO) { "sendPlay()" }
if (audioManager.isMusicActive) {
if (audioManager.isMusicActive && !wasRecentlyPausedByCap) {
log(TAG, INFO) { "Music is already playing, not sending play" }
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PLAY)
clearRecentCapPause()
}
suspend fun sendPause() {
@@ -34,10 +39,15 @@ class MediaControl @Inject constructor(
return
}
sendKey(KeyEvent.KEYCODE_MEDIA_PAUSE)
markRecentCapPause()
}
suspend fun sendPlayPause() {
log(TAG) { "sendPlayPause()" }
if (wasRecentlyPausedByCap) {
sendPlay()
return
}
if (audioManager.isMusicActive) {
sendPause()
} else {
@@ -71,7 +81,16 @@ class MediaControl @Inject constructor(
)
}
private fun markRecentCapPause() {
capPauseExpiryElapsedRealtime = SystemClock.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
}
}
@@ -99,9 +99,15 @@ class PlayPause @Inject constructor(
previous = prevState,
current = currState,
onePodMode = reactions.onePodMode,
isCurrentlyPlaying = mediaControl.isPlaying
isCurrentlyPlaying = mediaControl.isPlaying,
wasRecentlyPausedByUs = mediaControl.wasRecentlyPausedByCap,
)
if (decision.usedRecentCapPauseOverride) {
log(TAG, VERBOSE) {
"Resume override: recent CAP pause window is active, allowing play despite playing=true"
}
}
log(TAG, VERBOSE) { "Decision: ${decision.reason}" }
// Execute the decision
@@ -131,17 +137,19 @@ class PlayPause @Inject constructor(
previous: EarDetectionState,
current: EarDetectionState,
onePodMode: Boolean,
isCurrentlyPlaying: Boolean
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean = false,
): PlayPauseDecision = if (onePodMode) {
evaluateOnePodMode(previous, current, isCurrentlyPlaying)
evaluateOnePodMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
} else {
evaluateNormalMode(previous, current, isCurrentlyPlaying)
evaluateNormalMode(previous, current, isCurrentlyPlaying, wasRecentlyPausedByUs)
}
private fun evaluateOnePodMode(
previous: EarDetectionState,
current: EarDetectionState,
isCurrentlyPlaying: Boolean
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByUs: Boolean,
): PlayPauseDecision {
val netChange = current.podCount - previous.podCount
@@ -154,10 +162,11 @@ class PlayPause @Inject constructor(
)
// Net increase: pod(s) inserted → play
netChange > 0 && !isCurrentlyPlaying -> PlayPauseDecision(
netChange > 0 && (!isCurrentlyPlaying || wasRecentlyPausedByUs) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)"
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)",
usedRecentCapPauseOverride = isCurrentlyPlaying && wasRecentlyPausedByUs,
)
// No net change, or action not appropriate for current playing state
@@ -172,17 +181,19 @@ class PlayPause @Inject constructor(
private fun evaluateNormalMode(
previous: EarDetectionState,
current: EarDetectionState,
isCurrentlyPlaying: Boolean
isCurrentlyPlaying: Boolean,
wasRecentlyPausedByCap: Boolean,
): PlayPauseDecision {
val wasWorn = previous.bothInEar
val isWorn = current.bothInEar
return when {
// Transition: not worn → worn, and not playing → play
!wasWorn && isWorn && !isCurrentlyPlaying -> PlayPauseDecision(
!wasWorn && isWorn && (!isCurrentlyPlaying || wasRecentlyPausedByCap) -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "Normal mode: both pods in ear"
reason = "Normal mode: both pods in ear",
usedRecentCapPauseOverride = isCurrentlyPlaying && wasRecentlyPausedByCap,
)
// Transition: worn → not worn, and playing → pause
@@ -235,7 +246,8 @@ class PlayPause @Inject constructor(
data class PlayPauseDecision(
val shouldPlay: Boolean,
val shouldPause: Boolean,
val reason: String
val reason: String,
val usedRecentCapPauseOverride: Boolean = false,
)
companion object {
@@ -0,0 +1,68 @@
package eu.darken.capod.common
import android.media.AudioManager
import android.os.SystemClock
import io.mockk.Runs
import io.mockk.clearMocks
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.jupiter.api.AfterEach
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class MediaControlTest : BaseTest() {
private lateinit var audioManager: AudioManager
private lateinit var mediaControl: MediaControl
@BeforeEach
fun setup() {
mockkStatic(SystemClock::class)
every { SystemClock.elapsedRealtime() } returns 1_000L
every { SystemClock.uptimeMillis() } returns 1_000L
audioManager = mockk(relaxed = true)
every { audioManager.dispatchMediaKeyEvent(any()) } just Runs
mediaControl = MediaControl(audioManager)
}
@AfterEach
fun teardown() {
unmockkStatic(SystemClock::class)
}
@Test
fun `sendPlay ignores stale active state after cap pause`() = runTest {
every { audioManager.isMusicActive } returns true
mediaControl.sendPause()
assertTrue(mediaControl.wasRecentlyPausedByCap)
clearMocks(audioManager, answers = false, recordedCalls = true)
mediaControl.sendPlay()
assertFalse(mediaControl.wasRecentlyPausedByCap)
verify(exactly = 2) { audioManager.dispatchMediaKeyEvent(any()) }
}
@Test
fun `sendPlayPause resumes after cap pause even when audio manager still reports active`() = runTest {
every { audioManager.isMusicActive } returns true
mediaControl.sendPause()
assertTrue(mediaControl.wasRecentlyPausedByCap)
clearMocks(audioManager, answers = false, recordedCalls = true)
mediaControl.sendPlayPause()
assertFalse(mediaControl.wasRecentlyPausedByCap)
verify(exactly = 2) { audioManager.dispatchMediaKeyEvent(any()) }
}
}
@@ -16,6 +16,8 @@ import io.mockk.MockKAnnotations
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.junit.jupiter.api.AfterEach
import kotlinx.coroutines.flow.flowOf
import org.junit.jupiter.api.BeforeEach
import testhelpers.BaseTest
@@ -62,6 +64,11 @@ abstract class BaseBlePodsTest : BaseTest() {
every { SystemClockWrap.elapsedRealtimeNanos } returns 1000L
}
@AfterEach
fun teardown() {
unmockkObject(SystemClockWrap)
}
internal suspend inline fun <reified T : BlePodSnapshot?> create(
hex: String,
address: String = "77:49:4C:D8:25:0C",
@@ -79,4 +86,4 @@ abstract class BaseBlePodsTest : BaseTest() {
block.invoke(factory.create(result) as T)
}
}
}
@@ -89,6 +89,24 @@ class PlayPauseLogicTest : BaseTest() {
decision.shouldPause shouldBe false
}
@Test
fun `one in to both in - should play if recently paused by cap`() {
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 = true,
wasRecentlyPausedByUs = true,
)
decision.shouldPlay shouldBe true
decision.shouldPause shouldBe false
decision.usedRecentCapPauseOverride shouldBe true
}
@Test
fun `none in to one in - no action (need both)`() {
val previous = EarDetectionState.fromDualPod(left = false, right = false)
@@ -288,6 +306,24 @@ class PlayPauseLogicTest : BaseTest() {
decision.shouldPause shouldBe false
}
@Test
fun `one in to both in - should play if recently paused by cap`() {
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 = true,
wasRecentlyPausedByUs = true,
)
decision.shouldPlay shouldBe true
decision.shouldPause shouldBe false
decision.usedRecentCapPauseOverride shouldBe true
}
@Test
fun `both in to one in - should pause if playing`() {
val previous = EarDetectionState.fromDualPod(left = true, right = true)