Support for multi-select in the conversation list.

// FREEBIE

Closes #1601
Closes #2214

Fixes #2188
Fixes #786
This commit is contained in:
Moxie Marlinspike 2014-12-13 18:10:59 -08:00
parent ebf6a2d833
commit ed556fbd3a
5 changed files with 227 additions and 114 deletions

View File

@ -118,7 +118,7 @@
<string name="ConversationFragment_transport_s_sent_received_s">Transport: %1$s\nSent/Received: %2$s</string>
<string name="ConversationFragment_sender_s_transport_s_sent_s_received_s">Sender: %1$s\nTransport: %2$s\nSent: %3$s\nReceived: %4$s</string>
<string name="ConversationFragment_confirm_message_delete">Confirm message delete</string>
<string name="ConversationFragment_are_you_sure_you_want_to_permanently_delete_this_message">Are you sure that you want to permanently delete this message?</string>
<string name="ConversationFragment_are_you_sure_you_want_to_permanently_delete_all_selected_messages">Are you sure that you want to permanently delete all selected messages?</string>
<string name="ConversationFragment_save_to_sd_card">Save to storage?</string>
<string name="ConversationFragment_this_media_has_been_stored_in_an_encrypted_database_warning">Saving this media to storage will allow any other apps on your phone to access it.\n\nContinue?</string>
<string name="ConversationFragment_error_while_saving_attachment_to_sd_card">Error while saving attachment to storage!</string>
@ -130,6 +130,8 @@
<string name="ConversationFragment_push">PUSH</string>
<string name="ConversationFragment_mms">MMS</string>
<string name="ConversationFragment_sms">SMS</string>
<string name="ConversationFragment_deleting">Deleting...</string>
<string name="ConversationFragment_deleting_messages">Deleting messages...</string>
<!-- ConversationListFragment -->
<string name="ConversationListFragment_delete_threads_question">Delete threads?</string>

View File

