From bae9a0fca17e2823fce0a2b56fad8255a5af3740 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 12 Aug 2026 10:42:34 -0300 Subject: [PATCH] Migrate media-send capture to proper shape. --- .../mediasend/v2/MediaSelectionActivity.kt | 6 +- .../v2/text/TextStoryPostCreationFragment.kt | 9 +- .../signal/mediasend/MediaSendEventHandler.kt | 2 - .../signal/mediasend/MediaSendFlowEvent.kt | 16 ++ .../mediasend/MediaSendFlowViewModel.kt | 113 +------- .../signal/mediasend/MediaSendNavigation.kt | 20 +- .../capture/MediaCameraCaptureScreen.kt | 11 +- .../screens/capture/MediaCaptureRepository.kt | 83 ++++++ .../screens/capture/MediaCaptureScreen.kt | 73 +++-- .../capture/MediaCaptureScreenEvents.kt | 18 +- .../screens/capture/MediaCaptureState.kt | 50 ++++ .../screens/capture/MediaCaptureViewModel.kt | 130 +++++++++ .../mediasend/screens/capture/TextStoryBar.kt | 12 +- .../screens/capture/TextStoryBarEvents.kt | 15 + .../org/signal/mediasend/test/TestTags.kt | 7 + .../screens/capture/MediaCaptureScreenTest.kt | 168 +++++++++++ .../capture/MediaCaptureViewModelTest.kt | 262 ++++++++++++++++++ .../screens/capture/TextStoryBarTest.kt | 4 +- 18 files changed, 830 insertions(+), 169 deletions(-) create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureRepository.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureState.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureViewModel.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBarEvents.kt create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt index ece0f26efc..541a64fede 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt index 9157d5236e..8f9eed4584 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt @@ -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) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEventHandler.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEventHandler.kt index 5512ed7e85..50eaf694aa 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEventHandler.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEventHandler.kt @@ -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) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt index ed5d3164f0..ae7630ace2 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowEvent.kt @@ -7,6 +7,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 } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt index ec71dfaccc..264a526e62 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendFlowViewModel.kt @@ -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 { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt index d8a106b07d..116afa3c3a 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt @@ -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 ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt index ad81368455..9288af8a99 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCameraCaptureScreen.kt @@ -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 = {} ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureRepository.kt new file mode 100644 index 0000000000..580e4ec033 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureRepository.kt @@ -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 + ) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt index 03121da5c2..37168cfce9 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt @@ -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() { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvents.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvents.kt index ce4b47bdfb..4918cf63af 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvents.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreenEvents.kt @@ -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 } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureState.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureState.kt new file mode 100644 index 0000000000..163f14a7b6 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureState.kt @@ -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 = 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 +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureViewModel.kt new file mode 100644 index 0000000000..52d724238a --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureViewModel.kt @@ -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, + private val parentEventEmitter: (MediaSendFlowEvent) -> Unit, + selectedCaptureScreen: MediaSendRoute.Capture, + private val repository: MediaCaptureRepository = MediaCaptureRepository() +) : EventDrivenViewModel(TAG) { + + companion object { + private val TAG = Log.tag(MediaCaptureViewModel::class) + } + + private val _state: MutableStateFlow = MutableStateFlow( + with(parentState.value) { + MediaCaptureState( + selectedCaptureScreen = selectedCaptureScreen, + selectedMedia = selectedMedia, + isCameraFirst = isCameraFirst, + isStory = isStory, + storiesEnabled = storiesEnabled, + mode = mode, + mediaConstraints = mediaConstraints, + storyMaxVideoDuration = storyMaxVideoDuration + ) + } + ) + + val state: StateFlow = _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, + private val parentEventEmitter: (MediaSendFlowEvent) -> Unit, + private val selectedCaptureScreen: MediaSendRoute.Capture + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return MediaCaptureViewModel(parentState, parentEventEmitter, selectedCaptureScreen) as T + } + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt index 3689830497..fa13233e9e 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBar.kt @@ -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( diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBarEvents.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBarEvents.kt new file mode 100644 index 0000000000..8e349fbb36 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/TextStoryBarEvents.kt @@ -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 +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt index 042667a3ef..6b6c1b16d4 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt @@ -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" diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt new file mode 100644 index 0000000000..4e87386756 --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt @@ -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() + + @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 + ) + } +} diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt new file mode 100644 index 0000000000..cee7b8abaf --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureViewModelTest.kt @@ -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() + + @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()) } 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): 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 + ) + } +} diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/TextStoryBarTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/TextStoryBarTest.kt index 8c2e7241bb..c60f4a4acb 100644 --- a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/TextStoryBarTest.kt +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/TextStoryBarTest.kt @@ -34,7 +34,7 @@ class TextStoryBarTest { @get:Rule val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) - private val events = mutableListOf() + private val events = mutableListOf() @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()) } }