From 4218189b053b00c9d73a5222462c19a039178969 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 31 Jul 2026 10:36:26 -0300 Subject: [PATCH] Add sticker support to V3 media send. Relocate editors into view model and add trash. --- .../mediasend/v3/MediaSendV3Activity.kt | 7 + .../mediasend/v3/MediaSendV3Extensions.kt | 23 +++ .../scribbles/ImageEditorFragment.java | 2 +- .../StickerSelectActivityContract.kt | 52 +++++++ .../stickers/AnalogClockStickerRenderer.kt | 1 + .../stickers/DigitalClockStickerRenderer.kt | 1 + .../org/signal/mediasend/MediaSendEvent.kt | 3 + .../signal/mediasend/MediaSendNavDisplay.kt | 6 +- .../org/signal/mediasend/MediaSendScreen.kt | 1 + .../signal/mediasend/MediaSendViewModel.kt | 34 ++++ .../signal/mediasend/edit/ImageController.kt | 147 ++++++++++++++++-- .../signal/mediasend/edit/MediaEditScreen.kt | 11 +- .../mediasend/edit/MediaEditScreenEvent.kt | 1 + .../mediasend/edit/image/BrushWidths.kt | 3 +- .../mediasend/edit/image/ImageEditor.kt | 49 +++++- .../image/ImageEditorGestureAwareControl.kt | 2 +- .../mediasend/edit/image/ImageEditorState.kt | 2 +- .../edit/image/ImageEditorToolbar.kt | 11 +- .../mediasend/edit/image/RotationDial.kt | 2 +- .../core/ImageEditorTouchHandler.kt | 27 ++++ .../imageeditor/core}/TappableRenderer.kt | 4 +- 21 files changed, 356 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivityContract.kt rename {app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers => lib/image-editor/src/main/java/org/signal/imageeditor/core}/TappableRenderer.kt (52%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt index 1b7b9e46c9..917ba37dab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt @@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.rememberRecipientField import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet +import org.thoughtcrime.securesms.scribbles.StickerSelectActivityContract import org.thoughtcrime.securesms.util.CommunicationActions /** @@ -53,6 +54,10 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee private val viewModel: MediaSendViewModel by viewModels { MediaSendViewModel.Factory(args = contractArgs) } + private val stickerLauncher = registerForActivityResult(StickerSelectActivityContract()) { result -> + viewModel.onStickerSelected(result?.toRenderer()) + } + override val textStoryDestinations: Set get() = destinations().toSet() @@ -130,6 +135,8 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee ) } + is HudCommand.SelectSticker -> stickerLauncher.launch(Unit) + is HudCommand.GoToConversation -> { lifecycleScope.launch(Dispatchers.Default) { val recipient = Recipient.resolved(RecipientId.from(it.recipientId.id)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Extensions.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Extensions.kt index 8c10a2b52d..2f8843ee99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Extensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Extensions.kt @@ -5,7 +5,15 @@ package org.thoughtcrime.securesms.mediasend.v3 +import org.signal.imageeditor.core.Renderer +import org.signal.imageeditor.core.renderers.UriGlideRenderer +import org.signal.mediasend.SentMediaQuality import org.signal.mediasend.StorySendRequirements +import org.thoughtcrime.securesms.mms.PushMediaConstraints +import org.thoughtcrime.securesms.scribbles.StickerSelectResult +import org.thoughtcrime.securesms.scribbles.stickers.AnalogClockStickerRenderer +import org.thoughtcrime.securesms.scribbles.stickers.DigitalClockStickerRenderer +import org.thoughtcrime.securesms.scribbles.stickers.FeatureSticker import org.thoughtcrime.securesms.stories.Stories /** @@ -16,3 +24,18 @@ fun StorySendRequirements.toAppSendRequirements(): Stories.MediaTransform.SendRe StorySendRequirements.CAN_NOT_SEND -> Stories.MediaTransform.SendRequirements.CAN_NOT_SEND StorySendRequirements.REQUIRES_CROP -> Stories.MediaTransform.SendRequirements.REQUIRES_CLIP } + +/** + * Turns a sticker pick into a [Renderer] the image editor can place. + */ +internal fun StickerSelectResult.toRenderer(): Renderer = when (this) { + is StickerSelectResult.Sticker -> { + val constraints = PushMediaConstraints(SentMediaQuality.HIGH) + UriGlideRenderer(uri, true, constraints.imageMaxWidth, constraints.imageMaxHeight) + } + + is StickerSelectResult.Feature -> when (featureSticker) { + FeatureSticker.DIGITAL_CLOCK -> DigitalClockStickerRenderer(System.currentTimeMillis()) + FeatureSticker.ANALOG_CLOCK -> AnalogClockStickerRenderer(System.currentTimeMillis()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index fbe57abc6e..53b6cabccb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -44,6 +44,7 @@ import org.signal.imageeditor.core.ColorableRenderer; import org.signal.imageeditor.core.ImageEditorView; import org.signal.imageeditor.core.Renderer; import org.signal.imageeditor.core.SelectableRenderer; +import org.signal.imageeditor.core.TappableRenderer; import org.signal.imageeditor.core.model.EditorElement; import org.signal.imageeditor.core.model.EditorModel; import org.signal.imageeditor.core.renderers.BezierDrawingRenderer; @@ -64,7 +65,6 @@ import org.thoughtcrime.securesms.mms.PushMediaConstraints; import org.thoughtcrime.securesms.scribbles.stickers.AnalogClockStickerRenderer; import org.thoughtcrime.securesms.scribbles.stickers.DigitalClockStickerRenderer; import org.thoughtcrime.securesms.scribbles.stickers.FeatureSticker; -import org.thoughtcrime.securesms.scribbles.stickers.TappableRenderer; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.SaveAttachmentUtil; import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter; diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivityContract.kt b/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivityContract.kt new file mode 100644 index 0000000000..2a15230b3d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/StickerSelectActivityContract.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.scribbles + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.scribbles.stickers.FeatureSticker + +/** + * Launches [ImageEditorStickerSelectActivity] and describes what was picked. + */ +internal class StickerSelectActivityContract : ActivityResultContract() { + + companion object { + private val TAG = Log.tag(StickerSelectActivityContract::class) + } + + override fun createIntent(context: Context, input: Unit): Intent { + return Intent(context, ImageEditorStickerSelectActivity::class.java) + } + + override fun parseResult(resultCode: Int, intent: Intent?): StickerSelectResult? { + if (resultCode != Activity.RESULT_OK || intent == null) { + return null + } + + val featureStickerType: String? = intent.getStringExtra(ImageEditorStickerSelectActivity.EXTRA_FEATURE_STICKER) + if (featureStickerType != null) { + val featureSticker = FeatureSticker.entries.firstOrNull { it.type == featureStickerType } + if (featureSticker == null) { + Log.w(TAG, "Unrecognized feature sticker type.") + return null + } + + return StickerSelectResult.Feature(featureSticker) + } + + return intent.data?.let { StickerSelectResult.Sticker(it) } + } +} + +internal sealed interface StickerSelectResult { + data class Sticker(val uri: Uri) : StickerSelectResult + data class Feature(val featureSticker: FeatureSticker) : StickerSelectResult +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/AnalogClockStickerRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/AnalogClockStickerRenderer.kt index c3e3c8a13d..a3630bd3ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/AnalogClockStickerRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/AnalogClockStickerRenderer.kt @@ -7,6 +7,7 @@ import android.os.Parcelable import org.signal.imageeditor.core.Bounds import org.signal.imageeditor.core.RendererContext import org.signal.imageeditor.core.SelectableRenderer +import org.signal.imageeditor.core.TappableRenderer import org.signal.imageeditor.core.renderers.InvalidateableRenderer import org.thoughtcrime.securesms.dependencies.AppDependencies diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/DigitalClockStickerRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/DigitalClockStickerRenderer.kt index 81b3b3dcfd..c02d16d13c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/DigitalClockStickerRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/DigitalClockStickerRenderer.kt @@ -7,6 +7,7 @@ import android.os.Parcelable import org.signal.imageeditor.core.Bounds import org.signal.imageeditor.core.RendererContext import org.signal.imageeditor.core.SelectableRenderer +import org.signal.imageeditor.core.TappableRenderer import org.signal.imageeditor.core.renderers.InvalidateableRenderer import org.thoughtcrime.securesms.dependencies.AppDependencies diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt index 8f1dbe4cc5..4082b127f1 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt @@ -36,6 +36,9 @@ sealed interface HudCommand { val isViewOnceAvailable: Boolean ) : HudCommand + /** Show the app's sticker picker. The pick is handed back via [MediaSendViewModel.onStickerSelected]. */ + data object SelectSticker : HudCommand + data class GoToConversation(val recipientId: MediaRecipientId) : HudCommand data object GoToLinkedDevices : HudCommand data class GoToQuickTransfer(val qrData: String) : HudCommand diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt index 4431df98fd..241f34ab32 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt @@ -31,6 +31,7 @@ import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Snackbars import org.signal.core.ui.compose.showSnackbar import org.signal.mediasend.capture.MediaCaptureScreen +import org.signal.mediasend.edit.ImageController import org.signal.mediasend.edit.MediaEditScreen import org.signal.mediasend.select.MediaSelectScreen import org.signal.mediasend.select.MediaSelectScreenState @@ -45,6 +46,7 @@ import org.signal.mediasend.select.MediaSelectScreenState internal fun MediaSendNavDisplay( stateFlow: StateFlow, snackbarEvents: Flow, + imageControllers: ImageController.Container, backStack: NavBackStack, eventHandler: MediaSendEventHandler, modifier: Modifier = Modifier, @@ -103,7 +105,8 @@ internal fun MediaSendNavDisplay( val state by stateFlow.collectAsStateWithLifecycle() MediaEditScreen( state = state, - onEvent = eventHandler::onMediaEditScreenEvent + onEvent = eventHandler::onMediaEditScreenEvent, + imageControllers = imageControllers ) } @@ -151,6 +154,7 @@ private fun MediaSendNavDisplayPreview() { MediaSendNavDisplay( stateFlow = MutableStateFlow(MediaSendState(isCameraFirst = true)), snackbarEvents = emptyFlow(), + imageControllers = remember { ImageController.Container() }, backStack = rememberNavBackStack(MediaSendNavKey.Edit), eventHandler = MediaSendEventHandler.Empty, textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") }, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt index 45b7b141fd..911a87213f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt @@ -76,6 +76,7 @@ fun MediaSendScreen( MediaSendNavDisplay( stateFlow = viewModel.state, snackbarEvents = viewModel.snackbarEvents, + imageControllers = viewModel.imageControllers, backStack = viewModel.backStack, eventHandler = viewModel, modifier = modifier, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index bd2541107b..99433c9a03 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -50,13 +50,16 @@ import org.signal.core.util.ContentTypeUtil import org.signal.core.util.StringUtil import org.signal.core.util.logging.Log import org.signal.core.util.next +import org.signal.imageeditor.core.Renderer import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.model.EditorModel import org.signal.imageeditor.core.renderers.UriGlideRenderer import org.signal.mediasend.capture.CameraXScreenEvent import org.signal.mediasend.capture.MediaCaptureScreenEvent +import org.signal.mediasend.edit.ImageController import org.signal.mediasend.edit.MediaEditScreenEvent import org.signal.mediasend.edit.image.BrushTool +import org.signal.mediasend.edit.image.BrushWidthsState import org.signal.mediasend.edit.video.VideoTrimData import org.signal.mediasend.preupload.PreUploadController import org.signal.mediasend.preupload.PreUploadResult @@ -146,6 +149,9 @@ class MediaSendViewModel( private val hudCommandChannel = Channel(Channel.BUFFERED) val hudCommands: Flow = hudCommandChannel.receiveAsFlow() + /** Per-image editor controllers, held here so results arriving from outside the flow can be applied immediately. */ + internal val imageControllers = ImageController.Container(BrushWidthsState(internalState.value.brushWidths)) + /** Media filter errors. */ private val _mediaErrors = MutableSharedFlow(replay = 1) val mediaErrors: SharedFlow = _mediaErrors.asSharedFlow() @@ -302,6 +308,10 @@ class MediaSendViewModel( ) } + MediaEditScreenEvent.StickerClick -> { + sendHudCommand(HudCommand.SelectSticker) + } + MediaEditScreenEvent.NavigateToGallery -> { backStack.goToFolders() } @@ -324,6 +334,28 @@ class MediaSendViewModel( } } + /** + * Result of the picker opened for [HudCommand.SelectSticker], applied to the focused image. A null [renderer] means + * the picker was dismissed. + */ + fun onStickerSelected(renderer: Renderer?) { + val controller = focusedImageController() ?: return + + if (renderer != null) { + controller.insertSticker(renderer) + } else { + controller.cancelStickerInsertion() + } + } + + private fun focusedImageController(): ImageController? { + val snapshot = state.value + val uri = snapshot.focusedMedia?.uri ?: return null + val editorState = snapshot.editorStateMap[uri] as? EditorState.Image ?: return null + + return imageControllers.getOrCreate(uri, editorState.model) + } + private fun setBrushWidth(tool: BrushTool, fraction: Float) { val brushWidths = state.value.brushWidths.with(tool, fraction) @@ -633,6 +665,8 @@ class MediaSendViewModel( ) } + media.forEach { imageControllers.remove(it.uri) } + if (newSelection.isEmpty() && !snapshot.suppressEmptyError) { viewModelScope.launch { _mediaErrors.emit(MediaFilterError.NoItems) 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 efccd93ae9..8f41b076d1 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 @@ -8,13 +8,14 @@ package org.signal.mediasend.edit import android.graphics.Paint import android.net.Uri import androidx.compose.runtime.Stable -import androidx.compose.runtime.annotation.RememberInComposition import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateMap +import org.signal.imageeditor.core.Renderer import org.signal.imageeditor.core.SelectableRenderer +import org.signal.imageeditor.core.TappableRenderer import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.model.EditorModel import org.signal.imageeditor.core.renderers.MultiLineTextRenderer @@ -31,11 +32,16 @@ import org.signal.mediasend.edit.image.ImageEditorState * trim/seek interaction flows through the screen's event channel. */ @Stable -class ImageController @RememberInComposition constructor( +internal class ImageController( private val editorModel: EditorModel, private val brushWidths: BrushWidthsState = BrushWidthsState() ) { + companion object { + /** Size of a new sticker, relative to the visible area of the image. */ + private const val STICKER_SCALE = 0.4f + } + val isUserInEdit: Boolean by derivedStateOf { mode != Mode.NONE } val imageEditorState = ImageEditorState(editorModel).also { @@ -55,6 +61,8 @@ class ImageController @RememberInComposition constructor( private var minDialScaleDown: Float = 1f private var drawSessionSnapshot: ByteArray? = null private var drawSessionDirty: Boolean by mutableStateOf(false) + private var modeBeforeStickerInsertion: Mode = Mode.NONE + private var modeBeforeDrag: Mode = Mode.NONE var textEditingElement: EditorElement? by mutableStateOf(null) private set @@ -102,6 +110,11 @@ class ImageController @RememberInComposition constructor( val isUserEnteringText: Boolean by derivedStateOf { mode == Mode.TEXT } val isUserInsertingSticker: Boolean by derivedStateOf { mode == Mode.INSERT_STICKER } + val isUserDraggingElement: Boolean by derivedStateOf { mode == Mode.DELETE } + + var isDraggedElementOverTrash: Boolean by mutableStateOf(false) + private set + fun requestCancelEdit() { if (hasUnsavedChanges) { showDiscardDialog = true @@ -288,20 +301,32 @@ class ImageController @RememberInComposition constructor( imageEditorState.invalidate() } - fun onEntityTapped(element: EditorElement?) { + fun onEntityDown(element: EditorElement?) { if (element != null && element.renderer is SelectableRenderer) { - (element.renderer as SelectableRenderer).onSelected(true) - editorModel.setSelected(element) - selectedElement = element - mode = when (element.renderer) { - is MultiLineTextRenderer -> Mode.MOVE_TEXT - else -> Mode.MOVE_STICKER - } + selectElement(element) } else { clearSelection() } } + fun onEntitySingleTap(element: EditorElement?) { + val tappable = element?.renderer as? TappableRenderer ?: return + + tappable.onTapped() + imageEditorState.invalidate() + } + + private fun selectElement(element: EditorElement) { + (selectedElement?.renderer as? SelectableRenderer)?.onSelected(false) + (element.renderer as? SelectableRenderer)?.onSelected(true) + editorModel.setSelected(element) + selectedElement = element + mode = when (element.renderer) { + is MultiLineTextRenderer -> Mode.MOVE_TEXT + else -> Mode.MOVE_STICKER + } + } + private fun clearSelection() { if (selectedElement != null) { (selectedElement?.renderer as? SelectableRenderer)?.onSelected(false) @@ -313,9 +338,93 @@ class ImageController @RememberInComposition constructor( } fun enterStickerMode() { + modeBeforeStickerInsertion = mode mode = Mode.INSERT_STICKER } + fun insertSticker(renderer: Renderer) { + val element = EditorElement(renderer, EditorModel.Z_STICKERS) + editorModel.addElementCentered(element, STICKER_SCALE) + + // The picker can be opened mid-draw-session, where touches would otherwise keep painting. + imageEditorState.isDrawing = false + imageEditorState.isBlur = false + + selectElement(element) + imageEditorState.invalidate() + } + + fun cancelStickerInsertion() { + if (mode == Mode.INSERT_STICKER) { + mode = modeBeforeStickerInsertion + } + } + + /** Reveals the trash so the drag can end in a delete. */ + fun onDragStarted(element: EditorElement?) { + if (mode == Mode.CROP || element == null || element.renderer !is SelectableRenderer) { + return + } + + modeBeforeDrag = mode + selectElement(element) + isDraggedElementOverTrash = false + mode = Mode.DELETE + setTrashVisible(true) + } + + fun onDragMoved(element: EditorElement?, isOverTrash: Boolean) { + if (mode != Mode.DELETE || element == null || isOverTrash == isDraggedElementOverTrash) { + return + } + + isDraggedElementOverTrash = isOverTrash + + if (isOverTrash) { + element.animatePartialFadeOut(imageEditorState::invalidate) + } else { + element.animatePartialFadeIn(imageEditorState::invalidate) + } + } + + fun onDragEnded(element: EditorElement?, isOverTrash: Boolean) { + if (mode != Mode.DELETE) { + return + } + + isDraggedElementOverTrash = false + setTrashVisible(false) + + if (element == null) { + exitEditMode() + return + } + + if (isOverTrash) { + editorModel.delete(element) + editorModel.updateUndoRedoAvailabilityState() + + when (modeBeforeDrag) { + Mode.DRAW, Mode.HIGHLIGHT, Mode.BLUR -> { + selectedElement = null + mode = modeBeforeDrag + } + else -> exitEditMode() + } + } else { + element.animatePartialFadeIn(imageEditorState::invalidate) + selectElement(element) + } + } + + private fun setTrashVisible(visible: Boolean) { + editorModel.trash.flags + .setVisible(visible) + .persist() + + imageEditorState.invalidate() + } + fun lockCrop() { editorModel.setCropAspectLock(true) isCropAspectRatioLocked = true @@ -369,14 +478,28 @@ class ImageController @RememberInComposition constructor( INSERT_STICKER } + /** + * The controller for each image in the selection. Owned by the view-model rather than the composition so edits from + * outside the Edit screen apply right away. + */ @Stable - class Container @RememberInComposition constructor( + class Container( private val brushWidths: BrushWidthsState = BrushWidthsState() ) { private val controllers = SnapshotStateMap() fun getOrCreate(uri: Uri, editorModel: EditorModel): ImageController { - return controllers.getOrPut(uri) { ImageController(editorModel, brushWidths) } + val existing = controllers[uri] + if (existing != null && existing.editorModel === editorModel) { + return existing + } + + // Re-adding a removed item builds it a fresh model, leaving any cached controller editing one nobody renders. + return ImageController(editorModel, brushWidths).also { controllers[uri] = it } + } + + fun remove(uri: Uri) { + controllers.remove(uri) } } } 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 f442768333..c8cbcd9ea5 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 @@ -61,7 +61,6 @@ import org.signal.mediasend.MediaSendState import org.signal.mediasend.edit.document.DocumentPage import org.signal.mediasend.edit.image.BrushWidthBar 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 @@ -74,9 +73,10 @@ import org.signal.mediasend.edit.video.VideoTrimBar import org.signal.mediasend.rememberPreviewState @Composable -fun MediaEditScreen( +internal fun MediaEditScreen( state: MediaSendState, - onEvent: (MediaEditScreenEvent) -> Unit + onEvent: (MediaEditScreenEvent) -> Unit, + imageControllers: ImageController.Container ) { val scope = rememberCoroutineScope() @@ -105,8 +105,6 @@ fun MediaEditScreen( Box(modifier = Modifier.fillMaxSize()) { val isSmallWindowBreakpoint = rememberWindowBreakpoint() is WindowBreakpoint.Small - val imageControllers = remember { ImageController.Container(BrushWidthsState(state.brushWidths)) } - val videoEditorViewModel = rememberVideoEditorViewModel() val focusedUri = state.focusedMedia?.uri @@ -508,7 +506,8 @@ private fun MediaEditScreenPreview() { selectedMedia.first().uri to EditorState.Image(EditorModel.create(0)) ) ), - onEvent = {} + onEvent = {}, + imageControllers = remember { ImageController.Container() } ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt index e59119c311..f7338c501c 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt @@ -14,6 +14,7 @@ sealed interface MediaEditScreenEvent { data class FocusedMediaChanged(val media: Media) : MediaEditScreenEvent data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaEditScreenEvent data class AddMessageClick(val startWithEmojiKeyboard: Boolean = false) : MediaEditScreenEvent + data object StickerClick : MediaEditScreenEvent data object NextClick : MediaEditScreenEvent data object NavigateBack : MediaEditScreenEvent data object NavigateToGallery : MediaEditScreenEvent diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidths.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidths.kt index ed6f786542..4ed88b63bd 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidths.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidths.kt @@ -7,7 +7,6 @@ package org.signal.mediasend.edit.image import android.os.Parcelable import androidx.compose.runtime.Stable -import androidx.compose.runtime.annotation.RememberInComposition import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -66,7 +65,7 @@ data class BrushWidths( * selection draws with the same brush. */ @Stable -class BrushWidthsState @RememberInComposition constructor(initialWidths: BrushWidths = BrushWidths()) { +internal class BrushWidthsState(initialWidths: BrushWidths = BrushWidths()) { var widths: BrushWidths by mutableStateOf(initialWidths) private set 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 f7e65ce7c5..f1c47a980d 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 @@ -5,6 +5,7 @@ package org.signal.mediasend.edit.image +import android.graphics.Matrix import android.graphics.PointF import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.awaitEachGesture @@ -27,30 +28,40 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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 androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import org.signal.imageeditor.core.ImageEditorTouchHandler +import org.signal.imageeditor.core.model.EditorElement import org.signal.mediasend.edit.ImageController @Composable -fun ImageEditor( +internal fun ImageEditor( controller: ImageController, modifier: Modifier = Modifier ) { val context = LocalContext.current val state = controller.imageEditorState + val hapticFeedback = LocalHapticFeedback.current DisposableEffect(state) { state.attach() onDispose { state.detach() } } + LaunchedEffect(controller.isDraggedElementOverTrash) { + if (controller.isDraggedElementOverTrash) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + Box(modifier = modifier) { Canvas( modifier = Modifier @@ -107,6 +118,9 @@ private fun HiddenTextInput(controller: ImageController) { } } +/** How far a finger travels before a touch on an element counts as a drag. */ +private const val MAX_MOVE_SQUARED_BEFORE_DRAG = 10f + private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: ImageController): Modifier { return this.pointerInput(controller, controller.textEditingElement) { val touchHandler = ImageEditorTouchHandler() @@ -134,7 +148,7 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller val hitElement = touchHandler.onDown(state.editorModel, state.viewMatrix, down.position.toPointF()) if (!state.isDrawing && !state.isBlur) { - controller.onEntityTapped(hitElement) + controller.onEntityDown(hitElement) } // In NONE mode with nothing hit, let the pager handle the gesture @@ -146,6 +160,9 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller state.isGestureActive = true var previousPointerCount = 1 + var inDrag = false + var draggedElement: EditorElement? = null + var droppedOnTrash = false try { while (true) { @@ -155,8 +172,23 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller if (currentCount == 0) { event.changes.forEach { it.consume() } + + if (inDrag) { + val upPoint = (event.changes.firstOrNull()?.position ?: down.position).toPointF() + droppedOnTrash = previousPointerCount == 1 && + touchHandler.checkTrashIntersect(state.editorModel, upPoint) && + state.editorModel.findElementAtPoint(upPoint, state.viewMatrix, Matrix()) === draggedElement + } + + val wasSingleTap = !inDrag && !touchHandler.isDrawingSession() + touchHandler.onUp(state.editorModel) state.onGestureCompleted?.invoke() + + if (wasSingleTap) { + controller.onEntitySingleTap(hitElement) + } + break } @@ -174,11 +206,24 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller state.invalidate() } + val position = currentPressed.first().position + if (inDrag) { + controller.onDragMoved(draggedElement, touchHandler.checkTrashIntersect(state.editorModel, position.toPointF())) + } else if (currentCount == 1 && !touchHandler.isDrawingSession() && (position - down.position).getDistanceSquared() > MAX_MOVE_SQUARED_BEFORE_DRAG) { + inDrag = true + draggedElement = touchHandler.getSelected() + controller.onDragStarted(draggedElement) + } + event.changes.forEach { it.consume() } previousPointerCount = currentCount } } finally { state.isGestureActive = false + + if (inDrag) { + controller.onDragEnded(draggedElement, droppedOnTrash) + } } } } 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 index 13feb0f764..60c3a5a7f5 100644 --- 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 @@ -15,7 +15,7 @@ import org.signal.mediasend.edit.ImageController * Hides or shows content based on whether the user is performing a gesture. */ @Composable -fun ImageEditorGestureAwareControl( +internal fun ImageEditorGestureAwareControl( imageEditorController: ImageController?, modifier: Modifier = Modifier, extraCheck: () -> Boolean = { true }, 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 21353201fa..8c55266108 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 @@ -32,7 +32,7 @@ import org.signal.imageeditor.core.model.EditorModel * flow: mutations go into the model, the composable only reads and renders. */ @Stable -class ImageEditorState( +internal class ImageEditorState( val editorModel: EditorModel ) { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt index 590b7818e4..162642fc4c 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt @@ -36,13 +36,15 @@ import org.signal.mediasend.rememberPreviewState import java.util.EnumMap @Composable -fun ImageEditorToolbar( +internal fun ImageEditorToolbar( imageEditorController: ImageController, state: MediaSendState, onEvent: (MediaEditScreenEvent) -> Unit, modifier: Modifier = Modifier ) { when { + // The trash is the only thing on screen during a drag. + imageEditorController.isUserDraggingElement -> Unit imageEditorController.shouldDisplayTextColorBar -> { HSVColorBar( state = imageEditorController.textColorBarState, @@ -57,7 +59,7 @@ fun ImageEditorToolbar( ImageEditorCropAndResizeToolbar(imageEditorController, modifier) } else -> { - ImageEditorDrawStateToolbar(imageEditorController, modifier) + ImageEditorDrawStateToolbar(imageEditorController, onEvent, modifier) } } } @@ -94,6 +96,7 @@ private fun ImageEditorNoneStateToolbar( @Composable private fun ImageEditorDrawStateToolbar( imageEditorController: ImageController, + onEvent: (MediaEditScreenEvent) -> Unit, modifier: Modifier = Modifier ) { MediaEditorToolbar( @@ -131,6 +134,7 @@ private fun ImageEditorDrawStateToolbar( onCheckChanged = { if (!imageEditorController.isUserInsertingSticker) { imageEditorController.enterStickerMode() + onEvent(MediaEditScreenEvent.StickerClick) } } ) @@ -290,7 +294,8 @@ private fun ImageEditorDrawStateToolbarPreview() { ImageController(EditorModel.create(0)).apply { enterDrawMode() } - } + }, + onEvent = {} ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/RotationDial.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/RotationDial.kt index b26931ace7..86408e27dd 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/RotationDial.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/RotationDial.kt @@ -58,7 +58,7 @@ private val MINOR_INDICATOR_HEIGHT = 12.dp private val MAJOR_INDICATOR_HEIGHT = 24.dp @Composable -fun RotationDial( +internal fun RotationDial( imageEditorController: ImageController, modifier: Modifier = Modifier ) { diff --git a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt index 11fa3a7c09..91ed098f86 100644 --- a/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/ImageEditorTouchHandler.kt @@ -12,6 +12,7 @@ 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 +import org.signal.imageeditor.core.renderers.TrashRenderer /** * Public facade for touch handling on an [EditorModel]. @@ -33,6 +34,7 @@ class ImageEditorTouchHandler { private var editSession: EditSession? = null private var moreThanOnePointerUsedInSession: Boolean = false + private var drawingSession: Boolean = false /** Configures whether the next gesture should create a drawing session if no element is hit. */ fun setDrawing(drawing: Boolean, blur: Boolean) { @@ -124,6 +126,28 @@ class ImageEditorTouchHandler { return editSession != null } + /** True when the gesture is laying down a stroke rather than moving an element. */ + fun isDrawingSession(): Boolean { + return drawingSession + } + + /** Whether [point] is over the trash, growing or shrinking it to match. False while the trash is hidden. */ + fun checkTrashIntersect(model: EditorModel, point: PointF): Boolean { + if (drawingSession) { + return false + } + + val trashRenderer = model.trash.renderer as? TrashRenderer + + return if (model.checkTrashIntersectsPoint(point)) { + trashRenderer?.expand() + true + } else { + trashRenderer?.shrink() + false + } + } + fun getSelected(): EditorElement? { return editSession?.selected } @@ -137,8 +161,11 @@ class ImageEditorTouchHandler { ): EditSession? { val session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected) if (session == null && drawing) { + drawingSession = true return startDrawingSession(model, viewMatrix, point) } + + drawingSession = false return session } diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/TappableRenderer.kt b/lib/image-editor/src/main/java/org/signal/imageeditor/core/TappableRenderer.kt similarity index 52% rename from app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/TappableRenderer.kt rename to lib/image-editor/src/main/java/org/signal/imageeditor/core/TappableRenderer.kt index 7d03ce384c..5dff59b068 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/stickers/TappableRenderer.kt +++ b/lib/image-editor/src/main/java/org/signal/imageeditor/core/TappableRenderer.kt @@ -1,6 +1,4 @@ -package org.thoughtcrime.securesms.scribbles.stickers - -import org.signal.imageeditor.core.Renderer +package org.signal.imageeditor.core /** * A renderer that can handle a tap event