Implement new bottom fragment UX for multiforward.

This commit is contained in:
Alex Hart 2021-08-12 14:06:09 -03:00 committed by Cody Henthorne
parent 561cca5208
commit dc1e56de4e
26 changed files with 805 additions and 123 deletions

View file

@ -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<Cursor> onCreateLoader(int id, Bundle args) {
FragmentActivity activity = requireActivity();

View file

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

View file

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

View file

@ -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<MultiselectPart> messages = getListAdapter().getSelectedItems();
Set<MultiselectPart> 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<MultiselectPart> 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<Media> mediaList = new ArrayList<>(mediaMessage.getSlideDeck().getSlides().size());
List<Attachment> 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:

View file

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

View file

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

View file

@ -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<MessageRecord> messageRecords,
@NonNull Set<MultiselectPart> 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<MultiselectPart> 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() &&

View file

@ -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<MultiselectPart> {
val parts: LinkedHashSet<MultiselectPart> = 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)
}
}

View file

@ -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<MultiselectPart>): Boolean {
val textParts: Set<MultiselectPart> = toSet().filter(this::couldContainText).toSet()
return textParts.any { selectedParts.contains(it) }
}
fun isMediaSelected(selectedParts: Set<MultiselectPart>): Boolean {
val mediaParts: Set<MultiselectPart> = 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<MultiselectPart>

View file

@ -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<MultiShareArgs> = requireNotNull(requireArguments().getParcelableArrayList(ARG_MULTISHARE_ARGS))
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
childFragmentManager.addFragmentOnAttachListener { _, fragment ->
fragment.arguments = Bundle().apply {
putInt(ContactSelectionListFragment.DISPLAY_MODE, getDefaultDisplayMode())
putBoolean(ContactSelectionListFragment.REFRESHABLE, false)
putBoolean(ContactSelectionListFragment.RECENTS, true)
putParcelable(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.shareSelectionLimit())
putBoolean(ContactSelectionListFragment.HIDE_COUNT, true)
putBoolean(ContactSelectionListFragment.DISPLAY_CHIPS, false)
putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, true)
putBoolean(ContactSelectionListFragment.RV_CLIP, false)
putInt(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(48))
}
}
val view = inflater.inflate(R.layout.multiselect_forward_fragment, container, false)
view.minimumHeight = resources.displayMetrics.heightPixels
return view
}
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<RecipientId>, number: String?, callback: Consumer<Boolean>) {
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<RecipientId>, number: String?) {
viewModel.removeSelectedContact(recipientId, null)
}
override fun onSelectionChanged() {
}
override fun onSuggestedLimitReached(limit: Int) {
}
override fun onHardLimitReached(limit: Int) {
Toast.makeText(requireContext(), R.string.MultiselectForwardFragment__limit_reached, Toast.LENGTH_SHORT).show()
}
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)
}
}
}

View file

@ -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<MultiShareArgs>
) {
companion object {
@JvmStatic
fun create(context: Context, selectedParts: Set<MultiselectPart>, consumer: Consumer<MultiselectForwardFragmentArgs>) {
SignalExecutors.BOUNDED.execute {
val conversationMessages: Set<ConversationMessage> = 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<MultiShareArgs> = conversationMessages.map { buildMultiShareArgs(context, it, selectedParts) }
ThreadUtil.runOnMain { consumer.accept(MultiselectForwardFragmentArgs(canSendToNonPush, multiShareArgs)) }
}
}
@WorkerThread
private fun buildMultiShareArgs(context: Context, conversationMessage: ConversationMessage, selectedParts: Set<MultiselectPart>): 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<Media> = 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()
)
}
}
}

View file

@ -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<MultiShareArgs>,
shareContacts: List<ShareContact>,
resultHandlers: MultiselectForwardResultHandlers
) {
SignalExecutors.BOUNDED.execute {
val threadDatabase: ThreadDatabase = DatabaseFactory.getThreadDatabase(context)
val sharedContactsAndThreads: Set<ShareContactAndThread> = shareContacts
.asSequence()
.distinct()
.filter { it.recipientId.isPresent }
.map { Recipient.resolved(it.recipientId.get()) }
.map { ShareContactAndThread(it.id, threadDatabase.getOrCreateThreadIdFor(it), it.isForceSmsSelection) }
.toSet()
val mappedArgs: List<MultiShareArgs> = 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<MultiShareSender.MultiShareSendResultCollection>,
resultHandlers: MultiselectForwardResultHandlers
) {
if (results.any { it.containsFailures() }) {
if (results.all { it.containsOnlyFailures() }) {
resultHandlers.onAllMessagesFailed()
} else {
resultHandlers.onSomeMessagesFailed()
}
} else {
resultHandlers.onAllMessageSentSuccessfully()
}
}
}

View file

@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import org.thoughtcrime.securesms.sharing.ShareContact
data class MultiselectForwardState(
val selectedContacts: List<ShareContact> = emptyList(),
val stage: Stage = Stage.SELECTION
) {
enum class Stage {
SELECTION,
SOME_FAILED,
ALL_FAILED,
SUCCESS
}
}

View file

@ -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<MultiShareArgs>,
private val repository: MultiselectForwardRepository
) : ViewModel() {
private val store = Store(MultiselectForwardState())
val state: LiveData<MultiselectForwardState> = store.stateLiveData
val shareContactMappingModels: LiveData<List<ShareSelectionMappingModel>> = Transformations.map(state) { s -> s.selectedContacts.mapIndexed { i, c -> ShareSelectionMappingModel(c, i == 0) } }
fun addSelectedContact(recipientId: Optional<RecipientId>, number: String?) {
store.update { it.copy(selectedContacts = it.selectedContacts + ShareContact(recipientId, number)) }
}
fun removeSelectedContact(recipientId: Optional<RecipientId>, number: String?) {
store.update { it.copy(selectedContacts = it.selectedContacts - ShareContact(recipientId, number)) }
}
fun send(additionalMessage: String) {
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<MultiShareArgs>,
private val repository: MultiselectForwardRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(MultiselectForwardViewModel(records, repository)))
}
}
}

