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 541562ee61
commit 814c8edc4f
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)
}
}
}