Enable sharing to stories and refactor share activity.

This commit is contained in:
Alex Hart
2022-04-07 15:47:56 -03:00
committed by Greyson Parrelli
parent fd4543ffe0
commit 523537cf05
62 changed files with 1188 additions and 1855 deletions

View File

@@ -10,65 +10,73 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.signal.core.util.BreakIteratorCompat;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.stories.Stories;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.ParcelUtil;
import org.thoughtcrime.securesms.util.Util;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
public final class MultiShareArgs implements Parcelable {
private final Set<ShareContactAndThread> shareContactAndThreads;
private final List<Media> 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 final List<Mention> mentions;
private final long timestamp;
private final long expiresAt;
private final boolean isTextStory;
private final Set<ContactSearchKey> contactSearchKeys;
private final List<Media> 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 final List<Mention> mentions;
private final long timestamp;
private final long expiresAt;
private final boolean isTextStory;
private MultiShareArgs(@NonNull Builder builder) {
shareContactAndThreads = builder.shareContactAndThreads;
media = builder.media == null ? new ArrayList<>() : 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;
mentions = builder.mentions == null ? new ArrayList<>() : new ArrayList<>(builder.mentions);
timestamp = builder.timestamp;
expiresAt = builder.expiresAt;
isTextStory = builder.isTextStory;
contactSearchKeys = builder.contactSearchKeys;
media = builder.media == null ? new ArrayList<>() : 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;
mentions = builder.mentions == null ? new ArrayList<>() : new ArrayList<>(builder.mentions);
timestamp = builder.timestamp;
expiresAt = builder.expiresAt;
isTextStory = builder.isTextStory;
}
protected MultiShareArgs(Parcel in) {
shareContactAndThreads = new HashSet<>(Objects.requireNonNull(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;
mentions = in.createTypedArrayList(Mention.CREATOR);
timestamp = in.readLong();
expiresAt = in.readLong();
isTextStory = ParcelUtil.readBoolean(in);
List<ContactSearchKey.ParcelableRecipientSearchKey> parcelableRecipientSearchKeys = in.createTypedArrayList(ContactSearchKey.ParcelableRecipientSearchKey.CREATOR);
contactSearchKeys = parcelableRecipientSearchKeys.stream()
.map(ContactSearchKey.ParcelableRecipientSearchKey::asContactSearchKey)
.collect(Collectors.toSet());
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;
mentions = in.createTypedArrayList(Mention.CREATOR);
timestamp = in.readLong();
expiresAt = in.readLong();
isTextStory = ParcelUtil.readBoolean(in);
String linkedPreviewString = in.readString();
LinkPreview preview;
@@ -81,8 +89,15 @@ public final class MultiShareArgs implements Parcelable {
linkPreview = preview;
}
public Set<ShareContactAndThread> getShareContactAndThreads() {
return shareContactAndThreads;
public Set<ContactSearchKey> getContactSearchKeys() {
return contactSearchKeys;
}
public Set<ContactSearchKey.RecipientSearchKey> getRecipientSearchKeys() {
return contactSearchKeys.stream()
.filter(key -> key instanceof ContactSearchKey.RecipientSearchKey)
.map(key -> (ContactSearchKey.RecipientSearchKey) key)
.collect(Collectors.toSet());
}
public @NonNull List<Media> getMedia() {
@@ -134,13 +149,34 @@ public final class MultiShareArgs implements Parcelable {
}
public boolean isValidForStories() {
return isTextStory || !media.isEmpty() && media.stream().allMatch(m -> MediaUtil.isImageOrVideoType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType()));
return isTextStory ||
!media.isEmpty() && media.stream().allMatch(m -> MediaUtil.isImageOrVideoType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType())) ||
MediaUtil.isImageType(dataType) ||
MediaUtil.isVideoType(dataType) ||
isValidForTextStoryGeneration();
}
public boolean isValidForNonStories() {
return !isTextStory;
}
public boolean isValidForTextStoryGeneration() {
if (isTextStory || !media.isEmpty()) {
return false;
}
if (!Util.isEmpty(getDraftText())) {
BreakIteratorCompat breakIteratorCompat = BreakIteratorCompat.getInstance();
breakIteratorCompat.setText(getDraftText());
if (breakIteratorCompat.countBreaks() > Stories.MAX_BODY_SIZE) {
return false;
}
}
return linkPreview != null || !Util.isEmpty(draftText);
}
public @NonNull InterstitialContentType getInterstitialContentType() {
if (!requiresInterstitial()) {
return InterstitialContentType.NONE;
@@ -148,6 +184,8 @@ public final class MultiShareArgs implements Parcelable {
(this.getDataUri() != null && this.getDataUri() != Uri.EMPTY && this.getDataType() != null && MediaUtil.isImageOrVideoType(this.getDataType())))
{
return InterstitialContentType.MEDIA;
} else if (!TextUtils.isEmpty(this.getDraftText()) && allRecipientsAreStories()) {
return InterstitialContentType.MEDIA;
} else if (!TextUtils.isEmpty(this.getDraftText())) {
return InterstitialContentType.TEXT;
} else {
@@ -155,6 +193,9 @@ public final class MultiShareArgs implements Parcelable {
}
}
public boolean allRecipientsAreStories() {
return !contactSearchKeys.isEmpty() && contactSearchKeys.stream().allMatch(key -> key instanceof ContactSearchKey.RecipientSearchKey.Story);
}
public static final Creator<MultiShareArgs> CREATOR = new Creator<MultiShareArgs>() {
@Override
@@ -175,7 +216,7 @@ public final class MultiShareArgs implements Parcelable {
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeTypedList(Stream.of(shareContactAndThreads).toList());
dest.writeTypedList(Stream.of(contactSearchKeys).map(ContactSearchKey::requireParcelable).toList());
dest.writeTypedList(media);
dest.writeString(draftText);
dest.writeParcelable(stickerLocator, flags);
@@ -200,32 +241,35 @@ public final class MultiShareArgs implements Parcelable {
}
public Builder buildUpon() {
return buildUpon(shareContactAndThreads);
return buildUpon(contactSearchKeys);
}
public Builder buildUpon(@NonNull Set<ShareContactAndThread> shareContactAndThreads) {
return new Builder(shareContactAndThreads).asBorderless(borderless)
.asViewOnce(viewOnce)
.withDataType(dataType)
.withDataUri(dataUri)
.withDraftText(draftText)
.withLinkPreview(linkPreview)
.withMedia(media)
.withStickerLocator(stickerLocator)
.withMentions(mentions)
.withTimestamp(timestamp)
.withExpiration(expiresAt)
.asTextStory(isTextStory);
public Builder buildUpon(@NonNull Set<ContactSearchKey> recipientSearchKeys) {
return new Builder(recipientSearchKeys).asBorderless(borderless)
.asViewOnce(viewOnce)
.withDataType(dataType)
.withDataUri(dataUri)
.withDraftText(draftText)
.withLinkPreview(linkPreview)
.withMedia(media)
.withStickerLocator(stickerLocator)
.withMentions(mentions)
.withTimestamp(timestamp)
.withExpiration(expiresAt)
.asTextStory(isTextStory);
}
private boolean requiresInterstitial() {
return stickerLocator == null &&
(!media.isEmpty() || !TextUtils.isEmpty(draftText) || MediaUtil.isImageOrVideoType(dataType));
(!media.isEmpty() ||
!TextUtils.isEmpty(draftText) ||
MediaUtil.isImageOrVideoType(dataType) ||
(!contactSearchKeys.isEmpty() && contactSearchKeys.stream().anyMatch(key -> key instanceof ContactSearchKey.RecipientSearchKey.Story)));
}
public static final class Builder {
private final Set<ShareContactAndThread> shareContactAndThreads;
private final Set<ContactSearchKey> contactSearchKeys;
private List<Media> media;
private String draftText;
@@ -240,8 +284,12 @@ public final class MultiShareArgs implements Parcelable {
private long expiresAt;
private boolean isTextStory;
public Builder(@NonNull Set<ShareContactAndThread> shareContactAndThreads) {
this.shareContactAndThreads = shareContactAndThreads;
public Builder() {
this(Collections.emptySet());
}
public Builder(@NonNull Set<ContactSearchKey> contactSearchKeys) {
this.contactSearchKeys = contactSearchKeys;
}
public @NonNull Builder withMedia(@Nullable List<Media> media) {

View File

@@ -1,26 +1,33 @@
package org.thoughtcrime.securesms.sharing;
import android.content.Context;
import android.graphics.Color;
import android.net.Uri;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.core.util.Consumer;
import com.annimon.stream.Stream;
import org.signal.core.util.BreakIteratorCompat;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.TransportOptions;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.StoryType;
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryBackgroundColors;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
import org.thoughtcrime.securesms.mms.Slide;
@@ -32,6 +39,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.OutgoingEncryptedMessage;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.stories.Stories;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.Util;
@@ -63,7 +72,7 @@ public final class MultiShareSender {
@WorkerThread
public static MultiShareSendResultCollection sendSync(@NonNull MultiShareArgs multiShareArgs) {
List<MultiShareSendResult> results = new ArrayList<>(multiShareArgs.getShareContactAndThreads().size());
List<MultiShareSendResult> results = new ArrayList<>(multiShareArgs.getContactSearchKeys().size());
Context context = ApplicationDependencies.getApplication();
boolean isMmsEnabled = Util.isMmsCapable(context);
String message = multiShareArgs.getDraftText();
@@ -73,45 +82,48 @@ public final class MultiShareSender {
slideDeck = buildSlideDeck(context, multiShareArgs);
} catch (SlideNotFoundException e) {
Log.w(TAG, "Could not create slide for media message");
for (ShareContactAndThread shareContactAndThread : multiShareArgs.getShareContactAndThreads()) {
results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.GENERIC_ERROR));
for (ContactSearchKey.RecipientSearchKey recipientSearchKey : multiShareArgs.getRecipientSearchKeys()) {
results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.GENERIC_ERROR));
}
return new MultiShareSendResultCollection(results);
}
long distributionListSentTimestamp = System.currentTimeMillis();
for (ContactSearchKey.RecipientSearchKey recipientSearchKey : multiShareArgs.getRecipientSearchKeys()) {
Recipient recipient = Recipient.resolved(recipientSearchKey.getRecipientId());
for (ShareContactAndThread shareContactAndThread : multiShareArgs.getShareContactAndThreads()) {
Recipient recipient = Recipient.resolved(shareContactAndThread.getRecipientId());
List<Mention> mentions = getValidMentionsForRecipient(recipient, multiShareArgs.getMentions());
TransportOption transport = resolveTransportOption(context, recipient);
boolean forceSms = recipient.isForceSmsSelection() && transport.isSms();
int subscriptionId = transport.getSimSubscriptionId().orElse(-1);
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
boolean needsSplit = !transport.isSms() &&
message != null &&
message.length() > transport.calculateCharacters(message).maxPrimaryMessageSize;
boolean hasMmsMedia = !multiShareArgs.getMedia().isEmpty() ||
(multiShareArgs.getDataUri() != null && multiShareArgs.getDataUri() != Uri.EMPTY) ||
multiShareArgs.getStickerLocator() != null ||
recipient.isGroup() ||
recipient.getEmail().isPresent();
boolean hasPushMedia = hasMmsMedia ||
multiShareArgs.getLinkPreview() != null ||
!mentions.isEmpty() ||
needsSplit;
long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient);
List<Mention> mentions = getValidMentionsForRecipient(recipient, multiShareArgs.getMentions());
TransportOption transport = resolveTransportOption(context, recipient);
boolean forceSms = recipient.isForceSmsSelection() && transport.isSms();
int subscriptionId = transport.getSimSubscriptionId().orElse(-1);
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
boolean needsSplit = !transport.isSms() &&
message != null &&
message.length() > transport.calculateCharacters(message).maxPrimaryMessageSize;
boolean hasMmsMedia = !multiShareArgs.getMedia().isEmpty() ||
(multiShareArgs.getDataUri() != null && multiShareArgs.getDataUri() != Uri.EMPTY) ||
multiShareArgs.getStickerLocator() != null ||
recipient.isGroup() ||
recipient.getEmail().isPresent();
boolean hasPushMedia = hasMmsMedia ||
multiShareArgs.getLinkPreview() != null ||
!mentions.isEmpty() ||
needsSplit;
long sentTimestamp = recipient.isDistributionList() ? distributionListSentTimestamp : System.currentTimeMillis();
boolean canSendAsTextStory = recipientSearchKey.isStory() && multiShareArgs.isValidForTextStoryGeneration();
if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !isMmsEnabled) {
results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.MMS_NOT_ENABLED));
} else if (hasMmsMedia && transport.isSms() || hasPushMedia && !transport.isSms() || multiShareArgs.isTextStory()) {
sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, shareContactAndThread.getThreadId(), forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId, mentions, shareContactAndThread.isStory());
results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS));
} else if (shareContactAndThread.isStory()) {
results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.INVALID_SHARE_TO_STORY));
results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.MMS_NOT_ENABLED));
} else if (hasMmsMedia && transport.isSms() || hasPushMedia && !transport.isSms() || canSendAsTextStory) {
sendMediaMessage(context, multiShareArgs, recipient, slideDeck, transport, threadId, forceSms, expiresIn, multiShareArgs.isViewOnce(), subscriptionId, mentions, recipientSearchKey.isStory(), sentTimestamp, canSendAsTextStory);
results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.SUCCESS));
} else if (recipientSearchKey.isStory()) {
results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.INVALID_SHARE_TO_STORY));
} else {
sendTextMessage(context, multiShareArgs, recipient, shareContactAndThread.getThreadId(), forceSms, expiresIn, subscriptionId);
results.add(new MultiShareSendResult(shareContactAndThread, MultiShareSendResult.Type.SUCCESS));
sendTextMessage(context, multiShareArgs, recipient, threadId, forceSms, expiresIn, subscriptionId);
results.add(new MultiShareSendResult(recipientSearchKey, MultiShareSendResult.Type.SUCCESS));
}
// XXX We must do this to avoid sending out messages to the same recipient with the same
@@ -122,9 +134,9 @@ public final class MultiShareSender {
return new MultiShareSendResultCollection(results);
}
public static @NonNull TransportOption getWorstTransportOption(@NonNull Context context, @NonNull Set<ShareContactAndThread> shareContactAndThreads) {
for (ShareContactAndThread shareContactAndThread : shareContactAndThreads) {
TransportOption option = resolveTransportOption(context, shareContactAndThread.isForceSms() && !shareContactAndThread.isStory());
public static @NonNull TransportOption getWorstTransportOption(@NonNull Context context, @NonNull Set<ContactSearchKey.RecipientSearchKey> recipientSearchKeys) {
for (ContactSearchKey.RecipientSearchKey recipientSearchKey : recipientSearchKeys) {
TransportOption option = resolveTransportOption(context, Recipient.resolved(recipientSearchKey.getRecipientId()).isForceSmsSelection() && !recipientSearchKey.isStory());
if (option.isSms()) {
return option;
}
@@ -158,7 +170,9 @@ public final class MultiShareSender {
boolean isViewOnce,
int subscriptionId,
@NonNull List<Mention> validatedMentions,
boolean isStory)
boolean isStory,
long sentTimestamp,
boolean canSendAsTextStory)
{
String body = multiShareArgs.getDraftText();
if (transportOption.isType(TransportOption.Type.TEXTSECURE) && !forceSms && body != null) {
@@ -188,7 +202,7 @@ public final class MultiShareSender {
OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient,
new SlideDeck(),
body,
System.currentTimeMillis(),
sentTimestamp,
subscriptionId,
0L,
false,
@@ -203,6 +217,8 @@ public final class MultiShareSender {
Collections.emptyList());
outgoingMessages.add(outgoingMediaMessage);
} else if (canSendAsTextStory) {
outgoingMessages.add(generateTextStory(recipient, multiShareArgs, sentTimestamp, storyType));
} else {
for (final Slide slide : slideDeck.getSlides()) {
SlideDeck singletonDeck = new SlideDeck();
@@ -211,7 +227,7 @@ public final class MultiShareSender {
OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient,
singletonDeck,
body,
System.currentTimeMillis(),
sentTimestamp,
subscriptionId,
0L,
false,
@@ -225,17 +241,13 @@ public final class MultiShareSender {
validatedMentions);
outgoingMessages.add(outgoingMediaMessage);
// XXX We must do this to avoid sending out messages to the same recipient with the same
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
ThreadUtil.sleep(5);
}
}
} else {
OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient,
slideDeck,
body,
System.currentTimeMillis(),
sentTimestamp,
subscriptionId,
expiresIn,
isViewOnce,
@@ -283,6 +295,59 @@ public final class MultiShareSender {
MessageSender.send(context, outgoingTextMessage, threadId, forceSms, null, null);
}
private static @NonNull OutgoingMediaMessage generateTextStory(@NonNull Recipient recipient,
@NonNull MultiShareArgs multiShareArgs,
long sentTimestamp,
@NonNull StoryType storyType)
{
return new OutgoingMediaMessage(
recipient,
Base64.encodeBytes(StoryTextPost.newBuilder()
.setBody(getBodyForTextStory(multiShareArgs.getDraftText(), multiShareArgs.getLinkPreview()))
.setStyle(StoryTextPost.Style.DEFAULT)
.setBackground(TextStoryBackgroundColors.getRandomBackgroundColor().serialize())
.setTextBackgroundColor(0)
.setTextForegroundColor(Color.WHITE)
.build()
.toByteArray()),
Collections.emptyList(),
sentTimestamp,
-1,
0,
false,
ThreadDatabase.DistributionTypes.DEFAULT,
storyType.toTextStoryType(),
null,
false,
null,
Collections.emptyList(),
multiShareArgs.getLinkPreview() != null ? Collections.singletonList(multiShareArgs.getLinkPreview())
: Collections.emptyList(),
Collections.emptyList(),
Collections.emptySet(),
Collections.emptySet());
}
private static @NonNull String getBodyForTextStory(@Nullable String draftText, @Nullable LinkPreview linkPreview) {
if (Util.isEmpty(draftText)) {
return "";
}
BreakIteratorCompat breakIteratorCompat = BreakIteratorCompat.getInstance();
breakIteratorCompat.setText(draftText);
String trimmed = breakIteratorCompat.take(Stories.MAX_BODY_SIZE).toString();
if (linkPreview == null) {
return trimmed;
}
if (linkPreview.getUrl().equals(trimmed)) {
return "";
}
return trimmed.replace(linkPreview.getUrl(), "").trim();
}
private static boolean shouldSendAsPush(@NonNull Recipient recipient, boolean forceSms) {
return recipient.isDistributionList() ||
recipient.isServiceIdOnly() ||
@@ -346,16 +411,16 @@ public final class MultiShareSender {
}
private static final class MultiShareSendResult {
private final ShareContactAndThread contactAndThread;
private final Type type;
private final ContactSearchKey.RecipientSearchKey recipientSearchKey;
private final Type type;
private MultiShareSendResult(ShareContactAndThread contactAndThread, Type type) {
this.contactAndThread = contactAndThread;
this.type = type;
private MultiShareSendResult(ContactSearchKey.RecipientSearchKey contactSearchKey, Type type) {
this.recipientSearchKey = contactSearchKey;
this.type = type;
}
public ShareContactAndThread getContactAndThread() {
return contactAndThread;
public ContactSearchKey.RecipientSearchKey getContactSearchKey() {
return recipientSearchKey;
}
public Type getType() {

View File

@@ -1,745 +0,0 @@
/*
* Copyright (C) 2014-2017 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.sharing;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import android.widget.ImageView;
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.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.util.Consumer;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.SearchToolbar;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sharing.interstitial.ShareInterstitialActivity;
import org.thoughtcrime.securesms.stories.Stories;
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs;
import org.thoughtcrime.securesms.stories.settings.hide.HideStoryFromDialogFragment;
import org.thoughtcrime.securesms.util.ConversationUtil;
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.LifecycleDisposable;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.signal.core.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.schedulers.Schedulers;
import kotlin.Unit;
/**
* Entry point for sharing content into the app.
*
* Handles contact selection when necessary, but also serves as an entry point for when the contact
* is known (such as choosing someone in a direct share).
*/
public class ShareActivity extends PassphraseRequiredActivity
implements ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.OnSelectionLimitReachedListener
{
private static final String TAG = Log.tag(ShareActivity.class);
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";
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 RecyclerView contactsRecycler;
private View contactsRecyclerDivider;
private ShareSelectionAdapter adapter;
private boolean disallowMultiShare;
private ShareIntents.Args args;
private ShareViewModel viewModel;
private final LifecycleDisposable disposables = new LifecycleDisposable();
@Override
protected void onPreCreate() {
dynamicTheme.onCreate(this);
dynamicLanguage.onCreate(this);
}
@Override
protected void onCreate(Bundle icicle, boolean ready) {
setContentView(R.layout.share_activity);
disposables.bindTo(getLifecycle());
initializeArgs();
initializeViewModel();
initializeMedia();
initializeIntent();
initializeToolbar();
initializeResources();
initializeSearch();
}
@Override
public void onResume() {
Log.i(TAG, "onResume()");
super.onResume();
dynamicTheme.onResume(this);
dynamicLanguage.onResume(this);
handleDirectShare();
}
@Override
public void onStop() {
super.onStop();
if (!isFinishing() && !viewModel.isMultiShare()) {
finish();
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
} else {
return super.onOptionsItemSelected(item);
}
}
@Override
public void onBackPressed() {
if (searchToolbar.isVisible()) searchToolbar.collapse();
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.onSuccessfulShare();
finish();
break;
default:
super.onActivityResult(requestCode, resultCode, data);
}
} else {
shareConfirm.setClickable(true);
super.onActivityResult(requestCode, resultCode, data);
}
}
@Override
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull java.util.function.Consumer<Boolean> callback) {
if (disallowMultiShare) {
Toast.makeText(this, R.string.ShareActivity__sharing_to_multiple_chats_is, Toast.LENGTH_LONG).show();
callback.accept(false);
} else {
disposables.add(viewModel.onContactSelected(new ShareContact(recipientId, number))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
switch (result) {
case TRUE:
callback.accept(true);
break;
case FALSE:
callback.accept(false);
break;
case FALSE_AND_SHOW_PERMISSION_TOAST:
Toast.makeText(this, R.string.ShareActivity_you_do_not_have_permission_to_send_to_this_group, Toast.LENGTH_SHORT).show();
callback.accept(false);
break;
case FALSE_AND_SHOW_SMS_MULTISELECT_TOAST:
Toast.makeText(this, R.string.ShareActivity__sharing_to_multiple_chats_is, Toast.LENGTH_LONG).show();
callback.accept(false);
break;
}
}));
}
}
@Override
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
viewModel.onContactDeselected(new ShareContact(recipientId, number));
}
@Override
public void onSelectionChanged() {
}
private void animateInSelection() {
contactsRecyclerDivider.animate()
.alpha(1f)
.translationY(0);
contactsRecycler.animate()
.alpha(1f)
.translationY(0);
}
private void animateOutSelection() {
contactsRecyclerDivider.animate()
.alpha(0f)
.translationY(ViewUtil.dpToPx(48));
contactsRecycler.animate()
.alpha(0f)
.translationY(ViewUtil.dpToPx(48));
}
private void initializeIntent() {
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
int mode = DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_SELF | DisplayMode.FLAG_HIDE_NEW;
if (Util.isDefaultSmsProvider(this)) {
mode |= DisplayMode.FLAG_SMS;
}
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);
getIntent().putExtra(ContactSelectionListFragment.RV_CLIP, false);
getIntent().putExtra(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(48));
}
private void handleDirectShare() {
boolean isDirectShare = getIntent().hasExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID);
boolean intentHasRecipient = getIntent().hasExtra(EXTRA_RECIPIENT_ID);
if (intentHasRecipient) {
handleDestination();
} else if (isDirectShare) {
String extraShortcutId = getIntent().getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID);
SimpleTask.run(getLifecycle(),
() -> getDirectShareExtras(extraShortcutId),
extras -> {
if (extras != null) {
addShortcutExtrasToIntent(extras);
handleDestination();
}
}
);
}
}
/**
* @param extraShortcutId EXTRA_SHORTCUT_ID String as included in direct share intent
* @return shortcutExtras or null
*/
@WorkerThread
private @Nullable Bundle getDirectShareExtras(@NonNull String extraShortcutId) {
Bundle shortcutExtras = getShortcutExtrasFor(extraShortcutId);
if (shortcutExtras == null) {
shortcutExtras = createExtrasFromExtraShortcutId(extraShortcutId);
}
return shortcutExtras;
}
/**
* Search for dynamic shortcut originally declared in {@link ConversationUtil} and return extras
*
* @param extraShortcutId EXTRA_SHORTCUT_ID String as included in direct share intent
* @return shortcutExtras or null
*/
@WorkerThread
private @Nullable Bundle getShortcutExtrasFor(@NonNull String extraShortcutId) {
List<ShortcutInfoCompat> shortcuts = ShortcutManagerCompat.getDynamicShortcuts(this);
for (ShortcutInfoCompat shortcutInfo : shortcuts) {
if (extraShortcutId.equals(shortcutInfo.getId())) {
return shortcutInfo.getIntent().getExtras();
}
}
return null;
}
/**
* @param extraShortcutId EXTRA_SHORTCUT_ID string as included in direct share intent
*/
@WorkerThread
private @Nullable Bundle createExtrasFromExtraShortcutId(@NonNull String extraShortcutId) {
Bundle extras = new Bundle();
RecipientId recipientId = ConversationUtil.getRecipientId(extraShortcutId);
Long threadId = null;
int distributionType = ThreadDatabase.DistributionTypes.DEFAULT;
if (recipientId != null) {
threadId = SignalDatabase.threads().getThreadIdFor(recipientId);
extras.putString(EXTRA_RECIPIENT_ID, recipientId.serialize());
extras.putLong(EXTRA_THREAD_ID, threadId != null ? threadId : -1);
extras.putInt(EXTRA_DISTRIBUTION_TYPE, distributionType);
return extras;
}
return null;
}
/**
* @param shortcutExtras as found by {@link ShareActivity#getShortcutExtrasFor)} or
* {@link ShareActivity#createExtrasFromExtraShortcutId)}
*/
private void addShortcutExtrasToIntent(@NonNull Bundle shortcutExtras) {
getIntent().putExtra(EXTRA_RECIPIENT_ID, shortcutExtras.getString(EXTRA_RECIPIENT_ID, null));
getIntent().putExtra(EXTRA_THREAD_ID, shortcutExtras.getLong(EXTRA_THREAD_ID, -1));
getIntent().putExtra(EXTRA_DISTRIBUTION_TYPE, shortcutExtras.getInt(EXTRA_DISTRIBUTION_TYPE, -1));
}
private void initializeToolbar() {
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(true);
}
}
private void initializeResources() {
searchToolbar = findViewById(R.id.search_toolbar);
searchAction = findViewById(R.id.search_action);
shareConfirm = findViewById(R.id.share_confirm);
shareContainer = findViewById(R.id.container);
contactsFragment = new ContactSelectionListFragment();
adapter = new ShareSelectionAdapter();
contactsRecycler = findViewById(R.id.selected_list);
contactsRecyclerDivider = findViewById(R.id.divider);
contactsRecycler.setAdapter(adapter);
RecyclerView.ItemAnimator itemAnimator = Objects.requireNonNull(contactsRecycler.getItemAnimator());
ShareFlowConstants.applySelectedContactsRecyclerAnimationSpeeds(itemAnimator);
getSupportFragmentManager().beginTransaction()
.replace(R.id.contact_selection_list_fragment, contactsFragment)
.commit();
shareConfirm.setOnClickListener(unused -> {
shareConfirm.setEnabled(false);
Set<ShareContact> shareContacts = viewModel.getShareContacts();
StoryDialogs.INSTANCE.guardWithAddToYourStoryDialog(this,
shareContacts.stream()
.filter(contact -> contact.getRecipientId().isPresent())
.map(contact -> Recipient.resolved(contact.getRecipientId().get()))
.filter(Recipient::isMyStory)
.map(myStory -> new ContactSearchKey.Story(myStory.getId()))
.collect(java.util.stream.Collectors.toList()),
() -> {
performSend(shareContacts);
return Unit.INSTANCE;
},
() -> {
shareConfirm.setEnabled(true);
new HideStoryFromDialogFragment().show(getSupportFragmentManager(), null);
return Unit.INSTANCE;
},
() -> {
shareConfirm.setEnabled(true);
return Unit.INSTANCE;
});
});
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();
}
});
viewModel.getSmsShareRestriction().observe(this, smsShareRestriction -> {
final int displayMode;
switch (smsShareRestriction) {
case NO_RESTRICTIONS:
disallowMultiShare = false;
displayMode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1);
if (displayMode == -1) {
Log.w(TAG, "DisplayMode not set yet.");
return;
}
if (Util.isDefaultSmsProvider(this) && (displayMode & DisplayMode.FLAG_SMS) == 0) {
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode | DisplayMode.FLAG_SMS);
contactsFragment.setQueryFilter(null);
}
break;
case DISALLOW_SMS_CONTACTS:
disallowMultiShare = false;
displayMode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1);
if (displayMode == -1) {
Log.w(TAG, "DisplayMode not set yet.");
return;
}
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode & ~DisplayMode.FLAG_SMS);
contactsFragment.setQueryFilter(null);
break;
case DISALLOW_MULTI_SHARE:
disallowMultiShare = true;
break;
}
validateAvailableRecipients();
});
}
private void performSend(Set<ShareContact> shareContacts) {
if (shareContacts.isEmpty()) throw new AssertionError();
else if (shareContacts.size() == 1) onConfirmSingleDestination(shareContacts.iterator().next());
else onConfirmMultipleDestinations(shareContacts);
}
private void initializeArgs() {
this.args = ShareIntents.Args.from(getIntent());
}
private void initializeViewModel() {
this.viewModel = ViewModelProviders.of(this, new ShareViewModel.Factory()).get(ShareViewModel.class);
}
private void initializeSearch() {
//noinspection IntegerDivisionInFloatingPointContext
searchAction.setOnClickListener(v -> searchToolbar.display(searchAction.getX() + (searchAction.getWidth() / 2),
searchAction.getY() + (searchAction.getHeight() / 2)));
searchToolbar.setListener(new SearchToolbar.SearchListener() {
@Override
public void onSearchTextChange(String text) {
if (contactsFragment != null) {
contactsFragment.setQueryFilter(text);
}
}
@Override
public void onSearchClosed() {
if (contactsFragment != null) {
contactsFragment.resetQueryFilter();
}
}
});
}
private void initializeMedia() {
if (Intent.ACTION_SEND_MULTIPLE.equals(getIntent().getAction())) {
Log.i(TAG, "Multiple media share.");
List<Uri> uris = getIntent().getParcelableArrayListExtra(Intent.EXTRA_STREAM);
viewModel.onMultipleMediaShared(uris);
} else if (Intent.ACTION_SEND.equals(getIntent().getAction()) || getIntent().hasExtra(Intent.EXTRA_STREAM)) {
Log.i(TAG, "Single media share.");
Uri uri = getIntent().getParcelableExtra(Intent.EXTRA_STREAM);
String type = getIntent().getType();
viewModel.onSingleMediaShared(uri, type);
} else {
Log.i(TAG, "Internal media share.");
viewModel.onNonExternalShare();
}
}
private void handleDestination() {
Intent intent = getIntent();
long threadId = intent.getLongExtra(EXTRA_THREAD_ID, -1);
int distributionType = intent.getIntExtra(EXTRA_DISTRIBUTION_TYPE, -1);
RecipientId recipientId = RecipientId.from(intent.getStringExtra(EXTRA_RECIPIENT_ID));
boolean hasPreexistingDestination = threadId != -1 && distributionType != -1;
if (hasPreexistingDestination) {
if (contactsFragment.getView() != null) {
contactsFragment.getView().setVisibility(View.GONE);
}
onSingleDestinationChosen(threadId, recipientId);
}
}
private void onConfirmSingleDestination(@NonNull ShareContact shareContact) {
if (shareContact.getRecipientId().isPresent() && Recipient.resolved(shareContact.getRecipientId().get()).isDistributionList()) {
onConfirmMultipleDestinations(Collections.singleton(shareContact));
return;
}
shareConfirm.setClickable(false);
SimpleTask.run(this.getLifecycle(),
() -> resolveShareContact(shareContact),
result -> onSingleDestinationChosen(result.getThreadId(), result.getRecipientId()));
}
private void onConfirmMultipleDestinations(@NonNull Set<ShareContact> shareContacts) {
shareConfirm.setClickable(false);
SimpleTask.run(this.getLifecycle(),
() -> resolvedShareContacts(shareContacts),
this::onMultipleDestinationsChosen);
}
private Set<ShareContactAndThread> resolvedShareContacts(@NonNull Set<ShareContact> sharedContacts) {
Set<Recipient> recipients = Stream.of(sharedContacts)
.map(contact -> contact.getRecipientId()
.map(Recipient::resolved)
.orElseGet(() -> Recipient.external(this, contact.getNumber())))
.collect(Collectors.toSet());
Map<RecipientId, Long> existingThreads = SignalDatabase.threads()
.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(), recipient.isDistributionList()))
.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 = SignalDatabase.threads().getThreadIdIfExistsFor(recipient.getId());
return new ShareContactAndThread(recipient.getId(), existingThread, recipient.isForceSmsSelection() || !recipient.isRegistered(), recipient.isDistributionList());
}
private void validateAvailableRecipients() {
resolveShareData(data -> {
int mode = getIntent().getIntExtra(ContactSelectionListFragment.DISPLAY_MODE, -1);
if (mode == -1) return;
boolean isMmsOrSmsSupported = data != null ? data.isMmsOrSmsSupported() : Util.isDefaultSmsProvider(this);
boolean isStoriesSupported = Stories.isFeatureEnabled() && data != null && data.isStoriesSupported();
mode = isMmsOrSmsSupported ? mode | DisplayMode.FLAG_SMS : mode & ~DisplayMode.FLAG_SMS;
mode = isStoriesSupported ? mode | DisplayMode.FLAG_STORIES : mode & ~DisplayMode.FLAG_STORIES;
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, mode);
contactsFragment.reset();
});
}
private void resolveShareData(@NonNull Consumer<ShareData> onResolved) {
AtomicReference<AlertDialog> progressWheel = new AtomicReference<>();
if (viewModel.getShareData().getValue() == null) {
progressWheel.set(SimpleProgressDialog.show(this));
}
viewModel.getShareData().observe(this, (data) -> {
if (data == null) return;
if (progressWheel.get() != null) {
progressWheel.get().dismiss();
progressWheel.set(null);
}
if (!data.isPresent() && args.isEmpty()) {
Log.w(TAG, "No data to share!");
Toast.makeText(this, R.string.ShareActivity_multiple_attachments_are_only_supported, Toast.LENGTH_LONG).show();
finish();
return;
}
onResolved.accept(data.orElse(null));
});
}
private void onMultipleDestinationsChosen(@NonNull Set<ShareContactAndThread> 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) {
ConversationIntents.Builder builder = ConversationIntents.createBuilder(this, recipientId, threadId)
.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.");
}
viewModel.onSuccessfulShare();
finish();
startActivity(builder.build());
}
private void openInterstitial(@NonNull Set<ShareContactAndThread> shareContactAndThreads, @Nullable ShareData shareData) {
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> media = new ArrayList<>(multiShareArgs.getMedia());
if (media.isEmpty()) {
media.add(new Media(multiShareArgs.getDataUri(),
multiShareArgs.getDataType(),
0,
0,
0,
0,
0,
false,
false,
Optional.empty(),
Optional.empty(),
Optional.empty()));
}
Intent intent = MediaSelectionActivity.share(this,
MultiShareSender.getWorstTransportOption(this, multiShareArgs.getShareContactAndThreads()),
media,
Stream.of(multiShareArgs.getShareContactAndThreads()).map(ShareContactAndThread::getRecipientId).toList(),
multiShareArgs.getDraftText());
startActivityForResult(intent, RESULT_MEDIA_CONFIRMATION);
break;
default:
//noinspection CodeBlock2Expr
MultiShareSender.send(multiShareArgs, results -> {
MultiShareDialogs.displayResultDialog(this, results, () -> {
viewModel.onSuccessfulShare();
finish();
});
});
break;
}
}
@Override
public void onSuggestedLimitReached(int limit) {
}
@Override
public void onHardLimitReached(int limit) {
MultiShareDialogs.displayMaxSelectedDialog(this, limit);
}
}

