Refactor media-send to match structure of registration. Add drag to select.

This commit is contained in:
Alex Hart
2026-08-04 12:33:39 -03:00
parent cdc47989ce
commit a8c575e645
89 changed files with 1320 additions and 705 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
/**
@@ -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
@@ -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()
@@ -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
@@ -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.
@@ -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
@@ -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.
*/
@@ -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,
@@ -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}")
}
@@ -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
@@ -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 {
@@ -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
@@ -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,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(
@@ -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 = {},
@@ -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
@@ -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
@@ -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
@@ -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.).
@@ -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
@@ -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?,
@@ -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,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
@@ -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
@@ -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)
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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,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(
@@ -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
@@ -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,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(
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
@@ -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;
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)) },
@@ -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
}
@@ -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)
}
}
@@ -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
}
}
}
@@ -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"
@@ -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,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() {
@@ -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
@@ -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
)
}
@@ -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
@@ -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
@@ -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"
)
}
}
}