diff --git a/libraries/audio/impl/build.gradle.kts b/libraries/audio/impl/build.gradle.kts index 419e81962c..c0db3c4f86 100644 --- a/libraries/audio/impl/build.gradle.kts +++ b/libraries/audio/impl/build.gradle.kts @@ -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) } diff --git a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt index bc6a42d2f9..a9bc6fdbfd 100644 --- a/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt +++ b/libraries/audio/impl/src/main/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocus.kt @@ -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) diff --git a/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt new file mode 100644 index 0000000000..d0e255dd41 --- /dev/null +++ b/libraries/audio/impl/src/test/kotlin/io/element/android/libraries/audio/impl/DefaultAudioFocusTest.kt @@ -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()) + 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()) + DefaultAudioFocus(context).requestAudioFocus(requester) {} + val request = requireNotNull(shadowOf(audioManager).lastAudioFocusRequest) + return requireNotNull(request.audioFocusRequest).audioAttributes.usage + } +}