From 43f729414bada89a24b5b680917b646b7db396f2 Mon Sep 17 00:00:00 2001 From: vmfunc Date: Thu, 12 Feb 2026 16:28:37 +0100 Subject: [PATCH] request audio focus when recording voice messages Signed-off-by: vmfunc --- features/messages/impl/build.gradle.kts | 2 + .../DefaultVoiceMessageComposerPresenter.kt | 7 ++++ ...efaultVoiceMessageComposerPresenterTest.kt | 41 +++++++++++++++++++ features/messages/test/build.gradle.kts | 1 + ...ultVoiceMessageComposerPresenterFactory.kt | 5 +++ 5 files changed, 56 insertions(+) diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index ad6562a83c..01482d0df5 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(projects.libraries.preferences.api) implementation(projects.libraries.recentemojis.api) implementation(projects.libraries.roomselect.api) + implementation(projects.libraries.audio.api) implementation(projects.libraries.voiceplayer.api) implementation(projects.libraries.voicerecorder.api) implementation(projects.libraries.mediaplayer.api) @@ -95,6 +96,7 @@ dependencies { testImplementation(projects.libraries.mediapickers.test) testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.preferences.test) + testImplementation(projects.libraries.audio.test) testImplementation(projects.libraries.voicerecorder.test) testImplementation(projects.libraries.mediaplayer.test) testImplementation(projects.libraries.mediaviewer.test) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 9b5961c364..88d778a955 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -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.VoiceMessageComposerPresenter 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.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.timeline.Timeline @@ -58,6 +60,7 @@ class DefaultVoiceMessageComposerPresenter( @Assisted private val timelineMode: Timeline.Mode, private val voiceRecorder: VoiceRecorder, private val analyticsService: AnalyticsService, + private val audioFocus: AudioFocus, mediaSenderFactory: MediaSenderFactory, private val player: VoiceMessageComposerPlayer, private val messageComposerContext: MessageComposerContext, @@ -246,8 +249,10 @@ class DefaultVoiceMessageComposerPresenter( private fun CoroutineScope.startRecording() = launch { try { + audioFocus.requestAudioFocus(AudioFocusRequester.VoiceMessage) {} voiceRecorder.startRecord() } catch (e: SecurityException) { + audioFocus.releaseAudioFocus() Timber.e(e, "Voice message error") analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e)) } @@ -255,10 +260,12 @@ class DefaultVoiceMessageComposerPresenter( private fun CoroutineScope.finishRecording() = launch { voiceRecorder.stopRecord() + audioFocus.releaseAudioFocus() } private fun CoroutineScope.cancelRecording() = launch { voiceRecorder.stopRecord(cancelled = true) + audioFocus.releaseAudioFocus() } private fun CoroutineScope.deleteRecording() = launch { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt index 8a7324d9e5..53f725ad83 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenterTest.kt @@ -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.impl.messagecomposer.aReplyMode 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.media.AudioInfo 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.room.FakeJoinedRoom 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.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.impl.DefaultMediaSender @@ -80,6 +83,12 @@ class DefaultVoiceMessageComposerPresenterTest { timelineMode = Timeline.Mode.Live, mediaOptimizationConfigProvider = { MediaOptimizationConfig(compressImages = true, videoCompressionPreset = VideoCompressionPreset.STANDARD) }, ) + private val requestAudioFocusResult = lambdaRecorder Unit, Unit> { _, _ -> } + private val releaseAudioFocusResult = lambdaRecorder { } + private val audioFocus: AudioFocus = FakeAudioFocus( + requestAudioFocusResult = requestAudioFocusResult, + releaseAudioFocusResult = releaseAudioFocusResult, + ) private val messageComposerContext = FakeMessageComposerContext() companion object { @@ -159,6 +168,37 @@ 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 - abort recording`() = runTest { val presenter = createDefaultVoiceMessageComposerPresenter() @@ -653,6 +693,7 @@ class DefaultVoiceMessageComposerPresenterTest { timelineMode = Timeline.Mode.Live, voiceRecorder = voiceRecorder, analyticsService = analyticsService, + audioFocus = audioFocus, mediaSenderFactory = { mediaSender }, player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this), messageComposerContext = messageComposerContext, diff --git a/features/messages/test/build.gradle.kts b/features/messages/test/build.gradle.kts index 09d357357d..29c00ece73 100644 --- a/features/messages/test/build.gradle.kts +++ b/features/messages/test/build.gradle.kts @@ -17,6 +17,7 @@ android { dependencies { api(projects.features.messages.impl) implementation(projects.libraries.matrix.test) + implementation(projects.libraries.audio.test) implementation(projects.libraries.mediaplayer.test) implementation(projects.libraries.mediaupload.test) implementation(projects.libraries.mediaviewer.api) diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt index 0de1b69d78..17de179b55 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/voicemessages/composer/FakeDefaultVoiceMessageComposerPresenterFactory.kt @@ -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.libraries.matrix.api.timeline.Timeline 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.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.impl.DefaultMediaSender @@ -38,6 +39,10 @@ class FakeDefaultVoiceMessageComposerPresenterFactory( timelineMode = timelineMode, voiceRecorder = FakeVoiceRecorder(), analyticsService = FakeAnalyticsService(), + audioFocus = FakeAudioFocus( + requestAudioFocusResult = { _, _ -> }, + releaseAudioFocusResult = { }, + ), mediaSenderFactory = { mediaSender }, player = VoiceMessageComposerPlayer( mediaPlayer = FakeMediaPlayer(),