Record and send voice messages (#1596)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
da345f9afa
commit
5c582bba1b
68 changed files with 2274 additions and 82 deletions
20
features/messages/impl/src/main/AndroidManifest.xml
Normal file
20
features/messages/impl/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,5 +50,6 @@ data class MessagesState(
|
|||
val enableTextFormatting: Boolean,
|
||||
val enableVoiceMessages: Boolean,
|
||||
val enableInRoomCalls: Boolean,
|
||||
val appName: String,
|
||||
val eventSink: (MessagesEvents) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,5 +86,6 @@ fun aMessagesState() = MessagesState(
|
|||
enableTextFormatting = true,
|
||||
enableVoiceMessages = true,
|
||||
enableInRoomCalls = true,
|
||||
appName = "Element",
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue