mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 12:55:11 +01:00
Refactor media-send to match structure of registration. Add drag to select.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+7
-7
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
app:tint="@color/signal_colorOnSurface"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.signal.mediasend.edit.video.VideoThumbnailsRangeSelectorView
|
||||
<org.signal.mediasend.screens.edit.video.VideoThumbnailsRangeSelectorView
|
||||
android:id="@+id/video_timeline"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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
|
||||
@@ -41,6 +42,8 @@ import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.list.ReorderListEvent.ItemMoved
|
||||
|
||||
@@ -82,24 +85,28 @@ fun rememberReorderableListState(
|
||||
val baseAutoScrollSpeed = with(LocalDensity.current) { 10.dp.toPx() }
|
||||
val scrollAcceleration = 2f
|
||||
|
||||
// Gated on there being an overscroll to act on: waiting on frames is what asks for the next one, so an ungated loop
|
||||
// would hold the list at the display's frame rate for as long as it is on screen.
|
||||
LaunchedEffect(state, autoScroll) {
|
||||
if (!autoScroll) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
while (true) {
|
||||
withFrameNanos { }
|
||||
snapshotFlow { state.dragOverscrollAmount != 0f }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { isOverscrolling ->
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<Int>) : DragSelectEvent
|
||||
|
||||
/** The range has retracted back over [indices], which it had previously selected. */
|
||||
data class RangeUnselected(val indices: Set<Int>) : 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ import org.signal.core.models.parcelers.NullableCharSequenceParceler
|
||||
* class MyMediaSendContract : MediaSendActivityContract(MyMediaSendActivity::class.java)
|
||||
* ```
|
||||
*/
|
||||
class MediaSendActivityContract(private val clazz: Class<out Activity>) : ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?>() {
|
||||
class MediaSendFlowActivityContract(private val clazz: Class<out Activity>) : ActivityResultContract<MediaSendFlowActivityContract.Args, MediaSendFlowActivityContract.Result?>() {
|
||||
|
||||
/**
|
||||
* Creates the intent to launch the media send activity.
|
||||
+25
-12
@@ -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<Media>) : MediaSendEvent
|
||||
data class RemoveMedia(val media: Set<Media>) : 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
|
||||
|
||||
+8
-43
@@ -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<Media> = 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<TransientMediaFolderListParceler> List<MediaFolder> = emptyList(),
|
||||
|
||||
/**
|
||||
* The selected [MediaFolder] for which to display content in the Select screen
|
||||
*/
|
||||
val selectedMediaFolder: @WriteWith<TransientMediaFolderParceler> MediaFolder? = null,
|
||||
|
||||
/**
|
||||
* The media content for a given selected [MediaFolder]
|
||||
*/
|
||||
val selectedMediaFolderItems: @WriteWith<TransientMediaListParceler> List<Media> = 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<TransientInFlightFlagParceler> Boolean = false,
|
||||
|
||||
val mediaConstraints: @WriteWith<TransientMediaConstraintsParceler> 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<List<MediaFolder>> {
|
||||
override fun create(parcel: Parcel): List<MediaFolder> = emptyList()
|
||||
override fun List<MediaFolder>.write(parcel: Parcel, flags: Int) = Unit
|
||||
}
|
||||
|
||||
private object TransientMediaFolderParceler : Parceler<MediaFolder?> {
|
||||
override fun create(parcel: Parcel): MediaFolder? = null
|
||||
override fun MediaFolder?.write(parcel: Parcel, flags: Int) = Unit
|
||||
}
|
||||
|
||||
private object TransientMediaListParceler : Parceler<List<Media>> {
|
||||
override fun create(parcel: Parcel): List<Media> = emptyList()
|
||||
override fun List<Media>.write(parcel: Parcel, flags: Int) = Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op parceler for flags tracking work that cannot outlive the process that started it.
|
||||
*/
|
||||
+135
-180
@@ -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<Boolean>
|
||||
) : 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<String> = 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<MediaSendState> = savedStateHandle.getMutableStateFlow(KEY_STATE, defaultState)
|
||||
val state: StateFlow<MediaSendState> = internalState.asStateFlow()
|
||||
private val internalState: MutableStateFlow<MediaSendFlowState> = savedStateHandle.getMutableStateFlow(KEY_STATE, defaultState)
|
||||
val state: StateFlow<MediaSendFlowState> = internalState.asStateFlow()
|
||||
|
||||
private val editedVideoUris: MutableSet<Uri> = mutableSetOf<Uri>().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<Int> = 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<Media>, focusNewlyAdded: Boolean) {
|
||||
viewModelScope.launch {
|
||||
mutateSelection {
|
||||
val snapshot = state.value
|
||||
val newSelectionList: List<Media> = linkedSetOf<Media>().apply {
|
||||
addAll(snapshot.selectedMedia)
|
||||
addAll(media)
|
||||
}.toList()
|
||||
val selectedUris: Set<Uri> = 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<Media> = 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<Media>) {
|
||||
val snapshot = state.value
|
||||
val newSelection = snapshot.selectedMedia - media
|
||||
mutateSelection {
|
||||
val snapshot = state.value
|
||||
val removedUris: Set<Uri> = 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<Media, Media>) {
|
||||
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<Media>) {
|
||||
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<Boolean> = 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(),
|
||||
@@ -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<MediaSendState>,
|
||||
snackbarEvents: Flow<SnackbarEvent>,
|
||||
toastEvents: Flow<ToastEvent>,
|
||||
imageControllers: ImageController.Container,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
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<ToastEvent>
|
||||
) {
|
||||
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<SnackbarEvent>
|
||||
) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<ToastEvent>
|
||||
) {
|
||||
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<SnackbarEvent>
|
||||
) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<MediaSendViewModel>(factory = MediaSendViewModel.Factory(args = contractArgs))
|
||||
val viewModel = viewModel<MediaSendFlowViewModel>(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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+23
-16
@@ -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<Media, PreUploadResult> = 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<Uri, PreUploadResult> = 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<Media>) {
|
||||
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<Media>) {
|
||||
val orderMap: MutableMap<Long, Int> = LinkedHashMap()
|
||||
val orderedUploadResults: LinkedHashMap<Media, PreUploadResult> = LinkedHashMap()
|
||||
val orderedUploadResults: LinkedHashMap<Uri, PreUploadResult> = 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}")
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+3
-3
@@ -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(
|
||||
+10
-10
@@ -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 = {},
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+7
-7
@@ -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.).
|
||||
+2
-2
@@ -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
|
||||
+18
-18
@@ -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?,
|
||||
+1
-1
@@ -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
|
||||
+3
-3
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+6
-6
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+2
-2
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+2
-2
@@ -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.
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+2
-2
@@ -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(
|
||||
+3
-3
@@ -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(
|
||||
+1
-1
@@ -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
|
||||
+10
-10
@@ -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
|
||||
+3
-3
@@ -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(
|
||||
+2
-2
@@ -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
|
||||
+2
-2
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+2
-2
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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
|
||||
|
||||
+80
-4
@@ -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)) },
|
||||
+46
@@ -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<Media>) : MediaSelectScreenEvent
|
||||
|
||||
/** A run of media that a drag has retracted back over, undoing its own selection. */
|
||||
data class MediaUnselected(val media: Set<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
|
||||
}
|
||||
+19
-3
@@ -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<Media>
|
||||
|
||||
/** 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<MediaFolder>,
|
||||
override val selectedMedia: List<Media>,
|
||||
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<Media>,
|
||||
override val selectedMedia: List<Media>,
|
||||
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<Media>, 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)
|
||||
}
|
||||
}
|
||||
+163
@@ -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<MediaSendFlowState>,
|
||||
private val parentEventEmitter: (MediaSendEvent) -> Unit,
|
||||
mediaFolder: MediaFolder?,
|
||||
private val repository: MediaSendRepository = MediaSendDependencies.mediaSendRepository
|
||||
) : EventDrivenViewModel<MediaSelectScreenEvent>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MediaSelectViewModel::class)
|
||||
}
|
||||
|
||||
private val _state: MutableStateFlow<MediaSelectScreenState> = MutableStateFlow(
|
||||
if (mediaFolder != null) {
|
||||
MediaSelectScreenState.Files(
|
||||
selectedMediaFolder = mediaFolder,
|
||||
selectedMediaFolderItems = emptyList(),
|
||||
selectedMedia = emptyList()
|
||||
)
|
||||
} else {
|
||||
MediaSelectScreenState.Folders(
|
||||
mediaFolders = emptyList(),
|
||||
selectedMedia = emptyList()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
val state: StateFlow<MediaSelectScreenState> = _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<MediaSendFlowState>,
|
||||
private val parentEventEmitter: (MediaSendEvent) -> Unit,
|
||||
private val mediaFolder: MediaFolder?
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return MediaSelectViewModel(parentState, parentEventEmitter, mediaFolder) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
-27
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
+3
-1
@@ -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
|
||||
@@ -15,7 +15,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<org.signal.mediasend.edit.video.VideoEditorPlayButtonLayout
|
||||
<org.signal.mediasend.screens.edit.video.VideoEditorPlayButtonLayout
|
||||
android:id="@+id/video_editor_hud"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<org.signal.mediasend.edit.video.VideoThumbnailsRangeSelectorView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<org.signal.mediasend.screens.edit.video.VideoThumbnailsRangeSelectorView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
|
||||
@@ -10,11 +10,11 @@ import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.camera.CameraDependencies
|
||||
import org.signal.mediasend.edit.image.BrushWidths
|
||||
import org.signal.mediasend.screens.edit.image.BrushWidths
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Stands up the module dependency graph that [MediaSendState] reaches into for its own defaults, so tests can build a
|
||||
* Stands up the module dependency graph that [MediaSendFlowState] reaches into for its own defaults, so tests can build a
|
||||
* state without an app around them.
|
||||
*/
|
||||
class MediaSendDependenciesRule(private val application: Application) : ExternalResource() {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
package org.signal.mediasend.screens.edit
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
+6
-6
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
package org.signal.mediasend.screens.edit
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
@@ -25,7 +25,7 @@ import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.ContentTypeUtil
|
||||
import org.signal.mediasend.EditorState
|
||||
import org.signal.mediasend.MediaSendDependenciesRule
|
||||
import org.signal.mediasend.MediaSendState
|
||||
import org.signal.mediasend.MediaSendFlowState
|
||||
import org.signal.mediasend.test.TestTags
|
||||
|
||||
/**
|
||||
@@ -144,7 +144,7 @@ class MediaEditorToolbarSharedButtonsTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setContent(state: MediaSendState, editorState: EditorState) {
|
||||
private fun setContent(state: MediaSendFlowState, editorState: EditorState) {
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
MediaEditorToolbar {
|
||||
@@ -158,12 +158,12 @@ class MediaEditorToolbarSharedButtonsTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun state(media: Media, isStory: Boolean = false, viewOnce: Boolean = false): MediaSendState {
|
||||
return MediaSendState(
|
||||
private fun state(media: Media, isStory: Boolean = false, viewOnce: Boolean = false): MediaSendFlowState {
|
||||
return MediaSendFlowState(
|
||||
selectedMedia = listOf(media),
|
||||
focusedMedia = media,
|
||||
isStory = isStory,
|
||||
viewOnceToggleState = if (viewOnce) MediaSendState.ViewOnceToggleState.ONCE else MediaSendState.ViewOnceToggleState.OFF
|
||||
viewOnceToggleState = if (viewOnce) MediaSendFlowState.ViewOnceToggleState.ONCE else MediaSendFlowState.ViewOnceToggleState.OFF
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
package org.signal.mediasend.screens.edit
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.layout.Column
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
package org.signal.mediasend.screens.edit
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.screens.select
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
import org.signal.core.ui.CoreUiDependenciesRule
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.mediasend.MediaSendDependenciesRule
|
||||
import org.signal.mediasend.test.TestTags
|
||||
|
||||
/**
|
||||
* Covers the gallery grid's press-and-drag range selection: the gesture has to survive the tiles' own click handling and
|
||||
* take the drag away from the grid's scrolling, neither of which is visible from the state alone.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class MediaSelectScreenDragTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@get:Rule
|
||||
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
|
||||
|
||||
@get:Rule
|
||||
val mediaSendDependenciesRule = MediaSendDependenciesRule(ApplicationProvider.getApplicationContext())
|
||||
|
||||
private val events = mutableListOf<MediaSelectScreenEvent>()
|
||||
|
||||
private val selected: Set<Media>
|
||||
get() = events.filterIsInstance<MediaSelectScreenEvent.MediaSelected>().flatMap { it.media }.toSet()
|
||||
|
||||
private val unselected: Set<Media>
|
||||
get() = events.filterIsInstance<MediaSelectScreenEvent.MediaUnselected>().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<Media>(), 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<Media>(), 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<Media>(), 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<Media> = 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<Media> = (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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user