Close the media preview screen ASAP with sending queue enabled (#4089)
* Close the attachment preview screen ASAP when sending media with the send queue is enabled * When the send queue FF is not enabled make sure to dismiss the screen after the media has been sent * Make sure we get a scaled thumbnail from videos too, not only for images * Unify several state holders into `SendActionState`. * Fix lint issues, add `Flow.firstInstanceOf` extension fun * Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
committed by
GitHub
parent
28cf775c32
commit
f72fb9650b
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<AttachmentsPreviewState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -72,71 +78,79 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
|
||||
|
||||
val ongoingSendAttachmentJob = remember { mutableStateOf<Job?>(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<MediaUploadInfo>>(AsyncData.Uninitialized) }
|
||||
var useSendQueue by remember { mutableStateOf(false) }
|
||||
var preprocessMediaJob by remember { mutableStateOf<Job?>(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 -> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val observableSendState = snapshotFlow { sendActionState.value }
|
||||
|
||||
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
|
||||
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<SendActionState.Sending.ReadyToUpload>().mediaInfo
|
||||
|
||||
// Pre-processing is done, send the attachment
|
||||
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
// 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,
|
||||
caption = caption,
|
||||
sendActionState = sendActionState,
|
||||
dismissAfterSend = !useSendQueue,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
AttachmentsPreviewEvents.Cancel -> {
|
||||
coroutineScope.cancel(
|
||||
AttachmentsPreviewEvents.CancelAndDismiss -> {
|
||||
// Cancel media preprocessing and sending
|
||||
preprocessMediaJob?.cancel()
|
||||
ongoingSendAttachmentJob.value?.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<AsyncData<MediaUploadInfo>>,
|
||||
) = launch {
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) = 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<AsyncData<MediaUploadInfo>>,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) {
|
||||
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<MediaUploadInfo>,
|
||||
sendActionState: MutableState<SendActionState>,
|
||||
) = 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<SendActionState>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AttachmentsPreviewState> {
|
||||
override val values: Sequence<AttachmentsPreviewState>
|
||||
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) },
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Idle)
|
||||
assertThat(awaitItem().sendActionState).isEqualTo(SendActionState.Sending.Processing(displayProgress = false))
|
||||
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.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<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
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<File, FileInfo, String?, String?, ProgressCallback?, Result<FakeMediaUploadHandler>> { _, _, _, _, _ ->
|
||||
Result.failure(failure)
|
||||
}
|
||||
val onDoneListenerResult = lambdaRecorder<Unit> {}
|
||||
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.Idle)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAttachmentsPreviewPresenter(
|
||||
@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<Unit> {}
|
||||
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 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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <reified T> Flow<*>.firstInstanceOf(): T {
|
||||
return first { it is T } as T
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user