Browse Source

Show error dialog when voice message fails to send (#1796)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
pull/1804/head
jonnyandrew 10 months ago committed by GitHub
parent
commit
c3471a1d5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
  2. 6
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
  3. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt
  4. 32
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
  5. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt
  6. 12
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt
  7. 34
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageSendingFailedDialog.kt
  8. 36
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
  9. BIN
      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
  10. BIN
      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

10
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.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent 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.aVoiceMessageComposerState
import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessagePreviewState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -65,7 +66,14 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
), ),
aMessagesState().copy( aMessagesState().copy(
isCallOngoing = true, isCallOngoing = true,
) ),
aMessagesState().copy(
enableVoiceMessages = true,
voiceMessageComposerState = aVoiceMessageComposerState(
voiceMessageState = aVoiceMessagePreviewState(),
showSendFailureDialog = true
),
),
) )
} }

6
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.timeline.model.TimelineItem
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents 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.VoiceMessagePermissionRationaleDialog
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageSendingFailedDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
@ -340,6 +341,11 @@ private fun MessagesViewContent(
appName = state.appName 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. // 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. // Any state change that should trigger a height size should be added to the list of remembered values here.

1
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 AcceptPermissionRationale: VoiceMessageComposerEvents
data object DismissPermissionsRationale: VoiceMessageComposerEvents data object DismissPermissionsRationale: VoiceMessageComposerEvents
data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents
data object DismissSendFailureDialog: VoiceMessageComposerEvents
} }

32
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() val permissionState = permissionsPresenter.present()
var isSending by remember { mutableStateOf(false) } var isSending by remember { mutableStateOf(false) }
var showSendFailureDialog by remember { mutableStateOf(false) }
LaunchedEffect(recorderState) { LaunchedEffect(recorderState) {
val recording = recorderState as? VoiceRecorderState.Finished val recording = recorderState as? VoiceRecorderState.Finished
@ -138,6 +139,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
permissionState.eventSink(PermissionsEvents.CloseDialog) permissionState.eventSink(PermissionsEvents.CloseDialog)
} }
val onDismissSendFailureDialog = {
showSendFailureDialog = false
}
val onSendButtonPress = lambda@{ val onSendButtonPress = lambda@{
val finishedState = recorderState as? VoiceRecorderState.Finished val finishedState = recorderState as? VoiceRecorderState.Finished
if (finishedState == null) { if (finishedState == null) {
@ -152,11 +157,16 @@ class VoiceMessageComposerPresenter @Inject constructor(
isSending = true isSending = true
player.pause() player.pause()
analyticsService.captureComposerEvent() analyticsService.captureComposerEvent()
appCoroutineScope.sendMessage( appCoroutineScope.launch {
file = finishedState.file, val result = sendMessage(
mimeType = finishedState.mimeType, file = finishedState.file,
waveform = finishedState.waveform, mimeType = finishedState.mimeType,
).invokeOnCompletion { waveform = finishedState.waveform,
)
if (result.isFailure) {
showSendFailureDialog = true
}
}.invokeOnCompletion {
isSending = false isSending = false
} }
} }
@ -175,6 +185,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale() VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale()
VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale() VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale()
is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event) is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event)
VoiceMessageComposerEvents.DismissSendFailureDialog -> onDismissSendFailureDialog()
} }
} }
@ -193,6 +204,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
else -> VoiceMessageState.Idle else -> VoiceMessageState.Idle
}, },
showPermissionRationaleDialog = permissionState.showDialog, showPermissionRationaleDialog = permissionState.showDialog,
showSendFailureDialog = showSendFailureDialog,
keepScreenOn = keepScreenOn, keepScreenOn = keepScreenOn,
eventSink = handleEvents, eventSink = handleEvents,
) )
@ -239,11 +251,11 @@ class VoiceMessageComposerPresenter @Inject constructor(
voiceRecorder.deleteRecording() voiceRecorder.deleteRecording()
} }
private fun CoroutineScope.sendMessage( private suspend fun sendMessage(
file: File, file: File,
mimeType: String, mimeType: String,
waveform: List<Float> waveform: List<Float>,
) = launch { ): Result<Unit> {
val result = mediaSender.sendVoiceMessage( val result = mediaSender.sendVoiceMessage(
uri = file.toUri(), uri = file.toUri(),
mimeType = mimeType, mimeType = mimeType,
@ -252,10 +264,12 @@ class VoiceMessageComposerPresenter @Inject constructor(
if (result.isFailure) { if (result.isFailure) {
Timber.e(result.exceptionOrNull(), "Voice message error") Timber.e(result.exceptionOrNull(), "Voice message error")
return@launch return result
} }
voiceRecorder.deleteRecording() voiceRecorder.deleteRecording()
return result
} }
private fun AnalyticsService.captureComposerEvent() = private fun AnalyticsService.captureComposerEvent() =

1
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( data class VoiceMessageComposerState(
val voiceMessageState: VoiceMessageState, val voiceMessageState: VoiceMessageState,
val showPermissionRationaleDialog: Boolean, val showPermissionRationaleDialog: Boolean,
val showSendFailureDialog: Boolean,
val keepScreenOn: Boolean, val keepScreenOn: Boolean,
val eventSink: (VoiceMessageComposerEvents) -> Unit, val eventSink: (VoiceMessageComposerEvents) -> Unit,
) )

12
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 package io.element.android.features.messages.impl.voicemessages.composer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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 io.element.android.libraries.textcomposer.model.VoiceMessageState
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -32,13 +33,24 @@ internal fun aVoiceMessageComposerState(
voiceMessageState: VoiceMessageState = VoiceMessageState.Idle, voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
keepScreenOn: Boolean = false, keepScreenOn: Boolean = false,
showPermissionRationaleDialog: Boolean = false, showPermissionRationaleDialog: Boolean = false,
showSendFailureDialog: Boolean = false,
) = VoiceMessageComposerState( ) = VoiceMessageComposerState(
voiceMessageState = voiceMessageState, voiceMessageState = voiceMessageState,
showPermissionRationaleDialog = showPermissionRationaleDialog, showPermissionRationaleDialog = showPermissionRationaleDialog,
showSendFailureDialog = showSendFailureDialog,
keepScreenOn = keepScreenOn, keepScreenOn = keepScreenOn,
eventSink = {}, 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() internal var aWaveformLevels = List(100) { it.toFloat() / 100 }.toPersistentList()

34
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),
)
}

36
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 @Test
fun `present - send error - missing recording is tracked`() = runTest { fun `present - send error - missing recording is tracked`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createVoiceMessageComposerPresenter()

BIN
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 (Stored with Git LFS)

Binary file not shown.

BIN
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 (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save