Optimize uploads during media composition.

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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