Add text story support.

This commit is contained in:
Alex Hart
2026-07-28 14:10:55 -03:00
parent 46b82d92a8
commit 361b767a3f
19 changed files with 341 additions and 177 deletions
+1 -1
View File
@@ -481,7 +481,7 @@
android:screenOrientation="portrait"
android:exported="false"
android:launchMode="singleTop"
android:theme="@style/Signal.DayNight.NoActionBar"
android:theme="@style/TextSecure.DarkNoActionBar"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
@@ -11,15 +11,16 @@ import android.content.Intent
import android.os.Parcelable
import org.signal.core.models.media.Media
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.MediaSendRecipient
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3Activity
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Single entry point for launching the media send flow.
@@ -27,13 +28,10 @@ import org.thoughtcrime.securesms.recipients.RecipientId
* Callers describe the flow they want, and this decides whether to launch the v2 [MediaSelectionActivity] or the
* v3 [MediaSendV3Activity], translating the request into that implementation's arguments.
*
* Flows v3 cannot express yet -- quick restore, text stories, and shares with pre-selected recipients -- always
* launch v2.
* Every flow is expressible by both implementations, so the choice is purely [useV3].
*/
object MediaSendLauncher {
private val TAG = Log.tag(MediaSendLauncher::class)
private val useV3: Boolean
get() = SignalStore.internal.useNewMediaActivity
@@ -81,11 +79,18 @@ object MediaSendLauncher {
}
fun cameraForQuickRestore(context: Context): Intent {
if (useV3) {
Log.i(TAG, "Quick restore is unsupported by v3. Falling back to v2.")
return if (useV3) {
v3Intent(
context,
MediaSendActivityContract.Args(
isCameraFirst = true,
mode = MediaSendActivityContract.Mode.ChooseAfterMediaSelection,
isForQuickRestore = true
)
)
} else {
MediaSelectionActivity.cameraForQuickRestore(context)
}
return MediaSelectionActivity.cameraForQuickRestore(context)
}
fun addToGroupStory(context: Context, recipientId: RecipientId): Intent {
@@ -177,11 +182,21 @@ object MediaSendLauncher {
message: CharSequence?,
asTextStory: Boolean
): Intent {
if (useV3) {
Log.i(TAG, "Sharing with pre-selected recipients is unsupported by v3. Falling back to v2.")
return if (useV3) {
v3Intent(
context,
MediaSendActivityContract.Args(
mode = MediaSendActivityContract.Mode.MultiRecipient,
additionalRecipients = recipientSearchKeys.map { MediaSendRecipient(it.recipientId.toMediaRecipientId(), it.isStory) },
initialMedia = media,
initialMessage = message,
isStory = recipientSearchKeys.any { it.isStory },
asTextStory = asTextStory
)
)
} else {
MediaSelectionActivity.share(context, messageSendType, media, recipientSearchKeys, message, asTextStory)
}
return MediaSelectionActivity.share(context, messageSendType, media, recipientSearchKeys, message, asTextStory)
}
/**
@@ -200,7 +215,8 @@ object MediaSendLauncher {
}
private fun v3Intent(context: Context, args: MediaSendActivityContract.Args): Intent {
return Intent(context, MediaSendV3Activity::class.java).putExtra(MediaSendActivityContract.EXTRA_ARGS, args)
return Intent(context, MediaSendV3Activity::class.java)
.putExtra(MediaSendActivityContract.EXTRA_ARGS, args.copy(maxSelection = RemoteConfig.maxAttachmentCount))
}
private fun RecipientId.toMediaRecipientId(): MediaRecipientId = MediaRecipientId(toLong())
@@ -6,7 +6,6 @@ sealed class HudCommand {
object SaveMedia : HudCommand()
object GoToText : HudCommand()
object GoToCapture : HudCommand()
object GoToReview : HudCommand()
object ResumeEntryTransition : HudCommand()
@@ -26,7 +26,6 @@ import org.signal.core.models.media.Media
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.Debouncer
import org.signal.core.util.OVERRIDE_TRANSITION_CLOSE_COMPAT
import org.signal.core.util.concurrent.LifecycleDisposable
@@ -46,11 +45,9 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEvent
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEventViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationViewModel
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationFragment
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -58,6 +55,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class MediaSelectionActivity :
PassphraseRequiredActivity(),
MediaReviewFragment.Callback,
TextStoryPostCreationFragment.Callback,
EmojiKeyboardPageFragment.Callback,
EmojiEventListener,
EmojiSearchFragment.Callback {
@@ -70,17 +68,20 @@ class MediaSelectionActivity :
private val lifecycleDisposable = LifecycleDisposable()
private val textViewModel: TextStoryPostCreationViewModel by viewModels(
factoryProducer = {
TextStoryPostCreationViewModel.Factory(TextStoryPostSendRepository())
}
)
private val addMessageCommandViewModel: EmojiKeyboardEventViewModel by viewModels()
private val destination: MediaSelectionDestination
get() = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION)))
override val textStoryDestinations: Set<ContactSearchKey.RecipientSearchKey>
get() = (destination.getRecipientSearchKeyList() + destination.getRecipientSearchKey()).filterNotNull().toSet()
override val isAddToGroupStoryFlow: Boolean
get() = intent.getBooleanExtra(IS_ADD_TO_GROUP_STORY_FLOW, false)
override val textStoryDraftText: CharSequence?
get() = if (shareToTextStory) draftText else null
private val isStory: Boolean
get() = intent.getBooleanExtra(IS_STORY, false)
@@ -136,7 +137,7 @@ class MediaSelectionActivity :
selectedMedia = state?.selectedMedia ?: emptyList(),
onEvent = { event ->
when (event) {
MediaCaptureScreenEvent.ShowCamera -> debouncer.publish { viewModel.sendCommand(HudCommand.GoToCapture) }
MediaCaptureScreenEvent.ShowCamera -> debouncer.publish { popTextStoryPostCreationFragment() }
MediaCaptureScreenEvent.ShowTextStory -> viewModel.sendCommand(HudCommand.GoToText)
MediaCaptureScreenEvent.NextClicked -> viewModel.sendCommand(HudCommand.GoToReview)
is MediaCaptureScreenEvent.Camera -> Unit
@@ -151,10 +152,6 @@ class MediaSelectionActivity :
}
if (savedInstanceState == null) {
if (shareToTextStory) {
initializeTextStory()
}
val navHostFragment = NavHostFragment.create(R.navigation.media)
supportFragmentManager
@@ -165,7 +162,6 @@ class MediaSelectionActivity :
navigateToStartDestination()
} else {
viewModel.onRestoreState(this, savedInstanceState)
textViewModel.restoreFromInstanceState(savedInstanceState)
}
(supportFragmentManager.findFragmentByTag(NAV_HOST_TAG) as NavHostFragment).navController.addOnDestinationChangedListener { _, d, _ ->
@@ -239,22 +235,10 @@ class MediaSelectionActivity :
viewModel.clearMediaErrors()
}
private fun initializeTextStory() {
val message = draftText?.toString() ?: return
val firstLink = LinkPreviewUtil.findValidPreviewUrls(message).findFirst()
val firstLinkUrl = firstLink.map { it.url }.orElse(null)
val iterator = BreakIteratorCompat.getInstance()
iterator.setText(message)
val trimmedMessage = iterator.take(700).toString()
if (firstLinkUrl == message) {
textViewModel.setLinkPreview(firstLinkUrl)
} else if (firstLinkUrl != null) {
textViewModel.setLinkPreview(firstLinkUrl)
textViewModel.setBody(trimmedMessage.replace(firstLinkUrl, "").trim())
} else {
textViewModel.setBody(trimmedMessage.trim())
private fun popTextStoryPostCreationFragment() {
val navController = findNavController(R.id.fragment_container)
if (navController.currentDestination?.id == R.id.textStoryPostCreationFragment) {
navController.popBackStack()
}
}
@@ -275,7 +259,6 @@ class MediaSelectionActivity :
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
viewModel.onSaveState(outState)
textViewModel.saveToInstanceState(outState)
}
override fun onSentWithResult(mediaSendActivityResult: MediaSendActivityResult) {
@@ -17,13 +17,15 @@ import androidx.core.view.postDelayed
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.createSavedStateHandle
import androidx.navigation.fragment.findNavController
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.camera.CameraDisplay
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.horizontalGutters
@@ -42,14 +44,14 @@ import org.thoughtcrime.securesms.databinding.StoriesTextPostCreationFragmentBin
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewState
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment
import org.thoughtcrime.securesms.mediasend.v2.stories.StoriesMultiselectForwardActivity
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.visible
import java.util.Optional
@@ -58,20 +60,12 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
private var _binding: StoriesTextPostCreationFragmentBinding? = null
private val binding: StoriesTextPostCreationFragmentBinding get() = _binding!!
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = {
requireActivity()
}
)
private val callback: Callback
get() = requireListener()
private val viewModel: TextStoryPostCreationViewModel by viewModels(
ownerProducer = {
requireActivity()
},
factoryProducer = {
TextStoryPostCreationViewModel.Factory(TextStoryPostSendRepository())
}
)
private val viewModel: TextStoryPostCreationViewModel by activityViewModel { extras ->
TextStoryPostCreationViewModel.create(extras, callback.textStoryDraftText)
}
private val linkPreviewViewModel: LinkPreviewViewModelV2 by activityViewModel { extras ->
LinkPreviewViewModelV2(extras.createSavedStateHandle(), enablePlaceholder = true)
@@ -95,11 +89,6 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
binding.storyTextPost.enableCreationMode()
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += sharedViewModel.hudCommands.subscribe {
if (it == HudCommand.GoToCapture) {
findNavController().popBackStack()
}
}
lifecycleDisposable += viewModel.typeface.subscribeBy { typeface ->
binding.storyTextPost.setTypeface(typeface)
@@ -145,8 +134,7 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
if (it.isNotEmpty()) {
performSend(it.toSet())
} else {
binding.send.isClickable = true
binding.sendInProgressIndicator.visible = false
onSendAborted()
}
}
@@ -156,9 +144,7 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
binding.storyTextPost.disableCreationMode()
val contacts = (sharedViewModel.destination.getRecipientSearchKeyList() + sharedViewModel.destination.getRecipientSearchKey())
.filterIsInstance(ContactSearchKey::class.java)
.toSet()
val contacts: Set<ContactSearchKey.RecipientSearchKey> = callback.textStoryDestinations
if (contacts.isEmpty()) {
val bitmap = binding.storyTextPost.drawToBitmap()
@@ -174,14 +160,8 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
)
)
}
} else if (sharedViewModel.isAddToGroupStoryFlow) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(getString(R.string.MediaReviewFragment__add_to_the_group_story, sharedViewModel.state.value!!.recipient!!.getDisplayName(requireContext())))
.setPositiveButton(R.string.MediaReviewFragment__add_to_story) { _, _ -> performSend(contacts) }
.setNegativeButton(android.R.string.cancel) { _, _ ->
binding.sendInProgressIndicator.visible = false
}
.show()
} else if (callback.isAddToGroupStoryFlow) {
confirmAddToGroupStory(contacts)
} else {
performSend(contacts)
}
@@ -284,6 +264,32 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
}
}
private fun confirmAddToGroupStory(contacts: Set<ContactSearchKey.RecipientSearchKey>) {
val context = requireContext()
viewLifecycleOwner.lifecycleScope.launch {
val displayName = withContext(Dispatchers.Default) {
Recipient.resolved(contacts.first().recipientId).getDisplayName(context)
}
MaterialAlertDialogBuilder(context)
.setMessage(getString(R.string.MediaReviewFragment__add_to_the_group_story, displayName))
.setPositiveButton(R.string.MediaReviewFragment__add_to_story) { _, _ -> performSend(contacts) }
.setNegativeButton(android.R.string.cancel) { _, _ -> onSendAborted() }
.setOnCancelListener { onSendAborted() }
.show()
}
}
/**
* Re-enables the screen after the user backs out of sending, since nothing else will restore it.
*/
private fun onSendAborted() {
binding.send.isClickable = true
binding.sendInProgressIndicator.visible = false
binding.storyTextPost.enableCreationMode()
}
private fun performSend(contacts: Set<ContactSearchKey>) {
lifecycleDisposable += viewModel.send(
contacts = contacts,
@@ -292,7 +298,7 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
when (result) {
TextStoryPostSendResult.Success -> {
Toast.makeText(requireContext(), R.string.TextStoryPostCreationFragment__sent_story, Toast.LENGTH_SHORT).show()
requireActivity().finish()
callback.onSentWithoutResult()
}
TextStoryPostSendResult.Failure -> {
Toast.makeText(requireContext(), R.string.TextStoryPostCreationFragment__failed_to_send_story, Toast.LENGTH_SHORT).show()
@@ -331,4 +337,30 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati
}
override fun onCanceled() = Unit
/**
* Flow-level information the host is responsible for, so that this fragment can be dropped into
* any media send flow without knowing which one it is running in.
*/
interface Callback {
/**
* The recipients to send to, or empty if the user still needs to select them.
*/
val textStoryDestinations: Set<ContactSearchKey.RecipientSearchKey>
val isAddToGroupStoryFlow: Boolean
/**
* Text the flow was launched with, used to seed the post body and link preview.
*/
val textStoryDraftText: CharSequence?
/**
* The story was sent by this flow, so there is no payload to hand back to whoever launched it.
*
* Shares the name and semantics of [MediaReviewFragment.Callback.onSentWithoutResult] so a host
* supporting both flows can satisfy them with one implementation.
*/
fun onSentWithoutResult()
}
}
@@ -5,8 +5,11 @@ import android.graphics.Typeface
import android.net.Uri
import android.os.Bundle
import androidx.annotation.ColorInt
import androidx.core.os.bundleOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.CreationExtras
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
@@ -17,6 +20,7 @@ import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
@@ -25,13 +29,19 @@ import org.thoughtcrime.securesms.fonts.TextFont
import org.thoughtcrime.securesms.fonts.TextToScript
import org.thoughtcrime.securesms.fonts.TypefaceCache
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository
import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult
import org.thoughtcrime.securesms.util.rx.RxStore
class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRepository, private val identityChangesSince: Long = System.currentTimeMillis()) : ViewModel() {
class TextStoryPostCreationViewModel(
savedStateHandle: SavedStateHandle,
private val repository: TextStoryPostSendRepository,
draftText: CharSequence? = null,
private val identityChangesSince: Long = System.currentTimeMillis()
) : ViewModel() {
private val store = RxStore(TextStoryPostCreationState())
private val store = RxStore(restoreState(savedStateHandle) ?: buildInitialState(draftText))
private val textFontSubject: Subject<TextFont> = BehaviorSubject.create()
private val temporaryBodySubject: Subject<String> = BehaviorSubject.createDefault("")
private val disposables = CompositeDisposable()
@@ -42,6 +52,10 @@ class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRe
val typeface: Flowable<Typeface> = internalTypeface.observeOn(AndroidSchedulers.mainThread())
init {
savedStateHandle.setSavedStateProvider(TEXT_STORY_INSTANCE_STATE) {
bundleOf(TEXT_STORY_INSTANCE_STATE to store.state)
}
textFontSubject.onNext(store.state.textFont)
val scriptGuess = temporaryBodySubject.observeOn(Schedulers.io()).map { TextToScript.guessScript(it) }
@@ -64,18 +78,6 @@ class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRe
disposables.clear()
}
fun saveToInstanceState(outState: Bundle) {
outState.putParcelable(TEXT_STORY_INSTANCE_STATE, store.state)
}
fun restoreFromInstanceState(inState: Bundle) {
if (inState.containsKey(TEXT_STORY_INSTANCE_STATE)) {
val state: TextStoryPostCreationState = inState.getParcelableCompat(TEXT_STORY_INSTANCE_STATE, TextStoryPostCreationState::class.java)!!
textFontSubject.onNext(store.state.textFont)
store.update { state }
}
}
fun getBody(): CharSequence {
return store.state.body
}
@@ -135,14 +137,46 @@ class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRe
return store.state.linkPreviewUri
}
class Factory(private val repository: TextStoryPostSendRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(TextStoryPostCreationViewModel(repository)) as T
}
}
companion object {
private val TAG = Log.tag(TextStoryPostCreationViewModel::class.java)
private const val TEXT_STORY_INSTANCE_STATE = "text.story.instance.state"
private const val MAX_DRAFT_LENGTH = 700
fun create(extras: CreationExtras, draftText: CharSequence? = null): TextStoryPostCreationViewModel {
return TextStoryPostCreationViewModel(
savedStateHandle = extras.createSavedStateHandle(),
repository = TextStoryPostSendRepository(),
draftText = draftText
)
}
private fun restoreState(savedStateHandle: SavedStateHandle): TextStoryPostCreationState? {
return savedStateHandle
.get<Bundle>(TEXT_STORY_INSTANCE_STATE)
?.getParcelableCompat(TEXT_STORY_INSTANCE_STATE, TextStoryPostCreationState::class.java)
}
private fun buildInitialState(draftText: CharSequence?): TextStoryPostCreationState {
val message: String = draftText?.toString()?.takeIf { it.isNotBlank() } ?: return TextStoryPostCreationState()
val firstLinkUrl: String? = LinkPreviewUtil
.findValidPreviewUrls(message)
.findFirst()
.map { it.url }
.orElse(null)
val iterator = BreakIteratorCompat.getInstance()
iterator.setText(message)
val trimmedMessage = iterator.take(MAX_DRAFT_LENGTH).toString()
return when {
firstLinkUrl == null -> TextStoryPostCreationState(body = trimmedMessage.trim())
firstLinkUrl == message -> TextStoryPostCreationState(linkPreviewUri = firstLinkUrl)
else -> TextStoryPostCreationState(
body = trimmedMessage.replace(firstLinkUrl, "").trim(),
linkPreviewUri = firstLinkUrl
)
}
}
}
}
@@ -6,7 +6,6 @@ import android.view.View
import android.widget.EditText
import androidx.constraintlayout.widget.Group
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import com.google.android.material.snackbar.Snackbar
import org.thoughtcrime.securesms.R
@@ -17,6 +16,7 @@ import org.thoughtcrime.securesms.stories.StoryLinkPreviewView
import org.thoughtcrime.securesms.util.LinkUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
import org.thoughtcrime.securesms.util.visible
@@ -30,7 +30,9 @@ class TextStoryPostLinkEntryFragment(private val shouldPreset: Boolean = false)
factoryProducer = { LinkPreviewViewModel.Factory(LinkPreviewRepository(), true) }
)
private val viewModel: TextStoryPostCreationViewModel by activityViewModels()
private val viewModel: TextStoryPostCreationViewModel by activityViewModel { extras ->
TextStoryPostCreationViewModel.create(extras)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
input = view.findViewById(R.id.input)
@@ -22,7 +22,6 @@ import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.view.updateLayoutParams
import androidx.core.widget.doAfterTextChanged
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.viewModels
import androidx.transition.TransitionManager
import com.airbnb.lottie.SimpleColorFilter
import io.reactivex.rxjava3.kotlin.subscribeBy
@@ -37,6 +36,7 @@ import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setUpForColor
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.activityViewModel
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
import java.util.Locale
@@ -48,11 +48,9 @@ class TextStoryPostTextEntryFragment : KeyboardEntryDialogFragment(
contentLayoutId = R.layout.stories_text_post_text_entry_fragment
) {
private val viewModel: TextStoryPostCreationViewModel by viewModels(
ownerProducer = {
requireActivity()
}
)
private val viewModel: TextStoryPostCreationViewModel by activityViewModel { extras ->
TextStoryPostCreationViewModel.create(extras)
}
private val lifecycleDisposable = LifecycleDisposable()
@@ -5,11 +5,13 @@
package org.thoughtcrime.securesms.mediasend.v3
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
@@ -21,6 +23,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.mediasend.HudCommand
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.MediaSendRecipient
import org.signal.mediasend.MediaSendScreen
import org.signal.mediasend.MediaSendViewModel
import org.signal.mediasend.edit.LocalAddAMessageRowTextField
@@ -29,7 +32,9 @@ import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.QuickRestoreInfoDialog
import org.thoughtcrime.securesms.mediasend.v2.review.AddMessageDialogFragment
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationFragment
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceActivity
@@ -39,13 +44,31 @@ import org.thoughtcrime.securesms.util.CommunicationActions
/**
* Encapsulates the media send flow for v3.
*/
class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomSheet.Callbacks {
class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomSheet.Callbacks, TextStoryPostCreationFragment.Callback {
private val contractArgs: MediaSendActivityContract.Args by lazy { MediaSendActivityContract.Args.fromIntent(intent) }
private val viewModel: MediaSendViewModel by viewModels { MediaSendViewModel.Factory(args = contractArgs) }
override val textStoryDestinations: Set<ContactSearchKey.RecipientSearchKey>
get() = destinations().toSet()
override val isAddToGroupStoryFlow: Boolean
get() = contractArgs.isAddToGroupStoryFlow
override val textStoryDraftText: CharSequence?
get() = if (contractArgs.asTextStory) contractArgs.initialMessage else null
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
super.attachBaseContext(newBase)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
if (savedInstanceState == null && contractArgs.isForQuickRestore) {
QuickRestoreInfoDialog.show(supportFragmentManager)
}
supportFragmentManager.setFragmentResultListener(AddMessageDialogFragment.REQUEST_KEY, this) { _, bundle ->
if (bundle.getBoolean(AddMessageDialogFragment.RESULT_INCREMENT_VIEW_ONCE_STATE)) {
viewModel.setMessage(null)
@@ -69,6 +92,12 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
) {
MediaSendScreen(
contractArgs = contractArgs,
textStoryEditorSlot = {
AndroidFragment(
clazz = TextStoryPostCreationFragment::class.java,
modifier = Modifier.fillMaxSize()
)
},
sendSlot = {
AndroidFragment(
clazz = MediaSendV3ForwardFragment::class.java,
@@ -112,6 +141,8 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
is HudCommand.FinishWithResult -> finishWithResult(it.payload)
is HudCommand.FinishWithoutResult -> onSentWithoutResult()
is HudCommand.ResolveUntrustedIdentities -> {
SafetyNumberBottomSheet
.forRecipientIdsAndDestinations(it.untrustedRecipientIds.map(RecipientId::from), destinations())
@@ -129,6 +160,11 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
}
}
override fun onSentWithoutResult() {
setResult(RESULT_OK, Intent())
finish()
}
override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>) {
viewModel.performSend()
}
@@ -142,9 +178,15 @@ class MediaSendV3Activity : PassphraseRequiredActivity(), SafetyNumberBottomShee
finish()
}
/**
* Reads from state rather than the launch args so that recipients chosen inside the flow are included.
*/
private fun destinations(): List<ContactSearchKey.RecipientSearchKey> {
return contractArgs.recipientId
?.let { listOf(ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id), contractArgs.isStory)) }
?: emptyList()
val state = viewModel.state.value
val single = state.recipientId?.let { MediaSendRecipient(it, state.isStory) }
return (listOfNotNull(single) + state.additionalRecipients)
.distinct()
.map { ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id.id), it.isStory) }
}
}
@@ -11,21 +11,16 @@ 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.MediaSendRecipient
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
@@ -38,10 +33,6 @@ import org.signal.core.ui.R as CoreUiR
*/
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))
}
@@ -78,31 +69,9 @@ class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activit
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.ReadyToSend -> {
Log.w(TAG, "Unexpected hand-off to the caller from contact selection.")
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)
}
}
}
val recipients = selectedRecipients.map { MediaSendRecipient(MediaRecipientId(it.recipientId.toLong()), it.isStory) }
viewModel.setAdditionalRecipients(recipients)
viewModel.performSend()
}
override fun getContainer(): ViewGroup {
@@ -25,6 +25,7 @@ import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.MediaFilterError
import org.signal.mediasend.MediaFilterResult
import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendRecipient
import org.signal.mediasend.MediaSendRepository
import org.signal.mediasend.SendRequest
import org.signal.mediasend.SendResult
@@ -225,16 +226,16 @@ object MediaSendV3Repository : MediaSendRepository {
*/
private fun buildSingleContact(request: SendRequest): ContactSearchKey.RecipientSearchKey? {
return request.singleRecipientId
?.takeIf { request.recipientIds.isEmpty() }
?.takeIf { request.recipients.isEmpty() }
?.let { ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id), request.isStory) }
}
private fun buildRecipients(request: SendRequest): List<ContactSearchKey.RecipientSearchKey> {
return buildList {
request.singleRecipientId?.let { add(it) }
addAll(request.recipientIds)
}.distinctBy(MediaRecipientId::id).map {
ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id), request.isStory)
request.singleRecipientId?.let { add(MediaSendRecipient(it, request.isStory)) }
addAll(request.recipients)
}.distinct().map {
ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id.id), it.isStory)
}
}
@@ -37,6 +37,9 @@ class StoryLinkPreviewView @JvmOverloads constructor(
private val binding = StoriesTextPostLinkPreviewBinding.bind(this)
private val spinnerStub = Stub<View>(binding.loadingSpinner)
private var canClose: Boolean = false
private var hasContent: Boolean = false
init {
binding.linkPreviewImage.isClickable = false
binding.linkPreviewLarge.isClickable = false
@@ -70,6 +73,9 @@ class StoryLinkPreviewView @JvmOverloads constructor(
visibility = hiddenVisibility
}
hasContent = linkPreview != null
updateCloseVisibility()
return future ?: SettableFuture(false)
}
@@ -85,6 +91,8 @@ class StoryLinkPreviewView @JvmOverloads constructor(
spinnerStub.get().visible = linkPreviewState.isLoading
if (linkPreviewState.isLoading) {
visible = true
hasContent = true
updateCloseVisibility()
}
}
@@ -175,7 +183,12 @@ class StoryLinkPreviewView @JvmOverloads constructor(
}
fun setCanClose(canClose: Boolean) {
binding.linkPreviewClose.visible = canClose
this.canClose = canClose
updateCloseVisibility()
}
private fun updateCloseVisibility() {
binding.linkPreviewClose.visible = canClose && hasContent
}
private fun formatDate(date: Long): String? {
@@ -57,6 +57,10 @@ class MediaSendActivityContract(private val clazz: Class<out Activity>) : Activi
* Optional recipient identifier for single-recipient flows.
*/
val recipientId: MediaRecipientId? = null,
/**
* Recipients known up front for multi-recipient flows, in addition to [recipientId].
*/
val additionalRecipients: List<MediaSendRecipient> = emptyList(),
val mode: Mode = Mode.SingleRecipient,
/**
* Initial media to populate the selection.
@@ -80,6 +84,14 @@ class MediaSendActivityContract(private val clazz: Class<out Activity>) : Activi
* Whether this is specifically the "add to group story" flow.
*/
val isAddToGroupStoryFlow: Boolean = false,
/**
* Whether the flow should open directly into the text story editor, seeded with [initialMessage].
*/
val asTextStory: Boolean = false,
/**
* Whether the flow was entered to scan a quick restore QR code, which shows an explanatory sheet.
*/
val isForQuickRestore: Boolean = false,
/**
* Maximum number of media items that can be selected.
*/
@@ -47,6 +47,12 @@ sealed interface HudCommand {
*/
data class FinishWithResult(val payload: Parcelable) : HudCommand
/**
* The flow performed the send itself, so there is no payload. Callers still need to be told the send
* succeeded, as opposed to [CloseScreen] which is a cancellation.
*/
data object FinishWithoutResult : HudCommand
/** The send was blocked by safety number changes for [untrustedRecipientIds]. */
data class ResolveUntrustedIdentities(val untrustedRecipientIds: List<Long>) : HudCommand
}
@@ -0,0 +1,21 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* A recipient the flow will send to, along with whether the send targets that recipient's story.
*
* Story-ness is per-recipient rather than per-flow because a single send can mix targets, e.g. a group
* chat alongside that same group's story.
*/
@Parcelize
data class MediaSendRecipient(
val id: MediaRecipientId,
val isStory: Boolean = false
) : Parcelable
@@ -140,7 +140,7 @@ data class SendRequest(
val message: CharSequence?,
val isViewOnce: Boolean,
val singleRecipientId: MediaRecipientId?,
val recipientIds: List<MediaRecipientId>,
val recipients: List<MediaSendRecipient>,
val scheduledTime: Long,
val sendType: Int,
val isStory: Boolean,
@@ -105,9 +105,9 @@ data class MediaSendState(
*/
val isAddToGroupStoryFlow: Boolean = false,
/**
* Additional recipient IDs for multi-recipient sends.
* Additional recipients for multi-recipient sends.
*/
val additionalRecipientIds: List<MediaRecipientId> = emptyList(),
val additionalRecipients: List<MediaSendRecipient> = emptyList(),
/**
* Scheduled send time (-1 for immediate).
*/
@@ -89,12 +89,13 @@ class MediaSendViewModel(
private val defaultState = MediaSendState(
isCameraFirst = args.isCameraFirst,
recipientId = args.recipientId,
additionalRecipients = args.additionalRecipients,
mode = args.mode,
isStory = args.isStory,
isReply = args.isReply,
isAddToGroupStoryFlow = args.isAddToGroupStoryFlow,
maxSelection = args.maxSelection,
message = args.initialMessage,
message = if (args.asTextStory) null else args.initialMessage,
isContactSelectionRequired = args.mode == MediaSendActivityContract.Mode.ChooseAfterMediaSelection,
sendType = args.sendType
)
@@ -104,6 +105,7 @@ class MediaSendViewModel(
key = KEY_BACK_STACK
) {
val startKey = when {
args.asTextStory -> MediaSendNavKey.Capture.TextStory
args.isCameraFirst -> MediaSendNavKey.Capture.Camera
args.initialMedia.isNotEmpty() -> MediaSendNavKey.Edit
else -> MediaSendNavKey.Select.Folders
@@ -861,8 +863,11 @@ class MediaSendViewModel(
fun getStorySendRequirements(): StorySendRequirements = state.value.storySendRequirements
/**
* Computed for every flow, not just story flows: the contact picker consults this before allowing a story
* to be selected, so leaving it at its default would strip story selections made mid-flow.
*/
private suspend fun updateStorySendRequirements(media: List<Media>) {
if (!state.value.isStory) return
val requirements = repository.getStorySendRequirements(media)
updateState { copy(storySendRequirements = requirements) }
}
@@ -871,8 +876,8 @@ class MediaSendViewModel(
//region Recipients
fun setAdditionalRecipients(recipientIds: List<MediaRecipientId>) {
updateState { copy(additionalRecipientIds = recipientIds) }
fun setAdditionalRecipients(recipients: List<MediaSendRecipient>) {
updateState { copy(additionalRecipients = recipients) }
}
fun setScheduledTime(time: Long) {
@@ -939,7 +944,7 @@ class MediaSendViewModel(
viewModelScope.launch {
when (val result = send()) {
is SendResult.ReadyToSend -> sendHudCommand(HudCommand.FinishWithResult(result.payload))
is SendResult.Success -> sendHudCommand(HudCommand.CloseScreen)
is SendResult.Success -> sendHudCommand(HudCommand.FinishWithoutResult)
is SendResult.UntrustedIdentity -> {
updateState { copy(isSending = false) }
@@ -966,7 +971,7 @@ class MediaSendViewModel(
// Check for untrusted identities
val allRecipientIds = buildSet {
snapshot.recipientId?.let { add(it.id) }
addAll(snapshot.additionalRecipientIds.map { it.id })
addAll(snapshot.additionalRecipients.map { it.id.id })
}
if (allRecipientIds.isNotEmpty()) {
@@ -983,7 +988,7 @@ class MediaSendViewModel(
message = snapshot.message,
isViewOnce = isViewOnceEnabled(),
singleRecipientId = snapshot.recipientId,
recipientIds = snapshot.additionalRecipientIds,
recipients = snapshot.additionalRecipients,
scheduledTime = snapshot.scheduledTime,
sendType = snapshot.sendType,
isStory = snapshot.isStory,
@@ -5,6 +5,12 @@
package org.signal.mediasend.capture
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.spacedBy
@@ -55,6 +61,12 @@ import org.signal.mediasend.R
import org.signal.mediasend.edit.rememberPreviewMedia
import org.signal.mediasend.rememberPreviewState
/**
* The text story editor slides in over a stationary camera, so it always sits on top.
*/
private const val CAMERA_Z_INDEX = 0f
private const val TEXT_STORY_Z_INDEX = 1f
/**
* Screen that allows user to capture the media they will send using a camera or text story
*/
@@ -70,13 +82,32 @@ fun MediaCaptureScreen(
.fillMaxSize()
.background(color = Color.Black)
) {
when (selectedCaptureScreen) {
is MediaSendNavKey.Capture.TextStory -> textStoryEditorSlot()
else -> {
MediaCameraCaptureScreen(
state = state,
onEvent = onEvent
)
AnimatedContent(
targetState = selectedCaptureScreen,
transitionSpec = {
if (targetState is MediaSendNavKey.Capture.TextStory) {
ContentTransform(
targetContentEnter = slideInHorizontally { width -> width },
initialContentExit = ExitTransition.KeepUntilTransitionsFinished,
targetContentZIndex = TEXT_STORY_Z_INDEX
)
} else {
ContentTransform(
targetContentEnter = EnterTransition.None,
initialContentExit = slideOutHorizontally { width -> width },
targetContentZIndex = CAMERA_Z_INDEX
)
}
}
) { captureScreen ->
when (captureScreen) {
is MediaSendNavKey.Capture.TextStory -> textStoryEditorSlot()
else -> {
MediaCameraCaptureScreen(
state = state,
onEvent = onEvent
)
}
}
}