diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 968c0c9788..27a1d0d206 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -179,7 +179,7 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:windowSoftInputMode="adjustResize" /> - Story(recipientId) - ParcelableType.KNOWN_RECIPIENT -> KnownRecipient(recipientId) + ParcelableType.STORY -> RecipientSearchKey.Story(recipientId) + ParcelableType.KNOWN_RECIPIENT -> RecipientSearchKey.KnownRecipient(recipientId) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt index 2839d43398..44f47ed9b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt @@ -67,7 +67,7 @@ class ContactSearchMediator( return viewModel.selectionState } - fun addToVisibleGroupStories(groupStories: Set) { + fun addToVisibleGroupStories(groupStories: Set) { viewModel.addToVisibleGroupStories(groupStories) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt index 1e270783a8..c40acd96a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt @@ -12,8 +12,8 @@ class ContactSearchRepository { val isSelectable = when (it) { is ContactSearchKey.Expand -> false is ContactSearchKey.Header -> false - is ContactSearchKey.KnownRecipient -> canSelectRecipient(it.recipientId) - is ContactSearchKey.Story -> canSelectRecipient(it.recipientId) + is ContactSearchKey.RecipientSearchKey.KnownRecipient -> canSelectRecipient(it.recipientId) + is ContactSearchKey.RecipientSearchKey.Story -> canSelectRecipient(it.recipientId) } ContactSearchSelectionResult(it, isSelectable) }.toSet() diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt index 9fd3f59a9f..19049e9fe4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -86,7 +86,7 @@ class ContactSearchViewModel( return selectionStore.state } - fun addToVisibleGroupStories(groupStories: Set) { + fun addToVisibleGroupStories(groupStories: Set) { configurationStore.update { state -> state.copy( groupStories = state.groupStories + groupStories.map { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/RecipientSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/RecipientSearchKey.kt deleted file mode 100644 index 2df5f6bf5a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/RecipientSearchKey.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.thoughtcrime.securesms.contacts.paged - -import org.thoughtcrime.securesms.recipients.RecipientId - -/** - * A Contact Search Key that is backed by a recipient, along with information about whether it is a story. - */ -interface RecipientSearchKey { - val recipientId: RecipientId - val isStory: Boolean -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index d5c43d5b78..d0823a3b8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -12,6 +12,7 @@ import android.widget.TextView import android.widget.Toast import androidx.annotation.PluralsRes import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.setFragmentResultListener @@ -79,11 +80,17 @@ class MultiselectForwardFragment : private var handler: Handler? = null private fun createViewModelFactory(): MultiselectForwardViewModel.Factory { - return MultiselectForwardViewModel.Factory(getMultiShareArgs(), MultiselectForwardRepository(requireContext())) + return MultiselectForwardViewModel.Factory(getMultiShareArgs(), isSelectionOnly, MultiselectForwardRepository(requireContext())) } private fun getMultiShareArgs(): ArrayList = requireNotNull(requireArguments().getParcelableArrayList(ARG_MULTISHARE_ARGS)) + private val forceDisableAddMessage: Boolean + get() = requireArguments().getBoolean(ARG_FORCE_DISABLE_ADD_MESSAGE, false) + + private val isSelectionOnly: Boolean + get() = requireArguments().getBoolean(ARG_FORCE_SELECTION_ONLY, false) + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { return if (parentFragment != null) { requireParentFragment().onGetLayoutInflater(savedInstanceState) @@ -155,7 +162,7 @@ class MultiselectForwardFragment : contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) { contactSelection -> shareSelectionAdapter.submitList(contactSelection.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) }) - addMessage.visible = contactSelection.any { key -> key !is ContactSearchKey.Story } && getMultiShareArgs().isNotEmpty() + addMessage.visible = !forceDisableAddMessage && contactSelection.any { key -> key !is ContactSearchKey.RecipientSearchKey.Story } && getMultiShareArgs().isNotEmpty() if (contactSelection.isNotEmpty() && !bottomBar.isVisible) { bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom) @@ -188,13 +195,13 @@ class MultiselectForwardFragment : setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle -> val recipientId: RecipientId = bundle.getParcelable(CreateStoryWithViewersFragment.STORY_RECIPIENT)!! - contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.Story(recipientId))) + contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey.Story(recipientId))) contactFilterView.clear() } setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle -> val groups: Set = bundle.getParcelableArrayList(ChooseGroupStoryBottomSheet.RESULT_SET)?.toSet() ?: emptySet() - val keys: Set = groups.map { ContactSearchKey.Story(it) }.toSet() + val keys: Set = groups.map { ContactSearchKey.RecipientSearchKey.Story(it) }.toSet() contactSearchMediator.addToVisibleGroupStories(keys) contactSearchMediator.setKeysSelected(keys) contactFilterView.clear() @@ -392,6 +399,8 @@ class MultiselectForwardFragment : const val ARG_MULTISHARE_ARGS = "multiselect.forward.fragment.arg.multishare.args" const val ARG_CAN_SEND_TO_NON_PUSH = "multiselect.forward.fragment.arg.can.send.to.non.push" const val ARG_TITLE = "multiselect.forward.fragment.title" + const val ARG_FORCE_DISABLE_ADD_MESSAGE = "multiselect.forward.fragment.force.disable.add.message" + const val ARG_FORCE_SELECTION_ONLY = "multiselect.forward.fragment.force.disable.add.message" const val RESULT_KEY = "result_key" const val RESULT_SELECTION = "result_selection_recipients" const val RESULT_SENT = "result_sent" @@ -400,26 +409,37 @@ class MultiselectForwardFragment : fun showBottomSheet(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) { val fragment = MultiselectForwardBottomSheet() - fragment.arguments = Bundle().apply { - putParcelableArrayList(ARG_MULTISHARE_ARGS, ArrayList(multiselectForwardFragmentArgs.multiShareArgs)) - putBoolean(ARG_CAN_SEND_TO_NON_PUSH, multiselectForwardFragmentArgs.canSendToNonPush) - putInt(ARG_TITLE, multiselectForwardFragmentArgs.title) - } - - fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + showDialogFragment(supportFragmentManager, fragment, multiselectForwardFragmentArgs) } @JvmStatic fun showFullScreen(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) { val fragment = MultiselectForwardFullScreenDialogFragment() - fragment.arguments = Bundle().apply { - putParcelableArrayList(ARG_MULTISHARE_ARGS, ArrayList(multiselectForwardFragmentArgs.multiShareArgs)) - putBoolean(ARG_CAN_SEND_TO_NON_PUSH, multiselectForwardFragmentArgs.canSendToNonPush) - putInt(ARG_TITLE, multiselectForwardFragmentArgs.title) + showDialogFragment(supportFragmentManager, fragment, multiselectForwardFragmentArgs) + } + + @JvmStatic + fun create(multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs): Fragment { + return MultiselectForwardFragment().apply { + arguments = createArgumentsBundle(multiselectForwardFragmentArgs) } + } + + private fun showDialogFragment(supportFragmentManager: FragmentManager, fragment: DialogFragment, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) { + fragment.arguments = createArgumentsBundle(multiselectForwardFragmentArgs) fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) } + + private fun createArgumentsBundle(multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs): Bundle { + return Bundle().apply { + putParcelableArrayList(ARG_MULTISHARE_ARGS, ArrayList(multiselectForwardFragmentArgs.multiShareArgs)) + putBoolean(ARG_CAN_SEND_TO_NON_PUSH, multiselectForwardFragmentArgs.canSendToNonPush) + putInt(ARG_TITLE, multiselectForwardFragmentArgs.title) + putBoolean(ARG_FORCE_DISABLE_ADD_MESSAGE, multiselectForwardFragmentArgs.forceDisableAddMessage) + putBoolean(ARG_FORCE_SELECTION_ONLY, multiselectForwardFragmentArgs.forceSelectionOnly) + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt index c19485cc36..eb5a63fa33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt @@ -23,14 +23,18 @@ import java.util.function.Consumer /** * Arguments for the MultiselectForwardFragment. * - * @param canSendToNonPush Whether non-push recipients will be displayed - * @param multiShareArgs The items to forward. If this is an empty list, the fragment owner will be sent back a selected list of contacts. - * @param title The title to display at the top of the sheet + * @param canSendToNonPush Whether non-push recipients will be displayed + * @param multiShareArgs The items to forward. If this is an empty list, the fragment owner will be sent back a selected list of contacts. + * @param title The title to display at the top of the sheet + * @param forceDisableAddMessage Hide the add message field even if it would normally be available. + * @param forceSelectionOnly Force the fragment to only select recipients, never actually performing the send. */ -class MultiselectForwardFragmentArgs( +class MultiselectForwardFragmentArgs @JvmOverloads constructor( val canSendToNonPush: Boolean, val multiShareArgs: List = listOf(), - @StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to + @StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to, + val forceDisableAddMessage: Boolean = false, + val forceSelectionOnly: Boolean = false ) { companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt index fa99a79c22..8db1ca2e3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt @@ -5,12 +5,10 @@ import io.reactivex.rxjava3.core.Single import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.database.SignalDatabase -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sharing.MultiShareArgs import org.thoughtcrime.securesms.sharing.MultiShareSender -import org.thoughtcrime.securesms.sharing.ShareContactAndThread import java.util.Optional class MultiselectForwardRepository(context: Context) { @@ -44,28 +42,20 @@ class MultiselectForwardRepository(context: Context) { resultHandlers: MultiselectForwardResultHandlers ) { SignalExecutors.BOUNDED.execute { - val threadDatabase: ThreadDatabase = SignalDatabase.threads - - val sharedContactsAndThreads: Set = shareContacts + val filteredContacts: Set = shareContacts .asSequence() - .filter { it is ContactSearchKey.Story || it is ContactSearchKey.KnownRecipient } - .map { - val recipient = Recipient.resolved(it.requireShareContact().recipientId.get()) - val isStory = it is ContactSearchKey.Story || recipient.isDistributionList - val thread = if (isStory) -1L else threadDatabase.getOrCreateThreadIdFor(recipient) - ShareContactAndThread(recipient.id, thread, recipient.isForceSmsSelection, it is ContactSearchKey.Story) - } + .filter { it is ContactSearchKey.RecipientSearchKey.Story || it is ContactSearchKey.RecipientSearchKey.KnownRecipient } .toSet() - val mappedArgs: List = multiShareArgs.map { it.buildUpon(sharedContactsAndThreads).build() } + val mappedArgs: List = multiShareArgs.map { it.buildUpon(filteredContacts).build() } val results = mappedArgs.sortedBy { it.timestamp }.map { MultiShareSender.sendSync(it) } if (additionalMessage.isNotEmpty()) { - val additional = MultiShareArgs.Builder(sharedContactsAndThreads.filterNot { it.isStory }.toSet()) + val additional = MultiShareArgs.Builder(filteredContacts.filterNot { it is ContactSearchKey.RecipientSearchKey.Story }.toSet()) .withDraftText(additionalMessage) .build() - if (additional.shareContactAndThreads.isNotEmpty()) { + if (additional.contactSearchKeys.isNotEmpty()) { val additionalResult: MultiShareSender.MultiShareSendResultCollection = MultiShareSender.sendSync(additional) handleResults(results + additionalResult, resultHandlers) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt index ca818a4c30..7faa7f48d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt @@ -4,7 +4,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords import org.thoughtcrime.securesms.sharing.MultiShareArgs @@ -12,6 +11,7 @@ import org.thoughtcrime.securesms.util.livedata.Store class MultiselectForwardViewModel( private val records: List, + private val isSelectionOnly: Boolean, private val repository: MultiselectForwardRepository ) : ViewModel() { @@ -25,7 +25,7 @@ class MultiselectForwardViewModel( store.update { it.copy(stage = MultiselectForwardState.Stage.FirstConfirmation) } } else { store.update { it.copy(stage = MultiselectForwardState.Stage.LoadingIdentities) } - UntrustedRecords.checkForBadIdentityRecords(selectedContacts.filterIsInstance(RecipientSearchKey::class.java).toSet()) { identityRecords -> + UntrustedRecords.checkForBadIdentityRecords(selectedContacts.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java).toSet()) { identityRecords -> if (identityRecords.isEmpty()) { performSend(additionalMessage, selectedContacts) } else { @@ -49,7 +49,7 @@ class MultiselectForwardViewModel( private fun performSend(additionalMessage: String, selectedContacts: Set) { store.update { it.copy(stage = MultiselectForwardState.Stage.SendPending) } - if (records.isEmpty()) { + if (records.isEmpty() || isSelectionOnly) { store.update { it.copy(stage = MultiselectForwardState.Stage.SelectionConfirmed(selectedContacts)) } } else { repository.send( @@ -67,10 +67,11 @@ class MultiselectForwardViewModel( class Factory( private val records: List, - private val repository: MultiselectForwardRepository + private val isSelectionOnly: Boolean, + private val repository: MultiselectForwardRepository, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return requireNotNull(modelClass.cast(MultiselectForwardViewModel(records, repository))) + return requireNotNull(modelClass.cast(MultiselectForwardViewModel(records, isSelectionOnly, repository))) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt index ff830fd03d..d0203e59df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt @@ -19,6 +19,7 @@ import androidx.navigation.fragment.NavHostFragment import androidx.transition.AutoTransition import androidx.transition.TransitionManager import com.google.android.material.animation.ArgbEvaluatorCompat +import org.signal.core.util.BreakIteratorCompat import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R @@ -26,15 +27,18 @@ import org.thoughtcrime.securesms.TransportOption import org.thoughtcrime.securesms.TransportOptions import org.thoughtcrime.securesms.components.emoji.EmojiEventListener import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.contacts.paged.ContactSearchState import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog 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.Media 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.recipients.RecipientId import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.FeatureFlags @@ -56,7 +60,11 @@ class MediaSelectionActivity : lateinit var viewModel: MediaSelectionViewModel - private val textViewModel: TextStoryPostCreationViewModel by viewModels() + private val textViewModel: TextStoryPostCreationViewModel by viewModels( + factoryProducer = { + TextStoryPostCreationViewModel.Factory(TextStoryPostSendRepository()) + } + ) private val destination: MediaSelectionDestination get() = MediaSelectionDestination.fromBundle(requireNotNull(intent.getBundleExtra(DESTINATION))) @@ -64,6 +72,12 @@ class MediaSelectionActivity : private val isStory: Boolean get() = intent.getBooleanExtra(IS_STORY, false) + private val shareToTextStory: Boolean + get() = intent.getBooleanExtra(AS_TEXT_STORY, false) + + private val draftText: CharSequence? + get() = intent.getCharSequenceExtra(MESSAGE) + override fun attachBaseContext(newBase: Context) { delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES super.attachBaseContext(newBase) @@ -74,7 +88,7 @@ class MediaSelectionActivity : val transportOption: TransportOption = requireNotNull(intent.getParcelableExtra(TRANSPORT_OPTION)) val initialMedia: List = intent.getParcelableArrayListExtra(MEDIA) ?: listOf() - val message: CharSequence? = intent.getCharSequenceExtra(MESSAGE) + val message: CharSequence? = if (shareToTextStory) null else draftText val isReply: Boolean = intent.getBooleanExtra(IS_REPLY, false) val factory = MediaSelectionViewModel.Factory(destination, transportOption, initialMedia, message, isReply, isStory, MediaSelectionRepository(this)) @@ -100,6 +114,10 @@ class MediaSelectionActivity : } if (savedInstanceState == null) { + if (shareToTextStory) { + initializeTextStory() + } + cameraSwitch.isSelected = true val navHostFragment = NavHostFragment.create(R.navigation.media) @@ -168,6 +186,25 @@ class MediaSelectionActivity : } } + 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 canDisplayStorySwitch(): Boolean { return Stories.isFeatureEnabled() && FeatureFlags.storiesTextPosts() && @@ -292,6 +329,11 @@ class MediaSelectionActivity : private inner class OnBackPressed : OnBackPressedCallback(true) { override fun handleOnBackPressed() { val navController = Navigation.findNavController(this@MediaSelectionActivity, R.id.fragment_container) + + if (shareToTextStory && navController.currentDestination?.id == R.id.textStoryPostCreationFragment) { + finish() + } + if (!navController.popBackStack()) { finish() } @@ -310,6 +352,7 @@ class MediaSelectionActivity : private const val DESTINATION = "destination" private const val IS_REPLY = "is_reply" private const val IS_STORY = "is_story" + private const val AS_TEXT_STORY = "as_text_story" @JvmStatic fun camera(context: Context): Intent { @@ -383,15 +426,18 @@ class MediaSelectionActivity : context: Context, transportOption: TransportOption, media: List, - recipientIds: List, - message: CharSequence? + recipientSearchKeys: List, + message: CharSequence?, + asTextStory: Boolean ): Intent { return buildIntent( context = context, transportOption = transportOption, media = media, - destination = MediaSelectionDestination.MultipleRecipients(recipientIds), - message = message + destination = MediaSelectionDestination.MultipleRecipients(recipientSearchKeys), + message = message, + asTextStory = asTextStory, + startAction = if (asTextStory) R.id.action_directly_to_textPostCreationFragment else -1 ) } @@ -403,7 +449,8 @@ class MediaSelectionActivity : destination: MediaSelectionDestination = MediaSelectionDestination.ChooseAfterMediaSelection, message: CharSequence? = null, isReply: Boolean = false, - isStory: Boolean = false + isStory: Boolean = false, + asTextStory: Boolean = false ): Intent { return Intent(context, MediaSelectionActivity::class.java).apply { putExtra(START_ACTION, startAction) @@ -413,6 +460,7 @@ class MediaSelectionActivity : putExtra(DESTINATION, destination.toBundle()) putExtra(IS_REPLY, isReply) putExtra(IS_STORY, isStory) + putExtra(AS_TEXT_STORY, asTextStory) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionDestination.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionDestination.kt index 93aeef9e92..2142c11662 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionDestination.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionDestination.kt @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.mediasend.v2 import android.os.Bundle import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.recipients.RecipientId sealed class MediaSelectionDestination { @@ -30,7 +29,7 @@ sealed class MediaSelectionDestination { } class SingleRecipient(private val id: RecipientId) : MediaSelectionDestination() { - override fun getRecipientSearchKey(): RecipientSearchKey = ContactSearchKey.KnownRecipient(id) + override fun getRecipientSearchKey(): ContactSearchKey.RecipientSearchKey = ContactSearchKey.RecipientSearchKey.KnownRecipient(id) override fun toBundle(): Bundle { return Bundle().apply { @@ -39,18 +38,25 @@ sealed class MediaSelectionDestination { } } - class MultipleRecipients(val recipientIds: List) : MediaSelectionDestination() { - override fun getRecipientSearchKeyList(): List = recipientIds.map { ContactSearchKey.KnownRecipient(it) } + class MultipleRecipients(val recipientSearchKeys: List) : MediaSelectionDestination() { + + companion object { + fun fromParcel(parcelables: List): MultipleRecipients { + return MultipleRecipients(parcelables.map { it.asContactSearchKey() }.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java)) + } + } + + override fun getRecipientSearchKeyList(): List = recipientSearchKeys override fun toBundle(): Bundle { return Bundle().apply { - putParcelableArrayList(RECIPIENT_LIST, ArrayList(recipientIds)) + putParcelableArrayList(RECIPIENT_LIST, ArrayList(recipientSearchKeys.map { it.requireParcelable() })) } } } - open fun getRecipientSearchKey(): RecipientSearchKey? = null - open fun getRecipientSearchKeyList(): List = emptyList() + open fun getRecipientSearchKey(): ContactSearchKey.RecipientSearchKey? = null + open fun getRecipientSearchKeyList(): List = emptyList() abstract fun toBundle(): Bundle @@ -65,7 +71,7 @@ sealed class MediaSelectionDestination { bundle.containsKey(WALLPAPER) -> Wallpaper bundle.containsKey(AVATAR) -> Avatar bundle.containsKey(RECIPIENT) -> SingleRecipient(requireNotNull(bundle.getParcelable(RECIPIENT))) - bundle.containsKey(RECIPIENT_LIST) -> MultipleRecipients(requireNotNull(bundle.getParcelableArrayList(RECIPIENT_LIST))) + bundle.containsKey(RECIPIENT_LIST) -> MultipleRecipients.fromParcel(requireNotNull(bundle.getParcelableArrayList(RECIPIENT_LIST))) else -> ChooseAfterMediaSelection } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index f9ac91581d..0216000dae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -12,7 +12,6 @@ import org.signal.core.util.logging.Log import org.signal.imageeditor.core.model.EditorModel import org.thoughtcrime.securesms.TransportOption import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.ThreadDatabase @@ -72,8 +71,8 @@ class MediaSelectionRepository(context: Context) { message: CharSequence?, isSms: Boolean, isViewOnce: Boolean, - singleContact: RecipientSearchKey?, - contacts: List, + singleContact: ContactSearchKey.RecipientSearchKey?, + contacts: List, mentions: List, transport: TransportOption ): Maybe { @@ -198,14 +197,14 @@ class MediaSelectionRepository(context: Context) { } @WorkerThread - private fun sendMessages(contacts: List, body: String, preUploadResults: Collection, mentions: List, isViewOnce: Boolean) { + private fun sendMessages(contacts: List, body: String, preUploadResults: Collection, mentions: List, isViewOnce: Boolean) { val broadcastMessages: MutableList = ArrayList(contacts.size) val storyMessages: MutableMap> = mutableMapOf() val distributionListSentTimestamps: MutableMap = mutableMapOf() for (contact in contacts) { val recipient = Recipient.resolved(contact.recipientId) - val isStory = contact is ContactSearchKey.Story || recipient.isDistributionList + val isStory = contact.isStory || recipient.isDistributionList if (isStory && recipient.isActiveGroup) { SignalDatabase.groups.markDisplayAsStory(recipient.requireGroupId()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index de5e55b803..c0af050b09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -12,7 +12,7 @@ import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.subjects.PublishSubject import org.thoughtcrime.securesms.TransportOption import org.thoughtcrime.securesms.components.mention.MentionAnnotation -import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.mediasend.VideoEditorFragment @@ -280,7 +280,7 @@ class MediaSelectionViewModel( } fun send( - selectedContacts: List = emptyList() + selectedContacts: List = emptyList() ): Maybe { return UntrustedRecords.checkForBadIdentityRecords(selectedContacts.toSet()).andThen( repository.send( diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/UntrustedRecords.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/UntrustedRecords.kt index 44e4eeee2d..03013212f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/UntrustedRecords.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/UntrustedRecords.kt @@ -4,7 +4,7 @@ import androidx.core.util.Consumer import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.SignalExecutors -import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.dependencies.ApplicationDependencies @@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.recipients.Recipient object UntrustedRecords { - fun checkForBadIdentityRecords(contactSearchKeys: Set): Completable { + fun checkForBadIdentityRecords(contactSearchKeys: Set): Completable { return Completable.fromAction { val untrustedRecords: List = checkForBadIdentityRecordsSync(contactSearchKeys) if (untrustedRecords.isNotEmpty()) { @@ -21,13 +21,13 @@ object UntrustedRecords { }.subscribeOn(Schedulers.io()) } - fun checkForBadIdentityRecords(contactSearchKeys: Set, consumer: Consumer>) { + fun checkForBadIdentityRecords(contactSearchKeys: Set, consumer: Consumer>) { SignalExecutors.BOUNDED.execute { consumer.accept(checkForBadIdentityRecordsSync(contactSearchKeys)) } } - private fun checkForBadIdentityRecordsSync(contactSearchKeys: Set): List { + private fun checkForBadIdentityRecordsSync(contactSearchKeys: Set): List { val recipients: List = contactSearchKeys .map { Recipient.resolved(it.recipientId) } .map { recipient -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index 8dec2fc136..ad24c3806a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -28,7 +28,6 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.TransportOption import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult @@ -140,7 +139,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { } setFragmentResultListener(MultiselectForwardFragment.RESULT_KEY) { _, bundle -> - val parcelizedKeys: List = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!! + val parcelizedKeys: List = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!! val contactSearchKeys = parcelizedKeys.map { it.asContactSearchKey() } performSend(contactSearchKeys) } @@ -269,7 +268,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { .alpha(1f) sharedViewModel - .send(selection.filterIsInstance(RecipientSearchKey::class.java)) + .send(selection.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java)) .subscribe( { result -> callback.onSentWithResult(result) }, { error -> callback.onSendError(error) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt index c57443d7d1..767f35f46d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt @@ -80,7 +80,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment( mediator.getSelectionState().observe(viewLifecycleOwner) { state -> adapter.submitList( - state.filterIsInstance(ContactSearchKey.Story::class.java) + state.filterIsInstance(ContactSearchKey.RecipientSearchKey.Story::class.java) .map { it.recipientId } .mapIndexed { index, recipientId -> ShareSelectionMappingModel( @@ -144,7 +144,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment( RESULT_SET, ArrayList( mediator.getSelectedContacts() - .filterIsInstance(ContactSearchKey.Story::class.java) + .filterIsInstance(ContactSearchKey.RecipientSearchKey.Story::class.java) .map { it.recipientId } ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseStoryTypeBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseStoryTypeBottomSheet.kt index 6aa1fb861e..f3e0a7cf33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseStoryTypeBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseStoryTypeBottomSheet.kt @@ -38,9 +38,9 @@ class ChooseStoryTypeBottomSheet : DSLSettingsBottomSheetFragment( ), icon = DSLSettingsIcon.from( R.drawable.ic_plus_24, - R.color.core_grey_15, + R.color.signal_icon_tint_primary, R.drawable.circle_tintable, - R.color.core_grey_80, + R.color.signal_button_secondary_ripple, DimensionUnit.DP.toPixels(8f).toInt() ), onClick = { @@ -60,9 +60,9 @@ class ChooseStoryTypeBottomSheet : DSLSettingsBottomSheetFragment( ), icon = DSLSettingsIcon.from( R.drawable.ic_group_outline_24, - R.color.core_grey_15, + R.color.signal_icon_tint_primary, R.drawable.circle_tintable, - R.color.core_grey_80, + R.color.signal_button_secondary_ripple, DimensionUnit.DP.toPixels(8f).toInt() ), onClick = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt index 121151d910..38ab857343 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryBackgroundColors.kt @@ -98,4 +98,7 @@ object TextStoryBackgroundColors { return backgroundColors[indexOfNextColor] } + + @JvmStatic + fun getRandomBackgroundColor() = backgroundColors.random() } 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 6e3a1a88f5..4f1247ef14 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 @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend.v2.text import android.content.pm.ActivityInfo import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.appcompat.widget.AppCompatImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.drawToBitmap @@ -10,12 +11,19 @@ import androidx.core.view.postDelayed import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.mediasend.v2.HudCommand import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel +import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository +import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult import org.thoughtcrime.securesms.stories.StoryTextPostView +import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs +import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -35,6 +43,9 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati private val viewModel: TextStoryPostCreationViewModel by viewModels( ownerProducer = { requireActivity() + }, + factoryProducer = { + TextStoryPostCreationViewModel.Factory(TextStoryPostSendRepository()) } ) @@ -113,8 +124,33 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati send.setOnClickListener { storyTextPostView.hideCloseButton() - viewModel.setBitmap(storyTextPostView.drawToBitmap()) - findNavController().safeNavigate(R.id.action_textStoryPostCreationFragment_to_textStoryPostSendFragment) + + val contacts = (sharedViewModel.destination.getRecipientSearchKeyList() + sharedViewModel.destination.getRecipientSearchKey()) + .filterIsInstance(ContactSearchKey::class.java) + .toSet() + + if (contacts.isEmpty()) { + viewModel.setBitmap(storyTextPostView.drawToBitmap()) + findNavController().safeNavigate(R.id.action_textStoryPostCreationFragment_to_textStoryPostSendFragment) + } else { + send.isClickable = false + StoryDialogs.guardWithAddToYourStoryDialog( + contacts = contacts, + context = requireContext(), + onAddToStory = { + performSend(contacts) + }, + onEditViewers = { + send.isClickable = true + storyTextPostView.hideCloseButton() + HideStoryFromDialogFragment().show(childFragmentManager, null) + }, + onCancel = { + send.isClickable = true + storyTextPostView.hideCloseButton() + } + ) + } } } @@ -130,4 +166,26 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati storyTextPostView.isEnabled = true } } + + private fun performSend(contacts: Set) { + lifecycleDisposable += viewModel.send( + contacts = contacts, + linkPreviewViewModel.linkPreviewState.value?.linkPreview?.orElse(null) + ).observeOn(AndroidSchedulers.mainThread()).subscribe { result -> + when (result) { + TextStoryPostSendResult.Success -> { + Toast.makeText(requireContext(), R.string.TextStoryPostCreationFragment__sent_story, Toast.LENGTH_SHORT).show() + requireActivity().finish() + } + TextStoryPostSendResult.Failure -> { + Toast.makeText(requireContext(), R.string.TextStoryPostCreationFragment__failed_to_send_story, Toast.LENGTH_SHORT).show() + requireActivity().finish() + } + is TextStoryPostSendResult.UntrustedRecordsError -> { + send.isClickable = true + SafetyNumberChangeDialog.show(childFragmentManager, result.untrustedRecords) + } + } + } + } } 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 8050959631..5e2fdb2e81 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 @@ -7,22 +7,25 @@ import androidx.annotation.ColorInt import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.BehaviorSubject import io.reactivex.rxjava3.subjects.Subject import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.dependencies.ApplicationDependencies -import org.thoughtcrime.securesms.fonts.Fonts import org.thoughtcrime.securesms.fonts.TextFont import org.thoughtcrime.securesms.fonts.TextToScript import org.thoughtcrime.securesms.fonts.TypefaceCache -import org.thoughtcrime.securesms.util.FutureTaskListener +import org.thoughtcrime.securesms.linkpreview.LinkPreview +import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository +import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult import org.thoughtcrime.securesms.util.livedata.Store -import java.util.concurrent.ExecutionException -class TextStoryPostCreationViewModel : ViewModel() { +class TextStoryPostCreationViewModel(private val repository: TextStoryPostSendRepository) : ViewModel() { private val store = Store(TextStoryPostCreationState()) private val textFontSubject: Subject = BehaviorSubject.create() @@ -57,30 +60,6 @@ class TextStoryPostCreationViewModel : ViewModel() { internalThumbnail.value = bitmap } - private fun asyncFontEmitter(async: Fonts.FontResult.Async): Observable { - return Observable.create { - it.onNext(async.placeholder) - - val listener = object : FutureTaskListener { - override fun onSuccess(result: Typeface) { - it.onNext(result) - it.onComplete() - } - - override fun onFailure(exception: ExecutionException?) { - Log.w(TAG, "Failed to load remote font.", exception) - it.onComplete() - } - } - - it.setCancellable { - async.future.removeListener(listener) - } - - async.future.addListener(listener) - } - } - override fun onCleared() { disposables.clear() thumbnail.value?.recycle() @@ -144,6 +123,20 @@ class TextStoryPostCreationViewModel : ViewModel() { temporaryBodySubject.onNext(temporaryBody) } + fun send(contacts: Set, linkPreview: LinkPreview?): Single { + return repository.send( + contacts, + store.state, + linkPreview + ) + } + + 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" diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt index d53cb25359..8e4ec66dde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt @@ -109,13 +109,13 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragm setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle -> val recipientId: RecipientId = bundle.getParcelable(CreateStoryWithViewersFragment.STORY_RECIPIENT)!! - contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.Story(recipientId))) + contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey.Story(recipientId))) contactSearchMediator.onFilterChanged("") } setFragmentResultListener(ChooseGroupStoryBottomSheet.GROUP_STORY) { _, bundle -> val groups: Set = bundle.getParcelableArrayList(ChooseGroupStoryBottomSheet.RESULT_SET)?.toSet() ?: emptySet() - val keys: Set = groups.map { ContactSearchKey.Story(it) }.toSet() + val keys: Set = groups.map { ContactSearchKey.RecipientSearchKey.Story(it) }.toSet() contactSearchMediator.addToVisibleGroupStories(keys) contactSearchMediator.onFilterChanged("") contactSearchMediator.setKeysSelected(keys) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt index 99f32c70fa..e17ce89e11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendRepository.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.mediasend.v2.text.send import io.reactivex.rxjava3.core.Single import org.signal.core.util.ThreadUtil import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.contacts.paged.RecipientSearchKey import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.model.StoryType @@ -22,7 +21,7 @@ class TextStoryPostSendRepository { fun send(contactSearchKey: Set, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Single { return UntrustedRecords - .checkForBadIdentityRecords(contactSearchKey.filterIsInstance(RecipientSearchKey::class.java).toSet()) + .checkForBadIdentityRecords(contactSearchKey.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java).toSet()) .toSingleDefault(TextStoryPostSendResult.Success) .onErrorReturn { if (it is UntrustedRecords.UntrustedRecordsException) { @@ -47,7 +46,7 @@ class TextStoryPostSendRepository { for (contact in contactSearchKey) { val recipient = Recipient.resolved(contact.requireShareContact().recipientId.get()) - val isStory = contact is ContactSearchKey.Story || recipient.isDistributionList + val isStory = contact is ContactSearchKey.RecipientSearchKey.Story || recipient.isDistributionList if (isStory && recipient.isActiveGroup) { SignalDatabase.groups.markDisplayAsStory(recipient.requireGroupId()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java index 051f81332c..a2952dc4a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/sharablegrouplink/GroupLinkBottomSheetDialogFragment.java @@ -83,7 +83,7 @@ public final class GroupLinkBottomSheetDialogFragment extends BottomSheetDialogF getParentFragmentManager(), new MultiselectForwardFragmentArgs( true, - Collections.singletonList(new MultiShareArgs.Builder(Collections.emptySet()) + Collections.singletonList(new MultiShareArgs.Builder() .withDraftText(groupLink.getUrl()) .build()), R.string.MultiselectForwardFragment__share_with diff --git a/app/src/main/java/org/thoughtcrime/securesms/shakereport/ShakeToReport.java b/app/src/main/java/org/thoughtcrime/securesms/shakereport/ShakeToReport.java index 7d1acc3e72..7c0882488e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/shakereport/ShakeToReport.java +++ b/app/src/main/java/org/thoughtcrime/securesms/shakereport/ShakeToReport.java @@ -130,7 +130,7 @@ public final class ShakeToReport implements ShakeDetector.Listener { activity.getSupportFragmentManager(), new MultiselectForwardFragmentArgs( true, - Collections.singletonList(new MultiShareArgs.Builder(Collections.emptySet()) + Collections.singletonList(new MultiShareArgs.Builder() .withDraftText(url) .build()), R.string.MultiselectForwardFragment__share_with diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java index a4be32d72f..2347bd9592 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java @@ -10,65 +10,73 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; +import org.signal.core.util.BreakIteratorCompat; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.stories.Stories; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.ParcelUtil; +import org.thoughtcrime.securesms.util.Util; import java.io.IOException; import java.util.ArrayList; -import java.util.HashSet; +import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; public final class MultiShareArgs implements Parcelable { - private final Set shareContactAndThreads; - private final List media; - private final String draftText; - private final StickerLocator stickerLocator; - private final boolean borderless; - private final Uri dataUri; - private final String dataType; - private final boolean viewOnce; - private final LinkPreview linkPreview; - private final List mentions; - private final long timestamp; - private final long expiresAt; - private final boolean isTextStory; + private final Set contactSearchKeys; + private final List media; + private final String draftText; + private final StickerLocator stickerLocator; + private final boolean borderless; + private final Uri dataUri; + private final String dataType; + private final boolean viewOnce; + private final LinkPreview linkPreview; + private final List mentions; + private final long timestamp; + private final long expiresAt; + private final boolean isTextStory; private MultiShareArgs(@NonNull Builder builder) { - shareContactAndThreads = builder.shareContactAndThreads; - media = builder.media == null ? new ArrayList<>() : new ArrayList<>(builder.media); - draftText = builder.draftText; - stickerLocator = builder.stickerLocator; - borderless = builder.borderless; - dataUri = builder.dataUri; - dataType = builder.dataType; - viewOnce = builder.viewOnce; - linkPreview = builder.linkPreview; - mentions = builder.mentions == null ? new ArrayList<>() : new ArrayList<>(builder.mentions); - timestamp = builder.timestamp; - expiresAt = builder.expiresAt; - isTextStory = builder.isTextStory; + contactSearchKeys = builder.contactSearchKeys; + media = builder.media == null ? new ArrayList<>() : new ArrayList<>(builder.media); + draftText = builder.draftText; + stickerLocator = builder.stickerLocator; + borderless = builder.borderless; + dataUri = builder.dataUri; + dataType = builder.dataType; + viewOnce = builder.viewOnce; + linkPreview = builder.linkPreview; + mentions = builder.mentions == null ? new ArrayList<>() : new ArrayList<>(builder.mentions); + timestamp = builder.timestamp; + expiresAt = builder.expiresAt; + isTextStory = builder.isTextStory; } protected MultiShareArgs(Parcel in) { - shareContactAndThreads = new HashSet<>(Objects.requireNonNull(in.createTypedArrayList(ShareContactAndThread.CREATOR))); - media = in.createTypedArrayList(Media.CREATOR); - draftText = in.readString(); - stickerLocator = in.readParcelable(StickerLocator.class.getClassLoader()); - borderless = in.readByte() != 0; - dataUri = in.readParcelable(Uri.class.getClassLoader()); - dataType = in.readString(); - viewOnce = in.readByte() != 0; - mentions = in.createTypedArrayList(Mention.CREATOR); - timestamp = in.readLong(); - expiresAt = in.readLong(); - isTextStory = ParcelUtil.readBoolean(in); + List parcelableRecipientSearchKeys = in.createTypedArrayList(ContactSearchKey.ParcelableRecipientSearchKey.CREATOR); + + contactSearchKeys = parcelableRecipientSearchKeys.stream() + .map(ContactSearchKey.ParcelableRecipientSearchKey::asContactSearchKey) + .collect(Collectors.toSet()); + media = in.createTypedArrayList(Media.CREATOR); + draftText = in.readString(); + stickerLocator = in.readParcelable(StickerLocator.class.getClassLoader()); + borderless = in.readByte() != 0; + dataUri = in.readParcelable(Uri.class.getClassLoader()); + dataType = in.readString(); + viewOnce = in.readByte() != 0; + mentions = in.createTypedArrayList(Mention.CREATOR); + timestamp = in.readLong(); + expiresAt = in.readLong(); + isTextStory = ParcelUtil.readBoolean(in); String linkedPreviewString = in.readString(); LinkPreview preview; @@ -81,8 +89,15 @@ public final class MultiShareArgs implements Parcelable { linkPreview = preview; } - public Set getShareContactAndThreads() { - return shareContactAndThreads; + public Set getContactSearchKeys() { + return contactSearchKeys; + } + + public Set getRecipientSearchKeys() { + return contactSearchKeys.stream() + .filter(key -> key instanceof ContactSearchKey.RecipientSearchKey) + .map(key -> (ContactSearchKey.RecipientSearchKey) key) + .collect(Collectors.toSet()); } public @NonNull List getMedia() { @@ -134,13 +149,34 @@ public final class MultiShareArgs implements Parcelable { } public boolean isValidForStories() { - return isTextStory || !media.isEmpty() && media.stream().allMatch(m -> MediaUtil.isImageOrVideoType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType())); + return isTextStory || + !media.isEmpty() && media.stream().allMatch(m -> MediaUtil.isImageOrVideoType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType())) || + MediaUtil.isImageType(dataType) || + MediaUtil.isVideoType(dataType) || + isValidForTextStoryGeneration(); } public boolean isValidForNonStories() { return !isTextStory; } + public boolean isValidForTextStoryGeneration() { + if (isTextStory || !media.isEmpty()) { + return false; + } + + if (!Util.isEmpty(getDraftText())) { + BreakIteratorCompat breakIteratorCompat = BreakIteratorCompat.getInstance(); + breakIteratorCompat.setText(getDraftText()); + + if (breakIteratorCompat.countBreaks() > Stories.MAX_BODY_SIZE) { + return false; + } + } + + return linkPreview != null || !Util.isEmpty(draftText); + } + public @NonNull InterstitialContentType getInterstitialContentType() { if (!requiresInterstitial()) { return InterstitialContentType.NONE; @@ -148,6 +184,8 @@ public final class MultiShareArgs implements Parcelable { (this.getDataUri() != null && this.getDataUri() != Uri.EMPTY && this.getDataType() != null && MediaUtil.isImageOrVideoType(this.getDataType()))) { return InterstitialContentType.MEDIA; + } else if (!TextUtils.isEmpty(this.getDraftText()) && allRecipientsAreStories()) { + return InterstitialContentType.MEDIA; } else if (!TextUtils.isEmpty(this.getDraftText())) { return InterstitialContentType.TEXT; } else { @@ -155,6 +193,9 @@ public final class MultiShareArgs implements Parcelable { } } + public boolean allRecipientsAreStories() { + return !contactSearchKeys.isEmpty() && contactSearchKeys.stream().allMatch(key -> key instanceof ContactSearchKey.RecipientSearchKey.Story); + } public static final Creator CREATOR = new Creator() { @Override @@ -175,7 +216,7 @@ public final class MultiShareArgs implements Parcelable { @Override public void writeToParcel(Parcel dest, int flags) { - dest.writeTypedList(Stream.of(shareContactAndThreads).toList()); + dest.writeTypedList(Stream.of(contactSearchKeys).map(ContactSearchKey::requireParcelable).toList()); dest.writeTypedList(media); dest.writeString(draftText); dest.writeParcelable(stickerLocator, flags); @@ -200,32 +241,35 @@ public final class MultiShareArgs implements Parcelable { } public Builder buildUpon() { - return buildUpon(shareContactAndThreads); + return buildUpon(contactSearchKeys); } - public Builder buildUpon(@NonNull Set shareContactAndThreads) { - return new Builder(shareContactAndThreads).asBorderless(borderless) - .asViewOnce(viewOnce) - .withDataType(dataType) - .withDataUri(dataUri) - .withDraftText(draftText) - .withLinkPreview(linkPreview) - .withMedia(media) - .withStickerLocator(stickerLocator) - .withMentions(mentions) - .withTimestamp(timestamp) - .withExpiration(expiresAt) - .asTextStory(isTextStory); + public Builder buildUpon(@NonNull Set recipientSearchKeys) { + return new Builder(recipientSearchKeys).asBorderless(borderless) + .asViewOnce(viewOnce) + .withDataType(dataType) + .withDataUri(dataUri) + .withDraftText(draftText) + .withLinkPreview(linkPreview) + .withMedia(media) + .withStickerLocator(stickerLocator) + .withMentions(mentions) + .withTimestamp(timestamp) + .withExpiration(expiresAt) + .asTextStory(isTextStory); } private boolean requiresInterstitial() { return stickerLocator == null && - (!media.isEmpty() || !TextUtils.isEmpty(draftText) || MediaUtil.isImageOrVideoType(dataType)); + (!media.isEmpty() || + !TextUtils.isEmpty(draftText) || + MediaUtil.isImageOrVideoType(dataType) || + (!contactSearchKeys.isEmpty() && contactSearchKeys.stream().anyMatch(key -> key instanceof ContactSearchKey.RecipientSearchKey.Story))); } public static final class Builder { - private final Set shareContactAndThreads; + private final Set contactSearchKeys; private List media; private String draftText; @@ -240,8 +284,12 @@ public final class MultiShareArgs implements Parcelable { private long expiresAt; private boolean isTextStory; - public Builder(@NonNull Set shareContactAndThreads) { - this.shareContactAndThreads = shareContactAndThreads; + public Builder() { + this(Collections.emptySet()); + } + + public Builder(@NonNull Set contactSearchKeys) { + this.contactSearchKeys = contactSearchKeys; } public @NonNull Builder withMedia(@Nullable List media) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java index 9812f3a1d1..24d4c6b677 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -1,26 +1,33 @@ package org.thoughtcrime.securesms.sharing; import android.content.Context; +import android.graphics.Color; import android.net.Uri; import androidx.annotation.MainThread; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import androidx.core.util.Consumer; import com.annimon.stream.Stream; +import org.signal.core.util.BreakIteratorCompat; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.TransportOptions; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.StoryType; +import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryBackgroundColors; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; import org.thoughtcrime.securesms.mms.Slide; @@ -32,6 +39,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.stories.Stories; +import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.MessageUtil; import org.thoughtcrime.securesms.util.Util; @@ -63,7 +72,7 @@ public final class MultiShareSender { @WorkerThread public static MultiShareSendResultCollection sendSync(@NonNull MultiShareArgs multiShareArgs) { - List results = new ArrayList<>(multiShareArgs.getShareContactAndThreads().size()); + List results = new ArrayList<>(multiShareArgs.getContactSearchKeys().size()); Context context = ApplicationDependencies.getApplication(); boolean isMmsEnabled = Util.isMmsCapable(context); String message = multiShareArgs.getDraftText(); @@ -73,45 +82,48 @@ public final class MultiShareSender { slideDeck = buildSlideDeck(context, multiShareArgs); } catch (SlideNotFoundException e) { Log.w(TAG, "Could not create slide for media message"); - for (ShareContactAndThread shareContactAndThread : multiShareArgs.getShareContactAndThreads()) { - results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.GENERIC_ERROR)); + for (ContactSearchKey.RecipientSearchKey recipientSearchKey : multiShareArgs.getRecipientSearchKeys()) { + results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.GENERIC_ERROR)); } return new MultiShareSendResultCollection(results); } + long distributionListSentTimestamp = System.currentTimeMillis(); + for (ContactSearchKey.RecipientSearchKey recipientSearchKey : multiShareArgs.getRecipientSearchKeys()) { + Recipient recipient = Recipient.resolved(recipientSearchKey.getRecipientId()); - for (ShareContactAndThread shareContactAndThread : multiShareArgs.getShareContactAndThreads()) { - Recipient recipient = Recipient.resolved(shareContactAndThread.getRecipientId()); - - List mentions = getValidMentionsForRecipient(recipient, multiShareArgs.getMentions()); - TransportOption transport = resolveTransportOption(context, recipient); - boolean forceSms = recipient.isForceSmsSelection() && transport.isSms(); - int subscriptionId = transport.getSimSubscriptionId().orElse(-1); - long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds()); - boolean needsSplit = !transport.isSms() && - message != null && - message.length() > transport.calculateCharacters(message).maxPrimaryMessageSize; - boolean hasMmsMedia = !multiShareArgs.getMedia().isEmpty() || - (multiShareArgs.getDataUri() != null && multiShareArgs.getDataUri() != Uri.EMPTY) || - multiShareArgs.getStickerLocator() != null || - recipient.isGroup() || - recipient.getEmail().isPresent(); - boolean hasPushMedia = hasMmsMedia || - multiShareArgs.getLinkPreview() != null || - !mentions.isEmpty() || - needsSplit; + long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); + List mentions = getValidMentionsForRecipient(recipient, multiShareArgs.getMentions()); + TransportOption transport = resolveTransportOption(context, recipient); + boolean forceSms = recipient.isForceSmsSelection() && transport.isSms(); + int subscriptionId = transport.getSimSubscriptionId().orElse(-1); + long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds()); + boolean needsSplit = !transport.isSms() && + message != null && + message.length() > transport.calculateCharacters(message).maxPrimaryMessageSize; + boolean hasMmsMedia = !multiShareArgs.getMedia().isEmpty() || + (multiShareArgs.getDataUri() != null && multiShareArgs.getDataUri() != Uri.EMPTY) || + multiShareArgs.getStickerLocator() != null || + recipient.isGroup() || + recipient.getEmail().isPresent(); + boolean hasPushMedia = hasMmsMedia || + multiShareArgs.getLinkPreview() != null || + !mentions.isEmpty() || + needsSplit; + long sentTimestamp = recipient.isDistributionList() ? distributionListSentTimestamp : System.currentTimeMillis(); + boolean canSendAsTextStory = recipientSearchKey.isStory() && multiShareArgs.isValidForTextStoryGeneration(); if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !isMmsEnabled) { - results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.MMS_NOT_ENABLED)); - } else if (hasMmsMedia && transport.isSms() || hasPushMedia && !transport.isSms() || multiShareArgs.isTextStory()) { - sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, shareContactAndThread.getThreadId(), forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId, mentions, shareContactAndThread.isStory()); - results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); - } else if (shareContactAndThread.isStory()) { - results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.INVALID_SHARE_TO_STORY)); + results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.MMS_NOT_ENABLED)); + } else if (hasMmsMedia && transport.isSms() || hasPushMedia && !transport.isSms() || canSendAsTextStory) { + sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, threadId, forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId, mentions, recipientSearchKey.isStory(), sentTimestamp, canSendAsTextStory); + results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.SUCCESS)); + } else if (recipientSearchKey.isStory()) { + results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.INVALID_SHARE_TO_STORY)); } else { - sendTextMessage(context, multiShareArgs, recipient, shareContactAndThread.getThreadId(), forceSms, expiresIn, subscriptionId); - results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); + sendTextMessage(context, multiShareArgs, recipient, threadId, forceSms, expiresIn, subscriptionId); + results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.SUCCESS)); } // XXX We must do this to avoid sending out messages to the same recipient with the same @@ -122,9 +134,9 @@ public final class MultiShareSender { return new MultiShareSendResultCollection(results); } - public static @NonNull TransportOption getWorstTransportOption(@NonNull Context context, @NonNull Set shareContactAndThreads) { - for (ShareContactAndThread shareContactAndThread : shareContactAndThreads) { - TransportOption option = resolveTransportOption(context, shareContactAndThread.isForceSms() && !shareContactAndThread.isStory()); + public static @NonNull TransportOption getWorstTransportOption(@NonNull Context context, @NonNull Set recipientSearchKeys) { + for (ContactSearchKey.RecipientSearchKey recipientSearchKey : recipientSearchKeys) { + TransportOption option = resolveTransportOption(context, Recipient.resolved(recipientSearchKey.getRecipientId()).isForceSmsSelection() && !recipientSearchKey.isStory()); if (option.isSms()) { return option; } @@ -158,7 +170,9 @@ public final class MultiShareSender { boolean isViewOnce, int subscriptionId, @NonNull List validatedMentions, - boolean isStory) + boolean isStory, + long sentTimestamp, + boolean canSendAsTextStory) { String body = multiShareArgs.getDraftText(); if (transportOption.isType(TransportOption.Type.TEXTSECURE) && !forceSms && body != null) { @@ -188,7 +202,7 @@ public final class MultiShareSender { OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, new SlideDeck(), body, - System.currentTimeMillis(), + sentTimestamp, subscriptionId, 0L, false, @@ -203,6 +217,8 @@ public final class MultiShareSender { Collections.emptyList()); outgoingMessages.add(outgoingMediaMessage); + } else if (canSendAsTextStory) { + outgoingMessages.add(generateTextStory(recipient, multiShareArgs, sentTimestamp, storyType)); } else { for (final Slide slide : slideDeck.getSlides()) { SlideDeck singletonDeck = new SlideDeck(); @@ -211,7 +227,7 @@ public final class MultiShareSender { OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, singletonDeck, body, - System.currentTimeMillis(), + sentTimestamp, subscriptionId, 0L, false, @@ -225,17 +241,13 @@ public final class MultiShareSender { validatedMentions); outgoingMessages.add(outgoingMediaMessage); - - // XXX We must do this to avoid sending out messages to the same recipient with the same - // sentTimestamp. If we do this, they'll be considered dupes by the receiver. - ThreadUtil.sleep(5); } } } else { OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, slideDeck, body, - System.currentTimeMillis(), + sentTimestamp, subscriptionId, expiresIn, isViewOnce, @@ -283,6 +295,59 @@ public final class MultiShareSender { MessageSender.send(context, outgoingTextMessage, threadId, forceSms, null, null); } + private static @NonNull OutgoingMediaMessage generateTextStory(@NonNull Recipient recipient, + @NonNull MultiShareArgs multiShareArgs, + long sentTimestamp, + @NonNull StoryType storyType) + { + return new OutgoingMediaMessage( + recipient, + Base64.encodeBytes(StoryTextPost.newBuilder() + .setBody(getBodyForTextStory(multiShareArgs.getDraftText(), multiShareArgs.getLinkPreview())) + .setStyle(StoryTextPost.Style.DEFAULT) + .setBackground(TextStoryBackgroundColors.getRandomBackgroundColor().serialize()) + .setTextBackgroundColor(0) + .setTextForegroundColor(Color.WHITE) + .build() + .toByteArray()), + Collections.emptyList(), + sentTimestamp, + -1, + 0, + false, + ThreadDatabase.DistributionTypes.DEFAULT, + storyType.toTextStoryType(), + null, + false, + null, + Collections.emptyList(), + multiShareArgs.getLinkPreview() != null ? Collections.singletonList(multiShareArgs.getLinkPreview()) + : Collections.emptyList(), + Collections.emptyList(), + Collections.emptySet(), + Collections.emptySet()); + } + + private static @NonNull String getBodyForTextStory(@Nullable String draftText, @Nullable LinkPreview linkPreview) { + if (Util.isEmpty(draftText)) { + return ""; + } + + BreakIteratorCompat breakIteratorCompat = BreakIteratorCompat.getInstance(); + breakIteratorCompat.setText(draftText); + + String trimmed = breakIteratorCompat.take(Stories.MAX_BODY_SIZE).toString(); + if (linkPreview == null) { + return trimmed; + } + + if (linkPreview.getUrl().equals(trimmed)) { + return ""; + } + + return trimmed.replace(linkPreview.getUrl(), "").trim(); + } + private static boolean shouldSendAsPush(@NonNull Recipient recipient, boolean forceSms) { return recipient.isDistributionList() || recipient.isServiceIdOnly() || @@ -346,16 +411,16 @@ public final class MultiShareSender { } private static final class MultiShareSendResult { - private final ShareContactAndThread contactAndThread; - private final Type type; + private final ContactSearchKey.RecipientSearchKey recipientSearchKey; + private final Type type; - private MultiShareSendResult(ShareContactAndThread contactAndThread, Type type) { - this.contactAndThread = contactAndThread; - this.type = type; + private MultiShareSendResult(ContactSearchKey.RecipientSearchKey contactSearchKey, Type type) { + this.recipientSearchKey = contactSearchKey; + this.type = type; } - public ShareContactAndThread getContactAndThread() { - return contactAndThread; + public ContactSearchKey.RecipientSearchKey getContactSearchKey() { + return recipientSearchKey; } public Type getType() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java deleted file mode 100644 index fdce1974cb..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java +++ /dev/null @@ -1,745 +0,0 @@ -/* - * Copyright (C) 2014-2017 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.thoughtcrime.securesms.sharing; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; -import android.view.MenuItem; -import android.view.View; -import android.widget.ImageView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.Toolbar; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.core.content.pm.ShortcutInfoCompat; -import androidx.core.content.pm.ShortcutManagerCompat; -import androidx.core.util.Consumer; -import androidx.lifecycle.ViewModelProviders; -import androidx.recyclerview.widget.RecyclerView; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.ContactSelectionListFragment; -import org.thoughtcrime.securesms.PassphraseRequiredActivity; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.SearchToolbar; -import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; -import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; -import org.thoughtcrime.securesms.conversation.ConversationIntents; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.ThreadDatabase; -import org.thoughtcrime.securesms.mediasend.Media; -import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.sharing.interstitial.ShareInterstitialActivity; -import org.thoughtcrime.securesms.stories.Stories; -import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs; -import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment; -import org.thoughtcrime.securesms.util.ConversationUtil; -import org.thoughtcrime.securesms.util.DynamicLanguage; -import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; -import org.thoughtcrime.securesms.util.DynamicTheme; -import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.LifecycleDisposable; -import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.ViewUtil; -import org.signal.core.util.concurrent.SimpleTask; -import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.atomic.AtomicReference; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.schedulers.Schedulers; -import kotlin.Unit; - -/** - * Entry point for sharing content into the app. - * - * Handles contact selection when necessary, but also serves as an entry point for when the contact - * is known (such as choosing someone in a direct share). - */ -public class ShareActivity extends PassphraseRequiredActivity - implements ContactSelectionListFragment.OnContactSelectedListener, - ContactSelectionListFragment.OnSelectionLimitReachedListener -{ - private static final String TAG = Log.tag(ShareActivity.class); - - private static final short RESULT_TEXT_CONFIRMATION = 1; - private static final short RESULT_MEDIA_CONFIRMATION = 2; - - public static final String EXTRA_THREAD_ID = "thread_id"; - public static final String EXTRA_RECIPIENT_ID = "recipient_id"; - public static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type"; - - private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); - private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); - - private ConstraintLayout shareContainer; - private ContactSelectionListFragment contactsFragment; - private SearchToolbar searchToolbar; - private ImageView searchAction; - private View shareConfirm; - private RecyclerView contactsRecycler; - private View contactsRecyclerDivider; - private ShareSelectionAdapter adapter; - private boolean disallowMultiShare; - - private ShareIntents.Args args; - private ShareViewModel viewModel; - - private final LifecycleDisposable disposables = new LifecycleDisposable(); - - @Override - protected void onPreCreate() { - dynamicTheme.onCreate(this); - dynamicLanguage.onCreate(this); - } - - @Override - protected void onCreate(Bundle icicle, boolean ready) { - setContentView(R.layout.share_activity); - - disposables.bindTo(getLifecycle()); - - initializeArgs(); - initializeViewModel(); - initializeMedia(); - initializeIntent(); - initializeToolbar(); - initializeResources(); - initializeSearch(); - } - - @Override - public void onResume() { - Log.i(TAG, "onResume()"); - super.onResume(); - dynamicTheme.onResume(this); - dynamicLanguage.onResume(this); - handleDirectShare(); - } - - @Override - public void onStop() { - super.onStop(); - - if (!isFinishing() && !viewModel.isMultiShare()) { - finish(); - } - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - if (item.getItemId() == android.R.id.home) { - onBackPressed(); - return true; - } else { - return super.onOptionsItemSelected(item); - } - } - - @Override - public void onBackPressed() { - if (searchToolbar.isVisible()) searchToolbar.collapse(); - else super.onBackPressed(); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (resultCode == RESULT_OK) { - switch (requestCode) { - case RESULT_MEDIA_CONFIRMATION: - case RESULT_TEXT_CONFIRMATION: - viewModel.onSuccessfulShare(); - finish(); - break; - default: - super.onActivityResult(requestCode, resultCode, data); - } - } else { - shareConfirm.setClickable(true); - super.onActivityResult(requestCode, resultCode, data); - } - } - - @Override - public void onBeforeContactSelected(@NonNull Optional recipientId, String number, @NonNull java.util.function.Consumer callback) { - if (disallowMultiShare) { - Toast.makeText(this, R.string.ShareActivity__sharing_to_multiple_chats_is, Toast.LENGTH_LONG).show(); - callback.accept(false); - } else { - disposables.add(viewModel.onContactSelected(new ShareContact(recipientId, number)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - switch (result) { - case TRUE: - callback.accept(true); - break; - case FALSE: - callback.accept(false); - break; - case FALSE_AND_SHOW_PERMISSION_TOAST: - Toast.makeText(this, R.string.ShareActivity_you_do_not_have_permission_to_send_to_this_group, Toast.LENGTH_SHORT).show(); - callback.accept(false); - break; - case FALSE_AND_SHOW_SMS_MULTISELECT_TOAST: - Toast.makeText(this, R.string.ShareActivity__sharing_to_multiple_chats_is, Toast.LENGTH_LONG).show(); - callback.accept(false); - break; - } - })); - } - } - - @Override - public void onContactDeselected(@NonNull Optional recipientId, String number) { - viewModel.onContactDeselected(new ShareContact(recipientId, number)); - } - - @Override - public void onSelectionChanged() { - } - - private void animateInSelection() { - contactsRecyclerDivider.animate() - .alpha(1f) - .translationY(0); - contactsRecycler.animate() - .alpha(1f) - .translationY(0); - } - - private void animateOutSelection() { - contactsRecyclerDivider.animate() - .alpha(0f) - .translationY(ViewUtil.dpToPx(48)); - contactsRecycler.animate() - .alpha(0f) - .translationY(ViewUtil.dpToPx(48)); - } - - private void initializeIntent() { - if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { - int mode = DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_SELF | DisplayMode.FLAG_HIDE_NEW; - - if (Util.isDefaultSmsProvider(this)) { - mode |= DisplayMode.FLAG_SMS; - } - - mode |= DisplayMode.FLAG_HIDE_GROUPS_V1; - - getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, mode); - } - - getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); - getIntent().putExtra(ContactSelectionListFragment.RECENTS, true); - getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.shareSelectionLimit()); - getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true); - getIntent().putExtra(ContactSelectionListFragment.DISPLAY_CHIPS, false); - getIntent().putExtra(ContactSelectionListFragment.CAN_SELECT_SELF, true); - getIntent().putExtra(ContactSelectionListFragment.RV_CLIP, false); - getIntent().putExtra(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(48)); - } - - private void handleDirectShare() { - boolean isDirectShare = getIntent().hasExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID); - boolean intentHasRecipient = getIntent().hasExtra(EXTRA_RECIPIENT_ID); - - if (intentHasRecipient) { - handleDestination(); - } else if (isDirectShare) { - String extraShortcutId = getIntent().getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID); - SimpleTask.run(getLifecycle(), - () -> getDirectShareExtras(extraShortcutId), - extras -> { - if (extras != null) { - addShortcutExtrasToIntent(extras); - handleDestination(); - } - } - ); - } - } - - /** - * @param extraShortcutId EXTRA_SHORTCUT_ID String as included in direct share intent - * @return shortcutExtras or null - */ - @WorkerThread - private @Nullable Bundle getDirectShareExtras(@NonNull String extraShortcutId) { - Bundle shortcutExtras = getShortcutExtrasFor(extraShortcutId); - if (shortcutExtras == null) { - shortcutExtras = createExtrasFromExtraShortcutId(extraShortcutId); - } - return shortcutExtras; - } - - /** - * Search for dynamic shortcut originally declared in {@link ConversationUtil} and return extras - * - * @param extraShortcutId EXTRA_SHORTCUT_ID String as included in direct share intent - * @return shortcutExtras or null - */ - @WorkerThread - private @Nullable Bundle getShortcutExtrasFor(@NonNull String extraShortcutId) { - List shortcuts = ShortcutManagerCompat.getDynamicShortcuts(this); - for (ShortcutInfoCompat shortcutInfo : shortcuts) { - if (extraShortcutId.equals(shortcutInfo.getId())) { - return shortcutInfo.getIntent().getExtras(); - } - } - return null; - } - - /** - * @param extraShortcutId EXTRA_SHORTCUT_ID string as included in direct share intent - */ - @WorkerThread - private @Nullable Bundle createExtrasFromExtraShortcutId(@NonNull String extraShortcutId) { - Bundle extras = new Bundle(); - RecipientId recipientId = ConversationUtil.getRecipientId(extraShortcutId); - Long threadId = null; - int distributionType = ThreadDatabase.DistributionTypes.DEFAULT; - - if (recipientId != null) { - threadId = SignalDatabase.threads().getThreadIdFor(recipientId); - extras.putString(EXTRA_RECIPIENT_ID, recipientId.serialize()); - extras.putLong(EXTRA_THREAD_ID, threadId != null ? threadId : -1); - extras.putInt(EXTRA_DISTRIBUTION_TYPE, distributionType); - return extras; - } - return null; - } - - /** - * @param shortcutExtras as found by {@link ShareActivity#getShortcutExtrasFor)} or - * {@link ShareActivity#createExtrasFromExtraShortcutId)} - */ - private void addShortcutExtrasToIntent(@NonNull Bundle shortcutExtras) { - getIntent().putExtra(EXTRA_RECIPIENT_ID, shortcutExtras.getString(EXTRA_RECIPIENT_ID, null)); - getIntent().putExtra(EXTRA_THREAD_ID, shortcutExtras.getLong(EXTRA_THREAD_ID, -1)); - getIntent().putExtra(EXTRA_DISTRIBUTION_TYPE, shortcutExtras.getInt(EXTRA_DISTRIBUTION_TYPE, -1)); - } - - private void initializeToolbar() { - Toolbar toolbar = findViewById(R.id.toolbar); - setSupportActionBar(toolbar); - - ActionBar actionBar = getSupportActionBar(); - - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - } - } - - private void initializeResources() { - searchToolbar = findViewById(R.id.search_toolbar); - searchAction = findViewById(R.id.search_action); - shareConfirm = findViewById(R.id.share_confirm); - shareContainer = findViewById(R.id.container); - contactsFragment = new ContactSelectionListFragment(); - adapter = new ShareSelectionAdapter(); - contactsRecycler = findViewById(R.id.selected_list); - contactsRecyclerDivider = findViewById(R.id.divider); - - contactsRecycler.setAdapter(adapter); - - RecyclerView.ItemAnimator itemAnimator = Objects.requireNonNull(contactsRecycler.getItemAnimator()); - ShareFlowConstants.applySelectedContactsRecyclerAnimationSpeeds(itemAnimator); - - getSupportFragmentManager().beginTransaction() - .replace(R.id.contact_selection_list_fragment, contactsFragment) - .commit(); - - shareConfirm.setOnClickListener(unused -> { - shareConfirm.setEnabled(false); - - Set shareContacts = viewModel.getShareContacts(); - - StoryDialogs.INSTANCE.guardWithAddToYourStoryDialog(this, - shareContacts.stream() - .filter(contact -> contact.getRecipientId().isPresent()) - .map(contact -> Recipient.resolved(contact.getRecipientId().get())) - .filter(Recipient::isMyStory) - .map(myStory -> new ContactSearchKey.Story(myStory.getId())) - .collect(java.util.stream.Collectors.toList()), - () -> { - performSend(shareContacts); - return Unit.INSTANCE; - }, - () -> { - shareConfirm.setEnabled(true); - new HideStoryFromDialogFragment().show(getSupportFragmentManager(), null); - return Unit.INSTANCE; - }, - () -> { - shareConfirm.setEnabled(true); - return Unit.INSTANCE; - }); - }); - - viewModel.getSelectedContactModels().observe(this, models -> { - adapter.submitList(models, () -> contactsRecycler.scrollToPosition(models.size() - 1)); - - shareConfirm.setEnabled(!models.isEmpty()); - shareConfirm.setAlpha(models.isEmpty() ? 0.5f : 1f); - if (models.isEmpty()) { - animateOutSelection(); - } else { - animateInSelection(); - } - }); - - viewModel.getSmsShareRestriction().observe(this, smsShareRestriction -> { - final int displayMode; - - switch (smsShareRestriction) { - case NO_RESTRICTIONS: - disallowMultiShare = false; - displayMode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1); - - if (displayMode == -1) { - Log.w(TAG, "DisplayMode not set yet."); - return; - } - - if (Util.isDefaultSmsProvider(this) && (displayMode & DisplayMode.FLAG_SMS) == 0) { - getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode | DisplayMode.FLAG_SMS); - contactsFragment.setQueryFilter(null); - } - break; - case DISALLOW_SMS_CONTACTS: - disallowMultiShare = false; - displayMode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1); - - if (displayMode == -1) { - Log.w(TAG, "DisplayMode not set yet."); - return; - } - - getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode & ~DisplayMode.FLAG_SMS); - contactsFragment.setQueryFilter(null); - break; - case DISALLOW_MULTI_SHARE: - disallowMultiShare = true; - break; - } - - validateAvailableRecipients(); - }); - } - - private void performSend(Set shareContacts) { - if (shareContacts.isEmpty()) throw new AssertionError(); - else if (shareContacts.size() == 1) onConfirmSingleDestination(shareContacts.iterator().next()); - else onConfirmMultipleDestinations(shareContacts); - } - - private void initializeArgs() { - this.args = ShareIntents.Args.from(getIntent()); - } - - private void initializeViewModel() { - this.viewModel = ViewModelProviders.of(this, new ShareViewModel.Factory()).get(ShareViewModel.class); - } - - private void initializeSearch() { - //noinspection IntegerDivisionInFloatingPointContext - searchAction.setOnClickListener(v -> searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2), - searchAction.getY() + (searchAction.getHeight() / 2))); - - searchToolbar.setListener(new SearchToolbar.SearchListener() { - @Override - public void onSearchTextChange(String text) { - if (contactsFragment != null) { - contactsFragment.setQueryFilter(text); - } - } - - @Override - public void onSearchClosed() { - if (contactsFragment != null) { - contactsFragment.resetQueryFilter(); - } - } - }); - } - - private void initializeMedia() { - if (Intent.ACTION_SEND_MULTIPLE.equals(getIntent().getAction())) { - Log.i(TAG, "Multiple media share."); - List uris = getIntent().getParcelableArrayListExtra(Intent.EXTRA_STREAM); - - viewModel.onMultipleMediaShared(uris); - } else if (Intent.ACTION_SEND.equals(getIntent().getAction()) || getIntent().hasExtra(Intent.EXTRA_STREAM)) { - Log.i(TAG, "Single media share."); - Uri uri = getIntent().getParcelableExtra(Intent.EXTRA_STREAM); - String type = getIntent().getType(); - - viewModel.onSingleMediaShared(uri, type); - } else { - Log.i(TAG, "Internal media share."); - viewModel.onNonExternalShare(); - } - } - - private void handleDestination() { - Intent intent = getIntent(); - long threadId = intent.getLongExtra(EXTRA_THREAD_ID, -1); - int distributionType = intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, -1); - RecipientId recipientId = RecipientId.from(intent.getStringExtra(EXTRA_RECIPIENT_ID)); - - boolean hasPreexistingDestination = threadId != -1 && distributionType != -1; - - if (hasPreexistingDestination) { - if (contactsFragment.getView() != null) { - contactsFragment.getView().setVisibility(View.GONE); - } - onSingleDestinationChosen(threadId, recipientId); - } - } - - private void onConfirmSingleDestination(@NonNull ShareContact shareContact) { - if (shareContact.getRecipientId().isPresent() && Recipient.resolved(shareContact.getRecipientId().get()).isDistributionList()) { - onConfirmMultipleDestinations(Collections.singleton(shareContact)); - return; - } - - shareConfirm.setClickable(false); - SimpleTask.run(this.getLifecycle(), - () -> resolveShareContact(shareContact), - result -> onSingleDestinationChosen(result.getThreadId(), result.getRecipientId())); - } - - private void onConfirmMultipleDestinations(@NonNull Set shareContacts) { - shareConfirm.setClickable(false); - SimpleTask.run(this.getLifecycle(), - () -> resolvedShareContacts(shareContacts), - this::onMultipleDestinationsChosen); - } - - private Set resolvedShareContacts(@NonNull Set sharedContacts) { - Set recipients = Stream.of(sharedContacts) - .map(contact -> contact.getRecipientId() - .map(Recipient::resolved) - .orElseGet(() -> Recipient.external(this, contact.getNumber()))) - .collect(Collectors.toSet()); - - Map existingThreads = SignalDatabase.threads() - .getThreadIdsIfExistsFor(Stream.of(recipients) - .map(Recipient::getId) - .toArray(RecipientId[]::new)); - - return Stream.of(recipients) - .map(recipient -> new ShareContactAndThread(recipient.getId(), Util.getOrDefault(existingThreads, recipient.getId(), -1L), recipient.isForceSmsSelection() || !recipient.isRegistered(), recipient.isDistributionList())) - .collect(Collectors.toSet()); - } - - @WorkerThread - private ShareContactAndThread resolveShareContact(@NonNull ShareContact shareContact) { - Recipient recipient; - if (shareContact.getRecipientId().isPresent()) { - recipient = Recipient.resolved(shareContact.getRecipientId().get()); - } else { - Log.i(TAG, "[onContactSelected] Maybe creating a new recipient."); - recipient = Recipient.external(this, shareContact.getNumber()); - } - - long existingThread = SignalDatabase.threads().getThreadIdIfExistsFor(recipient.getId()); - return new ShareContactAndThread(recipient.getId(), existingThread, recipient.isForceSmsSelection() || !recipient.isRegistered(), recipient.isDistributionList()); - } - - private void validateAvailableRecipients() { - resolveShareData(data -> { - int mode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1); - - if (mode == -1) return; - - boolean isMmsOrSmsSupported = data != null ? data.isMmsOrSmsSupported() : Util.isDefaultSmsProvider(this); - boolean isStoriesSupported = Stories.isFeatureEnabled() && data != null && data.isStoriesSupported(); - - mode = isMmsOrSmsSupported ? mode | DisplayMode.FLAG_SMS : mode & ~DisplayMode.FLAG_SMS; - mode = isStoriesSupported ? mode | DisplayMode.FLAG_STORIES : mode & ~DisplayMode.FLAG_STORIES; - getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, mode); - - contactsFragment.reset(); - }); - } - - private void resolveShareData(@NonNull Consumer onResolved) { - AtomicReference progressWheel = new AtomicReference<>(); - - if (viewModel.getShareData().getValue() == null) { - progressWheel.set(SimpleProgressDialog.show(this)); - } - - viewModel.getShareData().observe(this, (data) -> { - if (data == null) return; - - if (progressWheel.get() != null) { - progressWheel.get().dismiss(); - progressWheel.set(null); - } - - if (!data.isPresent() && args.isEmpty()) { - Log.w(TAG, "No data to share!"); - Toast.makeText(this, R.string.ShareActivity_multiple_attachments_are_only_supported, Toast.LENGTH_LONG).show(); - finish(); - return; - } - - onResolved.accept(data.orElse(null)); - }); - } - - private void onMultipleDestinationsChosen(@NonNull Set shareContactAndThreads) { - if (!viewModel.isExternalShare()) { - openInterstitial(shareContactAndThreads, null); - return; - } - - resolveShareData(data -> openInterstitial(shareContactAndThreads, data)); - } - - private void onSingleDestinationChosen(long threadId, @NonNull RecipientId recipientId) { - if (!viewModel.isExternalShare()) { - openConversation(threadId, recipientId, null); - return; - } - - resolveShareData(data -> openConversation(threadId, recipientId, data)); - } - - private void openConversation(long threadId, @NonNull RecipientId recipientId, @Nullable ShareData shareData) { - ConversationIntents.Builder builder = ConversationIntents.createBuilder(this, recipientId, threadId) - .withMedia(args.getExtraMedia()) - .withDraftText(args.getExtraText() != null ? args.getExtraText().toString() : null) - .withStickerLocator(args.getExtraSticker()) - .asBorderless(args.isBorderless()); - - if (shareData != null && shareData.isForIntent()) { - Log.i(TAG, "Shared data is a single file."); - builder.withDataUri(shareData.getUri()) - .withDataType(shareData.getMimeType()); - } else if (shareData != null && shareData.isForMedia()) { - Log.i(TAG, "Shared data is set of media."); - builder.withMedia(shareData.getMedia()); - } else if (shareData != null && shareData.isForPrimitive()) { - Log.i(TAG, "Shared data is a primitive type."); - } else if (shareData == null && args.getExtraSticker() != null) { - builder.withDataType(getIntent().getType()); - } else { - Log.i(TAG, "Shared data was not external."); - } - - viewModel.onSuccessfulShare(); - - finish(); - startActivity(builder.build()); - } - - private void openInterstitial(@NonNull Set shareContactAndThreads, @Nullable ShareData shareData) { - MultiShareArgs.Builder builder = new MultiShareArgs.Builder(shareContactAndThreads) - .withMedia(args.getExtraMedia()) - .withDraftText(args.getExtraText() != null ? args.getExtraText().toString() : null) - .withStickerLocator(args.getExtraSticker()) - .asBorderless(args.isBorderless()); - - if (shareData != null && shareData.isForIntent()) { - Log.i(TAG, "Shared data is a single file."); - builder.withDataUri(shareData.getUri()) - .withDataType(shareData.getMimeType()); - } else if (shareData != null && shareData.isForMedia()) { - Log.i(TAG, "Shared data is set of media."); - builder.withMedia(shareData.getMedia()); - } else if (shareData != null && shareData.isForPrimitive()) { - Log.i(TAG, "Shared data is a primitive type."); - } else if (shareData == null && args.getExtraSticker() != null) { - builder.withDataType(getIntent().getType()); - } else { - Log.i(TAG, "Shared data was not external."); - } - - MultiShareArgs multiShareArgs = builder.build(); - InterstitialContentType interstitialContentType = multiShareArgs.getInterstitialContentType(); - switch (interstitialContentType) { - case TEXT: - startActivityForResult(ShareInterstitialActivity.createIntent(this, multiShareArgs), RESULT_TEXT_CONFIRMATION); - break; - case MEDIA: - List media = new ArrayList<>(multiShareArgs.getMedia()); - if (media.isEmpty()) { - media.add(new Media(multiShareArgs.getDataUri(), - multiShareArgs.getDataType(), - 0, - 0, - 0, - 0, - 0, - false, - false, - Optional.empty(), - Optional.empty(), - Optional.empty())); - } - - Intent intent = MediaSelectionActivity.share(this, - MultiShareSender.getWorstTransportOption(this, multiShareArgs.getShareContactAndThreads()), - media, - Stream.of(multiShareArgs.getShareContactAndThreads()).map(ShareContactAndThread::getRecipientId).toList(), - multiShareArgs.getDraftText()); - startActivityForResult(intent, RESULT_MEDIA_CONFIRMATION); - break; - default: - //noinspection CodeBlock2Expr - MultiShareSender.send(multiShareArgs, results -> { - MultiShareDialogs.displayResultDialog(this, results, () -> { - viewModel.onSuccessfulShare(); - finish(); - }); - }); - break; - } - } - - @Override - public void onSuggestedLimitReached(int limit) { - } - - @Override - public void onHardLimitReached(int limit) { - MultiShareDialogs.displayMaxSelectedDialog(this, limit); - } -} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java deleted file mode 100644 index b36c73ac63..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.thoughtcrime.securesms.sharing; - -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.recipients.RecipientId; - -import java.util.Objects; - -public final class ShareContactAndThread implements Parcelable { - private final RecipientId recipientId; - private final long threadId; - private final boolean forceSms; - private final boolean isStory; - - public ShareContactAndThread(@NonNull RecipientId recipientId, long threadId, boolean forceSms, boolean isStory) { - this.recipientId = recipientId; - this.threadId = threadId; - this.forceSms = forceSms; - this.isStory = isStory; - } - - protected ShareContactAndThread(@NonNull Parcel in) { - recipientId = in.readParcelable(RecipientId.class.getClassLoader()); - threadId = in.readLong(); - forceSms = in.readByte() == 1; - isStory = in.readByte() == 1; - } - - public @NonNull RecipientId getRecipientId() { - return recipientId; - } - - public long getThreadId() { - return threadId; - } - - public boolean isForceSms() { - return forceSms; - } - - public boolean isStory() { - return isStory; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - ShareContactAndThread that = (ShareContactAndThread) o; - return threadId == that.threadId && - forceSms == that.forceSms && - isStory == that.isStory && - recipientId.equals(that.recipientId); - } - - @Override - public int hashCode() { - return Objects.hash(recipientId, threadId, forceSms, isStory); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeParcelable(recipientId, flags); - dest.writeLong(threadId); - dest.writeByte((byte) (forceSms ? 1 : 0)); - dest.writeByte((byte) (isStory ? 1 : 0)); - } - - public static final Creator CREATOR = new Creator() { - @Override - public ShareContactAndThread createFromParcel(@NonNull Parcel in) { - return new ShareContactAndThread(in); - } - - @Override - public ShareContactAndThread[] newArray(int size) { - return new ShareContactAndThread[size]; - } - }; - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java deleted file mode 100644 index 8fdf540634..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.thoughtcrime.securesms.sharing; - -import android.net.Uri; - -import androidx.annotation.NonNull; - -import org.thoughtcrime.securesms.mediasend.Media; -import org.thoughtcrime.securesms.util.MediaUtil; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -class ShareData { - - private final Optional uri; - private final Optional mimeType; - private final Optional> media; - private final boolean external; - private final boolean isMmsOrSmsSupported; - - static ShareData forIntentData(@NonNull Uri uri, @NonNull String mimeType, boolean external, boolean isMmsOrSmsSupported) { - return new ShareData(Optional.of(uri), Optional.of(mimeType), Optional.empty(), external, isMmsOrSmsSupported); - } - - static ShareData forPrimitiveTypes() { - return new ShareData(Optional.empty(), Optional.empty(), Optional.empty(), true, true); - } - - static ShareData forMedia(@NonNull List media, boolean isMmsOrSmsSupported) { - return new ShareData(Optional.empty(), Optional.empty(), Optional.of(new ArrayList<>(media)), true, isMmsOrSmsSupported); - } - - private ShareData(Optional uri, Optional mimeType, Optional> media, boolean external, boolean isMmsOrSmsSupported) { - this.uri = uri; - this.mimeType = mimeType; - this.media = media; - this.external = external; - this.isMmsOrSmsSupported = isMmsOrSmsSupported; - } - - boolean isForIntent() { - return uri.isPresent(); - } - - boolean isForPrimitive() { - return !uri.isPresent() && !media.isPresent(); - } - - boolean isForMedia() { - return media.isPresent(); - } - - public @NonNull Uri getUri() { - return uri.get(); - } - - public @NonNull String getMimeType() { - return mimeType.get(); - } - - public @NonNull ArrayList getMedia() { - return media.get(); - } - - public boolean isExternal() { - return external; - } - - public boolean isMmsOrSmsSupported() { - return isMmsOrSmsSupported; - } - - public boolean isStoriesSupported() { - if (isForIntent()) { - return MediaUtil.isStorySupportedType(getMimeType()); - } else if (isForMedia()) { - return getMedia().stream().allMatch(media -> MediaUtil.isStorySupportedType(media.getMimeType())); - } else { - return false; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareIntents.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareIntents.java deleted file mode 100644 index 59e6fc0460..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareIntents.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.thoughtcrime.securesms.sharing; - -import android.content.Intent; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.thoughtcrime.securesms.mediasend.Media; -import org.thoughtcrime.securesms.stickers.StickerLocator; - -import java.util.ArrayList; - -final class ShareIntents { - - private static final String EXTRA_MEDIA = "extra_media"; - private static final String EXTRA_BORDERLESS = "extra_borderless"; - private static final String EXTRA_STICKER = "extra_sticker"; - - private ShareIntents() { - } - - public static final class Args { - - private final CharSequence extraText; - private final ArrayList extraMedia; - private final StickerLocator extraSticker; - private final boolean isBorderless; - - public static Args from(@NonNull Intent intent) { - return new Args(intent.getStringExtra(Intent.EXTRA_TEXT), - intent.getParcelableArrayListExtra(EXTRA_MEDIA), - intent.getParcelableExtra(EXTRA_STICKER), - intent.getBooleanExtra(EXTRA_BORDERLESS, false)); - } - - private Args(@Nullable CharSequence extraText, - @Nullable ArrayList extraMedia, - @Nullable StickerLocator extraSticker, - boolean isBorderless) - { - this.extraText = extraText; - this.extraMedia = extraMedia; - this.extraSticker = extraSticker; - this.isBorderless = isBorderless; - } - - public @Nullable ArrayList getExtraMedia() { - return extraMedia; - } - - public @Nullable CharSequence getExtraText() { - return extraText; - } - - public @Nullable StickerLocator getExtraSticker() { - return extraSticker; - } - - public boolean isBorderless() { - return isBorderless; - } - - public boolean isEmpty() { - return extraSticker == null && - (extraMedia == null || extraMedia.isEmpty()) && - TextUtils.isEmpty(extraText); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java deleted file mode 100644 index ddea09df07..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java +++ /dev/null @@ -1,256 +0,0 @@ -package org.thoughtcrime.securesms.sharing; - -import android.Manifest; -import android.content.Context; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.net.Uri; -import android.provider.OpenableColumns; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; -import androidx.core.content.ContextCompat; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; - -import org.signal.core.util.concurrent.SignalExecutors; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.TransportOption; -import org.thoughtcrime.securesms.TransportOptions; -import org.thoughtcrime.securesms.attachments.Attachment; -import org.thoughtcrime.securesms.attachments.UriAttachment; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.mediasend.Media; -import org.thoughtcrime.securesms.mediasend.MediaSendConstants; -import org.thoughtcrime.securesms.mms.MediaConstraints; -import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.UriUtil; -import org.thoughtcrime.securesms.util.Util; - -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -class ShareRepository { - - private static final String TAG = Log.tag(ShareRepository.class); - - /** - * Handles a single URI that may be local or external. - */ - void getResolved(@Nullable Uri uri, @Nullable String mimeType, @NonNull Callback> callback) { - SignalExecutors.BOUNDED.execute(() -> { - try { - callback.onResult(Optional.of(getResolvedInternal(uri, mimeType))); - } catch (IOException e) { - Log.w(TAG, "Failed to resolve!", e); - callback.onResult(Optional.empty()); - } - }); - } - - /** - * Handles multiple URIs that are all assumed to be external images/videos. - */ - void getResolved(@NonNull List uris, @NonNull Callback> callback) { - SignalExecutors.BOUNDED.execute(() -> { - try { - callback.onResult(Optional.ofNullable(getResolvedInternal(uris))); - } catch (IOException e) { - Log.w(TAG, "Failed to resolve!", e); - callback.onResult(Optional.empty()); - } - }); - } - - @WorkerThread - private @NonNull ShareData getResolvedInternal(@Nullable Uri uri, @Nullable String mimeType) throws IOException { - Context context = ApplicationDependencies.getApplication(); - - if (uri == null) { - return ShareData.forPrimitiveTypes(); - } - - if (!UriUtil.isValidExternalUri(context, uri)) { - throw new IOException("Invalid external URI!"); - } - - mimeType = getMimeType(context, uri, mimeType); - - if (PartAuthority.isLocalUri(uri)) { - return ShareData.forIntentData(uri, mimeType, false, false); - } else { - InputStream stream = null; - try { - stream = context.getContentResolver().openInputStream(uri); - } catch (SecurityException e) { - Log.w(TAG, "Failed to read stream!", e); - } - - if (stream == null) { - throw new IOException("Failed to open stream!"); - } - - long size = getSize(context, uri); - String fileName = getFileName(context, uri); - - Uri blobUri; - - if (MediaUtil.isImageType(mimeType) || MediaUtil.isVideoType(mimeType)) { - blobUri = BlobProvider.getInstance() - .forData(stream, size) - .withMimeType(mimeType) - .withFileName(fileName) - .createForSingleSessionOnDisk(context); - } else { - blobUri = BlobProvider.getInstance() - .forData(stream, size) - .withMimeType(mimeType) - .withFileName(fileName) - .createForSingleSessionOnDisk(context); - // TODO Convert to multi-session after file drafts are fixed. - } - - return ShareData.forIntentData(blobUri, mimeType, true, isMmsSupported(context, asUriAttachment(blobUri, mimeType, size))); - } - } - - private @NonNull UriAttachment asUriAttachment(@NonNull Uri uri, @NonNull String mimeType, long size) { - return new UriAttachment(uri, mimeType, -1, size, null, false, false, false, false, null, null, null, null, null); - } - - private boolean isMmsSupported(@NonNull Context context, @NonNull Attachment attachment) { - boolean canReadPhoneState = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED; - if (!Util.isDefaultSmsProvider(context) || !canReadPhoneState || !Util.isMmsCapable(context)) { - return false; - } - - TransportOptions options = new TransportOptions(context, true); - options.setDefaultTransport(TransportOption.Type.SMS); - MediaConstraints mmsConstraints = MediaConstraints.getMmsMediaConstraints(options.getSelectedTransport().getSimSubscriptionId().orElse(-1)); - - return mmsConstraints.isSatisfied(context, attachment) || mmsConstraints.canResize(attachment); - } - - @WorkerThread - private @Nullable ShareData getResolvedInternal(@NonNull List uris) throws IOException { - Context context = ApplicationDependencies.getApplication(); - - Map mimeTypes = Stream.of(uris) - .map(uri -> new Pair<>(uri, getMimeType(context, uri, null))) - .filter(p -> MediaUtil.isImageType(p.second) || MediaUtil.isVideoType(p.second)) - .collect(Collectors.toMap(p -> p.first, p -> p.second)); - - if (mimeTypes.isEmpty()) { - return null; - } - - List media = new ArrayList<>(mimeTypes.size()); - - for (Map.Entry entry : mimeTypes.entrySet()) { - Uri uri = entry.getKey(); - String mimeType = entry.getValue(); - - InputStream stream; - try { - stream = context.getContentResolver().openInputStream(uri); - if (stream == null) { - throw new IOException("Failed to open stream!"); - } - } catch (IOException e) { - Log.w(TAG, "Failed to open: " + uri); - continue; - } - - long size = getSize(context, uri); - Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); - long duration = getDuration(context, uri); - Uri blobUri = BlobProvider.getInstance() - .forData(stream, size) - .withMimeType(mimeType) - .createForSingleSessionOnDisk(context); - - media.add(new Media(blobUri, - mimeType, - System.currentTimeMillis(), - dimens.first, - dimens.second, - size, - duration, - false, - false, - Optional.of(Media.ALL_MEDIA_BUCKET_ID), - Optional.empty(), - Optional.empty())); - - if (media.size() >= MediaSendConstants.MAX_PUSH) { - Log.w(TAG, "Exceeded the attachment limit! Skipping the rest."); - break; - } - } - - if (media.size() > 0) { - boolean isMmsSupported = Stream.of(media) - .allMatch(m -> isMmsSupported(context, asUriAttachment(m.getUri(), m.getMimeType(), m.getSize()))); - return ShareData.forMedia(media, isMmsSupported); - } else { - return null; - } - } - - private static @NonNull String getMimeType(@NonNull Context context, @NonNull Uri uri, @Nullable String mimeType) { - String updatedMimeType = MediaUtil.getMimeType(context, uri); - - if (updatedMimeType == null) { - updatedMimeType = MediaUtil.getCorrectedMimeType(mimeType); - } - - return updatedMimeType != null ? updatedMimeType : MediaUtil.UNKNOWN; - } - - private static long getSize(@NonNull Context context, @NonNull Uri uri) throws IOException { - long size = 0; - - try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { - size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); - } - } - - if (size <= 0) { - size = MediaUtil.getMediaSize(context, uri); - } - - return size; - } - - private static @Nullable String getFileName(@NonNull Context context, @NonNull Uri uri) { - if (uri.getScheme().equalsIgnoreCase("file")) { - return uri.getLastPathSegment(); - } - - try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) >= 0) { - return cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); - } - } - - return null; - } - - private static long getDuration(@NonNull Context context, @NonNull Uri uri) { - return 0; - } - - interface Callback { - void onResult(@NonNull E result); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java deleted file mode 100644 index 0ee3b3ef56..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java +++ /dev/null @@ -1,190 +0,0 @@ -package org.thoughtcrime.securesms.sharing; - -import android.content.Context; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import androidx.lifecycle.ViewModel; -import androidx.lifecycle.ViewModelProvider; - -import com.annimon.stream.Stream; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.providers.BlobProvider; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.DefaultValueLiveData; -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList; - -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; - -import io.reactivex.rxjava3.core.Single; - -public class ShareViewModel extends ViewModel { - - private static final String TAG = Log.tag(ShareViewModel.class); - - private final Context context; - private final ShareRepository shareRepository; - private final MutableLiveData> shareData; - private final MutableLiveData> selectedContacts; - private final LiveData smsShareRestriction; - - private boolean mediaUsed; - private boolean externalShare; - - private ShareViewModel() { - this.context = ApplicationDependencies.getApplication(); - this.shareRepository = new ShareRepository(); - this.shareData = new MutableLiveData<>(); - this.selectedContacts = new DefaultValueLiveData<>(Collections.emptySet()); - this.smsShareRestriction = Transformations.map(selectedContacts, this::updateShareRestriction); - } - - void onSingleMediaShared(@NonNull Uri uri, @Nullable String mimeType) { - externalShare = true; - shareRepository.getResolved(uri, mimeType, shareData::postValue); - } - - void onMultipleMediaShared(@NonNull List uris) { - externalShare = true; - shareRepository.getResolved(uris, shareData::postValue); - } - - boolean isMultiShare() { - return selectedContacts.getValue().size() > 1; - } - - @NonNull Single onContactSelected(@NonNull ShareContact selectedContact) { - return Single.fromCallable(() -> { - if (selectedContact.getRecipientId().isPresent()) { - Recipient recipient = Recipient.resolved(selectedContact.getRecipientId().get()); - - if (recipient.isPushV2Group()) { - Optional record = SignalDatabase.groups().getGroup(recipient.requireGroupId()); - - if (record.isPresent() && record.get().isAnnouncementGroup() && !record.get().isAdmin(Recipient.self())) { - return ContactSelectResult.FALSE_AND_SHOW_PERMISSION_TOAST; - } - } else if (SmsShareRestriction.DISALLOW_SMS_CONTACTS.equals(smsShareRestriction.getValue()) && isRecipientAnSmsContact(recipient)) { - return ContactSelectResult.FALSE_AND_SHOW_SMS_MULTISELECT_TOAST; - } - } - - Set contacts = new LinkedHashSet<>(selectedContacts.getValue()); - if (contacts.add(selectedContact)) { - selectedContacts.postValue(contacts); - return ContactSelectResult.TRUE; - } else { - return ContactSelectResult.FALSE; - } - }); - } - - void onContactDeselected(@NonNull ShareContact selectedContact) { - Set contacts = new LinkedHashSet<>(selectedContacts.getValue()); - if (contacts.remove(selectedContact)) { - selectedContacts.setValue(contacts); - } - } - - @NonNull Set getShareContacts() { - Set contacts = selectedContacts.getValue(); - if (contacts == null) { - return Collections.emptySet(); - } else { - return contacts; - } - } - - @NonNull LiveData getSelectedContactModels() { - return Transformations.map(selectedContacts, set -> Stream.of(set) - .mapIndexed((i, c) -> new ShareSelectionMappingModel(c, i == 0)) - .collect(MappingModelList.toMappingModelList())); - } - - @NonNull LiveData getSmsShareRestriction() { - return Transformations.distinctUntilChanged(smsShareRestriction); - } - - void onNonExternalShare() { - shareData.setValue(Optional.empty()); - externalShare = false; - } - - public void onSuccessfulShare() { - mediaUsed = true; - } - - @NonNull LiveData> getShareData() { - return shareData; - } - - boolean isExternalShare() { - return externalShare; - } - - @Override - protected void onCleared() { - ShareData data = shareData.getValue() != null ? shareData.getValue().orElse(null) : null; - - if (data != null && data.isExternal() && data.isForIntent() && !mediaUsed) { - Log.i(TAG, "Clearing out unused data."); - BlobProvider.getInstance().delete(context, data.getUri()); - } - } - - private @NonNull SmsShareRestriction updateShareRestriction(@NonNull Set shareContacts) { - if (shareContacts.isEmpty()) { - return SmsShareRestriction.NO_RESTRICTIONS; - } else if (shareContacts.size() == 1) { - ShareContact shareContact = shareContacts.iterator().next(); - - if (shareContact.getRecipientId().isPresent()) { - Recipient recipient = Recipient.live(shareContact.getRecipientId().get()).get(); - - if (isRecipientAnSmsContact(recipient)) { - return SmsShareRestriction.DISALLOW_MULTI_SHARE; - } else { - return SmsShareRestriction.DISALLOW_SMS_CONTACTS; - } - } else { - return SmsShareRestriction.DISALLOW_MULTI_SHARE; - } - } else { - return SmsShareRestriction.DISALLOW_SMS_CONTACTS; - } - } - - private static boolean isRecipientAnSmsContact(@NonNull Recipient recipient) { - return !recipient.isDistributionList() && (!recipient.isRegistered() || recipient.isForceSmsSelection()); - } - - enum ContactSelectResult { - TRUE, FALSE, FALSE_AND_SHOW_PERMISSION_TOAST, FALSE_AND_SHOW_SMS_MULTISELECT_TOAST - } - - enum SmsShareRestriction { - NO_RESTRICTIONS, - DISALLOW_SMS_CONTACTS, - DISALLOW_MULTI_SHARE - } - - public static class Factory extends ViewModelProvider.NewInstanceFactory { - @Override - public @NonNull T create(@NonNull Class modelClass) { - //noinspection ConstantConditions - return modelClass.cast(new ShareViewModel()); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java index 1a002b71c5..7b171da86f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java @@ -7,7 +7,7 @@ import android.view.View; import androidx.annotation.NonNull; import androidx.appcompat.widget.Toolbar; -import androidx.lifecycle.ViewModelProviders; +import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.RecyclerView; import com.annimon.stream.Stream; @@ -79,21 +79,25 @@ public class ShareInterstitialActivity extends PassphraseRequiredActivity { ShareInterstitialRepository repository = new ShareInterstitialRepository(); ShareInterstitialViewModel.Factory factory = new ShareInterstitialViewModel.Factory(args, repository); - viewModel = ViewModelProviders.of(this, factory).get(ShareInterstitialViewModel.class); + viewModel = new ViewModelProvider(this, factory).get(ShareInterstitialViewModel.class); LinkPreviewRepository linkPreviewRepository = new LinkPreviewRepository(); LinkPreviewViewModel.Factory linkPreviewViewModelFactory = new LinkPreviewViewModel.Factory(linkPreviewRepository); - linkPreviewViewModel = ViewModelProviders.of(this, linkPreviewViewModelFactory).get(LinkPreviewViewModel.class); + linkPreviewViewModel = new ViewModelProvider(this, linkPreviewViewModelFactory).get(LinkPreviewViewModel.class); - boolean hasSms = Stream.of(args.getShareContactAndThreads()) + boolean hasSms = Stream.of(args.getRecipientSearchKeys()) .anyMatch(c -> { Recipient recipient = Recipient.resolved(c.getRecipientId()); + if (recipient.isDistributionList()) { + return false; + } + return !recipient.isRegistered() || recipient.isForceSmsSelection(); }); if (hasSms) { - linkPreviewViewModel.onTransportChanged(hasSms); + linkPreviewViewModel.onTransportChanged(true); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java index ce74106804..56e4baa925 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java @@ -7,22 +7,22 @@ import androidx.core.util.Consumer; import com.annimon.stream.Stream; import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.sharing.ShareContactAndThread; import java.util.List; import java.util.Set; class ShareInterstitialRepository { - void loadRecipients(@NonNull Set shareContactAndThreads, Consumer> consumer) { - SignalExecutors.BOUNDED.execute(() -> consumer.accept(resolveRecipients(shareContactAndThreads))); + void loadRecipients(@NonNull Set recipientSearchKeys, Consumer> consumer) { + SignalExecutors.BOUNDED.execute(() -> consumer.accept(resolveRecipients(recipientSearchKeys))); } @WorkerThread - private List resolveRecipients(@NonNull Set shareContactAndThreads) { - return Stream.of(shareContactAndThreads) - .map(ShareContactAndThread::getRecipientId) + private List resolveRecipients(@NonNull Set recipientSearchKeys) { + return Stream.of(recipientSearchKeys) + .map(ContactSearchKey.RecipientSearchKey::getRecipientId) .map(Recipient::resolved) .toList(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java index 112a79743e..07f5934672 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java @@ -33,7 +33,7 @@ private final MultiShareArgs args; this.recipients = new MutableLiveData<>(); this.draftText = new DefaultValueLiveData<>(Util.firstNonNull(args.getDraftText(), "")); - repository.loadRecipients(args.getShareContactAndThreads(), + repository.loadRecipients(args.getRecipientSearchKeys(), list -> recipients.postValue(Stream.of(list) .mapIndexed((i, r) -> new ShareInterstitialMappingModel(r, i == 0)) .collect(MappingModelList.toMappingModelList()))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ResolvedShareData.kt b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ResolvedShareData.kt new file mode 100644 index 0000000000..9fdc307926 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ResolvedShareData.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.sharing.v2 + +import android.net.Uri +import org.thoughtcrime.securesms.sharing.MultiShareArgs +import java.lang.UnsupportedOperationException + +sealed class ResolvedShareData { + + abstract val isMmsOrSmsSupported: Boolean + + abstract fun toMultiShareArgs(): MultiShareArgs + + data class Primitive(val text: CharSequence) : ResolvedShareData() { + override val isMmsOrSmsSupported: Boolean = true + + override fun toMultiShareArgs(): MultiShareArgs { + return MultiShareArgs.Builder(setOf()).withDraftText(text.toString()).build() + } + } + + data class ExternalUri( + val uri: Uri, + val mimeType: String, + override val isMmsOrSmsSupported: Boolean + ) : ResolvedShareData() { + override fun toMultiShareArgs(): MultiShareArgs { + return MultiShareArgs.Builder(setOf()).withDataUri(uri).withDataType(mimeType).build() + } + } + + data class Media( + val media: List, + override val isMmsOrSmsSupported: Boolean + ) : ResolvedShareData() { + override fun toMultiShareArgs(): MultiShareArgs { + return MultiShareArgs.Builder(setOf()).withMedia(media).build() + } + } + + object Failure : ResolvedShareData() { + override val isMmsOrSmsSupported: Boolean get() = throw UnsupportedOperationException() + override fun toMultiShareArgs(): MultiShareArgs = throw UnsupportedOperationException() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareActivity.kt new file mode 100644 index 0000000000..2fbd28fe59 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareActivity.kt @@ -0,0 +1,215 @@ +package org.thoughtcrime.securesms.sharing.v2 + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.ViewGroup +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutManagerCompat +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFullScreenDialogFragment +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity.Companion.share +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.sharing.MultiShareDialogs +import org.thoughtcrime.securesms.sharing.MultiShareSender +import org.thoughtcrime.securesms.sharing.MultiShareSender.MultiShareSendResultCollection +import org.thoughtcrime.securesms.sharing.interstitial.ShareInterstitialActivity +import org.thoughtcrime.securesms.util.ConversationUtil +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme +import org.thoughtcrime.securesms.util.LifecycleDisposable +import java.util.Optional + +class ShareActivity : PassphraseRequiredActivity(), MultiselectForwardFragment.Callback { + + private val dynamicTheme = DynamicNoActionBarTheme() + private val lifecycleDisposable = LifecycleDisposable() + + private lateinit var finishOnOkResultLauncher: ActivityResultLauncher + + private val viewModel: ShareViewModel by viewModels { + ShareViewModel.Factory(getUnresolvedShareData(), directShareTarget, ShareRepository(this)) + } + + private val directShareTarget: RecipientId? + get() = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID).let { ConversationUtil.getRecipientId(it) } + + override fun onPreCreate() { + super.onPreCreate() + dynamicTheme.onCreate(this) + } + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + setContentView(R.layout.share_activity_v2) + + finishOnOkResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == Activity.RESULT_OK) { + finish() + } + } + + lifecycleDisposable += viewModel.events.subscribe { shareEvent -> + when (shareEvent) { + is ShareEvent.OpenConversation -> openConversation(shareEvent) + is ShareEvent.OpenMediaInterstitial -> openMediaInterstitial(shareEvent) + is ShareEvent.OpenTextInterstitial -> openTextInterstitial(shareEvent) + is ShareEvent.SendWithoutInterstitial -> sendWithoutInterstitial(shareEvent) + } + } + + lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { shareState -> + when (shareState.loadState) { + ShareState.ShareDataLoadState.Init -> Unit + ShareState.ShareDataLoadState.Failed -> finish() + is ShareState.ShareDataLoadState.Loaded -> { + ensureFragment(shareState.loadState.resolvedShareData) + } + } + } + } + + override fun onResume() { + super.onResume() + dynamicTheme.onResume(this) + } + + override fun onFinishForwardAction() = Unit + + override fun exitFlow() = Unit + + override fun onSearchInputFocused() = Unit + + override fun setResult(bundle: Bundle) { + if (bundle.containsKey(MultiselectForwardFragment.RESULT_SENT)) { + throw AssertionError("Should never happen.") + } + + if (!bundle.containsKey(MultiselectForwardFragment.RESULT_SELECTION)) { + throw AssertionError("Expected a recipient selection!") + } + + val parcelizedKeys: List = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!! + val contactSearchKeys = parcelizedKeys.map { it.asContactSearchKey() } + + viewModel.onContactSelectionConfirmed(contactSearchKeys) + } + + override fun getContainer(): ViewGroup = findViewById(R.id.container) + + override fun getDialogBackgroundColor(): Int = ContextCompat.getColor(this, R.color.signal_background_primary) + + private fun getUnresolvedShareData(): UnresolvedShareData { + return when { + intent.action == Intent.ACTION_SEND_MULTIPLE -> { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let { + UnresolvedShareData.ExternalMultiShare(it) + } ?: error("ACTION_SEND_MULTIPLE with EXTRA_STREAM but the EXTRA_STREAM was null") + } + intent.action == Intent.ACTION_SEND && intent.hasExtra(Intent.EXTRA_STREAM) -> { + intent.getParcelableExtra(Intent.EXTRA_STREAM)?.let { + UnresolvedShareData.ExternalSingleShare(it, intent.type) + } ?: error("ACTION_SEND with EXTRA_STREAM but the EXTRA_STREAM was null") + } + intent.action == Intent.ACTION_SEND && intent.hasExtra(Intent.EXTRA_TEXT) -> { + intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.let { + UnresolvedShareData.ExternalPrimitiveShare(it) + } ?: error("ACTION_SEND with EXTRA_TEXT but the EXTRA_TEXT was null") + } + else -> null + } ?: error("Intent Action: ${Intent.ACTION_SEND_MULTIPLE} could not be resolved with the given arguments.") + } + + private fun ensureFragment(resolvedShareData: ResolvedShareData) { + if (supportFragmentManager.fragments.none { it is MultiselectForwardFullScreenDialogFragment }) { + supportFragmentManager.beginTransaction() + .replace( + R.id.fragment_container, + MultiselectForwardFragment.create( + MultiselectForwardFragmentArgs( + canSendToNonPush = resolvedShareData.isMmsOrSmsSupported, + multiShareArgs = listOf(resolvedShareData.toMultiShareArgs()), + title = R.string.MultiselectForwardFragment__share_with, + forceDisableAddMessage = true, + forceSelectionOnly = true + ) + ) + ).commitNow() + } + } + + private fun openConversation(shareEvent: ShareEvent.OpenConversation) { + if (shareEvent.contact.isStory) { + error("Can't open a conversation for a story!") + } + + val multiShareArgs = shareEvent.getMultiShareArgs() + val conversationIntentBuilder = ConversationIntents.createBuilder(this, shareEvent.contact.recipientId, -1L) + .withDataUri(multiShareArgs.dataUri) + .withDataType(multiShareArgs.dataType) + .withMedia(multiShareArgs.media) + .withDraftText(multiShareArgs.draftText) + .withStickerLocator(multiShareArgs.stickerLocator) + .asBorderless(multiShareArgs.isBorderless) + + finish() + startActivity(conversationIntentBuilder.build()) + } + + private fun openMediaInterstitial(shareEvent: ShareEvent.OpenMediaInterstitial) { + val multiShareArgs = shareEvent.getMultiShareArgs() + val media: MutableList = ArrayList(multiShareArgs.media) + if (media.isEmpty() && multiShareArgs.dataUri != null) { + media.add( + Media( + multiShareArgs.dataUri, + multiShareArgs.dataType, + 0, + 0, + 0, + 0, + 0, + false, + false, + Optional.empty(), + Optional.empty(), + Optional.empty() + ) + ) + } + + val shareAsTextStory = multiShareArgs.allRecipientsAreStories() && media.isEmpty() + + val intent = share( + this, + MultiShareSender.getWorstTransportOption(this, multiShareArgs.recipientSearchKeys), + media, + multiShareArgs.recipientSearchKeys.toList(), + multiShareArgs.draftText, + shareAsTextStory + ) + + finishOnOkResultLauncher.launch(intent) + } + + private fun openTextInterstitial(shareEvent: ShareEvent.OpenTextInterstitial) { + finishOnOkResultLauncher.launch(ShareInterstitialActivity.createIntent(this, shareEvent.getMultiShareArgs())) + } + + private fun sendWithoutInterstitial(shareEvent: ShareEvent.SendWithoutInterstitial) { + MultiShareSender.send(shareEvent.getMultiShareArgs()) { results: MultiShareSendResultCollection? -> + MultiShareDialogs.displayResultDialog(this, results!!) { + finish() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareEvent.kt new file mode 100644 index 0000000000..51718a835d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareEvent.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.sharing.v2 + +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.sharing.MultiShareArgs + +sealed class ShareEvent { + + protected abstract val shareData: ResolvedShareData + protected abstract val contacts: List + + fun getMultiShareArgs(): MultiShareArgs { + return shareData.toMultiShareArgs().buildUpon( + contacts.toSet() + ).build() + } + + data class OpenConversation(override val shareData: ResolvedShareData, val contact: ContactSearchKey.RecipientSearchKey) : ShareEvent() { + override val contacts: List = listOf(contact) + } + + data class OpenMediaInterstitial(override val shareData: ResolvedShareData, override val contacts: List) : ShareEvent() + data class OpenTextInterstitial(override val shareData: ResolvedShareData, override val contacts: List) : ShareEvent() + data class SendWithoutInterstitial(override val shareData: ResolvedShareData, override val contacts: List) : ShareEvent() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareRepository.kt new file mode 100644 index 0000000000..1b6743ed80 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareRepository.kt @@ -0,0 +1,189 @@ +package org.thoughtcrime.securesms.sharing.v2 + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.OpenableColumns +import androidx.annotation.NonNull +import androidx.annotation.WorkerThread +import androidx.core.content.ContextCompat +import androidx.core.util.toKotlinPair +import io.reactivex.rxjava3.core.Single +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.TransportOption +import org.thoughtcrime.securesms.TransportOptions +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.attachments.UriAttachment +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.MediaSendConstants +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.UriUtil +import org.thoughtcrime.securesms.util.Util +import java.io.IOException +import java.io.InputStream +import java.util.Optional + +class ShareRepository(context: Context) { + + private val appContext = context.applicationContext + + fun resolve(unresolvedShareData: UnresolvedShareData): Single { + return when (unresolvedShareData) { + is UnresolvedShareData.ExternalMultiShare -> Single.fromCallable { resolve(unresolvedShareData) } + is UnresolvedShareData.ExternalSingleShare -> Single.fromCallable { resolve(unresolvedShareData) } + is UnresolvedShareData.ExternalPrimitiveShare -> Single.just(ResolvedShareData.Primitive(unresolvedShareData.text)) + } + } + + @NonNull + @WorkerThread + @Throws(IOException::class) + private fun resolve(multiShareExternal: UnresolvedShareData.ExternalSingleShare): ResolvedShareData { + if (!UriUtil.isValidExternalUri(appContext, multiShareExternal.uri)) { + return ResolvedShareData.Failure + } + + val uri = multiShareExternal.uri + val mimeType = getMimeType(appContext, uri, multiShareExternal.mimeType) + + val stream: InputStream = try { + appContext.contentResolver.openInputStream(uri) + } catch (e: SecurityException) { + Log.w(TAG, "Failed to read stream!", e) + null + } ?: return ResolvedShareData.Failure + + val size = getSize(appContext, uri) + val name = getFileName(appContext, uri) + + val blobUri = BlobProvider.getInstance() + .forData(stream, size) + .withMimeType(mimeType) + .withFileName(name) + .createForSingleSessionOnDisk(appContext) + + return ResolvedShareData.ExternalUri( + uri = blobUri, + mimeType = mimeType, + isMmsOrSmsSupported = isMmsSupported(appContext, asUriAttachment(blobUri, mimeType, size)) + ) + } + + @NonNull + @WorkerThread + private fun resolve(externalMultiShare: UnresolvedShareData.ExternalMultiShare): ResolvedShareData { + val mimeTypes: Map = externalMultiShare.uris + .associateWith { uri -> getMimeType(appContext, uri, null) } + .filterValues { + MediaUtil.isImageType(it) || MediaUtil.isVideoType(it) + } + + if (mimeTypes.isEmpty()) { + return ResolvedShareData.Failure + } + + val media: List = mimeTypes.toList() + .take(MediaSendConstants.MAX_PUSH) + .map { (uri, mimeType) -> + val stream: InputStream = try { + appContext.contentResolver.openInputStream(uri) + } catch (e: IOException) { + Log.w(TAG, "Failed to open: $uri") + return@map null + } ?: return ResolvedShareData.Failure + + val size = getSize(appContext, uri) + val dimens: Pair = MediaUtil.getDimensions(appContext, mimeType, uri).toKotlinPair() + val duration = 0L + val blobUri = BlobProvider.getInstance() + .forData(stream, size) + .withMimeType(mimeType) + .createForSingleSessionOnDisk(appContext) + + Media( + blobUri, + mimeType, + System.currentTimeMillis(), + dimens.first, + dimens.second, + size, + duration, + false, + false, + Optional.of(Media.ALL_MEDIA_BUCKET_ID), + Optional.empty(), + Optional.empty() + ) + }.filterNotNull() + + return if (media.isNotEmpty()) { + val isMmsSupported = media.all { isMmsSupported(appContext, asUriAttachment(it.uri, it.mimeType, it.size)) } + + ResolvedShareData.Media(media, isMmsSupported) + } else { + ResolvedShareData.Failure + } + } + + companion object { + private val TAG = Log.tag(ShareRepository::class.java) + + private fun getMimeType(context: Context, uri: Uri, mimeType: String?): String { + var updatedMimeType = MediaUtil.getMimeType(context, uri) + if (updatedMimeType == null) { + updatedMimeType = MediaUtil.getCorrectedMimeType(mimeType) + } + return updatedMimeType ?: MediaUtil.UNKNOWN + } + + @Throws(IOException::class) + private fun getSize(context: Context, uri: Uri): Long { + var size: Long = 0 + + context.contentResolver.query(uri, null, null, null, null).use { cursor -> + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)) + } + } + + if (size <= 0) { + size = MediaUtil.getMediaSize(context, uri) + } + + return size + } + + private fun getFileName(context: Context, uri: Uri): String? { + if (uri.scheme.equals("file", ignoreCase = true)) { + return uri.lastPathSegment + } + + context.contentResolver.query(uri, null, null, null, null).use { cursor -> + if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) >= 0) { + return cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) + } + } + return null + } + + private fun asUriAttachment(uri: Uri, mimeType: String, size: Long): UriAttachment { + return UriAttachment(uri, mimeType, -1, size, null, false, false, false, false, null, null, null, null, null) + } + + private fun isMmsSupported(context: Context, attachment: Attachment): Boolean { + val canReadPhoneState = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED + + if (!Util.isDefaultSmsProvider(context) || !canReadPhoneState || !Util.isMmsCapable(context)) { + return false + } + + val options = TransportOptions(context, true) + options.setDefaultTransport(TransportOption.Type.SMS) + val mmsConstraints = MediaConstraints.getMmsMediaConstraints(options.selectedTransport.simSubscriptionId.orElse(-1)) + return mmsConstraints.isSatisfied(context, attachment) || mmsConstraints.canResize(attachment) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareState.kt b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareState.kt new file mode 100644 index 0000000000..c3be3ac50f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareState.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.sharing.v2 + +data class ShareState( + val loadState: ShareDataLoadState = ShareDataLoadState.Init +) { + sealed class ShareDataLoadState { + object Init : ShareDataLoadState() + data class Loaded(val resolvedShareData: ResolvedShareData) : ShareDataLoadState() + object Failed : ShareDataLoadState() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareViewModel.kt new file mode 100644 index 0000000000..42feaeb80f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/ShareViewModel.kt @@ -0,0 +1,96 @@ +package org.thoughtcrime.securesms.sharing.v2 + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.subjects.PublishSubject +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.sharing.InterstitialContentType +import org.thoughtcrime.securesms.util.rx.RxStore + +class ShareViewModel( + unresolvedShareData: UnresolvedShareData, + directShareTarget: RecipientId?, + shareRepository: ShareRepository +) : ViewModel() { + + companion object { + private val TAG = Log.tag(ShareViewModel::class.java) + } + + private val store = RxStore(ShareState()) + private val disposables = CompositeDisposable() + private val eventSubject = PublishSubject.create() + + val state: Flowable = store.stateFlowable + val events: Observable = eventSubject + + init { + disposables += shareRepository.resolve(unresolvedShareData).subscribeBy( + onSuccess = { data -> + when { + data == ResolvedShareData.Failure -> { + moveToFailedState() + } + directShareTarget != null -> { + eventSubject.onNext(ShareEvent.OpenConversation(data, ContactSearchKey.RecipientSearchKey.KnownRecipient(directShareTarget))) + } + else -> { + store.update { it.copy(loadState = ShareState.ShareDataLoadState.Loaded(data)) } + } + } + }, + onError = this::moveToFailedState + ) + } + + fun onContactSelectionConfirmed(contactSearchKeys: List) { + val loadState = store.state.loadState + if (loadState !is ShareState.ShareDataLoadState.Loaded) { + return + } + + val recipientKeys = contactSearchKeys.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java) + val hasStory = recipientKeys.any { it.isStory } + val openConversation = !hasStory && recipientKeys.size == 1 + val resolvedShareData = loadState.resolvedShareData + + if (openConversation) { + eventSubject.onNext(ShareEvent.OpenConversation(resolvedShareData, recipientKeys.first())) + return + } + + val event = when (resolvedShareData.toMultiShareArgs().interstitialContentType) { + InterstitialContentType.MEDIA -> ShareEvent.OpenMediaInterstitial(resolvedShareData, recipientKeys) + InterstitialContentType.TEXT -> ShareEvent.OpenTextInterstitial(resolvedShareData, recipientKeys) + InterstitialContentType.NONE -> ShareEvent.SendWithoutInterstitial(resolvedShareData, recipientKeys) + } + + eventSubject.onNext(event) + } + + override fun onCleared() { + disposables.clear() + } + + private fun moveToFailedState(throwable: Throwable? = null) { + Log.w(TAG, "Could not load share data.", throwable) + store.update { it.copy(loadState = ShareState.ShareDataLoadState.Failed) } + } + + class Factory( + private val unresolvedShareData: UnresolvedShareData, + private val directShareTarget: RecipientId?, + private val shareRepository: ShareRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(ShareViewModel(unresolvedShareData, directShareTarget, shareRepository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/UnresolvedShareData.kt b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/UnresolvedShareData.kt new file mode 100644 index 0000000000..88a059c5b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/v2/UnresolvedShareData.kt @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.sharing.v2 + +import android.net.Uri + +sealed class UnresolvedShareData { + data class ExternalMultiShare(val uris: List) : UnresolvedShareData() + data class ExternalSingleShare(val uri: Uri, val mimeType: String?) : UnresolvedShareData() + data class ExternalPrimitiveShare(val text: CharSequence) : UnresolvedShareData() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java index 19cccd26e8..09ec330a2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerManagementActivity.java @@ -100,7 +100,7 @@ public final class StickerManagementActivity extends PassphraseRequiredActivity getSupportFragmentManager(), new MultiselectForwardFragmentArgs( true, - Collections.singletonList(new MultiShareArgs.Builder(Collections.emptySet()) + Collections.singletonList(new MultiShareArgs.Builder() .withDraftText(StickerUrl.createShareLink(packId, packKey)) .build()), R.string.MultiselectForwardFragment__share_with diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java index a570f06c6b..5d00174a0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerPackPreviewActivity.java @@ -242,7 +242,7 @@ public final class StickerPackPreviewActivity extends PassphraseRequiredActivity getSupportFragmentManager(), new MultiselectForwardFragmentArgs( true, - Collections.singletonList(new MultiShareArgs.Builder(Collections.emptySet()) + Collections.singletonList(new MultiShareArgs.Builder() .withDraftText(StickerUrl.createShareLink(packId, packKey)) .build()), R.string.MultiselectForwardFragment__share_with diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt index 159cea7e7c..7c2ce96341 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/Stories.kt @@ -20,6 +20,9 @@ import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.FeatureFlags object Stories { + + const val MAX_BODY_SIZE = 700 + @JvmStatic fun isFeatureAvailable(): Boolean { return FeatureFlags.stories() && Recipient.self().storiesCapability == Recipient.Capability.SUPPORTED 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 374d803c89..6084c4acde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.ImageSlide import org.thoughtcrime.securesms.util.concurrent.ListenableFuture import org.thoughtcrime.securesms.util.concurrent.SettableFuture +import org.thoughtcrime.securesms.util.views.Stub import org.thoughtcrime.securesms.util.visible import java.text.DateFormat import java.text.SimpleDateFormat @@ -36,6 +37,7 @@ class StoryLinkPreviewView @JvmOverloads constructor( private val url: TextView = findViewById(R.id.link_preview_url) private val description: TextView = findViewById(R.id.link_preview_description) private val fallbackIcon: ImageView = findViewById(R.id.link_preview_fallback_icon) + private val loadingSpinner: Stub = Stub(findViewById(R.id.loading_spinner)) fun bind(linkPreview: LinkPreview?, hiddenVisibility: Int = View.INVISIBLE): ListenableFuture { var listenableFuture: ListenableFuture? = null @@ -78,6 +80,11 @@ class StoryLinkPreviewView @JvmOverloads constructor( } bind(linkPreview, hiddenVisibility) + + loadingSpinner.get().visible = linkPreviewState.isLoading + if (linkPreviewState.isLoading) { + visible = true + } } private fun formatUrl(linkPreview: LinkPreview) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryDialogs.kt index 4cfbb3e205..e434000ac3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryDialogs.kt @@ -41,7 +41,7 @@ object StoryDialogs { return false } - return shareContacts.any { it is ContactSearchKey.Story && Recipient.resolved(it.recipientId).isMyStory } + return shareContacts.any { it is ContactSearchKey.RecipientSearchKey.Story && Recipient.resolved(it.recipientId).isMyStory } } fun resendStory(context: Context, resend: () -> Unit) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt index cc87631951..8028335010 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingItem.kt @@ -138,7 +138,6 @@ object StoriesLandingItem { val storyTextPostModel = StoryTextPostModel.parseFrom(record) GlideApp.with(storyPreview) .load(storyTextPostModel) - .addListener(HideBlurAfterLoadListener()) .placeholder(storyTextPostModel.getPlaceholder()) .centerCrop() .dontAnimate() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt index 046b96cbab..604f2643a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt @@ -35,10 +35,9 @@ object StoryGroupReplySender { } return messageAndRecipient.flatMapCompletable { (message, recipient) -> - UntrustedRecords.checkForBadIdentityRecords(setOf(ContactSearchKey.KnownRecipient(recipient.id))) + UntrustedRecords.checkForBadIdentityRecords(setOf(ContactSearchKey.RecipientSearchKey.KnownRecipient(recipient.id))) .andThen( Completable.create { - MessageSender.send( context, OutgoingMediaMessage( diff --git a/app/src/main/res/layout/multiselect_forward_fragment.xml b/app/src/main/res/layout/multiselect_forward_fragment.xml index 0ae40e491d..c1eef83443 100644 --- a/app/src/main/res/layout/multiselect_forward_fragment.xml +++ b/app/src/main/res/layout/multiselect_forward_fragment.xml @@ -22,6 +22,8 @@ android:layout_height="0dp" android:layout_marginTop="12dp" android:layout_weight="1" + android:clipChildren="false" + android:clipToPadding="false" android:paddingBottom="44dp" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> diff --git a/app/src/main/res/layout/share_activity.xml b/app/src/main/res/layout/share_activity.xml deleted file mode 100644 index 952bcff3d5..0000000000 --- a/app/src/main/res/layout/share_activity.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/share_activity_v2.xml b/app/src/main/res/layout/share_activity_v2.xml new file mode 100644 index 0000000000..f7172a7e47 --- /dev/null +++ b/app/src/main/res/layout/share_activity_v2.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml b/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml index 08ddc2821e..420c6f7f63 100644 --- a/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml +++ b/app/src/main/res/layout/stories_choose_group_bottom_sheet.xml @@ -36,13 +36,15 @@ android:minHeight="44dp" android:paddingHorizontal="16dp" android:textAppearance="@style/Signal.Text.Body" - app:backgroundTint="@color/core_grey_65" /> + app:backgroundTint="@color/signal_background_dialog_secondary" /> \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_link_preview.xml b/app/src/main/res/layout/stories_text_post_link_preview.xml index 80f3323226..67ff8eb2d3 100644 --- a/app/src/main/res/layout/stories_text_post_link_preview.xml +++ b/app/src/main/res/layout/stories_text_post_link_preview.xml @@ -104,6 +104,16 @@ app:layout_constraintTop_toBottomOf="@id/link_preview_description" tools:text="www.asdf.com" /> + + diff --git a/app/src/main/res/layout/stories_text_post_link_preview_spinner.xml b/app/src/main/res/layout/stories_text_post_link_preview_spinner.xml new file mode 100644 index 0000000000..a120cf7188 --- /dev/null +++ b/app/src/main/res/layout/stories_text_post_link_preview_spinner.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/stories_text_post_view.xml b/app/src/main/res/layout/stories_text_post_view.xml index 6edf9fa822..b3fe157d68 100644 --- a/app/src/main/res/layout/stories_text_post_view.xml +++ b/app/src/main/res/layout/stories_text_post_view.xml @@ -7,10 +7,10 @@ tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> + android:layout_height="match_parent" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ff177d3509..9514a225ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4647,6 +4647,10 @@ No Internet Connection Couldn\'t Load Content + + Sent story + + Failed to send story Turn off censorship circumvention? diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt index 7a70293e47..dd058c73ae 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt @@ -47,16 +47,16 @@ class ContactSearchPagedDataSourceTest { val expected = listOf( ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.RECENTS), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.INDIVIDUALS) ) @@ -71,15 +71,15 @@ class ContactSearchPagedDataSourceTest { val result = testSubject.load(5, 10) { false } val expected = listOf( - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.INDIVIDUALS), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), - ContactSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.KnownRecipient(RecipientId.UNKNOWN), ContactSearchKey.Expand(ContactSearchConfiguration.SectionKey.INDIVIDUALS) ) @@ -95,17 +95,17 @@ class ContactSearchPagedDataSourceTest { val expected = listOf( ContactSearchKey.Header(ContactSearchConfiguration.SectionKey.STORIES), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), - ContactSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), + ContactSearchKey.RecipientSearchKey.Story(RecipientId.UNKNOWN), ) val resultKeys = result.map { it.contactSearchKey }