Media upload cancellation (#1058)

* Initial implementation of media upload cancellation

* Add tests

* Add changelog

* Update screenshots

* Add documentation

* Fix lint issues

* Fix review comments

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2023-08-17 11:02:03 +02:00 committed by GitHub
parent 4a630f141d
commit 983b83a56f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 414 additions and 65 deletions

View file

@ -52,6 +52,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerView
import io.element.android.features.messages.impl.timeline.TimelineView
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet
@ -100,7 +101,11 @@ fun MessagesView(
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
AttachmentStateView(state.composerState.attachmentsState, onPreviewAttachments)
AttachmentStateView(
state = state.composerState.attachmentsState,
onPreviewAttachments = onPreviewAttachments,
onCancel = { state.composerState.eventSink(MessageComposerEvents.CancelSendAttachment) },
)
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@ -229,7 +234,8 @@ private fun ReinviteDialog(state: MessagesState) {
@Composable
private fun AttachmentStateView(
state: AttachmentsState,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onCancel: () -> Unit,
) {
when (state) {
AttachmentsState.None -> Unit
@ -242,7 +248,9 @@ private fun AttachmentStateView(
is AttachmentsState.Sending.Uploading -> ProgressDialogType.Determinate(state.progress)
is AttachmentsState.Sending.Processing -> ProgressDialogType.Indeterminate
},
text = stringResource(id = CommonStrings.common_sending)
text = stringResource(id = CommonStrings.common_sending),
isCancellable = true,
onDismissRequest = onCancel,
)
}
}

View file

@ -28,8 +28,12 @@ import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.mediaupload.api.MediaSender
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment,
@ -50,10 +54,18 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mutableStateOf<SendActionState>(SendActionState.Idle)
}
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) {
AttachmentsPreviewEvents.SendAttachment -> coroutineScope.sendAttachment(attachment, sendActionState)
AttachmentsPreviewEvents.ClearSendState -> sendActionState.value = SendActionState.Idle
AttachmentsPreviewEvents.SendAttachment -> ongoingSendAttachmentJob.value = coroutineScope.sendAttachment(attachment, sendActionState)
AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let {
it.cancel()
ongoingSendAttachmentJob.value = null
}
sendActionState.value = SendActionState.Idle
}
}
}
@ -72,7 +84,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
is Attachment.Media -> {
sendMedia(
mediaAttachment = attachment,
sendActionState = sendActionState
sendActionState = sendActionState,
)
}
}
@ -81,10 +93,13 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private suspend fun sendMedia(
mediaAttachment: Attachment.Media,
sendActionState: MutableState<SendActionState>,
) {
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat())
if (context.isActive) {
sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
}
sendActionState.value = SendActionState.Sending.Processing
@ -93,13 +108,17 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mimeType = mediaAttachment.localMedia.info.mimeType,
compressIfPossible = mediaAttachment.compressIfPossible,
progressCallback = progressCallback
).fold(
onSuccess = {
sendActionState.value = SendActionState.Done
},
onFailure = {
sendActionState.value = SendActionState.Failure(it)
).getOrThrow()
}.fold(
onSuccess = {
sendActionState.value = SendActionState.Done
},
onFailure = { error ->
if (error is CancellationException) {
throw error
} else {
sendActionState.value = SendActionState.Failure(error)
}
)
}
}
)
}

View file

@ -96,7 +96,9 @@ private fun AttachmentSendStateView(
is SendActionState.Sending.Uploading -> ProgressDialogType.Determinate(sendActionState.progress)
SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate
},
text = stringResource(id = CommonStrings.common_sending)
text = stringResource(id = CommonStrings.common_sending),
isCancellable = true,
onDismissRequest = onDismissClicked,
)
}
is SendActionState.Failure -> {

View file

@ -36,4 +36,5 @@ sealed interface MessageComposerEvents {
object VideoFromCamera : PickAttachmentSource
object Location : PickAttachmentSource
}
object CancelSendAttachment : MessageComposerEvents
}

