diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 001e891af5..242b384e68 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -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" /> + 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt index 1fe0df6c01..c676bb1a9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt @@ -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 = 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) { + 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) { 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 + + 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() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt index b3f648a4df..903a2fcbd0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationViewModel.kt @@ -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 = BehaviorSubject.create() private val temporaryBodySubject: Subject = BehaviorSubject.createDefault("") private val disposables = CompositeDisposable() @@ -42,6 +52,10 @@ class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRe val typeface: Flowable = 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 create(modelClass: Class): 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(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 + ) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt index e8a6c81fdb..1f8dc62f4c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostLinkEntryFragment.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt index a4d2fb94c9..1a1fedb323 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostTextEntryFragment.kt @@ -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() 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 index 9f343a01d7..436fb5b0be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt @@ -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 + 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) { 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 { - 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) } } } 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 index 6ecd12badd..c00199aadc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ForwardFragment.kt @@ -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 = 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 { 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 d44123b46d..8ae5659e6d 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 @@ -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 { 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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt index 93379cc758..f6ddece1fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt @@ -37,6 +37,9 @@ class StoryLinkPreviewView @JvmOverloads constructor( private val binding = StoriesTextPostLinkPreviewBinding.bind(this) private val spinnerStub = Stub(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? { 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 37d6b7708d..6f1b0aa60a 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 @@ -57,6 +57,10 @@ class MediaSendActivityContract(private val clazz: Class) : 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 = emptyList(), val mode: Mode = Mode.SingleRecipient, /** * Initial media to populate the selection. @@ -80,6 +84,14 @@ class MediaSendActivityContract(private val clazz: Class) : 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. */ diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt index 6af5f13edf..8f1dbe4cc5 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt @@ -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) : HudCommand } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRecipient.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRecipient.kt new file mode 100644 index 0000000000..8009197861 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRecipient.kt @@ -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 diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt index 7c059950a6..a6d3d93c21 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt @@ -140,7 +140,7 @@ data class SendRequest( val message: CharSequence?, val isViewOnce: Boolean, val singleRecipientId: MediaRecipientId?, - val recipientIds: List, + val recipients: List, val scheduledTime: Long, val sendType: Int, val isStory: Boolean, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt index 8e7cb0c181..4ab3175017 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt @@ -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 = emptyList(), + val additionalRecipients: List = emptyList(), /** * Scheduled send time (-1 for immediate). */ 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 039dc5dbc4..95d62945f3 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 @@ -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) { - 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) { - updateState { copy(additionalRecipientIds = recipientIds) } + fun setAdditionalRecipients(recipients: List) { + 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, diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt index 8df22eff8a..48d7bf6f6a 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt @@ -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 + ) + } } }