Image edition before sending: crop and rotate. (#6842)

* 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 <android@element.io>
Co-authored-by: Benoit Marty <benoitm@element.io>

* Update design and UX

Update and add tests
Improve preview

* Update screenshots

* For quality issue and improve preview

* Update screenshots

* Remove default value of data class.

* Rename file.

* Fix tests.

* Allow detecting touch events outside the image by applying the drag detection to the parent node and offsetting the touch events

* Improve touch detection.

* Update screenshots

* Remove useless line.

---------

Co-authored-by: Gianluca Iavicoli <gianluca.iavicoli04@gmail.com>
Co-authored-by: ElementBot <android@element.io>
Co-authored-by: Jorge Martín <jorgem@element.io>
This commit is contained in:
Benoit Marty 2026-05-26 10:07:05 +02:00 committed by GitHub
commit 1f3d848c79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2030 additions and 90 deletions

View file

@ -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)

View file

@ -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
}
}

View file

@ -8,8 +8,17 @@
package io.element.android.features.messages.impl.attachments.preview
import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect
sealed interface AttachmentsPreviewEvent {
data object SendAttachment : AttachmentsPreviewEvent
data object CancelAndDismiss : AttachmentsPreviewEvent
data object CancelAndClearSendState : AttachmentsPreviewEvent
data object OpenImageEditor : AttachmentsPreviewEvent
data object CloseImageEditor : AttachmentsPreviewEvent
data object RotateImageToTheLeft : AttachmentsPreviewEvent
data object ApplyImageEdits : AttachmentsPreviewEvent
data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
data object ClearImageEditError : AttachmentsPreviewEvent
}

View file