View File

@@ -1,89 +0,0 @@
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;
private final boolean isStory;
public ShareContactAndThread(@NonNull RecipientId recipientId, long threadId, boolean forceSms, boolean isStory) {
this.recipientId = recipientId;
this.threadId = threadId;
this.forceSms = forceSms;
this.isStory = isStory;
}
protected ShareContactAndThread(@NonNull Parcel in) {
recipientId = in.readParcelable(RecipientId.class.getClassLoader());
threadId = in.readLong();
forceSms = in.readByte() == 1;
isStory = in.readByte() == 1;
}
public @NonNull RecipientId getRecipientId() {
return recipientId;
}
public long getThreadId() {
return threadId;
}
public boolean isForceSms() {
return forceSms;
}
public boolean isStory() {
return isStory;
}
@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 &&
isStory == that.isStory &&
recipientId.equals(that.recipientId);
}
@Override
public int hashCode() {
return Objects.hash(recipientId, threadId, forceSms, isStory);
}
@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));
dest.writeByte((byte) (isStory ? 1 : 0));
}
public static final Creator<ShareContactAndThread> CREATOR = new Creator<ShareContactAndThread>() {
@Override
public ShareContactAndThread createFromParcel(@NonNull Parcel in) {
return new ShareContactAndThread(in);
}
@Override
public ShareContactAndThread[] newArray(int size) {
return new ShareContactAndThread[size];
}
};
}

