mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Merge pull request #682 from d4rken-org/feat/focus-duck-fallback
Reaction: Lower conversation volume even on phones that block it
This commit is contained in:
@@ -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<AudioPlaybackConfiguration>) {
|
||||
// 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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+72
-14
@@ -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()
|
||||
|
||||
@@ -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<AudioManager.AudioPlaybackCallback>
|
||||
private lateinit var initRunnableSlot: CapturingSlot<Runnable>
|
||||
private lateinit var focusRequest: AudioFocusRequest
|
||||
private lateinit var focusRequestFactory: MediaControl.DuckFocusRequestFactory
|
||||
private lateinit var focusListenerSlot: CapturingSlot<AudioManager.OnAudioFocusChangeListener>
|
||||
|
||||
@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<Handler>()
|
||||
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<Runnable>()
|
||||
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()) }
|
||||
}
|
||||
}
|
||||
|
||||
+147
-7
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user