From cecd3088be57ba2a5a5428529d3fb5881609522b Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 18 Aug 2026 12:36:30 +0200 Subject: [PATCH 1/2] feat(reaction): Duck via audio focus when the volume write is ignored ColorOS 16 accepts setStreamVolume from a backgrounded app and leaves the level where it was, so the conversation reaction ducked nothing. duckMusicVolume now classifies the outcome (Ducked / Unchanged / Skipped) instead of collapsing everything into a nullable duck, and a level that came back *higher* stays a skip: that is the user raising the volume between the two reads, not a refusal. On Unchanged the reaction requests AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK and lets the framework attenuate the other player. Teardown abandons the focus and, if the volume ended up below the pre-duck level anyway, restores it (guard for a device that applies the write asynchronously). The focus request is built by an injected factory because AudioFocusRequest.Builder is an unmocked stub in plain JVM unit tests. --- .../eu/darken/capod/common/MediaControl.kt | 142 ++++++++++++++---- .../capod/common/dagger/AndroidModule.kt | 18 +++ .../core/conversation/ConversationReaction.kt | 86 +++++++++-- 3 files changed, 204 insertions(+), 42 deletions(-) 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 4397f288..cee0302a 100644 --- a/app/src/main/java/eu/darken/capod/common/MediaControl.kt +++ b/app/src/main/java/eu/darken/capod/common/MediaControl.kt @@ -1,5 +1,7 @@ package eu.darken.capod.common +import android.media.AudioAttributes +import android.media.AudioFocusRequest import android.media.AudioManager import android.media.AudioPlaybackConfiguration import android.os.Build @@ -25,6 +27,7 @@ class MediaControl @Inject constructor( private val audioManager: AudioManager, private val timeSource: TimeSource, @AudioCallbackHandler private val audioCallbackHandler: Handler, + private val duckFocusRequestFactory: DuckFocusRequestFactory, ) { /** * Set when [sendPause] dispatches a pause we expect to take effect, cleared when [sendPlay] @@ -47,6 +50,11 @@ class MediaControl @Inject constructor( */ private val dispatchLock = Mutex() + /** The granted ducking focus request, or `null` when we don't hold focus. Guarded by `this`. */ + private var duckFocusRequest: AudioFocusRequest? = null + + private val duckFocusListener = AudioManager.OnAudioFocusChangeListener { change -> onDuckFocusChanged(change) } + private val playbackCallback = object : AudioManager.AudioPlaybackCallback() { override fun onPlaybackConfigChanged(configs: List) { // The edge is derived from this delivery's own snapshot, not a live isMusicActive read: @@ -263,24 +271,25 @@ class MediaControl @Inject constructor( fun currentMusicVolume(): Int = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) /** - * Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and returns - * the prior + the volume actually applied, so the caller can later restore it and detect whether - * the user changed the volume in the meantime. + * Lowers STREAM_MUSIC volume by [reductionPercent] (relative to the current level) and classifies + * what actually happened, so the caller can restore the prior level later, fall back to audio + * focus, or do nothing at all. * - * Returns `null` (no-op) when nothing is playing, the device has fixed volume, the computed - * target wouldn't actually lower the volume, or the write didn't land. No + * Returns [DuckOutcome.Skipped] when nothing is playing, the device has fixed volume, the computed + * target wouldn't actually lower the volume, the level came back higher, or the write was denied. + * Returns [DuckOutcome.Unchanged] when the write was accepted but the level did not drop. No * [AudioManager.FLAG_SHOW_UI] — this fires on a frequent push event and the volume panel * flashing would be noisy. The applied target is read back from the system because Bluetooth * absolute-volume routes can quantize the requested value. */ - fun duckMusicVolume(reductionPercent: Int): VolumeDuck? { + fun duckMusicVolume(reductionPercent: Int): DuckOutcome { if (!audioManager.isMusicActive) { log(TAG, INFO) { "duckMusicVolume: nothing playing, skipping" } - return null + return DuckOutcome.Skipped } if (audioManager.isVolumeFixed) { log(TAG, INFO) { "duckMusicVolume: device has fixed volume, skipping" } - return null + return DuckOutcome.Skipped } val percent = reductionPercent.coerceIn(0, 100) val max = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) @@ -293,30 +302,45 @@ class MediaControl @Inject constructor( val target = (prior * (100 - percent) / 100).coerceIn(min, max) if (target >= prior) { log(TAG, INFO) { "duckMusicVolume: target $target >= current $prior, skipping" } - return null + return DuckOutcome.Skipped } return try { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0) val applied = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - if (applied >= prior) { - // No attenuation happened. Either the write was accepted and dropped (ColorOS 16 - // does this while the app is in the background: no exception, volume untouched), - // the route quantized the target back up to where it started, or the user raised - // the volume in between. Reporting a duck would have the caller track a session - // that never attenuated anything, and later "restore" a level it never left. - log(TAG, WARN) { - "duckMusicVolume($percent%): volume did not decrease, $prior -> $applied " + - "(requested $target, min=$min, max=$max)" + when { + applied > prior -> { + // The level came back HIGHER than we found it: the user raised the volume between + // the two reads. That is not the ROM refusing the write, so it must not read as + // one — a fallback here would fire on a device whose volume writes work fine. + log(TAG, WARN) { + "duckMusicVolume($percent%): volume increased, $prior -> $applied " + + "(requested $target, min=$min, max=$max)" + } + DuckOutcome.Skipped + } + + applied == prior -> { + // No attenuation happened even though the write was accepted: ColorOS 16 does + // this while the app is in the background (no exception, volume untouched), and + // a route may quantize the target back up to where it started. Reporting a duck + // would have the caller track a session that never attenuated anything, and + // later "restore" a level it never left. + log(TAG, WARN) { + "duckMusicVolume($percent%): volume did not decrease, $prior -> $applied " + + "(requested $target, min=$min, max=$max)" + } + DuckOutcome.Unchanged(priorVolume = prior) + } + + else -> { + log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" } + DuckOutcome.Ducked(priorVolume = prior, appliedVolume = applied) } - null - } else { - log(TAG, INFO) { "duckMusicVolume($percent%): $prior -> $applied (requested $target)" } - VolumeDuck(priorVolume = prior, appliedVolume = applied) } } catch (e: SecurityException) { // setStreamVolume throws under Do-Not-Disturb without notification policy access. log(TAG, WARN) { "duckMusicVolume: setStreamVolume denied: ${e.message}" } - null + DuckOutcome.Skipped } } @@ -331,11 +355,73 @@ class MediaControl @Inject constructor( } } - /** Snapshot of a volume duck so the caller can restore the prior level and detect user changes. */ - data class VolumeDuck( - val priorVolume: Int, - val appliedVolume: Int, - ) + /** + * Requests transient ducking audio focus so the framework attenuates the other player for us. + * Fallback for devices where the volume write is accepted but ignored ([DuckOutcome.Unchanged]). + * + * Idempotent — returns `true` when focus is held, whether this call obtained it or an earlier one + * did. A grant only describes the instant of the request, so the held state is dropped again when + * the system takes focus away permanently. + */ + @Synchronized + fun requestDuckFocus(): Boolean { + if (duckFocusRequest != null) { + log(TAG, INFO) { "requestDuckFocus(): already held" } + return true + } + val request = duckFocusRequestFactory.create(duckFocusListener) + val result = audioManager.requestAudioFocus(request) + return if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + duckFocusRequest = request + log(TAG, INFO) { "requestDuckFocus(): granted" } + true + } else { + log(TAG, INFO) { "requestDuckFocus(): denied ($result)" } + false + } + } + + /** Releases the ducking focus taken by [requestDuckFocus]. No-op when we don't hold it. */ + @Synchronized + fun abandonDuckFocus() { + val request = duckFocusRequest ?: return + duckFocusRequest = null + audioManager.abandonAudioFocusRequest(request) + log(TAG, INFO) { "abandonDuckFocus(): focus released" } + } + + val isDuckFocusHeld: Boolean + @Synchronized get() = duckFocusRequest != null + + @Synchronized + private fun onDuckFocusChanged(change: Int) { + // Only a PERMANENT loss ends our request. AUDIOFOCUS_LOSS_TRANSIENT leaves it in place, so + // clearing on that would forget a request the system still tracks and let the next + // abandon/re-request pair fight the other app over a temporary interruption. + if (change != AudioManager.AUDIOFOCUS_LOSS) return + log(TAG, INFO) { "Duck focus permanently lost" } + duckFocusRequest = null + } + + /** What a [duckMusicVolume] call actually achieved. */ + sealed interface DuckOutcome { + /** The level dropped: [priorVolume] is what to restore, [appliedVolume] what landed. */ + data class Ducked(val priorVolume: Int, val appliedVolume: Int) : DuckOutcome + + /** The write was accepted but the level did not move — the read-back proves no attenuation. */ + data class Unchanged(val priorVolume: Int) : DuckOutcome + + /** Nothing was attempted, or the result is nothing for the caller to act on. */ + data object Skipped : DuckOutcome + } + + /** + * Test seam for building the ducking focus request: [AudioFocusRequest.Builder] and + * [AudioAttributes.Builder] are unmocked stubs that throw in this module's plain JVM unit tests. + */ + fun interface DuckFocusRequestFactory { + fun create(listener: AudioManager.OnAudioFocusChangeListener): AudioFocusRequest + } companion object { private val TAG = logTag("MediaControl") diff --git a/app/src/main/java/eu/darken/capod/common/dagger/AndroidModule.kt b/app/src/main/java/eu/darken/capod/common/dagger/AndroidModule.kt index 8c164581..a13415a6 100644 --- a/app/src/main/java/eu/darken/capod/common/dagger/AndroidModule.kt +++ b/app/src/main/java/eu/darken/capod/common/dagger/AndroidModule.kt @@ -4,6 +4,8 @@ import android.app.Application import android.app.NotificationManager import android.bluetooth.BluetoothManager import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFocusRequest import android.media.AudioManager import android.os.Handler import android.os.HandlerThread @@ -11,6 +13,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import eu.darken.capod.common.MediaControl import javax.inject.Qualifier import javax.inject.Singleton @@ -43,6 +46,21 @@ class AndroidModule { fun audioCallbackHandler(): Handler = Handler(HandlerThread("CAPod-MediaControl").apply { start() }.looper) + @Provides + @Singleton + fun duckFocusRequestFactory(): MediaControl.DuckFocusRequestFactory = + MediaControl.DuckFocusRequestFactory { listener -> + AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANT) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setOnAudioFocusChangeListener(listener) + .build() + } + } @Qualifier diff --git a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt index 08b8c3ea..add7cb3d 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt @@ -82,6 +82,14 @@ class ConversationReaction @Inject constructor( private sealed interface Kind { data object Paused : Kind data class Ducked(val priorVolume: Int, val appliedVolume: Int) : Kind + + /** + * Fallback for a volume write the system accepted but ignored: we hold + * `AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK` and let the framework attenuate the other player. + * Named for what is actually true — a granted request does not prove anything was + * attenuated. [priorVolume] is only kept for the late-write guard at teardown. + */ + data class FocusHeld(val priorVolume: Int) : Kind } private data class Active( @@ -155,6 +163,13 @@ class ConversationReaction @Inject constructor( // Duplicate START for the same speaker — don't re-act, just keep the session alive. // A START also cancels a pending wind-down fuse / settle: the wearer is speaking again. armTimer(current, TimerPhase.STALE_BACKSTOP) + // Exception: a focus session that lost focus permanently mid-conversation has to be + // re-requested here. Every later START is treated as a keep-alive, so this is the + // only place that recovery can happen. + if (current.kind is Kind.FocusHeld && !mediaControl.isDuckFocusHeld) { + val regained = mediaControl.requestDuckFocus() + log(TAG, INFO) { "START from $address — duck focus was lost, re-requested (granted=$regained)" } + } log(TAG) { "START from $address — already active ($action), keep-alive" } return } @@ -185,20 +200,47 @@ class ConversationReaction @Inject constructor( ReactionConfig.MIN_CONVERSATION_VOLUME_REDUCTION, ReactionConfig.MAX_CONVERSATION_VOLUME_REDUCTION, ) - val duck = mediaControl.duckMusicVolume(reduction) - if (duck != null) { - val record = Active( - nextId(), - address, - Kind.Ducked(duck.priorVolume, duck.appliedVolume), - timeSource.elapsedRealtime(), - ) - active = record - armTimer(record, TimerPhase.STALE_BACKSTOP) - log(TAG, INFO) { "START on $address → ducked volume ${duck.priorVolume}→${duck.appliedVolume}" } - } else { - active = null - log(TAG) { "START on $address → duck no-op" } + when (val outcome = mediaControl.duckMusicVolume(reduction)) { + is MediaControl.DuckOutcome.Ducked -> { + val record = Active( + nextId(), + address, + Kind.Ducked(outcome.priorVolume, outcome.appliedVolume), + timeSource.elapsedRealtime(), + ) + active = record + armTimer(record, TimerPhase.STALE_BACKSTOP) + log(TAG, INFO) { + "START on $address → ducked volume ${outcome.priorVolume}→${outcome.appliedVolume}" + } + } + + is MediaControl.DuckOutcome.Unchanged -> { + // The write was accepted but the level never moved (ColorOS 16 refuses a + // backgrounded app's write). Ask the framework to duck the other player + // instead. A grant does not prove anything was attenuated: a player whose + // content is marked speech (podcasts) may pause instead of duck, and a + // player that never requested focus need not be attenuated at all. + if (mediaControl.requestDuckFocus()) { + val record = Active( + nextId(), + address, + Kind.FocusHeld(outcome.priorVolume), + timeSource.elapsedRealtime(), + ) + active = record + armTimer(record, TimerPhase.STALE_BACKSTOP) + log(TAG, INFO) { "START on $address → volume write ignored, holding duck focus" } + } else { + active = null + log(TAG) { "START on $address → duck no-op" } + } + } + + MediaControl.DuckOutcome.Skipped -> { + active = null + log(TAG) { "START on $address → duck no-op" } + } } } @@ -301,6 +343,8 @@ class ConversationReaction @Inject constructor( } is Kind.Ducked -> revertDuck(kind, reason) + + is Kind.FocusHeld -> revertFocus(kind, reason) } } @@ -343,6 +387,7 @@ class ConversationReaction @Inject constructor( private fun revert(record: Active, reason: String) { when (val kind = record.kind) { is Kind.Ducked -> revertDuck(kind, reason) + is Kind.FocusHeld -> revertFocus(kind, reason) is Kind.Paused -> log(TAG) { "Clearing pause ($reason) — leaving playback as-is" } } } @@ -358,6 +403,19 @@ class ConversationReaction @Inject constructor( mediaControl.restoreMusicVolume(kind.priorVolume) } + private fun revertFocus(kind: Kind.FocusHeld, reason: String) { + mediaControl.abandonDuckFocus() + log(TAG, INFO) { "Released duck focus ($reason)" } + // Late-write guard: a device may apply the volume write asynchronously. The read-back showed + // equality, so we took the focus path — if the write landed afterwards, teardown would leave + // the stream index permanently lowered. Restore ONLY when the level is below what we found. + val current = mediaControl.currentMusicVolume() + if (current < kind.priorVolume) { + log(TAG, INFO) { "Restoring volume to ${kind.priorVolume} (late write landed at $current, $reason)" } + mediaControl.restoreMusicVolume(kind.priorVolume) + } + } + /** Must be called under [mutex]. Clears the active slot and cancels its pending timer. */ private fun clearActive() { disengageJob?.cancel() From 27cc0e8d00ed06bec3ab8e80222d1b6f0e65a42c Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 18 Aug 2026 12:37:33 +0200 Subject: [PATCH 2/2] test(reaction): Cover the audio-focus duck fallback Pins the outcome mapping of duckMusicVolume (a higher read-back stays a skip, an unmoved one is a refusal), the focus request lifecycle (idempotent grant, retry after denial, abandon only what is held, permanent loss clears the state but a transient one does not), and the reaction side: focus is requested only for an ignored write, released at teardown, re-requested by a keep-alive after a permanent loss, and a late-landing volume write is restored. The negative assertions are load-bearing: the MediaControl mock is relaxed, so an accidental focus request on a healthy duck would pass silently. --- .../darken/capod/common/MediaControlTest.kt | 140 ++++++++++++++-- .../conversation/ConversationReactionTest.kt | 154 +++++++++++++++++- 2 files changed, 273 insertions(+), 21 deletions(-) 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()