Implement Stories feature behind flag.

Co-Authored-By: Greyson Parrelli <37311915+greyson-signal@users.noreply.github.com>
Co-Authored-By: Rashad Sookram <95182499+rashad-signal@users.noreply.github.com>
This commit is contained in:
Alex Hart
2022-02-24 13:40:28 -04:00
parent 765185952e
commit 174cd860a0
416 changed files with 19506 additions and 857 deletions

View File

@@ -93,6 +93,7 @@ import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.ConversationItemAnimator;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration;
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.conversation.ui.error.EnableCallNotificationSettingsDialog;
@@ -185,7 +186,7 @@ import java.util.concurrent.ExecutionException;
import kotlin.Unit;
@SuppressLint("StaticFieldLeak")
public class ConversationFragment extends LoggingFragment implements MultiselectForwardFragment.Callback {
public class ConversationFragment extends LoggingFragment implements MultiselectForwardBottomSheet.Callback {
private static final String TAG = Log.tag(ConversationFragment.class);
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
@@ -1013,7 +1014,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
MultiselectForwardFragmentArgs.create(requireContext(),
multiselectParts,
args -> MultiselectForwardFragment.show(getChildFragmentManager(), args));
args -> MultiselectForwardFragment.showBottomSheet(getChildFragmentManager(), args));
}
private void handleResendMessage(final MessageRecord message) {
@@ -1307,6 +1308,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
}
@Override
public void onDismissForwardSheet() {
}
public interface ConversationFragmentListener extends VoiceNoteMediaControllerOwner {
boolean isKeyboardOpen();

View File

@@ -108,6 +108,7 @@ import org.thoughtcrime.securesms.PromptMmsActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.ShortcutLauncherActivity;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
import org.thoughtcrime.securesms.audio.AudioRecorder;
@@ -300,6 +301,7 @@ import org.whispersystems.signalservice.api.SignalSessionLock;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
@@ -2955,7 +2957,7 @@ public class ConversationParentFragment extends Fragment
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull();
List<Mention> mentions = new ArrayList<>(result.getMentions());
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, quote, Collections.emptyList(), Collections.emptyList(), mentions);
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, result.isStory(), null, quote, Collections.emptyList(), Collections.emptyList(), mentions);
OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message);
final Context context = requireContext().getApplicationContext();
@@ -3031,7 +3033,7 @@ public class ConversationParentFragment extends Fragment
}
}
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions);
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(Recipient.resolved(recipientId), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, false, null, quote, contacts, previews, mentions);
final SettableFuture<Void> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();

View File

@@ -17,6 +17,7 @@ import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.avatar.view.AvatarView;
import org.thoughtcrime.securesms.badges.BadgeImageView;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.mms.GlideRequests;
@@ -29,7 +30,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
public class ConversationTitleView extends RelativeLayout {
private AvatarImageView avatar;
private AvatarView avatar;
private BadgeImageView badge;
private TextView title;
private TextView subtitle;
@@ -111,7 +112,7 @@ public class ConversationTitleView extends RelativeLayout {
title.setCompoundDrawablesRelativeWithIntrinsicBounds(startDrawable, null, endDrawable, null);
if (recipient != null) {
this.avatar.setAvatar(glideRequests, recipient, false);
this.avatar.displayChatAvatar(glideRequests, recipient, false);
}
if (recipient == null || recipient.isSelf()) {

View File

@@ -10,8 +10,11 @@ import android.graphics.drawable.Drawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.OvalShape
import android.os.Build
import android.os.Parcel
import android.os.Parcelable
import androidx.annotation.ColorInt
import com.google.common.base.Objects
import kotlinx.parcelize.Parcelize
import org.signal.core.util.ColorUtil
import org.thoughtcrime.securesms.components.RotatableGradientDrawable
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
@@ -25,11 +28,12 @@ import kotlin.math.min
* @param linearGradient The LinearGradient to render. Null if this is for a single color.
* @param singleColor The single color to render. Null if this is for a linear gradient.
*/
@Parcelize
class ChatColors private constructor(
val id: Id,
private val linearGradient: LinearGradient?,
private val singleColor: Int?
) {
) : Parcelable {
fun isGradient(): Boolean = Build.VERSION.SDK_INT >= 21 && linearGradient != null
@@ -182,7 +186,7 @@ class ChatColors private constructor(
ChatColors(id, null, color)
}
sealed class Id(val longValue: Long) {
sealed class Id(val longValue: Long) : Parcelable {
/**
* Represents user selection of 'auto'.
*/
@@ -211,6 +215,12 @@ class ChatColors private constructor(
return Objects.hashCode(longValue)
}
override fun writeToParcel(dest: Parcel, flags: Int) {
dest.writeLong(longValue)
}
override fun describeContents(): Int = 0
companion object {
@JvmStatic
fun forLongValue(longValue: Long): Id {
@@ -221,14 +231,26 @@ class ChatColors private constructor(
else -> Custom(longValue)
}
}
@JvmField
val CREATOR = object : Parcelable.Creator<Id> {
override fun createFromParcel(parcel: Parcel): Id {
return forLongValue(parcel.readLong())
}
override fun newArray(size: Int): Array<Id?> {
return arrayOfNulls(size)
}
}
}
}
@Parcelize
data class LinearGradient(
val degrees: Float,
val colors: IntArray,
val positions: FloatArray
) {
) : Parcelable {
override fun equals(other: Any?): Boolean {
if (this === other) return true

View File

@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.conversation.colors
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.annimon.stream.Stream
import org.signal.core.util.MapUtil
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette.Names.all
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry.FullMember
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DefaultValueLiveData
import org.whispersystems.libsignal.util.guava.Optional
import java.util.HashMap
import java.util.HashSet
object NameColors {
fun createSessionMembersCache(): MutableMap<GroupId, Set<Recipient>> {
return mutableMapOf()
}
fun getNameColorsMapLiveData(
recipientId: LiveData<RecipientId>,
sessionMemberCache: MutableMap<GroupId, Set<Recipient>>
): LiveData<Map<RecipientId, NameColor>> {
val recipient = Transformations.switchMap(recipientId) { r: RecipientId? -> Recipient.live(r!!).liveData }
val group = Transformations.map(recipient) { obj: Recipient -> obj.groupId }
val groupMembers = Transformations.switchMap(group) { g: Optional<GroupId> ->
g.transform { groupId: GroupId -> this.getSessionGroupRecipients(groupId, sessionMemberCache) }
.or { DefaultValueLiveData(emptySet()) }
}
return Transformations.map(groupMembers) { members: Set<Recipient>? ->
val sorted = Stream.of(members)
.filter { member: Recipient? -> member != Recipient.self() }
.sortBy { obj: Recipient -> obj.requireStringId() }
.toList()
val names = all
val colors: MutableMap<RecipientId, NameColor> = HashMap()
for (i in sorted.indices) {
colors[sorted[i].id] = names[i % names.size]
}
colors
}
}
private fun getSessionGroupRecipients(groupId: GroupId, sessionMemberCache: MutableMap<GroupId, Set<Recipient>>): LiveData<Set<Recipient>> {
val fullMembers = Transformations.map(
LiveGroup(groupId).fullMembers
) { members: List<FullMember>? ->
Stream.of(members)
.map { it.member }
.toList()
}
return Transformations.map(fullMembers) { currentMembership: List<Recipient>? ->
val cachedMembers: MutableSet<Recipient> = MapUtil.getOrDefault(sessionMemberCache, groupId, HashSet()).toMutableSet()
cachedMembers.addAll(currentMembership!!)
sessionMemberCache[groupId] = cachedMembers
cachedMembers
}
}
}

View File

@@ -0,0 +1,67 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.setFragmentResult
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.fragments.findListener
class MultiselectForwardBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(), MultiselectForwardFragment.Callback {
override val peekHeightPercentage: Float = 0.67f
private var callback: Callback? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.multiselect_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
callback = findListener<Callback>()
if (savedInstanceState == null) {
val fragment = MultiselectForwardFragment()
fragment.arguments = requireArguments()
childFragmentManager.beginTransaction()
.replace(R.id.multiselect_container, fragment)
.commitAllowingStateLoss()
}
}
override fun getContainer(): ViewGroup {
return requireView().parent.parent.parent as ViewGroup
}
override fun setResult(bundle: Bundle) {
setFragmentResult(MultiselectForwardFragment.RESULT_SELECTION, bundle)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
callback?.onDismissForwardSheet()
}
override fun onFinishForwardAction() {
callback?.onFinishForwardAction()
}
override fun exitFlow() {
dismissAllowingStateLoss()
}
override fun onSearchInputFocused() {
(requireDialog() as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
interface Callback {
fun onFinishForwardAction()
fun onDismissForwardSheet()
}
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.content.DialogInterface
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -9,67 +8,56 @@ import android.view.View
import android.view.ViewGroup
import android.view.animation.AnimationUtils
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.PluralsRes
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet
import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryFlowDialogFragment
import org.thoughtcrime.securesms.stories.settings.create.CreateStoryWithViewersFragment
import org.thoughtcrime.securesms.util.BottomSheetUtil
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.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import org.thoughtcrime.securesms.util.visible
import org.whispersystems.libsignal.util.guava.Optional
import java.util.function.Consumer
private const val ARG_MULTISHARE_ARGS = "multiselect.forward.fragment.arg.multishare.args"
private const val ARG_CAN_SEND_TO_NON_PUSH = "multiselect.forward.fragment.arg.can.send.to.non.push"
private const val ARG_TITLE = "multiselect.forward.fragment.title"
private val TAG = Log.tag(MultiselectForwardFragment::class.java)
class MultiselectForwardFragment :
FixedRoundedCornerBottomSheetDialogFragment(),
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.OnSelectionLimitReachedListener,
SafetyNumberChangeDialog.Callback {
override val peekHeightPercentage: Float = 0.67f
Fragment(),
SafetyNumberChangeDialog.Callback,
ChooseStoryTypeBottomSheet.Callback {
private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory)
private val disposables = LifecycleDisposable()
private lateinit var selectionFragment: ContactSelectionListFragment
private lateinit var contactFilterView: ContactFilterView
private lateinit var addMessage: EditText
private lateinit var contactSearchMediator: ContactSearchMediator
private var callback: Callback? = null
private lateinit var callback: Callback
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
private var handler: Handler? = null
private fun createViewModelFactory(): MultiselectForwardViewModel.Factory {
@@ -79,63 +67,44 @@ class MultiselectForwardFragment :
private fun getMultiShareArgs(): ArrayList<MultiShareArgs> = requireNotNull(requireArguments().getParcelableArrayList(ARG_MULTISHARE_ARGS))
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
childFragmentManager.addFragmentOnAttachListener { _, fragment ->
fragment.arguments = Bundle().apply {
putInt(ContactSelectionListFragment.DISPLAY_MODE, getDefaultDisplayMode())
putBoolean(ContactSelectionListFragment.REFRESHABLE, false)
putBoolean(ContactSelectionListFragment.RECENTS, true)
putParcelable(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.shareSelectionLimit())
putBoolean(ContactSelectionListFragment.HIDE_COUNT, true)
putBoolean(ContactSelectionListFragment.DISPLAY_CHIPS, false)
putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, true)
putBoolean(ContactSelectionListFragment.RV_CLIP, false)
putInt(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(48))
}
}
val view = inflater.inflate(R.layout.multiselect_forward_fragment, container, false)
view.minimumHeight = resources.displayMetrics.heightPixels
return view
return inflater.inflate(R.layout.multiselect_forward_fragment, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
callback = findListener()
disposables.bindTo(viewLifecycleOwner.lifecycle)
view.minimumHeight = resources.displayMetrics.heightPixels
selectionFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list_fragment) as ContactSelectionListFragment
val contactSearchRecycler: RecyclerView = view.findViewById(R.id.contact_selection_list)
contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), this::getConfiguration)
callback = findListener()!!
disposables.bindTo(viewLifecycleOwner.lifecycle)
contactFilterView = view.findViewById(R.id.contact_filter_edit_text)
contactFilterView.setOnSearchInputFocusChangedListener { _, hasFocus ->
if (hasFocus) {
(requireDialog() as BottomSheetDialog).behavior.state = BottomSheetBehavior.STATE_EXPANDED
callback.onSearchInputFocused()
}
}
contactFilterView.setOnFilterChangedListener {
if (it.isNullOrEmpty()) {
selectionFragment.resetQueryFilter()
} else {
selectionFragment.setQueryFilter(it)
}
contactSearchMediator.onFilterChanged(it)
}
val title: TextView = view.findViewById(R.id.title)
val container = view.parent.parent.parent as FrameLayout
val title: TextView? = view.findViewById(R.id.title)
val container = callback.getContainer()
val bottomBar = LayoutInflater.from(requireContext()).inflate(R.layout.multiselect_forward_fragment_bottom_bar, container, false)
val shareSelectionRecycler: RecyclerView = bottomBar.findViewById(R.id.selected_list)
val shareSelectionAdapter = ShareSelectionAdapter()
val sendButton: View = bottomBar.findViewById(R.id.share_confirm)
title.setText(requireArguments().getInt(ARG_TITLE))
title?.setText(requireArguments().getInt(ARG_TITLE))
addMessage = bottomBar.findViewById(R.id.add_message)
sendButton.setOnClickListener {
sendButton.isEnabled = false
viewModel.send(addMessage.text.toString())
viewModel.send(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
}
shareSelectionRecycler.adapter = shareSelectionAdapter
@@ -144,8 +113,8 @@ class MultiselectForwardFragment :
container.addView(bottomBar)
viewModel.shareContactMappingModels.observe(viewLifecycleOwner) {
shareSelectionAdapter.submitList(it)
contactSearchMediator.getSelectionState().observe(viewLifecycleOwner) {
shareSelectionAdapter.submitList(it.mapIndexed { index, key -> ShareSelectionMappingModel(key.requireShareContact(), index == 0) })
if (it.isNotEmpty() && !bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom)
@@ -158,7 +127,7 @@ class MultiselectForwardFragment :
viewModel.state.observe(viewLifecycleOwner) {
when (it.stage) {
MultiselectForwardState.Stage.Selection -> { }
MultiselectForwardState.Stage.Selection -> {}
MultiselectForwardState.Stage.FirstConfirmation -> displayFirstSendConfirmation()
is MultiselectForwardState.Stage.SafetyConfirmation -> displaySafetyNumberConfirmation(it.stage.identities)
MultiselectForwardState.Stage.LoadingIdentities -> {}
@@ -170,17 +139,27 @@ class MultiselectForwardFragment :
MultiselectForwardState.Stage.SomeFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
MultiselectForwardState.Stage.AllFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_failed_to_send)
MultiselectForwardState.Stage.Success -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
is MultiselectForwardState.Stage.SelectionConfirmed -> dismissWithResult(it.stage.recipients)
is MultiselectForwardState.Stage.SelectionConfirmed -> dismissWithSelection(it.stage.selectedContacts)
}
sendButton.isEnabled = it.stage == MultiselectForwardState.Stage.Selection
}
bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
selectionFragment.setRecyclerViewPaddingBottom(bottom - top)
addMessage.visible = getMultiShareArgs().isNotEmpty()
setFragmentResultListener(CreateStoryWithViewersFragment.REQUEST_KEY) { _, bundle ->
val recipientId: RecipientId = bundle.getParcelable(CreateStoryWithViewersFragment.STORY_RECIPIENT)!!
contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.Story(recipientId)))
contactFilterView.clear()
}
addMessage.visible = getMultiShareArgs().isNotEmpty()
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()
contactSearchMediator.addToVisibleGroupStories(keys)
contactSearchMediator.setKeysSelected(keys)
contactFilterView.clear()
}
}
override fun onResume() {
@@ -207,9 +186,9 @@ class MultiselectForwardFragment :
handler?.removeCallbacksAndMessages(null)
}
override fun onDismiss(dialog: DialogInterface) {
override fun onDestroyView() {
dismissibleDialog?.dismissNow()
super.onDismiss(dialog)
super.onDestroyView()
}
private fun displayFirstSendConfirmation() {
@@ -222,7 +201,7 @@ class MultiselectForwardFragment :
.setMessage(R.string.MultiselectForwardFragment__forwarded_messages_are_now)
.setPositiveButton(resources.getQuantityString(R.plurals.MultiselectForwardFragment_send_d_messages, messageCount, messageCount)) { d, _ ->
d.dismiss()
viewModel.confirmFirstSend(addMessage.text.toString())
viewModel.confirmFirstSend(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
}
.setNegativeButton(android.R.string.cancel) { d, _ ->
d.dismiss()
@@ -238,84 +217,35 @@ class MultiselectForwardFragment :
private fun dismissAndShowToast(@PluralsRes toastTextResId: Int) {
val argCount = getMessageCount()
callback?.onFinishForwardAction()
callback.onFinishForwardAction()
dismissibleDialog?.dismiss()
Toast.makeText(requireContext(), requireContext().resources.getQuantityString(toastTextResId, argCount), Toast.LENGTH_SHORT).show()
dismissAllowingStateLoss()
}
private fun dismissWithResult(recipientIds: List<RecipientId>) {
callback?.onFinishForwardAction()
dismissibleDialog?.dismiss()
setFragmentResult(
RESULT_SELECTION,
Bundle().apply {
putParcelableArrayList(RESULT_SELECTION_RECIPIENTS, ArrayList(recipientIds))
}
)
dismissAllowingStateLoss()
callback.exitFlow()
}
private fun getMessageCount(): Int = getMultiShareArgs().size + if (addMessage.text.isNotEmpty()) 1 else 0
private fun handleMessageExpired() {
dismissAllowingStateLoss()
callback?.onFinishForwardAction()
callback.onFinishForwardAction()
dismissibleDialog?.dismiss()
Toast.makeText(requireContext(), resources.getQuantityString(R.plurals.MultiselectForwardFragment__couldnt_forward_messages, getMultiShareArgs().size), Toast.LENGTH_LONG).show()
callback.exitFlow()
}
private fun getDefaultDisplayMode(): Int {
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or
ContactsCursorLoader.DisplayMode.FLAG_SELF or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_RECENT_HEADER
private fun dismissWithSelection(selectedContacts: Set<ContactSearchKey>) {
callback.onFinishForwardAction()
dismissibleDialog?.dismiss()
if (Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)) {
mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS
val resultsBundle = Bundle().apply {
putParcelableArrayList(RESULT_SELECTION_RECIPIENTS, ArrayList(selectedContacts.map { it.requireParcelable() }))
}
return mode or ContactsCursorLoader.DisplayMode.FLAG_HIDE_GROUPS_V1
}
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
if (recipientId.isPresent) {
disposables.add(
viewModel.addSelectedContact(recipientId, null)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { success ->
if (!success) {
Toast.makeText(requireContext(), R.string.ShareActivity_you_do_not_have_permission_to_send_to_this_group, Toast.LENGTH_SHORT).show()
}
callback.accept(success)
contactFilterView.clear()
}
)
} else {
Log.w(TAG, "Rejecting non-present recipient. Can't forward to an unknown contact.")
callback.accept(false)
}
}
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String?) {
viewModel.removeSelectedContact(recipientId, null)
}
override fun onSelectionChanged() {
}
override fun onSuggestedLimitReached(limit: Int) {
}
override fun onHardLimitReached(limit: Int) {
Toast.makeText(requireContext(), R.string.MultiselectForwardFragment__limit_reached, Toast.LENGTH_SHORT).show()
callback.setResult(resultsBundle)
callback.exitFlow()
}
override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList<RecipientId>) {
viewModel.confirmSafetySend(addMessage.text.toString())
viewModel.confirmSafetySend(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
}
override fun onMessageResentAfterSafetyNumberChange() {
@@ -326,14 +256,98 @@ class MultiselectForwardFragment :
viewModel.cancelSend()
}
companion object {
private fun getHeaderAction(): HeaderAction {
return HeaderAction(
R.string.ContactsCursorLoader_new_story,
R.drawable.ic_plus_20
) {
ChooseStoryTypeBottomSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
private fun getConfiguration(contactSearchState: ContactSearchState): ContactSearchConfiguration {
return ContactSearchConfiguration.build {
query = contactSearchState.query
addSection(
ContactSearchConfiguration.Section.Stories(
groupStories = contactSearchState.groupStories,
includeHeader = true,
headerAction = getHeaderAction(),
expandConfig = ContactSearchConfiguration.ExpandConfig(
isExpanded = contactSearchState.expandedSections.contains(ContactSearchConfiguration.SectionKey.STORIES)
)
)
)
if (query.isNullOrEmpty()) {
addSection(
ContactSearchConfiguration.Section.Recents(
includeHeader = true
)
)
}
addSection(
ContactSearchConfiguration.Section.Individuals(
includeHeader = true,
transportType = if (includeSms()) ContactSearchConfiguration.TransportType.ALL else ContactSearchConfiguration.TransportType.PUSH,
includeSelf = true
)
)
addSection(
ContactSearchConfiguration.Section.Groups(
includeHeader = true,
includeMms = includeSms()
)
)
}
}
private fun includeSms(): Boolean {
return Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)
}
override fun onGroupStoryClicked() {
ChooseGroupStoryBottomSheet().show(parentFragmentManager, ChooseGroupStoryBottomSheet.GROUP_STORY)
}
override fun onNewStoryClicked() {
CreateStoryFlowDialogFragment().show(parentFragmentManager, CreateStoryWithViewersFragment.REQUEST_KEY)
}
interface Callback {
fun onFinishForwardAction()
fun exitFlow()
fun onSearchInputFocused()
fun setResult(bundle: Bundle)
fun getContainer(): ViewGroup
}
companion object {
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 RESULT_SELECTION = "result_selection"
const val RESULT_SELECTION_RECIPIENTS = "result_selection_recipients"
@JvmStatic
fun show(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
val fragment = 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)
}
@JvmStatic
fun showFullScreen(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
val fragment = MultiselectForwardFullScreenDialogFragment()
fragment.arguments = Bundle().apply {
putParcelableArrayList(ARG_MULTISHARE_ARGS, ArrayList(multiselectForwardFragmentArgs.multiShareArgs))
@@ -344,8 +358,4 @@ class MultiselectForwardFragment :
fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
interface Callback {
fun onFinishForwardAction()
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.setFragmentResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FullScreenDialogFragment
import org.thoughtcrime.securesms.util.fragments.findListener
class MultiselectForwardFullScreenDialogFragment : FullScreenDialogFragment(), MultiselectForwardFragment.Callback {
override fun getTitle(): Int = R.string.MediaReviewFragment__send_to
override fun getDialogLayoutResource(): Int = R.layout.fragment_container
override fun onFinishForwardAction() {
findListener<Callback>()?.onFinishForwardAction()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
val fragment = MultiselectForwardFragment()
fragment.arguments = requireArguments()
childFragmentManager.beginTransaction()
.replace(R.id.fragment_container, fragment)
.commitAllowingStateLoss()
}
}
override fun getContainer(): ViewGroup {
return requireView().findViewById(R.id.full_screen_dialog_content) as ViewGroup
}
override fun setResult(bundle: Bundle) {
setFragmentResult(MultiselectForwardFragment.RESULT_SELECTION, bundle)
}
override fun exitFlow() {
dismissAllowingStateLoss()
}
override fun onSearchInputFocused() = Unit
interface Callback {
fun onFinishForwardAction()
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.core.util.Consumer
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.database.identity.IdentityRecordList
@@ -13,7 +14,6 @@ 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.ShareContact
import org.thoughtcrime.securesms.sharing.ShareContactAndThread
import org.whispersystems.libsignal.util.guava.Optional
@@ -27,9 +27,11 @@ class MultiselectForwardRepository(context: Context) {
val onAllMessagesFailed: () -> Unit
)
fun checkForBadIdentityRecords(shareContacts: List<ShareContact>, consumer: Consumer<List<IdentityRecord>>) {
fun checkForBadIdentityRecords(contactSearchKeys: Set<ContactSearchKey>, consumer: Consumer<List<IdentityRecord>>) {
SignalExecutors.BOUNDED.execute {
val recipients: List<Recipient> = shareContacts.map { Recipient.resolved(it.recipientId.get()) }
val recipients: List<Recipient> = contactSearchKeys
.filterIsInstance<ContactSearchKey.KnownRecipient>()
.map { Recipient.resolved(it.recipientId) }
val identityRecordList: IdentityRecordList = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients)
consumer.accept(identityRecordList.untrustedRecords)
@@ -55,7 +57,7 @@ class MultiselectForwardRepository(context: Context) {
fun send(
additionalMessage: String,
multiShareArgs: List<MultiShareArgs>,
shareContacts: List<ShareContact>,
shareContacts: Set<ContactSearchKey>,
resultHandlers: MultiselectForwardResultHandlers
) {
SignalExecutors.BOUNDED.execute {
@@ -63,10 +65,13 @@ class MultiselectForwardRepository(context: Context) {
val sharedContactsAndThreads: Set<ShareContactAndThread> = shareContacts
.asSequence()
.distinct()
.filter { it.recipientId.isPresent }
.map { Recipient.resolved(it.recipientId.get()) }
.map { ShareContactAndThread(it.id, threadDatabase.getOrCreateThreadIdFor(it), it.isForceSmsSelection) }
.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)
}
.toSet()
val mappedArgs: List<MultiShareArgs> = multiShareArgs.map { it.buildUpon(sharedContactsAndThreads).build() }

View File

@@ -1,11 +1,9 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.ShareContact
data class MultiselectForwardState(
val selectedContacts: List<ShareContact> = emptyList(),
val stage: Stage = Stage.Selection
) {
sealed class Stage {
@@ -17,6 +15,6 @@ data class MultiselectForwardState(
object SomeFailed : Stage()
object AllFailed : Stage()
object Success : Stage()
data class SelectionConfirmed(val recipients: List<RecipientId>) : Stage()
data class SelectionConfirmed(val selectedContacts: Set<ContactSearchKey>) : Stage()
}
}

View File

@@ -1,17 +1,12 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.ShareContact
import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
class MultiselectForwardViewModel(
private val records: List<MultiShareArgs>,
@@ -22,31 +17,15 @@ class MultiselectForwardViewModel(
val state: LiveData<MultiselectForwardState> = store.stateLiveData
val shareContactMappingModels: LiveData<List<ShareSelectionMappingModel>> = Transformations.map(state) { s -> s.selectedContacts.mapIndexed { i, c -> ShareSelectionMappingModel(c, i == 0) } }
fun addSelectedContact(recipientId: Optional<RecipientId>, number: String?): Single<Boolean> {
return repository
.canSelectRecipient(recipientId)
.doOnSuccess { allowed ->
if (allowed) {
store.update { it.copy(selectedContacts = it.selectedContacts + ShareContact(recipientId, number)) }
}
}
}
fun removeSelectedContact(recipientId: Optional<RecipientId>, number: String?) {
store.update { it.copy(selectedContacts = it.selectedContacts - ShareContact(recipientId, number)) }
}
fun send(additionalMessage: String) {
fun send(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
if (SignalStore.tooltips().showMultiForwardDialog()) {
SignalStore.tooltips().markMultiForwardDialogSeen()
store.update { it.copy(stage = MultiselectForwardState.Stage.FirstConfirmation) }
} else {
store.update { it.copy(stage = MultiselectForwardState.Stage.LoadingIdentities) }
repository.checkForBadIdentityRecords(store.state.selectedContacts) { identityRecords ->
repository.checkForBadIdentityRecords(selectedContacts) { identityRecords ->
if (identityRecords.isEmpty()) {
performSend(additionalMessage)
performSend(additionalMessage, selectedContacts)
} else {
store.update { it.copy(stage = MultiselectForwardState.Stage.SafetyConfirmation(identityRecords)) }
}
@@ -54,33 +33,27 @@ class MultiselectForwardViewModel(
}
}
fun confirmFirstSend(additionalMessage: String) {
send(additionalMessage)
fun confirmFirstSend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
send(additionalMessage, selectedContacts)
}
fun confirmSafetySend(additionalMessage: String) {
send(additionalMessage)
fun confirmSafetySend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
send(additionalMessage, selectedContacts)
}
fun cancelSend() {
store.update { it.copy(stage = MultiselectForwardState.Stage.Selection) }
}
private fun performSend(additionalMessage: String) {
private fun performSend(additionalMessage: String, selectedContacts: Set<ContactSearchKey>) {
store.update { it.copy(stage = MultiselectForwardState.Stage.SendPending) }
if (records.isEmpty()) {
store.update { state ->
state.copy(
stage = MultiselectForwardState.Stage.SelectionConfirmed(
state.selectedContacts.filter { it.recipientId.isPresent }.map { it.recipientId.get() }.distinct()
)
)
}
store.update { it.copy(stage = MultiselectForwardState.Stage.SelectionConfirmed(selectedContacts)) }
} else {
repository.send(
additionalMessage = additionalMessage,
multiShareArgs = records,
shareContacts = store.state.selectedContacts,
shareContacts = selectedContacts,
MultiselectForwardRepository.MultiselectForwardResultHandlers(
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.Success) } },
onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.AllFailed) } },