diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 6ff7f7e3223..2661f7e3306 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentMediaInfo.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentMediaInfo.kt new file mode 100644 index 00000000000..7feeff18dd8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentMediaInfo.kt @@ -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 + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt index d473d4c3f4b..a7c64845d1c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt @@ -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 } 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 fc7f3034a63..cab00d99c1a 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 @@ -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 @@ -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 @@ -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( @@ -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, @@ -87,6 +92,14 @@ class AttachmentsPreviewPresenter( val sendActionState = remember { mutableStateOf(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(null) } + var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) } + var isApplyingImageEdits by remember { mutableStateOf(false) } + var displayImageEditError by remember { mutableStateOf(false) } + var editedTempFile by remember { mutableStateOf(null) } val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) val textEditorState by rememberUpdatedState( @@ -97,7 +110,7 @@ class AttachmentsPreviewPresenter( var preprocessMediaJob by remember { mutableStateOf(null) } - val mediaAttachment = attachment as Attachment.Media + val mediaAttachment = currentAttachment as Attachment.Media val mediaOptimizationSelectorPresenter = remember { mediaOptimizationSelectorPresenterFactory.create( localMedia = mediaAttachment.localMedia, @@ -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 @@ -127,7 +147,7 @@ class AttachmentsPreviewPresenter( mediaOptimizationSelectorState = mediaOptimizationSelectorState, ) ?: return@LaunchedEffect preprocessMediaJob = coroutineScope.preProcessAttachment( - attachment = attachment, + attachment = currentAttachment, mediaOptimizationConfig = config, displayProgress = false, sendActionState = sendActionState, @@ -135,10 +155,14 @@ class AttachmentsPreviewPresenter( } } + 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. @@ -169,7 +193,7 @@ class AttachmentsPreviewPresenter( videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, ) preprocessMediaJob = preProcessAttachment( - attachment = attachment, + attachment = currentAttachment, mediaOptimizationConfig = config, displayProgress = true, sendActionState = sendActionState, @@ -188,6 +212,9 @@ 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() @@ -195,33 +222,36 @@ class AttachmentsPreviewPresenter( // 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 @@ -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, @@ -318,8 +425,8 @@ class AttachmentsPreviewPresenter( } private fun dismiss( - attachment: Attachment, sendActionState: MutableState, + editedTempFile: File?, ) { // Delete the temporary file when (attachment) { @@ -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() @@ -343,6 +451,12 @@ class AttachmentsPreviewPresenter( } } + private fun resetPreparedMedia(sendActionState: MutableState) { + sendActionState.value.mediaUploadInfo()?.let(::cleanUp) + mediaSender.cleanUp() + sendActionState.value = SendActionState.Idle + } + private suspend fun sendPreProcessedMedia( mediaUploadInfo: MediaUploadInfo, caption: String?, 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 97ca230d775..463479fe550 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 @@ -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, 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 70d7ab006ef..a2df440a12d 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 @@ -11,6 +11,8 @@ 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.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState +import io.element.android.features.messages.impl.attachments.preview.imageeditor.anAttachmentImageEditorState import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation import io.element.android.libraries.architecture.AsyncData @@ -42,6 +44,9 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider false + is SendActionState.Sending.Processing -> !state.sendActionState.displayProgress + SendActionState.Done -> false + else -> true + } + fun postSendAttachment() { state.eventSink(AttachmentsPreviewEvent.SendAttachment) } @@ -93,33 +105,84 @@ fun AttachmentsPreviewView( state.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState) } + fun postOpenImageEditor() { + state.eventSink(AttachmentsPreviewEvent.OpenImageEditor) + } + + fun postCloseImageEditor() { + state.eventSink(AttachmentsPreviewEvent.CloseImageEditor) + } + + fun postResetImageEditor() { + state.eventSink(AttachmentsPreviewEvent.ResetImageEdits) + } + + fun postApplyImageEdits() { + state.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) + } + BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) { - postCancel() + if (state.imageEditorState != null) { + postCloseImageEditor() + } else { + postCancel() + } } - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - navigationIcon = { - BackButton( - imageVector = CompoundIcons.Close(), - onClick = ::postCancel, - ) - }, - title = {}, + if (state.imageEditorState != null) { + AttachmentImageEditorView( + state = state.imageEditorState, + onCropRectChange = { cropRect -> + state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) + }, + onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) }, + onCancelClick = ::postCloseImageEditor, + onResetClick = ::postResetImageEditor, + onDoneClick = ::postApplyImageEdits, + modifier = modifier, + ) + } else { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + BackButton( + onClick = ::postCancel, + ) + }, + title = { + Text( + modifier = Modifier.semantics { + heading() + }, + text = stringResource(R.string.screen_media_upload_preview_title), + ) + }, + actions = { + if (state.canEditImage && canShowEditAction) { + TextButton( + stringResource(CommonStrings.action_edit), + onClick = ::postOpenImageEditor + ) + } + } + ) + } + ) { paddingValues -> + AttachmentPreviewContent( + modifier = Modifier.padding(paddingValues), + state = state, + localMediaRenderer = localMediaRenderer, + onSendClick = ::postSendAttachment, ) } - ) { paddingValues -> - AttachmentPreviewContent( - modifier = Modifier.padding(paddingValues), - state = state, - localMediaRenderer = localMediaRenderer, - onSendClick = ::postSendAttachment, - ) } AttachmentSendStateView( sendActionState = state.sendActionState, + isApplyingImageEdits = state.isApplyingImageEdits, + displayImageEditError = state.displayImageEditError, + onDismissImageEditError = { state.eventSink(AttachmentsPreviewEvent.ClearImageEditError) }, onDismissClick = ::postClearSendState, onRetryClick = ::postSendAttachment ) @@ -128,36 +191,56 @@ fun AttachmentsPreviewView( @Composable private fun AttachmentSendStateView( sendActionState: SendActionState, + isApplyingImageEdits: Boolean, + displayImageEditError: Boolean, + onDismissImageEditError: () -> Unit, onDismissClick: () -> Unit, onRetryClick: () -> Unit ) { - when (sendActionState) { - is SendActionState.Sending.Processing -> { - if (sendActionState.displayProgress) { + when { + isApplyingImageEdits -> { + ProgressDialog( + type = ProgressDialogType.Indeterminate, + text = stringResource(CommonStrings.common_preparing), + showCancelButton = false, + onDismissRequest = {}, + ) + } + displayImageEditError -> { + AlertDialog( + title = stringResource(CommonStrings.common_error), + content = stringResource(CommonStrings.common_something_went_wrong_message), + onDismiss = onDismissImageEditError, + ) + } + else -> when (sendActionState) { + is SendActionState.Sending.Processing -> { + if (sendActionState.displayProgress) { + ProgressDialog( + type = ProgressDialogType.Indeterminate, + text = stringResource(CommonStrings.common_preparing), + showCancelButton = true, + onDismissRequest = onDismissClick, + ) + } + } + is SendActionState.Sending.Uploading -> { ProgressDialog( type = ProgressDialogType.Indeterminate, - text = stringResource(CommonStrings.common_preparing), + text = stringResource(id = CommonStrings.common_sending), showCancelButton = true, onDismissRequest = onDismissClick, ) } + is SendActionState.Failure -> { + RetryDialog( + content = stringResource(sendAttachmentError(sendActionState.error)), + onDismiss = onDismissClick, + onRetry = onRetryClick + ) + } + else -> Unit } - is SendActionState.Sending.Uploading -> { - ProgressDialog( - type = ProgressDialogType.Indeterminate, - text = stringResource(id = CommonStrings.common_sending), - showCancelButton = true, - onDismissRequest = onDismissClick, - ) - } - is SendActionState.Failure -> { - RetryDialog( - content = stringResource(sendAttachmentError(sendActionState.error)), - onDismiss = onDismissClick, - onRetry = onRetryClick - ) - } - else -> Unit } } @@ -184,10 +267,10 @@ private fun AttachmentPreviewContent( } } } - val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType - if (mimeType?.isMimeTypeImage() == true) { + val mediaInfo = (state.attachment as? Attachment.Media)?.localMedia?.info + if (mediaInfo?.isImageAttachment() == true) { ImageOptimizationSelector(state.mediaOptimizationSelectorState) - } else if (mimeType?.isMimeTypeVideo() == true) { + } else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) { VideoPresetSelector(state = state.mediaOptimizationSelectorState) } @@ -220,7 +303,8 @@ private fun AttachmentPreviewContent( private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) { if (state.displayMediaSelectorViews == true) { Row( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .niceClickable { state.isImageOptimizationEnabled?.let { value -> state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value)) @@ -229,7 +313,9 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) { .padding(horizontal = 16.dp, vertical = 16.dp) ) { Text( - modifier = Modifier.weight(1f).align(Alignment.CenterVertically), + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title), style = ElementTheme.typography.fontBodyLgRegular, ) @@ -255,7 +341,8 @@ private fun VideoPresetSelector( if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) { Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 16.dp) .niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) } ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt new file mode 100644 index 00000000000..cb668021846 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt @@ -0,0 +1,191 @@ +/* + * 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.imageeditor + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.messages.impl.attachments.preview.resolvedImageMimeType +import io.element.android.libraries.androidutils.bitmap.rotateToExifMetadataOrientation +import io.element.android.libraries.androidutils.bitmap.writeBitmap +import io.element.android.libraries.androidutils.file.createTmpFile +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.extensions.runCatchingExceptions +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.di.annotations.ApplicationContext +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.math.roundToInt + +private const val EDITED_MEDIA_DIR_NAME = "edited-media" + +interface AttachmentImageEditor { + suspend fun canEdit(localMedia: LocalMedia): Boolean + + suspend fun exportEdits( + localMedia: LocalMedia, + edits: AttachmentImageEdits, + ): Result +} + +data class EditedLocalMedia( + val localMedia: LocalMedia, + val file: File, +) + +@ContributesBinding(AppScope::class) +class DefaultAttachmentImageEditor( + @ApplicationContext private val context: Context, + private val dispatchers: CoroutineDispatchers, +) : AttachmentImageEditor { + override suspend fun canEdit(localMedia: LocalMedia): Boolean = withContext(dispatchers.io) { + localMedia.info.resolvedImageMimeType() + ?.takeIf { it.isEditableStillImageMimeType() } + ?.let { return@withContext true } + + val decodedMimeType = context.contentResolver.openInputStream(localMedia.uri)?.use { input -> + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(input, null, options) + options.outMimeType + } + + decodedMimeType.isEditableStillImageMimeType() + } + + override suspend fun exportEdits( + localMedia: LocalMedia, + edits: AttachmentImageEdits, + ): Result = withContext(dispatchers.io) { + runCatchingExceptions { + val sourceMimeType = localMedia.info.resolvedImageMimeType() ?: localMedia.info.mimeType + val exportedMimeType = exportedMimeTypeFor(sourceMimeType) + val exifOrientation = context.contentResolver.openInputStream(localMedia.uri)?.let { input -> + input.use { + ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED) + } + } ?: ExifInterface.ORIENTATION_UNDEFINED + + val decodedBitmap = context.contentResolver.openInputStream(localMedia.uri)?.use { input -> + BitmapFactory.decodeStream(input) + } ?: error("Unable to decode image from ${localMedia.uri}") + + val normalizedBitmap = decodedBitmap.rotateToExifMetadataOrientation(exifOrientation) + if (normalizedBitmap !== decodedBitmap) { + decodedBitmap.recycle() + } + + val rotatedBitmap = normalizedBitmap.rotateQuarterTurns(edits.rotationQuarterTurns) + if (rotatedBitmap !== normalizedBitmap) { + normalizedBitmap.recycle() + } + + val cropRect = edits.cropRect.toPixelRect( + imageWidth = rotatedBitmap.width, + imageHeight = rotatedBitmap.height, + ) + val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 && + cropRect.width() == rotatedBitmap.width && cropRect.height() == rotatedBitmap.height + val croppedBitmap = if (isCropUnchanged) { + rotatedBitmap + } else { + Bitmap.createBitmap( + rotatedBitmap, + cropRect.left, + cropRect.top, + cropRect.width(), + cropRect.height(), + ) + } + if (croppedBitmap !== rotatedBitmap) { + rotatedBitmap.recycle() + } + + val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() } + val outputFile = context.createTmpFile(baseDir = editedMediaDir, extension = compressFileExtension(exportedMimeType)) + outputFile.writeBitmap( + bitmap = croppedBitmap, + format = compressFormat(exportedMimeType), + quality = 90, + ) + croppedBitmap.recycle() + + EditedLocalMedia( + localMedia = localMedia.copy( + uri = Uri.fromFile(outputFile), + info = localMedia.info.copy(mimeType = exportedMimeType), + ), + file = outputFile, + ) + } + } +} + +internal fun exportedMimeTypeFor(sourceMimeType: String?): String { + return if (sourceMimeType == MimeTypes.Png) { + MimeTypes.Png + } else { + MimeTypes.Jpeg + } +} + +private fun Bitmap.rotateQuarterTurns(quarterTurns: Int): Bitmap { + val normalizedTurns = (quarterTurns % 4 + 4) % 4 + if (normalizedTurns == 0) return this + val matrix = Matrix().apply { + postRotate(normalizedTurns * 90f) + } + return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) +} + +private data class PixelCropRect( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, +) { + fun width() = right - left + fun height() = bottom - top +} + +private fun NormalizedCropRect.toPixelRect(imageWidth: Int, imageHeight: Int): PixelCropRect { + val leftPx = (left * imageWidth).roundToInt().coerceIn(0, imageWidth - 1) + val topPx = (top * imageHeight).roundToInt().coerceIn(0, imageHeight - 1) + val rightPx = (right * imageWidth).roundToInt().coerceIn(leftPx + 1, imageWidth) + val bottomPx = (bottom * imageHeight).roundToInt().coerceIn(topPx + 1, imageHeight) + return PixelCropRect( + left = leftPx, + top = topPx, + right = rightPx, + bottom = bottomPx, + ) +} + +private fun compressFormat(mimeType: String) = when (mimeType) { + MimeTypes.Png -> Bitmap.CompressFormat.PNG + else -> Bitmap.CompressFormat.JPEG +} + +private fun compressFileExtension(mimeType: String) = when (mimeType) { + MimeTypes.Png -> "png" + else -> "jpeg" +} + +private fun String?.isEditableStillImageMimeType(): Boolean { + return this != null && + this.isMimeTypeImage() && + !this.isMimeTypeAnimatedImage() && + this != MimeTypes.Svg +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorState.kt new file mode 100644 index 00000000000..3c8af52ce88 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorState.kt @@ -0,0 +1,164 @@ +/* + * 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.imageeditor + +import androidx.annotation.FloatRange +import androidx.compose.runtime.Immutable +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +private const val DEFAULT_CROP_MARGIN = 0f +private const val MIN_CROP_SIZE = 0.1f + +@Immutable +data class AttachmentImageEditorState( + val localMedia: LocalMedia, + val edits: AttachmentImageEdits, + // For preview only + val previewDebug: Boolean, +) + +@Immutable +data class AttachmentImageEdits( + val cropRect: NormalizedCropRect = NormalizedCropRect.default(), + val rotationQuarterTurns: Int = 0, +) { + val normalizedRotationQuarterTurns: Int + get() = rotationQuarterTurns % 4 + + val rotationDegrees: Int + get() = normalizedRotationQuarterTurns * 90 + + val hasChanges: Boolean + get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0 + + fun rotateAntiClockwise(): AttachmentImageEdits { + return copy( + rotationQuarterTurns = (normalizedRotationQuarterTurns + 3) % 4, + // Also update the crop rect to keep the same selected area + cropRect = NormalizedCropRect( + left = cropRect.top, + top = 1f - cropRect.right, + right = cropRect.bottom, + bottom = 1f - cropRect.left, + ) + ) + } +} + +@Immutable +data class NormalizedCropRect( + @FloatRange(from = 0.0, to = 1.0) val left: Float, + @FloatRange(from = 0.0, to = 1.0) val top: Float, + @FloatRange(from = 0.0, to = 1.0) val right: Float, + @FloatRange(from = 0.0, to = 1.0) val bottom: Float, +) { + init { + require(left in 0f..1f) + require(top in 0f..1f) + require(right in 0f..1f) + require(bottom in 0f..1f) + require(left < right) + require(top < bottom) + } + + val width: Float + get() = right - left + + val height: Float + get() = bottom - top + + fun applyChange( + dragTarget: CropDragTarget, + deltaX: Float, + deltaY: Float, + ): NormalizedCropRect = when (dragTarget) { + is CropDragTarget.Move -> translate(deltaX, deltaY) + is CropDragTarget.Corner -> dragWithCorner(dragTarget, deltaX, deltaY) + is CropDragTarget.Edge -> dragWithEdge(dragTarget, deltaX, deltaY) + } + + private fun translate(deltaX: Float, deltaY: Float): NormalizedCropRect { + val clampedLeft = (left + deltaX).coerceIn(0f, 1f - width) + val clampedTop = (top + deltaY).coerceIn(0f, 1f - height) + return copy( + left = clampedLeft, + top = clampedTop, + right = clampedLeft + width, + bottom = clampedTop + height, + ) + } + + private fun dragWithCorner( + dragTarget: CropDragTarget.Corner, + deltaX: Float, + deltaY: Float, + ) = when (dragTarget) { + CropDragTarget.Corner.TopLeft -> copy( + left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), + top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), + ) + CropDragTarget.Corner.TopRight -> copy( + right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), + top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), + ) + CropDragTarget.Corner.BottomRight -> copy( + right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), + bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), + ) + CropDragTarget.Corner.BottomLeft -> copy( + left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), + bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), + ) + } + + private fun dragWithEdge( + dragTarget: CropDragTarget.Edge, + deltaX: Float, + deltaY: Float, + ) = when (dragTarget) { + CropDragTarget.Edge.Top -> copy( + top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), + ) + CropDragTarget.Edge.Right -> copy( + right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), + ) + CropDragTarget.Edge.Bottom -> copy( + bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), + ) + CropDragTarget.Edge.Left -> copy( + left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), + ) + } + + companion object { + fun default() = NormalizedCropRect( + left = DEFAULT_CROP_MARGIN, + top = DEFAULT_CROP_MARGIN, + right = 1f - DEFAULT_CROP_MARGIN, + bottom = 1f - DEFAULT_CROP_MARGIN, + ) + } +} + +sealed interface CropDragTarget { + data object Move : CropDragTarget + + sealed interface Corner : CropDragTarget { + data object TopLeft : Corner + data object TopRight : Corner + data object BottomRight : Corner + data object BottomLeft : Corner + } + + sealed interface Edge : CropDragTarget { + data object Top : Edge + data object Right : Edge + data object Bottom : Edge + data object Left : Edge + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt new file mode 100644 index 00000000000..df4bb5257f6 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt @@ -0,0 +1,92 @@ +/* + * 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.imageeditor + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.core.net.toUri +import io.element.android.libraries.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +open class AttachmentImageEditorStateProvider : PreviewParameterProvider { + private val caterpillarCrop = NormalizedCropRect( + left = 0.3f, + top = 0.3f, + right = 0.8f, + bottom = 0.75f, + ) + + override val values: Sequence + get() = sequenceOf( + anAttachmentImageEditorState( + edits = AttachmentImageEdits( + // Cheat a bit so that the crop match the sample image size (1024 * 682) + cropRect = 0.17f.let { correction -> + NormalizedCropRect( + left = 0f, + top = correction, + right = 1f, + bottom = 1 - correction, + ) + }, + ), + ), + anAttachmentImageEditorState( + edits = AttachmentImageEdits( + cropRect = caterpillarCrop, + ), + ), + anAttachmentImageEditorState( + edits = AttachmentImageEdits( + cropRect = caterpillarCrop, + ), + previewDebug = true, + ), + anAttachmentImageEditorState( + edits = AttachmentImageEdits( + cropRect = caterpillarCrop, + ).rotateAntiClockwise(), + ), + // Small crop + anAttachmentImageEditorState( + edits = AttachmentImageEdits( + cropRect = NormalizedCropRect( + left = 0.3f, + top = 0.6f, + right = 0.4f, + bottom = 0.7f, + ), + ), + previewDebug = true, + ), + // Big crop + anAttachmentImageEditorState( + edits = AttachmentImageEdits( + cropRect = NormalizedCropRect( + left = 0.05f, + top = 0.05f, + right = 0.95f, + bottom = 0.95f, + ), + ), + previewDebug = true, + ), + ) +} + +internal fun anAttachmentImageEditorState( + localMedia: LocalMedia = LocalMedia( + uri = "file://preview-image".toUri(), + info = anImageMediaInfo(), + ), + edits: AttachmentImageEdits = AttachmentImageEdits(), + previewDebug: Boolean = false, +) = AttachmentImageEditorState( + localMedia = localMedia, + edits = edits, + previewDebug = previewDebug, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt new file mode 100644 index 00000000000..6011b52c415 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt @@ -0,0 +1,647 @@ +/* + * 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.imageeditor + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.text.toPx +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.ui.strings.CommonStrings +import kotlin.math.min + +private val minHandleTouchRadius = 16.dp +private val maxHandleTouchRadius = 56.dp + +/** + * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AttachmentImageEditorView( + state: AttachmentImageEditorState, + onCropRectChange: (NormalizedCropRect) -> Unit, + onRotateClick: () -> Unit, + onResetClick: () -> Unit, + onCancelClick: () -> Unit, + onDoneClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val rotateContentDescription = stringResource(R.string.screen_image_edition_a11y_rotate_to_the_left) + val rotationStateDescription = pluralStringResource( + R.plurals.screen_image_edition_a11y_rotation_state, + state.edits.rotationDegrees, + state.edits.rotationDegrees, + ) + val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { + BackButton( + imageVector = CompoundIcons.Close(), + onClick = onCancelClick, + ) + }, + title = { + Text( + modifier = Modifier.semantics { + heading() + }, + text = stringResource(R.string.screen_image_edition_title), + ) + }, + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .background(ElementTheme.colors.bgCanvasDefault) + .padding(paddingValues) + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center, + ) { + CropEditorCanvas( + state = state, + onCropRectChange = onCropRectChange, + ) + } + Row( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .widthIn(max = 360.dp) + .navigationBarsPadding() + .padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 18.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { + TextButton( + text = stringResource(CommonStrings.action_reset), + destructive = true, + onClick = onResetClick, + ) + } + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center, + ) { + IconButton( + onClick = onRotateClick, + modifier = Modifier + .background( + color = rotateButtonBackground, + shape = CircleShape, + ) + .border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape) + .clearAndSetSemantics { + contentDescription = rotateContentDescription + stateDescription = rotationStateDescription + } + ) { + Icon( + modifier = Modifier + .size(22.dp), + imageVector = CompoundIcons.RotateLeft(), + contentDescription = null, + ) + } + } + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterEnd, + ) { + TextButton( + text = stringResource(CommonStrings.action_done), + onClick = onDoneClick, + ) + } + } + } + } +} + +@Composable +private fun BoxScope.CropEditorCanvas( + state: AttachmentImageEditorState, + onCropRectChange: (NormalizedCropRect) -> Unit, +) { + var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) } + val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns + + var imageRect by remember { mutableStateOf(Rect.Zero) } + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding(20.dp), + ) { + val displayedSize = remember(maxWidth, maxHeight, imageSize, rotationQuarterTurns) { + val sourceWidth = imageSize.width.takeIf { it > 0 } ?: 1 + val sourceHeight = imageSize.height.takeIf { it > 0 } ?: 1 + val aspectRatio = if (rotationQuarterTurns % 2 == 0) { + sourceWidth.toFloat() / sourceHeight.toFloat() + } else { + sourceHeight.toFloat() / sourceWidth.toFloat() + } + fitSize( + containerWidth = constraints.maxWidth.toFloat(), + containerHeight = constraints.maxHeight.toFloat(), + aspectRatio = aspectRatio, + ) + } + val density = LocalDensity.current + val displayedWidthDp = with(density) { displayedSize.width.toDp() } + val displayedHeightDp = with(density) { displayedSize.height.toDp() } + val imageLayoutSize = remember(displayedSize, rotationQuarterTurns) { + if (rotationQuarterTurns % 2 == 0) { + displayedSize + } else { + Size( + width = displayedSize.height, + height = displayedSize.width, + ) + } + } + val imageLayoutWidthDp = with(density) { imageLayoutSize.width.toDp() } + val imageLayoutHeightDp = with(density) { imageLayoutSize.height.toDp() } + + Box( + modifier = Modifier + .size(displayedWidthDp, displayedHeightDp) + .align(Alignment.Center) + .onPlaced { + imageRect = it.boundsInParent() + }, + contentAlignment = Alignment.Center, + ) { + if (LocalInspectionMode.current) { + Image( + painter = painterResource(id = CommonDrawables.sample_background), + contentDescription = null, + modifier = Modifier + .requiredSize(imageLayoutWidthDp, imageLayoutHeightDp) + .graphicsLayer { rotationZ = rotationQuarterTurns * 90f }, + contentScale = ContentScale.Fit, + ) + } else { + AsyncImage( + model = state.localMedia.uri, + contentDescription = stringResource(CommonStrings.common_image), + modifier = Modifier + .requiredSize(imageLayoutWidthDp, imageLayoutHeightDp) + .graphicsLayer { rotationZ = rotationQuarterTurns * 90f }, + contentScale = ContentScale.Fit, + onState = { painterState -> + if (painterState is AsyncImagePainter.State.Success) { + imageSize = IntSize( + width = painterState.result.image.width, + height = painterState.result.image.height, + ) + } + } + ) + } + } + val minHandleTouchRadiusPx = minHandleTouchRadius.toPx() + val maxHandleTouchRadiusPx = maxHandleTouchRadius.toPx() + val touchRadiusPx by rememberUpdatedState( + (min( + state.edits.cropRect.width * imageRect.width, + state.edits.cropRect.height * imageRect.height, + ) / 4f).coerceIn( + minHandleTouchRadiusPx, + maxHandleTouchRadiusPx, + ) + ) + var dragTarget by remember { mutableStateOf(null) } + val latestCropRect by rememberUpdatedState(state.edits.cropRect) + val drawGuidelines = dragTarget == CropDragTarget.Move || state.previewDebug + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + dragTarget = detectDragTarget( + touchPoint = offset, + imageOffset = imageRect.topLeft, + cropRect = latestCropRect, + canvasSize = Size(imageRect.width, imageRect.height), + handleTouchRadius = touchRadiusPx, + ) + }, + onDragCancel = { + dragTarget = null + }, + onDragEnd = { + dragTarget = null + }, + ) { change, dragAmount -> + val activeTarget = dragTarget ?: return@detectDragGestures + change.consume() + onCropRectChange( + latestCropRect.applyChange( + dragTarget = activeTarget, + deltaX = dragAmount.x / size.width.toFloat(), + deltaY = dragAmount.y / size.height.toFloat(), + ) + ) + } + }, + contentAlignment = Alignment.Center, + ) { + CropOverlay( + imageSize = DpSize(displayedWidthDp, displayedHeightDp), + cropRect = state.edits.cropRect, + drawGuidelines = drawGuidelines, + previewDebug = state.previewDebug, + touchRadiusPx = touchRadiusPx, + dragTarget = dragTarget, + ) + } + } +} + +@Composable +private fun CropOverlay( + imageSize: DpSize, + cropRect: NormalizedCropRect, + drawGuidelines: Boolean, + previewDebug: Boolean, + touchRadiusPx: Float, + dragTarget: CropDragTarget?, +) { + val borderColor = ElementTheme.colors.iconPrimary + val guideColor = ElementTheme.colors.iconPrimary + + Canvas( + modifier = Modifier.size(imageSize.width, imageSize.height) + ) { + val cropLeft = cropRect.left * size.width + val cropTop = cropRect.top * size.height + val cropRight = cropRect.right * size.width + val cropBottom = cropRect.bottom * size.height + // Hardcoded black: the crop overlay must always darken the image regardless of theme. + // No semantic token exists for this use case in the Compound design system. + val overlayColor = Color.Black.copy(alpha = 0.48f) + // Overlay above the crop area + drawRect( + color = overlayColor, + topLeft = Offset.Zero, + size = Size(width = size.width, height = cropTop), + ) + // Overlay on the left of the crop area + drawRect( + color = overlayColor, + topLeft = Offset(0f, cropTop), + size = Size(width = cropLeft, height = cropBottom - cropTop), + ) + // Overlay on the right of the crop area + drawRect( + color = overlayColor, + topLeft = Offset(cropRight, cropTop), + size = Size(width = size.width - cropRight, height = cropBottom - cropTop), + ) + // Overlay below the crop area + drawRect( + color = overlayColor, + topLeft = Offset(0f, cropBottom), + size = Size(width = size.width, height = size.height - cropBottom), + ) + // Main frame of the crop area + drawRect( + color = borderColor, + topLeft = Offset(cropLeft, cropTop), + size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop), + style = Stroke(width = 1.dp.toPx()), + ) + // Guidelines dividing the crop area into 9 equal parts + if (drawGuidelines) { + val thirdWidth = (cropRight - cropLeft) / 3f + val thirdHeight = (cropBottom - cropTop) / 3f + for (index in 1..2) { + val offsetX = cropLeft + thirdWidth * index + val offsetY = cropTop + thirdHeight * index + // Vertical guide line + drawLine( + color = guideColor, + start = Offset(offsetX, cropTop), + end = Offset(offsetX, cropBottom), + strokeWidth = 1.dp.toPx(), + ) + // Horizontal guide line + drawLine( + color = guideColor, + start = Offset(cropLeft, offsetY), + end = Offset(cropRight, offsetY), + strokeWidth = 1.dp.toPx(), + ) + } + } + // Corner handles + val handleLength = 18.dp.toPx() + val handleOffset = 2.dp.toPx() + // Top left corner + drawCornerHandle( + x = cropLeft - handleOffset, + y = cropTop - handleOffset, + handleLength = handleLength, + color = borderColor, + position = CropDragTarget.Corner.TopLeft, + ) + // Top right corner + drawCornerHandle( + x = cropRight + handleOffset, + y = cropTop - handleOffset, + handleLength = handleLength, + color = borderColor, + position = CropDragTarget.Corner.TopRight, + ) + // Bottom left corner + drawCornerHandle( + x = cropLeft - handleOffset, + y = cropBottom + handleOffset, + handleLength = handleLength, + color = borderColor, + position = CropDragTarget.Corner.BottomLeft, + ) + // Bottom right corner + drawCornerHandle( + x = cropRight + handleOffset, + y = cropBottom + handleOffset, + handleLength = handleLength, + color = borderColor, + position = CropDragTarget.Corner.BottomRight, + ) + val handleColor = borderColor + // Top handle + drawEdgeHandle( + center = Offset((cropLeft + cropRight) / 2f, cropTop - handleOffset), + horizontal = true, + handleLength = handleLength, + color = handleColor, + ) + // Right handle + drawEdgeHandle( + center = Offset(cropRight + handleOffset, (cropTop + cropBottom) / 2f), + horizontal = false, + handleLength = handleLength, + color = handleColor, + ) + // Bottom handle + drawEdgeHandle( + center = Offset((cropLeft + cropRight) / 2f, cropBottom + handleOffset), + horizontal = true, + handleLength = handleLength, + color = handleColor, + ) + // Left handle + drawEdgeHandle( + center = Offset(cropLeft - handleOffset, (cropTop + cropBottom) / 2f), + horizontal = false, + handleLength = handleLength, + color = handleColor, + ) + + if (previewDebug) { + // Draw disk around touchable area + listOf( + CropDragTarget.Edge.Top, + CropDragTarget.Edge.Right, + CropDragTarget.Edge.Bottom, + CropDragTarget.Edge.Left, + CropDragTarget.Corner.TopLeft, + CropDragTarget.Corner.TopRight, + CropDragTarget.Corner.BottomRight, + CropDragTarget.Corner.BottomLeft, + CropDragTarget.Move, + ).forEach { target -> + val color = when (target) { + is CropDragTarget.Move -> Color.Red + is CropDragTarget.Corner -> Color.Blue + is CropDragTarget.Edge -> Color.Green + }.copy(alpha = if (dragTarget == target) 9f else 0.5f) + drawCircle( + color = color, + radius = touchRadiusPx, + center = computeOffset(target, cropRect, Size(size.width, size.height)), + ) + } + } + } +} + +private fun fitSize( + containerWidth: Float, + containerHeight: Float, + aspectRatio: Float, +): Size { + val widthBasedHeight = containerWidth / aspectRatio + return if (widthBasedHeight <= containerHeight) { + Size(width = containerWidth, height = widthBasedHeight) + } else { + Size(width = containerHeight * aspectRatio, height = containerHeight) + } +} + +private fun detectDragTarget( + touchPoint: Offset, + imageOffset: Offset, + cropRect: NormalizedCropRect, + canvasSize: Size, + handleTouchRadius: Float, +): CropDragTarget? { + // Give priority on Move (extra detection of the center of crop area) + // to ensure that user can move a small crop, then to corners and at last to edges. + val handlesArea = mapOf( + CropDragTarget.Move to computeOffset(CropDragTarget.Move, cropRect, canvasSize), + CropDragTarget.Corner.TopLeft to computeOffset(CropDragTarget.Corner.TopLeft, cropRect, canvasSize), + CropDragTarget.Corner.TopRight to computeOffset(CropDragTarget.Corner.TopRight, cropRect, canvasSize), + CropDragTarget.Corner.BottomRight to computeOffset(CropDragTarget.Corner.BottomRight, cropRect, canvasSize), + CropDragTarget.Corner.BottomLeft to computeOffset(CropDragTarget.Corner.BottomLeft, cropRect, canvasSize), + CropDragTarget.Edge.Top to computeOffset(CropDragTarget.Edge.Top, cropRect, canvasSize), + CropDragTarget.Edge.Right to computeOffset(CropDragTarget.Edge.Right, cropRect, canvasSize), + CropDragTarget.Edge.Bottom to computeOffset(CropDragTarget.Edge.Bottom, cropRect, canvasSize), + CropDragTarget.Edge.Left to computeOffset(CropDragTarget.Edge.Left, cropRect, canvasSize), + ) + handlesArea.forEach { (target, corner) -> + if ((corner - touchPoint + imageOffset).getDistance() <= handleTouchRadius) { + return target + } + } + val cropLeft = imageOffset.x + cropRect.left * canvasSize.width + val cropTop = imageOffset.y + cropRect.top * canvasSize.height + val cropRight = imageOffset.x + cropRect.right * canvasSize.width + val cropBottom = imageOffset.y + cropRect.bottom * canvasSize.height + return if (touchPoint.x in cropLeft..cropRight && touchPoint.y in cropTop..cropBottom) { + CropDragTarget.Move + } else { + null + } +} + +private fun computeOffset( + target: CropDragTarget, + cropRect: NormalizedCropRect, + canvasSize: Size, +) = when (target) { + CropDragTarget.Move -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f) + CropDragTarget.Corner.TopLeft -> Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height) + CropDragTarget.Edge.Top -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height) + CropDragTarget.Corner.TopRight -> Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height) + CropDragTarget.Edge.Right -> Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f) + CropDragTarget.Corner.BottomRight -> Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height) + CropDragTarget.Edge.Bottom -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height) + CropDragTarget.Corner.BottomLeft -> Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height) + CropDragTarget.Edge.Left -> Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f) +} + +// x and y are the coordinates of the corner +private fun DrawScope.drawCornerHandle( + x: Float, + y: Float, + handleLength: Float, + color: Color, + position: CropDragTarget.Corner, +) { + val strokeWidth = 4.dp.toPx() + val correction = strokeWidth / 2 + val horizontalCorrection = if (position.isLeft()) -correction else correction + val horizontalEndX = if (position.isLeft()) x + handleLength else x - handleLength + val verticalEndY = if (position.isTop()) y + handleLength else y - handleLength + val verticalCorrection = if (position.isTop()) -correction else correction + // Horizontal line + drawLine( + color = color, + start = Offset(x + horizontalCorrection, y), + end = Offset(horizontalEndX + horizontalCorrection, y), + strokeWidth = strokeWidth, + ) + // Vertical line + drawLine( + color = color, + start = Offset(x, y + verticalCorrection), + end = Offset(x, verticalEndY + verticalCorrection), + strokeWidth = strokeWidth, + ) +} + +private fun CropDragTarget.Corner.isLeft() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.BottomLeft +private fun CropDragTarget.Corner.isTop() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.TopRight + +private fun DrawScope.drawEdgeHandle( + center: Offset, + horizontal: Boolean, + handleLength: Float, + color: Color, +) { + val start = if (horizontal) { + Offset(center.x - handleLength / 2f, center.y) + } else { + Offset(center.x, center.y - handleLength / 2f) + } + val end = if (horizontal) { + Offset(center.x + handleLength / 2f, center.y) + } else { + Offset(center.x, center.y + handleLength / 2f) + } + drawLine( + color = color, + start = start, + end = end, + strokeWidth = 4.dp.toPx(), + ) +} + +// Only preview in dark, dark theme is forced on the Node. +@Preview +@Composable +internal fun AttachmentImageEditorViewPreview( + @PreviewParameter(AttachmentImageEditorStateProvider::class) state: AttachmentImageEditorState, +) = ElementPreviewDark { + AttachmentImageEditorView( + state = state, + onCropRectChange = {}, + onRotateClick = {}, + onResetClick = {}, + onCancelClick = {}, + onDoneClick = {}, + ) +} diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index f5629f5e2d5..908646b048d 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -16,6 +16,12 @@ "Travel & Places" "Recent emojis" "Symbols" + "Rotate the image to the left" + + "%1$d degree" + "%1$d degrees" + + "Edit photo" "Captions might not be visible to people using older apps." "Tap to change the video upload quality" "The file could not be uploaded." @@ -26,6 +32,7 @@ "Item %1$d of %2$d" "Optimise image quality" "Processing…" + "Add media" "Block user" "Check if you want to hide all current and future messages from this user" "This message will be reported to your homeserver’s administrator. They will not be able to read any encrypted messages." 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 9a9dc088349..140d4f8ea38 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 @@ -11,11 +11,17 @@ package io.element.android.features.messages.impl.attachments import android.net.Uri +import androidx.core.net.toUri import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvent import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter import io.element.android.features.messages.impl.attachments.preview.OnDoneListener import io.element.android.features.messages.impl.attachments.preview.SendActionState +import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditor +import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits +import io.element.android.features.messages.impl.attachments.preview.imageeditor.EditedLocalMedia +import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect +import io.element.android.features.messages.impl.attachments.preview.imageeditor.assertIsSimilarTo import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation @@ -73,6 +79,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.io.File +import kotlin.io.path.createTempFile @Suppress("LargeClass") @RunWith(RobolectricTestRunner::class) @@ -551,6 +558,92 @@ class AttachmentsPreviewPresenterTest { } @Test + fun `present - applying image edits updates the attachment`() = runTest { + val editedUri = Uri.parse("file:///tmp/edited.jpeg") + val presenter = createAttachmentsPreviewPresenter( + displayMediaQualitySelectorViews = true, + attachmentImageEditor = FakeAttachmentImageEditor { + Result.success( + EditedLocalMedia( + localMedia = aLocalMedia(uri = editedUri), + file = File("/tmp/edited.jpeg"), + ) + ) + } + ) + + presenter.test { + val initialState = awaitItem() + initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) + val editorState = awaitItem() + assertThat(editorState.imageEditorState).isNotNull() + + editorState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) + val rotatedState = awaitItem() + assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(3) + + rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) + assertThat(awaitItem().isApplyingImageEdits).isTrue() + + val appliedState = awaitItem() + assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri) + assertThat(appliedState.imageEditorState).isNull() + assertThat(appliedState.isApplyingImageEdits).isFalse() + } + } + + @Test + fun `present - reopening image editor keeps original media and previous edits`() = runTest { + val editedUri = Uri.parse("file:///tmp/edited.jpeg") + val originalLocalMedia = aLocalMedia(uri = mockMediaUrl) + val cropRect = NormalizedCropRect( + left = 0.2f, + top = 0.15f, + right = 0.85f, + bottom = 0.9f, + ) + val presenter = createAttachmentsPreviewPresenter( + localMedia = originalLocalMedia, + displayMediaQualitySelectorViews = true, + attachmentImageEditor = FakeAttachmentImageEditor { + Result.success( + EditedLocalMedia( + localMedia = aLocalMedia(uri = editedUri), + file = File("/tmp/edited.jpeg"), + ) + ) + } + ) + + presenter.test { + val initialState = awaitItem() + initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) + val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() + + editorState.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) + val croppedState = awaitItem() + croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) + val rotatedState = awaitItem() + rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) + + val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last() + assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri) + + appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) + val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() + assertThat(reopenedState.imageEditorState!!.localMedia.uri).isEqualTo(originalLocalMedia.uri) + val rotatedCropRect = NormalizedCropRect( + left = cropRect.top, + top = 1f - cropRect.right, + right = cropRect.bottom, + bottom = 1f - cropRect.left, + ) + reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(rotatedCropRect) + assertThat(reopenedState.imageEditorState.edits.rotationQuarterTurns).isEqualTo(3) + assertThat(reopenedState.imageEditorState.edits.rotationDegrees).isEqualTo(270) + } + } + fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest { // Even though the user has enabled "Optimize media quality" globally, picking the file // through the Files picker (sendAsFile = true) must skip compression. Regression test @@ -581,6 +674,121 @@ class AttachmentsPreviewPresenterTest { } } + @Test + fun `present - sending edited media keeps the edited file available until upload starts`() = runTest { + val editedFile = createTempFile(suffix = ".jpeg").toFile().apply { + writeText("edited-media") + } + val sendFileResult = + lambdaRecorder> { file, _, _, _, _ -> + assertThat(file.exists()).isTrue() + Result.success(FakeMediaUploadHandler()) + } + val room = FakeJoinedRoom( + liveTimeline = FakeTimeline().apply { + sendFileLambda = sendFileResult + }, + ) + val presenter = createAttachmentsPreviewPresenter( + room = room, + displayMediaQualitySelectorViews = true, + onDoneListener = OnDoneListener {}, + mediaPreProcessor = FakeMediaPreProcessor().apply { + givenResult( + Result.success( + MediaUploadInfo.AnyFile( + file = editedFile, + fileInfo = FileInfo( + mimetype = MimeTypes.Jpeg, + size = editedFile.length(), + thumbnailInfo = null, + thumbnailSource = null, + ) + ) + ) + ) + }, + attachmentImageEditor = FakeAttachmentImageEditor { + Result.success( + EditedLocalMedia( + localMedia = aLocalMedia(uri = editedFile.toUri()), + file = editedFile, + ) + ) + } + ) + + presenter.test { + val initialState = awaitItem() + initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) + val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() + + editorState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) + val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last() + + appliedState.eventSink(AttachmentsPreviewEvent.SendAttachment) + consumeItemsUntilPredicate { it.sendActionState == SendActionState.Done } + + sendFileResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - image with generic mime type and png extension is still editable`() = runTest { + val localMedia = aLocalMedia( + uri = mockMediaUrl, + mediaInfo = anImageMediaInfo().copy( + mimeType = MimeTypes.OctetStream, + filename = "Screenshot.png", + fileExtension = "png", + ), + ) + val presenter = createAttachmentsPreviewPresenter(localMedia = localMedia) + + presenter.test { + val initialState = awaitItem() + assertThat(initialState.canEditImage).isTrue() + + initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) + val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() + assertThat(editorState.imageEditorState).isNotNull() + } + } + + @Test + fun `present - image can still be edited when editor can decode it despite generic media info`() = runTest { + val localMedia = aLocalMedia( + uri = mockMediaUrl, + mediaInfo = anImageMediaInfo().copy( + mimeType = MimeTypes.OctetStream, + filename = "", + fileExtension = "", + ), + ) + val presenter = createAttachmentsPreviewPresenter( + localMedia = localMedia, + attachmentImageEditor = FakeAttachmentImageEditor( + canEditResult = true, + ) { + Result.success( + EditedLocalMedia( + localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/decoded.jpeg")), + file = File("/tmp/decoded.jpeg"), + ) + ) + } + ) + + presenter.test { + val initialState = consumeItemsUntilPredicate { it.canEditImage }.last() + assertThat(initialState.canEditImage).isTrue() + + initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) + val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() + assertThat(editorState.imageEditorState).isNotNull() + } + } + @Test fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest { val mediaPreProcessor = FakeMediaPreProcessor() @@ -652,6 +860,14 @@ class AttachmentsPreviewPresenterTest { } ), mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), + attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor { + Result.success( + EditedLocalMedia( + localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")), + file = File("/tmp/default-edited.jpeg"), + ) + ) + }, videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( @@ -669,6 +885,7 @@ class AttachmentsPreviewPresenterTest { }, permalinkBuilder = permalinkBuilder, temporaryUriDeleter = temporaryUriDeleter, + attachmentImageEditor = attachmentImageEditor, sessionCoroutineScope = this, dispatchers = testCoroutineDispatchers(), mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, @@ -679,6 +896,22 @@ class AttachmentsPreviewPresenterTest { ) } + private class FakeAttachmentImageEditor( + private val canEditResult: Boolean = true, + private val result: () -> Result, + ) : AttachmentImageEditor { + override suspend fun canEdit(localMedia: LocalMedia): Boolean { + return canEditResult + } + + override suspend fun exportEdits( + localMedia: LocalMedia, + edits: AttachmentImageEdits, + ): Result { + return result() + } + } + private val mediaUploadInfo = MediaUploadInfo.AnyFile( File("test"), FileInfo( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt new file mode 100644 index 00000000000..43c893dd051 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt @@ -0,0 +1,45 @@ +/* + * 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.imageeditor + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class AttachmentImageEditsTest { + @Test + fun `rotate normalizes after a full turn`() { + var edits = AttachmentImageEdits() + repeat(4) { + edits = edits.rotateAntiClockwise() + } + assertThat(edits.normalizedRotationQuarterTurns).isEqualTo(0) + assertThat(edits.rotationDegrees).isEqualTo(0) + assertThat(edits.hasChanges).isFalse() + } + + @Test + fun `rotate updates rotation and crop`() { + val sut = AttachmentImageEdits( + cropRect = NormalizedCropRect( + left = 0.2f, + top = 0.3f, + right = 0.8f, + bottom = 0.9f, + ), + rotationQuarterTurns = 0, + ) + val result = sut.rotateAntiClockwise() + assertThat(result.normalizedRotationQuarterTurns).isEqualTo(3) + assertThat(result.rotationDegrees).isEqualTo(270) + assertThat(result.cropRect.left).isWithin(0.0001f).of(0.3f) + assertThat(result.cropRect.top).isWithin(0.0001f).of(0.2f) + assertThat(result.cropRect.right).isWithin(0.0001f).of(0.9f) + assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.8f) + assertThat(result.hasChanges).isTrue() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/DefaultAttachmentImageEditorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/DefaultAttachmentImageEditorTest.kt new file mode 100644 index 00000000000..f9db4f35f97 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/DefaultAttachmentImageEditorTest.kt @@ -0,0 +1,24 @@ +/* + * 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.imageeditor + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.core.mimetype.MimeTypes +import org.junit.Test + +class DefaultAttachmentImageEditorTest { + @Test + fun `exported mime type preserves png`() { + assertThat(exportedMimeTypeFor(MimeTypes.Png)).isEqualTo(MimeTypes.Png) + } + + @Test + fun `exported mime type normalizes non-png images to jpeg`() { + assertThat(exportedMimeTypeFor("image/heic")).isEqualTo(MimeTypes.Jpeg) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/NormalizedCropRectTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/NormalizedCropRectTest.kt new file mode 100644 index 00000000000..c70c6169e16 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/NormalizedCropRectTest.kt @@ -0,0 +1,180 @@ +/* + * 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.imageeditor + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class NormalizedCropRectTest { + private val rect = NormalizedCropRect( + left = 0.1f, + top = 0.2f, + right = 0.7f, + bottom = 0.8f, + ) + + @Test + fun `applyChange with top handle only updates the top edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Edge.Top, + deltaX = 0.3f, + deltaY = 0.1f, + ) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = 0.3f, + right = rect.right, + bottom = rect.bottom, + ) + ) + } + + @Test + fun `applyChange with left handle only updates the left edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Edge.Left, + deltaX = 0.1f, + deltaY = 0.3f, + ) + result.assertIsSimilarTo( + NormalizedCropRect( + left = 0.2f, + top = rect.top, + right = rect.right, + bottom = rect.bottom, + ) + ) + } + + @Test + fun `applyChange with right handle only updates the right edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Edge.Right, + deltaX = -0.1f, + deltaY = 0.3f, + ) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = rect.top, + right = 0.6f, + bottom = rect.bottom, + ) + ) + } + + @Test + fun `applyChange with bottom handle target only updates the bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Edge.Bottom, + deltaX = -0.1f, + deltaY = -0.3f, + ) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = rect.top, + right = rect.right, + bottom = 0.5f, + ) + ) + } + + @Test + fun `applyChange with top left handle updates the top and left bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Corner.TopLeft, + deltaX = 0.1f, + deltaY = 0.1f, + ) + result.assertIsSimilarTo( + NormalizedCropRect( + left = 0.2f, + top = 0.3f, + right = rect.right, + bottom = rect.bottom, + ) + ) + } + + @Test + fun `applyChange with top right handle updates the top and right bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Corner.TopRight, + deltaX = -0.1f, + deltaY = 0.1f, + ) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = 0.3f, + right = 0.6f, + bottom = rect.bottom, + ) + ) + } + + @Test + fun `applyChange with bottom left handle updates the bottom and left bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Corner.BottomLeft, + deltaX = 0.1f, + deltaY = -0.1f, + ) + result.assertIsSimilarTo( + NormalizedCropRect( + left = 0.2f, + top = rect.top, + right = rect.right, + bottom = 0.7f, + ) + ) + } + + @Test + fun `applyChange with bottom right handle updates the bottom and right bottem edge`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Corner.BottomRight, + deltaX = -0.1f, + deltaY = -0.1f, + ) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = rect.top, + right = 0.6f, + bottom = 0.7f, + ) + ) + } + + @Test + fun `translate keeps the crop rect inside bounds`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Move, + deltaX = 0.6f, + deltaY = 0.6f, + ) + result.assertIsSimilarTo( + NormalizedCropRect( + left = 0.4f, + top = 0.4f, + right = 1.0f, + bottom = 1.0f, + ) + ) + } +} + +internal fun NormalizedCropRect.assertIsSimilarTo(expectedResult: NormalizedCropRect) { + assertThat(left).isWithin(0.0001f).of(expectedResult.left) + assertThat(top).isWithin(0.0001f).of(expectedResult.top) + assertThat(right).isWithin(0.0001f).of(expectedResult.right) + assertThat(bottom).isWithin(0.0001f).of(expectedResult.bottom) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt index 83e8a17cf45..09164e36d11 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt @@ -9,7 +9,9 @@ package io.element.android.libraries.mediaviewer.impl.local import android.content.Context +import android.graphics.BitmapFactory import android.net.Uri +import android.webkit.MimeTypeMap import androidx.core.net.toUri import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -17,6 +19,7 @@ import io.element.android.libraries.androidutils.file.getFileName import io.element.android.libraries.androidutils.file.getFileSize import io.element.android.libraries.androidutils.file.getMimeType import io.element.android.libraries.androidutils.filesize.FileSizeFormatter +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.core.UserId @@ -85,8 +88,12 @@ class AndroidLocalMediaFactory( waveform: List?, duration: String?, ): LocalMedia { - val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream val fileName = name ?: context.getFileName(uri) ?: "" + val resolvedMimeType = resolveMimeType( + uri = uri, + mimeType = mimeType, + fileName = fileName, + ) val fileSize = context.getFileSize(uri) val calculatedFormattedFileSize = formattedFileSize ?: fileSizeFormatter.format(fileSize) val fileExtension = fileExtensionExtractor.extractFromName(fileName) @@ -110,4 +117,36 @@ class AndroidLocalMediaFactory( ) ) } + + private fun resolveMimeType( + uri: Uri, + mimeType: String?, + fileName: String, + ): String { + val explicitMimeType = mimeType.takeUnless { it.isNullOrBlank() || it == MimeTypes.OctetStream } + if (explicitMimeType != null) return explicitMimeType + + val resolverMimeType = context.getMimeType(uri).takeUnless { it.isNullOrBlank() || it == MimeTypes.OctetStream } + if (resolverMimeType != null) return resolverMimeType + + val decodedImageMimeType = decodeImageMimeType(uri) + if (decodedImageMimeType != null) return decodedImageMimeType + + val extensionMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension( + fileExtensionExtractor.extractFromName(fileName) + ) + if (!extensionMimeType.isNullOrBlank()) return extensionMimeType + + return MimeTypes.OctetStream + } + + private fun decodeImageMimeType(uri: Uri): String? { + return tryOrNull { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(inputStream, null, options) + options.outMimeType + } + } + } } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt index f01ac1d7499..26f9087b390 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt @@ -8,7 +8,9 @@ package io.element.android.libraries.mediaviewer.impl.local +import android.graphics.Bitmap import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.androidutils.file.getMimeType import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MediaFile @@ -22,9 +24,13 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import java.io.File +import java.io.FileOutputStream @RunWith(RobolectricTestRunner::class) class AndroidLocalMediaFactoryTest { + private val context = RuntimeEnvironment.getApplication() + @Test fun `test AndroidLocalMediaFactory`() { val sut = createAndroidLocalMediaFactory() @@ -58,13 +64,34 @@ class AndroidLocalMediaFactoryTest { ) } + @Test + fun `createFromUri detects image mime type from content when picker mime type is generic`() { + val imageFile = File(context.cacheDir, "picked-media").apply { + FileOutputStream(this).use { outputStream -> + Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + .compress(Bitmap.CompressFormat.PNG, 100, outputStream) + } + } + + val result = createAndroidLocalMediaFactory().createFromUri( + uri = imageFile.toURI().toString().let(android.net.Uri::parse), + mimeType = MimeTypes.OctetStream, + name = imageFile.name, + formattedFileSize = null, + ) + + assertThat(context.getMimeType(result.uri)).isNull() + assertThat(result.info.mimeType).isEqualTo(MimeTypes.Png) + assertThat(result.info.fileExtension).isEmpty() + } + private fun aMediaFile(): MediaFile { return FakeMediaFile("aPath") } private fun createAndroidLocalMediaFactory(): AndroidLocalMediaFactory { return AndroidLocalMediaFactory( - RuntimeEnvironment.getApplication(), + context, FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation() ) diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index e89d45b2286..5608486d0ea 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -18,6 +18,7 @@ "Info" "Join call" "Jump to bottom" + "Jump to unread" "Move the map to my location" "Mentions only" "Muted" diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_0_en.png new file mode 100644 index 00000000000..3bd0c2ba54a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51f0b3f7e4bb16728f21055de37b7b2780fd2a1fc65b6bd4564334daeab20763 +size 329042 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_1_en.png new file mode 100644 index 00000000000..bf0a11f093b --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5febc6580e4f0bda75a27445157ace7f1acb620c17cba55ca5d2a9330743c1de +size 283397 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png new file mode 100644 index 00000000000..2ae328d7eb1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34b6dfe4e65612615c3dc87e5f65bd0b160d97527c4a4749b496bf8d48819d96 +size 256641 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_3_en.png new file mode 100644 index 00000000000..553c54a63db --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d99c470fd5134e0a84b284ed32f4c93de01561e630ef32d7c22a4b476bb871b1 +size 277852 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_4_en.png new file mode 100644 index 00000000000..6c3a6f4bf6e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b15a04a861812e3e4dee0a6a30c6afef4ab171c5261d1e5bd5a234bb7296d97 +size 251908 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_5_en.png new file mode 100644 index 00000000000..530c5e5bb66 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c32b4744750b9f18612bded2e292dde151e2bfdd8a69a36c88044f5ca3a76f8e +size 311315 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_0_en.png index 586bf842749..ccca24ef6ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22e3d682c4866bd5c519ab88d08290708929af805438a0bd093200cddcbd41b2 -size 399376 +oid sha256:a14113653096095323ecb94cb3dd694985717b8585c8cfc6b7a45cf7a2483a79 +size 402427 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_1_en.png index 27d8600ab52..c511e0e162f 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:02bb9e9de3b0ef480cedbed50483bdd3a899497ecc40ead72491106c6f6b6611 -size 399030 +oid sha256:95c64e1e7055dd048b88f0e19a57fe696ee93505d74f96bc4f1915fcce769d7d +size 402072 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_2_en.png index f1cde998deb..f229a2a82eb 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:947ccb947f4a961ff7d17b936f2c866d66fea0361879d51ad4c65d18465c1a9f -size 59226 +oid sha256:4ee2ee54c694a45316bbdcfba7038391fe808f5292356748e9e66f46135a9ae8 +size 61457 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_3_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_3_en.png index 586bf842749..ccca24ef6ae 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22e3d682c4866bd5c519ab88d08290708929af805438a0bd093200cddcbd41b2 -size 399376 +oid sha256:a14113653096095323ecb94cb3dd694985717b8585c8cfc6b7a45cf7a2483a79 +size 402427 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_4_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_4_en.png index dd5a1e333c7..09e08632b9b 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d9e221ec2f4ee764967e94c32f52b1615b25dec8fc7697dd5bcd01fc4da8d69 -size 59098 +oid sha256:44c7b0e86781ff6112c3be5b575b548c5ae6e9530f6f27a070ebb008d606a20c +size 61326 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_5_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_5_en.png index d695d41c94a..f8fbf8c1a11 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:547bf4e3bec05c219f5a72cfd8d506eb7c39429970cced5c2b8f2999ae390265 -size 86149 +oid sha256:ccedfe061af8a77da7ddf6b20d23899bfff986fd6c74b87480b588e148ddd8de +size 89013 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_6_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_6_en.png index 4a6e1cd8284..fbff934ed8a 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d326595038160376db620a07a180de7af37ebfc76d4927ed1176ef6f4370aab -size 72700 +oid sha256:f52a267ee2aa300185191aa3e610787c58d7222bc8e4a7570879d4c5fd37133d +size 328936 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_7_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_7_en.png index fa4beb6dfb8..5f90adbd8a4 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8e8bcb6fdffb2d8673e4ee4e16e21672ffe99e717c48fd35b8411a8aa0530e9 -size 405064 +oid sha256:1feeb32f9dce486c54db41727d9d9801f606353008945988331c2ce391dec451 +size 75364 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_8_en.png index d789e92297c..d7cdc540f71 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3caecb171d3095ef5c3593c6289b2d99e6cd6a6c635d822ad24061e502bdeb2e -size 82790 +oid sha256:2bfda29fcaade9508e0d742b0de7ac230d41a358e98755e4b08f1ed7d5affe30 +size 408106 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_9_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_9_en.png new file mode 100644 index 00000000000..f06abb6c7b7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_9_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fdc4e2d82297927d2062586398fda3f3b7f6febfde8888a062229caf713ff54 +size 85154 diff --git a/tools/localazy/config.json b/tools/localazy/config.json index cb9c2ecb0d4..9936341cee9 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -273,6 +273,7 @@ "screen_room_timeline.*", "screen\\.room_timeline.*", "screen_room_typing.*", + "screen\\.image_edition\\..*", "screen\\.media_upload.*" ] },