Enable sharing to stories and refactor share activity.

This commit is contained in:
Alex Hart
2022-04-07 15:47:56 -03:00
committed by Greyson Parrelli
parent fd4543ffe0
commit 523537cf05
62 changed files with 1188 additions and 1855 deletions

View File

@@ -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=""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = {

View File

@@ -98,4 +98,7 @@ object TextStoryBackgroundColors {
return backgroundColors[indexOfNextColor]
}
@JvmStatic
fun getRandomBackgroundColor() = backgroundColors.random()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -138,7 +138,6 @@ object StoriesLandingItem {
val storyTextPostModel = StoryTextPostModel.parseFrom(record)
GlideApp.with(storyPreview)
.load(storyTextPostModel)
.addListener(HideBlurAfterLoadListener())
.placeholder(storyTextPostModel.getPlaceholder())
.centerCrop()
.dontAnimate()

View File

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

View File

@@ -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" />

View File

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

View 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>

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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