diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index ad6562a83c8..01482d0df5f 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 9b5961c3640..6fdd2f1752e 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,14 @@ class DefaultVoiceMessageComposerPresenter( private fun CoroutineScope.startRecording() = launch { 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() } catch (e: SecurityException) { + audioFocus.releaseAudioFocus() Timber.e(e, "Voice message error") analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e)) } @@ -255,10 +264,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 8a7324d9e54..9c55bf3a856 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,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 fun `present - abort recording`() = runTest { val presenter = createDefaultVoiceMessageComposerPresenter() @@ -647,12 +711,14 @@ class DefaultVoiceMessageComposerPresenterTest { private fun TestScope.createDefaultVoiceMessageComposerPresenter( permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(), voiceRecorder: VoiceRecorder = this@DefaultVoiceMessageComposerPresenterTest.voiceRecorder, + audioFocus: AudioFocus = this@DefaultVoiceMessageComposerPresenterTest.audioFocus, ): DefaultVoiceMessageComposerPresenter { return DefaultVoiceMessageComposerPresenter( sessionCoroutineScope = backgroundScope, 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 09d357357d9..29c00ece739 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 0de1b69d78f..17de179b553 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(), diff --git a/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt b/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt index 9a3c178b9c6..aeccaa9b8a8 100644 --- a/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt +++ b/libraries/audio/api/src/main/kotlin/io/element/android/libraries/audio/api/AudioFocus.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.audio.api enum class AudioFocusRequester { ElementCall, VoiceMessage, + RecordVoiceMessage, MediaViewer, } 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 aa945ea5072..ca1f7b35c41 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 @@ -81,7 +81,8 @@ class DefaultAudioFocus( private fun AudioFocusRequester.toAudioUsage(): Int { return when (this) { AudioFocusRequester.ElementCall, - AudioFocusRequester.VoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION + AudioFocusRequester.VoiceMessage, + AudioFocusRequester.RecordVoiceMessage -> AudioAttributes.USAGE_VOICE_COMMUNICATION AudioFocusRequester.MediaViewer -> AudioAttributes.USAGE_MEDIA } } @@ -89,7 +90,8 @@ private fun AudioFocusRequester.toAudioUsage(): Int { private fun AudioFocusRequester.toAudioStream(): Int { return when (this) { AudioFocusRequester.ElementCall, - AudioFocusRequester.VoiceMessage -> AudioManager.STREAM_VOICE_CALL + AudioFocusRequester.VoiceMessage, + AudioFocusRequester.RecordVoiceMessage -> AudioManager.STREAM_VOICE_CALL AudioFocusRequester.MediaViewer -> AudioManager.STREAM_MUSIC } } @@ -98,7 +100,8 @@ private fun AudioFocusRequester.willPausedWhenDucked(): Boolean { return when (this) { // (note that for Element Call, there is no action when the focus is lost) AudioFocusRequester.ElementCall, - AudioFocusRequester.VoiceMessage -> true + AudioFocusRequester.VoiceMessage, + AudioFocusRequester.RecordVoiceMessage -> true // For the MediaViewer, we let the system automatically handle the ducking // https://developer.android.com/media/optimize/audio-focus#automatic-ducking AudioFocusRequester.MediaViewer -> false