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));
}
}