From d391f49939378463c7889a10b03d00d638fe8203 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Fri, 17 Jul 2026 10:22:27 +0100 Subject: [PATCH 1/3] Coordinate media playback audio focus --- .../android/audio/VoicePlaybackController.kt | 100 +++++++++++++----- .../ui/conversation/media/MediaVideo.kt | 33 ++++-- ...icePlaybackAudioFocusPolicyCoverageTest.kt | 47 ++++++++ .../VideoPlaybackCoordinationCoverageTest.kt | 50 +++++++++ 4 files changed, 200 insertions(+), 30 deletions(-) create mode 100644 app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt create mode 100644 app/src/test/java/dev/ipf/whitenoise/android/ui/VideoPlaybackCoordinationCoverageTest.kt diff --git a/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt b/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt index 79d9d32f0..bf56bcbf8 100644 --- a/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt +++ b/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt @@ -42,6 +42,7 @@ internal class VoicePlaybackRequestSerializer { object VoicePlaybackController { private const val TAG = "VoicePlaybackController" private const val TICK_INTERVAL_MS = 60L + private const val DUCK_VOLUME = 0.2f // Cap on cached per-clip durations. Each entry is a boxed Int keyed by an // absolute file path; without a bound the map held one entry per distinct @@ -105,15 +106,10 @@ object VoicePlaybackController { private var audioManager: AudioManager? = null private var focusRequest: AudioFocusRequest? = null + private var resumeOnAudioFocusGain = false + private var duckedForAudioFocusLoss = false private val focusListener = - AudioManager.OnAudioFocusChangeListener { change -> - when (change) { - AudioManager.AUDIOFOCUS_LOSS, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK, - -> pause() - } - } + AudioManager.OnAudioFocusChangeListener { change -> handleAudioFocusChange(change) } /** Call once from Application.onCreate so playback can request audio focus. */ fun attach(context: Context) { @@ -218,11 +214,13 @@ object VoicePlaybackController { file: File, ownerKey: String?, ): PlaybackStartResult { + if (resumeOnAudioFocusGain) return PlaybackStartResult.FocusDenied + clearAudioFocusInterruption(restoreVolume = true) if (currentKey == key && player != null) { - // Re-acquire focus before resuming: a transient focus loss - // auto-paused us (focusListener → pause()) and abandoned focus, - // so another app may now own it. Restarting without re-requesting - // would let two streams play at once or get our start() clobbered. + // User-paused playback abandons focus, so reacquire it before + // resuming. The transient-loss path retains focus and is resumed + // only by AUDIOFOCUS_GAIN; the guard above prevents a manual start + // while another transient owner still has focus. if (!requestFocus()) { // Focus denied — stay paused rather than playing unfocused. return PlaybackStartResult.FocusDenied @@ -395,9 +393,63 @@ object VoicePlaybackController { focusRequest = null } + private fun handleAudioFocusChange(change: Int) { + when (change) { + AudioManager.AUDIOFOCUS_LOSS -> pause() + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> pauseForTransientAudioFocusLoss() + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> duckForTransientAudioFocusLoss() + AudioManager.AUDIOFOCUS_GAIN -> restoreAfterAudioFocusGain() + } + } + + private fun pauseForTransientAudioFocusLoss() { + val mp = player ?: return + if (!runCatching { mp.isPlaying }.getOrDefault(false)) return + if (runCatching { mp.pause() }.isFailure) return + resumeOnAudioFocusGain = true + _state.value = + _state.value.copy( + isPlaying = false, + positionMs = runCatching { mp.currentPosition }.getOrDefault(_state.value.positionMs), + ) + stopTicker() + } + + private fun duckForTransientAudioFocusLoss() { + val mp = player ?: return + if (!runCatching { mp.isPlaying }.getOrDefault(false)) return + duckedForAudioFocusLoss = runCatching { mp.setVolume(DUCK_VOLUME, DUCK_VOLUME) }.isSuccess + } + + private fun restoreAfterAudioFocusGain() { + val mp = player + if (duckedForAudioFocusLoss) { + mp?.runCatching { setVolume(1f, 1f) } + duckedForAudioFocusLoss = false + } + if (!resumeOnAudioFocusGain) return + resumeOnAudioFocusGain = false + if (mp == null || !startCurrentPlayer(mp)) return + _state.value = + _state.value.copy( + isPlaying = true, + durationMs = runCatching { mp.duration }.getOrDefault(_state.value.durationMs), + ) + startTicker() + } + + private fun clearAudioFocusInterruption(restoreVolume: Boolean) { + if (restoreVolume && duckedForAudioFocusLoss) { + player?.runCatching { setVolume(1f, 1f) } + } + resumeOnAudioFocusGain = false + duckedForAudioFocusLoss = false + } + /** Pause the active player (no-op if nothing is active). */ fun pause() { nextPlaybackGeneration() + clearAudioFocusInterruption(restoreVolume = true) val mp = player ?: run { _state.value = _state.value.copy(isPlaying = false) @@ -405,19 +457,18 @@ object VoicePlaybackController { abandonFocus() return } - if (runCatching { mp.isPlaying }.getOrDefault(false)) { - mp.pause() - _state.value = - _state.value.copy( - isPlaying = false, - positionMs = mp.currentPosition, - ) - // Release focus while paused so other apps stop being ducked for - // the (potentially indefinite) pause. Resuming re-requests focus - // in playLocked(). Safe no-op if focus is not currently held. - abandonFocus() - } + val wasPlaying = runCatching { mp.isPlaying }.getOrDefault(false) + if (wasPlaying) runCatching { mp.pause() } + _state.value = + _state.value.copy( + isPlaying = false, + positionMs = runCatching { mp.currentPosition }.getOrDefault(_state.value.positionMs), + ) stopTicker() + // Release focus while user-paused so other apps stop being ducked for + // the (potentially indefinite) pause. A transient system pause uses a + // separate path and deliberately retains focus for the paired gain. + abandonFocus() } /** Seek the active player to [positionMs] (clamped to duration). */ @@ -456,6 +507,7 @@ object VoicePlaybackController { private fun releasePlayerInternal() { stopTicker() + clearAudioFocusInterruption(restoreVolume = false) player?.let { mp -> runCatching { if (mp.isPlaying) mp.stop() } runCatching { mp.release() } diff --git a/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt b/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt index 3dd20b151..16ef32fb0 100644 --- a/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt +++ b/app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import dev.ipf.marmotkit.MediaAttachmentReferenceFfi import dev.ipf.whitenoise.android.R +import dev.ipf.whitenoise.android.audio.VoicePlaybackController import dev.ipf.whitenoise.android.media.AttachmentCachePublication import dev.ipf.whitenoise.android.media.AttachmentPlaintextCache import dev.ipf.whitenoise.android.media.MediaCacheDirs @@ -72,6 +73,13 @@ import java.io.IOException private val videoMaterializations = SingleFlight() +private val videoPlaybackAudioAttributes = + androidx.media3.common.AudioAttributes + .Builder() + .setContentType(androidx.media3.common.C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(androidx.media3.common.C.USAGE_MEDIA) + .build() + /** * Single video tile in an album grid. Auto-materialises on first * composition (mine + cached short-circuit; otherwise FFI download honoring @@ -927,6 +935,7 @@ private fun FullscreenVideoPlayer( .Builder(context) .build() .apply { + setAudioAttributes(videoPlaybackAudioAttributes, true) addListener( object : androidx.media3.common.Player.Listener { override fun onPlayerError(error: androidx.media3.common.PlaybackException) { @@ -940,11 +949,14 @@ private fun FullscreenVideoPlayer( androidx.media3.common.MediaItem .fromUri(android.net.Uri.fromFile(file)), ) - prepare() - playWhenReady = true } } DisposableEffect(exo) { onDispose { exo.release() } } + LaunchedEffect(exo) { + VoicePlaybackController.pause() + exo.prepare() + exo.playWhenReady = true + } androidx.compose.ui.window.Dialog( onDismissRequest = onDismiss, properties = @@ -1121,6 +1133,7 @@ internal fun VideoViewerPage( .Builder(context) .build() .apply { + setAudioAttributes(videoPlaybackAudioAttributes, true) addListener( object : androidx.media3.common.Player.Listener { override fun onPlayerError(error: androidx.media3.common.PlaybackException) { @@ -1153,14 +1166,22 @@ internal fun VideoViewerPage( androidx.media3.common.MediaItem .fromUri(android.net.Uri.fromFile(file)), ) - prepare() } } DisposableEffect(exo) { onDispose { exo.release() } } - // Pre-composed neighbour pages must NOT play audio — only the visible - // one autoplays. Pause when the page scrolls off-screen. + // Only the current page owns a decoder or audio focus. HorizontalPager + // pre-composes neighbours, so preparing in remember would hold multiple + // MediaCodec instances and could invalidate healthy cache entries when a + // neighbour fails decoder acquisition. LaunchedEffect(isCurrent, exo) { - if (isCurrent) exo.playWhenReady = true else exo.pause() + if (isCurrent) { + VoicePlaybackController.pause() + exo.prepare() + exo.playWhenReady = true + } else { + exo.playWhenReady = false + exo.stop() + } } androidx.compose.ui.viewinterop.AndroidView( modifier = Modifier.fillMaxSize().background(Color.Black), diff --git a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt new file mode 100644 index 000000000..c55456f23 --- /dev/null +++ b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt @@ -0,0 +1,47 @@ +package dev.ipf.whitenoise.android.audio + +import dev.ipf.whitenoise.android.functionBody +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class VoicePlaybackAudioFocusPolicyCoverageTest { + @Test + fun transientFocusChangesDoNotUseTheUserPausePath() { + val source = voicePlaybackSource().readText() + val listener = source.functionBody("handleAudioFocusChange") + + assertTrue("permanent focus loss must pause normally", "AUDIOFOCUS_LOSS -> pause()" in listener) + assertTrue( + "transient focus loss must retain focus for automatic resume", + "AUDIOFOCUS_LOSS_TRANSIENT -> pauseForTransientAudioFocusLoss()" in listener, + ) + assertTrue( + "duck requests must lower volume instead of pausing", + "AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> duckForTransientAudioFocusLoss()" in listener, + ) + assertTrue("focus gain must restore interrupted playback", "AUDIOFOCUS_GAIN -> restoreAfterAudioFocusGain()" in listener) + assertFalse("transient focus changes must not abandon the held request", "abandonFocus()" in listener) + } + + @Test + fun transientPauseDuckAndGainPathsPreserveTheirIntent() { + val source = voicePlaybackSource().readText() + val transientPause = source.functionBody("pauseForTransientAudioFocusLoss") + val duck = source.functionBody("duckForTransientAudioFocusLoss") + val gain = source.functionBody("restoreAfterAudioFocusGain") + + assertTrue("transient pause must remember to resume", "resumeOnAudioFocusGain = true" in transientPause) + assertFalse("transient pause must retain audio focus", "abandonFocus()" in transientPause) + assertTrue("duck must lower both channels", "setVolume(DUCK_VOLUME, DUCK_VOLUME)" in duck) + assertTrue("gain must restore both channels", "setVolume(1f, 1f)" in gain) + assertTrue("gain must restart only an interrupted clip", "if (!resumeOnAudioFocusGain) return" in gain) + } + + private fun voicePlaybackSource(): File = + listOf( + File("src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt"), + File("app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt"), + ).firstOrNull(File::exists) ?: error("Missing VoicePlaybackController.kt") +} diff --git a/app/src/test/java/dev/ipf/whitenoise/android/ui/VideoPlaybackCoordinationCoverageTest.kt b/app/src/test/java/dev/ipf/whitenoise/android/ui/VideoPlaybackCoordinationCoverageTest.kt new file mode 100644 index 000000000..7b0dd1c4d --- /dev/null +++ b/app/src/test/java/dev/ipf/whitenoise/android/ui/VideoPlaybackCoordinationCoverageTest.kt @@ -0,0 +1,50 @@ +package dev.ipf.whitenoise.android.ui + +import dev.ipf.whitenoise.android.functionBody +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class VideoPlaybackCoordinationCoverageTest { + @Test + fun everyVideoPlayerOwnsAudioFocusAndPausesVoicePlayback() { + val source = mediaVideoSource().readText() + + listOf("FullscreenVideoPlayer", "VideoViewerPage").forEach { functionName -> + val body = source.functionBody(functionName) + + assertTrue( + "$functionName must delegate audio focus to Media3", + "setAudioAttributes(videoPlaybackAudioAttributes, true)" in body, + ) + assertTrue( + "$functionName must stop voice-note audio before video playback", + "VoicePlaybackController.pause()" in body, + ) + } + } + + @Test + fun pagerVideoOnlyPreparesWhileItIsCurrent() { + val body = mediaVideoSource().readText().functionBody("VideoViewerPage") + val beforePlaybackEffect = body.substringBefore("LaunchedEffect(isCurrent, exo)") + val playbackEffect = + body + .substringAfter("LaunchedEffect(isCurrent, exo)") + .substringBefore("androidx.compose.ui.viewinterop.AndroidView") + + assertFalse( + "pre-composed neighbour pages must not eagerly prepare a decoder", + "prepare()" in beforePlaybackEffect, + ) + assertTrue("the current page must prepare its player", "if (isCurrent)" in playbackEffect && "exo.prepare()" in playbackEffect) + assertTrue("an off-screen page must release its decoder", "exo.stop()" in playbackEffect) + } + + private fun mediaVideoSource(): File = + listOf( + File("src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt"), + File("app/src/main/java/dev/ipf/whitenoise/android/ui/conversation/media/MediaVideo.kt"), + ).firstOrNull(File::exists) ?: error("Missing MediaVideo.kt") +} From 33830ead0ac63a728252f55b5df97fc0d9455e98 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Fri, 17 Jul 2026 11:53:22 +0100 Subject: [PATCH 2/3] Harden voice focus interruption handling --- .../android/audio/VoicePlaybackController.kt | 12 +- ...icePlaybackAudioFocusPolicyCoverageTest.kt | 1 + .../audio/VoicePlaybackControllerFocusTest.kt | 138 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt b/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt index bf56bcbf8..5a2eac06b 100644 --- a/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt +++ b/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt @@ -405,7 +405,11 @@ object VoicePlaybackController { private fun pauseForTransientAudioFocusLoss() { val mp = player ?: return if (!runCatching { mp.isPlaying }.getOrDefault(false)) return - if (runCatching { mp.pause() }.isFailure) return + if (runCatching { mp.pause() }.isFailure) { + releasePlayerInternal() + _state.value = PlaybackState() + return + } resumeOnAudioFocusGain = true _state.value = _state.value.copy( @@ -418,7 +422,11 @@ object VoicePlaybackController { private fun duckForTransientAudioFocusLoss() { val mp = player ?: return if (!runCatching { mp.isPlaying }.getOrDefault(false)) return - duckedForAudioFocusLoss = runCatching { mp.setVolume(DUCK_VOLUME, DUCK_VOLUME) }.isSuccess + if (runCatching { mp.setVolume(DUCK_VOLUME, DUCK_VOLUME) }.isSuccess) { + duckedForAudioFocusLoss = true + } else { + pauseForTransientAudioFocusLoss() + } } private fun restoreAfterAudioFocusGain() { diff --git a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt index c55456f23..ea21eeec1 100644 --- a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt +++ b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackAudioFocusPolicyCoverageTest.kt @@ -35,6 +35,7 @@ class VoicePlaybackAudioFocusPolicyCoverageTest { assertTrue("transient pause must remember to resume", "resumeOnAudioFocusGain = true" in transientPause) assertFalse("transient pause must retain audio focus", "abandonFocus()" in transientPause) assertTrue("duck must lower both channels", "setVolume(DUCK_VOLUME, DUCK_VOLUME)" in duck) + assertTrue("failed ducking must fall back to a resumable pause", "pauseForTransientAudioFocusLoss()" in duck) assertTrue("gain must restore both channels", "setVolume(1f, 1f)" in gain) assertTrue("gain must restart only an interrupted clip", "if (!resumeOnAudioFocusGain) return" in gain) } diff --git a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt index 334c2a785..0470c4a49 100644 --- a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt +++ b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt @@ -131,6 +131,78 @@ class VoicePlaybackControllerFocusTest { assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) } + @Test + fun transientLossRetainsFocusAndResumesTheInterruptedPlayerOnGain() { + val context = RuntimeEnvironment.getApplication() + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + val heldFocusRequest = controllerField("focusRequest") + assertNotNull(heldFocusRequest) + val mediaPlayer = TrackingMediaPlayer() + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + + assertFalse(mediaPlayer.playing) + assertFalse(VoicePlaybackController.state.value.isPlaying) + assertSame(heldFocusRequest, controllerField("focusRequest")) + assertTrue(controllerField("resumeOnAudioFocusGain") as Boolean) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) + + assertTrue(mediaPlayer.playing) + assertTrue(VoicePlaybackController.state.value.isPlaying) + assertSame(heldFocusRequest, controllerField("focusRequest")) + assertFalse(controllerField("resumeOnAudioFocusGain") as Boolean) + } + + @Test + fun duckableLossLowersVolumeAndGainRestoresItWithoutRestarting() { + val mediaPlayer = TrackingMediaPlayer() + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) + + assertTrue(mediaPlayer.playing) + assertEquals(0.2f, mediaPlayer.leftVolume, 0f) + assertEquals(0.2f, mediaPlayer.rightVolume, 0f) + assertEquals(0, mediaPlayer.startCount) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) + + assertEquals(1f, mediaPlayer.leftVolume, 0f) + assertEquals(1f, mediaPlayer.rightVolume, 0f) + assertEquals(0, mediaPlayer.startCount) + } + + @Test + fun failedDuckFallsBackToTransientPause() { + val mediaPlayer = TrackingMediaPlayer(failVolumeChange = true) + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) + + assertFalse(mediaPlayer.playing) + assertFalse(VoicePlaybackController.state.value.isPlaying) + assertTrue(controllerField("resumeOnAudioFocusGain") as Boolean) + } + + @Test + fun failedTransientPauseReleasesBrokenPlayerAndFocus() { + val context = RuntimeEnvironment.getApplication() + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + val mediaPlayer = TrackingMediaPlayer(failPause = true) + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + + assertTrue(mediaPlayer.released) + assertNull(controllerField("player")) + assertNull(controllerField("focusRequest")) + assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) + } + // Hit the private focus path directly; public playback needs MediaPlayer file // setup and would obscure the focus bookkeeping this regression protects. private fun requestFocus(): Boolean { @@ -149,6 +221,26 @@ class VoicePlaybackControllerFocusTest { return method.invoke(VoicePlaybackController, mediaPlayer) as Boolean } + private fun handleAudioFocusChange(change: Int) { + val method = VoicePlaybackController::class.java.getDeclaredMethod("handleAudioFocusChange", Integer.TYPE) + method.isAccessible = true + method.invoke(VoicePlaybackController, change) + } + + private fun primeActivePlayer(mediaPlayer: TrackingMediaPlayer) { + setControllerField("player", mediaPlayer) + setControllerField("currentKey", "voice-key") + setControllerField("currentOwnerKey", "owner") + setPlaybackState( + VoicePlaybackController.PlaybackState( + key = "voice-key", + isPlaying = true, + positionMs = mediaPlayer.positionMs, + durationMs = mediaPlayer.durationMs, + ), + ) + } + private fun controllerField(name: String): Any? { val field = VoicePlaybackController::class.java.getDeclaredField(name) field.isAccessible = true @@ -183,4 +275,50 @@ class VoicePlaybackControllerFocusTest { released = true } } + + private class TrackingMediaPlayer( + private val failPause: Boolean = false, + private val failVolumeChange: Boolean = false, + ) : MediaPlayer() { + var playing = true + var released = false + var leftVolume = 1f + var rightVolume = 1f + var startCount = 0 + val positionMs = 123 + val durationMs = 456 + + override fun isPlaying(): Boolean = playing + + override fun pause() { + if (failPause) throw IllegalStateException("pause failed") + playing = false + } + + override fun start() { + startCount += 1 + playing = true + } + + override fun stop() { + playing = false + } + + override fun getCurrentPosition(): Int = positionMs + + override fun getDuration(): Int = durationMs + + override fun setVolume( + leftVolume: Float, + rightVolume: Float, + ) { + if (failVolumeChange) throw IllegalStateException("setVolume failed") + this.leftVolume = leftVolume + this.rightVolume = rightVolume + } + + override fun release() { + released = true + } + } } From 64e5d238a1b8c57ab52c6d8ef459b6f1ce3e6023 Mon Sep 17 00:00:00 2001 From: Mubarak Auwal Date: Fri, 17 Jul 2026 13:00:53 +0100 Subject: [PATCH 3/3] Fail closed on voice player control errors --- .../android/audio/VoicePlaybackController.kt | 55 ++++++++++--- .../audio/VoicePlaybackControllerFocusTest.kt | 78 ++++++++++++++++++- 2 files changed, 122 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt b/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt index 5a2eac06b..977af58ed 100644 --- a/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt +++ b/app/src/main/java/dev/ipf/whitenoise/android/audio/VoicePlaybackController.kt @@ -404,10 +404,15 @@ object VoicePlaybackController { private fun pauseForTransientAudioFocusLoss() { val mp = player ?: return - if (!runCatching { mp.isPlaying }.getOrDefault(false)) return - if (runCatching { mp.pause() }.isFailure) { - releasePlayerInternal() - _state.value = PlaybackState() + val wasPlaying = + runCatching { mp.isPlaying }.getOrElse { failure -> + releaseAfterPlayerControlFailure("MediaPlayer state query failed during transient focus loss", failure) + return + } + if (!wasPlaying) return + val pauseFailure = runCatching { mp.pause() }.exceptionOrNull() + if (pauseFailure != null) { + releaseAfterPlayerControlFailure("MediaPlayer transient pause failed", pauseFailure) return } resumeOnAudioFocusGain = true @@ -421,7 +426,12 @@ object VoicePlaybackController { private fun duckForTransientAudioFocusLoss() { val mp = player ?: return - if (!runCatching { mp.isPlaying }.getOrDefault(false)) return + val wasPlaying = + runCatching { mp.isPlaying }.getOrElse { failure -> + releaseAfterPlayerControlFailure("MediaPlayer state query failed while ducking", failure) + return + } + if (!wasPlaying) return if (runCatching { mp.setVolume(DUCK_VOLUME, DUCK_VOLUME) }.isSuccess) { duckedForAudioFocusLoss = true } else { @@ -432,7 +442,11 @@ object VoicePlaybackController { private fun restoreAfterAudioFocusGain() { val mp = player if (duckedForAudioFocusLoss) { - mp?.runCatching { setVolume(1f, 1f) } + val restoreFailure = mp?.runCatching { setVolume(1f, 1f) }?.exceptionOrNull() + if (restoreFailure != null) { + releaseAfterPlayerControlFailure("MediaPlayer volume restore failed", restoreFailure) + return + } duckedForAudioFocusLoss = false } if (!resumeOnAudioFocusGain) return @@ -448,12 +462,25 @@ object VoicePlaybackController { private fun clearAudioFocusInterruption(restoreVolume: Boolean) { if (restoreVolume && duckedForAudioFocusLoss) { - player?.runCatching { setVolume(1f, 1f) } + val restoreFailure = player?.runCatching { setVolume(1f, 1f) }?.exceptionOrNull() + if (restoreFailure != null) { + releaseAfterPlayerControlFailure("MediaPlayer volume restore failed", restoreFailure) + return + } } resumeOnAudioFocusGain = false duckedForAudioFocusLoss = false } + private fun releaseAfterPlayerControlFailure( + message: String, + failure: Throwable, + ) { + Log.w(TAG, message, failure) + releasePlayerInternal() + _state.value = PlaybackState() + } + /** Pause the active player (no-op if nothing is active). */ fun pause() { nextPlaybackGeneration() @@ -465,8 +492,18 @@ object VoicePlaybackController { abandonFocus() return } - val wasPlaying = runCatching { mp.isPlaying }.getOrDefault(false) - if (wasPlaying) runCatching { mp.pause() } + val wasPlaying = + runCatching { mp.isPlaying }.getOrElse { failure -> + releaseAfterPlayerControlFailure("MediaPlayer state query failed while pausing", failure) + return + } + if (wasPlaying) { + val pauseFailure = runCatching { mp.pause() }.exceptionOrNull() + if (pauseFailure != null) { + releaseAfterPlayerControlFailure("MediaPlayer pause failed", pauseFailure) + return + } + } _state.value = _state.value.copy( isPlaying = false, diff --git a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt index 0470c4a49..20db35a21 100644 --- a/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt +++ b/app/src/test/java/dev/ipf/whitenoise/android/audio/VoicePlaybackControllerFocusTest.kt @@ -203,6 +203,73 @@ class VoicePlaybackControllerFocusTest { assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) } + @Test + fun failedUserPauseControlsReleaseBrokenPlayerAndFocus() { + val context = RuntimeEnvironment.getApplication() + listOf( + TrackingMediaPlayer(failPlayingQuery = true), + TrackingMediaPlayer(failPause = true), + ).forEach { mediaPlayer -> + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + primeActivePlayer(mediaPlayer) + + VoicePlaybackController.pause() + + assertTrue(mediaPlayer.released) + assertNull(controllerField("player")) + assertNull(controllerField("focusRequest")) + assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) + } + } + + @Test + fun failedFocusLossStateQueriesReleaseBrokenPlayerAndFocus() { + val context = RuntimeEnvironment.getApplication() + listOf( + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK, + ).forEach { focusChange -> + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + val mediaPlayer = TrackingMediaPlayer(failPlayingQuery = true) + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(focusChange) + + assertTrue(mediaPlayer.released) + assertNull(controllerField("player")) + assertNull(controllerField("focusRequest")) + assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) + } + } + + @Test + fun failedVolumeRestoreReleasesBrokenPlayerAndFocus() { + val context = RuntimeEnvironment.getApplication() + val restoreActions: List<() -> Unit> = + listOf( + { handleAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN) }, + { VoicePlaybackController.pause() }, + ) + restoreActions.forEach { restoreAction -> + VoicePlaybackController.attach(context) + assertTrue(requestFocus()) + val mediaPlayer = TrackingMediaPlayer(failVolumeRestore = true) + primeActivePlayer(mediaPlayer) + + handleAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK) + assertEquals(0.2f, mediaPlayer.leftVolume, 0f) + + restoreAction() + + assertTrue(mediaPlayer.released) + assertNull(controllerField("player")) + assertNull(controllerField("focusRequest")) + assertEquals(VoicePlaybackController.PlaybackState(), VoicePlaybackController.state.value) + } + } + // Hit the private focus path directly; public playback needs MediaPlayer file // setup and would obscure the focus bookkeeping this regression protects. private fun requestFocus(): Boolean { @@ -279,6 +346,8 @@ class VoicePlaybackControllerFocusTest { private class TrackingMediaPlayer( private val failPause: Boolean = false, private val failVolumeChange: Boolean = false, + private val failVolumeRestore: Boolean = false, + private val failPlayingQuery: Boolean = false, ) : MediaPlayer() { var playing = true var released = false @@ -288,7 +357,10 @@ class VoicePlaybackControllerFocusTest { val positionMs = 123 val durationMs = 456 - override fun isPlaying(): Boolean = playing + override fun isPlaying(): Boolean { + if (failPlayingQuery) throw IllegalStateException("isPlaying failed") + return playing + } override fun pause() { if (failPause) throw IllegalStateException("pause failed") @@ -312,7 +384,9 @@ class VoicePlaybackControllerFocusTest { leftVolume: Float, rightVolume: Float, ) { - if (failVolumeChange) throw IllegalStateException("setVolume failed") + if (failVolumeChange || (failVolumeRestore && leftVolume == 1f && rightVolume == 1f)) { + throw IllegalStateException("setVolume failed") + } this.leftVolume = leftVolume this.rightVolume = rightVolume }