Merge pull request #3943 from element-hq/feature/bma/_poc/mediaPreprocessing

Hide media preprocessing
This commit is contained in:
Benoit Marty 2024-11-26 16:42:42 +01:00 committed by GitHub
commit d6b653f127
11 changed files with 325 additions and 38 deletions

View file

@ -8,6 +8,7 @@
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.LaunchedEffect
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
@ -19,10 +20,16 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.mediaupload.api.allFiles
import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.TextEditorState
import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@ -39,6 +46,7 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
private val permalinkBuilder: PermalinkBuilder, private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter, private val temporaryUriDeleter: TemporaryUriDeleter,
private val featureFlagsService: FeatureFlagService,
) : Presenter<AttachmentsPreviewState> { ) : Presenter<AttachmentsPreviewState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -63,19 +71,62 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) } val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(null) }
val userSentAttachment = remember { mutableStateOf(false) }
val mediaUploadInfoState = remember { mutableStateOf<AsyncData<MediaUploadInfo>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
preProcessAttachment(
attachment,
mediaUploadInfoState,
)
}
LaunchedEffect(userSentAttachment.value, mediaUploadInfoState.value) {
if (userSentAttachment.value) {
// User confirmed sending the attachment
when (val mediaUploadInfo = mediaUploadInfoState.value) {
is AsyncData.Success -> {
// Pre-processing is done, send the attachment
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }
ongoingSendAttachmentJob.value = coroutineScope.launch {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo.data,
caption = caption,
sendActionState = sendActionState,
)
}
}
is AsyncData.Failure -> {
// Pre-processing has failed, show the error
sendActionState.value = SendActionState.Failure(mediaUploadInfo.error)
}
AsyncData.Uninitialized,
is AsyncData.Loading -> {
// Pre-processing is still in progress, do nothing
}
}
}
}
fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) {
when (attachmentsPreviewEvents) { when (attachmentsPreviewEvents) {
is AttachmentsPreviewEvents.SendAttachment -> { is AttachmentsPreviewEvents.SendAttachment -> coroutineScope.launch {
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) val useSendQueue = featureFlagsService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue)
.takeIf { it.isNotEmpty() } userSentAttachment.value = true
ongoingSendAttachmentJob.value = coroutineScope.sendAttachment( val instantSending = mediaUploadInfoState.value.isReady() && useSendQueue
attachment = attachment, sendActionState.value = if (instantSending) {
caption = caption, SendActionState.Sending.InstantSending
sendActionState = sendActionState, } else {
) SendActionState.Sending.Processing
}
} }
AttachmentsPreviewEvents.Cancel -> { AttachmentsPreviewEvents.Cancel -> {
coroutineScope.cancel(attachment) coroutineScope.cancel(
attachment,
mediaUploadInfoState.value,
sendActionState,
)
} }
AttachmentsPreviewEvents.ClearSendState -> { AttachmentsPreviewEvents.ClearSendState -> {
ongoingSendAttachmentJob.value?.let { ongoingSendAttachmentJob.value?.let {
@ -95,56 +146,95 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
) )
} }
private fun CoroutineScope.sendAttachment( private fun CoroutineScope.preProcessAttachment(
attachment: Attachment, attachment: Attachment,
caption: String?, mediaUploadInfoState: MutableState<AsyncData<MediaUploadInfo>>,
sendActionState: MutableState<SendActionState>,
) = launch { ) = launch {
when (attachment) { when (attachment) {
is Attachment.Media -> { is Attachment.Media -> {
sendMedia( preProcessMedia(
mediaAttachment = attachment, mediaAttachment = attachment,
caption = caption, mediaUploadInfoState = mediaUploadInfoState,
sendActionState = sendActionState,
) )
} }
} }
} }
private suspend fun preProcessMedia(
mediaAttachment: Attachment.Media,
mediaUploadInfoState: MutableState<AsyncData<MediaUploadInfo>>,
) {
mediaUploadInfoState.value = AsyncData.Loading()
mediaSender.preProcessMedia(
uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType,
).fold(
onSuccess = { mediaUploadInfo ->
mediaUploadInfoState.value = AsyncData.Success(mediaUploadInfo)
},
onFailure = {
Timber.e(it, "Failed to pre-process media")
if (it is CancellationException) {
throw it
} else {
mediaUploadInfoState.value = AsyncData.Failure(it)
}
}
)
}
private fun CoroutineScope.cancel( private fun CoroutineScope.cancel(
attachment: Attachment, attachment: Attachment,
mediaUploadInfo: AsyncData<MediaUploadInfo>,
sendActionState: MutableState<SendActionState>,
) = launch { ) = launch {
// Delete the temporary file // Delete the temporary file
when (attachment) { when (attachment) {
is Attachment.Media -> { is Attachment.Media -> {
temporaryUriDeleter.delete(attachment.localMedia.uri) temporaryUriDeleter.delete(attachment.localMedia.uri)
mediaUploadInfo.dataOrNull()?.let { data ->
cleanUp(data)
}
} }
} }
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Done
onDoneListener() onDoneListener()
} }
private suspend fun sendMedia( private fun cleanUp(
mediaAttachment: Attachment.Media, mediaUploadInfo: MediaUploadInfo,
) {
mediaUploadInfo.allFiles().forEach { file ->
file.safeDelete()
}
}
private suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?, caption: String?,
sendActionState: MutableState<SendActionState>, sendActionState: MutableState<SendActionState>,
) = runCatching { ) = runCatching {
val context = coroutineContext val context = coroutineContext
val progressCallback = object : ProgressCallback { val progressCallback = object : ProgressCallback {
override fun onProgress(current: Long, total: Long) { override fun onProgress(current: Long, total: Long) {
// Note will not happen if useSendQueue is true
if (context.isActive) { if (context.isActive) {
sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat()) sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat())
} }
} }
} }
sendActionState.value = SendActionState.Sending.Processing mediaSender.sendPreProcessedMedia(
mediaSender.sendMedia( mediaUploadInfo = mediaUploadInfo,
uri = mediaAttachment.localMedia.uri,
mimeType = mediaAttachment.localMedia.info.mimeType,
caption = caption, caption = caption,
formattedCaption = null,
progressCallback = progressCallback progressCallback = progressCallback
).getOrThrow() ).getOrThrow()
}.fold( }.fold(
onSuccess = { onSuccess = {
cleanUp(mediaUploadInfo)
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Done
onDoneListener() onDoneListener()
}, },
onFailure = { error -> onFailure = { error ->

View file

@ -27,9 +27,11 @@ sealed interface SendActionState {
@Immutable @Immutable
sealed interface Sending : SendActionState { sealed interface Sending : SendActionState {
data object InstantSending : Sending
data object Processing : Sending data object Processing : Sending
data class Uploading(val progress: Float) : Sending data class Uploading(val progress: Float) : Sending
} }
data class Failure(val error: Throwable) : SendActionState data class Failure(val error: Throwable) : SendActionState
data object Done : SendActionState
} }

View file

@ -26,6 +26,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()), anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()), anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()), anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)), anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))), anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
) )

