kopia lustrzana https://github.com/ryukoposting/Signal-Android
Implement new bottom fragment UX for multiforward.
rodzic
561cca5208
commit
dc1e56de4e
|
@ -26,6 +26,7 @@ import android.database.Cursor;
|
||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
import android.util.AttributeSet;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
@ -37,6 +38,7 @@ import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.Px;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||||
import androidx.constraintlayout.widget.ConstraintSet;
|
import androidx.constraintlayout.widget.ConstraintSet;
|
||||||
|
@ -178,12 +180,16 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||||
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context;
|
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (getParentFragment() instanceof OnSelectionLimitReachedListener) {
|
||||||
|
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
|
||||||
|
}
|
||||||
|
|
||||||
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
|
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
|
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -262,7 +268,9 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||||
|
|
||||||
recyclerView.setClipToPadding(recyclerViewClipping);
|
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));
|
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
|
||||||
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
|
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
|
@Override
|
||||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||||
FragmentActivity activity = requireActivity();
|
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
|
@Override
|
||||||
public void handleReaction(@NonNull MaskView.MaskTarget maskTarget,
|
public void handleReaction(@NonNull MaskView.MaskTarget maskTarget,
|
||||||
@NonNull MessageRecord messageRecord,
|
@NonNull ConversationMessage conversationMessage,
|
||||||
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
|
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
|
||||||
@NonNull ConversationReactionOverlay.OnHideListener onHideListener)
|
@NonNull ConversationReactionOverlay.OnHideListener onHideListener)
|
||||||
{
|
{
|
||||||
reactionDelegate.setOnToolbarItemClickedListener(toolbarListener);
|
reactionDelegate.setOnToolbarItemClickedListener(toolbarListener);
|
||||||
reactionDelegate.setOnHideListener(onHideListener);
|
reactionDelegate.setOnHideListener(onHideListener);
|
||||||
reactionDelegate.show(this, maskTarget, recipient.get(), messageRecord, inputAreaHeight());
|
reactionDelegate.show(this, maskTarget, recipient.get(), conversationMessage, inputAreaHeight());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -66,14 +66,12 @@ import com.annimon.stream.Collectors;
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
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.concurrent.SignalExecutors;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.LoggingFragment;
|
import org.thoughtcrime.securesms.LoggingFragment;
|
||||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
import org.thoughtcrime.securesms.VerifyIdentityActivity;
|
import org.thoughtcrime.securesms.VerifyIdentityActivity;
|
||||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
|
||||||
import org.thoughtcrime.securesms.components.ConversationScrollToView;
|
import org.thoughtcrime.securesms.components.ConversationScrollToView;
|
||||||
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
||||||
import org.thoughtcrime.securesms.components.MaskView;
|
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.ConversationMessage.ConversationMessageFactory;
|
||||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||||
import org.thoughtcrime.securesms.conversation.colors.ColorizerView;
|
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.MultiselectItemDecoration;
|
||||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
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.EnableCallNotificationSettingsDialog;
|
||||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
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.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||||
import org.thoughtcrime.securesms.longmessage.LongMessageActivity;
|
import org.thoughtcrime.securesms.longmessage.LongMessageActivity;
|
||||||
import org.thoughtcrime.securesms.mediasend.Media;
|
|
||||||
import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity;
|
import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity;
|
||||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestState;
|
import org.thoughtcrime.securesms.messagerequests.MessageRequestState;
|
||||||
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel;
|
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.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
|
||||||
import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity;
|
import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity;
|
||||||
import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
|
import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
|
||||||
import org.thoughtcrime.securesms.sharing.ShareIntents;
|
|
||||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
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.HtmlUtil;
|
||||||
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
|
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
|
||||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||||
import org.thoughtcrime.securesms.util.SetUtil;
|
|
||||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||||
import org.thoughtcrime.securesms.util.SignalProxyUtil;
|
import org.thoughtcrime.securesms.util.SignalProxyUtil;
|
||||||
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
|
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.util.views.AdaptiveActionsToolbar;
|
||||||
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
|
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
|
||||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
|
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
|
@ -774,14 +769,14 @@ public class ConversationFragment extends LoggingFragment {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setCorrectMenuVisibility(@NonNull Menu menu) {
|
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();
|
actionMode.finish();
|
||||||
return;
|
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_forward).setVisible(menuState.shouldShowForwardAction());
|
||||||
menu.findItem(R.id.menu_context_reply).setVisible(menuState.shouldShowReplyAction());
|
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));
|
startActivity(MessageDetailsActivity.getIntentForMessageDetails(requireContext(), message.getMessageRecord(), recipient.getId(), threadId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleForwardMessage(ConversationMessage conversationMessage) {
|
private void handleForwardMessageParts(Set<MultiselectPart> multiselectParts) {
|
||||||
if (conversationMessage.getMessageRecord().isViewOnce()) {
|
|
||||||
throw new AssertionError("Cannot forward a view-once message.");
|
|
||||||
}
|
|
||||||
|
|
||||||
listener.onForwardClicked();
|
listener.onForwardClicked();
|
||||||
|
|
||||||
SimpleTask.run(getLifecycle(), () -> {
|
MultiselectForwardFragmentArgs.create(requireContext(),
|
||||||
ShareIntents.Builder shareIntentBuilder = new ShareIntents.Builder(requireActivity());
|
multiselectParts,
|
||||||
shareIntentBuilder.setText(conversationMessage.getDisplayBody(requireContext()));
|
args -> MultiselectForwardFragment.show(getParentFragmentManager(), args));
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleResendMessage(final MessageRecord message) {
|
private void handleResendMessage(final MessageRecord message) {
|
||||||
|
@ -1311,7 +1247,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||||
void onForwardClicked();
|
void onForwardClicked();
|
||||||
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
|
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
|
||||||
void handleReaction(@NonNull MaskView.MaskTarget maskTarget,
|
void handleReaction(@NonNull MaskView.MaskTarget maskTarget,
|
||||||
@NonNull MessageRecord messageRecord,
|
@NonNull ConversationMessage conversationMessage,
|
||||||
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
|
@NonNull Toolbar.OnMenuItemClickListener toolbarListener,
|
||||||
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
|
@NonNull ConversationReactionOverlay.OnHideListener onHideListener);
|
||||||
void onCursorChanged();
|
void onCursorChanged();
|
||||||
|
@ -1429,7 +1365,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||||
{
|
{
|
||||||
isReacting = true;
|
isReacting = true;
|
||||||
list.setLayoutFrozen(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;
|
isReacting = false;
|
||||||
list.setLayoutFrozen(false);
|
list.setLayoutFrozen(false);
|
||||||
WindowUtil.setLightStatusBarFromTheme(requireActivity());
|
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_copy: handleCopyMessage(conversationMessage.getMultiselectCollection().toSet()); return true;
|
||||||
case R.id.action_reply: handleReplyMessage(conversationMessage); return true;
|
case R.id.action_reply: handleReplyMessage(conversationMessage); return true;
|
||||||
case R.id.action_multiselect: handleEnterMultiSelect(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;
|
case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); return true;
|
||||||
default: return false;
|
default: return false;
|
||||||
}
|
}
|
||||||
|
@ -1911,7 +1847,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||||
actionMode.finish();
|
actionMode.finish();
|
||||||
return true;
|
return true;
|
||||||
case R.id.menu_context_forward:
|
case R.id.menu_context_forward:
|
||||||
handleForwardMessage(getSelectedConversationMessage());
|
handleForwardMessageParts(getListAdapter().getSelectedItems());
|
||||||
actionMode.finish();
|
actionMode.finish();
|
||||||
return true;
|
return true;
|
||||||
case R.id.menu_context_resend:
|
case R.id.menu_context_resend:
|
||||||
|
|
|
@ -40,10 +40,10 @@ final class ConversationReactionDelegate {
|
||||||
void show(@NonNull Activity activity,
|
void show(@NonNull Activity activity,
|
||||||
@NonNull MaskView.MaskTarget maskTarget,
|
@NonNull MaskView.MaskTarget maskTarget,
|
||||||
@NonNull Recipient conversationRecipient,
|
@NonNull Recipient conversationRecipient,
|
||||||
@NonNull MessageRecord messageRecord,
|
@NonNull ConversationMessage conversationMessage,
|
||||||
int maskPaddingBottom)
|
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) {
|
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,
|
public void show(@NonNull Activity activity,
|
||||||
@NonNull MaskView.MaskTarget maskTarget,
|
@NonNull MaskView.MaskTarget maskTarget,
|
||||||
@NonNull Recipient conversationRecipient,
|
@NonNull Recipient conversationRecipient,
|
||||||
@NonNull MessageRecord messageRecord,
|
@NonNull ConversationMessage conversationMessage,
|
||||||
int maskPaddingBottom,
|
int maskPaddingBottom,
|
||||||
@NonNull PointF lastSeenDownPoint)
|
@NonNull PointF lastSeenDownPoint)
|
||||||
{
|
{
|
||||||
|
@ -159,12 +159,12 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.messageRecord = messageRecord;
|
this.messageRecord = conversationMessage.getMessageRecord();
|
||||||
this.conversationRecipient = conversationRecipient;
|
this.conversationRecipient = conversationRecipient;
|
||||||
overlayState = OverlayState.UNINITAILIZED;
|
overlayState = OverlayState.UNINITAILIZED;
|
||||||
selected = -1;
|
selected = -1;
|
||||||
|
|
||||||
setupToolbarMenuItems();
|
setupToolbarMenuItems(conversationMessage);
|
||||||
setupSelectedEmoji();
|
setupSelectedEmoji();
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= 21) {
|
if (Build.VERSION.SDK_INT >= 21) {
|
||||||
|
@ -504,8 +504,8 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setupToolbarMenuItems() {
|
private void setupToolbarMenuItems(@NonNull ConversationMessage conversationMessage) {
|
||||||
MenuState menuState = MenuState.getMenuState(conversationRecipient, Collections.singleton(messageRecord), false);
|
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_copy).setVisible(menuState.shouldShowCopyAction());
|
||||||
toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction());
|
toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction());
|
||||||
|
|
|
@ -2,12 +2,15 @@ package org.thoughtcrime.securesms.conversation;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
final class MenuState {
|
final class MenuState {
|
||||||
|
|
||||||
|
@ -58,19 +61,23 @@ final class MenuState {
|
||||||
}
|
}
|
||||||
|
|
||||||
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
|
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
|
||||||
@NonNull Set<MessageRecord> messageRecords,
|
@NonNull Set<MultiselectPart> selectedParts,
|
||||||
boolean shouldShowMessageRequest)
|
boolean shouldShowMessageRequest)
|
||||||
{
|
{
|
||||||
|
|
||||||
Builder builder = new Builder();
|
Builder builder = new Builder();
|
||||||
boolean actionMessage = false;
|
boolean actionMessage = false;
|
||||||
boolean hasText = false;
|
boolean hasText = false;
|
||||||
boolean sharedContact = false;
|
boolean sharedContact = false;
|
||||||
boolean viewOnce = false;
|
boolean viewOnce = false;
|
||||||
boolean remoteDelete = false;
|
boolean remoteDelete = false;
|
||||||
boolean hasInMemory = 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)) {
|
if (isActionMessage(messageRecord)) {
|
||||||
actionMessage = true;
|
actionMessage = true;
|
||||||
if (messageRecord.isInMemoryMessageRecord()) {
|
if (messageRecord.isInMemoryMessageRecord()) {
|
||||||
|
@ -78,8 +85,15 @@ final class MenuState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageRecord.getBody().length() > 0) {
|
if (!(part instanceof MultiselectPart.Attachments)) {
|
||||||
hasText = true;
|
if (messageRecord.getBody().length() > 0) {
|
||||||
|
hasText = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mediaIsSelected = true;
|
||||||
|
if (messageRecord.isMediaPending()) {
|
||||||
|
hasPendingMedia = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getSharedContacts().isEmpty()) {
|
if (messageRecord.isMms() && !((MmsMessageRecord) messageRecord).getSharedContacts().isEmpty()) {
|
||||||
|
@ -95,33 +109,53 @@ final class MenuState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageRecords.size() > 1) {
|
boolean shouldShowForwardAction = !actionMessage &&
|
||||||
builder.shouldShowForwardAction(false)
|
!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)
|
.shouldShowReplyAction(false)
|
||||||
.shouldShowDetailsAction(false)
|
.shouldShowDetailsAction(false)
|
||||||
.shouldShowSaveAttachmentAction(false)
|
.shouldShowSaveAttachmentAction(false)
|
||||||
.shouldShowResendAction(false);
|
.shouldShowResendAction(false);
|
||||||
} else {
|
} else {
|
||||||
MessageRecord messageRecord = messageRecords.iterator().next();
|
MessageRecord messageRecord = selectedParts.iterator().next().getMessageRecord();
|
||||||
|
|
||||||
builder.shouldShowResendAction(messageRecord.isFailed())
|
builder.shouldShowResendAction(messageRecord.isFailed())
|
||||||
.shouldShowSaveAttachmentAction(!actionMessage &&
|
.shouldShowSaveAttachmentAction(mediaIsSelected &&
|
||||||
|
!actionMessage &&
|
||||||
!viewOnce &&
|
!viewOnce &&
|
||||||
messageRecord.isMms() &&
|
messageRecord.isMms() &&
|
||||||
!messageRecord.isMediaPending() &&
|
!hasPendingMedia &&
|
||||||
!messageRecord.isMmsNotification() &&
|
!messageRecord.isMmsNotification() &&
|
||||||
((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
|
((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
|
||||||
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
|
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
|
||||||
.shouldShowForwardAction(!actionMessage && !sharedContact && !viewOnce && !remoteDelete && !messageRecord.isMediaPending())
|
.shouldShowForwardAction(shouldShowForwardAction)
|
||||||
.shouldShowDetailsAction(!actionMessage)
|
.shouldShowDetailsAction(!actionMessage)
|
||||||
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest));
|
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest));
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText)
|
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText)
|
||||||
.shouldShowDeleteAction(!hasInMemory)
|
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
|
||||||
.build();
|
.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) {
|
static boolean canReplyToMessage(@NonNull Recipient conversationRecipient, boolean actionMessage, @NonNull MessageRecord messageRecord, boolean isDisplayingMessageRequest) {
|
||||||
return !actionMessage &&
|
return !actionMessage &&
|
||||||
!messageRecord.isRemoteDelete() &&
|
!messageRecord.isRemoteDelete() &&
|
||||||
|
|
|
@ -1,9 +1,20 @@
|
||||||
package org.thoughtcrime.securesms.conversation.mutiselect
|
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.conversation.ConversationMessage
|
||||||
|
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
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.mms.TextSlide
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||||
|
import org.thoughtcrime.securesms.util.Util
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General helper object for all things multiselect. This is only utilized by
|
* 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> {
|
private fun getMmsParts(conversationMessage: ConversationMessage, mmsMessageRecord: MmsMessageRecord): Set<MultiselectPart> {
|
||||||
val parts: LinkedHashSet<MultiselectPart> = linkedSetOf()
|
val parts: LinkedHashSet<MultiselectPart> = linkedSetOf()
|
||||||
|
|
||||||
val slideDeck = mmsMessageRecord.slideDeck
|
val slideDeck: SlideDeck = mmsMessageRecord.slideDeck
|
||||||
|
|
||||||
if (slideDeck.slides.filterNot { it is TextSlide }.isNotEmpty()) {
|
if (slideDeck.slides.filterNot { it is TextSlide }.isNotEmpty()) {
|
||||||
parts.add(MultiselectPart.Attachments(conversationMessage))
|
parts.add(MultiselectPart.Attachments(conversationMessage))
|
||||||
|
@ -58,4 +69,37 @@ object Multiselect {
|
||||||
|
|
||||||
return parts
|
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
|
package org.thoughtcrime.securesms.conversation.mutiselect
|
||||||
|
|
||||||
import java.lang.IllegalArgumentException
|
|
||||||
import java.lang.UnsupportedOperationException
|
|
||||||
|
|
||||||
sealed class MultiselectCollection {
|
sealed class MultiselectCollection {
|
||||||
|
|
||||||
data class Single(val singlePart: MultiselectPart) : 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 val size: Int
|
||||||
|
|
||||||
abstract fun toSet(): Set<MultiselectPart>
|
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() {
|
public Builder buildUpon() {
|
||||||
|
return buildUpon(shareContactAndThreads);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder buildUpon(@NonNull Set<ShareContactAndThread> shareContactAndThreads) {
|
||||||
return new Builder(shareContactAndThreads).asBorderless(borderless)
|
return new Builder(shareContactAndThreads).asBorderless(borderless)
|
||||||
.asViewOnce(viewOnce)
|
.asViewOnce(viewOnce)
|
||||||
.withDataType(dataType)
|
.withDataType(dataType)
|
||||||
|
|
|
@ -50,11 +50,11 @@ public final class MultiShareSender {
|
||||||
|
|
||||||
@MainThread
|
@MainThread
|
||||||
public static void send(@NonNull MultiShareArgs multiShareArgs, @NonNull Consumer<MultiShareSendResultCollection> results) {
|
public static void send(@NonNull MultiShareArgs multiShareArgs, @NonNull Consumer<MultiShareSendResultCollection> results) {
|
||||||
SimpleTask.run(() -> sendInternal(multiShareArgs), results::accept);
|
SimpleTask.run(() -> sendSync(multiShareArgs), results::accept);
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
private static MultiShareSendResultCollection sendInternal(@NonNull MultiShareArgs multiShareArgs) {
|
public static MultiShareSendResultCollection sendSync(@NonNull MultiShareArgs multiShareArgs) {
|
||||||
Context context = ApplicationDependencies.getApplication();
|
Context context = ApplicationDependencies.getApplication();
|
||||||
boolean isMmsEnabled = Util.isMmsCapable(context);
|
boolean isMmsEnabled = Util.isMmsCapable(context);
|
||||||
String message = multiShareArgs.getDraftText();
|
String message = multiShareArgs.getDraftText();
|
||||||
|
@ -199,6 +199,10 @@ public final class MultiShareSender {
|
||||||
public boolean containsFailures() {
|
public boolean containsFailures() {
|
||||||
return Stream.of(results).anyMatch(result -> result.type != MultiShareSendResult.Type.SUCCESS);
|
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 {
|
private static final class MultiShareSendResult {
|
||||||
|
|
|
@ -8,11 +8,11 @@ import org.whispersystems.libsignal.util.guava.Optional;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
final class ShareContact {
|
public final class ShareContact {
|
||||||
private final Optional<RecipientId> recipientId;
|
private final Optional<RecipientId> recipientId;
|
||||||
private final String number;
|
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.recipientId = recipientId;
|
||||||
this.number = number;
|
this.number = number;
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ public final class ShareContactAndThread implements Parcelable {
|
||||||
private final long threadId;
|
private final long threadId;
|
||||||
private final boolean forceSms;
|
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.recipientId = recipientId;
|
||||||
this.threadId = threadId;
|
this.threadId = threadId;
|
||||||
this.forceSms = forceSms;
|
this.forceSms = forceSms;
|
||||||
|
|
|
@ -3,8 +3,8 @@ package org.thoughtcrime.securesms.sharing;
|
||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
import org.thoughtcrime.securesms.util.MappingAdapter;
|
import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||||
|
|
||||||
class ShareSelectionAdapter extends MappingAdapter {
|
public class ShareSelectionAdapter extends MappingAdapter {
|
||||||
ShareSelectionAdapter() {
|
public ShareSelectionAdapter() {
|
||||||
registerFactory(ShareSelectionMappingModel.class,
|
registerFactory(ShareSelectionMappingModel.class,
|
||||||
ShareSelectionViewHolder.createFactory(R.layout.share_contact_selection_item));
|
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.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.util.MappingModel;
|
import org.thoughtcrime.securesms.util.MappingModel;
|
||||||
|
|
||||||
class ShareSelectionMappingModel implements MappingModel<ShareSelectionMappingModel> {
|
public class ShareSelectionMappingModel implements MappingModel<ShareSelectionMappingModel> {
|
||||||
|
|
||||||
private final ShareContact shareContact;
|
private final ShareContact shareContact;
|
||||||
private final boolean isFirst;
|
private final boolean isFirst;
|
||||||
|
|
||||||
ShareSelectionMappingModel(@NonNull ShareContact shareContact, boolean isFirst) {
|
public ShareSelectionMappingModel(@NonNull ShareContact shareContact, boolean isFirst) {
|
||||||
this.shareContact = shareContact;
|
this.shareContact = shareContact;
|
||||||
this.isFirst = isFirst;
|
this.isFirst = isFirst;
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ class ShareSelectionMappingModel implements MappingModel<ShareSelectionMappingMo
|
||||||
.transform(Recipient::resolved)
|
.transform(Recipient::resolved)
|
||||||
.transform(recipient -> recipient.isSelf() ? context.getString(R.string.note_to_self)
|
.transform(recipient -> recipient.isSelf() ? context.getString(R.string.note_to_self)
|
||||||
: recipient.getShortDisplayNameIncludingUsername(context))
|
: recipient.getShortDisplayNameIncludingUsername(context))
|
||||||
.or(shareContact.getNumber());
|
.or(shareContact::getNumber);
|
||||||
|
|
||||||
return isFirst ? name : context.getString(R.string.ShareActivity__comma_s, name);
|
return isFirst ? name : context.getString(R.string.ShareActivity__comma_s, name);
|
||||||
}
|
}
|
||||||
|
|
|
@ -118,13 +118,13 @@ public final class FeatureFlags {
|
||||||
SENDER_KEY,
|
SENDER_KEY,
|
||||||
RETRY_RECEIPTS,
|
RETRY_RECEIPTS,
|
||||||
SUGGEST_SMS_BLACKLIST,
|
SUGGEST_SMS_BLACKLIST,
|
||||||
ANNOUNCEMENT_GROUPS
|
ANNOUNCEMENT_GROUPS,
|
||||||
|
FORWARD_MULTIPLE_MESSAGES
|
||||||
);
|
);
|
||||||
|
|
||||||
@VisibleForTesting
|
@VisibleForTesting
|
||||||
static final Set<String> NOT_REMOTE_CAPABLE = SetUtil.newHashSet(
|
static final Set<String> NOT_REMOTE_CAPABLE = SetUtil.newHashSet(
|
||||||
PHONE_NUMBER_PRIVACY_VERSION,
|
PHONE_NUMBER_PRIVACY_VERSION
|
||||||
FORWARD_MULTIPLE_MESSAGES
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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>
|
|
@ -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 -->
|
<!-- DSLSettingsToolbar -->
|
||||||
<string name="DSLSettingsToolbar__navigate_up">Navigate up</string>
|
<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 -->
|
<!-- EOF -->
|
||||||
|
|
||||||
|
|
|
@ -389,6 +389,17 @@
|
||||||
<item name="backgroundTint">@color/react_with_any_background</item>
|
<item name="backgroundTint">@color/react_with_any_background</item>
|
||||||
</style>
|
</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="">
|
<style name="ShapeAppearanceOverlay.Signal.BottomSheet.Rounded" parent="">
|
||||||
<item name="cornerFamily">rounded</item>
|
<item name="cornerFamily">rounded</item>
|
||||||
<item name="cornerSizeTopRight">18dp</item>
|
<item name="cornerSizeTopRight">18dp</item>
|
||||||
|
|
Ładowanie…
Reference in New Issue