diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt index 53c8b91dd5..49104988c6 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/EditorController.kt @@ -5,6 +5,7 @@ package org.signal.mediasend.edit +import android.graphics.Paint import android.net.Uri import androidx.compose.runtime.Stable import androidx.compose.runtime.annotation.RememberInComposition @@ -19,7 +20,7 @@ sealed interface EditorController { @Stable class Container @RememberInComposition constructor() { - val controllers = SnapshotStateMap() + private val controllers = SnapshotStateMap() fun getOrCreateImageController(uri: Uri, editorModel: EditorModel): Image { return controllers.getOrPut(uri) { Image(editorModel) } as Image @@ -32,11 +33,51 @@ sealed interface EditorController { override val isUserInEdit: Boolean get() = mode != Mode.NONE + val imageEditorState = ImageEditorState(editorModel).also { + it.onGestureCompleted = { drawSessionDirty = true } + } + var mode: Mode by mutableStateOf(Mode.NONE) - var isCropLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked) + var isCropAspectRatioLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked) private set + val dialRotation: Float + get() = editorModel.mainImage?.let { Math.toDegrees(it.localRotationAngle.toDouble()).toFloat() } ?: 0f + + private var initialDialScale: Float = editorModel.mainImage?.localScaleX ?: 1f + private var initialDialImageDegrees: Float = 0f + private var minDialScaleDown: Float = 1f + private var drawSessionSnapshot: ByteArray? = null + private var drawSessionDirty: Boolean = false + + var showDiscardDialog: Boolean by mutableStateOf(false) + private set + + val hasUnsavedChanges: Boolean + get() = when { + mode == Mode.CROP -> imageEditorState.undoAvailable + isInDrawSession -> drawSessionDirty + else -> false + } + + fun requestCancelEdit() { + if (hasUnsavedChanges) { + showDiscardDialog = true + } else { + cancelEdit() + } + } + + fun dismissDiscardDialog() { + showDiscardDialog = false + } + + fun confirmDiscardEdit() { + showDiscardDialog = false + cancelEdit() + } + val isUserDrawing: Boolean get() = mode == Mode.DRAW || mode == Mode.HIGHLIGHT @@ -58,26 +99,78 @@ sealed interface EditorController { } fun cancelEdit() { - mode = Mode.NONE + when { + mode == Mode.CROP -> { + editorModel.clearUndoStack() + editorModel.doneCrop() + } + isInDrawSession -> { + drawSessionSnapshot?.let { editorModel.restoreFromSnapshot(it) } + } + } + exitEditMode() } fun commitEdit() { + if (mode == Mode.CROP) { + editorModel.doneCrop() + } + exitEditMode() + } + + private fun exitEditMode() { + drawSessionSnapshot = null + drawSessionDirty = false mode = Mode.NONE + imageEditorState.isDrawing = false + imageEditorState.isBlur = false } fun enterDrawMode() { + snapshotIfNewDrawSession() mode = Mode.DRAW + syncDrawingState() } fun enterHighlightMode() { + snapshotIfNewDrawSession() mode = Mode.HIGHLIGHT + syncDrawingState() } fun enterBlurMode() { + snapshotIfNewDrawSession() mode = Mode.BLUR + syncDrawingState() + } + + private fun snapshotIfNewDrawSession() { + if (!isInDrawSession) { + drawSessionSnapshot = editorModel.createSnapshot() + drawSessionDirty = false + } + } + + fun setDrawColor(color: Int) { + imageEditorState.drawColor = color + } + + fun setDrawThickness(thickness: Float) { + imageEditorState.drawThickness = thickness + } + + private val isInDrawSession: Boolean + get() = mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR + + private fun syncDrawingState() { + imageEditorState.isDrawing = true + imageEditorState.isBlur = mode == Mode.BLUR + imageEditorState.drawCap = if (mode == Mode.HIGHLIGHT) Paint.Cap.SQUARE else Paint.Cap.ROUND } fun enterCropMode() { + editorModel.startCrop() + initialDialScale = editorModel.mainImage?.localScaleX ?: 1f mode = Mode.CROP } @@ -91,12 +184,12 @@ sealed interface EditorController { fun lockCrop() { editorModel.setCropAspectLock(true) - isCropLocked = true + isCropAspectRatioLocked = true } fun unlockCrop() { editorModel.setCropAspectLock(false) - isCropLocked = false + isCropAspectRatioLocked = false } fun flip() { @@ -107,6 +200,26 @@ sealed interface EditorController { editorModel.rotate90anticlockwise() } + fun onDialGestureStart() { + val mainImage = editorModel.mainImage ?: return + initialDialScale = mainImage.localScaleX + minDialScaleDown = 1f + editorModel.pushUndoPoint() + editorModel.updateUndoRedoAvailabilityState() + initialDialImageDegrees = Math.toDegrees(mainImage.localRotationAngle.toDouble()).toFloat() + } + + fun onDialRotationChanged(degrees: Float) { + editorModel.setMainImageEditorMatrixRotation(degrees - initialDialImageDegrees, minDialScaleDown) + } + + fun onDialGestureEnd() { + val mainImage = editorModel.mainImage ?: return + mainImage.commitEditorMatrix() + editorModel.postEdit(true) + initialDialScale = mainImage.localScaleX + } + fun toggleImageQuality() { // TODO } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt index c2f730e300..588c9f6158 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt @@ -5,39 +5,98 @@ package org.signal.mediasend.edit +import android.graphics.PointF +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.viewinterop.AndroidView -import org.signal.imageeditor.core.ImageEditorView +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import org.signal.imageeditor.core.ImageEditorTouchHandler @Composable fun ImageEditor( - controller: EditorController.Image, + state: ImageEditorState, modifier: Modifier = Modifier ) { - AndroidView( - factory = { context -> ImageEditorView(context) }, - update = { view -> - view.model = controller.editorModel - view.mode = mapMode(controller.mode) - }, - onReset = { }, - modifier = modifier.clipToBounds() - ) -} + val context = LocalContext.current -private fun mapMode(mode: EditorController.Image.Mode): ImageEditorView.Mode { - return when (mode) { - EditorController.Image.Mode.NONE -> ImageEditorView.Mode.MoveAndResize - EditorController.Image.Mode.CROP -> ImageEditorView.Mode.MoveAndResize - EditorController.Image.Mode.TEXT -> ImageEditorView.Mode.MoveAndResize - EditorController.Image.Mode.DRAW -> ImageEditorView.Mode.Draw - EditorController.Image.Mode.HIGHLIGHT -> ImageEditorView.Mode.Draw - EditorController.Image.Mode.BLUR -> ImageEditorView.Mode.Blur - EditorController.Image.Mode.MOVE_STICKER -> ImageEditorView.Mode.MoveAndResize - EditorController.Image.Mode.MOVE_TEXT -> ImageEditorView.Mode.MoveAndResize - EditorController.Image.Mode.DELETE -> ImageEditorView.Mode.MoveAndResize - EditorController.Image.Mode.INSERT_STICKER -> ImageEditorView.Mode.MoveAndResize + DisposableEffect(state) { + state.attach() + onDispose { state.detach() } + } + + Canvas( + modifier = modifier + .clipToBounds() + .onSizeChanged { state.updateViewMatrix(it.width.toFloat(), it.height.toFloat()) } + .imageEditorPointerInput(state) + ) { + state.revision + + val nativeCanvas = drawContext.canvas.nativeCanvas + val rendererContext = state.getOrCreateRendererContext(context, nativeCanvas) + rendererContext.save() + try { + rendererContext.canvasMatrix.initial(state.viewMatrix) + state.editorModel.draw(rendererContext, null) + } finally { + rendererContext.restore() + } } } + +private fun Modifier.imageEditorPointerInput(state: ImageEditorState): Modifier { + return this.pointerInput(state) { + val touchHandler = ImageEditorTouchHandler() + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = true) + down.consume() + touchHandler.setDrawing(state.isDrawing, state.isBlur) + touchHandler.setDrawingBrush(state.drawColor, state.drawThickness, state.drawCap) + touchHandler.onDown(state.editorModel, state.viewMatrix, down.position.toPointF()) + + var previousPointerCount = 1 + + 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() + } + + event.changes.forEach { it.consume() } + previousPointerCount = currentCount + } + } + } +} + +private fun Offset.toPointF(): PointF = PointF(x, y) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt new file mode 100644 index 0000000000..97340cbc4e --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorState.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Point +import android.graphics.RectF +import android.graphics.Typeface +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.signal.imageeditor.core.Bounds +import org.signal.imageeditor.core.Renderer +import org.signal.imageeditor.core.RendererContext +import org.signal.imageeditor.core.model.EditorModel + +/** + * Compose-observable wrapper around [EditorModel]. + * + * Hooks into the model's invalidation callback so that a revision counter (read during the + * Canvas draw phase) triggers redraws whenever the model changes. This gives us unidirectional + * flow: mutations go into the model, the composable only reads and renders. + */ +@Stable +class ImageEditorState( + val editorModel: EditorModel +) { + + var revision: Long by mutableLongStateOf(0L) + private set + + var undoAvailable: Boolean by mutableStateOf(false) + private set + + var redoAvailable: Boolean by mutableStateOf(false) + private set + + var isDrawing: Boolean = false + var isBlur: Boolean = false + var drawColor: Int = 0xff000000.toInt() + var drawThickness: Float = 0.02f + var drawCap: Paint.Cap = Paint.Cap.ROUND + var onGestureCompleted: (() -> Unit)? = null + + val viewMatrix: Matrix = Matrix() + val visibleViewPort: RectF = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM) + + private val viewPort: RectF = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM) + private val screen: RectF = RectF() + + private var rendererContext: RendererContext? = null + + private val rendererReady = RendererContext.Ready { renderer: Renderer, cropMatrix: Matrix?, size: Point? -> + editorModel.onReady(renderer, cropMatrix, size) + revision++ + } + + private val rendererInvalidate = RendererContext.Invalidate { _: Renderer -> + revision++ + } + + private val defaultTypefaceProvider = RendererContext.TypefaceProvider { _: Context, _: Renderer, _: RendererContext.Invalidate -> + Typeface.DEFAULT + } + + /** Manually triggers a Canvas redraw. Call after touch moves that modify the model directly. */ + fun invalidate() { + revision++ + } + + /** Hooks into the [EditorModel]'s invalidation and undo/redo callbacks. Call in [DisposableEffect]. */ + fun attach() { + editorModel.setInvalidate { revision++ } + editorModel.setUndoRedoStackListener { undo, redo -> + undoAvailable = undo + redoAvailable = redo + } + } + + /** Unhooks from the [EditorModel]. Call in [DisposableEffect]'s onDispose. */ + fun detach() { + editorModel.setInvalidate(null) + editorModel.setUndoRedoStackListener(null) + } + + /** Recomputes the view matrix to map the editor's coordinate space to the given pixel dimensions. */ + fun updateViewMatrix(width: Float, height: Float) { + screen.set(0f, 0f, width, height) + viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL) + + val values = FloatArray(9) + viewMatrix.getValues(values) + val scale = values[0] / values[4] + + val tempViewPort = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM) + if (scale < 1) { + tempViewPort.top /= scale + tempViewPort.bottom /= scale + } else { + tempViewPort.left *= scale + tempViewPort.right *= scale + } + + visibleViewPort.set(tempViewPort) + viewMatrix.setRectToRect(visibleViewPort, screen, Matrix.ScaleToFit.CENTER) + editorModel.setVisibleViewPort(visibleViewPort) + revision++ + } + + /** Returns a cached [RendererContext], recreating it only when the canvas instance changes. */ + fun getOrCreateRendererContext(context: Context, canvas: Canvas): RendererContext { + val current = rendererContext + if (current != null && current.canvas === canvas) return current + return RendererContext(context, canvas, rendererReady, rendererInvalidate, defaultTypefaceProvider).also { + rendererContext = it + } + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt index 159f6ec851..1d01f8097c 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbar.kt @@ -176,7 +176,7 @@ private fun ImageEditorCropAndResizeToolbar( val cropUnlockImageVector = SignalIcons.CropUnlock.imageVector IconCrossfadeToggleButton( - target = if (imageEditorController.isCropLocked) CropLock.LOCKED else CropLock.UNLOCKED, + target = if (imageEditorController.isCropAspectRatioLocked) CropLock.LOCKED else CropLock.UNLOCKED, setTarget = { target -> when (target) { CropLock.LOCKED -> imageEditorController.lockCrop() @@ -208,9 +208,16 @@ private fun CommitButton(imageEditorController: EditorController.Image) { @Composable private fun DiscardButton(imageEditorController: EditorController.Image) { + if (imageEditorController.showDiscardDialog) { + MediaSendDialogs.DiscardEditsConfirmationDialog( + onDiscard = imageEditorController::confirmDiscardEdit, + onDismiss = imageEditorController::dismissDiscardDialog + ) + } + ImageEditorButton( imageVector = SignalIcons.X.imageVector, - onClick = imageEditorController::cancelEdit, + onClick = imageEditorController::requestCancelEdit, colors = IconButtons.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ) 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 57416003f3..3e7e9b9ae7 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 @@ -79,7 +79,7 @@ fun MediaEditScreen( when (val editorState = state.editorStateMap[uri]) { is EditorState.Image -> { ImageEditor( - controller = controllers.getOrCreateImageController(uri, editorState.model), + state = controllers.getOrCreateImageController(uri, editorState.model).imageEditorState, modifier = Modifier.fillMaxSize() ) } @@ -118,6 +118,14 @@ fun MediaEditScreen( when (currentController) { is EditorController.Image -> { + if (currentController.mode == EditorController.Image.Mode.CROP) { + RotationDial( + imageEditorController = currentController, + modifier = Modifier + .widthIn(max = 380.dp) + .padding(horizontal = 16.dp) + ) + } if (isSmallWindowBreakpoint) { ImageEditorToolbar(imageEditorController = currentController) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaSendDialogs.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaSendDialogs.kt new file mode 100644 index 0000000000..8e7edb7cc2 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaSendDialogs.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews +import org.signal.mediasend.R + +object MediaSendDialogs { + + @Composable + fun DiscardEditsConfirmationDialog( + onDiscard: () -> Unit, + onDismiss: () -> Unit + ) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.MediaSendDialogs__discard_changes), + body = stringResource(R.string.MediaSendDialogs__youll_lose_any_changes), + confirm = stringResource(R.string.MediaSendDialogs__discard), + onConfirm = onDiscard, + dismiss = stringResource(android.R.string.cancel), + onDismiss = onDismiss + ) + } +} + +@Preview +@Composable +private fun DiscardEditsConfirmationDialogPreview() { + Previews.Preview { + MediaSendDialogs.DiscardEditsConfirmationDialog( + onDiscard = {}, + onDismiss = {} + ) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/RotationDial.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/RotationDial.kt new file mode 100644 index 0000000000..18ef64d810 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/RotationDial.kt @@ -0,0 +1,246 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Previews +import org.signal.imageeditor.core.model.EditorModel +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.roundToInt + +private const val MAX_DEGREES: Float = 44.99999f +private const val MIN_DEGREES: Float = -44.99999f + +private val SPACE_BETWEEN_INDICATORS = 12.dp +private val INDICATOR_WIDTH = 1.dp +private val MINOR_INDICATOR_HEIGHT = 12.dp +private val MAJOR_INDICATOR_HEIGHT = 24.dp + +@Composable +fun RotationDial( + imageEditorController: EditorController.Image, + modifier: Modifier = Modifier +) { + val hapticFeedback = LocalHapticFeedback.current + val spaceBetweenIndicatorsPx = with(LocalDensity.current) { SPACE_BETWEEN_INDICATORS.toPx() } + + var degrees by remember { mutableFloatStateOf(imageEditorController.dialRotation) } + var isInGesture by remember { mutableStateOf(false) } + + val snapDegrees = calculateSnapDegrees(degrees, isInGesture) + val dialDegrees = getDialDegrees(snapDegrees) + val displayDegree = dialDegrees.roundToInt() + + val modFiveColor = MaterialTheme.colorScheme.onSurface + val minorColor = MaterialTheme.colorScheme.outline + + Box( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .clip(RoundedCornerShape(percent = 50)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + down.consume() + + isInGesture = true + degrees = imageEditorController.dialRotation + imageEditorController.onDialGestureStart() + + val dragged = drag(down.id) { change -> + val dragAmount = change.positionChange().x + change.consume() + + val degreeIncrement = -dragAmount / spaceBetweenIndicatorsPx + val prevDialDegrees = getDialDegrees(degrees) + val newDialDegrees = getDialDegrees(degrees + degreeIncrement) + + val offEndOfMax = prevDialDegrees >= MAX_DEGREES / 2f && newDialDegrees <= MIN_DEGREES / 2f + val offEndOfMin = newDialDegrees >= MAX_DEGREES / 2f && prevDialDegrees <= MIN_DEGREES / 2f + + if (prevDialDegrees.roundToInt() != newDialDegrees.roundToInt()) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + + val newDegrees = when { + offEndOfMax -> degrees + (MAX_DEGREES - prevDialDegrees) + offEndOfMin -> degrees - (MAX_DEGREES - abs(prevDialDegrees)) + else -> degrees + degreeIncrement + } + + degrees = newDegrees + val newSnapDegrees = calculateSnapDegrees(newDegrees, true) + imageEditorController.onDialRotationChanged(newSnapDegrees) + } + + isInGesture = false + val finalSnapDegrees = calculateSnapDegrees(degrees, false) + imageEditorController.onDialRotationChanged(finalSnapDegrees) + imageEditorController.onDialGestureEnd() + } + } + ) { + // Tick marks layer (full width, centered on pill center) + Canvas(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) { + val canvasWidth = size.width + val canvasHeight = size.height + val centerY = canvasHeight / 2f + val indicatorWidthPx = INDICATOR_WIDTH.toPx() + val minorHeightPx = MINOR_INDICATOR_HEIGHT.toPx() + val majorHeightPx = MAJOR_INDICATOR_HEIGHT.toPx() + val spacingPx = SPACE_BETWEEN_INDICATORS.toPx() + + val approximateCenterDegree = dialDegrees.roundToInt() + val fractionalOffset = dialDegrees - approximateCenterDegree + val dialOffset = spacingPx * fractionalOffset + + val centerX = canvasWidth / 2f + val startX = centerX - dialOffset + + fun heightForDegree(degree: Int): Float = if (degree == 0) majorHeightPx else minorHeightPx + + // Draw rightward from center + var currentDegree = approximateCenterDegree + var x = startX + while (x < canvasWidth && currentDegree <= ceil(MAX_DEGREES).toInt()) { + val h = heightForDegree(currentDegree) + val color = if (currentDegree % 5 == 0) modFiveColor else minorColor + drawRect( + color = color, + topLeft = Offset(x - indicatorWidthPx / 2f, centerY - h / 2f), + size = Size(indicatorWidthPx, h) + ) + x += spacingPx + currentDegree += 1 + } + + // Draw leftward from center + currentDegree = approximateCenterDegree - 1 + x = startX - spacingPx + while (x >= 0 && currentDegree >= floor(MIN_DEGREES).toInt()) { + val h = heightForDegree(currentDegree) + val color = if (currentDegree % 5 == 0) modFiveColor else minorColor + drawRect( + color = color, + topLeft = Offset(x - indicatorWidthPx / 2f, centerY - h / 2f), + size = Size(indicatorWidthPx, h) + ) + x -= spacingPx + currentDegree -= 1 + } + + // Draw the center-most indicator on top + val centerHeight = heightForDegree(approximateCenterDegree) + drawRect( + color = modFiveColor, + topLeft = Offset(centerX - indicatorWidthPx / 2f, centerY - centerHeight / 2f), + size = Size(indicatorWidthPx, centerHeight) + ) + } + + // Degree text overlay (on top of ticks, with background to cover them) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxHeight() + ) { + Text( + text = "$displayDegree", + color = MaterialTheme.colorScheme.onSurface, + fontSize = 15.sp, + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(start = 16.dp, end = 24.dp) + ) + + Dividers.Vertical( + thickness = Dp.Hairline, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.fillMaxHeight().padding(vertical = 22.dp) + ) + } + } +} + +private fun getDialDegrees(degrees: Float): Float { + val alpha = degrees % 360f + + if (alpha % 90 == 0f) { + return 0f + } + + val beta = floor(alpha / 90f) + val offset = alpha - beta * 90f + + return if (offset > 45f) { + offset - 90f + } else { + offset + } +} + +private fun calculateSnapDegrees(degrees: Float, isInGesture: Boolean): Float { + return if (isInGesture) { + val dialDegrees = getDialDegrees(degrees) + if (dialDegrees.roundToInt() == 0) { + degrees - dialDegrees + } else { + degrees + } + } else { + degrees + } +} + +@Preview +@Composable +fun RotationDialPreview() { + Previews.Preview { + RotationDial( + imageEditorController = remember { + EditorController.Image(EditorModel.create(0)) + } + ) + } +} diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml new file mode 100644 index 0000000000..d6b00d08c9 --- /dev/null +++ b/feature/media-send/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Discard changes? + You\'ll lose any changes you\'ve made to this photo. + Discard + diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.java new file mode 100644 index 0000000000..721c042588 --- /dev/null +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.java @@ -0,0 +1,196 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.imageeditor.core; + +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PointF; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.imageeditor.core.model.EditorElement; +import org.signal.imageeditor.core.model.EditorModel; +import org.signal.imageeditor.core.model.ThumbRenderer; +import org.signal.imageeditor.core.renderers.BezierDrawingRenderer; + +/** + * Public facade for touch handling on an {@link EditorModel}. + *

+ * Encapsulates the {@link EditSession} creation and lifecycle so that callers outside + * this package (e.g. a Compose component) can drive the editor without accessing + * package-private classes directly. + *

+ * Usage: call the on* methods in order as pointer events arrive. The handler manages + * edit session state internally. + */ +public final class ImageEditorTouchHandler { + + private boolean drawing; + private boolean blur; + private int drawColor = 0xff000000; + private float drawThickness = 0.02f; + @NonNull + private Paint.Cap drawCap = Paint.Cap.ROUND; + + @Nullable private EditSession editSession; + private boolean moreThanOnePointerUsedInSession; + + /** Configures whether the next gesture should create a drawing session if no element is hit. */ + public void setDrawing(boolean drawing, boolean blur) { + this.drawing = drawing; + this.blur = blur; + } + + /** Sets the brush parameters used when creating new drawing sessions. */ + public void setDrawingBrush(int color, float thickness, @NonNull Paint.Cap cap) { + this.drawColor = color; + this.drawThickness = thickness; + this.drawCap = cap; + } + + /** Begins a new gesture. Creates either a move/resize, thumb drag, or drawing session. */ + @Nullable + public EditorElement onDown(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF point) { + Matrix inverse = new Matrix(); + EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse); + + moreThanOnePointerUsedInSession = false; + model.pushUndoPoint(); + editSession = startEdit(model, viewMatrix, inverse, point, selected); + + return editSession != null ? editSession.getSelected() : null; + } + + /** Feeds pointer positions to the active session. Call for every move event. */ + public void onMove(@NonNull EditorModel model, @NonNull PointF[] pointers) { + if (editSession == null) return; + + int pointerCount = Math.min(2, pointers.length); + for (int p = 0; p < pointerCount; p++) { + editSession.movePoint(p, pointers[p]); + } + model.moving(editSession.getSelected()); + } + + /** Transitions a single-finger session to a two-finger session (e.g. pinch-to-zoom). */ + public void onSecondPointerDown(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF newPointerPoint, int pointerIndex) { + if (editSession == null) return; + + moreThanOnePointerUsedInSession = true; + editSession.commit(); + model.pushUndoPoint(); + + Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix); + if (newInverse != null) { + editSession = editSession.newPoint(newInverse, newPointerPoint, pointerIndex); + } else { + editSession = null; + } + + if (editSession == null) { + model.dragDropRelease(); + } + } + + /** Transitions a two-finger session back to single-finger when one pointer lifts. */ + public void onSecondPointerUp(@NonNull EditorModel model, @NonNull Matrix viewMatrix, int releasedIndex) { + if (editSession == null) return; + + editSession.commit(); + model.pushUndoPoint(); + model.dragDropRelease(); + + Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix); + if (newInverse != null) { + editSession = editSession.removePoint(newInverse, releasedIndex); + } else { + editSession = null; + } + } + + /** Ends the current gesture: commits the session and calls {@link EditorModel#postEdit}. */ + public void onUp(@NonNull EditorModel model) { + if (editSession != null) { + editSession.commit(); + model.dragDropRelease(); + editSession = null; + } + model.postEdit(moreThanOnePointerUsedInSession); + } + + public void cancel() { + editSession = null; + } + + public boolean hasActiveSession() { + return editSession != null; + } + + @Nullable + public EditorElement getSelected() { + return editSession != null ? editSession.getSelected() : null; + } + + private @Nullable EditSession startEdit( + @NonNull EditorModel model, + @NonNull Matrix viewMatrix, + @NonNull Matrix inverse, + @NonNull PointF point, + @Nullable EditorElement selected + ) { + EditSession session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected); + if (session == null && drawing) { + return startDrawingSession(model, viewMatrix, point); + } + return session; + } + + private @Nullable EditSession startDrawingSession(@NonNull EditorModel model, @NonNull Matrix viewMatrix, @NonNull PointF point) { + BezierDrawingRenderer renderer = new BezierDrawingRenderer(drawColor, drawThickness * Bounds.FULL_BOUNDS.width(), drawCap, model.findCropRelativeToRoot()); + EditorElement element = new EditorElement(renderer, blur ? EditorModel.Z_MASK : EditorModel.Z_DRAWING); + + model.addElementCentered(element, 1); + + Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix); + + return DrawingSession.start(element, renderer, elementInverseMatrix, point); + } + + private static @Nullable EditSession startMoveAndResizeSession( + @NonNull EditorModel model, + @NonNull Matrix viewMatrix, + @NonNull Matrix inverse, + @NonNull PointF point, + @Nullable EditorElement selected + ) { + if (selected == null) return null; + + if (selected.getRenderer() instanceof ThumbRenderer) { + ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer(); + + EditorElement thumbControlledElement = model.findById(thumb.getElementToControl()); + if (thumbControlledElement == null) return null; + + EditorElement thumbsParent = model.getRoot().findParent(selected); + if (thumbsParent == null) return null; + + Matrix thumbContainerRelativeMatrix = model.findRelativeMatrix(thumbsParent, thumbControlledElement); + if (thumbContainerRelativeMatrix == null) return null; + + selected = thumbControlledElement; + + Matrix elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix); + if (elementInverseMatrix != null) { + return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumbContainerRelativeMatrix, thumb.getControlPoint(), point); + } else { + return null; + } + } + + return ElementDragEditSession.startDrag(selected, inverse, point); + } +} diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/model/EditorModel.java b/lib/image-editor/src/main/java/org/signal/imageeditor/core/model/EditorModel.java index 3e978923a5..da9f96c591 100644 --- a/lib/image-editor/src/main/java/org/signal/imageeditor/core/model/EditorModel.java +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/model/EditorModel.java @@ -156,6 +156,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { public void setMainImageEditorMatrixRotation(float angle, float minScaleDown) { setEditorMatrixToRotationMatrixAboutParentsOrigin(editorElementHierarchy.getMainImage(), angle); scaleMainImageEditorMatrixToFitInsideCropBounds(minScaleDown, 2f); + invalidate.run(); } private void scaleMainImageEditorMatrixToFitInsideCropBounds(float minScaleDown, float maxScaleUp) { @@ -276,6 +277,32 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element; } + /** Serializes the current element tree for later restoration via {@link #restoreFromSnapshot}. */ + public byte[] createSnapshot() { + return ElementStack.getBytes(editorElementHierarchy.getRoot()); + } + + /** Restores the element tree from a snapshot created by {@link #createSnapshot}. */ + public void restoreFromSnapshot(@NonNull byte[] snapshot) { + final EditorElement oldRootElement = editorElementHierarchy.getRoot(); + + Parcel parcel = Parcel.obtain(); + try { + parcel.unmarshall(snapshot, 0, snapshot.length); + parcel.setDataPosition(0); + EditorElement newRoot = parcel.readParcelable(EditorElement.class.getClassLoader()); + if (newRoot != null) { + setEditorElementHierarchy(EditorElementHierarchy.create(newRoot)); + restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate, false); + invalidate.run(); + editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate); + inBoundsMemory.push(editorElementHierarchy.getMainImage(), editorElementHierarchy.getCropEditorElement()); + } + } finally { + parcel.recycle(); + } + } + public void pushUndoPoint() { boolean cropping = isCropping(); if (cropping && !currentCropIsAcceptable()) { @@ -457,6 +484,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { } updateUndoRedoAvailableState(getActiveUndoRedoStacks(cropping)); + invalidate.run(); } /**