From c65761a034992a15593f57acf4c3ead2ddcb404c Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 17 Aug 2021 16:15:09 -0300 Subject: [PATCH] Fix several issues with multiforwarding. * Better forwarding and animations. * Handle audio with text. * Increase max forwardable count to 32 * Onboarding dialog. * Send forth link previews. * Safety number support. * Fix slide behaviour. --- .../contacts/ContactsCursorLoader.java | 34 ++-- .../conversation/ConversationAdapter.java | 5 + .../conversation/ConversationFragment.java | 58 ++++++- .../conversation/ConversationItem.java | 93 ++++++----- .../conversation/ConversationUpdateItem.java | 5 + .../securesms/conversation/MenuState.java | 4 +- .../mutiselect/MultiselectCollection.kt | 2 + .../mutiselect/MultiselectItemAnimator.kt | 154 ++++++++++++++++++ .../mutiselect/MultiselectItemDecoration.kt | 100 +++++++++--- .../mutiselect/MultiselectPart.kt | 6 + .../mutiselect/Multiselectable.kt | 3 + .../forward/MultiselectForwardFragment.kt | 131 +++++++++++++-- .../forward/MultiselectForwardFragmentArgs.kt | 9 +- .../forward/MultiselectForwardRepository.kt | 15 +- .../forward/MultiselectForwardState.kt | 17 +- .../forward/MultiselectForwardViewModel.kt | 36 +++- .../ui/error/SafetyNumberChangeDialog.java | 9 +- .../securesms/keyvalue/TooltipValues.java | 10 ++ .../securesms/sharing/MultiShareArgs.java | 33 +++- .../securesms/sharing/MultiShareSender.java | 44 ++++- ...ultiselect_forward_fragment_bottom_bar.xml | 1 + app/src/main/res/values/strings.xml | 10 ++ 22 files changed, 662 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemAnimator.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java index d847167dc..78e69f582 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactsCursorLoader.java @@ -26,12 +26,9 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; -import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.phonenumbers.NumberUtil; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.UsernameUtil; @@ -48,15 +45,16 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { private static final String TAG = Log.tag(ContactsCursorLoader.class); public static final class DisplayMode { - public static final int FLAG_PUSH = 1; - public static final int FLAG_SMS = 1 << 1; - public static final int FLAG_ACTIVE_GROUPS = 1 << 2; - public static final int FLAG_INACTIVE_GROUPS = 1 << 3; - public static final int FLAG_SELF = 1 << 4; - public static final int FLAG_BLOCK = 1 << 5; - public static final int FLAG_HIDE_GROUPS_V1 = 1 << 5; - public static final int FLAG_HIDE_NEW = 1 << 6; - public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF; + public static final int FLAG_PUSH = 1; + public static final int FLAG_SMS = 1 << 1; + public static final int FLAG_ACTIVE_GROUPS = 1 << 2; + public static final int FLAG_INACTIVE_GROUPS = 1 << 3; + public static final int FLAG_SELF = 1 << 4; + public static final int FLAG_BLOCK = 1 << 5; + public static final int FLAG_HIDE_GROUPS_V1 = 1 << 5; + public static final int FLAG_HIDE_NEW = 1 << 6; + public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7; + public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF; } private static final int RECENT_CONVERSATION_MAX = 25; @@ -115,7 +113,9 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { Cursor recentConversations = getRecentConversationsCursor(); if (recentConversations.getCount() > 0) { - cursorList.add(ContactsCursorRows.forRecentsHeader(getContext())); + if (!hideRecentsHeader(mode)) { + cursorList.add(ContactsCursorRows.forRecentsHeader(getContext())); + } cursorList.add(recentConversations); } } @@ -139,7 +139,9 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { Cursor groups = getRecentConversationsCursor(true); if (groups.getCount() > 0) { - cursorList.add(ContactsCursorRows.forRecentsHeader(getContext())); + if (!hideRecentsHeader(mode)) { + cursorList.add(ContactsCursorRows.forRecentsHeader(getContext())); + } cursorList.add(groups); } } @@ -279,6 +281,10 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader { return flagSet(mode, DisplayMode.FLAG_HIDE_NEW); } + private static boolean hideRecentsHeader(int mode) { + return flagSet(mode, DisplayMode.FLAG_HIDE_RECENT_HEADER); + } + private static boolean flagSet(int mode, int flag) { return (mode & flag) > 0; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index b0bab45b0..528a665d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -16,6 +16,7 @@ */ package org.thoughtcrime.securesms.conversation; +import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Context; import android.view.LayoutInflater; @@ -567,6 +568,10 @@ public class ConversationAdapter return new HashSet<>(selected); } + public void removeFromSelection(@NonNull Set parts) { + selected.removeAll(parts); + } + /** * Clears all selected records from multi-select mode. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 39220bf31..6f802e935 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -89,6 +89,7 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderV import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.conversation.colors.ColorizerView; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemAnimator; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment; @@ -169,6 +170,7 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -258,11 +260,33 @@ public class ConversationFragment extends LoggingFragment { ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent()); colorizerView.setBackground(args.getChatColors().getChatBubbleMask()); - final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true); + final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true); + final MultiselectItemAnimator multiselectItemAnimator = new MultiselectItemAnimator(() -> { + ConversationAdapter adapter = getListAdapter(); + if (adapter == null) { + return false; + } else { + return Util.hasItems(adapter.getSelectedItems()); + } + }, multiselectPart -> { + ConversationAdapter adapter = getListAdapter(); + if (adapter == null) { + return false; + } else { + return adapter.getSelectedItems().contains(multiselectPart); + } + }); + MultiselectItemDecoration multiselectItemDecoration = new MultiselectItemDecoration(requireContext(), + () -> conversationViewModel.getWallpaper().getValue(), + multiselectItemAnimator::getSelectedProgressForPart, + multiselectItemAnimator::isInitialAnimation); + list.setHasFixedSize(false); list.setLayoutManager(layoutManager); - list.addItemDecoration(new MultiselectItemDecoration(requireContext(), () -> conversationViewModel.getWallpaper().getValue())); - list.setItemAnimator(null); + list.addItemDecoration(multiselectItemDecoration); + list.setItemAnimator(multiselectItemAnimator); + + getViewLifecycleOwner().getLifecycle().addObserver(multiselectItemDecoration); if (Build.VERSION.SDK_INT >= 31) { list.setOverScrollMode(View.OVER_SCROLL_NEVER); @@ -675,6 +699,7 @@ public class ConversationFragment extends LoggingFragment { ConversationAdapter.initializePool(list.getRecycledViewPool()); adapter.registerAdapterDataObserver(snapToTopDataObserver); + adapter.registerAdapterDataObserver(new CheckExpirationDataObserver()); setLastSeen(conversationViewModel.getLastSeen()); @@ -1706,6 +1731,33 @@ public class ConversationFragment extends LoggingFragment { actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback); } + private final class CheckExpirationDataObserver extends RecyclerView.AdapterDataObserver { + @Override + public void onItemRangeRemoved(int positionStart, int itemCount) { + ConversationAdapter adapter = getListAdapter(); + if (adapter == null || actionMode == null) { + return; + } + + Set selected = adapter.getSelectedItems(); + Set expired = new HashSet<>(); + + for (final MultiselectPart multiselectPart : selected) { + if (multiselectPart.isExpired()) { + expired.add(multiselectPart); + } + } + + adapter.removeFromSelection(expired); + + if (adapter.getSelectedItems().isEmpty()) { + actionMode.finish(); + } else { + actionMode.setTitle(String.valueOf(adapter.getSelectedItems().size())); + } + } + } + private final class ConversationSnapToTopDataObserver extends SnapToTopDataObserver { public ConversationSnapToTopDataObserver(@NonNull RecyclerView recyclerView, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 36ba8ab8b..7b4ef324a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -24,7 +24,6 @@ import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.graphics.Color; -import android.graphics.Paint; import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.Typeface; @@ -141,7 +140,6 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; -import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Locale; @@ -533,63 +531,69 @@ public final class ConversationItem extends RelativeLayout implements BindableCo MultiselectPart bottom = parts.asDouble().getBottomPart(); if (hasThumbnail(messageRecord)) { - Projection thumbnailProjection = Projection.relativeToParent(this, mediaThumbnailStub.require(), null); - float mediaBoundary = thumbnailProjection.getY() + thumbnailProjection.getHeight(); - - if (lastYDownRelativeToThis > mediaBoundary) { - return bottom; - } else { - return top; - } + return isTouchBelowBoundary(mediaThumbnailStub.require()) ? bottom : top; } else if (hasDocument(messageRecord)) { - Projection documentProjection = Projection.relativeToParent(this, documentViewStub.get(), null); - float documentBoundary = documentProjection.getY() + documentProjection.getHeight(); - - if (lastYDownRelativeToThis > documentBoundary) { - return bottom; - } else { - return top; - } - } else { + return isTouchBelowBoundary(documentViewStub.get()) ? bottom : top; + } else if (hasAudio(messageRecord)) { + return isTouchBelowBoundary(audioViewStub.get()) ? bottom : top; + } { throw new IllegalStateException("Found a situation where we have something other than a thumbnail or a document."); } } + private boolean isTouchBelowBoundary(@NonNull View child) { + Projection childProjection = Projection.relativeToParent(this, child, null); + float childBoundary = childProjection.getY() + childProjection.getHeight(); + + return lastYDownRelativeToThis > childBoundary; + } + @Override public int getTopBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) { - if (multiselectPart instanceof MultiselectPart.Attachments && hasThumbnail(messageRecord)) { - Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null); - return (int) projection.getY(); - } if (multiselectPart instanceof MultiselectPart.Attachments && hasDocument(messageRecord)) { - Projection projection = Projection.relativeToViewRoot(documentViewStub.get(), null); - return (int) projection.getY(); - } else if (multiselectPart instanceof MultiselectPart.Text && hasThumbnail(messageRecord)) { - Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null); - return (int) projection.getY() + projection.getHeight(); - } else if (multiselectPart instanceof MultiselectPart.Text && hasDocument(messageRecord)) { - Projection projection = Projection.relativeToViewRoot(documentViewStub.get(), null); - return (int) projection.getY() + projection.getHeight(); + + boolean isTextPart = multiselectPart instanceof MultiselectPart.Text; + boolean isAttachmentPart = multiselectPart instanceof MultiselectPart.Attachments; + + if (hasThumbnail(messageRecord) && isAttachmentPart) { + return getProjectionTop(mediaThumbnailStub.require()); + } else if (hasThumbnail(messageRecord) && isTextPart) { + return getProjectionBottom(mediaThumbnailStub.require()); + } else if (hasDocument(messageRecord) && isAttachmentPart) { + return getProjectionTop(documentViewStub.get()); + } else if (hasDocument(messageRecord) && isTextPart) { + return getProjectionBottom(documentViewStub.get()); + } else if (hasAudio(messageRecord) && isAttachmentPart) { + return getProjectionTop(audioViewStub.get()); + } else if (hasAudio(messageRecord) && isTextPart) { + return getProjectionBottom(audioViewStub.get()); } else if (hasNoBubble(messageRecord)) { return getTop(); } else { - Projection projection = Projection.relativeToViewRoot(bodyBubble, null); - return (int) projection.getY(); + return getProjectionTop(bodyBubble); } } + private static int getProjectionTop(@NonNull View child) { + return (int) Projection.relativeToViewRoot(child, null).getY(); + } + + private static int getProjectionBottom(@NonNull View child) { + Projection projection = Projection.relativeToViewRoot(child, null); + return (int) projection.getY() + projection.getHeight(); + } + @Override public int getBottomBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) { if (multiselectPart instanceof MultiselectPart.Attachments && hasThumbnail(messageRecord)) { - Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null); - return (int) projection.getY() + projection.getHeight(); + return getProjectionBottom(mediaThumbnailStub.require()); } else if (multiselectPart instanceof MultiselectPart.Attachments && hasDocument(messageRecord)) { - Projection projection = Projection.relativeToViewRoot(documentViewStub.get(), null); - return (int) projection.getY() + projection.getHeight(); + return getProjectionBottom(documentViewStub.get()); + } else if (multiselectPart instanceof MultiselectPart.Attachments && hasAudio(messageRecord)) { + return getProjectionBottom(audioViewStub.get()); } else if (hasNoBubble(messageRecord)) { return getBottom(); } else { - Projection projection = Projection.relativeToViewRoot(bodyBubble, null); - return (int) projection.getY() + projection.getHeight(); + return getProjectionBottom(bodyBubble); } } @@ -1731,6 +1735,17 @@ public final class ConversationItem extends RelativeLayout implements BindableCo return projections; } + @Override + public @Nullable View getHorizontalTranslationTarget() { + if (messageRecord.isOutgoing()) { + return null; + } else if (groupThread) { + return contactPhotoHolder; + } else { + return bodyBubble; + } + } + private class SharedContactEventListener implements SharedContactView.EventListener { @Override public void onAddToContactsClicked(@NonNull Contact contact) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 4d6966bca..9d22117a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -230,6 +230,11 @@ public final class ConversationUpdateItem extends FrameLayout return Collections.emptyList(); } + @Override + public @Nullable View getHorizontalTranslationTarget() { + return null; + } + static final class RecipientObserverManager { private final Observer recipientObserver; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index dd9603b7d..d1fcf491b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -14,6 +14,8 @@ import java.util.stream.Collectors; final class MenuState { + private static final int MAX_FORWARDABLE_COUNT = 32; + private final boolean forward; private final boolean reply; private final boolean details; @@ -114,7 +116,7 @@ final class MenuState { !viewOnce && !remoteDelete && !hasPendingMedia && - ((FeatureFlags.forwardMultipleMessages() && selectedParts.size() <= 5) || selectedParts.size() == 1); + ((FeatureFlags.forwardMultipleMessages() && selectedParts.size() <= MAX_FORWARDABLE_COUNT) || selectedParts.size() == 1); int uniqueRecords = selectedParts.stream() .map(MultiselectPart::getMessageRecord) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectCollection.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectCollection.kt index 7cafeb54e..5e3076768 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectCollection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectCollection.kt @@ -35,6 +35,8 @@ sealed class MultiselectCollection { } } + fun isExpired(): Boolean = toSet().any(MultiselectPart::isExpired) + fun isTextSelected(selectedParts: Set): Boolean { val textParts: Set = toSet().filter(this::couldContainText).toSet() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemAnimator.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemAnimator.kt new file mode 100644 index 000000000..34e65a2c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemAnimator.kt @@ -0,0 +1,154 @@ +package org.thoughtcrime.securesms.conversation.mutiselect + +import android.animation.ValueAnimator +import androidx.core.animation.doOnEnd +import androidx.recyclerview.widget.RecyclerView + +/** + * Class for managing the triggering of item animations (here in the form of decoration redraws) whenever + * there is a "selection" edge detected. + * + * Can be expanded upon in the future to animate other things, such as message sends. + */ +class MultiselectItemAnimator( + private val isInMultiSelectMode: () -> Boolean, + private val isPartSelected: (MultiselectPart) -> Boolean +) : RecyclerView.ItemAnimator() { + + private data class Selection( + val multiselectPart: MultiselectPart, + val viewHolder: RecyclerView.ViewHolder + ) + + var isInitialAnimation: Boolean = true + private set + + private val selected: MutableSet = mutableSetOf() + + private val pendingSelectedAnimations: MutableSet = mutableSetOf() + + private val selectedAnimations: MutableMap = mutableMapOf() + + fun getSelectedProgressForPart(multiselectPart: MultiselectPart): Float { + return if (pendingSelectedAnimations.any { it.multiselectPart == multiselectPart }) { + 0f + } else { + selectedAnimations.filter { it.key.multiselectPart == multiselectPart }.values.firstOrNull()?.animatedFraction ?: 1f + } + } + + override fun animateDisappearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo?): Boolean { + dispatchAnimationFinished(viewHolder) + return false + } + + override fun animateAppearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean { + dispatchAnimationFinished(viewHolder) + return false + } + + override fun animatePersistence(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean { + dispatchAnimationFinished(viewHolder) + return false + } + + override fun animateChange(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean { + if (oldHolder != newHolder) { + dispatchAnimationFinished(oldHolder) + } + + val isInMultiSelectMode = isInMultiSelectMode() + if (!isInMultiSelectMode) { + selected.clear() + isInitialAnimation = true + dispatchAnimationFinished(newHolder) + return false + } + + var isAnimationStarted = false + val parts: MultiselectCollection? = (newHolder.itemView as? Multiselectable)?.conversationMessage?.multiselectCollection + + if (parts == null || parts.isExpired()) { + dispatchAnimationFinished(newHolder) + return false + } + + parts.toSet().forEach { part -> + val partIsSelected = isPartSelected(part) + if (selected.contains(part) && !partIsSelected) { + pendingSelectedAnimations.add(Selection(part, newHolder)) + selected.remove(part) + isAnimationStarted = true + } else if (!selected.contains(part) && partIsSelected) { + pendingSelectedAnimations.add(Selection(part, newHolder)) + selected.add(part) + isAnimationStarted = true + } else if (isInitialAnimation) { + pendingSelectedAnimations.add(Selection(part, newHolder)) + isAnimationStarted = true + } + } + + if (isAnimationStarted) { + dispatchAnimationStarted(newHolder) + } else { + dispatchAnimationFinished(newHolder) + } + + return isAnimationStarted + } + + override fun runPendingAnimations() { + for (selection in pendingSelectedAnimations) { + val animator = ValueAnimator.ofFloat(0f, 1f) + selectedAnimations[selection] = animator + animator.duration = 150L + animator.addUpdateListener { + (selection.viewHolder.itemView.parent as RecyclerView).invalidateItemDecorations() + } + animator.doOnEnd { + dispatchAnimationFinished(selection.viewHolder) + selectedAnimations.remove(selection) + isInitialAnimation = false + } + animator.start() + } + + pendingSelectedAnimations.clear() + } + + override fun endAnimation(item: RecyclerView.ViewHolder) { + endSelectedAnimation(item) + } + + override fun endAnimations() { + endSelectedAnimations() + dispatchAnimationsFinished() + } + + override fun isRunning(): Boolean { + return selectedAnimations.values.any { it.isRunning } + } + + override fun onAnimationFinished(viewHolder: RecyclerView.ViewHolder) { + dispatchItemDecorationRedraw(viewHolder) + } + + private fun dispatchItemDecorationRedraw(viewHolder: RecyclerView.ViewHolder) { + val parent = (viewHolder.itemView.parent as RecyclerView) + parent.post { parent.invalidateItemDecorations() } + } + + private fun endSelectedAnimation(item: RecyclerView.ViewHolder) { + val selections = selectedAnimations.filter { (k, _) -> k.viewHolder == item } + selections.forEach { (k, v) -> + v.end() + selectedAnimations.remove(k) + } + } + + fun endSelectedAnimations() { + selectedAnimations.values.forEach { it.end() } + selectedAnimations.clear() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt index 8c654ea70..5138e25f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectItemDecoration.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.mutiselect import android.content.Context +import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint @@ -11,6 +12,8 @@ import android.view.View import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.ContextCompat import androidx.core.view.children +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.RecyclerView import com.airbnb.lottie.SimpleColorFilter import org.thoughtcrime.securesms.R @@ -20,11 +23,17 @@ import org.thoughtcrime.securesms.util.SetUtil import org.thoughtcrime.securesms.util.ThemeUtil import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.wallpaper.ChatWallpaper +import java.lang.Integer.max /** * Decoration which renders the background shade and selection bubble for a {@link Multiselectable} item. */ -class MultiselectItemDecoration(context: Context, private val chatWallpaperProvider: () -> ChatWallpaper?) : RecyclerView.ItemDecoration() { +class MultiselectItemDecoration( + context: Context, + private val chatWallpaperProvider: () -> ChatWallpaper?, + private val selectedAnimationProgressProvider: (MultiselectPart) -> Float, + private val isInitialAnimation: () -> Boolean +) : RecyclerView.ItemDecoration(), DefaultLifecycleObserver { private val path = Path() private val rect = Rect() @@ -43,6 +52,21 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi private val ultramarine30 = ContextCompat.getColor(context, R.color.core_ultramarine_33) private val ultramarine = ContextCompat.getColor(context, R.color.signal_accent_primary) + private var checkedBitmap: Bitmap? = null + + override fun onCreate(owner: LifecycleOwner) { + val bitmap = Bitmap.createBitmap(circleRadius * 2, circleRadius * 2, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + checkDrawable.draw(canvas) + checkedBitmap = bitmap + } + + override fun onDestroy(owner: LifecycleOwner) { + checkedBitmap?.recycle() + checkedBitmap = null + } + private val unselectedPaint = Paint().apply { isAntiAlias = true strokeWidth = 1.5f @@ -60,20 +84,43 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi color = transparentBlack20 } + private val checkPaint = Paint().apply { + isAntiAlias = true + style = Paint.Style.FILL + } + override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { val adapter = parent.adapter as ConversationAdapter val isLtr = ViewUtil.isLtr(view) if (adapter.selectedItems.isNotEmpty() && view is Multiselectable) { - outRect.set( - if (isLtr) gutter else 0, - 0, - if (isLtr) 0 else gutter, - 0 - ) - } else { - outRect.setEmpty() + val firstPart = view.conversationMessage.multiselectCollection.toSet().first() + val target = view.getHorizontalTranslationTarget() + + if (target != null) { + val start = if (isLtr) { + target.left + } else { + parent.right - target.right + } + + val translation: Float = if (isInitialAnimation()) { + max(0, gutter - start) * selectedAnimationProgressProvider(firstPart) + } else { + max(0, gutter - start).toFloat() + } + + view.translationX = if (isLtr) { + translation + } else { + -translation + } + } + } else if (view is Multiselectable) { + view.translationX = 0f } + + outRect.setEmpty() } /** @@ -111,7 +158,7 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi val shadeAll = selectedParts.size == parts.size || (selectedPart is MultiselectPart.Text && child.hasNonSelectableMedia()) if (shadeAll) { - rect.set(0, view.top, parent.right, view.bottom) + rect.set(0, view.top, view.right, view.bottom) } else { rect.set(0, child.getTopBoundaryOfMultiselectPart(selectedPart), parent.right, child.getBottomBoundaryOfMultiselectPart(selectedPart)) } @@ -144,9 +191,9 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi } if (chatWallpaperProvider() == null && !isDarkTheme) { - checkDrawable.colorFilter = SimpleColorFilter(ultramarine) + checkPaint.colorFilter = SimpleColorFilter(ultramarine) } else { - checkDrawable.clearColorFilter() + checkPaint.colorFilter = null } multiselectChildren.forEach { child -> @@ -159,10 +206,15 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi drawPhotoCircle(canvas, parent, topBoundary, bottomBoundary) } + val alphaProgress = selectedAnimationProgressProvider(it) if (adapter.selectedItems.contains(it)) { - drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary) + drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary, 1f - alphaProgress) + drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary, alphaProgress) } else { - drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary) + drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary, alphaProgress) + if (!isInitialAnimation()) { + drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary, 1f - alphaProgress) + } } } } @@ -187,7 +239,7 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi /** * Draws the checkmark for selected content */ - private fun drawSelectedCircle(canvas: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int) { + private fun drawSelectedCircle(canvas: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int, alphaProgress: Float) { val topX: Float = if (ViewUtil.isLtr(parent)) { paddingStart } else { @@ -195,25 +247,33 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi }.toFloat() val topY: Float = topBoundary + (bottomBoundary - topBoundary).toFloat() / 2 - circleRadius + val bitmap = checkedBitmap - canvas.save() - canvas.translate(topX, topY) - checkDrawable.draw(canvas) - canvas.restore() + val alpha = checkPaint.alpha + checkPaint.alpha = (alpha * alphaProgress).toInt() + + if (bitmap != null) { + canvas.drawBitmap(bitmap, topX, topY, checkPaint) + } + + checkPaint.alpha = alpha } /** * Draws the empty circle for unselected content */ - private fun drawUnselectedCircle(c: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int) { + private fun drawUnselectedCircle(c: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int, alphaProgress: Float) { val centerX: Float = if (ViewUtil.isLtr(parent)) { paddingStart + circleRadius } else { parent.right - circleRadius - paddingStart }.toFloat() + val alpha = unselectedPaint.alpha + unselectedPaint.alpha = (alpha * alphaProgress).toInt() val centerY: Float = topBoundary + (bottomBoundary - topBoundary).toFloat() / 2 c.drawCircle(centerX, centerY, circleRadius.toFloat(), unselectedPaint) + unselectedPaint.alpha = alpha } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectPart.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectPart.kt index 4a4d1a5e3..07a16bea2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectPart.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/MultiselectPart.kt @@ -10,6 +10,12 @@ sealed class MultiselectPart(open val conversationMessage: ConversationMessage) fun getMessageRecord(): MessageRecord = conversationMessage.messageRecord + fun isExpired(): Boolean { + val expiresAt = conversationMessage.messageRecord.expireStarted + conversationMessage.messageRecord.expiresIn + + return expiresAt > 0 && expiresAt < System.currentTimeMillis() + } + /** * Represents the body of the message */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt index 18cf0d5f0..9b437f8d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselectable.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.conversation.mutiselect +import android.view.View import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.colors.Colorizable @@ -12,5 +13,7 @@ interface Multiselectable : Colorizable { fun getMultiselectPartForLatestTouch(): MultiselectPart + fun getHorizontalTranslationTarget(): View? + fun hasNonSelectableMedia(): Boolean } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index b998ef9cb..5e7b327b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -8,18 +10,23 @@ import android.view.animation.AnimationUtils import android.widget.EditText import android.widget.FrameLayout import android.widget.Toast +import androidx.annotation.PluralsRes import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager 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 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.conversation.ui.error.SafetyNumberChangeDialog +import org.thoughtcrime.securesms.database.IdentityDatabase +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sharing.MultiShareArgs import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter @@ -30,13 +37,18 @@ import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.views.SimpleProgressDialog import org.thoughtcrime.securesms.util.visible import org.whispersystems.libsignal.util.guava.Optional +import java.lang.UnsupportedOperationException 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 val TAG = Log.tag(MultiselectForwardFragment::class.java) -class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment(), ContactSelectionListFragment.OnContactSelectedListener, ContactSelectionListFragment.OnSelectionLimitReachedListener { +class MultiselectForwardFragment : + FixedRoundedCornerBottomSheetDialogFragment(), + ContactSelectionListFragment.OnContactSelectedListener, + ContactSelectionListFragment.OnSelectionLimitReachedListener, + SafetyNumberChangeDialog.Callback { override val peekHeightPercentage: Float = 0.67f @@ -44,9 +56,12 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment() private lateinit var selectionFragment: ContactSelectionListFragment private lateinit var contactFilterView: ContactFilterView + private lateinit var addMessage: EditText private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null + private var handler: Handler? = null + private fun createViewModelFactory(): MultiselectForwardViewModel.Factory { return MultiselectForwardViewModel.Factory(getMultiShareArgs(), MultiselectForwardRepository(requireContext())) } @@ -99,15 +114,14 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment() val shareSelectionRecycler: RecyclerView = bottomBar.findViewById(R.id.selected_list) val shareSelectionAdapter = ShareSelectionAdapter() val sendButton: View = bottomBar.findViewById(R.id.share_confirm) - val addMessage: EditText = bottomBar.findViewById(R.id.add_message) val addMessageWrapper: View = bottomBar.findViewById(R.id.add_message_wrapper) + addMessage = bottomBar.findViewById(R.id.add_message) + addMessageWrapper.visible = FeatureFlags.forwardMultipleMessages() sendButton.setOnClickListener { - it.isEnabled = false - dismissibleDialog = SimpleProgressDialog.showDelayed(requireContext()) - + sendButton.isEnabled = false viewModel.send(addMessage.text.toString()) } @@ -130,20 +144,22 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment() } viewModel.state.observe(viewLifecycleOwner) { - val toastTextResId: Int? = when (it.stage) { - MultiselectForwardState.Stage.SELECTION -> null - MultiselectForwardState.Stage.SOME_FAILED -> R.plurals.MultiselectForwardFragment_messages_sent - MultiselectForwardState.Stage.ALL_FAILED -> R.plurals.MultiselectForwardFragment_messages_failed_to_send - MultiselectForwardState.Stage.SUCCESS -> R.plurals.MultiselectForwardFragment_messages_sent + when (it.stage) { + MultiselectForwardState.Stage.Selection -> { } + MultiselectForwardState.Stage.FirstConfirmation -> displayFirstSendConfirmation() + is MultiselectForwardState.Stage.SafetyConfirmation -> displaySafetyNumberConfirmation(it.stage.identities) + MultiselectForwardState.Stage.LoadingIdentities -> {} + MultiselectForwardState.Stage.SendPending -> { + handler?.removeCallbacksAndMessages(null) + dismissibleDialog?.dismiss() + dismissibleDialog = SimpleProgressDialog.showDelayed(requireContext()) + } + 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) } - if (toastTextResId != null) { - val argCount = getMultiShareArgs().size - - dismissibleDialog?.dismiss() - Toast.makeText(requireContext(), requireContext().resources.getQuantityString(toastTextResId, argCount), Toast.LENGTH_SHORT).show() - dismissAllowingStateLoss() - } + sendButton.isEnabled = it.stage == MultiselectForwardState.Stage.Selection } bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ -> @@ -151,8 +167,75 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment() } } + override fun onResume() { + super.onResume() + + val now = System.currentTimeMillis() + val expiringMessages = getMultiShareArgs().filter { it.expiresAt > 0L } + val firstToExpire = expiringMessages.minByOrNull { it.expiresAt } + val earliestExpiration = firstToExpire?.expiresAt ?: -1L + + if (earliestExpiration > 0) { + if (earliestExpiration <= now) { + handleMessageExpired() + } else { + handler = Handler(Looper.getMainLooper()) + handler?.postDelayed(this::handleMessageExpired, earliestExpiration - now) + } + } + } + + override fun onPause() { + super.onPause() + + handler?.removeCallbacksAndMessages(null) + } + + private fun displayFirstSendConfirmation() { + SignalStore.tooltips().markMultiForwardDialogSeen() + + val messageCount = getMessageCount() + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.MultiselectForwardFragment__faster_forwards) + .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()) + } + .setNegativeButton(android.R.string.cancel) { d, _ -> + d.dismiss() + viewModel.cancelSend() + } + .show() + } + + private fun displaySafetyNumberConfirmation(identityRecords: List) { + SafetyNumberChangeDialog.show(childFragmentManager, identityRecords) + } + + private fun dismissAndShowToast(@PluralsRes toastTextResId: Int) { + val argCount = getMessageCount() + + dismissibleDialog?.dismiss() + Toast.makeText(requireContext(), requireContext().resources.getQuantityString(toastTextResId, argCount), Toast.LENGTH_SHORT).show() + dismissAllowingStateLoss() + } + + private fun getMessageCount(): Int = getMultiShareArgs().size + if (addMessage.text.isNotEmpty()) 1 else 0 + + private fun handleMessageExpired() { + dismissAllowingStateLoss() + dismissibleDialog?.dismiss() + Toast.makeText(requireContext(), resources.getQuantityString(R.plurals.MultiselectForwardFragment__couldnt_forward_messages, getMultiShareArgs().size), Toast.LENGTH_SHORT).show() + } + 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 + 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 if (Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)) { mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS @@ -186,6 +269,18 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment() Toast.makeText(requireContext(), R.string.MultiselectForwardFragment__limit_reached, Toast.LENGTH_SHORT).show() } + override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList) { + viewModel.confirmSafetySend(addMessage.text.toString()) + } + + override fun onMessageResentAfterSafetyNumberChange() { + throw UnsupportedOperationException() + } + + override fun onCanceled() { + viewModel.cancelSend() + } + companion object { @JvmStatic fun show(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt index b15d75687..e58b15d6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt @@ -42,7 +42,10 @@ class MultiselectForwardFragmentArgs( @WorkerThread private fun buildMultiShareArgs(context: Context, conversationMessage: ConversationMessage, selectedParts: Set): MultiShareArgs { - val builder = MultiShareArgs.Builder(setOf()).withMentions(conversationMessage.mentions) + val builder = MultiShareArgs.Builder(setOf()) + .withMentions(conversationMessage.mentions) + .withTimestamp(conversationMessage.messageRecord.timestamp) + .withExpiration(conversationMessage.messageRecord.expireStarted + conversationMessage.messageRecord.expiresIn) if (conversationMessage.multiselectCollection.isTextSelected(selectedParts)) { val mediaMessage: MmsMessageRecord? = conversationMessage.messageRecord as? MmsMessageRecord @@ -56,6 +59,9 @@ class MultiselectForwardFragmentArgs( } else { builder.withDraftText(conversationMessage.getDisplayBody(context).toString()) } + + val linkPreview = mediaMessage?.linkPreviews?.firstOrNull() + builder.withLinkPreview(linkPreview) } if (conversationMessage.messageRecord.isMms && conversationMessage.multiselectCollection.isMediaSelected(selectedParts)) { @@ -96,6 +102,7 @@ class MultiselectForwardFragmentArgs( val media = firstSlide.asAttachment().toMedia() if (media != null) { + builder.asBorderless(media.isBorderless) builder.withMedia(listOf(media)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt index ec07295c6..79c4475ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt @@ -1,9 +1,12 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward import android.content.Context +import androidx.core.util.Consumer import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.IdentityDatabase import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.identity.IdentityRecordList import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.sharing.MultiShareArgs import org.thoughtcrime.securesms.sharing.MultiShareSender @@ -20,6 +23,16 @@ class MultiselectForwardRepository(context: Context) { val onAllMessagesFailed: () -> Unit ) + fun checkForBadIdentityRecords(shareContacts: List, consumer: Consumer>) { + SignalExecutors.BOUNDED.execute { + val identityDatabase: IdentityDatabase = DatabaseFactory.getIdentityDatabase(context) + val recipients: List = shareContacts.map { Recipient.resolved(it.recipientId.get()) } + val identityRecordList: IdentityRecordList = identityDatabase.getIdentities(recipients) + + consumer.accept(identityRecordList.untrustedRecords) + } + } + fun send( additionalMessage: String, multiShareArgs: List, @@ -38,7 +51,7 @@ class MultiselectForwardRepository(context: Context) { .toSet() val mappedArgs: List = multiShareArgs.map { it.buildUpon(sharedContactsAndThreads).build() } - val results = mappedArgs.map { MultiShareSender.sendSync(it) } + val results = mappedArgs.sortedBy { it.timestamp }.map { MultiShareSender.sendSync(it) } if (additionalMessage.isNotEmpty()) { val additional = MultiShareArgs.Builder(sharedContactsAndThreads) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt index 83a0c937d..87b0ad106 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt @@ -1,15 +1,20 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward +import org.thoughtcrime.securesms.database.IdentityDatabase import org.thoughtcrime.securesms.sharing.ShareContact data class MultiselectForwardState( val selectedContacts: List = emptyList(), - val stage: Stage = Stage.SELECTION + val stage: Stage = Stage.Selection ) { - enum class Stage { - SELECTION, - SOME_FAILED, - ALL_FAILED, - SUCCESS + sealed class Stage { + object Selection : Stage() + object FirstConfirmation : Stage() + object LoadingIdentities : Stage() + data class SafetyConfirmation(val identities: List) : Stage() + object SendPending : Stage() + object SomeFailed : Stage() + object AllFailed : Stage() + object Success : Stage() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt index b32743d8e..4ece2ef01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sharing.MultiShareArgs import org.thoughtcrime.securesms.sharing.ShareContact @@ -31,14 +32,43 @@ class MultiselectForwardViewModel( } fun send(additionalMessage: String) { + 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 -> + if (identityRecords.isEmpty()) { + performSend(additionalMessage) + } else { + store.update { it.copy(stage = MultiselectForwardState.Stage.SafetyConfirmation(identityRecords)) } + } + } + } + } + + fun confirmFirstSend(additionalMessage: String) { + send(additionalMessage) + } + + fun confirmSafetySend(additionalMessage: String) { + send(additionalMessage) + } + + fun cancelSend() { + store.update { it.copy(stage = MultiselectForwardState.Stage.Selection) } + } + + private fun performSend(additionalMessage: String) { + store.update { it.copy(stage = MultiselectForwardState.Stage.SendPending) } repository.send( additionalMessage = additionalMessage, multiShareArgs = records, shareContacts = store.state.selectedContacts, MultiselectForwardRepository.MultiselectForwardResultHandlers( - onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.SUCCESS) } }, - onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.ALL_FAILED) } }, - onSomeMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.SOME_FAILED) } } + onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.Success) } }, + onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.AllFailed) } }, + onSomeMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.SomeFailed) } } ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java index acea05ee5..b37485014 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java @@ -214,7 +214,12 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa if (activity instanceof Callback && !skipCallbacks) { callback = (Callback) activity; } else { - callback = null; + Fragment parent = getParentFragment(); + if (parent instanceof Callback && !skipCallbacks) { + callback = (Callback) parent; + } else { + callback = null; + } } LiveData trustOrVerifyResultLiveData = viewModel.trustOrVerifyChangedRecipients(); @@ -244,6 +249,8 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa private void handleCancel(@NonNull DialogInterface dialogInterface, int which) { if (getActivity() instanceof Callback) { ((Callback) getActivity()).onCanceled(); + } else if (getParentFragment() instanceof Callback) { + ((Callback) getParentFragment()).onCanceled(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/TooltipValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/TooltipValues.java index 6db83250f..190a034be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/TooltipValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/TooltipValues.java @@ -12,6 +12,7 @@ public class TooltipValues extends SignalStoreValues { private static final String BLUR_HUD_ICON = "tooltip.blur_hud_icon"; private static final String GROUP_CALL_SPEAKER_VIEW = "tooltip.group_call_speaker_view"; private static final String GROUP_CALL_TOOLTIP_DISPLAY_COUNT = "tooltip.group_call_tooltip_display_count"; + private static final String MULTI_FORWARD_DIALOG = "tooltip.multi.forward.dialog"; TooltipValues(@NonNull KeyValueStore store) { @@ -20,6 +21,7 @@ public class TooltipValues extends SignalStoreValues { @Override public void onFirstEverAppLaunch() { + markMultiForwardDialogSeen(); } @Override @@ -54,4 +56,12 @@ public class TooltipValues extends SignalStoreValues { public void markGroupCallingLobbyEntered() { putInteger(GROUP_CALL_TOOLTIP_DISPLAY_COUNT, Integer.MAX_VALUE); } + + public boolean showMultiForwardDialog() { + return getBoolean(MULTI_FORWARD_DIALOG, true); + } + + public void markMultiForwardDialogSeen() { + putBoolean(MULTI_FORWARD_DIALOG, false); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java index 4b30767a7..264572f54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java @@ -11,6 +11,7 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageId; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.stickers.StickerLocator; @@ -35,6 +36,8 @@ public final class MultiShareArgs implements Parcelable { private final boolean viewOnce; private final LinkPreview linkPreview; private final List mentions; + private final long timestamp; + private final long expiresAt; private MultiShareArgs(@NonNull Builder builder) { shareContactAndThreads = builder.shareContactAndThreads; @@ -47,6 +50,8 @@ public final class MultiShareArgs implements Parcelable { viewOnce = builder.viewOnce; linkPreview = builder.linkPreview; mentions = builder.mentions == null ? new ArrayList<>() : new ArrayList<>(builder.mentions); + timestamp = builder.timestamp; + expiresAt = builder.expiresAt; } protected MultiShareArgs(Parcel in) { @@ -59,6 +64,8 @@ public final class MultiShareArgs implements Parcelable { dataType = in.readString(); viewOnce = in.readByte() != 0; mentions = in.createTypedArrayList(Mention.CREATOR); + timestamp = in.readLong(); + expiresAt = in.readLong(); String linkedPreviewString = in.readString(); LinkPreview preview; @@ -111,6 +118,14 @@ public final class MultiShareArgs implements Parcelable { return mentions; } + public long getTimestamp() { + return timestamp; + } + + public long getExpiresAt() { + return expiresAt; + } + public @NonNull InterstitialContentType getInterstitialContentType() { if (!requiresInterstitial()) { return InterstitialContentType.NONE; @@ -154,6 +169,8 @@ public final class MultiShareArgs implements Parcelable { dest.writeString(dataType); dest.writeByte((byte) (viewOnce ? 1 : 0)); dest.writeTypedList(mentions); + dest.writeLong(timestamp); + dest.writeLong(expiresAt); if (linkPreview != null) { try { @@ -179,7 +196,9 @@ public final class MultiShareArgs implements Parcelable { .withLinkPreview(linkPreview) .withMedia(media) .withStickerLocator(stickerLocator) - .withMentions(mentions); + .withMentions(mentions) + .withTimestamp(timestamp) + .withExpiration(expiresAt); } private boolean requiresInterstitial() { @@ -200,6 +219,8 @@ public final class MultiShareArgs implements Parcelable { private LinkPreview linkPreview; private boolean viewOnce; private List mentions; + private long timestamp; + private long expiresAt; public Builder(@NonNull Set shareContactAndThreads) { this.shareContactAndThreads = shareContactAndThreads; @@ -250,6 +271,16 @@ public final class MultiShareArgs implements Parcelable { return this; } + public @NonNull Builder withTimestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + public @NonNull Builder withExpiration(long expiresAt) { + this.expiresAt = expiresAt; + return this; + } + public @NonNull MultiShareArgs build() { return new MultiShareArgs(this); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java index b6d8b19e0..8d9db23ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.SlideFactory; import org.thoughtcrime.securesms.mms.StickerSlide; @@ -58,12 +59,23 @@ public final class MultiShareSender { @WorkerThread public static MultiShareSendResultCollection sendSync(@NonNull MultiShareArgs multiShareArgs) { - Context context = ApplicationDependencies.getApplication(); - boolean isMmsEnabled = Util.isMmsCapable(context); - String message = multiShareArgs.getDraftText(); - SlideDeck slideDeck = buildSlideDeck(context, multiShareArgs); + List results = new ArrayList<>(multiShareArgs.getShareContactAndThreads().size()); + Context context = ApplicationDependencies.getApplication(); + boolean isMmsEnabled = Util.isMmsCapable(context); + String message = multiShareArgs.getDraftText(); + SlideDeck slideDeck; + + try { + 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)); + } + + return new MultiShareSendResultCollection(results); + } - List results = new ArrayList<>(multiShareArgs.getShareContactAndThreads().size()); for (ShareContactAndThread shareContactAndThread : multiShareArgs.getShareContactAndThreads()) { Recipient recipient = Recipient.resolved(shareContactAndThread.getRecipientId()); @@ -91,7 +103,7 @@ public final class MultiShareSender { sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, shareContactAndThread.getThreadId(), forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId, mentions); results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); } else { - sendTextMessage(context, multiShareArgs, recipient, shareContactAndThread.getThreadId() ,forceSms, expiresIn, subscriptionId); + sendTextMessage(context, multiShareArgs, recipient, shareContactAndThread.getThreadId(), forceSms, expiresIn, subscriptionId); results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); } @@ -180,16 +192,26 @@ public final class MultiShareSender { MessageSender.send(context, outgoingTextMessage, threadId, forceSms, null); } - private static @NonNull SlideDeck buildSlideDeck(@NonNull Context context, @NonNull MultiShareArgs multiShareArgs) { + private static @NonNull SlideDeck buildSlideDeck(@NonNull Context context, @NonNull MultiShareArgs multiShareArgs) throws SlideNotFoundException { SlideDeck slideDeck = new SlideDeck(); if (multiShareArgs.getStickerLocator() != null) { slideDeck.addSlide(new StickerSlide(context, multiShareArgs.getDataUri(), 0, multiShareArgs.getStickerLocator(), multiShareArgs.getDataType())); } else if (!multiShareArgs.getMedia().isEmpty()) { for (Media media : multiShareArgs.getMedia()) { - slideDeck.addSlide(SlideFactory.getSlide(context, media.getMimeType(), media.getUri(), media.getWidth(), media.getHeight())); + Slide slide = SlideFactory.getSlide(context, media.getMimeType(), media.getUri(), media.getWidth(), media.getHeight()); + if (slide != null) { + slideDeck.addSlide(slide); + } else { + throw new SlideNotFoundException(); + } } } else if (multiShareArgs.getDataUri() != null) { - slideDeck.addSlide(SlideFactory.getSlide(context, multiShareArgs.getDataType(), multiShareArgs.getDataUri(), 0, 0)); + Slide slide = SlideFactory.getSlide(context, multiShareArgs.getDataType(), multiShareArgs.getDataUri(), 0, 0); + if (slide != null) { + slideDeck.addSlide(slide); + } else { + throw new SlideNotFoundException(); + } } return slideDeck; @@ -244,8 +266,12 @@ public final class MultiShareSender { } private enum Type { + GENERIC_ERROR, MMS_NOT_ENABLED, SUCCESS } } + + private static final class SlideNotFoundException extends Exception { + } } diff --git a/app/src/main/res/layout/multiselect_forward_fragment_bottom_bar.xml b/app/src/main/res/layout/multiselect_forward_fragment_bottom_bar.xml index 5722206a5..1a42bf6d4 100644 --- a/app/src/main/res/layout/multiselect_forward_fragment_bottom_bar.xml +++ b/app/src/main/res/layout/multiselect_forward_fragment_bottom_bar.xml @@ -47,6 +47,7 @@ android:layout_marginBottom="8dp" android:background="@drawable/rounded_rectangle_secondary" android:hint="@string/MultiselectForwardFragment__add_a_message" + android:inputType="textCapSentences" android:minHeight="44dp" android:paddingStart="16dp" android:paddingEnd="16dp" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47caec1b8..8d08d83b5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3691,6 +3691,12 @@ Navigate up Forward to Add a message + Faster forwards + Forwarded messages are now sent immediately. + + Send %1$d message + Send %1$d messages + Message sent Messages sent @@ -3699,6 +3705,10 @@ Message failed to send Messages failed to send + + Couldn\'t forward message because it\'s no longer available. + Couldn\'t forward messages because they\'re no longer available. + Limit reached