diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index ea72f342dd..eebd9ae39f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -26,6 +26,7 @@ import android.database.Cursor; import android.os.AsyncTask; import android.os.Bundle; import android.text.TextUtils; +import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -37,6 +38,7 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.Px; import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintSet; @@ -178,12 +180,16 @@ public final class ContactSelectionListFragment extends LoggingFragment onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context; } + if (getParentFragment() instanceof OnSelectionLimitReachedListener) { + onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment(); + } + if (context instanceof AbstractContactsCursorLoaderFactoryProvider) { cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context; } if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) { - cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context; + cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment(); } } @@ -262,7 +268,9 @@ public final class ContactSelectionListFragment extends LoggingFragment recyclerView.setClipToPadding(recyclerViewClipping); - swipeRefresh.setEnabled(arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true))); + boolean isRefreshable = arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true)); + swipeRefresh.setNestedScrollingEnabled(isRefreshable); + swipeRefresh.setEnabled(isRefreshable); hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false)); selectionLimit = arguments.getParcelable(SELECTION_LIMITS); @@ -438,6 +446,10 @@ public final class ContactSelectionListFragment extends LoggingFragment } } + public void setRecyclerViewPaddingBottom(@Px int paddingBottom) { + ViewUtil.setPaddingBottom(recyclerView, paddingBottom); + } + @Override public @NonNull Loader onCreateLoader(int id, Bundle args) { FragmentActivity activity = requireActivity(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt new file mode 100644 index 0000000000..5c00b78cd4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FixedRoundedCornerBottomSheetDialogFragment.kt @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.components + +import android.app.Dialog +import android.os.Bundle +import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * Forces rounded corners on BottomSheet + */ +abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.Widget_Signal_FixedRoundedCorners) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) as BottomSheetDialog + + dialog.behavior.peekHeight = (resources.displayMetrics.heightPixels * 0.50).toInt() + + val shapeAppearanceModel = ShapeAppearanceModel.builder() + .setTopLeftCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat()) + .setTopRightCorner(CornerFamily.ROUNDED, ViewUtil.dpToPx(requireContext(), 18).toFloat()) + .build() + + val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel) + + dialogBackground.setTint(ContextCompat.getColor(requireContext(), R.color.signal_background_primary)) + + dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (bottomSheet.background !== dialogBackground) { + ViewCompat.setBackground(bottomSheet, dialogBackground) + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) {} + }) + + return dialog + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 61dad90d24..7cf2b45ea1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -3664,13 +3664,13 @@ public class ConversationActivity extends PassphraseRequiredActivity @Override public void handleReaction(@NonNull MaskView.MaskTarget maskTarget, - @NonNull MessageRecord messageRecord, + @NonNull ConversationMessage conversationMessage, @NonNull Toolbar.OnMenuItemClickListener toolbarListener, @NonNull ConversationReactionOverlay.OnHideListener onHideListener) { reactionDelegate.setOnToolbarItemClickedListener(toolbarListener); reactionDelegate.setOnHideListener(onHideListener); - reactionDelegate.show(this, maskTarget, recipient.get(), messageRecord, inputAreaHeight()); + reactionDelegate.show(this, maskTarget, recipient.get(), conversationMessage, inputAreaHeight()); } @Override 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 50ea790f20..1b761ceade 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -66,14 +66,12 @@ import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import org.signal.core.util.StreamUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.VerifyIdentityActivity; -import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.components.ConversationScrollToView; import org.thoughtcrime.securesms.components.ConversationTypingView; import org.thoughtcrime.securesms.components.MaskView; @@ -91,9 +89,10 @@ 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.Multiselect; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment; +import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs; import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog; import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -122,7 +121,6 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.longmessage.LongMessageActivity; -import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity; import org.thoughtcrime.securesms.messagerequests.MessageRequestState; import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel; @@ -141,7 +139,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity; import org.thoughtcrime.securesms.revealable.ViewOnceUtil; -import org.thoughtcrime.securesms.sharing.ShareIntents; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.stickers.StickerLocator; @@ -151,7 +148,6 @@ import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.HtmlUtil; import org.thoughtcrime.securesms.util.RemoteDeleteUtil; import org.thoughtcrime.securesms.util.SaveAttachmentTask; -import org.thoughtcrime.securesms.util.SetUtil; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.SignalProxyUtil; import org.thoughtcrime.securesms.util.SnapToTopDataObserver; @@ -168,7 +164,6 @@ import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; import org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar; import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; -import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; import java.io.InputStream; @@ -774,14 +769,14 @@ public class ConversationFragment extends LoggingFragment { } private void setCorrectMenuVisibility(@NonNull Menu menu) { - Set messages = getListAdapter().getSelectedItems(); + Set selectedParts = getListAdapter().getSelectedItems(); - if (actionMode != null && messages.size() == 0) { + if (actionMode != null && selectedParts.size() == 0) { actionMode.finish(); return; } - MenuState menuState = MenuState.getMenuState(recipient.get(), Stream.of(messages).map(MultiselectPart::getMessageRecord).collect(Collectors.toSet()), messageRequestViewModel.shouldShowMessageRequest()); + MenuState menuState = MenuState.getMenuState(recipient.get(), selectedParts, messageRequestViewModel.shouldShowMessageRequest()); menu.findItem(R.id.menu_context_forward).setVisible(menuState.shouldShowForwardAction()); menu.findItem(R.id.menu_context_reply).setVisible(menuState.shouldShowReplyAction()); @@ -951,71 +946,12 @@ public class ConversationFragment extends LoggingFragment { startActivity(MessageDetailsActivity.getIntentForMessageDetails(requireContext(), message.getMessageRecord(), recipient.getId(), threadId)); } - private void handleForwardMessage(ConversationMessage conversationMessage) { - if (conversationMessage.getMessageRecord().isViewOnce()) { - throw new AssertionError("Cannot forward a view-once message."); - } - + private void handleForwardMessageParts(Set multiselectParts) { listener.onForwardClicked(); - SimpleTask.run(getLifecycle(), () -> { - ShareIntents.Builder shareIntentBuilder = new ShareIntents.Builder(requireActivity()); - shareIntentBuilder.setText(conversationMessage.getDisplayBody(requireContext())); - - if (conversationMessage.getMessageRecord().isMms()) { - MmsMessageRecord mediaMessage = (MmsMessageRecord) conversationMessage.getMessageRecord(); - boolean isAlbum = mediaMessage.containsMediaSlide() && - mediaMessage.getSlideDeck().getSlides().size() > 1 && - mediaMessage.getSlideDeck().getAudioSlide() == null && - mediaMessage.getSlideDeck().getDocumentSlide() == null && - mediaMessage.getSlideDeck().getStickerSlide() == null; - - if (isAlbum) { - ArrayList mediaList = new ArrayList<>(mediaMessage.getSlideDeck().getSlides().size()); - List attachments = Stream.of(mediaMessage.getSlideDeck().getSlides()) - .filter(s -> s.hasImage() || s.hasVideo()) - .map(Slide::asAttachment) - .toList(); - - for (Attachment attachment : attachments) { - Uri uri = attachment.getUri(); - - if (uri != null) { - mediaList.add(new Media(uri, - attachment.getContentType(), - System.currentTimeMillis(), - attachment.getWidth(), - attachment.getHeight(), - attachment.getSize(), - 0, - attachment.isBorderless(), - attachment.isVideoGif(), - Optional.absent(), - Optional.fromNullable(attachment.getCaption()), - Optional.absent())); - } - } - - if (!mediaList.isEmpty()) { - shareIntentBuilder.setMedia(mediaList); - } - } else if (mediaMessage.containsMediaSlide()) { - Slide slide = mediaMessage.getSlideDeck().getSlides().get(0); - shareIntentBuilder.setSlide(slide); - } - - if (mediaMessage.getSlideDeck().getTextSlide() != null && mediaMessage.getSlideDeck().getTextSlide().getUri() != null) { - try (InputStream stream = PartAuthority.getAttachmentStream(requireContext(), mediaMessage.getSlideDeck().getTextSlide().getUri())) { - String fullBody = StreamUtil.readFullyAsString(stream); - shareIntentBuilder.setText(fullBody); - } catch (IOException e) { - Log.w(TAG, "Failed to read long message text when forwarding."); - } - } - } - - return shareIntentBuilder.build(); - }, this::startActivity); + MultiselectForwardFragmentArgs.create(requireContext(), + multiselectParts, + args -> MultiselectForwardFragment.show(getParentFragmentManager(), args)); } private void handleResendMessage(final MessageRecord message) { @@ -1311,7 +1247,7 @@ public class ConversationFragment extends LoggingFragment { void onForwardClicked(); void onMessageRequest(@NonNull MessageRequestViewModel viewModel); void handleReaction(@NonNull MaskView.MaskTarget maskTarget, - @NonNull MessageRecord messageRecord, + @NonNull ConversationMessage conversationMessage, @NonNull Toolbar.OnMenuItemClickListener toolbarListener, @NonNull ConversationReactionOverlay.OnHideListener onHideListener); void onCursorChanged(); @@ -1429,7 +1365,7 @@ public class ConversationFragment extends LoggingFragment { { isReacting = true; list.setLayoutFrozen(true); - listener.handleReaction(getMaskTarget(itemView), messageRecord, new ReactionsToolbarListener(item.getConversationMessage()), () -> { + listener.handleReaction(getMaskTarget(itemView), item.getConversationMessage(), new ReactionsToolbarListener(item.getConversationMessage()), () -> { isReacting = false; list.setLayoutFrozen(false); WindowUtil.setLightStatusBarFromTheme(requireActivity()); @@ -1841,7 +1777,7 @@ public class ConversationFragment extends LoggingFragment { case R.id.action_copy: handleCopyMessage(conversationMessage.getMultiselectCollection().toSet()); return true; case R.id.action_reply: handleReplyMessage(conversationMessage); return true; case R.id.action_multiselect: handleEnterMultiSelect(conversationMessage); return true; - case R.id.action_forward: handleForwardMessage(conversationMessage); return true; + case R.id.action_forward: handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet()); return true; case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); return true; default: return false; } @@ -1911,7 +1847,7 @@ public class ConversationFragment extends LoggingFragment { actionMode.finish(); return true; case R.id.menu_context_forward: - handleForwardMessage(getSelectedConversationMessage()); + handleForwardMessageParts(getListAdapter().getSelectedItems()); actionMode.finish(); return true; case R.id.menu_context_resend: diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java index 92506bc458..b0262db289 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionDelegate.java @@ -40,10 +40,10 @@ final class ConversationReactionDelegate { void show(@NonNull Activity activity, @NonNull MaskView.MaskTarget maskTarget, @NonNull Recipient conversationRecipient, - @NonNull MessageRecord messageRecord, + @NonNull ConversationMessage conversationMessage, int maskPaddingBottom) { - resolveOverlay().show(activity, maskTarget, conversationRecipient, messageRecord, maskPaddingBottom, lastSeenDownPoint); + resolveOverlay().show(activity, maskTarget, conversationRecipient, conversationMessage, maskPaddingBottom, lastSeenDownPoint); } void showMask(@NonNull MaskView.MaskTarget maskTarget, int maskPaddingTop, int maskPaddingBottom) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index 1b85902527..1b4f1f1a82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -150,7 +150,7 @@ public final class ConversationReactionOverlay extends RelativeLayout { public void show(@NonNull Activity activity, @NonNull MaskView.MaskTarget maskTarget, @NonNull Recipient conversationRecipient, - @NonNull MessageRecord messageRecord, + @NonNull ConversationMessage conversationMessage, int maskPaddingBottom, @NonNull PointF lastSeenDownPoint) { @@ -159,12 +159,12 @@ public final class ConversationReactionOverlay extends RelativeLayout { return; } - this.messageRecord = messageRecord; + this.messageRecord = conversationMessage.getMessageRecord(); this.conversationRecipient = conversationRecipient; overlayState = OverlayState.UNINITAILIZED; selected = -1; - setupToolbarMenuItems(); + setupToolbarMenuItems(conversationMessage); setupSelectedEmoji(); if (Build.VERSION.SDK_INT >= 21) { @@ -504,8 +504,8 @@ public final class ConversationReactionOverlay extends RelativeLayout { .orElse(null); } - private void setupToolbarMenuItems() { - MenuState menuState = MenuState.getMenuState(conversationRecipient, Collections.singleton(messageRecord), false); + private void setupToolbarMenuItems(@NonNull ConversationMessage conversationMessage) { + MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false); toolbar.getMenu().findItem(R.id.action_copy).setVisible(menuState.shouldShowCopyAction()); toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction()); 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 cdb2ec0736..dd9603b7d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -2,12 +2,15 @@ package org.thoughtcrime.securesms.conversation; import androidx.annotation.NonNull; +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.FeatureFlags; import java.util.Set; +import java.util.stream.Collectors; final class MenuState { @@ -58,19 +61,23 @@ final class MenuState { } static MenuState getMenuState(@NonNull Recipient conversationRecipient, - @NonNull Set messageRecords, + @NonNull Set selectedParts, boolean shouldShowMessageRequest) { - Builder builder = new Builder(); - boolean actionMessage = false; - boolean hasText = false; - boolean sharedContact = false; - boolean viewOnce = false; - boolean remoteDelete = false; - boolean hasInMemory = false; + Builder builder = new Builder(); + boolean actionMessage = false; + boolean hasText = false; + boolean sharedContact = false; + boolean viewOnce = false; + boolean remoteDelete = false; + boolean hasInMemory = false; + boolean hasPendingMedia = false; + boolean mediaIsSelected = false; + + for (MultiselectPart part : selectedParts) { + MessageRecord messageRecord = part.getMessageRecord(); - for (MessageRecord messageRecord : messageRecords) { if (isActionMessage(messageRecord)) { actionMessage = true; if (messageRecord.isInMemoryMessageRecord()) { @@ -78,8 +85,15 @@ final class MenuState { } } - if (messageRecord.getBody().length() > 0) { - hasText = true; + if (!(part instanceof MultiselectPart.Attachments)) { + if (messageRecord.getBody().length() > 0) { + hasText = true; + } + } else { + mediaIsSelected = true; + if (messageRecord.isMediaPending()) { + hasPendingMedia = true; + } } if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getSharedContacts().isEmpty()) { @@ -95,33 +109,53 @@ final class MenuState { } } - if (messageRecords.size() > 1) { - builder.shouldShowForwardAction(false) + boolean shouldShowForwardAction = !actionMessage && + !sharedContact && + !viewOnce && + !remoteDelete && + !hasPendingMedia && + ((FeatureFlags.forwardMultipleMessages() && selectedParts.size() <= 5) || selectedParts.size() == 1); + + int uniqueRecords = selectedParts.stream() + .map(MultiselectPart::getMessageRecord) + .collect(Collectors.toSet()) + .size(); + + if (uniqueRecords > 1) { + builder.shouldShowForwardAction(shouldShowForwardAction) .shouldShowReplyAction(false) .shouldShowDetailsAction(false) .shouldShowSaveAttachmentAction(false) .shouldShowResendAction(false); } else { - MessageRecord messageRecord = messageRecords.iterator().next(); + MessageRecord messageRecord = selectedParts.iterator().next().getMessageRecord(); builder.shouldShowResendAction(messageRecord.isFailed()) - .shouldShowSaveAttachmentAction(!actionMessage && + .shouldShowSaveAttachmentAction(mediaIsSelected && + !actionMessage && !viewOnce && messageRecord.isMms() && - !messageRecord.isMediaPending() && + !hasPendingMedia && !messageRecord.isMmsNotification() && ((MediaMmsMessageRecord)messageRecord).containsMediaSlide() && ((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null) - .shouldShowForwardAction(!actionMessage && !sharedContact && !viewOnce && !remoteDelete && !messageRecord.isMediaPending()) + .shouldShowForwardAction(shouldShowForwardAction) .shouldShowDetailsAction(!actionMessage) .shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest)); } return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText) - .shouldShowDeleteAction(!hasInMemory) + .shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts)) .build(); } + private static boolean onlyContainsCompleteMessages(@NonNull Set multiselectParts) { + return multiselectParts.stream() + .map(MultiselectPart::getConversationMessage) + .map(ConversationMessage::getMultiselectCollection) + .allMatch(collection -> multiselectParts.containsAll(collection.toSet())); + } + static boolean canReplyToMessage(@NonNull Recipient conversationRecipient, boolean actionMessage, @NonNull MessageRecord messageRecord, boolean isDisplayingMessageRequest) { return !actionMessage && !messageRecord.isRemoteDelete() && diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselect.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselect.kt index d243ab83e4..18a6cae792 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselect.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/Multiselect.kt @@ -1,9 +1,20 @@ package org.thoughtcrime.securesms.conversation.mutiselect +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import org.thoughtcrime.securesms.TransportOption +import org.thoughtcrime.securesms.TransportOptions +import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.mms.TextSlide import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.Util /** * General helper object for all things multiselect. This is only utilized by @@ -46,7 +57,7 @@ object Multiselect { private fun getMmsParts(conversationMessage: ConversationMessage, mmsMessageRecord: MmsMessageRecord): Set { val parts: LinkedHashSet = linkedSetOf() - val slideDeck = mmsMessageRecord.slideDeck + val slideDeck: SlideDeck = mmsMessageRecord.slideDeck if (slideDeck.slides.filterNot { it is TextSlide }.isNotEmpty()) { parts.add(MultiselectPart.Attachments(conversationMessage)) @@ -58,4 +69,37 @@ object Multiselect { return parts } + + fun canSendToNonPush(context: Context, multiselectPart: MultiselectPart): Boolean { + return when (multiselectPart) { + is MultiselectPart.Attachments -> canSendAllAttachmentsToNonPush(context, multiselectPart.conversationMessage.messageRecord) + is MultiselectPart.Message -> canSendAllAttachmentsToNonPush(context, multiselectPart.conversationMessage.messageRecord) + is MultiselectPart.Text -> true + is MultiselectPart.Update -> throw AssertionError("Should never get to here.") + } + } + + private fun canSendAllAttachmentsToNonPush(context: Context, messageRecord: MessageRecord): Boolean { + return if (messageRecord is MmsMessageRecord) { + messageRecord.slideDeck.asAttachments().all { isMmsSupported(context, it) } + } else { + true + } + } + + /** + * Helper function to determine whether a given attachment can be sent via MMS. + */ + private fun isMmsSupported(context: Context, attachment: Attachment): Boolean { + val canReadPhoneState = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED + if (!Util.isDefaultSmsProvider(context) || !canReadPhoneState || !Util.isMmsCapable(context)) { + return false + } + + val options = TransportOptions(context, true) + options.setDefaultTransport(TransportOption.Type.SMS) + + val mmsConstraints = MediaConstraints.getMmsMediaConstraints(options.selectedTransport.simSubscriptionId.or(-1)) + return mmsConstraints.isSatisfied(context, attachment) || mmsConstraints.canResize(attachment) + } } 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 233a7fc156..7cafeb54e5 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 @@ -1,8 +1,5 @@ package org.thoughtcrime.securesms.conversation.mutiselect -import java.lang.IllegalArgumentException -import java.lang.UnsupportedOperationException - sealed class MultiselectCollection { data class Single(val singlePart: MultiselectPart) : MultiselectCollection() { @@ -38,6 +35,26 @@ sealed class MultiselectCollection { } } + fun isTextSelected(selectedParts: Set): Boolean { + val textParts: Set = toSet().filter(this::couldContainText).toSet() + + return textParts.any { selectedParts.contains(it) } + } + + fun isMediaSelected(selectedParts: Set): Boolean { + val mediaParts: Set = toSet().filter(this::couldContainMedia).toSet() + + return mediaParts.any { selectedParts.contains(it) } + } + + private fun couldContainText(multiselectPart: MultiselectPart): Boolean { + return multiselectPart is MultiselectPart.Text || multiselectPart is MultiselectPart.Message + } + + private fun couldContainMedia(multiselectPart: MultiselectPart): Boolean { + return multiselectPart is MultiselectPart.Attachments || multiselectPart is MultiselectPart.Message + } + abstract val size: Int abstract fun toSet(): Set 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 new file mode 100644 index 0000000000..e7c79a599f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -0,0 +1,181 @@ +package org.thoughtcrime.securesms.conversation.mutiselect.forward + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.AnimationUtils +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.Toast +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.RecyclerView +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.recipients.RecipientId +import org.thoughtcrime.securesms.sharing.MultiShareArgs +import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.util.ViewUtil +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 val TAG = Log.tag(MultiselectForwardFragment::class.java) + +class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment(), ContactSelectionListFragment.OnContactSelectedListener, ContactSelectionListFragment.OnSelectionLimitReachedListener { + + private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory) + + private lateinit var selectionFragment: ContactSelectionListFragment + + private fun createViewModelFactory(): MultiselectForwardViewModel.Factory { + return MultiselectForwardViewModel.Factory(getMultiShareArgs(), MultiselectForwardRepository(requireContext())) + } + + private fun getMultiShareArgs(): ArrayList = 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 + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + selectionFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list_fragment) as ContactSelectionListFragment + + val contactFilterView: ContactFilterView = view.findViewById(R.id.contact_filter_edit_text) + + contactFilterView.setOnFilterChangedListener { + if (it.isNullOrEmpty()) { + selectionFragment.resetQueryFilter() + } else { + selectionFragment.setQueryFilter(it) + } + } + + val container = view.parent.parent.parent as FrameLayout + 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) + val addMessage: EditText = bottomBar.findViewById(R.id.add_message) + val addMessageWrapper: View = bottomBar.findViewById(R.id.add_message_wrapper) + + addMessageWrapper.visible = FeatureFlags.forwardMultipleMessages() + + sendButton.setOnClickListener { + viewModel.send(addMessage.text.toString()) + } + + shareSelectionRecycler.adapter = shareSelectionAdapter + + bottomBar.visible = false + + container.addView(bottomBar) + + viewModel.shareContactMappingModels.observe(viewLifecycleOwner) { + shareSelectionAdapter.submitList(it) + + if (it.isNotEmpty() && !bottomBar.isVisible) { + bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom) + bottomBar.visible = true + } else if (it.isEmpty() && bottomBar.isVisible) { + bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_to_bottom) + bottomBar.visible = false + } + } + + viewModel.state.observe(viewLifecycleOwner) { + val toastTextResId: Int? = when (it.stage) { + MultiselectForwardState.Stage.SELECTION -> null + MultiselectForwardState.Stage.SOME_FAILED -> R.string.MultiselectForwardFragment__messages_sent + MultiselectForwardState.Stage.ALL_FAILED -> R.string.MultiselectForwardFragment__messages_failed_to_send + MultiselectForwardState.Stage.SUCCESS -> R.string.MultiselectForwardFragment__messages_sent + } + + if (toastTextResId != null) { + Toast.makeText(requireContext(), toastTextResId, Toast.LENGTH_SHORT).show() + dismissAllowingStateLoss() + } + } + + bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ -> + selectionFragment.setRecyclerViewPaddingBottom(bottom - top) + } + } + + 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 + + if (Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)) { + mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS + } + + return mode or ContactsCursorLoader.DisplayMode.FLAG_HIDE_GROUPS_V1 + } + + override fun onBeforeContactSelected(recipientId: Optional, number: String?, callback: Consumer) { + if (recipientId.isPresent) { + viewModel.addSelectedContact(recipientId, null) + callback.accept(true) + } else { + Log.w(TAG, "Rejecting non-present recipient. Can't forward to an unknown contact.") + callback.accept(false) + } + } + + override fun onContactDeselected(recipientId: Optional, 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() + } + + companion object { + @JvmStatic + fun show(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) { + val fragment = MultiselectForwardFragment() + + fragment.arguments = Bundle().apply { + putParcelableArrayList(ARG_MULTISHARE_ARGS, ArrayList(multiselectForwardFragmentArgs.multiShareArgs)) + putBoolean(ARG_CAN_SEND_TO_NON_PUSH, multiselectForwardFragmentArgs.canSendToNonPush) + } + + fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } +} 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 new file mode 100644 index 0000000000..eaa56ceb28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt @@ -0,0 +1,122 @@ +package org.thoughtcrime.securesms.conversation.mutiselect.forward + +import android.content.Context +import androidx.annotation.WorkerThread +import org.signal.core.util.StreamUtil +import org.signal.core.util.ThreadUtil +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.sharing.MultiShareArgs +import org.whispersystems.libsignal.util.guava.Optional +import java.util.function.Consumer + +class MultiselectForwardFragmentArgs( + val canSendToNonPush: Boolean, + val multiShareArgs: List +) { + + companion object { + @JvmStatic + fun create(context: Context, selectedParts: Set, consumer: Consumer) { + SignalExecutors.BOUNDED.execute { + val conversationMessages: Set = selectedParts + .map { it.conversationMessage } + .toSet() + + if (conversationMessages.any { it.messageRecord.isViewOnce }) { + throw AssertionError("Cannot forward view once media") + } + + val canSendToNonPush: Boolean = selectedParts.all { Multiselect.canSendToNonPush(context, it) } + val multiShareArgs: List = conversationMessages.map { buildMultiShareArgs(context, it, selectedParts) } + + ThreadUtil.runOnMain { consumer.accept(MultiselectForwardFragmentArgs(canSendToNonPush, multiShareArgs)) } + } + } + + @WorkerThread + private fun buildMultiShareArgs(context: Context, conversationMessage: ConversationMessage, selectedParts: Set): MultiShareArgs { + val builder = MultiShareArgs.Builder(setOf()) + + if (conversationMessage.multiselectCollection.isTextSelected(selectedParts)) { + val mediaMessage: MmsMessageRecord? = conversationMessage.messageRecord as? MmsMessageRecord + val textSlideUri = mediaMessage?.slideDeck?.textSlide?.uri + if (textSlideUri != null) { + PartAuthority.getAttachmentStream(context, textSlideUri).use { + val body = StreamUtil.readFullyAsString(it) + val msg = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, mediaMessage, body) + builder.withDraftText(msg.getDisplayBody(context).toString()) + } + } else { + builder.withDraftText(conversationMessage.getDisplayBody(context).toString()) + } + } + + if (conversationMessage.messageRecord.isMms && conversationMessage.multiselectCollection.isMediaSelected(selectedParts)) { + val mediaMessage: MmsMessageRecord = conversationMessage.messageRecord as MmsMessageRecord + val isAlbum = mediaMessage.containsMediaSlide() && + mediaMessage.slideDeck.slides.size > 1 && + mediaMessage.slideDeck.audioSlide == null && + mediaMessage.slideDeck.documentSlide == null && + mediaMessage.slideDeck.stickerSlide == null + + if (isAlbum) { + val mediaList: ArrayList = ArrayList(mediaMessage.slideDeck.slides.size) + val attachments = mediaMessage.slideDeck.slides + .filter { s -> s.hasImage() || s.hasVideo() } + .map { it.asAttachment() } + .toList() + + attachments.forEach { attachment -> + val media = attachment.toMedia() + if (media != null) { + mediaList.add(media) + } + } + + if (mediaList.isNotEmpty()) { + builder.withMedia(mediaList) + } + } else if (mediaMessage.containsMediaSlide()) { + builder.withMedia(listOf()) + + builder.withStickerLocator(mediaMessage.slideDeck.stickerSlide?.asAttachment()?.sticker) + + val firstSlide = mediaMessage.slideDeck.slides[0] + val media = firstSlide.asAttachment().toMedia() + + if (media != null) { + builder.withMedia(listOf(media)) + } + } + } + + return builder.build() + } + + private fun Attachment.toMedia(): Media? { + val uri = this.uri ?: return null + + return Media( + uri, + contentType, + System.currentTimeMillis(), + width, + height, + size, + 0, + isBorderless, + isVideoGif, + Optional.absent(), + Optional.fromNullable(caption), + Optional.absent() + ) + } + } +} 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 new file mode 100644 index 0000000000..ec07295c6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardRepository.kt @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.conversation.mutiselect.forward + +import android.content.Context +import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.sharing.MultiShareArgs +import org.thoughtcrime.securesms.sharing.MultiShareSender +import org.thoughtcrime.securesms.sharing.ShareContact +import org.thoughtcrime.securesms.sharing.ShareContactAndThread + +class MultiselectForwardRepository(context: Context) { + + private val context = context.applicationContext + + class MultiselectForwardResultHandlers( + val onAllMessageSentSuccessfully: () -> Unit, + val onSomeMessagesFailed: () -> Unit, + val onAllMessagesFailed: () -> Unit + ) + + fun send( + additionalMessage: String, + multiShareArgs: List, + shareContacts: List, + resultHandlers: MultiselectForwardResultHandlers + ) { + SignalExecutors.BOUNDED.execute { + val threadDatabase: ThreadDatabase = DatabaseFactory.getThreadDatabase(context) + + val sharedContactsAndThreads: Set = shareContacts + .asSequence() + .distinct() + .filter { it.recipientId.isPresent } + .map { Recipient.resolved(it.recipientId.get()) } + .map { ShareContactAndThread(it.id, threadDatabase.getOrCreateThreadIdFor(it), it.isForceSmsSelection) } + .toSet() + + val mappedArgs: List = multiShareArgs.map { it.buildUpon(sharedContactsAndThreads).build() } + val results = mappedArgs.map { MultiShareSender.sendSync(it) } + + if (additionalMessage.isNotEmpty()) { + val additional = MultiShareArgs.Builder(sharedContactsAndThreads) + .withDraftText(additionalMessage) + .build() + + val additionalResult: MultiShareSender.MultiShareSendResultCollection = MultiShareSender.sendSync(additional) + + handleResults(results + additionalResult, resultHandlers) + } else { + handleResults(results, resultHandlers) + } + } + } + + private fun handleResults( + results: List, + resultHandlers: MultiselectForwardResultHandlers + ) { + if (results.any { it.containsFailures() }) { + if (results.all { it.containsOnlyFailures() }) { + resultHandlers.onAllMessagesFailed() + } else { + resultHandlers.onSomeMessagesFailed() + } + } else { + resultHandlers.onAllMessageSentSuccessfully() + } + } +} 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 new file mode 100644 index 0000000000..83a0c937df --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.conversation.mutiselect.forward + +import org.thoughtcrime.securesms.sharing.ShareContact + +data class MultiselectForwardState( + val selectedContacts: List = emptyList(), + val stage: Stage = Stage.SELECTION +) { + enum class Stage { + SELECTION, + SOME_FAILED, + ALL_FAILED, + SUCCESS + } +} 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 new file mode 100644 index 0000000000..b32743d8eb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.conversation.mutiselect.forward + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Transformations +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +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, + private val repository: MultiselectForwardRepository +) : ViewModel() { + + private val store = Store(MultiselectForwardState()) + + val state: LiveData = store.stateLiveData + + val shareContactMappingModels: LiveData> = Transformations.map(state) { s -> s.selectedContacts.mapIndexed { i, c -> ShareSelectionMappingModel(c, i == 0) } } + + fun addSelectedContact(recipientId: Optional, number: String?) { + store.update { it.copy(selectedContacts = it.selectedContacts + ShareContact(recipientId, number)) } + } + + fun removeSelectedContact(recipientId: Optional, number: String?) { + store.update { it.copy(selectedContacts = it.selectedContacts - ShareContact(recipientId, number)) } + } + + fun send(additionalMessage: String) { + 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) } } + ) + ) + } + + class Factory( + private val records: List, + private val repository: MultiselectForwardRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return requireNotNull(modelClass.cast(MultiselectForwardViewModel(records, repository))) + } + } +} 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 2b93900905..9c38457aa2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java @@ -158,6 +158,10 @@ public final class MultiShareArgs implements Parcelable { } public Builder buildUpon() { + return buildUpon(shareContactAndThreads); + } + + public Builder buildUpon(@NonNull Set shareContactAndThreads) { return new Builder(shareContactAndThreads).asBorderless(borderless) .asViewOnce(viewOnce) .withDataType(dataType) 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 8cf6c6d43d..2fb898a3a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -50,11 +50,11 @@ public final class MultiShareSender { @MainThread public static void send(@NonNull MultiShareArgs multiShareArgs, @NonNull Consumer results) { - SimpleTask.run(() -> sendInternal(multiShareArgs), results::accept); + SimpleTask.run(() -> sendSync(multiShareArgs), results::accept); } @WorkerThread - private static MultiShareSendResultCollection sendInternal(@NonNull MultiShareArgs multiShareArgs) { + public static MultiShareSendResultCollection sendSync(@NonNull MultiShareArgs multiShareArgs) { Context context = ApplicationDependencies.getApplication(); boolean isMmsEnabled = Util.isMmsCapable(context); String message = multiShareArgs.getDraftText(); @@ -199,6 +199,10 @@ public final class MultiShareSender { public boolean containsFailures() { return Stream.of(results).anyMatch(result -> result.type != MultiShareSendResult.Type.SUCCESS); } + + public boolean containsOnlyFailures() { + return Stream.of(results).allMatch(result -> result.type != MultiShareSendResult.Type.SUCCESS); + } } private static final class MultiShareSendResult { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java index e3776ba452..4fb62d4f3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java @@ -8,11 +8,11 @@ import org.whispersystems.libsignal.util.guava.Optional; import java.util.Objects; -final class ShareContact { +public final class ShareContact { private final Optional recipientId; private final String number; - ShareContact(@NonNull Optional recipientId, @Nullable String number) { + public ShareContact(@NonNull Optional recipientId, @Nullable String number) { this.recipientId = recipientId; this.number = number; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java index 2408067d22..411d2e4bad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java @@ -14,7 +14,7 @@ public final class ShareContactAndThread implements Parcelable { private final long threadId; private final boolean forceSms; - ShareContactAndThread(@NonNull RecipientId recipientId, long threadId, boolean forceSms) { + public ShareContactAndThread(@NonNull RecipientId recipientId, long threadId, boolean forceSms) { this.recipientId = recipientId; this.threadId = threadId; this.forceSms = forceSms; diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java index ba4c578490..8445c08e4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java @@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.sharing; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.MappingAdapter; -class ShareSelectionAdapter extends MappingAdapter { - ShareSelectionAdapter() { +public class ShareSelectionAdapter extends MappingAdapter { + public ShareSelectionAdapter() { registerFactory(ShareSelectionMappingModel.class, ShareSelectionViewHolder.createFactory(R.layout.share_contact_selection_item)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java index d21a033917..c446a4178d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java @@ -8,12 +8,12 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.MappingModel; -class ShareSelectionMappingModel implements MappingModel { +public class ShareSelectionMappingModel implements MappingModel { private final ShareContact shareContact; private final boolean isFirst; - ShareSelectionMappingModel(@NonNull ShareContact shareContact, boolean isFirst) { + public ShareSelectionMappingModel(@NonNull ShareContact shareContact, boolean isFirst) { this.shareContact = shareContact; this.isFirst = isFirst; } @@ -23,7 +23,7 @@ class ShareSelectionMappingModel implements MappingModel recipient.isSelf() ? context.getString(R.string.note_to_self) : recipient.getShortDisplayNameIncludingUsername(context)) - .or(shareContact.getNumber()); + .or(shareContact::getNumber); return isFirst ? name : context.getString(R.string.ShareActivity__comma_s, name); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 4533148365..d16d223436 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -118,13 +118,13 @@ public final class FeatureFlags { SENDER_KEY, RETRY_RECEIPTS, SUGGEST_SMS_BLACKLIST, - ANNOUNCEMENT_GROUPS + ANNOUNCEMENT_GROUPS, + FORWARD_MULTIPLE_MESSAGES ); @VisibleForTesting static final Set NOT_REMOTE_CAPABLE = SetUtil.newHashSet( - PHONE_NUMBER_PRIVACY_VERSION, - FORWARD_MULTIPLE_MESSAGES + PHONE_NUMBER_PRIVACY_VERSION ); /** diff --git a/app/src/main/res/drawable/ic_send_24.xml b/app/src/main/res/drawable/ic_send_24.xml new file mode 100644 index 0000000000..18e008be51 --- /dev/null +++ b/app/src/main/res/drawable/ic_send_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/multiselect_forward_fragment.xml b/app/src/main/res/layout/multiselect_forward_fragment.xml new file mode 100644 index 0000000000..658eaa513c --- /dev/null +++ b/app/src/main/res/layout/multiselect_forward_fragment.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000000..57fd44e4b6 --- /dev/null +++ b/app/src/main/res/layout/multiselect_forward_fragment_bottom_bar.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5598c74391..87ccda8131 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3689,6 +3689,11 @@ Navigate up + Forward to + Add a message + Messages sent + Messages failed to send + Limit reached diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index a50690124c..a125dfae69 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -389,6 +389,17 @@ @color/react_with_any_background + + + +