From a8c575e645bcf7107edac8163a22d88aab4e53ce Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 4 Aug 2026 12:33:39 -0300 Subject: [PATCH] Refactor media-send to match structure of registration. Add drag to select. --- .../mediasend/AvatarSelectionActivity.java | 4 +- .../securesms/mediasend/MediaSendLauncher.kt | 38 +-- .../securesms/mediasend/VideoTrimTransform.kt | 2 +- .../mediasend/v2/MediaSelectionActivity.kt | 4 +- .../mediasend/v2/MediaSelectionRepository.kt | 2 +- .../mediasend/v2/MediaSelectionState.kt | 2 +- .../mediasend/v2/MediaSelectionViewModel.kt | 2 +- .../v2/capture/MediaCaptureFragment.kt | 4 +- .../v2/review/MediaReviewFragment.kt | 4 +- .../v2/review/QualitySelectorBottomSheet.kt | 2 +- .../v2/text/TextStoryPostCreationFragment.kt | 6 +- .../v2/videos/MediaReviewVideoPageFragment.kt | 6 +- .../mediasend/v3/MediaSendV3Activity.kt | 12 +- .../v3/MediaSendV3ForwardFragment.kt | 14 +- .../mediasend/v3/MediaSendV3Repository.kt | 2 +- .../res/layout/v2_media_review_fragment.xml | 2 +- .../core/ui/compose/list/DragToReorder.kt | 27 +- .../core/ui/compose/list/DragToSelect.kt | 244 ++++++++++++++ .../java/org/signal/mediasend/EditorState.kt | 2 +- ...ct.kt => MediaSendFlowActivityContract.kt} | 2 +- ...ediaSendEvent.kt => MediaSendFlowEvent.kt} | 37 +- ...ediaSendState.kt => MediaSendFlowState.kt} | 51 +-- ...ViewModel.kt => MediaSendFlowViewModel.kt} | 315 ++++++++---------- .../signal/mediasend/MediaSendNavDisplay.kt | 218 ------------ .../signal/mediasend/MediaSendNavigation.kt | 185 ++++++++++ .../signal/mediasend/MediaSendRepository.kt | 2 +- .../org/signal/mediasend/MediaSendScreen.kt | 18 +- .../mediasend/PreviewMediaConstraints.kt | 4 +- .../preupload/PreUploadController.kt | 39 ++- .../{ => screens}/MediaSendMetrics.kt | 2 +- .../{ => screens/capture}/CameraFragment.java | 3 +- .../{ => screens}/capture/CameraXFragment.kt | 5 +- .../capture/CameraXScreenEvent.kt | 2 +- .../capture/MediaCameraCaptureScreen.kt | 6 +- .../capture/MediaCaptureScreen.kt | 20 +- .../capture/MediaCaptureScreenEvent.kt | 2 +- .../{ => screens}/capture/TextStoryBar.kt | 2 +- .../{ => screens}/edit/AddAMessageRow.kt | 2 +- .../{ => screens}/edit/ImageController.kt | 14 +- .../{ => screens}/edit/MediaEditControl.kt | 4 +- .../{ => screens}/edit/MediaEditScreen.kt | 36 +- .../edit/MediaEditScreenDialogs.kt | 2 +- .../edit/MediaEditScreenEvent.kt | 6 +- .../edit/MediaEditSummaryPill.kt | 2 +- .../{ => screens}/edit/MediaEditorToolbar.kt | 12 +- .../edit/QualitySelectorSheetContent.kt | 2 +- .../{ => screens}/edit/ScheduleSendMenu.kt | 2 +- .../{ => screens}/edit/ThumbnailRow.kt | 4 +- .../edit/document/DocumentPage.kt | 2 +- .../{ => screens}/edit/image/BlurFacesBar.kt | 2 +- .../{ => screens}/edit/image/BrushWidthBar.kt | 2 +- .../edit/image/BrushWidthMetrics.kt | 2 +- .../edit/image/BrushWidthPreview.kt | 2 +- .../{ => screens}/edit/image/BrushWidths.kt | 2 +- .../edit/image/DrawModeColorBar.kt | 4 +- .../edit/image/FaceDetectionResult.kt | 2 +- .../{ => screens}/edit/image/HSVColorBar.kt | 2 +- .../{ => screens}/edit/image/ImageEditor.kt | 4 +- .../edit/image/ImageEditorClearAllButton.kt | 6 +- .../edit/image/ImageEditorState.kt | 2 +- .../edit/image/ImageEditorToolbar.kt | 20 +- .../edit/image/ImageEditorUndoRedoButtons.kt | 6 +- .../{ => screens}/edit/image/RotationDial.kt | 4 +- .../edit/image/TextModeColorBar.kt | 4 +- .../edit/video/VideoEditorFragment.kt | 2 +- .../edit/video/VideoEditorPlayButtonLayout.kt | 2 +- .../edit/video/VideoEditorViewModel.kt | 2 +- .../VideoThumbnailsRangeSelectorView.java | 2 +- .../edit/video/VideoThumbnailsView.java | 2 +- .../{ => screens}/edit/video/VideoTrimBar.kt | 4 +- .../{ => screens}/edit/video/VideoTrimData.kt | 2 +- .../select/MediaPermissionController.kt | 2 +- .../{ => screens}/select/MediaPermissions.kt | 2 +- .../{ => screens}/select/MediaSelectScreen.kt | 84 ++++- .../screens/select/MediaSelectScreenEvent.kt | 46 +++ .../select/MediaSelectScreenState.kt | 22 +- .../screens/select/MediaSelectViewModel.kt | 163 +++++++++ .../select/MediaSelectScreenEvent.kt | 27 -- .../org/signal/mediasend/test/TestTags.kt | 3 + .../{ => util}/MeteredConnectivity.kt | 2 +- .../signal/mediasend/{ => util}/VideoUtil.kt | 4 +- .../res/layout/mediasend_video_fragment.xml | 2 +- .../res/layout/mediasend_video_timeline.xml | 2 +- .../mediasend/MediaSendDependenciesRule.kt | 4 +- .../{ => screens}/edit/AddAMessageRowTest.kt | 2 +- .../MediaEditorToolbarSharedButtonsTest.kt | 12 +- .../edit/ScheduleSendMenuTest.kt | 2 +- .../edit/ScheduleSendOptionTest.kt | 2 +- .../select/MediaSelectScreenDragTest.kt | 184 ++++++++++ 89 files changed, 1320 insertions(+), 705 deletions(-) create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/list/DragToSelect.kt rename feature/media-send/src/main/java/org/signal/mediasend/{MediaSendActivityContract.kt => MediaSendFlowActivityContract.kt} (97%) rename feature/media-send/src/main/java/org/signal/mediasend/{MediaSendEvent.kt => MediaSendFlowEvent.kt} (60%) rename feature/media-send/src/main/java/org/signal/mediasend/{MediaSendState.kt => MediaSendFlowState.kt} (76%) rename feature/media-send/src/main/java/org/signal/mediasend/{MediaSendViewModel.kt => MediaSendFlowViewModel.kt} (84%) delete mode 100644 feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/MediaSendMetrics.kt (97%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens/capture}/CameraFragment.java (91%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/capture/CameraXFragment.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/capture/CameraXScreenEvent.kt (94%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/capture/MediaCameraCaptureScreen.kt (92%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/capture/MediaCaptureScreen.kt (94%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/capture/MediaCaptureScreenEvent.kt (91%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/capture/TextStoryBar.kt (98%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/AddAMessageRow.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/ImageController.kt (97%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/MediaEditControl.kt (93%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/MediaEditScreen.kt (94%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/MediaEditScreenDialogs.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/MediaEditScreenEvent.kt (89%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/MediaEditSummaryPill.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/MediaEditorToolbar.kt (92%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/QualitySelectorSheetContent.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/ScheduleSendMenu.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/ThumbnailRow.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/document/DocumentPage.kt (98%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/BlurFacesBar.kt (97%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/BrushWidthBar.kt (98%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/BrushWidthMetrics.kt (95%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/BrushWidthPreview.kt (96%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/BrushWidths.kt (97%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/DrawModeColorBar.kt (95%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/FaceDetectionResult.kt (98%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/HSVColorBar.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/ImageEditor.kt (98%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/ImageEditorClearAllButton.kt (91%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/ImageEditorState.kt (98%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/ImageEditorToolbar.kt (94%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/ImageEditorUndoRedoButtons.kt (94%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/RotationDial.kt (98%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/image/TextModeColorBar.kt (96%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/video/VideoEditorFragment.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/video/VideoEditorPlayButtonLayout.kt (97%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/video/VideoEditorViewModel.kt (97%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/video/VideoThumbnailsRangeSelectorView.java (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/video/VideoThumbnailsView.java (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/video/VideoTrimBar.kt (98%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/edit/video/VideoTrimData.kt (96%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/select/MediaPermissionController.kt (99%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/select/MediaPermissions.kt (95%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/select/MediaSelectScreen.kt (87%) create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreenEvent.kt rename feature/media-send/src/main/java/org/signal/mediasend/{ => screens}/select/MediaSelectScreenState.kt (54%) create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectViewModel.kt delete mode 100644 feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt rename feature/media-send/src/main/java/org/signal/mediasend/{ => util}/MeteredConnectivity.kt (98%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => util}/VideoUtil.kt (90%) rename feature/media-send/src/test/java/org/signal/mediasend/{ => screens}/edit/AddAMessageRowTest.kt (98%) rename feature/media-send/src/test/java/org/signal/mediasend/{ => screens}/edit/MediaEditorToolbarSharedButtonsTest.kt (94%) rename feature/media-send/src/test/java/org/signal/mediasend/{ => screens}/edit/ScheduleSendMenuTest.kt (98%) rename feature/media-send/src/test/java/org/signal/mediasend/{ => screens}/edit/ScheduleSendOptionTest.kt (98%) create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/screens/select/MediaSelectScreenDragTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java index 9cf70979c3..01de1886ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/AvatarSelectionActivity.java @@ -17,8 +17,8 @@ import androidx.fragment.app.FragmentTransaction; import org.signal.core.models.media.Media; import org.signal.imageeditor.core.model.EditorModel; import org.signal.mediasend.MediaConstraints; -import org.signal.mediasend.CameraFragment; -import org.signal.mediasend.capture.CameraXFragment; +import org.signal.mediasend.screens.capture.CameraFragment; +import org.signal.mediasend.screens.capture.CameraXFragment; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.mediasend.v2.gallery.MediaGalleryFragment; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendLauncher.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendLauncher.kt index aae5e46c5f..6bf90346d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendLauncher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendLauncher.kt @@ -12,7 +12,7 @@ import android.os.Parcelable import org.signal.core.models.media.Media import org.signal.core.util.getParcelableExtraCompat import org.signal.mediasend.MediaRecipientId -import org.signal.mediasend.MediaSendActivityContract +import org.signal.mediasend.MediaSendFlowActivityContract import org.signal.mediasend.MediaSendRecipient import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType @@ -45,9 +45,9 @@ object MediaSendLauncher { return if (useV3) { v3Intent( context, - MediaSendActivityContract.Args( + MediaSendFlowActivityContract.Args( isCameraFirst = true, - mode = MediaSendActivityContract.Mode.ChooseAfterMediaSelection, + mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection, isStory = isStory ) ) @@ -66,9 +66,9 @@ object MediaSendLauncher { return if (useV3) { v3Intent( context, - MediaSendActivityContract.Args( + MediaSendFlowActivityContract.Args( isCameraFirst = true, - mode = MediaSendActivityContract.Mode.SingleRecipient, + mode = MediaSendFlowActivityContract.Mode.SingleRecipient, recipientId = recipientId.toMediaRecipientId(), isReply = isReply ) @@ -82,9 +82,9 @@ object MediaSendLauncher { return if (useV3) { v3Intent( context, - MediaSendActivityContract.Args( + MediaSendFlowActivityContract.Args( isCameraFirst = true, - mode = MediaSendActivityContract.Mode.ChooseAfterMediaSelection, + mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection, isForQuickRestore = true ) ) @@ -97,9 +97,9 @@ object MediaSendLauncher { return if (useV3) { v3Intent( context, - MediaSendActivityContract.Args( + MediaSendFlowActivityContract.Args( isCameraFirst = true, - mode = MediaSendActivityContract.Mode.SingleRecipient, + mode = MediaSendFlowActivityContract.Mode.SingleRecipient, recipientId = recipientId.toMediaRecipientId(), isStory = true, isAddToGroupStoryFlow = true @@ -122,8 +122,8 @@ object MediaSendLauncher { return if (useV3) { v3Intent( context, - MediaSendActivityContract.Args( - mode = MediaSendActivityContract.Mode.SingleRecipient, + MediaSendFlowActivityContract.Args( + mode = MediaSendFlowActivityContract.Mode.SingleRecipient, recipientId = recipientId.toMediaRecipientId(), initialMedia = media, initialMessage = message, @@ -146,8 +146,8 @@ object MediaSendLauncher { return if (useV3) { v3Intent( context, - MediaSendActivityContract.Args( - mode = MediaSendActivityContract.Mode.SingleRecipient, + MediaSendFlowActivityContract.Args( + mode = MediaSendFlowActivityContract.Mode.SingleRecipient, recipientId = recipientId.toMediaRecipientId(), initialMedia = media, initialMessage = message @@ -163,8 +163,8 @@ object MediaSendLauncher { return if (useV3) { v3Intent( context, - MediaSendActivityContract.Args( - mode = MediaSendActivityContract.Mode.ChooseAfterMediaSelection, + MediaSendFlowActivityContract.Args( + mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection, initialMedia = media ) ) @@ -185,8 +185,8 @@ object MediaSendLauncher { return if (useV3) { v3Intent( context, - MediaSendActivityContract.Args( - mode = MediaSendActivityContract.Mode.MultiRecipient, + MediaSendFlowActivityContract.Args( + mode = MediaSendFlowActivityContract.Mode.MultiRecipient, additionalRecipients = recipientSearchKeys.map { MediaSendRecipient(it.recipientId.toMediaRecipientId(), it.isStory) }, initialMedia = media, initialMessage = message, @@ -214,9 +214,9 @@ object MediaSendLauncher { return data.getParcelableExtraCompat(MediaSendActivityResult.EXTRA_RESULT, Parcelable::class.java) as? MediaSendActivityResult } - private fun v3Intent(context: Context, args: MediaSendActivityContract.Args): Intent { + private fun v3Intent(context: Context, args: MediaSendFlowActivityContract.Args): Intent { return Intent(context, MediaSendV3Activity::class.java) - .putExtra(MediaSendActivityContract.EXTRA_ARGS, args.copy(maxSelection = RemoteConfig.maxAttachmentCount)) + .putExtra(MediaSendFlowActivityContract.EXTRA_ARGS, args.copy(maxSelection = RemoteConfig.maxAttachmentCount)) } private fun RecipientId.toMediaRecipientId(): MediaRecipientId = MediaRecipientId(toLong()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt index ed8eb926c3..a1f96604a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/VideoTrimTransform.kt @@ -5,7 +5,7 @@ import androidx.annotation.WorkerThread import org.signal.core.models.media.Media import org.signal.core.models.media.TransformProperties import org.signal.mediasend.SentMediaQuality -import org.signal.mediasend.edit.video.VideoTrimData +import org.signal.mediasend.screens.edit.video.VideoTrimData class VideoTrimTransform(private val data: VideoTrimData) : MediaTransform { @WorkerThread diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt index 9837b7c97c..cd5c6f1a83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt @@ -35,8 +35,8 @@ import org.signal.core.util.logging.Log import org.signal.core.util.overrideActivityTransitionCompat import org.signal.mediasend.MediaSendNavKey import org.signal.mediasend.MediaValidator -import org.signal.mediasend.capture.MediaCaptureBottomBar -import org.signal.mediasend.capture.MediaCaptureScreenEvent +import org.signal.mediasend.screens.capture.MediaCaptureBottomBar +import org.signal.mediasend.screens.capture.MediaCaptureScreenEvent import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.emoji.EmojiEventListener diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index 3c040ea198..14f6dc3a8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -16,7 +16,7 @@ import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.MediaConstraints import org.signal.mediasend.MediaValidator import org.signal.mediasend.SentMediaQuality -import org.signal.mediasend.edit.video.VideoTrimData +import org.signal.mediasend.screens.edit.video.VideoTrimData import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.database.SignalDatabase diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt index 4821afe01d..0b2bd413ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionState.kt @@ -4,7 +4,7 @@ import android.net.Uri import org.signal.core.models.media.Media import org.signal.mediasend.MediaConstraints import org.signal.mediasend.SentMediaQuality -import org.signal.mediasend.edit.video.VideoTrimData +import org.signal.mediasend.screens.edit.video.VideoTrimData import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mms.TranscodingConfigProvider diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index f7e3c4e42e..24d25c9ef3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -28,7 +28,7 @@ import org.signal.core.util.logging.Log import org.signal.mediasend.MediaConstraints import org.signal.mediasend.MediaValidator import org.signal.mediasend.SentMediaQuality -import org.signal.mediasend.edit.video.VideoTrimData +import org.signal.mediasend.screens.edit.video.VideoTrimData import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt index ad4baf0434..745fb3afc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt @@ -12,9 +12,9 @@ import org.signal.core.ui.permissions.Permissions import org.signal.core.util.SeekableFileDescriptor import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.logging.Log -import org.signal.mediasend.CameraFragment import org.signal.mediasend.MediaConstraints -import org.signal.mediasend.capture.CameraXFragment +import org.signal.mediasend.screens.capture.CameraFragment +import org.signal.mediasend.screens.capture.CameraXFragment import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.mediasend.v2.HudCommand diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index 2c74df123a..0174976d81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -42,8 +42,8 @@ import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.logging.Log import org.signal.mediasend.MediaConstraints import org.signal.mediasend.SentMediaQuality -import org.signal.mediasend.edit.video.VideoThumbnailsRangeSelectorView -import org.signal.mediasend.edit.video.VideoTrimData +import org.signal.mediasend.screens.edit.video.VideoThumbnailsRangeSelectorView +import org.signal.mediasend.screens.edit.video.VideoTrimData import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheet.kt index c62179fa5b..b701629df2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/QualitySelectorBottomSheet.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.viewModels import org.signal.core.ui.compose.BottomSheets import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment -import org.signal.mediasend.edit.QualitySelectorSheetContent +import org.signal.mediasend.screens.edit.QualitySelectorSheetContent import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt index c676bb1a9b..60ca6d9595 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt @@ -33,9 +33,9 @@ import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.ui.getWindowBreakpoint import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.dp -import org.signal.mediasend.capture.MediaCaptureScreenEvent -import org.signal.mediasend.capture.TextStoryHorizontalBar -import org.signal.mediasend.capture.TextStoryVerticalBar +import org.signal.mediasend.screens.capture.MediaCaptureScreenEvent +import org.signal.mediasend.screens.capture.TextStoryHorizontalBar +import org.signal.mediasend.screens.capture.TextStoryVerticalBar import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.colors.ChatColors diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt index c1aa972ced..48e11e6787 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/videos/MediaReviewVideoPageFragment.kt @@ -10,9 +10,9 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch import org.signal.core.util.getParcelableCompat -import org.signal.mediasend.edit.video.VideoEditorFragment -import org.signal.mediasend.edit.video.VideoEditorViewModel -import org.signal.mediasend.edit.video.VideoThumbnailsRangeSelectorView +import org.signal.mediasend.screens.edit.video.VideoEditorFragment +import org.signal.mediasend.screens.edit.video.VideoEditorViewModel +import org.signal.mediasend.screens.edit.video.VideoThumbnailsRangeSelectorView import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.mediasend.v2.HudCommand import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel 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 9ac154f431..a9d39083f3 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 @@ -32,13 +32,13 @@ import org.signal.core.ui.compose.LocalChatColorProvider import org.signal.core.ui.compose.LocalDisplayNameProvider import org.signal.core.ui.getWindowBreakpoint import org.signal.mediasend.HudCommand -import org.signal.mediasend.MediaSendActivityContract +import org.signal.mediasend.MediaSendFlowActivityContract +import org.signal.mediasend.MediaSendFlowViewModel import org.signal.mediasend.MediaSendNavKey import org.signal.mediasend.MediaSendRecipient import org.signal.mediasend.MediaSendScreen -import org.signal.mediasend.MediaSendViewModel -import org.signal.mediasend.edit.LocalAddAMessageRowTextField -import org.signal.mediasend.edit.LocalScheduledSendTimeFormatter +import org.signal.mediasend.screens.edit.LocalAddAMessageRowTextField +import org.signal.mediasend.screens.edit.LocalScheduledSendTimeFormatter import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.components.emoji.EmojiEventListener import org.thoughtcrime.securesms.components.emoji.EmojiTextView @@ -77,9 +77,9 @@ class MediaSendV3Activity : ScheduleMessageTimePickerBottomSheet.ScheduleCallback, ScheduleMessageDialogCallback { - private val contractArgs: MediaSendActivityContract.Args by lazy { MediaSendActivityContract.Args.fromIntent(intent) } + private val contractArgs: MediaSendFlowActivityContract.Args by lazy { MediaSendFlowActivityContract.Args.fromIntent(intent) } - private val viewModel: MediaSendViewModel by viewModels { MediaSendViewModel.Factory(args = contractArgs) } + private val viewModel: MediaSendFlowViewModel by viewModels { MediaSendFlowViewModel.Factory(args = contractArgs) } private val addMessageCommandViewModel: EmojiKeyboardEventViewModel by viewModels() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt index 32733871d6..53d07d61a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt @@ -31,10 +31,10 @@ import org.signal.core.util.getParcelableArrayListCompat import org.signal.core.util.logging.Log import org.signal.mediasend.EditorState import org.signal.mediasend.MediaRecipientId -import org.signal.mediasend.MediaSendActivityContract +import org.signal.mediasend.MediaSendFlowActivityContract +import org.signal.mediasend.MediaSendFlowState +import org.signal.mediasend.MediaSendFlowViewModel import org.signal.mediasend.MediaSendRecipient -import org.signal.mediasend.MediaSendState -import org.signal.mediasend.MediaSendViewModel import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration @@ -56,7 +56,7 @@ import org.signal.core.ui.R as CoreUiR * View-backed wrapper around [MultiselectForwardFragment] that provides the [ViewGroup] container * required by [MultiselectForwardFragment.Callback.getContainer] for bottom bar inflation. * - * Implements the callback interface and uses the shared [MediaSendViewModel] to drive + * Implements the callback interface and uses the shared [MediaSendFlowViewModel] to drive * the send flow forward. * * For story sends this also acts as the [SearchConfigurationProvider], swapping the default @@ -79,8 +79,8 @@ class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activit private val PREVIEW_HEIGHT = 260.dp } - private val viewModel: MediaSendViewModel by activityViewModels { - MediaSendViewModel.Factory(args = MediaSendActivityContract.Args.fromIntent(requireActivity().intent)) + private val viewModel: MediaSendFlowViewModel by activityViewModels { + MediaSendFlowViewModel.Factory(args = MediaSendFlowActivityContract.Args.fromIntent(requireActivity().intent)) } /** @@ -213,7 +213,7 @@ class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activit * Renders the first [PREVIEW_COUNT] selected media into single-session blobs so the picker shows what is actually * being posted, edits included. Items that aren't routed through the image editor are previewed as-is. */ - private fun renderStoryPreviews(state: MediaSendState) { + private fun renderStoryPreviews(state: MediaSendFlowState) { val context = requireContext().applicationContext viewLifecycleOwner.lifecycleScope.launch { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index 0e02a2bae1..3b9db95400 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -39,8 +39,8 @@ import org.signal.mediasend.SendRequest import org.signal.mediasend.SendResult import org.signal.mediasend.SentMediaQuality import org.signal.mediasend.StorySendRequirements -import org.signal.mediasend.edit.image.BrushWidths import org.signal.mediasend.preupload.PreUploadResult +import org.signal.mediasend.screens.edit.image.BrushWidths import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType diff --git a/app/src/main/res/layout/v2_media_review_fragment.xml b/app/src/main/res/layout/v2_media_review_fragment.xml index 57c9b808a7..c30e04428f 100644 --- a/app/src/main/res/layout/v2_media_review_fragment.xml +++ b/app/src/main/res/layout/v2_media_review_fragment.xml @@ -172,7 +172,7 @@ app:tint="@color/signal_colorOnSurface" tools:visibility="visible" /> - + while (isOverscrolling) { + withFrameNanos { } - val overscrollAmount = state.dragOverscrollAmount - if (overscrollAmount != 0f) { - val scrollDirection = if (overscrollAmount < 0f) -1f else 1f - val scrollAmount = (scrollDirection * baseAutoScrollSpeed + overscrollAmount * scrollAcceleration) - .coerceIn(-maxAutoScrollSpeed, maxAutoScrollSpeed) - lazyListState.scrollBy(scrollAmount) + val overscrollAmount = state.dragOverscrollAmount + val scrollDirection = if (overscrollAmount < 0f) -1f else 1f + val scrollAmount = (scrollDirection * baseAutoScrollSpeed + overscrollAmount * scrollAcceleration) + .coerceIn(-maxAutoScrollSpeed, maxAutoScrollSpeed) + lazyListState.scrollBy(scrollAmount) - state.swapDraggingItemIfNeeded() + state.swapDraggingItemIfNeeded() + } } - } } return state } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/list/DragToSelect.kt b/core/ui/src/main/java/org/signal/core/ui/compose/list/DragToSelect.kt new file mode 100644 index 0000000000..8b5178d7eb --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/list/DragToSelect.kt @@ -0,0 +1,244 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose.list + +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlin.math.abs + +/** How close to an edge the pointer has to get before the grid starts scrolling under it. */ +private val AUTO_SCROLL_HOT_ZONE = 56.dp + +/** Per-frame scroll distance at the outer and inner edge of a hot zone. */ +private val MAX_AUTO_SCROLL_SPEED = 30.dp +private val MIN_AUTO_SCROLL_SPEED = 10.dp + +/** + * Range selection by dragging across a lazy grid. + * + * A long press arms the gesture, so taps and scrolling still work; dragging from there grows a contiguous range out + * from the pressed item, and retracting it back towards that item takes the range away again. Nearing the top or bottom + * edge scrolls the grid, and the range keeps growing while the pointer sits there. + * + * The handler owns the selection itself — this only reports which item indices the gesture has covered and uncovered. + * It may also refuse the gesture outright by calling [DragToSelectState.cancel] from [DragSelectEvent.Started], which + * is how a caller keeps a drag from starting on an item that is already selected. + */ +@Composable +fun rememberDragToSelectState( + lazyGridState: LazyGridState, + onEvent: DragToSelectState.(DragSelectEvent) -> Unit +): DragToSelectState { + val hapticFeedback = LocalHapticFeedback.current + val density = LocalDensity.current + val currentOnEvent by rememberUpdatedState(onEvent) + + val state = remember(lazyGridState, hapticFeedback, density) { + DragToSelectState( + state = lazyGridState, + hapticFeedback = hapticFeedback, + autoScrollHotZone = with(density) { AUTO_SCROLL_HOT_ZONE.toPx() }, + onEvent = { dragToSelectState, event -> currentOnEvent(dragToSelectState, event) } + ) + } + + val minAutoScrollSpeed = with(density) { MIN_AUTO_SCROLL_SPEED.toPx() } + val maxAutoScrollSpeed = with(density) { MAX_AUTO_SCROLL_SPEED.toPx() } + + // Gated on actually being in a hot zone: waiting on frames is what asks for the next one, so an ungated loop would + // hold the grid at the display's frame rate for as long as it is on screen. + LaunchedEffect(state) { + snapshotFlow { state.autoScrollDepth != 0f } + .distinctUntilChanged() + .collectLatest { isAutoScrolling -> + while (isAutoScrolling) { + withFrameNanos { } + + val depth = state.autoScrollDepth + val direction = if (depth < 0f) -1f else 1f + lazyGridState.scrollBy(direction * (minAutoScrollSpeed + (maxAutoScrollSpeed - minAutoScrollSpeed) * abs(depth))) + + // The pointer has not moved, but the items under it have. + state.extendSelectionToPointer() + } + } + } + + return state +} + +class DragToSelectState internal constructor( + private val state: LazyGridState, + private val hapticFeedback: HapticFeedback, + private val autoScrollHotZone: Float, + private val onEvent: (DragToSelectState, DragSelectEvent) -> Unit +) { + + /** How far into an edge hot zone the pointer is, from 0 to 1, negative towards the start of the grid. */ + internal var autoScrollDepth by mutableFloatStateOf(0f) + private set + + private var anchorIndex: Int? by mutableStateOf(null) + private var coveredRange: IntRange? = null + private var pointerPosition: Offset? = null + + /** Whether a range is currently being dragged out. */ + val isActive: Boolean + get() = anchorIndex != null + + /** + * Gives up the gesture where it stands, leaving the selection as it is. The pointer is still down at this point, so + * the rest of the gesture is ignored rather than waiting for it to lift. + */ + fun cancel() { + anchorIndex = null + coveredRange = null + pointerPosition = null + autoScrollDepth = 0f + } + + internal fun onDragStart(position: Offset) { + val index = indexAt(position) ?: return + + anchorIndex = index + coveredRange = index..index + pointerPosition = position + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + + onEvent(this, DragSelectEvent.Started(index)) + + if (isActive) { + onEvent(this, DragSelectEvent.RangeSelected(setOf(index))) + } + } + + internal fun onDrag(position: Offset) { + if (!isActive) { + return + } + + pointerPosition = position + autoScrollDepth = autoScrollDepthAt(position) + extendSelection(position) + } + + internal fun onDragEnd() = cancel() + + /** Re-runs the hit test where the pointer already is, for when the grid moves rather than the pointer. */ + internal fun extendSelectionToPointer() { + pointerPosition?.let { extendSelection(it) } + } + + private fun extendSelection(position: Offset) { + val anchor = anchorIndex ?: return + val covered = coveredRange ?: return + val index = indexAt(position) ?: return + + val updated = minOf(anchor, index)..maxOf(anchor, index) + if (updated == covered) { + return + } + + coveredRange = updated + + val selected = updated.filterNot { it in covered }.toSet() + val unselected = covered.filterNot { it in updated }.toSet() + + if (selected.isNotEmpty()) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + onEvent(this, DragSelectEvent.RangeSelected(selected)) + } + + if (unselected.isNotEmpty()) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) + onEvent(this, DragSelectEvent.RangeUnselected(unselected)) + } + } + + /** + * Zero unless the pointer is within [autoScrollHotZone] of an edge of the viewport. Hot zones are capped at a third of + * the viewport each, so that a short grid still leaves somewhere in the middle that does not scroll. + */ + private fun autoScrollDepthAt(position: Offset): Float { + val viewportStart = state.layoutInfo.viewportStartOffset + val viewportEnd = state.layoutInfo.viewportEndOffset + val hotZone = autoScrollHotZone.coerceAtMost((viewportEnd - viewportStart) / 3f) + + if (hotZone <= 0f) { + return 0f + } + + return when { + position.y < viewportStart + hotZone -> -((viewportStart + hotZone - position.y) / hotZone).coerceAtMost(1f) + position.y > viewportEnd - hotZone -> ((position.y - (viewportEnd - hotZone)) / hotZone).coerceAtMost(1f) + else -> 0f + } + } + + private fun indexAt(position: Offset): Int? { + return state.layoutInfo.visibleItemsInfo.firstOrNull { position in it }?.index + } + + private operator fun LazyGridItemInfo.contains(position: Offset): Boolean { + return position.x >= offset.x && + position.x < offset.x + size.width && + position.y >= offset.y && + position.y < offset.y + size.height + } +} + +sealed interface DragSelectEvent { + /** + * A long press has landed on the item at [index] and a range is about to be dragged out from it. + * + * The item is reported before it is selected, so that a handler that does not want the gesture — because the item is + * already selected, say — can call [DragToSelectState.cancel] and be sure nothing was selected on its behalf. + */ + data class Started(val index: Int) : DragSelectEvent + + /** The range has grown over [indices]. */ + data class RangeSelected(val indices: Set) : DragSelectEvent + + /** The range has retracted back over [indices], which it had previously selected. */ + data class RangeUnselected(val indices: Set) : DragSelectEvent +} + +/** + * Enables drag-to-select within a lazy grid. Apply to the grid itself, not to its items. + */ +@Composable +fun Modifier.dragToSelect(dragToSelectState: DragToSelectState): Modifier { + return pointerInput(dragToSelectState) { + detectDragGestures( + dragHandleXRange = null, + onDragStart = { offset -> dragToSelectState.onDragStart(offset) }, + onDragEnd = { dragToSelectState.onDragEnd() }, + onDragCancel = { dragToSelectState.onDragEnd() }, + onDrag = { change, _ -> dragToSelectState.onDrag(change.position) } + ) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt index a0f38c2484..29c99ad59e 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt @@ -11,7 +11,7 @@ import androidx.core.os.bundleOf import kotlinx.parcelize.Parcelize import org.signal.core.util.getParcelableCompat import org.signal.imageeditor.core.model.EditorModel -import org.signal.mediasend.edit.video.VideoTrimData +import org.signal.mediasend.screens.edit.video.VideoTrimData /** * Sealed interface for per-media editor state. All subtypes are [Parcelable] so the diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowActivityContract.kt similarity index 97% rename from feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt rename to feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowActivityContract.kt index 6f1b0aa60a..3af1205a07 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowActivityContract.kt @@ -32,7 +32,7 @@ import org.signal.core.models.parcelers.NullableCharSequenceParceler * class MyMediaSendContract : MediaSendActivityContract(MyMediaSendActivity::class.java) * ``` */ -class MediaSendActivityContract(private val clazz: Class) : ActivityResultContract() { +class MediaSendFlowActivityContract(private val clazz: Class) : ActivityResultContract() { /** * Creates the intent to launch the media send activity. diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt similarity index 60% rename from feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt rename to feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt index ece9b0c73e..262458fdb0 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt @@ -6,20 +6,33 @@ package org.signal.mediasend import android.os.Parcelable -import org.signal.mediasend.capture.MediaCaptureScreenEvent -import org.signal.mediasend.edit.MediaEditScreenEvent -import org.signal.mediasend.select.MediaSelectScreenEvent +import org.signal.core.models.media.Media +import org.signal.core.models.media.MediaFolder +import org.signal.mediasend.screens.capture.MediaCaptureScreenEvent +import org.signal.mediasend.screens.edit.MediaEditScreenEvent interface MediaSendEventHandler { - fun onMediaSelectScreenEvent(mediaSelectScreenEvent: MediaSelectScreenEvent) fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvent) fun onMediaCaptureScreenEvent(mediaCaptureScreenEvent: MediaCaptureScreenEvent) +} - object Empty : MediaSendEventHandler { - override fun onMediaSelectScreenEvent(mediaSelectScreenEvent: MediaSelectScreenEvent) = Unit - override fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvent) = Unit - override fun onMediaCaptureScreenEvent(mediaCaptureScreenEvent: MediaCaptureScreenEvent) = Unit - } +/** + * Changes to the flow itself, raised by the screens within it. A screen owns what only it renders; the selection, the + * back stack and the snackbar belong to the flow, so a screen asks for those through [MediaSendFlowViewModel.onEvent]. + */ +internal sealed interface MediaSendEvent { + data class AddMedia(val media: Set) : MediaSendEvent + data class RemoveMedia(val media: Set) : MediaSendEvent + data class SetFocusedMedia(val media: Media) : MediaSendEvent + data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaSendEvent + data class ShowSnackbar(val snackbar: SnackbarEvent) : MediaSendEvent + + /** Whoever was mid-gesture has stopped, so [MediaSendFlowState.isSelectionRejected] has served its purpose. */ + data object SelectionRejectionShown : MediaSendEvent + + data class NavigateToFiles(val mediaFolder: MediaFolder) : MediaSendEvent + data object NavigateToEdit : MediaSendEvent + data object NavigateToCamera : MediaSendEvent } /** @@ -36,19 +49,19 @@ sealed interface HudCommand { val isViewOnceAvailable: Boolean ) : HudCommand - /** Show the app's sticker picker. The pick is handed back via [MediaSendViewModel.onStickerSelected]. */ + /** Show the app's sticker picker. The pick is handed back via [MediaSendFlowViewModel.onStickerSelected]. */ data object SelectSticker : HudCommand /** * Show the app's date and time picker for a scheduled send. The pick is handed back via - * [MediaSendViewModel.onScheduledSendTimeSelected]. + * [MediaSendFlowViewModel.onScheduledSendTimeSelected]. */ data object PickScheduledSendTime : HudCommand /** * Clear whatever the app requires before a send can be scheduled for [scheduledTime], such as the scheduled messages * intro sheet or the exact alarm permission. Once cleared, the app calls - * [MediaSendViewModel.onScheduledSendConfirmed] to let the send proceed. + * [MediaSendFlowViewModel.onScheduledSendConfirmed] to let the send proceed. */ data class ConfirmScheduledSend(val scheduledTime: Long) : HudCommand diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt similarity index 76% rename from feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt rename to feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt index 7932ec344e..b2d0405425 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt @@ -13,12 +13,10 @@ import kotlinx.parcelize.Parcelize import kotlinx.parcelize.WriteWith import org.signal.camera.CameraDependencies import org.signal.core.models.media.Media -import org.signal.core.models.media.MediaFolder import org.signal.core.models.parcelers.NullableCharSequenceParceler import org.signal.core.util.ContentTypeUtil -import org.signal.mediasend.edit.image.BrushWidths -import org.signal.mediasend.edit.video.VideoTrimData -import org.signal.mediasend.select.MediaPermissions +import org.signal.mediasend.screens.edit.image.BrushWidths +import org.signal.mediasend.screens.edit.video.VideoTrimData import kotlin.time.Duration /** @@ -27,7 +25,7 @@ import kotlin.time.Duration * Fully [Parcelable] for [SavedStateHandle] persistence — no separate serialization needed. */ @Parcelize -data class MediaSendState( +data class MediaSendFlowState( val isCameraFirst: Boolean = false, /** * Optional recipient identifier for single-recipient flows. @@ -36,7 +34,7 @@ data class MediaSendState( /** * Mode of operation — determines whether we return a result or send immediately. */ - val mode: MediaSendActivityContract.Mode = MediaSendActivityContract.Mode.SingleRecipient, + val mode: MediaSendFlowActivityContract.Mode = MediaSendFlowActivityContract.Mode.SingleRecipient, val selectedMedia: List = emptyList(), /** * The currently focused/visible media item in the pager. @@ -116,25 +114,11 @@ data class MediaSendState( val scheduledTime: Long = -1, /** - * The [MediaFolder] list available on the system + * Whether media the user picked has just been refused. Read by whichever screen is picking, so a gesture that keeps + * handing us items can stop on the first refusal, and cleared by it through + * [MediaSendFlowViewModel.selectionRejectionShown]. */ - val mediaFolders: @WriteWith List = emptyList(), - - /** - * The selected [MediaFolder] for which to display content in the Select screen - */ - val selectedMediaFolder: @WriteWith MediaFolder? = null, - - /** - * The media content for a given selected [MediaFolder] - */ - val selectedMediaFolderItems: @WriteWith List = emptyList(), - - /** - * How much of the device's media the user currently lets us read. Re-derived alongside [mediaFolders], since a - * permission granted while we were backgrounded is only observable by looking again. - */ - val mediaPermissions: MediaPermissions = MediaPermissions.FULL, + val isSelectionRejected: @WriteWith Boolean = false, val mediaConstraints: @WriteWith MediaConstraints = MediaSendDependencies.mediaSendRepository.getMediaConstraints(), @@ -165,25 +149,6 @@ data class MediaSendState( return (editorStateMap[uri] as? EditorState.VideoTrim)?.videoTrimData ?: VideoTrimData() } - /** - * No-op parcelers for fields that are re-loaded on init and should not - * contribute to the saved-state bundle size. - */ - private object TransientMediaFolderListParceler : Parceler> { - override fun create(parcel: Parcel): List = emptyList() - override fun List.write(parcel: Parcel, flags: Int) = Unit - } - - private object TransientMediaFolderParceler : Parceler { - override fun create(parcel: Parcel): MediaFolder? = null - override fun MediaFolder?.write(parcel: Parcel, flags: Int) = Unit - } - - private object TransientMediaListParceler : Parceler> { - override fun create(parcel: Parcel): List = emptyList() - override fun List.write(parcel: Parcel, flags: Int) = Unit - } - /** * No-op parceler for flags tracking work that cannot outlive the process that started it. */ diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt similarity index 84% rename from feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt rename to feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt index 5839e062ab..a647e949fa 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt @@ -36,36 +36,33 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.signal.core.models.media.Media -import org.signal.core.models.media.MediaFolder import org.signal.core.ui.compose.DialogController import org.signal.core.ui.compose.DialogResult import org.signal.core.ui.compose.PermissionController import org.signal.core.ui.compose.SignalIcons -import org.signal.core.ui.compose.Snackbars import org.signal.core.ui.util.StorageUtil 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.ScheduleSendOption -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 -import org.signal.mediasend.select.MediaPermissionController -import org.signal.mediasend.select.MediaPermissions -import org.signal.mediasend.select.MediaSelectScreenEvent +import org.signal.mediasend.screens.capture.CameraXScreenEvent +import org.signal.mediasend.screens.capture.MediaCaptureScreenEvent +import org.signal.mediasend.screens.edit.ImageController +import org.signal.mediasend.screens.edit.MediaEditScreenEvent +import org.signal.mediasend.screens.edit.ScheduleSendOption +import org.signal.mediasend.screens.edit.image.BrushTool +import org.signal.mediasend.screens.edit.image.BrushWidthsState +import org.signal.mediasend.screens.edit.video.VideoTrimData +import org.signal.mediasend.util.MeteredConnectivity import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants import java.io.FileInputStream import java.io.IOException @@ -79,22 +76,22 @@ import kotlin.time.Duration.Companion.seconds * Configuration-survivable state manager for the media send flow. * * Uses [SavedStateHandle] for automatic state persistence across process death. - * [MediaSendState] is fully [Parcelable] and saved directly as a single key. + * [MediaSendFlowState] is fully [Parcelable] and saved directly as a single key. */ -class MediaSendViewModel( +class MediaSendFlowViewModel( private val savedStateHandle: SavedStateHandle, private val repository: MediaSendRepository, private val preUploadController: PreUploadController, isMeteredFlow: Flow ) : ViewModel(), MediaSendEventHandler { - private val args: MediaSendActivityContract.Args = savedStateHandle[KEY_ARGS] + private val args: MediaSendFlowActivityContract.Args = savedStateHandle[KEY_ARGS] ?: throw IllegalStateException("MediaSendViewModel requires args in SavedStateHandle. Use Factory to create.") private val identityChangesSince: Long = savedStateHandle[KEY_IDENTITY_CHANGES_SINCE] ?: throw IllegalStateException("MediaSendViewModel requires identityChangesSince in SavedStateHandle. Use Factory to create.") - private val defaultState = MediaSendState( + private val defaultState = MediaSendFlowState( isCameraFirst = args.isCameraFirst, recipientId = args.recipientId, additionalRecipients = args.additionalRecipients, @@ -104,7 +101,7 @@ class MediaSendViewModel( isAddToGroupStoryFlow = args.isAddToGroupStoryFlow, maxSelection = args.maxSelection, message = if (args.asTextStory) null else args.initialMessage, - isContactSelectionRequired = args.mode == MediaSendActivityContract.Mode.ChooseAfterMediaSelection, + isContactSelectionRequired = args.mode == MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection, sendType = args.sendType ) @@ -138,16 +135,14 @@ class MediaSendViewModel( permanentDenialMessage = R.string.MediaSendViewModel__signal_needs_the_storage_permission ) - internal val readMediaPermission = MediaPermissionController() - private val qrCheckRequest: Channel = Channel(Channel.RENDEZVOUS) /** * Main UI state. Backed by [SavedStateHandle] for automatic process death survival. * Writes to this flow are automatically persisted. */ - private val internalState: MutableStateFlow = savedStateHandle.getMutableStateFlow(KEY_STATE, defaultState) - val state: StateFlow = internalState.asStateFlow() + private val internalState: MutableStateFlow = savedStateHandle.getMutableStateFlow(KEY_STATE, defaultState) + val state: StateFlow = internalState.asStateFlow() private val editedVideoUris: MutableSet = mutableSetOf().apply { addAll(savedStateHandle[KEY_EDITED_VIDEO_URIS] ?: emptyList()) @@ -160,6 +155,15 @@ class MediaSendViewModel( /** 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)) + /** + * Serializes changes to the selection. Adding validates the whole selection off the main thread, so the read of the + * current selection and the write of the new one straddle a suspension point; without this, anything that changed the + * selection in between would be overwritten. A drag across the gallery grid produces exactly that traffic. + * + * Uncontended locking does not suspend, so a lone change still lands within the caller's frame. + */ + private val selectionMutex = Mutex() + /** Character count for the message field. */ val messageCharacterCount: Flow = state .map { it.message?.let { msg -> StringUtil.getGraphemeCount(msg) } ?: 0 } @@ -219,81 +223,39 @@ class MediaSendViewModel( if (args.initialMedia.isNotEmpty()) { addMedia(args.initialMedia.toSet()) } - - refreshMediaFolders() } /** Updates state atomically — automatically persisted via SavedStateHandle-backed MutableStateFlow. */ - private inline fun updateState(crossinline transform: MediaSendState.() -> MediaSendState) { + private inline fun updateState(crossinline transform: MediaSendFlowState.() -> MediaSendFlowState) { internalState.update { it.transform() } } + /** + * Runs [mutation] with the selection held still, so that it is the only thing changing + * [MediaSendFlowState.selectedMedia] while it runs. See [selectionMutex]. + */ + private fun mutateSelection(mutation: suspend () -> Unit) { + viewModelScope.launch { + selectionMutex.withLock { + mutation() + } + } + } + //region Media Selection - /** - * Re-reads the gallery from the media store, along with the level of access we currently have. The contents of - * the selected folder are re-read too, since widening selected-photos access adds items to it without changing - * the folder itself. - */ - fun refreshMediaFolders() { - viewModelScope.launch { - val mediaPermissions = MediaPermissions.current() - val folders = repository.getFolders() - - val reloadedFolder = internalState.value.selectedMediaFolder?.takeIf { it in folders } - val reloadedItems = reloadedFolder?.let { repository.getMedia(it.bucketId) } - - internalState.update { current -> - // The folder can be picked while we are still loading, so what we re-read is only applied if it is still - // what is on screen. Anything else keeps whatever the folder click itself put there. - val selectedFolder = current.selectedMediaFolder?.takeIf { it in folders } - - current.copy( - mediaPermissions = mediaPermissions, - mediaFolders = folders, - selectedMediaFolder = selectedFolder, - selectedMediaFolderItems = when { - selectedFolder == null -> emptyList() - selectedFolder == reloadedFolder && reloadedItems != null -> reloadedItems - else -> current.selectedMediaFolderItems - } - ) - } - } - } - - override fun onMediaSelectScreenEvent(mediaSelectScreenEvent: MediaSelectScreenEvent) { - when (mediaSelectScreenEvent) { - is MediaSelectScreenEvent.FolderClick -> onFolderClick(mediaSelectScreenEvent.mediaFolder) - is MediaSelectScreenEvent.MediaClick -> onMediaClick(mediaSelectScreenEvent.media) - is MediaSelectScreenEvent.SetFocusedMedia -> setFocusedMedia(mediaSelectScreenEvent.media) - is MediaSelectScreenEvent.ReorderSelectedMedia -> reorderMedia(mediaSelectScreenEvent.fromIndex, mediaSelectScreenEvent.toIndex) - MediaSelectScreenEvent.NavigateToEdit -> backStack.goToEdit() - MediaSelectScreenEvent.NavigateToCamera -> backStack.goToCamera() - MediaSelectScreenEvent.Refresh -> refreshMediaFolders() - MediaSelectScreenEvent.RequestMediaPermissions -> requestReadMediaPermissions(reportDenial = true) - MediaSelectScreenEvent.SelectMorePhotos -> requestReadMediaPermissions(reportDenial = false) - } - } - - /** - * Prompts for the gallery's read permissions and refreshes on any result, granted or not: selected-photos access - * comes back as a denial of the broad permissions but still changes what we can see. - */ - private fun requestReadMediaPermissions(reportDenial: Boolean) { - viewModelScope.launch { - val denied = readMediaPermission.request(permanentDenialSheet = reportDenial) - - if (reportDenial && denied) { - internalSnackbarEvents.trySend( - SnackbarEvent( - message = R.string.MediaSelectScreen__signal_needs_access_to_show_your_photos_and_videos, - duration = Snackbars.Duration.LONG - ) - ) - } - - refreshMediaFolders() + /** Applies a change a screen has asked for to the flow's own state. See [MediaSendEvent]. */ + internal fun onEvent(event: MediaSendEvent) { + when (event) { + is MediaSendEvent.AddMedia -> addMedia(event.media) + is MediaSendEvent.RemoveMedia -> removeMedia(event.media) + is MediaSendEvent.SetFocusedMedia -> setFocusedMedia(event.media) + is MediaSendEvent.ReorderSelectedMedia -> reorderMedia(event.fromIndex, event.toIndex) + is MediaSendEvent.ShowSnackbar -> internalSnackbarEvents.trySend(event.snackbar) + MediaSendEvent.SelectionRejectionShown -> updateState { copy(isSelectionRejected = false) } + is MediaSendEvent.NavigateToFiles -> backStack.goToFiles(event.mediaFolder) + MediaSendEvent.NavigateToEdit -> backStack.goToEdit() + MediaSendEvent.NavigateToCamera -> backStack.goToCamera() } } @@ -337,7 +299,7 @@ class MediaSendViewModel( is MediaEditScreenEvent.VideoSeek -> error("VideoSeek is routed to the video player bus by MediaEditScreen and must not reach the view-model.") is MediaEditScreenEvent.AddMessageClick -> { - val snapshot: MediaSendState = state.value + val snapshot: MediaSendFlowState = state.value sendHudCommand( HudCommand.ShowAddAMessageDialog( @@ -508,29 +470,6 @@ class MediaSendViewModel( backStack.goToEdit() } - private fun onFolderClick(mediaFolder: MediaFolder?) { - if (mediaFolder != null) { - backStack.goToFiles(mediaFolder) - } - - viewModelScope.launch { - if (mediaFolder != null) { - val media = repository.getMedia(mediaFolder.bucketId) - internalState.update { it.copy(selectedMediaFolder = mediaFolder, selectedMediaFolderItems = media) } - } else { - internalState.update { it.copy(selectedMediaFolder = null, selectedMediaFolderItems = emptyList()) } - } - } - } - - private fun onMediaClick(media: Media) { - if (media.uri in internalState.value.selectedMedia.map { it.uri }) { - removeMedia(media) - } else { - addMedia(media) - } - } - private fun sendHudCommand(hudCommand: HudCommand) { viewModelScope.launch { hudCommandChannel.send(hudCommand) @@ -538,7 +477,7 @@ class MediaSendViewModel( } /** - * Adds [media] to the selection, preserving insertion order and uniqueness by equality. + * Adds [media] to the selection, preserving insertion order and uniqueness by URI. * * Validates against constraints and starts pre-uploads for newly added items. * @@ -551,16 +490,19 @@ class MediaSendViewModel( /** * Adds [media] to the selection, optionally moving focus to the newly added item. * - * Focus is updated within the same atomic state write that adds the media, so [MediaSendState.focusedMedia] - * is never left pointing at an item that is not yet present in [MediaSendState.selectedMedia]. + * Focus is updated within the same atomic state write that adds the media, so [MediaSendFlowState.focusedMedia] + * is never left pointing at an item that is not yet present in [MediaSendFlowState.selectedMedia]. */ private fun addMedia(media: Set, focusNewlyAdded: Boolean) { - viewModelScope.launch { + mutateSelection { val snapshot = state.value - val newSelectionList: List = linkedSetOf().apply { - addAll(snapshot.selectedMedia) - addAll(media) - }.toList() + val selectedUris: Set = snapshot.selectedMedia.mapTo(mutableSetOf()) { it.uri } + + // Anything already selected keeps the instance we hold rather than the one being handed to us: ours has been + // through population, and may carry a caption or a file name the caller's copy does not know about. + val newSelectionList: List = snapshot.selectedMedia + media + .filterNot { it.uri in selectedUris } + .distinctBy { it.uri } // Validate and filter through repository val filterResult = repository.validateAndFilterMedia( @@ -593,9 +535,9 @@ class MediaSendViewModel( selectedMedia = updatedMedia, focusedMedia = newFocus, editorStateMap = editorStateMap + initializedEditorStates, - // Re-bind to the populated instance by URI: population fills in a video's 0x0 dimensions, producing a new - // Media that no longer equals the pre-population capture, which would otherwise leak past equality-based - // removal on back. Cleared once more than the capture is selected. + // Re-bind to the populated instance by URI, so the capture we hold is the one that is actually selected: + // population fills in a video's 0x0 dimensions, producing a new Media that no longer equals the + // pre-population capture. Cleared once more than the capture is selected. cameraFirstCapture = if (updatedMedia.size > 1) { null } else { @@ -634,6 +576,7 @@ class MediaSendViewModel( } internalSnackbarEvents.trySend(SnackbarEvent(message = message)) + updateState { copy(isSelectionRejected = true) } if (isSelectionEmpty && backStack.lastOrNull() == MediaSendNavKey.Edit) { backStack.resetTo(if (state.value.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select.Folders) @@ -723,43 +666,49 @@ class MediaSendViewModel( * @param media Media items to remove. */ fun removeMedia(media: Set) { - val snapshot = state.value - val newSelection = snapshot.selectedMedia - media + mutateSelection { + val snapshot = state.value + val removedUris: Set = media.mapTo(mutableSetOf()) { it.uri } - val newFocus = when { - newSelection.isEmpty() -> null - snapshot.focusedMedia in media -> { - val oldFocusIndex = snapshot.selectedMedia.indexOf(snapshot.focusedMedia) - newSelection[oldFocusIndex.coerceIn(0, newSelection.size - 1)] + // Removal is by URI, not by equality. Population and editing both replace a selected item with a new, non-equal + // Media for the same URI, so the instance a caller is holding is rarely the one we have. + val newSelection = snapshot.selectedMedia.filterNot { it.uri in removedUris } + + val newFocus = when { + newSelection.isEmpty() -> null + snapshot.focusedMedia?.uri in removedUris -> { + val oldFocusIndex = snapshot.selectedMedia.indexOfFirst { it.uri == snapshot.focusedMedia?.uri } + newSelection[oldFocusIndex.coerceIn(0, newSelection.size - 1)] + } + + else -> snapshot.focusedMedia } - else -> snapshot.focusedMedia + val newCameraFirstCapture = if (snapshot.cameraFirstCapture?.uri in removedUris) null else snapshot.cameraFirstCapture + + updateState { + copy( + selectedMedia = newSelection, + focusedMedia = newFocus, + editorStateMap = editorStateMap - removedUris, + cameraFirstCapture = newCameraFirstCapture + ) + } + + removedUris.forEach { imageControllers.remove(it) } + + // Update story requirements + viewModelScope.launch { + updateStorySendRequirements(newSelection) + } + + // Delete blobs and cancel uploads + viewModelScope.launch { + repository.deleteBlobs(media.toList()) + } + preUploadController.cancelUpload(media) + preUploadController.updateDisplayOrder(newSelection) } - - val newCameraFirstCapture = if (snapshot.cameraFirstCapture in media) null else snapshot.cameraFirstCapture - - updateState { - copy( - selectedMedia = newSelection, - focusedMedia = newFocus, - editorStateMap = editorStateMap - media.map { it.uri }.toSet(), - cameraFirstCapture = newCameraFirstCapture - ) - } - - media.forEach { imageControllers.remove(it.uri) } - - // Update story requirements - viewModelScope.launch { - updateStorySendRequirements(newSelection) - } - - // Delete blobs and cancel uploads - viewModelScope.launch { - repository.deleteBlobs(media.toList()) - } - preUploadController.cancelUpload(media) - preUploadController.updateDisplayOrder(newSelection) } /** @@ -768,21 +717,25 @@ class MediaSendViewModel( fun applyMediaUpdates(oldToNew: Map) { if (oldToNew.isEmpty()) return - val snapshot = state.value - val updatedSelection = snapshot.selectedMedia.map { oldToNew[it] ?: it } - updateState { copy(selectedMedia = updatedSelection) } + mutateSelection { + val snapshot = state.value + val updatedSelection = snapshot.selectedMedia.map { oldToNew[it] ?: it } + updateState { copy(selectedMedia = updatedSelection) } - preUploadController.applyMediaUpdates(oldToNew, snapshot.recipientId) - preUploadController.updateCaptions(updatedSelection) - preUploadController.updateDisplayOrder(updatedSelection) + preUploadController.applyMediaUpdates(oldToNew, snapshot.recipientId) + preUploadController.updateCaptions(updatedSelection) + preUploadController.updateDisplayOrder(updatedSelection) + } } /** * Sets the current ordering of selected media. */ fun setDisplayOrder(mediaInOrder: List) { - updateState { copy(selectedMedia = mediaInOrder) } - preUploadController.updateDisplayOrder(mediaInOrder) + mutateSelection { + updateState { copy(selectedMedia = mediaInOrder) } + preUploadController.updateDisplayOrder(mediaInOrder) + } } //endregion @@ -797,7 +750,7 @@ class MediaSendViewModel( val snapshot = state.value if (!snapshot.isPreUploadEnabled) return - val isChatDestination = (snapshot.mode is MediaSendActivityContract.Mode.SingleRecipient && !snapshot.isStory) || !snapshot.storiesEnabled + val isChatDestination = (snapshot.mode is MediaSendFlowActivityContract.Mode.SingleRecipient && !snapshot.isStory) || !snapshot.storiesEnabled val filteredPreUploadMedia = if (isChatDestination) { media.filter { !ContentTypeUtil.isDocumentType(it.contentType) } @@ -1008,16 +961,18 @@ class MediaSendViewModel( /** Moves the media at [fromIndex] to [toIndex]. Called once per drag, once the item has been dropped. */ private fun reorderMedia(fromIndex: Int, toIndex: Int) { - val selectedMedia = state.value.selectedMedia + mutateSelection { + val selectedMedia = state.value.selectedMedia - if (fromIndex == toIndex || fromIndex !in selectedMedia.indices || toIndex !in selectedMedia.indices) { - return + if (fromIndex == toIndex || fromIndex !in selectedMedia.indices || toIndex !in selectedMedia.indices) { + return@mutateSelection + } + + val reordered = selectedMedia.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } + + updateState { copy(selectedMedia = reordered) } + preUploadController.updateDisplayOrder(reordered) } - - val reordered = selectedMedia.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } - - updateState { copy(selectedMedia = reordered) } - preUploadController.updateDisplayOrder(reordered) } private fun isNonGifVideo(media: Media): Boolean { @@ -1364,13 +1319,13 @@ class MediaSendViewModel( * A flow that picks its destination mid-flight has nothing to attribute an upload to yet, so it waits for the send. */ private fun shouldPreUpload(metered: Boolean): Boolean { - return !metered && args.mode != MediaSendActivityContract.Mode.ChooseAfterMediaSelection + return !metered && args.mode != MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection } //endregion companion object { - private val TAG = Log.tag(MediaSendViewModel::class) + private val TAG = Log.tag(MediaSendFlowViewModel::class) private const val KEY_ARGS = "media_send_vm_args" private const val KEY_IDENTITY_CHANGES_SINCE = "media_send_vm_identity_changes_since" @@ -1380,14 +1335,14 @@ class MediaSendViewModel( } /** - * Factory that creates [MediaSendViewModel] from a [SavedStateHandle] and static dependencies. + * Factory that creates [MediaSendFlowViewModel] from a [SavedStateHandle] and static dependencies. * * On first creation, [args] and [identityChangesSince] are written into the [SavedStateHandle]. * On process death restoration, the [SavedStateHandle] already contains the persisted values * and the constructor parameters are ignored. */ class Factory( - private val args: MediaSendActivityContract.Args, + private val args: MediaSendFlowActivityContract.Args, private val identityChangesSince: Long = System.currentTimeMillis(), private val repository: MediaSendRepository = MediaSendDependencies.mediaSendRepository, private val isMeteredFlow: Flow = MeteredConnectivity.isMetered(MediaSendDependencies.application) @@ -1403,7 +1358,7 @@ class MediaSendViewModel( savedStateHandle[KEY_IDENTITY_CHANGES_SINCE] = identityChangesSince } - return MediaSendViewModel( + return MediaSendFlowViewModel( savedStateHandle = savedStateHandle, repository = repository, preUploadController = PreUploadController(), 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 deleted file mode 100644 index a4c11ad5ce..0000000000 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt +++ /dev/null @@ -1,218 +0,0 @@ -package org.signal.mediasend - -import android.annotation.SuppressLint -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -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.platform.LocalContext -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation3.runtime.NavBackStack -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.NavKey -import androidx.navigation3.runtime.rememberNavBackStack -import androidx.navigation3.ui.NavDisplay -import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner -import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.emptyFlow -import org.signal.core.ui.compose.AllDevicePreviews -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 -import kotlin.time.Duration.Companion.seconds - -/** - * Enforces the following flow of: - * - * Capture -> Edit -> Send - * Select -> Edit -> Send - */ -@Composable -internal fun MediaSendNavDisplay( - stateFlow: StateFlow, - snackbarEvents: Flow, - toastEvents: Flow, - imageControllers: ImageController.Container, - backStack: NavBackStack, - eventHandler: MediaSendEventHandler, - modifier: Modifier = Modifier, - textStoryEditorSlot: @Composable () -> Unit = {}, - sendSlot: @Composable (MediaSendState) -> Unit = {} -) { - Box { - NavDisplay( - backStack = backStack, - modifier = modifier.fillMaxSize() - ) { key -> - when (key) { - is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) { - val state by stateFlow.collectAsStateWithLifecycle() - - MediaCaptureScreen( - selectedCaptureScreen = key, - state = state, - onEvent = eventHandler::onMediaCaptureScreenEvent, - textStoryEditorSlot = textStoryEditorSlot - ) - } - - MediaSendNavKey.Select.Folders -> NavEntry(key) { - val state by stateFlow.collectAsStateWithLifecycle() - val screenState = remember(state.mediaFolders, state.selectedMedia, state.mediaPermissions) { - MediaSelectScreenState.Folders( - mediaFolders = state.mediaFolders, - selectedMedia = state.selectedMedia, - mediaPermissions = state.mediaPermissions - ) - } - - MediaSelectScreen( - state = screenState, - onEvent = eventHandler::onMediaSelectScreenEvent - ) - } - - is MediaSendNavKey.Select.Files -> NavEntry(key) { - val state by stateFlow.collectAsStateWithLifecycle() - val screenState = remember(state.selectedMedia, state.selectedMediaFolderItems, state.mediaPermissions) { - MediaSelectScreenState.Files( - selectedMediaFolder = key.folder, - selectedMediaFolderItems = state.selectedMediaFolderItems, - selectedMedia = state.selectedMedia, - mediaPermissions = state.mediaPermissions - ) - } - - MediaSelectScreen( - state = screenState, - onEvent = eventHandler::onMediaSelectScreenEvent - ) - } - - is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) { - val state by stateFlow.collectAsStateWithLifecycle() - MediaEditScreen( - state = state, - onEvent = eventHandler::onMediaEditScreenEvent, - imageControllers = imageControllers - ) - } - - is MediaSendNavKey.Send -> NavEntry(key) { - val state by stateFlow.collectAsStateWithLifecycle() - sendSlot(state) - } - - else -> error("Unknown key: $key") - } - } - - Snackbar(snackbarEvents) - Toast(toastEvents) - } -} - -private val TOAST_DURATION = 3.seconds - -/** - * Shows each [ToastEvent] over the middle of the screen for [TOAST_DURATION]. A new event replaces whatever is showing - * and restarts that countdown. - */ -@Composable -private fun BoxScope.Toast( - toastEvents: Flow -) { - var event: ToastEvent? by remember { mutableStateOf(null) } - var visible: Boolean by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - toastEvents.collectLatest { - event = it - visible = true - delay(TOAST_DURATION) - visible = false - } - } - - AnimatedVisibility( - visible = visible, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.align(Alignment.Center) - ) { - // Held past the hide so that it is still there to fade out. - event?.let { MediaSendToast(event = it) } - } -} - -@SuppressLint("LocalContextGetResourceValueCall") -@Composable -private fun BoxScope.Snackbar( - snackbarEvents: Flow -) { - val context = LocalContext.current - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(snackbarHostState) { - snackbarEvents.collect { event -> - snackbarHostState.showSnackbar( - message = context.getString(event.message), - duration = event.duration - ) - } - } - - Snackbars.Host( - snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter) - ) -} - -@AllDevicePreviews -@Composable -private fun MediaSendNavDisplayPreview() { - Previews.Preview { - CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides rememberNavigationEventDispatcherOwner(parent = null)) { - MediaSendNavDisplay( - stateFlow = MutableStateFlow(MediaSendState(isCameraFirst = true)), - snackbarEvents = emptyFlow(), - toastEvents = emptyFlow(), - imageControllers = remember { ImageController.Container() }, - backStack = rememberNavBackStack(MediaSendNavKey.Edit), - eventHandler = MediaSendEventHandler.Empty, - textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") }, - sendSlot = { _ -> BoxWithText("Send Slot") } - ) - } - } -} - -@Composable -private fun BoxWithText(text: String, modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = text) - } -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt new file mode 100644 index 0000000000..9a863a0717 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt @@ -0,0 +1,185 @@ +package org.signal.mediasend + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.platform.LocalContext +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import org.signal.core.ui.compose.Snackbars +import org.signal.core.ui.compose.showSnackbar +import org.signal.mediasend.screens.capture.MediaCaptureScreen +import org.signal.mediasend.screens.edit.MediaEditScreen +import org.signal.mediasend.screens.select.MediaSelectScreen +import org.signal.mediasend.screens.select.MediaSelectViewModel +import kotlin.time.Duration.Companion.seconds + +/** + * Enforces the following flow of: + * + * Capture -> Edit -> Send + * Select -> Edit -> Send + */ +@Composable +internal fun MediaSendNavigation( + viewModel: MediaSendFlowViewModel, + modifier: Modifier = Modifier, + textStoryEditorSlot: @Composable () -> Unit = {}, + sendSlot: @Composable (MediaSendFlowState) -> Unit = {} +) { + Box { + NavDisplay( + backStack = viewModel.backStack, + modifier = modifier.fillMaxSize(), + // Each select screen owns a view model of its own, so entries need their own stores rather than the activity's. + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ) + ) { key -> + when (key) { + is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) { + val state by viewModel.state.collectAsStateWithLifecycle() + + MediaCaptureScreen( + selectedCaptureScreen = key, + state = state, + onEvent = viewModel::onMediaCaptureScreenEvent, + textStoryEditorSlot = textStoryEditorSlot + ) + } + + MediaSendNavKey.Select.Folders -> NavEntry(key) { + val selectViewModel: MediaSelectViewModel = viewModel( + factory = MediaSelectViewModel.Factory( + parentState = viewModel.state, + parentEventEmitter = viewModel::onEvent, + mediaFolder = null + ) + ) + val state by selectViewModel.state.collectAsStateWithLifecycle() + + selectViewModel.readMediaPermission.Content() + + MediaSelectScreen( + state = state, + onEvent = selectViewModel::onEvent + ) + } + + is MediaSendNavKey.Select.Files -> NavEntry(key) { + val selectViewModel: MediaSelectViewModel = viewModel( + factory = MediaSelectViewModel.Factory( + parentState = viewModel.state, + parentEventEmitter = viewModel::onEvent, + mediaFolder = key.folder + ) + ) + val state by selectViewModel.state.collectAsStateWithLifecycle() + + selectViewModel.readMediaPermission.Content() + + MediaSelectScreen( + state = state, + onEvent = selectViewModel::onEvent + ) + } + + is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) { + val state by viewModel.state.collectAsStateWithLifecycle() + MediaEditScreen( + state = state, + onEvent = viewModel::onMediaEditScreenEvent, + imageControllers = viewModel.imageControllers + ) + } + + is MediaSendNavKey.Send -> NavEntry(key) { + val state by viewModel.state.collectAsStateWithLifecycle() + sendSlot(state) + } + + else -> error("Unknown key: $key") + } + } + + Snackbar(viewModel.snackbarEvents) + Toast(viewModel.toastEvents) + } +} + +private val TOAST_DURATION = 3.seconds + +/** + * Shows each [ToastEvent] over the middle of the screen for [TOAST_DURATION]. A new event replaces whatever is showing + * and restarts that countdown. + */ +@Composable +private fun BoxScope.Toast( + toastEvents: Flow +) { + var event: ToastEvent? by remember { mutableStateOf(null) } + var visible: Boolean by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + toastEvents.collectLatest { + event = it + visible = true + delay(TOAST_DURATION) + visible = false + } + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.Center) + ) { + // Held past the hide so that it is still there to fade out. + event?.let { MediaSendToast(event = it) } + } +} + +@SuppressLint("LocalContextGetResourceValueCall") +@Composable +private fun BoxScope.Snackbar( + snackbarEvents: Flow +) { + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(snackbarHostState) { + snackbarEvents.collect { event -> + snackbarHostState.showSnackbar( + message = context.getString(event.message), + duration = event.duration + ) + } + } + + Snackbars.Host( + snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt index 62b301675a..9ef297cfd8 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt @@ -12,8 +12,8 @@ import kotlinx.coroutines.flow.Flow import org.signal.core.models.media.Media import org.signal.core.models.media.MediaFolder import org.signal.imageeditor.core.model.EditorModel -import org.signal.mediasend.edit.image.BrushWidths import org.signal.mediasend.preupload.PreUploadResult +import org.signal.mediasend.screens.edit.image.BrushWidths import java.io.InputStream import kotlin.time.Duration 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 2610537b25..48dfa9a279 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 @@ -19,17 +19,17 @@ import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.LocalDisplayNameProvider import org.signal.core.ui.compose.theme.SignalTheme -import org.signal.mediasend.edit.MediaEditScreenDialogs +import org.signal.mediasend.screens.edit.MediaEditScreenDialogs @Composable fun MediaSendScreen( - contractArgs: MediaSendActivityContract.Args, + contractArgs: MediaSendFlowActivityContract.Args, modifier: Modifier = Modifier, textStoryEditorSlot: @Composable () -> Unit = {}, - sendSlot: @Composable (MediaSendState) -> Unit = {}, + sendSlot: @Composable (MediaSendFlowState) -> Unit = {}, onExternalHudCommand: (HudCommand) -> Unit = {} ) { - val viewModel = viewModel(factory = MediaSendViewModel.Factory(args = contractArgs)) + val viewModel = viewModel(factory = MediaSendFlowViewModel.Factory(args = contractArgs)) LaunchedEffect(viewModel) { viewModel.hudCommands.collect { command -> @@ -85,15 +85,9 @@ fun MediaSendScreen( } viewModel.writeStoragePermission.Content() - viewModel.readMediaPermission.Content() - MediaSendNavDisplay( - stateFlow = viewModel.state, - snackbarEvents = viewModel.snackbarEvents, - toastEvents = viewModel.toastEvents, - imageControllers = viewModel.imageControllers, - backStack = viewModel.backStack, - eventHandler = viewModel, + MediaSendNavigation( + viewModel = viewModel, modifier = modifier, textStoryEditorSlot = textStoryEditorSlot, sendSlot = sendSlot diff --git a/feature/media-send/src/main/java/org/signal/mediasend/PreviewMediaConstraints.kt b/feature/media-send/src/main/java/org/signal/mediasend/PreviewMediaConstraints.kt index b561fa798a..4e90a4f43c 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/PreviewMediaConstraints.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/PreviewMediaConstraints.kt @@ -7,7 +7,7 @@ package org.signal.mediasend import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import org.signal.mediasend.edit.image.BrushWidths +import org.signal.mediasend.screens.edit.image.BrushWidths import org.thoughtcrime.securesms.video.TranscodingConfig import kotlin.time.Duration.Companion.seconds @@ -35,7 +35,7 @@ object PreviewMediaConstraints : MediaConstraints() { @Composable internal fun rememberPreviewState() = remember { - MediaSendState( + MediaSendFlowState( mediaConstraints = PreviewMediaConstraints, storyMaxVideoDuration = 30.seconds, storiesEnabled = true, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadController.kt b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadController.kt index a64d4ce162..609529f759 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadController.kt @@ -6,6 +6,7 @@ package org.signal.mediasend.preupload import android.content.Context +import android.net.Uri import androidx.annotation.WorkerThread import org.signal.core.models.media.Media import org.signal.core.util.ThreadUtil @@ -32,7 +33,13 @@ class PreUploadController { private val callback: PreUploadRepository = MediaSendDependencies.preUploadRepository private val context: Context = MediaSendDependencies.application - private val uploadResults: LinkedHashMap = LinkedHashMap() + + /** + * Keyed by URI rather than by [Media] itself: there is one pre-uploaded attachment per item on disk, and everything + * else about a [Media] changes as it moves through the flow. Editing a caption, trimming a video or filling in a + * video's dimensions all produce a new, non-equal [Media] for the same attachment. + */ + private val uploadResults: LinkedHashMap = LinkedHashMap() private val executor: Executor = SignalExecutors.newCachedSingleThreadExecutor("signal-PreUpload", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD) @@ -58,7 +65,7 @@ class PreUploadController { executor.execute { for (media in mediaItems) { Log.d(TAG, "Canceling existing preuploads.") - cancelUploadInternal(media) + cancelUploadInternal(media.uri) Log.d(TAG, "Re-uploading media with recipient.") uploadMediaInternal(media, recipientId) } @@ -77,9 +84,9 @@ class PreUploadController { for ((oldMedia, newMedia) in oldToNew) { val same = oldMedia == newMedia && hasSameTransformProperties(oldMedia, newMedia) - if (!same || !uploadResults.containsKey(newMedia)) { + if (!same || !uploadResults.containsKey(newMedia.uri)) { Log.d(TAG, "Canceling existing preuploads.") - cancelUploadInternal(oldMedia) + cancelUploadInternal(oldMedia.uri) Log.d(TAG, "Applying media updates.") uploadMediaInternal(newMedia, recipientId) } @@ -107,7 +114,7 @@ class PreUploadController { */ fun cancelUpload(media: Media) { Log.d(TAG, "User canceling media upload.") - executor.execute { cancelUploadInternal(media) } + executor.execute { cancelUploadInternal(media.uri) } } /** @@ -119,7 +126,7 @@ class PreUploadController { Log.d(TAG, "Canceling uploads.") executor.execute { for (media in mediaItems) { - cancelUploadInternal(media) + cancelUploadInternal(media.uri) } } } @@ -131,8 +138,8 @@ class PreUploadController { Log.d(TAG, "Canceling all uploads.") executor.execute { val keysSnapshot = uploadResults.keys.toList() - for (media in keysSnapshot) { - cancelUploadInternal(media) + for (uri in keysSnapshot) { + cancelUploadInternal(uri) } } } @@ -181,25 +188,25 @@ class PreUploadController { val result = callback.preUpload(context, media, recipientId) if (result != null) { - uploadResults[media] = result + uploadResults[media.uri] = result } else { Log.w(TAG, "Failed to upload media with URI: ${media.uri}") } } - private fun cancelUploadInternal(media: Media) { - val result = uploadResults[media] ?: return + private fun cancelUploadInternal(uri: Uri) { + val result = uploadResults[uri] ?: return Log.d(TAG, "Canceling attachment upload jobs for ${result.attachmentId}") callback.cancelJobs(context, result.jobIds) - uploadResults.remove(media) + uploadResults.remove(uri) callback.deleteAttachment(context, result.attachmentId) } @WorkerThread private fun updateCaptionsInternal(updatedMedia: List) { for (updated in updatedMedia) { - val result = uploadResults[updated] + val result = uploadResults[updated.uri] if (result != null) { callback.updateAttachmentCaption(context, result.attachmentId, updated.caption) @@ -212,14 +219,14 @@ class PreUploadController { @WorkerThread private fun updateDisplayOrderInternal(mediaInOrder: List) { val orderMap: MutableMap = LinkedHashMap() - val orderedUploadResults: LinkedHashMap = LinkedHashMap() + val orderedUploadResults: LinkedHashMap = LinkedHashMap() for ((index, media) in mediaInOrder.withIndex()) { - val result = uploadResults[media] + val result = uploadResults[media.uri] if (result != null) { orderMap[result.attachmentId] = index - orderedUploadResults[media] = result + orderedUploadResults[media.uri] = result } else { Log.w(TAG, "When updating display order, no pre-upload result could be found for media with URI: ${media.uri}") } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/MediaSendMetrics.kt similarity index 97% rename from feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/MediaSendMetrics.kt index 16efe05e60..a8160c0502 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/MediaSendMetrics.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend +package org.signal.mediasend.screens import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition diff --git a/feature/media-send/src/main/java/org/signal/mediasend/CameraFragment.java b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraFragment.java similarity index 91% rename from feature/media-send/src/main/java/org/signal/mediasend/CameraFragment.java rename to feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraFragment.java index 97083663c6..6637157be1 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/CameraFragment.java +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraFragment.java @@ -3,11 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend; +package org.signal.mediasend.screens.capture; import androidx.annotation.NonNull; import org.signal.core.util.SeekableFileDescriptor; +import org.signal.mediasend.MediaConstraints; public interface CameraFragment { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXFragment.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXFragment.kt index 23bf49857b..6210d82d2f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXFragment.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.capture +package org.signal.mediasend.screens.capture import android.Manifest import android.content.Context @@ -91,11 +91,10 @@ import org.signal.core.util.EncryptedProxyFileDescriptor import org.signal.core.util.SeekableFileDescriptor import org.signal.core.util.closeQuietly import org.signal.core.util.logging.Log -import org.signal.mediasend.CameraFragment import org.signal.mediasend.MediaConstraints import org.signal.mediasend.MediaSendDependencies import org.signal.mediasend.R -import org.signal.mediasend.VideoUtil +import org.signal.mediasend.util.VideoUtil import java.io.ByteArrayOutputStream import java.io.IOException diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXScreenEvent.kt similarity index 94% rename from feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXScreenEvent.kt index 0427830928..c0acc759c6 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/CameraXScreenEvent.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.capture +package org.signal.mediasend.screens.capture import org.signal.core.util.SeekableFileDescriptor diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt similarity index 92% rename from feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt index 6e8ad5f883..f61c319fff 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt @@ -3,13 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.capture +package org.signal.mediasend.screens.capture import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.tooling.preview.Preview import org.signal.core.ui.compose.Previews -import org.signal.mediasend.MediaSendState +import org.signal.mediasend.MediaSendFlowState import org.signal.mediasend.rememberPreviewState /** @@ -17,7 +17,7 @@ import org.signal.mediasend.rememberPreviewState */ @Composable fun MediaCameraCaptureScreen( - state: MediaSendState, + state: MediaSendFlowState, onEvent: (MediaCaptureScreenEvent) -> Unit ) { CameraXScreen( diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt similarity index 94% rename from feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt index 48d7bf6f6a..145144a119 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.capture +package org.signal.mediasend.screens.capture import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform @@ -54,12 +54,12 @@ import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.theme.SignalTheme import org.signal.glide.compose.GlideImage import org.signal.glide.decryptableuri.DecryptableUri -import org.signal.mediasend.MediaSendActivityContract +import org.signal.mediasend.MediaSendFlowActivityContract +import org.signal.mediasend.MediaSendFlowState import org.signal.mediasend.MediaSendNavKey -import org.signal.mediasend.MediaSendState import org.signal.mediasend.R -import org.signal.mediasend.edit.rememberPreviewMedia import org.signal.mediasend.rememberPreviewState +import org.signal.mediasend.screens.edit.rememberPreviewMedia /** * The text story editor slides in over a stationary camera, so it always sits on top. @@ -73,7 +73,7 @@ private const val TEXT_STORY_Z_INDEX = 1f @Composable fun MediaCaptureScreen( selectedCaptureScreen: MediaSendNavKey.Capture, - state: MediaSendState, + state: MediaSendFlowState, onEvent: (MediaCaptureScreenEvent) -> Unit, textStoryEditorSlot: @Composable () -> Unit ) { @@ -128,10 +128,10 @@ fun MediaCaptureScreen( } @Composable -private fun rememberCanDisplayBottomBar(state: MediaSendState): Boolean { +private fun rememberCanDisplayBottomBar(state: MediaSendFlowState): Boolean { return remember(state) { - val isSingleStory = state.mode == MediaSendActivityContract.Mode.SingleRecipient && state.isStory - state.isCameraFirst && state.storiesEnabled && (state.mode == MediaSendActivityContract.Mode.ChooseAfterMediaSelection || isSingleStory) + val isSingleStory = state.mode == MediaSendFlowActivityContract.Mode.SingleRecipient && state.isStory + state.isCameraFirst && state.storiesEnabled && (state.mode == MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection || isSingleStory) } } @@ -291,7 +291,7 @@ fun MediaCaptureScreenPreview() { .copy( isCameraFirst = true, storiesEnabled = true, - mode = MediaSendActivityContract.Mode.ChooseAfterMediaSelection + mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection ), onEvent = {}, textStoryEditorSlot = {} @@ -311,7 +311,7 @@ fun MediaCaptureScreenWithSelectedMediaPreview() { .copy( isCameraFirst = true, storiesEnabled = true, - mode = MediaSendActivityContract.Mode.ChooseAfterMediaSelection, + mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection, selectedMedia = selectedMedia ), onEvent = {}, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvent.kt similarity index 91% rename from feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreenEvent.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvent.kt index 41ce3a184f..39919b5314 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvent.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.capture +package org.signal.mediasend.screens.capture sealed interface MediaCaptureScreenEvent { data object ShowCamera : MediaCaptureScreenEvent diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/TextStoryBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt similarity index 98% rename from feature/media-send/src/main/java/org/signal/mediasend/capture/TextStoryBar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt index 1b800c8316..822c61e359 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/TextStoryBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.capture +package org.signal.mediasend.screens.capture import androidx.compose.foundation.background import androidx.compose.foundation.border diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/AddAMessageRow.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/AddAMessageRow.kt index 785b3e575c..75bba4e393 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/AddAMessageRow.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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/screens/edit/ImageController.kt similarity index 97% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt index c61b65bb3b..67985c598d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import android.content.Context import android.graphics.Paint @@ -22,12 +22,12 @@ 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 -import org.signal.mediasend.edit.image.BrushTool -import org.signal.mediasend.edit.image.BrushWidthsState -import org.signal.mediasend.edit.image.FaceDetectionResult -import org.signal.mediasend.edit.image.HSVColorBarState -import org.signal.mediasend.edit.image.ImageEditorState -import org.signal.mediasend.edit.image.detectFaces +import org.signal.mediasend.screens.edit.image.BrushTool +import org.signal.mediasend.screens.edit.image.BrushWidthsState +import org.signal.mediasend.screens.edit.image.FaceDetectionResult +import org.signal.mediasend.screens.edit.image.HSVColorBarState +import org.signal.mediasend.screens.edit.image.ImageEditorState +import org.signal.mediasend.screens.edit.image.detectFaces /** * Holds the editor state for a single image (modes, undo, selection, etc.). diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditControl.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditControl.kt similarity index 93% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditControl.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditControl.kt index 2bbf5a8ac6..170286f128 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditControl.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditControl.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition @@ -13,7 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import org.signal.mediasend.MediaSendMetrics +import org.signal.mediasend.screens.MediaSendMetrics /** * A control on the edit screen, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout 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/screens/edit/MediaEditScreen.kt similarity index 94% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt index 231b7c909a..9060b7bb39 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import android.net.Uri import androidx.activity.compose.BackHandler @@ -52,26 +52,26 @@ import org.signal.glide.decryptableuri.DecryptableUri import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.EditorState import org.signal.mediasend.MediaSendDependencies -import org.signal.mediasend.MediaSendMetrics -import org.signal.mediasend.MediaSendState -import org.signal.mediasend.edit.document.DocumentPage -import org.signal.mediasend.edit.image.BlurFacesBar -import org.signal.mediasend.edit.image.BrushWidthBar -import org.signal.mediasend.edit.image.BrushWidthPreview -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.VideoEditorViewModel -import org.signal.mediasend.edit.video.VideoTrimBar +import org.signal.mediasend.MediaSendFlowState import org.signal.mediasend.rememberPreviewState +import org.signal.mediasend.screens.MediaSendMetrics +import org.signal.mediasend.screens.edit.document.DocumentPage +import org.signal.mediasend.screens.edit.image.BlurFacesBar +import org.signal.mediasend.screens.edit.image.BrushWidthBar +import org.signal.mediasend.screens.edit.image.BrushWidthPreview +import org.signal.mediasend.screens.edit.image.DrawModeColorBar +import org.signal.mediasend.screens.edit.image.ImageEditor +import org.signal.mediasend.screens.edit.image.ImageEditorClearAllButton +import org.signal.mediasend.screens.edit.image.ImageEditorToolbar +import org.signal.mediasend.screens.edit.image.ImageEditorUndoRedoButtons +import org.signal.mediasend.screens.edit.image.RotationDial +import org.signal.mediasend.screens.edit.video.VideoEditorFragment +import org.signal.mediasend.screens.edit.video.VideoEditorViewModel +import org.signal.mediasend.screens.edit.video.VideoTrimBar @Composable internal fun MediaEditScreen( - state: MediaSendState, + state: MediaSendFlowState, onEvent: (MediaEditScreenEvent) -> Unit, imageControllers: ImageController.Container ) { @@ -409,7 +409,7 @@ internal fun MediaEditScreen( */ @Composable private fun MediaToolbar( - state: MediaSendState, + state: MediaSendFlowState, onEvent: (MediaEditScreenEvent) -> Unit, focusedUri: Uri?, focusedEditorState: EditorState?, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenDialogs.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenDialogs.kt index 38b490f71d..0d7f1128cb 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenDialogs.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenDialogs.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Column 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/screens/edit/MediaEditScreenEvent.kt similarity index 89% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvent.kt index fc24255a33..691567989e 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvent.kt @@ -3,12 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import org.signal.core.models.media.Media import org.signal.mediasend.SentMediaQuality -import org.signal.mediasend.edit.image.BrushTool -import org.signal.mediasend.edit.video.VideoTrimData +import org.signal.mediasend.screens.edit.image.BrushTool +import org.signal.mediasend.screens.edit.video.VideoTrimData sealed interface MediaEditScreenEvent { data class RemoveMedia(val media: Media) : MediaEditScreenEvent diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditSummaryPill.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditSummaryPill.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditSummaryPill.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditSummaryPill.kt index 363c568791..dffee94bbf 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditSummaryPill.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditSummaryPill.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt similarity index 92% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt index 542a51375f..7efdfa8b84 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -31,7 +31,7 @@ import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.copied.androidx.compose.material3.IconButtonColors import org.signal.core.ui.rememberWindowBreakpoint import org.signal.mediasend.EditorState -import org.signal.mediasend.MediaSendState +import org.signal.mediasend.MediaSendFlowState import org.signal.mediasend.SentMediaQuality import org.signal.mediasend.test.TestTags @@ -95,7 +95,7 @@ internal fun MediaEditorToolbarButton( @Composable internal fun MediaEditorToolbarSharedButtons( - state: MediaSendState, + state: MediaSendFlowState, editorState: EditorState, onEvent: (MediaEditScreenEvent) -> Unit ) { @@ -138,7 +138,7 @@ internal fun MediaEditorToolbarSharedButtons( } } -private fun isQualityVisible(state: MediaSendState, editorState: EditorState): Boolean { +private fun isQualityVisible(state: MediaSendFlowState, editorState: EditorState): Boolean { return !state.isStory && editorState !is EditorState.Document } @@ -150,7 +150,7 @@ private fun isSaveVisible(editorState: EditorState): Boolean { * Adding a second attachment would silently drop view-once, so the entry point -- and the selection rail it belongs to * -- goes away while it is on. */ -internal fun isAddMediaVisible(state: MediaSendState, editorState: EditorState?): Boolean { +internal fun isAddMediaVisible(state: MediaSendFlowState, editorState: EditorState?): Boolean { return !state.isViewOnceEnabled && editorState !is EditorState.Document } @@ -158,6 +158,6 @@ internal fun isAddMediaVisible(state: MediaSendState, editorState: EditorState?) * Whether [MediaEditorToolbarSharedButtons] would render anything, so callers with no buttons of their own can skip the * toolbar rather than leave an empty one behind. */ -internal fun hasSharedToolbarButtons(state: MediaSendState, editorState: EditorState): Boolean { +internal fun hasSharedToolbarButtons(state: MediaSendFlowState, editorState: EditorState): Boolean { return isQualityVisible(state, editorState) || isSaveVisible(editorState) || isAddMediaVisible(state, editorState) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/QualitySelectorSheetContent.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/QualitySelectorSheetContent.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/QualitySelectorSheetContent.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/QualitySelectorSheetContent.kt index c0dffe926d..e5ef87bbf2 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/QualitySelectorSheetContent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/QualitySelectorSheetContent.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ScheduleSendMenu.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ScheduleSendMenu.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/ScheduleSendMenu.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ScheduleSendMenu.kt index a1dbd7a426..14e776098e 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ScheduleSendMenu.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ScheduleSendMenu.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Row diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ThumbnailRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ThumbnailRow.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/ThumbnailRow.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ThumbnailRow.kt index 93923d3036..917fc60e14 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ThumbnailRow.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ThumbnailRow.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit +package org.signal.mediasend.screens.edit import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -55,7 +55,7 @@ import org.signal.core.ui.compose.list.rememberReorderableListState import org.signal.core.ui.compose.list.reorderableList import org.signal.core.util.ContentTypeUtil import org.signal.glide.compose.GlideImage -import org.signal.mediasend.MediaSendMetrics +import org.signal.mediasend.screens.MediaSendMetrics import kotlin.math.abs import kotlin.math.floor import kotlin.math.roundToInt diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/document/DocumentPage.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/document/DocumentPage.kt similarity index 98% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/document/DocumentPage.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/document/DocumentPage.kt index 2f37946e69..3e264e3d51 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/document/DocumentPage.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/document/DocumentPage.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.document +package org.signal.mediasend.screens.edit.document import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BlurFacesBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BlurFacesBar.kt similarity index 97% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/BlurFacesBar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BlurFacesBar.kt index dcd0489dc2..efaa2dd19a 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BlurFacesBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BlurFacesBar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidthBar.kt similarity index 98% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthBar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidthBar.kt index a188f1ded8..cb9a99069d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidthBar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthMetrics.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidthMetrics.kt similarity index 95% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthMetrics.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidthMetrics.kt index 625661db47..947c67b810 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthMetrics.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidthMetrics.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.ui.unit.dp diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthPreview.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidthPreview.kt similarity index 96% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthPreview.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidthPreview.kt index 53f79f50f3..228d8ae4bc 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidthPreview.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidthPreview.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import android.graphics.Matrix import androidx.compose.animation.core.animateFloatAsState 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/screens/edit/image/BrushWidths.kt similarity index 97% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/BrushWidths.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/BrushWidths.kt index 4ed88b63bd..9eb329bf5b 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/screens/edit/image/BrushWidths.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import android.os.Parcelable import androidx.compose.runtime.Stable diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/DrawModeColorBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/DrawModeColorBar.kt similarity index 95% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/DrawModeColorBar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/DrawModeColorBar.kt index 4f6481f0ba..0dc91cd283 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/DrawModeColorBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/DrawModeColorBar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.foundation.layout.Row import androidx.compose.material3.Icon @@ -20,7 +20,7 @@ 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 +import org.signal.mediasend.screens.edit.ImageController /** * Color bar that allows the user to change between highlighter and brush. diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/FaceDetectionResult.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/FaceDetectionResult.kt similarity index 98% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/FaceDetectionResult.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/FaceDetectionResult.kt index 136607ff3f..f45af68a9f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/FaceDetectionResult.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/FaceDetectionResult.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import android.content.Context import android.graphics.Matrix diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/HSVColorBar.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/HSVColorBar.kt index 0dd17dd2ef..528c98f2c7 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/HSVColorBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/HSVColorBar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures 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/screens/edit/image/ImageEditor.kt similarity index 98% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditor.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt index 067102e8c9..f92380f1d8 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/screens/edit/image/ImageEditor.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import android.graphics.Matrix import android.graphics.PointF @@ -42,7 +42,7 @@ import androidx.compose.ui.unit.dp import org.signal.imageeditor.core.ImageEditorTouchHandler import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.renderers.MultiLineTextRenderer -import org.signal.mediasend.edit.ImageController +import org.signal.mediasend.screens.edit.ImageController @Composable internal fun ImageEditor( 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/screens/edit/image/ImageEditorClearAllButton.kt similarity index 91% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorClearAllButton.kt index 4ab7bf5171..b2bd2971af 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorClearAllButton.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorClearAllButton.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme @@ -18,8 +18,8 @@ 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 -import org.signal.mediasend.edit.MediaEditControl +import org.signal.mediasend.screens.edit.ImageController +import org.signal.mediasend.screens.edit.MediaEditControl @Composable internal fun ImageEditorClearAllButton( 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/screens/edit/image/ImageEditorState.kt similarity index 98% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorState.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorState.kt index 885a57d8d8..dac7faa55c 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/screens/edit/image/ImageEditorState.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import android.content.Context import android.graphics.Canvas 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/screens/edit/image/ImageEditorToolbar.kt similarity index 94% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt index d7b6e894db..ddaa8bcc0d 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/screens/edit/image/ImageEditorToolbar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.size @@ -26,20 +26,20 @@ import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.next import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.EditorState -import org.signal.mediasend.MediaSendState -import org.signal.mediasend.edit.ImageController -import org.signal.mediasend.edit.MediaEditScreenDialogs -import org.signal.mediasend.edit.MediaEditScreenEvent -import org.signal.mediasend.edit.MediaEditorToolbar -import org.signal.mediasend.edit.MediaEditorToolbarButton -import org.signal.mediasend.edit.MediaEditorToolbarSharedButtons +import org.signal.mediasend.MediaSendFlowState import org.signal.mediasend.rememberPreviewState +import org.signal.mediasend.screens.edit.ImageController +import org.signal.mediasend.screens.edit.MediaEditScreenDialogs +import org.signal.mediasend.screens.edit.MediaEditScreenEvent +import org.signal.mediasend.screens.edit.MediaEditorToolbar +import org.signal.mediasend.screens.edit.MediaEditorToolbarButton +import org.signal.mediasend.screens.edit.MediaEditorToolbarSharedButtons import java.util.EnumMap @Composable internal fun ImageEditorToolbar( imageEditorController: ImageController, - state: MediaSendState, + state: MediaSendFlowState, editorState: EditorState.Image, onEvent: (MediaEditScreenEvent) -> Unit, modifier: Modifier = Modifier @@ -69,7 +69,7 @@ internal fun ImageEditorToolbar( @Composable private fun ImageEditorNoneStateToolbar( imageEditorController: ImageController, - state: MediaSendState, + state: MediaSendFlowState, editorState: EditorState.Image, onEvent: (MediaEditScreenEvent) -> Unit, modifier: Modifier = Modifier 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/screens/edit/image/ImageEditorUndoRedoButtons.kt similarity index 94% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorUndoRedoButtons.kt index 019f415b98..652fd9e0e4 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorUndoRedoButtons.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorUndoRedoButtons.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy import androidx.compose.foundation.layout.Row @@ -21,8 +21,8 @@ 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 -import org.signal.mediasend.edit.MediaEditControl +import org.signal.mediasend.screens.edit.ImageController +import org.signal.mediasend.screens.edit.MediaEditControl @Composable internal fun ImageEditorUndoRedoButtons( 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/screens/edit/image/RotationDial.kt similarity index 98% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/RotationDial.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/RotationDial.kt index 86408e27dd..4098c6fa72 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/screens/edit/image/RotationDial.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -43,7 +43,7 @@ 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 org.signal.mediasend.edit.ImageController +import org.signal.mediasend.screens.edit.ImageController import kotlin.math.abs import kotlin.math.ceil import kotlin.math.floor diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/TextModeColorBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/TextModeColorBar.kt similarity index 96% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/image/TextModeColorBar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/TextModeColorBar.kt index c1145e94df..7d31d10b99 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/TextModeColorBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/TextModeColorBar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.image +package org.signal.mediasend.screens.edit.image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -21,7 +21,7 @@ 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 +import org.signal.mediasend.screens.edit.ImageController /** * Color bar for the text element being edited or moved, with a toggle that cycles its style. Laid out along whichever diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorFragment.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorFragment.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt index 4f31fc968c..33f22e3626 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorFragment.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorFragment.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.video +package org.signal.mediasend.screens.edit.video import android.net.Uri import android.os.Build diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorPlayButtonLayout.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorPlayButtonLayout.kt similarity index 97% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorPlayButtonLayout.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorPlayButtonLayout.kt index 35b2a2c7c4..534e3aed54 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorPlayButtonLayout.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorPlayButtonLayout.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.video +package org.signal.mediasend.screens.edit.video import android.animation.Animator import android.content.Context diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorViewModel.kt similarity index 97% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorViewModel.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorViewModel.kt index b84259b93a..4adba38403 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoEditorViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoEditorViewModel.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.video +package org.signal.mediasend.screens.edit.video import android.net.Uri import androidx.lifecycle.ViewModel diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsRangeSelectorView.java b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoThumbnailsRangeSelectorView.java similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsRangeSelectorView.java rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoThumbnailsRangeSelectorView.java index e630876864..610ed2619d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsRangeSelectorView.java +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoThumbnailsRangeSelectorView.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.video; +package org.signal.mediasend.screens.edit.video; import android.content.Context; import android.content.res.TypedArray; diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsView.java b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoThumbnailsView.java similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsView.java rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoThumbnailsView.java index cbc84dd752..045e8605df 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoThumbnailsView.java +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoThumbnailsView.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.video; +package org.signal.mediasend.screens.edit.video; import android.content.Context; import android.graphics.Bitmap; diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoTrimBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimBar.kt similarity index 98% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoTrimBar.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimBar.kt index 3473dff249..657f8f293a 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoTrimBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimBar.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.video +package org.signal.mediasend.screens.edit.video import android.content.Context import android.net.Uri @@ -26,7 +26,7 @@ import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.horizontalGutters import org.signal.core.util.logging.Log import org.signal.mediasend.R -import org.signal.mediasend.edit.MediaEditScreenEvent +import org.signal.mediasend.screens.edit.MediaEditScreenEvent import org.thoughtcrime.securesms.video.interfaces.MediaInputFactory import java.io.IOException import java.util.concurrent.TimeUnit diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoTrimData.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimData.kt similarity index 96% rename from feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoTrimData.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimData.kt index d6d4d0fce5..106733a541 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/video/VideoTrimData.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/video/VideoTrimData.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.edit.video +package org.signal.mediasend.screens.edit.video import android.os.Bundle import android.os.Parcelable diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissionController.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaPermissionController.kt similarity index 99% rename from feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissionController.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaPermissionController.kt index 4fa8498e01..7e10685059 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissionController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaPermissionController.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.select +package org.signal.mediasend.screens.select import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissions.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaPermissions.kt similarity index 95% rename from feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissions.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaPermissions.kt index 535adc4645..1816f9d9fd 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaPermissions.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaPermissions.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.select +package org.signal.mediasend.screens.select import org.signal.core.ui.util.StorageUtil diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt similarity index 87% rename from feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt index 44b1e11d48..44aec85c89 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreen.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.select +package org.signal.mediasend.screens.select import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedVisibility @@ -34,8 +34,10 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -45,6 +47,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -58,6 +61,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -79,14 +83,19 @@ import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.ensureWidthIsAtLeastHeight +import org.signal.core.ui.compose.list.DragSelectEvent +import org.signal.core.ui.compose.list.DragToSelectState import org.signal.core.ui.compose.list.ReorderableItem +import org.signal.core.ui.compose.list.dragToSelect +import org.signal.core.ui.compose.list.rememberDragToSelectState import org.signal.core.ui.compose.list.rememberReorderBuffer import org.signal.core.ui.compose.list.rememberReorderableListState import org.signal.core.ui.compose.list.reorderableList import org.signal.glide.compose.GlideImage -import org.signal.mediasend.MediaSendMetrics import org.signal.mediasend.R -import org.signal.mediasend.edit.rememberPreviewMedia +import org.signal.mediasend.screens.MediaSendMetrics +import org.signal.mediasend.screens.edit.rememberPreviewMedia +import org.signal.mediasend.test.TestTags import org.signal.core.ui.permissions.Permissions as PermissionsUtil /** How many empty tiles stand in for the gallery we are not allowed to show. Matches the v2 gallery. */ @@ -109,6 +118,18 @@ internal fun MediaSelectScreen( val gridConfiguration = rememberGridConfiguration(state is MediaSelectScreenState.Folders && !showPlaceholders) val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val gridState = rememberLazyGridState() + val dragToSelectState = rememberDragToSelectMediaState(state, onEvent, gridState) + + // Once the selection starts refusing items there is nothing left for the drag to do, and letting it run on would + // raise a refusal for every further tile it crosses. + LaunchedEffect(state.isSelectionRejected) { + if (state.isSelectionRejected) { + dragToSelectState.cancel() + onEvent(MediaSelectScreenEvent.SelectionRejectionShown) + } + } + // The system prompt and the app settings round-trip both land us back here, and neither tells us what changed. val currentOnEvent by rememberUpdatedState(onEvent) LifecycleResumeEffect(Unit) { @@ -144,13 +165,22 @@ internal fun MediaSelectScreen( Box(modifier = Modifier.weight(1f)) { LazyVerticalGrid( + state = gridState, columns = gridConfiguration.gridCells, horizontalArrangement = spacedBy(gridConfiguration.horizontalSpacing), verticalArrangement = spacedBy(gridConfiguration.verticalSpacing), - userScrollEnabled = !showPlaceholders, + userScrollEnabled = !showPlaceholders && !dragToSelectState.isActive, modifier = Modifier .padding(horizontal = gridConfiguration.horizontalMargin) .fillMaxSize() + .testTag(TestTags.MEDIA_SELECT_GRID) + .then( + if (state is MediaSelectScreenState.Files && !showPlaceholders) { + Modifier.dragToSelect(dragToSelectState) + } else { + Modifier + } + ) ) { if (showPlaceholders) { items(PLACEHOLDER_COUNT) { @@ -217,6 +247,48 @@ internal fun MediaSelectScreen( } } +/** + * Turns the covered index ranges reported by the gesture into batched selection events for the media those indices + * stand for. Only the file grid is homogeneous enough for this: one tile per item, so a grid index is an index into + * [MediaSelectScreenState.Files.selectedMediaFolderItems]. + */ +@Composable +private fun rememberDragToSelectMediaState( + state: MediaSelectScreenState, + onEvent: (MediaSelectScreenEvent) -> Unit, + gridState: LazyGridState +): DragToSelectState { + return rememberDragToSelectState(gridState) { event -> + val items = (state as? MediaSelectScreenState.Files)?.selectedMediaFolderItems ?: return@rememberDragToSelectState + + when (event) { + is DragSelectEvent.Started -> { + // A range only ever grows out from an unselected tile. Long pressing one that is already selected unselects it + // and stops there, which is how the v2 gallery behaved. + val media = items.getOrNull(event.index) + if (media != null && state.selectedMedia.any { it.uri == media.uri }) { + onEvent(MediaSelectScreenEvent.MediaUnselected(setOf(media))) + cancel() + } + } + + is DragSelectEvent.RangeSelected -> { + val media = event.indices.mapNotNullTo(mutableSetOf(), items::getOrNull) + if (media.isNotEmpty()) { + onEvent(MediaSelectScreenEvent.MediaSelected(media)) + } + } + + is DragSelectEvent.RangeUnselected -> { + val media = event.indices.mapNotNullTo(mutableSetOf(), items::getOrNull) + if (media.isNotEmpty()) { + onEvent(MediaSelectScreenEvent.MediaUnselected(media)) + } + } + } + } +} + @Composable private fun rememberGridConfiguration(isRootGrid: Boolean): GridConfiguration { val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass @@ -483,8 +555,12 @@ private fun MediaTile( if (selectionIndex >= 0) 12.dp else 0.dp ) + // Square regardless of what is in it. The thumbnail emits nothing until it has loaded, so a tile sized by its content + // is zero-height until then: the row collapses, and the tile cannot be hit by a gesture that goes by item geometry. BoxWithConstraints( modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) .background(color = MaterialTheme.colorScheme.surfaceVariant) .clickable( onClick = { onEvent(MediaSelectScreenEvent.MediaClick(media)) }, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreenEvent.kt new file mode 100644 index 0000000000..69be91adfc --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreenEvent.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.screens.select + +import org.signal.core.models.media.Media +import org.signal.core.models.media.MediaFolder +import org.signal.mediasend.MediaSendFlowState + +sealed interface MediaSelectScreenEvent { + + /** The parent flow's state changed and needs to be merged into this screen's state. */ + data class ParentStateChanged(val parentState: MediaSendFlowState) : MediaSelectScreenEvent { + // The parent's state carries the message the user is typing and every item they have picked. Only the parts this + // screen reads are worth logging, and they are the only parts safe to. + override fun toString(): String = "ParentStateChanged(selectedMedia=${parentState.selectedMedia.size}, isSelectionRejected=${parentState.isSelectionRejected})" + } + + /** The screen has stopped what the refusal was meant to stop, so the flow can drop it. */ + data object SelectionRejectionShown : MediaSelectScreenEvent + + data class FolderClick(val mediaFolder: MediaFolder?) : MediaSelectScreenEvent + data class MediaClick(val media: Media) : MediaSelectScreenEvent + + /** A run of media covered by a drag across the grid. Batched, since a drag can cross many tiles in a single frame. */ + data class MediaSelected(val media: Set) : MediaSelectScreenEvent + + /** A run of media that a drag has retracted back over, undoing its own selection. */ + data class MediaUnselected(val media: Set) : MediaSelectScreenEvent + + data class SetFocusedMedia(val media: Media) : MediaSelectScreenEvent + data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaSelectScreenEvent + data object NavigateToEdit : MediaSelectScreenEvent + data object NavigateToCamera : MediaSelectScreenEvent + + /** Re-read the gallery and the current permission level, e.g. after coming back from app settings. */ + data object Refresh : MediaSelectScreenEvent + + /** The up-front "Allow access" ask, made when we cannot read anything at all. */ + data object RequestMediaPermissions : MediaSelectScreenEvent + + /** Re-ask while holding selected-photos access, so the user can widen what we can see. */ + data object SelectMorePhotos : MediaSelectScreenEvent +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenState.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreenState.kt similarity index 54% rename from feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenState.kt rename to feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreenState.kt index 6d9f3d47ba..b3d598633c 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectScreenState.kt @@ -3,25 +3,30 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend.select +package org.signal.mediasend.screens.select import org.signal.core.models.media.Media import org.signal.core.models.media.MediaFolder sealed interface MediaSelectScreenState { + /** The flow's current selection, as last reported by the parent. */ val selectedMedia: List /** How much of the device's media we are allowed to read. */ val mediaPermissions: MediaPermissions + /** Whether media just handed to the flow was refused, as last reported by the parent. */ + val isSelectionRejected: Boolean + /** Whether the media store actually gave us anything to render for this screen. */ val hasContent: Boolean data class Folders( val mediaFolders: List, override val selectedMedia: List, - override val mediaPermissions: MediaPermissions = MediaPermissions.FULL + override val mediaPermissions: MediaPermissions = MediaPermissions.FULL, + override val isSelectionRejected: Boolean = false ) : MediaSelectScreenState { override val hasContent: Boolean get() = mediaFolders.isNotEmpty() @@ -31,9 +36,20 @@ sealed interface MediaSelectScreenState { val selectedMediaFolder: MediaFolder, val selectedMediaFolderItems: List, override val selectedMedia: List, - override val mediaPermissions: MediaPermissions = MediaPermissions.FULL + override val mediaPermissions: MediaPermissions = MediaPermissions.FULL, + override val isSelectionRejected: Boolean = false ) : MediaSelectScreenState { override val hasContent: Boolean get() = selectedMediaFolderItems.isNotEmpty() } + + fun withParentState(selectedMedia: List, isSelectionRejected: Boolean): MediaSelectScreenState = when (this) { + is Folders -> copy(selectedMedia = selectedMedia, isSelectionRejected = isSelectionRejected) + is Files -> copy(selectedMedia = selectedMedia, isSelectionRejected = isSelectionRejected) + } + + fun withMediaPermissions(mediaPermissions: MediaPermissions): MediaSelectScreenState = when (this) { + is Folders -> copy(mediaPermissions = mediaPermissions) + is Files -> copy(mediaPermissions = mediaPermissions) + } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectViewModel.kt new file mode 100644 index 0000000000..ef4a16b8d6 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectViewModel.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.screens.select + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.signal.core.models.media.Media +import org.signal.core.models.media.MediaFolder +import org.signal.core.ui.compose.EventDrivenViewModel +import org.signal.core.ui.compose.Snackbars +import org.signal.core.util.logging.Log +import org.signal.mediasend.MediaSendDependencies +import org.signal.mediasend.MediaSendEvent +import org.signal.mediasend.MediaSendFlowState +import org.signal.mediasend.MediaSendRepository +import org.signal.mediasend.R +import org.signal.mediasend.SnackbarEvent + +/** + * Drives the select screen, for one appearance of it: either the list of folders, or the contents of a single folder. + * + * The gallery and how much of it we are allowed to read are only of interest to this screen, so they live here. The + * selection is not -- the rest of the flow sends it -- so it arrives as [MediaSelectScreenEvent.ParentStateChanged] and + * is rendered from a copy in this screen's own state. Anything this screen wants done to it goes back out as a + * [MediaSendEvent]. + * + * @param mediaFolder The folder whose contents to show, or null for the list of folders. + */ +internal class MediaSelectViewModel( + private val parentState: StateFlow, + private val parentEventEmitter: (MediaSendEvent) -> Unit, + mediaFolder: MediaFolder?, + private val repository: MediaSendRepository = MediaSendDependencies.mediaSendRepository +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(MediaSelectViewModel::class) + } + + private val _state: MutableStateFlow = MutableStateFlow( + if (mediaFolder != null) { + MediaSelectScreenState.Files( + selectedMediaFolder = mediaFolder, + selectedMediaFolderItems = emptyList(), + selectedMedia = emptyList() + ) + } else { + MediaSelectScreenState.Folders( + mediaFolders = emptyList(), + selectedMedia = emptyList() + ) + } + ) + + val state: StateFlow = _state.asStateFlow() + + /** Hosted here, since this is the only screen that asks for the gallery's permissions. */ + val readMediaPermission = MediaPermissionController() + + init { + parentState + .distinctUntilChangedBy { it.selectedMedia to it.isSelectionRejected } + .onEach { onEvent(MediaSelectScreenEvent.ParentStateChanged(it)) } + .launchIn(viewModelScope) + + refresh() + } + + override suspend fun processEvent(event: MediaSelectScreenEvent) { + when (event) { + is MediaSelectScreenEvent.ParentStateChanged -> _state.update { it.withParentState(event.parentState.selectedMedia, event.parentState.isSelectionRejected) } + MediaSelectScreenEvent.SelectionRejectionShown -> parentEventEmitter(MediaSendEvent.SelectionRejectionShown) + is MediaSelectScreenEvent.FolderClick -> event.mediaFolder?.let { parentEventEmitter(MediaSendEvent.NavigateToFiles(it)) } + is MediaSelectScreenEvent.MediaClick -> applyMediaClickEvent(event.media) + is MediaSelectScreenEvent.MediaSelected -> parentEventEmitter(MediaSendEvent.AddMedia(event.media)) + is MediaSelectScreenEvent.MediaUnselected -> parentEventEmitter(MediaSendEvent.RemoveMedia(event.media)) + is MediaSelectScreenEvent.SetFocusedMedia -> parentEventEmitter(MediaSendEvent.SetFocusedMedia(event.media)) + is MediaSelectScreenEvent.ReorderSelectedMedia -> parentEventEmitter(MediaSendEvent.ReorderSelectedMedia(event.fromIndex, event.toIndex)) + MediaSelectScreenEvent.NavigateToEdit -> parentEventEmitter(MediaSendEvent.NavigateToEdit) + MediaSelectScreenEvent.NavigateToCamera -> parentEventEmitter(MediaSendEvent.NavigateToCamera) + MediaSelectScreenEvent.Refresh -> refresh() + MediaSelectScreenEvent.RequestMediaPermissions -> requestReadMediaPermissions(reportDenial = true) + MediaSelectScreenEvent.SelectMorePhotos -> requestReadMediaPermissions(reportDenial = false) + } + } + + private fun applyMediaClickEvent(media: Media) { + if (_state.value.selectedMedia.any { it.uri == media.uri }) { + parentEventEmitter(MediaSendEvent.RemoveMedia(setOf(media))) + } else { + parentEventEmitter(MediaSendEvent.AddMedia(setOf(media))) + } + } + + /** + * Re-reads what this screen shows from the media store, along with the level of access we currently have. Widening + * selected-photos access adds items to a folder without changing which folder it is, so the contents have to be + * re-read even when it looks like nothing about the folder has changed. + */ + private fun refresh() { + viewModelScope.launch { + val mediaPermissions = MediaPermissions.current() + + val reloaded: MediaSelectScreenState = when (val snapshot = _state.value) { + is MediaSelectScreenState.Folders -> snapshot.copy(mediaFolders = repository.getFolders()) + is MediaSelectScreenState.Files -> snapshot.copy(selectedMediaFolderItems = repository.getMedia(snapshot.selectedMediaFolder.bucketId)) + } + + // Only what the parent reports can have changed while we were reading, and that is not ours to overwrite. + _state.update { current -> + reloaded + .withMediaPermissions(mediaPermissions) + .withParentState(current.selectedMedia, current.isSelectionRejected) + } + } + } + + /** + * Prompts for the gallery's read permissions and refreshes on any result, granted or not: selected-photos access + * comes back as a denial of the broad permissions but still changes what we can see. + */ + private fun requestReadMediaPermissions(reportDenial: Boolean) { + viewModelScope.launch { + val denied = readMediaPermission.request(permanentDenialSheet = reportDenial) + + if (reportDenial && denied) { + parentEventEmitter( + MediaSendEvent.ShowSnackbar( + SnackbarEvent( + message = R.string.MediaSelectScreen__signal_needs_access_to_show_your_photos_and_videos, + duration = Snackbars.Duration.LONG + ) + ) + ) + } + + refresh() + } + } + + class Factory( + private val parentState: StateFlow, + private val parentEventEmitter: (MediaSendEvent) -> Unit, + private val mediaFolder: MediaFolder? + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return MediaSelectViewModel(parentState, parentEventEmitter, mediaFolder) as T + } + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt deleted file mode 100644 index 521436b598..0000000000 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.signal.mediasend.select - -import org.signal.core.models.media.Media -import org.signal.core.models.media.MediaFolder - -sealed interface MediaSelectScreenEvent { - data class FolderClick(val mediaFolder: MediaFolder?) : MediaSelectScreenEvent - data class MediaClick(val media: Media) : MediaSelectScreenEvent - data class SetFocusedMedia(val media: Media) : MediaSelectScreenEvent - data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaSelectScreenEvent - data object NavigateToEdit : MediaSelectScreenEvent - data object NavigateToCamera : MediaSelectScreenEvent - - /** Re-read the gallery and the current permission level, e.g. after coming back from app settings. */ - data object Refresh : MediaSelectScreenEvent - - /** The up-front "Allow access" ask, made when we cannot read anything at all. */ - data object RequestMediaPermissions : MediaSelectScreenEvent - - /** Re-ask while holding selected-photos access, so the user can widen what we can see. */ - data object SelectMorePhotos : MediaSelectScreenEvent -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt index b4bcb07bbe..7307a36a9b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt @@ -18,6 +18,9 @@ object TestTags { const val MEDIA_EDITOR_TOOLBAR_SAVE_BUTTON = "media_editor_toolbar_save_button" const val MEDIA_EDITOR_TOOLBAR_ADD_MEDIA_BUTTON = "media_editor_toolbar_add_media_button" + // Media Select Screen + const val MEDIA_SELECT_GRID = "media_select_grid" + // Schedule Send Menu const val SCHEDULE_SEND_PICK_TIME_OPTION = "schedule_send_pick_time_option" diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MeteredConnectivity.kt b/feature/media-send/src/main/java/org/signal/mediasend/util/MeteredConnectivity.kt similarity index 98% rename from feature/media-send/src/main/java/org/signal/mediasend/MeteredConnectivity.kt rename to feature/media-send/src/main/java/org/signal/mediasend/util/MeteredConnectivity.kt index c7a1ca942d..66b442989f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MeteredConnectivity.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/util/MeteredConnectivity.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend +package org.signal.mediasend.util import android.Manifest import android.content.Context diff --git a/feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.kt b/feature/media-send/src/main/java/org/signal/mediasend/util/VideoUtil.kt similarity index 90% rename from feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.kt rename to feature/media-send/src/main/java/org/signal/mediasend/util/VideoUtil.kt index be2c2c322f..8985d7ff80 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/VideoUtil.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/util/VideoUtil.kt @@ -3,8 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend +package org.signal.mediasend.util +import org.signal.mediasend.MediaConstraints +import org.signal.mediasend.MediaSendDependencies import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants import kotlin.math.floor import kotlin.math.min diff --git a/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml b/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml index 45d49dc6b6..611169687e 100644 --- a/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml +++ b/feature/media-send/src/main/res/layout/mediasend_video_fragment.xml @@ -15,7 +15,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> - -() + + private val selected: Set + get() = events.filterIsInstance().flatMap { it.media }.toSet() + + private val unselected: Set + get() = events.filterIsInstance().flatMap { it.media }.toSet() + + @Test + fun `Given an unselected tile, when it is long pressed, then it alone is selected`() { + setContent() + + composeTestRule.onNodeWithTag(TestTags.MEDIA_SELECT_GRID).performTouchInput { + down(tileCenter(0)) + advanceEventTime(LONG_PRESS_MS) + up() + } + composeTestRule.waitForIdle() + + assertEquals(setOf(media(0)), selected) + assertEquals(emptySet(), unselected) + } + + @Test + fun `Given a long press, when dragging across tiles, then the range is selected`() { + setContent() + + composeTestRule.onNodeWithTag(TestTags.MEDIA_SELECT_GRID).performTouchInput { + down(tileCenter(0)) + advanceEventTime(LONG_PRESS_MS) + moveTo(tileCenter(1)) + moveTo(tileCenter(2)) + up() + } + composeTestRule.waitForIdle() + + assertEquals(setOf(media(0), media(1), media(2)), selected) + assertEquals(emptySet(), unselected) + } + + @Test + fun `Given a range has been dragged out, when retracting back over it, then those tiles are unselected`() { + setContent() + + composeTestRule.onNodeWithTag(TestTags.MEDIA_SELECT_GRID).performTouchInput { + down(tileCenter(0)) + advanceEventTime(LONG_PRESS_MS) + moveTo(tileCenter(2)) + moveTo(tileCenter(1)) + up() + } + composeTestRule.waitForIdle() + + assertEquals(setOf(media(0), media(1), media(2)), selected) + assertEquals(setOf(media(2)), unselected) + } + + @Test + fun `Given a selected tile, when it is long pressed, then it is unselected and no range is dragged out`() { + setContent(selectedMedia = listOf(media(0))) + + composeTestRule.onNodeWithTag(TestTags.MEDIA_SELECT_GRID).performTouchInput { + down(tileCenter(0)) + advanceEventTime(LONG_PRESS_MS) + moveTo(tileCenter(1)) + up() + } + composeTestRule.waitForIdle() + + assertEquals(setOf(media(0)), unselected) + assertEquals(emptySet(), selected) + } + + /** + * Read off the laid-out tiles rather than derived from the column count, so the gesture lands where the tiles actually + * are whatever width the grid ended up. + */ + private fun tileCenter(index: Int): Offset { + val grid = composeTestRule.onNodeWithTag(TestTags.MEDIA_SELECT_GRID).fetchSemanticsNode() + val tile = grid.children[index] + val offset = tile.positionInRoot - grid.positionInRoot + + return Offset(offset.x + tile.size.width / 2f, offset.y + tile.size.height / 2f) + } + + private fun media(index: Int): Media = MEDIA[index] + + private fun setContent(selectedMedia: List = emptyList()) { + composeTestRule.setContent { + SignalTheme { + Box(modifier = Modifier.size(GRID_WIDTH.dp, GRID_HEIGHT.dp)) { + MediaSelectScreen( + state = MediaSelectScreenState.Files( + selectedMediaFolder = FOLDER, + selectedMediaFolderItems = MEDIA, + selectedMedia = selectedMedia + ), + onEvent = { events += it } + ) + } + } + } + } + + private companion object { + private const val LONG_PRESS_MS = 600L + private const val GRID_WIDTH = 400f + private const val GRID_HEIGHT = 800f + + private val FOLDER = MediaFolder( + thumbnailUri = "content://folder".toUri(), + title = "Camera", + itemCount = 8, + bucketId = "bucket", + folderType = MediaFolder.FolderType.CAMERA + ) + + private val MEDIA: List = (0 until 8).map { index -> + Media( + uri = "content://media/$index".toUri(), + contentType = "image/jpeg", + date = index.toLong(), + width = 100, + height = 100, + size = 1024, + duration = 0, + isBorderless = false, + isVideoGif = false, + bucketId = "bucket", + caption = null, + transformProperties = null, + fileName = "media_$index.jpg" + ) + } + } +}