Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions features/messages/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -246,19 +249,27 @@ 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))
}
}

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,6 +83,12 @@ class DefaultVoiceMessageComposerPresenterTest {
timelineMode = Timeline.Mode.Live,
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()

companion object {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions features/messages/test/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +39,10 @@ class FakeDefaultVoiceMessageComposerPresenterFactory(
timelineMode = timelineMode,
voiceRecorder = FakeVoiceRecorder(),
analyticsService = FakeAnalyticsService(),
audioFocus = FakeAudioFocus(
requestAudioFocusResult = { _, _ -> },
releaseAudioFocusResult = { },
),
mediaSenderFactory = { mediaSender },
player = VoiceMessageComposerPlayer(
mediaPlayer = FakeMediaPlayer(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ package io.element.android.libraries.audio.api
enum class AudioFocusRequester {
ElementCall,
VoiceMessage,
RecordVoiceMessage,
MediaViewer,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,17 @@ 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
}
}

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
}
}
Expand All @@ -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
Expand Down
Loading