From 10e5b24cfdc34914881fad713c899245b584aca9 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 15 Mar 2018 11:17:40 -0700 Subject: [PATCH] Allow batch deletion of media. It is now possible to batch-delete media in the "media overview" screen. You can long press to enter multi-select mode. Then a delete button appears on the menu bar. After pressing delete, you will get a confirmation, and if the user confirms, the items will delete while a progres dialog shows. --- res/layout/media_overview_gallery_item.xml | 14 ++ res/menu/media_overview_context.xml | 7 + res/values/colors.xml | 2 + res/values/strings.xml | 10 + .../securesms/MediaGalleryAdapter.java | 93 +++++----- .../securesms/MediaOverviewActivity.java | 171 +++++++++++++++++- .../securesms/MediaPreviewActivity.java | 15 +- .../securesms/util/AttachmentUtil.java | 25 +++ 8 files changed, 282 insertions(+), 55 deletions(-) create mode 100644 res/menu/media_overview_context.xml diff --git a/res/layout/media_overview_gallery_item.xml b/res/layout/media_overview_gallery_item.xml index 5c649bdcbb..44112d8af9 100644 --- a/res/layout/media_overview_gallery_item.xml +++ b/res/layout/media_overview_gallery_item.xml @@ -11,4 +11,18 @@ android:layout_height="match_parent" android:contentDescription="@string/media_preview_activity__media_content_description" /> + + + + + diff --git a/res/menu/media_overview_context.xml b/res/menu/media_overview_context.xml new file mode 100644 index 0000000000..4c3c26f781 --- /dev/null +++ b/res/menu/media_overview_context.xml @@ -0,0 +1,7 @@ + + + + diff --git a/res/values/colors.xml b/res/values/colors.xml index 55b19f38f2..f506215986 100644 --- a/res/values/colors.xml +++ b/res/values/colors.xml @@ -40,4 +40,6 @@ #8cf437 #00FFFFFF + + #88000000 diff --git a/res/values/strings.xml b/res/values/strings.xml index eb80d38108..65803f27a2 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -401,6 +401,16 @@ Media + + Delete selected message? + Delete selected messages? + + + This will permanently delete the selected message. + This will permanently delete all %1$d selected messages. + + Deleting + Deleting messages... Documents diff --git a/src/org/thoughtcrime/securesms/MediaGalleryAdapter.java b/src/org/thoughtcrime/securesms/MediaGalleryAdapter.java index 6942b1c069..526531e0de 100644 --- a/src/org/thoughtcrime/securesms/MediaGalleryAdapter.java +++ b/src/org/thoughtcrime/securesms/MediaGalleryAdapter.java @@ -17,7 +17,6 @@ package org.thoughtcrime.securesms; import android.content.Context; -import android.content.Intent; import android.support.annotation.NonNull; import android.view.LayoutInflater; import android.view.View; @@ -27,14 +26,16 @@ import android.widget.TextView; import com.codewaves.stickyheadergrid.StickyHeaderGridAdapter; import org.thoughtcrime.securesms.components.ThumbnailView; -import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.util.MediaUtil; +import java.util.Collection; +import java.util.HashSet; import java.util.Locale; +import java.util.Set; class MediaGalleryAdapter extends StickyHeaderGridAdapter { @@ -44,16 +45,19 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter { private final Context context; private final GlideRequests glideRequests; private final Locale locale; - private final Address address; + private final ItemClickListener itemClickListener; + private final Set selected; private BucketedThreadMedia media; private static class ViewHolder extends StickyHeaderGridAdapter.ItemViewHolder { ThumbnailView imageView; + View selectedIndicator; ViewHolder(View v) { super(v); - imageView = v.findViewById(R.id.image); + imageView = v.findViewById(R.id.image); + selectedIndicator = v.findViewById(R.id.selected_indicator); } } @@ -66,14 +70,18 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter { } } - MediaGalleryAdapter(@NonNull Context context, @NonNull GlideRequests glideRequests, - BucketedThreadMedia media, Locale locale, Address address) + MediaGalleryAdapter(@NonNull Context context, + @NonNull GlideRequests glideRequests, + BucketedThreadMedia media, + Locale locale, + ItemClickListener clickListener) { - this.context = context; - this.glideRequests = glideRequests; - this.locale = locale; - this.media = media; - this.address = address; + this.context = context; + this.glideRequests = glideRequests; + this.locale = locale; + this.media = media; + this.itemClickListener = clickListener; + this.selected = new HashSet<>(); } public void setMedia(BucketedThreadMedia media) { @@ -97,16 +105,22 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter { @Override public void onBindItemViewHolder(ItemViewHolder viewHolder, int section, int offset) { - MediaRecord mediaRecord = media.get(section, offset); - ThumbnailView thumbnailView = ((ViewHolder)viewHolder).imageView; - - Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); + MediaRecord mediaRecord = media.get(section, offset); + ThumbnailView thumbnailView = ((ViewHolder)viewHolder).imageView; + View selectedIndicator = ((ViewHolder)viewHolder).selectedIndicator; + Slide slide = MediaUtil.getSlideForAttachment(context, mediaRecord.getAttachment()); if (slide != null) { thumbnailView.setImageResource(glideRequests, slide, false, false); } - thumbnailView.setOnClickListener(new OnMediaClickListener(mediaRecord)); + thumbnailView.setOnClickListener(view -> itemClickListener.onMediaClicked(mediaRecord)); + thumbnailView.setOnLongClickListener(view -> { + itemClickListener.onMediaLongClicked(mediaRecord); + return true; + }); + + selectedIndicator.setVisibility(selected.contains(mediaRecord) ? View.VISIBLE : View.GONE); } @Override @@ -119,32 +133,29 @@ class MediaGalleryAdapter extends StickyHeaderGridAdapter { return media.getSectionItemCount(section); } - private class OnMediaClickListener implements View.OnClickListener { - - private final MediaRecord mediaRecord; - - private OnMediaClickListener(MediaRecord mediaRecord) { - this.mediaRecord = mediaRecord; - } - - @Override - public void onClick(View v) { - if (mediaRecord.getAttachment().getDataUri() != null) { - Intent intent = new Intent(context, MediaPreviewActivity.class); - intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate()); - intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize()); - intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, address); - intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing()); - intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true); - - if (mediaRecord.getAddress() != null) { - intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, mediaRecord.getAddress()); - } - - intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType()); - context.startActivity(intent); - } + public void toggleSelection(@NonNull MediaRecord mediaRecord) { + if (!selected.remove(mediaRecord)) { + selected.add(mediaRecord); } + notifyDataSetChanged(); } + public int getSelectedMediaCount() { + return selected.size(); + } + + @NonNull + public Collection getSelectedMedia() { + return new HashSet<>(selected); + } + + public void clearSelection() { + selected.clear(); + notifyDataSetChanged(); + } + + interface ItemClickListener { + void onMediaClicked(@NonNull MediaRecord mediaRecord); + void onMediaLongClicked(MediaRecord mediaRecord); + } } diff --git a/src/org/thoughtcrime/securesms/MediaOverviewActivity.java b/src/org/thoughtcrime/securesms/MediaOverviewActivity.java index 8905c8d2eb..fe8270a195 100644 --- a/src/org/thoughtcrime/securesms/MediaOverviewActivity.java +++ b/src/org/thoughtcrime/securesms/MediaOverviewActivity.java @@ -16,8 +16,13 @@ */ package org.thoughtcrime.securesms; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; import android.content.res.Configuration; +import android.content.res.Resources; import android.database.Cursor; +import android.os.Build; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.design.widget.TabLayout; @@ -27,31 +32,40 @@ import android.support.v4.app.FragmentStatePagerAdapter; import android.support.v4.app.LoaderManager; import android.support.v4.content.Loader; import android.support.v4.view.ViewPager; +import android.support.v7.app.AlertDialog; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.view.ActionMode; import android.support.v7.widget.DividerItemDecoration; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.Toolbar; import android.view.LayoutInflater; +import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.view.Window; import android.widget.TextView; import com.codewaves.stickyheadergrid.StickyHeaderGridLayoutManager; import org.thoughtcrime.securesms.database.Address; import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter; +import org.thoughtcrime.securesms.database.MediaDatabase; import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader; import org.thoughtcrime.securesms.database.loaders.BucketedThreadMediaLoader.BucketedThreadMedia; import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.AttachmentUtil; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.StickyHeaderDecoration; import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; +import java.util.Collection; import java.util.Locale; /** @@ -186,9 +200,14 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity } } - public static class MediaOverviewGalleryFragment extends MediaOverviewFragment { + public static class MediaOverviewGalleryFragment + extends MediaOverviewFragment + implements MediaGalleryAdapter.ItemClickListener + { private StickyHeaderGridLayoutManager gridManager; + private ActionMode actionMode; + private ActionModeCallback actionModeCallback = new ActionModeCallback(); @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { @@ -198,7 +217,11 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity this.noMedia = ViewUtil.findById(view, R.id.no_images); this.gridManager = new StickyHeaderGridLayoutManager(getResources().getInteger(R.integer.media_overview_cols)); - this.recyclerView.setAdapter(new MediaGalleryAdapter(getContext(), GlideApp.with(this), new BucketedThreadMedia(getContext()), locale, recipient.getAddress())); + this.recyclerView.setAdapter(new MediaGalleryAdapter(getContext(), + GlideApp.with(this), + new BucketedThreadMedia(getContext()), + locale, + this)); this.recyclerView.setLayoutManager(gridManager); this.recyclerView.setHasFixedSize(true); @@ -232,6 +255,150 @@ public class MediaOverviewActivity extends PassphraseRequiredActionBarActivity public void onLoaderReset(Loader cursorLoader) { ((MediaGalleryAdapter) recyclerView.getAdapter()).setMedia(new BucketedThreadMedia(getContext())); } + + @Override + public void onMediaClicked(@NonNull MediaDatabase.MediaRecord mediaRecord) { + if (actionMode != null) { + handleMediaMultiSelectClick(mediaRecord); + } else { + handleMediaPreviewClick(mediaRecord); + } + } + + private void handleMediaMultiSelectClick(@NonNull MediaDatabase.MediaRecord mediaRecord) { + MediaGalleryAdapter adapter = getListAdapter(); + + adapter.toggleSelection(mediaRecord); + if (adapter.getSelectedMediaCount() == 0) { + actionMode.finish(); + actionMode = null; + } else { + actionMode.setTitle(String.valueOf(adapter.getSelectedMediaCount())); + } + } + + private void handleMediaPreviewClick(@NonNull MediaDatabase.MediaRecord mediaRecord) { + if (mediaRecord.getAttachment().getDataUri() == null) { + return; + } + + Context context = getContext(); + if (context == null) { + return; + } + + Intent intent = new Intent(context, MediaPreviewActivity.class); + intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate()); + intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize()); + intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, recipient.getAddress()); + intent.putExtra(MediaPreviewActivity.OUTGOING_EXTRA, mediaRecord.isOutgoing()); + intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, true); + + if (mediaRecord.getAddress() != null) { + intent.putExtra(MediaPreviewActivity.ADDRESS_EXTRA, mediaRecord.getAddress()); + } + + intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType()); + context.startActivity(intent); + } + + @Override + public void onMediaLongClicked(MediaDatabase.MediaRecord mediaRecord) { + if (actionMode == null) { + ((MediaGalleryAdapter) recyclerView.getAdapter()).toggleSelection(mediaRecord); + recyclerView.getAdapter().notifyDataSetChanged(); + + actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(actionModeCallback); + } + } + + @SuppressLint("StaticFieldLeak") + private void handleDeleteMedia(@NonNull Collection mediaRecords) { + int recordCount = mediaRecords.size(); + Resources res = getContext().getResources(); + String confirmTitle = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_title, + recordCount, + recordCount); + String confirmMessage = res.getQuantityString(R.plurals.MediaOverviewActivity_Media_delete_confirm_message, + recordCount, + recordCount); + + AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); + builder.setIconAttribute(R.attr.dialog_alert_icon); + builder.setTitle(confirmTitle); + builder.setMessage(confirmMessage); + builder.setCancelable(true); + + builder.setPositiveButton(R.string.delete, (dialogInterface, i) -> { + new ProgressDialogAsyncTask(getContext(), + R.string.MediaOverviewActivity_Media_delete_progress_title, + R.string.MediaOverviewActivity_Media_delete_progress_message) + { + @Override + protected Void doInBackground(MediaDatabase.MediaRecord... records) { + if (records == null || records.length == 0) { + return null; + } + + for (MediaDatabase.MediaRecord record : records) { + AttachmentUtil.deleteAttachment(getContext(), record.getAttachment()); + } + return null; + } + + }.execute(mediaRecords.toArray(new MediaDatabase.MediaRecord[mediaRecords.size()])); + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + } + + private MediaGalleryAdapter getListAdapter() { + return (MediaGalleryAdapter) recyclerView.getAdapter(); + } + + private class ActionModeCallback implements ActionMode.Callback { + + private int originalStatusBarColor; + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + mode.getMenuInflater().inflate(R.menu.media_overview_context, menu); + mode.setTitle("1"); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + Window window = getActivity().getWindow(); + originalStatusBarColor = 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) { + switch (menuItem.getItemId()) { + case R.id.delete: + handleDeleteMedia(getListAdapter().getSelectedMedia()); + mode.finish(); + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + actionMode = null; + getListAdapter().clearSelection(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + getActivity().getWindow().setStatusBarColor(originalStatusBarColor); + } + } + } } public static class MediaOverviewDocumentsFragment extends MediaOverviewFragment { diff --git a/src/org/thoughtcrime/securesms/MediaPreviewActivity.java b/src/org/thoughtcrime/securesms/MediaPreviewActivity.java index 976c3580ad..2290fc054e 100644 --- a/src/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/src/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientModifiedListener; +import org.thoughtcrime.securesms.util.AttachmentUtil; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.DynamicLanguage; import org.thoughtcrime.securesms.util.SaveAttachmentTask; @@ -277,18 +278,8 @@ public class MediaPreviewActivity extends PassphraseRequiredActionBarActivity im if (mediaItem.attachment == null) { return null; } - Context context = MediaPreviewActivity.this.getApplicationContext(); - AttachmentId attachmentId = mediaItem.attachment.getAttachmentId(); - long mmsId = mediaItem.attachment.getMmsId(); - int attachmentCount = DatabaseFactory.getAttachmentDatabase(context) - .getAttachmentsForMessage(mmsId) - .size(); - - if (attachmentCount <= 1) { - DatabaseFactory.getMmsDatabase(context).delete(mmsId); - } else { - DatabaseFactory.getAttachmentDatabase(context).deleteAttachment(attachmentId); - } + AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(), + mediaItem.attachment); return null; } }.execute(); diff --git a/src/org/thoughtcrime/securesms/util/AttachmentUtil.java b/src/org/thoughtcrime/securesms/util/AttachmentUtil.java index 2851670cc1..4975594913 100644 --- a/src/org/thoughtcrime/securesms/util/AttachmentUtil.java +++ b/src/org/thoughtcrime/securesms/util/AttachmentUtil.java @@ -6,10 +6,14 @@ import android.net.ConnectivityManager; import android.net.NetworkInfo; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.annotation.WorkerThread; import android.text.TextUtils; import android.util.Log; import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.attachments.AttachmentId; +import org.thoughtcrime.securesms.attachments.DatabaseAttachment; +import org.thoughtcrime.securesms.database.DatabaseFactory; import java.util.Collections; import java.util.Set; @@ -36,6 +40,27 @@ public class AttachmentUtil { } } + /** + * Deletes the specified attachment. If its the only attachment for its linked message, the entire + * message is deleted. + */ + @WorkerThread + public static void deleteAttachment(@NonNull Context context, + @NonNull DatabaseAttachment attachment) + { + AttachmentId attachmentId = attachment.getAttachmentId(); + long mmsId = attachment.getMmsId(); + int attachmentCount = DatabaseFactory.getAttachmentDatabase(context) + .getAttachmentsForMessage(mmsId) + .size(); + + if (attachmentCount <= 1) { + DatabaseFactory.getMmsDatabase(context).delete(mmsId); + } else { + DatabaseFactory.getAttachmentDatabase(context).deleteAttachment(attachmentId); + } + } + private static boolean isNonDocumentType(String contentType) { return MediaUtil.isImageType(contentType) ||