From bcad1f9dce2bebd9ae76a03ddf9d98309880b410 Mon Sep 17 00:00:00 2001 From: Gianluca Iavicoli Date: Thu, 21 May 2026 15:08:26 +0200 Subject: [PATCH 01/12] Add crop and rotate editing before sending images (#6363) * feat(messages): add crop and rotate before image upload * Update screenshots * chore: trigger CI after screenshot update * fix: resolve detekt violations in image editor and media viewer modules * fix: require explicit edits param, use plurals for rotation a11y, remove redundant @Inject * fix: require explicit edits param, use plurals for rotation a11y, remove redundant @Inject * fix: use semantically correct RotateRight icon for image rotation action * Update screenshots * chore: trigger CI after screenshot update --------- Co-authored-by: ElementBot Co-authored-by: Benoit Marty --- features/messages/impl/build.gradle.kts | 1 + .../preview/AttachmentMediaInfo.kt | 44 ++ .../preview/AttachmentsPreviewEvent.kt | 8 + .../preview/AttachmentsPreviewPresenter.kt | 151 ++++- .../preview/AttachmentsPreviewState.kt | 5 + .../AttachmentsPreviewStateProvider.kt | 10 + .../preview/AttachmentsPreviewView.kt | 121 +++- .../imageeditor/AttachmentImageEditModels.kt | 126 +++++ .../imageeditor/AttachmentImageEditor.kt | 191 +++++++ .../imageeditor/AttachmentImageEditorView.kt | 522 ++++++++++++++++++ .../impl/src/main/res/values/temporary.xml | 8 + .../AttachmentsPreviewPresenterTest.kt | 225 ++++++++ .../AttachmentImageEditModelsTest.kt | 78 +++ .../impl/local/AndroidLocalMediaFactory.kt | 41 +- .../local/AndroidLocalMediaFactoryTest.kt | 29 +- ...tor_AttachmentImageEditorView_Day_0_en.png | 3 + ...r_AttachmentImageEditorView_Night_0_en.png | 3 + 17 files changed, 1517 insertions(+), 49 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentMediaInfo.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt create mode 100644 features/messages/impl/src/main/res/values/temporary.xml create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Night_0_en.png diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 6ff7f7e322..2661f7e330 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 0000000000..7feeff18dd --- /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 d473d4c3f4..1957a8b0f7 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,16 @@ 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 RotateImage : AttachmentsPreviewEvent + data object ApplyImageEdits : 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 fc7f3034a6..b5cb13e3e3 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,17 @@ 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) { + 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 +146,7 @@ class AttachmentsPreviewPresenter( mediaOptimizationSelectorState = mediaOptimizationSelectorState, ) ?: return@LaunchedEffect preprocessMediaJob = coroutineScope.preProcessAttachment( - attachment = attachment, + attachment = currentAttachment, mediaOptimizationConfig = config, displayProgress = false, sendActionState = sendActionState, @@ -135,10 +154,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 +192,7 @@ class AttachmentsPreviewPresenter( videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, ) preprocessMediaJob = preProcessAttachment( - attachment = attachment, + attachment = currentAttachment, mediaOptimizationConfig = config, displayProgress = true, sendActionState = sendActionState, @@ -188,6 +211,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 +221,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 +266,82 @@ 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, + ) + } + } + AttachmentsPreviewEvent.CloseImageEditor -> { + imageEditorState = null + } + is AttachmentsPreviewEvent.UpdateImageCropRect -> { + val pendingState = imageEditorState ?: return + imageEditorState = pendingState.copy( + edits = pendingState.edits.copy(cropRect = event.cropRect) + ) + } + AttachmentsPreviewEvent.RotateImage -> { + val pendingState = imageEditorState ?: return + imageEditorState = pendingState.copy( + edits = pendingState.edits.rotateClockwise() + ) + } + 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 +418,8 @@ class AttachmentsPreviewPresenter( } private fun dismiss( - attachment: Attachment, sendActionState: MutableState, + editedTempFile: File?, ) { // Delete the temporary file when (attachment) { @@ -330,6 +430,7 @@ class AttachmentsPreviewPresenter( } } } + editedTempFile?.safeDelete() // Reset the sendActionState to ensure that dialog is closed before the screen sendActionState.value = SendActionState.Done onDoneListener() @@ -343,6 +444,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 97ca230d77..463479fe55 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 70d7ab006e..ced90550c3 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.AttachmentImageEdits 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 +102,75 @@ fun AttachmentsPreviewView( state.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState) } - BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) { - postCancel() + fun postOpenImageEditor() { + state.eventSink(AttachmentsPreviewEvent.OpenImageEditor) } - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - navigationIcon = { - BackButton( - imageVector = CompoundIcons.Close(), - onClick = ::postCancel, - ) - }, - title = {}, + fun postCloseImageEditor() { + state.eventSink(AttachmentsPreviewEvent.CloseImageEditor) + } + + fun postApplyImageEdits() { + state.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) + } + + BackHandler(enabled = state.sendActionState !is SendActionState.Sending.Uploading && state.sendActionState !is SendActionState.Done) { + if (state.imageEditorState != null) { + postCloseImageEditor() + } else { + postCancel() + } + } + + if (state.imageEditorState != null) { + AttachmentImageEditorView( + state = state.imageEditorState, + onCropRectChange = { cropRect -> + state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) + }, + onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImage) }, + onCancelClick = ::postCloseImageEditor, + onDoneClick = ::postApplyImageEdits, + modifier = modifier, + ) + } else { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + BackButton( + imageVector = CompoundIcons.Close(), + onClick = ::postCancel, + ) + }, + title = {}, + actions = { + if (state.canEditImage && canShowEditAction) { + IconButton(onClick = ::postOpenImageEditor) { + Icon( + imageVector = CompoundIcons.Edit(), + contentDescription = stringResource(CommonStrings.action_edit), + ) + } + } + } + ) + } + ) { 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,10 +179,29 @@ fun AttachmentsPreviewView( @Composable private fun AttachmentSendStateView( sendActionState: SendActionState, + isApplyingImageEdits: Boolean, + displayImageEditError: Boolean, + onDismissImageEditError: () -> Unit, onDismissClick: () -> Unit, onRetryClick: () -> Unit ) { - when (sendActionState) { + 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( @@ -158,6 +228,7 @@ private fun AttachmentSendStateView( ) } else -> Unit + } } } @@ -184,10 +255,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) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt new file mode 100644 index 0000000000..90872df4f2 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt @@ -0,0 +1,126 @@ +/* + * 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.runtime.Immutable +import io.element.android.libraries.mediaviewer.api.local.LocalMedia + +private const val DEFAULT_CROP_MARGIN = 0.1f +private const val MIN_CROP_SIZE = 0.1f + +@Immutable +data class AttachmentImageEditorState( + val localMedia: LocalMedia, + val edits: AttachmentImageEdits, +) + +@Immutable +data class AttachmentImageEdits( + val cropRect: NormalizedCropRect = NormalizedCropRect.default(), + val rotationQuarterTurns: Int = 0, +) { + val normalizedRotationQuarterTurns: Int + get() = (rotationQuarterTurns % 4 + 4) % 4 + + val rotationDegrees: Int + get() = normalizedRotationQuarterTurns * 90 + + val hasChanges: Boolean + get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0 + + fun rotateClockwise(): AttachmentImageEdits { + return copy(rotationQuarterTurns = (normalizedRotationQuarterTurns + 1) % 4) + } +} + +@Immutable +data class NormalizedCropRect( + val left: Float, + val top: Float, + val right: Float, + 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 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, + ) + } + + fun resize(dragTarget: CropDragTarget, deltaX: Float, deltaY: Float): NormalizedCropRect = when (dragTarget) { + CropDragTarget.Move -> translate(deltaX, deltaY) + CropDragTarget.TopLeft -> copy( + left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), + top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), + ) + CropDragTarget.Top -> copy( + top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), + ) + CropDragTarget.TopRight -> copy( + right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), + top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), + ) + CropDragTarget.Right -> copy( + right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), + ) + CropDragTarget.BottomRight -> copy( + right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), + bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), + ) + CropDragTarget.Bottom -> copy( + bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), + ) + CropDragTarget.BottomLeft -> copy( + left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), + bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), + ) + CropDragTarget.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, + ) + } +} + +enum class CropDragTarget { + Move, + TopLeft, + Top, + TopRight, + Right, + BottomRight, + Bottom, + BottomLeft, + Left, +} 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 0000000000..f003c19084 --- /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) { + "image/png" -> Bitmap.CompressFormat.PNG + else -> Bitmap.CompressFormat.JPEG +} + +private fun compressFileExtension(mimeType: String) = when (mimeType) { + "image/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/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 0000000000..bf3958e83f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorView.kt @@ -0,0 +1,522 @@ +/* + * 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.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.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.Size +import androidx.compose.ui.graphics.Color +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.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.stateDescription +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +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.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +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.mediaviewer.api.anImageMediaInfo +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.ui.strings.CommonStrings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AttachmentImageEditorView( + state: AttachmentImageEditorState, + onCropRectChange: (NormalizedCropRect) -> Unit, + onRotateClick: () -> Unit, + onCancelClick: () -> Unit, + onDoneClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val rotateContentDescription = stringResource(R.string.screen_media_upload_preview_rotate) + val rotationStateDescription = pluralStringResource( + R.plurals.a11y_media_upload_preview_rotation_degrees, + state.edits.rotationDegrees, + state.edits.rotationDegrees, + ) + val rotateButtonBackground = ElementTheme.colors.bgSubtlePrimary + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + navigationIcon = { + BackButton( + imageVector = CompoundIcons.Close(), + onClick = onCancelClick, + ) + }, + 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, + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(132.dp) + .background(ElementTheme.colors.bgCanvasDefault) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { + TextButton( + text = stringResource(CommonStrings.action_cancel), + onClick = onCancelClick, + ) + } + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center, + ) { + IconButton( + onClick = onRotateClick, + modifier = Modifier + .size(72.dp) + .background( + color = rotateButtonBackground, + shape = CircleShape, + ) + .clearAndSetSemantics { + contentDescription = rotateContentDescription + stateDescription = rotationStateDescription + } + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Spacer(modifier = Modifier.height(2.dp)) + Icon( + modifier = Modifier + .size(22.dp), + imageVector = CompoundIcons.RotateRight(), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = "${state.edits.rotationDegrees}°", + style = ElementTheme.typography.fontBodyXsMedium, + color = ElementTheme.colors.textSecondary, + ) + } + } + } + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterEnd, + ) { + TextButton( + text = stringResource(CommonStrings.action_done), + onClick = onDoneClick, + ) + } + } + } + } + } +} + +@Composable +private fun CropEditorCanvas( + state: AttachmentImageEditorState, + onCropRectChange: (NormalizedCropRect) -> Unit, +) { + var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) } + val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + ) { + 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), + 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, + ) + } + } + ) + } + + CropOverlay( + cropRect = state.edits.cropRect, + onCropRectChange = onCropRectChange, + ) + } + } +} + +@Composable +private fun CropOverlay( + cropRect: NormalizedCropRect, + onCropRectChange: (NormalizedCropRect) -> Unit, +) { + var dragTarget by remember { mutableStateOf(null) } + val latestCropRect by rememberUpdatedState(cropRect) + val borderColor = ElementTheme.colors.textPrimary + val guideColor = ElementTheme.colors.textSecondary + + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { offset -> + dragTarget = detectDragTarget( + touchPoint = offset, + cropRect = latestCropRect, + canvasSize = Size(size.width.toFloat(), size.height.toFloat()), + handleTouchRadius = 32.dp.toPx(), + ) + }, + onDragCancel = { + dragTarget = null + }, + onDragEnd = { + dragTarget = null + }, + ) { change, dragAmount -> + val activeTarget = dragTarget ?: return@detectDragGestures + change.consume() + onCropRectChange( + latestCropRect.resize( + dragTarget = activeTarget, + deltaX = dragAmount.x / size.width.toFloat(), + deltaY = dragAmount.y / size.height.toFloat(), + ) + ) + } + } + ) { + 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) + + drawRect( + color = overlayColor, + topLeft = Offset.Zero, + size = Size(width = size.width, height = cropTop), + ) + drawRect( + color = overlayColor, + topLeft = Offset(0f, cropTop), + size = Size(width = cropLeft, height = cropBottom - cropTop), + ) + drawRect( + color = overlayColor, + topLeft = Offset(cropRight, cropTop), + size = Size(width = size.width - cropRight, height = cropBottom - cropTop), + ) + drawRect( + color = overlayColor, + topLeft = Offset(0f, cropBottom), + size = Size(width = size.width, height = size.height - cropBottom), + ) + + drawRect( + color = borderColor, + topLeft = Offset(cropLeft, cropTop), + size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop), + style = Stroke(width = 2.dp.toPx()), + ) + + val thirdWidth = (cropRight - cropLeft) / 3f + val thirdHeight = (cropBottom - cropTop) / 3f + repeat(2) { index -> + val offsetX = cropLeft + thirdWidth * (index + 1) + val offsetY = cropTop + thirdHeight * (index + 1) + drawLine( + color = guideColor, + start = Offset(offsetX, cropTop), + end = Offset(offsetX, cropBottom), + strokeWidth = 1.dp.toPx(), + ) + drawLine( + color = guideColor, + start = Offset(cropLeft, offsetY), + end = Offset(cropRight, offsetY), + strokeWidth = 1.dp.toPx(), + ) + } + + val handleLength = 16.dp.toPx() + val handleColor = borderColor + drawCornerHandle(cropLeft, cropTop, handleLength, handleColor, true, true) + drawCornerHandle(cropRight, cropTop, handleLength, handleColor, false, true) + drawCornerHandle(cropLeft, cropBottom, handleLength, handleColor, true, false) + drawCornerHandle(cropRight, cropBottom, handleLength, handleColor, false, false) + drawEdgeHandle( + center = Offset((cropLeft + cropRight) / 2f, cropTop), + horizontal = true, + handleLength = handleLength, + color = handleColor, + ) + drawEdgeHandle( + center = Offset(cropRight, (cropTop + cropBottom) / 2f), + horizontal = false, + handleLength = handleLength, + color = handleColor, + ) + drawEdgeHandle( + center = Offset((cropLeft + cropRight) / 2f, cropBottom), + horizontal = true, + handleLength = handleLength, + color = handleColor, + ) + drawEdgeHandle( + center = Offset(cropLeft, (cropTop + cropBottom) / 2f), + horizontal = false, + handleLength = handleLength, + color = handleColor, + ) + } +} + +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, + cropRect: NormalizedCropRect, + canvasSize: Size, + handleTouchRadius: Float, +): CropDragTarget? { + val corners = mapOf( + CropDragTarget.TopLeft to Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height), + CropDragTarget.Top to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height), + CropDragTarget.TopRight to Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height), + CropDragTarget.Right to Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), + CropDragTarget.BottomRight to Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height), + CropDragTarget.Bottom to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height), + CropDragTarget.BottomLeft to Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height), + CropDragTarget.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), + ) + corners.forEach { (target, corner) -> + if ((corner - touchPoint).getDistance() <= handleTouchRadius) { + return target + } + } + val cropLeft = cropRect.left * canvasSize.width + val cropTop = cropRect.top * canvasSize.height + val cropRight = cropRect.right * canvasSize.width + val cropBottom = cropRect.bottom * canvasSize.height + return if (touchPoint.x in cropLeft..cropRight && touchPoint.y in cropTop..cropBottom) { + CropDragTarget.Move + } else { + null + } +} + +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCornerHandle( + x: Float, + y: Float, + handleLength: Float, + color: Color, + isLeft: Boolean, + isTop: Boolean, +) { + val horizontalEndX = if (isLeft) x + handleLength else x - handleLength + val verticalEndY = if (isTop) y + handleLength else y - handleLength + drawLine( + color = color, + start = Offset(x, y), + end = Offset(horizontalEndX, y), + strokeWidth = 3.dp.toPx(), + ) + drawLine( + color = color, + start = Offset(x, y), + end = Offset(x, verticalEndY), + strokeWidth = 3.dp.toPx(), + ) +} + +private fun androidx.compose.ui.graphics.drawscope.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 = 3.dp.toPx(), + ) +} + +@PreviewsDayNight +@Composable +internal fun AttachmentImageEditorViewPreview() = ElementPreview { + AttachmentImageEditorView( + state = AttachmentImageEditorState( + localMedia = LocalMedia( + uri = "file://preview-image".toUri(), + info = anImageMediaInfo(), + ), + edits = AttachmentImageEdits(), + ), + onCropRectChange = {}, + onRotateClick = {}, + onCancelClick = {}, + onDoneClick = {}, + ) +} diff --git a/features/messages/impl/src/main/res/values/temporary.xml b/features/messages/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..f0050224a9 --- /dev/null +++ b/features/messages/impl/src/main/res/values/temporary.xml @@ -0,0 +1,8 @@ + + + Rotate + + %1$d degree + %1$d degrees + + 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 9a9dc08834..5cbfd331c1 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,16 @@ 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.video.MediaOptimizationSelectorState import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation @@ -73,6 +78,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 +557,85 @@ 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.RotateImage) + val rotatedState = awaitItem() + assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(1) + + 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.RotateImage) + 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) + assertThat(reopenedState.imageEditorState?.edits?.cropRect).isEqualTo(cropRect) + assertThat(reopenedState.imageEditorState?.edits?.rotationDegrees).isEqualTo(90) + } + } + 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 +666,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 +852,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 +877,7 @@ class AttachmentsPreviewPresenterTest { }, permalinkBuilder = permalinkBuilder, temporaryUriDeleter = temporaryUriDeleter, + attachmentImageEditor = attachmentImageEditor, sessionCoroutineScope = this, dispatchers = testCoroutineDispatchers(), mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, @@ -679,6 +888,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/AttachmentImageEditModelsTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt new file mode 100644 index 0000000000..fa9c6367f8 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt @@ -0,0 +1,78 @@ +/* + * 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 AttachmentImageEditModelsTest { + @Test + fun `resize with top handle only updates the top edge`() { + val rect = NormalizedCropRect( + left = 0.2f, + top = 0.2f, + right = 0.8f, + bottom = 0.8f, + ) + + val resized = rect.resize( + dragTarget = CropDragTarget.Top, + deltaX = 0.3f, + deltaY = 0.1f, + ) + + assertThat(resized.left).isEqualTo(rect.left) + assertThat(resized.right).isEqualTo(rect.right) + assertThat(resized.bottom).isEqualTo(rect.bottom) + assertThat(resized.top).isEqualTo(0.3f) + } + + @Test + fun `translate keeps the crop rect inside bounds`() { + val rect = NormalizedCropRect( + left = 0.2f, + top = 0.2f, + right = 0.8f, + bottom = 0.8f, + ) + + val translated = rect.translate( + deltaX = 0.6f, + deltaY = 0.6f, + ) + + assertThat(translated.left).isWithin(0.0001f).of(0.4f) + assertThat(translated.top).isWithin(0.0001f).of(0.4f) + assertThat(translated.right).isWithin(0.0001f).of(1.0f) + assertThat(translated.bottom).isWithin(0.0001f).of(1.0f) + } + + @Test + fun `rotate clockwise normalizes after a full turn`() { + var edits = AttachmentImageEdits() + + repeat(4) { + edits = edits.rotateClockwise() + } + + assertThat(edits.normalizedRotationQuarterTurns).isEqualTo(0) + assertThat(edits.rotationDegrees).isEqualTo(0) + assertThat(edits.hasChanges).isFalse() + } + + @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/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 83e8a17cf4..09164e36d1 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 f01ac1d749..26f9087b39 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/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Day_0_en.png new file mode 100644 index 0000000000..37cc9056e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:490534398a7061e52f66e3ec46d6212107ee2512ff91c768d6618adb24e858f5 +size 376383 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Night_0_en.png new file mode 100644 index 0000000000..cb129ba75a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6e02017a71217184dc12c4b24b68e344fd20ca374e90e6073506170dd103e16b +size 375365 From bb2779549e6f94689f9e71be66b835d1616d4e7b Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 21 May 2026 18:04:49 +0200 Subject: [PATCH 02/12] Update design and UX Update and add tests Improve preview --- .../preview/AttachmentsPreviewEvent.kt | 3 +- .../preview/AttachmentsPreviewPresenter.kt | 9 +- .../preview/AttachmentsPreviewView.kt | 86 +++-- .../imageeditor/AttachmentImageEditModels.kt | 85 +++-- .../imageeditor/AttachmentImageEditor.kt | 4 +- .../AttachmentImageEditorStateProvider.kt | 61 ++++ .../imageeditor/AttachmentImageEditorView.kt | 323 ++++++++++-------- .../impl/src/main/res/values/localazy.xml | 7 + .../impl/src/main/res/values/temporary.xml | 8 - .../AttachmentsPreviewPresenterTest.kt | 4 +- .../AttachmentImageEditModelsTest.kt | 78 ----- .../imageeditor/AttachmentImageEditsTest.kt | 45 +++ .../DefaultAttachmentImageEditorTest.kt | 24 ++ .../imageeditor/NormalizedCropRectTest.kt | 137 ++++++++ .../src/main/res/values/localazy.xml | 1 + tools/localazy/config.json | 1 + 16 files changed, 575 insertions(+), 301 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt delete mode 100644 features/messages/impl/src/main/res/values/temporary.xml delete mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/DefaultAttachmentImageEditorTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/NormalizedCropRectTest.kt 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 1957a8b0f7..a7c64845d1 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 @@ -16,8 +16,9 @@ sealed interface AttachmentsPreviewEvent { data object CancelAndClearSendState : AttachmentsPreviewEvent data object OpenImageEditor : AttachmentsPreviewEvent data object CloseImageEditor : AttachmentsPreviewEvent - data object RotateImage : 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 b5cb13e3e3..7e8bfbdc3f 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 @@ -287,10 +287,15 @@ class AttachmentsPreviewPresenter( edits = pendingState.edits.copy(cropRect = event.cropRect) ) } - AttachmentsPreviewEvent.RotateImage -> { + AttachmentsPreviewEvent.RotateImageToTheLeft -> { val pendingState = imageEditorState ?: return imageEditorState = pendingState.copy( - edits = pendingState.edits.rotateClockwise() + edits = pendingState.edits.rotateAntiClockwise() + ) + } + AttachmentsPreviewEvent.ResetImageEdits -> { + imageEditorState = imageEditorState?.copy( + edits = AttachmentImageEdits() ) } AttachmentsPreviewEvent.ApplyImageEdits -> { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 2c51fa4499..1714a8b8dc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -31,11 +31,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp 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.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError @@ -56,12 +57,11 @@ import io.element.android.libraries.designsystem.modifiers.niceClickable import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.PreviewsDayNight -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.ListItem import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Switch 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.mediaviewer.api.local.LocalMedia @@ -76,6 +76,9 @@ import io.element.android.wysiwyg.display.TextDisplay import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +/** + * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3514 + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun AttachmentsPreviewView( @@ -110,6 +113,10 @@ fun AttachmentsPreviewView( state.eventSink(AttachmentsPreviewEvent.CloseImageEditor) } + fun postResetImageEditor() { + state.eventSink(AttachmentsPreviewEvent.ResetImageEdits) + } + fun postApplyImageEdits() { state.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) } @@ -128,8 +135,9 @@ fun AttachmentsPreviewView( onCropRectChange = { cropRect -> state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) }, - onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImage) }, + onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) }, onCancelClick = ::postCloseImageEditor, + onResetClick = ::postResetImageEditor, onDoneClick = ::postApplyImageEdits, modifier = modifier, ) @@ -140,19 +148,23 @@ fun AttachmentsPreviewView( TopAppBar( navigationIcon = { BackButton( - imageVector = CompoundIcons.Close(), onClick = ::postCancel, ) }, - title = {}, + title = { + Text( + modifier = Modifier.semantics { + heading() + }, + text = stringResource(R.string.screen_media_upload_preview_title), + ) + }, actions = { if (state.canEditImage && canShowEditAction) { - IconButton(onClick = ::postOpenImageEditor) { - Icon( - imageVector = CompoundIcons.Edit(), - contentDescription = stringResource(CommonStrings.action_edit), - ) - } + TextButton( + stringResource(CommonStrings.action_edit), + onClick = ::postOpenImageEditor + ) } } ) @@ -202,32 +214,32 @@ private fun AttachmentSendStateView( ) } else -> when (sendActionState) { - is SendActionState.Sending.Processing -> { - if (sendActionState.displayProgress) { + 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.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 + is SendActionState.Failure -> { + RetryDialog( + content = stringResource(sendAttachmentError(sendActionState.error)), + onDismiss = onDismissClick, + onRetry = onRetryClick + ) + } + else -> Unit } } } @@ -291,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)) @@ -300,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, ) @@ -326,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/AttachmentImageEditModels.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt index 90872df4f2..3143f2462e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt @@ -10,7 +10,7 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito import androidx.compose.runtime.Immutable import io.element.android.libraries.mediaviewer.api.local.LocalMedia -private const val DEFAULT_CROP_MARGIN = 0.1f +private const val DEFAULT_CROP_MARGIN = 0f private const val MIN_CROP_SIZE = 0.1f @Immutable @@ -25,7 +25,7 @@ data class AttachmentImageEdits( val rotationQuarterTurns: Int = 0, ) { val normalizedRotationQuarterTurns: Int - get() = (rotationQuarterTurns % 4 + 4) % 4 + get() = rotationQuarterTurns % 4 val rotationDegrees: Int get() = normalizedRotationQuarterTurns * 90 @@ -33,8 +33,17 @@ data class AttachmentImageEdits( val hasChanges: Boolean get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0 - fun rotateClockwise(): AttachmentImageEdits { - return copy(rotationQuarterTurns = (normalizedRotationQuarterTurns + 1) % 4) + 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, + ) + ) } } @@ -60,7 +69,13 @@ data class NormalizedCropRect( val height: Float get() = bottom - top - fun translate(deltaX: Float, deltaY: Float): NormalizedCropRect { + 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( @@ -71,34 +86,36 @@ data class NormalizedCropRect( ) } - fun resize(dragTarget: CropDragTarget, deltaX: Float, deltaY: Float): NormalizedCropRect = when (dragTarget) { - CropDragTarget.Move -> translate(deltaX, deltaY) - CropDragTarget.TopLeft -> copy( + 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.Top -> copy( - top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), - ) - CropDragTarget.TopRight -> copy( + CropDragTarget.Corner.TopRight -> copy( right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), ) - CropDragTarget.Right -> copy( - right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), - ) - CropDragTarget.BottomRight -> copy( + CropDragTarget.Corner.BottomRight -> copy( right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f), bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), ) - CropDragTarget.Bottom -> copy( - bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), - ) - CropDragTarget.BottomLeft -> copy( + CropDragTarget.Corner.BottomLeft -> copy( left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f), ) - CropDragTarget.Left -> copy( + } + + 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), ) } @@ -113,14 +130,20 @@ data class NormalizedCropRect( } } -enum class CropDragTarget { - Move, - TopLeft, - Top, - TopRight, - Right, - BottomRight, - Bottom, - BottomLeft, - Left, +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/AttachmentImageEditor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt index f003c19084..cb66802184 100644 --- 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 @@ -174,12 +174,12 @@ private fun NormalizedCropRect.toPixelRect(imageWidth: Int, imageHeight: Int): P } private fun compressFormat(mimeType: String) = when (mimeType) { - "image/png" -> Bitmap.CompressFormat.PNG + MimeTypes.Png -> Bitmap.CompressFormat.PNG else -> Bitmap.CompressFormat.JPEG } private fun compressFileExtension(mimeType: String) = when (mimeType) { - "image/png" -> "png" + MimeTypes.Png -> "png" else -> "jpeg" } 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 0000000000..d6f4a835bc --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt @@ -0,0 +1,61 @@ +/* + * 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, + ).rotateAntiClockwise(), + ), + ) +} + +private fun anAttachmentImageEditorState( + localMedia: LocalMedia = LocalMedia( + uri = "file://preview-image".toUri(), + info = anImageMediaInfo(), + ), + edits: AttachmentImageEdits = AttachmentImageEdits(), +) = + AttachmentImageEditorState( + localMedia = localMedia, + edits = edits, + ) 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 index bf3958e83f..7f8a42322a 100644 --- 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 @@ -10,20 +10,19 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito 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.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height 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 @@ -37,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset 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 @@ -48,18 +48,20 @@ 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.IntSize import androidx.compose.ui.unit.dp -import androidx.core.net.toUri 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.ElementPreview -import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.preview.ElementPreviewDark 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 @@ -67,27 +69,29 @@ 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.mediaviewer.api.anImageMediaInfo -import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.ui.strings.CommonStrings +/** + * 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_media_upload_preview_rotate) + val rotateContentDescription = stringResource(R.string.screen_image_edition_a11y_rotate_to_the_left) val rotationStateDescription = pluralStringResource( - R.plurals.a11y_media_upload_preview_rotation_degrees, + R.plurals.screen_image_edition_a11y_rotation_state, state.edits.rotationDegrees, state.edits.rotationDegrees, ) - val rotateButtonBackground = ElementTheme.colors.bgSubtlePrimary + val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault Scaffold( modifier = modifier.fillMaxSize(), @@ -99,7 +103,14 @@ fun AttachmentImageEditorView( onClick = onCancelClick, ) }, - title = {}, + title = { + Text( + modifier = Modifier.semantics { + heading() + }, + text = stringResource(R.string.screen_image_edition_title), + ) + }, ) } ) { paddingValues -> @@ -118,79 +129,59 @@ fun AttachmentImageEditorView( onCropRectChange = onCropRectChange, ) } - - Box( + Row( modifier = Modifier - .fillMaxWidth() - .height(132.dp) - .background(ElementTheme.colors.bgCanvasDefault) + .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, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .navigationBarsPadding() - .padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, ) { - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterStart, - ) { - TextButton( - text = stringResource(CommonStrings.action_cancel), - onClick = onCancelClick, - ) - } - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center, - ) { - IconButton( - onClick = onRotateClick, - modifier = Modifier - .size(72.dp) - .background( - color = rotateButtonBackground, - shape = CircleShape, - ) - .clearAndSetSemantics { - contentDescription = rotateContentDescription - stateDescription = rotationStateDescription - } - ) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Spacer(modifier = Modifier.height(2.dp)) - Icon( - modifier = Modifier - .size(22.dp), - imageVector = CompoundIcons.RotateRight(), - contentDescription = null, - ) - Spacer(modifier = Modifier.height(3.dp)) - Text( - text = "${state.edits.rotationDegrees}°", - style = ElementTheme.typography.fontBodyXsMedium, - color = ElementTheme.colors.textSecondary, - ) + 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 } - } - } - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.CenterEnd, ) { - TextButton( - text = stringResource(CommonStrings.action_done), - onClick = onDoneClick, + 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, + ) + } } } } @@ -207,6 +198,7 @@ private fun CropEditorCanvas( BoxWithConstraints( modifier = Modifier .fillMaxSize() + .padding(20.dp), ) { val displayedSize = remember(maxWidth, maxHeight, imageSize, rotationQuarterTurns) { val sourceWidth = imageSize.width.takeIf { it > 0 } ?: 1 @@ -287,9 +279,9 @@ private fun CropOverlay( ) { var dragTarget by remember { mutableStateOf(null) } val latestCropRect by rememberUpdatedState(cropRect) - val borderColor = ElementTheme.colors.textPrimary - val guideColor = ElementTheme.colors.textSecondary - + val borderColor = ElementTheme.colors.iconPrimary + val guideColor = ElementTheme.colors.iconPrimary + val drawGuidelines = dragTarget == CropDragTarget.Move Canvas( modifier = Modifier .fillMaxSize() @@ -313,7 +305,7 @@ private fun CropOverlay( val activeTarget = dragTarget ?: return@detectDragGestures change.consume() onCropRectChange( - latestCropRect.resize( + latestCropRect.applyChange( dragTarget = activeTarget, deltaX = dragAmount.x / size.width.toFloat(), deltaY = dragAmount.y / size.height.toFloat(), @@ -329,80 +321,120 @@ private fun CropOverlay( // 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 = 2.dp.toPx()), + style = Stroke(width = 1.dp.toPx()), ) - - val thirdWidth = (cropRight - cropLeft) / 3f - val thirdHeight = (cropBottom - cropTop) / 3f - repeat(2) { index -> - val offsetX = cropLeft + thirdWidth * (index + 1) - val offsetY = cropTop + thirdHeight * (index + 1) - drawLine( - color = guideColor, - start = Offset(offsetX, cropTop), - end = Offset(offsetX, cropBottom), - strokeWidth = 1.dp.toPx(), - ) - drawLine( - color = guideColor, - start = Offset(cropLeft, offsetY), - end = Offset(cropRight, offsetY), - strokeWidth = 1.dp.toPx(), - ) + // Guide lines dividing the crop area into 9 equal parts + if (drawGuidelines) { + val thirdWidth = (cropRight - cropLeft) / 3f + val thirdHeight = (cropBottom - cropTop) / 3f + (1..2).forEach { index -> + 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(), + ) + } } - - val handleLength = 16.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 - drawCornerHandle(cropLeft, cropTop, handleLength, handleColor, true, true) - drawCornerHandle(cropRight, cropTop, handleLength, handleColor, false, true) - drawCornerHandle(cropLeft, cropBottom, handleLength, handleColor, true, false) - drawCornerHandle(cropRight, cropBottom, handleLength, handleColor, false, false) + // Top handle drawEdgeHandle( - center = Offset((cropLeft + cropRight) / 2f, cropTop), + center = Offset((cropLeft + cropRight) / 2f, cropTop - handleOffset), horizontal = true, handleLength = handleLength, color = handleColor, ) + // Right handle drawEdgeHandle( - center = Offset(cropRight, (cropTop + cropBottom) / 2f), + center = Offset(cropRight + handleOffset, (cropTop + cropBottom) / 2f), horizontal = false, handleLength = handleLength, color = handleColor, ) + // Bottom handle drawEdgeHandle( - center = Offset((cropLeft + cropRight) / 2f, cropBottom), + center = Offset((cropLeft + cropRight) / 2f, cropBottom + handleOffset), horizontal = true, handleLength = handleLength, color = handleColor, ) + // Left handle drawEdgeHandle( - center = Offset(cropLeft, (cropTop + cropBottom) / 2f), + center = Offset(cropLeft - handleOffset, (cropTop + cropBottom) / 2f), horizontal = false, handleLength = handleLength, color = handleColor, @@ -430,14 +462,14 @@ private fun detectDragTarget( handleTouchRadius: Float, ): CropDragTarget? { val corners = mapOf( - CropDragTarget.TopLeft to Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height), - CropDragTarget.Top to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height), - CropDragTarget.TopRight to Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height), - CropDragTarget.Right to Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), - CropDragTarget.BottomRight to Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height), - CropDragTarget.Bottom to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height), - CropDragTarget.BottomLeft to Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height), - CropDragTarget.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), + CropDragTarget.Corner.TopLeft to Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height), + CropDragTarget.Edge.Top to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height), + CropDragTarget.Corner.TopRight to Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height), + CropDragTarget.Edge.Right to Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), + CropDragTarget.Corner.BottomRight to Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height), + CropDragTarget.Edge.Bottom to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height), + CropDragTarget.Corner.BottomLeft to Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height), + CropDragTarget.Edge.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), ) corners.forEach { (target, corner) -> if ((corner - touchPoint).getDistance() <= handleTouchRadius) { @@ -455,31 +487,40 @@ private fun detectDragTarget( } } -private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawCornerHandle( +// x and y are the coordinates of the corner +private fun DrawScope.drawCornerHandle( x: Float, y: Float, handleLength: Float, color: Color, - isLeft: Boolean, - isTop: Boolean, + position: CropDragTarget.Corner, ) { - val horizontalEndX = if (isLeft) x + handleLength else x - handleLength - val verticalEndY = if (isTop) y + handleLength else y - handleLength + 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, y), - end = Offset(horizontalEndX, y), - strokeWidth = 3.dp.toPx(), + start = Offset(x + horizontalCorrection, y), + end = Offset(horizontalEndX + horizontalCorrection, y), + strokeWidth = strokeWidth, ) + // Vertical line drawLine( color = color, - start = Offset(x, y), - end = Offset(x, verticalEndY), - strokeWidth = 3.dp.toPx(), + start = Offset(x, y + verticalCorrection), + end = Offset(x, verticalEndY + verticalCorrection), + strokeWidth = strokeWidth, ) } -private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawEdgeHandle( +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, @@ -499,23 +540,21 @@ private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawEdgeHandle( color = color, start = start, end = end, - strokeWidth = 3.dp.toPx(), + strokeWidth = 4.dp.toPx(), ) } -@PreviewsDayNight +// Only preview in dark, dark theme is forced on the Node. +@Preview @Composable -internal fun AttachmentImageEditorViewPreview() = ElementPreview { +internal fun AttachmentImageEditorViewPreview( + @PreviewParameter(AttachmentImageEditorStateProvider::class) state: AttachmentImageEditorState, +) = ElementPreviewDark { AttachmentImageEditorView( - state = AttachmentImageEditorState( - localMedia = LocalMedia( - uri = "file://preview-image".toUri(), - info = anImageMediaInfo(), - ), - edits = AttachmentImageEdits(), - ), + 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 f5629f5e2d..908646b048 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/main/res/values/temporary.xml b/features/messages/impl/src/main/res/values/temporary.xml deleted file mode 100644 index f0050224a9..0000000000 --- a/features/messages/impl/src/main/res/values/temporary.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - Rotate - - %1$d degree - %1$d degrees - - 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 5cbfd331c1..962f800764 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 @@ -577,7 +577,7 @@ class AttachmentsPreviewPresenterTest { val editorState = awaitItem() assertThat(editorState.imageEditorState).isNotNull() - editorState.eventSink(AttachmentsPreviewEvent.RotateImage) + editorState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) val rotatedState = awaitItem() assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(1) @@ -621,7 +621,7 @@ class AttachmentsPreviewPresenterTest { editorState.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) val croppedState = awaitItem() - croppedState.eventSink(AttachmentsPreviewEvent.RotateImage) + croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) val rotatedState = awaitItem() rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt deleted file mode 100644 index fa9c6367f8..0000000000 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModelsTest.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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 AttachmentImageEditModelsTest { - @Test - fun `resize with top handle only updates the top edge`() { - val rect = NormalizedCropRect( - left = 0.2f, - top = 0.2f, - right = 0.8f, - bottom = 0.8f, - ) - - val resized = rect.resize( - dragTarget = CropDragTarget.Top, - deltaX = 0.3f, - deltaY = 0.1f, - ) - - assertThat(resized.left).isEqualTo(rect.left) - assertThat(resized.right).isEqualTo(rect.right) - assertThat(resized.bottom).isEqualTo(rect.bottom) - assertThat(resized.top).isEqualTo(0.3f) - } - - @Test - fun `translate keeps the crop rect inside bounds`() { - val rect = NormalizedCropRect( - left = 0.2f, - top = 0.2f, - right = 0.8f, - bottom = 0.8f, - ) - - val translated = rect.translate( - deltaX = 0.6f, - deltaY = 0.6f, - ) - - assertThat(translated.left).isWithin(0.0001f).of(0.4f) - assertThat(translated.top).isWithin(0.0001f).of(0.4f) - assertThat(translated.right).isWithin(0.0001f).of(1.0f) - assertThat(translated.bottom).isWithin(0.0001f).of(1.0f) - } - - @Test - fun `rotate clockwise normalizes after a full turn`() { - var edits = AttachmentImageEdits() - - repeat(4) { - edits = edits.rotateClockwise() - } - - assertThat(edits.normalizedRotationQuarterTurns).isEqualTo(0) - assertThat(edits.rotationDegrees).isEqualTo(0) - assertThat(edits.hasChanges).isFalse() - } - - @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/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 0000000000..43c893dd05 --- /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 0000000000..f9db4f35f9 --- /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 0000000000..b51f10727c --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/NormalizedCropRectTest.kt @@ -0,0 +1,137 @@ +/* + * 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, + ) + assertThat(result.left).isEqualTo(rect.left) + assertThat(result.right).isEqualTo(rect.right) + assertThat(result.bottom).isEqualTo(rect.bottom) + assertThat(result.top).isWithin(0.0001f).of(0.3f) + } + + @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, + ) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.right).isEqualTo(rect.right) + assertThat(result.bottom).isEqualTo(rect.bottom) + assertThat(result.left).isWithin(0.0001f).of(0.2f) + } + + @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, + ) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.left).isEqualTo(rect.left) + assertThat(result.bottom).isEqualTo(rect.bottom) + assertThat(result.right).isWithin(0.0001f).of(0.6f) + } + + @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, + ) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.left).isEqualTo(rect.left) + assertThat(result.right).isEqualTo(rect.right) + assertThat(result.bottom).isWithin(0.0001f).of(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, + ) + assertThat(result.top).isWithin(0.0001f).of(0.3f) + assertThat(result.left).isWithin(0.0001f).of(0.2f) + assertThat(result.right).isEqualTo(rect.right) + assertThat(result.bottom).isEqualTo(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, + ) + assertThat(result.top).isWithin(0.0001f).of(0.3f) + assertThat(result.right).isWithin(0.0001f).of(0.6f) + assertThat(result.left).isEqualTo(rect.left) + assertThat(result.bottom).isEqualTo(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, + ) + assertThat(result.bottom).isWithin(0.0001f).of(0.7f) + assertThat(result.left).isWithin(0.0001f).of(0.2f) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.right).isEqualTo(rect.right) + } + + @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, + ) + assertThat(result.bottom).isWithin(0.0001f).of(0.7f) + assertThat(result.right).isWithin(0.0001f).of(0.6f) + assertThat(result.top).isEqualTo(rect.top) + assertThat(result.left).isEqualTo(rect.left) + } + + @Test + fun `translate keeps the crop rect inside bounds`() { + val result = rect.applyChange( + dragTarget = CropDragTarget.Move, + deltaX = 0.6f, + deltaY = 0.6f, + ) + assertThat(result.left).isWithin(0.0001f).of(0.4f) + assertThat(result.top).isWithin(0.0001f).of(0.4f) + assertThat(result.right).isWithin(0.0001f).of(1.0f) + assertThat(result.bottom).isWithin(0.0001f).of(1.0f) + } +} diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index e89d45b228..5608486d0e 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/tools/localazy/config.json b/tools/localazy/config.json index cb9c2ecb0d..9936341cee 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.*" ] }, From aa92bb61c1a4d0aae94d05147481c361a68d4aa3 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 22 May 2026 08:45:58 +0000 Subject: [PATCH 03/12] Update screenshots --- ...nts.preview.imageeditor_AttachmentImageEditorView_0_en.png | 3 +++ ...nts.preview.imageeditor_AttachmentImageEditorView_1_en.png | 3 +++ ...nts.preview.imageeditor_AttachmentImageEditorView_2_en.png | 3 +++ ...preview.imageeditor_AttachmentImageEditorView_Day_0_en.png | 3 --- ...eview.imageeditor_AttachmentImageEditorView_Night_0_en.png | 3 --- ...s.impl.attachments.preview_AttachmentsPreviewView_0_en.png | 4 ++-- ...s.impl.attachments.preview_AttachmentsPreviewView_1_en.png | 4 ++-- ...s.impl.attachments.preview_AttachmentsPreviewView_2_en.png | 4 ++-- ...s.impl.attachments.preview_AttachmentsPreviewView_3_en.png | 4 ++-- ...s.impl.attachments.preview_AttachmentsPreviewView_4_en.png | 4 ++-- ...s.impl.attachments.preview_AttachmentsPreviewView_5_en.png | 4 ++-- ...s.impl.attachments.preview_AttachmentsPreviewView_6_en.png | 4 ++-- ...s.impl.attachments.preview_AttachmentsPreviewView_7_en.png | 4 ++-- ...s.impl.attachments.preview_AttachmentsPreviewView_8_en.png | 4 ++-- ...s.impl.attachments.preview_AttachmentsPreviewView_9_en.png | 3 +++ 15 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview_AttachmentsPreviewView_9_en.png 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 0000000000..3bd0c2ba54 --- /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 0000000000..bf0a11f093 --- /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 0000000000..553c54a63d --- /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:d99c470fd5134e0a84b284ed32f4c93de01561e630ef32d7c22a4b476bb871b1 +size 277852 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Day_0_en.png deleted file mode 100644 index 37cc9056e1..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:490534398a7061e52f66e3ec46d6212107ee2512ff91c768d6618adb24e858f5 -size 376383 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Night_0_en.png deleted file mode 100644 index cb129ba75a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6e02017a71217184dc12c4b24b68e344fd20ca374e90e6073506170dd103e16b -size 375365 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 586bf84274..ccca24ef6a 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 27d8600ab5..c511e0e162 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 f1cde998de..f229a2a82e 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 586bf84274..ccca24ef6a 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 dd5a1e333c..09e08632b9 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 d695d41c94..f8fbf8c1a1 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 4a6e1cd828..fbff934ed8 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 fa4beb6dfb..5f90adbd8a 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 d789e92297..d7cdc540f7 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 0000000000..f06abb6c7b --- /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 From a158da1d18a421e5feb95af159318cb45916ae75 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 22 May 2026 12:13:09 +0200 Subject: [PATCH 04/12] For quality issue and improve preview --- .../preview/AttachmentsPreviewPresenter.kt | 1 + .../AttachmentsPreviewStateProvider.kt | 4 ++-- .../imageeditor/AttachmentImageEditModels.kt | 2 ++ .../AttachmentImageEditorStateProvider.kt | 19 +++++++++++++------ .../imageeditor/AttachmentImageEditorView.kt | 8 +++++--- 5 files changed, 23 insertions(+), 11 deletions(-) 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 7e8bfbdc3f..78dd6c2e82 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 @@ -133,6 +133,7 @@ class AttachmentsPreviewPresenter( // 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. + @Suppress("ComplexCondition") if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && preprocessMediaJob == null && imageEditorState == null && diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index ced90550c3..a2df440a12 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 @@ -12,7 +12,7 @@ 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.AttachmentImageEdits +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 @@ -45,7 +45,7 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider Unit, ) { var dragTarget by remember { mutableStateOf(null) } val latestCropRect by rememberUpdatedState(cropRect) val borderColor = ElementTheme.colors.iconPrimary val guideColor = ElementTheme.colors.iconPrimary - val drawGuidelines = dragTarget == CropDragTarget.Move + val drawGuidelines = dragTarget == CropDragTarget.Move || forceDrawGuidelines Canvas( modifier = Modifier .fillMaxSize() @@ -352,11 +354,11 @@ private fun CropOverlay( size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop), style = Stroke(width = 1.dp.toPx()), ) - // Guide lines dividing the crop area into 9 equal parts + // Guidelines dividing the crop area into 9 equal parts if (drawGuidelines) { val thirdWidth = (cropRight - cropLeft) / 3f val thirdHeight = (cropBottom - cropTop) / 3f - (1..2).forEach { index -> + for (index in 1..2) { val offsetX = cropLeft + thirdWidth * index val offsetY = cropTop + thirdHeight * index // Vertical guide line From 4876b7c9304941f0cb38fdabd45d3234bfe90329 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 22 May 2026 10:29:31 +0000 Subject: [PATCH 05/12] Update screenshots --- ...nts.preview.imageeditor_AttachmentImageEditorView_2_en.png | 4 ++-- ...nts.preview.imageeditor_AttachmentImageEditorView_3_en.png | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_3_en.png 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 index 553c54a63d..86b9dc57b8 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d99c470fd5134e0a84b284ed32f4c93de01561e630ef32d7c22a4b476bb871b1 -size 277852 +oid sha256:7e5c43a16d24ce7efed1c90cfa9bc6d73ee12c486b2ad2823c531b87e272ac66 +size 282369 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 0000000000..553c54a63d --- /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 From fe6a17e977ad9275562744c28dc6cddaef6b76d4 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 22 May 2026 13:56:50 +0200 Subject: [PATCH 06/12] Remove default value of data class. --- .../impl/attachments/preview/AttachmentsPreviewPresenter.kt | 1 + .../preview/imageeditor/AttachmentImageEditModels.kt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 78dd6c2e82..72d840c4ad 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 @@ -276,6 +276,7 @@ class AttachmentsPreviewPresenter( imageEditorState = AttachmentImageEditorState( localMedia = originalLocalMedia, edits = appliedImageEdits, + forceDrawGuidelines = false, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt index 885bd077d1..d103e6b913 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt @@ -18,7 +18,7 @@ data class AttachmentImageEditorState( val localMedia: LocalMedia, val edits: AttachmentImageEdits, // For preview only - val forceDrawGuidelines: Boolean = false, + val forceDrawGuidelines: Boolean, ) @Immutable From 94c3bb9c4103f039a5cf4c2e652a541b113a7fe2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 22 May 2026 13:59:21 +0200 Subject: [PATCH 07/12] Rename file. --- ...AttachmentImageEditModels.kt => AttachmentImageEditorState.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/{AttachmentImageEditModels.kt => AttachmentImageEditorState.kt} (100%) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorState.kt similarity index 100% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditModels.kt rename to features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorState.kt From afa2a824b5c2f06206954c0c65ad6e2146a69f16 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 22 May 2026 15:28:48 +0200 Subject: [PATCH 08/12] Fix tests. --- .../AttachmentsPreviewPresenterTest.kt | 16 ++- .../imageeditor/NormalizedCropRectTest.kt | 116 ++++++++++++------ 2 files changed, 92 insertions(+), 40 deletions(-) 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 962f800764..140d4f8ea3 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 @@ -21,6 +21,7 @@ import io.element.android.features.messages.impl.attachments.preview.imageeditor 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 @@ -579,7 +580,7 @@ class AttachmentsPreviewPresenterTest { editorState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) val rotatedState = awaitItem() - assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(1) + assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(3) rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) assertThat(awaitItem().isApplyingImageEdits).isTrue() @@ -630,9 +631,16 @@ class AttachmentsPreviewPresenterTest { appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() - assertThat(reopenedState.imageEditorState?.localMedia?.uri).isEqualTo(originalLocalMedia.uri) - assertThat(reopenedState.imageEditorState?.edits?.cropRect).isEqualTo(cropRect) - assertThat(reopenedState.imageEditorState?.edits?.rotationDegrees).isEqualTo(90) + 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) } } 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 index b51f10727c..14cb07eed7 100644 --- 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 @@ -25,10 +25,14 @@ class NormalizedCropRectTest { deltaX = 0.3f, deltaY = 0.1f, ) - assertThat(result.left).isEqualTo(rect.left) - assertThat(result.right).isEqualTo(rect.right) - assertThat(result.bottom).isEqualTo(rect.bottom) - assertThat(result.top).isWithin(0.0001f).of(0.3f) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = 0.3f, + right = rect.right, + bottom = rect.bottom, + ) + ) } @Test @@ -38,10 +42,14 @@ class NormalizedCropRectTest { deltaX = 0.1f, deltaY = 0.3f, ) - assertThat(result.top).isEqualTo(rect.top) - assertThat(result.right).isEqualTo(rect.right) - assertThat(result.bottom).isEqualTo(rect.bottom) - assertThat(result.left).isWithin(0.0001f).of(0.2f) + result.assertIsSimilarTo( + NormalizedCropRect( + left = 0.2f, + top = rect.top, + right = rect.right, + bottom = rect.bottom, + ) + ) } @Test @@ -51,10 +59,15 @@ class NormalizedCropRectTest { deltaX = -0.1f, deltaY = 0.3f, ) - assertThat(result.top).isEqualTo(rect.top) - assertThat(result.left).isEqualTo(rect.left) - assertThat(result.bottom).isEqualTo(rect.bottom) - assertThat(result.right).isWithin(0.0001f).of(0.6f) + val s = assertThat(result) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = rect.top, + right = 0.6f, + bottom = rect.bottom, + ) + ) } @Test @@ -64,10 +77,14 @@ class NormalizedCropRectTest { deltaX = -0.1f, deltaY = -0.3f, ) - assertThat(result.top).isEqualTo(rect.top) - assertThat(result.left).isEqualTo(rect.left) - assertThat(result.right).isEqualTo(rect.right) - assertThat(result.bottom).isWithin(0.0001f).of(0.5f) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = rect.top, + right = rect.right, + bottom = 0.5f, + ) + ) } @Test @@ -77,10 +94,14 @@ class NormalizedCropRectTest { deltaX = 0.1f, deltaY = 0.1f, ) - assertThat(result.top).isWithin(0.0001f).of(0.3f) - assertThat(result.left).isWithin(0.0001f).of(0.2f) - assertThat(result.right).isEqualTo(rect.right) - assertThat(result.bottom).isEqualTo(rect.bottom) + result.assertIsSimilarTo( + NormalizedCropRect( + left = 0.2f, + top = 0.3f, + right = rect.right, + bottom = rect.bottom, + ) + ) } @Test @@ -90,10 +111,14 @@ class NormalizedCropRectTest { deltaX = -0.1f, deltaY = 0.1f, ) - assertThat(result.top).isWithin(0.0001f).of(0.3f) - assertThat(result.right).isWithin(0.0001f).of(0.6f) - assertThat(result.left).isEqualTo(rect.left) - assertThat(result.bottom).isEqualTo(rect.bottom) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = 0.3f, + right = 0.6f, + bottom = rect.bottom, + ) + ) } @Test @@ -103,10 +128,14 @@ class NormalizedCropRectTest { deltaX = 0.1f, deltaY = -0.1f, ) - assertThat(result.bottom).isWithin(0.0001f).of(0.7f) - assertThat(result.left).isWithin(0.0001f).of(0.2f) - assertThat(result.top).isEqualTo(rect.top) - assertThat(result.right).isEqualTo(rect.right) + result.assertIsSimilarTo( + NormalizedCropRect( + left = 0.2f, + top = rect.top, + right = rect.right, + bottom = 0.7f, + ) + ) } @Test @@ -116,10 +145,14 @@ class NormalizedCropRectTest { deltaX = -0.1f, deltaY = -0.1f, ) - assertThat(result.bottom).isWithin(0.0001f).of(0.7f) - assertThat(result.right).isWithin(0.0001f).of(0.6f) - assertThat(result.top).isEqualTo(rect.top) - assertThat(result.left).isEqualTo(rect.left) + result.assertIsSimilarTo( + NormalizedCropRect( + left = rect.left, + top = rect.top, + right = 0.6f, + bottom = 0.7f, + ) + ) } @Test @@ -129,9 +162,20 @@ class NormalizedCropRectTest { deltaX = 0.6f, deltaY = 0.6f, ) - assertThat(result.left).isWithin(0.0001f).of(0.4f) - assertThat(result.top).isWithin(0.0001f).of(0.4f) - assertThat(result.right).isWithin(0.0001f).of(1.0f) - assertThat(result.bottom).isWithin(0.0001f).of(1.0f) + 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) +} From 6e7444620919ffedee1ffb6efc115c7c621d9b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 22 May 2026 16:41:44 +0200 Subject: [PATCH 09/12] Allow detecting touch events outside the image by applying the drag detection to the parent node and offsetting the touch events --- .../imageeditor/AttachmentImageEditorView.kt | 107 +++++++++++------- 1 file changed, 63 insertions(+), 44 deletions(-) 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 index 2aff3aaff1..f7c5ab6c06 100644 --- 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 @@ -14,6 +14,7 @@ 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 @@ -34,6 +35,7 @@ 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 @@ -41,6 +43,8 @@ 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 @@ -53,6 +57,7 @@ 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 @@ -188,13 +193,15 @@ fun AttachmentImageEditorView( } @Composable -private fun CropEditorCanvas( +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() @@ -233,7 +240,10 @@ private fun CropEditorCanvas( Box( modifier = Modifier .size(displayedWidthDp, displayedHeightDp) - .align(Alignment.Center), + .align(Alignment.Center) + .onPlaced { + imageRect = it.boundsInParent() + }, contentAlignment = Alignment.Center, ) { if (LocalInspectionMode.current) { @@ -263,11 +273,50 @@ private fun CropEditorCanvas( } ) } + } + val touchRadius = 56.dp + var dragTarget by remember { mutableStateOf(null) } + val latestCropRect by rememberUpdatedState(state.edits.cropRect) + val drawGuidelines = dragTarget == CropDragTarget.Move || state.forceDrawGuidelines + 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 = touchRadius.toPx(), + ) + }, + 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, - forceDrawGuidelines = state.forceDrawGuidelines, - onCropRectChange = onCropRectChange, + drawGuidelines = drawGuidelines, ) } } @@ -275,46 +324,15 @@ private fun CropEditorCanvas( @Composable private fun CropOverlay( + imageSize: DpSize, cropRect: NormalizedCropRect, - forceDrawGuidelines: Boolean, - onCropRectChange: (NormalizedCropRect) -> Unit, + drawGuidelines: Boolean, ) { - var dragTarget by remember { mutableStateOf(null) } - val latestCropRect by rememberUpdatedState(cropRect) val borderColor = ElementTheme.colors.iconPrimary val guideColor = ElementTheme.colors.iconPrimary - val drawGuidelines = dragTarget == CropDragTarget.Move || forceDrawGuidelines + Canvas( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { offset -> - dragTarget = detectDragTarget( - touchPoint = offset, - cropRect = latestCropRect, - canvasSize = Size(size.width.toFloat(), size.height.toFloat()), - handleTouchRadius = 32.dp.toPx(), - ) - }, - 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(), - ) - ) - } - } + modifier = Modifier.size(imageSize.width, imageSize.height) ) { val cropLeft = cropRect.left * size.width val cropTop = cropRect.top * size.height @@ -459,6 +477,7 @@ private fun fitSize( private fun detectDragTarget( touchPoint: Offset, + imageOffset: Offset, cropRect: NormalizedCropRect, canvasSize: Size, handleTouchRadius: Float, @@ -474,14 +493,14 @@ private fun detectDragTarget( CropDragTarget.Edge.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), ) corners.forEach { (target, corner) -> - if ((corner - touchPoint).getDistance() <= handleTouchRadius) { + if ((corner - touchPoint + imageOffset).getDistance() <= handleTouchRadius) { return target } } - val cropLeft = cropRect.left * canvasSize.width - val cropTop = cropRect.top * canvasSize.height - val cropRight = cropRect.right * canvasSize.width - val cropBottom = cropRect.bottom * canvasSize.height + 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 { From 8a4ff4c45663cf49eaef64b961785131ae989abc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 25 May 2026 21:16:12 +0200 Subject: [PATCH 10/12] Improve touch detection. --- .../preview/AttachmentsPreviewPresenter.kt | 2 +- .../imageeditor/AttachmentImageEditorState.kt | 29 ++++-- .../AttachmentImageEditorStateProvider.kt | 30 +++++- .../imageeditor/AttachmentImageEditorView.kt | 93 ++++++++++++++++--- 4 files changed, 128 insertions(+), 26 deletions(-) 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 72d840c4ad..cab00d99c1 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 @@ -276,7 +276,7 @@ class AttachmentsPreviewPresenter( imageEditorState = AttachmentImageEditorState( localMedia = originalLocalMedia, edits = appliedImageEdits, - forceDrawGuidelines = false, + previewDebug = false, ) } } 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 index d103e6b913..3c8af52ce8 100644 --- 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 @@ -7,6 +7,7 @@ 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 @@ -18,7 +19,7 @@ data class AttachmentImageEditorState( val localMedia: LocalMedia, val edits: AttachmentImageEdits, // For preview only - val forceDrawGuidelines: Boolean, + val previewDebug: Boolean, ) @Immutable @@ -51,10 +52,10 @@ data class AttachmentImageEdits( @Immutable data class NormalizedCropRect( - val left: Float, - val top: Float, - val right: Float, - val bottom: Float, + @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) @@ -71,7 +72,11 @@ data class NormalizedCropRect( val height: Float get() = bottom - top - fun applyChange(dragTarget: CropDragTarget, deltaX: Float, deltaY: Float): NormalizedCropRect = when (dragTarget) { + 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) @@ -88,7 +93,11 @@ data class NormalizedCropRect( ) } - private fun dragWithCorner(dragTarget: CropDragTarget.Corner, deltaX: Float, deltaY: Float) = when (dragTarget) { + 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), @@ -107,7 +116,11 @@ data class NormalizedCropRect( ) } - private fun dragWithEdge(dragTarget: CropDragTarget.Edge, deltaX: Float, deltaY: Float) = when (dragTarget) { + 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), ) 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 index 09a4390202..df4bb5257f 100644 --- 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 @@ -44,13 +44,37 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider(null) } val latestCropRect by rememberUpdatedState(state.edits.cropRect) - val drawGuidelines = dragTarget == CropDragTarget.Move || state.forceDrawGuidelines + val drawGuidelines = dragTarget == CropDragTarget.Move || state.previewDebug Box( modifier = Modifier .fillMaxSize() @@ -290,7 +304,7 @@ private fun BoxScope.CropEditorCanvas( imageOffset = imageRect.topLeft, cropRect = latestCropRect, canvasSize = Size(imageRect.width, imageRect.height), - handleTouchRadius = touchRadius.toPx(), + handleTouchRadius = touchRadiusPx, ) }, onDragCancel = { @@ -317,6 +331,9 @@ private fun BoxScope.CropEditorCanvas( imageSize = DpSize(displayedWidthDp, displayedHeightDp), cropRect = state.edits.cropRect, drawGuidelines = drawGuidelines, + previewDebug = state.previewDebug, + touchRadiusPx = touchRadiusPx, + dragTarget = dragTarget, ) } } @@ -327,6 +344,9 @@ 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 @@ -459,6 +479,32 @@ private fun CropOverlay( 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)), + ) + } + } } } @@ -482,17 +528,20 @@ private fun detectDragTarget( canvasSize: Size, handleTouchRadius: Float, ): CropDragTarget? { - val corners = mapOf( - CropDragTarget.Corner.TopLeft to Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height), - CropDragTarget.Edge.Top to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height), - CropDragTarget.Corner.TopRight to Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height), - CropDragTarget.Edge.Right to Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), - CropDragTarget.Corner.BottomRight to Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height), - CropDragTarget.Edge.Bottom to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height), - CropDragTarget.Corner.BottomLeft to Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height), - CropDragTarget.Edge.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), + // 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), ) - corners.forEach { (target, corner) -> + handlesArea.forEach { (target, corner) -> if ((corner - touchPoint + imageOffset).getDistance() <= handleTouchRadius) { return target } @@ -508,6 +557,22 @@ private fun detectDragTarget( } } +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, From 1f6824d285925951b56f8200927c437ccd89675c Mon Sep 17 00:00:00 2001 From: ElementBot Date: Mon, 25 May 2026 19:34:34 +0000 Subject: [PATCH 11/12] Update screenshots --- ...nts.preview.imageeditor_AttachmentImageEditorView_2_en.png | 4 ++-- ...nts.preview.imageeditor_AttachmentImageEditorView_4_en.png | 3 +++ ...nts.preview.imageeditor_AttachmentImageEditorView_5_en.png | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.attachments.preview.imageeditor_AttachmentImageEditorView_5_en.png 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 index 86b9dc57b8..2ae328d7eb 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7e5c43a16d24ce7efed1c90cfa9bc6d73ee12c486b2ad2823c531b87e272ac66 -size 282369 +oid sha256:34b6dfe4e65612615c3dc87e5f65bd0b160d97527c4a4749b496bf8d48819d96 +size 256641 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 0000000000..6c3a6f4bf6 --- /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 0000000000..530c5e5bb6 --- /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 From 6cef5cec1bd581bbba2de772a7c4dcca7d906771 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 26 May 2026 09:22:43 +0200 Subject: [PATCH 12/12] Remove useless line. --- .../attachments/preview/imageeditor/NormalizedCropRectTest.kt | 1 - 1 file changed, 1 deletion(-) 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 index 14cb07eed7..c70c6169e1 100644 --- 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 @@ -59,7 +59,6 @@ class NormalizedCropRectTest { deltaX = -0.1f, deltaY = 0.3f, ) - val s = assertThat(result) result.assertIsSimilarTo( NormalizedCropRect( left = rect.left,