Optimize uploads during media composition.

By uploading in advance (when on unmetered connections), media messages
can send almost instantly.
This commit is contained in:
Greyson Parrelli 2020-01-08 15:56:51 -05:00
parent 92e97e61c1
commit fadcc606f8
37 changed files with 1413 additions and 452 deletions

View File

@ -37,6 +37,7 @@ import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.ringrtc.CallConnectionFactory;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
@ -72,6 +73,7 @@ import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
import org.thoughtcrime.securesms.stickers.BlessedPacks;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.webrtc.voiceengine.WebRtcAudioManager;
import org.webrtc.voiceengine.WebRtcAudioUtils;
@ -129,6 +131,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
initializeRingRtc();
initializePendingMessages();
initializeBlobProvider();
initializeCleanup();
initializeCameraX();
NotificationChannels.create(this);
ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
@ -367,11 +370,18 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
}
private void initializeBlobProvider() {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
SignalExecutors.BOUNDED.execute(() -> {
BlobProvider.getInstance().onSessionStart(this);
});
}
private void initializeCleanup() {
SignalExecutors.BOUNDED.execute(() -> {
int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments();
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
});
}
@SuppressLint("RestrictedApi")
private void initializeCameraX() {
if (CameraXUtil.isSupported()) {

View File

@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.attachments;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.thoughtcrime.securesms.util.Util;
public class AttachmentId {
public class AttachmentId implements Parcelable {
@JsonProperty
private final long rowId;
@ -19,6 +22,11 @@ public class AttachmentId {
this.uniqueId = uniqueId;
}
private AttachmentId(Parcel in) {
this.rowId = in.readLong();
this.uniqueId = in.readLong();
}
public long getRowId() {
return rowId;
}
@ -54,4 +62,28 @@ public class AttachmentId {
public int hashCode() {
return Util.hashCode(rowId, uniqueId);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeLong(rowId);
dest.writeLong(uniqueId);
}
public static final Creator<AttachmentId> CREATOR = new Creator<AttachmentId>() {
@Override
public AttachmentId createFromParcel(Parcel in) {
return new AttachmentId(in);
}
@Override
public AttachmentId[] newArray(int size) {
return new AttachmentId[size];
}
};
}

View File

@ -11,12 +11,15 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformPropertie
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import java.util.Comparator;
public class DatabaseAttachment extends Attachment {
private final AttachmentId attachmentId;
private final long mmsId;
private final boolean hasData;
private final boolean hasThumbnail;
private final int displayOrder;
public DatabaseAttachment(AttachmentId attachmentId, long mmsId,
boolean hasData, boolean hasThumbnail,
@ -25,13 +28,14 @@ public class DatabaseAttachment extends Attachment {
byte[] digest, String fastPreflightId, boolean voiceNote,
int width, int height, boolean quote, @Nullable String caption,
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash,
@Nullable TransformProperties transformProperties)
@Nullable TransformProperties transformProperties, int displayOrder)
{
super(contentType, transferProgress, size, fileName, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, caption, stickerLocator, blurHash, transformProperties);
this.attachmentId = attachmentId;
this.hasData = hasData;
this.hasThumbnail = hasThumbnail;
this.mmsId = mmsId;
this.displayOrder = displayOrder;
}
@Override
@ -58,6 +62,10 @@ public class DatabaseAttachment extends Attachment {
return attachmentId;
}
public int getDisplayOrder() {
return displayOrder;
}
@Override
public boolean equals(Object other) {
return other != null &&
@ -81,4 +89,11 @@ public class DatabaseAttachment extends Attachment {
public boolean hasThumbnail() {
return hasThumbnail;
}
public static class DisplayOrderComparator implements Comparator<DatabaseAttachment> {
@Override
public int compare(DatabaseAttachment lhs, DatabaseAttachment rhs) {
return Integer.compare(lhs.getDisplayOrder(), rhs.getDisplayOrder());
}
}
}

View File

@ -7,7 +7,6 @@ import android.os.Build;
import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.logging.Log;
import android.util.Pair;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
@ -15,6 +14,7 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.Pair;
import java.io.IOException;
import java.util.concurrent.ExecutorService;

View File

@ -44,7 +44,6 @@ import android.provider.Telephony;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.Pair;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuInflater;
@ -72,7 +71,6 @@ import androidx.appcompat.widget.Toolbar;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.core.view.MenuItemCompat;
import androidx.lifecycle.ViewModelProviders;
import com.annimon.stream.Stream;
@ -164,6 +162,7 @@ import org.thoughtcrime.securesms.maps.PlacePickerActivity;
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult;
import org.thoughtcrime.securesms.messagerequests.MessageRequestFragment;
import org.thoughtcrime.securesms.messagerequests.MessageRequestFragmentViewModel;
import org.thoughtcrime.securesms.mms.AttachmentManager;
@ -184,7 +183,6 @@ import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.mms.StickerSlide;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
@ -216,11 +214,11 @@ import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.TextSecurePreferences.MediaKeyboardMode;
@ -233,15 +231,13 @@ import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.views.Stub;
import org.whispersystems.libsignal.InvalidMessageException;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@ -591,24 +587,21 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
initializeSecurity(isSecureText, isDefaultSms);
break;
case MEDIA_SENDER:
long expiresIn = recipient.get().getExpireMessages() * 1000L;
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
boolean initiating = threadId == -1;
TransportOption transport = data.getParcelableExtra(MediaSendActivity.EXTRA_TRANSPORT);
String message = data.getStringExtra(MediaSendActivity.EXTRA_MESSAGE);
boolean viewOnce = data.getBooleanExtra(MediaSendActivity.EXTRA_VIEW_ONCE, false);
QuoteModel quote = viewOnce ? null : inputPanel.getQuote().orNull();
SlideDeck slideDeck = new SlideDeck();
MediaSendActivityResult result = data.getParcelableExtra(MediaSendActivity.EXTRA_RESULT);
sendButton.setTransport(result.getTransport());
if (transport == null) {
throw new IllegalStateException("Received a null transport from the MediaSendActivity.");
if (result.isPushPreUpload()) {
sendMediaMessage(result);
return;
}
sendButton.setTransport(transport);
long expiresIn = recipient.get().getExpireMessages() * 1000L;
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
boolean initiating = threadId == -1;
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull();
SlideDeck slideDeck = new SlideDeck();
List<Media> mediaList = data.getParcelableArrayListExtra(MediaSendActivity.EXTRA_MEDIA);
for (Media mediaItem : mediaList) {
for (Media mediaItem : result.getNonUploadedMedia()) {
if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
slideDeck.addSlide(new VideoSlide(this, mediaItem.getUri(), 0, mediaItem.getCaption().orNull()));
} else if (MediaUtil.isGif(mediaItem.getMimeType())) {
@ -622,14 +615,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
final Context context = ConversationActivity.this.getApplicationContext();
sendMediaMessage(transport.isSms(),
message,
sendMediaMessage(result.getTransport().isSms(),
result.getBody(),
slideDeck,
quote,
Collections.emptyList(),
Collections.emptyList(),
expiresIn,
viewOnce,
result.isViewOnce(),
subscriptionId,
initiating,
true).addListener(new AssertedSuccessListener<Void>() {
@ -1540,12 +1533,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
@Override
protected void onPostExecute(@NonNull Pair<IdentityRecordList, String> result) {
Log.i(TAG, "Got identity records: " + result.first.isUnverified());
identityRecords.replaceWith(result.first);
Log.i(TAG, "Got identity records: " + result.first().isUnverified());
identityRecords.replaceWith(result.first());
if (result.second != null) {
if (result.second() != null) {
Log.d(TAG, "Replacing banner...");
unverifiedBannerView.get().display(result.second, result.first.getUnverifiedRecords(),
unverifiedBannerView.get().display(result.second(), result.first().getUnverifiedRecords(),
new UnverifiedClickedListener(),
new UnverifiedDismissedListener());
} else if (unverifiedBannerView.resolved()) {
@ -2114,28 +2107,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
return rawText;
}
private Pair<String, Optional<Slide>> getSplitMessage(String rawText, int maxPrimaryMessageSize) {
String bodyText = rawText;
Optional<Slide> textSlide = Optional.absent();
if (bodyText.length() > maxPrimaryMessageSize) {
bodyText = rawText.substring(0, maxPrimaryMessageSize);
byte[] textData = rawText.getBytes();
String timestamp = new SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US).format(new Date());
String filename = String.format("signal-%s.txt", timestamp);
Uri textUri = BlobProvider.getInstance()
.forData(textData)
.withMimeType(MediaUtil.LONG_TEXT)
.withFileName(filename)
.createForSingleSessionInMemory();
textSlide = Optional.of(new TextSlide(this, textUri, filename, textData.length));
}
return new Pair<>(bodyText, textSlide);
}
private MediaConstraints getCurrentMediaConstraints() {
return sendButton.getSelectedTransport().getType() == Type.TEXTSECURE
? MediaConstraints.getPushMediaConstraints()
@ -2241,6 +2212,35 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
}
private void sendMediaMessage(@NonNull MediaSendActivityResult result) {
long expiresIn = recipient.get().getExpireMessages() * 1000L;
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull();
boolean initiating = threadId == -1;
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, quote, Collections.emptyList(), Collections.emptyList());
OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message );
ApplicationContext.getInstance(this).getTypingStatusSender().onTypingStopped(threadId);
inputPanel.clearQuote();
attachmentManager.clear(glideRequests, false);
silentlySetComposeText("");
long id = fragment.stageOutgoingMessage(message);
SimpleTask.run(() -> {
if (initiating) {
DatabaseFactory.getRecipientDatabase(this).setProfileSharing(recipient.getId(), true);
}
long resultId = MessageSender.sendPushWithPreUploadedMedia(this, secureMessage, result.getPreUploadResults(), threadId, () -> fragment.releaseOutgoingMessage(id));
int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments();
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
return resultId;
}, this::sendComplete);
}
private void sendMediaMessage(final boolean forceSms, final long expiresIn, final boolean viewOnce, final int subscriptionId, boolean initiating)
throws InvalidMessageException
{
@ -2266,11 +2266,11 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
if (isSecureText && !forceSms) {
Pair<String, Optional<Slide>> splitMessage = getSplitMessage(body, sendButton.getSelectedTransport().calculateCharacters(body).maxPrimaryMessageSize);
body = splitMessage.first;
MessageUtil.SplitResult splitMessage = MessageUtil.getSplitMessage(this, body, sendButton.getSelectedTransport().calculateCharacters(body).maxPrimaryMessageSize);
body = splitMessage.getBody();
if (splitMessage.second.isPresent()) {
slideDeck.addSlide(splitMessage.second.get());
if (splitMessage.getTextSlide().isPresent()) {
slideDeck.addSlide(splitMessage.getTextSlide().get());
}
}
@ -2301,22 +2301,16 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
final long id = fragment.stageOutgoingMessage(outgoingMessage);
new AsyncTask<Void, Void, Long>() {
@Override
protected Long doInBackground(Void... param) {
if (initiating) {
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true);
}
return MessageSender.send(context, outgoingMessage, threadId, forceSms, () -> fragment.releaseOutgoingMessage(id));
SimpleTask.run(() -> {
if (initiating) {
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true);
}
@Override
protected void onPostExecute(Long result) {
sendComplete(result);
future.set(null);
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
return MessageSender.send(context, outgoingMessage, threadId, forceSms, () -> fragment.releaseOutgoingMessage(id));
}, result -> {
sendComplete(result);
future.set(null);
});
})
.onAnyDenied(() -> future.set(null))
.execute();
@ -2471,7 +2465,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
long expiresIn = recipient.get().getExpireMessages() * 1000L;
boolean initiating = threadId == -1;
AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first, result.second, MediaUtil.AUDIO_AAC, true);
AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first(), result.second(), MediaUtil.AUDIO_AAC, true);
SlideDeck slideDeck = new SlideDeck();
slideDeck.addSlide(audioSlide);
@ -2481,7 +2475,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
BlobProvider.getInstance().delete(ConversationActivity.this, result.first);
BlobProvider.getInstance().delete(ConversationActivity.this, result.first());
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
@ -2511,7 +2505,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
BlobProvider.getInstance().delete(ConversationActivity.this, result.first);
BlobProvider.getInstance().delete(ConversationActivity.this, result.first());
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);

View File

@ -76,6 +76,7 @@ import java.io.OutputStream;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
@ -120,6 +121,7 @@ public class AttachmentDatabase extends Database {
private static final String DATA_HASH = "data_hash";
static final String BLUR_HASH = "blur_hash";
static final String TRANSFORM_PROPERTIES = "transform_properties";
static final String DISPLAY_ORDER = "display_order";
public static final String DIRECTORY = "parts";
@ -128,6 +130,8 @@ public class AttachmentDatabase extends Database {
public static final int TRANSFER_PROGRESS_PENDING = 2;
public static final int TRANSFER_PROGRESS_FAILED = 3;
public static final long PREUPLOAD_MESSAGE_ID = -8675309;
private static final String PART_ID_WHERE = ROW_ID + " = ? AND " + UNIQUE_ID + " = ?";
private static final String PART_ID_WHERE_NOT = ROW_ID + " != ? AND " + UNIQUE_ID + " != ?";
@ -138,7 +142,8 @@ public class AttachmentDatabase extends Database {
UNIQUE_ID, DIGEST, FAST_PREFLIGHT_ID, VOICE_NOTE,
QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT,
CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID,
DATA_HASH, BLUR_HASH, TRANSFORM_PROPERTIES, TRANSFER_FILE };
DATA_HASH, BLUR_HASH, TRANSFORM_PROPERTIES, TRANSFER_FILE,
DISPLAY_ORDER };
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " +
MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " +
@ -154,7 +159,8 @@ public class AttachmentDatabase extends Database {
CAPTION + " TEXT DEFAULT NULL, " + STICKER_PACK_ID + " TEXT DEFAULT NULL, " +
STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1, " +
DATA_HASH + " TEXT DEFAULT NULL, " + BLUR_HASH + " TEXT DEFAULT NULL, " +
TRANSFORM_PROPERTIES + " TEXT DEFAULT NULL, " + TRANSFER_FILE + " TEXT DEFAULT NULL);";
TRANSFORM_PROPERTIES + " TEXT DEFAULT NULL, " + TRANSFER_FILE + " TEXT DEFAULT NULL, " +
DISPLAY_ORDER + " INTEGER DEFAULT 0);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS part_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");",
@ -320,6 +326,33 @@ public class AttachmentDatabase extends Database {
notifyAttachmentListeners();
}
/**
* Deletes all attachments with an ID of {@link #PREUPLOAD_MESSAGE_ID}. These represent
* attachments that were pre-uploaded and haven't been assigned to a message. This should only be
* done when you *know* that all attachments *should* be assigned a real mmsId. For instance, when
* the app starts. Otherwise you could delete attachments that are legitimately being
* pre-uploaded.
*/
public int deleteAbandonedPreuploadedAttachments() {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
String query = MMS_ID + " = ?";
String[] args = new String[] { String.valueOf(PREUPLOAD_MESSAGE_ID) };
int count = 0;
try (Cursor cursor = db.query(TABLE_NAME, null, query, args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID));
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID));
AttachmentId id = new AttachmentId(rowId, uniqueId);
deleteAttachment(id);
count++;
}
}
return count;
}
public void deleteAttachmentFilesForViewOnceMessage(long mmsId) {
Log.d(TAG, "[deleteAttachmentFilesForViewOnceMessage] mmsId: " + mmsId);
@ -538,6 +571,32 @@ public class AttachmentDatabase extends Database {
database.update(TABLE_NAME, contentValues, PART_ID_WHERE, destinationId.toStrings());
}
public void updateAttachmentCaption(@NonNull AttachmentId id, @Nullable String caption) {
ContentValues values = new ContentValues(1);
values.put(CAPTION, caption);
databaseHelper.getWritableDatabase().update(TABLE_NAME, values, PART_ID_WHERE, id.toStrings());
}
public void updateDisplayOrder(@NonNull Map<AttachmentId, Integer> orderMap) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
for (Map.Entry<AttachmentId, Integer> entry : orderMap.entrySet()) {
ContentValues values = new ContentValues(1);
values.put(DISPLAY_ORDER, entry.getValue());
databaseHelper.getWritableDatabase().update(TABLE_NAME, values, PART_ID_WHERE, entry.getKey().toStrings());
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public void updateAttachmentAfterUpload(@NonNull AttachmentId id, @NonNull Attachment attachment) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
@ -554,6 +613,42 @@ public class AttachmentDatabase extends Database {
database.update(TABLE_NAME, values, PART_ID_WHERE, id.toStrings());
}
public @NonNull DatabaseAttachment insertAttachmentForPreUpload(@NonNull Attachment attachment) throws MmsException {
Map<Attachment, AttachmentId> result = insertAttachmentsForMessage(PREUPLOAD_MESSAGE_ID,
Collections.singletonList(attachment),
Collections.emptyList());
if (result.values().isEmpty()) {
throw new MmsException("Bad attachment result!");
}
DatabaseAttachment databaseAttachment = getAttachment(result.values().iterator().next());
if (databaseAttachment == null) {
throw new MmsException("Failed to retrieve attachment we just inserted!");
}
return databaseAttachment;
}
public void updateMessageId(@NonNull Collection<AttachmentId> attachmentIds, long mmsId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
ContentValues values = new ContentValues(1);
values.put(MMS_ID, mmsId);
for (AttachmentId attachmentId : attachmentIds) {
db.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings());
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
@NonNull Map<Attachment, AttachmentId> insertAttachmentsForMessage(long mmsId, @NonNull List<Attachment> attachments, @NonNull List<Attachment> quoteAttachment)
throws MmsException
{
@ -957,7 +1052,8 @@ public class AttachmentDatabase extends Database {
object.getInt(STICKER_ID))
: null,
BlurHash.parseOrNull(object.getString(BLUR_HASH)),
TransformProperties.parse(object.getString(TRANSFORM_PROPERTIES))));
TransformProperties.parse(object.getString(TRANSFORM_PROPERTIES)),
object.getInt(DISPLAY_ORDER)));
}
}
@ -988,7 +1084,8 @@ public class AttachmentDatabase extends Database {
cursor.getInt(cursor.getColumnIndexOrThrow(STICKER_ID)))
: null,
BlurHash.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(BLUR_HASH))),
TransformProperties.parse(cursor.getString(cursor.getColumnIndexOrThrow(TRANSFORM_PROPERTIES)))));
TransformProperties.parse(cursor.getString(cursor.getColumnIndexOrThrow(TRANSFORM_PROPERTIES))),
cursor.getInt(cursor.getColumnIndexOrThrow(DISPLAY_ORDER))));
}
} catch (JSONException e) {
throw new AssertionError(e);

View File

@ -44,6 +44,7 @@ public class MediaDatabase extends Database {
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BLUR_HASH + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.NAME + ", "
+ MmsDatabase.TABLE_NAME + "." + MmsDatabase.MESSAGE_BOX + ", "
@ -247,9 +248,9 @@ public class MediaDatabase extends Database {
}
public enum Sorting {
Newest (AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " DESC"),
Oldest (AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + " ASC" ),
Largest(AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + " DESC");
Newest (AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " DESC, " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + " DESC"),
Oldest (AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.MMS_ID + " ASC, " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + " DESC"),
Largest(AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.SIZE + " DESC, " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + " DESC");
private final String postFix;

View File

@ -212,7 +212,8 @@ public class MmsDatabase extends MessagingDatabase {
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", " +
"'" + AttachmentDatabase.BLUR_HASH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BLUR_HASH + ", " +
"'" + AttachmentDatabase.TRANSFORM_PROPERTIES + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES +
"'" + AttachmentDatabase.TRANSFORM_PROPERTIES + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", " +
"'" + AttachmentDatabase.DISPLAY_ORDER + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER +
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
};
@ -702,6 +703,7 @@ public class MmsDatabase extends MessagingDatabase {
List<Attachment> attachments = Stream.of(associatedAttachments).filterNot(Attachment::isQuote)
.filterNot(contactAttachments::contains)
.filterNot(previewAttachments::contains)
.sorted(new DatabaseAttachment.DisplayOrderComparator())
.map(a -> (Attachment)a).toList();
Recipient recipient = Recipient.resolved(RecipientId.from(recipientId));
@ -865,7 +867,8 @@ public class MmsDatabase extends MessagingDatabase {
databaseAttachment.getCaption(),
databaseAttachment.getSticker(),
databaseAttachment.getBlurHash(),
databaseAttachment.getTransformProperties()));
databaseAttachment.getTransformProperties(),
databaseAttachment.getDisplayOrder()));
}
return insertMediaMessage(request.getBody(),
@ -1563,7 +1566,7 @@ public class MmsDatabase extends MessagingDatabase {
List<NetworkFailure> networkFailures = getFailures(networkDocument);
List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor);
List<Contact> contacts = getSharedContacts(cursor, attachments);
Set<Attachment> contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).collect(Collectors.toSet());
Set<Attachment> contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).withoutNulls().collect(Collectors.toSet());
List<LinkPreview> previews = getLinkPreviews(cursor, attachments);
Set<Attachment> previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet());
SlideDeck slideDeck = getSlideDeck(Stream.of(attachments).filterNot(contactAttachments::contains).filterNot(previewAttachments::contains).toList());
@ -1601,9 +1604,10 @@ public class MmsDatabase extends MessagingDatabase {
}
private SlideDeck getSlideDeck(@NonNull List<DatabaseAttachment> attachments) {
List<? extends Attachment> messageAttachments = Stream.of(attachments)
.filterNot(Attachment::isQuote)
.toList();
List<DatabaseAttachment> messageAttachments = Stream.of(attachments)
.filterNot(Attachment::isQuote)
.sorted(new DatabaseAttachment.DisplayOrderComparator())
.toList();
return new SlideDeck(context, messageAttachments);
}

