diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEventHandler.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEventHandler.kt deleted file mode 100644 index 50eaf694aa..0000000000 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEventHandler.kt +++ /dev/null @@ -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) -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt index ae7630ace2..ee2ec7eb0b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt @@ -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 } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt index 4bf93fbe25..3f0b2a43e3 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowState.kt @@ -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 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. */ diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt index 264a526e62..46191d3749 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt @@ -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 -) : 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() internal val linkedDeviceScannedDialog = DialogController() - internal val saveToStorageDialog = DialogController() internal val discardMediaDialog = DialogController() internal val addToGroupStoryDialog = DialogController() - internal val writeStoragePermission = PermissionController( - permission = Manifest.permission.WRITE_EXTERNAL_STORAGE, - permanentDenialMessage = R.string.MediaSendViewModel__signal_needs_the_storage_permission - ) - private val qrCheckRequest: Channel = 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 { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt index 116afa3c3a..97162a3ebb 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt @@ -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. */ diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt index 0309543c81..e34d0ce8a2 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt @@ -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, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt index 94d13df77f..f1b9f3f66a 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt @@ -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() }, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvents.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvents.kt index 971a4fa5f6..244ba93ca9 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvents.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreenEvents.kt @@ -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 diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditState.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditState.kt new file mode 100644 index 0000000000..99807a7bb1 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditState.kt @@ -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 = emptyList(), + val focusedMedia: Media? = null, + val editorStateMap: Map = 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 = 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 + ) +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditViewModel.kt new file mode 100644 index 0000000000..46557d6dde --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditViewModel.kt @@ -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, + private val parentEventEmitter: (MediaSendFlowEvent) -> Unit, + private val repository: MediaSendRepository = MediaSendDependencies.mediaSendRepository +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(MediaEditViewModel::class) + } + + private val _state: MutableStateFlow = MutableStateFlow(MediaEditState().withParentState(parentState.value)) + val state: StateFlow = _state.asStateFlow() + + /** Hosted here, since this is the only screen that saves media or asks for what saving it needs. */ + val saveToStorageDialog = DialogController() + 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, + private val parentEventEmitter: (MediaSendFlowEvent) -> Unit + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return MediaEditViewModel(parentState, parentEventEmitter) as T + } + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt index 03e384367f..efbe34d6f5 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt @@ -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) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt index 833d1ef6a5..7633d92cbf 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt @@ -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 = {} ) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectViewModel.kt index 4ab5d004a4..89bb863e09 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/select/MediaSelectViewModel.kt @@ -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) diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureRepositoryTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureRepositoryTest.kt new file mode 100644 index 0000000000..2d51c69ad5 --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureRepositoryTest.kt @@ -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() + + 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()) } returns imageBuilder + every { forData(any(), 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() + } +} diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt index 4e87386756..d9c08e8ef3 100644 --- a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt @@ -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, diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt index cee7b8abaf..9d4bc4095c 100644 --- a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt @@ -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> = 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( diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditStateTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditStateTest.kt new file mode 100644 index 0000000000..788c3d6192 --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditStateTest.kt @@ -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 + ) + } +} diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditViewModelTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditViewModelTest.kt new file mode 100644 index 0000000000..c4b273c986 --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditViewModelTest.kt @@ -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() + + @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(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> = 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 { + val gate = CompletableDeferred() + 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 = 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 + ) + } +} diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditorToolbarSharedButtonsTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditorToolbarSharedButtonsTest.kt index b82022912f..d9de08525e 100644 --- a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditorToolbarSharedButtonsTest.kt +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/MediaEditorToolbarSharedButtonsTest.kt @@ -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 ) } diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/image/ImageEditorCropToolbarTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/image/ImageEditorCropToolbarTest.kt index 169dc8cf63..11b4d26e1c 100644 --- a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/image/ImageEditorCropToolbarTest.kt +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/image/ImageEditorCropToolbarTest.kt @@ -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 = {} )