diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 16abcfdd42..849cd92f29 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -306,6 +306,8 @@ licensee { allowUrl("https://asm.ow2.io/license.html") allowUrl("https://www.gnu.org/licenses/agpl-3.0.txt") ignoreDependencies("com.github.matrix-org", "matrix-analytics-events") + // Ignore dependency that are not third-party licenses to us. + ignoreDependencies(groupId = "io.element.android") } fun Project.configureLicensesTasks(reportingExtension: ReportingExtension) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt index 945d3d3d32..e679a43da7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvents.kt @@ -12,6 +12,6 @@ import androidx.compose.runtime.Immutable @Immutable sealed interface AttachmentsPreviewEvents { data object SendAttachment : AttachmentsPreviewEvents - data object Cancel : AttachmentsPreviewEvents - data object ClearSendState : AttachmentsPreviewEvents + data object CancelAndDismiss : AttachmentsPreviewEvents + data object CancelAndClearSendState : AttachmentsPreviewEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index 449095b74b..cac84ce9e2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -16,14 +16,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.element.android.features.messages.impl.attachments.Attachment 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.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.firstInstanceOf +import io.element.android.libraries.di.annotations.SessionCoroutineScope 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 @@ -48,6 +52,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( private val permalinkBuilder: PermalinkBuilder, private val temporaryUriDeleter: TemporaryUriDeleter, private val featureFlagService: FeatureFlagService, + @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val dispatchers: CoroutineDispatchers, ) : Presenter { @AssistedFactory interface Factory { @@ -72,71 +78,79 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( val ongoingSendAttachmentJob = remember { mutableStateOf(null) } - val userSentAttachment = remember { mutableStateOf(false) } val allowCaption by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionCreation).collectAsState(initial = false) val showCaptionCompatibilityWarning by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MediaCaptionWarning).collectAsState(initial = false) - val mediaUploadInfoState = remember { mutableStateOf>(AsyncData.Uninitialized) } + var useSendQueue by remember { mutableStateOf(false) } + var preprocessMediaJob by remember { mutableStateOf(null) } LaunchedEffect(Unit) { - preProcessAttachment( + useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue) + + preprocessMediaJob = preProcessAttachment( attachment, - mediaUploadInfoState, + sendActionState ) } - LaunchedEffect(userSentAttachment.value, mediaUploadInfoState.value) { - if (userSentAttachment.value) { - // User confirmed sending the attachment - when (val mediaUploadInfo = mediaUploadInfoState.value) { - is AsyncData.Success -> { + val observableSendState = snapshotFlow { sendActionState.value } + + fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { + when (attachmentsPreviewEvents) { + is AttachmentsPreviewEvents.SendAttachment -> { + ongoingSendAttachmentJob.value = coroutineScope.launch { + // If the processing was hidden before, make it visible now + if (sendActionState.value is SendActionState.Sending.Processing) { + sendActionState.value = SendActionState.Sending.Processing(displayProgress = true) + } + + // Wait until the media is ready to be uploaded + val mediaUploadInfo = observableSendState.firstInstanceOf().mediaInfo + // Pre-processing is done, send the attachment val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) .takeIf { it.isNotEmpty() } - ongoingSendAttachmentJob.value = coroutineScope.launch { + + // If we're supposed to send the media as a background job, we can dismiss this screen already + if (useSendQueue && coroutineContext.isActive) { + onDoneListener() + } + + // If using the send queue, send it using the session coroutine scope so it doesn't matter if this screen or the chat one are closed + val sendMediaCoroutineScope = if (useSendQueue) sessionCoroutineScope else coroutineScope + sendMediaCoroutineScope.launch(dispatchers.io) { sendPreProcessedMedia( - mediaUploadInfo = mediaUploadInfo.data, + mediaUploadInfo = mediaUploadInfo, caption = caption, sendActionState = sendActionState, + dismissAfterSend = !useSendQueue, ) } } - 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 - } } - } - } + AttachmentsPreviewEvents.CancelAndDismiss -> { + // Cancel media preprocessing and sending + preprocessMediaJob?.cancel() + ongoingSendAttachmentJob.value?.cancel() - fun handleEvents(attachmentsPreviewEvents: AttachmentsPreviewEvents) { - when (attachmentsPreviewEvents) { - is AttachmentsPreviewEvents.SendAttachment -> coroutineScope.launch { - val useSendQueue = featureFlagService.isFeatureEnabled(FeatureFlags.MediaUploadOnSendQueue) - userSentAttachment.value = true - val instantSending = mediaUploadInfoState.value.isReady() && useSendQueue - sendActionState.value = if (instantSending) { - SendActionState.Sending.InstantSending - } else { - SendActionState.Sending.Processing - } - } - AttachmentsPreviewEvents.Cancel -> { - coroutineScope.cancel( + // Dismiss the screen + dismiss( attachment, - mediaUploadInfoState.value, sendActionState, ) } - AttachmentsPreviewEvents.ClearSendState -> { + AttachmentsPreviewEvents.CancelAndClearSendState -> { + // Cancel media sending ongoingSendAttachmentJob.value?.let { it.cancel() ongoingSendAttachmentJob.value = null } - sendActionState.value = SendActionState.Idle + + val mediaUploadInfo = sendActionState.value.mediaUploadInfo() + sendActionState.value = if (mediaUploadInfo != null) { + SendActionState.Sending.ReadyToUpload(mediaUploadInfo) + } else { + SendActionState.Idle + } } } } @@ -153,13 +167,13 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( private fun CoroutineScope.preProcessAttachment( attachment: Attachment, - mediaUploadInfoState: MutableState>, - ) = launch { + sendActionState: MutableState, + ) = launch(dispatchers.io) { when (attachment) { is Attachment.Media -> { preProcessMedia( mediaAttachment = attachment, - mediaUploadInfoState = mediaUploadInfoState, + sendActionState = sendActionState, ) } } @@ -167,37 +181,36 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( private suspend fun preProcessMedia( mediaAttachment: Attachment.Media, - mediaUploadInfoState: MutableState>, + sendActionState: MutableState, ) { - mediaUploadInfoState.value = AsyncData.Loading() + sendActionState.value = SendActionState.Sending.Processing(displayProgress = false) mediaSender.preProcessMedia( uri = mediaAttachment.localMedia.uri, mimeType = mediaAttachment.localMedia.info.mimeType, ).fold( onSuccess = { mediaUploadInfo -> - mediaUploadInfoState.value = AsyncData.Success(mediaUploadInfo) + sendActionState.value = SendActionState.Sending.ReadyToUpload(mediaUploadInfo) }, onFailure = { Timber.e(it, "Failed to pre-process media") if (it is CancellationException) { throw it } else { - mediaUploadInfoState.value = AsyncData.Failure(it) + sendActionState.value = SendActionState.Failure(it, null) } } ) } - private fun CoroutineScope.cancel( + private fun dismiss( attachment: Attachment, - mediaUploadInfo: AsyncData, sendActionState: MutableState, - ) = launch { + ) { // Delete the temporary file when (attachment) { is Attachment.Media -> { temporaryUriDeleter.delete(attachment.localMedia.uri) - mediaUploadInfo.dataOrNull()?.let { data -> + sendActionState.value.mediaUploadInfo()?.let { data -> cleanUp(data) } } @@ -219,13 +232,14 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( mediaUploadInfo: MediaUploadInfo, caption: String?, sendActionState: MutableState, + dismissAfterSend: Boolean, ) = runCatching { val context = coroutineContext val progressCallback = object : ProgressCallback { override fun onProgress(current: Long, total: Long) { // Note will not happen if useSendQueue is true if (context.isActive) { - sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat()) + sendActionState.value = SendActionState.Sending.Uploading(current.toFloat() / total.toFloat(), mediaUploadInfo) } } } @@ -240,14 +254,17 @@ class AttachmentsPreviewPresenter @AssistedInject constructor( cleanUp(mediaUploadInfo) // Reset the sendActionState to ensure that dialog is closed before the screen sendActionState.value = SendActionState.Done - onDoneListener() + + if (dismissAfterSend) { + onDoneListener() + } }, onFailure = { error -> Timber.e(error, "Failed to send attachment") if (error is CancellationException) { throw error } else { - sendActionState.value = SendActionState.Failure(error) + sendActionState.value = SendActionState.Failure(error, mediaUploadInfo) } } ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt index 6e67f04bfb..90438e14c3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewState.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.compose.runtime.Immutable import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.textcomposer.model.TextEditorState data class AttachmentsPreviewState( @@ -26,11 +27,18 @@ sealed interface SendActionState { @Immutable sealed interface Sending : SendActionState { - data object InstantSending : Sending - data object Processing : Sending - data class Uploading(val progress: Float) : Sending + data class Processing(val displayProgress: Boolean) : Sending + data class ReadyToUpload(val mediaInfo: MediaUploadInfo) : Sending + data class Uploading(val progress: Float, val mediaUploadInfo: MediaUploadInfo) : Sending } - data class Failure(val error: Throwable) : SendActionState + data class Failure(val error: Throwable, val mediaUploadInfo: MediaUploadInfo?) : SendActionState data object Done : SendActionState + + fun mediaUploadInfo(): MediaUploadInfo? = when (this) { + is Sending.ReadyToUpload -> mediaInfo + is Sending.Uploading -> mediaUploadInfo + is Failure -> mediaUploadInfo + else -> null + } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index 570b473ad6..5c54d3a68e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -10,19 +10,25 @@ package io.element.android.features.messages.impl.attachments.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.core.net.toUri import io.element.android.features.messages.impl.attachments.Attachment +import io.element.android.libraries.core.mimetype.MimeTypes +import io.element.android.libraries.matrix.api.media.ImageInfo +import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.anImageMediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown +import java.io.File open class AttachmentsPreviewStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( anAttachmentsPreviewState(), - anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing), - anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)), - anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))), + anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = false)), + anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing(displayProgress = true)), + anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())), + anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f, aMediaUploadInfo())), + anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())), anAttachmentsPreviewState(allowCaption = false), anAttachmentsPreviewState(showCaptionCompatibilityWarning = true), ) @@ -44,3 +50,20 @@ fun anAttachmentsPreviewState( showCaptionCompatibilityWarning = showCaptionCompatibilityWarning, eventSink = {} ) + +fun aMediaUploadInfo( + filePath: String = "file://path", + thumbnailFilePath: String? = null, +) = MediaUploadInfo.Image( + file = File(filePath), + imageInfo = ImageInfo( + height = 100, + width = 100, + mimetype = MimeTypes.Jpeg, + size = 1000, + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), + thumbnailFile = thumbnailFilePath?.let { File(it) }, + ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index c89afa7f31..ca9faddfee 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -59,11 +59,11 @@ fun AttachmentsPreviewView( } fun postCancel() { - state.eventSink(AttachmentsPreviewEvents.Cancel) + state.eventSink(AttachmentsPreviewEvents.CancelAndDismiss) } fun postClearSendState() { - state.eventSink(AttachmentsPreviewEvents.ClearSendState) + state.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState) } BackHandler(enabled = state.sendActionState !is SendActionState.Sending) { @@ -106,12 +106,14 @@ private fun AttachmentSendStateView( ) { when (sendActionState) { is SendActionState.Sending.Processing -> { - ProgressDialog( - type = ProgressDialogType.Indeterminate, - text = stringResource(id = CommonStrings.common_sending), - showCancelButton = true, - onDismissRequest = onDismissClick, - ) + if (sendActionState.displayProgress) { + ProgressDialog( + type = ProgressDialogType.Indeterminate, + text = stringResource(id = CommonStrings.common_sending), + showCancelButton = true, + onDismissRequest = onDismissClick, + ) + } } is SendActionState.Sending.Uploading -> { ProgressDialog( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 6356925d78..63a759bd67 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -20,6 +20,7 @@ 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.fixtures.aMediaAttachment import io.element.android.libraries.androidutils.file.TemporaryUriDeleter +import io.element.android.libraries.core.mimetype.MimeTypes 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 @@ -35,20 +36,24 @@ import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSender +import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter 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.value import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -121,13 +126,14 @@ class AttachmentsPreviewPresenterTest { }.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) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f)) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0f, mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(0.5f, mediaUploadInfo)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Uploading(1f, mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce() @@ -159,10 +165,10 @@ class AttachmentsPreviewPresenterTest { // 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.Sending.Processing(displayProgress = false)) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce() @@ -191,12 +197,13 @@ class AttachmentsPreviewPresenterTest { }.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(displayProgress = false)) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) // Pre-processing finishes processLatch.complete(Unit) - assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = true)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendFileResult.assertions().isCalledOnce() onDoneListener.assertions().isCalledOnce() @@ -222,8 +229,7 @@ class AttachmentsPreviewPresenterTest { 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) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) // Pre-processing finishes processLatch.complete(Unit) assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java) @@ -252,8 +258,7 @@ class AttachmentsPreviewPresenterTest { 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.Sending.Processing(displayProgress = false)) assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Failure::class.java) } } @@ -271,7 +276,7 @@ class AttachmentsPreviewPresenterTest { }.test { val initialState = awaitItem() assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) - initialState.eventSink(AttachmentsPreviewEvents.Cancel) + initialState.eventSink(AttachmentsPreviewEvents.CancelAndDismiss) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) deleteCallback.assertions().isCalledOnce() @@ -305,8 +310,8 @@ class AttachmentsPreviewPresenterTest { initialState.textEditorState.setMarkdown(A_CAPTION) 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(displayProgress = false)) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Sending.ReadyToUpload::class.java) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendImageResult.assertions().isCalledOnce().with( any(), @@ -346,8 +351,8 @@ class AttachmentsPreviewPresenterTest { initialState.textEditorState.setMarkdown(A_CAPTION) 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(displayProgress = false)) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Sending.ReadyToUpload::class.java) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendVideoResult.assertions().isCalledOnce().with( any(), @@ -385,8 +390,8 @@ class AttachmentsPreviewPresenterTest { initialState.textEditorState.setMarkdown(A_CAPTION) 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(displayProgress = false)) + assertThat(awaitItem().sendActionState).isInstanceOf(SendActionState.Sending.ReadyToUpload::class.java) assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Done) sendAudioResult.assertions().isCalledOnce().with( any(), @@ -400,7 +405,7 @@ class AttachmentsPreviewPresenterTest { } @Test - fun `present - send media failure scenario`() = runTest { + fun `present - send media failure scenario without media queue`() = runTest { val failure = MediaPreProcessor.Failure(null) val sendFileResult = lambdaRecorder> { _, _, _, _, _ -> Result.failure(failure) @@ -408,7 +413,7 @@ class AttachmentsPreviewPresenterTest { val room = FakeMatrixRoom( sendFileResult = sendFileResult, ) - val presenter = createAttachmentsPreviewPresenter(room = room) + val presenter = createAttachmentsPreviewPresenter(room = room, mediaUploadOnSendQueueEnabled = false) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -416,20 +421,28 @@ class AttachmentsPreviewPresenterTest { 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) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) val failureState = awaitItem() - assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure)) + assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo)) sendFileResult.assertions().isCalledOnce() - failureState.eventSink(AttachmentsPreviewEvents.ClearSendState) - val clearedState = awaitItem() - assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Idle) + failureState.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState) + val clearedState = awaitLastSequentialItem() + assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) } } @Test - fun `present - dismissing the progress dialog stops media upload`() = runTest { - val presenter = createAttachmentsPreviewPresenter() + fun `present - send media failure scenario with media queue`() = runTest { + val failure = MediaPreProcessor.Failure(null) + val sendFileResult = lambdaRecorder> { _, _, _, _, _ -> + Result.failure(failure) + } + val onDoneListenerResult = lambdaRecorder {} + val room = FakeMatrixRoom( + sendFileResult = sendFileResult, + ) + val presenter = createAttachmentsPreviewPresenter(room = room, mediaUploadOnSendQueueEnabled = true, onDoneListener = onDoneListenerResult) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -437,14 +450,62 @@ class AttachmentsPreviewPresenterTest { 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) - initialState.eventSink(AttachmentsPreviewEvents.ClearSendState) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + + // Check that the onDoneListener is called so the screen would be dismissed + onDoneListenerResult.assertions().isCalledOnce() + + val failureState = awaitItem() + assertThat(failureState.sendActionState).isEqualTo(SendActionState.Failure(failure, mediaUploadInfo)) + sendFileResult.assertions().isCalledOnce() + failureState.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState) + val clearedState = awaitLastSequentialItem() + assertThat(clearedState.sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + } + } + + @Test + fun `present - dismissing the progress dialog stops media upload without media queue`() = runTest { + val presenter = createAttachmentsPreviewPresenter(mediaUploadOnSendQueueEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.sendActionState).isEqualTo(SendActionState.Idle) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle) + initialState.eventSink(AttachmentsPreviewEvents.SendAttachment) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + initialState.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState) + // The sending is cancelled and the state is kept at ReadyToUpload + ensureAllEventsConsumed() + } + } + + @Test + fun `present - dismissing the progress dialog stops media upload with media queue`() = runTest { + val onDoneListenerResult = lambdaRecorder {} + val presenter = createAttachmentsPreviewPresenter(mediaUploadOnSendQueueEnabled = true, onDoneListener = onDoneListenerResult) + 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(displayProgress = false)) + assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.ReadyToUpload(mediaUploadInfo)) + initialState.eventSink(AttachmentsPreviewEvents.CancelAndClearSendState) + // The sending is cancelled and the state is kept at ReadyToUpload + ensureAllEventsConsumed() + + // Check that the onDoneListener is called so the screen would be dismissed + onDoneListenerResult.assertions().isCalledOnce() } } - private fun createAttachmentsPreviewPresenter( + private fun TestScope.createAttachmentsPreviewPresenter( localMedia: LocalMedia = aLocalMedia( uri = mockMediaUrl, ), @@ -469,7 +530,19 @@ class AttachmentsPreviewPresenterTest { FeatureFlags.MediaCaptionCreation.key to allowCaption, FeatureFlags.MediaCaptionWarning.key to showCaptionCompatibilityWarning, ), - ) + ), + sessionCoroutineScope = this, + dispatchers = testCoroutineDispatchers(), ) } + + private val mediaUploadInfo = MediaUploadInfo.AnyFile( + File("test"), + FileInfo( + mimetype = MimeTypes.Any, + size = 999L, + thumbnailInfo = null, + thumbnailSource = null, + ) + ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17f4c7a15f..77142d08c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -194,7 +194,7 @@ opusencoder = "io.element.android:opusencoder:1.1.0" zxing_cpp = "io.github.zxing-cpp:android:2.2.0" # Analytics -posthog = "com.posthog:posthog-android:3.9.3" +posthog = "com.posthog:posthog-android:3.10.0" sentry = "io.sentry:sentry-android:7.19.1" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0" diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/Flow.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/Flow.kt new file mode 100644 index 0000000000..263cc91648 --- /dev/null +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/Flow.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.core.coroutine + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first + +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +/** + * Returns the first element of the flow that is an instance of [T], waiting for it if necessary. + */ +suspend inline fun Flow<*>.firstInstanceOf(): T { + return first { it is T } as T +} diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ThumbnailFactory.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ThumbnailFactory.kt index 5f0a1626cd..7151836f17 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ThumbnailFactory.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/ThumbnailFactory.kt @@ -11,6 +11,7 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.media.MediaMetadataRetriever +import android.media.MediaMetadataRetriever.OPTION_CLOSEST_SYNC import android.media.ThumbnailUtils import android.os.Build import android.os.CancellationSignal @@ -18,6 +19,7 @@ import android.provider.MediaStore import android.util.Size import androidx.core.net.toUri import com.vanniktech.blurhash.BlurHash +import io.element.android.libraries.androidutils.bitmap.resizeToMax import io.element.android.libraries.androidutils.file.createTmpFile import io.element.android.libraries.androidutils.media.runAndRelease import io.element.android.libraries.core.mimetype.MimeTypes @@ -89,7 +91,11 @@ class ThumbnailFactory @Inject constructor( return createThumbnail(mimeType = MimeTypes.Jpeg) { MediaMetadataRetriever().runAndRelease { setDataSource(context, file.toUri()) - getFrameAtTime(VIDEO_THUMB_FRAME) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + getScaledFrameAtTime(VIDEO_THUMB_FRAME, OPTION_CLOSEST_SYNC, THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT) + } else { + getFrameAtTime(VIDEO_THUMB_FRAME)?.resizeToMax(THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT) + } } } } diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png index 16f3005b0b..acdab75b0d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26764da6595dee37219d5bbf3e53bad8db6451d7d39fa7741c61d96c89938bf8 -size 51246 +oid sha256:603ff14c11328797ad3ff07002aa77397d1e9f8c39291f9c762776b591d68ada +size 395102 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png index ccfd70d1bc..16f3005b0b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b9d5ee3eb77075ca147fc7ed96c83a846dcfcf8e09bc129a24daa5ed19f262a7 -size 51217 +oid sha256:26764da6595dee37219d5bbf3e53bad8db6451d7d39fa7741c61d96c89938bf8 +size 51246 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png index 26f522cfa6..acdab75b0d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f2882937b7dd90da5d1708185f0130b75659eed57eaf2a966280a891f4ddbd6 -size 89083 +oid sha256:603ff14c11328797ad3ff07002aa77397d1e9f8c39291f9c762776b591d68ada +size 395102 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png index 2ddab5fe37..ccfd70d1bc 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7af7dedec8de4ac367fc870d2c235060b3a57d27b2bb879dc784a3cef3c2a370 -size 390628 +oid sha256:b9d5ee3eb77075ca147fc7ed96c83a846dcfcf8e09bc129a24daa5ed19f262a7 +size 51217 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png index acdab75b0d..26f522cfa6 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:603ff14c11328797ad3ff07002aa77397d1e9f8c39291f9c762776b591d68ada -size 395102 +oid sha256:9f2882937b7dd90da5d1708185f0130b75659eed57eaf2a966280a891f4ddbd6 +size 89083 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_6_en.png new file mode 100644 index 0000000000..2ddab5fe37 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7af7dedec8de4ac367fc870d2c235060b3a57d27b2bb879dc784a3cef3c2a370 +size 390628 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png new file mode 100644 index 0000000000..acdab75b0d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsView_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:603ff14c11328797ad3ff07002aa77397d1e9f8c39291f9c762776b591d68ada +size 395102