1 /* 2 * Copyright (C) 2010 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package com.android.mail.browse; 19 20 import android.content.Context; 21 import android.net.Uri; 22 import android.os.AsyncTask; 23 import android.support.v7.view.ActionMode; 24 import android.view.Menu; 25 import android.view.MenuInflater; 26 import android.view.MenuItem; 27 import android.widget.Toast; 28 29 import com.android.mail.R; 30 import com.android.mail.analytics.Analytics; 31 import com.android.mail.providers.Account; 32 import com.android.mail.providers.AccountObserver; 33 import com.android.mail.providers.Conversation; 34 import com.android.mail.providers.Folder; 35 import com.android.mail.providers.MailAppProvider; 36 import com.android.mail.providers.Settings; 37 import com.android.mail.providers.UIProvider; 38 import com.android.mail.providers.UIProvider.AccountCapabilities; 39 import com.android.mail.providers.UIProvider.ConversationColumns; 40 import com.android.mail.providers.UIProvider.FolderCapabilities; 41 import com.android.mail.providers.UIProvider.FolderType; 42 import com.android.mail.ui.ControllableActivity; 43 import com.android.mail.ui.ConversationCheckedSet; 44 import com.android.mail.ui.ConversationListCallbacks; 45 import com.android.mail.ui.ConversationSetObserver; 46 import com.android.mail.ui.ConversationUpdater; 47 import com.android.mail.ui.DestructiveAction; 48 import com.android.mail.ui.FolderOperation; 49 import com.android.mail.ui.FolderSelectionDialog; 50 import com.android.mail.utils.LogTag; 51 import com.android.mail.utils.LogUtils; 52 import com.android.mail.utils.Utils; 53 import com.google.common.annotations.VisibleForTesting; 54 import com.google.common.collect.Lists; 55 56 import java.util.Collection; 57 import java.util.List; 58 59 /** 60 * A component that displays a custom view for an {@code ActionBar}'s {@code 61 * ContextMode} specific to operating on a set of conversations. 62 */ 63 public class SelectedConversationsActionMenu implements ActionMode.Callback, 64 ConversationSetObserver { 65 66 private static final String LOG_TAG = LogTag.getLogTag(); 67 68 /** 69 * The set of conversations to display the menu for. 70 */ 71 protected final ConversationCheckedSet mCheckedSet; 72 73 private final ControllableActivity mActivity; 74 private final ConversationListCallbacks mListController; 75 /** 76 * Context of the activity. A dialog requires the context of an activity rather than the global 77 * root context of the process. So mContext = mActivity.getApplicationContext() will fail. 78 */ 79 private final Context mContext; 80 81 @VisibleForTesting 82 private ActionMode mActionMode; 83 84 private boolean mActivated = false; 85 86 /** Object that can update conversation state on our behalf. */ 87 private final ConversationUpdater mUpdater; 88 89 private Account mAccount; 90 91 private final Folder mFolder; 92 93 private AccountObserver mAccountObserver; 94 95 private MenuItem mDiscardOutboxMenuItem; 96 SelectedConversationsActionMenu( ControllableActivity activity, ConversationCheckedSet checkedSet, Folder folder)97 public SelectedConversationsActionMenu( 98 ControllableActivity activity, ConversationCheckedSet checkedSet, Folder folder) { 99 mActivity = activity; 100 mListController = activity.getListHandler(); 101 mCheckedSet = checkedSet; 102 mAccountObserver = new AccountObserver() { 103 @Override 104 public void onChanged(Account newAccount) { 105 mAccount = newAccount; 106 } 107 }; 108 mAccount = mAccountObserver.initialize(activity.getAccountController()); 109 mFolder = folder; 110 mContext = mActivity.getActivityContext(); 111 mUpdater = activity.getConversationUpdater(); 112 } 113 onActionItemClicked(MenuItem item)114 public boolean onActionItemClicked(MenuItem item) { 115 return onActionItemClicked(mActionMode, item); 116 } 117 118 @Override onActionItemClicked(ActionMode mode, MenuItem item)119 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 120 boolean handled = true; 121 // If the user taps a new menu item, commit any existing destructive actions. 122 mListController.commitDestructiveActions(true); 123 final int itemId = item.getItemId(); 124 125 Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, itemId, 126 "cab_mode", 0); 127 128 UndoCallback undoCallback = null; // not applicable here (yet) 129 if (itemId == R.id.delete) { 130 LogUtils.i(LOG_TAG, "Delete selected from CAB menu"); 131 performDestructiveAction(R.id.delete, undoCallback); 132 } else if (itemId == R.id.discard_drafts) { 133 LogUtils.i(LOG_TAG, "Discard drafts selected from CAB menu"); 134 performDestructiveAction(R.id.discard_drafts, undoCallback); 135 } else if (itemId == R.id.discard_outbox) { 136 LogUtils.i(LOG_TAG, "Discard outbox selected from CAB menu"); 137 performDestructiveAction(R.id.discard_outbox, undoCallback); 138 } else if (itemId == R.id.archive) { 139 LogUtils.i(LOG_TAG, "Archive selected from CAB menu"); 140 performDestructiveAction(R.id.archive, undoCallback); 141 } else if (itemId == R.id.remove_folder) { 142 destroy(R.id.remove_folder, mCheckedSet.values(), 143 mUpdater.getDeferredRemoveFolder(mCheckedSet.values(), mFolder, true, 144 true, true, undoCallback)); 145 } else if (itemId == R.id.mute) { 146 destroy(R.id.mute, mCheckedSet.values(), mUpdater.getBatchAction(R.id.mute, 147 undoCallback)); 148 } else if (itemId == R.id.report_spam) { 149 destroy(R.id.report_spam, mCheckedSet.values(), 150 mUpdater.getBatchAction(R.id.report_spam, undoCallback)); 151 } else if (itemId == R.id.mark_not_spam) { 152 // Currently, since spam messages are only shown in list with other spam messages, 153 // marking a message not as spam is a destructive action 154 destroy (R.id.mark_not_spam, 155 mCheckedSet.values(), mUpdater.getBatchAction(R.id.mark_not_spam, 156 undoCallback)) ; 157 } else if (itemId == R.id.report_phishing) { 158 destroy(R.id.report_phishing, 159 mCheckedSet.values(), mUpdater.getBatchAction(R.id.report_phishing, 160 undoCallback)); 161 } else if (itemId == R.id.read) { 162 markConversationsRead(true); 163 } else if (itemId == R.id.unread) { 164 markConversationsRead(false); 165 } else if (itemId == R.id.star) { 166 starConversations(true); 167 } else if (itemId == R.id.toggle_read_unread) { 168 if (mActionMode != null) { 169 markConversationsRead(mActionMode.getMenu().findItem(R.id.read).isVisible()); 170 } 171 } else if (itemId == R.id.remove_star) { 172 if (mFolder.isType(UIProvider.FolderType.STARRED)) { 173 LogUtils.d(LOG_TAG, "We are in a starred folder, removing the star"); 174 performDestructiveAction(R.id.remove_star, undoCallback); 175 } else { 176 LogUtils.d(LOG_TAG, "Not in a starred folder."); 177 starConversations(false); 178 } 179 } else if (itemId == R.id.move_to || itemId == R.id.change_folders) { 180 boolean cantMove = false; 181 Account acct = mAccount; 182 // Special handling for virtual folders 183 if (mFolder.supportsCapability(FolderCapabilities.IS_VIRTUAL)) { 184 Uri accountUri = null; 185 for (Conversation conv: mCheckedSet.values()) { 186 if (accountUri == null) { 187 accountUri = conv.accountUri; 188 } else if (!accountUri.equals(conv.accountUri)) { 189 // Tell the user why we can't do this 190 Toast.makeText(mContext, R.string.cant_move_or_change_labels, 191 Toast.LENGTH_LONG).show(); 192 cantMove = true; 193 return handled; 194 } 195 } 196 if (!cantMove) { 197 // Get the actual account here, so that we display its folders in the dialog 198 acct = MailAppProvider.getAccountFromAccountUri(accountUri); 199 } 200 } 201 if (!cantMove) { 202 final FolderSelectionDialog dialog = FolderSelectionDialog.getInstance( 203 acct, mCheckedSet.values(), true, mFolder, 204 item.getItemId() == R.id.move_to); 205 if (dialog != null) { 206 dialog.show(mActivity.getFragmentManager(), null); 207 } 208 } 209 } else if (itemId == R.id.move_to_inbox) { 210 new AsyncTask<Void, Void, Folder>() { 211 @Override 212 protected Folder doInBackground(final Void... params) { 213 // Get the "move to" inbox 214 return Utils.getFolder(mContext, mAccount.settings.moveToInbox, 215 true /* allowHidden */); 216 } 217 218 @Override 219 protected void onPostExecute(final Folder moveToInbox) { 220 final List<FolderOperation> ops = Lists.newArrayListWithCapacity(1); 221 // Add inbox 222 ops.add(new FolderOperation(moveToInbox, true)); 223 mUpdater.assignFolder(ops, mCheckedSet.values(), true, 224 true /* showUndo */, false /* isMoveTo */); 225 } 226 }.execute((Void[]) null); 227 } else if (itemId == R.id.mark_important) { 228 markConversationsImportant(true); 229 } else if (itemId == R.id.mark_not_important) { 230 if (mFolder.supportsCapability(UIProvider.FolderCapabilities.ONLY_IMPORTANT)) { 231 performDestructiveAction(R.id.mark_not_important, undoCallback); 232 } else { 233 markConversationsImportant(false); 234 } 235 } else { 236 handled = false; 237 } 238 return handled; 239 } 240 241 /** 242 * Clear the selection and perform related UI changes to keep the state consistent. 243 */ clearChecked()244 private void clearChecked() { 245 mCheckedSet.clear(); 246 } 247 248 /** 249 * Update the underlying list adapter and redraw the menus if necessary. 250 */ updateSelection()251 private void updateSelection() { 252 mUpdater.refreshConversationList(); 253 if (mActionMode != null) { 254 // Calling mActivity.invalidateOptionsMenu doesn't have the correct behavior, since 255 // the action mode is not refreshed when activity's options menu is invalidated. 256 // Since we need to refresh our own menu, it is easy to call onPrepareActionMode 257 // directly. 258 onPrepareActionMode(mActionMode, mActionMode.getMenu()); 259 } 260 } 261 performDestructiveAction(final int action, UndoCallback undoCallback)262 private void performDestructiveAction(final int action, UndoCallback undoCallback) { 263 final Collection<Conversation> conversations = mCheckedSet.values(); 264 final Settings settings = mAccount.settings; 265 final boolean showDialog; 266 // no confirmation dialog by default unless user preference or common sense dictates one 267 if (action == R.id.discard_drafts) { 268 // drafts are lost forever, so always confirm 269 showDialog = true; 270 } else if (settings != null && (action == R.id.archive || action == R.id.delete)) { 271 showDialog = (action == R.id.delete) ? settings.confirmDelete : settings.confirmArchive; 272 } else { 273 showDialog = false; 274 } 275 if (showDialog) { 276 mUpdater.makeDialogListener(action, true /* fromSelectedSet */, null /* undoCallback */); 277 final int resId; 278 if (action == R.id.delete) { 279 resId = R.plurals.confirm_delete_conversation; 280 } else if (action == R.id.discard_drafts) { 281 resId = R.plurals.confirm_discard_drafts_conversation; 282 } else { 283 resId = R.plurals.confirm_archive_conversation; 284 } 285 final CharSequence message = Utils.formatPlural(mContext, resId, conversations.size()); 286 final ConfirmDialogFragment c = ConfirmDialogFragment.newInstance(message); 287 c.displayDialog(mActivity.getFragmentManager()); 288 } else { 289 // No need to show the dialog, just make a destructive action and destroy the 290 // selected set immediately. 291 // TODO(viki): Stop using the deferred action here. Use the registered action. 292 destroy(action, conversations, mUpdater.getDeferredBatchAction(action, undoCallback)); 293 } 294 } 295 296 /** 297 * Destroy these conversations through the conversation updater 298 * @param actionId the ID of the action: R.id.archive, R.id.delete, ... 299 * @param target conversations to destroy 300 * @param action the action that performs the destruction 301 */ destroy(int actionId, final Collection<Conversation> target, final DestructiveAction action)302 private void destroy(int actionId, final Collection<Conversation> target, 303 final DestructiveAction action) { 304 LogUtils.i(LOG_TAG, "About to remove %d converations", target.size()); 305 mUpdater.delete(actionId, target, action, true); 306 } 307 308 /** 309 * Marks the read state of currently selected conversations (<b>and</b> the backing storage) 310 * to the value provided here. 311 * @param read is true if the conversations are to be marked as read, false if they are to be 312 * marked unread. 313 */ markConversationsRead(boolean read)314 private void markConversationsRead(boolean read) { 315 final Collection<Conversation> targets = mCheckedSet.values(); 316 // The conversations are marked read but not viewed. 317 mUpdater.markConversationsRead(targets, read, false); 318 updateSelection(); 319 } 320 321 /** 322 * Marks the important state of currently selected conversations (<b>and</b> the backing 323 * storage) to the value provided here. 324 * @param important is true if the conversations are to be marked as important, false if they 325 * are to be marked not important. 326 */ markConversationsImportant(boolean important)327 private void markConversationsImportant(boolean important) { 328 final Collection<Conversation> target = mCheckedSet.values(); 329 final int priority = important ? UIProvider.ConversationPriority.HIGH 330 : UIProvider.ConversationPriority.LOW; 331 mUpdater.updateConversation(target, ConversationColumns.PRIORITY, priority); 332 // Update the conversations in the selection too. 333 for (final Conversation c : target) { 334 c.priority = priority; 335 } 336 updateSelection(); 337 } 338 339 /** 340 * Marks the selected conversations with the star setting provided here. 341 * @param star true if you want all the conversations to have stars, false if you want to remove 342 * stars from all conversations 343 */ starConversations(boolean star)344 private void starConversations(boolean star) { 345 final Collection<Conversation> target = mCheckedSet.values(); 346 mUpdater.updateConversation(target, ConversationColumns.STARRED, star); 347 // Update the conversations in the selection too. 348 for (final Conversation c : target) { 349 c.starred = star; 350 } 351 updateSelection(); 352 } 353 354 @Override onCreateActionMode(ActionMode mode, Menu menu)355 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 356 mCheckedSet.addObserver(this); 357 final MenuInflater inflater = mActivity.getMenuInflater(); 358 inflater.inflate(R.menu.conversation_list_selection_actions_menu, menu); 359 mActionMode = mode; 360 updateCount(); 361 return true; 362 } 363 364 @Override onPrepareActionMode(ActionMode mode, Menu menu)365 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 366 // Update the actionbar to select operations available on the current conversation. 367 final Collection<Conversation> conversations = mCheckedSet.values(); 368 boolean showStar = false; 369 boolean showMarkUnread = false; 370 boolean showMarkImportant = false; 371 boolean showMarkNotSpam = false; 372 boolean showMarkAsPhishing = false; 373 374 // TODO(shahrk): Clean up these dirty calls using Utils.setMenuItemPresent(...) or 375 // in another way 376 377 for (Conversation conversation : conversations) { 378 if (!conversation.starred) { 379 showStar = true; 380 } 381 if (conversation.read) { 382 showMarkUnread = true; 383 } 384 if (!conversation.isImportant()) { 385 showMarkImportant = true; 386 } 387 if (conversation.spam) { 388 showMarkNotSpam = true; 389 } 390 if (!conversation.phishing) { 391 showMarkAsPhishing = true; 392 } 393 if (showStar && showMarkUnread && showMarkImportant && showMarkNotSpam && 394 showMarkAsPhishing) { 395 break; 396 } 397 } 398 final boolean canStar = mFolder != null && !mFolder.isTrash(); 399 final MenuItem star = menu.findItem(R.id.star); 400 star.setVisible(showStar && canStar); 401 final MenuItem unstar = menu.findItem(R.id.remove_star); 402 unstar.setVisible(!showStar && canStar); 403 final MenuItem read = menu.findItem(R.id.read); 404 read.setVisible(!showMarkUnread); 405 final MenuItem unread = menu.findItem(R.id.unread); 406 unread.setVisible(showMarkUnread); 407 408 // We only ever show one of: 409 // 1) remove folder 410 // 2) archive 411 final MenuItem removeFolder = menu.findItem(R.id.remove_folder); 412 final MenuItem moveTo = menu.findItem(R.id.move_to); 413 final MenuItem moveToInbox = menu.findItem(R.id.move_to_inbox); 414 final boolean showRemoveFolder = mFolder != null && mFolder.isType(FolderType.DEFAULT) 415 && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 416 && !mFolder.isProviderFolder() 417 && mAccount.supportsCapability(AccountCapabilities.ARCHIVE); 418 final boolean showMoveTo = mFolder != null 419 && mFolder.supportsCapability(FolderCapabilities.ALLOWS_REMOVE_CONVERSATION); 420 final boolean showMoveToInbox = mFolder != null 421 && mFolder.supportsCapability(FolderCapabilities.ALLOWS_MOVE_TO_INBOX); 422 removeFolder.setVisible(showRemoveFolder); 423 moveTo.setVisible(showMoveTo); 424 moveToInbox.setVisible(showMoveToInbox); 425 426 final MenuItem changeFolders = menu.findItem(R.id.change_folders); 427 changeFolders.setVisible(mAccount.supportsCapability( 428 UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV)); 429 430 if (mFolder != null && showRemoveFolder) { 431 removeFolder.setTitle(mActivity.getActivityContext().getString(R.string.remove_folder, 432 mFolder.name)); 433 } 434 final MenuItem archive = menu.findItem(R.id.archive); 435 if (archive != null) { 436 archive.setVisible( 437 mAccount.supportsCapability(UIProvider.AccountCapabilities.ARCHIVE) && 438 mFolder.supportsCapability(FolderCapabilities.ARCHIVE)); 439 } 440 final MenuItem spam = menu.findItem(R.id.report_spam); 441 spam.setVisible(!showMarkNotSpam 442 && mAccount.supportsCapability(UIProvider.AccountCapabilities.REPORT_SPAM) 443 && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM)); 444 final MenuItem notSpam = menu.findItem(R.id.mark_not_spam); 445 notSpam.setVisible(showMarkNotSpam && 446 mAccount.supportsCapability(UIProvider.AccountCapabilities.REPORT_SPAM) && 447 mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM)); 448 final MenuItem phishing = menu.findItem(R.id.report_phishing); 449 phishing.setVisible(showMarkAsPhishing && 450 mAccount.supportsCapability(UIProvider.AccountCapabilities.REPORT_PHISHING) && 451 mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING)); 452 453 final MenuItem mute = menu.findItem(R.id.mute); 454 if (mute != null) { 455 mute.setVisible(mAccount.supportsCapability(UIProvider.AccountCapabilities.MUTE) 456 && (mFolder != null && mFolder.isInbox())); 457 } 458 final MenuItem markImportant = menu.findItem(R.id.mark_important); 459 markImportant.setVisible(showMarkImportant 460 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 461 final MenuItem markNotImportant = menu.findItem(R.id.mark_not_important); 462 markNotImportant.setVisible(!showMarkImportant 463 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 464 465 boolean shouldShowDiscardOutbox = mFolder != null && mFolder.isType(FolderType.OUTBOX); 466 mDiscardOutboxMenuItem = menu.findItem(R.id.discard_outbox); 467 if (mDiscardOutboxMenuItem != null) { 468 mDiscardOutboxMenuItem.setVisible(shouldShowDiscardOutbox); 469 } 470 final boolean showDelete = mFolder != null && !mFolder.isType(FolderType.OUTBOX) 471 && mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE); 472 final MenuItem trash = menu.findItem(R.id.delete); 473 trash.setVisible(showDelete); 474 // We only want to show the discard drafts menu item if we are not showing the delete menu 475 // item, and the current folder is a draft folder and the account supports discarding 476 // drafts for a conversation 477 final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() && 478 mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS); 479 final MenuItem discardDrafts = menu.findItem(R.id.discard_drafts); 480 if (discardDrafts != null) { 481 discardDrafts.setVisible(showDiscardDrafts); 482 } 483 484 return true; 485 } 486 487 @Override onDestroyActionMode(ActionMode mode)488 public void onDestroyActionMode(ActionMode mode) { 489 mActionMode = null; 490 // The action mode may have been destroyed due to this menu being deactivated, in which 491 // case resources need not be cleaned up. However, if it was destroyed while this menu is 492 // active, that implies the user hit "Done" in the top right, and resources need cleaning. 493 if (mActivated) { 494 destroy(); 495 // Only commit destructive actions if the user actually pressed 496 // done; otherwise, this was handled when we toggled conversation 497 // selection state. 498 mActivity.getListHandler().commitDestructiveActions(true); 499 } 500 } 501 502 @Override onSetPopulated(ConversationCheckedSet set)503 public void onSetPopulated(ConversationCheckedSet set) { 504 // Noop. This object can only exist while the set is non-empty. 505 } 506 507 @Override onSetEmpty()508 public void onSetEmpty() { 509 LogUtils.d(LOG_TAG, "onSetEmpty called."); 510 destroy(); 511 } 512 513 @Override onSetChanged(ConversationCheckedSet set)514 public void onSetChanged(ConversationCheckedSet set) { 515 // If the set is empty, the menu buttons are invalid and most like the menu will be cleaned 516 // up. Avoid making any changes to stop flickering ("Add Star" -> "Remove Star") just 517 // before hiding the menu. 518 if (set.isEmpty()) { 519 return; 520 } 521 updateCount(); 522 } 523 524 /** 525 * Updates the visible count of how many conversations are selected. 526 */ updateCount()527 private void updateCount() { 528 if (mActionMode != null) { 529 mActionMode.setTitle(String.format("%d", mCheckedSet.size())); 530 } 531 } 532 533 /** 534 * Activates and shows this menu (essentially starting an {@link ActionMode}) if the selected 535 * set is non-empty. 536 */ activate()537 public void activate() { 538 if (mCheckedSet.isEmpty()) { 539 return; 540 } 541 mListController.onCabModeEntered(); 542 mActivated = true; 543 if (mActionMode == null) { 544 mActivity.startSupportActionMode(this); 545 } 546 } 547 548 /** 549 * De-activates and hides the menu (essentially disabling the {@link ActionMode}), but maintains 550 * the selection conversation set, and internally updates state as necessary. 551 */ deactivate()552 public void deactivate() { 553 mListController.onCabModeExited(); 554 mActivated = false; 555 if (mActionMode != null) { 556 mActionMode.finish(); 557 } 558 } 559 560 @VisibleForTesting 561 /** 562 * Returns true if CAB mode is active. 563 */ isActivated()564 public boolean isActivated() { 565 return mActivated; 566 } 567 568 /** 569 * Destroys and cleans up the resources associated with this menu. 570 */ destroy()571 private void destroy() { 572 deactivate(); 573 mCheckedSet.removeObserver(this); 574 clearChecked(); 575 mUpdater.refreshConversationList(); 576 if (mAccountObserver != null) { 577 mAccountObserver.unregisterAndDestroy(); 578 mAccountObserver = null; 579 } 580 } 581 } 582