Merge pull request #6194 from vmfunc/feature/audio-focus-voice-recording
request audio focus when recording voice messages
This commit is contained in:
commit
783585bd01
7 changed files with 92 additions and 3 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ package io.element.android.libraries.audio.api
|
||||||
enum class AudioFocusRequester {
|
enum class AudioFocusRequester {
|
||||||
ElementCall,
|
ElementCall,
|
||||||
VoiceMessage,
|
VoiceMessage,
|
||||||
|
RecordVoiceMessage,
|
||||||
MediaViewer,
|
MediaViewer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue