mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-14 01:03:28 +01:00
Migrate media-send capture to proper shape.
This commit is contained in:
@@ -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()
|
||||
|
||||
+4
-5
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+5
-6
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
+83
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
-39
@@ -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() {
|
||||
|
||||
+16
-2
@@ -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
|
||||
}
|
||||
|
||||
+50
@@ -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
|
||||
}
|
||||
+130
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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(
|
||||
|
||||
+15
@@ -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"
|
||||
|
||||
|
||||
+168
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+262
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user