diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a106d2e62c..fc640a2c62 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -482,7 +482,7 @@ android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" /> = PublishSubject.create() - private lateinit var mediaActivityLauncher: ActivityResultLauncher + private lateinit var mediaSendLauncher: ActivityResultLauncher 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt new file mode 100644 index 0000000000..d65e9d613f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt @@ -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() + ) + } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Extensions.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Extensions.kt new file mode 100644 index 0000000000..00cef5a6f0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Extensions.kt @@ -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(MediaSendV3Activity::class.java) + +fun Fragment.mediaSendLauncher(callback: (MediaSendActivityContract.Result?) -> Unit = {}): ActivityResultLauncher = registerForActivityResult(contract(), callback) + +fun AppCompatActivity.mediaSendLauncher(callback: (MediaSendActivityContract.Result?) -> Unit = {}): ActivityResultLauncher = 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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt new file mode 100644 index 0000000000..598fd0d290 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt @@ -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 = 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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index 4f2b7fb278..3daf510712 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt index a898c26824..b33e0ad4d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt @@ -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, destinations: List): Factory { + return SheetFactory(SafetyNumberBottomSheetArgs(recipientIds, destinations)) + } + /** * Create a factory to generate a sheet for the given identity records and single destination. * diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt deleted file mode 100644 index 17625988b7..0000000000 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt +++ /dev/null @@ -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(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) - } - } - } -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt index 24616e9b8d..aa8b8b33ca 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt @@ -30,7 +30,7 @@ import org.signal.core.models.media.Media * class MyMediaSendContract : MediaSendActivityContract(MyMediaSendActivity::class.java) * ``` */ -class MediaSendActivityContract : ActivityResultContract() { +class MediaSendActivityContract(private val clazz: Class) : ActivityResultContract() { /** * Creates the intent to launch the media send activity. @@ -38,7 +38,9 @@ class MediaSendActivityContract : ActivityResultContract Edit -> Send + * Select -> Edit -> Send + */ +@Composable +fun MediaSendNavDisplay( + stateFlow: StateFlow, + backStack: NavBackStack, + 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) + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt index 58e45d67ba..ddf28fa512 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt @@ -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, - 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(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) - } -} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index 076ffb4367..183dfafde9 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -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, private val savedStateHandle: SavedStateHandle, private val repository: MediaSendRepository, - private val preUploadController: PreUploadController + private val preUploadController: PreUploadController, + isMeteredFlow: Flow ) : 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 + private val repository: MediaSendRepository = MediaSendDependencies.mediaSendRepository, + private val isMeteredFlow: Flow = MeteredConnectivity.isMetered(MediaSendDependencies.application) ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class, 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" - } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt b/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt index 2e3520adf5..15ac8082f8 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt @@ -16,6 +16,14 @@ internal fun NavBackStack.goToEdit() { } } +internal fun NavBackStack.goToSend() { + if (contains(MediaSendNavKey.Send)) { + popTo(MediaSendNavKey.Send) + } else { + add(MediaSendNavKey.Send) + } +} + internal fun NavBackStack.pop() { if (isNotEmpty()) { removeAt(size - 1) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt index 4058fe1564..438fd745d7 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt @@ -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 } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index 83bc99bea6..d12ed0938a 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -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 } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt index b235be2cb9..6bc9f29e82 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt @@ -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,