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