Merge pull request #6194 from vmfunc/feature/audio-focus-voice-recording

request audio focus when recording voice messages
This commit is contained in:
Benoit Marty 2026-02-23 13:35:57 +01:00 committed by GitHub
commit 783585bd01
7 changed files with 92 additions and 3 deletions

View file

@ -53,6 +53,7 @@ dependencies {
implementation(projects.libraries.preferences.api) implementation(projects.libraries.preferences.api)
implementation(projects.libraries.recentemojis.api) implementation(projects.libraries.recentemojis.api)
implementation(projects.libraries.roomselect.api) implementation(projects.libraries.roomselect.api)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.voiceplayer.api) implementation(projects.libraries.voiceplayer.api)
implementation(projects.libraries.voicerecorder.api) implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.mediaplayer.api) implementation(projects.libraries.mediaplayer.api)
@ -95,6 +96,7 @@ dependencies {
testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.audio.test)
testImplementation(projects.libraries.voicerecorder.test) testImplementation(projects.libraries.voicerecorder.test)
testImplementation(projects.libraries.mediaplayer.test) testImplementation(projects.libraries.mediaplayer.test)
testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.mediaviewer.test)

View file

@ -30,6 +30,8 @@ import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerEvent
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
@ -58,6 +60,7 @@ class DefaultVoiceMessageComposerPresenter(
@Assisted private val timelineMode: Timeline.Mode, @Assisted private val timelineMode: Timeline.Mode,
private val voiceRecorder: VoiceRecorder, private val voiceRecorder: VoiceRecorder,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val audioFocus: AudioFocus,
mediaSenderFactory: MediaSenderFactory, mediaSenderFactory: MediaSenderFactory,
private val player: VoiceMessageComposerPlayer, private val player: VoiceMessageComposerPlayer,
private val messageComposerContext: MessageComposerContext, private val messageComposerContext: MessageComposerContext,
@ -246,8 +249,14 @@ class DefaultVoiceMessageComposerPresenter(
private fun CoroutineScope.startRecording() = launch { private fun CoroutineScope.startRecording() = launch {
try { try {
audioFocus.requestAudioFocus(AudioFocusRequester.RecordVoiceMessage) {
// something else grabbed focus (phone call, etc) - finish gracefully
// so the user keeps their partial recording
sessionCoroutineScope.finishRecording()
}
voiceRecorder.startRecord() voiceRecorder.startRecord()
} catch (e: SecurityException) { } catch (e: SecurityException) {
audioFocus.releaseAudioFocus()
Timber.e(e, "Voice message error") Timber.e(e, "Voice message error")
analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e)) analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e))
} }
@ -255,10 +264,12 @@ class DefaultVoiceMessageComposerPresenter(
private fun CoroutineScope.finishRecording() = launch { private fun CoroutineScope.finishRecording() = launch {
voiceRecorder.stopRecord() voiceRecorder.stopRecord()
audioFocus.releaseAudioFocus()
} }
private fun CoroutineScope.cancelRecording() = launch { private fun CoroutineScope.cancelRecording() = launch {
voiceRecorder.stopRecord(cancelled = true) voiceRecorder.stopRecord(cancelled = true)
audioFocus.releaseAudioFocus()
} }
private fun CoroutineScope.deleteRecording() = launch { private fun CoroutineScope.deleteRecording() = launch {

View file

@ -19,12 +19,15 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer.
import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.impl.messagecomposer.aReplyMode import io.element.android.features.messages.impl.messagecomposer.aReplyMode
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
@ -80,6 +83,12 @@ class DefaultVoiceMessageComposerPresenterTest {
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }, mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) },
) )
private val requestAudioFocusResult = lambdaRecorder<AudioFocusRequester, () -> Unit, Unit> { _, _ -> }
private val releaseAudioFocusResult = lambdaRecorder<Unit> { }
private val audioFocus: AudioFocus = FakeAudioFocus(
requestAudioFocusResult = requestAudioFocusResult,
releaseAudioFocusResult = releaseAudioFocusResult,
)
private val messageComposerContext = FakeMessageComposerContext() private val messageComposerContext = FakeMessageComposerContext()
companion object { companion object {
@ -159,6 +168,61 @@ class DefaultVoiceMessageComposerPresenterTest {
} }
} }
@Test
fun `present - recording requests audio focus and releases on stop`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
val recordingState = awaitItem()
requestAudioFocusResult.assertions().isCalledOnce()
releaseAudioFocusResult.assertions().isNeverCalled()
recordingState.eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Stop))
awaitItem()
releaseAudioFocusResult.assertions().isCalledOnce()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - cancelling recording releases audio focus`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter()
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Cancel))
awaitItem()
requestAudioFocusResult.assertions().isCalledOnce()
releaseAudioFocusResult.assertions().isCalledOnce()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - audio focus loss during recording finishes gracefully`() = runTest {
var onFocusLost: (() -> Unit)? = null
val testAudioFocus = FakeAudioFocus(
requestAudioFocusResult = { _, callback -> onFocusLost = callback },
releaseAudioFocusResult = { },
)
val presenter = createDefaultVoiceMessageComposerPresenter(audioFocus = testAudioFocus)
presenter.test {
awaitItem().eventSink(VoiceMessageComposerEvent.RecorderEvent(VoiceMessageRecorderEvent.Start))
awaitItem()
// simulate focus loss (phone call, etc)
onFocusLost?.invoke()
advanceUntilIdle()
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
voiceRecorder.assertCalls(started = 1, stopped = 1)
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - abort recording`() = runTest { fun `present - abort recording`() = runTest {
val presenter = createDefaultVoiceMessageComposerPresenter() val presenter = createDefaultVoiceMessageComposerPresenter()
@ -647,12 +711,14 @@ class DefaultVoiceMessageComposerPresenterTest {
private fun TestScope.createDefaultVoiceMessageComposerPresenter( private fun TestScope.createDefaultVoiceMessageComposerPresenter(
permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(), permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(),
voiceRecorder: VoiceRecorder = this@DefaultVoiceMessageComposerPresenterTest.voiceRecorder, voiceRecorder: VoiceRecorder = this@DefaultVoiceMessageComposerPresenterTest.voiceRecorder,
audioFocus: AudioFocus = this@DefaultVoiceMessageComposerPresenterTest.audioFocus,
): DefaultVoiceMessageComposerPresenter { ): DefaultVoiceMessageComposerPresenter {
return DefaultVoiceMessageComposerPresenter( return DefaultVoiceMessageComposerPresenter(
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
timelineMode = Timeline.Mode.Live, timelineMode = Timeline.Mode.Live,
voiceRecorder = voiceRecorder, voiceRecorder = voiceRecorder,
analyticsService = analyticsService, analyticsService = analyticsService,
audioFocus = audioFocus,
mediaSenderFactory = { mediaSender }, mediaSenderFactory = { mediaSender },
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this), player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
messageComposerContext = messageComposerContext, messageComposerContext = messageComposerContext,

View file

@ -17,6 +17,7 @@ android {
dependencies { dependencies {
api(projects.features.messages.impl) api(projects.features.messages.impl)
implementation(projects.libraries.matrix.test) implementation(projects.libraries.matrix.test)
implementation(projects.libraries.audio.test)
implementation(projects.libraries.mediaplayer.test) implementation(projects.libraries.mediaplayer.test)
implementation(projects.libraries.mediaupload.test) implementation(projects.libraries.mediaupload.test)
implementation(projects.libraries.mediaviewer.api) implementation(projects.libraries.mediaviewer.api)

View file

@ -13,6 +13,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.mediaplayer.test.FakeAudioFocus
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.impl.DefaultMediaSender import io.element.android.libraries.mediaupload.impl.DefaultMediaSender
@ -38,6 +39,10 @@ class FakeDefaultVoiceMessageComposerPresenterFactory(
timelineMode = timelineMode, timelineMode = timelineMode,
voiceRecorder = FakeVoiceRecorder(), voiceRecorder = FakeVoiceRecorder(),
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
audioFocus = FakeAudioFocus(
requestAudioFocusResult = { _, _ -> },
releaseAudioFocusResult = { },
),
mediaSenderFactory = { mediaSender }, mediaSenderFactory = { mediaSender },
player = VoiceMessageComposerPlayer( player = VoiceMessageComposerPlayer(
mediaPlayer = FakeMediaPlayer(), mediaPlayer = FakeMediaPlayer(),

View file

@ -11,6 +11,7 @@ package io.element.android.libraries.audio.api
enum class AudioFocusRequester { enum class AudioFocusRequester {
ElementCall, ElementCall,
VoiceMessage, VoiceMessage,
RecordVoiceMessage,
MediaViewer, MediaViewer,
} }

View file

@ -81,7 +81,8 @@ class DefaultAudioFocus(
private fun AudioFocusRequester.toAudioUsage(): Int { private fun AudioFocusRequester.toAudioUsage(): Int {
return when (this) { return when (this) {
AudioFocusRequester.ElementCall, AudioFocusRequester.ElementCall,
AudioFocusRequester.VoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION AudioFocusRequester.VoiceMessage,
AudioFocusRequester.RecordVoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION
AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA
} }
} }
@ -89,7 +90,8 @@ private fun AudioFocusRequester.toAudioUsage(): Int {
private fun AudioFocusRequester.toAudioStream(): Int { private fun AudioFocusRequester.toAudioStream(): Int {
return when (this) { return when (this) {
AudioFocusRequester.ElementCall, AudioFocusRequester.ElementCall,
AudioFocusRequester.VoiceMessage -> AudioManager.STREAM_VOICE_CALL AudioFocusRequester.VoiceMessage,
AudioFocusRequester.RecordVoiceMessage -> AudioManager.STREAM_VOICE_CALL
AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC
} }
} }
@ -98,7 +100,8 @@ private fun AudioFocusRequester.willPausedWhenDucked(): Boolean {
return when (this) { return when (this) {
// (note that for Element Call, there is no action when the focus is lost) // (note that for Element Call, there is no action when the focus is lost)
AudioFocusRequester.ElementCall, AudioFocusRequester.ElementCall,
AudioFocusRequester.VoiceMessage -> true AudioFocusRequester.VoiceMessage,
AudioFocusRequester.RecordVoiceMessage -> true
// For the MediaViewer, we let the system automatically handle the ducking // For the MediaViewer, we let the system automatically handle the ducking
// https://developer.android.com/media/optimize/audio-focus#automatic-ducking // https://developer.android.com/media/optimize/audio-focus#automatic-ducking
AudioFocusRequester.MediaViewer -> false AudioFocusRequester.MediaViewer -> false