1 /* 2 * Copyright (C) 2012 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.ui; 19 20 import android.app.SearchManager; 21 import android.app.SearchableInfo; 22 import android.content.ContentResolver; 23 import android.content.Context; 24 import android.database.Cursor; 25 import android.net.Uri; 26 import android.os.AsyncTask; 27 import android.os.Bundle; 28 import android.support.v4.view.MenuItemCompat; 29 import android.support.v7.app.ActionBar; 30 import android.support.v7.widget.SearchView; 31 import android.support.v7.widget.SearchView.OnQueryTextListener; 32 import android.support.v7.widget.SearchView.OnSuggestionListener; 33 import android.text.TextUtils; 34 import android.view.Menu; 35 import android.view.MenuItem; 36 37 import com.android.mail.ConversationListContext; 38 import com.android.mail.R; 39 import com.android.mail.providers.Account; 40 import com.android.mail.providers.AccountObserver; 41 import com.android.mail.providers.Conversation; 42 import com.android.mail.providers.Folder; 43 import com.android.mail.providers.FolderObserver; 44 import com.android.mail.providers.SearchRecentSuggestionsProvider; 45 import com.android.mail.providers.UIProvider; 46 import com.android.mail.providers.UIProvider.AccountCapabilities; 47 import com.android.mail.providers.UIProvider.FolderCapabilities; 48 import com.android.mail.providers.UIProvider.FolderType; 49 import com.android.mail.utils.LogTag; 50 import com.android.mail.utils.LogUtils; 51 import com.android.mail.utils.Utils; 52 53 /** 54 * Controller to manage the various states of the {@link android.app.ActionBar}. 55 */ 56 public class ActionBarController implements ViewMode.ModeChangeListener, 57 OnQueryTextListener, OnSuggestionListener, MenuItemCompat.OnActionExpandListener { 58 59 private final Context mContext; 60 61 protected ActionBar mActionBar; 62 protected ControllableActivity mActivity; 63 protected ActivityController mController; 64 /** 65 * The current mode of the ActionBar and Activity 66 */ 67 private ViewMode mViewModeController; 68 69 /** 70 * The account currently being shown 71 */ 72 private Account mAccount; 73 /** 74 * The folder currently being shown 75 */ 76 private Folder mFolder; 77 78 private SearchView mSearchWidget; 79 private MenuItem mSearch; 80 private MenuItem mEmptyTrashItem; 81 private MenuItem mEmptySpamItem; 82 83 /** True if the current device is a tablet, false otherwise. */ 84 protected final boolean mIsOnTablet; 85 private Conversation mCurrentConversation; 86 87 public static final String LOG_TAG = LogTag.getLogTag(); 88 89 private FolderObserver mFolderObserver; 90 91 /** Updates the resolver and tells it the most recent account. */ 92 private final class UpdateProvider extends AsyncTask<Bundle, Void, Void> { 93 final Uri mAccount; 94 final ContentResolver mResolver; 95 public UpdateProvider(Uri account, ContentResolver resolver) { 96 mAccount = account; 97 mResolver = resolver; 98 } 99 100 @Override 101 protected Void doInBackground(Bundle... params) { 102 mResolver.call(mAccount, UIProvider.AccountCallMethods.SET_CURRENT_ACCOUNT, 103 mAccount.toString(), params[0]); 104 return null; 105 } 106 } 107 108 private final AccountObserver mAccountObserver = new AccountObserver() { 109 @Override 110 public void onChanged(Account newAccount) { 111 updateAccount(newAccount); 112 } 113 }; 114 115 public ActionBarController(Context context) { 116 mContext = context; 117 mIsOnTablet = Utils.useTabletUI(context.getResources()); 118 } 119 120 public void expandSearch() { 121 if (mSearch != null) { 122 MenuItemCompat.expandActionView(mSearch); 123 } 124 } 125 126 /** 127 * Close the search view if it is expanded. 128 */ 129 public void collapseSearch() { 130 if (mSearch != null) { 131 MenuItemCompat.collapseActionView(mSearch); 132 } 133 } 134 135 /** 136 * Get the search menu item. 137 */ 138 protected MenuItem getSearch() { 139 return mSearch; 140 } 141 142 public boolean onCreateOptionsMenu(Menu menu) { 143 mEmptyTrashItem = menu.findItem(R.id.empty_trash); 144 mEmptySpamItem = menu.findItem(R.id.empty_spam); 145 mSearch = menu.findItem(R.id.search); 146 147 if (mSearch != null) { 148 mSearchWidget = (SearchView) MenuItemCompat.getActionView(mSearch); 149 MenuItemCompat.setOnActionExpandListener(mSearch, this); 150 SearchManager searchManager = (SearchManager) mActivity.getActivityContext() 151 .getSystemService(Context.SEARCH_SERVICE); 152 if (searchManager != null && mSearchWidget != null) { 153 SearchableInfo info = searchManager.getSearchableInfo(mActivity.getComponentName()); 154 mSearchWidget.setSearchableInfo(info); 155 mSearchWidget.setOnQueryTextListener(this); 156 mSearchWidget.setOnSuggestionListener(this); 157 mSearchWidget.setIconifiedByDefault(true); 158 } 159 } 160 161 // the menu should be displayed if the mode is known 162 return getMode() != ViewMode.UNKNOWN; 163 } 164 165 public int getOptionsMenuId() { 166 switch (getMode()) { 167 case ViewMode.UNKNOWN: 168 return R.menu.conversation_list_menu; 169 case ViewMode.CONVERSATION: 170 return R.menu.conversation_actions; 171 case ViewMode.CONVERSATION_LIST: 172 return R.menu.conversation_list_menu; 173 case ViewMode.SEARCH_RESULTS_LIST: 174 return R.menu.conversation_list_search_results_actions; 175 case ViewMode.SEARCH_RESULTS_CONVERSATION: 176 return R.menu.conversation_actions; 177 case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION: 178 return R.menu.wait_mode_actions; 179 } 180 LogUtils.wtf(LOG_TAG, "Menu requested for unknown view mode"); 181 return R.menu.conversation_list_menu; 182 } 183 184 public void initialize(ControllableActivity activity, ActivityController callback, 185 ActionBar actionBar) { 186 mActionBar = actionBar; 187 mController = callback; 188 mActivity = activity; 189 190 mFolderObserver = new FolderObserver() { 191 @Override 192 public void onChanged(Folder newFolder) { 193 onFolderUpdated(newFolder); 194 } 195 }; 196 // Return values are purposely discarded. Initialization happens quite early, and we don't 197 // have a valid folder, or a valid list of accounts. 198 mFolderObserver.initialize(mController); 199 updateAccount(mAccountObserver.initialize(activity.getAccountController())); 200 } 201 202 private void updateAccount(Account account) { 203 final boolean accountChanged = mAccount == null || !mAccount.uri.equals(account.uri); 204 mAccount = account; 205 if (mAccount != null && accountChanged) { 206 final ContentResolver resolver = mActivity.getActivityContext().getContentResolver(); 207 final Bundle bundle = new Bundle(1); 208 bundle.putParcelable(UIProvider.SetCurrentAccountColumns.ACCOUNT, account); 209 final UpdateProvider updater = new UpdateProvider(mAccount.uri, resolver); 210 updater.execute(bundle); 211 setFolderAndAccount(); 212 } 213 } 214 215 /** 216 * Called by the owner of the ActionBar to change the current folder. 217 */ 218 public void setFolder(Folder folder) { 219 mFolder = folder; 220 setFolderAndAccount(); 221 } 222 223 public void onDestroy() { 224 if (mFolderObserver != null) { 225 mFolderObserver.unregisterAndDestroy(); 226 mFolderObserver = null; 227 } 228 mAccountObserver.unregisterAndDestroy(); 229 } 230 231 @Override 232 public void onViewModeChanged(int newMode) { 233 mActivity.supportInvalidateOptionsMenu(); 234 // Check if we are either on a phone, or in Conversation mode on tablet. For these, the 235 // recent folders is enabled. 236 switch (getMode()) { 237 case ViewMode.UNKNOWN: 238 break; 239 case ViewMode.CONVERSATION_LIST: 240 showNavList(); 241 break; 242 case ViewMode.SEARCH_RESULTS_CONVERSATION: 243 mActionBar.setDisplayHomeAsUpEnabled(true); 244 setEmptyMode(); 245 break; 246 case ViewMode.CONVERSATION: 247 case ViewMode.AD: 248 closeSearchField(); 249 mActionBar.setDisplayHomeAsUpEnabled(true); 250 setEmptyMode(); 251 break; 252 case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION: 253 // We want the user to be able to switch accounts while waiting for an account 254 // to sync. 255 showNavList(); 256 break; 257 } 258 } 259 260 /** 261 * Close the search query entry field to avoid keyboard events, and to restore the actionbar 262 * to non-search mode. 263 */ 264 private void closeSearchField() { 265 if (mSearch == null) { 266 return; 267 } 268 mSearch.collapseActionView(); 269 } 270 271 protected int getMode() { 272 if (mViewModeController != null) { 273 return mViewModeController.getMode(); 274 } else { 275 return ViewMode.UNKNOWN; 276 } 277 } 278 279 /** 280 * Helper function to ensure that the menu items that are prone to variable changes and race 281 * conditions are properly set to the correct visibility 282 */ 283 public void validateVolatileMenuOptionVisibility() { 284 if (mEmptyTrashItem != null) { 285 mEmptyTrashItem.setVisible(mAccount != null && mFolder != null 286 && mAccount.supportsCapability(AccountCapabilities.EMPTY_TRASH) 287 && mFolder.isTrash() && mFolder.totalCount > 0 288 && (mController.getConversationListCursor() == null 289 || mController.getConversationListCursor().getCount() > 0)); 290 } 291 if (mEmptySpamItem != null) { 292 mEmptySpamItem.setVisible(mAccount != null && mFolder != null 293 && mAccount.supportsCapability(AccountCapabilities.EMPTY_SPAM) 294 && mFolder.isType(FolderType.SPAM) && mFolder.totalCount > 0 295 && (mController.getConversationListCursor() == null 296 || mController.getConversationListCursor().getCount() > 0)); 297 } 298 } 299 300 public boolean onPrepareOptionsMenu(Menu menu) { 301 // We start out with every option enabled. Based on the current view, we disable actions 302 // that are possible. 303 LogUtils.d(LOG_TAG, "ActionBarView.onPrepareOptionsMenu()."); 304 305 if (mController.shouldHideMenuItems()) { 306 // Shortcut: hide all menu items if the drawer is shown 307 final int size = menu.size(); 308 309 for (int i = 0; i < size; i++) { 310 final MenuItem item = menu.getItem(i); 311 item.setVisible(false); 312 } 313 return false; 314 } 315 validateVolatileMenuOptionVisibility(); 316 317 switch (getMode()) { 318 case ViewMode.CONVERSATION: 319 case ViewMode.SEARCH_RESULTS_CONVERSATION: 320 // We update the ActionBar options when we are entering conversation view because 321 // waiting for the AbstractConversationViewFragment to do it causes duplicate icons 322 // to show up during the time between the conversation is selected and the fragment 323 // is added. 324 setConversationModeOptions(menu); 325 break; 326 case ViewMode.CONVERSATION_LIST: 327 // Show search if the account supports it 328 Utils.setMenuItemVisibility(menu, R.id.search, mAccount.supportsSearch()); 329 break; 330 case ViewMode.SEARCH_RESULTS_LIST: 331 // Hide compose and search 332 Utils.setMenuItemVisibility(menu, R.id.compose, false); 333 Utils.setMenuItemVisibility(menu, R.id.search, false); 334 break; 335 } 336 337 return false; 338 } 339 340 /** 341 * Put the ActionBar in List navigation mode. 342 */ 343 private void showNavList() { 344 setTitleModeFlags(ActionBar.DISPLAY_SHOW_TITLE); 345 setFolderAndAccount(); 346 } 347 348 private void setTitle(String title) { 349 if (!TextUtils.equals(title, mActionBar.getTitle())) { 350 mActionBar.setTitle(title); 351 } 352 } 353 354 /** 355 * Set the actionbar mode to empty: no title, no subtitle, no custom view. 356 */ 357 protected void setEmptyMode() { 358 // Disable title/subtitle and the custom view by setting the bitmask to all off. 359 setTitleModeFlags(0); 360 } 361 362 /** 363 * Removes the back button from being shown 364 */ 365 public void removeBackButton() { 366 if (mActionBar == null) { 367 return; 368 } 369 // Remove the back button but continue showing an icon. 370 final int mask = ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME; 371 mActionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_HOME, mask); 372 mActionBar.setHomeButtonEnabled(false); 373 } 374 375 public void setBackButton() { 376 if (mActionBar == null) { 377 return; 378 } 379 // Show home as up, and show an icon. 380 final int mask = ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME; 381 mActionBar.setDisplayOptions(mask, mask); 382 mActionBar.setHomeButtonEnabled(true); 383 } 384 385 @Override 386 public boolean onQueryTextSubmit(String query) { 387 if (mSearch != null) { 388 MenuItemCompat.collapseActionView(mSearch); 389 mSearchWidget.setQuery("", false); 390 } 391 mController.executeSearch(query.trim()); 392 return true; 393 } 394 395 @Override 396 public boolean onQueryTextChange(String newText) { 397 return false; 398 } 399 400 // Next two methods are called when search suggestions are clicked. 401 @Override 402 public boolean onSuggestionSelect(int position) { 403 return onSuggestionClick(position); 404 } 405 406 @Override 407 public boolean onSuggestionClick(int position) { 408 final Cursor c = mSearchWidget.getSuggestionsAdapter().getCursor(); 409 final boolean haveValidQuery = (c != null) && c.moveToPosition(position); 410 if (!haveValidQuery) { 411 LogUtils.d(LOG_TAG, "onSuggestionClick: Couldn't get a search query"); 412 // We haven't handled this query, but the default behavior will 413 // leave EXTRA_ACCOUNT un-populated, leading to a crash. So claim 414 // that we have handled the event. 415 return true; 416 } 417 collapseSearch(); 418 // what is in the text field 419 String queryText = mSearchWidget.getQuery().toString(); 420 // What the suggested query is 421 String query = c.getString(c.getColumnIndex(SearchManager.SUGGEST_COLUMN_TEXT_1)); 422 // If the text the user typed in is a prefix of what is in the search 423 // widget suggestion query, just take the search widget suggestion 424 // query. Otherwise, it is a suffix and we want to remove matching 425 // prefix portions. 426 if (!TextUtils.isEmpty(queryText) && query.indexOf(queryText) != 0) { 427 final int queryTokenIndex = queryText 428 .lastIndexOf(SearchRecentSuggestionsProvider.QUERY_TOKEN_SEPARATOR); 429 if (queryTokenIndex > -1) { 430 queryText = queryText.substring(0, queryTokenIndex); 431 } 432 // Since we auto-complete on each token in a query, if the query the 433 // user typed up until the last token is a substring of the 434 // suggestion they click, make sure we don't double include the 435 // query text. For example: 436 // user types john, that matches john palo alto 437 // User types john p, that matches john john palo alto 438 // Remove the first john 439 // Only do this if we have multiple query tokens. 440 if (queryTokenIndex > -1 && !TextUtils.isEmpty(query) && query.contains(queryText) 441 && queryText.length() < query.length()) { 442 int start = query.indexOf(queryText); 443 query = query.substring(0, start) + query.substring(start + queryText.length()); 444 } 445 } 446 mController.executeSearch(query.trim()); 447 return true; 448 } 449 450 /** 451 * Uses the current state to update the current folder {@link #mFolder} and the current 452 * account {@link #mAccount} shown in the actionbar. Also updates the actionbar subtitle to 453 * momentarily display the unread count if it has changed. 454 */ 455 private void setFolderAndAccount() { 456 // Very little can be done if the actionbar or activity is null. 457 if (mActionBar == null || mActivity == null) { 458 return; 459 } 460 if (ViewMode.isWaitingForSync(getMode())) { 461 // Account is not synced: clear title and update the subtitle. 462 setTitle(""); 463 return; 464 } 465 // Check if we should be changing the actionbar at all, and back off if not. 466 final boolean isShowingFolder = mIsOnTablet || ViewMode.isListMode(getMode()); 467 if (!isShowingFolder) { 468 // It isn't necessary to set the title in this case, as the title view will 469 // be hidden 470 return; 471 } 472 if (mFolder == null) { 473 // Clear the action bar title. We don't want the app name to be shown while 474 // waiting for the folder query to finish 475 setTitle(""); 476 return; 477 } 478 setTitle(mFolder.name); 479 } 480 481 482 /** 483 * Notify that the folder has changed. 484 */ 485 public void onFolderUpdated(Folder folder) { 486 if (folder == null) { 487 return; 488 } 489 /** True if we are changing folders. */ 490 final boolean changingFolders = (mFolder == null || !mFolder.equals(folder)); 491 mFolder = folder; 492 setFolderAndAccount(); 493 final ConversationListContext listContext = mController == null ? null : 494 mController.getCurrentListContext(); 495 if (changingFolders && !ConversationListContext.isSearchResult(listContext)) { 496 closeSearchField(); 497 } 498 // make sure that we re-validate the optional menu items 499 validateVolatileMenuOptionVisibility(); 500 } 501 502 @Override 503 public boolean onMenuItemActionExpand(MenuItem item) { 504 // Do nothing. Required as part of the interface, we ar only interested in 505 // onMenuItemActionCollapse(MenuItem). 506 // Have to return true here. Unlike other callbacks, the return value here is whether 507 // we want to suppress the action (rather than consume the action). We don't want to 508 // suppress the action. 509 return true; 510 } 511 512 @Override 513 public boolean onMenuItemActionCollapse(MenuItem item) { 514 // Have to return true here. Unlike other callbacks, the return value 515 // here is whether we want to suppress the action (rather than consume the action). We 516 // don't want to suppress the action. 517 return true; 518 } 519 520 /** 521 * Sets the actionbar mode: Pass it an integer which contains each of these values, perhaps 522 * OR'd together: {@link ActionBar#DISPLAY_SHOW_CUSTOM} and 523 * {@link ActionBar#DISPLAY_SHOW_TITLE}. To disable all, pass a zero. 524 * @param enabledFlags 525 */ 526 private void setTitleModeFlags(int enabledFlags) { 527 final int mask = ActionBar.DISPLAY_SHOW_TITLE | ActionBar.DISPLAY_SHOW_CUSTOM; 528 mActionBar.setDisplayOptions(enabledFlags, mask); 529 } 530 531 public void setCurrentConversation(Conversation conversation) { 532 mCurrentConversation = conversation; 533 } 534 535 //We need to do this here instead of in the fragment 536 public void setConversationModeOptions(Menu menu) { 537 if (mCurrentConversation == null) { 538 return; 539 } 540 final boolean showMarkImportant = !mCurrentConversation.isImportant(); 541 Utils.setMenuItemVisibility(menu, R.id.mark_important, showMarkImportant 542 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 543 Utils.setMenuItemVisibility(menu, R.id.mark_not_important, !showMarkImportant 544 && mAccount.supportsCapability(UIProvider.AccountCapabilities.MARK_IMPORTANT)); 545 final boolean isOutbox = mFolder.isType(FolderType.OUTBOX); 546 final boolean showDiscardOutbox = mFolder != null && isOutbox && 547 mCurrentConversation.sendingState == UIProvider.ConversationSendingState.SEND_ERROR; 548 Utils.setMenuItemVisibility(menu, R.id.discard_outbox, showDiscardOutbox); 549 final boolean showDelete = !isOutbox && mFolder != null && 550 mFolder.supportsCapability(UIProvider.FolderCapabilities.DELETE); 551 Utils.setMenuItemVisibility(menu, R.id.delete, showDelete); 552 // We only want to show the discard drafts menu item if we are not showing the delete menu 553 // item, and the current folder is a draft folder and the account supports discarding 554 // drafts for a conversation 555 final boolean showDiscardDrafts = !showDelete && mFolder != null && mFolder.isDraft() && 556 mAccount.supportsCapability(AccountCapabilities.DISCARD_CONVERSATION_DRAFTS); 557 Utils.setMenuItemVisibility(menu, R.id.discard_drafts, showDiscardDrafts); 558 final boolean archiveVisible = mAccount.supportsCapability(AccountCapabilities.ARCHIVE) 559 && mFolder != null && mFolder.supportsCapability(FolderCapabilities.ARCHIVE) 560 && !mFolder.isTrash(); 561 Utils.setMenuItemVisibility(menu, R.id.archive, archiveVisible); 562 Utils.setMenuItemVisibility(menu, R.id.remove_folder, !archiveVisible && mFolder != null 563 && mFolder.supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 564 && !mFolder.isProviderFolder() 565 && mAccount.supportsCapability(AccountCapabilities.ARCHIVE)); 566 Utils.setMenuItemVisibility(menu, R.id.move_to, mFolder != null 567 && mFolder.supportsCapability(FolderCapabilities.ALLOWS_REMOVE_CONVERSATION)); 568 Utils.setMenuItemVisibility(menu, R.id.move_to_inbox, mFolder != null 569 && mFolder.supportsCapability(FolderCapabilities.ALLOWS_MOVE_TO_INBOX)); 570 Utils.setMenuItemVisibility(menu, R.id.change_folders, mAccount.supportsCapability( 571 UIProvider.AccountCapabilities.MULTIPLE_FOLDERS_PER_CONV)); 572 573 final MenuItem removeFolder = menu.findItem(R.id.remove_folder); 574 if (mFolder != null && removeFolder != null) { 575 removeFolder.setTitle(mActivity.getApplicationContext().getString( 576 R.string.remove_folder, mFolder.name)); 577 } 578 Utils.setMenuItemVisibility(menu, R.id.report_spam, 579 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 580 && mFolder.supportsCapability(FolderCapabilities.REPORT_SPAM) 581 && !mCurrentConversation.spam); 582 Utils.setMenuItemVisibility(menu, R.id.mark_not_spam, 583 mAccount.supportsCapability(AccountCapabilities.REPORT_SPAM) && mFolder != null 584 && mFolder.supportsCapability(FolderCapabilities.MARK_NOT_SPAM) 585 && mCurrentConversation.spam); 586 Utils.setMenuItemVisibility(menu, R.id.report_phishing, 587 mAccount.supportsCapability(AccountCapabilities.REPORT_PHISHING) && mFolder != null 588 && mFolder.supportsCapability(FolderCapabilities.REPORT_PHISHING) 589 && !mCurrentConversation.phishing); 590 Utils.setMenuItemVisibility(menu, R.id.mute, 591 mAccount.supportsCapability(AccountCapabilities.MUTE) && mFolder != null 592 && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE) 593 && !mCurrentConversation.muted); 594 } 595 596 public void setViewModeController(ViewMode viewModeController) { 597 mViewModeController = viewModeController; 598 mViewModeController.addListener(this); 599 } 600 601 public Context getContext() { 602 return mContext; 603 } 604 } 605