From 6896f8ea1542689fbfdd361e871100240177a99e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 11 Feb 2019 15:05:37 -0800 Subject: [PATCH] Properly check attachment size during media send. Prevent users from trying to send videos that exceed the size limit. Also, this commit properly populates height/width on media shared into the app. Fixes #8573 --- res/values/strings.xml | 1 + .../conversation/ConversationActivity.java | 5 +- .../conversation/ConversationFragment.java | 1 + .../mediapreview/MediaPreviewViewModel.java | 1 + .../securesms/mediasend/Media.java | 10 +- .../mediasend/MediaPickerItemFragment.java | 8 +- .../securesms/mediasend/MediaRepository.java | 99 +++++++++++++++++-- .../mediasend/MediaSendActivity.java | 6 +- .../mediasend/MediaSendFragment.java | 9 +- .../mediasend/MediaSendViewModel.java | 54 ++++++++-- 10 files changed, 169 insertions(+), 25 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index 7967d06cb8..31b3b9e7bd 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -454,6 +454,7 @@ Add a caption... + An item was removed because it exceeded the size limit All media diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 336d44332b..95de9b70d2 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -1723,7 +1723,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity openContactShareEditor(uri); return new SettableFuture<>(false); } else if (MediaType.IMAGE.equals(mediaType) || MediaType.GIF.equals(mediaType) || MediaType.VIDEO.equals(mediaType)) { - Media media = new Media(uri, MediaUtil.getMimeType(this, uri), 0, width, height, Optional.absent(), Optional.absent()); + Media media = new Media(uri, MediaUtil.getMimeType(this, uri), 0, width, height, 0, Optional.absent(), Optional.absent()); startActivityForResult(MediaSendActivity.getIntent(ConversationActivity.this, Collections.singletonList(media), recipient, composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER); return new SettableFuture<>(false); } else { @@ -2368,7 +2368,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity @Override public void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height) { linkPreviewViewModel.onUserCancel(); - Media media = new Media(uri, mimeType, dateTaken, width, height, Optional.of(Media.ALL_MEDIA_BUCKET_ID), Optional.absent()); + // TODO: Carry over size? + Media media = new Media(uri, mimeType, dateTaken, width, height, 0, Optional.of(Media.ALL_MEDIA_BUCKET_ID), Optional.absent()); startActivityForResult(MediaSendActivity.getIntent(ConversationActivity.this, Collections.singletonList(media), recipient, composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER); } } diff --git a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 58ebf6dbf2..f35808bba1 100644 --- a/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -537,6 +537,7 @@ public class ConversationFragment extends Fragment System.currentTimeMillis(), attachment.getWidth(), attachment.getHeight(), + attachment.getSize(), Optional.absent(), Optional.fromNullable(attachment.getCaption()))); } diff --git a/src/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java b/src/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java index b49d0334c5..987d3cab22 100644 --- a/src/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java +++ b/src/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java @@ -105,6 +105,7 @@ public class MediaPreviewViewModel extends ViewModel { mediaRecord.getDate(), mediaRecord.getAttachment().getWidth(), mediaRecord.getAttachment().getHeight(), + mediaRecord.getAttachment().getSize(), Optional.absent(), Optional.fromNullable(mediaRecord.getAttachment().getCaption())); } diff --git a/src/org/thoughtcrime/securesms/mediasend/Media.java b/src/org/thoughtcrime/securesms/mediasend/Media.java index e080baca4d..3563d5ffb1 100644 --- a/src/org/thoughtcrime/securesms/mediasend/Media.java +++ b/src/org/thoughtcrime/securesms/mediasend/Media.java @@ -19,16 +19,18 @@ public class Media implements Parcelable { private final long date; private final int width; private final int height; + private final long size; private Optional bucketId; private Optional caption; - public Media(@NonNull Uri uri, @NonNull String mimeType, long date, int width, int height, Optional bucketId, Optional caption) { + public Media(@NonNull Uri uri, @NonNull String mimeType, long date, int width, int height, long size, Optional bucketId, Optional caption) { this.uri = uri; this.mimeType = mimeType; this.date = date; this.width = width; this.height = height; + this.size = size; this.bucketId = bucketId; this.caption = caption; } @@ -39,6 +41,7 @@ public class Media implements Parcelable { date = in.readLong(); width = in.readInt(); height = in.readInt(); + size = in.readLong(); bucketId = Optional.fromNullable(in.readString()); caption = Optional.fromNullable(in.readString()); } @@ -63,6 +66,10 @@ public class Media implements Parcelable { return height; } + public long getSize() { + return size; + } + public Optional getBucketId() { return bucketId; } @@ -87,6 +94,7 @@ public class Media implements Parcelable { dest.writeLong(date); dest.writeInt(width); dest.writeInt(height); + dest.writeLong(size); dest.writeString(bucketId.orNull()); dest.writeString(caption.orNull()); } diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java index 5af64e20e2..e76bc62a03 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -149,7 +149,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem @Override public void onMediaChosen(@NonNull Media media) { controller.onMediaSelected(bucketId, Collections.singleton(media)); - viewModel.onSelectedMediaChanged(Collections.singletonList(media)); + viewModel.onSelectedMediaChanged(requireContext(), Collections.singletonList(media)); } @Override @@ -165,7 +165,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem actionMode.setTitle(String.valueOf(selected.size())); } - viewModel.onSelectedMediaChanged(selected); + viewModel.onSelectedMediaChanged(requireContext(), selected); } @Override @@ -221,7 +221,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem if (menuItem.getItemId() == R.id.mediapicker_menu_confirm) { List selected = new ArrayList<>(adapter.getSelected()); actionMode.finish(); - viewModel.onSelectedMediaChanged(selected); + viewModel.onSelectedMediaChanged(requireContext(), selected); controller.onMediaSelected(bucketId, selected); return true; } @@ -232,7 +232,7 @@ public class MediaPickerItemFragment extends Fragment implements MediaPickerItem public void onDestroyActionMode(ActionMode mode) { actionMode = null; adapter.setSelected(Collections.emptySet()); - viewModel.onSelectedMediaChanged(Collections.emptyList()); + viewModel.onSelectedMediaChanged(requireContext(), Collections.emptyList()); if (Build.VERSION.SDK_INT >= 21) { requireActivity().getWindow().setStatusBarColor(statusBarColor); diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/src/org/thoughtcrime/securesms/mediasend/MediaRepository.java index 80afe3db17..7e8e1662fa 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaRepository.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -7,20 +7,24 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Environment; -import android.provider.MediaStore; import android.provider.MediaStore.Images; import android.provider.MediaStore.Video; +import android.provider.OpenableColumns; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; +import android.util.Pair; import com.annimon.stream.Stream; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.PartAuthority; +import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -47,6 +51,19 @@ class MediaRepository { AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getMediaInBucket(context, bucketId))); } + /** + * Given an existing list of {@link Media}, this will ensure that the media is populate with as + * much data as we have, like width/height. + */ + void getPopulatedMedia(@NonNull Context context, @NonNull List media, @NonNull Callback> callback) { + if (Stream.of(media).allMatch(this::isPopulated)) { + callback.onComplete(media); + return; + } + + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getPopulatedMedia(context, media))); + } + @WorkerThread private @NonNull List getFolders(@NonNull Context context) { FolderResult imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI); @@ -151,11 +168,11 @@ class MediaRepository { String[] projection; if (hasOrienation) { - projection = Build.VERSION.SDK_INT >= 16 ? new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT} - : new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION}; + projection = Build.VERSION.SDK_INT >= 16 ? new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE} + : new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.ORIENTATION, Images.Media.SIZE}; } else { - projection = Build.VERSION.SDK_INT >= 16 ? new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT} - : new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN }; + projection = Build.VERSION.SDK_INT >= 16 ? new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.WIDTH, Images.Media.HEIGHT, Images.Media.SIZE} + : new String[]{Images.Media._ID, Images.Media.MIME_TYPE, Images.Media.DATE_TAKEN, Images.Media.SIZE}; } if (Media.ALL_MEDIA_BUCKET_ID.equals(bucketId)) { @@ -171,19 +188,36 @@ class MediaRepository { int orientation = hasOrienation ? cursor.getInt(cursor.getColumnIndexOrThrow(Images.Media.ORIENTATION)) : 0; int width = 0; int height = 0; + long size = cursor.getLong(cursor.getColumnIndexOrThrow(Images.Media.SIZE)); if (Build.VERSION.SDK_INT >= 16) { width = cursor.getInt(cursor.getColumnIndexOrThrow(getWidthColumn(orientation))); height = cursor.getInt(cursor.getColumnIndexOrThrow(getHeightColumn(orientation))); } - media.add(new Media(uri, mimetype, dateTaken, width, height, Optional.of(bucketId), Optional.absent())); + media.add(new Media(uri, mimetype, dateTaken, width, height, size, Optional.of(bucketId), Optional.absent())); } } return media; } + @WorkerThread + private List getPopulatedMedia(@NonNull Context context, @NonNull List media) { + return Stream.of(media).map(m -> { + try { + if (isPopulated(m)) { + return m; + } else if (PartAuthority.isLocalUri(m.getUri())) { + return getLocallyPopulatedMedia(context, m); + } else { + return getContentResolverPopulatedMedia(context, m); + } + } catch (IOException e) { + return m; + } + }).toList(); + } @TargetApi(16) @SuppressWarnings("SuspiciousNameCombination") @@ -199,6 +233,59 @@ class MediaRepository { else return Images.Media.WIDTH; } + private boolean isPopulated(@NonNull Media media) { + return media.getWidth() > 0 && media.getHeight() > 0 && media.getSize() > 0; + } + + private Media getLocallyPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException { + int width = media.getWidth(); + int height = media.getHeight(); + long size = media.getSize(); + + if (size <= 0) { + Optional optionalSize = Optional.fromNullable(PartAuthority.getAttachmentSize(context, media.getUri())); + size = optionalSize.isPresent() ? optionalSize.get() : 0; + } + + if (size <= 0) { + size = MediaUtil.getMediaSize(context, media.getUri()); + } + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri()); + width = dimens.first; + height = dimens.second; + } + + return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption()); + } + + private Media getContentResolverPopulatedMedia(@NonNull Context context, @NonNull Media media) throws IOException { + int width = media.getWidth(); + int height = media.getHeight(); + long size = media.getSize(); + + if (size <= 0) { + try (Cursor cursor = context.getContentResolver().query(media.getUri(), null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + size = cursor.getLong(cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); + } + } + } + + if (size <= 0) { + size = MediaUtil.getMediaSize(context, media.getUri()); + } + + if (width == 0 || height == 0) { + Pair dimens = MediaUtil.getDimensions(context, media.getMimeType(), media.getUri()); + width = dimens.first; + height = dimens.second; + } + + return new Media(media.getUri(), media.getMimeType(), media.getDate(), width, height, size, media.getBucketId(), media.getCaption()); + } + private static class FolderResult { private final String cameraBucketId; private final Uri thumbnail; diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 9cc6e144bb..3441cccf5b 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.database.Address; +import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.scribbles.ScribbleFragment; import org.thoughtcrime.securesms.util.DynamicLanguage; @@ -105,6 +106,9 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple body = getIntent().getStringExtra(KEY_BODY); transport = getIntent().getParcelableExtra(KEY_TRANSPORT); + viewModel.setMediaConstraints(transport.isSms() ? MediaConstraints.getMmsMediaConstraints(transport.getSimSubscriptionId().or(-1)) + : MediaConstraints.getPushMediaConstraints()); + List media = getIntent().getParcelableArrayListExtra(KEY_MEDIA); if (!Util.isEmpty(media)) { @@ -211,7 +215,7 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple } private void navigateToMediaSend(List media, String body, TransportOption transport) { - viewModel.setInitialSelectedMedia(media); + viewModel.setInitialSelectedMedia(this, media); MediaSendFragment sendFragment = MediaSendFragment.newInstance(body, transport, dynamicLanguage.getCurrentLocale()); getSupportFragmentManager().beginTransaction() diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java index 097b32fa82..34b1718e08 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java @@ -27,6 +27,7 @@ import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.WindowManager; import android.widget.TextView; +import android.widget.Toast; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.TransportOption; @@ -334,6 +335,12 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl addButton.setOnClickListener(v -> controller.onAddMediaClicked(bucketId.get())); } }); + + viewModel.getError().observe(this, error -> { + if (error == MediaSendViewModel.Error.ITEM_TOO_LARGE) { + Toast.makeText(requireContext(), R.string.MediaSendActivity_an_item_was_removed_because_it_exceeded_the_size_limit, Toast.LENGTH_LONG).show(); + } + }); } private EmojiEditText getActiveInputField() { @@ -428,7 +435,7 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl bitmap.compress(Bitmap.CompressFormat.JPEG, 80, baos); Uri uri = PersistentBlobProvider.getInstance(context).create(context, baos.toByteArray(), MediaUtil.IMAGE_JPEG, null); - Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), media.getBucketId(), media.getCaption()); + Media updated = new Media(uri, MediaUtil.IMAGE_JPEG, media.getDate(), bitmap.getWidth(), bitmap.getHeight(), baos.size(), media.getBucketId(), media.getCaption()); updatedMedia.add(updated); renderTimer.split("item"); diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java index 2dabfb460a..7bf95e6337 100644 --- a/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -11,7 +11,9 @@ import android.text.TextUtils; import com.annimon.stream.Stream; +import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; @@ -31,8 +33,11 @@ class MediaSendViewModel extends ViewModel { private final MutableLiveData position; private final MutableLiveData> bucketId; private final MutableLiveData> folders; + private final SingleLiveEvent error; private final Map savedDrawState; + private MediaConstraints mediaConstraints; + private MediaSendViewModel(@NonNull MediaRepository repository) { this.repository = repository; this.selectedMedia = new MutableLiveData<>(); @@ -40,21 +45,37 @@ class MediaSendViewModel extends ViewModel { this.position = new MutableLiveData<>(); this.bucketId = new MutableLiveData<>(); this.folders = new MutableLiveData<>(); + this.error = new SingleLiveEvent<>(); this.savedDrawState = new HashMap<>(); position.setValue(-1); } - void setInitialSelectedMedia(@NonNull List newMedia) { - List filteredMedia = getFilteredMedia(newMedia); - boolean allBucketsPopulated = Stream.of(filteredMedia).reduce(true, (populated, m) -> populated && m.getBucketId().isPresent()); - - selectedMedia.setValue(filteredMedia); - bucketId.setValue(allBucketsPopulated ? computeBucketId(filteredMedia) : Optional.absent()); + void setMediaConstraints(@NonNull MediaConstraints mediaConstraints) { + this.mediaConstraints = mediaConstraints; } - void onSelectedMediaChanged(@NonNull List newMedia) { - List filteredMedia = getFilteredMedia(newMedia); + void setInitialSelectedMedia(@NonNull Context context, @NonNull List newMedia) { + repository.getPopulatedMedia(context, newMedia, populatedMedia -> { + List filteredMedia = getFilteredMedia(context, populatedMedia, mediaConstraints); + + if (filteredMedia.size() != newMedia.size()) { + error.postValue(Error.ITEM_TOO_LARGE); + } + + boolean allBucketsPopulated = Stream.of(filteredMedia).reduce(true, (populated, m) -> populated && m.getBucketId().isPresent()); + + selectedMedia.postValue(filteredMedia); + bucketId.postValue(allBucketsPopulated ? computeBucketId(filteredMedia) : Optional.absent()); + }); + } + + void onSelectedMediaChanged(@NonNull Context context, @NonNull List newMedia) { + List filteredMedia = getFilteredMedia(context, newMedia, mediaConstraints); + + if (filteredMedia.size() != newMedia.size()) { + error.setValue(Error.ITEM_TOO_LARGE); + } selectedMedia.setValue(filteredMedia); position.setValue(filteredMedia.isEmpty() ? -1 : 0); @@ -111,6 +132,10 @@ class MediaSendViewModel extends ViewModel { return bucketId; } + LiveData getError() { + return error; + } + private Optional computeBucketId(@NonNull List media) { if (media.isEmpty() || !media.get(0).getBucketId().isPresent()) return Optional.absent(); @@ -124,13 +149,22 @@ class MediaSendViewModel extends ViewModel { return Optional.of(candidate); } - private @NonNull List getFilteredMedia(@NonNull List media) { + private @NonNull List getFilteredMedia(@NonNull Context context, @NonNull List media, @NonNull MediaConstraints mediaConstraints) { return Stream.of(media).filter(m -> MediaUtil.isGif(m.getMimeType()) || MediaUtil.isImageType(m.getMimeType()) || - MediaUtil.isVideoType(m.getMimeType())).toList(); + MediaUtil.isVideoType(m.getMimeType())) + .filter(m -> { + return (MediaUtil.isImageType(m.getMimeType()) && !MediaUtil.isGif(m.getMimeType())) || + (MediaUtil.isGif(m.getMimeType()) && m.getSize() < mediaConstraints.getGifMaxSize(context)) || + (MediaUtil.isVideoType(m.getMimeType()) && m.getSize() < mediaConstraints.getVideoMaxSize(context)); + }).toList(); } + enum Error { + ITEM_TOO_LARGE + } + static class Factory extends ViewModelProvider.NewInstanceFactory { private final MediaRepository repository;