Record and send voice messages (#1596)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew 2023-10-23 18:28:00 +01:00 committed by GitHub
parent da345f9afa
commit 5c582bba1b
68 changed files with 2274 additions and 82 deletions

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2023 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
</manifest>

View file

@ -63,6 +63,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@ -101,6 +102,7 @@ class MessagesPresenter @AssistedInject constructor(
private val preferencesStore: PreferencesStore,
private val featureFlagsService: FeatureFlagService,
@Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta,
) : Presenter<MessagesState> {
@AssistedFactory
@ -203,6 +205,7 @@ class MessagesPresenter @AssistedInject constructor(
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
enableInRoomCalls = enableInRoomCalls,
appName = buildMeta.applicationName,
eventSink = { handleEvents(it) }
)
}

View file

@ -50,5 +50,6 @@ data class MessagesState(
val enableTextFormatting: Boolean,
val enableVoiceMessages: Boolean,
val enableInRoomCalls: Boolean,
val appName: String,
val eventSink: (MessagesEvents) -> Unit
)

View file

@ -86,5 +86,6 @@ fun aMessagesState() = MessagesState(
enableTextFormatting = true,
enableVoiceMessages = true,
enableInRoomCalls = true,
appName = "Element",
eventSink = {}
)

View file

@ -63,6 +63,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.VoiceMessagePermissionRationaleDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
@ -83,6 +85,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId
@ -107,6 +110,10 @@ fun MessagesView(
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
}
AttachmentStateView(
state = state.composerState.attachmentsState,
onPreviewAttachments = onPreviewAttachments,
@ -306,6 +313,18 @@ private fun MessagesViewContent(
enableTextFormatting = state.enableTextFormatting,
)
if (state.enableVoiceMessages && state.voiceMessageComposerState.showPermissionRationaleDialog) {
VoiceMessagePermissionRationaleDialog(
onContinue = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
},
onDismiss = {
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
},
appName = state.appName
)
}
ExpandableBottomSheetScaffold(
sheetDragHandle = if (state.composerState.showTextFormatting) {
@Composable { BottomSheetDragHandle() }

View file

@ -71,10 +71,14 @@ internal fun MessageComposerView(
}
}
fun onVoiceRecordButtonEvent(press: PressEvent) {
val onVoiceRecordButtonEvent = { press: PressEvent ->
voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
}
fun onSendVoiceMessage() {
voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
}
TextComposer(
modifier = modifier,
state = state.richTextEditorState,
@ -89,7 +93,8 @@ internal fun MessageComposerView(
onDismissTextFormatting = ::onDismissTextFormatting,
enableTextFormatting = enableTextFormatting,
enableVoiceMessages = enableVoiceMessages,
onVoiceRecordButtonEvent = ::onVoiceRecordButtonEvent,
onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
onSendVoiceMessage = ::onSendVoiceMessage,
onError = ::onError,
)
}

View file

@ -16,10 +16,15 @@
package io.element.android.features.messages.impl.voicemessages
import androidx.lifecycle.Lifecycle
import io.element.android.libraries.textcomposer.model.PressEvent
sealed interface VoiceMessageComposerEvents {
data class RecordButtonEvent(
val pressEvent: PressEvent
): VoiceMessageComposerEvents
data object SendVoiceMessage: VoiceMessageComposerEvents
data object AcceptPermissionRationale: VoiceMessageComposerEvents
data object DismissPermissionsRationale: VoiceMessageComposerEvents
data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents
}

View file

@ -16,49 +16,171 @@
package io.element.android.features.messages.impl.voicemessages
import android.Manifest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.textcomposer.model.PressEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@SingleIn(RoomScope::class)
class VoiceMessageComposerPresenter @Inject constructor() : Presenter<VoiceMessageComposerState> {
class VoiceMessageComposerPresenter @Inject constructor(
private val appCoroutineScope: CoroutineScope,
private val voiceRecorder: VoiceRecorder,
private val analyticsService: AnalyticsService,
private val mediaSender: MediaSender,
permissionsPresenterFactory: PermissionsPresenter.Factory
) : Presenter<VoiceMessageComposerState> {
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
@Composable
override fun present(): VoiceMessageComposerState {
var voiceMessageState by remember { mutableStateOf<VoiceMessageState>(VoiceMessageState.Idle) }
val localCoroutineScope = rememberCoroutineScope()
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)
fun onRecordButtonPress(event: VoiceMessageComposerEvents.RecordButtonEvent) = when(event.pressEvent) {
PressEvent.PressStart -> {
// TODO start the recording
voiceMessageState = VoiceMessageState.Recording
}
PressEvent.LongPressEnd -> {
// TODO finish the recording
voiceMessageState = VoiceMessageState.Idle
}
PressEvent.Tapped -> {
// TODO discard the recording and show the 'hold to record' tooltip
voiceMessageState = VoiceMessageState.Idle
val permissionState = permissionsPresenter.present()
var isSending by remember { mutableStateOf(false) }
val onLifecycleEvent = { event: Lifecycle.Event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
appCoroutineScope.finishRecording()
}
Lifecycle.Event.ON_DESTROY -> {
appCoroutineScope.cancelRecording()
}
else -> {}
}
}
val onRecordButtonPress = { event: VoiceMessageComposerEvents.RecordButtonEvent ->
val permissionGranted = permissionState.permissionGranted
when (event.pressEvent) {
PressEvent.PressStart -> {
Timber.v("Voice message record button pressed")
when {
permissionGranted -> {
localCoroutineScope.startRecording()
}
else -> {
Timber.i("Voice message permission needed")
permissionState.eventSink(PermissionsEvents.RequestPermissions)
}
}
}
PressEvent.LongPressEnd -> {
Timber.v("Voice message record button released")
localCoroutineScope.finishRecording()
}
PressEvent.Tapped -> {
Timber.v("Voice message record button tapped")
localCoroutineScope.cancelRecording()
}
}
}
fun handleEvents(event: VoiceMessageComposerEvents) {
val onAcceptPermissionsRationale = {
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
}
val onDismissPermissionsRationale = {
permissionState.eventSink(PermissionsEvents.CloseDialog)
}
val onSendButtonPress = lambda@{
val finishedState = recorderState as? VoiceRecorderState.Finished
if (finishedState == null) {
val exception = VoiceMessageException.FileException("No file to send")
analyticsService.trackError(exception)
Timber.e(exception)
return@lambda
}
if (isSending) {
return@lambda
}
isSending = true
appCoroutineScope.sendMessage(
file = finishedState.file,
mimeType = finishedState.mimeType,
).invokeOnCompletion {
isSending = false
}
}
val handleEvents: (VoiceMessageComposerEvents) -> Unit = { event ->
when (event) {
is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch {
onSendButtonPress()
}
VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale()
VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale()
is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event)
}
}
return VoiceMessageComposerState(
voiceMessageState = voiceMessageState,
eventSink = { handleEvents(it) }
voiceMessageState = when (val state = recorderState) {
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(level = state.level)
is VoiceRecorderState.Finished -> VoiceMessageState.Preview
else -> VoiceMessageState.Idle
},
showPermissionRationaleDialog = permissionState.showDialog,
eventSink = handleEvents,
)
}
private fun CoroutineScope.startRecording() = launch {
try {
voiceRecorder.startRecord()
} catch (e: SecurityException) {
Timber.e(e, "Voice message error")
analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e))
}
}
private fun CoroutineScope.finishRecording() = launch {
voiceRecorder.stopRecord()
}
private fun CoroutineScope.cancelRecording() = launch {
voiceRecorder.stopRecord(cancelled = true)
}
private fun CoroutineScope.sendMessage(
file: File, mimeType: String,
) = launch {
val result = mediaSender.sendVoiceMessage(
uri = file.toUri(),
mimeType = mimeType,
waveForm = emptyList(), // TODO generate waveform
)
if (result.isFailure) {
Timber.e(result.exceptionOrNull(), "Voice message error")
return@launch
}
voiceRecorder.deleteRecording()
}
}

View file

@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
@Stable
data class VoiceMessageComposerState(
val voiceMessageState: VoiceMessageState,
val showPermissionRationaleDialog: Boolean,
val eventSink: (VoiceMessageComposerEvents) -> Unit,
)

View file

@ -22,7 +22,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
override val values: Sequence<VoiceMessageComposerState>
get() = sequenceOf(
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording),
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(level = 0.5)),
)
}
@ -30,5 +30,6 @@ internal fun aVoiceMessageComposerState(
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
) = VoiceMessageComposerState(
voiceMessageState = voiceMessageState,
showPermissionRationaleDialog = false,
eventSink = {},
)

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.voicemessages
internal sealed class VoiceMessageException : Exception() {
data class FileException(
override val message: String?, override val cause: Throwable? = null
) : VoiceMessageException()
data class PermissionMissing(
override val message: String?, override val cause: Throwable?
) : VoiceMessageException()
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.voicemessages
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun VoiceMessagePermissionRationaleDialog(
onContinue: () -> Unit,
onDismiss: () -> Unit,
appName: String,
) {
ConfirmationDialog(
content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName),
onSubmitClicked = onContinue,
onDismiss = onDismiss,
submitText = stringResource(CommonStrings.action_continue),
cancelText = stringResource(CommonStrings.action_cancel),
)
}

