diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt
index b4578c1e32..935cf617ac 100644
--- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt
+++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt
@@ -76,6 +76,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
QualityHigh(icon(R.drawable.symbol_quality_high_24)),
QualityHighSlash(icon(R.drawable.symbol_quality_high_slash_24)),
Recent(icon(R.drawable.symbol_recent_24)),
+ Redo(icon(R.drawable.symbol_redo_24)),
Save(icon(R.drawable.symbol_save_android_24)),
Search(icon(R.drawable.symbol_search_24)),
Settings(icon(R.drawable.symbol_settings_android_24)),
@@ -86,6 +87,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Transfer(icon(R.drawable.symbol_transfer_24)),
TransferDisplay(icon(R.drawable.symbol_transfer_display_48)),
Trash(icon(R.drawable.symbol_trash_24)),
+ Undo(icon(R.drawable.symbol_undo_24)),
X(icon(R.drawable.symbol_x_24)),
XCircleFill(icon(R.drawable.symbol_x_circle_fill_24))
}
diff --git a/core/ui/src/main/res/drawable/symbol_redo_24.xml b/core/ui/src/main/res/drawable/symbol_redo_24.xml
new file mode 100644
index 0000000000..71aa453290
--- /dev/null
+++ b/core/ui/src/main/res/drawable/symbol_redo_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/ui/src/main/res/drawable/symbol_undo_24.xml b/core/ui/src/main/res/drawable/symbol_undo_24.xml
new file mode 100644
index 0000000000..b76443f9d0
--- /dev/null
+++ b/core/ui/src/main/res/drawable/symbol_undo_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt
index a4385c6e7d..cd930f89d2 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt
@@ -5,6 +5,10 @@
package org.signal.mediasend
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
@@ -12,4 +16,7 @@ import androidx.compose.ui.unit.dp
object MediaSendMetrics {
val SelectedMediaPreviewSize = DpSize(44.dp, 44.dp)
val SelectedMediaPreviewShape = RoundedCornerShape(8.dp)
+
+ val ControlEnterTransition: EnterTransition = fadeIn()
+ val ControlExitTransition: ExitTransition = fadeOut()
}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt
index 10bfccfc9a..efccd93ae9 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt
@@ -32,7 +32,7 @@ import org.signal.mediasend.edit.image.ImageEditorState
*/
@Stable
class ImageController @RememberInComposition constructor(
- val editorModel: EditorModel,
+ private val editorModel: EditorModel,
private val brushWidths: BrushWidthsState = BrushWidthsState()
) {
@@ -190,6 +190,20 @@ class ImageController @RememberInComposition constructor(
}
}
+ fun undo() {
+ editorModel.undo()
+ }
+
+ fun redo() {
+ editorModel.redo()
+ }
+
+ fun clearAllEdits() {
+ while (imageEditorState.undoAvailable) {
+ editorModel.undo()
+ }
+ }
+
fun setDrawColor(color: Int) {
imageEditorState.drawColor = brushTool?.applyAlpha(color) ?: color
}
@@ -321,6 +335,7 @@ class ImageController @RememberInComposition constructor(
}
fun onDialGestureStart() {
+ imageEditorState.isGestureActive = true
val mainImage = editorModel.mainImage ?: return
initialDialScale = mainImage.localScaleX
minDialScaleDown = 1f
@@ -334,6 +349,7 @@ class ImageController @RememberInComposition constructor(
}
fun onDialGestureEnd() {
+ imageEditorState.isGestureActive = false
val mainImage = editorModel.mainImage ?: return
mainImage.commitEditorMatrix()
editorModel.postEdit(true)
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt
index ebde3bdbb9..bd573514b8 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt
@@ -58,7 +58,9 @@ import org.signal.mediasend.edit.image.BrushWidthPreview
import org.signal.mediasend.edit.image.BrushWidthsState
import org.signal.mediasend.edit.image.DrawModeColorBar
import org.signal.mediasend.edit.image.ImageEditor
+import org.signal.mediasend.edit.image.ImageEditorClearAllButton
import org.signal.mediasend.edit.image.ImageEditorToolbar
+import org.signal.mediasend.edit.image.ImageEditorUndoRedoButtons
import org.signal.mediasend.edit.image.RotationDial
import org.signal.mediasend.edit.video.VideoEditorFragment
import org.signal.mediasend.edit.video.VideoEditorToolbar
@@ -353,6 +355,22 @@ fun MediaEditScreen(
.systemBarsPadding()
)
+ ImageEditorUndoRedoButtons(
+ imageEditorController = imageController,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(top = 12.dp, start = 16.dp)
+ .systemBarsPadding()
+ )
+
+ ImageEditorClearAllButton(
+ imageEditorController = imageController,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(top = 12.dp, end = 16.dp)
+ .systemBarsPadding()
+ )
+
if (state.isSavingMedia) {
MediaEditScreenDialogs.SavingToStorageProgressDialog()
}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt
index 9be8c5b7ce..f7e65ce7c5 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt
@@ -143,37 +143,42 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller
}
down.consume()
+ state.isGestureActive = true
var previousPointerCount = 1
- while (true) {
- val event = awaitPointerEvent()
- val currentPressed = event.changes.filter { it.pressed }
- val currentCount = currentPressed.size
+ try {
+ while (true) {
+ val event = awaitPointerEvent()
+ val currentPressed = event.changes.filter { it.pressed }
+ val currentCount = currentPressed.size
+
+ if (currentCount == 0) {
+ event.changes.forEach { it.consume() }
+ touchHandler.onUp(state.editorModel)
+ state.onGestureCompleted?.invoke()
+ break
+ }
+
+ if (currentCount == 2 && previousPointerCount < 2) {
+ val newPointer = event.changes.firstOrNull { it.changedToDown() } ?: currentPressed.last()
+ val pointerIndex = event.changes.indexOf(newPointer).coerceIn(0, 1)
+ touchHandler.onSecondPointerDown(state.editorModel, state.viewMatrix, newPointer.position.toPointF(), pointerIndex)
+ } else if (currentCount == 1 && previousPointerCount == 2) {
+ val released = event.changes.firstOrNull { !it.pressed && it.previousPressed }
+ val releasedIndex = if (released != null) event.changes.indexOf(released).coerceIn(0, 1) else 0
+ touchHandler.onSecondPointerUp(state.editorModel, state.viewMatrix, releasedIndex)
+ } else if (touchHandler.hasActiveSession()) {
+ val pointers = currentPressed.take(2).map { it.position.toPointF() }.toTypedArray()
+ touchHandler.onMove(state.editorModel, pointers)
+ state.invalidate()
+ }
- if (currentCount == 0) {
event.changes.forEach { it.consume() }
- touchHandler.onUp(state.editorModel)
- state.onGestureCompleted?.invoke()
- break
+ previousPointerCount = currentCount
}
-
- if (currentCount == 2 && previousPointerCount < 2) {
- val newPointer = event.changes.firstOrNull { it.changedToDown() } ?: currentPressed.last()
- val pointerIndex = event.changes.indexOf(newPointer).coerceIn(0, 1)
- touchHandler.onSecondPointerDown(state.editorModel, state.viewMatrix, newPointer.position.toPointF(), pointerIndex)
- } else if (currentCount == 1 && previousPointerCount == 2) {
- val released = event.changes.firstOrNull { !it.pressed && it.previousPressed }
- val releasedIndex = if (released != null) event.changes.indexOf(released).coerceIn(0, 1) else 0
- touchHandler.onSecondPointerUp(state.editorModel, state.viewMatrix, releasedIndex)
- } else if (touchHandler.hasActiveSession()) {
- val pointers = currentPressed.take(2).map { it.position.toPointF() }.toTypedArray()
- touchHandler.onMove(state.editorModel, pointers)
- state.invalidate()
- }
-
- event.changes.forEach { it.consume() }
- previousPointerCount = currentCount
+ } finally {
+ state.isGestureActive = false
}
}
}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt
new file mode 100644
index 0000000000..4eb882d77e
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.edit.image
+
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import org.signal.core.ui.compose.Buttons
+import org.signal.core.ui.compose.DayNightPreviews
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.theme.SignalTheme
+import org.signal.imageeditor.core.model.EditorModel
+import org.signal.mediasend.R
+import org.signal.mediasend.edit.ImageController
+
+@Composable
+internal fun ImageEditorClearAllButton(
+ imageEditorController: ImageController?,
+ modifier: Modifier = Modifier,
+ canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false
+) {
+ ImageEditorGestureAwareControl(
+ imageEditorController = imageEditorController,
+ modifier = modifier,
+ extraCheck = { canUndo }
+ ) {
+ Buttons.MediumTonal(
+ colors = ButtonDefaults.buttonColors(
+ containerColor = SignalTheme.colors.colorSurface5,
+ contentColor = MaterialTheme.colorScheme.onSurface
+ ),
+ onClick = {
+ imageEditorController?.clearAllEdits()
+ }
+ ) {
+ Text(text = stringResource(R.string.ImageEditorClearAllButton__clear_all))
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun ImageEditorClearAllButtonPreview() {
+ Previews.Preview {
+ ImageEditorClearAllButton(
+ imageEditorController = remember { ImageController(EditorModel.create(0x0)).apply { enterDrawMode() } },
+ canUndo = true
+ )
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorGestureAwareControl.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorGestureAwareControl.kt
new file mode 100644
index 0000000000..13feb0f764
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorGestureAwareControl.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.edit.image
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import org.signal.mediasend.MediaSendMetrics
+import org.signal.mediasend.edit.ImageController
+
+/**
+ * Hides or shows content based on whether the user is performing a gesture.
+ */
+@Composable
+fun ImageEditorGestureAwareControl(
+ imageEditorController: ImageController?,
+ modifier: Modifier = Modifier,
+ extraCheck: () -> Boolean = { true },
+ content: @Composable () -> Unit
+) {
+ val isGestureActive = imageEditorController?.imageEditorState?.isGestureActive ?: false
+
+ AnimatedVisibility(
+ modifier = modifier,
+ enter = MediaSendMetrics.ControlEnterTransition,
+ exit = MediaSendMetrics.ControlExitTransition,
+ visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && !isGestureActive && extraCheck()
+ ) {
+ content()
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt
index 47eadc937b..21353201fa 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt
@@ -45,6 +45,10 @@ class ImageEditorState(
var redoAvailable: Boolean by mutableStateOf(false)
private set
+ /** True while the user is actively manipulating the image: drawing, moving, scaling, or rotating. */
+ var isGestureActive: Boolean by mutableStateOf(false)
+ internal set
+
var textEditingElement: EditorElement? = null
var isDrawing: Boolean = false
var isBlur: Boolean = false
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt
new file mode 100644
index 0000000000..0fd2d62d2b
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.edit.image
+
+import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.compose.DayNightPreviews
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.SignalIcons
+import org.signal.core.ui.compose.theme.SignalTheme
+import org.signal.imageeditor.core.model.EditorModel
+import org.signal.mediasend.R
+import org.signal.mediasend.edit.ImageController
+
+@Composable
+internal fun ImageEditorUndoRedoButtons(
+ imageEditorController: ImageController?,
+ modifier: Modifier = Modifier,
+ canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false,
+ canRedo: Boolean = imageEditorController?.imageEditorState?.redoAvailable ?: false
+) {
+ ImageEditorGestureAwareControl(imageEditorController, modifier = modifier, extraCheck = {
+ canUndo || canRedo
+ }) {
+ Row(horizontalArrangement = spacedBy(8.dp)) {
+ IconButton(
+ enabled = canUndo,
+ onClick = {
+ imageEditorController?.undo()
+ },
+ colors = IconButtonDefaults.iconButtonColors(containerColor = SignalTheme.colors.colorSurface5)
+ ) {
+ Icon(
+ imageVector = SignalIcons.Undo.imageVector,
+ contentDescription = stringResource(R.string.ImageEditorUndoRedoButtons__undo_edit)
+ )
+ }
+
+ IconButton(
+ enabled = canRedo,
+ onClick = {
+ imageEditorController?.redo()
+ },
+ colors = IconButtonDefaults.iconButtonColors(containerColor = SignalTheme.colors.colorSurface5)
+ ) {
+ Icon(
+ imageVector = SignalIcons.Redo.imageVector,
+ contentDescription = stringResource(R.string.ImageEditorUndoRedoButtons__redo_edit)
+ )
+ }
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun ImageEditorUndoRedoButtonsPreview() {
+ Previews.Preview {
+ ImageEditorUndoRedoButtons(
+ imageEditorController = remember {
+ ImageController(EditorModel.create(0x0)).apply {
+ enterDrawMode()
+ }
+ },
+ canUndo = true,
+ canRedo = true
+ )
+ }
+}
diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml
index 8a834bb72e..b8e9d3eee7 100644
--- a/feature/media-send/src/main/res/values/strings.xml
+++ b/feature/media-send/src/main/res/values/strings.xml
@@ -51,7 +51,16 @@
Brush
+
+ Clear all
+
+
+ Undo edit
+
+ Redo edit
+
+
Play video