Add initial working E2E flow for MediaSendV3.

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

View File

@@ -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()
)
}
)
}
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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),