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:
parent
4a630f141d
commit
983b83a56f
19 changed files with 414 additions and 65 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -36,4 +36,5 @@ sealed interface MessageComposerEvents {
|
|||
object VideoFromCamera : PickAttachmentSource
|
||||
object Location : PickAttachmentSource
|
||||
}
|
||||
object CancelSendAttachment : MessageComposerEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue