mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-19 20:58:11 +01:00
Add initial working E2E flow for MediaSendV3.
This commit is contained in:
committed by
Greyson Parrelli
parent
17def87c17
commit
e2feaaf74c
@@ -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
|
||||
}
|
||||
+115
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user