View File

@@ -1,83 +0,0 @@
package org.thoughtcrime.securesms.sharing;
import android.net.Uri;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
class ShareData {
private final Optional<Uri> uri;
private final Optional<String> mimeType;
private final Optional<ArrayList<Media>> media;
private final boolean external;
private final boolean isMmsOrSmsSupported;
static ShareData forIntentData(@NonNull Uri uri, @NonNull String mimeType, boolean external, boolean isMmsOrSmsSupported) {
return new ShareData(Optional.of(uri), Optional.of(mimeType), Optional.empty(), external, isMmsOrSmsSupported);
}
static ShareData forPrimitiveTypes() {
return new ShareData(Optional.empty(), Optional.empty(), Optional.empty(), true, true);
}
static ShareData forMedia(@NonNull List<Media> media, boolean isMmsOrSmsSupported) {
return new ShareData(Optional.empty(), Optional.empty(), Optional.of(new ArrayList<>(media)), true, isMmsOrSmsSupported);
}
private ShareData(Optional<Uri> uri, Optional<String> mimeType, Optional<ArrayList<Media>> media, boolean external, boolean isMmsOrSmsSupported) {
this.uri = uri;
this.mimeType = mimeType;
this.media = media;
this.external = external;
this.isMmsOrSmsSupported = isMmsOrSmsSupported;
}
boolean isForIntent() {
return uri.isPresent();
}
boolean isForPrimitive() {
return !uri.isPresent() && !media.isPresent();
}
boolean isForMedia() {
return media.isPresent();
}
public @NonNull Uri getUri() {
return uri.get();
}
public @NonNull String getMimeType() {
return mimeType.get();
}
public @NonNull ArrayList<Media> getMedia() {
return media.get();
}
public boolean isExternal() {
return external;
}
public boolean isMmsOrSmsSupported() {
return isMmsOrSmsSupported;
}
public boolean isStoriesSupported() {
if (isForIntent()) {
return MediaUtil.isStorySupportedType(getMimeType());
} else if (isForMedia()) {
return getMedia().stream().allMatch(media -> MediaUtil.isStorySupportedType(media.getMimeType()));
} else {
return false;
}
}
}