@ -35,7 +35,11 @@ import org.thoughtcrime.securesms.util.LRUCache;
import java.lang.ref.SoftReference;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.thoughtcrime.securesms.ConversationFragment.SelectionClickListener;
/**
* A cursor adapter for a conversation thread. Ultimately
@ -55,19 +59,23 @@ public class ConversationAdapter extends CursorAdapter implements AbsListView.Re
public static final int MESSAGE_TYPE_INCOMING = 1;
public static final int MESSAGE_TYPE_GROUP_ACTION = 2;
private final Handler failedIconClickHandler;
private final Context context;
private final MasterSecret masterSecret;
private final boolean groupThread;
private final boolean pushDestination;
private final LayoutInflater inflater;
private final Set<MessageRecord> batchSelected = Collections.synchronizedSet(new HashSet<MessageRecord>());
public ConversationAdapter(Context context, MasterSecret masterSecret,
private final SelectionClickListener selectionClickListener;
private final Handler failedIconClickHandler;
private final Context context;
private final MasterSecret masterSecret;
private final boolean groupThread;
private final boolean pushDestination;
private final LayoutInflater inflater;
public ConversationAdapter(Context context, MasterSecret masterSecret, SelectionClickListener selectionClickListener,
Handler failedIconClickHandler, boolean groupThread, boolean pushDestination)
{
super(context, null, 0);
this.context = context;
this.masterSecret = masterSecret;
this.selectionClickListener = selectionClickListener;
this.failedIconClickHandler = failedIconClickHandler;
this.groupThread = groupThread;
this.pushDestination = pushDestination;
@ -81,7 +89,8 @@ public class ConversationAdapter extends CursorAdapter implements AbsListView.Re
String type = cursor.getString(cursor.getColumnIndexOrThrow(MmsSmsDatabase.TRANSPORT));
MessageRecord messageRecord = getMessageRecord(id, cursor, type);
item.set(masterSecret, messageRecord, failedIconClickHandler, groupThread, pushDestination);
item.set(masterSecret, messageRecord, batchSelected, selectionClickListener,
failedIconClickHandler, groupThread, pushDestination);
}
@Override
@ -158,6 +167,18 @@ public class ConversationAdapter extends CursorAdapter implements AbsListView.Re
this.getCursor().close();
}
public void toggleBatchSelected(MessageRecord messageRecord) {
if (batchSelected.contains(messageRecord)) {
batchSelected.remove(messageRecord);
} else {
batchSelected.add(messageRecord);
}
}
public Set<MessageRecord> getBatchSelected() {
return batchSelected;
}
@Override
public void onMovedToScrapHeap(View view) {
((ConversationItem)view).unbind();

View File

@ -40,17 +40,23 @@ import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DirectoryHelper;
import org.thoughtcrime.securesms.util.FutureTaskListener;
import org.thoughtcrime.securesms.util.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
import java.sql.Date;
import java.text.SimpleDateFormat;
import java.util.LinkedList;
import java.util.List;
public class ConversationFragment extends ListFragment
implements LoaderManager.LoaderCallbacks<Cursor>
{
private static final String TAG = ConversationFragment.class.getSimpleName();
private final ActionModeCallback actionModeCallback = new ActionModeCallback();
private final SelectionClickListener selectionClickListener = new SelectionClickListener();
private ConversationFragmentListener listener;
private MasterSecret masterSecret;
@ -96,7 +102,7 @@ public class ConversationFragment extends ListFragment
private void initializeListAdapter() {
if (this.recipients != null && this.threadId != -1) {
this.setListAdapter(new ConversationAdapter(getActivity(), masterSecret,
this.setListAdapter(new ConversationAdapter(getActivity(), masterSecret, selectionClickListener,
new FailedIconClickHandler(),
(!this.recipients.isSingleRecipient()) || this.recipients.isGroupRecipient(),
DirectoryHelper.isPushDestination(getActivity(), this.recipients)));
@ -106,49 +112,50 @@ public class ConversationFragment extends ListFragment
}
private void initializeContextualActionBar() {
getListView().setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
@Override
public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
if (actionMode != null) {
view.setSelected(true);
return false;
}
actionMode = ((ActionBarActivity)getActivity()).startSupportActionMode(actionModeCallback);
view.setSelected(true);
return true;
}
});
getListView().setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
if (actionMode != null) {
view.setSelected(true);
setCorrectMenuVisibility(getMessageRecord(), actionMode.getMenu());
}
}
});
getListView().setOnItemClickListener(selectionClickListener);
getListView().setOnItemLongClickListener(selectionClickListener);
}
private void setCorrectMenuVisibility(MessageRecord messageRecord, Menu menu) {
MenuItem resend = menu.findItem(R.id.menu_context_resend);
MenuItem saveAttachment = menu.findItem(R.id.menu_context_save_attachment);
private void setCorrectMenuVisibility(Menu menu) {
ConversationAdapter adapter = (ConversationAdapter) getListAdapter();
List<MessageRecord> messageRecords = getSelectedMessageRecords();
if (messageRecord.isFailed()) resend.setVisible(true);
else resend.setVisible(false);
if (actionMode != null && messageRecords.size() == 0) {
adapter.getBatchSelected().clear();
adapter.notifyDataSetChanged();
actionMode.finish();
return;
}
if (messageRecord.isMms() && !messageRecord.isMmsNotification()) {
saveAttachment.setVisible(((MediaMmsMessageRecord)messageRecord).containsMediaSlide());
if (messageRecords.size() > 1) {
menu.findItem(R.id.menu_context_forward).setVisible(false);
menu.findItem(R.id.menu_context_copy).setVisible(false);
menu.findItem(R.id.menu_context_details).setVisible(false);
menu.findItem(R.id.menu_context_save_attachment).setVisible(false);
menu.findItem(R.id.menu_context_resend).setVisible(false);
} else {
saveAttachment.setVisible(false);
MessageRecord messageRecord = messageRecords.get(0);
menu.findItem(R.id.menu_context_resend).setVisible(messageRecord.isFailed());
menu.findItem(R.id.menu_context_save_attachment).setVisible(messageRecord.isMms() &&
!messageRecord.isMmsNotification() &&
((MediaMmsMessageRecord)messageRecord).containsMediaSlide());
menu.findItem(R.id.menu_context_forward).setVisible(true);
menu.findItem(R.id.menu_context_details).setVisible(true);
menu.findItem(R.id.menu_context_copy).setVisible(true);
}
}
private MessageRecord getMessageRecord() {
Cursor cursor = ((CursorAdapter)getListAdapter()).getCursor();
ConversationItem conversationItem = (ConversationItem)(((ConversationAdapter)getListAdapter()).newView(getActivity(), cursor, null));
return conversationItem.getMessageRecord();
private MessageRecord getSelectedMessageRecord() {
List<MessageRecord> messageRecords = getSelectedMessageRecords();
if (messageRecords.size() == 1) return messageRecords.get(0);
else throw new AssertionError();
}
private List<MessageRecord> getSelectedMessageRecords() {
return new LinkedList<>(((ConversationAdapter)getListAdapter()).getBatchSelected());
}
public void reload(Recipients recipients, long threadId) {
@ -177,23 +184,32 @@ public class ConversationFragment extends ListFragment
clipboard.setText(body);
}
private void handleDeleteMessage(final MessageRecord message) {
final long messageId = message.getId();
private void handleDeleteMessages(final List<MessageRecord> messageRecords) {
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity());
builder.setTitle(R.string.ConversationFragment_confirm_message_delete);
builder.setIcon(Dialogs.resolveIcon(getActivity(), R.attr.dialog_alert_icon));
builder.setCancelable(true);
builder.setMessage(R.string.ConversationFragment_are_you_sure_you_want_to_permanently_delete_this_message);
builder.setMessage(R.string.ConversationFragment_are_you_sure_you_want_to_permanently_delete_all_selected_messages);
builder.setPositiveButton(R.string.yes, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
if (message.isMms()) {
DatabaseFactory.getMmsDatabase(getActivity()).delete(messageId);
} else {
DatabaseFactory.getSmsDatabase(getActivity()).deleteMessage(messageId);
}
new ProgressDialogAsyncTask<MessageRecord, Void, Void>(getActivity(),
R.string.ConversationFragment_deleting,
R.string.ConversationFragment_deleting_messages)
{
@Override
protected Void doInBackground(MessageRecord... messageRecords) {
for (MessageRecord messageRecord : messageRecords) {
if (messageRecord.isMms()) {
DatabaseFactory.getMmsDatabase(getActivity()).delete(messageRecord.getId());
} else {
DatabaseFactory.getSmsDatabase(getActivity()).deleteMessage(messageRecord.getId());
}
}
return null;
}
}.execute(messageRecords.toArray(new MessageRecord[messageRecords.size()]));
}
});
@ -312,16 +328,43 @@ public class ConversationFragment extends ListFragment
public void setComposeText(String text);
}
private ActionMode.Callback actionModeCallback = new ActionMode.Callback() {
public class SelectionClickListener
implements AdapterView.OnItemLongClickListener, AdapterView.OnItemClickListener
{
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
if (actionMode != null && view instanceof ConversationItem) {
MessageRecord messageRecord = ((ConversationItem)view).getMessageRecord();
((ConversationAdapter) getListAdapter()).toggleBatchSelected(messageRecord);
((ConversationAdapter) getListAdapter()).notifyDataSetChanged();
setCorrectMenuVisibility(actionMode.getMenu());
}
}
@Override
public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
if (actionMode == null && view instanceof ConversationItem) {
MessageRecord messageRecord = ((ConversationItem)view).getMessageRecord();
((ConversationAdapter) getListAdapter()).toggleBatchSelected(messageRecord);
((ConversationAdapter) getListAdapter()).notifyDataSetChanged();
actionMode = ((ActionBarActivity)getActivity()).startSupportActionMode(actionModeCallback);
return true;
}
return false;
}
}
private class ActionModeCallback implements ActionMode.Callback {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuInflater inflater = mode.getMenuInflater();
inflater.inflate(R.menu.conversation_context, menu);
MessageRecord messageRecord = getMessageRecord();
setCorrectMenuVisibility(messageRecord, menu);
setCorrectMenuVisibility(menu);
return true;
}
@ -332,41 +375,37 @@ public class ConversationFragment extends ListFragment
@Override
public void onDestroyActionMode(ActionMode mode) {
if (getListView() != null && getListView().getChildCount() > 0) {
for (int i = 0; i < getListView().getChildCount(); i++){
getListView().getChildAt(i).setSelected(false);
}
}
((ConversationAdapter)getListAdapter()).getBatchSelected().clear();
((ConversationAdapter)getListAdapter()).notifyDataSetChanged();
actionMode = null;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
MessageRecord messageRecord = getMessageRecord();
switch(item.getItemId()) {
case R.id.menu_context_copy:
handleCopyMessage(messageRecord);
handleCopyMessage(getSelectedMessageRecord());
actionMode.finish();
return true;
case R.id.menu_context_delete_message:
handleDeleteMessage(messageRecord);
handleDeleteMessages(getSelectedMessageRecords());
actionMode.finish();
return true;
case R.id.menu_context_details:
handleDisplayDetails(messageRecord);
handleDisplayDetails(getSelectedMessageRecord());
actionMode.finish();
return true;
case R.id.menu_context_forward:
handleForwardMessage(messageRecord);
handleForwardMessage(getSelectedMessageRecord());
actionMode.finish();
return true;
case R.id.menu_context_resend:
handleResendMessage(messageRecord);
handleResendMessage(getSelectedMessageRecord());
actionMode.finish();
return true;
case R.id.menu_context_save_attachment:
handleSaveAttachment((MediaMmsMessageRecord)messageRecord);
handleSaveAttachment((MediaMmsMessageRecord)getSelectedMessageRecord());
actionMode.finish();
return true;
}

View File

@ -25,10 +25,8 @@ import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.os.Handler;
import android.os.Message;
import android.provider.Contacts.Intents;
import android.provider.ContactsContract;
import android.provider.ContactsContract.QuickContact;
import android.util.AttributeSet;
@ -40,9 +38,10 @@ import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import org.thoughtcrime.securesms.ConversationFragment.SelectionClickListener;
import org.thoughtcrime.securesms.contacts.ContactPhotoFactory;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.contacts.ContactPhotoFactory;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
@ -61,6 +60,8 @@ import org.thoughtcrime.securesms.util.Emoji;
import org.thoughtcrime.securesms.util.FutureTaskListener;
import org.thoughtcrime.securesms.util.ListenableFutureTask;
import java.util.Set;
/**
* A view that displays an individual conversation item within a conversation
* thread. Used by ComposeMessageActivity's ListActivity via a ConversationAdapter.
@ -81,13 +82,13 @@ public class ConversationItem extends LinearLayout {
R.attr.conversation_item_sent_push_pending_background,
R.attr.conversation_item_sent_push_pending_triangle_background};
private final static int SENT_PUSH = 0;
private final static int SENT_PUSH_TRIANGLE = 1;
private final static int SENT_SMS = 2;
private final static int SENT_SMS_TRIANGLE = 3;
private final static int SENT_SMS_PENDING = 4;
private final static int SENT_SMS_PENDING_TRIANGLE = 5;
private final static int SENT_PUSH_PENDING = 6;
private final static int SENT_PUSH = 0;
private final static int SENT_PUSH_TRIANGLE = 1;
private final static int SENT_SMS = 2;
private final static int SENT_SMS_TRIANGLE = 3;
private final static int SENT_SMS_PENDING = 4;
private final static int SENT_SMS_PENDING_TRIANGLE = 5;
private final static int SENT_PUSH_PENDING = 6;
private final static int SENT_PUSH_PENDING_TRIANGLE = 7;
private Handler failedIconHandler;
@ -108,19 +109,21 @@ public class ConversationItem extends LinearLayout {
private View triangleTick;
private ImageView pendingIndicator;
private View mmsContainer;
private ImageView mmsThumbnail;
private Button mmsDownloadButton;
private TextView mmsDownloadingLabel;
private ListenableFutureTask<SlideDeck> slideDeck;
private FutureTaskListener<SlideDeck> slideDeckListener;
private TypedArray backgroundDrawables;
private Set<MessageRecord> batchSelected;
private SelectionClickListener selectionClickListener;
private View mmsContainer;
private ImageView mmsThumbnail;
private Button mmsDownloadButton;
private TextView mmsDownloadingLabel;
private ListenableFutureTask<SlideDeck> slideDeck;
private FutureTaskListener<SlideDeck> slideDeckListener;
private TypedArray backgroundDrawables;
private final FailedIconClickListener failedIconClickListener = new FailedIconClickListener();
private final MmsDownloadClickListener mmsDownloadClickListener = new MmsDownloadClickListener();
private final FailedIconClickListener failedIconClickListener = new FailedIconClickListener();
private final MmsDownloadClickListener mmsDownloadClickListener = new MmsDownloadClickListener();
private final MmsPreferencesClickListener mmsPreferencesClickListener = new MmsPreferencesClickListener();
private final ClickListener clickListener = new ClickListener();
private final Handler handler = new Handler();
private final ClickListener clickListener = new ClickListener();
private final Handler handler = new Handler();
private final Context context;
public ConversationItem(Context context) {
@ -157,19 +160,23 @@ public class ConversationItem extends LinearLayout {
setOnClickListener(clickListener);
if (failedImage != null) failedImage.setOnClickListener(failedIconClickListener);
if (mmsDownloadButton != null) mmsDownloadButton.setOnClickListener(mmsDownloadClickListener);
if (mmsThumbnail != null) mmsThumbnail.setOnLongClickListener(new MultiSelectLongClickListener());
}
public void set(MasterSecret masterSecret, MessageRecord messageRecord,
Set<MessageRecord> batchSelected, SelectionClickListener selectionClickListener,
Handler failedIconHandler, boolean groupThread, boolean pushDestination)
{
this.masterSecret = masterSecret;
this.messageRecord = messageRecord;
this.batchSelected = batchSelected;
this.selectionClickListener = selectionClickListener;
this.failedIconHandler = failedIconHandler;
this.groupThread = groupThread;
this.pushDestination = pushDestination;
this.messageRecord = messageRecord;
this.masterSecret = masterSecret;
this.failedIconHandler = failedIconHandler;
this.groupThread = groupThread;
this.pushDestination = pushDestination;
setBackgroundDrawables(messageRecord);
setConversationBackgroundDrawables(messageRecord);
setSelectionBackgroundDrawables(messageRecord);
setBodyText(messageRecord);
if (!messageRecord.isGroupAction()) {
@ -211,33 +218,57 @@ public class ConversationItem extends LinearLayout {
/// MessageRecord Attribute Parsers
private void setBackgroundDrawables(MessageRecord messageRecord) {
private void setConversationBackgroundDrawables(MessageRecord messageRecord) {
if (conversationParent != null && backgroundDrawables != null) {
if (messageRecord.isOutgoing()) {
final int background;
final int triangleBackground;
if (messageRecord.isPending() && pushDestination && !messageRecord.isForcedSms()) {
background = SENT_PUSH_PENDING;
background = SENT_PUSH_PENDING;
triangleBackground = SENT_PUSH_PENDING_TRIANGLE;
} else if (messageRecord.isPending() || messageRecord.isPendingSmsFallback()) {
background = SENT_SMS_PENDING;
background = SENT_SMS_PENDING;
triangleBackground = SENT_SMS_PENDING_TRIANGLE;
} else if (messageRecord.isPush()) {
background = SENT_PUSH;
background = SENT_PUSH;
triangleBackground = SENT_PUSH_TRIANGLE;
} else {
background = SENT_SMS;
background = SENT_SMS;
triangleBackground = SENT_SMS_TRIANGLE;
}
setViewBackgroundWithoutResettingPadding(conversationParent, backgroundDrawables.getResourceId(background, -1));
setViewBackgroundWithoutResettingPadding(triangleTick, backgroundDrawables.getResourceId(triangleBackground, -1));
}
}
}
private void setSelectionBackgroundDrawables(MessageRecord messageRecord) {
int[] attributes = new int[]{R.attr.conversation_list_item_background_selected,
R.attr.conversation_item_background};
TypedArray drawables = context.obtainStyledAttributes(attributes);
if (batchSelected.contains(messageRecord)) {
setBackgroundDrawable(drawables.getDrawable(0));
} else {
setBackgroundDrawable(drawables.getDrawable(1));
}
drawables.recycle();
}
private void setBodyText(MessageRecord messageRecord) {
bodyText.setText(Emoji.getInstance(context).emojify(messageRecord.getDisplayBody(), new Emoji.InvalidatingPageLoadedListener(bodyText)),
TextView.BufferType.SPANNABLE);
bodyText.setClickable(false);
bodyText.setFocusable(false);
bodyText.setText(Emoji.getInstance(context).emojify(messageRecord.getDisplayBody(),
new Emoji.InvalidatingPageLoadedListener(bodyText)),
TextView.BufferType.SPANNABLE);
if (bodyText.isClickable() && bodyText.isFocusable()) {
bodyText.setOnLongClickListener(new MultiSelectLongClickListener());
bodyText.setOnClickListener(new MultiSelectLongClickListener());
}
}
private void setContactPhoto(MessageRecord messageRecord) {
@ -365,12 +396,6 @@ public class ConversationItem extends LinearLayout {
if (slide.hasImage()) {
slide.setThumbnailOn(mmsThumbnail);
mmsThumbnail.setOnClickListener(new ThumbnailClickListener(slide));
mmsThumbnail.setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
return false;
}
});
mmsThumbnail.setVisibility(View.VISIBLE);
return;
}
@ -474,7 +499,9 @@ public class ConversationItem extends LinearLayout {
}
public void onClick(View v) {
if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType())) {
if (!batchSelected.isEmpty()) {
selectionClickListener.onItemClick(null, ConversationItem.this, -1, -1);
} else if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType())) {
Intent intent = new Intent(context, MediaPreviewActivity.class);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(slide.getUri(), slide.getContentType());
@ -545,6 +572,19 @@ public class ConversationItem extends LinearLayout {
}
}
private class MultiSelectLongClickListener implements OnLongClickListener, OnClickListener {
@Override
public boolean onLongClick(View view) {
selectionClickListener.onItemLongClick(null, ConversationItem.this, -1, -1);
return true;
}
@Override
public void onClick(View view) {
selectionClickListener.onItemClick(null, ConversationItem.this, -1, -1);
}
}
private void handleMessageApproval() {
final int title;
final int message;

View File

@ -182,4 +182,15 @@ public abstract class MessageRecord extends DisplayRecord {
return spannable;
}
public boolean equals(Object other) {
return other != null &&
other instanceof MessageRecord &&
((MessageRecord) other).getId() == getId() &&
((MessageRecord) other).isMms() == isMms();
}
public int hashCode() {
return (int)getId();
}
}