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()