View File

@ -296,7 +296,8 @@ public class MmsSmsDatabase extends Database {
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", " +
"'" + AttachmentDatabase.BLUR_HASH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BLUR_HASH + ", " +
"'" + AttachmentDatabase.TRANSFORM_PROPERTIES + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES +
"'" + AttachmentDatabase.TRANSFORM_PROPERTIES + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", " +
"'" + AttachmentDatabase.DISPLAY_ORDER + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER +
")) AS " + AttachmentDatabase.ATTACHMENT_JSON_ALIAS,
SmsDatabase.BODY, MmsSmsColumns.READ, MmsSmsColumns.THREAD_ID,
SmsDatabase.TYPE, SmsDatabase.RECIPIENT_ID, SmsDatabase.ADDRESS_DEVICE_ID, SmsDatabase.SUBJECT, MmsDatabase.MESSAGE_TYPE,

View File

@ -101,8 +101,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int REACTIONS_UNREAD_INDEX = 39;
private static final int RESUMABLE_DOWNLOADS = 40;
private static final int KEY_VALUE_STORE = 41;
private static final int ATTACHMENT_DISPLAY_ORDER = 42;
private static final int DATABASE_VERSION = 41;
private static final int DATABASE_VERSION = 42;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@ -694,6 +695,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
"type INTEGER)");
}
if (oldVersion < ATTACHMENT_DISPLAY_ORDER) {
db.execSQL("ALTER TABLE part ADD COLUMN display_order INTEGER DEFAULT 0");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

@ -133,6 +133,7 @@ class JobController {
Log.w(TAG, JobLogger.format(job, "Canceling while inactive."));
Log.w(TAG, JobLogger.format(job, "Job failed."));
job.cancel();
job.onFailure();
onFailure(job);
} else {

View File

@ -4,6 +4,7 @@ import android.content.Context;
import android.media.MediaDataSource;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import org.greenrobot.eventbus.EventBus;
@ -143,7 +144,7 @@ public final class AttachmentCompressionJob extends BaseJob {
{
try {
if (MediaUtil.isVideo(attachment) && MediaConstraints.isVideoTranscodeAvailable()) {
transcodeVideoIfNeededToDatabase(context, attachmentDatabase, attachment, constraints, EventBus.getDefault());
transcodeVideoIfNeededToDatabase(context, attachmentDatabase, attachment, constraints, EventBus.getDefault(), this::isCanceled);
} else if (constraints.isSatisfied(context, attachment)) {
if (MediaUtil.isJpeg(attachment)) {
MediaStream stripped = getResizedMedia(context, attachment, constraints);
@ -167,7 +168,8 @@ public final class AttachmentCompressionJob extends BaseJob {
@NonNull AttachmentDatabase attachmentDatabase,
@NonNull DatabaseAttachment attachment,
@NonNull MediaConstraints constraints,
@NonNull EventBus eventBus)
@NonNull EventBus eventBus,
@NonNull InMemoryTranscoder.CancelationSignal cancelationSignal)
throws UndeliverableMessageException
{
try (NotificationController notification = GenericForegroundService.startForegroundTask(context, context.getString(R.string.AttachmentUploadJob_compressing_video_start))) {
@ -190,7 +192,7 @@ public final class AttachmentCompressionJob extends BaseJob {
PartProgressEvent.Type.COMPRESSION,
100,
percent));
});
}, cancelationSignal);
attachmentDatabase.updateAttachmentData(attachment, mediaStream);
attachmentDatabase.markAttachmentAsTransformed(attachment.getAttachmentId());

View File

@ -115,7 +115,11 @@ public final class AttachmentUploadJob extends BaseJob {
}
@Override
public void onFailure() { }
public void onFailure() {
if (isCanceled()) {
DatabaseFactory.getAttachmentDatabase(context).deleteAttachment(attachmentId);
}
}
@Override
protected boolean onShouldRetry(@NonNull Exception exception) {
@ -135,6 +139,7 @@ public final class AttachmentUploadJob extends BaseJob {
.withWidth(attachment.getWidth())
.withHeight(attachment.getHeight())
.withCaption(attachment.getCaption())
.withCancelationSignal(this::isCanceled)
.withListener((total, progress) -> {
EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress));
if (notification != null) {

View File

@ -111,7 +111,7 @@ public class Camera1Fragment extends Fragment implements CameraFragment,
GestureDetector gestureDetector = new GestureDetector(flipGestureListener);
cameraPreview.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event));
viewModel.getMostRecentMediaItem(requireContext()).observe(this, this::presentRecentItemThumbnail);
viewModel.getMostRecentMediaItem().observe(this, this::presentRecentItemThumbnail);
viewModel.getHudState().observe(this, this::presentHud);
}

View File

@ -107,7 +107,7 @@ public class CameraXFragment extends Fragment implements CameraFragment {
onOrientationChanged(getResources().getConfiguration().orientation);
viewModel.getMostRecentMediaItem(requireContext()).observe(this, this::presentRecentItemThumbnail);
viewModel.getMostRecentMediaItem().observe(this, this::presentRecentItemThumbnail);
viewModel.getHudState().observe(this, this::presentHud);
}

View File

@ -4,6 +4,7 @@ import android.Manifest;
import android.annotation.TargetApi;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Environment;
import android.provider.MediaStore.Images;
@ -17,18 +18,23 @@ import android.util.Pair;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@ -38,6 +44,8 @@ import java.util.Map;
*/
class MediaRepository {
private static final String TAG = Log.tag(MediaRepository.class);
/**
* Retrieves a list of folders that contain media.
*/
@ -69,6 +77,14 @@ class MediaRepository {
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getMostRecentItem(context)));
}
void renderMedia(@NonNull Context context,
@NonNull List<Media> currentMedia,
@NonNull Map<Media, EditorModel> modelsToRender,
@NonNull Callback<LinkedHashMap<Media, Media>> callback)
{
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(renderMedia(context, currentMedia, modelsToRender)));
}
@WorkerThread
private @NonNull List<MediaFolder> getFolders(@NonNull Context context) {
if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {
@ -231,6 +247,43 @@ class MediaRepository {
}).toList();
}
@WorkerThread
private LinkedHashMap<Media, Media> renderMedia(@NonNull Context context,
@NonNull List<Media> currentMedia,
@NonNull Map<Media, EditorModel> modelsToRender)
{
LinkedHashMap<Media, Media> updatedMedia = new LinkedHashMap<>(currentMedia.size());
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
for (Media media : currentMedia) {
EditorModel modelToRender = modelsToRender.get(media);
if (modelToRender != null) {
Bitmap bitmap = modelToRender.render(context);
try {
outputStream.reset();
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream);
Uri uri = BlobProvider.getInstance()
.forData(outputStream.toByteArray())
.withMimeType(MediaUtil.IMAGE_JPEG)
.createForSingleSessionOnDisk(context);
Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), outputStream.size(), media.getBucketId(), media.getCaption());
updatedMedia.put(media, updated);
} catch (IOException e) {
Log.w(TAG, "Failed to render image. Using base image.");
updatedMedia.put(media, media);
} finally {
bitmap.recycle();
}
} else {
updatedMedia.put(media, media);
}
}
return updatedMedia;
}
@WorkerThread
private Optional<Media> getMostRecentItem(@NonNull Context context) {
if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {

View File

@ -1,14 +1,11 @@
package org.thoughtcrime.securesms.mediasend;
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Vibrator;
import android.text.Editable;
@ -46,41 +43,30 @@ import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.ViewOnceState;
import org.thoughtcrime.securesms.mms.GifSlide;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
import org.thoughtcrime.securesms.util.Function3;
import org.thoughtcrime.securesms.util.IOFunction;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.video.VideoUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.ByteArrayOutputStream;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.IOException;
@ -110,11 +96,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
{
private static final String TAG = MediaSendActivity.class.getSimpleName();
public static final String EXTRA_MEDIA = "media";
public static final String EXTRA_MESSAGE = "message";
public static final String EXTRA_TRANSPORT = "transport";
public static final String EXTRA_VIEW_ONCE = "view_once";
public static final String EXTRA_RESULT = "result";
private static final String KEY_RECIPIENT = "recipient_id";
private static final String KEY_BODY = "body";
@ -150,6 +132,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
private TextView charactersLeft;
private RecyclerView mediaRail;
private MediaRailAdapter mediaRailAdapter;
private AlertDialog progressDialog;
private int visibleHeight;
@ -232,6 +215,10 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
transport = getIntent().getParcelableExtra(KEY_TRANSPORT);
MeteredConnectivityObserver meteredConnectivityObserver = new MeteredConnectivityObserver(this, this);
meteredConnectivityObserver.isMetered().observe(this, viewModel::onMeteredConnectivityStatusChanged);
viewModel.onMeteredConnectivityStatusChanged(Optional.fromNullable(meteredConnectivityObserver.isMetered().getValue()).or(false));
viewModel.setTransport(transport);
viewModel.setRecipient(recipient != null ? recipient.get() : null);
viewModel.onBodyChanged(getIntent().getStringExtra(KEY_BODY));
@ -259,23 +246,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
.commit();
}
sendButton.setOnClickListener(v -> {
if (hud.isKeyboardOpen()) {
hud.hideSoftkey(composeText, null);
}
sendButton.setEnabled(false);
MediaSendFragment fragment = getMediaSendFragment();
if (fragment != null) {
processMedia(fragment.getAllMedia(), fragment.getSavedState(), processedMedia -> {
setActivityResultAndFinish(processedMedia, composeText.getTextTrimmed(), transport);
});
} else {
throw new AssertionError("No editor fragment available!");
}
});
sendButton.setOnClickListener(v -> onSendClicked());
sendButton.setOnLongClickListener(v -> true);
@ -418,7 +389,6 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
0);
}
private <T> void onMediaCaptured(Supplier<T> dataSupplier,
IOFunction<T, Long> getLength,
Function3<BlobProvider, T, Long, BlobProvider.BlobBuilder> createBlobBuilder,
@ -428,7 +398,6 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
{
SimpleTask.run(getLifecycle(), () -> {
try {
T data = dataSupplier.get();
long length = getLength.apply(data);
@ -542,16 +511,51 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
MediaSendFragment fragment = getMediaSendFragment();
if (fragment != null) {
processMedia(fragment.getAllMedia(), fragment.getSavedState(), processedMedia -> {
String body = viewModel.isViewOnce() ? "" : composeText.getTextTrimmed();
sendMessages(recipients, processedMedia, body, transport);
viewModel.onSendClicked(buildModelsToRender(fragment), recipients).observe(this, result -> {
finish();
});
} else {
throw new AssertionError("No editor fragment available!");
}
}
public void onAddMediaClicked(@NonNull String bucketId) {
private void onSendClicked() {
MediaSendFragment fragment = getMediaSendFragment();
if (fragment == null) {
throw new AssertionError("No editor fragment available!");
}
if (hud.isKeyboardOpen()) {
hud.hideSoftkey(composeText, null);
}
sendButton.setEnabled(false);
viewModel.onSendClicked(buildModelsToRender(fragment), Collections.emptyList()).observe(this, this::setActivityResultAndFinish);
}
private Map<Media, EditorModel> buildModelsToRender(@NonNull MediaSendFragment fragment) {
List<Media> mediaList = fragment.getAllMedia();
Map<Uri, Object> savedState = fragment.getSavedState();
Map<Media, EditorModel> modelsToRender = new HashMap<>();
for (Media media : mediaList) {
Object state = savedState.get(media.getUri());
if (state instanceof ImageEditorFragment.Data) {
EditorModel model = ((ImageEditorFragment.Data) state).readModel();
if (model != null && model.isChanged()) {
modelsToRender.put(media, model);
}
}
}
return modelsToRender;
}
private void onAddMediaClicked(@NonNull String bucketId) {
hud.hideCurrentInput(composeText);
// TODO: Get actual folder title somehow
@ -569,7 +573,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
.commit();
}
public void onNoMediaAvailable() {
private void onNoMediaAvailable() {
setResult(RESULT_CANCELED);
finish();
}
@ -700,13 +704,24 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
});
viewModel.getEvents().observe(this, event -> {
if (event == MediaSendViewModel.Event.VIEW_ONCE_TOOLTIP) {
TooltipPopup.forTarget(revealButton)
.setText(R.string.MediaSendActivity_tap_here_to_make_this_message_disappear_after_it_is_viewed)
.setBackgroundTint(getResources().getColor(R.color.core_blue))
.setTextColor(getResources().getColor(R.color.core_white))
.setOnDismissListener(() -> TextSecurePreferences.setHasSeenViewOnceTooltip(this, true))
.show(TooltipPopup.POSITION_ABOVE);
switch (event) {
case VIEW_ONCE_TOOLTIP:
TooltipPopup.forTarget(revealButton)
.setText(R.string.MediaSendActivity_tap_here_to_make_this_message_disappear_after_it_is_viewed)
.setBackgroundTint(getResources().getColor(R.color.core_blue))
.setTextColor(getResources().getColor(R.color.core_white))
.setOnDismissListener(() -> TextSecurePreferences.setHasSeenViewOnceTooltip(this, true))
.show(TooltipPopup.POSITION_ABOVE);
break;
case SHOW_RENDER_PROGRESS:
progressDialog = SimpleProgressDialog.show(new ContextThemeWrapper(MediaSendActivity.this, R.style.TextSecure_MediaSendProgressDialog));
break;
case HIDE_RENDER_PROGRESS:
if (progressDialog != null) {
progressDialog.dismiss();
progressDialog = null;
}
break;
}
});
}
@ -836,163 +851,19 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
}
}
@SuppressLint("StaticFieldLeak")
private void processMedia(@NonNull List<Media> mediaList, @NonNull Map<Uri, Object> savedState, @NonNull OnProcessComplete callback) {
Map<Media, EditorModel> modelsToRender = new HashMap<>();
for (Media media : mediaList) {
Object state = savedState.get(media.getUri());
if (state instanceof ImageEditorFragment.Data) {
EditorModel model = ((ImageEditorFragment.Data) state).readModel();
if (model != null && model.isChanged()) {
modelsToRender.put(media, model);
}
}
}
new AsyncTask<Void, Void, List<Media>>() {
private Stopwatch renderTimer;
private Runnable progressTimer;
private AlertDialog dialog;
@Override
protected void onPreExecute() {
renderTimer = new Stopwatch("ProcessMedia");
progressTimer = () -> {
dialog = SimpleProgressDialog.show(new ContextThemeWrapper(MediaSendActivity.this, R.style.TextSecure_MediaSendProgressDialog));
};
Util.runOnMainDelayed(progressTimer, 250);
}
@Override
protected List<Media> doInBackground(Void... voids) {
Context context = MediaSendActivity.this;
List<Media> updatedMedia = new ArrayList<>(mediaList.size());
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
for (Media media : mediaList) {
EditorModel modelToRender = modelsToRender.get(media);
if (modelToRender != null) {
Bitmap bitmap = modelToRender.render(context);
try {
outputStream.reset();
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream);
Uri uri = BlobProvider.getInstance()
.forData(outputStream.toByteArray())
.withMimeType(MediaUtil.IMAGE_JPEG)
.createForSingleSessionOnDisk(context);
Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), outputStream.size(), media.getBucketId(), media.getCaption());
updatedMedia.add(updated);
renderTimer.split("item");
} catch (IOException e) {
Log.w(TAG, "Failed to render image. Using base image.");
updatedMedia.add(media);
} finally {
bitmap.recycle();
}
} else {
updatedMedia.add(media);
}
}
return updatedMedia;
}
@Override
protected void onPostExecute(List<Media> media) {
callback.onComplete(media);
Util.cancelRunnableOnMain(progressTimer);
if (dialog != null) {
dialog.dismiss();
}
renderTimer.stop(TAG);
}
}.executeOnExecutor(SignalExecutors.BOUNDED);
}
private @Nullable MediaSendFragment getMediaSendFragment() {
return (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
}
private void setActivityResultAndFinish(@NonNull List<Media> media, @NonNull String message, @NonNull TransportOption transport) {
viewModel.onSendClicked();
ArrayList<Media> mediaList = new ArrayList<>(media);
if (mediaList.size() > 0) {
Intent intent = new Intent();
intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList);
intent.putExtra(EXTRA_MESSAGE, viewModel.isViewOnce() ? "" : message);
intent.putExtra(EXTRA_TRANSPORT, transport);
intent.putExtra(EXTRA_VIEW_ONCE, viewModel.isViewOnce());
setResult(RESULT_OK, intent);
} else {
setResult(RESULT_CANCELED);
}
private void setActivityResultAndFinish(@NonNull MediaSendActivityResult result) {
Intent intent = new Intent();
intent.putExtra(EXTRA_RESULT, result);
setResult(RESULT_OK, intent);
finish();
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom);
}
private void sendMessages(@NonNull List<Recipient> recipients, @NonNull List<Media> media, @NonNull String body, @NonNull TransportOption transport) {
SimpleTask.run(() -> {
List<OutgoingSecureMediaMessage> messages = new ArrayList<>(recipients.size());
for (Recipient recipient : recipients) {
SlideDeck slideDeck = buildSlideDeck(media);
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
body,
slideDeck.asAttachments(),
System.currentTimeMillis(),
-1,
recipient.getExpireMessages() * 1000,
viewModel.isViewOnce(),
ThreadDatabase.DistributionTypes.DEFAULT,
null,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList());
messages.add(new OutgoingSecureMediaMessage(message));
// 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.
Util.sleep(5);
}
MessageSender.sendMediaBroadcast(this, messages);
return null;
}, (nothing) -> {
finish();
});
}
private @NonNull SlideDeck buildSlideDeck(@NonNull List<Media> mediaList) {
SlideDeck slideDeck = new SlideDeck();
for (Media mediaItem : mediaList) {
if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
slideDeck.addSlide(new VideoSlide(this, mediaItem.getUri(), 0, mediaItem.getCaption().orNull()));
} else if (MediaUtil.isGif(mediaItem.getMimeType())) {
slideDeck.addSlide(new GifSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull()));
} else if (MediaUtil.isImageType(mediaItem.getMimeType())) {
slideDeck.addSlide(new ImageSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull(), null));
} else {
Log.w(TAG, "Asked to send an unexpected mimeType: '" + mediaItem.getMimeType() + "'. Skipping.");
}
}
return slideDeck;
}
private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener {
int beforeLength;
@ -1033,8 +904,4 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
@Override
public void onFocusChange(View v, boolean hasFocus) {}
}
private interface OnProcessComplete {
void onComplete(@NonNull List<Media> media);
}
}

View File

@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.mediasend;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult;
import org.thoughtcrime.securesms.util.ParcelUtil;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
/**
* A class that lets us nicely format data that we'll send back to {@link ConversationActivity}.
*/
public class MediaSendActivityResult implements Parcelable {
private final Collection<PreUploadResult> uploadResults;
private final Collection<Media> nonUploadedMedia;
private final String body;
private final TransportOption transport;
private final boolean viewOnce;
static @NonNull MediaSendActivityResult forPreUpload(@NonNull Collection<PreUploadResult> uploadResults,
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce)
{
Preconditions.checkArgument(uploadResults.size() > 0, "Must supply uploadResults!");
return new MediaSendActivityResult(uploadResults, Collections.emptyList(), body, transport, viewOnce);
}
static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull List<Media> nonUploadedMedia,
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce)
{
Preconditions.checkArgument(nonUploadedMedia.size() > 0, "Must supply media!");
return new MediaSendActivityResult(Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce);
}
private MediaSendActivityResult(@NonNull Collection<PreUploadResult> uploadResults,
@NonNull List<Media> nonUploadedMedia,
@NonNull String body,
@NonNull TransportOption transport,
boolean viewOnce)
{
this.uploadResults = uploadResults;
this.nonUploadedMedia = nonUploadedMedia;
this.body = body;
this.transport = transport;
this.viewOnce = viewOnce;
}
private MediaSendActivityResult(Parcel in) {
this.uploadResults = ParcelUtil.readParcelableCollection(in, PreUploadResult.class);
this.nonUploadedMedia = ParcelUtil.readParcelableCollection(in, Media.class);
this.body = in.readString();
this.transport = in.readParcelable(TransportOption.class.getClassLoader());
this.viewOnce = ParcelUtil.readBoolean(in);
}
public boolean isPushPreUpload() {
return uploadResults.size() > 0;
}
public @NonNull Collection<PreUploadResult> getPreUploadResults() {
return uploadResults;
}
public @NonNull Collection<Media> getNonUploadedMedia() {
return nonUploadedMedia;
}
public @NonNull String getBody() {
return body;
}
public @NonNull TransportOption getTransport() {
return transport;
}
public boolean isViewOnce() {
return viewOnce;
}
public static final Creator<MediaSendActivityResult> CREATOR = new Creator<MediaSendActivityResult>() {
@Override
public MediaSendActivityResult createFromParcel(Parcel in) {
return new MediaSendActivityResult(in);
}
@Override
public MediaSendActivityResult[] newArray(int size) {
return new MediaSendActivityResult[size];
}
};
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
ParcelUtil.writeParcelableCollection(dest, uploadResults);
ParcelUtil.writeParcelableCollection(dest, nonUploadedMedia);
dest.writeString(body);
dest.writeParcelable(transport, 0);
ParcelUtil.writeBoolean(dest, viewOnce);
}
}

