diff --git a/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt b/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt index 012dfd80..d104f2b5 100644 --- a/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt +++ b/app/src/test/java/eu/darken/capod/common/MediaControlTest.kt @@ -1,11 +1,11 @@ package eu.darken.capod.common import android.media.AudioAttributes +import android.media.AudioFocusRequest import android.media.AudioManager import android.media.AudioPlaybackConfiguration import android.os.Handler import android.view.KeyEvent -import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.shouldBe import io.mockk.CapturingSlot import io.mockk.Runs @@ -36,6 +36,9 @@ class MediaControlTest : BaseTest() { private lateinit var handler: Handler private lateinit var playbackCallbackSlot: CapturingSlot private lateinit var initRunnableSlot: CapturingSlot + private lateinit var focusRequest: AudioFocusRequest + private lateinit var focusRequestFactory: MediaControl.DuckFocusRequestFactory + private lateinit var focusListenerSlot: CapturingSlot @BeforeEach fun setup() { @@ -54,7 +57,13 @@ class MediaControlTest : BaseTest() { handler = mockk() initRunnableSlot = slot() every { handler.post(capture(initRunnableSlot)) } returns true - mediaControl = MediaControl(audioManager, timeSource, handler) + // AudioFocusRequest.Builder is an unmocked android.jar stub, so the request is handed to + // MediaControl through the injected factory instead of being built inside it. + focusRequest = mockk() + focusListenerSlot = slot() + focusRequestFactory = mockk() + every { focusRequestFactory.create(capture(focusListenerSlot)) } returns focusRequest + mediaControl = MediaControl(audioManager, timeSource, handler, focusRequestFactory) // Drain the init runnable so `playbackCallbackSlot` is populated for `fireCallback()`. initRunnableSlot.captured.run() // Everything posted after init (the pause arm) runs inline and synchronously, which keeps @@ -458,7 +467,7 @@ class MediaControlTest : BaseTest() { val freshHandler = mockk() every { freshHandler.post(any()) } returns true - MediaControl(freshAudioManager, timeSource, freshHandler) + MediaControl(freshAudioManager, timeSource, freshHandler, focusRequestFactory) verify(exactly = 0) { freshAudioManager.isMusicActive } verify(exactly = 0) { freshAudioManager.registerAudioPlaybackCallback(any(), any()) } @@ -476,7 +485,7 @@ class MediaControlTest : BaseTest() { val runnableSlot = slot() every { freshHandler.post(capture(runnableSlot)) } returns true - MediaControl(freshAudioManager, timeSource, freshHandler) + MediaControl(freshAudioManager, timeSource, freshHandler, focusRequestFactory) runnableSlot.captured.run() verifyOrder { @@ -503,7 +512,7 @@ class MediaControlTest : BaseTest() { true } - val undrained = MediaControl(freshAudioManager, timeSource, freshHandler) + val undrained = MediaControl(freshAudioManager, timeSource, freshHandler, focusRequestFactory) assertFalse(undrained.wasRecentlyPausedByCap) @@ -529,39 +538,142 @@ class MediaControlTest : BaseTest() { every { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } returns 100 every { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) } returnsMany listOf(40, 22) - mediaControl.duckMusicVolume(50) shouldBe MediaControl.VolumeDuck(priorVolume = 40, appliedVolume = 22) + mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Ducked(priorVolume = 40, appliedVolume = 22) verify { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 20, 0) } } /** - * A volume that came back *higher* (user raised it between the two reads) is not a duck either. - * Reporting one would have the caller later "restore" downwards, undoing the user's change. + * A volume that came back *higher* (user raised it between the two reads) is not a refusal: it + * must map to [MediaControl.DuckOutcome.Skipped], not `Unchanged`, or the caller would chase the + * audio-focus fallback on a device whose volume writes work fine. */ @Test - fun `duckMusicVolume treats a raised volume as a no-op`() { + fun `duckMusicVolume treats a raised volume as skipped, not a refusal`() { every { audioManager.isMusicActive } returns true every { audioManager.isVolumeFixed } returns false every { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } returns 100 every { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) } returnsMany listOf(40, 55) - mediaControl.duckMusicVolume(50).shouldBeNull() + mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Skipped } /** * ColorOS 16 accepts `setStreamVolume` from a backgrounded app, raises nothing, and leaves the - * volume where it was. Reporting that as a duck had the caller track a session that never - * attenuated anything and later restore a level that was never left. + * volume where it was. That is the case the audio-focus fallback exists for, so it reports + * [MediaControl.DuckOutcome.Unchanged] rather than a duck the caller would later "restore". */ @Test - fun `duckMusicVolume treats a silently ignored write as a no-op`() { + fun `duckMusicVolume reports a silently ignored write as unchanged`() { every { audioManager.isMusicActive } returns true every { audioManager.isVolumeFixed } returns false every { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } returns 100 every { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) } returnsMany listOf(40, 40) - mediaControl.duckMusicVolume(100).shouldBeNull() + mediaControl.duckMusicVolume(100) shouldBe MediaControl.DuckOutcome.Unchanged(priorVolume = 40) verify { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 0, 0) } } + + @Test + fun `duckMusicVolume skips when nothing is playing`() { + every { audioManager.isMusicActive } returns false + + mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Skipped + + verify(exactly = 0) { audioManager.setStreamVolume(any(), any(), any()) } + } + + @Test + fun `duckMusicVolume skips on fixed-volume devices`() { + every { audioManager.isMusicActive } returns true + every { audioManager.isVolumeFixed } returns true + + mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Skipped + + verify(exactly = 0) { audioManager.setStreamVolume(any(), any(), any()) } + } + + @Test + fun `duckMusicVolume skips when the reduction leaves no headroom`() { + every { audioManager.isMusicActive } returns true + every { audioManager.isVolumeFixed } returns false + every { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } returns 100 + every { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) } returns 0 + + mediaControl.duckMusicVolume(50) shouldBe MediaControl.DuckOutcome.Skipped + + verify(exactly = 0) { audioManager.setStreamVolume(any(), any(), any()) } + } + + @Test + fun `requestDuckFocus reports a granted request as held`() { + every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + + mediaControl.requestDuckFocus() shouldBe true + mediaControl.isDuckFocusHeld shouldBe true + } + + @Test + fun `requestDuckFocus reports a denied request and retries on the next call`() { + every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + mediaControl.requestDuckFocus() shouldBe false + mediaControl.isDuckFocusHeld shouldBe false + + // A denial leaves nothing held, so the next attempt must issue a fresh request. + every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + mediaControl.requestDuckFocus() shouldBe true + mediaControl.isDuckFocusHeld shouldBe true + + verify(exactly = 2) { audioManager.requestAudioFocus(focusRequest) } + } + + @Test + fun `requestDuckFocus is idempotent while focus is held`() { + every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + + mediaControl.requestDuckFocus() shouldBe true + mediaControl.requestDuckFocus() shouldBe true + + verify(exactly = 1) { audioManager.requestAudioFocus(focusRequest) } + } + + @Test + fun `abandonDuckFocus only abandons what is actually held`() { + // Not held: abandoning must not touch the audio system at all. + mediaControl.abandonDuckFocus() + verify(exactly = 0) { audioManager.abandonAudioFocusRequest(any()) } + + every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + mediaControl.requestDuckFocus() shouldBe true + + mediaControl.abandonDuckFocus() + mediaControl.abandonDuckFocus() + mediaControl.isDuckFocusHeld shouldBe false + verify(exactly = 1) { audioManager.abandonAudioFocusRequest(focusRequest) } + + // Re-requesting after an abandon starts a new request rather than reusing the stale state. + mediaControl.requestDuckFocus() shouldBe true + mediaControl.isDuckFocusHeld shouldBe true + verify(exactly = 2) { audioManager.requestAudioFocus(focusRequest) } + } + + @Test + fun `the focus listener drops the held state on a permanent loss only`() { + every { audioManager.requestAudioFocus(focusRequest) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + mediaControl.requestDuckFocus() shouldBe true + val listener = focusListenerSlot.captured + + // A transient loss is temporary — the request stays valid and we still hold it. + listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + mediaControl.isDuckFocusHeld shouldBe true + + listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) + mediaControl.isDuckFocusHeld shouldBe false + + // Nothing left to release: the system already took it. + mediaControl.abandonDuckFocus() + verify(exactly = 0) { audioManager.abandonAudioFocusRequest(any()) } + } } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt index 03e7088c..c64477bd 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt @@ -85,7 +85,7 @@ class ConversationReactionTest : BaseTest() { mediaControl = mockk(relaxed = true) { coEvery { sendPause(any()) } returns true every { isPlaying } returns false - every { duckMusicVolume(any()) } returns MediaControl.VolumeDuck(priorVolume = 10, appliedVolume = 5) + every { duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Ducked(priorVolume = 10, appliedVolume = 5) every { currentMusicVolume() } returns 5 } timeSource = TestTimeSource() @@ -181,14 +181,16 @@ class ConversationReactionTest : BaseTest() { } /** - * A duck the system refused (ColorOS 16 accepts `setStreamVolume` from a backgrounded app and - * leaves the volume alone) must leave no session behind: nothing to restore on the terminal, and - * no armed backstop that would restore a level that was never left. A repeat START retries the - * duck rather than treating the dead session as a keep-alive. + * A duck that never happened (nothing playing, fixed volume, no headroom, or a volume that came + * back higher — every reason maps to `Skipped`, pinned per reason in `MediaControlTest`) must + * leave no session behind: nothing to restore on the terminal, and no armed backstop that would + * restore a level that was never left. A repeat START retries the duck rather than treating the + * dead session as a keep-alive. It must also NOT reach for the audio-focus fallback — that is + * only for a write the ROM accepted and ignored. */ @Test - fun `LOWER_VOLUME refused duck arms nothing and never restores`() = runTest(UnconfinedTestDispatcher()) { - every { mediaControl.duckMusicVolume(any()) } returns null + fun `LOWER_VOLUME skipped duck arms nothing and never restores`() = runTest(UnconfinedTestDispatcher()) { + every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Skipped val job = launchReaction() emit(primaryAddress, ConversationAwarenessEvent.START) @@ -202,9 +204,147 @@ class ConversationReactionTest : BaseTest() { advanceBoth(staleTimeoutMs + 500) verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } + verify(exactly = 0) { mediaControl.requestDuckFocus() } job.cancel() } + /** + * The whole point of the focus fallback: a working duck must never request audio focus. The + * [mediaControl] mock is relaxed, so an accidental request would otherwise pass silently. + */ + @Test + fun `LOWER_VOLUME successful duck never requests audio focus`() = runTest(UnconfinedTestDispatcher()) { + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + emit(primaryAddress, ConversationAwarenessEvent.STOP) + advanceBoth(stopSettleMs + 50) + + verify(exactly = 1) { mediaControl.restoreMusicVolume(10) } + verify(exactly = 0) { mediaControl.requestDuckFocus() } + verify(exactly = 0) { mediaControl.abandonDuckFocus() } + job.cancel() + } + + /** + * ColorOS 16 accepts `setStreamVolume` from a backgrounded app and leaves the level untouched. + * The reaction then holds ducking audio focus for the conversation and releases it at the end. + */ + @Test + fun `LOWER_VOLUME unchanged duck falls back to audio focus`() = runTest(UnconfinedTestDispatcher()) { + every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10) + every { mediaControl.requestDuckFocus() } returns true + every { mediaControl.currentMusicVolume() } returns 10 // the write really never landed + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + verify(exactly = 1) { mediaControl.requestDuckFocus() } + verify(exactly = 0) { mediaControl.abandonDuckFocus() } + + emit(primaryAddress, ConversationAwarenessEvent.STOP) // cold terminal → settles briefly + advanceBoth(stopSettleMs + 50) + + verify(exactly = 1) { mediaControl.abandonDuckFocus() } + // Nothing was ever lowered, so there is nothing to restore. + verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } + job.cancel() + } + + /** + * Focus denied on top of an ignored volume write: nothing was attenuated, so no session may be + * recorded — otherwise the next START would be a keep-alive on a dead session and the backstop + * would later "release" focus we never held. + */ + @Test + fun `LOWER_VOLUME unchanged duck with denied focus arms nothing`() = runTest(UnconfinedTestDispatcher()) { + every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10) + every { mediaControl.requestDuckFocus() } returns false + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + verify(exactly = 1) { mediaControl.requestDuckFocus() } + + // Retried from scratch rather than treated as a keep-alive. + emit(primaryAddress, ConversationAwarenessEvent.START) + verify(exactly = 2) { mediaControl.duckMusicVolume(50) } + verify(exactly = 2) { mediaControl.requestDuckFocus() } + + emit(primaryAddress, ConversationAwarenessEvent.STOP) + advanceBoth(stopSettleMs + 50) + advanceBoth(staleTimeoutMs + 500) + + verify(exactly = 0) { mediaControl.abandonDuckFocus() } + verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } + job.cancel() + } + + @Test + fun `LOWER_VOLUME focus session releases focus when the owner disappears`() = + runTest(UnconfinedTestDispatcher()) { + every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10) + every { mediaControl.requestDuckFocus() } returns true + every { mediaControl.currentMusicVolume() } returns 10 + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + statesFlow.value = emptyMap() // device disconnected before STOP arrived + runCurrent() + + verify(exactly = 1) { mediaControl.abandonDuckFocus() } + verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } + job.cancel() + } + + /** + * Late-write guard: a device may apply the volume write asynchronously, after the read-back that + * showed equality and sent us down the focus path. Teardown would otherwise leave the stream + * index permanently lowered, so a level below the pre-duck one is restored. + */ + @Test + fun `LOWER_VOLUME focus session restores a volume write that landed late`() = + runTest(UnconfinedTestDispatcher()) { + every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10) + every { mediaControl.requestDuckFocus() } returns true + every { mediaControl.currentMusicVolume() } returns 5 // the write landed after all + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + emit(primaryAddress, ConversationAwarenessEvent.STOP) + advanceBoth(stopSettleMs + 50) + + verify(exactly = 1) { mediaControl.abandonDuckFocus() } + verify(exactly = 1) { mediaControl.restoreMusicVolume(10) } + job.cancel() + } + + /** + * A permanent focus loss mid-conversation is only recoverable on a later START — every one of + * them is a keep-alive for the running session, so the keep-alive path has to re-request. + */ + @Test + fun `LOWER_VOLUME keep-alive re-requests focus that was permanently lost`() = + runTest(UnconfinedTestDispatcher()) { + every { mediaControl.duckMusicVolume(any()) } returns MediaControl.DuckOutcome.Unchanged(priorVolume = 10) + every { mediaControl.requestDuckFocus() } returns true + every { mediaControl.currentMusicVolume() } returns 10 + every { mediaControl.isDuckFocusHeld } returns true + val job = launchReaction() + + emit(primaryAddress, ConversationAwarenessEvent.START) + verify(exactly = 1) { mediaControl.requestDuckFocus() } + + // Still held → the keep-alive must not re-request. + emit(primaryAddress, ConversationAwarenessEvent.START) + verify(exactly = 1) { mediaControl.requestDuckFocus() } + + every { mediaControl.isDuckFocusHeld } returns false + emit(primaryAddress, ConversationAwarenessEvent.START) + verify(exactly = 2) { mediaControl.requestDuckFocus() } + // Still the same session — no second duck attempt. + verify(exactly = 1) { mediaControl.duckMusicVolume(50) } + job.cancel() + } + @Test fun `LOWER_VOLUME missed STOP restores via stale timeout`() = runTest(UnconfinedTestDispatcher()) { val job = launchReaction()