Merge pull request #6949 from element-hq/feature/bma/flipImage

Add flip actions to image edition
This commit is contained in:
Benoit Marty
2026-06-03 15:01:12 +02:00
committed by GitHub
19 changed files with 239 additions and 51 deletions
@@ -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
@@ -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()
@@ -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,
@@ -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)
}
@@ -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,
@@ -75,6 +75,16 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider<Attachm
),
previewDebug = true,
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipHorizontally(),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipVertically(),
),
)
}
@@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -24,7 +23,6 @@ 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
@@ -90,6 +88,8 @@ fun AttachmentImageEditorView(
state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> 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 = {},
@@ -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_flip_image_horizontally">"Flip image horizontally"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Flipped horizontally"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Flip image vertically"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Flipped vertically"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</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>
@@ -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()
}
}
@@ -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()
}
}
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51f0b3f7e4bb16728f21055de37b7b2780fd2a1fc65b6bd4564334daeab20763
size 329042
oid sha256:b64ae2fd2462ec3b62750084f47ec29c984ef4279a465b926c9d36e23834092e
size 328321
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5febc6580e4f0bda75a27445157ace7f1acb620c17cba55ca5d2a9330743c1de
size 283397
oid sha256:864883e9220a4d653d2d6b555ace89c77891a1ade04cd2f49d89c8bb765c6774
size 282733
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:34b6dfe4e65612615c3dc87e5f65bd0b160d97527c4a4749b496bf8d48819d96
size 256641
oid sha256:de82970da241172b94f822f8792e6fc721dcfc7f0f67e3523962e253e9750884
size 255933
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d99c470fd5134e0a84b284ed32f4c93de01561e630ef32d7c22a4b476bb871b1
size 277852
oid sha256:e95d7091485a2e5bb3ca8966c8fa93f21e2e5fd2d939e7582224ebf32c05b619
size 277160
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4b15a04a861812e3e4dee0a6a30c6afef4ab171c5261d1e5bd5a234bb7296d97
size 251908
oid sha256:1ce3085cf45a94e014c9ea79a46d47741fb098853ac81e7531fdbfe32e8d6f5a
size 251213
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c32b4744750b9f18612bded2e292dde151e2bfdd8a69a36c88044f5ca3a76f8e
size 311315
oid sha256:1ca032575e4ebd75a2ebc5a4cb9f966e48cdfa56f4ff42d6d9309fa3577a1adb
size 310609
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:846467c023fb2f6a091c6d350927c2891ddcd164dace189d157a8570339e2d92
size 282932
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:91472488cf265e9ba62a8b6690fa34ced41ebbf7a4668424c02464086aaf9f9c
size 283181
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f52a267ee2aa300185191aa3e610787c58d7222bc8e4a7570879d4c5fd37133d
size 328936
oid sha256:0c6a5c845db0d9ffef0074062cea67134bdabbe91fd1826b2236a0f92e4a6dff
size 328222