Implement new bottom fragment UX for multiforward.
This commit is contained in:
parent
561cca5208
commit
dc1e56de4e
26 changed files with 805 additions and 123 deletions
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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() &&
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
10
app/src/main/res/drawable/ic_send_24.xml
Normal file
10
app/src/main/res/drawable/ic_send_24.xml
Normal 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>
|
41
app/src/main/res/layout/multiselect_forward_fragment.xml
Normal file
41
app/src/main/res/layout/multiselect_forward_fragment.xml
Normal 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>
|
|
@ -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>
|
|
@ -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 -->
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue