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
+