diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index c4d7dfae08..cc1c885adf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java index d9bdff3f5b..d8d5e1057e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentId.java @@ -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 CREATOR = new Creator() { + @Override + public AttachmentId createFromParcel(Parcel in) { + return new AttachmentId(in); + } + + @Override + public AttachmentId[] newArray(int size) { + return new AttachmentId[size]; + } + }; + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java index c2b03bc055..b4e4fbc597 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.java @@ -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 { + @Override + public int compare(DatabaseAttachment lhs, DatabaseAttachment rhs) { + return Integer.compare(lhs.getDisplayOrder(), rhs.getDisplayOrder()); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java index 0eeb48cff0..6317f63542 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 21d109d840..ecde995c87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -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 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() { @@ -1540,12 +1533,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity @Override protected void onPostExecute(@NonNull Pair 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> getSplitMessage(String rawText, int maxPrimaryMessageSize) { - String bodyText = rawText; - Optional 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> 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() { - @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() { @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() { @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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java index 73cbaf0bb7..45ee683b40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentDatabase.java @@ -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 orderMap) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (Map.Entry 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 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 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 insertAttachmentsForMessage(long mmsId, @NonNull List attachments, @NonNull List 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java index 1226404ab8..cedb1fd436 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 1ba23e7e6b..889646d817 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -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 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 networkFailures = getFailures(networkDocument); List attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor); List contacts = getSharedContacts(cursor, attachments); - Set contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).collect(Collectors.toSet()); + Set contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).withoutNulls().collect(Collectors.toSet()); List previews = getLinkPreviews(cursor, attachments); Set 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 attachments) { - List messageAttachments = Stream.of(attachments) - .filterNot(Attachment::isQuote) - .toList(); + List messageAttachments = Stream.of(attachments) + .filterNot(Attachment::isQuote) + .sorted(new DatabaseAttachment.DisplayOrderComparator()) + .toList(); return new SlideDeck(context, messageAttachments); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index cbbb9200a5..438de54343 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 38b6e32ba9..e2d99ecf09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java index 024c9ac86f..539677d254 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index 7975c60435..520124be8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java index e897d34c4c..3a2791f104 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java index fa66852bc8..c0ac69f411 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java index 1ffc2cc999..68f961efeb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java index cc47fb2dae..4380b9fa59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -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 currentMedia, + @NonNull Map modelsToRender, + @NonNull Callback> callback) + { + SignalExecutors.BOUNDED.execute(() -> callback.onComplete(renderMedia(context, currentMedia, modelsToRender))); + } + @WorkerThread private @NonNull List getFolders(@NonNull Context context) { if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { @@ -231,6 +247,43 @@ class MediaRepository { }).toList(); } + @WorkerThread + private LinkedHashMap renderMedia(@NonNull Context context, + @NonNull List currentMedia, + @NonNull Map modelsToRender) + { + LinkedHashMap 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 getMostRecentItem(@NonNull Context context) { if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 49363da430..4dfe322e80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -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 void onMediaCaptured(Supplier dataSupplier, IOFunction getLength, Function3 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 buildModelsToRender(@NonNull MediaSendFragment fragment) { + List mediaList = fragment.getAllMedia(); + Map savedState = fragment.getSavedState(); + Map 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 mediaList, @NonNull Map savedState, @NonNull OnProcessComplete callback) { - Map 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>() { - - 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 doInBackground(Void... voids) { - Context context = MediaSendActivity.this; - List 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) { - 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, @NonNull String message, @NonNull TransportOption transport) { - viewModel.onSendClicked(); - - ArrayList 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 recipients, @NonNull List media, @NonNull String body, @NonNull TransportOption transport) { - SimpleTask.run(() -> { - List 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 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); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java new file mode 100644 index 0000000000..7f1fc6afd0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java @@ -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 uploadResults; + private final Collection nonUploadedMedia; + private final String body; + private final TransportOption transport; + private final boolean viewOnce; + + static @NonNull MediaSendActivityResult forPreUpload(@NonNull Collection 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 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 uploadResults, + @NonNull List 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 getPreUploadResults() { + return uploadResults; + } + + public @NonNull Collection getNonUploadedMedia() { + return nonUploadedMedia; + } + + public @NonNull String getBody() { + return body; + } + + public @NonNull TransportOption getTransport() { + return transport; + } + + public boolean isViewOnce() { + return viewOnce; + } + + public static final Creator CREATOR = new Creator() { + @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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java index 8ba243d7cd..48982b3225 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -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> selectedMedia; private final MutableLiveData> bucketMedia; private final MutableLiveData> mostRecentMedia; @@ -54,13 +71,16 @@ class MediaSendViewModel extends ViewModel { private final SingleLiveEvent event; private final Map 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 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 newMedia) { + List originalMedia = getSelectedMediaOrDefault(); + if (!newMedia.isEmpty()) { selectedMedia.setValue(newMedia); } repository.getPopulatedMedia(context, newMedia, populatedMedia -> { Util.runOnMain(() -> { - List 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 state) { savedDrawState.clear(); savedDrawState.putAll(state); } - void onSendClicked() { + @NonNull LiveData onSendClicked(Map modelsToRender, @NonNull List recipients) { + if (isSms && recipients.size() > 0) { + throw new IllegalStateException("Provided recipients to send to, but this is SMS!"); + } + + MutableLiveData result = new MutableLiveData<>(); + Runnable dialogRunnable = () -> event.postValue(Event.SHOW_RENDER_PROGRESS); + String trimmedBody = isViewOnce() ? "" : body.toString().trim(); + List initialMedia = getSelectedMediaOrDefault(); + + Preconditions.checkState(initialMedia.size() > 0, "No media to send!"); + + Util.runOnMainDelayed(dialogRunnable, 250); + + repository.renderMedia(application, initialMedia, modelsToRender, (oldToNew) -> { + List 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 getDrawState() { @@ -424,7 +516,7 @@ class MediaSendViewModel extends ViewModel { return folders; } - @NonNull LiveData> getMostRecentMediaItem(@NonNull Context context) { + @NonNull LiveData> getMostRecentMediaItem() { return mostRecentMedia; } @@ -512,10 +604,63 @@ class MediaSendViewModel extends ViewModel { } } + private void updateAttachmentUploads(@NonNull List oldMedia, @NonNull List newMedia) { + if (!preUploadEnabled) return; + + DiffHelper.Result 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 recipients, @NonNull String body, @NonNull Collection preUploadResults) { + List 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 create(@NonNull Class modelClass) { - return modelClass.cast(new MediaSendViewModel(application, repository)); + return modelClass.cast(new MediaSendViewModel(application, repository, new MediaUploadRepository(application))); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java new file mode 100644 index 0000000000..79c2201b5c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaUploadRepository.java @@ -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 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 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 oldToNew, @Nullable Recipient recipient) { + executor.execute(() -> { + for (Map.Entry 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 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> callback) { + executor.execute(() -> callback.onResult(uploadResults.values())); + } + + void updateCaptions(@NonNull List updatedMedia) { + executor.execute(() -> updateCaptionsInternal(updatedMedia)); + } + + void updateDisplayOrder(@NonNull List 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 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 mediaInOrder) { + Map orderMap = new HashMap<>(); + Map 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 { + void onResult(@NonNull E result); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MeteredConnectivityObserver.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MeteredConnectivityObserver.java new file mode 100644 index 0000000000..54a15e70d0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MeteredConnectivityObserver.java @@ -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 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 isMetered() { + return metered; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java index 9ef692c70e..f28277b411 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sms/MessageSender.java @@ -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 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> databaseAttachments = new ArrayList<>(messages.get(0).getAttachments().size()); - List 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 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 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 attachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList(); + List 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 messages, @NonNull Collection 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 preUploadAttachmentIds = Stream.of(preUploadResults).map(PreUploadResult::getAttachmentId).toList(); + List preUploadJobIds = Stream.of(preUploadResults).map(PreUploadResult::getJobIds).flatMap(Stream::of).toList(); + List messageIds = new ArrayList<>(messages.size()); + List 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 secondaryMessages = messages.subList(1, messages.size()); + List> attachmentCopies = new ArrayList<>(); + List 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 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 compressionJobs = new ArrayList<>(databaseAttachments.size()); - List uploadJobs = new ArrayList<>(databaseAttachments.size()); - List copyJobs = new ArrayList<>(databaseAttachments.size()); - List messageJobs = new ArrayList<>(databaseAttachments.get(0).size()); - - for (List 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 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 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 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 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 messages) { - if (messages.isEmpty()) { - return false; + public static class PreUploadResult implements Parcelable { + private final AttachmentId attachmentId; + private final Collection jobIds; + + PreUploadResult(@NonNull AttachmentId attachmentId, @NonNull Collection 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 getJobIds() { + return jobIds; + } + + public static final Creator CREATOR = new Creator() { + @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); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DiffHelper.java b/app/src/main/java/org/thoughtcrime/securesms/util/DiffHelper.java new file mode 100644 index 0000000000..84834c0e9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DiffHelper.java @@ -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 Result calculate(@NonNull Collection oldList, @NonNull Collection newList) { + Set inserted = SetUtil.difference(newList, oldList); + Set removed = SetUtil.difference(oldList, newList); + + return new Result<>(inserted, removed); + } + + public static class Result { + private final Collection inserted; + private final Collection removed; + + public Result(@NonNull Collection inserted, @NonNull Collection removed) { + this.removed = removed; + this.inserted = inserted; + } + + public @NonNull Collection getInserted() { + return inserted; + } + + public @NonNull Collection getRemoved() { + return removed; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.java new file mode 100644 index 0000000000..e166c15d2d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageUtil.java @@ -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 = 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; + + private SplitResult(@NonNull String body, @NonNull Optional textSlide) { + this.body = body; + this.textSlide = textSlide; + } + + public @NonNull String getBody() { + return body; + } + + public @NonNull Optional getTextSlide() { + return textSlide; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java index c1eda1232b..80fd55f160 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ParcelUtil.java @@ -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 collection) { + dest.writeStringList(new ArrayList<>(collection)); + } + + public static @NonNull Collection readStringCollection(@NonNull Parcel in) { + List list = new ArrayList<>(); + in.readStringList(list); + return list; + } + + public static void writeParcelableCollection(@NonNull Parcel dest, @NonNull Collection collection) { + Parcelable[] values = collection.toArray(new Parcelable[0]); + dest.writeParcelableArray(values, 0); + } + + public static @NonNull Collection readParcelableCollection(@NonNull Parcel in, Class 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; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java index d030d673dc..cf53d18123 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SetUtil.java @@ -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 Set intersection(Set a, Set b) { + public static Set intersection(Collection a, Collection b) { Set intersection = new LinkedHashSet<>(a); intersection.retainAll(b); return intersection; } - public static Set difference(Set a, Set b) { + public static Set difference(Collection a, Collection b) { Set difference = new LinkedHashSet<>(a); difference.removeAll(b); return difference; diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java index 835f1bf385..5fd4010603 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/InMemoryTranscoder.java @@ -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(); + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/DiffHelperTest.java b/app/src/test/java/org/thoughtcrime/securesms/util/DiffHelperTest.java new file mode 100644 index 0000000000..4474e3d86d --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/DiffHelperTest.java @@ -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)); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 46643ea2f0..3eb4dc5762 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -341,7 +341,8 @@ public class SignalServiceMessageSender { dataStream, ciphertextLength, new AttachmentCipherOutputStreamFactory(attachmentKey), - attachment.getListener()); + attachment.getListener(), + attachment.getCancelationSignal()); AttachmentUploadAttributes uploadAttributes = null; diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java index 2a47633164..248c2b8f6a 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachment.java @@ -7,6 +7,7 @@ package org.whispersystems.signalservice.api.messages; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.internal.push.http.CancelationSignal; import java.io.InputStream; @@ -39,16 +40,17 @@ public abstract class SignalServiceAttachment { public static class Builder { - private InputStream inputStream; - private String contentType; - private String fileName; - private long length; - private ProgressListener listener; - private boolean voiceNote; - private int width; - private int height; - private String caption; - private String blurHash; + private InputStream inputStream; + private String contentType; + private String fileName; + private long length; + private ProgressListener listener; + private CancelationSignal cancelationSignal; + private boolean voiceNote; + private int width; + private int height; + private String caption; + private String blurHash; private Builder() {} @@ -77,6 +79,11 @@ public abstract class SignalServiceAttachment { return this; } + public Builder withCancelationSignal(CancelationSignal cancelationSignal) { + this.cancelationSignal = cancelationSignal; + return this; + } + public Builder withVoiceNote(boolean voiceNote) { this.voiceNote = voiceNote; return this; @@ -117,7 +124,8 @@ public abstract class SignalServiceAttachment { height, Optional.fromNullable(caption), Optional.fromNullable(blurHash), - listener); + listener, + cancelationSignal); } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentStream.java index 87359501e8..b58c61a0b1 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentStream.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceAttachmentStream.java @@ -7,6 +7,7 @@ package org.whispersystems.signalservice.api.messages; import org.whispersystems.libsignal.util.guava.Optional; +import org.whispersystems.signalservice.internal.push.http.CancelationSignal; import java.io.InputStream; @@ -15,33 +16,47 @@ import java.io.InputStream; */ public class SignalServiceAttachmentStream extends SignalServiceAttachment { - private final InputStream inputStream; - private final long length; - private final Optional fileName; - private final ProgressListener listener; - private final Optional preview; - private final boolean voiceNote; - private final int width; - private final int height; - private final Optional caption; - private final Optional blurHash; + private final InputStream inputStream; + private final long length; + private final Optional fileName; + private final ProgressListener listener; + private final CancelationSignal cancelationSignal; + private final Optional preview; + private final boolean voiceNote; + private final int width; + private final int height; + private final Optional caption; + private final Optional blurHash; - public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, Optional fileName, boolean voiceNote, ProgressListener listener) { - this(inputStream, contentType, length, fileName, voiceNote, Optional.absent(), 0, 0, Optional.absent(), Optional.absent(), listener); + public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, Optional fileName, boolean voiceNote, ProgressListener listener, CancelationSignal cancelationSignal) { + this(inputStream, contentType, length, fileName, voiceNote, Optional.absent(), 0, 0, Optional.absent(), Optional.absent(), listener, cancelationSignal); } - public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, Optional fileName, boolean voiceNote, Optional preview, int width, int height, Optional caption, Optional blurHash, ProgressListener listener) { + public SignalServiceAttachmentStream(InputStream inputStream, + String contentType, + long length, + Optional fileName, + boolean voiceNote, + Optional preview, + int width, + int height, + Optional caption, + Optional blurHash, + ProgressListener listener, + CancelationSignal cancelationSignal) + { super(contentType); - this.inputStream = inputStream; - this.length = length; - this.fileName = fileName; - this.listener = listener; - this.voiceNote = voiceNote; - this.preview = preview; - this.width = width; - this.height = height; - this.caption = caption; - this.blurHash = blurHash; + this.inputStream = inputStream; + this.length = length; + this.fileName = fileName; + this.listener = listener; + this.voiceNote = voiceNote; + this.preview = preview; + this.width = width; + this.height = height; + this.caption = caption; + this.blurHash = blurHash; + this.cancelationSignal = cancelationSignal; } @Override @@ -70,6 +85,10 @@ public class SignalServiceAttachmentStream extends SignalServiceAttachment { return listener; } + public CancelationSignal getCancelationSignal() { + return cancelationSignal; + } + public Optional getPreview() { return preview; } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java index 29161cef6b..b4470fcfda 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceContactsInputStream.java @@ -55,7 +55,7 @@ public class DeviceContactsInputStream extends ChunkedInputStream { InputStream avatarStream = new LimitedInputStream(in, avatarLength); String avatarContentType = details.getAvatar().getContentType(); - avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.absent(), false, null)); + avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.absent(), false, null, null)); } if (details.hasVerified()) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsInputStream.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsInputStream.java index a30224f9d0..9fe3de3697 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsInputStream.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/multidevice/DeviceGroupsInputStream.java @@ -52,7 +52,7 @@ public class DeviceGroupsInputStream extends ChunkedInputStream{ InputStream avatarStream = new ChunkedInputStream.LimitedInputStream(in, avatarLength); String avatarContentType = details.getAvatar().getContentType(); - avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.absent(), false, null)); + avatar = Optional.of(new SignalServiceAttachmentStream(avatarStream, avatarContentType, avatarLength, Optional.absent(), false, null, null)); } if (details.hasExpireTimer() && details.getExpireTimer() > 0) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushAttachmentData.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushAttachmentData.java index d4368f536a..42945a4e20 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushAttachmentData.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushAttachmentData.java @@ -7,6 +7,7 @@ package org.whispersystems.signalservice.internal.push; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener; +import org.whispersystems.signalservice.internal.push.http.CancelationSignal; import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory; import java.io.InputStream; @@ -18,15 +19,18 @@ public class PushAttachmentData { private final long dataSize; private final OutputStreamFactory outputStreamFactory; private final ProgressListener listener; + private final CancelationSignal cancelationSignal; public PushAttachmentData(String contentType, InputStream data, long dataSize, - OutputStreamFactory outputStreamFactory, ProgressListener listener) + OutputStreamFactory outputStreamFactory, ProgressListener listener, + CancelationSignal cancelationSignal) { this.contentType = contentType; this.data = data; this.dataSize = dataSize; this.outputStreamFactory = outputStreamFactory; this.listener = listener; + this.cancelationSignal = cancelationSignal; } public String getContentType() { @@ -48,4 +52,8 @@ public class PushAttachmentData { public ProgressListener getListener() { return listener; } + + public CancelationSignal getCancelationSignal() { + return cancelationSignal; + } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 43f28e5b78..40182eacda 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -50,6 +50,7 @@ import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResp import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse; import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException; import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException; +import org.whispersystems.signalservice.internal.push.http.CancelationSignal; import org.whispersystems.signalservice.internal.push.http.DigestingRequestBody; import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory; import org.whispersystems.signalservice.internal.storage.protos.ReadOperation; @@ -577,7 +578,7 @@ public class PushServiceSocket { formAttributes.getCredential(), formAttributes.getDate(), formAttributes.getSignature(), profileAvatar.getData(), profileAvatar.getContentType(), profileAvatar.getDataLength(), - profileAvatar.getOutputStreamFactory(), null); + profileAvatar.getOutputStreamFactory(), null, null); } } @@ -763,7 +764,8 @@ public class PushServiceSocket { uploadAttributes.getCredential(), uploadAttributes.getDate(), uploadAttributes.getSignature(), attachment.getData(), "application/octet-stream", attachment.getDataSize(), - attachment.getOutputStreamFactory(), attachment.getListener()); + attachment.getOutputStreamFactory(), attachment.getListener(), + attachment.getCancelationSignal()); return new Pair<>(id, digest); } @@ -851,7 +853,8 @@ public class PushServiceSocket { private byte[] uploadToCdn(String path, String acl, String key, String policy, String algorithm, String credential, String date, String signature, InputStream data, String contentType, long length, - OutputStreamFactory outputStreamFactory, ProgressListener progressListener) + OutputStreamFactory outputStreamFactory, ProgressListener progressListener, + CancelationSignal cancelationSignal) throws PushNetworkException, NonSuccessfulResponseCodeException { ConnectionHolder connectionHolder = getRandom(cdnClients, random); @@ -861,7 +864,7 @@ public class PushServiceSocket { .readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS) .build(); - DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener); + DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener, cancelationSignal); RequestBody requestBody = new MultipartBody.Builder() .setType(MultipartBody.FORM) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/CancelationSignal.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/CancelationSignal.java new file mode 100644 index 0000000000..b6e7014b1b --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/CancelationSignal.java @@ -0,0 +1,8 @@ +package org.whispersystems.signalservice.internal.push.http; + +/** + * Used to communicate to observers whether or not something is canceled. + */ +public interface CancelationSignal { + boolean isCanceled(); +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.java index 7a9b107a99..652234f6b3 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.java @@ -18,19 +18,22 @@ public class DigestingRequestBody extends RequestBody { private final String contentType; private final long contentLength; private final ProgressListener progressListener; + private final CancelationSignal cancelationSignal; private byte[] digest; public DigestingRequestBody(InputStream inputStream, OutputStreamFactory outputStreamFactory, String contentType, long contentLength, - ProgressListener progressListener) + ProgressListener progressListener, + CancelationSignal cancelationSignal) { this.inputStream = inputStream; this.outputStreamFactory = outputStreamFactory; this.contentType = contentType; this.contentLength = contentLength; this.progressListener = progressListener; + this.cancelationSignal = cancelationSignal; } @Override @@ -47,6 +50,10 @@ public class DigestingRequestBody extends RequestBody { long total = 0; while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) { + if (cancelationSignal != null && cancelationSignal.isCanceled()) { + throw new IOException("Canceled!"); + } + outputStream.write(buffer, 0, read); total += read;