Refactor media-edit to match pattern for media-send module.

This commit is contained in:
Alex Hart
2026-08-12 13:21:22 -03:00
parent bae9a0fca1
commit d6b0826f4e
20 changed files with 1070 additions and 249 deletions
@@ -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,
@@ -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() },
@@ -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
)
}
@@ -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
}
}
}
@@ -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)
}
@@ -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 = {}
)
@@ -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)
@@ -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()
}
}
@@ -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,
@@ -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(
@@ -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
)
}
}
@@ -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
)
}
}
@@ -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
)
}
@@ -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 = {}
)