diff --git a/AndroidManifest.xml b/AndroidManifest.xml index ec8f643389..937986b06b 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -278,6 +278,11 @@ android:windowSoftInputMode="stateHidden" android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/> + + diff --git a/res/drawable-hdpi/ic_add_caption.png b/res/drawable-hdpi/ic_add_caption.png new file mode 100644 index 0000000000..92cdffc7da Binary files /dev/null and b/res/drawable-hdpi/ic_add_caption.png differ diff --git a/res/drawable-hdpi/ic_add_photo.png b/res/drawable-hdpi/ic_add_photo.png new file mode 100644 index 0000000000..06df8c379e Binary files /dev/null and b/res/drawable-hdpi/ic_add_photo.png differ diff --git a/res/drawable-hdpi/ic_x_circle.png b/res/drawable-hdpi/ic_x_circle.png new file mode 100644 index 0000000000..f6292fabc9 Binary files /dev/null and b/res/drawable-hdpi/ic_x_circle.png differ diff --git a/res/drawable-mdpi/ic_add_caption.png b/res/drawable-mdpi/ic_add_caption.png new file mode 100644 index 0000000000..a80b6cf503 Binary files /dev/null and b/res/drawable-mdpi/ic_add_caption.png differ diff --git a/res/drawable-mdpi/ic_add_photo.png b/res/drawable-mdpi/ic_add_photo.png new file mode 100644 index 0000000000..27e783ca74 Binary files /dev/null and b/res/drawable-mdpi/ic_add_photo.png differ diff --git a/res/drawable-mdpi/ic_x_circle.png b/res/drawable-mdpi/ic_x_circle.png new file mode 100644 index 0000000000..11fee34e31 Binary files /dev/null and b/res/drawable-mdpi/ic_x_circle.png differ diff --git a/res/drawable-xhdpi/ic_add_caption.png b/res/drawable-xhdpi/ic_add_caption.png new file mode 100644 index 0000000000..0bf6f3552f Binary files /dev/null and b/res/drawable-xhdpi/ic_add_caption.png differ diff --git a/res/drawable-xhdpi/ic_add_photo.png b/res/drawable-xhdpi/ic_add_photo.png new file mode 100644 index 0000000000..b0e119af4c Binary files /dev/null and b/res/drawable-xhdpi/ic_add_photo.png differ diff --git a/res/drawable-xhdpi/ic_x_circle.png b/res/drawable-xhdpi/ic_x_circle.png new file mode 100644 index 0000000000..9c62d4cff0 Binary files /dev/null and b/res/drawable-xhdpi/ic_x_circle.png differ diff --git a/res/drawable-xxhdpi/ic_add_caption.png b/res/drawable-xxhdpi/ic_add_caption.png new file mode 100644 index 0000000000..d423e74e3c Binary files /dev/null and b/res/drawable-xxhdpi/ic_add_caption.png differ diff --git a/res/drawable-xxhdpi/ic_add_photo.png b/res/drawable-xxhdpi/ic_add_photo.png new file mode 100644 index 0000000000..791d9fb7e6 Binary files /dev/null and b/res/drawable-xxhdpi/ic_add_photo.png differ diff --git a/res/drawable-xxhdpi/ic_x_circle.png b/res/drawable-xxhdpi/ic_x_circle.png new file mode 100644 index 0000000000..7e13ad6000 Binary files /dev/null and b/res/drawable-xxhdpi/ic_x_circle.png differ diff --git a/res/drawable-xxxhdpi/ic_add_caption.png b/res/drawable-xxxhdpi/ic_add_caption.png new file mode 100644 index 0000000000..cb17f42275 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_add_caption.png differ diff --git a/res/drawable-xxxhdpi/ic_add_photo.png b/res/drawable-xxxhdpi/ic_add_photo.png new file mode 100644 index 0000000000..de0ab056ed Binary files /dev/null and b/res/drawable-xxxhdpi/ic_add_photo.png differ diff --git a/res/drawable-xxxhdpi/ic_x_circle.png b/res/drawable-xxxhdpi/ic_x_circle.png new file mode 100644 index 0000000000..cdfaa67ea9 Binary files /dev/null and b/res/drawable-xxxhdpi/ic_x_circle.png differ diff --git a/res/drawable/album_rail_item_background.xml b/res/drawable/media_rail_item_background.xml similarity index 84% rename from res/drawable/album_rail_item_background.xml rename to res/drawable/media_rail_item_background.xml index 6a2d6259fb..f9cceab4f7 100644 --- a/res/drawable/album_rail_item_background.xml +++ b/res/drawable/media_rail_item_background.xml @@ -1,5 +1,4 @@ - \ No newline at end of file diff --git a/res/drawable/mediapicker_item_border_dark.xml b/res/drawable/mediapicker_item_border_dark.xml new file mode 100644 index 0000000000..d77188b203 --- /dev/null +++ b/res/drawable/mediapicker_item_border_dark.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/res/drawable/mediapicker_item_border_light.xml b/res/drawable/mediapicker_item_border_light.xml new file mode 100644 index 0000000000..6dd07b0d26 --- /dev/null +++ b/res/drawable/mediapicker_item_border_light.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/res/layout/media_preview_album_rail_item.xml b/res/layout/media_preview_album_rail_item.xml index df2ca67c16..3ea969c611 100644 --- a/res/layout/media_preview_album_rail_item.xml +++ b/res/layout/media_preview_album_rail_item.xml @@ -1,12 +1,36 @@ - + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:animateLayoutChanges="true"> + + + + + + + + diff --git a/res/layout/mediapicker_activity.xml b/res/layout/mediapicker_activity.xml new file mode 100644 index 0000000000..b7068b063a --- /dev/null +++ b/res/layout/mediapicker_activity.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/res/layout/mediapicker_folder_fragment.xml b/res/layout/mediapicker_folder_fragment.xml new file mode 100644 index 0000000000..d51123309f --- /dev/null +++ b/res/layout/mediapicker_folder_fragment.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/res/layout/mediapicker_folder_item.xml b/res/layout/mediapicker_folder_item.xml new file mode 100644 index 0000000000..fd7b292533 --- /dev/null +++ b/res/layout/mediapicker_folder_item.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/mediapicker_item_fragment.xml b/res/layout/mediapicker_item_fragment.xml new file mode 100644 index 0000000000..cba46a512e --- /dev/null +++ b/res/layout/mediapicker_item_fragment.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/res/layout/mediapicker_media_item.xml b/res/layout/mediapicker_media_item.xml new file mode 100644 index 0000000000..993f4b9bef --- /dev/null +++ b/res/layout/mediapicker_media_item.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/mediasend_fragment.xml b/res/layout/mediasend_fragment.xml new file mode 100644 index 0000000000..c10b3ba854 --- /dev/null +++ b/res/layout/mediasend_fragment.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/layout/mediasend_image_fragment.xml b/res/layout/mediasend_image_fragment.xml new file mode 100644 index 0000000000..772908fba2 --- /dev/null +++ b/res/layout/mediasend_image_fragment.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/res/layout/mediasend_video_fragment.xml b/res/layout/mediasend_video_fragment.xml new file mode 100644 index 0000000000..e040cdd076 --- /dev/null +++ b/res/layout/mediasend_video_fragment.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/res/layout/progress_dialog.xml b/res/layout/progress_dialog.xml new file mode 100644 index 0000000000..993305c094 --- /dev/null +++ b/res/layout/progress_dialog.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/res/menu/mediapicker_default.xml b/res/menu/mediapicker_default.xml new file mode 100644 index 0000000000..1738fe56f2 --- /dev/null +++ b/res/menu/mediapicker_default.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/res/menu/mediapicker_multiselect.xml b/res/menu/mediapicker_multiselect.xml new file mode 100644 index 0000000000..5757848a4e --- /dev/null +++ b/res/menu/mediapicker_multiselect.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/res/values/attrs.xml b/res/values/attrs.xml index 290234bde1..7a7c70cfbd 100644 --- a/res/values/attrs.xml +++ b/res/values/attrs.xml @@ -102,6 +102,8 @@ + + diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 310c769aec..198b7dc713 100644 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -35,6 +35,11 @@ 100dp 320dp + 175dp + 85dp + + 120dp + 40dp 16dp 16dp diff --git a/res/values/strings.xml b/res/values/strings.xml index e04708f026..ca9c3c37df 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -435,6 +435,15 @@ Downloading MMS message Error downloading MMS message, tap to retry + + Send to %s + + + Tap to select + + + Add a caption... + Received a message encrypted using an old version of Signal that is no longer supported. Please ask the sender to update to the most recent version and resend the message. You have left the group. diff --git a/res/values/themes.xml b/res/values/themes.xml index 7aa071c51c..4c1541f27e 100644 --- a/res/values/themes.xml +++ b/res/values/themes.xml @@ -217,6 +217,8 @@ @color/device_link_item_background_light + @drawable/mediapicker_item_border_light + @color/import_export_item_background_light @color/import_export_item_background_shadow_light @drawable/clickable_card_light @@ -324,6 +326,8 @@ @color/device_link_item_background_dark + @drawable/mediapicker_item_border_dark + @color/import_export_item_background_dark @color/import_export_item_background_shadow_dark @drawable/clickable_card_dark @@ -420,4 +424,8 @@ + + diff --git a/src/org/thoughtcrime/securesms/ConversationActivity.java b/src/org/thoughtcrime/securesms/ConversationActivity.java index 5dc4f5341c..b34db22493 100644 --- a/src/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/org/thoughtcrime/securesms/ConversationActivity.java @@ -22,7 +22,6 @@ import android.annotation.TargetApi; import android.content.ActivityNotFoundException; import android.content.BroadcastReceiver; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.res.Configuration; @@ -126,6 +125,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.events.ReminderUpdateEvent; import org.thoughtcrime.securesms.giph.ui.GiphyActivity; +import org.thoughtcrime.securesms.mediasend.MediaSendActivity; +import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob; @@ -133,6 +134,7 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.AttachmentManager; import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType; import org.thoughtcrime.securesms.mms.AudioSlide; +import org.thoughtcrime.securesms.mms.GifSlide; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.ImageSlide; @@ -146,6 +148,7 @@ import org.thoughtcrime.securesms.mms.QuoteId; import org.thoughtcrime.securesms.mms.QuoteModel; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.MessageNotifier; import org.thoughtcrime.securesms.notifications.NotificationChannels; @@ -220,6 +223,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity public static final String THREAD_ID_EXTRA = "thread_id"; public static final String IS_ARCHIVED_EXTRA = "is_archived"; public static final String TEXT_EXTRA = "draft_text"; + public static final String MEDIA_EXTRA = "media_list"; public static final String DISTRIBUTION_TYPE_EXTRA = "distribution_type"; public static final String TIMING_EXTRA = "timing"; public static final String LAST_SEEN_EXTRA = "last_seen"; @@ -237,7 +241,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private static final int PICK_GIF = 10; private static final int SMS_DEFAULT = 11; private static final int PICK_CAMERA = 12; - private static final int EDIT_IMAGE = 13; + private static final int MEDIA_SENDER = 13; private GlideRequests glideRequests; protected ComposeText composeText; @@ -443,18 +447,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } switch (reqCode) { - case PICK_GALLERY: - MediaType mediaType; - - String mimeType = MediaUtil.getMimeType(this, data.getData()); - - if (MediaUtil.isGif(mimeType)) mediaType = MediaType.GIF; - else if (MediaUtil.isVideo(mimeType)) mediaType = MediaType.VIDEO; - else mediaType = MediaType.IMAGE; - - setMedia(data.getData(), mediaType); - - break; case PICK_DOCUMENT: setMedia(data.getData(), MediaType.DOCUMENT); break; @@ -526,6 +518,38 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity sendMediaMessage(transport.isSms(), message, slideDeck, Collections.emptyList(), expiresIn, subscriptionId, initiating); break; + + case MEDIA_SENDER: + expiresIn = recipient.getExpireMessages() * 1000L; + subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1); + initiating = threadId == -1; + transport = data.getParcelableExtra(MediaSendActivity.EXTRA_TRANSPORT); + message = data.getStringExtra(MediaSendActivity.EXTRA_MESSAGE); + slideDeck = new SlideDeck(); + + if (transport == null) { + throw new IllegalStateException("Received a null transport from the MediaSendActivity."); + } + + sendButton.setTransport(transport); + + List mediaList = data.getParcelableArrayListExtra(MediaSendActivity.EXTRA_MEDIA); + + 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())); + } else { + Log.w(TAG, "Asked to send an unexpected mimeType: '" + mediaItem.getMimeType() + "'. Skipping."); + } + } + + sendMediaMessage(transport.isSms(), message, slideDeck, Collections.emptyList(), expiresIn, subscriptionId, initiating); + + break; } } @@ -1094,14 +1118,22 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private ListenableFuture initializeDraft() { final SettableFuture result = new SettableFuture<>(); - final String draftText = getIntent().getStringExtra(TEXT_EXTRA); - final Uri draftMedia = getIntent().getData(); - final MediaType draftMediaType = MediaType.from(getIntent().getType()); + final String draftText = getIntent().getStringExtra(TEXT_EXTRA); + final Uri draftMedia = getIntent().getData(); + final MediaType draftMediaType = MediaType.from(getIntent().getType()); + final List mediaList = getIntent().getParcelableArrayListExtra(MEDIA_EXTRA); + + if (!Util.isEmpty(mediaList)) { + Intent sendIntent = MediaSendActivity.getIntent(this, mediaList, recipient, draftText, sendButton.getSelectedTransport()); + startActivityForResult(sendIntent, MEDIA_SENDER); + return new SettableFuture<>(false); + } if (draftText != null) { composeText.setText(draftText); result.set(true); } + if (draftMedia != null && draftMediaType != null) { return setMedia(draftMedia, draftMediaType); } @@ -1517,7 +1549,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity Log.i(TAG, "Selected: " + type); switch (type) { case AttachmentTypeSelector.ADD_GALLERY: - AttachmentManager.selectGallery(this, PICK_GALLERY); break; + AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient, sendButton.getSelectedTransport()); break; case AttachmentTypeSelector.ADD_DOCUMENT: AttachmentManager.selectDocument(this, PICK_DOCUMENT); break; case AttachmentTypeSelector.ADD_SOUND: @@ -1545,6 +1577,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity if (MediaType.VCARD.equals(mediaType) && isSecureText) { 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()); + startActivityForResult(MediaSendActivity.getIntent(ConversationActivity.this, Collections.singletonList(media), recipient, composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER); + return new SettableFuture<>(false); } else { return attachmentManager.setMedia(glideRequests, uri, mediaType, getCurrentMediaConstraints(), width, height); } @@ -1858,9 +1894,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity private ListenableFuture sendMediaMessage(final boolean forceSms, String body, SlideDeck slideDeck, List contacts, final long expiresIn, final int subscriptionId, final boolean initiating) { if (!isDefaultSms && (!isSecureText || forceSms)) { showDefaultSmsPrompt(); - SettableFuture future = new SettableFuture<>(); - future.set(null); - return future; + return new SettableFuture<>(null); } OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient, slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, distributionType, inputPanel.getQuote().orNull(), contacts); @@ -2158,11 +2192,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } @Override - public void onQuickAttachment(Uri uri) { - Intent intent = new Intent(); - intent.setData(uri); - - onActivityResult(PICK_GALLERY, RESULT_OK, intent); + public void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height) { + Media media = new Media(uri, mimeType, dateTaken, width, height, Optional.fromNullable(bucketId), Optional.absent()); + startActivityForResult(MediaSendActivity.getIntent(ConversationActivity.this, Collections.singletonList(media), recipient, composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER); } } diff --git a/src/org/thoughtcrime/securesms/ConversationFragment.java b/src/org/thoughtcrime/securesms/ConversationFragment.java index 7ac39f48d7..51ef5e0858 100644 --- a/src/org/thoughtcrime/securesms/ConversationFragment.java +++ b/src/org/thoughtcrime/securesms/ConversationFragment.java @@ -18,15 +18,14 @@ package org.thoughtcrime.securesms; import android.annotation.SuppressLint; import android.app.Activity; -import android.arch.lifecycle.Observer; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.database.Cursor; +import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; -import android.os.Parcelable; import android.support.annotation.NonNull; import android.support.v4.app.ActivityCompat; import android.support.v4.app.ActivityOptionsCompat; @@ -37,17 +36,16 @@ import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.support.v7.view.ActionMode; import android.support.v7.widget.LinearLayoutManager; -import android.support.v7.widget.LinearSmoothScroller; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.OnScrollListener; import android.text.ClipboardManager; import android.text.TextUtils; +import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.components.ConversationTypingView; import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager; import org.thoughtcrime.securesms.logging.Log; -import android.util.DisplayMetrics; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -74,6 +72,7 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; +import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.Slide; @@ -83,12 +82,13 @@ import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.SaveAttachmentTask; -import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; +import org.whispersystems.libsignal.util.guava.Optional; +import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.LinkedList; @@ -507,7 +507,32 @@ public class ConversationFragment extends Fragment composeIntent.putExtra(Intent.EXTRA_TEXT, message.getDisplayBody().toString()); if (message.isMms()) { MmsMessageRecord mediaMessage = (MmsMessageRecord) message; - if (mediaMessage.containsMediaSlide()) { + boolean isAlbum = mediaMessage.containsMediaSlide() && + mediaMessage.getSlideDeck().getSlides().size() > 1 && + mediaMessage.getSlideDeck().getAudioSlide() == null && + mediaMessage.getSlideDeck().getDocumentSlide() == null; + + if (isAlbum) { + ArrayList mediaList = new ArrayList<>(mediaMessage.getSlideDeck().getSlides().size()); + + for (Attachment attachment : mediaMessage.getSlideDeck().asAttachments()) { + Uri uri = attachment.getDataUri() != null ? attachment.getDataUri() : attachment.getThumbnailUri(); + + if (uri != null) { + mediaList.add(new Media(uri, + attachment.getContentType(), + System.currentTimeMillis(), + attachment.getWidth(), + attachment.getHeight(), + Optional.absent(), + Optional.fromNullable(attachment.getCaption()))); + } + } + + if (!mediaList.isEmpty()) { + composeIntent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaList); + } + } else if (mediaMessage.containsMediaSlide()) { Slide slide = mediaMessage.getSlideDeck().getSlides().get(0); composeIntent.putExtra(Intent.EXTRA_STREAM, slide.getUri()); composeIntent.setType(slide.getContentType()); @@ -537,7 +562,7 @@ public class ConversationFragment extends Fragment for (Slide slide : message.getSlideDeck().getSlides()) { if ((slide.hasImage() || slide.hasVideo() || slide.hasAudio() || slide.hasDocument()) && slide.getUri() != null) { SaveAttachmentTask saveTask = new SaveAttachmentTask(getActivity()); - saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(slide.getUri(), slide.getContentType(), message.getDateReceived(), slide.getFileName().orNull())); + saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new SaveAttachmentTask.Attachment(slide.getUri(), slide.getContentType(), message.getDateReceived(), slide.getFileName().orNull())); return; } } diff --git a/src/org/thoughtcrime/securesms/MediaPreviewActivity.java b/src/org/thoughtcrime/securesms/MediaPreviewActivity.java index af002c0263..adf7a03617 100644 --- a/src/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/src/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -60,7 +60,7 @@ import org.thoughtcrime.securesms.components.viewpager.ExtendedOnPageChangedList import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader; -import org.thoughtcrime.securesms.mediapreview.AlbumRailAdapter; +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequests; @@ -82,7 +82,7 @@ import java.util.WeakHashMap; */ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity implements RecipientModifiedListener, LoaderManager.LoaderCallbacks>, - AlbumRailAdapter.RailItemClickedListener + MediaRailAdapter.RailItemListener { private final static String TAG = MediaPreviewActivity.class.getSimpleName(); @@ -101,7 +101,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im private TextView caption; private View captionContainer; private RecyclerView albumRail; - private AlbumRailAdapter albumRailAdapter; + private MediaRailAdapter albumRailAdapter; private ViewGroup playbackControlsContainer; private Uri initialMediaUri; private String initialMediaType; @@ -163,6 +163,11 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im mediaPager.setCurrentItem(mediaPager.getCurrentItem() + distanceFromActive); } + @Override + public void onRailItemDeleteClicked(int distanceFromActive) { + throw new UnsupportedOperationException("Callback unsupported."); + } + @SuppressWarnings("ConstantConditions") private void initializeActionBar() { MediaItem mediaItem = getCurrentMediaItem(); @@ -211,7 +216,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im mediaPager.addOnPageChangeListener(new ViewPagerListener()); albumRail = findViewById(R.id.media_preview_album_rail); - albumRailAdapter = new AlbumRailAdapter(GlideApp.with(this), this); + albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false); albumRail.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)); albumRail.setAdapter(albumRailAdapter); @@ -254,7 +259,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im } albumRail.setVisibility(previewData.getAlbumThumbnails().isEmpty() ? View.GONE : View.VISIBLE); - albumRailAdapter.setRecords(previewData.getAlbumThumbnails(), previewData.getActivePosition()); + albumRailAdapter.setMedia(previewData.getAlbumThumbnails(), previewData.getActivePosition()); albumRail.smoothScrollToPosition(previewData.getActivePosition()); captionContainer.setVisibility(previewData.getCaption() == null ? View.GONE : View.VISIBLE); @@ -446,7 +451,7 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im mediaPager.setAdapter(adapter); adapter.setActive(true); - viewModel.setCursor(data.first, leftIsRecent); + viewModel.setCursor(this, data.first, leftIsRecent); if (restartItem < 0) mediaPager.setCurrentItem(data.second); else mediaPager.setCurrentItem(restartItem); diff --git a/src/org/thoughtcrime/securesms/ShareActivity.java b/src/org/thoughtcrime/securesms/ShareActivity.java index 86ff6456db..21fac447e7 100644 --- a/src/org/thoughtcrime/securesms/ShareActivity.java +++ b/src/org/thoughtcrime/securesms/ShareActivity.java @@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode; import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mms.PartAuthority; import org.thoughtcrime.securesms.providers.PersistentBlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; @@ -56,6 +57,7 @@ import org.thoughtcrime.securesms.util.ViewUtil; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; /** * An activity to quickly share content with contacts @@ -254,9 +256,13 @@ public class ShareActivity extends PassphraseRequiredActionBarActivity } private Intent getBaseShareIntent(final @NonNull Class target) { - final Intent intent = new Intent(this, target); - final String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT); + final Intent intent = new Intent(this, target); + final String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT); + final ArrayList mediaExtra = getIntent().getParcelableArrayListExtra(ConversationActivity.MEDIA_EXTRA); + intent.putExtra(ConversationActivity.TEXT_EXTRA, textExtra); + intent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaExtra); + if (resolvedExtra != null) intent.setDataAndType(resolvedExtra, mimeType); return intent; diff --git a/src/org/thoughtcrime/securesms/camera/CameraActivity.java b/src/org/thoughtcrime/securesms/camera/CameraActivity.java index f50191f33c..66316ed2f8 100644 --- a/src/org/thoughtcrime/securesms/camera/CameraActivity.java +++ b/src/org/thoughtcrime/securesms/camera/CameraActivity.java @@ -17,12 +17,9 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.providers.MemoryBlobProvider; -import org.thoughtcrime.securesms.providers.PersistentBlobProvider; import org.thoughtcrime.securesms.scribbles.ScribbleFragment; import org.thoughtcrime.securesms.util.DynamicLanguage; -import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; -import org.thoughtcrime.securesms.util.concurrent.LifecycleBoundTask; import org.thoughtcrime.securesms.util.concurrent.SettableFuture; import org.whispersystems.libsignal.util.guava.Optional; @@ -125,7 +122,7 @@ public class CameraActivity extends PassphraseRequiredActionBarActivity implemen result.addListener(new AssertedSuccessListener() { @Override public void onSuccess(Boolean result) { - ScribbleFragment fragment = ScribbleFragment.newInstance(captureUri, dynamicLanguage.getCurrentLocale(), Optional.of(transport)); + ScribbleFragment fragment = ScribbleFragment.newInstance(captureUri, dynamicLanguage.getCurrentLocale(), Optional.of(transport), true); getSupportFragmentManager().beginTransaction() .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) .replace(R.id.fragment_container, fragment, TAG_EDITOR) @@ -163,4 +160,7 @@ public class CameraActivity extends PassphraseRequiredActionBarActivity implemen Toast.makeText(this, R.string.CameraActivity_image_save_failure, Toast.LENGTH_SHORT).show(); finish(); } + + @Override + public void onTouchEventsNeeded(boolean needed) { } } diff --git a/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java b/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java index fb50fab701..9939da7db7 100644 --- a/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java +++ b/src/org/thoughtcrime/securesms/components/AttachmentTypeSelector.java @@ -257,10 +257,10 @@ public class AttachmentTypeSelector extends PopupWindow { private class RecentPhotoSelectedListener implements RecentPhotoViewRail.OnItemClickedListener { @Override - public void onItemClicked(Uri uri) { + public void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height) { animateWindowOutTranslate(getContentView()); - if (listener != null) listener.onQuickAttachment(uri); + if (listener != null) listener.onQuickAttachment(uri, mimeType, bucketId, dateTaken, width, height); } } @@ -289,8 +289,8 @@ public class AttachmentTypeSelector extends PopupWindow { } public interface AttachmentClickedListener { - public void onClick(int type); - public void onQuickAttachment(Uri uri); + void onClick(int type); + void onQuickAttachment(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height); } } diff --git a/src/org/thoughtcrime/securesms/components/ControllableViewPager.java b/src/org/thoughtcrime/securesms/components/ControllableViewPager.java index 5371219afc..9012216f15 100644 --- a/src/org/thoughtcrime/securesms/components/ControllableViewPager.java +++ b/src/org/thoughtcrime/securesms/components/ControllableViewPager.java @@ -7,10 +7,12 @@ import android.support.v4.view.ViewPager; import android.util.AttributeSet; import android.view.MotionEvent; +import org.thoughtcrime.securesms.components.viewpager.HackyViewPager; + /** * An implementation of {@link ViewPager} that disables swiping when the view is disabled. */ -public class ControllableViewPager extends ViewPager { +public class ControllableViewPager extends HackyViewPager { public ControllableViewPager(@NonNull Context context) { super(context); diff --git a/src/org/thoughtcrime/securesms/components/InputAwareLayout.java b/src/org/thoughtcrime/securesms/components/InputAwareLayout.java index d24c78f9cf..339a18ae9f 100644 --- a/src/org/thoughtcrime/securesms/components/InputAwareLayout.java +++ b/src/org/thoughtcrime/securesms/components/InputAwareLayout.java @@ -77,7 +77,7 @@ public class InputAwareLayout extends KeyboardAwareLinearLayout implements OnKey }); } - protected void hideSoftkey(final EditText inputTarget, @Nullable Runnable runAfterClose) { + public void hideSoftkey(final EditText inputTarget, @Nullable Runnable runAfterClose) { if (runAfterClose != null) postOnKeyboardClose(runAfterClose); ServiceUtil.getInputMethodManager(inputTarget.getContext()) diff --git a/src/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java b/src/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java index e2064f2e0c..e1dd23fe95 100644 --- a/src/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java +++ b/src/org/thoughtcrime/securesms/components/RecentPhotoViewRail.java @@ -4,6 +4,7 @@ package org.thoughtcrime.securesms.components; import android.content.Context; import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.provider.MediaStore; import android.support.annotation.NonNull; @@ -106,7 +107,10 @@ public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.Lo long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_TAKEN)); long dateModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.DATE_MODIFIED)); String mimeType = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.MIME_TYPE)); + String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.BUCKET_ID)); int orientation = cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.ORIENTATION)); + int width = Build.VERSION.SDK_INT >= 16 ? cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.WIDTH)) : 0; + int height = Build.VERSION.SDK_INT >= 16 ? cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns.HEIGHT)) : 0; final Uri uri = Uri.withAppendedPath(baseUri, Long.toString(id)); @@ -119,7 +123,7 @@ public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.Lo .into(viewHolder.imageView); viewHolder.imageView.setOnClickListener(v -> { - if (clickedListener != null) clickedListener.onItemClicked(uri); + if (clickedListener != null) clickedListener.onItemClicked(uri, mimeType, bucketId, dateTaken, width, height); }); } @@ -141,6 +145,6 @@ public class RecentPhotoViewRail extends FrameLayout implements LoaderManager.Lo } public interface OnItemClickedListener { - void onItemClicked(Uri uri); + void onItemClicked(Uri uri, String mimeType, String bucketId, long dateTaken, int width, int height); } } diff --git a/src/org/thoughtcrime/securesms/components/SendButton.java b/src/org/thoughtcrime/securesms/components/SendButton.java index 0da9c0ebba..cbbf722069 100644 --- a/src/org/thoughtcrime/securesms/components/SendButton.java +++ b/src/org/thoughtcrime/securesms/components/SendButton.java @@ -106,7 +106,7 @@ public class SendButton extends ImageButton @Override public boolean onLongClick(View v) { - if (transportOptions.getEnabledTransports().size() > 1) { + if (isEnabled() && transportOptions.getEnabledTransports().size() > 1) { getTransportOptionsPopup().display(transportOptions.getEnabledTransports()); return true; } diff --git a/src/org/thoughtcrime/securesms/components/ThumbnailView.java b/src/org/thoughtcrime/securesms/components/ThumbnailView.java index b7e61f3ae4..a6a5a26810 100644 --- a/src/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/src/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -295,11 +295,18 @@ public class ThumbnailView extends FrameLayout { SettableFuture future = new SettableFuture<>(); if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE); - glideRequests.load(new DecryptableUri(uri)) - .diskCacheStrategy(DiskCacheStrategy.NONE) - .transforms(new CenterCrop(), new RoundedCorners(radius)) - .transition(withCrossFade()) - .into(new GlideDrawableListeningTarget(image, future)); + + GlideRequest request = glideRequests.load(new DecryptableUri(uri)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(withCrossFade()); + + if (radius > 0) { + request = request.transforms(new CenterCrop(), new RoundedCorners(radius)); + } else { + request = request.transforms(new CenterCrop()); + } + + request.into(new GlideDrawableListeningTarget(image, future)); return future; } diff --git a/src/org/thoughtcrime/securesms/components/TransferControlView.java b/src/org/thoughtcrime/securesms/components/TransferControlView.java index 63d40beab2..602145d90c 100644 --- a/src/org/thoughtcrime/securesms/components/TransferControlView.java +++ b/src/org/thoughtcrime/securesms/components/TransferControlView.java @@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.events.PartProgressEvent; -import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.ViewUtil; @@ -117,11 +116,11 @@ public class TransferControlView extends FrameLayout { if (!isUpdateToExistingSet(slides)) { downloadProgress.clear(); Stream.of(slides).forEach(s -> downloadProgress.put(s.asAttachment(), 0f)); - } else { - for (Slide slide : slides) { - if (slide.asAttachment().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE) { - downloadProgress.put(slide.asAttachment(), 1f); - } + } + + for (Slide slide : slides) { + if (slide.asAttachment().getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE) { + downloadProgress.put(slide.asAttachment(), 1f); } } diff --git a/src/org/thoughtcrime/securesms/database/MediaDatabase.java b/src/org/thoughtcrime/securesms/database/MediaDatabase.java index ff35cead9b..b8521f0b7d 100644 --- a/src/org/thoughtcrime/securesms/database/MediaDatabase.java +++ b/src/org/thoughtcrime/securesms/database/MediaDatabase.java @@ -85,9 +85,7 @@ public class MediaDatabase extends Database { private final long date; private final boolean outgoing; - // TODO: Make private again - public MediaRecord(DatabaseAttachment attachment, @Nullable Address address, long date, boolean outgoing) { -// private MediaRecord(DatabaseAttachment attachment, @Nullable Address address, long date, boolean outgoing) { + private MediaRecord(DatabaseAttachment attachment, @Nullable Address address, long date, boolean outgoing) { this.attachment = attachment; this.address = address; this.date = date; diff --git a/src/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java b/src/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java index 0502e2caa3..b539fc28b4 100644 --- a/src/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java +++ b/src/org/thoughtcrime/securesms/database/loaders/RecentPhotosLoader.java @@ -5,6 +5,7 @@ import android.Manifest; import android.content.Context; import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.provider.MediaStore; import android.support.v4.content.CursorLoader; @@ -19,7 +20,19 @@ public class RecentPhotosLoader extends CursorLoader { MediaStore.Images.ImageColumns.DATE_TAKEN, MediaStore.Images.ImageColumns.DATE_MODIFIED, MediaStore.Images.ImageColumns.ORIENTATION, - MediaStore.Images.ImageColumns.MIME_TYPE + MediaStore.Images.ImageColumns.MIME_TYPE, + MediaStore.Images.ImageColumns.BUCKET_ID + }; + + private static final String[] PROJECTION_16 = new String[] { + MediaStore.Images.ImageColumns._ID, + MediaStore.Images.ImageColumns.DATE_TAKEN, + MediaStore.Images.ImageColumns.DATE_MODIFIED, + MediaStore.Images.ImageColumns.ORIENTATION, + MediaStore.Images.ImageColumns.MIME_TYPE, + MediaStore.Images.ImageColumns.BUCKET_ID, + MediaStore.Images.ImageColumns.WIDTH, + MediaStore.Images.ImageColumns.HEIGHT }; private final Context context; @@ -31,9 +44,11 @@ public class RecentPhotosLoader extends CursorLoader { @Override public Cursor loadInBackground() { + String[] projection = Build.VERSION.SDK_INT >= 16 ? PROJECTION_16 : PROJECTION; + if (Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { return context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - PROJECTION, null, null, + projection, null, null, MediaStore.Images.ImageColumns.DATE_MODIFIED + " DESC"); } else { return null; diff --git a/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java b/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java index 0247f7c226..9daeb87b48 100644 --- a/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java +++ b/src/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.java @@ -29,9 +29,11 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException import java.io.IOException; import java.io.InputStream; +import java.net.ConnectException; import java.util.concurrent.TimeUnit; import javax.inject.Inject; +import javax.net.ssl.SSLException; import androidx.work.Data; import androidx.work.WorkerParameters; @@ -94,7 +96,9 @@ public class AttachmentUploadJob extends ContextJob implements InjectableType { @Override protected boolean onShouldRetry(Exception exception) { - return exception instanceof PushNetworkException; + return exception instanceof PushNetworkException || + exception instanceof SSLException || + exception instanceof ConnectException; } protected SignalServiceAttachment getAttachmentFor(Attachment attachment) { diff --git a/src/org/thoughtcrime/securesms/mediapreview/AlbumRailAdapter.java b/src/org/thoughtcrime/securesms/mediapreview/AlbumRailAdapter.java deleted file mode 100644 index 9a82363931..0000000000 --- a/src/org/thoughtcrime/securesms/mediapreview/AlbumRailAdapter.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.thoughtcrime.securesms.mediapreview; - -import android.support.annotation.NonNull; -import android.support.v7.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.ThumbnailView; -import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; -import org.thoughtcrime.securesms.mms.GlideRequests; - -import java.util.ArrayList; -import java.util.List; - -public class AlbumRailAdapter extends RecyclerView.Adapter { - - private final GlideRequests glideRequests; - private final List records; - private final RailItemClickedListener listener; - - private int activePosition; - - public AlbumRailAdapter(@NonNull GlideRequests glideRequests, @NonNull RailItemClickedListener listener) { - this.glideRequests = glideRequests; - this.records = new ArrayList<>(); - this.listener = listener; - - setHasStableIds(true); - } - - @NonNull - @Override - public AlbumRailViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { - return new AlbumRailViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_preview_album_rail_item, viewGroup, false)); - } - - @Override - public void onBindViewHolder(@NonNull AlbumRailViewHolder albumRailViewHolder, int i) { - albumRailViewHolder.bind(records.get(i), i == activePosition, glideRequests, listener, i - activePosition); - } - - @Override - public void onViewRecycled(@NonNull AlbumRailViewHolder holder) { - holder.recycle(); - } - - @Override - public long getItemId(int position) { - return records.get(position).getAttachment().getAttachmentId().getUniqueId(); - } - - @Override - public int getItemCount() { - return records.size(); - } - - public void setRecords(@NonNull List records, int activePosition) { - this.activePosition = activePosition; - - this.records.clear(); - this.records.addAll(records); - - notifyDataSetChanged(); - } - - static class AlbumRailViewHolder extends RecyclerView.ViewHolder { - - private final ThumbnailView image; - - AlbumRailViewHolder(@NonNull View itemView) { - super(itemView); - image = (ThumbnailView) itemView; - } - - void bind(@NonNull MediaRecord record, boolean isActive, @NonNull GlideRequests glideRequests, - @NonNull RailItemClickedListener railItemClickedListener, int distanceFromActive) - { - if (record.getAttachment().getThumbnailUri() != null) { - image.setImageResource(glideRequests, record.getAttachment().getThumbnailUri()); - } else if (record.getAttachment().getDataUri() != null) { - image.setImageResource(glideRequests, record.getAttachment().getDataUri()); - } else { - image.clear(glideRequests); - } - - image.setBackgroundResource(isActive ? R.drawable.album_rail_item_background : 0); - image.setOnClickListener(v -> railItemClickedListener.onRailItemClicked(distanceFromActive)); - } - - void recycle() { - image.setOnClickListener(null); - } - } - - public interface RailItemClickedListener { - void onRailItemClicked(int distanceFromActive); - } -} diff --git a/src/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java b/src/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java index 20e3027535..b49d0334c5 100644 --- a/src/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java +++ b/src/org/thoughtcrime/securesms/mediapreview/MediaPreviewViewModel.java @@ -5,10 +5,13 @@ import android.arch.lifecycle.MutableLiveData; import android.arch.lifecycle.ViewModel; import android.content.Context; import android.database.Cursor; +import android.net.Uri; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; +import org.thoughtcrime.securesms.mediasend.Media; +import org.whispersystems.libsignal.util.guava.Optional; import java.util.Collections; import java.util.LinkedList; @@ -22,9 +25,15 @@ public class MediaPreviewViewModel extends ViewModel { private @Nullable Cursor cursor; - public void setCursor(@Nullable Cursor cursor, boolean leftIsRecent) { + public void setCursor(@NonNull Context context, @Nullable Cursor cursor, boolean leftIsRecent) { + boolean firstLoad = (this.cursor == null) && (cursor != null); + this.cursor = cursor; this.leftIsRecent = leftIsRecent; + + if (firstLoad) { + setActiveAlbumRailItem(context, 0); + } } public void setActiveAlbumRailItem(@NonNull Context context, int activePosition) { @@ -37,15 +46,17 @@ public class MediaPreviewViewModel extends ViewModel { cursor.moveToPosition(activePosition); - MediaRecord activeRecord = MediaRecord.from(context, cursor); - LinkedList rail = new LinkedList<>(); + MediaRecord activeRecord = MediaRecord.from(context, cursor); + LinkedList rail = new LinkedList<>(); - rail.add(activeRecord); + Media activeMedia = toMedia(activeRecord); + if (activeMedia != null) rail.add(activeMedia); while (cursor.moveToPrevious()) { MediaRecord record = MediaRecord.from(context, cursor); if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) { - rail.addFirst(record); + Media media = toMedia(record); + if (media != null) rail.addFirst(media); } else { break; } @@ -56,7 +67,8 @@ public class MediaPreviewViewModel extends ViewModel { while (cursor.moveToNext()) { MediaRecord record = MediaRecord.from(context, cursor); if (record.getAttachment().getMmsId() == activeRecord.getAttachment().getMmsId()) { - rail.addLast(record); + Media media = toMedia(record); + if (media != null) rail.addLast(media); } else { break; } @@ -68,7 +80,7 @@ public class MediaPreviewViewModel extends ViewModel { previewData.postValue(new PreviewData(rail.size() > 1 ? rail : Collections.emptyList(), activeRecord.getAttachment().getCaption(), - rail.indexOf(activeRecord))); + rail.indexOf(activeMedia))); } private int getCursorPosition(int position) { @@ -80,22 +92,39 @@ public class MediaPreviewViewModel extends ViewModel { else return cursor.getCount() - 1 - position; } + private @Nullable Media toMedia(@NonNull MediaRecord mediaRecord) { + Uri uri = mediaRecord.getAttachment().getThumbnailUri() != null ? mediaRecord.getAttachment().getThumbnailUri() + : mediaRecord.getAttachment().getDataUri(); + + if (uri == null) { + return null; + } + + return new Media(uri, + mediaRecord.getContentType(), + mediaRecord.getDate(), + mediaRecord.getAttachment().getWidth(), + mediaRecord.getAttachment().getHeight(), + Optional.absent(), + Optional.fromNullable(mediaRecord.getAttachment().getCaption())); + } + public LiveData getPreviewData() { return previewData; } public static class PreviewData { - private final List albumThumbnails; - private final String caption; - private final int activePosition; + private final List albumThumbnails; + private final String caption; + private final int activePosition; - public PreviewData(@NonNull List albumThumbnails, @Nullable String caption, int activePosition) { + public PreviewData(@NonNull List albumThumbnails, @Nullable String caption, int activePosition) { this.albumThumbnails = albumThumbnails; this.caption = caption; this.activePosition = activePosition; } - public @NonNull List getAlbumThumbnails() { + public @NonNull List getAlbumThumbnails() { return albumThumbnails; } diff --git a/src/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java b/src/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java new file mode 100644 index 0000000000..3bd63f8410 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediapreview/MediaRailAdapter.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.mediapreview; + +import android.support.annotation.NonNull; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.ThumbnailView; +import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.ArrayList; +import java.util.List; + +public class MediaRailAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final List media; + private final RailItemListener listener; + private final boolean deleteEnabled; + + private int activePosition; + + public MediaRailAdapter(@NonNull GlideRequests glideRequests, @NonNull RailItemListener listener, boolean deleteEnabled) { + this.glideRequests = glideRequests; + this.media = new ArrayList<>(); + this.listener = listener; + this.deleteEnabled = deleteEnabled; + } + + @NonNull + @Override + public MediaRailViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new MediaRailViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_preview_album_rail_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull MediaRailViewHolder mediaRailViewHolder, int i) { + mediaRailViewHolder.bind(media.get(i), i == activePosition, glideRequests, listener, i - activePosition, deleteEnabled); + } + + @Override + public void onViewRecycled(@NonNull MediaRailViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return media.size(); + } + + public void setMedia(@NonNull List media) { + setMedia(media, activePosition); + } + + public void setMedia(@NonNull List records, int activePosition) { + this.activePosition = activePosition; + + this.media.clear(); + this.media.addAll(records); + + notifyDataSetChanged(); + } + + public void setActivePosition(int activePosition) { + this.activePosition = activePosition; + notifyDataSetChanged(); + } + + static class MediaRailViewHolder extends RecyclerView.ViewHolder { + + private final ThumbnailView image; + private final View deleteButton; + + MediaRailViewHolder(@NonNull View itemView) { + super(itemView); + image = itemView.findViewById(R.id.rail_item_image); + deleteButton = itemView.findViewById(R.id.rail_item_delete); + } + + void bind(@NonNull Media media, boolean isActive, @NonNull GlideRequests glideRequests, + @NonNull RailItemListener railItemListener, int distanceFromActive, boolean deleteEnabled) + { + image.setImageResource(glideRequests, media.getUri()); + image.setBackgroundResource(isActive ? R.drawable.media_rail_item_background : 0); + image.setOnClickListener(v -> railItemListener.onRailItemClicked(distanceFromActive)); + + if (deleteEnabled && isActive) { + deleteButton.setVisibility(View.VISIBLE); + deleteButton.setOnClickListener(v -> railItemListener.onRailItemDeleteClicked(distanceFromActive)); + } else { + deleteButton.setVisibility(View.GONE); + } + } + + void recycle() { + image.setOnClickListener(null); + deleteButton.setOnClickListener(null); + } + } + + public interface RailItemListener { + void onRailItemClicked(int distanceFromActive); + void onRailItemDeleteClicked(int distanceFromActive); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/Media.java b/src/org/thoughtcrime/securesms/mediasend/Media.java new file mode 100644 index 0000000000..ac3e3e2c1f --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/Media.java @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.annotation.NonNull; + +import org.whispersystems.libsignal.util.guava.Optional; + +/** + * Represents a piece of media that the user has on their device. + */ +public class Media implements Parcelable { + + private final Uri uri; + private final String mimeType; + private final long date; + private final int width; + private final int height; + + private Optional bucketId; + private Optional caption; + + public Media(@NonNull Uri uri, @NonNull String mimeType, long date, int width, int height, Optional bucketId, Optional caption) { + this.uri = uri; + this.mimeType = mimeType; + this.date = date; + this.width = width; + this.height = height; + this.bucketId = bucketId; + this.caption = caption; + } + + protected Media(Parcel in) { + uri = in.readParcelable(Uri.class.getClassLoader()); + mimeType = in.readString(); + date = in.readLong(); + width = in.readInt(); + height = in.readInt(); + bucketId = Optional.fromNullable(in.readString()); + caption = Optional.fromNullable(in.readString()); + } + + public Uri getUri() { + return uri; + } + + public String getMimeType() { + return mimeType; + } + + public long getDate() { + return date; + } + + public int getWidth() { + return width; + } + + public int getHeight() { + return height; + } + + public Optional getBucketId() { + return bucketId; + } + + public Optional getCaption() { + return caption; + } + + public void setCaption(String caption) { + this.caption = Optional.fromNullable(caption); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(uri, flags); + dest.writeString(mimeType); + dest.writeLong(date); + dest.writeInt(width); + dest.writeInt(height); + dest.writeString(bucketId.orNull()); + dest.writeString(caption.orNull()); + } + + public static final Creator CREATOR = new Creator() { + @Override + public Media createFromParcel(Parcel in) { + return new Media(in); + } + + @Override + public Media[] newArray(int size) { + return new Media[size]; + } + }; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Media media = (Media) o; + + return uri.equals(media.uri); + } + + @Override + public int hashCode() { + return uri.hashCode(); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaFolder.java b/src/org/thoughtcrime/securesms/mediasend/MediaFolder.java new file mode 100644 index 0000000000..a6e3dd7e92 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaFolder.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.support.annotation.NonNull; + +/** + * Represents a folder that's shown in {@link MediaPickerFolderFragment}. + */ +public class MediaFolder { + + private final Uri thumbnailUri; + private final String title; + private final int itemCount; + private final String bucketId; + private final FolderType folderType; + + MediaFolder(@NonNull Uri thumbnailUri, @NonNull String title, int itemCount, @NonNull String bucketId, @NonNull FolderType folderType) { + this.thumbnailUri = thumbnailUri; + this.title = title; + this.itemCount = itemCount; + this.bucketId = bucketId; + this.folderType = folderType; + } + + Uri getThumbnailUri() { + return thumbnailUri; + } + + public String getTitle() { + return title; + } + + int getItemCount() { + return itemCount; + } + + public String getBucketId() { + return bucketId; + } + + FolderType getFolderType() { + return folderType; + } + + enum FolderType { + NORMAL, CAMERA + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java b/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java new file mode 100644 index 0000000000..27ad889353 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderAdapter.java @@ -0,0 +1,96 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.support.annotation.NonNull; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; + +import java.util.ArrayList; +import java.util.List; + +class MediaPickerFolderAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final List folders; + + MediaPickerFolderAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.folders = new ArrayList<>(); + } + + @NonNull + @Override + public FolderViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new FolderViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_folder_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull FolderViewHolder folderViewHolder, int i) { + folderViewHolder.bind(folders.get(i), glideRequests, eventListener); + } + + @Override + public void onViewRecycled(@NonNull FolderViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return folders.size(); + } + + void setFolders(@NonNull List folders) { + this.folders.clear(); + this.folders.addAll(folders); + notifyDataSetChanged(); + } + + static class FolderViewHolder extends RecyclerView.ViewHolder { + + private final ImageView thumbnail; + private final ImageView icon; + private final TextView title; + private final TextView count; + + FolderViewHolder(@NonNull View itemView) { + super(itemView); + + thumbnail = itemView.findViewById(R.id.mediapicker_folder_item_thumbnail); + icon = itemView.findViewById(R.id.mediapicker_folder_item_icon); + title = itemView.findViewById(R.id.mediapicker_folder_item_title); + count = itemView.findViewById(R.id.mediapicker_folder_item_count); + } + + void bind(@NonNull MediaFolder folder, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) { + title.setText(folder.getTitle()); + count.setText(String.valueOf(folder.getItemCount())); + icon.setImageResource(folder.getFolderType() == MediaFolder.FolderType.CAMERA ? R.drawable.ic_camera_alt_white_24dp : R.drawable.ic_folder_white_48dp); + + glideRequests.load(folder.getThumbnailUri()) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(thumbnail); + + itemView.setOnClickListener(v -> eventListener.onFolderClicked(folder)); + } + + void recycle() { + itemView.setOnClickListener(null); + } + } + + interface EventListener { + void onFolderClicked(@NonNull MediaFolder mediaFolder); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java new file mode 100644 index 0000000000..9215cddf5a --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaPickerFolderFragment.java @@ -0,0 +1,125 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.arch.lifecycle.ViewModelProviders; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.libsignal.util.guava.Optional; + +/** + * Allows the user to select a media folder to explore. + */ +public class MediaPickerFolderFragment extends Fragment implements MediaPickerFolderAdapter.EventListener { + + private static final String KEY_RECIPIENT_NAME = "recipient_name"; + + private String recipientName; + private MediaSendViewModel viewModel; + private Controller controller; + private GridLayoutManager layoutManager; + + public static @NonNull MediaPickerFolderFragment newInstance(@NonNull Recipient recipient) { + String name = Optional.fromNullable(recipient.getName()) + .or(Optional.fromNullable(recipient.getProfileName())) + .or(recipient.toShortString()); + + Bundle args = new Bundle(); + args.putString(KEY_RECIPIENT_NAME, name); + + MediaPickerFolderFragment fragment = new MediaPickerFolderFragment(); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + recipientName = getArguments().getString(KEY_RECIPIENT_NAME); + viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(new MediaRepository())).get(MediaSendViewModel.class); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement controller class."); + } + + controller = (Controller) getActivity(); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.mediapicker_folder_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + RecyclerView list = view.findViewById(R.id.mediapicker_folder_list); + MediaPickerFolderAdapter adapter = new MediaPickerFolderAdapter(GlideApp.with(this), this); + + layoutManager = new GridLayoutManager(requireContext(), 2); + onScreenWidthChanged(getScreenWidth()); + + list.setLayoutManager(layoutManager); + list.setAdapter(adapter); + + viewModel.getFolders(requireContext()).observe(this, adapter::setFolders); + + initToolbar(view.findViewById(R.id.mediapicker_toolbar)); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + onScreenWidthChanged(getScreenWidth()); + } + + private void initToolbar(Toolbar toolbar) { + ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(getString(R.string.MediaPickerActivity_send_to, recipientName)); + + toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + } + + private void onScreenWidthChanged(int newWidth) { + if (layoutManager != null) { + layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_folder_width)); + } + } + + private int getScreenWidth() { + Point size = new Point(); + requireActivity().getWindowManager().getDefaultDisplay().getSize(size); + return size.x; + } + + @Override + public void onFolderClicked(@NonNull MediaFolder folder) { + controller.onFolderSelected(folder); + } + + public interface Controller { + void onFolderSelected(@NonNull MediaFolder folder); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java b/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java new file mode 100644 index 0000000000..717c6d517e --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemAdapter.java @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.support.annotation.NonNull; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideRequests; +import org.thoughtcrime.securesms.util.MediaUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +public class MediaPickerItemAdapter extends RecyclerView.Adapter { + + private final GlideRequests glideRequests; + private final EventListener eventListener; + private final List media; + private final Set selected; + private final int maxSelection; + + private boolean forcedMultiSelect; + + public MediaPickerItemAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener, int maxSelection) { + this.glideRequests = glideRequests; + this.eventListener = eventListener; + this.media = new ArrayList<>(); + this.maxSelection = maxSelection; + this.selected = new TreeSet<>((m1, m2) -> { + if (m1.equals(m2)) return 0; + else return Long.compare(m2.getDate(), m1.getDate()); + }); + + setHasStableIds(true); + } + + @Override + public @NonNull ItemViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) { + return new ItemViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.mediapicker_media_item, viewGroup, false)); + } + + @Override + public void onBindViewHolder(@NonNull ItemViewHolder holder, int i) { + holder.bind(media.get(i), forcedMultiSelect, selected, maxSelection, glideRequests, eventListener); + } + + @Override + public void onViewRecycled(@NonNull ItemViewHolder holder) { + holder.recycle(); + } + + @Override + public int getItemCount() { + return media.size(); + } + + @Override + public long getItemId(int position) { + return media.get(position).getDate(); + } + + void setMedia(@NonNull List media) { + this.media.clear(); + this.media.addAll(media); + notifyDataSetChanged(); + } + + void setSelected(@NonNull Collection selected) { + this.selected.clear(); + this.selected.addAll(selected); + notifyDataSetChanged(); + } + + Set getSelected() { + return selected; + } + + void setForcedMultiSelect(boolean forcedMultiSelect) { + this.forcedMultiSelect = forcedMultiSelect; + notifyDataSetChanged(); + } + + static class ItemViewHolder extends RecyclerView.ViewHolder { + + private final ImageView thumbnail; + private final View playOverlay; + private final View selectedOverlay; + + ItemViewHolder(@NonNull View itemView) { + super(itemView); + thumbnail = itemView.findViewById(R.id.mediapicker_image_item_thumbnail); + playOverlay = itemView.findViewById(R.id.mediapicker_play_overlay); + selectedOverlay = itemView.findViewById(R.id.mediapicker_selected); + } + + void bind(@NonNull Media media, boolean multiSelect, Set selected, int maxSelection, @NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) { + glideRequests.load(media.getUri()) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .transition(DrawableTransitionOptions.withCrossFade()) + .into(thumbnail); + + playOverlay.setVisibility(MediaUtil.isVideoType(media.getMimeType()) ? View.VISIBLE : View.GONE); + selectedOverlay.setVisibility(selected.contains(media) ? View.VISIBLE : View.GONE); + + if (selected.isEmpty() && !multiSelect) { + itemView.setOnClickListener(v -> eventListener.onMediaChosen(media)); + if (maxSelection > 1) { + itemView.setOnLongClickListener(v -> { + selected.add(media); + eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); + return true; + }); + } + } else if (selected.contains(media)) { + itemView.setOnClickListener(v -> { + selected.remove(media); + eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); + }); + } else { + itemView.setOnClickListener(v -> { + if (selected.size() < maxSelection) { + selected.add(media); + eventListener.onMediaSelectionChanged(new ArrayList<>(selected)); + } + }); + } + } + + void recycle() { + itemView.setOnClickListener(null); + } + } + + interface EventListener { + void onMediaChosen(@NonNull Media media); + void onMediaSelectionChanged(@NonNull List media); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java new file mode 100644 index 0000000000..3018b93352 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaPickerItemFragment.java @@ -0,0 +1,241 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.arch.lifecycle.ViewModelProviders; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.view.ActionMode; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.Toolbar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Allows the user to select a set of media items from a specified folder. + */ +public class MediaPickerItemFragment extends Fragment implements MediaPickerItemAdapter.EventListener { + + private static final String KEY_BUCKET_ID = "bucket_id"; + private static final String KEY_FOLDER_TITLE = "folder_title"; + private static final String KEY_MAX_SELECTION = "max_selection"; + + private String bucketId; + private String folderTitle; + private int maxSelection; + private MediaSendViewModel viewModel; + private MediaPickerItemAdapter adapter; + private Controller controller; + private GridLayoutManager layoutManager; + private ActionMode actionMode; + private ActionMode.Callback actionModeCallback; + + public static MediaPickerItemFragment newInstance(@NonNull String bucketId, @NonNull String folderTitle, int maxSelection) { + Bundle args = new Bundle(); + args.putString(KEY_BUCKET_ID, bucketId); + args.putString(KEY_FOLDER_TITLE, folderTitle); + args.putInt(KEY_MAX_SELECTION, maxSelection); + + MediaPickerItemFragment fragment = new MediaPickerItemFragment(); + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + + bucketId = getArguments().getString(KEY_BUCKET_ID); + folderTitle = getArguments().getString(KEY_FOLDER_TITLE); + maxSelection = getArguments().getInt(KEY_MAX_SELECTION); + viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(new MediaRepository())).get(MediaSendViewModel.class); + actionModeCallback = new ActionModeCallback(); + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + if (!(getActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement controller class."); + } + + controller = (Controller) getActivity(); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.mediapicker_item_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + RecyclerView imageList = view.findViewById(R.id.mediapicker_item_list); + + adapter = new MediaPickerItemAdapter(GlideApp.with(this), this, maxSelection); + layoutManager = new GridLayoutManager(requireContext(), 4); + + imageList.setLayoutManager(layoutManager); + imageList.setAdapter(adapter); + + initToolbar(view.findViewById(R.id.mediapicker_toolbar)); + onScreenWidthChanged(getScreenWidth()); + + if (!Util.isEmpty(viewModel.getSelectedMedia().getValue())) { + adapter.setSelected(viewModel.getSelectedMedia().getValue()); + onMediaSelectionChanged(new ArrayList<>(viewModel.getSelectedMedia().getValue())); + } + + viewModel.getMediaInBucket(requireContext(), bucketId).observe(this, adapter::setMedia); + } + + @Override + public void onResume() { + super.onResume(); + + requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.mediapicker_default, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.mediapicker_menu_add) { + adapter.setForcedMultiSelect(true); + actionMode = ((AppCompatActivity) requireActivity()).startSupportActionMode(actionModeCallback); + actionMode.setTitle(getResources().getString(R.string.MediaPickerItemFragment_tap_to_select)); + return true; + } + return false; + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + onScreenWidthChanged(getScreenWidth()); + } + + @Override + public void onMediaChosen(@NonNull Media media) { + controller.onMediaSelected(bucketId, Collections.singleton(media)); + viewModel.onSelectedMediaChanged(Collections.singletonList(media)); + } + + @Override + public void onMediaSelectionChanged(@NonNull List selected) { + adapter.notifyDataSetChanged(); + + if (actionMode == null && !selected.isEmpty()) { + actionMode = ((AppCompatActivity) requireActivity()).startSupportActionMode(actionModeCallback); + actionMode.setTitle(String.valueOf(selected.size())); + } else if (actionMode != null && selected.isEmpty()) { + actionMode.finish(); + } else if (actionMode != null) { + actionMode.setTitle(String.valueOf(selected.size())); + } + + viewModel.onSelectedMediaChanged(selected); + } + + private void initToolbar(Toolbar toolbar) { + ((AppCompatActivity) requireActivity()).setSupportActionBar(toolbar); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setDisplayHomeAsUpEnabled(true); + ((AppCompatActivity) requireActivity()).getSupportActionBar().setTitle(folderTitle); + + toolbar.setNavigationOnClickListener(v -> requireActivity().onBackPressed()); + } + + private void onScreenWidthChanged(int newWidth) { + if (layoutManager != null) { + layoutManager.setSpanCount(newWidth / getResources().getDimensionPixelSize(R.dimen.media_picker_item_width)); + } + } + + private int getScreenWidth() { + Point size = new Point(); + requireActivity().getWindowManager().getDefaultDisplay().getSize(size); + return size.x; + } + + private class ActionModeCallback implements ActionMode.Callback { + + private int statusBarColor; + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + MenuInflater inflater = mode.getMenuInflater(); + inflater.inflate(R.menu.mediapicker_multiselect, menu); + + if (Build.VERSION.SDK_INT >= 21) { + Window window = requireActivity().getWindow(); + statusBarColor = window.getStatusBarColor(); + window.setStatusBarColor(getResources().getColor(R.color.action_mode_status_bar)); + } + + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) { + if (menuItem.getItemId() == R.id.mediapicker_menu_confirm) { + List selected = new ArrayList<>(adapter.getSelected()); + actionMode.finish(); + viewModel.onSelectedMediaChanged(selected); + controller.onMediaSelected(bucketId, selected); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + adapter.setSelected(Collections.emptySet()); + viewModel.onSelectedMediaChanged(Collections.emptyList()); + + if (Build.VERSION.SDK_INT >= 21) { + requireActivity().getWindow().setStatusBarColor(statusBarColor); + } + } + } + + + public interface Controller { + void onMediaSelected(@NonNull String bucketId, @NonNull Collection media); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/src/org/thoughtcrime/securesms/mediasend/MediaRepository.java new file mode 100644 index 0000000000..5ed56ce9d0 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaRepository.java @@ -0,0 +1,190 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore.Images; +import android.provider.MediaStore.Video; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.Pair; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Handles the retrieval of media present on the user's device. + */ +class MediaRepository { + + /** + * Retrieves a list of folders that contain media. + */ + void getFolders(@NonNull Context context, @NonNull Callback> callback) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getFolders(context))); + } + + /** + * Retrieves a list of media items (images and videos) that are present int he specified bucket. + */ + void getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Callback> callback) { + AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> callback.onComplete(getMediaInBucket(context, bucketId))); + } + + @WorkerThread + private @NonNull List getFolders(@NonNull Context context) { + Pair> imageFolders = getFolders(context, Images.Media.EXTERNAL_CONTENT_URI); + Pair> videoFolders = getFolders(context, Video.Media.EXTERNAL_CONTENT_URI); + Map folders = new HashMap<>(imageFolders.second()); + + for (Map.Entry entry : videoFolders.second().entrySet()) { + if (folders.containsKey(entry.getKey())) { + folders.get(entry.getKey()).incrementCount(entry.getValue().getCount()); + } else { + folders.put(entry.getKey(), entry.getValue()); + } + } + + String cameraBucketId = imageFolders.first() != null ? imageFolders.first() : videoFolders.first(); + FolderData cameraFolder = cameraBucketId != null ? folders.remove(cameraBucketId) : null; + List mediaFolders = Stream.of(folders.values()).map(folder -> new MediaFolder(folder.getThumbnail(), + folder.getTitle(), + folder.getCount(), + folder.getBucketId(), + MediaFolder.FolderType.NORMAL)) + .sorted((o1, o2) -> o1.getTitle().toLowerCase().compareTo(o2.getTitle().toLowerCase())) + .toList(); + + if (cameraFolder != null) { + mediaFolders.add(0, new MediaFolder(cameraFolder.getThumbnail(), cameraFolder.getTitle(), cameraFolder.getCount(), cameraFolder.getBucketId(), MediaFolder.FolderType.CAMERA)); + } + + return mediaFolders; + } + + @WorkerThread + private @NonNull Pair> getFolders(@NonNull Context context, @NonNull Uri contentUri) { + String cameraPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).getAbsolutePath() + File.separator + "Camera"; + String cameraBucketId = null; + Map folders = new HashMap<>(); + + String[] projection = new String[] { Images.Media.DATA, Images.Media.BUCKET_ID, Images.Media.BUCKET_DISPLAY_NAME }; + String selection = Images.Media.DATA + " NOT NULL"; + String sortBy = Images.Media.BUCKET_DISPLAY_NAME + " COLLATE NOCASE ASC, " + Images.Media.DATE_TAKEN + " DESC"; + + try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, null, sortBy)) { + while (cursor != null && cursor.moveToNext()) { + String path = cursor.getString(cursor.getColumnIndexOrThrow(projection[0])); + Uri thumbnail = Uri.fromFile(new File(path)); + String bucketId = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])); + String title = cursor.getString(cursor.getColumnIndexOrThrow(projection[2])); + FolderData folder = Util.getOrDefault(folders, bucketId, new FolderData(thumbnail, title, bucketId)); + + folder.incrementCount(); + folders.put(bucketId, folder); + + if (cameraBucketId == null && path.startsWith(cameraPath)) { + cameraBucketId = bucketId; + } + } + } + + return new Pair<>(cameraBucketId, folders); + } + + @WorkerThread + private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId) { + List images = getMediaInBucket(context, bucketId, Images.Media.EXTERNAL_CONTENT_URI); + List videos = getMediaInBucket(context, bucketId, Video.Media.EXTERNAL_CONTENT_URI); + List media = new ArrayList<>(images.size() + videos.size()); + + media.addAll(images); + media.addAll(videos); + Collections.sort(media, (o1, o2) -> Long.compare(o2.getDate(), o1.getDate())); + + return media; + } + + @WorkerThread + private @NonNull List getMediaInBucket(@NonNull Context context, @NonNull String bucketId, @NonNull Uri contentUri) { + List media = new LinkedList<>(); + String selection = Images.Media.BUCKET_ID + " = ? AND " + Images.Media.DATA + " NOT NULL"; + String sortBy = Images.Media.DATE_TAKEN + " DESC"; + String[] 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 }; + + try (Cursor cursor = context.getContentResolver().query(contentUri, projection, selection, new String[] { bucketId }, sortBy)) { + while (cursor != null && cursor.moveToNext()) { + Uri uri = Uri.withAppendedPath(contentUri, cursor.getString(cursor.getColumnIndexOrThrow(projection[0]))); + String mimetype = cursor.getString(cursor.getColumnIndexOrThrow(projection[1])); + long dateTaken = cursor.getLong(cursor.getColumnIndexOrThrow(projection[2])); + int width = 0; + int height = 0; + + if (Build.VERSION.SDK_INT >= 16) { + width = cursor.getInt(cursor.getColumnIndexOrThrow(projection[3])); + height = cursor.getInt(cursor.getColumnIndexOrThrow(projection[4])); + } + + media.add(new Media(uri, mimetype, dateTaken, width, height, Optional.of(bucketId), Optional.absent())); + } + } + + return media; + } + + private static class FolderData { + private final Uri thumbnail; + private final String title; + private final String bucketId; + + private int count; + + private FolderData(Uri thumbnail, String title, String bucketId) { + this.thumbnail = thumbnail; + this.title = title; + this.bucketId = bucketId; + } + + Uri getThumbnail() { + return thumbnail; + } + + String getTitle() { + return title; + } + + String getBucketId() { + return bucketId; + } + + int getCount() { + return count; + } + + void incrementCount() { + incrementCount(1); + } + + void incrementCount(int amount) { + count += amount; + } + } + + interface Callback { + void onComplete(@NonNull E result); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java new file mode 100644 index 0000000000..b32f14c001 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -0,0 +1,228 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.arch.lifecycle.ViewModelProviders; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; + +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.recipients.Recipient; +import org.thoughtcrime.securesms.scribbles.ScribbleFragment; +import org.thoughtcrime.securesms.util.DynamicLanguage; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Encompasses the entire flow of sending media, starting from the selection process to the actual + * captioning and editing of the content. + * + * This activity is intended to be launched via {@link #startActivityForResult(Intent, int)}. + * It will return the {@link Media} that the user decided to send. + */ +public class MediaSendActivity extends PassphraseRequiredActionBarActivity implements MediaPickerFolderFragment.Controller, + MediaPickerItemFragment.Controller, + MediaSendFragment.Controller, + ScribbleFragment.Controller +{ + public static final String EXTRA_MEDIA = "media"; + public static final String EXTRA_MESSAGE = "message"; + public static final String EXTRA_TRANSPORT = "transport"; + + private static final int MAX_PUSH = 32; + private static final int MAX_SMS = 1; + + private static final String KEY_ADDRESS = "address"; + private static final String KEY_BODY = "body"; + private static final String KEY_MEDIA = "media"; + private static final String KEY_TRANSPORT = "transport"; + + private static final String TAG_FOLDER_PICKER = "folder_picker"; + private static final String TAG_ITEM_PICKER = "item_picker"; + private static final String TAG_SEND = "send"; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + private final DynamicLanguage dynamicLanguage = new DynamicLanguage(); + + private Recipient recipient; + private TransportOption transport; + private MediaSendViewModel viewModel; + + /** + * Get an intent to launch the media send flow starting with the picker. + */ + public static Intent getIntent(@NonNull Context context, @NonNull Recipient recipient, @NonNull TransportOption transport) { + Intent intent = new Intent(context, MediaSendActivity.class); + intent.putExtra(KEY_ADDRESS, recipient.getAddress().serialize()); + intent.putExtra(KEY_TRANSPORT, transport); + return intent; + } + + /** + * Get an intent to launch the media send flow with a specific list of media. Will jump right to + * the editor screen. + */ + public static Intent getIntent(@NonNull Context context, + @NonNull List media, + @NonNull Recipient recipient, + @NonNull String body, + @NonNull TransportOption transport) + { + Intent intent = getIntent(context, recipient, transport); + intent.putParcelableArrayListExtra(KEY_MEDIA, new ArrayList<>(media)); + intent.putExtra(KEY_BODY, body); + return intent; + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + dynamicLanguage.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + setContentView(R.layout.mediapicker_activity); + setResult(RESULT_CANCELED); + + if (savedInstanceState != null) { + return; + } + + viewModel = ViewModelProviders.of(this, new MediaSendViewModel.Factory(new MediaRepository())).get(MediaSendViewModel.class); + recipient = Recipient.from(this, Address.fromSerialized(getIntent().getStringExtra(KEY_ADDRESS)), true); + transport = getIntent().getParcelableExtra(KEY_TRANSPORT); + + List media = getIntent().getParcelableArrayListExtra(KEY_MEDIA); + String body = getIntent().getStringExtra(KEY_BODY); + + if (!Util.isEmpty(media)) { + navigateToMediaSend(media, body, transport); + } else { + navigateToFolderPicker(recipient); + } + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + dynamicLanguage.onResume(this); + } + + @Override + public void onBackPressed() { + MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); + if (sendFragment == null || !sendFragment.handleBackPress()) { + super.onBackPressed(); + } + } + + @Override + public void onFolderSelected(@NonNull MediaFolder folder) { + viewModel.onFolderSelected(folder.getBucketId()); + + MediaPickerItemFragment fragment = MediaPickerItemFragment.newInstance(folder.getBucketId(), + folder.getTitle(), + transport.isSms() ? MAX_SMS : MAX_PUSH); + + getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + .replace(R.id.mediapicker_fragment_container, fragment, TAG_ITEM_PICKER) + .addToBackStack(null) + .commit(); + } + + @Override + public void onMediaSelected(@NonNull String bucketId, @NonNull Collection media) { + MediaSendFragment fragment = MediaSendFragment.newInstance("", transport, dynamicLanguage.getCurrentLocale()); + getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + .replace(R.id.mediapicker_fragment_container, fragment, TAG_SEND) + .addToBackStack(null) + .commit(); + } + + @Override + public void onAddMediaClicked(@NonNull String bucketId) { + MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient); + MediaPickerItemFragment itemFragment = MediaPickerItemFragment.newInstance(bucketId, + "", + transport.isSms() ? MAX_SMS : MAX_PUSH); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediapicker_fragment_container, folderFragment, TAG_FOLDER_PICKER) + .addToBackStack(null) + .commit(); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediapicker_fragment_container, itemFragment, TAG_ITEM_PICKER) + .addToBackStack(null) + .commit(); + } + + @Override + public void onSendClicked(@NonNull List media, @NonNull String message, @NonNull TransportOption transport) { + ArrayList mediaList = new ArrayList<>(media); + Intent intent = new Intent(); + + intent.putParcelableArrayListExtra(EXTRA_MEDIA, mediaList); + intent.putExtra(EXTRA_MESSAGE, message); + intent.putExtra(EXTRA_TRANSPORT, transport); + setResult(RESULT_OK, intent); + finish(); + + overridePendingTransition(R.anim.stationary, R.anim.camera_slide_to_bottom); + } + + @Override + public void onNoMediaAvailable() { + setResult(RESULT_CANCELED); + finish(); + } + + @Override + public void onImageEditComplete(@NonNull Uri uri, int width, int height, long size, @NonNull Optional message, @NonNull Optional transport) { + throw new UnsupportedOperationException("Callback unsupported."); + } + + @Override + public void onImageEditFailure() { + throw new UnsupportedOperationException("Callback unsupported."); + } + + @Override + public void onTouchEventsNeeded(boolean needed) { + MediaSendFragment fragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND); + if (fragment != null) { + fragment.onTouchEventsNeeded(needed); + } + } + + private void navigateToMediaSend(List media, String body, TransportOption transport) { + viewModel.setInitialSelectedMedia(media); + + MediaSendFragment sendFragment = MediaSendFragment.newInstance(body, transport, dynamicLanguage.getCurrentLocale()); + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediapicker_fragment_container, sendFragment, TAG_SEND) + .commit(); + } + + private void navigateToFolderPicker(@NonNull Recipient recipient) { + MediaPickerFolderFragment folderFragment = MediaPickerFolderFragment.newInstance(recipient); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.mediapicker_fragment_container, folderFragment, TAG_FOLDER_PICKER) + .commit(); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java new file mode 100644 index 0000000000..cb22f01359 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragment.java @@ -0,0 +1,506 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.annotation.SuppressLint; +import android.app.ProgressDialog; +import android.arch.lifecycle.ViewModelProviders; +import android.content.Context; +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.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.view.ViewPager; +import android.support.v7.app.AlertDialog; +import android.support.v7.view.ContextThemeWrapper; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.widget.TextView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.components.ComposeText; +import org.thoughtcrime.securesms.components.ControllableViewPager; +import org.thoughtcrime.securesms.components.InputAwareLayout; +import org.thoughtcrime.securesms.components.SendButton; +import org.thoughtcrime.securesms.components.emoji.EmojiDrawer; +import org.thoughtcrime.securesms.components.emoji.EmojiEditText; +import org.thoughtcrime.securesms.components.emoji.EmojiToggle; +import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.scribbles.widget.ScribbleView; +import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.thoughtcrime.securesms.util.Stopwatch; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; +import org.thoughtcrime.securesms.util.views.Stub; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +/** + * Allows the user to edit and caption a set of media items before choosing to send them. + */ +public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGlobalLayoutListener, + MediaRailAdapter.RailItemListener, + InputAwareLayout.OnKeyboardShownListener, + InputAwareLayout.OnKeyboardHiddenListener +{ + + private static final String TAG = MediaSendFragment.class.getSimpleName(); + + private static final String KEY_BODY = "body"; + private static final String KEY_TRANSPORT = "transport"; + private static final String KEY_LOCALE = "locale"; + + private InputAwareLayout hud; + private SendButton sendButton; + private View addButton; + private ComposeText composeText; + private ViewGroup composeContainer; + private EmojiEditText captionText; + private EmojiToggle emojiToggle; + private Stub emojiDrawer; + private ViewGroup playbackControlsContainer; + private TextView charactersLeft; + + private ControllableViewPager fragmentPager; + private MediaSendFragmentPagerAdapter fragmentPagerAdapter; + private RecyclerView mediaRail; + private MediaRailAdapter mediaRailAdapter; + + private int visibleHeight; + private MediaSendViewModel viewModel; + private Controller controller; + private Locale locale; + + private final Rect visibleBounds = new Rect(); + + public static MediaSendFragment newInstance(@NonNull String body, @NonNull TransportOption transport, @NonNull Locale locale) { + Bundle args = new Bundle(); + args.putString(KEY_BODY, body); + args.putParcelable(KEY_TRANSPORT, transport); + args.putSerializable(KEY_LOCALE, locale); + + MediaSendFragment fragment = new MediaSendFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + + if (!(requireActivity() instanceof Controller)) { + throw new IllegalStateException("Parent activity must implement controller interface."); + } + + controller = (Controller) requireActivity(); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return ThemeUtil.getThemedInflater(requireActivity(), inflater, R.style.TextSecure_DarkTheme) + .inflate(R.layout.mediasend_fragment, container, false); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + locale = (Locale) getArguments().getSerializable(KEY_LOCALE); + + initViewModel(); + + requireActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + hud = view.findViewById(R.id.mediasend_hud); + sendButton = view.findViewById(R.id.mediasend_send_button); + composeText = view.findViewById(R.id.mediasend_compose_text); + composeContainer = view.findViewById(R.id.mediasend_compose_container); + captionText = view.findViewById(R.id.mediasend_caption); + emojiToggle = view.findViewById(R.id.mediasend_emoji_toggle); + emojiDrawer = new Stub<>(view.findViewById(R.id.mediasend_emoji_drawer_stub)); + fragmentPager = view.findViewById(R.id.mediasend_pager); + mediaRail = view.findViewById(R.id.mediasend_media_rail); + addButton = view.findViewById(R.id.mediasend_add_button); + playbackControlsContainer = view.findViewById(R.id.mediasend_playback_controls_container); + charactersLeft = view.findViewById(R.id.mediasend_characters_left); + + View sendButtonBkg = view.findViewById(R.id.mediasend_send_button_bkg); + + sendButton.setOnClickListener(v -> { + if (hud.isKeyboardOpen()) { + hud.hideSoftkey(composeText, null); + } + + processMedia(fragmentPagerAdapter.getAllMedia(), fragmentPagerAdapter.getSavedState()); + }); + + sendButton.addOnTransportChangedListener((newTransport, manuallySelected) -> { + presentCharactersRemaining(); + composeText.setTransport(newTransport); + sendButtonBkg.getBackground().setColorFilter(newTransport.getBackgroundColor(), PorterDuff.Mode.MULTIPLY); + sendButtonBkg.getBackground().invalidateSelf(); + }); + + ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener(); + + composeText.setOnKeyListener(composeKeyPressedListener); + composeText.addTextChangedListener(composeKeyPressedListener); + composeText.setOnClickListener(composeKeyPressedListener); + composeText.setOnFocusChangeListener(composeKeyPressedListener); + + captionText.clearFocus(); + composeText.requestFocus(); + + emojiToggle.setOnClickListener(this::onEmojiToggleClicked); + + fragmentPagerAdapter = new MediaSendFragmentPagerAdapter(requireActivity().getSupportFragmentManager(), locale); + fragmentPager.setAdapter(fragmentPagerAdapter); + + FragmentPageChangeListener pageChangeListener = new FragmentPageChangeListener(); + fragmentPager.addOnPageChangeListener(pageChangeListener); + fragmentPager.post(() -> pageChangeListener.onPageSelected(fragmentPager.getCurrentItem())); + + mediaRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, true); + mediaRail.setLayoutManager(new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)); + mediaRail.setAdapter(mediaRailAdapter); + + hud.getRootView().getViewTreeObserver().addOnGlobalLayoutListener(this); + hud.addOnKeyboardShownListener(this); + hud.addOnKeyboardHiddenListener(this); + + captionText.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void onTextChanged(String text) { + viewModel.onCaptionChanged(text); + } + }); + + TransportOption transportOption = getArguments().getParcelable(KEY_TRANSPORT); + + sendButton.setTransport(transportOption); + sendButton.disableTransport(transportOption.getType() == TransportOption.Type.SMS ? TransportOption.Type.TEXTSECURE : TransportOption.Type.SMS); + + composeText.append(getArguments().getString(KEY_BODY)); + } + + @Override + public void onStart() { + super.onStart(); + fragmentPagerAdapter.restoreState(viewModel.getDrawState()); + } + + @Override + public void onStop() { + super.onStop(); + viewModel.saveDrawState(fragmentPagerAdapter.getSavedState()); + } + + @Override + public void onGlobalLayout() { + hud.getRootView().getWindowVisibleDisplayFrame(visibleBounds); + + int currentVisibleHeight = visibleBounds.height(); + + if (currentVisibleHeight != visibleHeight) { + hud.getLayoutParams().height = currentVisibleHeight; + hud.layout(visibleBounds.left, visibleBounds.top, visibleBounds.right, visibleBounds.bottom); + hud.requestLayout(); + + visibleHeight = currentVisibleHeight; + } + } + + @Override + public void onRailItemClicked(int distanceFromActive) { + viewModel.onPageChanged(fragmentPager.getCurrentItem() + distanceFromActive); + } + + @Override + public void onRailItemDeleteClicked(int distanceFromActive) { + viewModel.onMediaItemRemoved(fragmentPager.getCurrentItem() + distanceFromActive); + } + + @Override + public void onKeyboardShown() { + if (composeText.hasFocus()) { + composeContainer.setVisibility(View.VISIBLE); + captionText.setVisibility(View.GONE); + } else if (captionText.hasFocus()) { + mediaRail.setVisibility(View.GONE); + composeContainer.setVisibility(View.GONE); + } + } + + @Override + public void onKeyboardHidden() { + composeContainer.setVisibility(View.VISIBLE); + + if (!Util.isEmpty(viewModel.getSelectedMedia().getValue()) && viewModel.getSelectedMedia().getValue().size() > 1) { + mediaRail.setVisibility(View.VISIBLE); + captionText.setVisibility(View.VISIBLE); + } + } + + public void onTouchEventsNeeded(boolean needed) { + fragmentPager.setEnabled(!needed); + } + + public boolean handleBackPress() { + if (hud.isInputOpen()) { + hud.hideCurrentInput(composeText); + return true; + } + return false; + } + + private void initViewModel() { + viewModel = ViewModelProviders.of(requireActivity(), new MediaSendViewModel.Factory(new MediaRepository())).get(MediaSendViewModel.class); + + viewModel.getSelectedMedia().observe(this, media -> { + if (Util.isEmpty(media)) { + controller.onNoMediaAvailable(); + return; + } + + fragmentPagerAdapter.setMedia(media); + + mediaRail.setVisibility(media.size() > 1 ? View.VISIBLE : View.GONE); + captionText.setVisibility((media.size() > 1 || media.get(0).getCaption().isPresent()) ? View.VISIBLE : View.GONE); + mediaRailAdapter.setMedia(media); + }); + + viewModel.getPosition().observe(this, position -> { + if (position == null || position < 0) return; + + fragmentPager.setCurrentItem(position, true); + mediaRailAdapter.setActivePosition(position); + mediaRail.smoothScrollToPosition(position); + + if (!fragmentPagerAdapter.getAllMedia().isEmpty()) { + captionText.setText(fragmentPagerAdapter.getAllMedia().get(position).getCaption().or("")); + } + + View playbackControls = fragmentPagerAdapter.getPlaybackControls(position); + + if (playbackControls != null) { + ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + playbackControls.setLayoutParams(params); + playbackControlsContainer.removeAllViews(); + playbackControlsContainer.addView(playbackControls); + } else { + playbackControlsContainer.removeAllViews(); + } + }); + + viewModel.getBucketId().observe(this, bucketId -> { + if (bucketId == null || !bucketId.isPresent() || sendButton.getSelectedTransport().isSms()) { + addButton.setVisibility(View.GONE); + } else { + addButton.setVisibility(View.VISIBLE); + addButton.setOnClickListener(v -> controller.onAddMediaClicked(bucketId.get())); + } + }); + } + + private EmojiEditText getActiveInputField() { + if (captionText.hasFocus()) return captionText; + else return composeText; + } + + + private void presentCharactersRemaining() { + String messageBody = composeText.getTextTrimmed(); + TransportOption transportOption = sendButton.getSelectedTransport(); + CharacterState characterState = transportOption.calculateCharacters(messageBody); + + if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { + charactersLeft.setText(String.format(locale, + "%d/%d (%d)", + characterState.charactersRemaining, + characterState.maxMessageSize, + characterState.messagesSpent)); + charactersLeft.setVisibility(View.VISIBLE); + } else { + charactersLeft.setVisibility(View.GONE); + } + } + + private void onEmojiToggleClicked(View v) { + if (!emojiDrawer.resolved()) { + emojiToggle.attach(emojiDrawer.get()); + emojiDrawer.get().setEmojiEventListener(new EmojiDrawer.EmojiEventListener() { + @Override + public void onKeyEvent(KeyEvent keyEvent) { + getActiveInputField().dispatchKeyEvent(keyEvent); + } + + @Override + public void onEmojiSelected(String emoji) { + getActiveInputField().insertEmoji(emoji); + } + }); + } + + if (hud.getCurrentInput() == emojiDrawer.get()) { + hud.showSoftkey(composeText); + } else { + hud.hideSoftkey(composeText, () -> hud.post(() -> hud.show(composeText, emojiDrawer.get()))); + } + } + + @SuppressLint("StaticFieldLeak") + private void processMedia(@NonNull List mediaList, @NonNull Map savedState) { + Map> futures = new HashMap<>(); + + for (Media media : mediaList) { + Object state = savedState.get(media.getUri()); + + if (state instanceof ScribbleView.SavedState && !((ScribbleView.SavedState) state).isEmpty()) { + futures.put(media, ScribbleView.renderImage(requireContext(), media.getUri(), (ScribbleView.SavedState) state, GlideApp.with(this))); + } + } + + new AsyncTask>() { + + private Stopwatch renderTimer; + private Runnable progressTimer; + private AlertDialog dialog; + + @Override + protected void onPreExecute() { + renderTimer = new Stopwatch("ProcessMedia"); + progressTimer = () -> { + dialog = new AlertDialog.Builder(new ContextThemeWrapper(requireContext(), R.style.TextSecure_MediaSendProgressDialog)) + .setView(R.layout.progress_dialog) + .setCancelable(false) + .create(); + dialog.show(); + dialog.getWindow().setLayout(getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size), + getResources().getDimensionPixelSize(R.dimen.mediasend_progress_dialog_size)); + }; + Util.runOnMainDelayed(progressTimer, 250); + } + + @Override + protected List doInBackground(Void... voids) { + Context context = requireContext(); + List updatedMedia = new ArrayList<>(mediaList.size()); + + for (Media media : mediaList) { + if (futures.containsKey(media)) { + try { + Bitmap bitmap = futures.get(media).get(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + 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()); + + updatedMedia.add(updated); + renderTimer.split("item"); + + } catch (InterruptedException | ExecutionException e) { + Log.w(TAG, "Failed to render image. Using base image."); + updatedMedia.add(media); + } + } else { + updatedMedia.add(media); + } + } + return updatedMedia; + } + + @Override + protected void onPostExecute(List media) { + controller.onSendClicked(media, composeText.getTextTrimmed(), sendButton.getSelectedTransport()); + Util.cancelRunnableOnMain(progressTimer); + if (dialog != null) { + dialog.dismiss(); + } + renderTimer.stop(TAG); + } + }.execute(); + } + + private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener { + @Override + public void onPageSelected(int position) { + viewModel.onPageChanged(position); + } + } + + private class ComposeKeyPressedListener implements View.OnKeyListener, View.OnClickListener, TextWatcher, View.OnFocusChangeListener { + + int beforeLength; + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (keyCode == KeyEvent.KEYCODE_ENTER) { + if (TextSecurePreferences.isEnterSendsEnabled(requireContext())) { + sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER)); + sendButton.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ENTER)); + return true; + } + } + } + return false; + } + + @Override + public void onClick(View v) { + hud.showSoftkey(composeText); + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count,int after) { + beforeLength = composeText.getTextTrimmed().length(); + } + + @Override + public void afterTextChanged(Editable s) { + presentCharactersRemaining(); + } + + @Override + public void onTextChanged(CharSequence s, int start, int before,int count) {} + + @Override + public void onFocusChange(View v, boolean hasFocus) {} + } + + public interface Controller { + void onAddMediaClicked(@NonNull String bucketId); + void onSendClicked(@NonNull List media, @NonNull String body, @NonNull TransportOption transport); + void onNoMediaAvailable(); + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java new file mode 100644 index 0000000000..d85eba9f1f --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendFragmentPagerAdapter.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentStatePagerAdapter; +import android.view.View; +import android.view.ViewGroup; + +import org.thoughtcrime.securesms.scribbles.ScribbleFragment; +import org.thoughtcrime.securesms.util.MediaUtil; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter { + + private final Locale locale; + private final List media; + private final Map fragments; + private final Map savedState; + + MediaSendFragmentPagerAdapter(@NonNull FragmentManager fm, @NonNull Locale locale) { + super(fm); + this.locale = locale; + this.media = new ArrayList<>(); + this.fragments = new HashMap<>(); + this.savedState = new HashMap<>(); + } + + @Override + public Fragment getItem(int i) { + Media mediaItem = media.get(i); + + if (MediaUtil.isGif(mediaItem.getMimeType())) { + return MediaSendGifFragment.newInstance(mediaItem.getUri()); + } else if (MediaUtil.isImageType(mediaItem.getMimeType())) { + return ScribbleFragment.newInstance(mediaItem.getUri(), locale, Optional.absent(), true); + } else if (MediaUtil.isVideoType(mediaItem.getMimeType())) { + return MediaSendVideoFragment.newInstance(mediaItem.getUri()); + } else { + throw new UnsupportedOperationException("Can only render images and videos. Found mimetype: '" + mediaItem.getMimeType() + "'"); + } + } + + @Override + public int getItemPosition(@NonNull Object object) { + return POSITION_NONE; + } + + @NonNull + @Override + public Object instantiateItem(@NonNull ViewGroup container, int position) { + MediaSendPageFragment fragment = (MediaSendPageFragment) super.instantiateItem(container, position); + fragments.put(position, fragment); + + Object state = savedState.get(fragment.getUri()); + if (state != null) { + fragment.restoreState(state); + } + + return fragment; + } + + @Override + public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) { + MediaSendPageFragment fragment = (MediaSendPageFragment) object; + + Object state = fragment.saveState(); + if (state != null) { + savedState.put(fragment.getUri(), state); + } + + super.destroyItem(container, position, object); + fragments.remove(position); + } + + @Override + public int getCount() { + return media.size(); + } + + List getAllMedia() { + return media; + } + + void setMedia(@NonNull List media) { + this.media.clear(); + this.media.addAll(media); + notifyDataSetChanged(); + } + + Map getSavedState() { + for (MediaSendPageFragment fragment : fragments.values()) { + Object state = fragment.saveState(); + if (state != null) { + savedState.put(fragment.getUri(), state); + } + } + return new HashMap<>(savedState); + } + + void restoreState(@NonNull Map state) { + savedState.clear(); + savedState.putAll(state); + } + + @Nullable View getPlaybackControls(int position) { + return fragments.containsKey(position) ? fragments.get(position).getPlaybackControls() : null; + } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java new file mode 100644 index 0000000000..b471878155 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendGifFragment.java @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; +import org.thoughtcrime.securesms.mms.GlideApp; + +public class MediaSendGifFragment extends Fragment implements MediaSendPageFragment { + + private static final String KEY_URI = "uri"; + + private Uri uri; + + public static MediaSendGifFragment newInstance(@NonNull Uri uri) { + Bundle args = new Bundle(); + args.putParcelable(KEY_URI, uri); + + MediaSendGifFragment fragment = new MediaSendGifFragment(); + fragment.setArguments(args); + fragment.setUri(uri); + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.mediasend_image_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + uri = getArguments().getParcelable(KEY_URI); + GlideApp.with(this).load(new DecryptableStreamUriLoader.DecryptableUri(uri)).into((ImageView) view); + } + + @Override + public void setUri(@NonNull Uri uri) { + this.uri = uri; + } + + @Override + public @NonNull Uri getUri() { + return uri; + } + + @Override + public @Nullable View getPlaybackControls() { + return null; + } + + @Override + public @Nullable Object saveState() { + return null; + } + + @Override + public void restoreState(@NonNull Object state) { } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java new file mode 100644 index 0000000000..aab97cd043 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendPageFragment.java @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.View; + +/** + * A page that sits in the {@link MediaSendFragmentPagerAdapter}. + */ +public interface MediaSendPageFragment { + + @NonNull Uri getUri(); + + void setUri(@NonNull Uri uri); + + @Nullable View getPlaybackControls(); + + @Nullable Object saveState(); + + void restoreState(@NonNull Object state); +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java new file mode 100644 index 0000000000..cc751cb848 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendVideoFragment.java @@ -0,0 +1,88 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.Fragment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mms.VideoSlide; +import org.thoughtcrime.securesms.video.VideoPlayer; + +import java.io.IOException; + +public class MediaSendVideoFragment extends Fragment implements MediaSendPageFragment { + + private static final String TAG = MediaSendVideoFragment.class.getSimpleName(); + + private static final String KEY_URI = "uri"; + + private Uri uri; + + public static MediaSendVideoFragment newInstance(@NonNull Uri uri) { + Bundle args = new Bundle(); + args.putParcelable(KEY_URI, uri); + + MediaSendVideoFragment fragment = new MediaSendVideoFragment(); + fragment.setArguments(args); + fragment.setUri(uri); + return fragment; + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.mediasend_video_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + uri = getArguments().getParcelable(KEY_URI); + VideoSlide slide = new VideoSlide(requireContext(), uri, 0); + try { + ((VideoPlayer) view).setWindow(requireActivity().getWindow()); + ((VideoPlayer) view).setVideoSource(slide, false); + } catch (IOException e) { + Log.w(TAG, "Failed to play video.", e); + } + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + if (getView() != null) { + ((VideoPlayer) getView()).cleanup(); + } + } + + @Override + public void setUri(@NonNull Uri uri) { + this.uri = uri; + } + + @Override + public @NonNull Uri getUri() { + return uri; + } + + @Override + public @Nullable View getPlaybackControls() { + VideoPlayer player = (VideoPlayer) getView(); + return player != null ? player.getControlView() : null; + } + + @Override + public @Nullable Object saveState() { + return null; + } + + @Override + public void restoreState(@NonNull Object state) { } +} diff --git a/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java new file mode 100644 index 0000000000..365e410296 --- /dev/null +++ b/src/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -0,0 +1,136 @@ +package org.thoughtcrime.securesms.mediasend; + +import android.arch.lifecycle.LiveData; +import android.arch.lifecycle.MutableLiveData; +import android.arch.lifecycle.ViewModel; +import android.arch.lifecycle.ViewModelProvider; +import android.content.Context; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.util.Util; +import org.whispersystems.libsignal.util.guava.Optional; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Manages the observable datasets available in {@link MediaSendActivity}. + */ +class MediaSendViewModel extends ViewModel { + + private final MediaRepository repository; + private final MutableLiveData> selectedMedia; + private final MutableLiveData> bucketMedia; + private final MutableLiveData position; + private final MutableLiveData> bucketId; + private final MutableLiveData> folders; + private final Map savedDrawState; + + private MediaSendViewModel(@NonNull MediaRepository repository) { + this.repository = repository; + this.selectedMedia = new MutableLiveData<>(); + this.bucketMedia = new MutableLiveData<>(); + this.position = new MutableLiveData<>(); + this.bucketId = new MutableLiveData<>(); + this.folders = new MutableLiveData<>(); + this.savedDrawState = new HashMap<>(); + + position.setValue(-1); + } + + void setInitialSelectedMedia(@NonNull List newMedia) { + boolean allBucketsPopulated = Stream.of(newMedia).reduce(true, (populated, m) -> populated && m.getBucketId().isPresent()); + + selectedMedia.setValue(newMedia); + bucketId.setValue(allBucketsPopulated ? computeBucketId(newMedia) : Optional.absent()); + } + + void onSelectedMediaChanged(@NonNull List newMedia) { + selectedMedia.setValue(newMedia); + position.setValue(newMedia.isEmpty() ? -1 : 0); + } + + void onFolderSelected(@NonNull String bucketId) { + this.bucketId.setValue(Optional.of(bucketId)); + bucketMedia.setValue(Collections.emptyList()); + } + + void onPageChanged(int position) { + this.position.setValue(position); + } + + void onMediaItemRemoved(int position) { + selectedMedia.getValue().remove(position); + selectedMedia.setValue(selectedMedia.getValue()); + } + + void onCaptionChanged(@NonNull String newCaption) { + if (position.getValue() >= 0 && !Util.isEmpty(selectedMedia.getValue())) { + selectedMedia.getValue().get(position.getValue()).setCaption(TextUtils.isEmpty(newCaption) ? null : newCaption); + } + } + + void saveDrawState(@NonNull Map state) { + savedDrawState.clear(); + savedDrawState.putAll(state); + } + + @NonNull Map getDrawState() { + return savedDrawState; + } + + LiveData> getSelectedMedia() { + return selectedMedia; + } + + LiveData> getMediaInBucket(@NonNull Context context, @NonNull String bucketId) { + repository.getMediaInBucket(context, bucketId, bucketMedia::postValue); + return bucketMedia; + } + + @NonNull LiveData> getFolders(@NonNull Context context) { + repository.getFolders(context, folders::postValue); + return folders; + } + + LiveData getPosition() { + return position; + } + + LiveData> getBucketId() { + return bucketId; + } + + private Optional computeBucketId(@NonNull List media) { + if (media.isEmpty() || !media.get(0).getBucketId().isPresent()) return Optional.absent(); + + String candidate = media.get(0).getBucketId().get(); + for (int i = 1; i < media.size(); i++) { + if (!Util.equals(candidate, media.get(i).getBucketId().orNull())) { + return Optional.absent(); + } + } + + return Optional.of(candidate); + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final MediaRepository repository; + + Factory(@NonNull MediaRepository repository) { + this.repository = repository; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new MediaSendViewModel(repository)); + } + } +} diff --git a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java index 8aa631d255..37c349f8e9 100644 --- a/src/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/src/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -35,6 +35,8 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextUtils; +import org.thoughtcrime.securesms.TransportOption; +import org.thoughtcrime.securesms.mediasend.MediaSendActivity; import org.thoughtcrime.securesms.logging.Log; import android.util.Pair; import android.view.View; @@ -56,6 +58,7 @@ import org.thoughtcrime.securesms.components.location.SignalPlace; import org.thoughtcrime.securesms.giph.ui.GiphyActivity; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.providers.PersistentBlobProvider; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.scribbles.ScribbleActivity; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.MediaUtil; @@ -370,12 +373,13 @@ public class AttachmentManager { .execute(); } - public static void selectGallery(Activity activity, int requestCode) { + public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull TransportOption transport) { Permissions.with(activity) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .ifNecessary() .withPermanentDenialDialog(activity.getString(R.string.AttachmentManager_signal_requires_the_external_storage_permission_in_order_to_attach_photos_videos_or_audio)) .onAllGranted(() -> selectMediaType(activity, "image/*", new String[] {"image/*", "video/*"}, requestCode)) + .onAllGranted(() -> activity.startActivityForResult(MediaSendActivity.getIntent(activity, recipient, transport), requestCode)) .execute(); } diff --git a/src/org/thoughtcrime/securesms/mms/AudioSlide.java b/src/org/thoughtcrime/securesms/mms/AudioSlide.java index ef65cb3fcf..f38acb7172 100644 --- a/src/org/thoughtcrime/securesms/mms/AudioSlide.java +++ b/src/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -34,7 +34,7 @@ import org.thoughtcrime.securesms.util.ResUtil; public class AudioSlide extends Slide { public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) { - super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, voiceNote, false)); + super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, voiceNote, false)); } public AudioSlide(Context context, Uri uri, long dataSize, String contentType, boolean voiceNote) { diff --git a/src/org/thoughtcrime/securesms/mms/DocumentSlide.java b/src/org/thoughtcrime/securesms/mms/DocumentSlide.java index a6fc529c9e..25e72cfd04 100644 --- a/src/org/thoughtcrime/securesms/mms/DocumentSlide.java +++ b/src/org/thoughtcrime/securesms/mms/DocumentSlide.java @@ -19,7 +19,7 @@ public class DocumentSlide extends Slide { @NonNull String contentType, long size, @Nullable String fileName) { - super(context, constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, StorageUtil.getCleanFileName(fileName), false, false)); + super(context, constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, StorageUtil.getCleanFileName(fileName), null, false, false)); } @Override diff --git a/src/org/thoughtcrime/securesms/mms/GifSlide.java b/src/org/thoughtcrime/securesms/mms/GifSlide.java index bb8161a5d0..060859f4cc 100644 --- a/src/org/thoughtcrime/securesms/mms/GifSlide.java +++ b/src/org/thoughtcrime/securesms/mms/GifSlide.java @@ -13,8 +13,13 @@ public class GifSlide extends ImageSlide { super(context, attachment); } + public GifSlide(Context context, Uri uri, long size, int width, int height) { - super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, true, null, false, false)); + this(context, uri, size, width, height, null); + } + + public GifSlide(Context context, Uri uri, long size, int width, int height, @Nullable String caption) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_GIF, size, width, height, true, null, caption, false, false)); } @Override diff --git a/src/org/thoughtcrime/securesms/mms/ImageSlide.java b/src/org/thoughtcrime/securesms/mms/ImageSlide.java index ffe1fdbd77..2e2016da20 100644 --- a/src/org/thoughtcrime/securesms/mms/ImageSlide.java +++ b/src/org/thoughtcrime/securesms/mms/ImageSlide.java @@ -37,7 +37,11 @@ public class ImageSlide extends Slide { } public ImageSlide(Context context, Uri uri, long size, int width, int height) { - super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_JPEG, size, width, height, true, null, false, false)); + this(context, uri, size, width, height, null); + } + + public ImageSlide(Context context, Uri uri, long size, int width, int height, @Nullable String caption) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.IMAGE_JPEG, size, width, height, true, null, caption, false, false)); } @Override diff --git a/src/org/thoughtcrime/securesms/mms/Slide.java b/src/org/thoughtcrime/securesms/mms/Slide.java index 3c258410b7..fa33094643 100644 --- a/src/org/thoughtcrime/securesms/mms/Slide.java +++ b/src/org/thoughtcrime/securesms/mms/Slide.java @@ -135,12 +135,13 @@ public abstract class Slide { protected static Attachment constructAttachmentFromUri(@NonNull Context context, @NonNull Uri uri, @NonNull String defaultMime, - long size, - int width, - int height, - boolean hasThumbnail, - @Nullable String fileName, - boolean voiceNote, + long size, + int width, + int height, + boolean hasThumbnail, + @Nullable String fileName, + @Nullable String caption, + boolean voiceNote, boolean quote) { try { @@ -157,7 +158,7 @@ public abstract class Slide { fastPreflightId, voiceNote, quote, - null); + caption); } catch (NoSuchAlgorithmException e) { throw new AssertionError(e); } diff --git a/src/org/thoughtcrime/securesms/mms/VideoSlide.java b/src/org/thoughtcrime/securesms/mms/VideoSlide.java index 84750dd011..acb8556002 100644 --- a/src/org/thoughtcrime/securesms/mms/VideoSlide.java +++ b/src/org/thoughtcrime/securesms/mms/VideoSlide.java @@ -21,6 +21,7 @@ import android.content.res.Resources.Theme; import android.net.Uri; import android.support.annotation.DrawableRes; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; @@ -30,7 +31,11 @@ import org.thoughtcrime.securesms.util.ResUtil; public class VideoSlide extends Slide { public VideoSlide(Context context, Uri uri, long dataSize) { - super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(uri), null, false, false)); + this(context, uri, dataSize, null); + } + + public VideoSlide(Context context, Uri uri, long dataSize, @Nullable String caption) { + super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(uri), null, caption, false, false)); } public VideoSlide(Context context, Attachment attachment) { diff --git a/src/org/thoughtcrime/securesms/scribbles/ScribbleActivity.java b/src/org/thoughtcrime/securesms/scribbles/ScribbleActivity.java index a845ea7645..f12063c125 100644 --- a/src/org/thoughtcrime/securesms/scribbles/ScribbleActivity.java +++ b/src/org/thoughtcrime/securesms/scribbles/ScribbleActivity.java @@ -13,8 +13,6 @@ import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.util.DynamicLanguage; -import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; -import org.thoughtcrime.securesms.util.DynamicTheme; import org.whispersystems.libsignal.util.guava.Optional; @TargetApi(Build.VERSION_CODES.JELLY_BEAN) @@ -36,7 +34,7 @@ public class ScribbleActivity extends PassphraseRequiredActionBarActivity implem setContentView(R.layout.scribble_activity); if (savedInstanceState == null) { - ScribbleFragment fragment = ScribbleFragment.newInstance(getIntent().getData(), dynamicLanguage.getCurrentLocale(), Optional.absent()); + ScribbleFragment fragment = ScribbleFragment.newInstance(getIntent().getData(), dynamicLanguage.getCurrentLocale(), Optional.absent(), false); getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, fragment).commit(); } @@ -66,4 +64,7 @@ public class ScribbleActivity extends PassphraseRequiredActionBarActivity implem Toast.makeText(ScribbleActivity.this, R.string.ScribbleActivity_save_failure, Toast.LENGTH_SHORT).show(); finish(); } + + @Override + public void onTouchEventsNeeded(boolean needed) { } } diff --git a/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java b/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java index 2699d9c405..c5ea2834c1 100644 --- a/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java +++ b/src/org/thoughtcrime/securesms/scribbles/ScribbleFragment.java @@ -20,6 +20,7 @@ import android.view.WindowManager; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mediasend.MediaSendPageFragment; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.providers.PersistentBlobProvider; @@ -46,13 +47,17 @@ import java.util.concurrent.ExecutionException; import static android.app.Activity.RESULT_OK; @TargetApi(Build.VERSION_CODES.JELLY_BEAN) -public class ScribbleFragment extends Fragment implements ScribbleHud.EventListener, VerticalSlideColorPicker.OnColorChangeListener { +public class ScribbleFragment extends Fragment implements ScribbleHud.EventListener, + VerticalSlideColorPicker.OnColorChangeListener, + MediaSendPageFragment +{ private static final String TAG = ScribbleFragment.class.getSimpleName(); private static final String KEY_IMAGE_URI = "image_uri"; private static final String KEY_LOCALE = "locale"; private static final String KEY_TRANSPORT = "compose_mode"; + private static final String KEY_HIDE_SAVE = "hide_save"; public static final int SELECT_STICKER_REQUEST_CODE = 123; @@ -60,15 +65,20 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe private ScribbleHud scribbleHud; private ScribbleView scribbleView; private GlideRequests glideRequests; + private Uri imageUri; - public static ScribbleFragment newInstance(@NonNull Uri imageUri, @NonNull Locale locale, Optional transport) { + private ScribbleView.SavedState savedState; + + public static ScribbleFragment newInstance(@NonNull Uri imageUri, @NonNull Locale locale, Optional transport, boolean hideSave) { Bundle args = new Bundle(); args.putParcelable(KEY_IMAGE_URI, imageUri); args.putSerializable(KEY_LOCALE, locale); args.putParcelable(KEY_TRANSPORT, transport.orNull()); + args.putBoolean(KEY_HIDE_SAVE, hideSave); ScribbleFragment fragment = new ScribbleFragment(); fragment.setArguments(args); + fragment.setUri(imageUri); return fragment; } @@ -79,6 +89,7 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe throw new IllegalStateException("Parent activity must implement Controller interface."); } controller = (Controller) getActivity(); + imageUri = getArguments().getParcelable(KEY_IMAGE_URI); } @Nullable @@ -97,12 +108,46 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe scribbleHud.setEventListener(this); scribbleHud.setTransport(Optional.fromNullable(getArguments().getParcelable(KEY_TRANSPORT))); + scribbleHud.hideSaveButton(getArguments().getBoolean(KEY_HIDE_SAVE)); scribbleHud.setFullscreen((getActivity().getWindow().getAttributes().flags & WindowManager.LayoutParams.FLAG_FULLSCREEN) > 0); scribbleView.setMotionViewCallback(motionViewCallback); scribbleView.setDrawingChangedListener(() -> scribbleHud.setColorPalette(scribbleView.getUniqueColors())); scribbleView.setDrawingMode(false); - scribbleView.setImage(glideRequests, getArguments().getParcelable(KEY_IMAGE_URI)); + scribbleView.setImage(glideRequests, imageUri); + + if (savedState != null) { + scribbleView.restoreState(savedState); + } + } + + @Override + public void setUri(@NonNull Uri uri) { + this.imageUri = uri; + } + + @Override + public @NonNull Uri getUri() { + return imageUri; + } + + @Override + public @Nullable View getPlaybackControls() { + return null; + } + + @Override + public @Nullable Object saveState() { + return scribbleView.saveState(); + } + + @Override + public void restoreState(@NonNull Object state) { + if (state instanceof ScribbleView.SavedState) { + savedState = (ScribbleView.SavedState) state; + } else { + Log.w(TAG, "Received a bad saved state. Received class: " + state.getClass().getName()); + } } public boolean isEmojiKeyboardVisible() { @@ -204,27 +249,32 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe public void onModeStarted(@NonNull ScribbleHud.Mode mode) { switch (mode) { case DRAW: + controller.onTouchEventsNeeded(true); scribbleView.setDrawingMode(true); scribbleView.setDrawingBrushWidth(ScribbleView.DEFAULT_BRUSH_WIDTH); break; case HIGHLIGHT: + controller.onTouchEventsNeeded(true); scribbleView.setDrawingMode(true); scribbleView.setDrawingBrushWidth(ScribbleView.DEFAULT_BRUSH_WIDTH * 3); break; case TEXT: + controller.onTouchEventsNeeded(true); scribbleView.setDrawingMode(false); addTextSticker(); break; case STICKER: + controller.onTouchEventsNeeded(true); scribbleView.setDrawingMode(false); Intent intent = new Intent(getContext(), StickerSelectActivity.class); startActivityForResult(intent, SELECT_STICKER_REQUEST_CODE); break; case NONE: + controller.onTouchEventsNeeded(false); scribbleView.clearSelection(); scribbleView.setDrawingMode(false); break; @@ -283,13 +333,16 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe public void onEntitySelected(@Nullable MotionEntity entity) { if (entity == null) { scribbleHud.enterMode(ScribbleHud.Mode.NONE); + controller.onTouchEventsNeeded(false); } else if (entity instanceof TextEntity) { int textColor = ((TextEntity) entity).getLayer().getFont().getColor(); scribbleHud.enterMode(ScribbleHud.Mode.TEXT); scribbleHud.setActiveColor(textColor); + controller.onTouchEventsNeeded(true); } else { scribbleHud.enterMode(ScribbleHud.Mode.STICKER); + controller.onTouchEventsNeeded(true); } } @@ -302,5 +355,6 @@ public class ScribbleFragment extends Fragment implements ScribbleHud.EventListe public interface Controller { void onImageEditComplete(@NonNull Uri uri, int width, int height, long size, @NonNull Optional message, @NonNull Optional transport); void onImageEditFailure(); + void onTouchEventsNeeded(boolean needed); } } diff --git a/src/org/thoughtcrime/securesms/scribbles/ScribbleHud.java b/src/org/thoughtcrime/securesms/scribbles/ScribbleHud.java index 50c03d622a..28cef2afe1 100644 --- a/src/org/thoughtcrime/securesms/scribbles/ScribbleHud.java +++ b/src/org/thoughtcrime/securesms/scribbles/ScribbleHud.java @@ -201,6 +201,12 @@ public class ScribbleHud extends InputAwareLayout implements ViewTreeObserver.On } } + public void hideSaveButton(boolean hide) { + if (hide) { + saveButton.setVisibility(GONE); + } + } + public void dismissEmojiKeyboard() { hideCurrentInput(composeText); } diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/CanvasView.java b/src/org/thoughtcrime/securesms/scribbles/widget/CanvasView.java index 1f80c61300..d58b7b9193 100644 --- a/src/org/thoughtcrime/securesms/scribbles/widget/CanvasView.java +++ b/src/org/thoughtcrime/securesms/scribbles/widget/CanvasView.java @@ -58,9 +58,11 @@ public class CanvasView extends View { QUBIC_BEZIER; } - private int canvasWidth = 1; - private int canvasHeight = 1; - private Bitmap bitmap = null; + private int initialWidth = 0; + private int initialHeight = 0; + private int canvasWidth = 1; + private int canvasHeight = 1; + private Bitmap bitmap = null; private List pathLists = new ArrayList(); private List paintLists = new ArrayList(); @@ -177,8 +179,8 @@ public class CanvasView extends View { Path path = new Path(); // Save for ACTION_MOVE - this.startX = event.getX(); - this.startY = event.getY(); + this.startX = scaleX(event.getX()); + this.startY = scaleY(event.getY()); path.moveTo(this.startX, this.startY); @@ -279,7 +281,7 @@ public class CanvasView extends View { switch (this.drawer) { case PEN : for (int i = 0; i < event.getHistorySize(); i++) { - path.lineTo(event.getHistoricalX(i), event.getHistoricalY(i)); + path.lineTo(scaleX(event.getHistoricalX(i)), scaleY(event.getHistoricalY(i))); } break; case LINE : @@ -316,7 +318,7 @@ public class CanvasView extends View { Path path = this.getCurrentPath(); path.reset(); - path.moveTo(this.startX, this.startY); + path.moveTo(scaleX(this.startX), scaleY(this.startY)); path.quadTo(this.controlX, this.controlY, x, y); } @@ -344,6 +346,25 @@ public class CanvasView extends View { } } + public SavedState saveState() { + return new SavedState(pathLists, paintLists, historyPointer, initialWidth, initialHeight, canvasWidth, canvasHeight); + } + + public void restoreState(@NonNull SavedState state) { + this.pathLists.clear(); + this.pathLists.addAll(state.getPaths()); + + this.paintLists.clear(); + this.paintLists.addAll(state.getPaints()); + + this.historyPointer = state.getHistoryPointer(); + + this.initialWidth = state.getInitialWidth(); + this.initialHeight = state.getInitialHeight(); + + postInvalidate(); + } + public void setActive(boolean active) { this.active = active; } @@ -357,19 +378,8 @@ public class CanvasView extends View { protected void onDraw(Canvas canvas) { super.onDraw(canvas); - // Before "drawPath" canvas.drawColor(this.baseColor); - - if (this.bitmap != null) { - canvas.drawBitmap(this.bitmap, 0F, 0F, new Paint()); - } - - for (int i = 0; i < this.historyPointer; i++) { - Path path = this.pathLists.get(i); - Paint paint = this.paintLists.get(i); - - canvas.drawPath(path, paint); - } + render(canvas); } @Override @@ -377,18 +387,41 @@ public class CanvasView extends View { super.onSizeChanged(w, h, oldw, oldh); this.canvasWidth = w; this.canvasHeight = h; + + if (initialWidth == 0) { + initialWidth = canvasWidth; + } + + if (initialHeight == 0) { + initialHeight = canvasHeight; + } } public void render(Canvas canvas) { - float scaleX = 1.0F * canvas.getWidth() / canvasWidth; - float scaleY = 1.0F * canvas.getHeight() / canvasHeight; + render(canvas, initialWidth, initialHeight, canvasWidth, canvasHeight, pathLists, paintLists, historyPointer); + } + + public static void render(Canvas canvas, int initialWidth, int initialHeight, int canvasWidth, int canvasHeight, List pathLists, List paintLists, int historyPointer) { + float scaleX = 1f; + float scaleY = 1f; + + if (initialWidth > 0) { + scaleX *= (float) canvasWidth / initialWidth; + } + + if (initialHeight > 0) { + scaleY *= (float) canvasHeight / initialHeight; + } + + scaleX *= (float) canvas.getWidth() / canvasWidth; + scaleY *= (float) canvas.getHeight() / canvasHeight; Matrix matrix = new Matrix(); matrix.setScale(scaleX, scaleY); - for (int i = 0; i < this.historyPointer; i++) { - Path path = this.pathLists.get(i); - Paint paint = this.paintLists.get(i); + for (int i = 0; i < historyPointer; i++) { + Path path = pathLists.get(i); + Paint paint = paintLists.get(i); Path scaledPath = new Path(); path.transform(matrix, scaledPath); @@ -785,4 +818,64 @@ public class CanvasView extends View { return colors; } + + private float scaleX(float x) { + return ((float) initialWidth / canvasWidth) * x; + } + + private float scaleY(float y) { + return ((float) initialWidth / canvasWidth) * y; + } + + static class SavedState { + private final List paths; + private final List paints; + private final int historyPointer; + private final int initialWidth; + private final int initialHeight; + private final int canvasWidth; + private final int canvasHeight; + + SavedState(List paths, List paints, int historyPointer, int initialWidth, int initialHeight, int canvasWidth, int canvasHeight) { + this.paths = new ArrayList<>(paths); + this.paints = new ArrayList<>(paints); + this.historyPointer = historyPointer; + this.initialWidth = initialWidth; + this.initialHeight = initialHeight; + this.canvasWidth = canvasWidth; + this.canvasHeight = canvasHeight; + } + + List getPaths() { + return paths; + } + + List getPaints() { + return paints; + } + + int getHistoryPointer() { + return historyPointer; + } + + int getInitialWidth() { + return initialWidth; + } + + int getInitialHeight() { + return initialHeight; + } + + int getCanvasWidth() { + return canvasWidth; + } + + int getCanvasHeight() { + return canvasHeight; + } + + boolean isEmpty() { + return paths.size() <= 1; + } + } } \ No newline at end of file diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/MotionView.java b/src/org/thoughtcrime/securesms/scribbles/widget/MotionView.java index ea974e656b..3c4fe80968 100644 --- a/src/org/thoughtcrime/securesms/scribbles/widget/MotionView.java +++ b/src/org/thoughtcrime/securesms/scribbles/widget/MotionView.java @@ -49,6 +49,8 @@ import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.FrameLayout; +import com.annimon.stream.Stream; + import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.scribbles.multitouch.MoveGestureDetector; @@ -146,6 +148,17 @@ public class MotionView extends FrameLayout implements TextWatcher { updateUI(); } + public SavedState saveState() { + return new SavedState(entities); + } + + public void restoreState(@NonNull SavedState savedState) { + this.entities.clear(); + this.entities.addAll(savedState.getEntities()); + + postInvalidate(); + } + public void startEditing(TextEntity entity) { editText.setFocusableInTouchMode(true); editText.setFocusable(true); @@ -224,7 +237,7 @@ public class MotionView extends FrameLayout implements TextWatcher { @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); - drawAllEntities(canvas); + render(canvas, entities); } public void render(Canvas canvas) { @@ -232,11 +245,7 @@ public class MotionView extends FrameLayout implements TextWatcher { draw(canvas); } - /** - * draws all entities on the canvas - * @param canvas Canvas where to draw all entities - */ - private void drawAllEntities(Canvas canvas) { + public static void render(Canvas canvas, List entities) { for (int i = 0; i < entities.size(); i++) { entities.get(i).draw(canvas, null); } @@ -254,7 +263,7 @@ public class MotionView extends FrameLayout implements TextWatcher { // which doesn't have transparent pixels, the background will be black bmp.eraseColor(Color.WHITE); Canvas canvas = new Canvas(bmp); - drawAllEntities(canvas); + render(canvas, entities); return bmp; } @@ -494,4 +503,21 @@ public class MotionView extends FrameLayout implements TextWatcher { } } + static class SavedState { + + private final List entities; + + SavedState(List entities) { + this.entities = new ArrayList<>(entities); + Stream.of(entities).forEach(e -> e.setIsSelected(false)); + } + + List getEntities() { + return entities; + } + + boolean isEmpty() { + return entities.isEmpty(); + } + } } diff --git a/src/org/thoughtcrime/securesms/scribbles/widget/ScribbleView.java b/src/org/thoughtcrime/securesms/scribbles/widget/ScribbleView.java index ed1009b6e8..2986dc1f8b 100644 --- a/src/org/thoughtcrime/securesms/scribbles/widget/ScribbleView.java +++ b/src/org/thoughtcrime/securesms/scribbles/widget/ScribbleView.java @@ -29,7 +29,6 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.AttributeSet; import android.view.MotionEvent; -import android.view.View; import android.widget.FrameLayout; import android.widget.ImageView; @@ -93,8 +92,15 @@ public class ScribbleView extends FrameLayout { } public @NonNull ListenableFuture getRenderedImage(@NonNull GlideRequests glideRequests) { + return renderImage(getContext(), imageUri, saveState(), glideRequests); + } + + public static @NonNull ListenableFuture renderImage(@NonNull Context context, + @Nullable Uri imageUri, + @NonNull SavedState savedState, + @NonNull GlideRequests glideRequests) + { final SettableFuture future = new SettableFuture<>(); - final Context context = getContext(); final boolean isLowMemory = Util.isLowMemory(context); if (imageUri == null) { @@ -119,8 +125,15 @@ public class ScribbleView extends FrameLayout { @Override public void onResourceReady(@NonNull Bitmap bitmap, @Nullable Transition transition) { Canvas canvas = new Canvas(bitmap); - motionView.render(canvas); - canvasView.render(canvas); + MotionView.render(canvas, savedState.getMotionState().getEntities()); + CanvasView.render(canvas, + savedState.getCanvasState().getInitialWidth(), + savedState.getCanvasState().getInitialHeight(), + savedState.getCanvasState().getCanvasWidth(), + savedState.getCanvasState().getCanvasHeight(), + savedState.getCanvasState().getPaths(), + savedState.getCanvasState().getPaints(), + savedState.getCanvasState().getHistoryPointer()); future.set(bitmap); } @@ -128,11 +141,20 @@ public class ScribbleView extends FrameLayout { public void onLoadFailed(@Nullable Drawable errorDrawable) { future.setException(new Throwable("Failed to load image.")); } - }); + }); return future; } + public SavedState saveState() { + return new SavedState(canvasView.saveState(), motionView.saveState()); + } + + public void restoreState(@NonNull SavedState state) { + canvasView.restoreState(state.getCanvasState()); + motionView.restoreState(state.getMotionState()); + } + private void initialize(@NonNull Context context) { inflate(context, R.layout.scribble_view, this); @@ -221,4 +243,26 @@ public class ScribbleView extends FrameLayout { public interface DrawingChangedListener { void onDrawingChanged(); } + + public static class SavedState { + private final CanvasView.SavedState canvasState; + private final MotionView.SavedState motionState; + + SavedState(@NonNull CanvasView.SavedState canvasState, @NonNull MotionView.SavedState motionState) { + this.canvasState = canvasState; + this.motionState = motionState; + } + + CanvasView.SavedState getCanvasState() { + return canvasState; + } + + MotionView.SavedState getMotionState() { + return motionState; + } + + public boolean isEmpty() { + return canvasState.isEmpty() && motionState.isEmpty(); + } + } } diff --git a/src/org/thoughtcrime/securesms/util/MediaUtil.java b/src/org/thoughtcrime/securesms/util/MediaUtil.java index 48cf29cc76..c09959c3f7 100644 --- a/src/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/src/org/thoughtcrime/securesms/util/MediaUtil.java @@ -239,6 +239,8 @@ public class MediaUtil { if ("com.android.providers.media.documents".equals(uri.getAuthority())) { return uri.getLastPathSegment().contains("video"); + } else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) { + return true; } return false; @@ -248,6 +250,13 @@ public class MediaUtil { if ("com.android.providers.media.documents".equals(uri.getAuthority())) { long videoId = Long.parseLong(uri.getLastPathSegment().split(":")[1]); + return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(), + videoId, + MediaStore.Images.Thumbnails.MINI_KIND, + null); + } else if (uri.toString().startsWith(MediaStore.Video.Media.EXTERNAL_CONTENT_URI.toString())) { + long videoId = Long.parseLong(uri.getLastPathSegment()); + return MediaStore.Video.Thumbnails.getThumbnail(context.getContentResolver(), videoId, MediaStore.Images.Thumbnails.MINI_KIND, diff --git a/src/org/thoughtcrime/securesms/util/ThemeUtil.java b/src/org/thoughtcrime/securesms/util/ThemeUtil.java index 83f77198f6..4fbda71cfe 100644 --- a/src/org/thoughtcrime/securesms/util/ThemeUtil.java +++ b/src/org/thoughtcrime/securesms/util/ThemeUtil.java @@ -5,7 +5,11 @@ import android.content.res.Resources; import android.graphics.Color; import android.support.annotation.AttrRes; import android.support.annotation.NonNull; +import android.support.annotation.StyleRes; +import android.support.v7.view.ContextThemeWrapper; import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; import org.thoughtcrime.securesms.R; @@ -25,6 +29,11 @@ public class ThemeUtil { return Color.RED; } + public static LayoutInflater getThemedInflater(@NonNull Context context, @NonNull LayoutInflater inflater, @StyleRes int theme) { + Context contextThemeWrapper = new ContextThemeWrapper(context, theme); + return inflater.cloneInContext(contextThemeWrapper); + } + private static String getAttribute(Context context, int attribute, String defaultValue) { TypedValue outValue = new TypedValue(); diff --git a/src/org/thoughtcrime/securesms/util/Util.java b/src/org/thoughtcrime/securesms/util/Util.java index 6ea310b338..46fa48d432 100644 --- a/src/org/thoughtcrime/securesms/util/Util.java +++ b/src/org/thoughtcrime/securesms/util/Util.java @@ -133,6 +133,10 @@ public class Util { return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed()); } + public static boolean isEmpty(Collection collection) { + return collection == null || collection.isEmpty(); + } + public static V getOrDefault(@NonNull Map map, K key, V defaultValue) { return map.containsKey(key) ? map.get(key) : defaultValue; }