View File

@@ -1,70 +0,0 @@
package org.thoughtcrime.securesms.sharing;
import android.content.Intent;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import java.util.ArrayList;
final class ShareIntents {
private static final String EXTRA_MEDIA = "extra_media";
private static final String EXTRA_BORDERLESS = "extra_borderless";
private static final String EXTRA_STICKER = "extra_sticker";
private ShareIntents() {
}
public static final class Args {
private final CharSequence extraText;
private final ArrayList<Media> extraMedia;
private final StickerLocator extraSticker;
private final boolean isBorderless;
public static Args from(@NonNull Intent intent) {
return new Args(intent.getStringExtra(Intent.EXTRA_TEXT),
intent.getParcelableArrayListExtra(EXTRA_MEDIA),
intent.getParcelableExtra(EXTRA_STICKER),
intent.getBooleanExtra(EXTRA_BORDERLESS, false));
}
private Args(@Nullable CharSequence extraText,
@Nullable ArrayList<Media> extraMedia,
@Nullable StickerLocator extraSticker,
boolean isBorderless)
{
this.extraText = extraText;
this.extraMedia = extraMedia;
this.extraSticker = extraSticker;
this.isBorderless = isBorderless;
}
public @Nullable ArrayList<Media> getExtraMedia() {
return extraMedia;
}
public @Nullable CharSequence getExtraText() {
return extraText;
}
public @Nullable StickerLocator getExtraSticker() {
return extraSticker;
}
public boolean isBorderless() {
return isBorderless;
}
public boolean isEmpty() {
return extraSticker == null &&
(extraMedia == null || extraMedia.isEmpty()) &&
TextUtils.isEmpty(extraText);
}
}
}

