Add initial working E2E flow for MediaSendV3.

This commit is contained in:
Alex Hart
2026-04-08 16:39:21 -04:00
committed by Greyson Parrelli
parent 17def87c17
commit e2feaaf74c
17 changed files with 407 additions and 206 deletions
+1 -1
View File
@@ -482,7 +482,7 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
android:name="org.signal.mediasend.MediaSendActivity"
android:name=".mediasend.v3.MediaSendV3Activity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:exported="false"
android:launchMode="singleTop"
@@ -163,6 +163,7 @@ import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -271,7 +272,7 @@ class MainActivity :
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
private lateinit var mediaSendLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
@@ -298,7 +299,7 @@ class MainActivity :
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
mediaActivityLauncher = registerForActivityResult(MediaSendActivityContract()) { }
mediaSendLauncher = mediaSendLauncher()
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
@@ -1124,7 +1125,7 @@ class MainActivity :
if (isForQuickRestore) {
startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
} else if (SignalStore.internal.useNewMediaActivity) {
mediaActivityLauncher.launch(
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isStory = destination == MainNavigationListLocation.STORIES
@@ -0,0 +1,40 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.fragment.compose.AndroidFragment
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.MediaSendScreen
import org.thoughtcrime.securesms.PassphraseRequiredActivity
/**
* Encapsulates the media send flow for v3.
*/
class MediaSendV3Activity : PassphraseRequiredActivity() {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
enableEdgeToEdge()
val contractArgs = MediaSendActivityContract.Args.fromIntent(intent)
setContent {
MediaSendScreen(
contractArgs = contractArgs,
sendSlot = {
AndroidFragment(
clazz = MediaSendV3ForwardFragment::class.java,
modifier = Modifier.fillMaxSize()
)
}
)
}
}
}
@@ -0,0 +1,29 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.StorySendRequirements
import org.thoughtcrime.securesms.stories.Stories
private fun contract(): ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?> = MediaSendActivityContract(MediaSendV3Activity::class.java)
fun Fragment.mediaSendLauncher(callback: (MediaSendActivityContract.Result?) -> Unit = {}): ActivityResultLauncher<MediaSendActivityContract.Args> = registerForActivityResult(contract(), callback)
fun AppCompatActivity.mediaSendLauncher(callback: (MediaSendActivityContract.Result?) -> Unit = {}): ActivityResultLauncher<MediaSendActivityContract.Args> = registerForActivityResult(contract(), callback)
/**
* Maps the feature-module [StorySendRequirements] to the app-layer [Stories.MediaTransform.SendRequirements].
*/
fun StorySendRequirements.toAppSendRequirements(): Stories.MediaTransform.SendRequirements = when (this) {
StorySendRequirements.CAN_SEND -> Stories.MediaTransform.SendRequirements.VALID_DURATION
StorySendRequirements.CAN_NOT_SEND -> Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
StorySendRequirements.REQUIRES_CROP -> Stories.MediaTransform.SendRequirements.REQUIRES_CLIP
}
@@ -0,0 +1,115 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.signal.core.util.getParcelableArrayListCompat
import org.signal.core.util.logging.Log
import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.MediaSendViewModel
import org.signal.mediasend.SendResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.stories.Stories
import org.signal.core.ui.R as CoreUiR
/**
* View-backed wrapper around [MultiselectForwardFragment] that provides the [ViewGroup] container
* required by [MultiselectForwardFragment.Callback.getContainer] for bottom bar inflation.
*
* Implements the callback interface and uses the shared [MediaSendViewModel] to drive
* the send flow forward.
*/
class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback {
companion object {
private val TAG = Log.tag(MediaSendV3ForwardFragment::class.java)
}
private val viewModel: MediaSendViewModel by activityViewModels {
MediaSendViewModel.Factory(args = MediaSendActivityContract.Args.fromIntent(requireActivity().intent))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (savedInstanceState == null) {
val state = viewModel.state.value
val forwardFragment = MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
title = R.string.MediaReviewFragment__send_to,
storySendRequirements = state.storySendRequirements.toAppSendRequirements(),
isSearchEnabled = !state.isStory,
isViewOnce = state.viewOnceToggleState == MediaSendState.ViewOnceToggleState.ONCE
)
)
childFragmentManager.beginTransaction()
.replace(R.id.fragment_container, forwardFragment)
.commitNow()
}
}
override fun onFinishForwardAction() = Unit
override fun exitFlow() {
requireActivity().finish()
}
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
val selectedRecipients: List<ContactSearchKey.RecipientSearchKey> = bundle.getParcelableArrayListCompat(MultiselectForwardFragment.RESULT_SELECTION, ContactSearchKey.RecipientSearchKey::class.java)
?: emptyList()
val recipientIds = selectedRecipients.map { MediaRecipientId(it.recipientId.toLong()) }
viewModel.setAdditionalRecipients(recipientIds)
viewLifecycleOwner.lifecycleScope.launch {
when (val result = viewModel.send()) {
is SendResult.Success -> {
Log.d(TAG, "Send completed successfully.")
requireActivity().finish()
}
is SendResult.Error -> {
Log.w(TAG, "Send failed: ${result.message}")
requireActivity().finish()
}
is SendResult.UntrustedIdentity -> {
Log.w(TAG, "Send failed due to untrusted identities.")
SafetyNumberBottomSheet
.forRecipientIdsAndDestinations(result.recipientIds.map { RecipientId.from(it) }, selectedRecipients)
.show(childFragmentManager)
}
}
}
}
override fun getContainer(): ViewGroup {
return requireView().findViewById(R.id.fragment_container_wrapper)
}
override fun getDialogBackgroundColor(): Int {
return ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorBackground)
}
override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? {
return viewModel.getStorySendRequirements().toAppSendRequirements()
}
}
@@ -93,9 +93,6 @@ object MediaSendV3Repository : MediaSendRepository {
return@withContext SendResult.Error("No recipients provided.")
}
val singleContact = if (recipients.size == 1) recipients.first() else null
val contacts = if (recipients.size > 1) recipients else emptyList()
val legacyEditorStateMap = mapLegacyEditorState(request.editorStateMap)
val quality = SentMediaQuality.fromCode(request.quality)
@@ -106,8 +103,8 @@ object MediaSendV3Repository : MediaSendRepository {
quality = quality,
message = request.message,
isViewOnce = request.isViewOnce,
singleContact = singleContact,
contacts = contacts,
singleContact = null,
contacts = recipients,
mentions = emptyList(),
bodyRanges = null,
sendType = resolveSendType(request.sendType),
@@ -122,6 +122,17 @@ object SafetyNumberBottomSheet {
return SheetFactory(args)
}
/**
* Create a factory to generate a sheet for the given recipient IDs and destinations.
*
* @param recipientIds The list of untrusted recipient IDs
* @param destinations The list of locations the user was trying to send content
*/
@JvmStatic
fun forRecipientIdsAndDestinations(recipientIds: List<RecipientId>, destinations: List<ContactSearchKey.RecipientSearchKey>): Factory {
return SheetFactory(SafetyNumberBottomSheetArgs(recipientIds, destinations))
}
/**
* Create a factory to generate a sheet for the given identity records and single destination.
*
@@ -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)
}
}
}
}
@@ -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? {
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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"
}
}
@@ -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)
@@ -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
}
}
@@ -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
}
}
@@ -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,