Delete the temporary file only when the user explicitly cancel the upload.

This commit is contained in:
Benoit Marty 2024-11-07 09:20:48 +01:00
parent 22bb8796ef
commit 73b156371c
7 changed files with 88 additions and 38 deletions

View file

@ -12,5 +12,6 @@ import androidx.compose.runtime.Immutable
@Immutable @Immutable
sealed interface AttachmentsPreviewEvents { sealed interface AttachmentsPreviewEvents {
data object SendAttachment : AttachmentsPreviewEvents data object SendAttachment : AttachmentsPreviewEvents
data object Cancel : AttachmentsPreviewEvents
data object ClearSendState : AttachmentsPreviewEvents data object ClearSendState : AttachmentsPreviewEvents
} }

View file

@ -31,7 +31,14 @@ class AttachmentsPreviewNode @AssistedInject constructor(
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(inputs.attachment) private val onDoneListener = OnDoneListener {
navigateUp()
}
private val presenter = presenterFactory.create(
attachment = inputs.attachment,
onDoneListener = onDoneListener,
)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
@ -39,7 +46,6 @@ class AttachmentsPreviewNode @AssistedInject constructor(
val state = presenter.present() val state = presenter.present()
AttachmentsPreviewView( AttachmentsPreviewView(
state = state, state = state,
onDismiss = this::navigateUp,
modifier = modifier modifier = modifier
) )
} }

View file