View File

@@ -1,256 +0,0 @@
package org.thoughtcrime.securesms.sharing;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.core.content.ContextCompat;
import com.annimon.stream.Collectors;
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.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.UriAttachment;
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.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.UriUtil;
import org.thoughtcrime.securesms.util.Util;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
class ShareRepository {
private static final String TAG = Log.tag(ShareRepository.class);
/**
* Handles a single URI that may be local or external.
*/
void getResolved(@Nullable Uri uri, @Nullable String mimeType, @NonNull Callback<Optional<ShareData>> callback) {
SignalExecutors.BOUNDED.execute(() -> {
try {
callback.onResult(Optional.of(getResolvedInternal(uri, mimeType)));
} catch (IOException e) {
Log.w(TAG, "Failed to resolve!", e);
callback.onResult(Optional.empty());
}
});
}
/**
* Handles multiple URIs that are all assumed to be external images/videos.
*/
void getResolved(@NonNull List<Uri> uris, @NonNull Callback<Optional<ShareData>> callback) {
SignalExecutors.BOUNDED.execute(() -> {
try {
callback.onResult(Optional.ofNullable(getResolvedInternal(uris)));
} catch (IOException e) {
Log.w(TAG, "Failed to resolve!", e);
callback.onResult(Optional.empty());
}
});
}
@WorkerThread
private @NonNull ShareData getResolvedInternal(@Nullable Uri uri, @Nullable String mimeType) throws IOException {
Context context = ApplicationDependencies.getApplication();
if (uri == null) {
return ShareData.forPrimitiveTypes();
}
if (!UriUtil.isValidExternalUri(context, uri)) {
throw new IOException("Invalid external URI!");
}
mimeType = getMimeType(context, uri, mimeType);
if (PartAuthority.isLocalUri(uri)) {
return ShareData.forIntentData(uri, mimeType, false, false);
} else {
InputStream stream = null;
try {
stream = context.getContentResolver().openInputStream(uri);
} catch (SecurityException e) {
Log.w(TAG, "Failed to read stream!", e);
}
if (stream == null) {
throw new IOException("Failed to open stream!");
}
long size = getSize(context, uri);
String fileName = getFileName(context, uri);
Uri blobUri;
if (MediaUtil.isImageType(mimeType) || MediaUtil.isVideoType(mimeType)) {
blobUri = BlobProvider.getInstance()
.forData(stream, size)
.withMimeType(mimeType)
.withFileName(fileName)
.createForSingleSessionOnDisk(context);
} else {
blobUri = BlobProvider.getInstance()
.forData(stream, size)
.withMimeType(mimeType)
.withFileName(fileName)
.createForSingleSessionOnDisk(context);
// TODO Convert to multi-session after file drafts are fixed.
}
return ShareData.forIntentData(blobUri, mimeType, true, isMmsSupported(context, asUriAttachment(blobUri, mimeType, size)));
}
}
private @NonNull UriAttachment asUriAttachment(@NonNull Uri uri, @NonNull String mimeType, long size) {
return new UriAttachment(uri, mimeType, -1, size, null, false, false, false, false, null, null, null, null, null);
}
private boolean isMmsSupported(@NonNull Context context, @NonNull Attachment attachment) {
boolean canReadPhoneState = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED;
if (!Util.isDefaultSmsProvider(context) || !canReadPhoneState || !Util.isMmsCapable(context)) {
return false;
}
TransportOptions options = new TransportOptions(context, true);
options.setDefaultTransport(TransportOption.Type.SMS);
MediaConstraints mmsConstraints = MediaConstraints.getMmsMediaConstraints(options.getSelectedTransport().getSimSubscriptionId().orElse(-1));
return mmsConstraints.isSatisfied(context, attachment) || mmsConstraints.canResize(attachment);
}
@WorkerThread
private @Nullable ShareData getResolvedInternal(@NonNull List<Uri> uris) throws IOException {
Context context = ApplicationDependencies.getApplication();
Map<Uri, String> mimeTypes = Stream.of(uris)
.map(uri -> new Pair<>(uri, getMimeType(context, uri, null)))
.filter(p -> MediaUtil.isImageType(p.second) || MediaUtil.isVideoType(p.second))
.collect(Collectors.toMap(p -> p.first, p -> p.second));
if (mimeTypes.isEmpty()) {
return null;
}
List<Media> media = new ArrayList<>(mimeTypes.size());
for (Map.Entry<Uri, String> entry : mimeTypes.entrySet()) {
Uri uri = entry.getKey();
String mimeType = entry.getValue();
InputStream stream;
try {
stream = context.getContentResolver().openInputStream(uri);
if (stream == null) {
throw new IOException("Failed to open stream!");
}
} catch (IOException e) {
Log.w(TAG, "Failed to open: " + uri);
continue;
}
long size = getSize(context, uri);
Pair<Integer, Integer> dimens = MediaUtil.getDimensions(context, mimeType, uri);
long duration = getDuration(context, uri);
Uri blobUri = BlobProvider.getInstance()
.forData(stream, size)
.withMimeType(mimeType)
.createForSingleSessionOnDisk(context);
media.add(new Media(blobUri,
mimeType,
System.currentTimeMillis(),
dimens.first,
dimens.second,
size,
duration,
false,
false,
Optional.of(Media.ALL_MEDIA_BUCKET_ID),
Optional.empty(),
Optional.empty()));
if (media.size() >= MediaSendConstants.MAX_PUSH) {
Log.w(TAG, "Exceeded the attachment limit! Skipping the rest.");
break;
}
}
if (media.size() > 0) {
boolean isMmsSupported = Stream.of(media)
.allMatch(m -> isMmsSupported(context, asUriAttachment(m.getUri(), m.getMimeType(), m.getSize())));
return ShareData.forMedia(media, isMmsSupported);
} else {
return null;
}
}
private static @NonNull String getMimeType(@NonNull Context context, @NonNull Uri uri, @Nullable String mimeType) {
String updatedMimeType = MediaUtil.getMimeType(context, uri);
if (updatedMimeType == null) {
updatedMimeType = MediaUtil.getCorrectedMimeType(mimeType);
}
return updatedMimeType != null ? updatedMimeType : MediaUtil.UNKNOWN;
}
private static long getSize(@NonNull Context context, @NonNull Uri uri) throws IOException {
long size = 0;
try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) {
size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
}
}
if (size <= 0) {
size = MediaUtil.getMediaSize(context, uri);
}
return size;
}
private static @Nullable String getFileName(@NonNull Context context, @NonNull Uri uri) {
if (uri.getScheme().equalsIgnoreCase("file")) {
return uri.getLastPathSegment();
}
try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) >= 0) {
return cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME));
}
}
return null;
}
private static long getDuration(@NonNull Context context, @NonNull Uri uri) {
return 0;
}
interface Callback<E> {
void onResult(@NonNull E result);
}
}

