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