From a9ecf3c5dcd263e49811ebc2990e66d26b994f99 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 28 May 2026 15:00:03 +0200 Subject: [PATCH] Add flip actions to image edition --- .../preview/AttachmentsPreviewEvent.kt | 2 + .../preview/AttachmentsPreviewPresenter.kt | 12 ++++ .../preview/AttachmentsPreviewView.kt | 2 + .../imageeditor/AttachmentImageEditor.kt | 37 ++++++---- .../imageeditor/AttachmentImageEditorState.kt | 47 ++++++++++-- .../AttachmentImageEditorStateProvider.kt | 10 +++ .../imageeditor/AttachmentImageEditorView.kt | 72 +++++++++++++++---- .../impl/src/main/res/values/localazy.xml | 6 ++ .../AttachmentsPreviewPresenterTest.kt | 35 ++++++++- .../imageeditor/AttachmentImageEditsTest.kt | 33 +++++++++ 10 files changed, 219 insertions(+), 37 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt index a7c64845d1..fb9fddbb48 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewEvent.kt @@ -17,6 +17,8 @@ sealed interface AttachmentsPreviewEvent { data object OpenImageEditor : AttachmentsPreviewEvent data object CloseImageEditor : AttachmentsPreviewEvent data object RotateImageToTheLeft : AttachmentsPreviewEvent + data object FlipImageHorizontally : AttachmentsPreviewEvent + data object FlipImageVertically : AttachmentsPreviewEvent data object ApplyImageEdits : AttachmentsPreviewEvent data object ResetImageEdits : AttachmentsPreviewEvent data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index cab00d99c1..0f60d7ff0e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -295,6 +295,18 @@ class AttachmentsPreviewPresenter( edits = pendingState.edits.rotateAntiClockwise() ) } + AttachmentsPreviewEvent.FlipImageHorizontally -> { + val pendingState = imageEditorState ?: return + imageEditorState = pendingState.copy( + edits = pendingState.edits.flipHorizontally() + ) + } + AttachmentsPreviewEvent.FlipImageVertically -> { + val pendingState = imageEditorState ?: return + imageEditorState = pendingState.copy( + edits = pendingState.edits.flipVertically() + ) + } AttachmentsPreviewEvent.ResetImageEdits -> { imageEditorState = imageEditorState?.copy( edits = AttachmentImageEdits() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt index 1714a8b8dc..926106a5ee 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewView.kt @@ -136,6 +136,8 @@ fun AttachmentsPreviewView( state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) }, onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) }, + onFlipHorizontallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally) }, + onFlipVerticallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageVertically) }, onCancelClick = ::postCloseImageEditor, onResetClick = ::postResetImageEditor, onDoneClick = ::postApplyImageEdits, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt index cb66802184..f8d199a74e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditor.kt @@ -87,30 +87,30 @@ class DefaultAttachmentImageEditor( decodedBitmap.recycle() } - val rotatedBitmap = normalizedBitmap.rotateQuarterTurns(edits.rotationQuarterTurns) - if (rotatedBitmap !== normalizedBitmap) { + val transformedBitmap = normalizedBitmap.applyEdits(edits) + if (transformedBitmap !== normalizedBitmap) { normalizedBitmap.recycle() } val cropRect = edits.cropRect.toPixelRect( - imageWidth = rotatedBitmap.width, - imageHeight = rotatedBitmap.height, + imageWidth = transformedBitmap.width, + imageHeight = transformedBitmap.height, ) val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 && - cropRect.width() == rotatedBitmap.width && cropRect.height() == rotatedBitmap.height + cropRect.width() == transformedBitmap.width && cropRect.height() == transformedBitmap.height val croppedBitmap = if (isCropUnchanged) { - rotatedBitmap + transformedBitmap } else { Bitmap.createBitmap( - rotatedBitmap, + transformedBitmap, cropRect.left, cropRect.top, cropRect.width(), cropRect.height(), ) } - if (croppedBitmap !== rotatedBitmap) { - rotatedBitmap.recycle() + if (croppedBitmap !== transformedBitmap) { + transformedBitmap.recycle() } val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() } @@ -141,11 +141,22 @@ internal fun exportedMimeTypeFor(sourceMimeType: String?): String { } } -private fun Bitmap.rotateQuarterTurns(quarterTurns: Int): Bitmap { - val normalizedTurns = (quarterTurns % 4 + 4) % 4 - if (normalizedTurns == 0) return this +private fun Bitmap.applyEdits(edits: AttachmentImageEdits): Bitmap { + val normalizedTurns = (edits.rotationQuarterTurns % 4 + 4) % 4 + if (normalizedTurns == 0 && !edits.isFlippedHorizontally && !edits.isFlippedVertically) { + return this + } + val centerX = width / 2f + val centerY = height / 2f val matrix = Matrix().apply { - postRotate(normalizedTurns * 90f) + val scaleX = if (edits.isFlippedHorizontally) -1f else 1f + val scaleY = if (edits.isFlippedVertically) -1f else 1f + if (scaleX < 0f || scaleY < 0f) { + postScale(scaleX, scaleY, centerX, centerY) + } + if (normalizedTurns != 0) { + postRotate(normalizedTurns * 90f, centerX, centerY) + } } return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorState.kt index 3c8af52ce8..b798df2747 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorState.kt @@ -26,6 +26,8 @@ data class AttachmentImageEditorState( data class AttachmentImageEdits( val cropRect: NormalizedCropRect = NormalizedCropRect.default(), val rotationQuarterTurns: Int = 0, + val isFlippedHorizontally: Boolean = false, + val isFlippedVertically: Boolean = false, ) { val normalizedRotationQuarterTurns: Int get() = rotationQuarterTurns % 4 @@ -34,18 +36,32 @@ data class AttachmentImageEdits( get() = normalizedRotationQuarterTurns * 90 val hasChanges: Boolean - get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0 + get() = cropRect != NormalizedCropRect.default() || + normalizedRotationQuarterTurns != 0 || + isFlippedHorizontally || + isFlippedVertically 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, - ) + cropRect = cropRect.rotateAntiClockwise() + ) + } + + fun flipHorizontally(): AttachmentImageEdits { + return copy( + isFlippedHorizontally = !isFlippedHorizontally, + // Also update the crop rect to keep the same selected area + cropRect = cropRect.flipHorizontally(), + ) + } + + fun flipVertically(): AttachmentImageEdits { + return copy( + isFlippedVertically = !isFlippedVertically, + // Also update the crop rect to keep the same selected area + cropRect = cropRect.flipVertically(), ) } } @@ -135,6 +151,23 @@ data class NormalizedCropRect( ) } + fun rotateAntiClockwise() = copy( + left = top, + top = 1f - right, + right = bottom, + bottom = 1f - left, + ) + + fun flipHorizontally() = copy( + left = 1f - right, + right = 1f - left, + ) + + fun flipVertically() = copy( + top = 1f - bottom, + bottom = 1f - top, + ) + companion object { fun default() = NormalizedCropRect( left = DEFAULT_CROP_MARGIN, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt index df4bb5257f..7c542fd1a8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditorStateProvider.kt @@ -75,6 +75,16 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider Unit, onRotateClick: () -> Unit, + onFlipHorizontallyClick: () -> Unit, + onFlipVerticallyClick: () -> Unit, onResetClick: () -> Unit, onCancelClick: () -> Unit, onDoneClick: () -> Unit, @@ -101,8 +101,18 @@ fun AttachmentImageEditorView( state.edits.rotationDegrees, state.edits.rotationDegrees, ) - val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault - + val flipHorizontalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally) + val flipHorizontalState = if (state.edits.isFlippedHorizontally) { + stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_flipped) + } else { + stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_original) + } + val flipVerticalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_vertically) + val flipVerticalState = if (state.edits.isFlippedVertically) { + stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_flipped) + } else { + stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_original) + } Scaffold( modifier = modifier.fillMaxSize(), topBar = { @@ -158,37 +168,57 @@ fun AttachmentImageEditorView( onClick = onResetClick, ) } - Box( - modifier = Modifier.weight(1f), - contentAlignment = Alignment.Center, + Row( + modifier = Modifier.weight(2f), + // Center the content horizontally + horizontalArrangement = Arrangement.Center, ) { + IconButton( + onClick = onFlipHorizontallyClick, + modifier = Modifier + .clearAndSetSemantics { + contentDescription = flipHorizontalLabel + stateDescription = flipHorizontalState + } + ) { + Icon( + imageVector = CompoundIcons.FlipHorizontal(), + contentDescription = null, + ) + } 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, ) } + IconButton( + onClick = onFlipVerticallyClick, + modifier = Modifier + .clearAndSetSemantics { + contentDescription = flipVerticalLabel + stateDescription = flipVerticalState + } + ) { + Icon( + imageVector = CompoundIcons.FlipVertical(), + contentDescription = null, + ) + } } Box( modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd, ) { TextButton( - text = stringResource(CommonStrings.action_done), + text = stringResource(CommonStrings.action_save), onClick = onDoneClick, ) } @@ -204,6 +234,8 @@ private fun BoxScope.CropEditorCanvas( ) { var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) } val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns + val flipScaleX = if (state.edits.isFlippedHorizontally) -1f else 1f + val flipScaleY = if (state.edits.isFlippedVertically) -1f else 1f var imageRect by remember { mutableStateOf(Rect.Zero) } @@ -257,6 +289,10 @@ private fun BoxScope.CropEditorCanvas( contentDescription = null, modifier = Modifier .requiredSize(imageLayoutWidthDp, imageLayoutHeightDp) + .graphicsLayer { + scaleX = flipScaleX + scaleY = flipScaleY + } .graphicsLayer { rotationZ = rotationQuarterTurns * 90f }, contentScale = ContentScale.Fit, ) @@ -266,6 +302,10 @@ private fun BoxScope.CropEditorCanvas( contentDescription = stringResource(CommonStrings.common_image), modifier = Modifier .requiredSize(imageLayoutWidthDp, imageLayoutHeightDp) + .graphicsLayer { + scaleX = flipScaleX + scaleY = flipScaleY + } .graphicsLayer { rotationZ = rotationQuarterTurns * 90f }, contentScale = ContentScale.Fit, onState = { painterState -> @@ -642,6 +682,8 @@ internal fun AttachmentImageEditorViewPreview( state = state, onCropRectChange = {}, onRotateClick = {}, + onFlipHorizontallyClick = {}, + onFlipVerticallyClick = {}, onResetClick = {}, onCancelClick = {}, onDoneClick = {}, diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index 908646b048..173a4e78ad 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -16,6 +16,12 @@ "Travel & Places" "Recent emojis" "Symbols" + "Flip image horizontally" + "Flipped horizontally" + "Original" + "Flip image vertically" + "Flipped vertically" + "Original" "Rotate the image to the left" "%1$d degree" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 140d4f8ea3..817eb740d7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -624,7 +624,11 @@ class AttachmentsPreviewPresenterTest { val croppedState = awaitItem() croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) val rotatedState = awaitItem() - rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) + rotatedState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally) + val flippedHorizontallyState = awaitItem() + flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically) + val flippedState = awaitItem() + flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last() assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri) @@ -638,9 +642,36 @@ class AttachmentsPreviewPresenterTest { right = cropRect.bottom, bottom = 1f - cropRect.left, ) - reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(rotatedCropRect) + val flippedCropRect = NormalizedCropRect( + left = 1f - rotatedCropRect.right, + top = 1f - rotatedCropRect.bottom, + right = 1f - rotatedCropRect.left, + bottom = 1f - rotatedCropRect.top, + ) + reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(flippedCropRect) assertThat(reopenedState.imageEditorState.edits.rotationQuarterTurns).isEqualTo(3) assertThat(reopenedState.imageEditorState.edits.rotationDegrees).isEqualTo(270) + assertThat(reopenedState.imageEditorState.edits.isFlippedHorizontally).isTrue() + assertThat(reopenedState.imageEditorState.edits.isFlippedVertically).isTrue() + } + } + + @Test + fun `present - image editor flip events update edits`() = runTest { + val presenter = createAttachmentsPreviewPresenter(displayMediaQualitySelectorViews = true) + + presenter.test { + val initialState = awaitItem() + initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor) + val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last() + + editorState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally) + val flippedHorizontallyState = awaitItem() + assertThat(flippedHorizontallyState.imageEditorState?.edits?.isFlippedHorizontally).isTrue() + + flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically) + val flippedState = awaitItem() + assertThat(flippedState.imageEditorState?.edits?.isFlippedVertically).isTrue() } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt index 43c893dd05..eb989967ef 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/preview/imageeditor/AttachmentImageEditsTest.kt @@ -42,4 +42,37 @@ class AttachmentImageEditsTest { assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.8f) assertThat(result.hasChanges).isTrue() } + + @Test + fun `flip horizontally updates crop and change tracking`() { + val sut = AttachmentImageEdits( + cropRect = NormalizedCropRect( + left = 0.1f, + top = 0.3f, + right = 0.6f, + bottom = 0.9f, + ) + ) + val result = sut.flipHorizontally() + assertThat(result.isFlippedHorizontally).isTrue() + assertThat(result.cropRect.left).isWithin(0.0001f).of(0.4f) + assertThat(result.cropRect.right).isWithin(0.0001f).of(0.9f) + assertThat(result.cropRect.top).isWithin(0.0001f).of(0.3f) + assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.9f) + assertThat(result.hasChanges).isTrue() + } + + @Test + fun `flip vertical twice resets to default state`() { + val edits = AttachmentImageEdits().flipVertically().flipVertically() + assertThat(edits.isFlippedVertically).isFalse() + assertThat(edits.hasChanges).isFalse() + } + + @Test + fun `flip horizontally twice resets to default state`() { + val edits = AttachmentImageEdits().flipHorizontally().flipHorizontally() + assertThat(edits.isFlippedVertically).isFalse() + assertThat(edits.hasChanges).isFalse() + } }