@ -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>(SendActionState.Idle)
}
val originalLocalMedia = remember { (attachment as Attachment.Media).localMedia }
var currentAttachment by remember { mutableStateOf(attachment) }
var canEditImage by remember { mutableStateOf(originalLocalMedia.info.canEditImage()) }
var imageEditorState by remember { mutableStateOf<AttachmentImageEditorState?>(null) }
var appliedImageEdits by remember { mutableStateOf(AttachmentImageEdits()) }
var isApplyingImageEdits by remember { mutableStateOf(false) }
var displayImageEditError by remember { mutableStateOf(false) }
var editedTempFile by remember { mutableStateOf<File?>(null) }
val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false)
val textEditorState by rememberUpdatedState(
@ -97,7 +110,7 @@ class AttachmentsPreviewPresenter(
var preprocessMediaJob by remember { mutableStateOf<Job?>(null) }
val mediaAttachment = attachment as Attachment.Media
val mediaAttachment = currentAttachment as Attachment.Media
val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(
localMedia = mediaAttachment.localMedia,
@ -113,11 +126,18 @@ class AttachmentsPreviewPresenter(
LaunchedEffect(
mediaOptimizationSelectorState.displayMediaSelectorViews,
mediaOptimizationSelectorState.videoSizeEstimations,
currentAttachment,
imageEditorState,
isApplyingImageEdits,
) {
// If the media optimization selector is not displayed, we can pre-process the media
// to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && preprocessMediaJob == null) {
@Suppress("ComplexCondition")
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false &&
preprocessMediaJob == null &&
imageEditorState == null &&
!isApplyingImageEdits) {
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
return@LaunchedEffect
@ -127,7 +147,7 @@ class AttachmentsPreviewPresenter(
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
) ?: return@LaunchedEffect
preprocessMediaJob = coroutineScope.preProcessAttachment(
attachment = attachment,
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = false,
sendActionState = sendActionState,
@ -135,10 +155,14 @@ class AttachmentsPreviewPresenter(
}
}
LaunchedEffect(originalLocalMedia) {
canEditImage = originalLocalMedia.info.canEditImage() || attachmentImageEditor.canEdit(originalLocalMedia)
}
val maxUploadSize = mediaOptimizationSelectorState.maxUploadSize.dataOrNull()
LaunchedEffect(maxUploadSize) {
// Check file upload size if the media won't be processed for upload
val isImageFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeImage()
val isImageFile = mediaAttachment.localMedia.info.isImageAttachment()
val isVideoFile = mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo()
if (maxUploadSize != null && !(isImageFile || isVideoFile)) {
// If file size is not known, we're permissive and allow sending. The SDK will cancel the upload if needed.
@ -169,7 +193,7 @@ class AttachmentsPreviewPresenter(
videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD,
)
preprocessMediaJob = preProcessAttachment(
attachment = attachment,
attachment = currentAttachment,
mediaOptimizationConfig = config,
displayProgress = true,
sendActionState = sendActionState,
@ -188,6 +212,9 @@ class AttachmentsPreviewPresenter(
val caption = markdownTextEditorState.getMessageMarkdown(permalinkBuilder)
.takeIf { it.isNotEmpty() }
val editedTempFileToDelete = editedTempFile
editedTempFile = null
// If we're supposed to send the media as a background job, we can dismiss this screen already
if (coroutineContext.isActive) {
onDoneListener()
@ -195,33 +222,36 @@ class AttachmentsPreviewPresenter(
// Send the media using the session coroutine scope so it doesn't matter if this screen or the chat one are closed
sessionCoroutineScope.launch(dispatchers.io) {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = false,
inReplyToEventId = inReplyToEventId,
)
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
try {
sendPreProcessedMedia(
mediaUploadInfo = mediaUploadInfo,
caption = caption,
sendActionState = sendActionState,
dismissAfterSend = false,
inReplyToEventId = inReplyToEventId,
)
} finally {
editedTempFileToDelete?.safeDelete()
// Clean up the pre-processed media after it's been sent
mediaSender.cleanUp()
}
}
}
}
AttachmentsPreviewEvent.CancelAndDismiss -> {
displayFileTooLargeError = false
displayImageEditError = false
isApplyingImageEdits = false
// Cancel media preprocessing and sending
preprocessMediaJob?.cancel()
preprocessMediaJob = null
// If we couldn't send the pre-processed media, remove it
mediaSender.cleanUp()
ongoingSendAttachmentJob.value?.cancel()
// Dismiss the screen
dismiss(
attachment,
sendActionState,
)
dismiss(sendActionState, editedTempFile)
}
AttachmentsPreviewEvent.CancelAndClearSendState -> {
// Cancel media sending
@ -237,11 +267,88 @@ class AttachmentsPreviewPresenter(
SendActionState.Idle
}
}
AttachmentsPreviewEvent.OpenImageEditor -> {
val resolvedCanEditImage = canEditImage || originalLocalMedia.info.canEditImage()
if (resolvedCanEditImage) {
preprocessMediaJob?.cancel()
preprocessMediaJob = null
resetPreparedMedia(sendActionState)
imageEditorState = AttachmentImageEditorState(
localMedia = originalLocalMedia,
edits = appliedImageEdits,
previewDebug = false,
)
}
}
AttachmentsPreviewEvent.CloseImageEditor -> {
imageEditorState = null
}
is AttachmentsPreviewEvent.UpdateImageCropRect -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.copy(cropRect = event.cropRect)
)
}
AttachmentsPreviewEvent.RotateImageToTheLeft -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.rotateAntiClockwise()
)
}
AttachmentsPreviewEvent.ResetImageEdits -> {
imageEditorState = imageEditorState?.copy(
edits = AttachmentImageEdits()
)
}
AttachmentsPreviewEvent.ApplyImageEdits -> {
val pendingState = imageEditorState ?: return
if (!pendingState.edits.hasChanges) {
editedTempFile?.safeDelete()
editedTempFile = null
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(originalLocalMedia)
imageEditorState = null
resetPreparedMedia(sendActionState)
return
}
isApplyingImageEdits = true
displayImageEditError = false
coroutineScope.launch {
val result = withContext(dispatchers.io) {
attachmentImageEditor.exportEdits(
localMedia = originalLocalMedia,
edits = pendingState.edits,
)
}
result.fold(
onSuccess = { editedMedia ->
editedTempFile?.safeDelete()
editedTempFile = editedMedia.file
appliedImageEdits = pendingState.edits
currentAttachment = Attachment.Media(editedMedia.localMedia)
imageEditorState = null
resetPreparedMedia(sendActionState)
},
onFailure = {
Timber.e(it, "Failed to apply image edits")
displayImageEditError = true
}
)
isApplyingImageEdits = false
}
}
AttachmentsPreviewEvent.ClearImageEditError -> {
displayImageEditError = false
}
}
}
return AttachmentsPreviewState(
attachment = attachment,
attachment = currentAttachment,
imageEditorState = imageEditorState,
canEditImage = canEditImage,
isApplyingImageEdits = isApplyingImageEdits,
displayImageEditError = displayImageEditError,
sendActionState = sendActionState.value,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
@ -318,8 +425,8 @@ class AttachmentsPreviewPresenter(
}
private fun dismiss(
attachment: Attachment,
sendActionState: MutableState<SendActionState>,
editedTempFile: File?,
) {
// Delete the temporary file
when (attachment) {
@ -330,6 +437,7 @@ class AttachmentsPreviewPresenter(
}
}
}
editedTempFile?.safeDelete()
// Reset the sendActionState to ensure that dialog is closed before the screen
sendActionState.value = SendActionState.Done
onDoneListener()
@ -343,6 +451,12 @@ class AttachmentsPreviewPresenter(
}
}
private fun resetPreparedMedia(sendActionState: MutableState<SendActionState>) {
sendActionState.value.mediaUploadInfo()?.let(::cleanUp)
mediaSender.cleanUp()
sendActionState.value = SendActionState.Idle
}
private suspend fun sendPreProcessedMedia(
mediaUploadInfo: MediaUploadInfo,
caption: String?,

View file

@ -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,

View file

@ -11,6 +11,8 @@ package io.element.android.features.messages.impl.attachments.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.preview.imageeditor.anAttachmentImageEditorState
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.libraries.architecture.AsyncData
@ -42,6 +44,9 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
anAttachmentsPreviewState(
imageEditorState = anAttachmentImageEditorState(),
),
anAttachmentsPreviewState(displayFileTooLargeError = true),
anAttachmentsPreviewState(
mediaInfo = aVideoMediaInfo(),
@ -64,12 +69,17 @@ fun anAttachmentsPreviewState(
mediaInfo: MediaInfo = anImageMediaInfo(),
textEditorState: TextEditorState = aTextEditorStateMarkdown(),
sendActionState: SendActionState = SendActionState.Idle,
imageEditorState: AttachmentImageEditorState? = null,
mediaOptimizationSelectorState: MediaOptimizationSelectorState = aMediaOptimisationSelectorState(),
displayFileTooLargeError: Boolean = false,
) = AttachmentsPreviewState(
attachment = Attachment.Media(
localMedia = LocalMedia("file://path".toUri(), mediaInfo),
),
imageEditorState = imageEditorState,
canEditImage = true,
isApplyingImageEdits = false,
displayImageEditError = false,
sendActionState = sendActionState,
textEditorState = textEditorState,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,

View file

@ -31,19 +31,20 @@ 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
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditorView
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorEvent
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.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.ProgressDialogType
@ -60,6 +61,7 @@ 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
@ -74,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(
@ -81,6 +86,13 @@ fun AttachmentsPreviewView(
localMediaRenderer: LocalMediaRenderer,
modifier: Modifier = Modifier,
) {
val canShowEditAction = when (state.sendActionState) {
is SendActionState.Sending.Uploading -> false
is SendActionState.Sending.Processing -> !state.sendActionState.displayProgress
SendActionState.Done -> false
else -> true
}
fun postSendAttachment() {
state.eventSink(AttachmentsPreviewEvent.SendAttachment)
}
@ -93,33 +105,84 @@ fun AttachmentsPreviewView(
state.eventSink(AttachmentsPreviewEvent.CancelAndClearSendState)
}
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 postResetImageEditor() {
state.eventSink(AttachmentsPreviewEvent.ResetImageEdits)
}
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.RotateImageToTheLeft) },
onCancelClick = ::postCloseImageEditor,
onResetClick = ::postResetImageEditor,
onDoneClick = ::postApplyImageEdits,
modifier = modifier,
)
} else {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
onClick = ::postCancel,
)
},
title = {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(R.string.screen_media_upload_preview_title),
)
},
actions = {
if (state.canEditImage && canShowEditAction) {
TextButton(
stringResource(CommonStrings.action_edit),
onClick = ::postOpenImageEditor
)
}
}
)
}
) { paddingValues ->
AttachmentPreviewContent(
modifier = Modifier.padding(paddingValues),
state = state,
localMediaRenderer = localMediaRenderer,
onSendClick = ::postSendAttachment,
)
}
) { paddingValues ->
AttachmentPreviewContent(
modifier = Modifier.padding(paddingValues),
state = state,
localMediaRenderer = localMediaRenderer,
onSendClick = ::postSendAttachment,
)
}
AttachmentSendStateView(
sendActionState = state.sendActionState,
isApplyingImageEdits = state.isApplyingImageEdits,
displayImageEditError = state.displayImageEditError,
onDismissImageEditError = { state.eventSink(AttachmentsPreviewEvent.ClearImageEditError) },
onDismissClick = ::postClearSendState,
onRetryClick = ::postSendAttachment
)
@ -128,36 +191,56 @@ fun AttachmentsPreviewView(
@Composable
private fun AttachmentSendStateView(
sendActionState: SendActionState,
isApplyingImageEdits: Boolean,
displayImageEditError: Boolean,
onDismissImageEditError: () -> Unit,
onDismissClick: () -> Unit,
onRetryClick: () -> Unit
) {
when (sendActionState) {
is SendActionState.Sending.Processing -> {
if (sendActionState.displayProgress) {
when {
isApplyingImageEdits -> {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
showCancelButton = false,
onDismissRequest = {},
)
}
displayImageEditError -> {
AlertDialog(
title = stringResource(CommonStrings.common_error),
content = stringResource(CommonStrings.common_something_went_wrong_message),
onDismiss = onDismissImageEditError,
)
}
else -> when (sendActionState) {
is SendActionState.Sending.Processing -> {
if (sendActionState.displayProgress) {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
}
is SendActionState.Sending.Uploading -> {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(CommonStrings.common_preparing),
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
is SendActionState.Failure -> {
RetryDialog(
content = stringResource(sendAttachmentError(sendActionState.error)),
onDismiss = onDismissClick,
onRetry = onRetryClick
)
}
else -> Unit
}
is SendActionState.Sending.Uploading -> {
ProgressDialog(
type = ProgressDialogType.Indeterminate,
text = stringResource(id = CommonStrings.common_sending),
showCancelButton = true,
onDismissRequest = onDismissClick,
)
}
is SendActionState.Failure -> {
RetryDialog(
content = stringResource(sendAttachmentError(sendActionState.error)),
onDismiss = onDismissClick,
onRetry = onRetryClick
)
}
else -> Unit
}
}
@ -184,10 +267,10 @@ private fun AttachmentPreviewContent(
}
}
}
val mimeType = (state.attachment as? Attachment.Media)?.localMedia?.info?.mimeType
if (mimeType?.isMimeTypeImage() == true) {
val mediaInfo = (state.attachment as? Attachment.Media)?.localMedia?.info
if (mediaInfo?.isImageAttachment() == true) {
ImageOptimizationSelector(state.mediaOptimizationSelectorState)
} else if (mimeType?.isMimeTypeVideo() == true) {
} else if (mediaInfo?.mimeType?.isMimeTypeVideo() == true) {
VideoPresetSelector(state = state.mediaOptimizationSelectorState)
}
@ -220,7 +303,8 @@ private fun AttachmentPreviewContent(
private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
if (state.displayMediaSelectorViews == true) {
Row(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.niceClickable {
state.isImageOptimizationEnabled?.let { value ->
state.eventSink(MediaOptimizationSelectorEvent.SelectImageOptimization(!value))
@ -229,7 +313,9 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) {
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Text(
modifier = Modifier.weight(1f).align(Alignment.CenterVertically),
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically),
text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title),
style = ElementTheme.typography.fontBodyLgRegular,
)
@ -255,7 +341,8 @@ private fun VideoPresetSelector(
if (state.displayMediaSelectorViews == true && videoPresets != null && state.selectedVideoPreset != null) {
Column(
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
.niceClickable { state.eventSink(MediaOptimizationSelectorEvent.OpenVideoPresetSelectorDialog) }
) {

View file

@ -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<EditedLocalMedia>
}
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<EditedLocalMedia> = withContext(dispatchers.io) {
runCatchingExceptions {
val sourceMimeType = localMedia.info.resolvedImageMimeType() ?: localMedia.info.mimeType
val exportedMimeType = exportedMimeTypeFor(sourceMimeType)
val exifOrientation = context.contentResolver.openInputStream(localMedia.uri)?.let { input ->
input.use {
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED)
}
} ?: ExifInterface.ORIENTATION_UNDEFINED
val decodedBitmap = context.contentResolver.openInputStream(localMedia.uri)?.use { input ->
BitmapFactory.decodeStream(input)
} ?: error("Unable to decode image from ${localMedia.uri}")
val normalizedBitmap = decodedBitmap.rotateToExifMetadataOrientation(exifOrientation)
if (normalizedBitmap !== decodedBitmap) {
decodedBitmap.recycle()
}
val rotatedBitmap = normalizedBitmap.rotateQuarterTurns(edits.rotationQuarterTurns)
if (rotatedBitmap !== normalizedBitmap) {
normalizedBitmap.recycle()
}
val cropRect = edits.cropRect.toPixelRect(
imageWidth = rotatedBitmap.width,
imageHeight = rotatedBitmap.height,
)
val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 &&
cropRect.width() == rotatedBitmap.width && cropRect.height() == rotatedBitmap.height
val croppedBitmap = if (isCropUnchanged) {
rotatedBitmap
} else {
Bitmap.createBitmap(
rotatedBitmap,
cropRect.left,
cropRect.top,
cropRect.width(),
cropRect.height(),
)
}
if (croppedBitmap !== rotatedBitmap) {
rotatedBitmap.recycle()
}
val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() }
val outputFile = context.createTmpFile(baseDir = editedMediaDir, extension = compressFileExtension(exportedMimeType))
outputFile.writeBitmap(
bitmap = croppedBitmap,
format = compressFormat(exportedMimeType),
quality = 90,
)
croppedBitmap.recycle()
EditedLocalMedia(
localMedia = localMedia.copy(
uri = Uri.fromFile(outputFile),
info = localMedia.info.copy(mimeType = exportedMimeType),
),
file = outputFile,
)
}
}
}
internal fun exportedMimeTypeFor(sourceMimeType: String?): String {
return if (sourceMimeType == MimeTypes.Png) {
MimeTypes.Png
} else {
MimeTypes.Jpeg
}
}
private fun Bitmap.rotateQuarterTurns(quarterTurns: Int): Bitmap {
val normalizedTurns = (quarterTurns % 4 + 4) % 4
if (normalizedTurns == 0) return this
val matrix = Matrix().apply {
postRotate(normalizedTurns * 90f)
}
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
}
private data class PixelCropRect(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
) {
fun width() = right - left
fun height() = bottom - top
}
private fun NormalizedCropRect.toPixelRect(imageWidth: Int, imageHeight: Int): PixelCropRect {
val leftPx = (left * imageWidth).roundToInt().coerceIn(0, imageWidth - 1)
val topPx = (top * imageHeight).roundToInt().coerceIn(0, imageHeight - 1)
val rightPx = (right * imageWidth).roundToInt().coerceIn(leftPx + 1, imageWidth)
val bottomPx = (bottom * imageHeight).roundToInt().coerceIn(topPx + 1, imageHeight)
return PixelCropRect(
left = leftPx,
top = topPx,
right = rightPx,
bottom = bottomPx,
)
}
private fun compressFormat(mimeType: String) = when (mimeType) {
MimeTypes.Png -> Bitmap.CompressFormat.PNG
else -> Bitmap.CompressFormat.JPEG
}
private fun compressFileExtension(mimeType: String) = when (mimeType) {
MimeTypes.Png -> "png"
else -> "jpeg"
}
private fun String?.isEditableStillImageMimeType(): Boolean {
return this != null &&
this.isMimeTypeImage() &&
!this.isMimeTypeAnimatedImage() &&
this != MimeTypes.Svg
}

View file

@ -0,0 +1,164 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.annotation.FloatRange
import androidx.compose.runtime.Immutable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
private const val DEFAULT_CROP_MARGIN = 0f
private const val MIN_CROP_SIZE = 0.1f
@Immutable
data class AttachmentImageEditorState(
val localMedia: LocalMedia,
val edits: AttachmentImageEdits,
// For preview only
val previewDebug: Boolean,
)
@Immutable
data class AttachmentImageEdits(
val cropRect: NormalizedCropRect = NormalizedCropRect.default(),
val rotationQuarterTurns: Int = 0,
) {
val normalizedRotationQuarterTurns: Int
get() = rotationQuarterTurns % 4
val rotationDegrees: Int
get() = normalizedRotationQuarterTurns * 90
val hasChanges: Boolean
get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0
fun rotateAntiClockwise(): AttachmentImageEdits {
return copy(
rotationQuarterTurns = (normalizedRotationQuarterTurns + 3) % 4,
// Also update the crop rect to keep the same selected area
cropRect = NormalizedCropRect(
left = cropRect.top,
top = 1f - cropRect.right,
right = cropRect.bottom,
bottom = 1f - cropRect.left,
)
)
}
}
@Immutable
data class NormalizedCropRect(
@FloatRange(from = 0.0, to = 1.0) val left: Float,
@FloatRange(from = 0.0, to = 1.0) val top: Float,
@FloatRange(from = 0.0, to = 1.0) val right: Float,
@FloatRange(from = 0.0, to = 1.0) val bottom: Float,
) {
init {
require(left in 0f..1f)
require(top in 0f..1f)
require(right in 0f..1f)
require(bottom in 0f..1f)
require(left < right)
require(top < bottom)
}
val width: Float
get() = right - left
val height: Float
get() = bottom - top
fun applyChange(
dragTarget: CropDragTarget,
deltaX: Float,
deltaY: Float,
): NormalizedCropRect = when (dragTarget) {
is CropDragTarget.Move -> translate(deltaX, deltaY)
is CropDragTarget.Corner -> dragWithCorner(dragTarget, deltaX, deltaY)
is CropDragTarget.Edge -> dragWithEdge(dragTarget, deltaX, deltaY)
}
private fun translate(deltaX: Float, deltaY: Float): NormalizedCropRect {
val clampedLeft = (left + deltaX).coerceIn(0f, 1f - width)
val clampedTop = (top + deltaY).coerceIn(0f, 1f - height)
return copy(
left = clampedLeft,
top = clampedTop,
right = clampedLeft + width,
bottom = clampedTop + height,
)
}
private fun dragWithCorner(
dragTarget: CropDragTarget.Corner,
deltaX: Float,
deltaY: Float,
) = when (dragTarget) {
CropDragTarget.Corner.TopLeft -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.Corner.TopRight -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.Corner.BottomRight -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
CropDragTarget.Corner.BottomLeft -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
}
private fun dragWithEdge(
dragTarget: CropDragTarget.Edge,
deltaX: Float,
deltaY: Float,
) = when (dragTarget) {
CropDragTarget.Edge.Top -> copy(
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)
CropDragTarget.Edge.Right -> copy(
right = (right + deltaX).coerceIn(left + MIN_CROP_SIZE, 1f),
)
CropDragTarget.Edge.Bottom -> copy(
bottom = (bottom + deltaY).coerceIn(top + MIN_CROP_SIZE, 1f),
)
CropDragTarget.Edge.Left -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
)
}
companion object {
fun default() = NormalizedCropRect(
left = DEFAULT_CROP_MARGIN,
top = DEFAULT_CROP_MARGIN,
right = 1f - DEFAULT_CROP_MARGIN,
bottom = 1f - DEFAULT_CROP_MARGIN,
)
}
}
sealed interface CropDragTarget {
data object Move : CropDragTarget
sealed interface Corner : CropDragTarget {
data object TopLeft : Corner
data object TopRight : Corner
data object BottomRight : Corner
data object BottomLeft : Corner
}
sealed interface Edge : CropDragTarget {
data object Top : Edge
data object Right : Edge
data object Bottom : Edge
data object Left : Edge
}
}

View file

@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.net.toUri
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
open class AttachmentImageEditorStateProvider : PreviewParameterProvider<AttachmentImageEditorState> {
private val caterpillarCrop = NormalizedCropRect(
left = 0.3f,
top = 0.3f,
right = 0.8f,
bottom = 0.75f,
)
override val values: Sequence<AttachmentImageEditorState>
get() = sequenceOf(
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
// Cheat a bit so that the crop match the sample image size (1024 * 682)
cropRect = 0.17f.let { correction ->
NormalizedCropRect(
left = 0f,
top = correction,
right = 1f,
bottom = 1 - correction,
)
},
),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
),
previewDebug = true,
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).rotateAntiClockwise(),
),
// Small crop
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.3f,
top = 0.6f,
right = 0.4f,
bottom = 0.7f,
),
),
previewDebug = true,
),
// Big crop
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.05f,
top = 0.05f,
right = 0.95f,
bottom = 0.95f,
),
),
previewDebug = true,
),
)
}
internal fun anAttachmentImageEditorState(
localMedia: LocalMedia = LocalMedia(
uri = "file://preview-image".toUri(),
info = anImageMediaInfo(),
),
edits: AttachmentImageEdits = AttachmentImageEdits(),
previewDebug: Boolean = false,
) = AttachmentImageEditorState(
localMedia = localMedia,
edits = edits,
previewDebug = previewDebug,
)

