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>
This commit is contained in:
Gianluca Iavicoli 2026-05-21 15:08:26 +02:00 committed by Benoit Marty
parent 00efd9a01c
commit bcad1f9dce
17 changed files with 1517 additions and 49 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,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
}

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,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<SendActionState>,
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>) {
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.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<Attachment
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.ReadyToUpload(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(aMediaUploadInfo())),
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"), aMediaUploadInfo())),
anAttachmentsPreviewState(
imageEditorState = AttachmentImageEditorState(LocalMedia("file://path".toUri(), anImageMediaInfo()), edits = AttachmentImageEdits())
),
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

@ -39,11 +39,11 @@ 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
@ -56,6 +56,8 @@ 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
@ -81,6 +83,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 +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)
}

View file

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

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) {
"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
}

View file

@ -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<CropDragTarget?>(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 = {},
)
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="screen_media_upload_preview_rotate">Rotate</string>
<plurals name="a11y_media_upload_preview_rotation_degrees">
<item quantity="one">%1$d degree</item>
<item quantity="other">%1$d degrees</item>
</plurals>
</resources>

View file

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

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:490534398a7061e52f66e3ec46d6212107ee2512ff91c768d6618adb24e858f5
size 376383

View file

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