Add initial working E2E flow for MediaSendV3.

This commit is contained in:
Alex Hart
2026-04-08 16:10:09 -03:00
committed by Greyson Parrelli
parent 17def87c17
commit e2feaaf74c
17 changed files with 407 additions and 206 deletions

View File

@@ -1,77 +0,0 @@
package org.signal.mediasend
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.rememberNavBackStack
import org.signal.core.ui.compose.theme.SignalTheme
/**
* Activity for the media sending flow.
*/
abstract class MediaSendActivity : FragmentActivity() {
protected lateinit var contractArgs: MediaSendActivityContract.Args
private set
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
contractArgs = MediaSendActivityContract.Args.fromIntent(intent)
setContent {
val viewModel by viewModels<MediaSendViewModel>(factoryProducer = {
MediaSendViewModel.Factory(
args = contractArgs,
isMeteredFlow = MeteredConnectivity.isMetered(applicationContext)
)
})
val state by viewModel.state.collectAsStateWithLifecycle()
val backStack = rememberNavBackStack(
if (state.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select
)
SignalTheme {
Surface {
MediaSendNavDisplay(
state = state,
backStack = backStack,
callback = viewModel,
modifier = Modifier.fillMaxSize(),
cameraSlot = { },
textStoryEditorSlot = { },
videoEditorSlot = { },
sendSlot = { }
)
}
}
}
}
companion object {
/**
* Creates an intent for [MediaSendActivity].
*
* @param context The context.
* @param args The activity arguments.
*/
fun createIntent(
context: Context,
args: MediaSendActivityContract.Args = MediaSendActivityContract.Args()
): Intent {
return Intent(context, MediaSendActivity::class.java).apply {
putExtra(MediaSendActivityContract.EXTRA_ARGS, args)
}
}
}
}

View File