View file

@ -0,0 +1,647 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.min
private val minHandleTouchRadius = 16.dp
private val maxHandleTouchRadius = 56.dp
/**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AttachmentImageEditorView(
state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit,
onRotateClick: () -> Unit,
onResetClick: () -> Unit,
onCancelClick: () -> Unit,
onDoneClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val rotateContentDescription = stringResource(R.string.screen_image_edition_a11y_rotate_to_the_left)
val rotationStateDescription = pluralStringResource(
R.plurals.screen_image_edition_a11y_rotation_state,
state.edits.rotationDegrees,
state.edits.rotationDegrees,
)
val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
navigationIcon = {
BackButton(
imageVector = CompoundIcons.Close(),
onClick = onCancelClick,
)
},
title = {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(R.string.screen_image_edition_title),
)
},
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(ElementTheme.colors.bgCanvasDefault)
.padding(paddingValues)
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
CropEditorCanvas(
state = state,
onCropRectChange = onCropRectChange,
)
}
Row(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.widthIn(max = 360.dp)
.navigationBarsPadding()
.padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 18.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart,
) {
TextButton(
text = stringResource(CommonStrings.action_reset),
destructive = true,
onClick = onResetClick,
)
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
) {
IconButton(
onClick = onRotateClick,
modifier = Modifier
.background(
color = rotateButtonBackground,
shape = CircleShape,
)
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
.clearAndSetSemantics {
contentDescription = rotateContentDescription
stateDescription = rotationStateDescription
}
) {
Icon(
modifier = Modifier
.size(22.dp),
imageVector = CompoundIcons.RotateLeft(),
contentDescription = null,
)
}
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterEnd,
) {
TextButton(
text = stringResource(CommonStrings.action_done),
onClick = onDoneClick,
)
}
}
}
}
}
@Composable
private fun BoxScope.CropEditorCanvas(
state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit,
) {
var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) }
val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns
var imageRect by remember { mutableStateOf(Rect.Zero) }
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
) {
val displayedSize = remember(maxWidth, maxHeight, imageSize, rotationQuarterTurns) {
val sourceWidth = imageSize.width.takeIf { it > 0 } ?: 1
val sourceHeight = imageSize.height.takeIf { it > 0 } ?: 1
val aspectRatio = if (rotationQuarterTurns % 2 == 0) {
sourceWidth.toFloat() / sourceHeight.toFloat()
} else {
sourceHeight.toFloat() / sourceWidth.toFloat()
}
fitSize(
containerWidth = constraints.maxWidth.toFloat(),
containerHeight = constraints.maxHeight.toFloat(),
aspectRatio = aspectRatio,
)
}
val density = LocalDensity.current
val displayedWidthDp = with(density) { displayedSize.width.toDp() }
val displayedHeightDp = with(density) { displayedSize.height.toDp() }
val imageLayoutSize = remember(displayedSize, rotationQuarterTurns) {
if (rotationQuarterTurns % 2 == 0) {
displayedSize
} else {
Size(
width = displayedSize.height,
height = displayedSize.width,
)
}
}
val imageLayoutWidthDp = with(density) { imageLayoutSize.width.toDp() }
val imageLayoutHeightDp = with(density) { imageLayoutSize.height.toDp() }
Box(
modifier = Modifier
.size(displayedWidthDp, displayedHeightDp)
.align(Alignment.Center)
.onPlaced {
imageRect = it.boundsInParent()
},
contentAlignment = Alignment.Center,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
contentDescription = null,
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
)
} else {
AsyncImage(
model = state.localMedia.uri,
contentDescription = stringResource(CommonStrings.common_image),
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
onState = { painterState ->
if (painterState is AsyncImagePainter.State.Success) {
imageSize = IntSize(
width = painterState.result.image.width,
height = painterState.result.image.height,
)
}
}
)
}
}
val minHandleTouchRadiusPx = minHandleTouchRadius.toPx()
val maxHandleTouchRadiusPx = maxHandleTouchRadius.toPx()
val touchRadiusPx by rememberUpdatedState(
(min(
state.edits.cropRect.width * imageRect.width,
state.edits.cropRect.height * imageRect.height,
) / 4f).coerceIn(
minHandleTouchRadiusPx,
maxHandleTouchRadiusPx,
)
)
var dragTarget by remember { mutableStateOf<CropDragTarget?>(null) }
val latestCropRect by rememberUpdatedState(state.edits.cropRect)
val drawGuidelines = dragTarget == CropDragTarget.Move || state.previewDebug
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
dragTarget = detectDragTarget(
touchPoint = offset,
imageOffset = imageRect.topLeft,
cropRect = latestCropRect,
canvasSize = Size(imageRect.width, imageRect.height),
handleTouchRadius = touchRadiusPx,
)
},
onDragCancel = {
dragTarget = null
},
onDragEnd = {
dragTarget = null
},
) { change, dragAmount ->
val activeTarget = dragTarget ?: return@detectDragGestures
change.consume()
onCropRectChange(
latestCropRect.applyChange(
dragTarget = activeTarget,
deltaX = dragAmount.x / size.width.toFloat(),
deltaY = dragAmount.y / size.height.toFloat(),
)
)
}
},
contentAlignment = Alignment.Center,
) {
CropOverlay(
imageSize = DpSize(displayedWidthDp, displayedHeightDp),
cropRect = state.edits.cropRect,
drawGuidelines = drawGuidelines,
previewDebug = state.previewDebug,
touchRadiusPx = touchRadiusPx,
dragTarget = dragTarget,
)
}
}
}
@Composable
private fun CropOverlay(
imageSize: DpSize,
cropRect: NormalizedCropRect,
drawGuidelines: Boolean,
previewDebug: Boolean,
touchRadiusPx: Float,
dragTarget: CropDragTarget?,
) {
val borderColor = ElementTheme.colors.iconPrimary
val guideColor = ElementTheme.colors.iconPrimary
Canvas(
modifier = Modifier.size(imageSize.width, imageSize.height)
) {
val cropLeft = cropRect.left * size.width
val cropTop = cropRect.top * size.height
val cropRight = cropRect.right * size.width
val cropBottom = cropRect.bottom * size.height
// Hardcoded black: the crop overlay must always darken the image regardless of theme.
// No semantic token exists for this use case in the Compound design system.
val overlayColor = Color.Black.copy(alpha = 0.48f)
// Overlay above the crop area
drawRect(
color = overlayColor,
topLeft = Offset.Zero,
size = Size(width = size.width, height = cropTop),
)
// Overlay on the left of the crop area
drawRect(
color = overlayColor,
topLeft = Offset(0f, cropTop),
size = Size(width = cropLeft, height = cropBottom - cropTop),
)
// Overlay on the right of the crop area
drawRect(
color = overlayColor,
topLeft = Offset(cropRight, cropTop),
size = Size(width = size.width - cropRight, height = cropBottom - cropTop),
)
// Overlay below the crop area
drawRect(
color = overlayColor,
topLeft = Offset(0f, cropBottom),
size = Size(width = size.width, height = size.height - cropBottom),
)
// Main frame of the crop area
drawRect(
color = borderColor,
topLeft = Offset(cropLeft, cropTop),
size = Size(width = cropRight - cropLeft, height = cropBottom - cropTop),
style = Stroke(width = 1.dp.toPx()),
)
// Guidelines dividing the crop area into 9 equal parts
if (drawGuidelines) {
val thirdWidth = (cropRight - cropLeft) / 3f
val thirdHeight = (cropBottom - cropTop) / 3f
for (index in 1..2) {
val offsetX = cropLeft + thirdWidth * index
val offsetY = cropTop + thirdHeight * index
// Vertical guide line
drawLine(
color = guideColor,
start = Offset(offsetX, cropTop),
end = Offset(offsetX, cropBottom),
strokeWidth = 1.dp.toPx(),
)
// Horizontal guide line
drawLine(
color = guideColor,
start = Offset(cropLeft, offsetY),
end = Offset(cropRight, offsetY),
strokeWidth = 1.dp.toPx(),
)
}
}
// Corner handles
val handleLength = 18.dp.toPx()
val handleOffset = 2.dp.toPx()
// Top left corner
drawCornerHandle(
x = cropLeft - handleOffset,
y = cropTop - handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.TopLeft,
)
// Top right corner
drawCornerHandle(
x = cropRight + handleOffset,
y = cropTop - handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.TopRight,
)
// Bottom left corner
drawCornerHandle(
x = cropLeft - handleOffset,
y = cropBottom + handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.BottomLeft,
)
// Bottom right corner
drawCornerHandle(
x = cropRight + handleOffset,
y = cropBottom + handleOffset,
handleLength = handleLength,
color = borderColor,
position = CropDragTarget.Corner.BottomRight,
)
val handleColor = borderColor
// Top handle
drawEdgeHandle(
center = Offset((cropLeft + cropRight) / 2f, cropTop - handleOffset),
horizontal = true,
handleLength = handleLength,
color = handleColor,
)
// Right handle
drawEdgeHandle(
center = Offset(cropRight + handleOffset, (cropTop + cropBottom) / 2f),
horizontal = false,
handleLength = handleLength,
color = handleColor,
)
// Bottom handle
drawEdgeHandle(
center = Offset((cropLeft + cropRight) / 2f, cropBottom + handleOffset),
horizontal = true,
handleLength = handleLength,
color = handleColor,
)
// Left handle
drawEdgeHandle(
center = Offset(cropLeft - handleOffset, (cropTop + cropBottom) / 2f),
horizontal = false,
handleLength = handleLength,
color = handleColor,
)
if (previewDebug) {
// Draw disk around touchable area
listOf(
CropDragTarget.Edge.Top,
CropDragTarget.Edge.Right,
CropDragTarget.Edge.Bottom,
CropDragTarget.Edge.Left,
CropDragTarget.Corner.TopLeft,
CropDragTarget.Corner.TopRight,
CropDragTarget.Corner.BottomRight,
CropDragTarget.Corner.BottomLeft,
CropDragTarget.Move,
).forEach { target ->
val color = when (target) {
is CropDragTarget.Move -> Color.Red
is CropDragTarget.Corner -> Color.Blue
is CropDragTarget.Edge -> Color.Green
}.copy(alpha = if (dragTarget == target) 9f else 0.5f)
drawCircle(
color = color,
radius = touchRadiusPx,
center = computeOffset(target, cropRect, Size(size.width, size.height)),
)
}
}
}
}
private fun fitSize(
containerWidth: Float,
containerHeight: Float,
aspectRatio: Float,
): Size {
val widthBasedHeight = containerWidth / aspectRatio
return if (widthBasedHeight <= containerHeight) {
Size(width = containerWidth, height = widthBasedHeight)
} else {
Size(width = containerHeight * aspectRatio, height = containerHeight)
}
}
private fun detectDragTarget(
touchPoint: Offset,
imageOffset: Offset,
cropRect: NormalizedCropRect,
canvasSize: Size,
handleTouchRadius: Float,
): CropDragTarget? {
// Give priority on Move (extra detection of the center of crop area)
// to ensure that user can move a small crop, then to corners and at last to edges.
val handlesArea = mapOf(
CropDragTarget.Move to computeOffset(CropDragTarget.Move, cropRect, canvasSize),
CropDragTarget.Corner.TopLeft to computeOffset(CropDragTarget.Corner.TopLeft, cropRect, canvasSize),
CropDragTarget.Corner.TopRight to computeOffset(CropDragTarget.Corner.TopRight, cropRect, canvasSize),
CropDragTarget.Corner.BottomRight to computeOffset(CropDragTarget.Corner.BottomRight, cropRect, canvasSize),
CropDragTarget.Corner.BottomLeft to computeOffset(CropDragTarget.Corner.BottomLeft, cropRect, canvasSize),
CropDragTarget.Edge.Top to computeOffset(CropDragTarget.Edge.Top, cropRect, canvasSize),
CropDragTarget.Edge.Right to computeOffset(CropDragTarget.Edge.Right, cropRect, canvasSize),
CropDragTarget.Edge.Bottom to computeOffset(CropDragTarget.Edge.Bottom, cropRect, canvasSize),
CropDragTarget.Edge.Left to computeOffset(CropDragTarget.Edge.Left, cropRect, canvasSize),
)
handlesArea.forEach { (target, corner) ->
if ((corner - touchPoint + imageOffset).getDistance() <= handleTouchRadius) {
return target
}
}
val cropLeft = imageOffset.x + cropRect.left * canvasSize.width
val cropTop = imageOffset.y + cropRect.top * canvasSize.height
val cropRight = imageOffset.x + cropRect.right * canvasSize.width
val cropBottom = imageOffset.y + cropRect.bottom * canvasSize.height
return if (touchPoint.x in cropLeft..cropRight && touchPoint.y in cropTop..cropBottom) {
CropDragTarget.Move
} else {
null
}
}
private fun computeOffset(
target: CropDragTarget,
cropRect: NormalizedCropRect,
canvasSize: Size,
) = when (target) {
CropDragTarget.Move -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
CropDragTarget.Corner.TopLeft -> Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height)
CropDragTarget.Edge.Top -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height)
CropDragTarget.Corner.TopRight -> Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height)
CropDragTarget.Edge.Right -> Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
CropDragTarget.Corner.BottomRight -> Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height)
CropDragTarget.Edge.Bottom -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height)
CropDragTarget.Corner.BottomLeft -> Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height)
CropDragTarget.Edge.Left -> Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
}
// x and y are the coordinates of the corner
private fun DrawScope.drawCornerHandle(
x: Float,
y: Float,
handleLength: Float,
color: Color,
position: CropDragTarget.Corner,
) {
val strokeWidth = 4.dp.toPx()
val correction = strokeWidth / 2
val horizontalCorrection = if (position.isLeft()) -correction else correction
val horizontalEndX = if (position.isLeft()) x + handleLength else x - handleLength
val verticalEndY = if (position.isTop()) y + handleLength else y - handleLength
val verticalCorrection = if (position.isTop()) -correction else correction
// Horizontal line
drawLine(
color = color,
start = Offset(x + horizontalCorrection, y),
end = Offset(horizontalEndX + horizontalCorrection, y),
strokeWidth = strokeWidth,
)
// Vertical line
drawLine(
color = color,
start = Offset(x, y + verticalCorrection),
end = Offset(x, verticalEndY + verticalCorrection),
strokeWidth = strokeWidth,
)
}
private fun CropDragTarget.Corner.isLeft() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.BottomLeft
private fun CropDragTarget.Corner.isTop() = this == CropDragTarget.Corner.TopLeft || this == CropDragTarget.Corner.TopRight
private fun DrawScope.drawEdgeHandle(
center: Offset,
horizontal: Boolean,
handleLength: Float,
color: Color,
) {
val start = if (horizontal) {
Offset(center.x - handleLength / 2f, center.y)
} else {
Offset(center.x, center.y - handleLength / 2f)
}
val end = if (horizontal) {
Offset(center.x + handleLength / 2f, center.y)
} else {
Offset(center.x, center.y + handleLength / 2f)
}
drawLine(
color = color,
start = start,
end = end,
strokeWidth = 4.dp.toPx(),
)
}
// Only preview in dark, dark theme is forced on the Node.
@Preview
@Composable
internal fun AttachmentImageEditorViewPreview(
@PreviewParameter(AttachmentImageEditorStateProvider::class) state: AttachmentImageEditorState,
) = ElementPreviewDark {
AttachmentImageEditorView(
state = state,
onCropRectChange = {},
onRotateClick = {},
onResetClick = {},
onCancelClick = {},
onDoneClick = {},
)
}

View file

@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_recent">"Recent emojis"</string>
<string name="emoji_picker_category_symbols">"Symbols"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotate the image to the left"</string>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d degree"</item>
<item quantity="other">"%1$d degrees"</item>
</plurals>
<string name="screen_image_edition_title">"Edit photo"</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tap to change the video upload quality"</string>
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"The file could not be uploaded."</string>

View file

@ -11,11 +11,17 @@
package io.element.android.features.messages.impl.attachments
import android.net.Uri
import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewEvent
import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewPresenter
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEditor
import io.element.android.features.messages.impl.attachments.preview.imageeditor.AttachmentImageEdits
import io.element.android.features.messages.impl.attachments.preview.imageeditor.EditedLocalMedia
import io.element.android.features.messages.impl.attachments.preview.imageeditor.NormalizedCropRect
import io.element.android.features.messages.impl.attachments.preview.imageeditor.assertIsSimilarTo
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
@ -73,6 +79,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import kotlin.io.path.createTempFile
@Suppress("LargeClass")
@RunWith(RobolectricTestRunner::class)
@ -551,6 +558,92 @@ class AttachmentsPreviewPresenterTest {
}
@Test
fun `present - applying image edits updates the attachment`() = runTest {
val editedUri = Uri.parse("file:///tmp/edited.jpeg")
val presenter = createAttachmentsPreviewPresenter(
displayMediaQualitySelectorViews = true,
attachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = aLocalMedia(uri = editedUri),
file = File("/tmp/edited.jpeg"),
)
)
}
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = awaitItem()
assertThat(editorState.imageEditorState).isNotNull()
editorState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
val rotatedState = awaitItem()
assertThat(rotatedState.imageEditorState?.edits?.rotationQuarterTurns).isEqualTo(3)
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
assertThat(awaitItem().isApplyingImageEdits).isTrue()
val appliedState = awaitItem()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
assertThat(appliedState.imageEditorState).isNull()
assertThat(appliedState.isApplyingImageEdits).isFalse()
}
}
@Test
fun `present - reopening image editor keeps original media and previous edits`() = runTest {
val editedUri = Uri.parse("file:///tmp/edited.jpeg")
val originalLocalMedia = aLocalMedia(uri = mockMediaUrl)
val cropRect = NormalizedCropRect(
left = 0.2f,
top = 0.15f,
right = 0.85f,
bottom = 0.9f,
)
val presenter = createAttachmentsPreviewPresenter(
localMedia = originalLocalMedia,
displayMediaQualitySelectorViews = true,
attachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = aLocalMedia(uri = editedUri),
file = File("/tmp/edited.jpeg"),
)
)
}
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
editorState.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
val croppedState = awaitItem()
croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
val rotatedState = awaitItem()
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
appliedState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val reopenedState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
assertThat(reopenedState.imageEditorState!!.localMedia.uri).isEqualTo(originalLocalMedia.uri)
val rotatedCropRect = NormalizedCropRect(
left = cropRect.top,
top = 1f - cropRect.right,
right = cropRect.bottom,
bottom = 1f - cropRect.left,
)
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(rotatedCropRect)
assertThat(reopenedState.imageEditorState.edits.rotationQuarterTurns).isEqualTo(3)
assertThat(reopenedState.imageEditorState.edits.rotationDegrees).isEqualTo(270)
}
}
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
// Even though the user has enabled "Optimize media quality" globally, picking the file
// through the Files picker (sendAsFile = true) must skip compression. Regression test
@ -581,6 +674,121 @@ class AttachmentsPreviewPresenterTest {
}
}
@Test
fun `present - sending edited media keeps the edited file available until upload starts`() = runTest {
val editedFile = createTempFile(suffix = ".jpeg").toFile().apply {
writeText("edited-media")
}
val sendFileResult =
lambdaRecorder<File, FileInfo, String?, String?, EventId?, Result<FakeMediaUploadHandler>> { file, _, _, _, _ ->
assertThat(file.exists()).isTrue()
Result.success(FakeMediaUploadHandler())
}
val room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
sendFileLambda = sendFileResult
},
)
val presenter = createAttachmentsPreviewPresenter(
room = room,
displayMediaQualitySelectorViews = true,
onDoneListener = OnDoneListener {},
mediaPreProcessor = FakeMediaPreProcessor().apply {
givenResult(
Result.success(
MediaUploadInfo.AnyFile(
file = editedFile,
fileInfo = FileInfo(
mimetype = MimeTypes.Jpeg,
size = editedFile.length(),
thumbnailInfo = null,
thumbnailSource = null,
)
)
)
)
},
attachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = aLocalMedia(uri = editedFile.toUri()),
file = editedFile,
)
)
}
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
editorState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
appliedState.eventSink(AttachmentsPreviewEvent.SendAttachment)
consumeItemsUntilPredicate { it.sendActionState == SendActionState.Done }
sendFileResult.assertions().isCalledOnce()
}
}
@Test
fun `present - image with generic mime type and png extension is still editable`() = runTest {
val localMedia = aLocalMedia(
uri = mockMediaUrl,
mediaInfo = anImageMediaInfo().copy(
mimeType = MimeTypes.OctetStream,
filename = "Screenshot.png",
fileExtension = "png",
),
)
val presenter = createAttachmentsPreviewPresenter(localMedia = localMedia)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.canEditImage).isTrue()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
assertThat(editorState.imageEditorState).isNotNull()
}
}
@Test
fun `present - image can still be edited when editor can decode it despite generic media info`() = runTest {
val localMedia = aLocalMedia(
uri = mockMediaUrl,
mediaInfo = anImageMediaInfo().copy(
mimeType = MimeTypes.OctetStream,
filename = "",
fileExtension = "",
),
)
val presenter = createAttachmentsPreviewPresenter(
localMedia = localMedia,
attachmentImageEditor = FakeAttachmentImageEditor(
canEditResult = true,
) {
Result.success(
EditedLocalMedia(
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/decoded.jpeg")),
file = File("/tmp/decoded.jpeg"),
)
)
}
)
presenter.test {
val initialState = consumeItemsUntilPredicate { it.canEditImage }.last()
assertThat(initialState.canEditImage).isTrue()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
assertThat(editorState.imageEditorState).isNotNull()
}
}
@Test
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
val mediaPreProcessor = FakeMediaPreProcessor()
@ -652,6 +860,14 @@ class AttachmentsPreviewPresenterTest {
}
),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
attachmentImageEditor: AttachmentImageEditor = FakeAttachmentImageEditor {
Result.success(
EditedLocalMedia(
localMedia = localMedia.copy(uri = Uri.parse("file:///tmp/default-edited.jpeg")),
file = File("/tmp/default-edited.jpeg"),
)
)
},
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
@ -669,6 +885,7 @@ class AttachmentsPreviewPresenterTest {
},
permalinkBuilder = permalinkBuilder,
temporaryUriDeleter = temporaryUriDeleter,
attachmentImageEditor = attachmentImageEditor,
sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(),
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
@ -679,6 +896,22 @@ class AttachmentsPreviewPresenterTest {
)
}
private class FakeAttachmentImageEditor(
private val canEditResult: Boolean = true,
private val result: () -> Result<EditedLocalMedia>,
) : AttachmentImageEditor {
override suspend fun canEdit(localMedia: LocalMedia): Boolean {
return canEditResult
}
override suspend fun exportEdits(
localMedia: LocalMedia,
edits: AttachmentImageEdits,
): Result<EditedLocalMedia> {
return result()
}
}
private val mediaUploadInfo = MediaUploadInfo.AnyFile(
File("test"),
FileInfo(

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,180 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class NormalizedCropRectTest {
private val rect = NormalizedCropRect(
left = 0.1f,
top = 0.2f,
right = 0.7f,
bottom = 0.8f,
)
@Test
fun `applyChange with top handle only updates the top edge`() {
val result = rect.applyChange(
dragTarget = CropDragTarget.Edge.Top,
deltaX = 0.3f,
deltaY = 0.1f,
)
result.assertIsSimilarTo(
NormalizedCropRect(
left = rect.left,
top = 0.3f,
right = rect.right,
bottom = rect.bottom,
)
)
}
@Test
fun `applyChange with left handle only updates the left edge`() {
val result = rect.applyChange(
dragTarget = CropDragTarget.Edge.Left,
deltaX = 0.1f,
deltaY = 0.3f,
)
result.assertIsSimilarTo(
NormalizedCropRect(
left = 0.2f,
top = rect.top,
right = rect.right,
bottom = rect.bottom,
)
)
}
@Test
fun `applyChange with right handle only updates the right edge`() {
val result = rect.applyChange(
dragTarget = CropDragTarget.Edge.Right,
deltaX = -0.1f,
deltaY = 0.3f,
)
result.assertIsSimilarTo(
NormalizedCropRect(
left = rect.left,
top = rect.top,
right = 0.6f,
bottom = rect.bottom,
)
)
}
@Test
fun `applyChange with bottom handle target only updates the bottem edge`() {
val result = rect.applyChange(
dragTarget = CropDragTarget.Edge.Bottom,
deltaX = -0.1f,
deltaY = -0.3f,
)
result.assertIsSimilarTo(
NormalizedCropRect(
left = rect.left,
top = rect.top,
right = rect.right,
bottom = 0.5f,
)
)
}
@Test
fun `applyChange with top left handle updates the top and left bottem edge`() {
val result = rect.applyChange(
dragTarget = CropDragTarget.Corner.TopLeft,
deltaX = 0.1f,
deltaY = 0.1f,
)
result.assertIsSimilarTo(
NormalizedCropRect(
left = 0.2f,
top = 0.3f,
right = rect.right,
bottom = rect.bottom,
)
)
}
@Test
fun `applyChange with top right handle updates the top and right bottem edge`() {
val result = rect.applyChange(
dragTarget = CropDragTarget.Corner.TopRight,
deltaX = -0.1f,
deltaY = 0.1f,
)
result.assertIsSimilarTo(
NormalizedCropRect(
left = rect.left,
top = 0.3f,
right = 0.6f,
bottom = rect.bottom,
)
)
}
@Test
fun `applyChange with bottom left handle updates the bottom and left bottem edge`() {
val result = rect.applyChange(
dragTarget = CropDragTarget.Corner.BottomLeft,
deltaX = 0.1f,
deltaY = -0.1f,
)
result.assertIsSimilarTo(
NormalizedCropRect(
left = 0.2f,
top = rect.top,
right = rect.right,
bottom = 0.7f,
)
)
}
@Test
fun `applyChange with bottom right handle updates the bottom and right bottem edge`() {
val result = rect.applyChange(
dragTarget = CropDragTarget.Corner.BottomRight,
deltaX = -0.1f,
deltaY = -0.1f,
)
result.assertIsSimilarTo(
NormalizedCropRect(
left = rect.left,
top = rect.top,
right = 0.6f,
bottom = 0.7f,
)
)
}
@Test
fun `translate keeps the crop rect inside bounds`() {
val result = rect.applyChange(
dragTarget = CropDragTarget.Move,
deltaX = 0.6f,
deltaY = 0.6f,
)
result.assertIsSimilarTo(
NormalizedCropRect(
left = 0.4f,
top = 0.4f,
right = 1.0f,
bottom = 1.0f,
)
)
}
}
internal fun NormalizedCropRect.assertIsSimilarTo(expectedResult: NormalizedCropRect) {
assertThat(left).isWithin(0.0001f).of(expectedResult.left)
assertThat(top).isWithin(0.0001f).of(expectedResult.top)
assertThat(right).isWithin(0.0001f).of(expectedResult.right)
assertThat(bottom).isWithin(0.0001f).of(expectedResult.bottom)
}

View file

@ -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<Float>?,
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
}
}
}
}

View file

@ -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()
)

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51f0b3f7e4bb16728f21055de37b7b2780fd2a1fc65b6bd4564334daeab20763
size 329042

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5febc6580e4f0bda75a27445157ace7f1acb620c17cba55ca5d2a9330743c1de
size 283397

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:34b6dfe4e65612615c3dc87e5f65bd0b160d97527c4a4749b496bf8d48819d96
size 256641

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d99c470fd5134e0a84b284ed32f4c93de01561e630ef32d7c22a4b476bb871b1
size 277852

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b15a04a861812e3e4dee0a6a30c6afef4ab171c5261d1e5bd5a234bb7296d97
size 251908

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c32b4744750b9f18612bded2e292dde151e2bfdd8a69a36c88044f5ca3a76f8e
size 311315

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22e3d682c4866bd5c519ab88d08290708929af805438a0bd093200cddcbd41b2
size 399376
oid sha256:a14113653096095323ecb94cb3dd694985717b8585c8cfc6b7a45cf7a2483a79
size 402427

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:02bb9e9de3b0ef480cedbed50483bdd3a899497ecc40ead72491106c6f6b6611
size 399030
oid sha256:95c64e1e7055dd048b88f0e19a57fe696ee93505d74f96bc4f1915fcce769d7d
size 402072

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:947ccb947f4a961ff7d17b936f2c866d66fea0361879d51ad4c65d18465c1a9f
size 59226
oid sha256:4ee2ee54c694a45316bbdcfba7038391fe808f5292356748e9e66f46135a9ae8
size 61457

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22e3d682c4866bd5c519ab88d08290708929af805438a0bd093200cddcbd41b2
size 399376
oid sha256:a14113653096095323ecb94cb3dd694985717b8585c8cfc6b7a45cf7a2483a79
size 402427

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d9e221ec2f4ee764967e94c32f52b1615b25dec8fc7697dd5bcd01fc4da8d69
size 59098
oid sha256:44c7b0e86781ff6112c3be5b575b548c5ae6e9530f6f27a070ebb008d606a20c
size 61326

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:547bf4e3bec05c219f5a72cfd8d506eb7c39429970cced5c2b8f2999ae390265
size 86149
oid sha256:ccedfe061af8a77da7ddf6b20d23899bfff986fd6c74b87480b588e148ddd8de
size 89013

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d326595038160376db620a07a180de7af37ebfc76d4927ed1176ef6f4370aab
size 72700
oid sha256:f52a267ee2aa300185191aa3e610787c58d7222bc8e4a7570879d4c5fd37133d
size 328936

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a8e8bcb6fdffb2d8673e4ee4e16e21672ffe99e717c48fd35b8411a8aa0530e9
size 405064
oid sha256:1feeb32f9dce486c54db41727d9d9801f606353008945988331c2ce391dec451
size 75364

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3caecb171d3095ef5c3593c6289b2d99e6cd6a6c635d822ad24061e502bdeb2e
size 82790
oid sha256:2bfda29fcaade9508e0d742b0de7ac230d41a358e98755e4b08f1ed7d5affe30
size 408106

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1fdc4e2d82297927d2062586398fda3f3b7f6febfde8888a062229caf713ff54
size 85154

View file

@ -273,6 +273,7 @@
"screen_room_timeline.*",
"screen\\.room_timeline.*",
"screen_room_typing.*",
"screen\\.image_edition\\..*",
"screen\\.media_upload.*"
]
},