View file

@ -99,12 +99,17 @@ private fun AttachmentSendStateView(
onRetryClick: () -> Unit onRetryClick: () -> Unit
) { ) {
when (sendActionState) { when (sendActionState) {
is SendActionState.Sending -> { is SendActionState.Sending.Processing -> {
ProgressDialog( ProgressDialog(
type = when (sendActionState) { type = ProgressDialogType.Indeterminate,
is SendActionState.Sending.Uploading -> ProgressDialogType.Determinate(sendActionState.progress) text = stringResource(id = CommonStrings.common_sending),
SendActionState.Sending.Processing -> ProgressDialogType.Indeterminate showCancelButton = true,
}, onDismissRequest = onDismissClick,
)
}
is SendActionState.Sending.Uploading -> {
ProgressDialog(
type = ProgressDialogType.Determinate(sendActionState.progress),
text = stringResource(id = CommonStrings.common_sending), text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true, showCancelButton = true,
onDismissRequest = onDismissClick, onDismissRequest = onDismissClick,

View file

@ -20,6 +20,8 @@ import io.element.android.features.messages.impl.attachments.preview.OnDoneListe
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
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.media.AudioInfo import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.FileInfo
@ -40,10 +42,12 @@ import io.element.android.libraries.preferences.test.InMemorySessionPreferencesS
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder 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.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -84,16 +88,142 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
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))
advanceUntilIdle() assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce()
} }
} }
@Test
fun `present - send media after pre-processing success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
sendFileResult = sendFileResult,
)
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor(
processLatch = processLatch,
),
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
// Pre-processing finishes
processLatch.complete(Unit)
advanceUntilIdle()
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.InstantSending)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - send media before pre-processing success scenario`() = runTest {
val sendFileResult = lambdaRecorder<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
Result.success(FakeMediaUploadHandler())
}
val room = FakeMatrixRoom(
sendFileResult = sendFileResult,
)
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor(
processLatch = processLatch,
),
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
// Pre-processing finishes
processLatch.complete(Unit)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendFileResult.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce()
}
}
@Test
fun `present - send media with pre-processing failure after user sends media`() = runTest {
val room = FakeMatrixRoom()
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor().apply {
givenResult(Result.failure(Exception()))
},
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
// Pre-processing finishes
processLatch.complete(Unit)
assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java)
}
}
@Test
fun `present - send media with pre-processing failure before user sends media`() = runTest {
val room = FakeMatrixRoom()
val onDoneListener = lambdaRecorder<Unit> { }
val processLatch = CompletableDeferred<Unit>()
val presenter = createAttachmentsPreviewPresenter(
room = room,
mediaPreProcessor = FakeMediaPreProcessor().apply {
givenResult(Result.failure(Exception()))
},
onDoneListener = { onDoneListener() },
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
// Pre-processing finishes
processLatch.complete(Unit)
advanceUntilIdle()
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.InstantSending)
assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java)
}
}
@Test @Test
fun `present - cancel scenario`() = runTest { fun `present - cancel scenario`() = runTest {
val onDoneListener = lambdaRecorder<Unit> { } val onDoneListener = lambdaRecorder<Unit> { }
@ -108,6 +238,8 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.Cancel) initialState.eventSink(AttachmentsPreviewEvents.Cancel)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
deleteCallback.assertions().isCalledOnce() deleteCallback.assertions().isCalledOnce()
onDoneListener.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce()
} }
@ -138,8 +270,10 @@ class AttachmentsPreviewPresenterTest {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION) initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
advanceUntilIdle() assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendImageResult.assertions().isCalledOnce().with( sendImageResult.assertions().isCalledOnce().with(
any(), any(),
any(), any(),
@ -177,8 +311,10 @@ class AttachmentsPreviewPresenterTest {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION) initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
advanceUntilIdle() assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendVideoResult.assertions().isCalledOnce().with( sendVideoResult.assertions().isCalledOnce().with(
any(), any(),
any(), any(),
@ -214,8 +350,10 @@ class AttachmentsPreviewPresenterTest {
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.textEditorState.setMarkdown(A_CAPTION) initialState.textEditorState.setMarkdown(A_CAPTION)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
advanceUntilIdle() assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done)
sendAudioResult.assertions().isCalledOnce().with( sendAudioResult.assertions().isCalledOnce().with(
any(), any(),
any(), any(),
@ -243,8 +381,9 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
val loadingState = awaitItem() assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(loadingState.sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
val failureState = awaitItem() val failureState = awaitItem()
assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure)) assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure))
sendFileResult.assertions().isCalledOnce() sendFileResult.assertions().isCalledOnce()
@ -263,6 +402,8 @@ class AttachmentsPreviewPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle)
initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) initialState.eventSink(AttachmentsPreviewEvents.SendAttachment)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing)
initialState.eventSink(AttachmentsPreviewEvents.ClearSendState) initialState.eventSink(AttachmentsPreviewEvents.ClearSendState)
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
@ -277,7 +418,8 @@ class AttachmentsPreviewPresenterTest {
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(), mediaPreProcessor: MediaPreProcessor = FakeMediaPreProcessor(),
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
onDoneListener: OnDoneListener = OnDoneListener {}, onDoneListener: OnDoneListener = OnDoneListener { lambdaError() },
mediaUploadOnSendQueueEnabled: Boolean = true,
): AttachmentsPreviewPresenter { ): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter( return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia), attachment = aMediaAttachment(localMedia),
@ -285,6 +427,9 @@ class AttachmentsPreviewPresenterTest {
mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()), mediaSender = MediaSender(mediaPreProcessor, room, InMemorySessionPreferencesStore()),
permalinkBuilder = permalinkBuilder, permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter, temporaryUriDeleter = temporaryUriDeleter,
featureFlagsService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MediaUploadOnSendQueue.key to mediaUploadOnSendQueueEnabled),
)
) )
} }
} }

