Keep playing a voice message when another app plays a short sound (#7454)

* Request media audio focus when playing a voice message

Voice message playback asked for audio focus with
USAGE_VOICE_COMMUNICATION, the same bucket as Element Call and voice
message recording, while the player itself renders on the media stream.
Audio policy layers act on the declared usage, so a car head unit
switches to its call audio context and routes the playback away while
the player keeps running.

Declare USAGE_MEDIA (and STREAM_MUSIC on the legacy path) for playback,
which is what the player actually renders. Recording and Element Call
keep asking for voice communication focus.

* Keep playing a voice message through a short sound from another app

A notification, a navigation prompt or any other app taking focus for a
moment sends AUDIOFOCUS_LOSS_TRANSIENT or AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
and DefaultMediaPlayer pauses on it. The voice message stops part way
through and the user has to find it again and restart it.

Recording already ignores those two, because the capture path is not
affected by focus. Give voice message playback the same treatment, moving
the requester test into a named function rather than growing the inline
condition. A permanent AUDIOFOCUS_LOSS, a real phone call, still pauses,
and the media viewer is untouched.
This commit is contained in:
hayyaksi
2026-08-20 09:55:03 +03:00
committed by GitHub
parent 1c94e89ee8
commit 2432b0ccc6
3 changed files with 97 additions and 5 deletions
+3
View File
@@ -1,4 +1,5 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
@@ -22,4 +23,6 @@ dependencies {
implementation(libs.androidx.corektx)
implementation(projects.libraries.di)
testCommonDependencies(libs)
}
@@ -48,9 +48,8 @@ class DefaultAudioFocus(
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// For recording, ignore transient focus losses (e.g., notification sounds).
// The AudioRecord API keeps capturing regardless.
if (requester != AudioFocusRequester.RecordVoiceMessage) {
Timber.d("AudioFocus: transient loss ($it)")
if (requester.pausesOnTransientFocusLoss()) {
onFocusLost()
}
}
@@ -96,8 +95,8 @@ class DefaultAudioFocus(
private fun AudioFocusRequester.toAudioUsage(): Int {
return when (this) {
AudioFocusRequester.ElementCall,
AudioFocusRequester.VoiceMessage,
AudioFocusRequester.RecordVoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION
AudioFocusRequester.VoiceMessage,
AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA
}
}
@@ -105,12 +104,22 @@ private fun AudioFocusRequester.toAudioUsage(): Int {
private fun AudioFocusRequester.toAudioStream(): Int {
return when (this) {
AudioFocusRequester.ElementCall,
AudioFocusRequester.VoiceMessage,
AudioFocusRequester.RecordVoiceMessage -> AudioManager.STREAM_VOICE_CALL
AudioFocusRequester.VoiceMessage,
AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC
}
}
private fun AudioFocusRequester.pausesOnTransientFocusLoss(): Boolean {
return when (this) {
// The AudioRecord API keeps capturing regardless.
AudioFocusRequester.RecordVoiceMessage,
AudioFocusRequester.VoiceMessage -> false
AudioFocusRequester.ElementCall,
AudioFocusRequester.MediaViewer -> true
}
}
private fun AudioFocusRequester.willPausedWhenDucked(): Boolean {
return when (this) {
// (note that for Element Call, there is no action when the focus is lost)
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
* Copyright 2026 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.audio.impl
import android.media.AudioAttributes
import android.media.AudioManager
import androidx.core.content.getSystemService
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
class DefaultAudioFocusTest : RobolectricTest() {
@Test
fun `voice message playback requests focus for media, not for a call`() {
assertThat(usageOf(AudioFocusRequester.VoiceMessage)).isEqualTo(AudioAttributes.USAGE_MEDIA)
}
@Test
fun `media viewer playback requests focus for media`() {
assertThat(usageOf(AudioFocusRequester.MediaViewer)).isEqualTo(AudioAttributes.USAGE_MEDIA)
}
@Test
fun `voice message recording requests focus for voice communication`() {
assertThat(usageOf(AudioFocusRequester.RecordVoiceMessage)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION)
}
@Test
fun `element call requests focus for voice communication`() {
assertThat(usageOf(AudioFocusRequester.ElementCall)).isEqualTo(AudioAttributes.USAGE_VOICE_COMMUNICATION)
}
@Test
fun `a transient focus loss does not pause a voice message`() {
assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse()
assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)).isFalse()
}
@Test
fun `a permanent focus loss pauses a voice message`() {
assertThat(losesFocusOn(AudioFocusRequester.VoiceMessage, AudioManager.AUDIOFOCUS_LOSS)).isTrue()
}
@Test
fun `a transient focus loss does not stop a voice message recording`() {
assertThat(losesFocusOn(AudioFocusRequester.RecordVoiceMessage, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isFalse()
}
@Test
fun `a transient focus loss still pauses the media viewer`() {
assertThat(losesFocusOn(AudioFocusRequester.MediaViewer, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)).isTrue()
}
private fun losesFocusOn(requester: AudioFocusRequester, focusChange: Int): Boolean {
val context = RuntimeEnvironment.getApplication()
val audioManager = requireNotNull(context.getSystemService<AudioManager>())
var focusLost = false
DefaultAudioFocus(context).requestAudioFocus(requester) { focusLost = true }
val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest)
request.listener.onAudioFocusChange(focusChange)
return focusLost
}
private fun usageOf(requester: AudioFocusRequester): Int {
val context = RuntimeEnvironment.getApplication()
val audioManager = requireNotNull(context.getSystemService<AudioManager>())
DefaultAudioFocus(context).requestAudioFocus(requester) {}
val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest)
return requireNotNull(request.audioFocusRequest).audioAttributes.usage
}
}