@@ -30,7 +30,7 @@ import org.signal.core.models.media.Media
* class MyMediaSendContract : MediaSendActivityContract(MyMediaSendActivity::class.java)
* ```
*/
class MediaSendActivityContract : ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?>() {
class MediaSendActivityContract(private val clazz: Class<out Activity>) : ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?>() {
/**
* Creates the intent to launch the media send activity.
@@ -38,7 +38,9 @@ class MediaSendActivityContract : ActivityResultContract<MediaSendActivityContra
* Subclasses should override this if not using the constructor parameter.
*/
override fun createIntent(context: Context, input: Args): Intent {
return MediaSendActivity.createIntent(context, input)
return Intent(context, clazz).apply {
putExtra(EXTRA_ARGS, input)
}
}
override fun parseResult(resultCode: Int, intent: Intent?): Result? {

View File

@@ -21,9 +21,6 @@ interface MediaSendCallback : MediaEditScreenCallback, MediaSelectScreenCallback
/** Called when the user edits video trim data. */
fun onVideoEdited(uri: Uri, isEdited: Boolean) {}
/** Called when message text changes. */
fun onMessageChanged(text: CharSequence?) {}
object Empty : MediaSendCallback, MediaEditScreenCallback by MediaEditScreenCallback.Empty, MediaSelectScreenCallback by MediaSelectScreenCallback.Empty {
override fun setFocusedMedia(media: Media) = Unit
}

View File

@@ -0,0 +1,108 @@
package org.signal.mediasend
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.mediasend.edit.MediaEditScreen
import org.signal.mediasend.select.MediaSelectScreen
/**
* Enforces the following flow of:
*
* Capture -> Edit -> Send
* Select -> Edit -> Send
*/
@Composable
fun MediaSendNavDisplay(
stateFlow: StateFlow<MediaSendState>,
backStack: NavBackStack<NavKey>,
callback: MediaSendCallback,
modifier: Modifier = Modifier,
cameraSlot: @Composable () -> Unit = {},
textStoryEditorSlot: @Composable () -> Unit = {},
videoEditorSlot: @Composable () -> Unit = {},
sendSlot: @Composable (MediaSendState) -> Unit = {}
) {
NavDisplay(
backStack = backStack,
modifier = modifier.fillMaxSize()
) { key ->
when (key) {
is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) {
MediaCaptureScreen(
backStack = backStack,
cameraSlot = cameraSlot,
textStoryEditorSlot = textStoryEditorSlot
)
}
MediaSendNavKey.Select -> NavEntry(key) {
val state by stateFlow.collectAsStateWithLifecycle()
MediaSelectScreen(
state = state,
backStack = backStack,
callback = callback
)
}
is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) {
val state by stateFlow.collectAsStateWithLifecycle()
MediaEditScreen(
state = state,
backStack = backStack,
videoEditorSlot = videoEditorSlot,
callback = callback
)
}
is MediaSendNavKey.Send -> NavEntry(key) {
val state by stateFlow.collectAsStateWithLifecycle()
sendSlot(state)
}
else -> error("Unknown key: $key")
}
}
}
@AllDevicePreviews
@Composable
private fun MediaSendNavDisplayPreview() {
Previews.Preview {
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides rememberNavigationEventDispatcherOwner(parent = null)) {
MediaSendNavDisplay(
stateFlow = MutableStateFlow(MediaSendState(isCameraFirst = true)),
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
callback = MediaSendCallback.Empty,
cameraSlot = { BoxWithText("Camera Slot") },
textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") },
videoEditorSlot = { BoxWithText("Video Editor Slot") },
sendSlot = { _ -> BoxWithText("Send Slot") }
)
}
}
}
@Composable
private fun BoxWithText(text: String, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = text)
}
}

View File

@@ -1,101 +1,53 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.activity.compose.LocalActivity
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import androidx.navigationevent.NavigationEventDispatcherOwner
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.mediasend.edit.MediaEditScreen
import org.signal.mediasend.select.MediaSelectScreen
import org.signal.core.ui.compose.theme.SignalTheme
/**
* Enforces the following flow of:
*
* Capture -> Edit -> Send
* Select -> Edit -> Send
*/
@Composable
fun MediaSendNavDisplay(
state: MediaSendState,
backStack: NavBackStack<NavKey>,
callback: MediaSendCallback,
fun MediaSendScreen(
contractArgs: MediaSendActivityContract.Args,
modifier: Modifier = Modifier,
cameraSlot: @Composable () -> Unit = {},
textStoryEditorSlot: @Composable () -> Unit = {},
videoEditorSlot: @Composable () -> Unit = {},
sendSlot: @Composable () -> Unit = {}
sendSlot: @Composable (MediaSendState) -> Unit = {}
) {
NavDisplay(
backStack = backStack,
modifier = modifier.fillMaxSize()
) { key ->
when (key) {
is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) {
MediaCaptureScreen(
val viewModel = viewModel<MediaSendViewModel>(factory = MediaSendViewModel.Factory(args = contractArgs))
val state by viewModel.state.collectAsStateWithLifecycle()
val backStack = rememberNavBackStack(
if (state.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select
)
SignalTheme {
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides LocalActivity.current as NavigationEventDispatcherOwner) {
Surface {
MediaSendNavDisplay(
stateFlow = viewModel.state,
backStack = backStack,
callback = viewModel,
modifier = modifier,
cameraSlot = cameraSlot,
textStoryEditorSlot = textStoryEditorSlot
)
}
MediaSendNavKey.Select -> NavEntry(key) {
MediaSelectScreen(
state = state,
backStack = backStack,
callback = callback
)
}
is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) {
MediaEditScreen(
state = state,
backStack = backStack,
textStoryEditorSlot = textStoryEditorSlot,
videoEditorSlot = videoEditorSlot,
callback = callback
sendSlot = sendSlot
)
}
is MediaSendNavKey.Send -> NavEntry(key) {
sendSlot()
}
else -> error("Unknown key: $key")
}
}
}
@AllDevicePreviews
@Composable
private fun MediaSendNavDisplayPreview() {
Previews.Preview {
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides rememberNavigationEventDispatcherOwner(parent = null)) {
MediaSendNavDisplay(
state = MediaSendState(isCameraFirst = true),
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
callback = MediaSendCallback.Empty,
cameraSlot = { BoxWithText("Camera Slot") },
textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") },
videoEditorSlot = { BoxWithText("Video Editor Slot") },
sendSlot = { BoxWithText("Send Slot") }
)
}
}
}
@Composable
private fun BoxWithText(text: String, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = text)
}
}

View File

@@ -48,14 +48,18 @@ import kotlin.time.Duration.Companion.milliseconds
* [MediaSendState] is fully [Parcelable] and saved directly as a single key.
*/
class MediaSendViewModel(
private val args: MediaSendActivityContract.Args,
private val identityChangesSince: Long,
isMeteredFlow: Flow<Boolean>,
private val savedStateHandle: SavedStateHandle,
private val repository: MediaSendRepository,
private val preUploadController: PreUploadController
private val preUploadController: PreUploadController,
isMeteredFlow: Flow<Boolean>
) : ViewModel(), MediaSendCallback {
private val args: MediaSendActivityContract.Args = savedStateHandle[KEY_ARGS]
?: throw IllegalStateException("MediaSendViewModel requires args in SavedStateHandle. Use Factory to create.")
private val identityChangesSince: Long = savedStateHandle[KEY_IDENTITY_CHANGES_SINCE]
?: throw IllegalStateException("MediaSendViewModel requires identityChangesSince in SavedStateHandle. Use Factory to create.")
private val defaultState = MediaSendState(
isCameraFirst = args.isCameraFirst,
recipientId = args.recipientId,
@@ -138,7 +142,7 @@ class MediaSendViewModel(
it.copy(
mediaFolders = folders,
selectedMediaFolder = if (it.selectedMediaFolder in folders) it.selectedMediaFolder else null,
selectedMedia = if (it.selectedMediaFolder in folders) it.selectedMediaFolderItems else emptyList()
selectedMediaFolderItems = if (it.selectedMediaFolder in folders) it.selectedMediaFolderItems else emptyList()
)
}
}
@@ -568,8 +572,8 @@ class MediaSendViewModel(
updateState { copy(message = text) }
}
override fun onMessageChanged(text: CharSequence?) {
setMessage(text?.toString())
override fun onMessageChange(message: String) {
setMessage(message)
}
//endregion
@@ -711,33 +715,43 @@ class MediaSendViewModel(
//endregion
//region Factory
companion object {
private const val KEY_ARGS = "media_send_vm_args"
private const val KEY_IDENTITY_CHANGES_SINCE = "media_send_vm_identity_changes_since"
private const val KEY_STATE = "media_send_vm_state"
private const val KEY_EDITED_VIDEO_URIS = "media_send_vm_edited_video_uris"
}
/**
* Factory that creates [MediaSendViewModel] from a [SavedStateHandle] and static dependencies.
*
* On first creation, [args] and [identityChangesSince] are written into the [SavedStateHandle].
* On process death restoration, the [SavedStateHandle] already contains the persisted values
* and the constructor parameters are ignored.
*/
class Factory(
private val args: MediaSendActivityContract.Args,
private val identityChangesSince: Long = System.currentTimeMillis(),
private val isMeteredFlow: Flow<Boolean>
private val repository: MediaSendRepository = MediaSendDependencies.mediaSendRepository,
private val isMeteredFlow: Flow<Boolean> = MeteredConnectivity.isMetered(MediaSendDependencies.application)
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val savedStateHandle = extras.createSavedStateHandle()
if (!savedStateHandle.contains(KEY_ARGS)) {
savedStateHandle[KEY_ARGS] = args
}
if (!savedStateHandle.contains(KEY_IDENTITY_CHANGES_SINCE)) {
savedStateHandle[KEY_IDENTITY_CHANGES_SINCE] = identityChangesSince
}
return MediaSendViewModel(
args = args,
identityChangesSince = identityChangesSince,
isMeteredFlow = isMeteredFlow,
savedStateHandle = savedStateHandle,
repository = MediaSendDependencies.mediaSendRepository,
preUploadController = PreUploadController()
repository = repository,
preUploadController = PreUploadController(),
isMeteredFlow = isMeteredFlow
) as T
}
}
//endregion
companion object {
private const val KEY_STATE = "media_send_vm_state"
private const val KEY_EDITED_VIDEO_URIS = "media_send_vm_edited_video_uris"
}
}

View File

@@ -16,6 +16,14 @@ internal fun NavBackStack<NavKey>.goToEdit() {
}
}
internal fun NavBackStack<NavKey>.goToSend() {
if (contains(MediaSendNavKey.Send)) {
popTo(MediaSendNavKey.Send)
} else {
add(MediaSendNavKey.Send)
}
}
internal fun NavBackStack<NavKey>.pop() {
if (isNotEmpty()) {
removeAt(size - 1)

View File

@@ -29,10 +29,12 @@ import org.signal.core.ui.compose.SignalIcons
import org.signal.core.util.isNotNullOrBlank
@Composable
internal fun AddAMessageRow(
fun AddAMessageRow(
message: String?,
callback: AddAMessageRowCallback,
modifier: Modifier = Modifier
onNextClick: () -> Unit,
modifier: Modifier = Modifier,
onEmojiKeyboardClick: () -> Unit = {}
) {
Row(
horizontalArrangement = Arrangement.Center,
@@ -47,7 +49,7 @@ internal fun AddAMessageRow(
.heightIn(min = 40.dp)
) {
IconButtons.IconButton(
onClick = callback::onEmojiKeyboardClick
onClick = onEmojiKeyboardClick
) {
Icon(
painter = SignalIcons.Emoji.painter,
@@ -74,7 +76,7 @@ internal fun AddAMessageRow(
}
IconButtons.IconButton(
onClick = callback::onNextClick,
onClick = onNextClick,
modifier = Modifier
.padding(start = 12.dp)
.background(
@@ -99,19 +101,16 @@ private fun AddAMessageRowPreview() {
Previews.Preview {
AddAMessageRow(
message = null,
callback = AddAMessageRowCallback.Empty
callback = AddAMessageRowCallback.Empty,
onNextClick = {}
)
}
}
internal interface AddAMessageRowCallback {
interface AddAMessageRowCallback {
fun onMessageChange(message: String)
fun onEmojiKeyboardClick()
fun onNextClick()
object Empty : AddAMessageRowCallback {
override fun onMessageChange(message: String) = Unit
override fun onEmojiKeyboardClick() = Unit
override fun onNextClick() = Unit
}
}

View File

@@ -37,6 +37,7 @@ import org.signal.core.util.ContentTypeUtil
import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaSendNavKey
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.goToSend
@Composable
fun MediaEditScreen(
@@ -111,7 +112,8 @@ fun MediaEditScreen(
AddAMessageRow(
message = state.message,
callback = AddAMessageRowCallback.Empty,
callback = callback,
onNextClick = { backStack.goToSend() },
modifier = Modifier
.widthIn(max = 624.dp)
.padding(horizontal = 16.dp)
@@ -145,10 +147,10 @@ private fun MediaEditScreenPreview() {
}
}
interface MediaEditScreenCallback {
interface MediaEditScreenCallback : AddAMessageRowCallback {
fun setFocusedMedia(media: Media)
object Empty : MediaEditScreenCallback {
object Empty : MediaEditScreenCallback, AddAMessageRowCallback by AddAMessageRowCallback.Empty {
override fun setFocusedMedia(media: Media) = Unit
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -99,7 +100,9 @@ internal fun MediaSelectScreen(
}
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues)
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
LazyVerticalGrid(
columns = gridConfiguration.gridCells,