View File

@@ -1,190 +0,0 @@
package org.thoughtcrime.securesms.sharing;
import android.content.Context;
import android.net.Uri;
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.database.GroupDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import io.reactivex.rxjava3.core.Single;
public class ShareViewModel extends ViewModel {
private static final String TAG = Log.tag(ShareViewModel.class);
private final Context context;
private final ShareRepository shareRepository;
private final MutableLiveData<Optional<ShareData>> shareData;
private final MutableLiveData<Set<ShareContact>> selectedContacts;
private final LiveData<SmsShareRestriction> smsShareRestriction;
private boolean mediaUsed;
private boolean externalShare;
private ShareViewModel() {
this.context = ApplicationDependencies.getApplication();
this.shareRepository = new ShareRepository();
this.shareData = new MutableLiveData<>();
this.selectedContacts = new DefaultValueLiveData<>(Collections.emptySet());
this.smsShareRestriction = Transformations.map(selectedContacts, this::updateShareRestriction);
}
void onSingleMediaShared(@NonNull Uri uri, @Nullable String mimeType) {
externalShare = true;
shareRepository.getResolved(uri, mimeType, shareData::postValue);
}
void onMultipleMediaShared(@NonNull List<Uri> uris) {
externalShare = true;
shareRepository.getResolved(uris, shareData::postValue);
}
boolean isMultiShare() {
return selectedContacts.getValue().size() > 1;
}
@NonNull Single<ContactSelectResult> onContactSelected(@NonNull ShareContact selectedContact) {
return Single.fromCallable(() -> {
if (selectedContact.getRecipientId().isPresent()) {
Recipient recipient = Recipient.resolved(selectedContact.getRecipientId().get());
if (recipient.isPushV2Group()) {
Optional<GroupDatabase.GroupRecord> record = SignalDatabase.groups().getGroup(recipient.requireGroupId());
if (record.isPresent() && record.get().isAnnouncementGroup() && !record.get().isAdmin(Recipient.self())) {
return ContactSelectResult.FALSE_AND_SHOW_PERMISSION_TOAST;
}
} else if (SmsShareRestriction.DISALLOW_SMS_CONTACTS.equals(smsShareRestriction.getValue()) && isRecipientAnSmsContact(recipient)) {
return ContactSelectResult.FALSE_AND_SHOW_SMS_MULTISELECT_TOAST;
}
}
Set<ShareContact> contacts = new LinkedHashSet<>(selectedContacts.getValue());
if (contacts.add(selectedContact)) {
selectedContacts.postValue(contacts);
return ContactSelectResult.TRUE;
} else {
return ContactSelectResult.FALSE;
}
});
}
void onContactDeselected(@NonNull ShareContact selectedContact) {
Set<ShareContact> contacts = new LinkedHashSet<>(selectedContacts.getValue());
if (contacts.remove(selectedContact)) {
selectedContacts.setValue(contacts);
}
}
@NonNull Set<ShareContact> getShareContacts() {
Set<ShareContact> contacts = selectedContacts.getValue();
if (contacts == null) {
return Collections.emptySet();
} else {
return contacts;
}
}
@NonNull LiveData<MappingModelList> getSelectedContactModels() {
return Transformations.map(selectedContacts, set -> Stream.of(set)
.mapIndexed((i, c) -> new ShareSelectionMappingModel(c, i == 0))
.collect(MappingModelList.toMappingModelList()));
}
@NonNull LiveData<SmsShareRestriction> getSmsShareRestriction() {
return Transformations.distinctUntilChanged(smsShareRestriction);
}
void onNonExternalShare() {
shareData.setValue(Optional.empty());
externalShare = false;
}
public void onSuccessfulShare() {
mediaUsed = true;
}
@NonNull LiveData<Optional<ShareData>> getShareData() {
return shareData;
}
boolean isExternalShare() {
return externalShare;
}
@Override
protected void onCleared() {
ShareData data = shareData.getValue() != null ? shareData.getValue().orElse(null) : null;
if (data != null && data.isExternal() && data.isForIntent() && !mediaUsed) {
Log.i(TAG, "Clearing out unused data.");
BlobProvider.getInstance().delete(context, data.getUri());
}
}
private @NonNull SmsShareRestriction updateShareRestriction(@NonNull Set<ShareContact> shareContacts) {
if (shareContacts.isEmpty()) {
return SmsShareRestriction.NO_RESTRICTIONS;
} else if (shareContacts.size() == 1) {
ShareContact shareContact = shareContacts.iterator().next();
if (shareContact.getRecipientId().isPresent()) {
Recipient recipient = Recipient.live(shareContact.getRecipientId().get()).get();
if (isRecipientAnSmsContact(recipient)) {
return SmsShareRestriction.DISALLOW_MULTI_SHARE;
} else {
return SmsShareRestriction.DISALLOW_SMS_CONTACTS;
}
} else {
return SmsShareRestriction.DISALLOW_MULTI_SHARE;
}
} else {
return SmsShareRestriction.DISALLOW_SMS_CONTACTS;
}
}
private static boolean isRecipientAnSmsContact(@NonNull Recipient recipient) {
return !recipient.isDistributionList() && (!recipient.isRegistered() || recipient.isForceSmsSelection());
}
enum ContactSelectResult {
TRUE, FALSE, FALSE_AND_SHOW_PERMISSION_TOAST, FALSE_AND_SHOW_SMS_MULTISELECT_TOAST
}
enum SmsShareRestriction {
NO_RESTRICTIONS,
DISALLOW_SMS_CONTACTS,
DISALLOW_MULTI_SHARE
}
public static class Factory extends ViewModelProvider.NewInstanceFactory {
@Override
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ShareViewModel());
}
}
}

View File

@@ -7,7 +7,7 @@ import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.lifecycle.ViewModelProviders;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
@@ -79,21 +79,25 @@ public class ShareInterstitialActivity extends PassphraseRequiredActivity {
ShareInterstitialRepository repository = new ShareInterstitialRepository();
ShareInterstitialViewModel.Factory factory = new ShareInterstitialViewModel.Factory(args, repository);
viewModel = ViewModelProviders.of(this, factory).get(ShareInterstitialViewModel.class);
viewModel = new ViewModelProvider(this, factory).get(ShareInterstitialViewModel.class);
LinkPreviewRepository linkPreviewRepository = new LinkPreviewRepository();
LinkPreviewViewModel.Factory linkPreviewViewModelFactory = new LinkPreviewViewModel.Factory(linkPreviewRepository);
linkPreviewViewModel = ViewModelProviders.of(this, linkPreviewViewModelFactory).get(LinkPreviewViewModel.class);
linkPreviewViewModel = new ViewModelProvider(this, linkPreviewViewModelFactory).get(LinkPreviewViewModel.class);
boolean hasSms = Stream.of(args.getShareContactAndThreads())
boolean hasSms = Stream.of(args.getRecipientSearchKeys())
.anyMatch(c -> {
Recipient recipient = Recipient.resolved(c.getRecipientId());
if (recipient.isDistributionList()) {
return false;
}
return !recipient.isRegistered() || recipient.isForceSmsSelection();
});
if (hasSms) {
linkPreviewViewModel.onTransportChanged(hasSms);
linkPreviewViewModel.onTransportChanged(true);
}
}

View File