View file

@ -47,9 +47,13 @@ import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@SingleIn(RoomScope::class)
@ -100,6 +104,7 @@ class MessageComposerPresenter @Inject constructor(
val text: MutableState<String> = rememberSaveable {
mutableStateOf("")
}
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) }
@ -112,7 +117,12 @@ class MessageComposerPresenter @Inject constructor(
LaunchedEffect(attachmentsState.value) {
when (val attachmentStateValue = attachmentsState.value) {
is AttachmentsState.Sending.Processing -> localCoroutineScope.sendAttachment(attachmentStateValue.attachments.first(), attachmentsState)
is AttachmentsState.Sending.Processing -> {
ongoingSendAttachmentJob.value = localCoroutineScope.sendAttachment(
attachmentStateValue.attachments.first(),
attachmentsState,
)
}
else -> Unit
}
}
@ -169,6 +179,12 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false
// Navigation to the location picker screen is done at the view layer
}
is MessageComposerEvents.CancelSendAttachment -> {
ongoingSendAttachmentJob.value?.let {
it.cancel()
ongoingSendAttachmentJob.value == null
}
}
}
}
@ -212,13 +228,13 @@ class MessageComposerPresenter @Inject constructor(
private fun CoroutineScope.sendAttachment(
attachment: Attachment,
attachmentState: MutableState<AttachmentsState>,
) = launch {
when (attachment) {
is Attachment.Media -> {
) = when (attachment) {
is Attachment.Media -> {
launch {
sendMedia(
uri = attachment.localMedia.uri,
mimeType = attachment.localMedia.info.mimeType,
attachmentState = attachmentState
attachmentState = attachmentState,
)
}
}
@ -259,20 +275,27 @@ class MessageComposerPresenter @Inject constructor(
uri: Uri,
mimeType: String,
attachmentState: MutableState<AttachmentsState>,
) {
) = runCatching {
val context = coroutineContext
val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) {
attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat())
if (context.isActive) {
attachmentState.value = AttachmentsState.Sending.Uploading(current.toFloat() / total.toFloat())
}
}
}
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback)
.onSuccess {
attachmentState.value = AttachmentsState.None
}
.onFailure {
val snackbarMessage = SnackbarMessage(sendAttachmentError(it))
snackbarDispatcher.post(snackbarMessage)
attachmentState.value = AttachmentsState.None
}
mediaSender.sendMedia(uri, mimeType, compressIfPossible = false, progressCallback).getOrThrow()
}
.onSuccess {
attachmentState.value = AttachmentsState.None
}
.onFailure { cause ->
attachmentState.value = AttachmentsState.None
if (cause is CancellationException) {
throw cause
} else {
val snackbarMessage = SnackbarMessage(sendAttachmentError(cause))
snackbarDispatcher.post(snackbarMessage)
}
}
}

View file

@ -94,6 +94,21 @@ class AttachmentsPreviewPresenterTest {
}
}
@Test
fun `present - dismissing the progress dialog stops media upload`() = runTest {
val presenter = anAttachmentsPreviewPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
initialState.eventSink(AttachmentsPreviewEvents.ClearSendState)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
}
}
private fun anAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl,

View file

@ -500,6 +500,23 @@ class MessageComposerPresenterTest {
}
}
@Test
fun `present - CancelSendAttachment stops media upload`() = runTest {
val presenter = createPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
initialState.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles)
val sendingState = awaitItem()
assertThat(sendingState.showAttachmentSourcePicker).isFalse()
assertThat(sendingState.attachmentsState).isInstanceOf(AttachmentsState.Sending.Processing::class.java)
sendingState.eventSink(MessageComposerEvents.CancelSendAttachment)
assertThat(awaitItem().attachmentsState).isEqualTo(AttachmentsState.None)
}
}
private suspend fun ReceiveTurbine<MessageComposerState>.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)