mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-25 11:20:47 +01:00
Optimize uploads during media composition.
By uploading in advance (when on unmetered connections), media messages can send almost instantly.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.Manifest;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import android.os.Environment;
|
||||
import android.provider.MediaStore.Images;
|
||||
@@ -17,18 +18,23 @@ import android.util.Pair;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -38,6 +44,8 @@ import java.util.Map;
|
||||
*/
|
||||
class MediaRepository {
|
||||
|
||||
private static final String TAG = Log.tag(MediaRepository.class);
|
||||
|
||||
/**
|
||||
* Retrieves a list of folders that contain media.
|
||||
*/
|
||||
@@ -69,6 +77,14 @@ class MediaRepository {
|
||||
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(getMostRecentItem(context)));
|
||||
}
|
||||
|
||||
void renderMedia(@NonNull Context context,
|
||||
@NonNull List<Media> currentMedia,
|
||||
@NonNull Map<Media, EditorModel> modelsToRender,
|
||||
@NonNull Callback<LinkedHashMap<Media, Media>> callback)
|
||||
{
|
||||
SignalExecutors.BOUNDED.execute(() -> callback.onComplete(renderMedia(context, currentMedia, modelsToRender)));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull List<MediaFolder> getFolders(@NonNull Context context) {
|
||||
if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {
|
||||
@@ -231,6 +247,43 @@ class MediaRepository {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private LinkedHashMap<Media, Media> renderMedia(@NonNull Context context,
|
||||
@NonNull List<Media> currentMedia,
|
||||
@NonNull Map<Media, EditorModel> modelsToRender)
|
||||
{
|
||||
LinkedHashMap<Media, Media> updatedMedia = new LinkedHashMap<>(currentMedia.size());
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
for (Media media : currentMedia) {
|
||||
EditorModel modelToRender = modelsToRender.get(media);
|
||||
if (modelToRender != null) {
|
||||
Bitmap bitmap = modelToRender.render(context);
|
||||
try {
|
||||
outputStream.reset();
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream);
|
||||
|
||||
Uri uri = BlobProvider.getInstance()
|
||||
.forData(outputStream.toByteArray())
|
||||
.withMimeType(MediaUtil.IMAGE_JPEG)
|
||||
.createForSingleSessionOnDisk(context);
|
||||
|
||||
Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), outputStream.size(), media.getBucketId(), media.getCaption());
|
||||
|
||||
updatedMedia.put(media, updated);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to render image. Using base image.");
|
||||
updatedMedia.put(media, media);
|
||||
} finally {
|
||||
bitmap.recycle();
|
||||
}
|
||||
} else {
|
||||
updatedMedia.put(media, media);
|
||||
}
|
||||
}
|
||||
return updatedMedia;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private Optional<Media> getMostRecentItem(@NonNull Context context) {
|
||||
if (!Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE)) {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.Rect;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.os.Vibrator;
|
||||
import android.text.Editable;
|
||||
@@ -46,41 +43,30 @@ import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendViewModel.ViewOnceState;
|
||||
import org.thoughtcrime.securesms.mms.GifSlide;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.ImageSlide;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
|
||||
import org.thoughtcrime.securesms.util.Function3;
|
||||
import org.thoughtcrime.securesms.util.IOFunction;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
import org.thoughtcrime.securesms.video.VideoUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FileDescriptor;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
@@ -110,11 +96,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
{
|
||||
private static final String TAG = MediaSendActivity.class.getSimpleName();
|
||||
|
||||
public static final String EXTRA_MEDIA = "media";
|
||||
public static final String EXTRA_MESSAGE = "message";
|
||||
public static final String EXTRA_TRANSPORT = "transport";
|
||||
public static final String EXTRA_VIEW_ONCE = "view_once";
|
||||
|
||||
public static final String EXTRA_RESULT = "result";
|
||||
|
||||
private static final String KEY_RECIPIENT = "recipient_id";
|
||||
private static final String KEY_BODY = "body";
|
||||
@@ -150,6 +132,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
private TextView charactersLeft;
|
||||
private RecyclerView mediaRail;
|
||||
private MediaRailAdapter mediaRailAdapter;
|
||||
private AlertDialog progressDialog;
|
||||
|
||||
private int visibleHeight;
|
||||
|
||||
@@ -232,6 +215,10 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(getApplication(), new MediaRepository())).get(MediaSendViewModel.class);
|
||||
transport = getIntent().getParcelableExtra(KEY_TRANSPORT);
|
||||
|
||||
MeteredConnectivityObserver meteredConnectivityObserver = new MeteredConnectivityObserver(this, this);
|
||||
meteredConnectivityObserver.isMetered().observe(this, viewModel::onMeteredConnectivityStatusChanged);
|
||||
viewModel.onMeteredConnectivityStatusChanged(Optional.fromNullable(meteredConnectivityObserver.isMetered().getValue()).or(false));
|
||||
|
||||
viewModel.setTransport(transport);
|
||||
viewModel.setRecipient(recipient != null ? recipient.get() : null);
|
||||
viewModel.onBodyChanged(getIntent().getStringExtra(KEY_BODY));
|
||||
@@ -259,23 +246,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
.commit();
|
||||
}
|
||||
|
||||
sendButton.setOnClickListener(v -> {
|
||||
if (hud.isKeyboardOpen()) {
|
||||
hud.hideSoftkey(composeText, null);
|
||||
}
|
||||
|
||||
sendButton.setEnabled(false);
|
||||
|
||||
MediaSendFragment fragment = getMediaSendFragment();
|
||||
|
||||
if (fragment != null) {
|
||||
processMedia(fragment.getAllMedia(), fragment.getSavedState(), processedMedia -> {
|
||||
setActivityResultAndFinish(processedMedia, composeText.getTextTrimmed(), transport);
|
||||
});
|
||||
} else {
|
||||
throw new AssertionError("No editor fragment available!");
|
||||
}
|
||||
});
|
||||
sendButton.setOnClickListener(v -> onSendClicked());
|
||||
|
||||
sendButton.setOnLongClickListener(v -> true);
|
||||
|
||||
@@ -418,7 +389,6 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
0);
|
||||
}
|
||||
|
||||
|
||||
private <T> void onMediaCaptured(Supplier<T> dataSupplier,
|
||||
IOFunction<T, Long> getLength,
|
||||
Function3<BlobProvider, T, Long, BlobProvider.BlobBuilder> createBlobBuilder,
|
||||
@@ -428,7 +398,6 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
{
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
try {
|
||||
|
||||
T data = dataSupplier.get();
|
||||
long length = getLength.apply(data);
|
||||
|
||||
@@ -542,16 +511,51 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
MediaSendFragment fragment = getMediaSendFragment();
|
||||
|
||||
if (fragment != null) {
|
||||
processMedia(fragment.getAllMedia(), fragment.getSavedState(), processedMedia -> {
|
||||
String body = viewModel.isViewOnce() ? "" : composeText.getTextTrimmed();
|
||||
sendMessages(recipients, processedMedia, body, transport);
|
||||
viewModel.onSendClicked(buildModelsToRender(fragment), recipients).observe(this, result -> {
|
||||
finish();
|
||||
});
|
||||
} else {
|
||||
throw new AssertionError("No editor fragment available!");
|
||||
}
|
||||
}
|
||||
|
||||
public void onAddMediaClicked(@NonNull String bucketId) {
|
||||
private void onSendClicked() {
|
||||
MediaSendFragment fragment = getMediaSendFragment();
|
||||
|
||||
if (fragment == null) {
|
||||
throw new AssertionError("No editor fragment available!");
|
||||
}
|
||||
|
||||
if (hud.isKeyboardOpen()) {
|
||||
hud.hideSoftkey(composeText, null);
|
||||
}
|
||||
|
||||
sendButton.setEnabled(false);
|
||||
|
||||
viewModel.onSendClicked(buildModelsToRender(fragment), Collections.emptyList()).observe(this, this::setActivityResultAndFinish);
|
||||
}
|
||||
|
||||
private Map<Media, EditorModel> buildModelsToRender(@NonNull MediaSendFragment fragment) {
|
||||
List<Media> mediaList = fragment.getAllMedia();
|
||||
Map<Uri, Object> savedState = fragment.getSavedState();
|
||||
Map<Media, EditorModel> modelsToRender = new HashMap<>();
|
||||
|
||||
for (Media media : mediaList) {
|
||||
Object state = savedState.get(media.getUri());
|
||||
|
||||
if (state instanceof ImageEditorFragment.Data) {
|
||||
EditorModel model = ((ImageEditorFragment.Data) state).readModel();
|
||||
if (model != null && model.isChanged()) {
|
||||
modelsToRender.put(media, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modelsToRender;
|
||||
}
|
||||
|
||||
|
||||
private void onAddMediaClicked(@NonNull String bucketId) {
|
||||
hud.hideCurrentInput(composeText);
|
||||
|
||||
// TODO: Get actual folder title somehow
|
||||
@@ -569,7 +573,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
.commit();
|
||||
}
|
||||
|
||||
public void onNoMediaAvailable() {
|
||||
private void onNoMediaAvailable() {
|
||||
setResult(RESULT_CANCELED);
|
||||
finish();
|
||||
}
|
||||
@@ -700,13 +704,24 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
});
|
||||
|
||||
viewModel.getEvents().observe(this, event -> {
|
||||
if (event == MediaSendViewModel.Event.VIEW_ONCE_TOOLTIP) {
|
||||
TooltipPopup.forTarget(revealButton)
|
||||
.setText(R.string.MediaSendActivity_tap_here_to_make_this_message_disappear_after_it_is_viewed)
|
||||
.setBackgroundTint(getResources().getColor(R.color.core_blue))
|
||||
.setTextColor(getResources().getColor(R.color.core_white))
|
||||
.setOnDismissListener(() -> TextSecurePreferences.setHasSeenViewOnceTooltip(this, true))
|
||||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
switch (event) {
|
||||
case VIEW_ONCE_TOOLTIP:
|
||||
TooltipPopup.forTarget(revealButton)
|
||||
.setText(R.string.MediaSendActivity_tap_here_to_make_this_message_disappear_after_it_is_viewed)
|
||||
.setBackgroundTint(getResources().getColor(R.color.core_blue))
|
||||
.setTextColor(getResources().getColor(R.color.core_white))
|
||||
.setOnDismissListener(() -> TextSecurePreferences.setHasSeenViewOnceTooltip(this, true))
|
||||
.show(TooltipPopup.POSITION_ABOVE);
|
||||
break;
|
||||
case SHOW_RENDER_PROGRESS:
|
||||
progressDialog = SimpleProgressDialog.show(new ContextThemeWrapper(MediaSendActivity.this, R.style.TextSecure_MediaSendProgressDialog));
|
||||
break;
|
||||
case HIDE_RENDER_PROGRESS:
|
||||
if (progressDialog != null) {
|
||||
progressDialog.dismiss();
|
||||
progressDialog = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -836,163 +851,19 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void processMedia(@NonNull List<Media> mediaList, @NonNull Map<Uri, Object> savedState, @NonNull OnProcessComplete callback) {
|
||||
Map<Media, EditorModel> modelsToRender = new HashMap<>();
|
||||
|
||||
for (Media media : mediaList) {
|
||||
Object state = savedState.get(media.getUri());
|
||||
|
||||
if (state instanceof ImageEditorFragment.Data) {
|
||||
EditorModel model = ((ImageEditorFragment.Data) state).readModel();
|
||||
if (model != null && model.isChanged()) {
|
||||
modelsToRender.put(media, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new AsyncTask<Void, Void, List<Media>>() {
|
||||
|
||||
private Stopwatch renderTimer;
|
||||
private Runnable progressTimer;
|
||||
private AlertDialog dialog;
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
renderTimer = new Stopwatch("ProcessMedia");
|
||||
progressTimer = () -> {
|
||||
dialog = SimpleProgressDialog.show(new ContextThemeWrapper(MediaSendActivity.this, R.style.TextSecure_MediaSendProgressDialog));
|
||||
};
|
||||
Util.runOnMainDelayed(progressTimer, 250);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Media> doInBackground(Void... voids) {
|
||||
Context context = MediaSendActivity.this;
|
||||
List<Media> updatedMedia = new ArrayList<>(mediaList.size());
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
for (Media media : mediaList) {
|
||||
EditorModel modelToRender = modelsToRender.get(media);
|
||||
if (modelToRender != null) {
|
||||
Bitmap bitmap = modelToRender.render(context);
|
||||
try {
|
||||
outputStream.reset();
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream);
|
||||
|
||||
Uri uri = BlobProvider.getInstance()
|
||||
.forData(outputStream.toByteArray())
|
||||
.withMimeType(MediaUtil.IMAGE_JPEG)
|
||||
.createForSingleSessionOnDisk(context);
|
||||
|
||||
Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), outputStream.size(), media.getBucketId(), media.getCaption());
|
||||
|
||||
updatedMedia.add(updated);
|
||||
renderTimer.split("item");
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to render image. Using base image.");
|
||||
updatedMedia.add(media);
|
||||
} finally {
|
||||
bitmap.recycle();
|
||||
}
|
||||
} else {
|
||||
updatedMedia.add(media);
|
||||
}
|
||||
}
|
||||
return updatedMedia;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(List<Media> media) {
|
||||
callback.onComplete(media);
|
||||
Util.cancelRunnableOnMain(progressTimer);
|
||||
if (dialog != null) {
|
||||
dialog.dismiss();
|
||||
}
|
||||
renderTimer.stop(TAG);
|
||||
}
|
||||
}.executeOnExecutor(SignalExecutors.BOUNDED);
|
||||
}
|
||||
|
||||
private @Nullable MediaSendFragment getMediaSendFragment() {
|
||||
return (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
|
||||
}
|
||||
|
||||
private void setActivityResultAndFinish(@NonNull List<Media> media, @NonNull String message, @NonNull TransportOption transport) {
|
||||
viewModel.onSendClicked();
|
||||
|
||||
ArrayList<Media> mediaList = new ArrayList<>(media);
|
||||
|
||||
if (mediaList.size() > 0) {
|
||||
Intent intent = new Intent();
|
||||
|
||||
intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList);
|
||||
intent.putExtra(EXTRA_MESSAGE, viewModel.isViewOnce() ? "" : message);
|
||||
intent.putExtra(EXTRA_TRANSPORT, transport);
|
||||
intent.putExtra(EXTRA_VIEW_ONCE, viewModel.isViewOnce());
|
||||
|
||||
setResult(RESULT_OK, intent);
|
||||
} else {
|
||||
setResult(RESULT_CANCELED);
|
||||
}
|
||||
private void setActivityResultAndFinish(@NonNull MediaSendActivityResult result) {
|
||||
Intent intent = new Intent();
|
||||
intent.putExtra(EXTRA_RESULT, result);
|
||||
setResult(RESULT_OK, intent);
|
||||
|
||||
finish();
|
||||
|
||||
overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom);
|
||||
}
|
||||
|
||||
private void sendMessages(@NonNull List<Recipient> recipients, @NonNull List<Media> media, @NonNull String body, @NonNull TransportOption transport) {
|
||||
SimpleTask.run(() -> {
|
||||
List<OutgoingSecureMediaMessage> messages = new ArrayList<>(recipients.size());
|
||||
|
||||
for (Recipient recipient : recipients) {
|
||||
SlideDeck slideDeck = buildSlideDeck(media);
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
|
||||
body,
|
||||
slideDeck.asAttachments(),
|
||||
System.currentTimeMillis(),
|
||||
-1,
|
||||
recipient.getExpireMessages() * 1000,
|
||||
viewModel.isViewOnce(),
|
||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList());
|
||||
|
||||
messages.add(new OutgoingSecureMediaMessage(message));
|
||||
|
||||
// XXX We must do this to avoid sending out messages to the same recipient with the same
|
||||
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
|
||||
Util.sleep(5);
|
||||
}
|
||||
|
||||
MessageSender.sendMediaBroadcast(this, messages);
|
||||
return null;
|
||||
}, (nothing) -> {
|
||||
finish();
|
||||
});
|
||||
}
|
||||
|
||||
private @NonNull SlideDeck buildSlideDeck(@NonNull List<Media> mediaList) {
|
||||
SlideDeck slideDeck = new SlideDeck();
|
||||
|
||||
for (Media mediaItem : mediaList) {
|
||||
if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
|
||||
slideDeck.addSlide(new VideoSlide(this, mediaItem.getUri(), 0, mediaItem.getCaption().orNull()));
|
||||
} else if (MediaUtil.isGif(mediaItem.getMimeType())) {
|
||||
slideDeck.addSlide(new GifSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull()));
|
||||
} else if (MediaUtil.isImageType(mediaItem.getMimeType())) {
|
||||
slideDeck.addSlide(new ImageSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull(), null));
|
||||
} else {
|
||||
Log.w(TAG, "Asked to send an unexpected mimeType: '" + mediaItem.getMimeType() + "'. Skipping.");
|
||||
}
|
||||
}
|
||||
|
||||
return slideDeck;
|
||||
}
|
||||
|
||||
private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener {
|
||||
|
||||
int beforeLength;
|
||||
@@ -1033,8 +904,4 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
@Override
|
||||
public void onFocusChange(View v, boolean hasFocus) {}
|
||||
}
|
||||
|
||||
private interface OnProcessComplete {
|
||||
void onComplete(@NonNull List<Media> media);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.TransportOption;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Preconditions;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A class that lets us nicely format data that we'll send back to {@link ConversationActivity}.
|
||||
*/
|
||||
public class MediaSendActivityResult implements Parcelable {
|
||||
private final Collection<PreUploadResult> uploadResults;
|
||||
private final Collection<Media> nonUploadedMedia;
|
||||
private final String body;
|
||||
private final TransportOption transport;
|
||||
private final boolean viewOnce;
|
||||
|
||||
static @NonNull MediaSendActivityResult forPreUpload(@NonNull Collection<PreUploadResult> uploadResults,
|
||||
@NonNull String body,
|
||||
@NonNull TransportOption transport,
|
||||
boolean viewOnce)
|
||||
{
|
||||
Preconditions.checkArgument(uploadResults.size() > 0, "Must supply uploadResults!");
|
||||
return new MediaSendActivityResult(uploadResults, Collections.emptyList(), body, transport, viewOnce);
|
||||
}
|
||||
|
||||
static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull List<Media> nonUploadedMedia,
|
||||
@NonNull String body,
|
||||
@NonNull TransportOption transport,
|
||||
boolean viewOnce)
|
||||
{
|
||||
Preconditions.checkArgument(nonUploadedMedia.size() > 0, "Must supply media!");
|
||||
return new MediaSendActivityResult(Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce);
|
||||
}
|
||||
|
||||
private MediaSendActivityResult(@NonNull Collection<PreUploadResult> uploadResults,
|
||||
@NonNull List<Media> nonUploadedMedia,
|
||||
@NonNull String body,
|
||||
@NonNull TransportOption transport,
|
||||
boolean viewOnce)
|
||||
{
|
||||
this.uploadResults = uploadResults;
|
||||
this.nonUploadedMedia = nonUploadedMedia;
|
||||
this.body = body;
|
||||
this.transport = transport;
|
||||
this.viewOnce = viewOnce;
|
||||
}
|
||||
|
||||
private MediaSendActivityResult(Parcel in) {
|
||||
this.uploadResults = ParcelUtil.readParcelableCollection(in, PreUploadResult.class);
|
||||
this.nonUploadedMedia = ParcelUtil.readParcelableCollection(in, Media.class);
|
||||
this.body = in.readString();
|
||||
this.transport = in.readParcelable(TransportOption.class.getClassLoader());
|
||||
this.viewOnce = ParcelUtil.readBoolean(in);
|
||||
}
|
||||
|
||||
public boolean isPushPreUpload() {
|
||||
return uploadResults.size() > 0;
|
||||
}
|
||||
|
||||
public @NonNull Collection<PreUploadResult> getPreUploadResults() {
|
||||
return uploadResults;
|
||||
}
|
||||
|
||||
public @NonNull Collection<Media> getNonUploadedMedia() {
|
||||
return nonUploadedMedia;
|
||||
}
|
||||
|
||||
public @NonNull String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public @NonNull TransportOption getTransport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
public boolean isViewOnce() {
|
||||
return viewOnce;
|
||||
}
|
||||
|
||||
public static final Creator<MediaSendActivityResult> CREATOR = new Creator<MediaSendActivityResult>() {
|
||||
@Override
|
||||
public MediaSendActivityResult createFromParcel(Parcel in) {
|
||||
return new MediaSendActivityResult(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaSendActivityResult[] newArray(int size) {
|
||||
return new MediaSendActivityResult[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
ParcelUtil.writeParcelableCollection(dest, uploadResults);
|
||||
ParcelUtil.writeParcelableCollection(dest, nonUploadedMedia);
|
||||
dest.writeString(body);
|
||||
dest.writeParcelable(transport, 0);
|
||||
ParcelUtil.writeBoolean(dest, viewOnce);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend;
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
@@ -10,26 +11,41 @@ import androidx.lifecycle.ViewModelProvider;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.TransportOption;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult;
|
||||
import org.thoughtcrime.securesms.util.DiffHelper;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageUtil;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.libsignal.util.guava.Preconditions;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Manages the observable datasets available in {@link MediaSendActivity}.
|
||||
@@ -43,6 +59,7 @@ class MediaSendViewModel extends ViewModel {
|
||||
|
||||
private final Application application;
|
||||
private final MediaRepository repository;
|
||||
private final MediaUploadRepository uploadRepository;
|
||||
private final MutableLiveData<List<Media>> selectedMedia;
|
||||
private final MutableLiveData<List<Media>> bucketMedia;
|
||||
private final MutableLiveData<Optional<Media>> mostRecentMedia;
|
||||
@@ -54,13 +71,16 @@ class MediaSendViewModel extends ViewModel {
|
||||
private final SingleLiveEvent<Event> event;
|
||||
private final Map<Uri, Object> savedDrawState;
|
||||
|
||||
private TransportOption transport;
|
||||
private MediaConstraints mediaConstraints;
|
||||
private CharSequence body;
|
||||
private boolean sentMedia;
|
||||
private int maxSelection;
|
||||
private Page page;
|
||||
private boolean isSms;
|
||||
private boolean meteredConnection;
|
||||
private Optional<Media> lastCameraCapture;
|
||||
private boolean preUploadEnabled;
|
||||
|
||||
private boolean hudVisible;
|
||||
private boolean composeVisible;
|
||||
@@ -69,11 +89,16 @@ class MediaSendViewModel extends ViewModel {
|
||||
private RailState railState;
|
||||
private ViewOnceState viewOnceState;
|
||||
|
||||
|
||||
private @Nullable Recipient recipient;
|
||||
|
||||
private MediaSendViewModel(@NonNull Application application, @NonNull MediaRepository repository) {
|
||||
private MediaSendViewModel(@NonNull Application application,
|
||||
@NonNull MediaRepository repository,
|
||||
@NonNull MediaUploadRepository uploadRepository)
|
||||
{
|
||||
this.application = application;
|
||||
this.repository = repository;
|
||||
this.uploadRepository = uploadRepository;
|
||||
this.selectedMedia = new MutableLiveData<>();
|
||||
this.bucketMedia = new MutableLiveData<>();
|
||||
this.mostRecentMedia = new MutableLiveData<>();
|
||||
@@ -90,11 +115,14 @@ class MediaSendViewModel extends ViewModel {
|
||||
this.railState = RailState.GONE;
|
||||
this.viewOnceState = ViewOnceState.GONE;
|
||||
this.page = Page.UNKNOWN;
|
||||
this.preUploadEnabled = true;
|
||||
|
||||
position.setValue(-1);
|
||||
}
|
||||
|
||||
void setTransport(@NonNull TransportOption transport) {
|
||||
this.transport = transport;
|
||||
|
||||
if (transport.isSms()) {
|
||||
isSms = true;
|
||||
maxSelection = MAX_SMS;
|
||||
@@ -104,20 +132,24 @@ class MediaSendViewModel extends ViewModel {
|
||||
maxSelection = MAX_PUSH;
|
||||
mediaConstraints = MediaConstraints.getPushMediaConstraints();
|
||||
}
|
||||
|
||||
preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient);
|
||||
}
|
||||
|
||||
void setRecipient(@Nullable Recipient recipient) {
|
||||
this.recipient = recipient;
|
||||
this.recipient = recipient;
|
||||
this.preUploadEnabled = shouldPreUpload(application, meteredConnection, isSms, recipient);
|
||||
}
|
||||
|
||||
void onSelectedMediaChanged(@NonNull Context context, @NonNull List<Media> newMedia) {
|
||||
List<Media> originalMedia = getSelectedMediaOrDefault();
|
||||
|
||||
if (!newMedia.isEmpty()) {
|
||||
selectedMedia.setValue(newMedia);
|
||||
}
|
||||
|
||||
repository.getPopulatedMedia(context, newMedia, populatedMedia -> {
|
||||
Util.runOnMain(() -> {
|
||||
|
||||
List<Media> filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints);
|
||||
|
||||
if (filteredMedia.size() != newMedia.size()) {
|
||||
@@ -153,6 +185,8 @@ class MediaSendViewModel extends ViewModel {
|
||||
selectedMedia.setValue(filteredMedia);
|
||||
hudState.setValue(buildHudState());
|
||||
}
|
||||
|
||||
updateAttachmentUploads(originalMedia, getSelectedMediaOrDefault());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -221,6 +255,7 @@ class MediaSendViewModel extends ViewModel {
|
||||
selected.remove(lastCameraCapture.get());
|
||||
selectedMedia.setValue(selected);
|
||||
BlobProvider.getInstance().delete(application, lastCameraCapture.get().getUri());
|
||||
cancelUpload(lastCameraCapture.get());
|
||||
}
|
||||
|
||||
hudState.setValue(buildHudState());
|
||||
@@ -350,6 +385,8 @@ class MediaSendViewModel extends ViewModel {
|
||||
BlobProvider.getInstance().delete(context, removed.getUri());
|
||||
}
|
||||
|
||||
cancelUpload(removed);
|
||||
|
||||
if (page == Page.EDITOR && getSelectedMediaOrDefault().isEmpty()) {
|
||||
error.setValue(Error.NO_ITEMS);
|
||||
} else {
|
||||
@@ -385,6 +422,8 @@ class MediaSendViewModel extends ViewModel {
|
||||
selectedMedia.setValue(selected);
|
||||
position.setValue(selected.size() - 1);
|
||||
bucketId.setValue(Media.ALL_MEDIA_BUCKET_ID);
|
||||
|
||||
startUpload(media);
|
||||
}
|
||||
|
||||
void onCaptionChanged(@NonNull String newCaption) {
|
||||
@@ -397,13 +436,66 @@ class MediaSendViewModel extends ViewModel {
|
||||
repository.getMostRecentItem(application, mostRecentMedia::postValue);
|
||||
}
|
||||
|
||||
void onMeteredConnectivityStatusChanged(boolean metered) {
|
||||
Log.i(TAG, "Metered connectivity status set to: " + metered);
|
||||
|
||||
meteredConnection = metered;
|
||||
preUploadEnabled = shouldPreUpload(application, metered, isSms, recipient);
|
||||
}
|
||||
|
||||
void saveDrawState(@NonNull Map<Uri, Object> state) {
|
||||
savedDrawState.clear();
|
||||
savedDrawState.putAll(state);
|
||||
}
|
||||
|
||||
void onSendClicked() {
|
||||
@NonNull LiveData<MediaSendActivityResult> onSendClicked(Map<Media, EditorModel> modelsToRender, @NonNull List<Recipient> recipients) {
|
||||
if (isSms && recipients.size() > 0) {
|
||||
throw new IllegalStateException("Provided recipients to send to, but this is SMS!");
|
||||
}
|
||||
|
||||
MutableLiveData<MediaSendActivityResult> result = new MutableLiveData<>();
|
||||
Runnable dialogRunnable = () -> event.postValue(Event.SHOW_RENDER_PROGRESS);
|
||||
String trimmedBody = isViewOnce() ? "" : body.toString().trim();
|
||||
List<Media> initialMedia = getSelectedMediaOrDefault();
|
||||
|
||||
Preconditions.checkState(initialMedia.size() > 0, "No media to send!");
|
||||
|
||||
Util.runOnMainDelayed(dialogRunnable, 250);
|
||||
|
||||
repository.renderMedia(application, initialMedia, modelsToRender, (oldToNew) -> {
|
||||
List<Media> updatedMedia = new ArrayList<>(oldToNew.values());
|
||||
|
||||
if (isSms || MessageSender.isLocalSelfSend(application, recipient, isSms)) {
|
||||
Log.i(TAG, "SMS or local self-send. Skipping pre-upload.");
|
||||
result.postValue(MediaSendActivityResult.forTraditionalSend(updatedMedia, trimmedBody, transport, isViewOnce()));
|
||||
return;
|
||||
}
|
||||
|
||||
MessageUtil.SplitResult splitMessage = MessageUtil.getSplitMessage(application, trimmedBody, transport.calculateCharacters(trimmedBody).maxPrimaryMessageSize);
|
||||
String splitBody = splitMessage.getBody();
|
||||
|
||||
if (splitMessage.getTextSlide().isPresent()) {
|
||||
Slide slide = splitMessage.getTextSlide().get();
|
||||
uploadRepository.startUpload(new Media(Objects.requireNonNull(slide.getUri()), slide.getContentType(), System.currentTimeMillis(), 0, 0, slide.getFileSize(), Optional.absent(), Optional.absent()), recipient);
|
||||
}
|
||||
|
||||
uploadRepository.applyMediaUpdates(oldToNew, recipient);
|
||||
uploadRepository.updateCaptions(updatedMedia);
|
||||
uploadRepository.updateDisplayOrder(updatedMedia);
|
||||
uploadRepository.getPreUploadResults(uploadResults -> {
|
||||
if (recipients.size() > 0) {
|
||||
sendMessages(recipients, splitBody, uploadResults);
|
||||
uploadRepository.deleteAbandonedAttachments();
|
||||
}
|
||||
|
||||
Util.cancelRunnableOnMain(dialogRunnable);
|
||||
result.postValue(MediaSendActivityResult.forPreUpload(uploadResults, splitBody, transport, isViewOnce()));
|
||||
});
|
||||
});
|
||||
|
||||
sentMedia = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@NonNull Map<Uri, Object> getDrawState() {
|
||||
@@ -424,7 +516,7 @@ class MediaSendViewModel extends ViewModel {
|
||||
return folders;
|
||||
}
|
||||
|
||||
@NonNull LiveData<Optional<Media>> getMostRecentMediaItem(@NonNull Context context) {
|
||||
@NonNull LiveData<Optional<Media>> getMostRecentMediaItem() {
|
||||
return mostRecentMedia;
|
||||
}
|
||||
|
||||
@@ -512,10 +604,63 @@ class MediaSendViewModel extends ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private void updateAttachmentUploads(@NonNull List<Media> oldMedia, @NonNull List<Media> newMedia) {
|
||||
if (!preUploadEnabled) return;
|
||||
|
||||
DiffHelper.Result<Media> result = DiffHelper.calculate(oldMedia, newMedia);
|
||||
|
||||
uploadRepository.cancelUpload(result.getRemoved());
|
||||
uploadRepository.startUpload(result.getInserted(), recipient);
|
||||
}
|
||||
|
||||
private void cancelUpload(@NonNull Media media) {
|
||||
uploadRepository.cancelUpload(media);
|
||||
}
|
||||
|
||||
private void startUpload(@NonNull Media media) {
|
||||
if (!preUploadEnabled) return;
|
||||
uploadRepository.startUpload(media, recipient);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void sendMessages(@NonNull List<Recipient> recipients, @NonNull String body, @NonNull Collection<PreUploadResult> preUploadResults) {
|
||||
List<OutgoingSecureMediaMessage> messages = new ArrayList<>(recipients.size());
|
||||
|
||||
for (Recipient recipient : recipients) {
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient,
|
||||
body,
|
||||
Collections.emptyList(),
|
||||
System.currentTimeMillis(),
|
||||
-1,
|
||||
recipient.getExpireMessages() * 1000,
|
||||
isViewOnce(),
|
||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
null,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList());
|
||||
|
||||
messages.add(new OutgoingSecureMediaMessage(message));
|
||||
|
||||
// XXX We must do this to avoid sending out messages to the same recipient with the same
|
||||
// sentTimestamp. If we do this, they'll be considered dupes by the receiver.
|
||||
Util.sleep(5);
|
||||
}
|
||||
|
||||
MessageSender.sendMediaBroadcast(application, messages, preUploadResults);
|
||||
}
|
||||
|
||||
private static boolean shouldPreUpload(@NonNull Context context, boolean metered, boolean isSms, @Nullable Recipient recipient) {
|
||||
return !metered && !isSms && !MessageSender.isLocalSelfSend(context, recipient, isSms);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
if (!sentMedia) {
|
||||
clearPersistedMedia();
|
||||
uploadRepository.cancelAllUploads();
|
||||
uploadRepository.deleteAbandonedAttachments();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,7 +669,7 @@ class MediaSendViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
enum Event {
|
||||
VIEW_ONCE_TOOLTIP
|
||||
VIEW_ONCE_TOOLTIP, SHOW_RENDER_PROGRESS, HIDE_RENDER_PROGRESS
|
||||
}
|
||||
|
||||
enum Page {
|
||||
@@ -611,7 +756,7 @@ class MediaSendViewModel extends ViewModel {
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
return modelClass.cast(new MediaSendViewModel(application, repository));
|
||||
return modelClass.cast(new MediaSendViewModel(application, repository, new MediaUploadRepository(application)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GifSlide;
|
||||
import org.thoughtcrime.securesms.mms.ImageSlide;
|
||||
import org.thoughtcrime.securesms.mms.TextSlide;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* Manages the proactive upload of media during the selection process. Upload/cancel operations
|
||||
* need to be serialized, because they're asynchronous operations that depend on ordered completion.
|
||||
*
|
||||
* For example, if we begin upload of a {@link Media) but then immediately cancel it (before it was
|
||||
* enqueued on the {@link JobManager}), we need to wait until we have the jobId to cancel. This
|
||||
* class manages everything by using a single thread executor.
|
||||
*
|
||||
* This also means that unlike most repositories, the class itself is stateful. Keep that in mind
|
||||
* when using it.
|
||||
*/
|
||||
class MediaUploadRepository {
|
||||
|
||||
private static final String TAG = Log.tag(MediaUploadRepository.class);
|
||||
|
||||
private final Context context;
|
||||
private final LinkedHashMap<Media, PreUploadResult> uploadResults;
|
||||
private final Executor executor;
|
||||
|
||||
MediaUploadRepository(@NonNull Context context) {
|
||||
this.context = context;
|
||||
this.uploadResults = new LinkedHashMap<>();
|
||||
this.executor = SignalExecutors.newCachedSingleThreadExecutor("signal-MediaUpload");
|
||||
}
|
||||
|
||||
void startUpload(@NonNull Media media, @Nullable Recipient recipient) {
|
||||
executor.execute(() -> uploadMediaInternal(media, recipient));
|
||||
}
|
||||
|
||||
void startUpload(@NonNull Collection<Media> mediaItems, @Nullable Recipient recipient) {
|
||||
executor.execute(() -> {
|
||||
for (Media media : mediaItems) {
|
||||
cancelUploadInternal(media);
|
||||
uploadMediaInternal(media, recipient);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a map of old->new, cancel medias that were changed and upload their replacements. Will
|
||||
* also upload any media in the map that wasn't yet uploaded.
|
||||
*/
|
||||
void applyMediaUpdates(@NonNull Map<Media, Media> oldToNew, @Nullable Recipient recipient) {
|
||||
executor.execute(() -> {
|
||||
for (Map.Entry<Media, Media> entry : oldToNew.entrySet()) {
|
||||
if (!entry.getKey().equals(entry.getValue()) || !uploadResults.containsKey(entry.getValue())) {
|
||||
cancelUploadInternal(entry.getKey());
|
||||
uploadMediaInternal(entry.getValue(), recipient);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void cancelUpload(@NonNull Media media) {
|
||||
executor.execute(() -> cancelUploadInternal(media));
|
||||
}
|
||||
|
||||
void cancelUpload(@NonNull Collection<Media> mediaItems) {
|
||||
executor.execute(() -> {
|
||||
for (Media media : mediaItems) {
|
||||
cancelUploadInternal(media);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void cancelAllUploads() {
|
||||
executor.execute(() -> {
|
||||
for (Media media : new HashSet<>(uploadResults.keySet())) {
|
||||
cancelUploadInternal(media);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void getPreUploadResults(@NonNull Callback<Collection<PreUploadResult>> callback) {
|
||||
executor.execute(() -> callback.onResult(uploadResults.values()));
|
||||
}
|
||||
|
||||
void updateCaptions(@NonNull List<Media> updatedMedia) {
|
||||
executor.execute(() -> updateCaptionsInternal(updatedMedia));
|
||||
}
|
||||
|
||||
void updateDisplayOrder(@NonNull List<Media> mediaInOrder) {
|
||||
executor.execute(() -> updateDisplayOrderInternal(mediaInOrder));
|
||||
}
|
||||
|
||||
void deleteAbandonedAttachments() {
|
||||
executor.execute(() -> {
|
||||
int deleted = DatabaseFactory.getAttachmentDatabase(context).deleteAbandonedPreuploadedAttachments();
|
||||
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
|
||||
});
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void uploadMediaInternal(@NonNull Media media, @Nullable Recipient recipient) {
|
||||
Attachment attachment = asAttachment(context, media);
|
||||
PreUploadResult result = MessageSender.preUploadPushAttachment(context, attachment, recipient);
|
||||
|
||||
if (result != null) {
|
||||
uploadResults.put(media, result);
|
||||
} else {
|
||||
Log.w(TAG, "Failed to upload media with URI: " + media.getUri());
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelUploadInternal(@NonNull Media media) {
|
||||
JobManager jobManager = ApplicationDependencies.getJobManager();
|
||||
PreUploadResult result = uploadResults.get(media);
|
||||
|
||||
if (result != null) {
|
||||
Stream.of(result.getJobIds()).forEach(jobManager::cancel);
|
||||
uploadResults.remove(media);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void updateCaptionsInternal(@NonNull List<Media> updatedMedia) {
|
||||
AttachmentDatabase db = DatabaseFactory.getAttachmentDatabase(context);
|
||||
|
||||
for (Media updated : updatedMedia) {
|
||||
PreUploadResult result = uploadResults.get(updated);
|
||||
|
||||
if (result != null) {
|
||||
db.updateAttachmentCaption(result.getAttachmentId(), updated.getCaption().orNull());
|
||||
} else {
|
||||
Log.w(TAG,"When updating captions, no pre-upload result could be found for media with URI: " + updated.getUri());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void updateDisplayOrderInternal(@NonNull List<Media> mediaInOrder) {
|
||||
Map<AttachmentId, Integer> orderMap = new HashMap<>();
|
||||
Map<Media, PreUploadResult> orderedUploadResults = new LinkedHashMap<>();
|
||||
|
||||
for (int i = 0; i < mediaInOrder.size(); i++) {
|
||||
Media media = mediaInOrder.get(i);
|
||||
PreUploadResult result = uploadResults.get(media);
|
||||
|
||||
if (result != null) {
|
||||
orderMap.put(result.getAttachmentId(), i);
|
||||
orderedUploadResults.put(media, result);
|
||||
} else {
|
||||
Log.w(TAG, "When updating display order, no pre-upload result could be found for media with URI: " + media.getUri());
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseFactory.getAttachmentDatabase(context).updateDisplayOrder(orderMap);
|
||||
|
||||
if (orderedUploadResults.size() == uploadResults.size()) {
|
||||
uploadResults.clear();
|
||||
uploadResults.putAll(orderedUploadResults);
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull Attachment asAttachment(@NonNull Context context, @NonNull Media media) {
|
||||
if (MediaUtil.isVideoType(media.getMimeType())) {
|
||||
return new VideoSlide(context, media.getUri(), 0, media.getCaption().orNull()).asAttachment();
|
||||
} else if (MediaUtil.isGif(media.getMimeType())) {
|
||||
return new GifSlide(context, media.getUri(), 0, media.getWidth(), media.getHeight(), media.getCaption().orNull()).asAttachment();
|
||||
} else if (MediaUtil.isImageType(media.getMimeType())) {
|
||||
return new ImageSlide(context, media.getUri(), 0, media.getWidth(), media.getHeight(), media.getCaption().orNull(), null).asAttachment();
|
||||
} else if (MediaUtil.isTextType(media.getMimeType())) {
|
||||
return new TextSlide(context, media.getUri(), null, media.getSize()).asAttachment();
|
||||
} else {
|
||||
throw new AssertionError("Unexpected mimeType: " + media.getMimeType());
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback<E> {
|
||||
void onResult(@NonNull E result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.thoughtcrime.securesms.mediasend;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.ConnectivityManager;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.net.ConnectivityManagerCompat;
|
||||
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
|
||||
/**
|
||||
* Lifecycle-bound observer for whether or not the active network connection is metered.
|
||||
*/
|
||||
class MeteredConnectivityObserver extends BroadcastReceiver implements DefaultLifecycleObserver {
|
||||
|
||||
private final Context context;
|
||||
private final ConnectivityManager connectivityManager;
|
||||
private final MutableLiveData<Boolean> metered;
|
||||
|
||||
@MainThread
|
||||
MeteredConnectivityObserver(@NonNull Context context, @NonNull LifecycleOwner lifecycleOwner) {
|
||||
this.context = context;
|
||||
this.connectivityManager = ServiceUtil.getConnectivityManager(context);
|
||||
this.metered = new MutableLiveData<>();
|
||||
|
||||
this.metered.setValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager));
|
||||
lifecycleOwner.getLifecycle().addObserver(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@NonNull LifecycleOwner owner) {
|
||||
context.registerReceiver(this, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy(@NonNull LifecycleOwner owner) {
|
||||
context.unregisterReceiver(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
metered.postValue(ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return An observable value that is false when the network is unmetered, and true if the
|
||||
* network is either metered or unavailable.
|
||||
*/
|
||||
@NonNull LiveData<Boolean> isMetered() {
|
||||
return metered;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user