diff --git a/app/src/main/java/eu/darken/capod/common/MediaControl.kt b/app/src/main/java/eu/darken/capod/common/MediaControl.kt index 4d9540be..33e5222e 100644 --- a/app/src/main/java/eu/darken/capod/common/MediaControl.kt +++ b/app/src/main/java/eu/darken/capod/common/MediaControl.kt @@ -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 } } diff --git a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt index d1ccc160..7f90dcbf 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt b/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt new file mode 100644 index 00000000..526b6df3 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt @@ -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()) } + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/BaseBlePodsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/BaseBlePodsTest.kt index 755db0d4..18059777 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/BaseBlePodsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/ble/devices/BaseBlePodsTest.kt @@ -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 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) } -} \ No newline at end of file +} diff --git a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt index d8f42b29..e3b0a79b 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt @@ -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)