View file

@ -66,6 +66,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.mediapickers.test.FakePickerProvider
@ -75,6 +76,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
@ -607,20 +609,28 @@ class MessagesPresenterTest {
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
): MessagesPresenter {
val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
room = matrixRoom,
mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
mediaSender = mediaSender,
snackbarDispatcher = SnackbarDispatcher(),
analyticsService = analyticsService,
messageComposerContext = MessageComposerContextImpl(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
permissionsPresenterFactory = permissionsPresenterFactory,
)
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
this,
FakeVoiceRecorder(),
analyticsService,
mediaSender,
permissionsPresenterFactory,
)
val voiceMessageComposerPresenter = VoiceMessageComposerPresenter()
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
room = matrixRoom,
@ -649,6 +659,7 @@ class MessagesPresenterTest {
clipboardHelper = clipboardHelper,
preferencesStore = preferencesStore,
featureFlagsService = FakeFeatureFlagService(),
buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers,
)
}

View file

@ -18,16 +18,31 @@
package io.element.android.features.messages.voicemessages
import android.Manifest
import androidx.lifecycle.Lifecycle
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.VoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.textcomposer.model.PressEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -37,53 +52,349 @@ class VoiceMessageComposerPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val voiceRecorder = FakeVoiceRecorder()
private val analyticsService = FakeAnalyticsService()
private val matrixRoom = FakeMatrixRoom()
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
testPauseAndDestroy(initialState)
}
}
@Test
fun `present - recording state`() = runTest {
val presenter = createPresenter()
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Recording)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - abort recording`() = runTest {
val presenter = createPresenter()
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped))
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - finish recording`() = runTest {
val presenter = createPresenter()
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
testPauseAndDestroy(finalState)
}
}
private fun createPresenter() = VoiceMessageComposerPresenter()
@Test
fun `present - send recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - send recording before previous completed, waits`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().run {
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
}
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - send failures aren't tracked`() = runTest {
// Let sending fail due to media preprocessing error
mediaPreProcessor.givenResult(Result.failure(Exception()))
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
val finalState = awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(VoiceMessageState.Preview)
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
}
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
assertThat(analyticsService.trackedErrors).hasSize(0)
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - send failures can be retried`() = runTest {
// Let sending fail due to media preprocessing error
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
mediaPreProcessor.givenResult(Result.failure(Exception()))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
val previewState = awaitItem()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
ensureAllEventsConsumed()
assertThat(previewState.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
mediaPreProcessor.givenAudioResult()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - send error - missing recording is tracked`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
// Send the message before recording anything
initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
assertThat(analyticsService.trackedErrors).hasSize(1)
testPauseAndDestroy(initialState)
}
}
@Test
fun `present - record error - security exceptions are tracked`() = runTest {
val exception = SecurityException("")
voiceRecorder.givenThrowsSecurityException(exception)
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
assertThat(analyticsService.trackedErrors).containsExactly(
VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
)
testPauseAndDestroy(initialState)
}
}
@Test
fun `present - permission accepted first time`() = runTest {
val permissionsPresenter = createFakePermissionsPresenter(
recordPermissionGranted = false,
)
val presenter = createVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
permissionsPresenter.setPermissionGranted()
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - permission denied previously`() = runTest {
val permissionsPresenter = createFakePermissionsPresenter(
recordPermissionGranted = false,
)
val presenter = createVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
// See the dialog and accept it
awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(it.showPermissionRationaleDialog).isTrue()
it.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
}
// Dialog is hidden, user accepts permissions
assertThat(awaitItem().showPermissionRationaleDialog).isFalse()
permissionsPresenter.setPermissionGranted()
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2))
testPauseAndDestroy(finalState)
}
}
@Test
fun `present - permission rationale dismissed`() = runTest {
val permissionsPresenter = createFakePermissionsPresenter(
recordPermissionGranted = false,
)
val presenter = createVoiceMessageComposerPresenter(
permissionsPresenter = permissionsPresenter,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
// See the dialog and accept it
awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(it.showPermissionRationaleDialog).isTrue()
it.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
}
// Dialog is hidden, user tries to record again
awaitItem().also {
assertThat(it.showPermissionRationaleDialog).isFalse()
it.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
}
// Dialog is shown once again
val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
assertThat(it.showPermissionRationaleDialog).isTrue()
}
testPauseAndDestroy(finalState)
}
}
private suspend fun TurbineTestContext<VoiceMessageComposerState>.testPauseAndDestroy(
mostRecentState: VoiceMessageComposerState,
) {
mostRecentState.eventSink(
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE)
)
val onPauseState = when (mostRecentState.voiceMessageState) {
VoiceMessageState.Idle,
VoiceMessageState.Preview -> {
mostRecentState
}
is VoiceMessageState.Recording -> {
awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Preview)
}
}
}
onPauseState.eventSink(
VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY)
)
when (onPauseState.voiceMessageState) {
VoiceMessageState.Idle ->
ensureAllEventsConsumed()
is VoiceMessageState.Recording,
VoiceMessageState.Preview ->
assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
}
}
private fun TestScope.createVoiceMessageComposerPresenter(
permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(),
): VoiceMessageComposerPresenter {
return VoiceMessageComposerPresenter(
this,
voiceRecorder,
analyticsService,
mediaSender,
FakePermissionsPresenterFactory(permissionsPresenter),
)
}
private fun createFakePermissionsPresenter(
recordPermissionGranted: Boolean = true,
recordPermissionShowDialog: Boolean = false,
): FakePermissionsPresenter {
val initialPermissionState = aPermissionsState(
showDialog = recordPermissionShowDialog,
permission = Manifest.permission.RECORD_AUDIO,
permissionGranted = recordPermissionGranted,
)
return FakePermissionsPresenter(
initialState = initialPermissionState
)
}
}