Migrate media-send capture to proper shape.

This commit is contained in:
Alex Hart
2026-08-12 10:42:58 -03:00
parent 4e0a08ac96
commit bae9a0fca1
18 changed files with 830 additions and 169 deletions
@@ -141,9 +141,9 @@ class MediaSelectionActivity :
MediaCaptureScreenEvents.ShowCamera -> debouncer.publish { popTextStoryPostCreationFragment() }
MediaCaptureScreenEvents.ShowTextStory -> viewModel.sendCommand(HudCommand.GoToText)
MediaCaptureScreenEvents.NextClicked -> viewModel.sendCommand(HudCommand.GoToReview)
is MediaCaptureScreenEvents.Camera -> Unit
MediaCaptureScreenEvents.CycleTextStoryBackgroundColor -> Unit
MediaCaptureScreenEvents.AddLinkToTextStory -> Unit
is MediaCaptureScreenEvents.Camera,
is MediaCaptureScreenEvents.ParentStateChanged,
is MediaCaptureScreenEvents.SelectedCaptureScreenChanged -> Unit
}
},
modifier = Modifier.navigationBarsPadding()
@@ -33,7 +33,7 @@ import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.dp
import org.signal.mediasend.screens.capture.MediaCaptureScreenEvents
import org.signal.mediasend.screens.capture.TextStoryBarEvents
import org.signal.mediasend.screens.capture.TextStoryHorizontalBar
import org.signal.mediasend.screens.capture.TextStoryVerticalBar
import org.thoughtcrime.securesms.R
@@ -256,11 +256,10 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
binding.scene.addView(composeView)
}
private fun onTextStoryBarEvent(event: MediaCaptureScreenEvents) {
private fun onTextStoryBarEvent(event: TextStoryBarEvents) {
when (event) {
MediaCaptureScreenEvents.CycleTextStoryBackgroundColor -> viewModel.cycleBackgroundColor()
MediaCaptureScreenEvents.AddLinkToTextStory -> TextStoryPostLinkEntryFragment().show(childFragmentManager, null)
else -> Unit
TextStoryBarEvents.CycleBackgroundColor -> viewModel.cycleBackgroundColor()
TextStoryBarEvents.AddLink -> TextStoryPostLinkEntryFragment().show(childFragmentManager, null)
}
}
@@ -5,7 +5,6 @@
package org.signal.mediasend
import org.signal.mediasend.screens.capture.MediaCaptureScreenEvents
import org.signal.mediasend.screens.edit.MediaEditScreenEvents
/**
@@ -14,5 +13,4 @@ import org.signal.mediasend.screens.edit.MediaEditScreenEvents
*/
interface MediaSendEventHandler {
fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvents)
fun onMediaCaptureScreenEvent(mediaCaptureScreenEvent: MediaCaptureScreenEvents)
}
@@ -7,6 +7,7 @@ package org.signal.mediasend
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
import kotlin.time.Duration
/**
* Changes to the flow itself, raised by the screens within it. A screen owns what only it renders; the selection, the
@@ -22,8 +23,23 @@ internal sealed interface MediaSendFlowEvent {
/** Whoever was mid-gesture has stopped, so [MediaSendFlowState.isSelectionRejected] has served its purpose. */
data object SelectionRejectionShown : MediaSendFlowEvent
/**
* Media the camera captured and wrote out, which joins the selection and takes the user on to the editor.
*
* @param recordingDuration How long the recording ran, or null for a capture that was not recorded.
*/
data class MediaCaptured(val media: Media, val recordingDuration: Duration? = null) : MediaSendFlowEvent
/** Data read from a QR code. Every outcome of acting on it leaves the flow, so none of it is a screen's to handle. */
data class QrCodeScanned(val data: String) : MediaSendFlowEvent
/** The user asked to leave, which is confirmed first if it would throw a selection away. */
data object CloseRequested : 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
}
@@ -39,7 +39,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.signal.core.models.media.Media
import org.signal.core.ui.compose.DialogController
import org.signal.core.ui.compose.DialogResult
@@ -55,8 +54,6 @@ import org.signal.imageeditor.core.model.EditorModel
import org.signal.imageeditor.core.renderers.UriGlideRenderer
import org.signal.mediasend.preupload.PreUploadController
import org.signal.mediasend.preupload.PreUploadResult
import org.signal.mediasend.screens.capture.CameraXScreenEvents
import org.signal.mediasend.screens.capture.MediaCaptureScreenEvents
import org.signal.mediasend.screens.edit.ImageController
import org.signal.mediasend.screens.edit.MediaEditScreenEvents
import org.signal.mediasend.screens.edit.ScheduleSendOption
@@ -64,9 +61,6 @@ import org.signal.mediasend.screens.edit.image.BrushTool
import org.signal.mediasend.screens.edit.image.BrushWidthsState
import org.signal.mediasend.screens.edit.video.VideoTrimData
import org.signal.mediasend.util.MeteredConnectivity
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
import java.io.FileInputStream
import java.io.IOException
import kotlin.coroutines.resume
import kotlin.time.Duration
import kotlin.time.Duration.Companion.microseconds
@@ -262,9 +256,14 @@ class MediaSendFlowViewModel(
is MediaSendFlowEvent.ReorderSelectedMedia -> reorderMedia(event.fromIndex, event.toIndex)
is MediaSendFlowEvent.ShowSnackbar -> internalSnackbarEvents.trySend(event.snackbar)
MediaSendFlowEvent.SelectionRejectionShown -> updateState { copy(isSelectionRejected = false) }
is MediaSendFlowEvent.MediaCaptured -> onMediaCaptured(event.media, event.recordingDuration)
is MediaSendFlowEvent.QrCodeScanned -> qrCheckRequest.trySend(event.data)
MediaSendFlowEvent.CloseRequested -> onCloseRequested()
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()
}
}
@@ -304,30 +303,6 @@ class MediaSendFlowViewModel(
}
}
override fun onMediaCaptureScreenEvent(mediaCaptureScreenEvent: MediaCaptureScreenEvents) {
when (mediaCaptureScreenEvent) {
MediaCaptureScreenEvents.ShowCamera -> backStack.goToCamera()
MediaCaptureScreenEvents.ShowTextStory -> backStack.goToTextStory()
is MediaCaptureScreenEvents.Camera -> onCameraXScreenEvent(mediaCaptureScreenEvent.event)
MediaCaptureScreenEvents.NextClicked -> backStack.goToEdit()
MediaCaptureScreenEvents.CycleTextStoryBackgroundColor -> error("Handled directly in the fragment.")
MediaCaptureScreenEvents.AddLinkToTextStory -> error("Handled directly in the fragment.")
}
}
private fun onCameraXScreenEvent(event: CameraXScreenEvents) {
when (event) {
CameraXScreenEvents.CameraCloseClicked -> onCloseRequested()
CameraXScreenEvents.GalleryClicked -> backStack.goToFolders()
is CameraXScreenEvents.ImageCaptured -> handleImageCaptured(event)
is CameraXScreenEvents.VideoCaptured -> handleVideoCaptured(event)
is CameraXScreenEvents.QrCodeFound -> qrCheckRequest.trySend(event.data)
CameraXScreenEvents.VideoCaptureError -> {
internalSnackbarEvents.trySend(SnackbarEvent(message = R.string.MediaSendViewModel__error_recording_video))
}
}
}
override fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvents) {
when (mediaEditScreenEvent) {
is MediaEditScreenEvents.FocusedMediaChanged -> setFocusedMedia(mediaEditScreenEvent.media)
@@ -438,78 +413,14 @@ class MediaSendFlowViewModel(
repository.brushWidths = brushWidths
}
private fun handleImageCaptured(imageCaptured: CameraXScreenEvents.ImageCaptured) {
viewModelScope.launch {
val media: Media? = withContext(Dispatchers.IO) {
try {
val length = imageCaptured.data.size.toLong()
val uri = MediaSendDependencies.blobs
.forData(imageCaptured.data)
.withMimeType(ContentTypeUtil.IMAGE_JPEG)
.createForSingleSessionOnDisk(MediaSendDependencies.application)
/**
* Takes on media the camera captured and moves the user along to edit it.
*
* @param recordingDuration How long the capture ran, for the captures that were recorded.
*/
private fun onMediaCaptured(media: Media, recordingDuration: Duration?) {
recordingDuration?.let { onVideoRecorded(it) }
buildCapturedMedia(uri, ContentTypeUtil.IMAGE_JPEG, imageCaptured.width, imageCaptured.height, length)
} catch (e: IOException) {
null
}
}
if (media != null) {
onMediaRendered(media)
} else {
internalSnackbarEvents.trySend(SnackbarEvent(message = R.string.MediaSendViewModel__error_taking_photo))
}
}
}
private fun handleVideoCaptured(videoCaptured: CameraXScreenEvents.VideoCaptured) {
viewModelScope.launch {
val media: Media? = withContext(Dispatchers.IO) {
try {
videoCaptured.fd.use { descriptor ->
FileInputStream(descriptor.fileDescriptor).use { stream ->
val length = stream.channel.size()
val uri = MediaSendDependencies.blobs
.forData(stream, length)
.withMimeType(VideoConstants.RECORDED_VIDEO_CONTENT_TYPE)
.createForSingleSessionOnDisk(MediaSendDependencies.application)
buildCapturedMedia(uri, VideoConstants.RECORDED_VIDEO_CONTENT_TYPE, 0, 0, length)
}
}
} catch (e: IOException) {
null
}
}
if (media != null) {
onVideoRecorded(videoCaptured.durationMs.milliseconds)
onMediaRendered(media)
} else {
internalSnackbarEvents.trySend(SnackbarEvent(message = R.string.MediaSendViewModel__error_recording_video))
}
}
}
private fun buildCapturedMedia(uri: Uri, mimeType: String, width: Int, height: Int, size: Long): Media {
return Media(
uri = uri,
contentType = mimeType,
date = System.currentTimeMillis(),
width = width,
height = height,
size = size,
duration = 0,
isBorderless = false,
isVideoGif = false,
bucketId = Media.ALL_MEDIA_BUCKET_ID,
caption = null,
transformProperties = null,
fileName = null
)
}
private fun onMediaRendered(media: Media) {
if (args.isCameraFirst && internalState.value.cameraFirstCapture == null) {
addCameraFirstCapture(media)
} else {
@@ -36,6 +36,8 @@ import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.showSnackbar
import org.signal.mediasend.screens.capture.MediaCaptureScreen
import org.signal.mediasend.screens.capture.MediaCaptureScreenEvents
import org.signal.mediasend.screens.capture.MediaCaptureViewModel
import org.signal.mediasend.screens.edit.MediaEditScreen
import org.signal.mediasend.screens.select.MediaSelectScreen
import org.signal.mediasend.screens.select.MediaSelectViewModel
@@ -67,12 +69,24 @@ internal fun MediaSendNavigation(
) { key ->
when (key) {
is MediaSendRoute.Capture -> NavEntry(MediaSendRoute.Capture.Chrome) {
val state by viewModel.state.collectAsStateWithLifecycle()
val captureViewModel: MediaCaptureViewModel = viewModel(
factory = MediaCaptureViewModel.Factory(
parentState = viewModel.state,
parentEventEmitter = viewModel::onEvent,
selectedCaptureScreen = key
)
)
val state by captureViewModel.state.collectAsStateWithLifecycle()
// Toggling between the camera and the text story editor is navigation, so it arrives as a new key on an
// entry that is deliberately not recreated by it.
LaunchedEffect(key) {
captureViewModel.onEvent(MediaCaptureScreenEvents.SelectedCaptureScreenChanged(key))
}
MediaCaptureScreen(
selectedCaptureScreen = key,
state = state,
onEvent = viewModel::onMediaCaptureScreenEvent,
onEvent = captureViewModel::onEvent,
textStoryEditorSlot = textStoryEditorSlot
)
}
@@ -10,15 +10,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview
import org.signal.core.ui.compose.Previews
import org.signal.mediasend.MediaSendFlowState
import org.signal.mediasend.rememberPreviewState
import org.signal.mediasend.PreviewMediaConstraints
/**
* Allows the user to capture images and video from the hardware camera to utilize in the media send flow.
*/
@Composable
fun MediaCameraCaptureScreen(
state: MediaSendFlowState,
internal fun MediaCameraCaptureScreen(
state: MediaCaptureState,
onEvent: (MediaCaptureScreenEvents) -> Unit
) {
// Shared with the permission controller so that the microphone is asked for exactly when recording is on offer.
@@ -36,7 +35,7 @@ fun MediaCameraCaptureScreen(
onEvent = { event -> onEvent(MediaCaptureScreenEvents.Camera(event)) },
videoRecordingConfig = rememberVideoRecordingConfig(
mediaConstraints = state.mediaConstraints,
maxDurationSecondsOverride = if (state.isStory) state.storyMaxVideoDuration.inWholeSeconds.toInt() else 0
maxDurationSecondsOverride = state.maxVideoDurationSecondsOverride
),
onCheckPermissions = permissions.requestCapturePermissions,
onRequestMicPermission = permissions.requestMicrophonePermission,
@@ -51,7 +50,7 @@ fun MediaCameraCaptureScreen(
private fun MediaCameraCaptureScreenPreview() {
Previews.Preview {
MediaCameraCaptureScreen(
state = rememberPreviewState(),
state = MediaCaptureState(mediaConstraints = PreviewMediaConstraints),
onEvent = {}
)
}
@@ -0,0 +1,83 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.screens.capture
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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.signal.mediasend.MediaSendDependencies
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants
import java.io.FileInputStream
import java.io.IOException
/**
* Turns what the camera hands back into media the rest of the flow can work with, by writing it to a single-session
* blob. A recording's dimensions are left at zero, since those are only known once population has probed the file.
*/
internal class MediaCaptureRepository(
private val context: Context = MediaSendDependencies.application,
private val blobs: BlobProvider = MediaSendDependencies.blobs
) {
/** @return The captured image, or null if it could not be written out. */
suspend fun writeCapturedImage(data: ByteArray, width: Int, height: Int): Media? = withContext(Dispatchers.IO) {
try {
val uri = blobs
.forData(data)
.withMimeType(ContentTypeUtil.IMAGE_JPEG)
.createForSingleSessionOnDisk(context)
buildCapturedMedia(uri, ContentTypeUtil.IMAGE_JPEG, width, height, data.size.toLong())
} catch (e: IOException) {
null
}
}
/**
* @param fd The recording, which is closed here whether or not it could be written out.
* @return The captured recording, or null if it could not be written out.
*/
suspend fun writeCapturedVideo(fd: SeekableFileDescriptor): Media? = withContext(Dispatchers.IO) {
try {
fd.use { descriptor ->
FileInputStream(descriptor.fileDescriptor).use { stream ->
val length = stream.channel.size()
val uri = blobs
.forData(stream, length)
.withMimeType(VideoConstants.RECORDED_VIDEO_CONTENT_TYPE)
.createForSingleSessionOnDisk(context)
buildCapturedMedia(uri, VideoConstants.RECORDED_VIDEO_CONTENT_TYPE, 0, 0, length)
}
}
} catch (e: IOException) {
null
}
}
private fun buildCapturedMedia(uri: Uri, mimeType: String, width: Int, height: Int, size: Long): Media {
return Media(
uri = uri,
contentType = mimeType,
date = System.currentTimeMillis(),
width = width,
height = height,
size = size,
duration = 0,
isBorderless = false,
isVideoGif = false,
bucketId = Media.ALL_MEDIA_BUCKET_ID,
caption = null,
transformProperties = null,
fileName = null
)
}
}
@@ -37,6 +37,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -50,11 +51,11 @@ import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.glide.compose.GlideImage
import org.signal.glide.decryptableuri.DecryptableUri
import org.signal.mediasend.MediaSendFlowActivityContract
import org.signal.mediasend.MediaSendFlowState
import org.signal.mediasend.MediaSendRoute
import org.signal.mediasend.PreviewMediaConstraints
import org.signal.mediasend.R
import org.signal.mediasend.rememberPreviewState
import org.signal.mediasend.screens.edit.rememberPreviewMedia
import org.signal.mediasend.test.TestTags
/**
* The text story editor slides in over a stationary camera, so it always sits on top.
@@ -66,9 +67,8 @@ private const val TEXT_STORY_Z_INDEX = 1f
* Screen that allows user to capture the media they will send using a camera or text story
*/
@Composable
fun MediaCaptureScreen(
selectedCaptureScreen: MediaSendRoute.Capture,
state: MediaSendFlowState,
internal fun MediaCaptureScreen(
state: MediaCaptureState,
onEvent: (MediaCaptureScreenEvents) -> Unit,
textStoryEditorSlot: @Composable () -> Unit
) {
@@ -76,9 +76,10 @@ fun MediaCaptureScreen(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Black)
.testTag(TestTags.MEDIA_CAPTURE_SCREEN)
) {
Crossfade(
targetState = selectedCaptureScreen
targetState = state.selectedCaptureScreen
) { captureScreen ->
when (captureScreen) {
is MediaSendRoute.Capture.TextStory -> textStoryEditorSlot()
@@ -91,12 +92,11 @@ fun MediaCaptureScreen(
}
}
val canDisplayBottomBar = rememberCanDisplayBottomBar(state)
if (canDisplayBottomBar) {
if (state.canDisplayBottomBar) {
MediaCaptureBottomBar(
canDisplayMediaBar = state.selectedMedia.isNotEmpty(),
canDisplayToggleSwitch = state.selectedMedia.isEmpty(),
selectedCaptureScreen = selectedCaptureScreen,
canDisplayMediaBar = state.canDisplayMediaBar,
canDisplayToggleSwitch = state.canDisplayToggleSwitch,
selectedCaptureScreen = state.selectedCaptureScreen,
selectedMedia = state.selectedMedia,
onEvent = onEvent,
modifier = Modifier
@@ -107,14 +107,6 @@ fun MediaCaptureScreen(
}
}
@Composable
private fun rememberCanDisplayBottomBar(state: MediaSendFlowState): Boolean {
return remember(state) {
val isSingleStory = state.mode == MediaSendFlowActivityContract.Mode.SingleRecipient && state.isStory
state.isCameraFirst && state.storiesEnabled && (state.mode == MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection || isSingleStory)
}
}
@Composable
fun MediaCaptureBottomBar(
canDisplayToggleSwitch: Boolean,
@@ -156,14 +148,16 @@ private fun MediaCaptureToggleBar(
) {
SegmentedBarButton(
selected = selectedCaptureScreen == MediaSendRoute.Capture.Camera,
onClick = { onEvent(MediaCaptureScreenEvents.ShowCamera) }
onClick = { onEvent(MediaCaptureScreenEvents.ShowCamera) },
modifier = Modifier.testTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE)
) {
Text(text = stringResource(R.string.MediaCaptureScreen__camera))
}
SegmentedBarButton(
selected = selectedCaptureScreen == MediaSendRoute.Capture.TextStory,
onClick = { onEvent(MediaCaptureScreenEvents.ShowTextStory) }
onClick = { onEvent(MediaCaptureScreenEvents.ShowTextStory) },
modifier = Modifier.testTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE)
) {
Text(text = stringResource(R.string.MediaCaptureScreen__text))
}
@@ -174,11 +168,13 @@ private fun MediaCaptureToggleBar(
private fun SingleChoiceSegmentedButtonRowScope.SegmentedBarButton(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
SegmentedButton(
selected = selected,
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(percent = 50),
icon = {},
border = BorderStroke(0.dp, Color.Transparent),
@@ -227,7 +223,8 @@ private fun MediaCaptureMediaBar(
Text(
text = pluralStringResource(R.plurals.MediaCaptureScreen_n_items, selectedMedia.size, selectedMedia.size),
color = SignalTheme.colors.colorOnCustom
color = SignalTheme.colors.colorOnCustom,
modifier = Modifier.testTag(TestTags.MEDIA_CAPTURE_MEDIA_COUNT)
)
}
@@ -251,6 +248,7 @@ private fun NextButton(
.padding(bottom = cameraDisplay.getNextPaddingBottom().dp, end = cameraDisplay.getNextPaddingEnd().dp)
.size(48.dp)
.background(colorResource(org.signal.camera.R.color.CameraHud_control_background), shape = CircleShape)
.testTag(TestTags.MEDIA_CAPTURE_NEXT_BUTTON)
) {
Icon(
imageVector = SignalIcons.ArrowEnd.imageVector,
@@ -263,16 +261,10 @@ private fun NextButton(
@NightPreview
@Composable
fun MediaCaptureScreenPreview() {
private fun MediaCaptureScreenPreview() {
Previews.Preview {
MediaCaptureScreen(
selectedCaptureScreen = MediaSendRoute.Capture.Camera,
state = rememberPreviewState()
.copy(
isCameraFirst = true,
storiesEnabled = true,
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection
),
state = rememberPreviewCaptureState(),
onEvent = {},
textStoryEditorSlot = {}
)
@@ -281,25 +273,28 @@ fun MediaCaptureScreenPreview() {
@NightPreview
@Composable
fun MediaCaptureScreenWithSelectedMediaPreview() {
private fun MediaCaptureScreenWithSelectedMediaPreview() {
val selectedMedia = rememberPreviewMedia(1)
Previews.Preview {
MediaCaptureScreen(
selectedCaptureScreen = MediaSendRoute.Capture.Camera,
state = rememberPreviewState()
.copy(
isCameraFirst = true,
storiesEnabled = true,
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection,
selectedMedia = selectedMedia
),
state = rememberPreviewCaptureState().copy(selectedMedia = selectedMedia),
onEvent = {},
textStoryEditorSlot = {}
)
}
}
@Composable
private fun rememberPreviewCaptureState(): MediaCaptureState = remember {
MediaCaptureState(
isCameraFirst = true,
storiesEnabled = true,
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection,
mediaConstraints = PreviewMediaConstraints
)
}
@NightPreview
@Composable
fun MediaCaptureToggleBarPreview() {
@@ -5,11 +5,25 @@
package org.signal.mediasend.screens.capture
import org.signal.mediasend.MediaSendFlowState
import org.signal.mediasend.MediaSendRoute
sealed interface MediaCaptureScreenEvents {
/** The parent flow's state changed and needs to be merged into this screen's state. */
data class ParentStateChanged(val parentState: MediaSendFlowState) : MediaCaptureScreenEvents {
// 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})"
}
/** Navigation moved between the camera and the text story editor. */
data class SelectedCaptureScreenChanged(val selectedCaptureScreen: MediaSendRoute.Capture) : MediaCaptureScreenEvents
data object ShowCamera : MediaCaptureScreenEvents
data object ShowTextStory : MediaCaptureScreenEvents
data object NextClicked : MediaCaptureScreenEvents
data object CycleTextStoryBackgroundColor : MediaCaptureScreenEvents
data object AddLinkToTextStory : MediaCaptureScreenEvents
/** Something the camera reported. What becomes of it is the flow's to decide rather than this screen's. */
class Camera(val event: CameraXScreenEvents) : MediaCaptureScreenEvents
}
@@ -0,0 +1,50 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.screens.capture
import org.signal.core.models.media.Media
import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.MediaSendFlowActivityContract
import org.signal.mediasend.MediaSendRoute
import kotlin.time.Duration
/**
* What the capture screen renders. Which capture screen is showing is navigation and the selection is the flow's, so
* both arrive from the parent; everything else is fixed for the life of the flow and read once at construction.
*/
internal data class MediaCaptureState(
val selectedCaptureScreen: MediaSendRoute.Capture = MediaSendRoute.Capture.Camera,
val selectedMedia: List<Media> = emptyList(),
val isCameraFirst: Boolean = false,
val isStory: Boolean = false,
val storiesEnabled: Boolean = false,
val mode: MediaSendFlowActivityContract.Mode = MediaSendFlowActivityContract.Mode.SingleRecipient,
/** Null leaves recording on the most conservative limits this device supports. */
val mediaConstraints: MediaConstraints? = null,
val storyMaxVideoDuration: Duration = Duration.ZERO
) {
/**
* Whether the camera's own chrome is joined by the flow's. Only a camera-first flow headed somewhere a text story can
* go has anything to add.
*/
val canDisplayBottomBar: Boolean
get() {
val isSingleStory = mode == MediaSendFlowActivityContract.Mode.SingleRecipient && isStory
return isCameraFirst && storiesEnabled && (mode == MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection || isSingleStory)
}
/** The toggle holds the spot the media bar takes over once something has been captured. */
val canDisplayToggleSwitch: Boolean
get() = selectedMedia.isEmpty()
val canDisplayMediaBar: Boolean
get() = selectedMedia.isNotEmpty()
/** The cap a story puts on a recording's length, or zero to leave the device's own cap in place. */
val maxVideoDurationSecondsOverride: Int
get() = if (isStory) storyMaxVideoDuration.inWholeSeconds.toInt() else 0
}
@@ -0,0 +1,130 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.screens.capture
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.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.models.media.Media
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.mediasend.MediaSendFlowEvent
import org.signal.mediasend.MediaSendFlowState
import org.signal.mediasend.MediaSendRoute
import org.signal.mediasend.R
import org.signal.mediasend.SnackbarEvent
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* Drives the capture screen, which makes media rather than picking media that already exists.
*
* Writing a capture out is this screen's, since only it knows what the camera handed back. What becomes of the media
* afterwards is not: it leaves as [MediaSendFlowEvent.MediaCaptured], and the selection comes back as
* [MediaCaptureScreenEvents.ParentStateChanged].
*
* @param selectedCaptureScreen Which of the two capture screens navigation is currently showing.
*/
internal class MediaCaptureViewModel(
parentState: StateFlow<MediaSendFlowState>,
private val parentEventEmitter: (MediaSendFlowEvent) -> Unit,
selectedCaptureScreen: MediaSendRoute.Capture,
private val repository: MediaCaptureRepository = MediaCaptureRepository()
) : EventDrivenViewModel<MediaCaptureScreenEvents>(TAG) {
companion object {
private val TAG = Log.tag(MediaCaptureViewModel::class)
}
private val _state: MutableStateFlow<MediaCaptureState> = MutableStateFlow(
with(parentState.value) {
MediaCaptureState(
selectedCaptureScreen = selectedCaptureScreen,
selectedMedia = selectedMedia,
isCameraFirst = isCameraFirst,
isStory = isStory,
storiesEnabled = storiesEnabled,
mode = mode,
mediaConstraints = mediaConstraints,
storyMaxVideoDuration = storyMaxVideoDuration
)
}
)
val state: StateFlow<MediaCaptureState> = _state.asStateFlow()
init {
parentState
.distinctUntilChangedBy { it.selectedMedia }
.onEach { onEvent(MediaCaptureScreenEvents.ParentStateChanged(it)) }
.launchIn(viewModelScope)
}
override suspend fun processEvent(event: MediaCaptureScreenEvents) {
when (event) {
is MediaCaptureScreenEvents.ParentStateChanged -> _state.update { it.copy(selectedMedia = event.parentState.selectedMedia) }
is MediaCaptureScreenEvents.SelectedCaptureScreenChanged -> _state.update { it.copy(selectedCaptureScreen = event.selectedCaptureScreen) }
MediaCaptureScreenEvents.ShowCamera -> parentEventEmitter(MediaSendFlowEvent.NavigateToCamera)
MediaCaptureScreenEvents.ShowTextStory -> parentEventEmitter(MediaSendFlowEvent.NavigateToTextStory)
MediaCaptureScreenEvents.NextClicked -> parentEventEmitter(MediaSendFlowEvent.NavigateToEdit)
is MediaCaptureScreenEvents.Camera -> processCameraEvent(event.event)
}
}
private fun processCameraEvent(event: CameraXScreenEvents) {
when (event) {
is CameraXScreenEvents.ImageCaptured -> captureMedia(R.string.MediaSendViewModel__error_taking_photo) {
repository.writeCapturedImage(event.data, event.width, event.height)
}
is CameraXScreenEvents.VideoCaptured -> captureMedia(R.string.MediaSendViewModel__error_recording_video, event.durationMs.milliseconds) {
repository.writeCapturedVideo(event.fd)
}
CameraXScreenEvents.VideoCaptureError -> showSnackbar(R.string.MediaSendViewModel__error_recording_video)
is CameraXScreenEvents.QrCodeFound -> parentEventEmitter(MediaSendFlowEvent.QrCodeScanned(event.data))
CameraXScreenEvents.GalleryClicked -> parentEventEmitter(MediaSendFlowEvent.NavigateToFolders)
CameraXScreenEvents.CameraCloseClicked -> parentEventEmitter(MediaSendFlowEvent.CloseRequested)
}
}
/** Hands the capture [write] produces to the flow, or says why nothing arrived if it could not be written out. */
private fun captureMedia(@StringRes errorMessage: Int, recordingDuration: Duration? = null, write: suspend () -> Media?) {
viewModelScope.launch {
val media = write()
if (media != null) {
parentEventEmitter(MediaSendFlowEvent.MediaCaptured(media, recordingDuration))
} else {
showSnackbar(errorMessage)
}
}
}
private fun showSnackbar(@StringRes message: Int) {
parentEventEmitter(MediaSendFlowEvent.ShowSnackbar(SnackbarEvent(message = message)))
}
class Factory(
private val parentState: StateFlow<MediaSendFlowState>,
private val parentEventEmitter: (MediaSendFlowEvent) -> Unit,
private val selectedCaptureScreen: MediaSendRoute.Capture
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MediaCaptureViewModel(parentState, parentEventEmitter, selectedCaptureScreen) as T
}
}
}
@@ -32,7 +32,7 @@ import org.signal.mediasend.R
@Composable
fun TextStoryHorizontalBar(
background: Brush,
onEvent: (MediaCaptureScreenEvents) -> Unit,
onEvent: (TextStoryBarEvents) -> Unit,
modifier: Modifier = Modifier
) {
Row(
@@ -49,7 +49,7 @@ fun TextStoryHorizontalBar(
@Composable
fun TextStoryVerticalBar(
background: Brush,
onEvent: (MediaCaptureScreenEvents) -> Unit,
onEvent: (TextStoryBarEvents) -> Unit,
modifier: Modifier = Modifier
) {
Column(
@@ -66,12 +66,12 @@ fun TextStoryVerticalBar(
@Composable
private fun ColorButton(
background: Brush,
onEvent: (MediaCaptureScreenEvents) -> Unit
onEvent: (TextStoryBarEvents) -> Unit
) {
IconButtons.IconButton(
size = 48.dp,
onClick = {
onEvent(MediaCaptureScreenEvents.CycleTextStoryBackgroundColor)
onEvent(TextStoryBarEvents.CycleBackgroundColor)
}
) {
Box(
@@ -86,12 +86,12 @@ private fun ColorButton(
@Composable
private fun LinkButton(
onEvent: (MediaCaptureScreenEvents) -> Unit
onEvent: (TextStoryBarEvents) -> Unit
) {
IconButtons.IconButton(
size = 48.dp,
onClick = {
onEvent(MediaCaptureScreenEvents.AddLinkToTextStory)
onEvent(TextStoryBarEvents.AddLink)
}
) {
Icon(
@@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.screens.capture
/**
* The controls the text story editor offers alongside its canvas. The editor itself is still a fragment in the app
* module, which acts on these directly; only the bar that raises them lives here.
*/
sealed interface TextStoryBarEvents {
data object CycleBackgroundColor : TextStoryBarEvents
data object AddLink : TextStoryBarEvents
}
@@ -19,6 +19,13 @@ object TestTags {
const val MEDIA_EDITOR_TOOLBAR_ADD_MEDIA_BUTTON = "media_editor_toolbar_add_media_button"
const val MEDIA_EDITOR_TOOLBAR_MUTE_BUTTON = "media_editor_toolbar_mute_button"
// Media Capture Screen
const val MEDIA_CAPTURE_SCREEN = "media_capture_screen"
const val MEDIA_CAPTURE_CAMERA_TOGGLE = "media_capture_camera_toggle"
const val MEDIA_CAPTURE_TEXT_STORY_TOGGLE = "media_capture_text_story_toggle"
const val MEDIA_CAPTURE_MEDIA_COUNT = "media_capture_media_count"
const val MEDIA_CAPTURE_NEXT_BUTTON = "media_capture_next_button"
// Media Select Screen
const val MEDIA_SELECT_GRID = "media_select_grid"
@@ -0,0 +1,168 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.screens.capture
import android.app.Application
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.core.net.toUri
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.models.media.Media
import org.signal.core.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.mediasend.MediaSendDependenciesRule
import org.signal.mediasend.MediaSendFlowActivityContract
import org.signal.mediasend.MediaSendRoute
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.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class, qualifiers = "w400dp-h800dp")
class MediaCaptureScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
@get:Rule
val mediaSendDependenciesRule = MediaSendDependenciesRule(ApplicationProvider.getApplicationContext())
private val events = mutableListOf<MediaCaptureScreenEvents>()
@Test
fun `Given a camera-first flow with nothing captured, when displayed, then the toggle is offered`() {
setContent(cameraFirstState())
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).assertIsDisplayed()
}
@Test
fun `Given a flow headed straight to a chat, when displayed, then no bar is offered`() {
setContent(cameraFirstState().copy(mode = MediaSendFlowActivityContract.Mode.SingleRecipient, isStory = false))
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_SCREEN).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertDoesNotExist()
}
@Test
fun `Given a flow that is not camera-first, when displayed, then no bar is offered`() {
setContent(cameraFirstState().copy(isCameraFirst = false))
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertDoesNotExist()
}
@Test
fun `when the camera is picked from the toggle, then it is asked for`() {
setContent(cameraFirstState())
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).performClick()
assertThat(events).containsExactly(MediaCaptureScreenEvents.ShowCamera)
}
@Test
fun `when the text story is picked from the toggle, then it is asked for`() {
setContent(cameraFirstState())
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).performClick()
assertThat(events).containsExactly(MediaCaptureScreenEvents.ShowTextStory)
}
@Test
fun `Given something has been captured, when displayed, then the media bar replaces the toggle`() {
setContent(cameraFirstState().copy(selectedMedia = listOf(MEDIA)))
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MEDIA_COUNT).assertTextEquals("1 item")
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_CAMERA_TOGGLE).assertDoesNotExist()
}
@Test
fun `Given something has been captured, when next is clicked, then the flow is asked to move on`() {
setContent(cameraFirstState().copy(selectedMedia = listOf(MEDIA)))
composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_NEXT_BUTTON).performClick()
assertThat(events).containsExactly(MediaCaptureScreenEvents.NextClicked)
}
@Test
fun `Given the text story route, when displayed, then the editor is what fills the screen`() {
setContent(cameraFirstState())
composeTestRule.onNodeWithTag(TEXT_STORY_SLOT).assertIsDisplayed()
assertThat(events).isEmpty()
}
private fun cameraFirstState() = MediaCaptureState(
selectedCaptureScreen = MediaSendRoute.Capture.TextStory,
isCameraFirst = true,
storiesEnabled = true,
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection
)
private fun setContent(state: MediaCaptureState) {
composeTestRule.setContent {
SignalTheme {
MediaCaptureScreen(
state = state,
onEvent = { events += it },
textStoryEditorSlot = {
Box(
modifier = Modifier
.fillMaxSize()
.testTag(TEXT_STORY_SLOT)
)
}
)
}
}
composeTestRule.waitForIdle()
}
private companion object {
private const val TEXT_STORY_SLOT = "text_story_slot"
private val MEDIA = Media(
uri = "content://capture".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,262 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.screens.capture
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.mockk
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.core.util.SeekableFileDescriptor
import org.signal.mediasend.MediaSendDependenciesRule
import org.signal.mediasend.MediaSendFlowActivityContract
import org.signal.mediasend.MediaSendFlowEvent
import org.signal.mediasend.MediaSendFlowState
import org.signal.mediasend.MediaSendRoute
import org.signal.mediasend.R
import org.signal.mediasend.SnackbarEvent
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Covers the two halves of the capture screen's wiring: the parts of the flow's state it mirrors, and what it asks the
* flow to do with the media it captures.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class MediaCaptureViewModelTest {
@get:Rule
val mediaSendDependenciesRule = MediaSendDependenciesRule(ApplicationProvider.getApplicationContext())
private val testDispatcher = StandardTestDispatcher()
private val repository: MediaCaptureRepository = mockk(relaxed = true)
private val parentEvents = mutableListOf<MediaSendFlowEvent>()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@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())
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))
assertThat(viewModel.state.value.canDisplayBottomBar).isFalse()
}
@Test
fun `Given a story flow, when created, then recording is capped at the story limit`() = runTest {
val viewModel = createViewModel(MediaSendFlowState(isStory = true, storyMaxVideoDuration = 30.seconds))
assertThat(viewModel.state.value.maxVideoDurationSecondsOverride).isEqualTo(30)
}
@Test
fun `Given a chat flow, when created, then recording keeps the device's own cap`() = runTest {
val viewModel = createViewModel(MediaSendFlowState(isStory = false, storyMaxVideoDuration = 30.seconds))
assertThat(viewModel.state.value.maxVideoDurationSecondsOverride).isEqualTo(0)
}
@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))
advanceUntilIdle()
assertThat(viewModel.state.value.selectedMedia).containsExactly(MEDIA)
}
@Test
fun `when navigation moves to the text story editor, then the screen follows`() = runTest {
val viewModel = createViewModel()
viewModel.onEvent(MediaCaptureScreenEvents.SelectedCaptureScreenChanged(MediaSendRoute.Capture.TextStory))
advanceUntilIdle()
assertThat(viewModel.state.value.selectedCaptureScreen).isEqualTo(MediaSendRoute.Capture.TextStory)
}
@Test
fun `when the camera is asked for, then the flow is sent to it`() = runTest {
onEvent(MediaCaptureScreenEvents.ShowCamera)
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.NavigateToCamera)
}
@Test
fun `when the text story editor is asked for, then the flow is sent to it`() = runTest {
onEvent(MediaCaptureScreenEvents.ShowTextStory)
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.NavigateToTextStory)
}
@Test
fun `when next is clicked, then the flow moves on to the editor`() = runTest {
onEvent(MediaCaptureScreenEvents.NextClicked)
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.NavigateToEdit)
}
@Test
fun `when the gallery is opened from the camera, then the flow is sent to it`() = runTest {
onCameraEvent(CameraXScreenEvents.GalleryClicked)
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))
}
@Test
fun `when an image is captured, then the media it was written to is handed to the flow`() = runTest {
coEvery { repository.writeCapturedImage(any(), any(), any()) } returns MEDIA
onCameraEvent(CameraXScreenEvents.ImageCaptured(data = byteArrayOf(1, 2, 3), width = 100, height = 200))
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.MediaCaptured(MEDIA))
}
@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
onCameraEvent(CameraXScreenEvents.ImageCaptured(data = byteArrayOf(1, 2, 3), width = 100, height = 200))
assertThat(parentEvents).containsExactly(snackbar(R.string.MediaSendViewModel__error_taking_photo))
}
@Test
fun `when a recording is captured, then it is handed over with how long it ran`() = runTest {
coEvery { repository.writeCapturedVideo(any()) } returns MEDIA
onCameraEvent(CameraXScreenEvents.VideoCaptured(fd = mockk(relaxed = true), durationMs = 4_000))
assertThat(parentEvents).containsExactly(MediaSendFlowEvent.MediaCaptured(MEDIA, 4_000.milliseconds))
}
@Test
fun `when a recording cannot be written out, then the failure is reported and nothing is handed over`() = runTest {
coEvery { repository.writeCapturedVideo(any<SeekableFileDescriptor>()) } returns null
onCameraEvent(CameraXScreenEvents.VideoCaptured(fd = mockk(relaxed = true), durationMs = 4_000))
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()
}
/** Raises [event] on a freshly created screen and lets it settle. */
private fun TestScope.onEvent(event: MediaCaptureScreenEvents) {
createViewModel().onEvent(event)
advanceUntilIdle()
}
private fun TestScope.onCameraEvent(event: CameraXScreenEvents) {
onEvent(MediaCaptureScreenEvents.Camera(event))
}
private fun snackbar(@StringRes message: Int) = MediaSendFlowEvent.ShowSnackbar(SnackbarEvent(message = message))
private fun cameraFirstStoryCapableState() = MediaSendFlowState(
isCameraFirst = true,
storiesEnabled = true,
mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection
)
private fun createViewModel(parentState: MediaSendFlowState = MediaSendFlowState()) = createViewModel(MutableStateFlow(parentState))
private fun createViewModel(parentState: MutableStateFlow<MediaSendFlowState>): MediaCaptureViewModel {
return MediaCaptureViewModel(
parentState = parentState,
parentEventEmitter = { parentEvents += it },
selectedCaptureScreen = MediaSendRoute.Capture.Camera,
repository = repository
)
}
private companion object {
private val MEDIA = Media(
uri = "content://capture".toUri(),
contentType = "image/jpeg",
date = 0,
width = 100,
height = 200,
size = 3,
duration = 0,
isBorderless = false,
isVideoGif = false,
bucketId = Media.ALL_MEDIA_BUCKET_ID,
caption = null,
transformProperties = null,
fileName = null
)
}
}
@@ -34,7 +34,7 @@ class TextStoryBarTest {
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val events = mutableListOf<MediaCaptureScreenEvents>()
private val events = mutableListOf<TextStoryBarEvents>()
@Test
fun `Given the text story bar, when the labelled add link button is tapped, then a link is requested`() {
@@ -49,6 +49,6 @@ class TextStoryBarTest {
composeTestRule.onNodeWithContentDescription("Add link").performClick()
assertEquals(MediaCaptureScreenEvents.AddLinkToTextStory, events.single())
assertEquals(TextStoryBarEvents.AddLink, events.single())
}
}