View file

@ -27,6 +27,35 @@ class MediaSender @Inject constructor(
private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>() private val ongoingUploadJobs = ConcurrentHashMap<Job.Key, MediaUploadHandler>()
val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty() val hasOngoingMediaUploads get() = ongoingUploadJobs.isNotEmpty()
suspend fun preProcessMedia(
uri: Uri,
mimeType: String,
): Result<MediaUploadInfo> {
val compressIfPossible = sessionPreferencesStore.doesCompressMedia().first()
return preProcessor
.process(
uri = uri,
mimeType = mimeType,
deleteOriginal = false,
compressIfPossible = compressIfPossible,
)
}
suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?,
formattedCaption: String?,
progressCallback: ProgressCallback?,
): Result<Unit> {
return room.sendMedia(
uploadInfo = mediaUploadInfo,
progressCallback = progressCallback,
caption = caption,
formattedCaption = formattedCaption
)
.handleSendResult()
}
suspend fun sendMedia( suspend fun sendMedia(
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,

View file

@ -22,3 +22,11 @@ sealed interface MediaUploadInfo {
data class VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List<Float>) : MediaUploadInfo data class VoiceMessage(override val file: File, val audioInfo: AudioInfo, val waveform: List<Float>) : MediaUploadInfo
data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo data class AnyFile(override val file: File, val fileInfo: FileInfo) : MediaUploadInfo
} }
fun MediaUploadInfo.allFiles(): List<File> {
return listOfNotNull(
file,
(this@allFiles as? MediaUploadInfo.Image)?.thumbnailFile,
(this@allFiles as? MediaUploadInfo.Video)?.thumbnailFile,
)
}