@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.attachments.preview package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -36,13 +35,17 @@ import kotlin.coroutines.coroutineContext
class AttachmentsPreviewPresenter @AssistedInject constructor( class AttachmentsPreviewPresenter @AssistedInject constructor(
@Assisted private val attachment: Attachment, @Assisted private val attachment: Attachment,
@Assisted private val onDoneListener: OnDoneListener,
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter, private val temporaryUriDeleter: TemporaryUriDeleter,
) : Presenter<AttachmentsPreviewState> { ) : Presenter<AttachmentsPreviewState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(attachment: Attachment): AttachmentsPreviewPresenter fun create(
attachment: Attachment,
onDoneListener: OnDoneListener,
): AttachmentsPreviewPresenter
} }
@Composable @Composable
@ -60,20 +63,6 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
DisposableEffect(Unit) {
onDispose {
// Delete the temporary file when the composable is disposed, in case it was not sent
if (sendActionState.value == SendActionState.Idle) {
// Attachment has not been sent, maybe delete it
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
}
}
}
}
}
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) { when (attachmentsPreviewEvents) {
is AttachmentsPreviewEvents.SendAttachment -> { is AttachmentsPreviewEvents.SendAttachment -> {
@ -85,6 +74,9 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
sendActionState = sendActionState, sendActionState = sendActionState,
) )
} }
AttachmentsPreviewEvents.Cancel -> {
coroutineScope.cancel(attachment)
}
AttachmentsPreviewEvents.ClearSendState -> { AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let { ongoingSendAttachmentJob.value?.let {
it.cancel() it.cancel()
@ -119,6 +111,18 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
} }
} }
private fun CoroutineScope.cancel(
attachment: Attachment,
) = launch {
// Delete the temporary file
when (attachment) {
is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri)
}
}
onDoneListener()
}
private suspend fun sendMedia( private suspend fun sendMedia(
mediaAttachment: Attachment.Media, mediaAttachment: Attachment.Media,
caption: String?, caption: String?,
@ -141,7 +145,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
).getOrThrow() ).getOrThrow()
}.fold( }.fold(
onSuccess = { onSuccess = {
sendActionState.value = SendActionState.Done onDoneListener()
}, },
onFailure = { error -> onFailure = { error ->
Timber.e(error, "Failed to send attachment") Timber.e(error, "Failed to send attachment")

View file

@ -36,5 +36,4 @@ sealed interface SendActionState {
} }
data class Failure(val error: Throwable) : SendActionState data class Failure(val error: Throwable) : SendActionState
data object Done : SendActionState
} }

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.attachments.preview package io.element.android.features.messages.impl.attachments.preview
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
@ -17,9 +18,6 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -50,22 +48,22 @@ import me.saket.telephoto.zoomable.rememberZoomableState
@Composable @Composable
fun AttachmentsPreviewView( fun AttachmentsPreviewView(
state: AttachmentsPreviewState, state: AttachmentsPreviewState,
onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun postSendAttachment() { fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvents.SendAttachment) state.eventSink(AttachmentsPreviewEvents.SendAttachment)
} }
fun postCancel() {
state.eventSink(AttachmentsPreviewEvents.Cancel)
}
fun postClearSendState() { fun postClearSendState() {
state.eventSink(AttachmentsPreviewEvents.ClearSendState) state.eventSink(AttachmentsPreviewEvents.ClearSendState)
} }
if (state.sendActionState is SendActionState.Done) { BackHandler(enabled = state.sendActionState !is SendActionState.Sending) {
val latestOnDismiss by rememberUpdatedState(onDismiss) postCancel()
LaunchedEffect(state.sendActionState) {
latestOnDismiss()
}
} }
Scaffold( Scaffold(
@ -75,7 +73,7 @@ fun AttachmentsPreviewView(
navigationIcon = { navigationIcon = {
BackButton( BackButton(
imageVector = CompoundIcons.Close(), imageVector = CompoundIcons.Close(),
onClick = onDismiss, onClick = ::postCancel,
) )
}, },
title = {}, title = {},
@ -202,6 +200,5 @@ private fun AttachmentsPreviewBottomActions(
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark { internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
AttachmentsPreviewView( AttachmentsPreviewView(
state = state, state = state,
onDismiss = {},
) )
} }

View file

@ -0,0 +1,12 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview
fun interface OnDoneListener {
operator fun invoke()
}

View file

@ -16,6 +16,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvents
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
import io.element.android.features.messages.impl.attachments.preview.SendActionState import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.fixtures.aMediaAttachment import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
@ -42,6 +43,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -69,7 +71,11 @@ class AttachmentsPreviewPresenterTest {
), ),
sendFileResult = sendFileResult, sendFileResult = sendFileResult,
) )
val presenter = createAttachmentsPreviewPresenter(room = room) val onDoneListener = lambdaRecorder<Unit> { }
val presenter = createAttachmentsPreviewPresenter(
room = room,
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -80,9 +86,28 @@ class AttachmentsPreviewPresenterTest {
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f))
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f))
val successState = awaitItem() advanceUntilIdle()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - cancel scenario`() = runTest {
val onDoneListener = lambdaRecorder<Unit> { }
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createAttachmentsPreviewPresenter(
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.Cancel)
deleteCallback.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
} }
} }
@ -98,9 +123,11 @@ class AttachmentsPreviewPresenterTest {
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
sendImageResult = sendImageResult, sendImageResult = sendImageResult,
) )
val onDoneListener = lambdaRecorder<Unit> { }
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
room = room, room = room,
mediaPreProcessor = mediaPreProcessor, mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -110,8 +137,7 @@ class AttachmentsPreviewPresenterTest {
initialState.textEditorState.setMarkdown(A_CAPTION) initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
val successState = awaitItem() advanceUntilIdle()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
sendImageResult.assertions().isCalledOnce().with( sendImageResult.assertions().isCalledOnce().with(
any(), any(),
any(), any(),
@ -120,6 +146,7 @@ class AttachmentsPreviewPresenterTest {
any(), any(),
any(), any(),
) )
onDoneListener.assertions().isCalledOnce()
} }
} }
@ -135,9 +162,11 @@ class AttachmentsPreviewPresenterTest {
val room = FakeMatrixRoom( val room = FakeMatrixRoom(
sendVideoResult = sendVideoResult, sendVideoResult = sendVideoResult,
) )
val onDoneListener = lambdaRecorder<Unit> { }
val presenter = createAttachmentsPreviewPresenter( val presenter = createAttachmentsPreviewPresenter(
room = room, room = room,
mediaPreProcessor = mediaPreProcessor, mediaPreProcessor = mediaPreProcessor,
onDoneListener = { onDoneListener() },
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -147,8 +176,7 @@ class AttachmentsPreviewPresenterTest {
initialState.textEditorState.setMarkdown(A_CAPTION) initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
val successState = awaitItem() advanceUntilIdle()
assertThat(successState.sendActionState).isEqualTo(SendActionState.Done)
sendVideoResult.assertions().isCalledOnce().with( sendVideoResult.assertions().isCalledOnce().with(
any(), any(),
any(), any(),
@ -157,6 +185,7 @@ class AttachmentsPreviewPresenterTest {
any(), any(),
any(), any(),
) )
onDoneListener.assertions().isCalledOnce()
} }
} }
@ -210,9 +239,11 @@ class AttachmentsPreviewPresenterTest {
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
onDoneListener: OnDoneListener = OnDoneListener {},
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia),
onDoneListener = onDoneListener,
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter, temporaryUriDeleter = temporaryUriDeleter,