Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions features/messages/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ dependencies {
implementation(libs.jsoup)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.constraintlayout.compose)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.sigpwned.emoji4j)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Element Creations 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.features.messages.impl.attachments.preview

import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAnimatedImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.mediaviewer.api.MediaInfo
import java.util.Locale

internal fun MediaInfo.canEditImage(): Boolean {
val resolvedMimeType = resolvedImageMimeType() ?: return false
return resolvedMimeType.isMimeTypeImage() &&
!resolvedMimeType.isMimeTypeAnimatedImage() &&
resolvedMimeType != MimeTypes.Svg
}

internal fun MediaInfo.isImageAttachment(): Boolean {
return resolvedImageMimeType().isMimeTypeImage()
}

internal fun MediaInfo.resolvedImageMimeType(): String? {
return mimeType.takeIf { it.isMimeTypeImage() } ?: fileExtension.toImageMimeTypeOrNull()
}

private fun String.toImageMimeTypeOrNull(): String? {
return when (lowercase(Locale.ROOT)) {
"png" -> MimeTypes.Png
"jpg", "jpeg" -> MimeTypes.Jpeg
"gif" -> MimeTypes.Gif
"webp" -> MimeTypes.WebP
"svg" -> MimeTypes.Svg
"bmp" -> "image/bmp"
"heic" -> "image/heic"
"heif" -> "image/heif"
"avif" -> "image/avif"
else -> null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,17 @@

package io.element.android.features.messages.impl.attachments.preview

import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect

sealed interface AttachmentsPreviewEvent {
data object SendAttachment : AttachmentsPreviewEvent
data object CancelAndDismiss : AttachmentsPreviewEvent
data object CancelAndClearSendState : AttachmentsPreviewEvent
data object OpenImageEditor : AttachmentsPreviewEvent
data object CloseImageEditor : AttachmentsPreviewEvent
data object RotateImageToTheLeft : AttachmentsPreviewEvent
data object ApplyImageEdits : AttachmentsPreviewEvent
data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
data object ClearImageEditError : AttachmentsPreviewEvent
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditor
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
Expand All @@ -32,7 +35,6 @@ 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.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.EventId
Expand All @@ -51,7 +53,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File

@AssistedInject
class AttachmentsPreviewPresenter(
Expand All @@ -62,6 +66,7 @@ class AttachmentsPreviewPresenter(
mediaSenderFactory: MediaSenderFactory,
private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
private val attachmentImageEditor: AttachmentImageEditor,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
Expand All @@ -87,6 +92,14 @@ class AttachmentsPreviewPresenter(
val sendActionState = remember {
mutableStateOf<SendActionState>(SendActionState.Idle)
}
val originalLocalMedia = remember { (attachment as Attachment.Media).localMedia }
var currentAttachment by remember { mutableStateOf(attachment) }
var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) }
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) }
var isApplyingImageEdits by remember { mutableStateOf(false) }
var displayImageEditError by remember { mutableStateOf(false) }
var editedTempFile by remember { mutableStateOf<File?>(null) }

val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
Expand All @@ -97,7 +110,7 @@ class AttachmentsPreviewPresenter(

var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }

val mediaAttachment = attachment as Attachment.Media
val mediaAttachment = currentAttachment as Attachment.Media
val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(
localMedia = mediaAttachment.localMedia,
Expand All @@ -113,11 +126,18 @@ class AttachmentsPreviewPresenter(
LaunchedEffect(
mediaOptimizationSelectorState.displayMediaSelectorViews,
mediaOptimizationSelectorState.videoSizeEstimations,
currentAttachment,
imageEditorState,
isApplyingImageEdits,
) {
// If the media optimization selector is not displayed, we can pre-process the media
// to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && preprocessMediaJob == null) {
@Suppress("ComplexCondition")
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false &&
preprocessMediaJob == null &&
imageEditorState == null &&
!isApplyingImageEdits) {
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
return@LaunchedEffect
Expand All @@ -127,18 +147,22 @@ class AttachmentsPreviewPresenter(
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
) ?: return@LaunchedEffect
preprocessMediaJob = coroutineScope.preProcessAttachment(
attachment = attachment,
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = false,
sendActionState = sendActionState,
)
}
}

LaunchedEffect(originalLocalMedia) {
canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia)
}

val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
LaunchedEffect(maxUploadSize) {
// Check file upload size if the media won't be processed for upload
val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage()
val isImageFile = mediaAttachment.localMedia.info.isImageAttachment()
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
Expand Down Expand Up @@ -169,7 +193,7 @@ class AttachmentsPreviewPresenter(
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
)
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = true,
sendActionState = sendActionState,
Expand All @@ -188,40 +212,46 @@ class AttachmentsPreviewPresenter(
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }

val editedTempFileToDelete = editedTempFile
editedTempFile = null

// If we're supposed to send the media as a background job, we can dismiss this screen already
if (coroutineContext.isActive) {
onDoneListener()
}

// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
sessionCoroutineScope.launch(dispatchers.io) {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = false,
inReplyToEventId = inReplyToEventId,
)

// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
try {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = false,
inReplyToEventId = inReplyToEventId,
)
} finally {
editedTempFileToDelete?.safeDelete()
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
}
}
}
}
AttachmentsPreviewEvent.CancelAndDismiss -> {
displayFileTooLargeError = false
displayImageEditError = false
isApplyingImageEdits = false

// Cancel media preprocessing and sending
preprocessMediaJob?.cancel()
preprocessMediaJob = null
// If we couldn't send the pre-processed media, remove it
mediaSender.cleanUp()
ongoingSendAttachmentJob.value?.cancel()

// Dismiss the screen
dismiss(
attachment,
sendActionState,
)
dismiss(sendActionState, editedTempFile)
}
AttachmentsPreviewEvent.CancelAndClearSendState -> {
// Cancel media sending
Expand All @@ -237,11 +267,88 @@ class AttachmentsPreviewPresenter(
SendActionState.Idle
}
}
AttachmentsPreviewEvent.OpenImageEditor -> {
val resolvedCanEditImage = canEditImage || originalLocalMedia.info.canEditImage()
if (resolvedCanEditImage) {
preprocessMediaJob?.cancel()
preprocessMediaJob = null
resetPreparedMedia(sendActionState)
imageEditorState = AttachmentImageEditorState(
localMedia = originalLocalMedia,
edits = appliedImageEdits,
previewDebug = false,
)
}
}
AttachmentsPreviewEvent.CloseImageEditor -> {
imageEditorState = null
}
is AttachmentsPreviewEvent.UpdateImageCropRect -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.copy(cropRect = event.cropRect)
)
}
AttachmentsPreviewEvent.RotateImageToTheLeft -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.rotateAntiClockwise()
)
}
AttachmentsPreviewEvent.ResetImageEdits -> {
imageEditorState = imageEditorState?.copy(
edits = AttachmentImageEdits()
)
}
AttachmentsPreviewEvent.ApplyImageEdits -> {
val pendingState = imageEditorState ?: return
if (!pendingState.edits.hasChanges) {
editedTempFile?.safeDelete()
editedTempFile = null
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(originalLocalMedia)
imageEditorState = null
resetPreparedMedia(sendActionState)
return
}
isApplyingImageEdits = true
displayImageEditError = false
coroutineScope.launch {
val result = withContext(dispatchers.io) {
attachmentImageEditor.exportEdits(
localMedia = originalLocalMedia,
edits = pendingState.edits,
)
}
result.fold(
onSuccess = { editedMedia ->
editedTempFile?.safeDelete()
editedTempFile = editedMedia.file
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(editedMedia.localMedia)
imageEditorState = null
resetPreparedMedia(sendActionState)
},
onFailure = {
Timber.e(it, "Failed to apply image edits")
displayImageEditError = true
}
)
isApplyingImageEdits = false
}
}
AttachmentsPreviewEvent.ClearImageEditError -> {
displayImageEditError = false
}
}
}