View File

@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend;
import android.app.Application;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
@ -10,26 +11,41 @@ import androidx.lifecycle.ViewModelProvider;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult;
import org.thoughtcrime.securesms.util.DiffHelper;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Manages the observable datasets available in {@link MediaSendActivity}.
@ -43,6 +59,7 @@ class MediaSendViewModel extends ViewModel {
private final Application application;
private final MediaRepository repository;
private final MediaUploadRepository uploadRepository;
private final MutableLiveData<List<Media>> selectedMedia;
private final MutableLiveData<List<Media>> bucketMedia;
private final MutableLiveData<Optional<Media>> mostRecentMedia;
@ -54,13 +71,16 @@ class MediaSendViewModel extends ViewModel {
private final SingleLiveEvent<Event> event;
private final Map<Uri, Object> savedDrawState;
private TransportOption transport;
private MediaConstraints mediaConstraints;
private CharSequence body;
private boolean sentMedia;
private int maxSelection;
private Page page;
private boolean isSms;
private boolean meteredConnection;
private Optional<Media> lastCameraCapture;
private boolean preUploadEnabled;
private boolean hudVisible;
private boolean composeVisible;
@ -69,11 +89,16 @@ class MediaSendViewModel extends ViewModel {
private RailState railState;
private ViewOnceState viewOnceState;
private @Nullable Recipient recipient;
private MediaSendViewModel(@NonNull Application application, @NonNull MediaRepository repository) {
private MediaSendViewModel(@NonNull Application application,
@NonNull MediaRepository repository,
@NonNull MediaUploadRepository uploadRepository)
{
this.application = application;
this.repository = repository;
this.uploadRepository = uploadRepository;
this.selectedMedia = new MutableLiveData<>();
this.bucketMedia = new MutableLiveData<>();
this.mostRecentMedia = new MutableLiveData<>();
@ -90,11 +115,14 @@ class MediaSendViewModel extends ViewModel {
this.railState = RailState.GONE;
this.viewOnceState = ViewOnceState.GONE;
this.page = Page.UNKNOWN;
this.preUploadEnabled = true;
position.setValue(-1);
}
void setTransport(@NonNull TransportOption transport) {
this.transport = transport;
if (transport.isSms()) {
isSms = true;
maxSelection = MAX_SMS;
@ -104,20 +132,24 @@ class MediaSendViewModel extends ViewModel {
maxSelection = MAX_PUSH;
mediaConstraints = MediaConstraints.getPushMediaConstraints();
}
preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient);
}
void setRecipient(@Nullable Recipient recipient) {
this.recipient = recipient;
this.recipient = recipient;
this.preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient);
}
void onSelectedMediaChanged(@NonNull Context context, @NonNull List<Media> newMedia) {
List<Media> originalMedia = getSelectedMediaOrDefault();
if (!newMedia.isEmpty()) {
selectedMedia.setValue(newMedia);
}
repository.getPopulatedMedia(context, newMedia, populatedMedia -> {
Util.runOnMain(() -> {
List<Media> filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints);
if (filteredMedia.size() != newMedia.size()) {
@ -153,6 +185,8 @@ class MediaSendViewModel extends ViewModel {
selectedMedia.setValue(filteredMedia);
hudState.setValue(buildHudState());
}
updateAttachmentUploads(originalMedia, getSelectedMediaOrDefault());
});
});
}
@ -221,6 +255,7 @@ class MediaSendViewModel extends ViewModel {
selected.remove(lastCameraCapture.get());
selectedMedia.setValue(selected);
BlobProvider.getInstance().delete(application, lastCameraCapture.get().getUri());
cancelUpload(lastCameraCapture.get());
}
hudState.setValue(buildHudState());
@ -350,6 +385,8 @@ class MediaSendViewModel extends ViewModel {
BlobProvider.getInstance().delete(context, removed.getUri());
}
cancelUpload(removed);
if (page == Page.EDITOR && getSelectedMediaOrDefault().isEmpty()) {
error.setValue(Error.NO_ITEMS);
} else {
@ -385,6 +422,8 @@ class MediaSendViewModel extends ViewModel {
selectedMedia.setValue(selected);
position.setValue(selected.size() - 1);
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
startUpload(media);
}
void onCaptionChanged(@NonNull String newCaption) {
@ -397,13 +436,66 @@ class MediaSendViewModel extends ViewModel {
repository.getMostRecentItem(application, mostRecentMedia::postValue);
}
void onMeteredConnectivityStatusChanged(boolean metered) {
Log.i(TAG, "Metered connectivity status set to: " + metered);
meteredConnection = metered;
preUploadEnabled = shouldPreUpload(application, metered, isSms, recipient);
}
void saveDrawState(@NonNull Map<Uri, Object> state) {
savedDrawState.clear();
savedDrawState.putAll(state);
}
void onSendClicked() {
@NonNull LiveData<MediaSendActivityResult> onSendClicked(Map<Media, EditorModel> modelsToRender, @NonNull List<Recipient> recipients) {
if (isSms && recipients.size() > 0) {
throw new IllegalStateException("Provided recipients to send to, but this is SMS!");
}
MutableLiveData<MediaSendActivityResult> result = new MutableLiveData<>();
Runnable dialogRunnable = () -> event.postValue(Event.SHOW_RENDER_PROGRESS);
String trimmedBody = isViewOnce() ? "" : body.toString().trim();
List<Media> initialMedia = getSelectedMediaOrDefault();
Preconditions.checkState(initialMedia.size() > 0, "No media to send!");
Util.runOnMainDelayed(dialogRunnable, 250);
repository.renderMedia(application, initialMedia, modelsToRender, (oldToNew) -> {
List<Media> updatedMedia = new ArrayList<>(oldToNew.values());
if (isSms || MessageSender.isLocalSelfSend(application, recipient, isSms)) {
Log.i(TAG, "SMS or local self-send. Skipping pre-upload.");
result.postValue(MediaSendActivityResult.forTraditionalSend(updatedMedia, trimmedBody, transport, isViewOnce()));
return;
}
MessageUtil.SplitResult splitMessage = MessageUtil.getSplitMessage(application, trimmedBody, transport.calculateCharacters(trimmedBody).maxPrimaryMessageSize);
String splitBody = splitMessage.getBody();
if (splitMessage.getTextSlide().isPresent()) {
Slide slide = splitMessage.getTextSlide().get();
uploadRepository.startUpload(new Media(Objects.requireNonNull(slide.getUri()), slide.getContentType(), System.currentTimeMillis(), 0, 0, slide.getFileSize(), Optional.absent(), Optional.absent()), recipient);
}
uploadRepository.applyMediaUpdates(oldToNew, recipient);
uploadRepository.updateCaptions(updatedMedia);
uploadRepository.updateDisplayOrder(updatedMedia);
uploadRepository.getPreUploadResults(uploadResults -> {
if (recipients.size() > 0) {
sendMessages(recipients, splitBody, uploadResults);
uploadRepository.deleteAbandonedAttachments();
}
Util.cancelRunnableOnMain(dialogRunnable);
result.postValue(MediaSendActivityResult.forPreUpload(uploadResults, splitBody, transport, isViewOnce()));
});
});
sentMedia = true;
return result;
}
@NonNull Map<Uri, Object> getDrawState() {
@ -424,7 +516,7 @@ class MediaSendViewModel extends ViewModel {
return folders;
}
@NonNull LiveData<Optional<Media>> getMostRecentMediaItem(@NonNull Context context) {
@NonNull LiveData<Optional<Media>> getMostRecentMediaItem() {
return mostRecentMedia;
}
@ -512,10 +604,63 @@ class MediaSendViewModel extends ViewModel {
}
}
private void updateAttachmentUploads(@NonNull List<Media> oldMedia, @NonNull List<Media> newMedia) {
if (!preUploadEnabled) return;
DiffHelper.Result<Media> result = DiffHelper.calculate(oldMedia, newMedia);
uploadRepository.cancelUpload(result.getRemoved());
uploadRepository.startUpload(result.getInserted(), recipient);
}
private void cancelUpload(@NonNull Media media) {
uploadRepository.cancelUpload(media);
}
private void startUpload(@NonNull Media media) {
if (!preUploadEnabled) return;
uploadRepository.startUpload(media, recipient);
}
@WorkerThread
private void sendMessages(@NonNull List<Recipient> recipients, @NonNull String body, @NonNull Collection<PreUploadResult> preUploadResults) {
List<OutgoingSecureMediaMessage> messages = new ArrayList<>(recipients.size());
for (Recipient recipient : recipients) {
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
body,
Collections.emptyList(),
System.currentTimeMillis(),
-1,
recipient.getExpireMessages() * 1000,
isViewOnce(),
ThreadDatabase.DistributionTypes.DEFAULT,
null,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList());
messages.add(new OutgoingSecureMediaMessage(message));
// 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.
Util.sleep(5);
}
MessageSender.sendMediaBroadcast(application, messages, preUploadResults);
}
private static boolean shouldPreUpload(@NonNull Context context, boolean metered, boolean isSms, @Nullable Recipient recipient) {
return !metered && !isSms && !MessageSender.isLocalSelfSend(context, recipient, isSms);
}
@Override
protected void onCleared() {
if (!sentMedia) {
clearPersistedMedia();
uploadRepository.cancelAllUploads();
uploadRepository.deleteAbandonedAttachments();
}
}
@ -524,7 +669,7 @@ class MediaSendViewModel extends ViewModel {
}
enum Event {
VIEW_ONCE_TOOLTIP
VIEW_ONCE_TOOLTIP, SHOW_RENDER_PROGRESS, HIDE_RENDER_PROGRESS
}
enum Page {
@ -611,7 +756,7 @@ class MediaSendViewModel extends ViewModel {
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return modelClass.cast(new MediaSendViewModel(application, repository));
return modelClass.cast(new MediaSendViewModel(application, repository, new MediaUploadRepository(application)));
}
}
}

View File

@ -0,0 +1,207 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GifSlide;
import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor;
/**
* Manages the proactive upload of media during the selection process. Upload/cancel operations
* need to be serialized, because they're asynchronous operations that depend on ordered completion.
*
* For example, if we begin upload of a {@link Media) but then immediately cancel it (before it was
* enqueued on the {@link JobManager}), we need to wait until we have the jobId to cancel. This
* class manages everything by using a single thread executor.
*
* This also means that unlike most repositories, the class itself is stateful. Keep that in mind
* when using it.
*/
class MediaUploadRepository {
private static final String TAG = Log.tag(MediaUploadRepository.class);
private final Context context;
private final LinkedHashMap<Media, PreUploadResult> uploadResults;
private final Executor executor;
MediaUploadRepository(@NonNull Context context) {
this.context = context;
this.uploadResults = new LinkedHashMap<>();
this.executor = SignalExecutors.newCachedSingleThreadExecutor("signal-MediaUpload");
}
void startUpload(@NonNull Media media, @Nullable Recipient recipient) {
executor.execute(() -> uploadMediaInternal(media, recipient));
}
void startUpload(@NonNull Collection<Media> mediaItems, @Nullable Recipient recipient) {
executor.execute(() -> {
for (Media media : mediaItems) {
cancelUploadInternal(media);
uploadMediaInternal(media, recipient);
}
});
}
/**
* Given a map of old->new, cancel medias that were changed and upload their replacements. Will
* also upload any media in the map that wasn't yet uploaded.
*/
void applyMediaUpdates(@NonNull Map<Media, Media> oldToNew, @Nullable Recipient recipient) {
executor.execute(() -> {
for (Map.Entry<Media, Media> entry : oldToNew.entrySet()) {
if (!entry.getKey().equals(entry.getValue()) || !uploadResults.containsKey(entry.getValue())) {
cancelUploadInternal(entry.getKey());
uploadMediaInternal(entry.getValue(), recipient);
}
}
});
}
void cancelUpload(@NonNull Media media) {
executor.execute(() -> cancelUploadInternal(media));
}
void cancelUpload(@NonNull Collection<Media> mediaItems) {
executor.execute(() -> {
for (Media media : mediaItems) {
cancelUploadInternal(media);
}
});
}
void cancelAllUploads() {
executor.execute(() -> {
for (Media media : new HashSet<>(uploadResults.keySet())) {
cancelUploadInternal(media);
}
});
}
void getPreUploadResults(@NonNull Callback<Collection<PreUploadResult>> callback) {
executor.execute(() -> callback.onResult(uploadResults.values()));
}
void updateCaptions(@NonNull List<Media> updatedMedia) {
executor.execute(() -> updateCaptionsInternal(updatedMedia));
}
void updateDisplayOrder(@NonNull List<Media> mediaInOrder) {
executor.execute(() -> updateDisplayOrderInternal(mediaInOrder));
}
void deleteAbandonedAttachments() {
executor.execute(() -> {
int deleted = DatabaseFactory.getAttachmentDatabase(context).deleteAbandonedPreuploadedAttachments();
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
});
}
@WorkerThread
private void uploadMediaInternal(@NonNull Media media, @Nullable Recipient recipient) {
Attachment attachment = asAttachment(context, media);
PreUploadResult result = MessageSender.preUploadPushAttachment(context, attachment, recipient);
if (result != null) {
uploadResults.put(media, result);
} else {
Log.w(TAG, "Failed to upload media with URI: " + media.getUri());
}
}
private void cancelUploadInternal(@NonNull Media media) {
JobManager jobManager = ApplicationDependencies.getJobManager();
PreUploadResult result = uploadResults.get(media);
if (result != null) {
Stream.of(result.getJobIds()).forEach(jobManager::cancel);
uploadResults.remove(media);
}
}
@WorkerThread
private void updateCaptionsInternal(@NonNull List<Media> updatedMedia) {
AttachmentDatabase db = DatabaseFactory.getAttachmentDatabase(context);
for (Media updated : updatedMedia) {
PreUploadResult result = uploadResults.get(updated);
if (result != null) {
db.updateAttachmentCaption(result.getAttachmentId(), updated.getCaption().orNull());
} else {
Log.w(TAG,"When updating captions, no pre-upload result could be found for media with URI: " + updated.getUri());
}
}
}
@WorkerThread
private void updateDisplayOrderInternal(@NonNull List<Media> mediaInOrder) {
Map<AttachmentId, Integer> orderMap = new HashMap<>();
Map<Media, PreUploadResult> orderedUploadResults = new LinkedHashMap<>();
for (int i = 0; i < mediaInOrder.size(); i++) {
Media media = mediaInOrder.get(i);
PreUploadResult result = uploadResults.get(media);
if (result != null) {
orderMap.put(result.getAttachmentId(), i);
orderedUploadResults.put(media, result);
} else {
Log.w(TAG, "When updating display order, no pre-upload result could be found for media with URI: " + media.getUri());
}
}
DatabaseFactory.getAttachmentDatabase(context).updateDisplayOrder(orderMap);
if (orderedUploadResults.size() == uploadResults.size()) {
uploadResults.clear();
uploadResults.putAll(orderedUploadResults);
}
}
private static @NonNull Attachment asAttachment(@NonNull Context context, @NonNull Media media) {
if (MediaUtil.isVideoType(media.getMimeType())) {
return new VideoSlide(context, media.getUri(), 0, media.getCaption().orNull()).asAttachment();
} else if (MediaUtil.isGif(media.getMimeType())) {
return new GifSlide(context, media.getUri(), 0, media.getWidth(), media.getHeight(), media.getCaption().orNull()).asAttachment();
} else if (MediaUtil.isImageType(media.getMimeType())) {
return new ImageSlide(context, media.getUri(), 0, media.getWidth(), media.getHeight(), media.getCaption().orNull(), null).asAttachment();
} else if (MediaUtil.isTextType(media.getMimeType())) {
return new TextSlide(context, media.getUri(), null, media.getSize()).asAttachment();
} else {
throw new AssertionError("Unexpected mimeType: " + media.getMimeType());
}
}
interface Callback<E> {
void onResult(@NonNull E result);
}
}

View File

@ -0,0 +1,60 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.core.net.ConnectivityManagerCompat;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.thoughtcrime.securesms.util.ServiceUtil;
/**
* Lifecycle-bound observer for whether or not the active network connection is metered.
*/
class MeteredConnectivityObserver extends BroadcastReceiver implements DefaultLifecycleObserver {
private final Context context;
private final ConnectivityManager connectivityManager;
private final MutableLiveData<Boolean> metered;
@MainThread
MeteredConnectivityObserver(@NonNull Context context, @NonNull LifecycleOwner lifecycleOwner) {
this.context = context;
this.connectivityManager = ServiceUtil.getConnectivityManager(context);
this.metered = new MutableLiveData<>();
this.metered.setValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager));
lifecycleOwner.getLifecycle().addObserver(this);
}
@Override
public void onCreate(@NonNull LifecycleOwner owner) {
context.registerReceiver(this, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
}
@Override
public void onDestroy(@NonNull LifecycleOwner owner) {
context.unregisterReceiver(this);
}
@Override
public void onReceive(Context context, Intent intent) {
metered.postValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager));
}
/**
* @return An observable value that is false when the network is unmetered, and true if the
* network is either metered or unavailable.
*/
@NonNull LiveData<Boolean> isMetered() {
return metered;
}
}

View File

@ -17,8 +17,11 @@
package org.thoughtcrime.securesms.sms;
import android.content.Context;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
@ -59,14 +62,16 @@ import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.ParcelUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
public class MessageSender {
@ -118,7 +123,7 @@ public class MessageSender {
Recipient recipient = message.getRecipient();
long messageId = database.insertMessageOutbox(message, allocatedThreadId, forceSms, insertListener);
sendMediaMessage(context, recipient, forceSms, messageId, message.getExpiresIn());
sendMediaMessage(context, recipient, forceSms, messageId, Collections.emptyList());
return allocatedThreadId;
} catch (MmsException e) {
@ -127,72 +132,97 @@ public class MessageSender {
}
}
public static void sendMediaBroadcast(@NonNull Context context, @NonNull List<OutgoingSecureMediaMessage> messages) {
if (messages.isEmpty()) {
Log.w(TAG, "sendMediaBroadcast() - No messages!");
return;
}
if (!isValidBroadcastList(messages)) {
Log.w(TAG, "sendMediaBroadcast() - Invalid message list!");
return;
}
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
List<List<DatabaseAttachment>> databaseAttachments = new ArrayList<>(messages.get(0).getAttachments().size());
List<Long> messageIds = new ArrayList<>(messages.size());
for (int i = 0; i < messages.get(0).getAttachments().size(); i++) {
databaseAttachments.add(new ArrayList<>(messages.size()));
}
public static long sendPushWithPreUploadedMedia(final Context context,
final OutgoingMediaMessage message,
final Collection<PreUploadResult> preUploadResults,
final long threadId,
final SmsDatabase.InsertListener insertListener)
{
Preconditions.checkArgument(message.getAttachments().isEmpty(), "If the media is pre-uploaded, there should be no attachments on the message.");
try {
try {
mmsDatabase.beginTransaction();
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
for (OutgoingSecureMediaMessage message : messages) {
long allocatedThreadId = threadDatabase.getThreadIdFor(message.getRecipient(), message.getDistributionType());
long messageId = mmsDatabase.insertMessageOutbox(message, allocatedThreadId, false, null);
List<DatabaseAttachment> attachments = attachmentDatabase.getAttachmentsForMessage(messageId);
long allocatedThreadId;
if (attachments.size() != databaseAttachments.size()) {
Log.w(TAG, "Got back an attachment list that was a different size than expected. Expected: " + databaseAttachments.size() + " Actual: "+ attachments.size());
return;
}
for (int i = 0; i < attachments.size(); i++) {
databaseAttachments.get(i).add(attachments.get(i));
if (threadId == -1) {
allocatedThreadId = threadDatabase.getThreadIdFor(message.getRecipient(), message.getDistributionType());
} else {
allocatedThreadId = threadId;
}
Recipient recipient = message.getRecipient();
long messageId = mmsDatabase.insertMessageOutbox(message, allocatedThreadId, false, insertListener);
List<AttachmentId> attachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList();
List<String> jobIds = Stream.of(preUploadResults).map(PreUploadResult::getJobIds).flatMap(Stream::of).toList();
attachmentDatabase.updateMessageId(attachmentIds, messageId);
sendMediaMessage(context, recipient, false, messageId, jobIds);
return allocatedThreadId;
} catch (MmsException e) {
Log.w(TAG, e);
return threadId;
}
}
public static void sendMediaBroadcast(@NonNull Context context, @NonNull List<OutgoingSecureMediaMessage> messages, @NonNull Collection<PreUploadResult> preUploadResults) {
Preconditions.checkArgument(messages.size() > 0, "No messages!");
Preconditions.checkArgument(Stream.of(messages).allMatch(m -> m.getAttachments().isEmpty()), "Messages can't have attachments! They should be pre-uploaded.");
JobManager jobManager = ApplicationDependencies.getJobManager();
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
List<AttachmentId> preUploadAttachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList();
List<String> preUploadJobIds = Stream.of(preUploadResults).map(PreUploadResult::getJobIds).flatMap(Stream::of).toList();
List<Long> messageIds = new ArrayList<>(messages.size());
List<String> messageDependsOnIds = new ArrayList<>(preUploadJobIds);
mmsDatabase.beginTransaction();
try {
OutgoingSecureMediaMessage primaryMessage = messages.get(0);
long primaryThreadId = threadDatabase.getThreadIdFor(primaryMessage.getRecipient(), primaryMessage.getDistributionType());
long primaryMessageId = mmsDatabase.insertMessageOutbox(primaryMessage, primaryThreadId, false, null);
attachmentDatabase.updateMessageId(preUploadAttachmentIds, primaryMessageId);
messageIds.add(primaryMessageId);
if (messages.size() > 0) {
List<OutgoingSecureMediaMessage> secondaryMessages = messages.subList(1, messages.size());
List<List<AttachmentId>> attachmentCopies = new ArrayList<>();
List<DatabaseAttachment> preUploadAttachments = Stream.of(preUploadAttachmentIds)
.map(attachmentDatabase::getAttachment)
.toList();
for (int i = 0; i < preUploadAttachmentIds.size(); i++) {
attachmentCopies.add(new ArrayList<>(messages.size()));
}
for (OutgoingSecureMediaMessage secondaryMessage : secondaryMessages) {
long allocatedThreadId = threadDatabase.getThreadIdFor(secondaryMessage.getRecipient(), secondaryMessage.getDistributionType());
long messageId = mmsDatabase.insertMessageOutbox(secondaryMessage, allocatedThreadId, false, null);
List<AttachmentId> attachmentIds = new ArrayList<>(preUploadAttachmentIds.size());
for (int i = 0; i < preUploadAttachments.size(); i++) {
AttachmentId attachmentId = attachmentDatabase.insertAttachmentForPreUpload(preUploadAttachments.get(i)).getAttachmentId();
attachmentCopies.get(i).add(attachmentId);
attachmentIds.add(attachmentId);
}
attachmentDatabase.updateMessageId(attachmentIds, messageId);
messageIds.add(messageId);
}
mmsDatabase.setTransactionSuccessful();
} finally {
mmsDatabase.endTransaction();
}
List<Job> compressionJobs = new ArrayList<>(databaseAttachments.size());
List<Job> uploadJobs = new ArrayList<>(databaseAttachments.size());
List<Job> copyJobs = new ArrayList<>(databaseAttachments.size());
List<Job> messageJobs = new ArrayList<>(databaseAttachments.get(0).size());
for (List<DatabaseAttachment> attachmentList : databaseAttachments) {
DatabaseAttachment source = attachmentList.get(0);
compressionJobs.add(AttachmentCompressionJob.fromAttachment(source, false, -1));
uploadJobs.add(new AttachmentUploadJob(source.getAttachmentId()));
if (attachmentList.size() > 1) {
AttachmentId sourceId = source.getAttachmentId();
List<AttachmentId> destinationIds = Stream.of(attachmentList.subList(1, attachmentList.size()))
.map(DatabaseAttachment::getAttachmentId)
.toList();
copyJobs.add(new AttachmentCopyJob(sourceId, destinationIds));
for (int i = 0; i < attachmentCopies.size(); i++) {
Job copyJob = new AttachmentCopyJob(preUploadAttachmentIds.get(i), attachmentCopies.get(i));
jobManager.add(copyJob, preUploadJobIds);
messageDependsOnIds.add(copyJob.getId());
}
}
@ -204,32 +234,47 @@ public class MessageSender {
if (isLocalSelfSend(context, recipient, false)) {
sendLocalMediaSelf(context, messageId);
} else if (isGroupPushSend(recipient)) {
messageJobs.add(new PushGroupSendJob(messageId, recipient.getId(), null));
jobManager.add(new PushGroupSendJob(messageId, recipient.getId(), null), messageDependsOnIds);
} else {
messageJobs.add(new PushMediaSendJob(messageId, recipient));
jobManager.add(new PushMediaSendJob(messageId, recipient), messageDependsOnIds);
}
}
Log.i(TAG, String.format(Locale.ENGLISH, "sendMediaBroadcast() - Uploading %d attachment(s), copying %d of them, then sending %d messages.",
uploadJobs.size(),
copyJobs.size(),
messageJobs.size()));
JobManager.Chain chain = ApplicationDependencies.getJobManager()
.startChain(compressionJobs)
.then(uploadJobs);
if (copyJobs.size() > 0) {
chain = chain.then(copyJobs);
}
chain = chain.then(messageJobs);
chain.enqueue();
mmsDatabase.setTransactionSuccessful();
} catch (MmsException e) {
Log.w(TAG, "sendMediaBroadcast() - Failed to send messages!", e);
Log.w(TAG, "Failed to send messages.", e);
} finally {
mmsDatabase.endTransaction();
}
}
/**
* @return A result if the attachment was enqueued, or null if it failed to enqueue or shouldn't
* be enqueued (like in the case of a local self-send).
*/
public static @Nullable PreUploadResult preUploadPushAttachment(@NonNull Context context, @NonNull Attachment attachment, @Nullable Recipient recipient) {
if (recipient != null && isLocalSelfSend(context, recipient, false)) {
return null;
}
try {
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
DatabaseAttachment databaseAttachment = attachmentDatabase.insertAttachmentForPreUpload(attachment);
Job compressionJob = AttachmentCompressionJob.fromAttachment(databaseAttachment, false, -1);
Job uploadJob = new AttachmentUploadJob(databaseAttachment.getAttachmentId());
ApplicationDependencies.getJobManager()
.startChain(compressionJob)
.then(uploadJob)
.enqueue();
return new PreUploadResult(databaseAttachment.getAttachmentId(), Arrays.asList(compressionJob.getId(), uploadJob.getId()));
} catch (MmsException e) {
Log.w(TAG, "preUploadPushAttachment() - Failed to upload!", e);
return null;
}
}
public static void sendNewReaction(@NonNull Context context, long messageId, boolean isMms, @NonNull String emoji) {
MessagingDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
@ -258,31 +303,30 @@ public class MessageSender {
public static void resendGroupMessage(Context context, MessageRecord messageRecord, RecipientId filterRecipientId) {
if (!messageRecord.isMms()) throw new AssertionError("Not Group");
sendGroupPush(context, messageRecord.getRecipient(), messageRecord.getId(), filterRecipientId);
sendGroupPush(context, messageRecord.getRecipient(), messageRecord.getId(), filterRecipientId, Collections.emptyList());
}
public static void resend(Context context, MessageRecord messageRecord) {
long messageId = messageRecord.getId();
boolean forceSms = messageRecord.isForcedSms();
boolean keyExchange = messageRecord.isKeyExchange();
long expiresIn = messageRecord.getExpiresIn();
Recipient recipient = messageRecord.getRecipient();
if (messageRecord.isMms()) {
sendMediaMessage(context, recipient, forceSms, messageId, expiresIn);
sendMediaMessage(context, recipient, forceSms, messageId, Collections.emptyList());
} else {
sendTextMessage(context, recipient, forceSms, keyExchange, messageId);
}
}
private static void sendMediaMessage(Context context, Recipient recipient, boolean forceSms, long messageId, long expiresIn)
private static void sendMediaMessage(Context context, Recipient recipient, boolean forceSms, long messageId, @NonNull Collection<String> uploadJobIds)
{
if (isLocalSelfSend(context, recipient, forceSms)) {
sendLocalMediaSelf(context, messageId);
} else if (isGroupPushSend(recipient)) {
sendGroupPush(context, recipient, messageId, null);
sendGroupPush(context, recipient, messageId, null, uploadJobIds);
} else if (!forceSms && isPushMediaSend(context, recipient)) {
sendMediaPush(context, recipient, messageId);
sendMediaPush(context, recipient, messageId, uploadJobIds);
} else {
sendMms(context, messageId);
}
@ -295,25 +339,37 @@ public class MessageSender {
if (isLocalSelfSend(context, recipient, forceSms)) {
sendLocalTextSelf(context, messageId);
} else if (!forceSms && isPushTextSend(context, recipient, keyExchange)) {
sendTextPush(context, recipient, messageId);
sendTextPush(recipient, messageId);
} else {
sendSms(context, recipient, messageId);
}
}
private static void sendTextPush(Context context, Recipient recipient, long messageId) {
private static void sendTextPush(Recipient recipient, long messageId) {
JobManager jobManager = ApplicationDependencies.getJobManager();
jobManager.add(new PushTextSendJob(messageId, recipient));
}
private static void sendMediaPush(Context context, Recipient recipient, long messageId) {
private static void sendMediaPush(Context context, Recipient recipient, long messageId, @NonNull Collection<String> uploadJobIds) {
JobManager jobManager = ApplicationDependencies.getJobManager();
PushMediaSendJob.enqueue(context, jobManager, messageId, recipient);
if (uploadJobIds.size() > 0) {
Job mediaSend = new PushMediaSendJob(messageId, recipient);
jobManager.add(mediaSend, uploadJobIds);
} else {
PushMediaSendJob.enqueue(context, jobManager, messageId, recipient);
}
}
private static void sendGroupPush(Context context, Recipient recipient, long messageId, RecipientId filterRecipientId) {
private static void sendGroupPush(Context context, Recipient recipient, long messageId, RecipientId filterRecipientId, @NonNull Collection<String> uploadJobIds) {
JobManager jobManager = ApplicationDependencies.getJobManager();
PushGroupSendJob.enqueue(context, jobManager, messageId, recipient.getId(), filterRecipientId);
if (uploadJobIds.size() > 0) {
Job groupSend = new PushGroupSendJob(messageId, recipient.getId(), filterRecipientId);
jobManager.add(groupSend, uploadJobIds);
} else {
PushGroupSendJob.enqueue(context, jobManager, messageId, recipient.getId(), filterRecipientId);
}
}
private static void sendSms(Context context, Recipient recipient, long messageId) {
@ -370,8 +426,9 @@ public class MessageSender {
}
}
private static boolean isLocalSelfSend(@NonNull Context context, @NonNull Recipient recipient, boolean forceSms) {
return recipient.isLocalNumber() &&
public static boolean isLocalSelfSend(@NonNull Context context, @Nullable Recipient recipient, boolean forceSms) {
return recipient != null &&
recipient.isLocalNumber() &&
!forceSms &&
TextSecurePreferences.isPushRegistered(context) &&
!TextSecurePreferences.isMultiDevice(context);
@ -428,19 +485,49 @@ public class MessageSender {
}
}
private static boolean isValidBroadcastList(@NonNull List<OutgoingSecureMediaMessage> messages) {
if (messages.isEmpty()) {
return false;
public static class PreUploadResult implements Parcelable {
private final AttachmentId attachmentId;
private final Collection<String> jobIds;
PreUploadResult(@NonNull AttachmentId attachmentId, @NonNull Collection<String> jobIds) {
this.attachmentId = attachmentId;
this.jobIds = jobIds;
}
int attachmentSize = messages.get(0).getAttachments().size();
private PreUploadResult(Parcel in) {
this.attachmentId = in.readParcelable(AttachmentId.class.getClassLoader());
this.jobIds = ParcelUtil.readStringCollection(in);
}
for (OutgoingSecureMediaMessage message : messages) {
if (message.getAttachments().size() != attachmentSize) {
return false;
public @NonNull AttachmentId getAttachmentId() {
return attachmentId;
}
public @NonNull Collection<String> getJobIds() {
return jobIds;
}
public static final Creator<PreUploadResult> CREATOR = new Creator<PreUploadResult>() {
@Override
public PreUploadResult createFromParcel(Parcel in) {
return new PreUploadResult(in);
}
@Override
public PreUploadResult[] newArray(int size) {
return new PreUploadResult[size];
}
};
@Override
public int describeContents() {
return 0;
}
return true;
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeParcelable(attachmentId, flags);
ParcelUtil.writeStringCollection(dest, jobIds);
}
}
}

View File

@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
/**
* Helps determine the difference between two collections based on their {@link #equals(Object)}
* implementations.
*/
public class DiffHelper {
/**
* @return Result indicating the differences between the two collections. Important: The iteration
* order of the result will not necessarily match the iteration order of the original
* collection.
*/
public static <E> Result<E> calculate(@NonNull Collection<E> oldList, @NonNull Collection<E> newList) {
Set<E> inserted = SetUtil.difference(newList, oldList);
Set<E> removed = SetUtil.difference(oldList, newList);
return new Result<>(inserted, removed);
}
public static class Result<E> {
private final Collection<E> inserted;
private final Collection<E> removed;
public Result(@NonNull Collection<E> inserted, @NonNull Collection<E> removed) {
this.removed = removed;
this.inserted = inserted;
}
public @NonNull Collection<E> getInserted() {
return inserted;
}
public @NonNull Collection<E> getRemoved() {
return removed;
}
}
}

View File

@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.util;
import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.whispersystems.libsignal.util.guava.Optional;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
public final class MessageUtil {
private MessageUtil() {}
/**
* @return If the message is longer than the allowed text size, this will return trimmed text with
* an accompanying TextSlide. Otherwise it'll just return the original text.
*/
public static SplitResult getSplitMessage(@NonNull Context context, @NonNull String rawText, int maxPrimaryMessageSize) {
String bodyText = rawText;
Optional<TextSlide> textSlide = Optional.absent();
if (bodyText.length() > maxPrimaryMessageSize) {
bodyText = rawText.substring(0, maxPrimaryMessageSize);
byte[] textData = rawText.getBytes();
String timestamp = new SimpleDateFormat("yyyy-MM-dd-HHmmss", Locale.US).format(new Date());
String filename = String.format("signal-%s.txt", timestamp);
Uri textUri = BlobProvider.getInstance()
.forData(textData)
.withMimeType(MediaUtil.LONG_TEXT)
.withFileName(filename)
.createForSingleSessionInMemory();
textSlide = Optional.of(new TextSlide(context, textUri, filename, textData.length));
}
return new SplitResult(bodyText, textSlide);
}
public static class SplitResult {
private final String body;
private final Optional<TextSlide> textSlide;
private SplitResult(@NonNull String body, @NonNull Optional<TextSlide> textSlide) {
this.body = body;
this.textSlide = textSlide;
}
public @NonNull String getBody() {
return body;
}
public @NonNull Optional<TextSlide> getTextSlide() {
return textSlide;
}
}
}

View File

@ -3,6 +3,15 @@ package org.thoughtcrime.securesms.util;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
public class ParcelUtil {
public static byte[] serialize(Parcelable parceable) {
@ -25,4 +34,31 @@ public class ParcelUtil {
return creator.createFromParcel(parcel);
}
public static void writeStringCollection(@NonNull Parcel dest, @NonNull Collection<String> collection) {
dest.writeStringList(new ArrayList<>(collection));
}
public static @NonNull Collection<String> readStringCollection(@NonNull Parcel in) {
List<String> list = new ArrayList<>();
in.readStringList(list);
return list;
}
public static void writeParcelableCollection(@NonNull Parcel dest, @NonNull Collection<? extends Parcelable> collection) {
Parcelable[] values = collection.toArray(new Parcelable[0]);
dest.writeParcelableArray(values, 0);
}
public static @NonNull <E> Collection<E> readParcelableCollection(@NonNull Parcel in, Class<E> clazz) {
//noinspection unchecked
return Arrays.asList((E[]) in.readParcelableArray(clazz.getClassLoader()));
}
public static void writeBoolean(@NonNull Parcel dest, boolean value) {
dest.writeByte(value ? (byte) 1 : 0);
}
public static boolean readBoolean(@NonNull Parcel in) {
return in.readByte() != 0;
}
}

View File

@ -1,19 +1,19 @@
package org.thoughtcrime.securesms.util;
import java.util.HashSet;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;
public final class SetUtil {
private SetUtil() {}
public static <E> Set<E> intersection(Set<E> a, Set<E> b) {
public static <E> Set<E> intersection(Collection<E> a, Collection<E> b) {
Set<E> intersection = new LinkedHashSet<>(a);
intersection.retainAll(b);
return intersection;
}
public static <E> Set<E> difference(Set<E> a, Set<E> b) {
public static <E> Set<E> difference(Collection<E> a, Collection<E> b) {
Set<E> difference = new LinkedHashSet<>(a);
difference.removeAll(b);
return difference;

View File

@ -84,7 +84,9 @@ public final class InMemoryTranscoder implements Closeable {
: OUTPUT_FORMAT;
}
public @NonNull MediaStream transcode(@NonNull Progress progress) throws IOException, EncodingException, VideoSizeException {
public @NonNull MediaStream transcode(@NonNull Progress progress, @Nullable CancelationSignal cancelationSignal)
throws IOException, EncodingException, VideoSizeException
{
if (memoryFile != null) throw new AssertionError("Not expecting to reuse transcoder");
float durationSec = duration / 1000f;
@ -131,7 +133,7 @@ public final class InMemoryTranscoder implements Closeable {
converter.setListener(percent -> {
progress.onProgress(percent);
return false;
return cancelationSignal != null && cancelationSignal.isCanceled();
});
converter.convert();
@ -211,7 +213,10 @@ public final class InMemoryTranscoder implements Closeable {
}
public interface Progress {
void onProgress(int percent);
}
public interface CancelationSignal {
boolean isCanceled();
}
}

View File

@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import org.junit.Test;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class DiffHelperTest {
private static final Object A = new Object();
private static final Object B = new Object();
private static final Object C = new Object();
private static final Object D = new Object();
@Test
public void calculate_allRemoved() {
DiffHelper.Result result = DiffHelper.calculate(Arrays.asList(A, B), Collections.emptyList());
assertContentsEqual(Collections.emptyList(), result.getInserted());
assertContentsEqual(Arrays.asList(A, B), result.getRemoved());
}
@Test
public void calculate_allInserted() {
DiffHelper.Result result = DiffHelper.calculate(Collections.emptyList(), Arrays.asList(A, B));
assertContentsEqual(Arrays.asList(A, B), result.getInserted());
assertContentsEqual(Collections.emptyList(), result.getRemoved());
}
@Test
public void calculate_completeSwap() {
DiffHelper.Result result = DiffHelper.calculate(Collections.singleton(A), Collections.singleton(B));
assertContentsEqual(Collections.singleton(B), result.getInserted());
assertContentsEqual(Collections.singleton(A), result.getRemoved());
}
@Test
public void calculate_bothEmpty() {
DiffHelper.Result result = DiffHelper.calculate(Collections.emptyList(), Collections.emptyList());
assertContentsEqual(Collections.emptyList(), result.getInserted());
assertContentsEqual(Collections.emptyList(), result.getRemoved());
}
private void assertContentsEqual(@NonNull Collection expected, @NonNull Collection actual) {
assertEquals(expected.size(), actual.size());
assertTrue(expected.containsAll(actual));
}
}

View File

@ -341,7 +341,8 @@ public class SignalServiceMessageSender {
dataStream,
ciphertextLength,
new AttachmentCipherOutputStreamFactory(attachmentKey),
attachment.getListener());
attachment.getListener(),
attachment.getCancelationSignal());
AttachmentUploadAttributes uploadAttributes = null;

View File

@ -7,6 +7,7 @@
package org.whispersystems.signalservice.api.messages;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
import java.io.InputStream;
@ -39,16 +40,17 @@ public abstract class SignalServiceAttachment {
public static class Builder {
private InputStream inputStream;
private String contentType;
private String fileName;
private long length;
private ProgressListener listener;
private boolean voiceNote;
private int width;
private int height;
private String caption;
private String blurHash;
private InputStream inputStream;
private String contentType;
private String fileName;
private long length;
private ProgressListener listener;
private CancelationSignal cancelationSignal;
private boolean voiceNote;
private int width;
private int height;
private String caption;
private String blurHash;
private Builder() {}
@ -77,6 +79,11 @@ public abstract class SignalServiceAttachment {
return this;
}
public Builder withCancelationSignal(CancelationSignal cancelationSignal) {
this.cancelationSignal = cancelationSignal;
return this;
}
public Builder withVoiceNote(boolean voiceNote) {
this.voiceNote = voiceNote;
return this;
@ -117,7 +124,8 @@ public abstract class SignalServiceAttachment {
height,
Optional.fromNullable(caption),
Optional.fromNullable(blurHash),
listener);
listener,
cancelationSignal);
}
}

View File

@ -7,6 +7,7 @@
package org.whispersystems.signalservice.api.messages;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
import java.io.InputStream;
@ -15,33 +16,47 @@ import java.io.InputStream;
*/
public class SignalServiceAttachmentStream extends SignalServiceAttachment {
private final InputStream inputStream;
private final long length;
private final Optional<String> fileName;
private final ProgressListener listener;
private final Optional<byte[]> preview;
private final boolean voiceNote;
private final int width;
private final int height;
private final Optional<String> caption;
private final Optional<String> blurHash;
private final InputStream inputStream;
private final long length;
private final Optional<String> fileName;
private final ProgressListener listener;
private final CancelationSignal cancelationSignal;
private final Optional<byte[]> preview;
private final boolean voiceNote;
private final int width;
private final int height;
private final Optional<String> caption;
private final Optional<String> blurHash;
public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, Optional<String> fileName, boolean voiceNote, ProgressListener listener) {
this(inputStream, contentType, length, fileName, voiceNote, Optional.<byte[]>absent(), 0, 0, Optional.<String>absent(), Optional.<String>absent(), listener);
public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, Optional<String> fileName, boolean voiceNote, ProgressListener listener, CancelationSignal cancelationSignal) {
this(inputStream, contentType, length, fileName, voiceNote, Optional.<byte[]>absent(), 0, 0, Optional.<String>absent(), Optional.<String>absent(), listener, cancelationSignal);
}
public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, Optional<String> fileName, boolean voiceNote, Optional<byte[]> preview, int width, int height, Optional<String> caption, Optional<String> blurHash, ProgressListener listener) {
public SignalServiceAttachmentStream(InputStream inputStream,
String contentType,
long length,
Optional<String> fileName,
boolean voiceNote,
Optional<byte[]> preview,
int width,
int height,
Optional<String> caption,
Optional<String> blurHash,
ProgressListener listener,
CancelationSignal cancelationSignal)
{
super(contentType);
this.inputStream = inputStream;
this.length = length;
this.fileName = fileName;
this.listener = listener;
this.voiceNote = voiceNote;
this.preview = preview;
this.width = width;
this.height = height;
this.caption = caption;
this.blurHash = blurHash;
this.inputStream = inputStream;
this.length = length;
this.fileName = fileName;
this.listener = listener;
this.voiceNote = voiceNote;
this.preview = preview;
this.width = width;
this.height = height;
this.caption = caption;
this.blurHash = blurHash;
this.cancelationSignal = cancelationSignal;
}
@Override
@ -70,6 +85,10 @@ public class SignalServiceAttachmentStream extends SignalServiceAttachment {
return listener;
}
public CancelationSignal getCancelationSignal() {
return cancelationSignal;
}
public Optional<byte[]> getPreview() {
return preview;
}

View File

@ -55,7 +55,7 @@ public class DeviceContactsInputStream extends ChunkedInputStream {
InputStream avatarStream = new LimitedInputStream(in, avatarLength);
String avatarContentType = details.getAvatar().getContentType();
avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.<String>absent(), false, null));
avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.<String>absent(), false, null, null));
}
if (details.hasVerified()) {

View File

@ -52,7 +52,7 @@ public class DeviceGroupsInputStream extends ChunkedInputStream{
InputStream avatarStream = new ChunkedInputStream.LimitedInputStream(in, avatarLength);
String avatarContentType = details.getAvatar().getContentType();
avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.<String>absent(), false, null));
avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.<String>absent(), false, null, null));
}
if (details.hasExpireTimer() && details.getExpireTimer() > 0) {

View File

@ -7,6 +7,7 @@
package org.whispersystems.signalservice.internal.push;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory;
import java.io.InputStream;
@ -18,15 +19,18 @@ public class PushAttachmentData {
private final long dataSize;
private final OutputStreamFactory outputStreamFactory;
private final ProgressListener listener;
private final CancelationSignal cancelationSignal;
public PushAttachmentData(String contentType, InputStream data, long dataSize,
OutputStreamFactory outputStreamFactory, ProgressListener listener)
OutputStreamFactory outputStreamFactory, ProgressListener listener,
CancelationSignal cancelationSignal)
{
this.contentType = contentType;
this.data = data;
this.dataSize = dataSize;
this.outputStreamFactory = outputStreamFactory;
this.listener = listener;
this.cancelationSignal = cancelationSignal;
}
public String getContentType() {
@ -48,4 +52,8 @@ public class PushAttachmentData {
public ProgressListener getListener() {
return listener;
}
public CancelationSignal getCancelationSignal() {
return cancelationSignal;
}
}

View File

@ -50,6 +50,7 @@ import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResp
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException;
import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException;
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
import org.whispersystems.signalservice.internal.push.http.DigestingRequestBody;
import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory;
import org.whispersystems.signalservice.internal.storage.protos.ReadOperation;
@ -577,7 +578,7 @@ public class PushServiceSocket {
formAttributes.getCredential(), formAttributes.getDate(),
formAttributes.getSignature(), profileAvatar.getData(),
profileAvatar.getContentType(), profileAvatar.getDataLength(),
profileAvatar.getOutputStreamFactory(), null);
profileAvatar.getOutputStreamFactory(), null, null);
}
}
@ -763,7 +764,8 @@ public class PushServiceSocket {
uploadAttributes.getCredential(), uploadAttributes.getDate(),
uploadAttributes.getSignature(), attachment.getData(),
"application/octet-stream", attachment.getDataSize(),
attachment.getOutputStreamFactory(), attachment.getListener());
attachment.getOutputStreamFactory(), attachment.getListener(),
attachment.getCancelationSignal());
return new Pair<>(id, digest);
}
@ -851,7 +853,8 @@ public class PushServiceSocket {
private byte[] uploadToCdn(String path, String acl, String key, String policy, String algorithm,
String credential, String date, String signature,
InputStream data, String contentType, long length,
OutputStreamFactory outputStreamFactory, ProgressListener progressListener)
OutputStreamFactory outputStreamFactory, ProgressListener progressListener,
CancelationSignal cancelationSignal)
throws PushNetworkException, NonSuccessfulResponseCodeException
{
ConnectionHolder connectionHolder = getRandom(cdnClients, random);
@ -861,7 +864,7 @@ public class PushServiceSocket {
.readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS)
.build();
DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener);
DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener, cancelationSignal);
RequestBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)

View File

@ -0,0 +1,8 @@
package org.whispersystems.signalservice.internal.push.http;
/**
* Used to communicate to observers whether or not something is canceled.
*/
public interface CancelationSignal {
boolean isCanceled();
}

View File

@ -18,19 +18,22 @@ public class DigestingRequestBody extends RequestBody {
private final String contentType;
private final long contentLength;
private final ProgressListener progressListener;
private final CancelationSignal cancelationSignal;
private byte[] digest;
public DigestingRequestBody(InputStream inputStream,
OutputStreamFactory outputStreamFactory,
String contentType, long contentLength,
ProgressListener progressListener)
ProgressListener progressListener,
CancelationSignal cancelationSignal)
{
this.inputStream = inputStream;
this.outputStreamFactory = outputStreamFactory;
this.contentType = contentType;
this.contentLength = contentLength;
this.progressListener = progressListener;
this.cancelationSignal = cancelationSignal;
}
@Override
@ -47,6 +50,10 @@ public class DigestingRequestBody extends RequestBody {
long total = 0;
while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) {
if (cancelationSignal != null && cancelationSignal.isCanceled()) {
throw new IOException("Canceled!");
}
outputStream.write(buffer, 0, read);
total += read;