View file

@ -158,6 +158,10 @@ public final class MultiShareArgs implements Parcelable {
}
public Builder buildUpon() {
return buildUpon(shareContactAndThreads);
}
public Builder buildUpon(@NonNull Set<ShareContactAndThread> shareContactAndThreads) {
return new Builder(shareContactAndThreads).asBorderless(borderless)
.asViewOnce(viewOnce)
.withDataType(dataType)

View file

@ -50,11 +50,11 @@ public final class MultiShareSender {
@MainThread
public static void send(@NonNull MultiShareArgs multiShareArgs, @NonNull Consumer<MultiShareSendResultCollection> 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 {

View file

@ -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> recipientId;
private final String number;
ShareContact(@NonNull Optional<RecipientId> recipientId, @Nullable String number) {
public ShareContact(@NonNull Optional<RecipientId> recipientId, @Nullable String number) {
this.recipientId = recipientId;
this.number = number;
}

View file

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

View file

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

View file

@ -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<ShareSelectionMappingModel> {
public class ShareSelectionMappingModel implements MappingModel<ShareSelectionMappingModel> {
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<ShareSelectionMappingMo
.transform(Recipient::resolved)
.transform(recipient -> 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);
}

View file

@ -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<String> NOT_REMOTE_CAPABLE = SetUtil.newHashSet(
PHONE_NUMBER_PRIVACY_VERSION,
FORWARD_MULTIPLE_MESSAGES
PHONE_NUMBER_PRIVACY_VERSION
);
/**

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/core_white"
android:pathData="M22.1,10.915 L5.286,1.306A1.25,1.25 0,0 0,3.433 2.6L4.69,10.138 14,12 4.69,13.862 3.433,21.4a1.25,1.25 0,0 0,1.853 1.291L22.1,13.085A1.25,1.25 0,0 0,22.1 10.915Z" />
</vector>

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<View
android:id="@+id/anchor"
android:layout_width="48dp"
android:layout_height="2dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:background="@color/signal_icon_tint_tab_unselected" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="13dp"
android:gravity="center"
android:text="@string/MultiselectForwardFragment__forward_to"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold" />
<org.thoughtcrime.securesms.components.ContactFilterView
android:id="@+id/contact_filter_edit_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/dsl_settings_gutter"
android:layout_marginTop="17dp"
android:layout_marginRight="@dimen/dsl_settings_gutter"
android:minHeight="44dp" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/contact_selection_list_fragment"
android:name="org.thoughtcrime.securesms.ContactSelectionListFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="12dp"
android:layout_weight="1" />
</androidx.appcompat.widget.LinearLayoutCompat>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom">
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/signal_divider_minor"
app:layout_constraintBottom_toBottomOf="@id/share_confirm"
app:layout_constraintTop_toTopOf="@id/share_confirm" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/selected_list"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@color/signal_background_primary"
android:clipToPadding="false"
android:orientation="horizontal"
android:paddingStart="@dimen/dsl_settings_gutter"
android:paddingEnd="78dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/divider"
tools:listitem="@layout/share_contact_selection_item" />
<FrameLayout
android:id="@+id/add_message_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/signal_background_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/selected_list">
<org.thoughtcrime.securesms.components.emoji.EmojiEditText
android:id="@+id/add_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:background="@drawable/rounded_rectangle_secondary"
android:hint="@string/MultiselectForwardFragment__add_a_message"
android:minHeight="44dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textAppearance="@style/Signal.Text.Body" />
</FrameLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/share_confirm"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:contentDescription="@string/ShareActivity__share"
app:backgroundTint="@color/signal_accent_primary"
app:layout_constraintBottom_toBottomOf="@id/divider"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_send_24" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -3689,6 +3689,11 @@
<!-- DSLSettingsToolbar -->
<string name="DSLSettingsToolbar__navigate_up">Navigate up</string>
<string name="MultiselectForwardFragment__forward_to">Forward to</string>
<string name="MultiselectForwardFragment__add_a_message">Add a message</string>
<string name="MultiselectForwardFragment__messages_sent">Messages sent</string>
<string name="MultiselectForwardFragment__messages_failed_to_send">Messages failed to send</string>
<string name="MultiselectForwardFragment__limit_reached">Limit reached</string>
<!-- EOF -->

View file

@ -389,6 +389,17 @@
<item name="backgroundTint">@color/react_with_any_background</item>
</style>
<style name="Widget.Signal.FixedRoundedCorners" parent="ThemeOverlay.MaterialComponents.BottomSheetDialog">
<item name="android:windowIsFloating">false</item>
<item name="android:windowSoftInputMode">adjustResize</item>
<item name="bottomSheetStyle">@style/Widget.Signal.FixedRoundedCorners.BottomSheet</item>
</style>
<style name="Widget.Signal.FixedRoundedCorners.BottomSheet" parent="Widget.MaterialComponents.BottomSheet">
<item name="shapeAppearanceOverlay">@style/ShapeAppearanceOverlay.Signal.BottomSheet.Rounded</item>
<item name="backgroundTint">@color/signal_background_primary</item>
</style>
<style name="ShapeAppearanceOverlay.Signal.BottomSheet.Rounded" parent="">
<item name="cornerFamily">rounded</item>
<item name="cornerSizeTopRight">18dp</item>