mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-21 10:17:56 +00:00
Enable sharing to stories and refactor share activity.
This commit is contained in:
committed by
Greyson Parrelli
parent
fd4543ffe0
commit
523537cf05
@@ -179,7 +179,7 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity android:name=".sharing.ShareActivity"
|
||||
<activity android:name=".sharing.v2.ShareActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
|
||||
@@ -57,8 +57,6 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.components.viewpager.ExtendedOnPageChangedListener;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardBottomSheet;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||
@@ -73,7 +71,6 @@ import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sharing.ShareActivity;
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
|
||||
@@ -12,12 +12,12 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
|
||||
*
|
||||
* Note that if the recipient is a group, it's participant list size is used instead of viewerCount.
|
||||
*/
|
||||
data class Story(val recipient: Recipient, val viewerCount: Int) : ContactSearchData(ContactSearchKey.Story(recipient.id))
|
||||
data class Story(val recipient: Recipient, val viewerCount: Int) : ContactSearchData(ContactSearchKey.RecipientSearchKey.Story(recipient.id))
|
||||
|
||||
/**
|
||||
* A row displaying a known recipient.
|
||||
*/
|
||||
data class KnownRecipient(val recipient: Recipient) : ContactSearchData(ContactSearchKey.KnownRecipient(recipient.id))
|
||||
data class KnownRecipient(val recipient: Recipient) : ContactSearchData(ContactSearchKey.RecipientSearchKey.KnownRecipient(recipient.id))
|
||||
|
||||
/**
|
||||
* A row containing a title for a given section
|
||||
|
||||
@@ -19,34 +19,37 @@ sealed class ContactSearchKey {
|
||||
|
||||
open fun requireParcelable(): Parcelable = error("This key cannot be parcelized")
|
||||
|
||||
/**
|
||||
* Key to a Story
|
||||
*/
|
||||
data class Story(override val recipientId: RecipientId) : ContactSearchKey(), RecipientSearchKey {
|
||||
override fun requireShareContact(): ShareContact {
|
||||
return ShareContact(recipientId)
|
||||
sealed class RecipientSearchKey : ContactSearchKey() {
|
||||
|
||||
abstract val recipientId: RecipientId
|
||||
abstract val isStory: Boolean
|
||||
|
||||
data class Story(override val recipientId: RecipientId) : RecipientSearchKey() {
|
||||
override fun requireShareContact(): ShareContact {
|
||||
return ShareContact(recipientId)
|
||||
}
|
||||
|
||||
override fun requireParcelable(): Parcelable {
|
||||
return ParcelableRecipientSearchKey(ParcelableType.STORY, recipientId)
|
||||
}
|
||||
|
||||
override val isStory: Boolean = true
|
||||
}
|
||||
|
||||
override fun requireParcelable(): Parcelable {
|
||||
return ParcelableContactSearchKey(ParcelableType.STORY, recipientId)
|
||||
/**
|
||||
* Key to a recipient which already exists in our database
|
||||
*/
|
||||
data class KnownRecipient(override val recipientId: RecipientId) : RecipientSearchKey() {
|
||||
override fun requireShareContact(): ShareContact {
|
||||
return ShareContact(recipientId)
|
||||
}
|
||||
|
||||
override fun requireParcelable(): Parcelable {
|
||||
return ParcelableRecipientSearchKey(ParcelableType.KNOWN_RECIPIENT, recipientId)
|
||||
}
|
||||
|
||||
override val isStory: Boolean = false
|
||||
}
|
||||
|
||||
override val isStory: Boolean = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Key to a recipient which already exists in our database
|
||||
*/
|
||||
data class KnownRecipient(override val recipientId: RecipientId) : ContactSearchKey(), RecipientSearchKey {
|
||||
override fun requireShareContact(): ShareContact {
|
||||
return ShareContact(recipientId)
|
||||
}
|
||||
|
||||
override fun requireParcelable(): Parcelable {
|
||||
return ParcelableContactSearchKey(ParcelableType.KNOWN_RECIPIENT, recipientId)
|
||||
}
|
||||
|
||||
override val isStory: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,11 +63,11 @@ sealed class ContactSearchKey {
|
||||
data class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableContactSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
|
||||
data class ParcelableRecipientSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
|
||||
fun asContactSearchKey(): ContactSearchKey {
|
||||
return when (type) {
|
||||
ParcelableType.STORY -> Story(recipientId)
|
||||
ParcelableType.KNOWN_RECIPIENT -> KnownRecipient(recipientId)
|
||||
ParcelableType.STORY -> RecipientSearchKey.Story(recipientId)
|
||||
ParcelableType.KNOWN_RECIPIENT -> RecipientSearchKey.KnownRecipient(recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class ContactSearchMediator(
|
||||
return viewModel.selectionState
|
||||
}
|
||||
|
||||
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.Story>) {
|
||||
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey.Story>) {
|
||||
viewModel.addToVisibleGroupStories(groupStories)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -86,7 +86,7 @@ class ContactSearchViewModel(
|
||||
return selectionStore.state
|
||||
}
|
||||
|
||||
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.Story>) {
|
||||
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.RecipientSearchKey.Story>) {
|
||||
configurationStore.update { state ->
|
||||
state.copy(
|
||||
groupStories = state.groupStories + groupStories.map {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<MultiShareArgs> = 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<RecipientId> = bundle.getParcelableArrayList<RecipientId>(ChooseGroupStoryBottomSheet.RESULT_SET)?.toSet() ?: emptySet()
|
||||
val keys: Set<ContactSearchKey.Story> = groups.map { ContactSearchKey.Story(it) }.toSet()
|
||||
val keys: Set<ContactSearchKey.RecipientSearchKey.Story> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MultiShareArgs> = 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 {
|
||||
|
||||
@@ -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<ShareContactAndThread> = shareContacts
|
||||
val filteredContacts: Set<ContactSearchKey> = 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> = multiShareArgs.map { it.buildUpon(sharedContactsAndThreads).build() }
|
||||
val mappedArgs: List<MultiShareArgs> = 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)
|
||||
|
||||
@@ -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<MultiShareArgs>,
|
||||
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<ContactSearchKey>) {
|
||||
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<MultiShareArgs>,
|
||||
private val repository: MultiselectForwardRepository
|
||||
private val isSelectionOnly: Boolean,
|
||||
private val repository: MultiselectForwardRepository,
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(MultiselectForwardViewModel(records, repository)))
|
||||
return requireNotNull(modelClass.cast(MultiselectForwardViewModel(records, isSelectionOnly, repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Media> = 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<Media>,
|
||||
recipientIds: List<RecipientId>,
|
||||
message: CharSequence?
|
||||
recipientSearchKeys: List<ContactSearchKey.RecipientSearchKey>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RecipientId>) : MediaSelectionDestination() {
|
||||
override fun getRecipientSearchKeyList(): List<RecipientSearchKey> = recipientIds.map { ContactSearchKey.KnownRecipient(it) }
|
||||
class MultipleRecipients(val recipientSearchKeys: List<ContactSearchKey.RecipientSearchKey>) : MediaSelectionDestination() {
|
||||
|
||||
companion object {
|
||||
fun fromParcel(parcelables: List<ContactSearchKey.ParcelableRecipientSearchKey>): MultipleRecipients {
|
||||
return MultipleRecipients(parcelables.map { it.asContactSearchKey() }.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRecipientSearchKeyList(): List<ContactSearchKey.RecipientSearchKey> = 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<RecipientSearchKey> = emptyList()
|
||||
open fun getRecipientSearchKey(): ContactSearchKey.RecipientSearchKey? = null
|
||||
open fun getRecipientSearchKeyList(): List<ContactSearchKey.RecipientSearchKey> = 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RecipientSearchKey>,
|
||||
singleContact: ContactSearchKey.RecipientSearchKey?,
|
||||
contacts: List<ContactSearchKey.RecipientSearchKey>,
|
||||
mentions: List<Mention>,
|
||||
transport: TransportOption
|
||||
): Maybe<MediaSendActivityResult> {
|
||||
@@ -198,14 +197,14 @@ class MediaSelectionRepository(context: Context) {
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun sendMessages(contacts: List<RecipientSearchKey>, body: String, preUploadResults: Collection<PreUploadResult>, mentions: List<Mention>, isViewOnce: Boolean) {
|
||||
private fun sendMessages(contacts: List<ContactSearchKey.RecipientSearchKey>, body: String, preUploadResults: Collection<PreUploadResult>, mentions: List<Mention>, isViewOnce: Boolean) {
|
||||
val broadcastMessages: MutableList<OutgoingSecureMediaMessage> = ArrayList(contacts.size)
|
||||
val storyMessages: MutableMap<PreUploadResult, MutableList<OutgoingSecureMediaMessage>> = mutableMapOf()
|
||||
val distributionListSentTimestamps: MutableMap<PreUploadResult, Long> = 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())
|
||||
|
||||
@@ -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<RecipientSearchKey> = emptyList()
|
||||
selectedContacts: List<ContactSearchKey.RecipientSearchKey> = emptyList()
|
||||
): Maybe<MediaSendActivityResult> {
|
||||
return UntrustedRecords.checkForBadIdentityRecords(selectedContacts.toSet()).andThen(
|
||||
repository.send(
|
||||
|
||||
@@ -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<RecipientSearchKey>): Completable {
|
||||
fun checkForBadIdentityRecords(contactSearchKeys: Set<ContactSearchKey.RecipientSearchKey>): Completable {
|
||||
return Completable.fromAction {
|
||||
val untrustedRecords: List<IdentityRecord> = checkForBadIdentityRecordsSync(contactSearchKeys)
|
||||
if (untrustedRecords.isNotEmpty()) {
|
||||
@@ -21,13 +21,13 @@ object UntrustedRecords {
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun checkForBadIdentityRecords(contactSearchKeys: Set<RecipientSearchKey>, consumer: Consumer<List<IdentityRecord>>) {
|
||||
fun checkForBadIdentityRecords(contactSearchKeys: Set<ContactSearchKey.RecipientSearchKey>, consumer: Consumer<List<IdentityRecord>>) {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
consumer.accept(checkForBadIdentityRecordsSync(contactSearchKeys))
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkForBadIdentityRecordsSync(contactSearchKeys: Set<RecipientSearchKey>): List<IdentityRecord> {
|
||||
private fun checkForBadIdentityRecordsSync(contactSearchKeys: Set<ContactSearchKey.RecipientSearchKey>): List<IdentityRecord> {
|
||||
val recipients: List<Recipient> = contactSearchKeys
|
||||
.map { Recipient.resolved(it.recipientId) }
|
||||
.map { recipient ->
|
||||
|
||||
@@ -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<ContactSearchKey.ParcelableContactSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
|
||||
val parcelizedKeys: List<ContactSearchKey.ParcelableRecipientSearchKey> = 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) },
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -98,4 +98,7 @@ object TextStoryBackgroundColors {
|
||||
|
||||
return backgroundColors[indexOfNextColor]
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getRandomBackgroundColor() = backgroundColors.random()
|
||||
}
|
||||
|
||||
@@ -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<ContactSearchKey>) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TextFont> = BehaviorSubject.create()
|
||||
@@ -57,30 +60,6 @@ class TextStoryPostCreationViewModel : ViewModel() {
|
||||
internalThumbnail.value = bitmap
|
||||
}
|
||||
|
||||
private fun asyncFontEmitter(async: Fonts.FontResult.Async): Observable<Typeface> {
|
||||
return Observable.create {
|
||||
it.onNext(async.placeholder)
|
||||
|
||||
val listener = object : FutureTaskListener<Typeface> {
|
||||
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<ContactSearchKey>, linkPreview: LinkPreview?): Single<TextStoryPostSendResult> {
|
||||
return repository.send(
|
||||
contacts,
|
||||
store.state,
|
||||
linkPreview
|
||||
)
|
||||
}
|
||||
|
||||
class Factory(private val repository: TextStoryPostSendRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(TextStoryPostCreationViewModel(repository)) as T
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(TextStoryPostCreationViewModel::class.java)
|
||||
private const val TEXT_STORY_INSTANCE_STATE = "text.story.instance.state"
|
||||
|
||||
@@ -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<RecipientId> = bundle.getParcelableArrayList<RecipientId>(ChooseGroupStoryBottomSheet.RESULT_SET)?.toSet() ?: emptySet()
|
||||
val keys: Set<ContactSearchKey.Story> = groups.map { ContactSearchKey.Story(it) }.toSet()
|
||||
val keys: Set<ContactSearchKey.RecipientSearchKey.Story> = groups.map { ContactSearchKey.RecipientSearchKey.Story(it) }.toSet()
|
||||
contactSearchMediator.addToVisibleGroupStories(keys)
|
||||
contactSearchMediator.onFilterChanged("")
|
||||
contactSearchMediator.setKeysSelected(keys)
|
||||
|
||||
@@ -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<ContactSearchKey>, textStoryPostCreationState: TextStoryPostCreationState, linkPreview: LinkPreview?): Single<TextStoryPostSendResult> {
|
||||
return UntrustedRecords
|
||||
.checkForBadIdentityRecords(contactSearchKey.filterIsInstance(RecipientSearchKey::class.java).toSet())
|
||||
.checkForBadIdentityRecords(contactSearchKey.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java).toSet())
|
||||
.toSingleDefault<TextStoryPostSendResult>(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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ShareContactAndThread> shareContactAndThreads;
|
||||
private final List<Media> 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<Mention> mentions;
|
||||
private final long timestamp;
|
||||
private final long expiresAt;
|
||||
private final boolean isTextStory;
|
||||
private final Set<ContactSearchKey> contactSearchKeys;
|
||||
private final List<Media> 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<Mention> 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<ContactSearchKey.ParcelableRecipientSearchKey> 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<ShareContactAndThread> getShareContactAndThreads() {
|
||||
return shareContactAndThreads;
|
||||
public Set<ContactSearchKey> getContactSearchKeys() {
|
||||
return contactSearchKeys;
|
||||
}
|
||||
|
||||
public Set<ContactSearchKey.RecipientSearchKey> getRecipientSearchKeys() {
|
||||
return contactSearchKeys.stream()
|
||||
.filter(key -> key instanceof ContactSearchKey.RecipientSearchKey)
|
||||
.map(key -> (ContactSearchKey.RecipientSearchKey) key)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
public @NonNull List<Media> 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<MultiShareArgs> CREATOR = new Creator<MultiShareArgs>() {
|
||||
@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<ShareContactAndThread> 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<ContactSearchKey> 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<ShareContactAndThread> shareContactAndThreads;
|
||||
private final Set<ContactSearchKey> contactSearchKeys;
|
||||
|
||||
private List<Media> media;
|
||||
private String draftText;
|
||||
@@ -240,8 +284,12 @@ public final class MultiShareArgs implements Parcelable {
|
||||
private long expiresAt;
|
||||
private boolean isTextStory;
|
||||
|
||||
public Builder(@NonNull Set<ShareContactAndThread> shareContactAndThreads) {
|
||||
this.shareContactAndThreads = shareContactAndThreads;
|
||||
public Builder() {
|
||||
this(Collections.emptySet());
|
||||
}
|
||||
|
||||
public Builder(@NonNull Set<ContactSearchKey> contactSearchKeys) {
|
||||
this.contactSearchKeys = contactSearchKeys;
|
||||
}
|
||||
|
||||
public @NonNull Builder withMedia(@Nullable List<Media> media) {
|
||||
|
||||
@@ -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<MultiShareSendResult> results = new ArrayList<>(multiShareArgs.getShareContactAndThreads().size());
|
||||
List<MultiShareSendResult> 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<Mention> 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<Mention> 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<ShareContactAndThread> shareContactAndThreads) {
|
||||
for (ShareContactAndThread shareContactAndThread : shareContactAndThreads) {
|
||||
TransportOption option = resolveTransportOption(context, shareContactAndThread.isForceSms() && !shareContactAndThread.isStory());
|
||||
public static @NonNull TransportOption getWorstTransportOption(@NonNull Context context, @NonNull Set<ContactSearchKey.RecipientSearchKey> 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<Mention> 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() {
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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> recipientId, String number, @NonNull java.util.function.Consumer<Boolean> 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> 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<ShortcutInfoCompat> 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<ShareContact> 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<ShareContact> 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<Uri> 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<ShareContact> shareContacts) {
|
||||
shareConfirm.setClickable(false);
|
||||
SimpleTask.run(this.getLifecycle(),
|
||||
() -> resolvedShareContacts(shareContacts),
|
||||
this::onMultipleDestinationsChosen);
|
||||
}
|
||||
|
||||
private Set<ShareContactAndThread> resolvedShareContacts(@NonNull Set<ShareContact> sharedContacts) {
|
||||
Set<Recipient> recipients = Stream.of(sharedContacts)
|
||||
.map(contact -> contact.getRecipientId()
|
||||
.map(Recipient::resolved)
|
||||
.orElseGet(() -> Recipient.external(this, contact.getNumber())))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Map<RecipientId, Long> 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<ShareData> onResolved) {
|
||||
AtomicReference<AlertDialog> 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<ShareContactAndThread> 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<ShareContactAndThread> 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> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<ShareContactAndThread> CREATOR = new Creator<ShareContactAndThread>() {
|
||||
@Override
|
||||
public ShareContactAndThread createFromParcel(@NonNull Parcel in) {
|
||||
return new ShareContactAndThread(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShareContactAndThread[] newArray(int size) {
|
||||
return new ShareContactAndThread[size];
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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> uri;
|
||||
private final Optional<String> mimeType;
|
||||
private final Optional<ArrayList<Media>> 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> media, boolean isMmsOrSmsSupported) {
|
||||
return new ShareData(Optional.empty(), Optional.empty(), Optional.of(new ArrayList<>(media)), true, isMmsOrSmsSupported);
|
||||
}
|
||||
|
||||
private ShareData(Optional<Uri> uri, Optional<String> mimeType, Optional<ArrayList<Media>> 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<Media> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Media> 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<Media> extraMedia,
|
||||
@Nullable StickerLocator extraSticker,
|
||||
boolean isBorderless)
|
||||
{
|
||||
this.extraText = extraText;
|
||||
this.extraMedia = extraMedia;
|
||||
this.extraSticker = extraSticker;
|
||||
this.isBorderless = isBorderless;
|
||||
}
|
||||
|
||||
public @Nullable ArrayList<Media> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Optional<ShareData>> 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<Uri> uris, @NonNull Callback<Optional<ShareData>> 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<Uri> uris) throws IOException {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
|
||||
Map<Uri, String> 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> media = new ArrayList<>(mimeTypes.size());
|
||||
|
||||
for (Map.Entry<Uri, String> 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<Integer, Integer> 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<E> {
|
||||
void onResult(@NonNull E result);
|
||||
}
|
||||
}
|
||||
@@ -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<Optional<ShareData>> shareData;
|
||||
private final MutableLiveData<Set<ShareContact>> selectedContacts;
|
||||
private final LiveData<SmsShareRestriction> 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<Uri> uris) {
|
||||
externalShare = true;
|
||||
shareRepository.getResolved(uris, shareData::postValue);
|
||||
}
|
||||
|
||||
boolean isMultiShare() {
|
||||
return selectedContacts.getValue().size() > 1;
|
||||
}
|
||||
|
||||
@NonNull Single<ContactSelectResult> onContactSelected(@NonNull ShareContact selectedContact) {
|
||||
return Single.fromCallable(() -> {
|
||||
if (selectedContact.getRecipientId().isPresent()) {
|
||||
Recipient recipient = Recipient.resolved(selectedContact.getRecipientId().get());
|
||||
|
||||
if (recipient.isPushV2Group()) {
|
||||
Optional<GroupDatabase.GroupRecord> 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<ShareContact> 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<ShareContact> contacts = new LinkedHashSet<>(selectedContacts.getValue());
|
||||
if (contacts.remove(selectedContact)) {
|
||||
selectedContacts.setValue(contacts);
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull Set<ShareContact> getShareContacts() {
|
||||
Set<ShareContact> contacts = selectedContacts.getValue();
|
||||
if (contacts == null) {
|
||||
return Collections.emptySet();
|
||||
} else {
|
||||
return contacts;
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull LiveData<MappingModelList> getSelectedContactModels() {
|
||||
return Transformations.map(selectedContacts, set -> Stream.of(set)
|
||||
.mapIndexed((i, c) -> new ShareSelectionMappingModel(c, i == 0))
|
||||
.collect(MappingModelList.toMappingModelList()));
|
||||
}
|
||||
|
||||
@NonNull LiveData<SmsShareRestriction> getSmsShareRestriction() {
|
||||
return Transformations.distinctUntilChanged(smsShareRestriction);
|
||||
}
|
||||
|
||||
void onNonExternalShare() {
|
||||
shareData.setValue(Optional.empty());
|
||||
externalShare = false;
|
||||
}
|
||||
|
||||
public void onSuccessfulShare() {
|
||||
mediaUsed = true;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Optional<ShareData>> 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<ShareContact> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new ShareViewModel());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ShareContactAndThread> shareContactAndThreads, Consumer<List<Recipient>> consumer) {
|
||||
SignalExecutors.BOUNDED.execute(() -> consumer.accept(resolveRecipients(shareContactAndThreads)));
|
||||
void loadRecipients(@NonNull Set<ContactSearchKey.RecipientSearchKey> recipientSearchKeys, Consumer<List<Recipient>> consumer) {
|
||||
SignalExecutors.BOUNDED.execute(() -> consumer.accept(resolveRecipients(recipientSearchKeys)));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private List<Recipient> resolveRecipients(@NonNull Set<ShareContactAndThread> shareContactAndThreads) {
|
||||
return Stream.of(shareContactAndThreads)
|
||||
.map(ShareContactAndThread::getRecipientId)
|
||||
private List<Recipient> resolveRecipients(@NonNull Set<ContactSearchKey.RecipientSearchKey> recipientSearchKeys) {
|
||||
return Stream.of(recipientSearchKeys)
|
||||
.map(ContactSearchKey.RecipientSearchKey::getRecipientId)
|
||||
.map(Recipient::resolved)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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<org.thoughtcrime.securesms.mediasend.Media>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<Intent>
|
||||
|
||||
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<ContactSearchKey.ParcelableRecipientSearchKey> = 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<Uri>(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<Uri>(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<Media> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ContactSearchKey.RecipientSearchKey>
|
||||
|
||||
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<ContactSearchKey.RecipientSearchKey> = listOf(contact)
|
||||
}
|
||||
|
||||
data class OpenMediaInterstitial(override val shareData: ResolvedShareData, override val contacts: List<ContactSearchKey.RecipientSearchKey>) : ShareEvent()
|
||||
data class OpenTextInterstitial(override val shareData: ResolvedShareData, override val contacts: List<ContactSearchKey.RecipientSearchKey>) : ShareEvent()
|
||||
data class SendWithoutInterstitial(override val shareData: ResolvedShareData, override val contacts: List<ContactSearchKey.RecipientSearchKey>) : ShareEvent()
|
||||
}
|
||||
@@ -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<ResolvedShareData> {
|
||||
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<Uri, String> = externalMultiShare.uris
|
||||
.associateWith { uri -> getMimeType(appContext, uri, null) }
|
||||
.filterValues {
|
||||
MediaUtil.isImageType(it) || MediaUtil.isVideoType(it)
|
||||
}
|
||||
|
||||
if (mimeTypes.isEmpty()) {
|
||||
return ResolvedShareData.Failure
|
||||
}
|
||||
|
||||
val media: List<Media> = 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<Int, Int> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<ShareEvent>()
|
||||
|
||||
val state: Flowable<ShareState> = store.stateFlowable
|
||||
val events: Observable<ShareEvent> = 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<ContactSearchKey>) {
|
||||
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 <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ShareViewModel(unresolvedShareData, directShareTarget, shareRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.sharing.v2
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
sealed class UnresolvedShareData {
|
||||
data class ExternalMultiShare(val uris: List<Uri>) : UnresolvedShareData()
|
||||
data class ExternalSingleShare(val uri: Uri, val mimeType: String?) : UnresolvedShareData()
|
||||
data class ExternalPrimitiveShare(val text: CharSequence) : UnresolvedShareData()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<View> = Stub(findViewById(R.id.loading_spinner))
|
||||
|
||||
fun bind(linkPreview: LinkPreview?, hiddenVisibility: Int = View.INVISIBLE): ListenableFuture<Boolean> {
|
||||
var listenableFuture: ListenableFuture<Boolean>? = 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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -138,7 +138,6 @@ object StoriesLandingItem {
|
||||
val storyTextPostModel = StoryTextPostModel.parseFrom(record)
|
||||
GlideApp.with(storyPreview)
|
||||
.load(storyTextPostModel)
|
||||
.addListener(HideBlurAfterLoadListener())
|
||||
.placeholder(storyTextPostModel.getPlaceholder())
|
||||
.centerCrop()
|
||||
.dontAnimate()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:minHeight="?attr/actionBarSize"
|
||||
android:theme="?attr/actionBarStyle"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_toStartOf="@+id/search_action"
|
||||
android:text="@string/ShareActivity_share_with"
|
||||
android:textColor="@color/signal_text_primary"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/search_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:tint="@color/signal_icon_tint_primary"
|
||||
app:srcCompat="@drawable/ic_search_24" />
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
</androidx.appcompat.widget.Toolbar>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/contact_selection_list_fragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_below="@id/toolbar"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.SearchToolbar
|
||||
android:id="@+id/search_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:elevation="4dp"
|
||||
android:visibility="invisible"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:visibility="invisible" />
|
||||
|
||||
<View
|
||||
android:id="@+id/divider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="2dp"
|
||||
android:alpha="0"
|
||||
android:background="@drawable/compose_divider_background"
|
||||
android:translationY="48dp"
|
||||
app:layout_constraintBottom_toTopOf="@id/selected_list" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/selected_list"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:alpha="0"
|
||||
android:background="@color/signal_background_primary"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="78dp"
|
||||
android:translationY="48dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:listitem="@layout/share_contact_selection_item" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/share_confirm"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:contentDescription="@string/ShareActivity__share"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:srcCompat="@drawable/ic_continue_24" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
12
app/src/main/res/layout/share_activity_v2.xml
Normal file
12
app/src/main/res/layout/share_activity_v2.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/fragment_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -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" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/contact_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="44dp"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||
</LinearLayout>
|
||||
@@ -104,6 +104,16 @@
|
||||
app:layout_constraintTop_toBottomOf="@id/link_preview_description"
|
||||
tools:text="www.asdf.com" />
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/loading_spinner"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:inflatedId="@id/loading_spinner"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/loading_spinner"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:indicatorColor="@color/core_white"
|
||||
app:trackThickness="3dp" />
|
||||
@@ -7,10 +7,10 @@
|
||||
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||
|
||||
<ImageView
|
||||
android:importantForAccessibility="no"
|
||||
android:id="@+id/text_story_post_background"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Story.Text" />
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<org.thoughtcrime.securesms.stories.StoryTextView
|
||||
android:id="@+id/text_story_post_text"
|
||||
|
||||
@@ -71,4 +71,8 @@
|
||||
android:id="@+id/action_directly_to_mediaReviewFragment"
|
||||
app:destination="@id/mediaReviewFragment" />
|
||||
|
||||
<action
|
||||
android:id="@+id/action_directly_to_textPostCreationFragment"
|
||||
app:destination="@id/textStoryPostCreationFragment" />
|
||||
|
||||
</navigation>
|
||||
@@ -4647,6 +4647,10 @@
|
||||
<string name="StorySlateView__no_internet_connection">No Internet Connection</string>
|
||||
<!-- Displayed in the viewer when network is available but content could not be downloaded -->
|
||||
<string name="StorySlateView__couldnt_load_content">Couldn\'t Load Content</string>
|
||||
<!-- Toasted when the user externally shares to a text story successfully -->
|
||||
<string name="TextStoryPostCreationFragment__sent_story">Sent story</string>
|
||||
<!-- Toasted when the user external share to a text story fails -->
|
||||
<string name="TextStoryPostCreationFragment__failed_to_send_story">Failed to send story</string>
|
||||
|
||||
<!-- Title for a notification at the bottom of the chat list suggesting that the user disable censorship circumvention because the service has become reachable -->
|
||||
<string name="TurnOffCircumventionMegaphone_turn_off_censorship_circumvention">Turn off censorship circumvention?</string>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user