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.
This commit is contained in:
Greyson Parrelli 2018-03-15 11:17:40 -07:00 committed by Moxie Marlinspike
parent 0c768a24e4
commit 10e5b24cfd
8 changed files with 282 additions and 55 deletions

View File

@ -11,4 +11,18 @@
android:layout_height="match_parent"
android:contentDescription="@string/media_preview_activity__media_content_description" />
<FrameLayout
android:id="@+id/selected_indicator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/MediaOverview_Media_selected_overlay"
android:visibility="gone">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/check"
android:layout_gravity="center"/>
</FrameLayout>
</org.thoughtcrime.securesms.components.SquareFrameLayout>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/delete"
android:title="@string/delete"
android:icon="@drawable/ic_delete_white_24dp"
app:showAsAction="always"/>
</menu>

View File

@ -40,4 +40,6 @@
<color name="sticker_selected_color">#8cf437</color>
<color name="transparent">#00FFFFFF</color>
<color name="MediaOverview_Media_selected_overlay">#88000000</color>
</resources>

View File

@ -401,6 +401,16 @@
<!-- MediaOverviewActivity -->
<string name="MediaOverviewActivity_Media">Media</string>
<plurals name="MediaOverviewActivity_Media_delete_confirm_title">
<item quantity="one">Delete selected message?</item>
<item quantity="other">Delete selected messages?</item>
</plurals>
<plurals name="MediaOverviewActivity_Media_delete_confirm_message">
<item quantity="one">This will permanently delete the selected message.</item>
<item quantity="other">This will permanently delete all %1$d selected messages.</item>
</plurals>
<string name="MediaOverviewActivity_Media_delete_progress_title">Deleting</string>
<string name="MediaOverviewActivity_Media_delete_progress_message">Deleting messages...</string>
<string name="MediaOverviewActivity_Documents">Documents</string>
<!--- NotificationBarManager -->

View File

@ -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<MediaRecord> 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<MediaRecord> getSelectedMedia() {
return new HashSet<>(selected);
}
public void clearSelection() {
selected.clear();
notifyDataSetChanged();
}
interface ItemClickListener {
void onMediaClicked(@NonNull MediaRecord mediaRecord);
void onMediaLongClicked(MediaRecord mediaRecord);
}
}

View File

@ -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<BucketedThreadMedia> {
public static class MediaOverviewGalleryFragment
extends MediaOverviewFragment<BucketedThreadMedia>
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<BucketedThreadMedia> 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<MediaDatabase.MediaRecord> 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<MediaDatabase.MediaRecord, Void, Void>(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<Cursor> {

View File

@ -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();

View File

@ -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) ||