@@ -7,22 +7,22 @@ import androidx.core.util.Consumer;
import com.annimon.stream.Stream;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
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<ShareContactAndThread> shareContactAndThreads, Consumer<List<Recipient>> consumer) {
SignalExecutors.BOUNDED.execute(() -> consumer.accept(resolveRecipients(shareContactAndThreads)));
void loadRecipients(@NonNull Set<ContactSearchKey.RecipientSearchKey> recipientSearchKeys, Consumer<List<Recipient>> consumer) {
SignalExecutors.BOUNDED.execute(() -> consumer.accept(resolveRecipients(recipientSearchKeys)));
}
@WorkerThread
private List<Recipient> resolveRecipients(@NonNull Set<ShareContactAndThread> shareContactAndThreads) {
return Stream.of(shareContactAndThreads)
.map(ShareContactAndThread::getRecipientId)
private List<Recipient> resolveRecipients(@NonNull Set<ContactSearchKey.RecipientSearchKey> recipientSearchKeys) {
return Stream.of(recipientSearchKeys)
.map(ContactSearchKey.RecipientSearchKey::getRecipientId)
.map(Recipient::resolved)
.toList();
}

View File

@@ -33,7 +33,7 @@ private final MultiShareArgs args;
this.recipients = new MutableLiveData<>();
this.draftText = new DefaultValueLiveData<>(Util.firstNonNull(args.getDraftText(), ""));
repository.loadRecipients(args.getShareContactAndThreads(),
repository.loadRecipients(args.getRecipientSearchKeys(),
list -> recipients.postValue(Stream.of(list)
.mapIndexed((i, r) -> new ShareInterstitialMappingModel(r, i == 0))
.collect(MappingModelList.toMappingModelList())));

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.sharing.v2
import android.net.Uri
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import java.lang.UnsupportedOperationException
sealed class ResolvedShareData {
abstract val isMmsOrSmsSupported: Boolean
abstract fun toMultiShareArgs(): MultiShareArgs
data class Primitive(val text: CharSequence) : ResolvedShareData() {
override val isMmsOrSmsSupported: Boolean = true
override fun toMultiShareArgs(): MultiShareArgs {
return MultiShareArgs.Builder(setOf()).withDraftText(text.toString()).build()
}
}
data class ExternalUri(
val uri: Uri,
val mimeType: String,
override val isMmsOrSmsSupported: Boolean
) : ResolvedShareData() {
override fun toMultiShareArgs(): MultiShareArgs {
return MultiShareArgs.Builder(setOf()).withDataUri(uri).withDataType(mimeType).build()
}
}
data class Media(
val media: List<org.thoughtcrime.securesms.mediasend.Media>,
override val isMmsOrSmsSupported: Boolean
) : ResolvedShareData() {
override fun toMultiShareArgs(): MultiShareArgs {
return MultiShareArgs.Builder(setOf()).withMedia(media).build()
}
}
object Failure : ResolvedShareData() {
override val isMmsOrSmsSupported: Boolean get() = throw UnsupportedOperationException()
override fun toMultiShareArgs(): MultiShareArgs = throw UnsupportedOperationException()
}
}

View File

@@ -0,0 +1,215 @@
package org.thoughtcrime.securesms.sharing.v2
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutManagerCompat
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFullScreenDialogFragment
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity.Companion.share
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareDialogs
import org.thoughtcrime.securesms.sharing.MultiShareSender
import org.thoughtcrime.securesms.sharing.MultiShareSender.MultiShareSendResultCollection
import org.thoughtcrime.securesms.sharing.interstitial.ShareInterstitialActivity
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.LifecycleDisposable
import java.util.Optional
class ShareActivity : PassphraseRequiredActivity(), MultiselectForwardFragment.Callback {
private val dynamicTheme = DynamicNoActionBarTheme()
private val lifecycleDisposable = LifecycleDisposable()
private lateinit var finishOnOkResultLauncher: ActivityResultLauncher<Intent>
private val viewModel: ShareViewModel by viewModels {
ShareViewModel.Factory(getUnresolvedShareData(), directShareTarget, ShareRepository(this))
}
private val directShareTarget: RecipientId?
get() = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID).let { ConversationUtil.getRecipientId(it) }
override fun onPreCreate() {
super.onPreCreate()
dynamicTheme.onCreate(this)
}
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContentView(R.layout.share_activity_v2)
finishOnOkResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
finish()
}
}
lifecycleDisposable += viewModel.events.subscribe { shareEvent ->
when (shareEvent) {
is ShareEvent.OpenConversation -> openConversation(shareEvent)
is ShareEvent.OpenMediaInterstitial -> openMediaInterstitial(shareEvent)
is ShareEvent.OpenTextInterstitial -> openTextInterstitial(shareEvent)
is ShareEvent.SendWithoutInterstitial -> sendWithoutInterstitial(shareEvent)
}
}
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { shareState ->
when (shareState.loadState) {
ShareState.ShareDataLoadState.Init -> Unit
ShareState.ShareDataLoadState.Failed -> finish()
is ShareState.ShareDataLoadState.Loaded -> {
ensureFragment(shareState.loadState.resolvedShareData)
}
}
}
}
override fun onResume() {
super.onResume()
dynamicTheme.onResume(this)
}
override fun onFinishForwardAction() = Unit
override fun exitFlow() = Unit
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
if (bundle.containsKey(MultiselectForwardFragment.RESULT_SENT)) {
throw AssertionError("Should never happen.")
}
if (!bundle.containsKey(MultiselectForwardFragment.RESULT_SELECTION)) {
throw AssertionError("Expected a recipient selection!")
}
val parcelizedKeys: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
val contactSearchKeys = parcelizedKeys.map { it.asContactSearchKey() }
viewModel.onContactSelectionConfirmed(contactSearchKeys)
}
override fun getContainer(): ViewGroup = findViewById(R.id.container)
override fun getDialogBackgroundColor(): Int = ContextCompat.getColor(this, R.color.signal_background_primary)
private fun getUnresolvedShareData(): UnresolvedShareData {
return when {
intent.action == Intent.ACTION_SEND_MULTIPLE -> {
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let {
UnresolvedShareData.ExternalMultiShare(it)
} ?: error("ACTION_SEND_MULTIPLE with EXTRA_STREAM but the EXTRA_STREAM was null")
}
intent.action == Intent.ACTION_SEND && intent.hasExtra(Intent.EXTRA_STREAM) -> {
intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)?.let {
UnresolvedShareData.ExternalSingleShare(it, intent.type)
} ?: error("ACTION_SEND with EXTRA_STREAM but the EXTRA_STREAM was null")
}
intent.action == Intent.ACTION_SEND && intent.hasExtra(Intent.EXTRA_TEXT) -> {
intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.let {
UnresolvedShareData.ExternalPrimitiveShare(it)
} ?: error("ACTION_SEND with EXTRA_TEXT but the EXTRA_TEXT was null")
}
else -> null
} ?: error("Intent Action: ${Intent.ACTION_SEND_MULTIPLE} could not be resolved with the given arguments.")
}
private fun ensureFragment(resolvedShareData: ResolvedShareData) {
if (supportFragmentManager.fragments.none { it is MultiselectForwardFullScreenDialogFragment }) {
supportFragmentManager.beginTransaction()
.replace(
R.id.fragment_container,
MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
canSendToNonPush = resolvedShareData.isMmsOrSmsSupported,
multiShareArgs = listOf(resolvedShareData.toMultiShareArgs()),
title = R.string.MultiselectForwardFragment__share_with,
forceDisableAddMessage = true,
forceSelectionOnly = true
)
)
).commitNow()
}
}
private fun openConversation(shareEvent: ShareEvent.OpenConversation) {
if (shareEvent.contact.isStory) {
error("Can't open a conversation for a story!")
}
val multiShareArgs = shareEvent.getMultiShareArgs()
val conversationIntentBuilder = ConversationIntents.createBuilder(this, shareEvent.contact.recipientId, -1L)
.withDataUri(multiShareArgs.dataUri)
.withDataType(multiShareArgs.dataType)
.withMedia(multiShareArgs.media)
.withDraftText(multiShareArgs.draftText)
.withStickerLocator(multiShareArgs.stickerLocator)
.asBorderless(multiShareArgs.isBorderless)
finish()
startActivity(conversationIntentBuilder.build())
}
private fun openMediaInterstitial(shareEvent: ShareEvent.OpenMediaInterstitial) {
val multiShareArgs = shareEvent.getMultiShareArgs()
val media: MutableList<Media> = ArrayList(multiShareArgs.media)
if (media.isEmpty() && multiShareArgs.dataUri != null) {
media.add(
Media(
multiShareArgs.dataUri,
multiShareArgs.dataType,
0,
0,
0,
0,
0,
false,
false,
Optional.empty(),
Optional.empty(),
Optional.empty()
)
)
}
val shareAsTextStory = multiShareArgs.allRecipientsAreStories() && media.isEmpty()
val intent = share(
this,
MultiShareSender.getWorstTransportOption(this, multiShareArgs.recipientSearchKeys),
media,
multiShareArgs.recipientSearchKeys.toList(),
multiShareArgs.draftText,
shareAsTextStory
)
finishOnOkResultLauncher.launch(intent)
}
private fun openTextInterstitial(shareEvent: ShareEvent.OpenTextInterstitial) {
finishOnOkResultLauncher.launch(ShareInterstitialActivity.createIntent(this, shareEvent.getMultiShareArgs()))
}
private fun sendWithoutInterstitial(shareEvent: ShareEvent.SendWithoutInterstitial) {
MultiShareSender.send(shareEvent.getMultiShareArgs()) { results: MultiShareSendResultCollection? ->
MultiShareDialogs.displayResultDialog(this, results!!) {
finish()
}
}
}
}

View File

@@ -0,0 +1,24 @@
package org.thoughtcrime.securesms.sharing.v2
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.sharing.MultiShareArgs
sealed class ShareEvent {
protected abstract val shareData: ResolvedShareData
protected abstract val contacts: List<ContactSearchKey.RecipientSearchKey>
fun getMultiShareArgs(): MultiShareArgs {
return shareData.toMultiShareArgs().buildUpon(
contacts.toSet()
).build()
}
data class OpenConversation(override val shareData: ResolvedShareData, val contact: ContactSearchKey.RecipientSearchKey) : ShareEvent() {
override val contacts: List<ContactSearchKey.RecipientSearchKey> = listOf(contact)
}
data class OpenMediaInterstitial(override val shareData: ResolvedShareData, override val contacts: List<ContactSearchKey.RecipientSearchKey>) : ShareEvent()
data class OpenTextInterstitial(override val shareData: ResolvedShareData, override val contacts: List<ContactSearchKey.RecipientSearchKey>) : ShareEvent()
data class SendWithoutInterstitial(override val shareData: ResolvedShareData, override val contacts: List<ContactSearchKey.RecipientSearchKey>) : ShareEvent()
}