return AttachmentsPreviewState(
attachment = attachment,
attachment = currentAttachment,
imageEditorState = imageEditorState,
canEditImage = canEditImage,
isApplyingImageEdits = isApplyingImageEdits,
displayImageEditError = displayImageEditError,
sendActionState = sendActionState.value,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
Expand Down Expand Up @@ -318,8 +425,8 @@ class AttachmentsPreviewPresenter(
}

private fun dismiss(
attachment: Attachment,
sendActionState: MutableState<SendActionState>,
editedTempFile: File?,
) {
// Delete the temporary file
when (attachment) {
Expand All @@ -330,6 +437,7 @@ class AttachmentsPreviewPresenter(
}
}
}
editedTempFile?.safeDelete()
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Done
onDoneListener()
Expand All @@ -343,6 +451,12 @@ class AttachmentsPreviewPresenter(
}
}

private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
sendActionState.value.mediaUploadInfo()?.let(::cleanUp)
mediaSender.cleanUp()
sendActionState.value = SendActionState.Idle
}

private suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ 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.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.libraries.mediaupload.api.MediaUploadInfo
import io.element.android.libraries.textcomposer.model.TextEditorState

data class AttachmentsPreviewState(
val attachment: Attachment,
val imageEditorState: AttachmentImageEditorState?,
val canEditImage: Boolean,
val isApplyingImageEdits: Boolean,
val displayImageEditError: Boolean,
val sendActionState: SendActionState,
val textEditorState: TextEditorState,
val mediaOptimizationSelectorState: MediaOptimizationSelectorState,
Expand Down
Loading
Loading