View file

@ -16,10 +16,13 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaPreProcessor
import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.tests.testutils.simulateLongTask import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CompletableDeferred
import java.io.File import java.io.File
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class FakeMediaPreProcessor : MediaPreProcessor { class FakeMediaPreProcessor(
private val processLatch: CompletableDeferred<Unit>? = null,
) : MediaPreProcessor {
var processCallCount = 0 var processCallCount = 0
private set private set
@ -41,6 +44,7 @@ class FakeMediaPreProcessor : MediaPreProcessor {
deleteOriginal: Boolean, deleteOriginal: Boolean,
compressIfPossible: Boolean compressIfPossible: Boolean
): Result<MediaUploadInfo> = simulateLongTask { ): Result<MediaUploadInfo> = simulateLongTask {
processLatch?.await()
processCallCount++ processCallCount++
result result
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:361ff23fd2ff99aecfdffd10b45e9235d86183d4856cb2a3e99f85b9e04c2d59 oid sha256:3a92cb782d97553f364fab3df3fe159557a26aad8b7e5c176b40616c2f05abdd
size 51376 size 51410

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:efbfed755b29293009f45fca33f58863b612772de9a1d55593c979dbb04ff6f2 oid sha256:361ff23fd2ff99aecfdffd10b45e9235d86183d4856cb2a3e99f85b9e04c2d59
size 88981 size 51376

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:efbfed755b29293009f45fca33f58863b612772de9a1d55593c979dbb04ff6f2
size 88981