View File

@@ -0,0 +1,189 @@
package org.thoughtcrime.securesms.sharing.v2
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.OpenableColumns
import androidx.annotation.NonNull
import androidx.annotation.WorkerThread
import androidx.core.content.ContextCompat
import androidx.core.util.toKotlinPair
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.TransportOption
import org.thoughtcrime.securesms.TransportOptions
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.MediaSendConstants
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.UriUtil
import org.thoughtcrime.securesms.util.Util
import java.io.IOException
import java.io.InputStream
import java.util.Optional
class ShareRepository(context: Context) {
private val appContext = context.applicationContext
fun resolve(unresolvedShareData: UnresolvedShareData): Single<ResolvedShareData> {
return when (unresolvedShareData) {
is UnresolvedShareData.ExternalMultiShare -> Single.fromCallable { resolve(unresolvedShareData) }
is UnresolvedShareData.ExternalSingleShare -> Single.fromCallable { resolve(unresolvedShareData) }
is UnresolvedShareData.ExternalPrimitiveShare -> Single.just(ResolvedShareData.Primitive(unresolvedShareData.text))
}
}
@NonNull
@WorkerThread
@Throws(IOException::class)
private fun resolve(multiShareExternal: UnresolvedShareData.ExternalSingleShare): ResolvedShareData {
if (!UriUtil.isValidExternalUri(appContext, multiShareExternal.uri)) {
return ResolvedShareData.Failure
}
val uri = multiShareExternal.uri
val mimeType = getMimeType(appContext, uri, multiShareExternal.mimeType)
val stream: InputStream = try {
appContext.contentResolver.openInputStream(uri)
} catch (e: SecurityException) {
Log.w(TAG, "Failed to read stream!", e)
null
} ?: return ResolvedShareData.Failure
val size = getSize(appContext, uri)
val name = getFileName(appContext, uri)
val blobUri = BlobProvider.getInstance()
.forData(stream, size)
.withMimeType(mimeType)
.withFileName(name)
.createForSingleSessionOnDisk(appContext)
return ResolvedShareData.ExternalUri(
uri = blobUri,
mimeType = mimeType,
isMmsOrSmsSupported = isMmsSupported(appContext, asUriAttachment(blobUri, mimeType, size))
)
}
@NonNull
@WorkerThread
private fun resolve(externalMultiShare: UnresolvedShareData.ExternalMultiShare): ResolvedShareData {
val mimeTypes: Map<Uri, String> = externalMultiShare.uris
.associateWith { uri -> getMimeType(appContext, uri, null) }
.filterValues {
MediaUtil.isImageType(it) || MediaUtil.isVideoType(it)
}
if (mimeTypes.isEmpty()) {
return ResolvedShareData.Failure
}
val media: List<Media> = mimeTypes.toList()
.take(MediaSendConstants.MAX_PUSH)
.map { (uri, mimeType) ->
val stream: InputStream = try {
appContext.contentResolver.openInputStream(uri)
} catch (e: IOException) {
Log.w(TAG, "Failed to open: $uri")
return@map null
} ?: return ResolvedShareData.Failure
val size = getSize(appContext, uri)
val dimens: Pair<Int, Int> = MediaUtil.getDimensions(appContext, mimeType, uri).toKotlinPair()
val duration = 0L
val blobUri = BlobProvider.getInstance()
.forData(stream, size)
.withMimeType(mimeType)
.createForSingleSessionOnDisk(appContext)
Media(
blobUri,
mimeType,
System.currentTimeMillis(),
dimens.first,
dimens.second,
size,
duration,
false,
false,
Optional.of(Media.ALL_MEDIA_BUCKET_ID),
Optional.empty(),
Optional.empty()
)
}.filterNotNull()
return if (media.isNotEmpty()) {
val isMmsSupported = media.all { isMmsSupported(appContext, asUriAttachment(it.uri, it.mimeType, it.size)) }
ResolvedShareData.Media(media, isMmsSupported)
} else {
ResolvedShareData.Failure
}
}
companion object {
private val TAG = Log.tag(ShareRepository::class.java)
private fun getMimeType(context: Context, uri: Uri, mimeType: String?): String {
var updatedMimeType = MediaUtil.getMimeType(context, uri)
if (updatedMimeType == null) {
updatedMimeType = MediaUtil.getCorrectedMimeType(mimeType)
}
return updatedMimeType ?: MediaUtil.UNKNOWN
}
@Throws(IOException::class)
private fun getSize(context: Context, uri: Uri): Long {
var size: Long = 0
context.contentResolver.query(uri, null, null, null, null).use { cursor ->
if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.SIZE) >= 0) {
size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE))
}
}
if (size <= 0) {
size = MediaUtil.getMediaSize(context, uri)
}
return size
}
private fun getFileName(context: Context, uri: Uri): String? {
if (uri.scheme.equals("file", ignoreCase = true)) {
return uri.lastPathSegment
}
context.contentResolver.query(uri, null, null, null, null).use { cursor ->
if (cursor != null && cursor.moveToFirst() && cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) >= 0) {
return cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
}
}
return null
}
private fun asUriAttachment(uri: Uri, mimeType: String, size: Long): UriAttachment {
return UriAttachment(uri, mimeType, -1, size, null, false, false, false, false, null, null, null, null, null)
}
private fun isMmsSupported(context: Context, attachment: Attachment): Boolean {
val canReadPhoneState = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED
if (!Util.isDefaultSmsProvider(context) || !canReadPhoneState || !Util.isMmsCapable(context)) {
return false
}
val options = TransportOptions(context, true)
options.setDefaultTransport(TransportOption.Type.SMS)
val mmsConstraints = MediaConstraints.getMmsMediaConstraints(options.selectedTransport.simSubscriptionId.orElse(-1))
return mmsConstraints.isSatisfied(context, attachment) || mmsConstraints.canResize(attachment)
}
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.sharing.v2
data class ShareState(
val loadState: ShareDataLoadState = ShareDataLoadState.Init
) {
sealed class ShareDataLoadState {
object Init : ShareDataLoadState()
data class Loaded(val resolvedShareData: ResolvedShareData) : ShareDataLoadState()
object Failed : ShareDataLoadState()
}
}

View File

@@ -0,0 +1,96 @@
package org.thoughtcrime.securesms.sharing.v2
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.InterstitialContentType
import org.thoughtcrime.securesms.util.rx.RxStore
class ShareViewModel(
unresolvedShareData: UnresolvedShareData,
directShareTarget: RecipientId?,
shareRepository: ShareRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(ShareViewModel::class.java)
}
private val store = RxStore(ShareState())
private val disposables = CompositeDisposable()
private val eventSubject = PublishSubject.create<ShareEvent>()
val state: Flowable<ShareState> = store.stateFlowable
val events: Observable<ShareEvent> = eventSubject
init {
disposables += shareRepository.resolve(unresolvedShareData).subscribeBy(
onSuccess = { data ->
when {
data == ResolvedShareData.Failure -> {
moveToFailedState()
}
directShareTarget != null -> {
eventSubject.onNext(ShareEvent.OpenConversation(data, ContactSearchKey.RecipientSearchKey.KnownRecipient(directShareTarget)))
}
else -> {
store.update { it.copy(loadState = ShareState.ShareDataLoadState.Loaded(data)) }
}
}
},
onError = this::moveToFailedState
)
}
fun onContactSelectionConfirmed(contactSearchKeys: List<ContactSearchKey>) {
val loadState = store.state.loadState
if (loadState !is ShareState.ShareDataLoadState.Loaded) {
return
}
val recipientKeys = contactSearchKeys.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java)
val hasStory = recipientKeys.any { it.isStory }
val openConversation = !hasStory && recipientKeys.size == 1
val resolvedShareData = loadState.resolvedShareData
if (openConversation) {
eventSubject.onNext(ShareEvent.OpenConversation(resolvedShareData, recipientKeys.first()))
return
}
val event = when (resolvedShareData.toMultiShareArgs().interstitialContentType) {
InterstitialContentType.MEDIA -> ShareEvent.OpenMediaInterstitial(resolvedShareData, recipientKeys)
InterstitialContentType.TEXT -> ShareEvent.OpenTextInterstitial(resolvedShareData, recipientKeys)
InterstitialContentType.NONE -> ShareEvent.SendWithoutInterstitial(resolvedShareData, recipientKeys)
}
eventSubject.onNext(event)
}
override fun onCleared() {
disposables.clear()
}
private fun moveToFailedState(throwable: Throwable? = null) {
Log.w(TAG, "Could not load share data.", throwable)
store.update { it.copy(loadState = ShareState.ShareDataLoadState.Failed) }
}
class Factory(
private val unresolvedShareData: UnresolvedShareData,
private val directShareTarget: RecipientId?,
private val shareRepository: ShareRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(ShareViewModel(unresolvedShareData, directShareTarget, shareRepository)) as T
}
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.sharing.v2
import android.net.Uri
sealed class UnresolvedShareData {
data class ExternalMultiShare(val uris: List<Uri>) : UnresolvedShareData()
data class ExternalSingleShare(val uri: Uri, val mimeType: String?) : UnresolvedShareData()
data class ExternalPrimitiveShare(val text: CharSequence) : UnresolvedShareData()
}