From 8d187c8ba176de0224a1b8b30a572c11017ca162 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 20 Jan 2021 09:24:16 -0400 Subject: [PATCH] Add the ability to forward content to multiple chats at once. --- app/src/main/AndroidManifest.xml | 5 +- .../securesms/ContactSelectionActivity.java | 1 - .../ContactSelectionListFragment.java | 81 +++-- .../securesms/InviteActivity.java | 1 - .../blocked/BlockedUsersActivity.java | 2 - .../securesms/components/LabeledEditText.java | 14 +- .../SelectionAwareEmojiEditText.java | 45 +++ .../conversation/ConversationActivity.java | 29 +- .../securesms/database/ThreadDatabase.java | 14 + .../mediasend/MediaSendActivity.java | 40 ++- .../securesms/mms/AttachmentManager.java | 60 +--- .../securesms/mms/SlideFactory.java | 174 ++++++++++ .../securesms/recipients/Recipient.java | 9 + .../sharing/InterstitialContentType.java | 7 + .../securesms/sharing/MultiShareArgs.java | 235 +++++++++++++ .../securesms/sharing/MultiShareDialogs.java | 43 +++ .../securesms/sharing/MultiShareSender.java | 230 +++++++++++++ .../securesms/sharing/ShareActivity.java | 321 +++++++++++++++--- .../securesms/sharing/ShareContact.java | 41 +++ .../sharing/ShareContactAndThread.java | 80 +++++ .../securesms/sharing/ShareData.java | 26 +- .../securesms/sharing/ShareRepository.java | 38 ++- .../sharing/ShareSelectionAdapter.java | 11 + .../sharing/ShareSelectionMappingModel.java | 40 +++ .../sharing/ShareSelectionViewHolder.java | 36 ++ .../securesms/sharing/ShareViewModel.java | 54 ++- .../ShareInterstitialActivity.java | 163 +++++++++ .../ShareInterstitialMappingModel.java | 38 +++ .../ShareInterstitialRepository.java | 29 ++ .../ShareInterstitialSelectionAdapter.java | 11 + .../ShareInterstitialViewModel.java | 87 +++++ .../securesms/util/FeatureFlags.java | 12 +- .../thoughtcrime/securesms/util/ViewUtil.java | 15 + app/src/main/res/layout/share_activity.xml | 121 ++++--- .../layout/share_contact_selection_item.xml | 10 + .../layout/share_interstitial_activity.xml | 85 +++++ app/src/main/res/values/strings.xml | 12 + 37 files changed, 1988 insertions(+), 232 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/SelectionAwareEmojiEditText.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/InterstitialContentType.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareDialogs.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionViewHolder.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialMappingModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialSelectionAdapter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java create mode 100644 app/src/main/res/layout/share_contact_selection_item.xml create mode 100644 app/src/main/res/layout/share_interstitial_activity.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 35461dd30c..845c664a51 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -159,12 +159,15 @@ + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java index 3e92c2478a..b706234396 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionActivity.java @@ -99,7 +99,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit private void initializeResources() { contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment); - contactsFragment.setOnContactSelectedListener(this); contactsFragment.setOnRefreshListener(this); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java index 2d44020920..c45cd721b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ContactSelectionListFragment.java @@ -110,22 +110,26 @@ public final class ContactSelectionListFragment extends LoggingFragment public static final String SELECTION_LIMITS = "selection_limits"; public static final String CURRENT_SELECTION = "current_selection"; public static final String HIDE_COUNT = "hide_count"; + public static final String CAN_SELECT_SELF = "can_select_self"; + public static final String DISPLAY_CHIPS = "display_chips"; + + private ConstraintLayout constraintLayout; + private TextView emptyText; + private OnContactSelectedListener onContactSelectedListener; + private SwipeRefreshLayout swipeRefresh; + private View showContactsLayout; + private Button showContactsButton; + private TextView showContactsDescription; + private ProgressWheel showContactsProgress; + private String cursorFilter; + private RecyclerView recyclerView; + private RecyclerViewFastScroller fastScroller; + private ContactSelectionListAdapter cursorRecyclerViewAdapter; + private ChipGroup chipGroup; + private HorizontalScrollView chipGroupScrollContainer; + private WarningTextView groupLimit; + private OnSelectionLimitReachedListener onSelectionLimitReachedListener; - private ConstraintLayout constraintLayout; - private TextView emptyText; - private OnContactSelectedListener onContactSelectedListener; - private SwipeRefreshLayout swipeRefresh; - private View showContactsLayout; - private Button showContactsButton; - private TextView showContactsDescription; - private ProgressWheel showContactsProgress; - private String cursorFilter; - private RecyclerView recyclerView; - private RecyclerViewFastScroller fastScroller; - private ContactSelectionListAdapter cursorRecyclerViewAdapter; - private ChipGroup chipGroup; - private HorizontalScrollView chipGroupScrollContainer; - private WarningTextView groupLimit; @Nullable private FixedViewsAdapter headerAdapter; @Nullable private FixedViewsAdapter footerAdapter; @@ -136,6 +140,7 @@ public final class ContactSelectionListFragment extends LoggingFragment private Set currentSelection; private boolean isMulti; private boolean hideCount; + private boolean canSelectSelf; @Override public void onAttach(@NonNull Context context) { @@ -148,6 +153,14 @@ public final class ContactSelectionListFragment extends LoggingFragment if (context instanceof ScrollCallback) { scrollCallback = (ScrollCallback) context; } + + if (context instanceof OnContactSelectedListener) { + onContactSelectedListener = (OnContactSelectedListener) context; + } + + if (context instanceof OnSelectionLimitReachedListener) { + onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context; + } } @Override @@ -217,6 +230,7 @@ public final class ContactSelectionListFragment extends LoggingFragment hideCount = intent.getBooleanExtra(HIDE_COUNT, false); selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS); isMulti = selectionLimit != null; + canSelectSelf = intent.getBooleanExtra(CAN_SELECT_SELF, !isMulti); if (!isMulti) { selectionLimit = SelectionLimits.NO_LIMITS; @@ -464,14 +478,18 @@ public final class ContactSelectionListFragment extends LoggingFragment SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orNull(), contact.getNumber()) : SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber()); - if (isMulti && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) { + if (!canSelectSelf && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) { Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show(); return; } if (!isMulti || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) { if (selectionHardLimitReached()) { - GroupLimitDialog.showHardLimitMessage(requireContext()); + if (onSelectionLimitReachedListener != null) { + onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit()); + } else { + GroupLimitDialog.showHardLimitMessage(requireContext()); + } return; } @@ -489,11 +507,11 @@ public final class ContactSelectionListFragment extends LoggingFragment if (onContactSelectedListener != null) { if (onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null)) { markContactSelected(selected); - cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); } } else { markContactSelected(selected); - cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); } } else { new AlertDialog.Builder(requireContext()) @@ -507,16 +525,16 @@ public final class ContactSelectionListFragment extends LoggingFragment if (onContactSelectedListener != null) { if (onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber())) { markContactSelected(selectedContact); - cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); } } else { markContactSelected(selectedContact); - cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); } } } else { markContactUnselected(selectedContact); - cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); + cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE); if (onContactSelectedListener != null) { onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber()); @@ -611,7 +629,11 @@ public final class ContactSelectionListFragment extends LoggingFragment chipGroup.addView(chip); updateGroupLimit(getChipCount()); if (selectionWarningLimitReachedExactly()) { - GroupLimitDialog.showRecommendedLimitMessage(requireContext()); + if (onSelectionLimitReachedListener != null) { + onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit()); + } else { + GroupLimitDialog.showRecommendedLimitMessage(requireContext()); + } } } @@ -633,6 +655,10 @@ public final class ContactSelectionListFragment extends LoggingFragment } private void setChipGroupVisibility(int visibility) { + if (!requireActivity().getIntent().getBooleanExtra(DISPLAY_CHIPS, true)) { + return; + } + TransitionManager.beginDelayedTransition(constraintLayout, new AutoTransition().setDuration(CHIP_GROUP_REVEAL_DURATION_MS)); ConstraintSet constraintSet = new ConstraintSet(); @@ -641,10 +667,6 @@ public final class ContactSelectionListFragment extends LoggingFragment constraintSet.applyTo(constraintLayout); } - public void setOnContactSelectedListener(OnContactSelectedListener onContactSelectedListener) { - this.onContactSelectedListener = onContactSelectedListener; - } - public void setOnRefreshListener(SwipeRefreshLayout.OnRefreshListener onRefreshListener) { this.swipeRefresh.setOnRefreshListener(onRefreshListener); } @@ -660,6 +682,11 @@ public final class ContactSelectionListFragment extends LoggingFragment void onContactDeselected(Optional recipientId, String number); } + public interface OnSelectionLimitReachedListener { + void onSuggestedLimitReached(int limit); + void onHardLimitReached(int limit); + } + public interface ListCallback { void onInvite(); void onNewGroup(boolean forceV1); diff --git a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java index b25b80dc1a..e45b75edae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/InviteActivity.java @@ -107,7 +107,6 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac inviteText.setText(getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url))); updateSmsButtonText(contactsFragment.getSelectedContacts().size()); - contactsFragment.setOnContactSelectedListener(this); smsCancelButton.setOnClickListener(new SmsCancelClickListener()); smsSendButton.setOnClickListener(new SmsSendClickListener()); contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java index f5d33f7253..9f8bd6e1cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/blocked/BlockedUsersActivity.java @@ -124,8 +124,6 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements ContactSelectionListFragment fragment = new ContactSelectionListFragment(); Intent intent = getIntent(); - fragment.setOnContactSelectedListener(this); - intent.putExtra(ContactSelectionListFragment.REFRESHABLE, false); intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1); intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java index cf2ecc7dff..e599d4bca4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/LabeledEditText.java @@ -8,7 +8,6 @@ import android.text.TextUtils; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; -import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.TextView; @@ -17,6 +16,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ViewUtil; public class LabeledEditText extends FrameLayout implements View.OnFocusChangeListener { @@ -94,16 +94,6 @@ public class LabeledEditText extends FrameLayout implements View.OnFocusChangeLi } public void focusAndMoveCursorToEndAndOpenKeyboard() { - input.requestFocus(); - - int numberLength = getText().length(); - input.setSelection(numberLength, numberLength); - - InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); - imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT); - - if (!imm.isAcceptingText()) { - imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.HIDE_IMPLICIT_ONLY); - } + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(input); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SelectionAwareEmojiEditText.java b/app/src/main/java/org/thoughtcrime/securesms/components/SelectionAwareEmojiEditText.java new file mode 100644 index 0000000000..0b7aa85777 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SelectionAwareEmojiEditText.java @@ -0,0 +1,45 @@ +package org.thoughtcrime.securesms.components; + +import android.content.Context; +import android.util.AttributeSet; + +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.components.emoji.EmojiEditText; + +/** + * Selection aware {@link EmojiEditText}. This view allows the developer to provide an + * {@link OnSelectionChangedListener} that will be notified when the selection is changed. + */ +public class SelectionAwareEmojiEditText extends EmojiEditText { + + private OnSelectionChangedListener onSelectionChangedListener; + + public SelectionAwareEmojiEditText(Context context) { + super(context); + } + + public SelectionAwareEmojiEditText(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SelectionAwareEmojiEditText(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public void setOnSelectionChangedListener(@Nullable OnSelectionChangedListener onSelectionChangedListener) { + this.onSelectionChangedListener = onSelectionChangedListener; + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + if (onSelectionChangedListener != null) { + onSelectionChangedListener.onSelectionChanged(selStart, selEnd); + } + super.onSelectionChanged(selStart, selEnd); + } + + public interface OnSelectionChangedListener { + void onSelectionChanged(int selStart, int selEnd); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 19c539474a..e53669c214 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -200,7 +200,7 @@ import org.thoughtcrime.securesms.messagerequests.MessageRequestState; import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel; import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView; import org.thoughtcrime.securesms.mms.AttachmentManager; -import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType; +import org.thoughtcrime.securesms.mms.SlideFactory.MediaType; import org.thoughtcrime.securesms.mms.AudioSlide; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.GifSlide; @@ -216,6 +216,7 @@ import org.thoughtcrime.securesms.mms.QuoteId; import org.thoughtcrime.securesms.mms.QuoteModel; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.SlideFactory; import org.thoughtcrime.securesms.mms.StickerSlide; import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.notifications.NotificationChannels; @@ -612,10 +613,10 @@ public class ConversationActivity extends PassphraseRequiredActivity switch (reqCode) { case PICK_DOCUMENT: - setMedia(data.getData(), MediaType.DOCUMENT); + setMedia(data.getData(), SlideFactory.MediaType.DOCUMENT); break; case PICK_AUDIO: - setMedia(data.getData(), MediaType.AUDIO); + setMedia(data.getData(), SlideFactory.MediaType.AUDIO); break; case PICK_CONTACT: if (isSecureText && !isSmsForced()) { @@ -655,7 +656,7 @@ public class ConversationActivity extends PassphraseRequiredActivity break; case PICK_GIF: setMedia(data.getData(), - MediaType.GIF, + SlideFactory.MediaType.GIF, data.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0), data.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0), data.getBooleanExtra(GiphyActivity.EXTRA_BORDERLESS, false)); @@ -766,7 +767,7 @@ public class ConversationActivity extends PassphraseRequiredActivity getContentResolver().delete(attachmentManager.getCaptureUri(), null, null); - setMedia(mediaUri, MediaType.IMAGE); + setMedia(mediaUri, SlideFactory.MediaType.IMAGE); } catch (IOException ioe) { Log.w(TAG, "Could not handle public image", ioe); } @@ -1470,7 +1471,7 @@ public class ConversationActivity extends PassphraseRequiredActivity final CharSequence draftText = args.getDraftText(); final Uri draftMedia = getIntent().getData(); final String draftContentType = getIntent().getType(); - final MediaType draftMediaType = MediaType.from(draftContentType); + final MediaType draftMediaType = SlideFactory.MediaType.from(draftContentType); final List mediaList = args.getMedia(); final StickerLocator stickerLocator = args.getStickerLocator(); final boolean borderless = args.isBorderless(); @@ -1652,13 +1653,13 @@ public class ConversationActivity extends PassphraseRequiredActivity attachmentManager.setLocation(SignalPlace.deserialize(draft.getValue()), getCurrentMediaConstraints()).addListener(listener); break; case Draft.IMAGE: - setMedia(Uri.parse(draft.getValue()), MediaType.IMAGE).addListener(listener); + setMedia(Uri.parse(draft.getValue()), SlideFactory.MediaType.IMAGE).addListener(listener); break; case Draft.AUDIO: - setMedia(Uri.parse(draft.getValue()), MediaType.AUDIO).addListener(listener); + setMedia(Uri.parse(draft.getValue()), SlideFactory.MediaType.AUDIO).addListener(listener); break; case Draft.VIDEO: - setMedia(Uri.parse(draft.getValue()), MediaType.VIDEO).addListener(listener); + setMedia(Uri.parse(draft.getValue()), SlideFactory.MediaType.VIDEO).addListener(listener); break; case Draft.QUOTE: SettableFuture quoteResult = new SettableFuture<>(); @@ -2335,10 +2336,10 @@ public class ConversationActivity extends PassphraseRequiredActivity return new SettableFuture<>(false); } - if (MediaType.VCARD.equals(mediaType) && isSecureText) { + if (SlideFactory.MediaType.VCARD.equals(mediaType) && isSecureText) { openContactShareEditor(uri); return new SettableFuture<>(false); - } else if (MediaType.IMAGE.equals(mediaType) || MediaType.GIF.equals(mediaType) || MediaType.VIDEO.equals(mediaType)) { + } else if (SlideFactory.MediaType.IMAGE.equals(mediaType) || SlideFactory.MediaType.GIF.equals(mediaType) || SlideFactory.MediaType.VIDEO.equals(mediaType)) { String mimeType = MediaUtil.getMimeType(this, uri); if (mimeType == null) { mimeType = mediaType.toFallbackMimeType(); @@ -3033,9 +3034,9 @@ public class ConversationActivity extends PassphraseRequiredActivity () -> getKeyboardImageDetails(uri), details -> sendKeyboardImage(uri, contentType, details)); } else if (MediaUtil.isVideoType(contentType)) { - setMedia(uri, MediaType.VIDEO); + setMedia(uri, SlideFactory.MediaType.VIDEO); } else if (MediaUtil.isAudioType(contentType)) { - setMedia(uri, MediaType.AUDIO); + setMedia(uri, SlideFactory.MediaType.AUDIO); } } @@ -3536,7 +3537,7 @@ public class ConversationActivity extends PassphraseRequiredActivity private void sendKeyboardImage(@NonNull Uri uri, @NonNull String contentType, @Nullable KeyboardImageDetails details) { if (details == null || !details.hasTransparency) { - setMedia(uri, Objects.requireNonNull(MediaType.from(contentType))); + setMedia(uri, Objects.requireNonNull(SlideFactory.MediaType.from(contentType))); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index f05582e221..671e6cb48b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -65,6 +65,7 @@ import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -962,6 +963,19 @@ public class ThreadDatabase extends Database { } } + public Map getThreadIdsIfExistsFor(@NonNull RecipientId ... recipientIds) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + SqlUtil.Query query = SqlUtil.buildCollectionQuery(RECIPIENT_ID, Arrays.asList(recipientIds)); + + Map results = new HashMap<>(); + try (Cursor cursor = db.query(TABLE_NAME, new String[]{ ID, RECIPIENT_ID }, query.getWhere(), query.getWhereArgs(), null, null, null, "1")) { + while (cursor != null && cursor.moveToNext()) { + results.put(RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID)), CursorUtil.requireLong(cursor, ID)); + } + } + return results; + } + public long getOrCreateValidThreadId(@NonNull Recipient recipient, long candidateId) { return getOrCreateValidThreadId(recipient, candidateId, DistributionTypes.DEFAULT); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 856019f4f2..1a244bba15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -111,11 +111,12 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med public static final String EXTRA_RESULT = "result"; - private static final String KEY_RECIPIENT = "recipient_id"; - private static final String KEY_BODY = "body"; - private static final String KEY_MEDIA = "media"; - private static final String KEY_TRANSPORT = "transport"; - private static final String KEY_IS_CAMERA = "is_camera"; + private static final String KEY_RECIPIENT = "recipient_id"; + private static final String KEY_RECIPIENTS = "recipient_ids"; + private static final String KEY_BODY = "body"; + private static final String KEY_MEDIA = "media"; + private static final String KEY_TRANSPORT = "transport"; + private static final String KEY_IS_CAMERA = "is_camera"; private static final String TAG_FOLDER_PICKER = "folder_picker"; private static final String TAG_ITEM_PICKER = "item_picker"; @@ -195,6 +196,20 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med return intent; } + public static Intent buildShareIntent(@NonNull Context context, + @NonNull List media, + @NonNull List recipientIds, + @NonNull CharSequence body, + @NonNull TransportOption transportOption) + { + Intent intent = new Intent(context, MediaSendActivity.class); + intent.putParcelableArrayListExtra(KEY_MEDIA, new ArrayList<>(media)); + intent.putExtra(KEY_TRANSPORT, transportOption); + intent.putExtra(KEY_BODY, body == null ? "" : body); + intent.putParcelableArrayListExtra(KEY_RECIPIENTS, new ArrayList<>(recipientIds)); + return intent; + } + @Override protected void attachBaseContext(@NonNull Context newBase) { getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); @@ -332,7 +347,18 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med initViewModel(); revealButton.setOnClickListener(v -> viewModel.onRevealButtonToggled()); - continueButton.setOnClickListener(v -> navigateToContactSelect()); + + List recipientIds = getIntent().getParcelableArrayListExtra(KEY_RECIPIENTS); + continueButton.setOnClickListener(v -> { + continueButton.setEnabled(false); + if (recipientIds == null || recipientIds.isEmpty()) { + navigateToContactSelect(); + } else { + SimpleTask.run(getLifecycle(), + () -> Stream.of(recipientIds).map(Recipient::resolved).toList(), + this::onCameraContactsSendClicked); + } + }); } @Override @@ -551,7 +577,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med SimpleProgressDialog.DismissibleDialog dialog = SimpleProgressDialog.showDelayed(this, 300, 0); viewModel.onSendClicked(buildModelsToTransform(fragment), recipients, composeText.getMentions()).observe(this, result -> { dialog.dismiss(); - finish(); + setActivityResultAndFinish(result); }); } else { throw new AssertionError("No editor fragment available!"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index 0bb2f7d90b..c8385b06ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -29,7 +29,6 @@ import android.net.Uri; import android.os.AsyncTask; import android.provider.ContactsContract; import android.provider.OpenableColumns; -import android.text.TextUtils; import android.util.Pair; import android.view.View; import android.widget.Toast; @@ -44,7 +43,6 @@ import org.thoughtcrime.securesms.MediaPreviewActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.attachments.Attachment; -import org.thoughtcrime.securesms.blurhash.BlurHash; import org.thoughtcrime.securesms.components.AudioView; import org.thoughtcrime.securesms.components.DocumentView; import org.thoughtcrime.securesms.components.RemovableEditableMediaView; @@ -223,7 +221,7 @@ public class AttachmentManager { @SuppressLint("StaticFieldLeak") public ListenableFuture setMedia(@NonNull final GlideRequests glideRequests, @NonNull final Uri uri, - @NonNull final MediaType mediaType, + @NonNull final SlideFactory.MediaType mediaType, @NonNull final MediaConstraints constraints, final int width, final int height) @@ -286,7 +284,7 @@ public class AttachmentManager { } else { Attachment attachment = slide.asAttachment(); result.deferTo(thumbnail.setImageResource(glideRequests, slide, false, true, attachment.getWidth(), attachment.getHeight())); - removableMediaView.display(thumbnail, mediaType == MediaType.IMAGE); + removableMediaView.display(thumbnail, mediaType == SlideFactory.MediaType.IMAGE); } attachmentListener.onAttachmentChanged(); @@ -480,58 +478,4 @@ public class AttachmentManager { void onAttachmentChanged(); } - public enum MediaType { - IMAGE(MediaUtil.IMAGE_JPEG), - GIF(MediaUtil.IMAGE_GIF), - AUDIO(MediaUtil.AUDIO_AAC), - VIDEO(MediaUtil.VIDEO_MP4), - DOCUMENT(MediaUtil.UNKNOWN), - VCARD(MediaUtil.VCARD); - - private final String fallbackMimeType; - - MediaType(String fallbackMimeType) { - this.fallbackMimeType = fallbackMimeType; - } - - - public @NonNull Slide createSlide(@NonNull Context context, - @NonNull Uri uri, - @Nullable String fileName, - @Nullable String mimeType, - @Nullable BlurHash blurHash, - long dataSize, - int width, - int height) - { - if (mimeType == null) { - mimeType = "application/octet-stream"; - } - - switch (this) { - case IMAGE: return new ImageSlide(context, uri, dataSize, width, height, blurHash); - case GIF: return new GifSlide(context, uri, dataSize, width, height); - case AUDIO: return new AudioSlide(context, uri, dataSize, false); - case VIDEO: return new VideoSlide(context, uri, dataSize); - case VCARD: - case DOCUMENT: return new DocumentSlide(context, uri, mimeType, dataSize, fileName); - default: throw new AssertionError("unrecognized enum"); - } - } - - public static @Nullable MediaType from(final @Nullable String mimeType) { - if (TextUtils.isEmpty(mimeType)) return null; - if (MediaUtil.isGif(mimeType)) return GIF; - if (MediaUtil.isImageType(mimeType)) return IMAGE; - if (MediaUtil.isAudioType(mimeType)) return AUDIO; - if (MediaUtil.isVideoType(mimeType)) return VIDEO; - if (MediaUtil.isVcard(mimeType)) return VCARD; - - return DOCUMENT; - } - - public String toFallbackMimeType() { - return fallbackMimeType; - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java new file mode 100644 index 0000000000..423e6fb22a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/SlideFactory.java @@ -0,0 +1,174 @@ +package org.thoughtcrime.securesms.mms; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.text.TextUtils; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.blurhash.BlurHash; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.IOException; + +/** + * SlideFactory encapsulates logic related to constructing slides from a set of paramaeters as defined + * by {@link SlideFactory#getSlide}. + */ +public final class SlideFactory { + + private static final String TAG = Log.tag(SlideFactory.class); + + private SlideFactory() { + } + + /** + * Generates a slide from the given parameters. + * + * @param context Application context + * @param contentType The contentType of the given Uri + * @param uri The Uri pointing to the resource to create a slide out of + * @param width (Optional) width, can be 0. + * @param height (Optional) height, can be 0. + * + * @return A Slide with all the information we can gather about it. + */ + @WorkerThread + public static @Nullable Slide getSlide(@NonNull Context context, @Nullable String contentType, @NonNull Uri uri, int width, int height) { + MediaType mediaType = MediaType.from(contentType); + + try { + if (PartAuthority.isLocalUri(uri)) { + return getManuallyCalculatedSlideInfo(context, mediaType, uri, width, height); + } else { + Slide result = getContentResolverSlideInfo(context, mediaType, uri, width, height); + + if (result == null) return getManuallyCalculatedSlideInfo(context, mediaType, uri, width, height); + else return result; + } + } catch (IOException e) { + Log.w(TAG, e); + return null; + } + } + + private static @Nullable Slide getContentResolverSlideInfo(@NonNull Context context, @NonNull MediaType mediaType, @NonNull Uri uri, int width, int height) { + Cursor cursor = null; + long start = System.currentTimeMillis(); + + try { + cursor = context.getContentResolver().query(uri, null, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + String fileName = cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)); + long fileSize = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + String mimeType = context.getContentResolver().getType(uri); + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } + + Log.d(TAG, "remote slide with size " + fileSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, fileName, mimeType, null, fileSize, width, height); + } + } finally { + if (cursor != null) cursor.close(); + } + + return null; + } + + private static @NonNull Slide getManuallyCalculatedSlideInfo(@NonNull Context context, @NonNull MediaType mediaType, @NonNull Uri uri, int width, int height) throws IOException { + long start = System.currentTimeMillis(); + Long mediaSize = null; + String fileName = null; + String mimeType = null; + + if (PartAuthority.isLocalUri(uri)) { + mediaSize = PartAuthority.getAttachmentSize(context, uri); + fileName = PartAuthority.getAttachmentFileName(context, uri); + mimeType = PartAuthority.getAttachmentContentType(context, uri); + } + + if (mediaSize == null) { + mediaSize = MediaUtil.getMediaSize(context, uri); + } + + if (mimeType == null) { + mimeType = MediaUtil.getMimeType(context, uri); + } + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, mimeType, uri); + width = dimens.first; + height = dimens.second; + } + + Log.d(TAG, "local slide with size " + mediaSize + " took " + (System.currentTimeMillis() - start) + "ms"); + return mediaType.createSlide(context, uri, fileName, mimeType, null, mediaSize, width, height); + } + + public enum MediaType { + + IMAGE(MediaUtil.IMAGE_JPEG), + GIF(MediaUtil.IMAGE_GIF), + AUDIO(MediaUtil.AUDIO_AAC), + VIDEO(MediaUtil.VIDEO_MP4), + DOCUMENT(MediaUtil.UNKNOWN), + VCARD(MediaUtil.VCARD); + + private final String fallbackMimeType; + + MediaType(String fallbackMimeType) { + this.fallbackMimeType = fallbackMimeType; + } + + + public @NonNull Slide createSlide(@NonNull Context context, + @NonNull Uri uri, + @Nullable String fileName, + @Nullable String mimeType, + @Nullable BlurHash blurHash, + long dataSize, + int width, + int height) + { + if (mimeType == null) { + mimeType = "application/octet-stream"; + } + + switch (this) { + case IMAGE: return new ImageSlide(context, uri, dataSize, width, height, blurHash); + case GIF: return new GifSlide(context, uri, dataSize, width, height); + case AUDIO: return new AudioSlide(context, uri, dataSize, false); + case VIDEO: return new VideoSlide(context, uri, dataSize); + case VCARD: + case DOCUMENT: return new DocumentSlide(context, uri, mimeType, dataSize, fileName); + default: throw new AssertionError("unrecognized enum"); + } + } + + public static @Nullable MediaType from(final @Nullable String mimeType) { + if (TextUtils.isEmpty(mimeType)) return null; + if (MediaUtil.isGif(mimeType)) return GIF; + if (MediaUtil.isImageType(mimeType)) return IMAGE; + if (MediaUtil.isAudioType(mimeType)) return AUDIO; + if (MediaUtil.isVideoType(mimeType)) return VIDEO; + if (MediaUtil.isVcard(mimeType)) return VCARD; + + return DOCUMENT; + } + + public String toFallbackMimeType() { + return fallbackMimeType; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index a617bbd0e8..9d787ef14b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -499,6 +499,15 @@ public class Recipient { return StringUtil.isolateBidi(name); } + public @NonNull String getShortDisplayNameIncludingUsername(@NonNull Context context) { + String name = Util.getFirstNonEmpty(getName(context), + getProfileName().getGivenName(), + getDisplayName(context), + getUsername().orNull()); + + return StringUtil.isolateBidi(name); + } + public @NonNull MaterialColor getColor() { if (isGroupInternal()) { return MaterialColor.GROUP; diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/InterstitialContentType.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/InterstitialContentType.java new file mode 100644 index 0000000000..09528fdd55 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/InterstitialContentType.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.sharing; + +public enum InterstitialContentType { + MEDIA, + TEXT, + NONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java new file mode 100644 index 0000000000..a709646bab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java @@ -0,0 +1,235 @@ +package org.thoughtcrime.securesms.sharing; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +public final class MultiShareArgs implements Parcelable { + + private static final String ARGS = "ShareInterstitialArgs"; + + private final Set shareContactAndThreads; + private final ArrayList media; + private final String draftText; + private final StickerLocator stickerLocator; + private final boolean borderless; + private final Uri dataUri; + private final String dataType; + private final boolean viewOnce; + private final LinkPreview linkPreview; + + private MultiShareArgs(@NonNull Builder builder) { + shareContactAndThreads = builder.shareContactAndThreads; + media = builder.media == null ? new ArrayList<>() : builder.media; + draftText = builder.draftText; + stickerLocator = builder.stickerLocator; + borderless = builder.borderless; + dataUri = builder.dataUri; + dataType = builder.dataType; + viewOnce = builder.viewOnce; + linkPreview = builder.linkPreview; + } + + protected MultiShareArgs(Parcel in) { + shareContactAndThreads = new HashSet<>(in.createTypedArrayList(ShareContactAndThread.CREATOR)); + media = in.createTypedArrayList(Media.CREATOR); + draftText = in.readString(); + stickerLocator = in.readParcelable(StickerLocator.class.getClassLoader()); + borderless = in.readByte() != 0; + dataUri = in.readParcelable(Uri.class.getClassLoader()); + dataType = in.readString(); + viewOnce = in.readByte() != 0; + + LinkPreview preview; + try { + preview = LinkPreview.deserialize(in.readString()); + } catch (IOException e) { + preview = null; + } + + linkPreview = preview; + } + + public Set getShareContactAndThreads() { + return shareContactAndThreads; + } + + public ArrayList getMedia() { + return media; + } + + public StickerLocator getStickerLocator() { + return stickerLocator; + } + + public String getDataType() { + return dataType; + } + + public String getDraftText() { + return draftText; + } + + public Uri getDataUri() { + return dataUri; + } + + public boolean isBorderless() { + return borderless; + } + + public boolean isViewOnce() { + return viewOnce; + } + + public @Nullable LinkPreview getLinkPreview() { + return linkPreview; + } + + public @NonNull InterstitialContentType getInterstitialContentType() { + if (!requiresInterstitial()) { + return InterstitialContentType.NONE; + } else if (!this.getMedia().isEmpty() || + (this.getDataUri() != null && this.getDataUri() != Uri.EMPTY && this.getDataType() != null)) + { + return InterstitialContentType.MEDIA; + } else if (!TextUtils.isEmpty(this.getDraftText())) { + return InterstitialContentType.TEXT; + } else { + return InterstitialContentType.NONE; + } + } + + + public static final Creator CREATOR = new Creator() { + @Override + public MultiShareArgs createFromParcel(Parcel in) { + return new MultiShareArgs(in); + } + + @Override + public MultiShareArgs[] newArray(int size) { + return new MultiShareArgs[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeTypedList(Stream.of(shareContactAndThreads).toList()); + dest.writeTypedList(media); + dest.writeString(draftText); + dest.writeParcelable(stickerLocator, flags); + dest.writeByte((byte) (borderless ? 1 : 0)); + dest.writeParcelable(dataUri, flags); + dest.writeString(dataType); + dest.writeByte((byte) (viewOnce ? 1 : 0)); + + if (linkPreview != null) { + try { + dest.writeString(linkPreview.serialize()); + } catch (IOException e) { + dest.writeString(""); + } + } else { + dest.writeString(""); + } + } + + public Builder buildUpon() { + return new Builder(shareContactAndThreads).asBorderless(borderless) + .asViewOnce(viewOnce) + .withDataType(dataType) + .withDataUri(dataUri) + .withDraftText(draftText) + .withLinkPreview(linkPreview) + .withMedia(media) + .withStickerLocator(stickerLocator); + } + + private boolean requiresInterstitial() { + return !media.isEmpty() || !TextUtils.isEmpty(draftText) || MediaUtil.isImageOrVideoType(dataType); + } + + public static final class Builder { + + private final Set shareContactAndThreads; + + private ArrayList media; + private String draftText; + private StickerLocator stickerLocator; + private boolean borderless; + private Uri dataUri; + private String dataType; + private LinkPreview linkPreview; + private boolean viewOnce; + + public Builder(@NonNull Set shareContactAndThreads) { + this.shareContactAndThreads = shareContactAndThreads; + } + + public @NonNull Builder withMedia(@Nullable ArrayList media) { + this.media = media; + return this; + } + + public @NonNull Builder withDraftText(@Nullable String draftText) { + this.draftText = draftText; + return this; + } + + public @NonNull Builder withStickerLocator(@Nullable StickerLocator stickerLocator) { + this.stickerLocator = stickerLocator; + return this; + } + + public @NonNull Builder asBorderless(boolean borderless) { + this.borderless = borderless; + return this; + } + + public @NonNull Builder withDataUri(@Nullable Uri dataUri) { + this.dataUri = dataUri; + return this; + } + + public @NonNull Builder withDataType(@Nullable String dataType) { + this.dataType = dataType; + return this; + } + + public @NonNull Builder withLinkPreview(@Nullable LinkPreview linkPreview) { + this.linkPreview = linkPreview; + return this; + } + + public @NonNull Builder asViewOnce(boolean viewOnce) { + this.viewOnce = viewOnce; + return this; + } + + public @NonNull MultiShareArgs build() { + return new MultiShareArgs(this); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareDialogs.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareDialogs.java new file mode 100644 index 0000000000..9c181d4e5a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareDialogs.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.sharing; + +import android.app.AlertDialog; +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; + +public final class MultiShareDialogs { + private MultiShareDialogs() { + } + + public static void displayResultDialog(@NonNull Context context, + @NonNull MultiShareSender.MultiShareSendResultCollection resultCollection, + @NonNull Runnable onDismiss) + { + if (resultCollection.containsFailures()) { + displayFailuresDialog(context, onDismiss); + } else { + onDismiss.run(); + } + } + + public static void displayMaxSelectedDialog(@NonNull Context context, int hardLimit) { + new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.MultiShareDialogs__you_can_only_share_with_up_to, hardLimit)) + .setPositiveButton(android.R.string.ok, ((dialog, which) -> dialog.dismiss())) + .setCancelable(true) + .show(); + } + + private static void displayFailuresDialog(@NonNull Context context, + @NonNull Runnable onDismiss) + { + new AlertDialog.Builder(context) + .setMessage(R.string.MultiShareDialogs__failed_to_send_to_some_users) + .setPositiveButton(android.R.string.ok, ((dialog, which) -> dialog.dismiss())) + .setOnDismissListener(dialog -> onDismiss.run()) + .setCancelable(true) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java new file mode 100644 index 0000000000..0d85ed516d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -0,0 +1,230 @@ +package org.thoughtcrime.securesms.sharing; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.TransportOptions; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.StickerDatabase; +import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.SlideFactory; +import org.thoughtcrime.securesms.mms.StickerSlide; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientFormattingException; +import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.sms.OutgoingTextMessage; +import org.thoughtcrime.securesms.stickers.StickerLocator; +import org.thoughtcrime.securesms.util.MessageUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * MultiShareSender encapsulates send logic (stolen from {@link org.thoughtcrime.securesms.conversation.ConversationActivity} + * and provides a means to: + * + * 1. Send messages based off a {@link MultiShareArgs} object and + * 1. Parse through the result of the send via a {@link MultiShareSendResultCollection} + */ +public final class MultiShareSender { + + private static final String TAG = Log.tag(MultiShareSender.class); + + private MultiShareSender() { + } + + @MainThread + public static void send(@NonNull MultiShareArgs multiShareArgs, @NonNull Consumer results) { + SimpleTask.run(() -> sendInternal(multiShareArgs), results::accept); + } + + @WorkerThread + private static MultiShareSendResultCollection sendInternal(@NonNull MultiShareArgs multiShareArgs) { + Context context = ApplicationDependencies.getApplication(); + boolean isMmsEnabled = Util.isMmsCapable(context); + String message = multiShareArgs.getDraftText(); + SlideDeck slideDeck = buildSlideDeck(context, multiShareArgs); + + List results = new ArrayList<>(multiShareArgs.getShareContactAndThreads().size()); + + for (ShareContactAndThread shareContactAndThread : multiShareArgs.getShareContactAndThreads()) { + Recipient recipient = Recipient.resolved(shareContactAndThread.getRecipientId()); + + TransportOption transport = resolveTransportOption(context, recipient); + boolean forceSms = recipient.isForceSmsSelection() && transport.isSms(); + int subscriptionId = transport.getSimSubscriptionId().or(-1); + long expiresIn = recipient.getExpireMessages() * 1000L; + boolean needsSplit = !transport.isSms() && message.length() > transport.calculateCharacters(message).maxPrimaryMessageSize; + boolean isMediaMessage = !multiShareArgs.getMedia().isEmpty() || + (multiShareArgs.getDataUri() != null && multiShareArgs.getDataUri() != Uri.EMPTY) || + multiShareArgs.getStickerLocator() != null || + multiShareArgs.getLinkPreview() != null || + recipient.isGroup() || + recipient.getEmail().isPresent() || + needsSplit; + + if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !isMmsEnabled) { + results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.MMS_NOT_ENABLED)); + } else if (isMediaMessage) { + sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, shareContactAndThread.getThreadId(), forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId); + results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); + } else { + sendTextMessage(context, multiShareArgs, recipient, shareContactAndThread.getThreadId() ,forceSms, expiresIn, subscriptionId); + results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS)); + } + } + + return new MultiShareSendResultCollection(results); + } + + public static @NonNull TransportOption getWorseTransportOption(@NonNull Context context, @NonNull Set shareContactAndThreads) { + for (ShareContactAndThread shareContactAndThread : shareContactAndThreads) { + TransportOption option = resolveTransportOption(context, shareContactAndThread.isForceSms()); + if (option.isSms()) { + return option; + } + } + + return TransportOptions.getPushTransportOption(context); + } + + private static @NonNull TransportOption resolveTransportOption(@NonNull Context context, @NonNull Recipient recipient) { + return resolveTransportOption(context, recipient.isForceSmsSelection() || !recipient.isRegistered()); + } + + public static @NonNull TransportOption resolveTransportOption(@NonNull Context context, boolean forceSms) { + if (forceSms) { + TransportOptions options = new TransportOptions(context, false); + options.setDefaultTransport(TransportOption.Type.SMS); + return options.getSelectedTransport(); + } else { + return TransportOptions.getPushTransportOption(context); + } + } + + private static void sendMediaMessage(@NonNull Context context, + @NonNull MultiShareArgs multiShareArgs, + @NonNull Recipient recipient, + @NonNull SlideDeck slideDeck, + @NonNull TransportOption transportOption, + long threadId, + boolean forceSms, + long expiresIn, + boolean isViewOnce, + int subscriptionId) + { + String body = multiShareArgs.getDraftText(); + if (transportOption.isType(TransportOption.Type.TEXTSECURE) && !forceSms) { + MessageUtil.SplitResult splitMessage = MessageUtil.getSplitMessage(context, body, transportOption.calculateCharacters(body).maxPrimaryMessageSize); + body = splitMessage.getBody(); + + if (splitMessage.getTextSlide().isPresent()) { + slideDeck.addSlide(splitMessage.getTextSlide().get()); + } + } + + OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, + slideDeck, + body, + System.currentTimeMillis(), + subscriptionId, + expiresIn, + isViewOnce, + ThreadDatabase.DistributionTypes.DEFAULT, + null, + Collections.emptyList(), + multiShareArgs.getLinkPreview() != null ? Collections.singletonList(multiShareArgs.getLinkPreview()) + : Collections.emptyList(), + Collections.emptyList()); + + MessageSender.send(context, outgoingMediaMessage, threadId, forceSms, null); + } + + private static void sendTextMessage(@NonNull Context context, + @NonNull MultiShareArgs multiShareArgs, + @NonNull Recipient recipient, + long threadId, + boolean forceSms, + long expiresIn, + int subscriptionId) + { + OutgoingTextMessage outgoingTextMessage = new OutgoingTextMessage(recipient, multiShareArgs.getDraftText(), expiresIn, subscriptionId); + + MessageSender.send(context, outgoingTextMessage, threadId, forceSms, null); + } + + private static @NonNull SlideDeck buildSlideDeck(@NonNull Context context, @NonNull MultiShareArgs multiShareArgs) { + SlideDeck slideDeck = new SlideDeck(); + if (multiShareArgs.getStickerLocator() != null) { + slideDeck.addSlide(buildStickerSlide(context, multiShareArgs.getStickerLocator())); + } else if (!multiShareArgs.getMedia().isEmpty()) { + for (Media media : multiShareArgs.getMedia()) { + slideDeck.addSlide(SlideFactory.getSlide(context, media.getMimeType(), media.getUri(), media.getWidth(), media.getHeight())); + } + } else if (multiShareArgs.getDataUri() != null) { + slideDeck.addSlide(SlideFactory.getSlide(context, multiShareArgs.getDataType(), multiShareArgs.getDataUri(), 0, 0)); + } + + return slideDeck; + } + + private static @NonNull StickerSlide buildStickerSlide(@NonNull Context context, @NonNull StickerLocator stickerLocator) { + StickerDatabase stickerDatabase = DatabaseFactory.getStickerDatabase(context); + StickerRecord stickerRecord = stickerDatabase.getSticker(stickerLocator.getPackId(), stickerLocator.getStickerId(), false); + + return new StickerSlide(context, stickerRecord.getUri(), stickerRecord.getSize(), stickerLocator, stickerRecord.getContentType()); + } + + public static final class MultiShareSendResultCollection { + private final List results; + + private MultiShareSendResultCollection(List results) { + this.results = results; + } + + public boolean containsFailures() { + return Stream.of(results).anyMatch(result -> result.type != MultiShareSendResult.Type.SUCCESS); + } + } + + private static final class MultiShareSendResult { + private final ShareContactAndThread contactAndThread; + private final Type type; + + private MultiShareSendResult(ShareContactAndThread contactAndThread, Type type) { + this.contactAndThread = contactAndThread; + this.type = type; + } + + public ShareContactAndThread getContactAndThread() { + return contactAndThread; + } + + public Type getType() { + return type; + } + + private enum Type { + MMS_NOT_ENABLED, + SUCCESS + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java index 0d625d23bd..fcd8a68460 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java @@ -27,11 +27,19 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.Toolbar; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.core.util.Consumer; import androidx.lifecycle.ViewModelProviders; -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.recyclerview.widget.RecyclerView; +import androidx.transition.TransitionManager; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.ContactSelectionListFragment; @@ -41,19 +49,25 @@ import org.thoughtcrime.securesms.components.SearchToolbar; import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; import org.thoughtcrime.securesms.conversation.ConversationIntents; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mediasend.MediaSendActivity; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sharing.interstitial.ShareInterstitialActivity; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; -import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicReference; /** @@ -63,10 +77,14 @@ import java.util.concurrent.atomic.AtomicReference; * is known (such as choosing someone in a direct share). */ public class ShareActivity extends PassphraseRequiredActivity - implements ContactSelectionListFragment.OnContactSelectedListener, SwipeRefreshLayout.OnRefreshListener + implements ContactSelectionListFragment.OnContactSelectedListener, + ContactSelectionListFragment.OnSelectionLimitReachedListener { private static final String TAG = ShareActivity.class.getSimpleName(); + private static final short RESULT_TEXT_CONFIRMATION = 1; + private static final short RESULT_MEDIA_CONFIRMATION = 2; + public static final String EXTRA_THREAD_ID = "thread_id"; public static final String EXTRA_RECIPIENT_ID = "recipient_id"; public static final String EXTRA_DISTRIBUTION_TYPE = "distribution_type"; @@ -74,9 +92,12 @@ public class ShareActivity extends PassphraseRequiredActivity private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + private ConstraintLayout shareContainer; private ContactSelectionListFragment contactsFragment; private SearchToolbar searchToolbar; private ImageView searchAction; + private View shareConfirm; + private ShareSelectionAdapter adapter; private ShareViewModel viewModel; @@ -88,31 +109,14 @@ public class ShareActivity extends PassphraseRequiredActivity @Override protected void onCreate(Bundle icicle, boolean ready) { - if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { - int mode = DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_SELF; - - if (TextSecurePreferences.isSmsEnabled(this)) { - mode |= DisplayMode.FLAG_SMS; - } - - if (FeatureFlags.groupsV1ForcedMigration()) { - mode |= DisplayMode.FLAG_HIDE_GROUPS_V1; - } - - getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, mode); - } - - getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); - getIntent().putExtra(ContactSelectionListFragment.RECENTS, true); - setContentView(R.layout.share_activity); + initializeViewModel(); + initializeMedia(); + initializeIntent(); initializeToolbar(); initializeResources(); initializeSearch(); - initializeViewModel(); - initializeMedia(); - handleDestination(); } @@ -128,7 +132,7 @@ public class ShareActivity extends PassphraseRequiredActivity public void onStop() { super.onStop(); - if (!isFinishing()) { + if (!isFinishing() && !viewModel.isMultiShare()) { finish(); } } @@ -149,31 +153,75 @@ public class ShareActivity extends PassphraseRequiredActivity else super.onBackPressed(); } + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + if (resultCode == RESULT_OK) { + switch (requestCode) { + case RESULT_MEDIA_CONFIRMATION: + case RESULT_TEXT_CONFIRMATION: + viewModel.onSuccessulShare(); + finish(); + break; + default: + super.onActivityResult(requestCode, resultCode, data); + } + } else { + shareConfirm.setClickable(true); + super.onActivityResult(requestCode, resultCode, data); + } + } @Override public boolean onBeforeContactSelected(Optional recipientId, String number) { - SimpleTask.run(this.getLifecycle(), () -> { - Recipient recipient; - if (recipientId.isPresent()) { - recipient = Recipient.resolved(recipientId.get()); - } else { - Log.i(TAG, "[onContactSelected] Maybe creating a new recipient."); - recipient = Recipient.external(this, number); - } - - long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()); - return new Pair<>(existingThread, recipient); - }, result -> onDestinationChosen(result.first(), result.second().getId())); - - return true; + return viewModel.onContactSelected(new ShareContact(recipientId, number)); } @Override public void onContactDeselected(@NonNull Optional recipientId, String number) { + viewModel.onContactDeselected(new ShareContact(recipientId, number)); } - @Override - public void onRefresh() { + private void animateInSelection() { + TransitionManager.endTransitions(shareContainer); + TransitionManager.beginDelayedTransition(shareContainer); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(shareContainer); + constraintSet.setVisibility(R.id.selection_group, ConstraintSet.VISIBLE); + constraintSet.applyTo(shareContainer); + } + + private void animateOutSelection() { + TransitionManager.endTransitions(shareContainer); + TransitionManager.beginDelayedTransition(shareContainer); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(shareContainer); + constraintSet.setVisibility(R.id.selection_group, ConstraintSet.GONE); + constraintSet.applyTo(shareContainer); + } + + private void initializeIntent() { + if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) { + int mode = DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_SELF; + + if (TextSecurePreferences.isSmsEnabled(this) && viewModel.isExternalShare()) { + mode |= DisplayMode.FLAG_SMS; + } + + if (FeatureFlags.groupsV1ForcedMigration()) { + mode |= DisplayMode.FLAG_HIDE_GROUPS_V1; + } + + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, mode); + } + + getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false); + getIntent().putExtra(ContactSelectionListFragment.RECENTS, true); + getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, FeatureFlags.shareSelectionLimit()); + getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true); + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_CHIPS, false); + getIntent().putExtra(ContactSelectionListFragment.CAN_SELECT_SELF, true); } private void initializeToolbar() { @@ -190,14 +238,37 @@ public class ShareActivity extends PassphraseRequiredActivity private void initializeResources() { searchToolbar = findViewById(R.id.search_toolbar); searchAction = findViewById(R.id.search_action); - contactsFragment = (ContactSelectionListFragment) getSupportFragmentManager().findFragmentById(R.id.contact_selection_list_fragment); + shareConfirm = findViewById(R.id.share_confirm); + shareContainer = findViewById(R.id.container); + contactsFragment = new ContactSelectionListFragment(); + adapter = new ShareSelectionAdapter(); - if (contactsFragment == null) { - throw new IllegalStateException("Could not find contacts fragment!"); - } + RecyclerView contactsRecycler = findViewById(R.id.selected_list); + contactsRecycler.setAdapter(adapter); - contactsFragment.setOnContactSelectedListener(this); - contactsFragment.setOnRefreshListener(this); + getSupportFragmentManager().beginTransaction() + .replace(R.id.contact_selection_list_fragment, contactsFragment) + .commit(); + + shareConfirm.setOnClickListener(unused -> { + Set shareContacts = viewModel.getShareContacts(); + + if (shareContacts.isEmpty()) throw new AssertionError(); + else if (shareContacts.size() == 1) onConfirmSingleDestination(shareContacts.iterator().next()); + else onConfirmMultipleDestinations(shareContacts); + }); + + viewModel.getSelectedContactModels().observe(this, models -> { + adapter.submitList(models, () -> contactsRecycler.scrollToPosition(models.size() - 1)); + + shareConfirm.setEnabled(!models.isEmpty()); + shareConfirm.setAlpha(models.isEmpty() ? 0.5f : 1f); + if (models.isEmpty()) { + animateOutSelection(); + } else { + animateInSelection(); + } + }); } private void initializeViewModel() { @@ -260,16 +331,71 @@ public class ShareActivity extends PassphraseRequiredActivity if (contactsFragment.getView() != null) { contactsFragment.getView().setVisibility(View.GONE); } - onDestinationChosen(threadId, recipientId); + onSingleDestinationChosen(threadId, recipientId); + } else if (viewModel.isExternalShare()) { + validateAvailableRecipients(); } } - private void onDestinationChosen(long threadId, @NonNull RecipientId recipientId) { - if (!viewModel.isExternalShare()) { - openConversation(threadId, recipientId, null); - return; + private void onConfirmSingleDestination(@NonNull ShareContact shareContact) { + shareConfirm.setClickable(false); + SimpleTask.run(this.getLifecycle(), + () -> resolveShareContact(shareContact), + result -> onSingleDestinationChosen(result.getThreadId(), result.getRecipientId())); + } + + private void onConfirmMultipleDestinations(@NonNull Set shareContacts) { + shareConfirm.setClickable(false); + SimpleTask.run(this.getLifecycle(), + () -> resolvedShareContacts(shareContacts), + this::onMultipleDestinationsChosen); + } + + private Set resolvedShareContacts(@NonNull Set sharedContacts) { + Set recipients = Stream.of(sharedContacts) + .map(contact -> contact.getRecipientId() + .transform(Recipient::resolved) + .or(() -> Recipient.external(this, contact.getNumber()))) + .collect(Collectors.toSet()); + + Map existingThreads = DatabaseFactory.getThreadDatabase(this) + .getThreadIdsIfExistsFor(Stream.of(recipients) + .map(Recipient::getId) + .toArray(RecipientId[]::new)); + + return Stream.of(recipients) + .map(recipient -> new ShareContactAndThread(recipient.getId(), Util.getOrDefault(existingThreads, recipient.getId(), -1L), recipient.isForceSmsSelection() || !recipient.isRegistered())) + .collect(Collectors.toSet()); + } + + @WorkerThread + private ShareContactAndThread resolveShareContact(@NonNull ShareContact shareContact) { + Recipient recipient; + if (shareContact.getRecipientId().isPresent()) { + recipient = Recipient.resolved(shareContact.getRecipientId().get()); + } else { + Log.i(TAG, "[onContactSelected] Maybe creating a new recipient."); + recipient = Recipient.external(this, shareContact.getNumber()); } + long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId()); + return new ShareContactAndThread(recipient.getId(), existingThread, recipient.isForceSmsSelection() || !recipient.isRegistered()); + } + + private void validateAvailableRecipients() { + resolveShareData(data -> { + int mode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1); + + if (mode == -1) return; + + mode = data.isMmsOrSmsSupported() ? mode | DisplayMode.FLAG_SMS : mode & ~DisplayMode.FLAG_SMS; + getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, mode); + + contactsFragment.reset(); + }); + } + + private void resolveShareData(@NonNull Consumer onResolved) { AtomicReference progressWheel = new AtomicReference<>(); if (viewModel.getShareData().getValue() == null) { @@ -291,10 +417,28 @@ public class ShareActivity extends PassphraseRequiredActivity return; } - openConversation(threadId, recipientId, data.get()); + onResolved.accept(data.get()); }); } + private void onMultipleDestinationsChosen(@NonNull Set shareContactAndThreads) { + if (!viewModel.isExternalShare()) { + openInterstitial(shareContactAndThreads, null); + return; + } + + resolveShareData(data -> openInterstitial(shareContactAndThreads, data)); + } + + private void onSingleDestinationChosen(long threadId, @NonNull RecipientId recipientId) { + if (!viewModel.isExternalShare()) { + openConversation(threadId, recipientId, null); + return; + } + + resolveShareData(data -> openConversation(threadId, recipientId, data)); + } + private void openConversation(long threadId, @NonNull RecipientId recipientId, @Nullable ShareData shareData) { ShareIntents.Args args = ShareIntents.Args.from(getIntent()); ConversationIntents.Builder builder = ConversationIntents.createBuilder(this, recipientId, threadId) @@ -322,4 +466,77 @@ public class ShareActivity extends PassphraseRequiredActivity startActivity(builder.build()); } + + private void openInterstitial(@NonNull Set shareContactAndThreads, @Nullable ShareData shareData) { + ShareIntents.Args args = ShareIntents.Args.from(getIntent()); + MultiShareArgs.Builder builder = new MultiShareArgs.Builder(shareContactAndThreads) + .withMedia(args.getExtraMedia()) + .withDraftText(args.getExtraText() != null ? args.getExtraText().toString() : null) + .withStickerLocator(args.getExtraSticker()) + .asBorderless(args.isBorderless()); + + if (shareData != null && shareData.isForIntent()) { + Log.i(TAG, "Shared data is a single file."); + builder.withDataUri(shareData.getUri()) + .withDataType(shareData.getMimeType()); + } else if (shareData != null && shareData.isForMedia()) { + Log.i(TAG, "Shared data is set of media."); + builder.withMedia(shareData.getMedia()); + } else if (shareData != null && shareData.isForPrimitive()) { + Log.i(TAG, "Shared data is a primitive type."); + } else if (shareData == null && args.getExtraSticker() != null) { + builder.withDataType(getIntent().getType()); + } else { + Log.i(TAG, "Shared data was not external."); + } + + MultiShareArgs multiShareArgs = builder.build(); + InterstitialContentType interstitialContentType = multiShareArgs.getInterstitialContentType(); + switch (interstitialContentType) { + case TEXT: + startActivityForResult(ShareInterstitialActivity.createIntent(this, multiShareArgs), RESULT_TEXT_CONFIRMATION); + break; + case MEDIA: + List media = new ArrayList<>(multiShareArgs.getMedia()); + if (media.isEmpty()) { + media.add(new Media(multiShareArgs.getDataUri(), + multiShareArgs.getDataType(), + 0, + 0, + 0, + 0, + 0, + false, + Optional.absent(), + Optional.absent(), + Optional.absent())); + } + + startActivityForResult(MediaSendActivity.buildShareIntent(this, + media, + Stream.of(multiShareArgs.getShareContactAndThreads()).map(ShareContactAndThread::getRecipientId).toList(), + multiShareArgs.getDraftText(), + MultiShareSender.getWorseTransportOption(this, multiShareArgs.getShareContactAndThreads())), + RESULT_MEDIA_CONFIRMATION); + break; + default: + //noinspection CodeBlock2Expr + MultiShareSender.send(multiShareArgs, results -> { + MultiShareDialogs.displayResultDialog(this, results, () -> { + viewModel.onSuccessulShare(); + finish(); + }); + }); + break; + } + } + + @Override + public void onSuggestedLimitReached(int limit) { + } + + @Override + public void onHardLimitReached(int limit) { + MultiShareDialogs.displayMaxSelectedDialog(this, limit); + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java new file mode 100644 index 0000000000..e3776ba452 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContact.java @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.sharing; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Objects; + +final class ShareContact { + private final Optional recipientId; + private final String number; + + ShareContact(@NonNull Optional recipientId, @Nullable String number) { + this.recipientId = recipientId; + this.number = number; + } + + public Optional getRecipientId() { + return recipientId; + } + + public String getNumber() { + return number; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ShareContact that = (ShareContact) o; + return recipientId.equals(that.recipientId) && + Objects.equals(number, that.number); + } + + @Override + public int hashCode() { + return Objects.hash(recipientId, number); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java new file mode 100644 index 0000000000..2408067d22 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareContactAndThread.java @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.sharing; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Objects; + +public final class ShareContactAndThread implements Parcelable { + private final RecipientId recipientId; + private final long threadId; + private final boolean forceSms; + + ShareContactAndThread(@NonNull RecipientId recipientId, long threadId, boolean forceSms) { + this.recipientId = recipientId; + this.threadId = threadId; + this.forceSms = forceSms; + } + + protected ShareContactAndThread(@NonNull Parcel in) { + recipientId = in.readParcelable(RecipientId.class.getClassLoader()); + threadId = in.readLong(); + forceSms = in.readByte() == 1; + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public long getThreadId() { + return threadId; + } + + public boolean isForceSms() { + return forceSms; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ShareContactAndThread that = (ShareContactAndThread) o; + return threadId == that.threadId && + forceSms == that.forceSms && + recipientId.equals(that.recipientId); + } + + @Override + public int hashCode() { + return Objects.hash(recipientId, threadId, forceSms); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(recipientId, flags); + dest.writeLong(threadId); + dest.writeByte((byte) (forceSms ? 1 : 0)); + } + + public static final Creator CREATOR = new Creator() { + @Override + public ShareContactAndThread createFromParcel(@NonNull Parcel in) { + return new ShareContactAndThread(in); + } + + @Override + public ShareContactAndThread[] newArray(int size) { + return new ShareContactAndThread[size]; + } + }; + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java index 483896c1ab..52910359ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareData.java @@ -16,24 +16,26 @@ class ShareData { private final Optional mimeType; private final Optional> media; private final boolean external; + private final boolean isMmsOrSmsSupported; - static ShareData forIntentData(@NonNull Uri uri, @NonNull String mimeType, boolean external) { - return new ShareData(Optional.of(uri), Optional.of(mimeType), Optional.absent(), external); + static ShareData forIntentData(@NonNull Uri uri, @NonNull String mimeType, boolean external, boolean isMmsOrSmsSupported) { + return new ShareData(Optional.of(uri), Optional.of(mimeType), Optional.absent(), external, isMmsOrSmsSupported); } static ShareData forPrimitiveTypes() { - return new ShareData(Optional.absent(), Optional.absent(), Optional.absent(), true); + return new ShareData(Optional.absent(), Optional.absent(), Optional.absent(), true, true); } - static ShareData forMedia(@NonNull List media) { - return new ShareData(Optional.absent(), Optional.absent(), Optional.of(new ArrayList<>(media)), true); + static ShareData forMedia(@NonNull List media, boolean isMmsOrSmsSupported) { + return new ShareData(Optional.absent(), Optional.absent(), Optional.of(new ArrayList<>(media)), true, isMmsOrSmsSupported); } - private ShareData(Optional uri, Optional mimeType, Optional> media, boolean external) { - this.uri = uri; - this.mimeType = mimeType; - this.media = media; - this.external = external; + private ShareData(Optional uri, Optional mimeType, Optional> media, boolean external, boolean isMmsOrSmsSupported) { + this.uri = uri; + this.mimeType = mimeType; + this.media = media; + this.external = external; + this.isMmsOrSmsSupported = isMmsOrSmsSupported; } boolean isForIntent() { @@ -63,4 +65,8 @@ class ShareData { public boolean isExternal() { return external; } + + public boolean isMmsOrSmsSupported() { + return isMmsOrSmsSupported; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java index f4764466d4..9045f5750d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareRepository.java @@ -15,12 +15,17 @@ import com.annimon.stream.Stream; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.TransportOptions; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mediasend.MediaSendConstants; +import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.mms.PushMediaConstraints; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; @@ -72,7 +77,7 @@ class ShareRepository { mimeType = getMimeType(context, uri, mimeType); if (PartAuthority.isLocalUri(uri)) { - return ShareData.forIntentData(uri, mimeType, false); + return ShareData.forIntentData(uri, mimeType, false, false); } else { InputStream stream = context.getContentResolver().openInputStream(uri); @@ -99,10 +104,35 @@ class ShareRepository { .createForMultipleSessionsOnDisk(context); } - return ShareData.forIntentData(blobUri, mimeType, true); + return ShareData.forIntentData(blobUri, mimeType, true, isMmsSupported(context, mimeType, size)); } } + private boolean isMmsSupported(@NonNull Context context, @NonNull String mimeType, long size) { + if (!Util.isMmsCapable(context)) { + return false; + } + + TransportOptions options = new TransportOptions(context, true); + options.setDefaultTransport(TransportOption.Type.SMS); + MediaConstraints mmsConstraints = MediaConstraints.getMmsMediaConstraints(options.getSelectedTransport().getSimSubscriptionId().or(-1)); + + final boolean canMmsSupportFileSize; + if (MediaUtil.isGif(mimeType)) { + canMmsSupportFileSize = size <= mmsConstraints.getGifMaxSize(context); + } else if (MediaUtil.isVideo(mimeType)) { + canMmsSupportFileSize = size <= mmsConstraints.getVideoMaxSize(context); + } else if (MediaUtil.isImageType(mimeType)) { + canMmsSupportFileSize = size <= mmsConstraints.getImageMaxSize(context); + } else if (MediaUtil.isAudioType(mimeType)) { + canMmsSupportFileSize = size <= mmsConstraints.getAudioMaxSize(context); + } else { + canMmsSupportFileSize = size <= mmsConstraints.getDocumentMaxSize(context); + } + + return canMmsSupportFileSize; + } + @WorkerThread private @Nullable ShareData getResolvedInternal(@NonNull List uris) throws IOException { Context context = ApplicationDependencies.getApplication(); @@ -160,7 +190,9 @@ class ShareRepository { } if (media.size() > 0) { - return ShareData.forMedia(media); + boolean isMmsSupported = Stream.of(media) + .allMatch(m -> isMmsSupported(context, m.getMimeType(), m.getSize())); + return ShareData.forMedia(media, isMmsSupported); } else { return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java new file mode 100644 index 0000000000..ba4c578490 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionAdapter.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.sharing; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; + +class ShareSelectionAdapter extends MappingAdapter { + ShareSelectionAdapter() { + registerFactory(ShareSelectionMappingModel.class, + ShareSelectionViewHolder.createFactory(R.layout.share_contact_selection_item)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java new file mode 100644 index 0000000000..f0033cbbd1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionMappingModel.java @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.sharing; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MappingModel; + +class ShareSelectionMappingModel implements MappingModel { + + private final ShareContact shareContact; + private final boolean isLast; + + ShareSelectionMappingModel(@NonNull ShareContact shareContact, boolean isLast) { + this.shareContact = shareContact; + this.isLast = isLast; + } + + @NonNull String getName(@NonNull Context context) { + String name = shareContact.getRecipientId() + .transform(Recipient::resolved) + .transform(recipient -> recipient.isSelf() ? context.getString(R.string.note_to_self) + : recipient.getShortDisplayNameIncludingUsername(context)) + .or(shareContact.getNumber()); + + return isLast ? name : context.getString(R.string.ShareActivity__s_comma, name); + } + + @Override + public boolean areItemsTheSame(@NonNull ShareSelectionMappingModel newItem) { + return newItem.shareContact.equals(shareContact); + } + + @Override + public boolean areContentsTheSame(@NonNull ShareSelectionMappingModel newItem) { + return areItemsTheSame(newItem) && newItem.isLast == isLast; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionViewHolder.java new file mode 100644 index 0000000000..9e91cef0b4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareSelectionViewHolder.java @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms.sharing; + +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MappingAdapter; +import org.thoughtcrime.securesms.util.MappingViewHolder; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel; + +public class ShareSelectionViewHolder extends MappingViewHolder { + + protected final @NonNull TextView name; + + public ShareSelectionViewHolder(@NonNull View itemView) { + super(itemView); + + name = findViewById(R.id.recipient_view_name); + } + + @Override + public void bind(@NonNull ShareSelectionMappingModel model) { + name.setText(model.getName(context)); + } + + public static @NonNull MappingAdapter.Factory createFactory(@LayoutRes int layout) { + return new MappingAdapter.LayoutFactory<>(ShareSelectionViewHolder::new, layout); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java index fbaac16c0a..877b095122 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareViewModel.java @@ -7,15 +7,23 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; +import com.annimon.stream.Stream; + import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.providers.BlobProvider; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.MappingModel; import org.whispersystems.libsignal.util.guava.Optional; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; public class ShareViewModel extends ViewModel { @@ -24,14 +32,16 @@ public class ShareViewModel extends ViewModel { private final Context context; private final ShareRepository shareRepository; private final MutableLiveData> shareData; + private final MutableLiveData> selectedContacts; private boolean mediaUsed; private boolean externalShare; private ShareViewModel() { - this.context = ApplicationDependencies.getApplication(); - this.shareRepository = new ShareRepository(); - this.shareData = new MutableLiveData<>(); + this.context = ApplicationDependencies.getApplication(); + this.shareRepository = new ShareRepository(); + this.shareData = new MutableLiveData<>(); + this.selectedContacts = new DefaultValueLiveData<>(Collections.emptySet()); } void onSingleMediaShared(@NonNull Uri uri, @Nullable String mimeType) { @@ -44,11 +54,47 @@ public class ShareViewModel extends ViewModel { shareRepository.getResolved(uris, shareData::postValue); } + boolean isMultiShare() { + return selectedContacts.getValue().size() > 1; + } + + boolean onContactSelected(@NonNull ShareContact selectedContact) { + Set contacts = new LinkedHashSet<>(selectedContacts.getValue()); + if (contacts.add(selectedContact)) { + selectedContacts.setValue(contacts); + return true; + } else { + return false; + } + } + + void onContactDeselected(@NonNull ShareContact selectedContact) { + Set contacts = new LinkedHashSet<>(selectedContacts.getValue()); + if (contacts.remove(selectedContact)) { + selectedContacts.setValue(contacts); + } + } + + @NonNull Set getShareContacts() { + Set contacts = selectedContacts.getValue(); + if (contacts == null) { + return Collections.emptySet(); + } else { + return contacts; + } + } + + @NonNull LiveData>> getSelectedContactModels() { + return Transformations.map(selectedContacts, set -> Stream.of(set) + .>mapIndexed((i, c) -> new ShareSelectionMappingModel(c, i == set.size() - 1)) + .toList()); + } + void onNonExternalShare() { externalShare = false; } - void onSuccessulShare() { + public void onSuccessulShare() { mediaUsed = true; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java new file mode 100644 index 0000000000..8c196ac0c9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialActivity.java @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.RecyclerView; + +import com.dd.CircularProgressButton; + +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.LinkPreviewView; +import org.thoughtcrime.securesms.components.SelectionAwareEmojiEditText; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.sharing.MultiShareArgs; +import org.thoughtcrime.securesms.sharing.MultiShareDialogs; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; + +/** + * Handles display and editing of a text message (with possible link preview) before it is forwarded + * to multiple users. + */ +public class ShareInterstitialActivity extends PassphraseRequiredActivity { + + private static final String ARGS = "args"; + + private ShareInterstitialViewModel viewModel; + private LinkPreviewViewModel linkPreviewViewModel; + private CircularProgressButton confirm; + private RecyclerView contactsRecycler; + private Toolbar toolbar; + private LinkPreviewView preview; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private final ShareInterstitialSelectionAdapter adapter = new ShareInterstitialSelectionAdapter(); + + public static Intent createIntent(@NonNull Context context, @NonNull MultiShareArgs multiShareArgs) { + Intent intent = new Intent(context, ShareInterstitialActivity.class); + + intent.putExtra(ARGS, multiShareArgs); + + return intent; + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + dynamicTheme.onCreate(this); + setContentView(R.layout.share_interstitial_activity); + + MultiShareArgs args = getIntent().getParcelableExtra(ARGS); + + initializeViewModels(args); + initializeViews(args); + initializeObservers(); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + private void initializeViewModels(@NonNull MultiShareArgs args) { + ShareInterstitialRepository repository = new ShareInterstitialRepository(); + ShareInterstitialViewModel.Factory factory = new ShareInterstitialViewModel.Factory(args, repository); + + viewModel = ViewModelProviders.of(this, factory).get(ShareInterstitialViewModel.class); + + LinkPreviewRepository linkPreviewRepository = new LinkPreviewRepository(); + LinkPreviewViewModel.Factory linkPreviewViewModelFactory = new LinkPreviewViewModel.Factory(linkPreviewRepository); + + linkPreviewViewModel = ViewModelProviders.of(this, linkPreviewViewModelFactory).get(LinkPreviewViewModel.class); + } + + private void initializeViews(@NonNull MultiShareArgs args) { + confirm = findViewById(R.id.share_confirm); + toolbar = findViewById(R.id.toolbar); + preview = findViewById(R.id.link_preview); + + confirm.setOnClickListener(unused -> onConfirm()); + + SelectionAwareEmojiEditText text = findViewById(R.id.text); + + toolbar.setNavigationOnClickListener(unused -> finish()); + + text.addTextChangedListener(new AfterTextChanged(editable -> { + linkPreviewViewModel.onTextChanged(this, editable.toString(), text.getSelectionStart(), text.getSelectionEnd()); + viewModel.onDraftTextChanged(editable.toString()); + })); + + //noinspection CodeBlock2Expr + text.setOnSelectionChangedListener(((selStart, selEnd) -> { + linkPreviewViewModel.onTextChanged(this, text.getText().toString(), text.getSelectionStart(), text.getSelectionEnd()); + })); + + preview.setCloseClickedListener(linkPreviewViewModel::onUserCancel); + + int defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius); + preview.setCorners(defaultRadius, defaultRadius); + + text.setText(args.getDraftText()); + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(text); + + contactsRecycler = findViewById(R.id.selected_list); + contactsRecycler.setAdapter(adapter); + + confirm.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + int pad = Math.abs(v.getWidth() + ViewUtil.dpToPx(16)); + ViewUtil.setPaddingEnd(contactsRecycler, pad); + }); + } + + private void initializeObservers() { + viewModel.getRecipients().observe(this, models -> adapter.submitList(models, + () -> contactsRecycler.scrollToPosition(models.size() - 1))); + viewModel.hasDraftText().observe(this, this::handleHasDraftText); + + linkPreviewViewModel.getLinkPreviewState().observe(this, linkPreviewState -> { + preview.setVisibility(View.VISIBLE); + if (linkPreviewState.getError() != null) { + preview.setNoPreview(linkPreviewState.getError()); + viewModel.onLinkPreviewChanged(null); + } else if (linkPreviewState.isLoading()) { + preview.setLoading(); + viewModel.onLinkPreviewChanged(null); + } else if (linkPreviewState.getLinkPreview().isPresent()) { + preview.setLinkPreview(GlideApp.with(this), linkPreviewState.getLinkPreview().get(), true); + viewModel.onLinkPreviewChanged(linkPreviewState.getLinkPreview().get()); + } else if (!linkPreviewState.hasLinks()) { + preview.setVisibility(View.GONE); + viewModel.onLinkPreviewChanged(null); + } + }); + } + + private void handleHasDraftText(boolean hasDraftText) { + confirm.setEnabled(hasDraftText); + confirm.setAlpha(hasDraftText ? 1f : 0.5f); + } + + private void onConfirm() { + confirm.setClickable(false); + confirm.setIndeterminateProgressMode(true); + confirm.setProgress(50); + + viewModel.send(results -> { + MultiShareDialogs.displayResultDialog(this, results, () -> { + setResult(RESULT_OK); + finish(); + }); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialMappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialMappingModel.java new file mode 100644 index 0000000000..27e35ac59c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialMappingModel.java @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel; + +class ShareInterstitialMappingModel extends RecipientMappingModel { + + private final Recipient recipient; + private final boolean isLast; + + ShareInterstitialMappingModel(@NonNull Recipient recipient, boolean isLast) { + this.recipient = recipient; + this.isLast = isLast; + } + + @Override + public @NonNull String getName(@NonNull Context context) { + String name = recipient.isSelf() ? context.getString(R.string.note_to_self) + : recipient.getShortDisplayNameIncludingUsername(context); + + return isLast ? name : context.getString(R.string.ShareActivity__s_comma, name); + } + + @Override + public @NonNull Recipient getRecipient() { + return recipient; + } + + @Override + public boolean areContentsTheSame(@NonNull ShareInterstitialMappingModel newItem) { + return super.areContentsTheSame(newItem) && isLast == newItem.isLast; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java new file mode 100644 index 0000000000..ce74106804 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialRepository.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Stream; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.sharing.ShareContactAndThread; + +import java.util.List; +import java.util.Set; + +class ShareInterstitialRepository { + + void loadRecipients(@NonNull Set shareContactAndThreads, Consumer> consumer) { + SignalExecutors.BOUNDED.execute(() -> consumer.accept(resolveRecipients(shareContactAndThreads))); + } + + @WorkerThread + private List resolveRecipients(@NonNull Set shareContactAndThreads) { + return Stream.of(shareContactAndThreads) + .map(ShareContactAndThread::getRecipientId) + .map(Recipient::resolved) + .toList(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialSelectionAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialSelectionAdapter.java new file mode 100644 index 0000000000..2663234f1b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialSelectionAdapter.java @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder; + +class ShareInterstitialSelectionAdapter extends MappingAdapter { + ShareInterstitialSelectionAdapter() { + registerFactory(ShareInterstitialMappingModel.class, RecipientViewHolder.createFactory(R.layout.share_contact_selection_item, null)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java new file mode 100644 index 0000000000..61ace906f3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/interstitial/ShareInterstitialViewModel.java @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.sharing.interstitial; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.linkpreview.LinkPreview; +import org.thoughtcrime.securesms.sharing.MultiShareArgs; +import org.thoughtcrime.securesms.sharing.MultiShareSender; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.Util; + +import java.util.List; + +class ShareInterstitialViewModel extends ViewModel { + +private final MultiShareArgs args; + private final MutableLiveData>> recipients; + private final MutableLiveData draftText; + + private LinkPreview linkPreview; + + ShareInterstitialViewModel(@NonNull MultiShareArgs args, @NonNull ShareInterstitialRepository repository) { + this.args = args; + this.recipients = new MutableLiveData<>(); + this.draftText = new DefaultValueLiveData<>(Util.firstNonNull(args.getDraftText(), "")); + + repository.loadRecipients(args.getShareContactAndThreads(), + list -> recipients.postValue(Stream.of(list) + .>mapIndexed((i, r) -> new ShareInterstitialMappingModel(r, i == list.size() - 1)) + .toList())); + + } + + LiveData>> getRecipients() { + return recipients; + } + + LiveData hasDraftText() { + return Transformations.map(draftText, text -> !TextUtils.isEmpty(text)); + } + + void onDraftTextChanged(@NonNull String change) { + draftText.setValue(change); + } + + void onLinkPreviewChanged(@Nullable LinkPreview linkPreview) { + this.linkPreview = linkPreview; + } + + void send(@NonNull Consumer resultsConsumer) { + LinkPreview linkPreview = this.linkPreview; + String draftText = this.draftText.getValue(); + + MultiShareArgs.Builder builder = args.buildUpon() + .withDraftText(draftText) + .withLinkPreview(linkPreview); + + MultiShareSender.send(builder.build(), resultsConsumer); + } + + static class Factory implements ViewModelProvider.Factory { + + private final MultiShareArgs args; + private final ShareInterstitialRepository repository; + + Factory(@NonNull MultiShareArgs args, @NonNull ShareInterstitialRepository repository) { + this.args = args; + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new ShareInterstitialViewModel(args, repository)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index f75dc05f02..ec1909c2ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -72,6 +72,7 @@ public final class FeatureFlags { private static final String DEFAULT_MAX_BACKOFF = "android.defaultMaxBackoff"; private static final String OKHTTP_AUTOMATIC_RETRY = "android.okhttpAutomaticRetry"; private static final String ABOUT = "android.about"; + private static final String SHARE_SELECTION_LIMIT = "android.share.limit"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -100,7 +101,8 @@ public final class FeatureFlags { AUTOMATIC_SESSION_INTERVAL, DEFAULT_MAX_BACKOFF, OKHTTP_AUTOMATIC_RETRY, - ABOUT + ABOUT, + SHARE_SELECTION_LIMIT ); @VisibleForTesting @@ -139,7 +141,8 @@ public final class FeatureFlags { AUTOMATIC_SESSION_INTERVAL, DEFAULT_MAX_BACKOFF, OKHTTP_AUTOMATIC_RETRY, - ABOUT + ABOUT, + SHARE_SELECTION_LIMIT ); /** @@ -295,6 +298,11 @@ public final class FeatureFlags { return getInteger(CDS_REFRESH_INTERVAL, (int) TimeUnit.HOURS.toSeconds(48)); } + public static @NonNull SelectionLimits shareSelectionLimit() { + int limit = getInteger(SHARE_SELECTION_LIMIT, 5); + return new SelectionLimits(limit, limit); + } + /** The maximum number of grapheme */ public static int getMaxGroupNameGraphemeLength() { return Math.max(32, getInteger(GROUP_NAME_MAX_LENGTH, -1)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java index 909bed7637..1c68b2e7b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java @@ -30,6 +30,7 @@ import android.view.ViewTreeObserver; import android.view.animation.AlphaAnimation; import android.view.animation.Animation; import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; import android.widget.TextView; import androidx.annotation.IdRes; @@ -51,6 +52,20 @@ public final class ViewUtil { private ViewUtil() { } + public static void focusAndMoveCursorToEndAndOpenKeyboard(@NonNull EditText input) { + input.requestFocus(); + + int numberLength = input.getText().length(); + input.setSelection(numberLength, numberLength); + + InputMethodManager imm = (InputMethodManager) input.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(input, InputMethodManager.SHOW_IMPLICIT); + + if (!imm.isAcceptingText()) { + imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.HIDE_IMPLICIT_ONLY); + } + } + public static void focusAndShowKeyboard(@NonNull View view) { view.requestFocus(); if (view.hasWindowFocus()) { diff --git a/app/src/main/res/layout/share_activity.xml b/app/src/main/res/layout/share_activity.xml index b44ebbeeeb..cb5b95eaa7 100644 --- a/app/src/main/res/layout/share_activity.xml +++ b/app/src/main/res/layout/share_activity.xml @@ -1,60 +1,105 @@ - + + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + android:minHeight="?attr/actionBarSize" + android:theme="?attr/actionBarStyle" + app:layout_constraintTop_toTopOf="parent"> + android:layout_width="match_parent" + android:layout_height="match_parent"> - + + android:layout_centerVertical="true" + android:tint="@color/signal_icon_tint_primary" + app:srcCompat="@drawable/ic_search_24" /> - + + android:id="@+id/search_toolbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:elevation="4dp" + android:visibility="invisible" + app:layout_constraintTop_toTopOf="parent" + tools:visibility="invisible" /> - + + + + + + + + + diff --git a/app/src/main/res/layout/share_contact_selection_item.xml b/app/src/main/res/layout/share_contact_selection_item.xml new file mode 100644 index 0000000000..76d428c4b5 --- /dev/null +++ b/app/src/main/res/layout/share_contact_selection_item.xml @@ -0,0 +1,10 @@ + + diff --git a/app/src/main/res/layout/share_interstitial_activity.xml b/app/src/main/res/layout/share_interstitial_activity.xml new file mode 100644 index 0000000000..7a0b87f54a --- /dev/null +++ b/app/src/main/res/layout/share_interstitial_activity.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5001226580..dd4ed46406 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3087,6 +3087,18 @@ Skip + + Share + Send + %1$s, + + + Failed to send to some users + You can only share with up to %1$d chats + + + Forward message +