mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-14 01:03:28 +01:00
Refactor media-edit to match pattern for media-send module.
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import org.signal.mediasend.screens.edit.MediaEditScreenEvents
|
||||
|
||||
/**
|
||||
* The screen events that the flow, rather than the screen that raised them, is responsible for.
|
||||
* Implemented by [MediaSendFlowViewModel].
|
||||
*/
|
||||
interface MediaSendEventHandler {
|
||||
fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvents)
|
||||
}
|
||||
@@ -7,6 +7,9 @@ package org.signal.mediasend
|
||||
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
import org.signal.mediasend.screens.edit.ScheduleSendOption
|
||||
import org.signal.mediasend.screens.edit.image.BrushTool
|
||||
import org.signal.mediasend.screens.edit.video.VideoTrimData
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
@@ -36,10 +39,35 @@ internal sealed interface MediaSendFlowEvent {
|
||||
/** The user asked to leave, which is confirmed first if it would throw a selection away. */
|
||||
data object CloseRequested : MediaSendFlowEvent
|
||||
|
||||
//region Edits, which the flow holds so that they survive the editor being swiped away
|
||||
|
||||
data class SetMediaQuality(val quality: SentMediaQuality) : MediaSendFlowEvent
|
||||
data class SetBrushWidth(val tool: BrushTool, val fraction: Float) : MediaSendFlowEvent
|
||||
data class SetBlurFacesEnabled(val enabled: Boolean) : MediaSendFlowEvent
|
||||
data class VideoTrimChanged(val videoTrimData: VideoTrimData, val editingComplete: Boolean) : MediaSendFlowEvent
|
||||
data object ToggleViewOnce : MediaSendFlowEvent
|
||||
data object ToggleVideoMuted : MediaSendFlowEvent
|
||||
|
||||
//endregion
|
||||
|
||||
//region Requests the flow can only answer by leaving it, through a [MediaSendFlowHudCommand]
|
||||
|
||||
data class AddMessageRequested(val startWithEmojiKeyboard: Boolean) : MediaSendFlowEvent
|
||||
data class ScheduleSendRequested(val option: ScheduleSendOption) : MediaSendFlowEvent
|
||||
data object StickerRequested : MediaSendFlowEvent
|
||||
|
||||
//endregion
|
||||
|
||||
/** The user is done editing: on to choosing a destination, or straight into the send. */
|
||||
data object NextRequested : MediaSendFlowEvent
|
||||
|
||||
data class NavigateToFiles(val mediaFolder: MediaFolder) : MediaSendFlowEvent
|
||||
data object NavigateToFolders : MediaSendFlowEvent
|
||||
data object NavigateToEdit : MediaSendFlowEvent
|
||||
data object NavigateToCamera : MediaSendFlowEvent
|
||||
data object NavigateToTextStory : MediaSendFlowEvent
|
||||
data object NavigateBack : MediaSendFlowEvent
|
||||
|
||||
// Backing out lands somewhere different depending on the screen doing it, so each says which it is.
|
||||
data object NavigateBackFromSelect : MediaSendFlowEvent
|
||||
data object NavigateBackFromEdit : MediaSendFlowEvent
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.parcelers.NullableCharSequenceParceler
|
||||
import org.signal.core.util.ContentTypeUtil
|
||||
import org.signal.mediasend.screens.edit.image.BrushWidths
|
||||
import org.signal.mediasend.screens.edit.video.VideoTrimData
|
||||
import org.thoughtcrime.securesms.video.TranscodingConfig
|
||||
import kotlin.time.Duration
|
||||
|
||||
@@ -73,10 +72,6 @@ data class MediaSendFlowState(
|
||||
* Whether the media has been sent (prevents duplicate sends).
|
||||
*/
|
||||
val isSent: Boolean = false,
|
||||
/**
|
||||
* Whether the focused media is currently being written out to the device's shared storage.
|
||||
*/
|
||||
val isSavingMedia: @WriteWith<TransientInFlightFlagParceler> Boolean = false,
|
||||
/**
|
||||
* Whether this is a story send flow.
|
||||
*/
|
||||
@@ -158,10 +153,6 @@ data class MediaSendFlowState(
|
||||
val isViewOnceEnabled: Boolean
|
||||
get() = isViewOnceAvailable && viewOnceToggleState == ViewOnceToggleState.ONCE
|
||||
|
||||
fun getOrCreateVideoTrimData(uri: Uri): VideoTrimData {
|
||||
return (editorStateMap[uri] as? EditorState.VideoTrim)?.videoTrimData ?: VideoTrimData()
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op parceler for flags tracking work that cannot outlive the process that started it.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.Manifest
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
@@ -42,9 +41,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import org.signal.core.models.media.Media
|
||||
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.util.StorageUtil
|
||||
import org.signal.core.util.ContentTypeUtil
|
||||
import org.signal.core.util.StringUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -55,7 +52,6 @@ import org.signal.imageeditor.core.renderers.UriGlideRenderer
|
||||
import org.signal.mediasend.preupload.PreUploadController
|
||||
import org.signal.mediasend.preupload.PreUploadResult
|
||||
import org.signal.mediasend.screens.edit.ImageController
|
||||
import org.signal.mediasend.screens.edit.MediaEditScreenEvents
|
||||
import org.signal.mediasend.screens.edit.ScheduleSendOption
|
||||
import org.signal.mediasend.screens.edit.image.BrushTool
|
||||
import org.signal.mediasend.screens.edit.image.BrushWidthsState
|
||||
@@ -78,7 +74,7 @@ class MediaSendFlowViewModel(
|
||||
private val repository: MediaSendRepository,
|
||||
private val preUploadController: PreUploadController,
|
||||
isMeteredFlow: Flow<Boolean>
|
||||
) : ViewModel(), MediaSendEventHandler {
|
||||
) : ViewModel() {
|
||||
|
||||
private val args: MediaSendFlowActivityContract.Args = savedStateHandle[KEY_ARGS]
|
||||
?: throw IllegalStateException("MediaSendViewModel requires args in SavedStateHandle. Use Factory to create.")
|
||||
@@ -129,15 +125,9 @@ class MediaSendFlowViewModel(
|
||||
|
||||
internal val usernameScannedDialog = DialogController<String>()
|
||||
internal val linkedDeviceScannedDialog = DialogController<Unit>()
|
||||
internal val saveToStorageDialog = DialogController<Unit>()
|
||||
internal val discardMediaDialog = DialogController<Unit>()
|
||||
internal val addToGroupStoryDialog = DialogController<MediaRecipientId>()
|
||||
|
||||
internal val writeStoragePermission = PermissionController(
|
||||
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
permanentDenialMessage = R.string.MediaSendViewModel__signal_needs_the_storage_permission
|
||||
)
|
||||
|
||||
private val qrCheckRequest: Channel<String> = Channel(Channel.RENDEZVOUS)
|
||||
|
||||
/**
|
||||
@@ -259,15 +249,47 @@ class MediaSendFlowViewModel(
|
||||
is MediaSendFlowEvent.MediaCaptured -> onMediaCaptured(event.media, event.recordingDuration)
|
||||
is MediaSendFlowEvent.QrCodeScanned -> qrCheckRequest.trySend(event.data)
|
||||
MediaSendFlowEvent.CloseRequested -> onCloseRequested()
|
||||
|
||||
is MediaSendFlowEvent.SetMediaQuality -> setSentMediaQuality(event.quality)
|
||||
is MediaSendFlowEvent.SetBrushWidth -> setBrushWidth(event.tool, event.fraction)
|
||||
is MediaSendFlowEvent.SetBlurFacesEnabled -> setBlurFacesEnabled(event.enabled)
|
||||
is MediaSendFlowEvent.VideoTrimChanged -> onEditVideoDuration(
|
||||
totalDurationUs = event.videoTrimData.totalInputDurationUs,
|
||||
startTimeUs = event.videoTrimData.startTimeUs,
|
||||
endTimeUs = event.videoTrimData.endTimeUs,
|
||||
touchEnabled = event.editingComplete
|
||||
)
|
||||
MediaSendFlowEvent.ToggleViewOnce -> toggleViewOnce()
|
||||
MediaSendFlowEvent.ToggleVideoMuted -> toggleVideoMuted()
|
||||
|
||||
is MediaSendFlowEvent.AddMessageRequested -> onAddMessageRequested(event.startWithEmojiKeyboard)
|
||||
is MediaSendFlowEvent.ScheduleSendRequested -> onScheduleSendClick(event.option)
|
||||
MediaSendFlowEvent.StickerRequested -> sendHudCommand(MediaSendFlowHudCommand.SelectSticker)
|
||||
MediaSendFlowEvent.NextRequested -> onNextClick()
|
||||
|
||||
is MediaSendFlowEvent.NavigateToFiles -> backStack.goToFiles(event.mediaFolder)
|
||||
MediaSendFlowEvent.NavigateToFolders -> backStack.goToFolders()
|
||||
MediaSendFlowEvent.NavigateToEdit -> backStack.goToEdit()
|
||||
MediaSendFlowEvent.NavigateToCamera -> backStack.goToCamera()
|
||||
MediaSendFlowEvent.NavigateToTextStory -> backStack.goToTextStory()
|
||||
MediaSendFlowEvent.NavigateBack -> onPopFromSelect()
|
||||
MediaSendFlowEvent.NavigateBackFromSelect -> onPopFromSelect()
|
||||
MediaSendFlowEvent.NavigateBackFromEdit -> onPopFromEdit()
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the message field, which is a dialog the flow's host owns rather than anything on a screen. */
|
||||
private fun onAddMessageRequested(startWithEmojiKeyboard: Boolean) {
|
||||
val snapshot: MediaSendFlowState = state.value
|
||||
|
||||
sendHudCommand(
|
||||
MediaSendFlowHudCommand.ShowAddAMessageDialog(
|
||||
message = snapshot.message ?: "",
|
||||
startWithEmojiKeyboard = startWithEmojiKeyboard,
|
||||
isViewOnceAvailable = snapshot.isViewOnceAvailable
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves the flow at the user's request, confirming first if that would throw a selection away. Closing for reasons
|
||||
* of our own emits [MediaSendFlowHudCommand.CloseScreen] directly instead.
|
||||
@@ -303,71 +325,6 @@ class MediaSendFlowViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvents) {
|
||||
when (mediaEditScreenEvent) {
|
||||
is MediaEditScreenEvents.FocusedMediaChanged -> setFocusedMedia(mediaEditScreenEvent.media)
|
||||
is MediaEditScreenEvents.ReorderSelectedMedia -> reorderMedia(mediaEditScreenEvent.fromIndex, mediaEditScreenEvent.toIndex)
|
||||
MediaEditScreenEvents.NextClick -> onNextClick()
|
||||
is MediaEditScreenEvents.ScheduleSendClick -> onScheduleSendClick(mediaEditScreenEvent.option)
|
||||
MediaEditScreenEvents.NavigateBack -> onPopFromEdit()
|
||||
is MediaEditScreenEvents.VideoTrimChanged -> onEditVideoDuration(
|
||||
totalDurationUs = mediaEditScreenEvent.videoTrimData.totalInputDurationUs,
|
||||
startTimeUs = mediaEditScreenEvent.videoTrimData.startTimeUs,
|
||||
endTimeUs = mediaEditScreenEvent.videoTrimData.endTimeUs,
|
||||
touchEnabled = mediaEditScreenEvent.editingComplete
|
||||
)
|
||||
|
||||
is MediaEditScreenEvents.VideoSeek -> error("VideoSeek is routed to the video player bus by MediaEditScreen and must not reach the view-model.")
|
||||
is MediaEditScreenEvents.AddMessageClick -> {
|
||||
val snapshot: MediaSendFlowState = state.value
|
||||
|
||||
sendHudCommand(
|
||||
MediaSendFlowHudCommand.ShowAddAMessageDialog(
|
||||
message = snapshot.message ?: "",
|
||||
startWithEmojiKeyboard = mediaEditScreenEvent.startWithEmojiKeyboard,
|
||||
isViewOnceAvailable = snapshot.isViewOnceAvailable
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
MediaEditScreenEvents.StickerClick -> {
|
||||
sendHudCommand(MediaSendFlowHudCommand.SelectSticker)
|
||||
}
|
||||
|
||||
MediaEditScreenEvents.NavigateToGallery -> {
|
||||
backStack.goToFolders()
|
||||
}
|
||||
|
||||
is MediaEditScreenEvents.SetMediaQuality -> {
|
||||
setSentMediaQuality(mediaEditScreenEvent.quality)
|
||||
}
|
||||
|
||||
MediaEditScreenEvents.ToggleViewOnce -> {
|
||||
toggleViewOnce()
|
||||
}
|
||||
|
||||
MediaEditScreenEvents.ToggleVideoMuted -> {
|
||||
toggleVideoMuted()
|
||||
}
|
||||
|
||||
MediaEditScreenEvents.SaveMedia -> {
|
||||
saveFocusedMediaToStorage()
|
||||
}
|
||||
|
||||
is MediaEditScreenEvents.RemoveMedia -> {
|
||||
removeMedia(mediaEditScreenEvent.media)
|
||||
}
|
||||
|
||||
is MediaEditScreenEvents.BrushWidthChanged -> {
|
||||
setBrushWidth(mediaEditScreenEvent.tool, mediaEditScreenEvent.fraction)
|
||||
}
|
||||
|
||||
is MediaEditScreenEvents.ToggleBlurFaces -> {
|
||||
setBlurFacesEnabled(mediaEditScreenEvent.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the picker opened for [MediaSendFlowHudCommand.SelectSticker], applied to the focused image. A null [renderer] means
|
||||
* the picker was dismissed.
|
||||
@@ -972,54 +929,6 @@ class MediaSendFlowViewModel(
|
||||
|
||||
//endregion
|
||||
|
||||
//region Save To Storage
|
||||
|
||||
/**
|
||||
* Writes the focused image, edits included, out to the device's shared storage.
|
||||
*/
|
||||
private fun saveFocusedMediaToStorage() {
|
||||
val focusedUri = state.value.focusedMedia?.uri ?: return
|
||||
val editorState = state.value.editorStateMap[focusedUri] as? EditorState.Image ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
if (!repository.hasDismissedSaveToStorageWarning && saveToStorageDialog.show(Unit) != DialogResult.POSITIVE) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (!StorageUtil.canWriteToMediaStore() && !writeStoragePermission.request()) {
|
||||
internalSnackbarEvents.trySend(SnackbarEvent(message = R.string.MediaSendViewModel__unable_to_save_without_storage_permission))
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (state.value.isSavingMedia) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
updateState { copy(isSavingMedia = true) }
|
||||
val result = try {
|
||||
repository.saveImageToStorage(editorState.model)
|
||||
} finally {
|
||||
updateState { copy(isSavingMedia = false) }
|
||||
}
|
||||
|
||||
internalSnackbarEvents.trySend(
|
||||
SnackbarEvent(
|
||||
message = when (result) {
|
||||
SaveToStorageResult.SUCCESS -> R.string.MediaSendViewModel__media_saved
|
||||
SaveToStorageResult.FAILURE -> R.string.MediaSendViewModel__error_saving_media
|
||||
SaveToStorageResult.NO_WRITE_ACCESS -> R.string.MediaSendViewModel__unable_to_save_without_storage_permission
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markSaveToStorageWarningDismissed() {
|
||||
repository.markSaveToStorageWarningDismissed()
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region View Once
|
||||
|
||||
fun isViewOnceEnabled(): Boolean {
|
||||
|
||||
@@ -39,6 +39,8 @@ import org.signal.mediasend.screens.capture.MediaCaptureScreen
|
||||
import org.signal.mediasend.screens.capture.MediaCaptureScreenEvents
|
||||
import org.signal.mediasend.screens.capture.MediaCaptureViewModel
|
||||
import org.signal.mediasend.screens.edit.MediaEditScreen
|
||||
import org.signal.mediasend.screens.edit.MediaEditScreenDialogs
|
||||
import org.signal.mediasend.screens.edit.MediaEditViewModel
|
||||
import org.signal.mediasend.screens.select.MediaSelectScreen
|
||||
import org.signal.mediasend.screens.select.MediaSelectViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@@ -132,10 +134,20 @@ internal fun MediaSendNavigation(
|
||||
}
|
||||
|
||||
is MediaSendRoute.Edit -> NavEntry(MediaSendRoute.Edit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val editViewModel: MediaEditViewModel = viewModel(
|
||||
factory = MediaEditViewModel.Factory(
|
||||
parentState = viewModel.state,
|
||||
parentEventEmitter = viewModel::onEvent
|
||||
)
|
||||
)
|
||||
val state by editViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
SaveToStorageDialog(editViewModel)
|
||||
editViewModel.writeStoragePermission.Content()
|
||||
|
||||
MediaEditScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onMediaEditScreenEvent,
|
||||
onEvent = editViewModel::onEvent,
|
||||
imageControllers = viewModel.imageControllers,
|
||||
mediaInputFactory = MediaSendDependencies.mediaInputFactory
|
||||
)
|
||||
@@ -166,6 +178,24 @@ internal fun MediaSendNavigation(
|
||||
private val TOAST_DURATION = 3.seconds
|
||||
private val SEND_PROGRESS_DELAY = 300.milliseconds
|
||||
|
||||
/**
|
||||
* Warns that saving a copy to shared storage leaves it outside of Signal, before the first save of a session.
|
||||
*/
|
||||
@Composable
|
||||
private fun SaveToStorageDialog(viewModel: MediaEditViewModel) {
|
||||
viewModel.saveToStorageDialog.Content { _, onDismissRequest, onConfirm, _, _ ->
|
||||
MediaEditScreenDialogs.SaveToStorageConfirmationDialog(
|
||||
onSave = { doNotShowAgain ->
|
||||
if (doNotShowAgain) {
|
||||
viewModel.markSaveToStorageWarningDismissed()
|
||||
}
|
||||
onConfirm()
|
||||
},
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog displayed when the user tries to close out of media send, to warn them that they'll discard media.
|
||||
*/
|
||||
|
||||
@@ -61,18 +61,6 @@ fun MediaSendScreen(
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.saveToStorageDialog.Content { _, onDismissRequest, onConfirm, _, _ ->
|
||||
MediaEditScreenDialogs.SaveToStorageConfirmationDialog(
|
||||
onSave = { doNotShowAgain ->
|
||||
if (doNotShowAgain) {
|
||||
viewModel.markSaveToStorageWarningDismissed()
|
||||
}
|
||||
onConfirm()
|
||||
},
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.addToGroupStoryDialog.Content { recipientId, onDismissRequest, onConfirm, _, onDeny ->
|
||||
val groupName: String by LocalDisplayNameProvider.current(recipientId.id)
|
||||
|
||||
@@ -84,8 +72,6 @@ fun MediaSendScreen(
|
||||
)
|
||||
}
|
||||
|
||||
viewModel.writeStoragePermission.Content()
|
||||
|
||||
MediaSendNavigation(
|
||||
viewModel = viewModel,
|
||||
modifier = modifier,
|
||||
|
||||
+7
-18
@@ -70,9 +70,7 @@ import org.signal.glide.decryptableuri.DecryptableUri
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
import org.signal.mediasend.EditorState
|
||||
import org.signal.mediasend.MediaConstraints
|
||||
import org.signal.mediasend.MediaSendFlowState
|
||||
import org.signal.mediasend.PreviewMediaInputFactory
|
||||
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
|
||||
@@ -99,7 +97,7 @@ private val CHROME_SETTLE_WINDOW = 500.milliseconds
|
||||
|
||||
@Composable
|
||||
internal fun MediaEditScreen(
|
||||
state: MediaSendFlowState,
|
||||
state: MediaEditState,
|
||||
onEvent: (MediaEditScreenEvents) -> Unit,
|
||||
imageControllers: ImageController.Container,
|
||||
mediaInputFactory: MediaInputFactory
|
||||
@@ -138,12 +136,7 @@ internal fun MediaEditScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// During a camera-first flow, backing out of edit when the only selection is the capture itself should discard the
|
||||
// capture and return to the camera rather than leaving the empty editor on the back stack.
|
||||
val isOnlyCameraFirstCapture = state.cameraFirstCapture != null &&
|
||||
state.selectedMedia.size == 1 &&
|
||||
state.selectedMedia.firstOrNull() == state.cameraFirstCapture
|
||||
BackHandler(enabled = isOnlyCameraFirstCapture) {
|
||||
BackHandler(enabled = state.isOnlyCameraFirstCapture) {
|
||||
onEvent(MediaEditScreenEvents.NavigateBack)
|
||||
}
|
||||
|
||||
@@ -535,7 +528,7 @@ internal fun MediaEditScreen(
|
||||
*/
|
||||
@Composable
|
||||
private fun MediaToolbar(
|
||||
state: MediaSendFlowState,
|
||||
state: MediaEditState,
|
||||
onEvent: (MediaEditScreenEvents) -> Unit,
|
||||
focusedUri: Uri?,
|
||||
focusedEditorState: EditorState?,
|
||||
@@ -685,12 +678,10 @@ private fun MediaEditScreenPreview() {
|
||||
|
||||
Previews.Preview {
|
||||
MediaEditScreen(
|
||||
state = rememberPreviewState().copy(
|
||||
state = MediaEditState(
|
||||
selectedMedia = selectedMedia,
|
||||
focusedMedia = selectedMedia.first(),
|
||||
editorStateMap = mutableMapOf(
|
||||
selectedMedia.first().uri to EditorState.Image(EditorModel.create(0))
|
||||
)
|
||||
editorStateMap = mapOf(selectedMedia.first().uri to EditorState.Image(EditorModel.create(0)))
|
||||
),
|
||||
onEvent = {},
|
||||
imageControllers = remember { ImageController.Container() },
|
||||
@@ -706,12 +697,10 @@ private fun MediaEditScreenVideoPreview() {
|
||||
|
||||
Previews.Preview {
|
||||
MediaEditScreen(
|
||||
state = rememberPreviewState().copy(
|
||||
state = MediaEditState(
|
||||
selectedMedia = selectedMedia,
|
||||
focusedMedia = selectedMedia.first(),
|
||||
editorStateMap = mutableMapOf(
|
||||
selectedMedia.first().uri to EditorState.VideoTrim(VideoTrimData())
|
||||
)
|
||||
editorStateMap = mapOf(selectedMedia.first().uri to EditorState.VideoTrim(VideoTrimData()))
|
||||
),
|
||||
onEvent = {},
|
||||
imageControllers = remember { ImageController.Container() },
|
||||
|
||||
+9
@@ -6,11 +6,20 @@
|
||||
package org.signal.mediasend.screens.edit
|
||||
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.mediasend.MediaSendFlowState
|
||||
import org.signal.mediasend.SentMediaQuality
|
||||
import org.signal.mediasend.screens.edit.image.BrushTool
|
||||
import org.signal.mediasend.screens.edit.video.VideoTrimData
|
||||
|
||||
sealed interface MediaEditScreenEvents {
|
||||
|
||||
/** The parent flow's state changed and needs to be merged into this screen's state. */
|
||||
data class ParentStateChanged(val parentState: MediaSendFlowState) : MediaEditScreenEvents {
|
||||
// The parent's state carries the message the user is typing and every item they have picked. Only the size of the
|
||||
// selection is worth logging, and it is the only part safe to.
|
||||
override fun toString(): String = "ParentStateChanged(selectedMedia=${parentState.selectedMedia.size})"
|
||||
}
|
||||
|
||||
data class RemoveMedia(val media: Media) : MediaEditScreenEvents
|
||||
data class FocusedMediaChanged(val media: Media) : MediaEditScreenEvents
|
||||
data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaEditScreenEvents
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.screens.edit
|
||||
|
||||
import android.net.Uri
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.mediasend.EditorState
|
||||
import org.signal.mediasend.MediaRecipientId
|
||||
import org.signal.mediasend.MediaSendFlowState
|
||||
import org.signal.mediasend.SentMediaQuality
|
||||
import org.signal.mediasend.screens.edit.video.VideoTrimData
|
||||
import org.thoughtcrime.securesms.video.TranscodingConfig
|
||||
|
||||
/**
|
||||
* What the edit screen renders. Everything it edits belongs to the flow, so all of it is a copy kept current through
|
||||
* [MediaEditScreenEvents.ParentStateChanged] -- apart from [isSavingMedia], which is this screen's own work.
|
||||
*/
|
||||
internal data class MediaEditState(
|
||||
val selectedMedia: List<Media> = emptyList(),
|
||||
val focusedMedia: Media? = null,
|
||||
val editorStateMap: Map<Uri, EditorState> = emptyMap(),
|
||||
/** The camera-first capture, which backing out of the editor discards rather than keeps. */
|
||||
val cameraFirstCapture: Media? = null,
|
||||
/** The single recipient this media is headed to, or null when the destination is still to be chosen. */
|
||||
val recipientId: MediaRecipientId? = null,
|
||||
val message: CharSequence? = null,
|
||||
val sentMediaQuality: SentMediaQuality = SentMediaQuality.STANDARD,
|
||||
val videoTranscodingTiers: List<TranscodingConfig.QualityTier> = emptyList(),
|
||||
val isStory: Boolean = false,
|
||||
val isReply: Boolean = false,
|
||||
val isSending: Boolean = false,
|
||||
val isTouchEnabled: Boolean = true,
|
||||
val isMuteVideoAudioEnabled: Boolean = false,
|
||||
val isViewOnceAvailable: Boolean = false,
|
||||
val isViewOnceEnabled: Boolean = false,
|
||||
/** Whether the focused image is currently being written out to shared storage. */
|
||||
val isSavingMedia: Boolean = false
|
||||
) {
|
||||
|
||||
val focusedEditorState: EditorState?
|
||||
get() = focusedMedia?.uri?.let { editorStateMap[it] }
|
||||
|
||||
/**
|
||||
* Whether the selection is nothing but the capture that opened this editor. Backing out then discards it and returns
|
||||
* to the camera, rather than leaving an editor with nothing to edit behind.
|
||||
*/
|
||||
val isOnlyCameraFirstCapture: Boolean
|
||||
get() = cameraFirstCapture != null && selectedMedia.size == 1 && selectedMedia.firstOrNull() == cameraFirstCapture
|
||||
|
||||
fun getOrCreateVideoTrimData(uri: Uri): VideoTrimData {
|
||||
return (editorStateMap[uri] as? EditorState.VideoTrim)?.videoTrimData ?: VideoTrimData()
|
||||
}
|
||||
|
||||
/** Everything the flow reports, merged in. [isSavingMedia] is left alone: the flow knows nothing about it. */
|
||||
fun withParentState(parentState: MediaSendFlowState): MediaEditState = copy(
|
||||
selectedMedia = parentState.selectedMedia,
|
||||
focusedMedia = parentState.focusedMedia,
|
||||
editorStateMap = parentState.editorStateMap,
|
||||
cameraFirstCapture = parentState.cameraFirstCapture,
|
||||
recipientId = parentState.recipientId,
|
||||
message = parentState.message,
|
||||
sentMediaQuality = parentState.sentMediaQuality,
|
||||
videoTranscodingTiers = parentState.videoTranscodingTiers,
|
||||
isStory = parentState.isStory,
|
||||
isReply = parentState.isReply,
|
||||
isSending = parentState.isSending,
|
||||
isTouchEnabled = parentState.isTouchEnabled,
|
||||
isMuteVideoAudioEnabled = parentState.isMuteVideoAudioEnabled,
|
||||
isViewOnceAvailable = parentState.isViewOnceAvailable,
|
||||
isViewOnceEnabled = parentState.isViewOnceEnabled
|
||||
)
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.screens.edit
|
||||
|
||||
import android.Manifest
|
||||
import androidx.annotation.StringRes
|
||||
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.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.compose.DialogController
|
||||
import org.signal.core.ui.compose.DialogResult
|
||||
import org.signal.core.ui.compose.EventDrivenViewModel
|
||||
import org.signal.core.ui.compose.PermissionController
|
||||
import org.signal.core.ui.util.StorageUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.mediasend.EditorState
|
||||
import org.signal.mediasend.MediaSendDependencies
|
||||
import org.signal.mediasend.MediaSendFlowEvent
|
||||
import org.signal.mediasend.MediaSendFlowState
|
||||
import org.signal.mediasend.MediaSendRepository
|
||||
import org.signal.mediasend.R
|
||||
import org.signal.mediasend.SaveToStorageResult
|
||||
import org.signal.mediasend.SnackbarEvent
|
||||
|
||||
/**
|
||||
* Drives the edit screen.
|
||||
*
|
||||
* What the user edits is not this screen's to keep -- an edit has to survive the editor being swiped away -- so every
|
||||
* change leaves as a [MediaSendFlowEvent] and comes back as [MediaEditScreenEvents.ParentStateChanged]. Writing the
|
||||
* focused image out to shared storage is the exception: nothing else in the flow offers it, so it is done here.
|
||||
*/
|
||||
internal class MediaEditViewModel(
|
||||
parentState: StateFlow<MediaSendFlowState>,
|
||||
private val parentEventEmitter: (MediaSendFlowEvent) -> Unit,
|
||||
private val repository: MediaSendRepository = MediaSendDependencies.mediaSendRepository
|
||||
) : EventDrivenViewModel<MediaEditScreenEvents>(TAG) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MediaEditViewModel::class)
|
||||
}
|
||||
|
||||
private val _state: MutableStateFlow<MediaEditState> = MutableStateFlow(MediaEditState().withParentState(parentState.value))
|
||||
val state: StateFlow<MediaEditState> = _state.asStateFlow()
|
||||
|
||||
/** Hosted here, since this is the only screen that saves media or asks for what saving it needs. */
|
||||
val saveToStorageDialog = DialogController<Unit>()
|
||||
val writeStoragePermission = PermissionController(
|
||||
permission = Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
permanentDenialMessage = R.string.MediaSendViewModel__signal_needs_the_storage_permission
|
||||
)
|
||||
|
||||
init {
|
||||
parentState
|
||||
.onEach { onEvent(MediaEditScreenEvents.ParentStateChanged(it)) }
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override suspend fun processEvent(event: MediaEditScreenEvents) {
|
||||
when (event) {
|
||||
is MediaEditScreenEvents.ParentStateChanged -> _state.update { it.withParentState(event.parentState) }
|
||||
is MediaEditScreenEvents.FocusedMediaChanged -> parentEventEmitter(MediaSendFlowEvent.SetFocusedMedia(event.media))
|
||||
is MediaEditScreenEvents.ReorderSelectedMedia -> parentEventEmitter(MediaSendFlowEvent.ReorderSelectedMedia(event.fromIndex, event.toIndex))
|
||||
is MediaEditScreenEvents.RemoveMedia -> parentEventEmitter(MediaSendFlowEvent.RemoveMedia(setOf(event.media)))
|
||||
is MediaEditScreenEvents.SetMediaQuality -> parentEventEmitter(MediaSendFlowEvent.SetMediaQuality(event.quality))
|
||||
is MediaEditScreenEvents.BrushWidthChanged -> parentEventEmitter(MediaSendFlowEvent.SetBrushWidth(event.tool, event.fraction))
|
||||
is MediaEditScreenEvents.ToggleBlurFaces -> parentEventEmitter(MediaSendFlowEvent.SetBlurFacesEnabled(event.enabled))
|
||||
is MediaEditScreenEvents.VideoTrimChanged -> parentEventEmitter(MediaSendFlowEvent.VideoTrimChanged(event.videoTrimData, event.editingComplete))
|
||||
MediaEditScreenEvents.ToggleViewOnce -> parentEventEmitter(MediaSendFlowEvent.ToggleViewOnce)
|
||||
MediaEditScreenEvents.ToggleVideoMuted -> parentEventEmitter(MediaSendFlowEvent.ToggleVideoMuted)
|
||||
is MediaEditScreenEvents.AddMessageClick -> parentEventEmitter(MediaSendFlowEvent.AddMessageRequested(event.startWithEmojiKeyboard))
|
||||
is MediaEditScreenEvents.ScheduleSendClick -> parentEventEmitter(MediaSendFlowEvent.ScheduleSendRequested(event.option))
|
||||
MediaEditScreenEvents.StickerClick -> parentEventEmitter(MediaSendFlowEvent.StickerRequested)
|
||||
MediaEditScreenEvents.NextClick -> parentEventEmitter(MediaSendFlowEvent.NextRequested)
|
||||
MediaEditScreenEvents.NavigateToGallery -> parentEventEmitter(MediaSendFlowEvent.NavigateToFolders)
|
||||
MediaEditScreenEvents.NavigateBack -> parentEventEmitter(MediaSendFlowEvent.NavigateBackFromEdit)
|
||||
MediaEditScreenEvents.SaveMedia -> saveFocusedMediaToStorage()
|
||||
is MediaEditScreenEvents.VideoSeek -> error("VideoSeek is routed to the video player bus by MediaEditScreen and must not reach the view-model.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the focused image, edits included, out to the device's shared storage. Launched rather than awaited so that
|
||||
* the confirmation and the permission prompt do not hold up the events behind them.
|
||||
*/
|
||||
private fun saveFocusedMediaToStorage() {
|
||||
val editorState = _state.value.focusedEditorState as? EditorState.Image ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
if (!repository.hasDismissedSaveToStorageWarning && saveToStorageDialog.show(Unit) != DialogResult.POSITIVE) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (!StorageUtil.canWriteToMediaStore() && !writeStoragePermission.request()) {
|
||||
showSnackbar(R.string.MediaSendViewModel__unable_to_save_without_storage_permission)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (_state.value.isSavingMedia) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
_state.update { it.copy(isSavingMedia = true) }
|
||||
val result = try {
|
||||
repository.saveImageToStorage(editorState.model)
|
||||
} finally {
|
||||
_state.update { it.copy(isSavingMedia = false) }
|
||||
}
|
||||
|
||||
showSnackbar(
|
||||
when (result) {
|
||||
SaveToStorageResult.SUCCESS -> R.string.MediaSendViewModel__media_saved
|
||||
SaveToStorageResult.FAILURE -> R.string.MediaSendViewModel__error_saving_media
|
||||
SaveToStorageResult.NO_WRITE_ACCESS -> R.string.MediaSendViewModel__unable_to_save_without_storage_permission
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun markSaveToStorageWarningDismissed() {
|
||||
repository.markSaveToStorageWarningDismissed()
|
||||
}
|
||||
|
||||
private fun showSnackbar(@StringRes message: Int) {
|
||||
parentEventEmitter(MediaSendFlowEvent.ShowSnackbar(SnackbarEvent(message = message)))
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val parentState: StateFlow<MediaSendFlowState>,
|
||||
private val parentEventEmitter: (MediaSendFlowEvent) -> Unit
|
||||
) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return MediaEditViewModel(parentState, parentEventEmitter) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -32,7 +32,6 @@ 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.MediaSendFlowState
|
||||
import org.signal.mediasend.SentMediaQuality
|
||||
import org.signal.mediasend.test.TestTags
|
||||
|
||||
@@ -104,7 +103,7 @@ internal fun MediaEditorToolbarButton(
|
||||
|
||||
@Composable
|
||||
internal fun MediaEditorToolbarSharedButtons(
|
||||
state: MediaSendFlowState,
|
||||
state: MediaEditState,
|
||||
editorState: EditorState,
|
||||
onEvent: (MediaEditScreenEvents) -> Unit
|
||||
) {
|
||||
@@ -155,7 +154,7 @@ internal fun MediaEditorToolbarSharedButtons(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isQualityVisible(state: MediaSendFlowState, editorState: EditorState): Boolean {
|
||||
private fun isQualityVisible(state: MediaEditState, editorState: EditorState): Boolean {
|
||||
return !state.isStory && editorState !is EditorState.Document
|
||||
}
|
||||
|
||||
@@ -163,7 +162,7 @@ private fun isSaveVisible(editorState: EditorState): Boolean {
|
||||
return editorState is EditorState.Image || editorState is EditorState.Gif
|
||||
}
|
||||
|
||||
private fun isMuteVisible(state: MediaSendFlowState, editorState: EditorState): Boolean {
|
||||
private fun isMuteVisible(state: MediaEditState, editorState: EditorState): Boolean {
|
||||
return state.isMuteVideoAudioEnabled && editorState is EditorState.VideoTrim
|
||||
}
|
||||
|
||||
@@ -171,7 +170,7 @@ private fun isMuteVisible(state: MediaSendFlowState, editorState: EditorState):
|
||||
* 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: MediaSendFlowState, editorState: EditorState?): Boolean {
|
||||
internal fun isAddMediaVisible(state: MediaEditState, editorState: EditorState?): Boolean {
|
||||
return !state.isViewOnceEnabled && editorState !is EditorState.Document
|
||||
}
|
||||
|
||||
@@ -179,6 +178,6 @@ internal fun isAddMediaVisible(state: MediaSendFlowState, editorState: EditorSta
|
||||
* 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: MediaSendFlowState, editorState: EditorState): Boolean {
|
||||
internal fun hasSharedToolbarButtons(state: MediaEditState, editorState: EditorState): Boolean {
|
||||
return isQualityVisible(state, editorState) || isMuteVisible(state, editorState) || isSaveVisible(editorState) || isAddMediaVisible(state, editorState)
|
||||
}
|
||||
|
||||
+4
-5
@@ -29,12 +29,11 @@ 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.MediaSendFlowState
|
||||
import org.signal.mediasend.R
|
||||
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.MediaEditScreenEvents
|
||||
import org.signal.mediasend.screens.edit.MediaEditState
|
||||
import org.signal.mediasend.screens.edit.MediaEditorToolbar
|
||||
import org.signal.mediasend.screens.edit.MediaEditorToolbarButton
|
||||
import org.signal.mediasend.screens.edit.MediaEditorToolbarSharedButtons
|
||||
@@ -43,7 +42,7 @@ import java.util.EnumMap
|
||||
@Composable
|
||||
internal fun ImageEditorToolbar(
|
||||
imageEditorController: ImageController,
|
||||
state: MediaSendFlowState,
|
||||
state: MediaEditState,
|
||||
editorState: EditorState.Image,
|
||||
onEvent: (MediaEditScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -73,7 +72,7 @@ internal fun ImageEditorToolbar(
|
||||
@Composable
|
||||
private fun ImageEditorNoneStateToolbar(
|
||||
imageEditorController: ImageController,
|
||||
state: MediaSendFlowState,
|
||||
state: MediaEditState,
|
||||
editorState: EditorState.Image,
|
||||
onEvent: (MediaEditScreenEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@@ -292,7 +291,7 @@ private fun ImageEditorNoneStateToolbarPreview() {
|
||||
imageEditorController = remember {
|
||||
ImageController(EditorModel.create(0))
|
||||
},
|
||||
state = rememberPreviewState(),
|
||||
state = remember { MediaEditState() },
|
||||
editorState = remember { EditorState.Image(EditorModel.create(0)) },
|
||||
onEvent = {}
|
||||
)
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ internal class MediaSelectViewModel(
|
||||
is MediaSelectScreenEvents.ReorderSelectedMedia -> parentEventEmitter(MediaSendFlowEvent.ReorderSelectedMedia(event.fromIndex, event.toIndex))
|
||||
MediaSelectScreenEvents.NavigateToEdit -> parentEventEmitter(MediaSendFlowEvent.NavigateToEdit)
|
||||
MediaSelectScreenEvents.NavigateToCamera -> parentEventEmitter(MediaSendFlowEvent.NavigateToCamera)
|
||||
MediaSelectScreenEvents.NavigateBack -> parentEventEmitter(MediaSendFlowEvent.NavigateBack)
|
||||
MediaSelectScreenEvents.NavigateBack -> parentEventEmitter(MediaSendFlowEvent.NavigateBackFromSelect)
|
||||
MediaSelectScreenEvents.Refresh -> refresh()
|
||||
MediaSelectScreenEvents.RequestMediaPermissions -> requestReadMediaPermissions(reportDenial = true)
|
||||
MediaSelectScreenEvents.SelectMorePhotos -> requestReadMediaPermissions(reportDenial = false)
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.screens.capture
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
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.util.ContentTypeUtil
|
||||
import org.signal.core.util.SeekableFileDescriptor
|
||||
import org.signal.core.util.contentproviders.BlobProvider
|
||||
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
|
||||
import java.io.FileDescriptor
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Covers how a capture is described once it has been written out, and what happens when it cannot be.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, application = Application::class)
|
||||
class MediaCaptureRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
val temporaryFolder = TemporaryFolder()
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private val mimeType = slot<String>()
|
||||
|
||||
private val imageBuilder: BlobProvider.MemoryBlobBuilder = mockk {
|
||||
every { withMimeType(capture(mimeType)) } returns this
|
||||
every { createForSingleSessionOnDisk(any()) } returns BLOB_URI
|
||||
}
|
||||
|
||||
private val streamBuilder: BlobProvider.BlobBuilder = mockk {
|
||||
every { withMimeType(capture(mimeType)) } returns this
|
||||
every { createForSingleSessionOnDisk(any()) } returns BLOB_URI
|
||||
}
|
||||
|
||||
private val blobs: BlobProvider = mockk {
|
||||
every { forData(any<ByteArray>()) } returns imageBuilder
|
||||
every { forData(any<InputStream>(), any()) } returns streamBuilder
|
||||
}
|
||||
|
||||
private val repository = MediaCaptureRepository(context, blobs)
|
||||
|
||||
@Test
|
||||
fun `Given a captured image, when written out, then it describes the blob it was written to`() = runTest {
|
||||
val media = repository.writeCapturedImage(data = byteArrayOf(1, 2, 3, 4), width = 640, height = 480)
|
||||
|
||||
assertThat(media?.uri).isEqualTo(BLOB_URI)
|
||||
assertThat(media?.contentType).isEqualTo(ContentTypeUtil.IMAGE_JPEG)
|
||||
assertThat(mimeType.captured).isEqualTo(ContentTypeUtil.IMAGE_JPEG)
|
||||
assertThat(media?.width).isEqualTo(640)
|
||||
assertThat(media?.height).isEqualTo(480)
|
||||
assertThat(media?.size).isEqualTo(4L)
|
||||
assertThat(media?.bucketId).isEqualTo(Media.ALL_MEDIA_BUCKET_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given the blob cannot be written, when an image is captured, then nothing comes back`() = runTest {
|
||||
every { imageBuilder.createForSingleSessionOnDisk(any()) } throws IOException("no space")
|
||||
|
||||
assertThat(repository.writeCapturedImage(data = byteArrayOf(1), width = 1, height = 1)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a recording, when written out, then it carries the recording's length and leaves its dimensions to population`() = runTest {
|
||||
val recording = recording(byteCount = 2_048)
|
||||
|
||||
val media = repository.writeCapturedVideo(recording)
|
||||
|
||||
assertThat(media?.uri).isEqualTo(BLOB_URI)
|
||||
assertThat(media?.contentType).isEqualTo(VideoConstants.RECORDED_VIDEO_CONTENT_TYPE)
|
||||
assertThat(mimeType.captured).isEqualTo(VideoConstants.RECORDED_VIDEO_CONTENT_TYPE)
|
||||
assertThat(media?.size).isEqualTo(2_048L)
|
||||
assertThat(media?.width).isEqualTo(0)
|
||||
assertThat(media?.height).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a recording, when written out, then the descriptor is closed`() = runTest {
|
||||
val recording = recording(byteCount = 8)
|
||||
|
||||
repository.writeCapturedVideo(recording)
|
||||
|
||||
assertThat(recording.isClosed).isTrue()
|
||||
}
|
||||
|
||||
/**
|
||||
* The descriptor is the caller's to close, and a recording that fails part way through is exactly the case where
|
||||
* leaking it would go unnoticed.
|
||||
*/
|
||||
@Test
|
||||
fun `Given the blob cannot be written, when a recording is captured, then nothing comes back and the descriptor is still closed`() = runTest {
|
||||
every { streamBuilder.createForSingleSessionOnDisk(any()) } throws IOException("no space")
|
||||
val recording = recording(byteCount = 8)
|
||||
|
||||
assertThat(repository.writeCapturedVideo(recording)).isNull()
|
||||
assertThat(recording.isClosed).isTrue()
|
||||
}
|
||||
|
||||
/** A descriptor over a real file, since the length is read off the descriptor's own channel. */
|
||||
private fun recording(byteCount: Int): FakeRecording {
|
||||
val file = temporaryFolder.newFile()
|
||||
file.writeBytes(ByteArray(byteCount))
|
||||
return FakeRecording(FileInputStream(file))
|
||||
}
|
||||
|
||||
private class FakeRecording(private val stream: FileInputStream) : SeekableFileDescriptor {
|
||||
var isClosed: Boolean = false
|
||||
private set
|
||||
|
||||
override val fileDescriptor: FileDescriptor get() = stream.fd
|
||||
override val parcelFd get() = throw UnsupportedOperationException()
|
||||
|
||||
override fun close() {
|
||||
isClosed = true
|
||||
stream.close()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val BLOB_URI = "content://blob/capture".toUri()
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -36,7 +36,8 @@ import org.signal.mediasend.test.TestTags
|
||||
/**
|
||||
* Covers the chrome the flow adds over a capture screen: which bar is offered, to which flows, and what it raises.
|
||||
*
|
||||
* Rendered on the text story route throughout, so that the bars are under test rather than the camera behind them.
|
||||
* The bars are rendered on the text story route, so that they are what is under test rather than the camera behind
|
||||
* them. Which of the two the route actually puts up is covered on its own.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class, qualifiers = "w400dp-h800dp")
|
||||
@@ -119,6 +120,23 @@ class MediaCaptureScreenTest {
|
||||
assertThat(events).isEmpty()
|
||||
}
|
||||
|
||||
/** The camera is the fallback for every capture route that is not the text story, including the flow's chrome key. */
|
||||
@Test
|
||||
fun `Given the camera route, when displayed, then the text story editor is not what fills the screen`() {
|
||||
setContent(cameraFirstState().copy(selectedCaptureScreen = MediaSendRoute.Capture.Camera))
|
||||
|
||||
composeTestRule.onNodeWithTag(TEXT_STORY_SLOT).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_SCREEN).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given the camera route, when displayed, then the flow's chrome sits over it`() {
|
||||
setContent(cameraFirstState().copy(selectedCaptureScreen = MediaSendRoute.Capture.Camera))
|
||||
|
||||
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).assertIsDisplayed()
|
||||
}
|
||||
|
||||
private fun cameraFirstState() = MediaCaptureState(
|
||||
selectedCaptureScreen = MediaSendRoute.Capture.TextStory,
|
||||
isCameraFirst = true,
|
||||
|
||||
+90
-44
@@ -16,6 +16,7 @@ import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -71,6 +72,8 @@ class MediaCaptureViewModelTest {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
//region Chrome the flow's configuration decides
|
||||
|
||||
@Test
|
||||
fun `Given a camera-first flow that has yet to pick a destination, when created, then the bottom bar can display`() = runTest {
|
||||
val viewModel = createViewModel(cameraFirstStoryCapableState())
|
||||
@@ -78,9 +81,34 @@ class MediaCaptureViewModelTest {
|
||||
assertThat(viewModel.state.value.canDisplayBottomBar).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a camera-first flow aimed at one recipient's story, when created, then the bottom bar can display`() = runTest {
|
||||
val viewModel = createViewModel(
|
||||
cameraFirstStoryCapableState().copy(mode = MediaSendFlowActivityContract.Mode.SingleRecipient, isStory = true)
|
||||
)
|
||||
|
||||
assertThat(viewModel.state.value.canDisplayBottomBar).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a camera-first flow headed straight to a chat, when created, then the bottom bar stays hidden`() = runTest {
|
||||
val viewModel = createViewModel(cameraFirstStoryCapableState().copy(mode = MediaSendFlowActivityContract.Mode.SingleRecipient, isStory = false))
|
||||
val viewModel = createViewModel(
|
||||
cameraFirstStoryCapableState().copy(mode = MediaSendFlowActivityContract.Mode.SingleRecipient, isStory = false)
|
||||
)
|
||||
|
||||
assertThat(viewModel.state.value.canDisplayBottomBar).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given stories are unavailable, when created, then the bottom bar stays hidden`() = runTest {
|
||||
val viewModel = createViewModel(cameraFirstStoryCapableState().copy(storiesEnabled = false))
|
||||
|
||||
assertThat(viewModel.state.value.canDisplayBottomBar).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given the camera was not what opened the flow, when created, then the bottom bar stays hidden`() = runTest {
|
||||
val viewModel = createViewModel(cameraFirstStoryCapableState().copy(isCameraFirst = false))
|
||||
|
||||
assertThat(viewModel.state.value.canDisplayBottomBar).isFalse()
|
||||
}
|
||||
@@ -99,6 +127,10 @@ class MediaCaptureViewModelTest {
|
||||
assertThat(viewModel.state.value.maxVideoDurationSecondsOverride).isEqualTo(0)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Following the flow
|
||||
|
||||
@Test
|
||||
fun `when the flow's selection changes, then the screen's copy of it follows`() = runTest {
|
||||
val parentState = MutableStateFlow(MediaSendFlowState())
|
||||
@@ -120,53 +152,60 @@ class MediaCaptureViewModelTest {
|
||||
assertThat(viewModel.state.value.selectedCaptureScreen).isEqualTo(MediaSendRoute.Capture.TextStory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which capture screen is showing is not the flow's to report, and a capture landing in the selection is exactly when
|
||||
* the flow reports something while the text story editor is open.
|
||||
*/
|
||||
@Test
|
||||
fun `when the camera is asked for, then the flow is sent to it`() = runTest {
|
||||
onEvent(MediaCaptureScreenEvents.ShowCamera)
|
||||
fun `Given the text story editor is open, when the flow's selection changes, then it stays open`() = runTest {
|
||||
val parentState = MutableStateFlow(MediaSendFlowState())
|
||||
val viewModel = createViewModel(parentState)
|
||||
viewModel.onEvent(MediaCaptureScreenEvents.SelectedCaptureScreenChanged(MediaSendRoute.Capture.TextStory))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.NavigateToCamera)
|
||||
parentState.value = MediaSendFlowState(selectedMedia = listOf(MEDIA))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.selectedCaptureScreen).isEqualTo(MediaSendRoute.Capture.TextStory)
|
||||
assertThat(viewModel.state.value.selectedMedia).containsExactly(MEDIA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the text story editor is asked for, then the flow is sent to it`() = runTest {
|
||||
onEvent(MediaCaptureScreenEvents.ShowTextStory)
|
||||
fun `Given nothing has happened, when created, then the flow is left alone`() = runTest {
|
||||
createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.NavigateToTextStory)
|
||||
assertThat(parentEvents).isEmpty()
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Handing off to the flow
|
||||
|
||||
/**
|
||||
* Everything the flow, rather than this screen, is responsible for. Kept as one table so that an event added to the
|
||||
* screen without a home in the flow's own vocabulary shows up as a gap here.
|
||||
*/
|
||||
@Test
|
||||
fun `when next is clicked, then the flow moves on to the editor`() = runTest {
|
||||
onEvent(MediaCaptureScreenEvents.NextClicked)
|
||||
fun `Given work only the flow can do, when it is asked for, then it is handed over unchanged`() = runTest {
|
||||
val handOffs: List<Pair<MediaCaptureScreenEvents, MediaSendFlowEvent>> = listOf(
|
||||
MediaCaptureScreenEvents.ShowCamera to MediaSendFlowEvent.NavigateToCamera,
|
||||
MediaCaptureScreenEvents.ShowTextStory to MediaSendFlowEvent.NavigateToTextStory,
|
||||
MediaCaptureScreenEvents.NextClicked to MediaSendFlowEvent.NavigateToEdit,
|
||||
camera(CameraXScreenEvents.GalleryClicked) to MediaSendFlowEvent.NavigateToFolders,
|
||||
camera(CameraXScreenEvents.CameraCloseClicked) to MediaSendFlowEvent.CloseRequested,
|
||||
camera(CameraXScreenEvents.QrCodeFound("sgnl://example")) to MediaSendFlowEvent.QrCodeScanned("sgnl://example"),
|
||||
camera(CameraXScreenEvents.VideoCaptureError) to snackbar(R.string.MediaSendViewModel__error_recording_video)
|
||||
)
|
||||
|
||||
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.NavigateToEdit)
|
||||
}
|
||||
handOffs.forEach { (screenEvent, expected) ->
|
||||
parentEvents.clear()
|
||||
|
||||
@Test
|
||||
fun `when the gallery is opened from the camera, then the flow is sent to it`() = runTest {
|
||||
onCameraEvent(CameraXScreenEvents.GalleryClicked)
|
||||
createViewModel().onEvent(screenEvent)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.NavigateToFolders)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the camera is closed, then the flow is asked to close`() = runTest {
|
||||
onCameraEvent(CameraXScreenEvents.CameraCloseClicked)
|
||||
|
||||
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.CloseRequested)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when a qr code is read, then it is handed to the flow`() = runTest {
|
||||
onCameraEvent(CameraXScreenEvents.QrCodeFound("sgnl://example"))
|
||||
|
||||
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.QrCodeScanned("sgnl://example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when a recording fails outright, then the failure is reported`() = runTest {
|
||||
onCameraEvent(CameraXScreenEvents.VideoCaptureError)
|
||||
|
||||
assertThat(parentEvents).containsExactly(snackbar(R.string.MediaSendViewModel__error_recording_video))
|
||||
assertThat(parentEvents, name = screenEvent.toString()).containsExactly(expected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -178,6 +217,16 @@ class MediaCaptureViewModelTest {
|
||||
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.MediaCaptured(MEDIA))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when an image is captured, then it is written out with what the camera reported`() = runTest {
|
||||
coEvery { repository.writeCapturedImage(any(), any(), any()) } returns MEDIA
|
||||
val data = byteArrayOf(1, 2, 3)
|
||||
|
||||
onCameraEvent(CameraXScreenEvents.ImageCaptured(data = data, width = 640, height = 480))
|
||||
|
||||
coVerify(exactly = 1) { repository.writeCapturedImage(data, 640, 480) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when an image cannot be written out, then the failure is reported and nothing is handed over`() = runTest {
|
||||
coEvery { repository.writeCapturedImage(any(), any(), any()) } returns null
|
||||
@@ -187,6 +236,7 @@ class MediaCaptureViewModelTest {
|
||||
assertThat(parentEvents).containsExactly(snackbar(R.string.MediaSendViewModel__error_taking_photo))
|
||||
}
|
||||
|
||||
/** The duration rides along because it is what the flow drops back to standard quality on. */
|
||||
@Test
|
||||
fun `when a recording is captured, then it is handed over with how long it ran`() = runTest {
|
||||
coEvery { repository.writeCapturedVideo(any()) } returns MEDIA
|
||||
@@ -205,13 +255,7 @@ class MediaCaptureViewModelTest {
|
||||
assertThat(parentEvents).containsExactly(snackbar(R.string.MediaSendViewModel__error_recording_video))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given nothing has happened, when created, then the flow is left alone`() = runTest {
|
||||
createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents).isEmpty()
|
||||
}
|
||||
//endregion
|
||||
|
||||
/** Raises [event] on a freshly created screen and lets it settle. */
|
||||
private fun TestScope.onEvent(event: MediaCaptureScreenEvents) {
|
||||
@@ -220,9 +264,11 @@ class MediaCaptureViewModelTest {
|
||||
}
|
||||
|
||||
private fun TestScope.onCameraEvent(event: CameraXScreenEvents) {
|
||||
onEvent(MediaCaptureScreenEvents.Camera(event))
|
||||
onEvent(camera(event))
|
||||
}
|
||||
|
||||
private fun camera(event: CameraXScreenEvents) = MediaCaptureScreenEvents.Camera(event)
|
||||
|
||||
private fun snackbar(@StringRes message: Int) = MediaSendFlowEvent.ShowSnackbar(SnackbarEvent(message = message))
|
||||
|
||||
private fun cameraFirstStoryCapableState() = MediaSendFlowState(
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.screens.edit
|
||||
|
||||
import android.app.Application
|
||||
import androidx.core.net.toUri
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.mockk
|
||||
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.mediasend.EditorState
|
||||
import org.signal.mediasend.screens.edit.video.VideoTrimData
|
||||
|
||||
/**
|
||||
* Covers what the editor reads off its state rather than off a control: whether backing out discards a capture, and
|
||||
* which editor the focused item gets.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, application = Application::class)
|
||||
class MediaEditStateTest {
|
||||
|
||||
@Test
|
||||
fun `Given a capture is all that is selected, when backing out, then it is the capture that would be discarded`() {
|
||||
val state = MediaEditState(selectedMedia = listOf(CAPTURE), cameraFirstCapture = CAPTURE)
|
||||
|
||||
assertThat(state.isOnlyCameraFirstCapture).isTrue()
|
||||
}
|
||||
|
||||
/** Something was picked alongside the capture, so backing out is an ordinary step back up the stack. */
|
||||
@Test
|
||||
fun `Given a capture with something else selected, when backing out, then nothing would be discarded`() {
|
||||
val state = MediaEditState(selectedMedia = listOf(CAPTURE, GALLERY_ITEM), cameraFirstCapture = CAPTURE)
|
||||
|
||||
assertThat(state.isOnlyCameraFirstCapture).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a selection that was never captured, when backing out, then nothing would be discarded`() {
|
||||
val state = MediaEditState(selectedMedia = listOf(GALLERY_ITEM))
|
||||
|
||||
assertThat(state.isOnlyCameraFirstCapture).isFalse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Population replaces a capture with a new, non-equal [Media] for the same URI, so a stale capture must not read as
|
||||
* the thing on screen.
|
||||
*/
|
||||
@Test
|
||||
fun `Given a capture that is no longer what is selected, when backing out, then nothing would be discarded`() {
|
||||
val state = MediaEditState(selectedMedia = listOf(GALLERY_ITEM), cameraFirstCapture = CAPTURE)
|
||||
|
||||
assertThat(state.isOnlyCameraFirstCapture).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a focused item, when its editor is asked for, then the one keyed to its uri comes back`() {
|
||||
val editorState = EditorState.Image(mockk(relaxed = true))
|
||||
val state = MediaEditState(
|
||||
selectedMedia = listOf(CAPTURE, GALLERY_ITEM),
|
||||
focusedMedia = GALLERY_ITEM,
|
||||
editorStateMap = mapOf(CAPTURE.uri to EditorState.Gif, GALLERY_ITEM.uri to editorState)
|
||||
)
|
||||
|
||||
assertThat(state.focusedEditorState).isEqualTo(editorState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given nothing is focused, when an editor is asked for, then there is none`() {
|
||||
assertThat(MediaEditState(selectedMedia = listOf(CAPTURE)).focusedEditorState).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a trimmed video, when its trim is asked for, then the trim it was given comes back`() {
|
||||
val trimData = VideoTrimData(totalInputDurationUs = 1_000, startTimeUs = 100, endTimeUs = 900)
|
||||
val state = MediaEditState(editorStateMap = mapOf(CAPTURE.uri to EditorState.VideoTrim(trimData)))
|
||||
|
||||
assertThat(state.getOrCreateVideoTrimData(CAPTURE.uri)).isEqualTo(trimData)
|
||||
}
|
||||
|
||||
/** The trim bar composes before the editor state for a page exists, so an untrimmed video needs a default. */
|
||||
@Test
|
||||
fun `Given an item with no trim yet, when its trim is asked for, then an untrimmed default comes back`() {
|
||||
assertThat(MediaEditState().getOrCreateVideoTrimData(CAPTURE.uri)).isEqualTo(VideoTrimData())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val CAPTURE = media("content://capture")
|
||||
private val GALLERY_ITEM = media("content://gallery/1")
|
||||
|
||||
private fun media(uri: String) = Media(
|
||||
uri = uri.toUri(),
|
||||
contentType = "image/jpeg",
|
||||
date = 0,
|
||||
width = 100,
|
||||
height = 200,
|
||||
size = 1024,
|
||||
duration = 0,
|
||||
isBorderless = false,
|
||||
isVideoGif = false,
|
||||
bucketId = Media.ALL_MEDIA_BUCKET_ID,
|
||||
caption = null,
|
||||
transformProperties = null,
|
||||
fileName = null
|
||||
)
|
||||
}
|
||||
}
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.screens.edit
|
||||
|
||||
import android.app.Application
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.net.toUri
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isTrue
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
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.mediasend.EditorState
|
||||
import org.signal.mediasend.MediaRecipientId
|
||||
import org.signal.mediasend.MediaSendDependenciesRule
|
||||
import org.signal.mediasend.MediaSendFlowEvent
|
||||
import org.signal.mediasend.MediaSendFlowState
|
||||
import org.signal.mediasend.R
|
||||
import org.signal.mediasend.SaveToStorageResult
|
||||
import org.signal.mediasend.SentMediaQuality
|
||||
import org.signal.mediasend.SnackbarEvent
|
||||
import org.signal.mediasend.screens.edit.image.BrushTool
|
||||
import org.signal.mediasend.screens.edit.video.VideoTrimData
|
||||
import org.thoughtcrime.securesms.video.TranscodingConfig
|
||||
|
||||
/**
|
||||
* Covers what the edit screen keeps and what it hands off: the flow's state mirrored into its own, every edit raised as
|
||||
* a flow event, and the one job it does itself -- writing the focused image out to shared storage.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(manifest = Config.NONE, application = Application::class)
|
||||
class MediaEditViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mediaSendDependenciesRule = MediaSendDependenciesRule(ApplicationProvider.getApplicationContext())
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val repository = mediaSendDependenciesRule.mediaSendRepository
|
||||
private val parentEvents = mutableListOf<MediaSendFlowEvent>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
every { repository.hasDismissedSaveToStorageWarning } returns true
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
//region Parent state
|
||||
|
||||
@Test
|
||||
fun `when the flow's selection changes, then the screen's copy of it follows`() = runTest {
|
||||
val parentState = MutableStateFlow(MediaSendFlowState())
|
||||
val viewModel = createViewModel(parentState)
|
||||
|
||||
parentState.value = MediaSendFlowState(selectedMedia = listOf(MEDIA), focusedMedia = MEDIA)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.selectedMedia).containsExactly(MEDIA)
|
||||
assertThat(viewModel.state.value.focusedMedia).isEqualTo(MEDIA)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the flow reports a quality change, then the screen's copy of it follows`() = runTest {
|
||||
val parentState = MutableStateFlow(MediaSendFlowState(sentMediaQuality = SentMediaQuality.STANDARD))
|
||||
val viewModel = createViewModel(parentState)
|
||||
|
||||
parentState.value = MediaSendFlowState(sentMediaQuality = SentMediaQuality.HIGH)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.sentMediaQuality).isEqualTo(SentMediaQuality.HIGH)
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor reads nearly all of the flow's state, so a field left out of the mapping is a control that silently
|
||||
* renders a default. Every field this screen shows is asserted against a parent that differs from the default in all
|
||||
* of them.
|
||||
*/
|
||||
@Test
|
||||
fun `Given a flow with everything set, when it is reported, then every part the editor shows is mirrored`() = runTest {
|
||||
val editorState = EditorState.Image(mockk(relaxed = true))
|
||||
val tiers = listOf(mockk<TranscodingConfig.QualityTier>(relaxed = true))
|
||||
val parentState = MediaSendFlowState(
|
||||
selectedMedia = listOf(MEDIA),
|
||||
focusedMedia = MEDIA,
|
||||
editorStateMap = mapOf(MEDIA.uri to editorState),
|
||||
cameraFirstCapture = MEDIA,
|
||||
recipientId = MediaRecipientId(1L),
|
||||
message = "hello",
|
||||
sentMediaQuality = SentMediaQuality.HIGH,
|
||||
videoTranscodingTiers = tiers,
|
||||
isStory = false,
|
||||
isReply = true,
|
||||
isSending = true,
|
||||
isTouchEnabled = false,
|
||||
isMuteVideoAudioEnabled = true,
|
||||
viewOnceToggleState = MediaSendFlowState.ViewOnceToggleState.ONCE
|
||||
)
|
||||
|
||||
val state = createViewModel(MutableStateFlow(parentState)).state.value
|
||||
|
||||
assertThat(state).isEqualTo(
|
||||
MediaEditState(
|
||||
selectedMedia = listOf(MEDIA),
|
||||
focusedMedia = MEDIA,
|
||||
editorStateMap = mapOf(MEDIA.uri to editorState),
|
||||
cameraFirstCapture = MEDIA,
|
||||
recipientId = MediaRecipientId(1L),
|
||||
message = "hello",
|
||||
sentMediaQuality = SentMediaQuality.HIGH,
|
||||
videoTranscodingTiers = tiers,
|
||||
isStory = false,
|
||||
isReply = true,
|
||||
isSending = true,
|
||||
isTouchEnabled = false,
|
||||
isMuteVideoAudioEnabled = true,
|
||||
isViewOnceAvailable = true,
|
||||
isViewOnceEnabled = true,
|
||||
isSavingMedia = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a save in flight, when the flow's state changes, then the save is still reported`() = runTest {
|
||||
val parentState = MutableStateFlow(imageState())
|
||||
val save = holdSaveOpen()
|
||||
val viewModel = createViewModel(parentState)
|
||||
|
||||
viewModel.onEvent(MediaEditScreenEvents.SaveMedia)
|
||||
advanceUntilIdle()
|
||||
assertThat(viewModel.state.value.isSavingMedia).isTrue()
|
||||
|
||||
parentState.value = imageState().copy(isReply = true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(viewModel.state.value.isSavingMedia).isTrue()
|
||||
assertThat(viewModel.state.value.isReply).isTrue()
|
||||
save.complete(SaveToStorageResult.SUCCESS)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Handing off to the flow
|
||||
|
||||
/**
|
||||
* Everything the flow, rather than this screen, is responsible for: an edit has to survive the editor being swiped
|
||||
* away, and a request that leaves the flow was never a screen's to answer. Kept as one table so that an event added
|
||||
* to the screen without a home in the flow's own vocabulary shows up as a gap here.
|
||||
*/
|
||||
@Test
|
||||
fun `Given work only the flow can do, when it is asked for, then it is handed over unchanged`() = runTest {
|
||||
val trimData = VideoTrimData(totalInputDurationUs = 1_000, startTimeUs = 100, endTimeUs = 900)
|
||||
|
||||
val handOffs: List<Pair<MediaEditScreenEvents, MediaSendFlowEvent>> = listOf(
|
||||
MediaEditScreenEvents.FocusedMediaChanged(MEDIA) to MediaSendFlowEvent.SetFocusedMedia(MEDIA),
|
||||
MediaEditScreenEvents.ReorderSelectedMedia(fromIndex = 2, toIndex = 0) to MediaSendFlowEvent.ReorderSelectedMedia(2, 0),
|
||||
MediaEditScreenEvents.RemoveMedia(MEDIA) to MediaSendFlowEvent.RemoveMedia(setOf(MEDIA)),
|
||||
MediaEditScreenEvents.SetMediaQuality(SentMediaQuality.HIGH) to MediaSendFlowEvent.SetMediaQuality(SentMediaQuality.HIGH),
|
||||
MediaEditScreenEvents.BrushWidthChanged(BrushTool.MARKER, 0.5f) to MediaSendFlowEvent.SetBrushWidth(BrushTool.MARKER, 0.5f),
|
||||
MediaEditScreenEvents.ToggleBlurFaces(enabled = true) to MediaSendFlowEvent.SetBlurFacesEnabled(true),
|
||||
MediaEditScreenEvents.VideoTrimChanged(trimData, editingComplete = true) to MediaSendFlowEvent.VideoTrimChanged(trimData, editingComplete = true),
|
||||
MediaEditScreenEvents.ToggleViewOnce to MediaSendFlowEvent.ToggleViewOnce,
|
||||
MediaEditScreenEvents.ToggleVideoMuted to MediaSendFlowEvent.ToggleVideoMuted,
|
||||
MediaEditScreenEvents.AddMessageClick(startWithEmojiKeyboard = true) to MediaSendFlowEvent.AddMessageRequested(startWithEmojiKeyboard = true),
|
||||
MediaEditScreenEvents.ScheduleSendClick(ScheduleSendOption.PickTime) to MediaSendFlowEvent.ScheduleSendRequested(ScheduleSendOption.PickTime),
|
||||
MediaEditScreenEvents.StickerClick to MediaSendFlowEvent.StickerRequested,
|
||||
MediaEditScreenEvents.NextClick to MediaSendFlowEvent.NextRequested,
|
||||
MediaEditScreenEvents.NavigateToGallery to MediaSendFlowEvent.NavigateToFolders,
|
||||
MediaEditScreenEvents.NavigateBack to MediaSendFlowEvent.NavigateBackFromEdit
|
||||
)
|
||||
|
||||
handOffs.forEach { (screenEvent, expected) ->
|
||||
parentEvents.clear()
|
||||
|
||||
createViewModel().onEvent(screenEvent)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents, name = screenEvent.toString()).containsExactly(expected)
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Saving to storage
|
||||
|
||||
@Test
|
||||
fun `when the focused image is saved, then it is written out and confirmed`() = runTest {
|
||||
coEvery { repository.saveImageToStorage(any()) } returns SaveToStorageResult.SUCCESS
|
||||
val viewModel = createViewModel(MutableStateFlow(imageState()))
|
||||
|
||||
viewModel.onEvent(MediaEditScreenEvents.SaveMedia)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { repository.saveImageToStorage(any()) }
|
||||
assertThat(parentEvents).containsExactly(snackbar(R.string.MediaSendViewModel__media_saved))
|
||||
assertThat(viewModel.state.value.isSavingMedia).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when a save fails, then the failure is reported`() = runTest {
|
||||
coEvery { repository.saveImageToStorage(any()) } returns SaveToStorageResult.FAILURE
|
||||
val viewModel = createViewModel(MutableStateFlow(imageState()))
|
||||
|
||||
viewModel.onEvent(MediaEditScreenEvents.SaveMedia)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents).containsExactly(snackbar(R.string.MediaSendViewModel__error_saving_media))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when a save is refused for lack of access, then that is what is reported`() = runTest {
|
||||
coEvery { repository.saveImageToStorage(any()) } returns SaveToStorageResult.NO_WRITE_ACCESS
|
||||
val viewModel = createViewModel(MutableStateFlow(imageState()))
|
||||
|
||||
viewModel.onEvent(MediaEditScreenEvents.SaveMedia)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(parentEvents).containsExactly(snackbar(R.string.MediaSendViewModel__unable_to_save_without_storage_permission))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given the focus is not an editable image, when a save is asked for, then nothing is written`() = runTest {
|
||||
val documentState = MediaSendFlowState(
|
||||
selectedMedia = listOf(MEDIA),
|
||||
focusedMedia = MEDIA,
|
||||
editorStateMap = mapOf(MEDIA.uri to EditorState.Document(fileName = "report.pdf", fileSize = 1, extension = "pdf"))
|
||||
)
|
||||
val viewModel = createViewModel(MutableStateFlow(documentState))
|
||||
|
||||
viewModel.onEvent(MediaEditScreenEvents.SaveMedia)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { repository.saveImageToStorage(any()) }
|
||||
assertThat(parentEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given the warning has not been dismissed, when a save is asked for, then nothing is written until it is confirmed`() = runTest {
|
||||
every { repository.hasDismissedSaveToStorageWarning } returns false
|
||||
val viewModel = createViewModel(MutableStateFlow(imageState()))
|
||||
|
||||
viewModel.onEvent(MediaEditScreenEvents.SaveMedia)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { repository.saveImageToStorage(any()) }
|
||||
assertThat(viewModel.state.value.isSavingMedia).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a save in flight, when another is asked for, then it is not written twice`() = runTest {
|
||||
val save = holdSaveOpen()
|
||||
val viewModel = createViewModel(MutableStateFlow(imageState()))
|
||||
|
||||
viewModel.onEvent(MediaEditScreenEvents.SaveMedia)
|
||||
advanceUntilIdle()
|
||||
viewModel.onEvent(MediaEditScreenEvents.SaveMedia)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { repository.saveImageToStorage(any()) }
|
||||
save.complete(SaveToStorageResult.SUCCESS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when the warning is dismissed for good, then that is remembered`() = runTest {
|
||||
createViewModel().markSaveToStorageWarningDismissed()
|
||||
|
||||
coVerify(exactly = 1) { repository.markSaveToStorageWarningDismissed() }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
/** Makes the next save suspend until the returned deferred is completed. */
|
||||
private fun holdSaveOpen(): CompletableDeferred<SaveToStorageResult> {
|
||||
val gate = CompletableDeferred<SaveToStorageResult>()
|
||||
coEvery { repository.saveImageToStorage(any()) } coAnswers { gate.await() }
|
||||
return gate
|
||||
}
|
||||
|
||||
/** Raises [event] on a freshly created screen and lets it settle. */
|
||||
private fun TestScope.onEvent(event: MediaEditScreenEvents) {
|
||||
createViewModel().onEvent(event)
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
private fun snackbar(@StringRes message: Int) = MediaSendFlowEvent.ShowSnackbar(SnackbarEvent(message = message))
|
||||
|
||||
/** A flow whose focused item is an image, which is the only thing saving applies to. */
|
||||
private fun imageState() = MediaSendFlowState(
|
||||
selectedMedia = listOf(MEDIA),
|
||||
focusedMedia = MEDIA,
|
||||
editorStateMap = mapOf(MEDIA.uri to EditorState.Image(mockk(relaxed = true)))
|
||||
)
|
||||
|
||||
private fun createViewModel(parentState: MutableStateFlow<MediaSendFlowState> = MutableStateFlow(MediaSendFlowState())): MediaEditViewModel {
|
||||
return MediaEditViewModel(
|
||||
parentState = parentState,
|
||||
parentEventEmitter = { parentEvents += it },
|
||||
repository = repository
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val MEDIA = Media(
|
||||
uri = "content://media/1".toUri(),
|
||||
contentType = "image/jpeg",
|
||||
date = 0,
|
||||
width = 100,
|
||||
height = 200,
|
||||
size = 1024,
|
||||
duration = 0,
|
||||
isBorderless = false,
|
||||
isVideoGif = false,
|
||||
bucketId = Media.ALL_MEDIA_BUCKET_ID,
|
||||
caption = null,
|
||||
transformProperties = null,
|
||||
fileName = null
|
||||
)
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -25,7 +25,6 @@ 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.MediaSendFlowState
|
||||
import org.signal.mediasend.test.TestTags
|
||||
|
||||
/**
|
||||
@@ -174,7 +173,7 @@ class MediaEditorToolbarSharedButtonsTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setContent(state: MediaSendFlowState, editorState: EditorState) {
|
||||
private fun setContent(state: MediaEditState, editorState: EditorState) {
|
||||
composeTestRule.setContent {
|
||||
SignalTheme {
|
||||
MediaEditorToolbar {
|
||||
@@ -188,12 +187,13 @@ class MediaEditorToolbarSharedButtonsTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun state(media: Media, isStory: Boolean = false, viewOnce: Boolean = false, muteEnabled: Boolean = false): MediaSendFlowState {
|
||||
return MediaSendFlowState(
|
||||
private fun state(media: Media, isStory: Boolean = false, viewOnce: Boolean = false, muteEnabled: Boolean = false): MediaEditState {
|
||||
return MediaEditState(
|
||||
selectedMedia = listOf(media),
|
||||
focusedMedia = media,
|
||||
isStory = isStory,
|
||||
viewOnceToggleState = if (viewOnce) MediaSendFlowState.ViewOnceToggleState.ONCE else MediaSendFlowState.ViewOnceToggleState.OFF,
|
||||
isViewOnceAvailable = !isStory,
|
||||
isViewOnceEnabled = viewOnce,
|
||||
isMuteVideoAudioEnabled = muteEnabled
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
import org.signal.mediasend.EditorState
|
||||
import org.signal.mediasend.MediaSendDependenciesRule
|
||||
import org.signal.mediasend.MediaSendFlowState
|
||||
import org.signal.mediasend.screens.edit.ImageController
|
||||
import org.signal.mediasend.screens.edit.MediaEditState
|
||||
|
||||
/**
|
||||
* The aspect ratio toggle is an icon-only button whose icon is the only thing that says whether the crop is locked, so the
|
||||
@@ -70,7 +70,7 @@ class ImageEditorCropToolbarTest {
|
||||
|
||||
ImageEditorToolbar(
|
||||
imageEditorController = controller,
|
||||
state = MediaSendFlowState(),
|
||||
state = MediaEditState(),
|
||||
editorState = EditorState.Image(EDITOR_MODEL),
|
||||
onEvent = {}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user