From 638fdf495c51d949a7ed4d1b80db24c38acfc4da Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Tue, 14 Nov 2023 15:05:07 +0000 Subject: [PATCH] Show error dialog when voice message fails to send (#1796) --------- Co-authored-by: ElementBot --- .../messages/impl/MessagesStateProvider.kt | 10 +++++- .../features/messages/impl/MessagesView.kt | 6 ++++ .../composer/VoiceMessageComposerEvents.kt | 1 + .../composer/VoiceMessageComposerPresenter.kt | 32 ++++++++++++----- .../composer/VoiceMessageComposerState.kt | 1 + .../VoiceMessageComposerStateProvider.kt | 12 +++++++ .../VoiceMessageSendingFailedDialog.kt | 34 ++++++++++++++++++ .../VoiceMessageComposerPresenterTest.kt | 36 +++++++++++++++++++ ...esView-Day-0_0_null_11,NEXUS_5,1.0,en].png | 3 ++ ...View-Night-0_1_null_11,NEXUS_5,1.0,en].png | 3 ++ 10 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageSendingFailedDialog.kt create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_MessagesView_null_MessagesView-Day-0_0_null_11,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_MessagesView_null_MessagesView-Night-0_1_null_11,NEXUS_5,1.0,en].png diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 275b324862..38d6289a88 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState +import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -65,7 +66,14 @@ open class MessagesStateProvider : PreviewParameterProvider { ), aMessagesState().copy( isCallOngoing = true, - ) + ), + aMessagesState().copy( + enableVoiceMessages = true, + voiceMessageComposerState = aVoiceMessageComposerState( + voiceMessageState = aVoiceMessagePreviewState(), + showSendFailureDialog = true + ), + ), ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 4d7ef3ef3e..9c8a9dab8a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -75,6 +75,7 @@ import io.element.android.features.messages.impl.timeline.components.retrysendme import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog +import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog 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 @@ -340,6 +341,11 @@ private fun MessagesViewContent( appName = state.appName ) } + if (state.enableVoiceMessages && state.voiceMessageComposerState.showSendFailureDialog) { + VoiceMessageSendingFailedDialog( + onDismiss = { state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissSendFailureDialog) }, + ) + } // This key is used to force the sheet to be remeasured when the content changes. // Any state change that should trigger a height size should be added to the list of remembered values here. diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt index 0d384185df..0c83e834b0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt @@ -32,4 +32,5 @@ sealed interface VoiceMessageComposerEvents { data object AcceptPermissionRationale: VoiceMessageComposerEvents data object DismissPermissionsRationale: VoiceMessageComposerEvents data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents + data object DismissSendFailureDialog: VoiceMessageComposerEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt index cd54dc5588..528711b71e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt @@ -75,6 +75,7 @@ class VoiceMessageComposerPresenter @Inject constructor( val permissionState = permissionsPresenter.present() var isSending by remember { mutableStateOf(false) } + var showSendFailureDialog by remember { mutableStateOf(false) } LaunchedEffect(recorderState) { val recording = recorderState as? VoiceRecorderState.Finished @@ -138,6 +139,10 @@ class VoiceMessageComposerPresenter @Inject constructor( permissionState.eventSink(PermissionsEvents.CloseDialog) } + val onDismissSendFailureDialog = { + showSendFailureDialog = false + } + val onSendButtonPress = lambda@{ val finishedState = recorderState as? VoiceRecorderState.Finished if (finishedState == null) { @@ -152,11 +157,16 @@ class VoiceMessageComposerPresenter @Inject constructor( isSending = true player.pause() analyticsService.captureComposerEvent() - appCoroutineScope.sendMessage( - file = finishedState.file, - mimeType = finishedState.mimeType, - waveform = finishedState.waveform, - ).invokeOnCompletion { + appCoroutineScope.launch { + val result = sendMessage( + file = finishedState.file, + mimeType = finishedState.mimeType, + waveform = finishedState.waveform, + ) + if (result.isFailure) { + showSendFailureDialog = true + } + }.invokeOnCompletion { isSending = false } } @@ -175,6 +185,7 @@ class VoiceMessageComposerPresenter @Inject constructor( VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale() VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale() is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event) + VoiceMessageComposerEvents.DismissSendFailureDialog -> onDismissSendFailureDialog() } } @@ -193,6 +204,7 @@ class VoiceMessageComposerPresenter @Inject constructor( else -> VoiceMessageState.Idle }, showPermissionRationaleDialog = permissionState.showDialog, + showSendFailureDialog = showSendFailureDialog, keepScreenOn = keepScreenOn, eventSink = handleEvents, ) @@ -239,11 +251,11 @@ class VoiceMessageComposerPresenter @Inject constructor( voiceRecorder.deleteRecording() } - private fun CoroutineScope.sendMessage( + private suspend fun sendMessage( file: File, mimeType: String, - waveform: List - ) = launch { + waveform: List, + ): Result { val result = mediaSender.sendVoiceMessage( uri = file.toUri(), mimeType = mimeType, @@ -252,10 +264,12 @@ class VoiceMessageComposerPresenter @Inject constructor( if (result.isFailure) { Timber.e(result.exceptionOrNull(), "Voice message error") - return@launch + return result } voiceRecorder.deleteRecording() + + return result } private fun AnalyticsService.captureComposerEvent() = diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt index 055fa28177..aaab388ec1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt @@ -23,6 +23,7 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState data class VoiceMessageComposerState( val voiceMessageState: VoiceMessageState, val showPermissionRationaleDialog: Boolean, + val showSendFailureDialog: Boolean, val keepScreenOn: Boolean, val eventSink: (VoiceMessageComposerEvents) -> Unit, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt index f9856005bb..0e884f5491 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt @@ -17,6 +17,7 @@ package io.element.android.features.messages.impl.voicemessages.composer import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.designsystem.components.media.createFakeWaveform import io.element.android.libraries.textcomposer.model.VoiceMessageState import kotlinx.collections.immutable.toPersistentList import kotlin.time.Duration.Companion.seconds @@ -32,13 +33,24 @@ internal fun aVoiceMessageComposerState( voiceMessageState: VoiceMessageState = VoiceMessageState.Idle, keepScreenOn: Boolean = false, showPermissionRationaleDialog: Boolean = false, + showSendFailureDialog: Boolean = false, ) = VoiceMessageComposerState( voiceMessageState = voiceMessageState, showPermissionRationaleDialog = showPermissionRationaleDialog, + showSendFailureDialog = showSendFailureDialog, keepScreenOn = keepScreenOn, eventSink = {}, ) +internal fun aVoiceMessagePreviewState() = VoiceMessageState.Preview( + isSending = false, + isPlaying = false, + showCursor = false, + playbackProgress = 0f, + time = 10.seconds, + waveform = createFakeWaveform(), +) + internal var aWaveformLevels = List(100) { it.toFloat() / 100 }.toPersistentList() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageSendingFailedDialog.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageSendingFailedDialog.kt new file mode 100644 index 0000000000..a828e19f30 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageSendingFailedDialog.kt @@ -0,0 +1,34 @@ +/* + * 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.composer + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun VoiceMessageSendingFailedDialog( + onDismiss: () -> Unit, +) { + ErrorDialog( + title = stringResource(CommonStrings.common_error), + content = stringResource(CommonStrings.error_failed_uploading_voice_message), + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_ok), + ) +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt index 8fe1048c3c..8fb72d0e19 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt @@ -437,6 +437,42 @@ class VoiceMessageComposerPresenterTest { } } + @Test + fun `present - send failures are displayed as an error dialog`() = runTest { + val presenter = createVoiceMessageComposerPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + // Let sending fail due to media preprocessing error + mediaPreProcessor.givenResult(Result.failure(Exception())) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) + awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) + awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) + + assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState().toSendingState()) + assertThat(showSendFailureDialog).isTrue() + } + + awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState()) + assertThat(showSendFailureDialog).isTrue() + eventSink(VoiceMessageComposerEvents.DismissSendFailureDialog) + } + + val finalState = awaitItem().apply { + assertThat(voiceMessageState).isEqualTo(aPreviewState()) + assertThat(showSendFailureDialog).isFalse() + } + + + assertThat(matrixRoom.sendMediaCount).isEqualTo(0) + testPauseAndDestroy(finalState) + } + } + @Test fun `present - send error - missing recording is tracked`() = runTest { val presenter = createVoiceMessageComposerPresenter() diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_MessagesView_null_MessagesView-Day-0_0_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_MessagesView_null_MessagesView-Day-0_0_null_11,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..2251ff798a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_MessagesView_null_MessagesView-Day-0_0_null_11,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:083476cb480c6641924331689121581300247fd9d1f61c155a35389a07601c69 +size 51237 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_MessagesView_null_MessagesView-Night-0_1_null_11,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_MessagesView_null_MessagesView-Night-0_1_null_11,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..a4bc4d4f6b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl_MessagesView_null_MessagesView-Night-0_1_null_11,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:33b0fb2e0990a008ea6699a0d0fc59478bc3ce755637584af4e2577fc007e2c3 +size 45809