AbstractActivityController.java revision 9a70e919125d63971604e6dd3de8bc998e9c86da
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 18package com.android.mail.ui; 19 20import android.app.ActionBar; 21import android.app.ActionBar.LayoutParams; 22import android.app.Activity; 23import android.app.AlertDialog; 24import android.app.Dialog; 25import android.app.DialogFragment; 26import android.app.Fragment; 27import android.app.FragmentManager; 28import android.app.LoaderManager; 29import android.app.SearchManager; 30import android.content.ContentProviderOperation; 31import android.content.ContentResolver; 32import android.content.ContentValues; 33import android.content.Context; 34import android.content.CursorLoader; 35import android.content.DialogInterface; 36import android.content.Intent; 37import android.content.Loader; 38import android.database.Cursor; 39import android.database.DataSetObservable; 40import android.database.DataSetObserver; 41import android.net.Uri; 42import android.os.AsyncTask; 43import android.os.Bundle; 44import android.os.Handler; 45import android.provider.SearchRecentSuggestions; 46import android.view.DragEvent; 47import android.view.KeyEvent; 48import android.view.LayoutInflater; 49import android.view.Menu; 50import android.view.MenuInflater; 51import android.view.MenuItem; 52import android.view.MotionEvent; 53import android.view.View; 54import android.widget.Toast; 55 56import com.android.mail.ConversationListContext; 57import com.android.mail.R; 58import com.android.mail.browse.ConversationCursor; 59import com.android.mail.browse.ConversationPagerController; 60import com.android.mail.browse.MessageCursor.ConversationMessage; 61import com.android.mail.browse.SelectedConversationsActionMenu; 62import com.android.mail.browse.SyncErrorDialogFragment; 63import com.android.mail.compose.ComposeActivity; 64import com.android.mail.providers.Account; 65import com.android.mail.providers.Conversation; 66import com.android.mail.providers.ConversationInfo; 67import com.android.mail.providers.Folder; 68import com.android.mail.providers.FolderWatcher; 69import com.android.mail.providers.MailAppProvider; 70import com.android.mail.providers.Settings; 71import com.android.mail.providers.SuggestionsProvider; 72import com.android.mail.providers.UIProvider; 73import com.android.mail.providers.UIProvider.AccountCapabilities; 74import com.android.mail.providers.UIProvider.AccountCursorExtraKeys; 75import com.android.mail.providers.UIProvider.ConversationColumns; 76import com.android.mail.providers.UIProvider.FolderCapabilities; 77import com.android.mail.ui.ActionableToastBar.ActionClickedListener; 78import com.android.mail.utils.ContentProviderTask; 79import com.android.mail.utils.LogTag; 80import com.android.mail.utils.LogUtils; 81import com.android.mail.utils.Utils; 82import com.google.common.base.Objects; 83import com.google.common.collect.ImmutableList; 84import com.google.common.collect.Lists; 85import com.google.common.collect.Sets; 86 87import java.util.ArrayList; 88import java.util.Collection; 89import java.util.Collections; 90import java.util.HashMap; 91import java.util.Set; 92import java.util.TimerTask; 93 94 95/** 96 * This is an abstract implementation of the Activity Controller. This class 97 * knows how to respond to menu items, state changes, layout changes, etc. It 98 * weaves together the views and listeners, dispatching actions to the 99 * respective underlying classes. 100 * <p> 101 * Even though this class is abstract, it should provide default implementations 102 * for most, if not all the methods in the ActivityController interface. This 103 * makes the task of the subclasses easier: OnePaneActivityController and 104 * TwoPaneActivityController can be concise when the common functionality is in 105 * AbstractActivityController. 106 * </p> 107 * <p> 108 * In the Gmail codebase, this was called BaseActivityController 109 * </p> 110 */ 111public abstract class AbstractActivityController implements ActivityController { 112 // Keys for serialization of various information in Bundles. 113 /** Tag for {@link #mAccount} */ 114 private static final String SAVED_ACCOUNT = "saved-account"; 115 /** Tag for {@link #mFolder} */ 116 private static final String SAVED_FOLDER = "saved-folder"; 117 /** Tag for {@link #mCurrentConversation} */ 118 private static final String SAVED_CONVERSATION = "saved-conversation"; 119 /** Tag for {@link #mSelectedSet} */ 120 private static final String SAVED_SELECTED_SET = "saved-selected-set"; 121 private static final String SAVED_TOAST_BAR_OP = "saved-toast-bar-op"; 122 protected static final String SAVED_HIERARCHICAL_FOLDER = "saved-hierarchical-folder"; 123 /** Tag for {@link ConversationListContext#searchQuery} */ 124 private static final String SAVED_QUERY = "saved-query"; 125 126 /** Tag used when loading a wait fragment */ 127 protected static final String TAG_WAIT = "wait-fragment"; 128 /** Tag used when loading a conversation list fragment. */ 129 public static final String TAG_CONVERSATION_LIST = "tag-conversation-list"; 130 /** Tag used when loading a folder list fragment. */ 131 protected static final String TAG_FOLDER_LIST = "tag-folder-list"; 132 133 protected Account mAccount; 134 protected Folder mFolder; 135 protected MailActionBarView mActionBarView; 136 protected final ControllableActivity mActivity; 137 protected final Context mContext; 138 private final FragmentManager mFragmentManager; 139 protected final RecentFolderList mRecentFolderList; 140 protected ConversationListContext mConvListContext; 141 protected Conversation mCurrentConversation; 142 143 /** A {@link android.content.BroadcastReceiver} that suppresses new e-mail notifications. */ 144 private SuppressNotificationReceiver mNewEmailReceiver = null; 145 146 protected Handler mHandler = new Handler(); 147 148 /** 149 * The current mode of the application. All changes in mode are initiated by 150 * the activity controller. View mode changes are propagated to classes that 151 * attach themselves as listeners of view mode changes. 152 */ 153 protected final ViewMode mViewMode; 154 protected ContentResolver mResolver; 155 protected boolean isLoaderInitialized = false; 156 private AsyncRefreshTask mAsyncRefreshTask; 157 158 private boolean mDestroyed; 159 160 private final Set<Uri> mCurrentAccountUris = Sets.newHashSet(); 161 protected ConversationCursor mConversationListCursor; 162 private final DataSetObservable mConversationListObservable = new DataSetObservable() { 163 @Override 164 public void registerObserver(DataSetObserver observer) { 165 final int count = mObservers.size(); 166 super.registerObserver(observer); 167 LogUtils.d(LOG_TAG, "IN AAC.register(List)Observer: %s before=%d after=%d", observer, 168 count, mObservers.size()); 169 } 170 @Override 171 public void unregisterObserver(DataSetObserver observer) { 172 final int count = mObservers.size(); 173 super.unregisterObserver(observer); 174 LogUtils.d(LOG_TAG, "IN AAC.unregister(List)Observer: %s before=%d after=%d", observer, 175 count, mObservers.size()); 176 } 177 }; 178 179 private RefreshTimerTask mConversationListRefreshTask; 180 181 /** Listeners that are interested in changes to the current account. */ 182 private final DataSetObservable mAccountObservers = new DataSetObservable() { 183 @Override 184 public void registerObserver(DataSetObserver observer) { 185 final int count = mObservers.size(); 186 super.registerObserver(observer); 187 LogUtils.d(LOG_TAG, "IN AAC.register(Account)Observer: %s before=%d after=%d", 188 observer, count, mObservers.size()); 189 } 190 @Override 191 public void unregisterObserver(DataSetObserver observer) { 192 final int count = mObservers.size(); 193 super.unregisterObserver(observer); 194 LogUtils.d(LOG_TAG, "IN AAC.unregister(Account)Observer: %s before=%d after=%d", 195 observer, count, mObservers.size()); 196 } 197 }; 198 199 /** 200 * Selected conversations, if any. 201 */ 202 private final ConversationSelectionSet mSelectedSet = new ConversationSelectionSet(); 203 204 private final int mFolderItemUpdateDelayMs; 205 206 /** Keeps track of selected and unselected conversations */ 207 final protected ConversationPositionTracker mTracker; 208 209 /** 210 * Action menu associated with the selected set. 211 */ 212 SelectedConversationsActionMenu mCabActionMenu; 213 protected ActionableToastBar mToastBar; 214 protected ConversationPagerController mPagerController; 215 216 // this is split out from the general loader dispatcher because its loader doesn't return a 217 // basic Cursor 218 private final ConversationListLoaderCallbacks mListCursorCallbacks = 219 new ConversationListLoaderCallbacks(); 220 221 private final DataSetObservable mFolderObservable = new DataSetObservable(); 222 223 protected static final String LOG_TAG = LogTag.getLogTag(); 224 /** Constants used to differentiate between the types of loaders. */ 225 private static final int LOADER_ACCOUNT_CURSOR = 0; 226 private static final int LOADER_FOLDER_CURSOR = 2; 227 private static final int LOADER_RECENT_FOLDERS = 3; 228 private static final int LOADER_CONVERSATION_LIST = 4; 229 private static final int LOADER_ACCOUNT_INBOX = 5; 230 private static final int LOADER_SEARCH = 6; 231 private static final int LOADER_ACCOUNT_UPDATE_CURSOR = 7; 232 /** 233 * Guaranteed to be the last loader ID used by the activity. Loaders are owned by Activity or 234 * fragments, and within an activity, loader IDs need to be unique. A hack to ensure that the 235 * {@link FolderWatcher} can create its folder loaders without clashing with the IDs of those 236 * of the {@link AbstractActivityController}. Currently, the {@link FolderWatcher} is the only 237 * other class that uses this activity's LoaderManager. If another class needs activity-level 238 * loaders, consider consolidating the loaders in a central location: a UI-less fragment 239 * perhaps. 240 */ 241 public static final int LAST_LOADER_ID = 100; 242 243 private static final int ADD_ACCOUNT_REQUEST_CODE = 1; 244 245 /** The pending destructive action to be carried out before swapping the conversation cursor.*/ 246 private DestructiveAction mPendingDestruction; 247 protected AsyncRefreshTask mFolderSyncTask; 248 // Task for setting any share intents for the account to enabled. 249 // This gets cancelled if the user kills the app before it finishes, and 250 // will just run the next time the user opens the app. 251 private AsyncTask<String, Void, Void> mEnableShareIntents; 252 private Folder mFolderListFolder; 253 public static final String SYNC_ERROR_DIALOG_FRAGMENT_TAG = "SyncErrorDialogFragment"; 254 255 public AbstractActivityController(MailActivity activity, ViewMode viewMode) { 256 mActivity = activity; 257 mFragmentManager = mActivity.getFragmentManager(); 258 mViewMode = viewMode; 259 mContext = activity.getApplicationContext(); 260 mRecentFolderList = new RecentFolderList(mContext); 261 mTracker = new ConversationPositionTracker(this); 262 // Allow the fragment to observe changes to its own selection set. No other object is 263 // aware of the selected set. 264 mSelectedSet.addObserver(this); 265 266 mFolderItemUpdateDelayMs = 267 mContext.getResources().getInteger(R.integer.folder_item_refresh_delay_ms); 268 } 269 270 @Override 271 public Account getCurrentAccount() { 272 return mAccount; 273 } 274 275 @Override 276 public ConversationListContext getCurrentListContext() { 277 return mConvListContext; 278 } 279 280 @Override 281 public String getHelpContext() { 282 return "Mail"; 283 } 284 285 @Override 286 public final ConversationCursor getConversationListCursor() { 287 return mConversationListCursor; 288 } 289 290 /** 291 * Check if the fragment is attached to an activity and has a root view. 292 * @param in 293 * @return true if the fragment is valid, false otherwise 294 */ 295 private static final boolean isValidFragment(Fragment in) { 296 if (in == null || in.getActivity() == null || in.getView() == null) { 297 return false; 298 } 299 return true; 300 } 301 302 /** 303 * Get the conversation list fragment for this activity. If the conversation list fragment 304 * is not attached, this method returns null 305 * 306 */ 307 protected ConversationListFragment getConversationListFragment() { 308 final Fragment fragment = mFragmentManager.findFragmentByTag(TAG_CONVERSATION_LIST); 309 if (isValidFragment(fragment)) { 310 return (ConversationListFragment) fragment; 311 } 312 return null; 313 } 314 315 /** 316 * Returns the folder list fragment attached with this activity. If no such fragment is attached 317 * this method returns null. 318 * 319 */ 320 protected FolderListFragment getFolderListFragment() { 321 final Fragment fragment = mFragmentManager.findFragmentByTag(TAG_FOLDER_LIST); 322 if (isValidFragment(fragment)) { 323 return (FolderListFragment) fragment; 324 } 325 return null; 326 } 327 328 /** 329 * Initialize the action bar. This is not visible to OnePaneController and 330 * TwoPaneController so they cannot override this behavior. 331 */ 332 private void initializeActionBar() { 333 final ActionBar actionBar = mActivity.getActionBar(); 334 if (actionBar == null) { 335 return; 336 } 337 338 // be sure to inherit from the ActionBar theme when inflating 339 final LayoutInflater inflater = LayoutInflater.from(actionBar.getThemedContext()); 340 final boolean isSearch = mActivity.getIntent() != null 341 && Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction()); 342 mActionBarView = (MailActionBarView) inflater.inflate( 343 isSearch ? R.layout.search_actionbar_view : R.layout.actionbar_view, null); 344 mActionBarView.initialize(mActivity, this, mViewMode, actionBar, mRecentFolderList); 345 } 346 347 /** 348 * Attach the action bar to the activity. 349 */ 350 private void attachActionBar() { 351 final ActionBar actionBar = mActivity.getActionBar(); 352 if (actionBar != null && mActionBarView != null) { 353 actionBar.setCustomView(mActionBarView, new ActionBar.LayoutParams( 354 LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); 355 actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM, 356 ActionBar.DISPLAY_SHOW_CUSTOM | ActionBar.DISPLAY_SHOW_TITLE); 357 mActionBarView.attach(); 358 } 359 mViewMode.addListener(mActionBarView); 360 } 361 362 /** 363 * Returns whether the conversation list fragment is visible or not. 364 * Different layouts will have their own notion on the visibility of 365 * fragments, so this method needs to be overriden. 366 * 367 */ 368 protected abstract boolean isConversationListVisible(); 369 370 /** 371 * Switch the current account to the one provided as an argument to the method. 372 * @param account new account 373 * @param shouldReloadInbox whether the default inbox should be reloaded. 374 */ 375 private void switchAccount(Account account, boolean shouldReloadInbox){ 376 // Current account is different from the new account, restart loaders and show 377 // the account Inbox. 378 mAccount = account; 379 LogUtils.d(LOG_TAG, "AbstractActivityController.switchAccount(): mAccount = %s", 380 mAccount.uri); 381 cancelRefreshTask(); 382 updateSettings(); 383 if (shouldReloadInbox) { 384 loadAccountInbox(); 385 } 386 restartOptionalLoader(LOADER_RECENT_FOLDERS); 387 mActivity.invalidateOptionsMenu(); 388 disableNotificationsOnAccountChange(mAccount); 389 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR); 390 MailAppProvider.getInstance().setLastViewedAccount(mAccount.uri.toString()); 391 } 392 393 @Override 394 public void onAccountChanged(Account account) { 395 LogUtils.d(LOG_TAG, "onAccountChanged (%s) called.", account); 396 // Is the account or account settings different from the existing account? 397 final boolean firstLoad = mAccount == null; 398 final boolean accountChanged = firstLoad || !account.uri.equals(mAccount.uri); 399 final boolean settingsChanged = firstLoad || !account.settings.equals(mAccount.settings); 400 if (accountChanged || settingsChanged) { 401 if (account != null) { 402 final String accountName = account.name; 403 mHandler.post(new Runnable() { 404 @Override 405 public void run() { 406 MailActivity.setForegroundNdef(MailActivity.getMailtoNdef(accountName)); 407 } 408 }); 409 } 410 switchAccount(account, accountChanged); 411 } 412 } 413 414 /** 415 * Changes the settings for the current account. The new settings are provided as a parameter. 416 * @param settings 417 */ 418 public void updateSettings() { 419 mAccountObservers.notifyChanged(); 420 resetActionBarIcon(); 421 mActivity.invalidateOptionsMenu(); 422 // If the user was viewing the default Inbox here, and the new setting contains a different 423 // default Inbox, we don't want to load a different folder here. So do not change the 424 // current folder. 425 } 426 427 /** 428 * Adds a listener interested in change in the current account. If a class is storing a 429 * reference to the current account, it should listen on changes, so it can receive updates to 430 * settings. Must happen in the UI thread. 431 */ 432 @Override 433 public void registerAccountObserver(DataSetObserver obs) { 434 mAccountObservers.registerObserver(obs); 435 } 436 437 /** 438 * Removes a listener from receiving current account changes. 439 * Must happen in the UI thread. 440 */ 441 @Override 442 public void unregisterAccountObserver(DataSetObserver obs) { 443 mAccountObservers.unregisterObserver(obs); 444 } 445 446 @Override 447 public Account getAccount() { 448 return mAccount; 449 } 450 451 private void fetchSearchFolder(Intent intent) { 452 Bundle args = new Bundle(); 453 args.putString(ConversationListContext.EXTRA_SEARCH_QUERY, intent 454 .getStringExtra(ConversationListContext.EXTRA_SEARCH_QUERY)); 455 mActivity.getLoaderManager().restartLoader(LOADER_SEARCH, args, this); 456 } 457 458 @Override 459 public void onFolderChanged(Folder folder) { 460 changeFolder(folder, null); 461 } 462 463 /** 464 * Changes the folder to the value provided here. This causes the view mode to change. 465 * @param folder the folder to change to 466 * @param query if non-null, this represents the search string that the folder represents. 467 */ 468 private void changeFolder(Folder folder, String query) { 469 if (folder != null && !folder.equals(mFolder) 470 || (mViewMode.getMode() != ViewMode.CONVERSATION_LIST)) { 471 updateFolder(folder); 472 if (query != null) { 473 mConvListContext = ConversationListContext.forSearchQuery(mAccount, mFolder, query); 474 } else { 475 mConvListContext = ConversationListContext.forFolder(mAccount, mFolder); 476 } 477 showConversationList(mConvListContext); 478 479 // Add the folder that we were viewing to the recent folders list. 480 // TODO: this may need to be fine tuned. If this is the signal that is indicating that 481 // the list is shown to the user, this could fire in one pane if the user goes directly 482 // to a conversation 483 updateRecentFolderList(); 484 cancelRefreshTask(); 485 } 486 } 487 488 @Override 489 public void onFolderSelected(Folder folder) { 490 onFolderChanged(folder); 491 } 492 493 /** 494 * Update the recent folders. This only needs to be done once when accessing a new folder. 495 */ 496 private void updateRecentFolderList() { 497 if (mFolder != null) { 498 mRecentFolderList.touchFolder(mFolder, mAccount); 499 } 500 } 501 502 // TODO(mindyp): set this up to store a copy of the folder as a transient 503 // field in the account. 504 @Override 505 public void loadAccountInbox() { 506 restartOptionalLoader(LOADER_ACCOUNT_INBOX); 507 } 508 509 /** Set the current folder */ 510 private void updateFolder(Folder folder) { 511 // Start watching folder for sync status. 512 boolean wasNull = mFolder == null; 513 if (folder != null && !folder.equals(mFolder) && folder.isInitialized()) { 514 LogUtils.d(LOG_TAG, "AbstractActivityController.setFolder(%s)", folder.name); 515 final LoaderManager lm = mActivity.getLoaderManager(); 516 mFolder = folder; 517 mActionBarView.setFolder(mFolder); 518 519 // Only when we switch from one folder to another do we want to restart the 520 // folder and conversation list loaders (to trigger onCreateLoader). 521 // The first time this runs when the activity is [re-]initialized, we want to re-use the 522 // previous loader's instance and data upon configuration change (e.g. rotation). 523 // If there was not already an instance of the loader, init it. 524 if (lm.getLoader(LOADER_FOLDER_CURSOR) == null) { 525 lm.initLoader(LOADER_FOLDER_CURSOR, null, this); 526 } else { 527 lm.restartLoader(LOADER_FOLDER_CURSOR, null, this); 528 } 529 // In this case, we are starting from no folder, which would occur 530 // the first time the app was launched or on orientation changes. 531 // We want to attach to an existing loader, if available. 532 if (wasNull || lm.getLoader(LOADER_CONVERSATION_LIST) == null) { 533 lm.initLoader(LOADER_CONVERSATION_LIST, null, mListCursorCallbacks); 534 } else { 535 // However, if there was an existing folder AND we have changed 536 // folders, we want to restart the loader to get the information 537 // for the newly selected folder 538 lm.restartLoader(LOADER_CONVERSATION_LIST, null, mListCursorCallbacks); 539 } 540 } else if (!folder.isInitialized()) { 541 LogUtils.e(LOG_TAG, new Error(), "Uninitialized Folder %s in setFolder.", folder); 542 } else if (folder == null) { 543 LogUtils.wtf(LOG_TAG, "Folder in setFolder is null"); 544 } 545 } 546 547 @Override 548 public Folder getFolder() { 549 return mFolder; 550 } 551 552 @Override 553 public Folder getHierarchyFolder() { 554 return mFolderListFolder; 555 } 556 557 @Override 558 public void setHierarchyFolder(Folder folder) { 559 mFolderListFolder = folder; 560 } 561 562 @Override 563 public void onActivityResult(int requestCode, int resultCode, Intent data) { 564 if (requestCode == ADD_ACCOUNT_REQUEST_CODE) { 565 // We were waiting for the user to create an account 566 if (resultCode == Activity.RESULT_OK) { 567 // restart the loader to get the updated list of accounts 568 mActivity.getLoaderManager().initLoader( 569 LOADER_ACCOUNT_CURSOR, null, this); 570 } else { 571 // The user failed to create an account, just exit the app 572 mActivity.finish(); 573 } 574 } 575 } 576 577 @Override 578 public void onConversationListVisibilityChanged(boolean visible) { 579 if (mConversationListCursor != null) { 580 // The conversation list is visible. 581 Utils.setConversationCursorVisibility(mConversationListCursor, visible); 582 } 583 } 584 585 /** 586 * Called when a conversation is visible. Child classes must call the super class implementation 587 * before performing local computation. 588 */ 589 @Override 590 public void onConversationVisibilityChanged(boolean visible) { 591 return; 592 } 593 594 @Override 595 public boolean onCreate(Bundle savedState) { 596 initializeActionBar(); 597 // Allow shortcut keys to function for the ActionBar and menus. 598 mActivity.setDefaultKeyMode(Activity.DEFAULT_KEYS_SHORTCUT); 599 mResolver = mActivity.getContentResolver(); 600 mNewEmailReceiver = new SuppressNotificationReceiver(); 601 mRecentFolderList.initialize(mActivity); 602 603 // All the individual UI components listen for ViewMode changes. This 604 // simplifies the amount of logic in the AbstractActivityController, but increases the 605 // possibility of timing-related bugs. 606 mViewMode.addListener(this); 607 mPagerController = new ConversationPagerController(mActivity, this); 608 mToastBar = (ActionableToastBar) mActivity.findViewById(R.id.toast_bar); 609 attachActionBar(); 610 611 final Intent intent = mActivity.getIntent(); 612 // Immediately handle a clean launch with intent, and any state restoration 613 // that does not rely on restored fragments or loader data 614 // any state restoration that relies on those can be done later in 615 // onRestoreInstanceState, once fragments are up and loader data is re-delivered 616 if (savedState != null) { 617 if (savedState.containsKey(SAVED_ACCOUNT)) { 618 setAccount((Account) savedState.getParcelable(SAVED_ACCOUNT)); 619 mActivity.invalidateOptionsMenu(); 620 } 621 if (savedState.containsKey(SAVED_FOLDER)) { 622 final Folder folder = (Folder) savedState.getParcelable(SAVED_FOLDER); 623 if (savedState.containsKey(SAVED_QUERY)) { 624 changeFolder(folder, savedState.getString(SAVED_QUERY)); 625 } else { 626 onFolderChanged(folder); 627 } 628 } 629 } else if (intent != null) { 630 handleIntent(intent); 631 } 632 // Create the accounts loader; this loads the account switch spinner. 633 mActivity.getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this); 634 return true; 635 } 636 637 @Override 638 public void onRestart() { 639 DialogFragment fragment = (DialogFragment) 640 mFragmentManager.findFragmentByTag(SYNC_ERROR_DIALOG_FRAGMENT_TAG); 641 if (fragment != null) { 642 fragment.dismiss(); 643 } 644 } 645 646 @Override 647 public Dialog onCreateDialog(int id, Bundle bundle) { 648 return null; 649 } 650 651 @Override 652 public final boolean onCreateOptionsMenu(Menu menu) { 653 MenuInflater inflater = mActivity.getMenuInflater(); 654 inflater.inflate(mActionBarView.getOptionsMenuId(), menu); 655 mActionBarView.onCreateOptionsMenu(menu); 656 return true; 657 } 658 659 @Override 660 public final boolean onKeyDown(int keyCode, KeyEvent event) { 661 // TODO(viki): Auto-generated method stub 662 return false; 663 } 664 665 @Override 666 public final boolean onOptionsItemSelected(MenuItem item) { 667 final int id = item.getItemId(); 668 LogUtils.d(LOG_TAG, "AbstractController.onOptionsItemSelected(%d) called.", id); 669 boolean handled = true; 670 final Collection<Conversation> target = Conversation.listOf(mCurrentConversation); 671 final Settings settings = (mAccount == null) ? null : mAccount.settings; 672 // The user is choosing a new action; commit whatever they had been doing before. 673 commitDestructiveActions(); 674 switch (id) { 675 case R.id.archive: { 676 final boolean showDialog = (settings != null && settings.confirmArchive); 677 confirmAndDelete(target, showDialog, R.plurals.confirm_archive_conversation, 678 getAction(R.id.archive, target)); 679 break; 680 } 681 case R.id.remove_folder: 682 delete(target, getRemoveFolder(target, mFolder, true, false, true)); 683 break; 684 case R.id.delete: { 685 final boolean showDialog = (settings != null && settings.confirmDelete); 686 confirmAndDelete(target, showDialog, R.plurals.confirm_delete_conversation, 687 getAction(R.id.delete, target)); 688 break; 689 } 690 case R.id.mark_important: 691 updateConversation(Conversation.listOf(mCurrentConversation), 692 ConversationColumns.PRIORITY, UIProvider.ConversationPriority.HIGH); 693 break; 694 case R.id.mark_not_important: 695 if (mFolder != null && mFolder.isImportantOnly()) { 696 delete(target, getAction(R.id.mark_not_important, target)); 697 } else { 698 updateConversation(Conversation.listOf(mCurrentConversation), 699 ConversationColumns.PRIORITY, UIProvider.ConversationPriority.LOW); 700 } 701 break; 702 case R.id.mute: 703 delete(target, getAction(R.id.mute, target)); 704 break; 705 case R.id.report_spam: 706 delete(target, getAction(R.id.report_spam, target)); 707 break; 708 case R.id.mark_not_spam: 709 // Currently, since spam messages are only shown in list with other spam messages, 710 // marking a message not as spam is a destructive action 711 delete(target, getAction(R.id.mark_not_spam, target)); 712 break; 713 case R.id.report_phishing: 714 delete(target, getAction(R.id.report_phishing, target)); 715 break; 716 case android.R.id.home: 717 onUpPressed(); 718 break; 719 case R.id.compose: 720 ComposeActivity.compose(mActivity.getActivityContext(), mAccount); 721 break; 722 case R.id.show_all_folders: 723 showFolderList(); 724 break; 725 case R.id.refresh: 726 requestFolderRefresh(); 727 break; 728 case R.id.settings: 729 Utils.showSettings(mActivity.getActivityContext(), mAccount); 730 break; 731 case R.id.folder_options: 732 Utils.showFolderSettings(mActivity.getActivityContext(), mAccount, mFolder); 733 break; 734 case R.id.help_info_menu_item: 735 // TODO: enable context sensitive help 736 Utils.showHelp(mActivity.getActivityContext(), mAccount, null); 737 break; 738 case R.id.feedback_menu_item: 739 Utils.sendFeedback(mActivity.getActivityContext(), mAccount); 740 break; 741 case R.id.manage_folders_item: 742 Utils.showManageFolder(mActivity.getActivityContext(), mAccount); 743 break; 744 case R.id.change_folder: 745 new FoldersSelectionDialog(mActivity.getActivityContext(), mAccount, this, 746 Conversation.listOf(mCurrentConversation), false, mFolder).show(); 747 break; 748 default: 749 handled = false; 750 break; 751 } 752 return handled; 753 } 754 755 @Override 756 public void updateConversation(Collection<Conversation> target, ContentValues values) { 757 mConversationListCursor.updateValues(mContext, target, values); 758 refreshConversationList(); 759 } 760 761 @Override 762 public void updateConversation(Collection <Conversation> target, String columnName, 763 boolean value) { 764 mConversationListCursor.updateBoolean(mContext, target, columnName, value); 765 refreshConversationList(); 766 } 767 768 @Override 769 public void updateConversation(Collection <Conversation> target, String columnName, 770 int value) { 771 mConversationListCursor.updateInt(mContext, target, columnName, value); 772 refreshConversationList(); 773 } 774 775 @Override 776 public void updateConversation(Collection <Conversation> target, String columnName, 777 String value) { 778 mConversationListCursor.updateString(mContext, target, columnName, value); 779 refreshConversationList(); 780 } 781 782 @Override 783 public void markConversationMessagesUnread(Conversation conv, Set<Uri> unreadMessageUris, 784 String originalConversationInfo) { 785 // The only caller of this method is the conversation view, from where marking unread should 786 // *always* take you back to list mode. 787 showConversation(null); 788 789 // locally mark conversation unread (the provider is supposed to propagate message unread 790 // to conversation unread) 791 conv.read = false; 792 793 // only do a granular 'mark unread' if a subset of messages are unread 794 final int unreadCount = (unreadMessageUris == null) ? 0 : unreadMessageUris.size(); 795 final int numMessages = conv.getNumMessages(); 796 final boolean subsetIsUnread = (numMessages > 1 && unreadCount > 0 797 && unreadCount < numMessages); 798 799 if (!subsetIsUnread) { 800 // Conversations are neither marked read, nor viewed, and we don't want to show 801 // the next conversation. 802 markConversationsRead(Collections.singletonList(conv), false, false, false); 803 } else { 804 mConversationListCursor.setConversationColumn(conv.uri, ConversationColumns.READ, 0); 805 806 // locally update conversation's conversationInfo to revert to original version 807 if (originalConversationInfo != null) { 808 mConversationListCursor.setConversationColumn(conv.uri, 809 ConversationColumns.CONVERSATION_INFO, originalConversationInfo); 810 } 811 812 // applyBatch with each CPO as an UPDATE op on each affected message uri 813 final ArrayList<ContentProviderOperation> ops = Lists.newArrayList(); 814 String authority = null; 815 for (Uri messageUri : unreadMessageUris) { 816 if (authority == null) { 817 authority = messageUri.getAuthority(); 818 } 819 ops.add(ContentProviderOperation.newUpdate(messageUri) 820 .withValue(UIProvider.MessageColumns.READ, 0) 821 .build()); 822 } 823 824 new ContentProviderTask() { 825 @Override 826 protected void onPostExecute(Result result) { 827 // TODO: handle errors? 828 } 829 }.run(mResolver, authority, ops); 830 } 831 } 832 833 @Override 834 public void markConversationsRead(Collection<Conversation> targets, boolean read, 835 boolean viewed) { 836 // We want to show the next conversation if we are marking unread. 837 markConversationsRead(targets, read, viewed, true); 838 } 839 840 private void markConversationsRead(Collection<Conversation> targets, boolean read, 841 boolean markViewed, boolean showNext) { 842 // auto-advance if requested and the current conversation is being marked unread 843 if (showNext && !read) { 844 showNextConversation(targets); 845 } 846 847 for (Conversation target : targets) { 848 final ContentValues values = new ContentValues(); 849 values.put(ConversationColumns.READ, read); 850 if (markViewed) { 851 values.put(ConversationColumns.VIEWED, true); 852 } 853 final ConversationInfo info = target.conversationInfo; 854 if (info != null) { 855 info.markRead(read); 856 values.put(ConversationColumns.CONVERSATION_INFO, ConversationInfo.toString(info)); 857 } 858 updateConversation(Conversation.listOf(target), values); 859 } 860 // Update the conversations in the selection too. 861 for (final Conversation c : targets) { 862 c.read = read; 863 if (markViewed) { 864 c.markViewed(); 865 } 866 } 867 } 868 869 /** 870 * Auto-advance to a different conversation if the currently visible conversation in 871 * conversation mode is affected (deleted, marked unread, etc.). 872 * 873 * <p>Does nothing if outside of conversation mode. 874 * 875 * @param target the set of conversations being deleted/marked unread 876 */ 877 private void showNextConversation(Collection<Conversation> target) { 878 final boolean currentConversationInView = (mViewMode.getMode() == ViewMode.CONVERSATION) 879 && Conversation.contains(target, mCurrentConversation); 880 if (currentConversationInView) { 881 final Conversation next = mTracker.getNextConversation( 882 Settings.getAutoAdvanceSetting(mAccount.settings), target); 883 LogUtils.d(LOG_TAG, "showNextConversation: showing %s next.", next); 884 showConversation(next); 885 } 886 } 887 888 @Override 889 public void starMessage(ConversationMessage msg, boolean starred) { 890 if (msg.starred == starred) { 891 return; 892 } 893 894 msg.starred = starred; 895 896 // locally propagate the change to the owning conversation 897 // (figure the provider will properly propagate the change when it commits it) 898 // 899 // when unstarring, only propagate the change if this was the only message starred 900 final boolean conversationStarred = starred || msg.isConversationStarred(); 901 if (conversationStarred != msg.conversation.starred) { 902 msg.conversation.starred = conversationStarred; 903 mConversationListCursor.setConversationColumn(msg.conversation.uri, 904 ConversationColumns.STARRED, conversationStarred); 905 } 906 907 final ContentValues values = new ContentValues(1); 908 values.put(UIProvider.MessageColumns.STARRED, starred ? 1 : 0); 909 910 new ContentProviderTask.UpdateTask() { 911 @Override 912 protected void onPostExecute(Result result) { 913 // TODO: handle errors? 914 } 915 }.run(mResolver, msg.uri, values, null /* selection*/, null /* selectionArgs */); 916 } 917 918 private void requestFolderRefresh() { 919 if (mFolder != null) { 920 if (mAsyncRefreshTask != null) { 921 mAsyncRefreshTask.cancel(true); 922 } 923 mAsyncRefreshTask = new AsyncRefreshTask(mContext, mFolder.refreshUri); 924 mAsyncRefreshTask.execute(); 925 } 926 } 927 928 /** 929 * Confirm (based on user's settings) and delete a conversation from the conversation list and 930 * from the database. 931 * @param target the conversations to act upon 932 * @param showDialog true if a confirmation dialog is to be shown, false otherwise. 933 * @param confirmResource the resource ID of the string that is shown in the confirmation dialog 934 * @param action the action to perform after animating the deletion of the conversations. 935 */ 936 protected void confirmAndDelete(final Collection<Conversation> target, boolean showDialog, 937 int confirmResource, final DestructiveAction action) { 938 if (showDialog) { 939 final AlertDialog.OnClickListener onClick = new AlertDialog.OnClickListener() { 940 @Override 941 public void onClick(DialogInterface dialog, int which) { 942 delete(target, action); 943 } 944 }; 945 final CharSequence message = Utils.formatPlural(mContext, confirmResource, 946 target.size()); 947 new AlertDialog.Builder(mActivity.getActivityContext()).setMessage(message) 948 .setPositiveButton(R.string.ok, onClick) 949 .setNegativeButton(R.string.cancel, null) 950 .create().show(); 951 } else { 952 delete(target, action); 953 } 954 } 955 956 @Override 957 public void delete(final Collection<Conversation> target, final DestructiveAction action) { 958 // Order of events is critical! The Conversation View Fragment must be notified 959 // of the next conversation with showConversation(next) *before* the conversation list 960 // fragment has a chance to delete the conversation, animating it away. 961 962 // Update the conversation fragment if the current conversation is deleted. 963 showNextConversation(target); 964 // The conversation list deletes and performs the action if it exists. 965 final ConversationListFragment convListFragment = getConversationListFragment(); 966 if (convListFragment != null) { 967 LogUtils.d(LOG_TAG, "AAC.requestDelete: ListFragment is handling delete."); 968 convListFragment.requestDelete(target, action); 969 return; 970 } 971 // No visible UI element handled it on our behalf. Perform the action ourself. 972 action.performAction(); 973 } 974 975 /** 976 * Requests that the action be performed and the UI state is updated to reflect the new change. 977 * @param target 978 * @param action 979 */ 980 private void requestUpdate(final Collection<Conversation> target, 981 final DestructiveAction action) { 982 action.performAction(); 983 refreshConversationList(); 984 } 985 986 @Override 987 public void onPrepareDialog(int id, Dialog dialog, Bundle bundle) { 988 // TODO(viki): Auto-generated method stub 989 } 990 991 @Override 992 public boolean onPrepareOptionsMenu(Menu menu) { 993 mActionBarView.onPrepareOptionsMenu(menu); 994 return true; 995 } 996 997 @Override 998 public void onPause() { 999 isLoaderInitialized = false; 1000 enableNotifications(); 1001 } 1002 1003 @Override 1004 public void onResume() { 1005 // Register the receiver that will prevent the status receiver from 1006 // displaying its notification icon as long as we're running. 1007 // The SupressNotificationReceiver will block the broadcast if we're looking at the folder 1008 // that the notification was received for. 1009 disableNotifications(); 1010 } 1011 1012 @Override 1013 public void onSaveInstanceState(Bundle outState) { 1014 if (mAccount != null) { 1015 LogUtils.d(LOG_TAG, "Saving the account now"); 1016 outState.putParcelable(SAVED_ACCOUNT, mAccount); 1017 } 1018 if (mFolder != null) { 1019 outState.putParcelable(SAVED_FOLDER, mFolder); 1020 } 1021 // If this is a search activity, let's store the search query term as well. 1022 if (ConversationListContext.isSearchResult(mConvListContext)) { 1023 outState.putString(SAVED_QUERY, mConvListContext.searchQuery); 1024 } 1025 int mode = mViewMode.getMode(); 1026 if (mCurrentConversation != null 1027 && (mode == ViewMode.CONVERSATION || 1028 mViewMode.getMode() == ViewMode.SEARCH_RESULTS_CONVERSATION)) { 1029 outState.putParcelable(SAVED_CONVERSATION, mCurrentConversation); 1030 } 1031 if (!mSelectedSet.isEmpty()) { 1032 outState.putParcelable(SAVED_SELECTED_SET, mSelectedSet); 1033 } 1034 if (mToastBar.getVisibility() == View.VISIBLE) { 1035 outState.putParcelable(SAVED_TOAST_BAR_OP, mToastBar.getOperation()); 1036 } 1037 ConversationListFragment convListFragment = getConversationListFragment(); 1038 if (convListFragment != null) { 1039 convListFragment.getAnimatedAdapter() 1040 .onSaveInstanceState(outState); 1041 } 1042 } 1043 1044 @Override 1045 public void onSearchRequested(String query) { 1046 Intent intent = new Intent(); 1047 intent.setAction(Intent.ACTION_SEARCH); 1048 intent.putExtra(ConversationListContext.EXTRA_SEARCH_QUERY, query); 1049 intent.putExtra(Utils.EXTRA_ACCOUNT, mAccount); 1050 intent.setComponent(mActivity.getComponentName()); 1051 mActionBarView.collapseSearch(); 1052 mActivity.startActivity(intent); 1053 } 1054 1055 @Override 1056 public void onStop() { 1057 if (mEnableShareIntents != null) { 1058 mEnableShareIntents.cancel(true); 1059 } 1060 } 1061 1062 @Override 1063 public void onDestroy() { 1064 // unregister the ViewPager's observer on the conversation cursor 1065 mPagerController.onDestroy(); 1066 mActionBarView.onDestroy(); 1067 mRecentFolderList.destroy(); 1068 mDestroyed = true; 1069 } 1070 1071 /** 1072 * {@inheritDoc} Subclasses must override this to listen to mode changes 1073 * from the ViewMode. Subclasses <b>must</b> call the parent's 1074 * onViewModeChanged since the parent will handle common state changes. 1075 */ 1076 @Override 1077 public void onViewModeChanged(int newMode) { 1078 // Perform any mode specific work here. 1079 // reset the action bar icon based on the mode. Why don't the individual 1080 // controllers do 1081 // this themselves? 1082 1083 // Commit any destructive undoable actions the user may have performed. 1084 commitDestructiveActions(); 1085 1086 // We don't want to invalidate the options menu when switching to 1087 // conversation 1088 // mode, as it will happen when the conversation finishes loading. 1089 if (newMode != ViewMode.CONVERSATION) { 1090 mActivity.invalidateOptionsMenu(); 1091 } 1092 } 1093 1094 public boolean isDestroyed() { 1095 return mDestroyed; 1096 } 1097 1098 private void commitDestructiveActions() { 1099 ConversationListFragment fragment = this.getConversationListFragment(); 1100 if (fragment != null) { 1101 fragment.commitDestructiveActions(); 1102 } 1103 } 1104 1105 @Override 1106 public void onWindowFocusChanged(boolean hasFocus) { 1107 ConversationListFragment convList = getConversationListFragment(); 1108 if (hasFocus && convList != null && convList.isVisible()) { 1109 // The conversation list is visible. 1110 Utils.setConversationCursorVisibility(mConversationListCursor, true); 1111 } 1112 } 1113 1114 private void setAccount(Account account) { 1115 if (account == null) { 1116 LogUtils.w(LOG_TAG, new Error(), 1117 "AAC ignoring null (presumably invalid) account restoration"); 1118 return; 1119 } 1120 LogUtils.d(LOG_TAG, "AbstractActivityController.setAccount(): account = %s", account.uri); 1121 mAccount = account; 1122 if (account.settings == null) { 1123 LogUtils.w(LOG_TAG, new Error(), "AAC ignoring account with null settings."); 1124 return; 1125 } 1126 mAccountObservers.notifyChanged(); 1127 } 1128 1129 /** 1130 * Restore the state from the previous bundle. Subclasses should call this 1131 * method from the parent class, since it performs important UI 1132 * initialization. 1133 * 1134 * @param savedState 1135 */ 1136 @Override 1137 public void onRestoreInstanceState(Bundle savedState) { 1138 LogUtils.d(LOG_TAG, "IN AAC.onRestoreInstanceState"); 1139 if (savedState.containsKey(SAVED_CONVERSATION)) { 1140 // Open the conversation. 1141 final Conversation conversation = 1142 (Conversation)savedState.getParcelable(SAVED_CONVERSATION); 1143 if (conversation != null && conversation.position < 0) { 1144 // Set the position to 0 on this conversation, as we don't know where it is 1145 // in the list 1146 conversation.position = 0; 1147 } 1148 showConversation(conversation); 1149 } 1150 1151 if (savedState.containsKey(SAVED_TOAST_BAR_OP)) { 1152 ToastBarOperation op = ((ToastBarOperation) savedState 1153 .getParcelable(SAVED_TOAST_BAR_OP)); 1154 if (op != null) { 1155 if (op.getType() == ToastBarOperation.UNDO) { 1156 onUndoAvailable(op); 1157 } else if (op.getType() == ToastBarOperation.ERROR) { 1158 onError(mFolder, true); 1159 } 1160 } 1161 } 1162 1163 ConversationListFragment convListFragment = getConversationListFragment(); 1164 if (convListFragment != null) { 1165 convListFragment.getAnimatedAdapter() 1166 .onRestoreInstanceState(savedState); 1167 } 1168 /** 1169 * Restore the state of selected conversations. This needs to be done after the correct mode 1170 * is set and the action bar is fully initialized. If not, several key pieces of state 1171 * information will be missing, and the split views may not be initialized correctly. 1172 * @param savedState 1173 */ 1174 restoreSelectedConversations(savedState); 1175 } 1176 1177 private void handleIntent(Intent intent) { 1178 boolean handled = false; 1179 if (Intent.ACTION_VIEW.equals(intent.getAction())) { 1180 if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) { 1181 setAccount(Account.newinstance(intent 1182 .getStringExtra(Utils.EXTRA_ACCOUNT))); 1183 } 1184 if (mAccount == null) { 1185 return; 1186 } 1187 mActivity.invalidateOptionsMenu(); 1188 final boolean isConversationMode = intent.hasExtra(Utils.EXTRA_CONVERSATION); 1189 // TODO(viki): Allow the controller to set the mode instead of a mode transition. 1190 if (isConversationMode) { 1191 mViewMode.enterConversationMode(); 1192 } else { 1193 mViewMode.enterConversationListMode(); 1194 } 1195 1196 Folder folder = null; 1197 if (intent.hasExtra(Utils.EXTRA_FOLDER)) { 1198 // Open the folder. 1199 folder = Folder.fromString(intent.getStringExtra(Utils.EXTRA_FOLDER)); 1200 } 1201 if (folder != null) { 1202 onFolderChanged(folder); 1203 handled = true; 1204 } 1205 1206 if (isConversationMode) { 1207 // Open the conversation. 1208 LogUtils.d(LOG_TAG, "SHOW THE CONVERSATION at %s", 1209 intent.getParcelableExtra(Utils.EXTRA_CONVERSATION)); 1210 final Conversation conversation = 1211 (Conversation)intent.getParcelableExtra(Utils.EXTRA_CONVERSATION); 1212 if (conversation != null && conversation.position < 0) { 1213 // Set the position to 0 on this conversation, as we don't know where it is 1214 // in the list 1215 conversation.position = 0; 1216 } 1217 showConversation(conversation); 1218 handled = true; 1219 } 1220 1221 if (!handled) { 1222 // Nothing was saved; just load the account inbox. 1223 loadAccountInbox(); 1224 } 1225 } else if (Intent.ACTION_SEARCH.equals(intent.getAction())) { 1226 if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) { 1227 // Save this search query for future suggestions. 1228 final String query = intent.getStringExtra(SearchManager.QUERY); 1229 final String authority = mContext.getString(R.string.suggestions_authority); 1230 SearchRecentSuggestions suggestions = new SearchRecentSuggestions( 1231 mContext, authority, SuggestionsProvider.MODE); 1232 suggestions.saveRecentQuery(query, null); 1233 if (Utils.showTwoPaneSearchResults(mActivity.getActivityContext())) { 1234 mViewMode.enterSearchResultsConversationMode(); 1235 } else { 1236 mViewMode.enterSearchResultsListMode(); 1237 } 1238 setAccount((Account) intent.getParcelableExtra(Utils.EXTRA_ACCOUNT)); 1239 mActivity.invalidateOptionsMenu(); 1240 restartOptionalLoader(LOADER_RECENT_FOLDERS); 1241 fetchSearchFolder(intent); 1242 } else { 1243 LogUtils.e(LOG_TAG, "Missing account extra from search intent. Finishing"); 1244 mActivity.finish(); 1245 } 1246 } 1247 if (mAccount != null) { 1248 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR); 1249 } 1250 } 1251 1252 /** 1253 * Copy any selected conversations stored in the saved bundle into our selection set, 1254 * triggering {@link ConversationSetObserver} callbacks as our selection set changes. 1255 * 1256 */ 1257 private final void restoreSelectedConversations(Bundle savedState) { 1258 if (savedState == null) { 1259 mSelectedSet.clear(); 1260 return; 1261 } 1262 final ConversationSelectionSet selectedSet = savedState.getParcelable(SAVED_SELECTED_SET); 1263 if (selectedSet == null || selectedSet.isEmpty()) { 1264 mSelectedSet.clear(); 1265 return; 1266 } 1267 1268 // putAll will take care of calling our registered onSetPopulated method 1269 mSelectedSet.putAll(selectedSet); 1270 } 1271 1272 @Override 1273 public SubjectDisplayChanger getSubjectDisplayChanger() { 1274 return mActionBarView; 1275 } 1276 1277 /** 1278 * Children can override this method, but they must call super.showConversation(). 1279 * {@inheritDoc} 1280 */ 1281 @Override 1282 public void showConversation(Conversation conversation) { 1283 // Set the current conversation just in case it wasn't already set. 1284 setCurrentConversation(conversation); 1285 } 1286 1287 /** 1288 * Children can override this method, but they must call super.showWaitForInitialization(). 1289 * {@inheritDoc} 1290 */ 1291 @Override 1292 public void showWaitForInitialization() { 1293 mViewMode.enterWaitingForInitializationMode(); 1294 } 1295 1296 @Override 1297 public void hideWaitForInitialization() { 1298 } 1299 1300 @Override 1301 public void updateWaitMode() { 1302 final FragmentManager manager = mActivity.getFragmentManager(); 1303 final WaitFragment waitFragment = 1304 (WaitFragment)manager.findFragmentByTag(TAG_WAIT); 1305 if (waitFragment != null) { 1306 waitFragment.updateAccount(mAccount); 1307 } 1308 } 1309 1310 /** 1311 * Returns true if we are waiting for the account to sync, and cannot show any folders or 1312 * conversation for the current account yet. 1313 * 1314 */ 1315 public boolean inWaitMode() { 1316 final FragmentManager manager = mActivity.getFragmentManager(); 1317 final WaitFragment waitFragment = 1318 (WaitFragment)manager.findFragmentByTag(TAG_WAIT); 1319 if (waitFragment != null) { 1320 final Account fragmentAccount = waitFragment.getAccount(); 1321 return fragmentAccount.uri.equals(mAccount.uri) && 1322 mViewMode.getMode() == ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION; 1323 } 1324 return false; 1325 } 1326 1327 /** 1328 * Children can override this method, but they must call super.showConversationList(). 1329 * {@inheritDoc} 1330 */ 1331 @Override 1332 public void showConversationList(ConversationListContext listContext) { 1333 } 1334 1335 @Override 1336 public void onConversationSelected(Conversation conversation) { 1337 showConversation(conversation); 1338 if (Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction())) { 1339 mViewMode.enterSearchResultsConversationMode(); 1340 } else { 1341 mViewMode.enterConversationMode(); 1342 } 1343 } 1344 1345 /** 1346 * Set the current conversation. This is the conversation on which all actions are performed. 1347 * Do not modify mCurrentConversation except through this method, which makes it easy to 1348 * perform common actions associated with changing the current conversation. 1349 * @param conversation 1350 */ 1351 @Override 1352 public void setCurrentConversation(Conversation conversation) { 1353 mCurrentConversation = conversation; 1354 mTracker.initialize(mCurrentConversation); 1355 } 1356 1357 /** 1358 * {@inheritDoc} 1359 */ 1360 @Override 1361 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 1362 // Create a loader to listen in on account changes. 1363 switch (id) { 1364 case LOADER_ACCOUNT_CURSOR: 1365 return new CursorLoader(mContext, MailAppProvider.getAccountsUri(), 1366 UIProvider.ACCOUNTS_PROJECTION, null, null, null); 1367 case LOADER_FOLDER_CURSOR: 1368 final CursorLoader loader = new CursorLoader(mContext, mFolder.uri, 1369 UIProvider.FOLDERS_PROJECTION, null, null, null); 1370 loader.setUpdateThrottle(mFolderItemUpdateDelayMs); 1371 return loader; 1372 case LOADER_RECENT_FOLDERS: 1373 if (mAccount != null && mAccount.recentFolderListUri != null) { 1374 return new CursorLoader(mContext, mAccount.recentFolderListUri, 1375 UIProvider.FOLDERS_PROJECTION, null, null, null); 1376 } 1377 break; 1378 case LOADER_ACCOUNT_INBOX: 1379 final Uri defaultInbox = Settings.getDefaultInboxUri(mAccount.settings); 1380 final Uri inboxUri = defaultInbox.equals(Uri.EMPTY) ? 1381 mAccount.folderListUri : defaultInbox; 1382 LogUtils.d(LOG_TAG, "Loading the default inbox: %s", inboxUri); 1383 if (inboxUri != null) { 1384 return new CursorLoader(mContext, inboxUri, UIProvider.FOLDERS_PROJECTION, null, 1385 null, null); 1386 } 1387 break; 1388 case LOADER_SEARCH: 1389 return Folder.forSearchResults(mAccount, 1390 args.getString(ConversationListContext.EXTRA_SEARCH_QUERY), 1391 mActivity.getActivityContext()); 1392 case LOADER_ACCOUNT_UPDATE_CURSOR: 1393 return new CursorLoader(mContext, mAccount.uri, UIProvider.ACCOUNTS_PROJECTION, 1394 null, null, null); 1395 default: 1396 LogUtils.wtf(LOG_TAG, "Loader returned unexpected id: %d", id); 1397 } 1398 return null; 1399 } 1400 1401 @Override 1402 public void onLoaderReset(Loader<Cursor> loader) { 1403 1404 } 1405 1406 /** 1407 * {@link LoaderManager} currently has a bug in 1408 * {@link LoaderManager#restartLoader(int, Bundle, android.app.LoaderManager.LoaderCallbacks)} 1409 * where, if a previous onCreateLoader returned a null loader, this method will NPE. Work around 1410 * this bug by destroying any loaders that may have been created as null (essentially because 1411 * they are optional loads, and may not apply to a particular account). 1412 * <p> 1413 * A simple null check before restarting a loader will not work, because that would not 1414 * give the controller a chance to invalidate UI corresponding the prior loader result. 1415 * 1416 * @param id loader ID to safely restart 1417 */ 1418 private void restartOptionalLoader(int id) { 1419 final LoaderManager lm = mActivity.getLoaderManager(); 1420 lm.destroyLoader(id); 1421 lm.restartLoader(id, Bundle.EMPTY, this); 1422 } 1423 1424 @Override 1425 public void registerConversationListObserver(DataSetObserver observer) { 1426 mConversationListObservable.registerObserver(observer); 1427 } 1428 1429 @Override 1430 public void unregisterConversationListObserver(DataSetObserver observer) { 1431 mConversationListObservable.unregisterObserver(observer); 1432 } 1433 1434 @Override 1435 public void registerFolderObserver(DataSetObserver observer) { 1436 mFolderObservable.registerObserver(observer); 1437 } 1438 1439 @Override 1440 public void unregisterFolderObserver(DataSetObserver observer) { 1441 mFolderObservable.unregisterObserver(observer); 1442 } 1443 1444 /** 1445 * Returns true if the number of accounts is different, or if the current account has been 1446 * removed from the device 1447 * @param accountCursor 1448 * @return 1449 */ 1450 private boolean accountsUpdated(Cursor accountCursor) { 1451 // Check to see if the current account hasn't been set, or the account cursor is empty 1452 if (mAccount == null || !accountCursor.moveToFirst()) { 1453 return true; 1454 } 1455 1456 // Check to see if the number of accounts are different, from the number we saw on the last 1457 // updated 1458 if (mCurrentAccountUris.size() != accountCursor.getCount()) { 1459 return true; 1460 } 1461 1462 // Check to see if the account list is different or if the current account is not found in 1463 // the cursor. 1464 boolean foundCurrentAccount = false; 1465 do { 1466 final Uri accountUri = 1467 Uri.parse(accountCursor.getString(UIProvider.ACCOUNT_URI_COLUMN)); 1468 if (!foundCurrentAccount && mAccount.uri.equals(accountUri)) { 1469 foundCurrentAccount = true; 1470 } 1471 // Is there a new account that we do not know about? 1472 if (!mCurrentAccountUris.contains(accountUri)) { 1473 return true; 1474 } 1475 } while (accountCursor.moveToNext()); 1476 1477 // As long as we found the current account, the list hasn't been updated 1478 return !foundCurrentAccount; 1479 } 1480 1481 /** 1482 * Updates accounts for the app. If the current account is missing, the first 1483 * account in the list is set to the current account (we <em>have</em> to choose something). 1484 * 1485 * @param accounts cursor into the AccountCache 1486 * @return true if the update was successful, false otherwise 1487 */ 1488 private boolean updateAccounts(Cursor accounts) { 1489 if (accounts == null || !accounts.moveToFirst()) { 1490 return false; 1491 } 1492 1493 final Account[] allAccounts = Account.getAllAccounts(accounts); 1494 // A match for the current account's URI in the list of accounts. 1495 Account currentFromList = null; 1496 1497 // Save the uris for the accounts 1498 mCurrentAccountUris.clear(); 1499 for (Account account : allAccounts) { 1500 LogUtils.d(LOG_TAG, "updateAccounts(%s)", account); 1501 mCurrentAccountUris.add(account.uri); 1502 if (mAccount != null && account.uri.equals(mAccount.uri)) { 1503 currentFromList = account; 1504 } 1505 } 1506 1507 // 1. current account is already set and is in allAccounts: 1508 // 1a. It has changed -> load the updated account. 1509 // 2b. It is unchanged -> no-op 1510 // 2. current account is set and is not in allAccounts -> pick first (acct was deleted?) 1511 // 3. saved preference has an account -> pick that one 1512 // 4. otherwise just pick first 1513 1514 boolean accountChanged = false; 1515 /// Assume case 4, initialize to first account, and see if we can find anything better. 1516 Account newAccount = allAccounts[0]; 1517 if (currentFromList != null) { 1518 // Case 1: Current account exists but has changed 1519 if (!currentFromList.equals(mAccount)) { 1520 newAccount = currentFromList; 1521 accountChanged = true; 1522 } 1523 // Case 1b: else, current account is unchanged: nothing to do. 1524 } else { 1525 // Case 2: Current account is not in allAccounts, the account needs to change. 1526 accountChanged = true; 1527 if (mAccount == null) { 1528 // Case 3: Check for last viewed account, and check if it exists in the list. 1529 final String lastAccountUri = MailAppProvider.getInstance().getLastViewedAccount(); 1530 if (lastAccountUri != null) { 1531 for (final Account account : allAccounts) { 1532 if (lastAccountUri.equals(account.uri.toString())) { 1533 newAccount = account; 1534 break; 1535 } 1536 } 1537 } 1538 } 1539 } 1540 if (accountChanged) { 1541 onAccountChanged(newAccount); 1542 } 1543 // Whether we have updated the current account or not, we need to update the list of 1544 // accounts in the ActionBar. 1545 mActionBarView.setAccounts(allAccounts); 1546 return (allAccounts.length > 0); 1547 } 1548 1549 private void disableNotifications() { 1550 mNewEmailReceiver.activate(mContext, this); 1551 } 1552 1553 private void enableNotifications() { 1554 mNewEmailReceiver.deactivate(); 1555 } 1556 1557 private void disableNotificationsOnAccountChange(Account account) { 1558 // If the new mail suppression receiver is activated for a different account, we want to 1559 // activate it for the new account. 1560 if (mNewEmailReceiver.activated() && 1561 !mNewEmailReceiver.notificationsDisabledForAccount(account)) { 1562 // Deactivate the current receiver, otherwise multiple receivers may be registered. 1563 mNewEmailReceiver.deactivate(); 1564 mNewEmailReceiver.activate(mContext, this); 1565 } 1566 } 1567 1568 /** 1569 * {@inheritDoc} 1570 */ 1571 @Override 1572 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 1573 // We want to reinitialize only if we haven't ever been initialized, or 1574 // if the current account has vanished. 1575 if (data == null) { 1576 LogUtils.e(LOG_TAG, "Received null cursor from loader id: %d", loader.getId()); 1577 } 1578 switch (loader.getId()) { 1579 case LOADER_ACCOUNT_CURSOR: 1580 // If the account list is not null, and the account list cursor is empty, 1581 // we need to start the specified activity. 1582 if (data != null && data.getCount() == 0) { 1583 // If an empty cursor is returned, the MailAppProvider is indicating that 1584 // no accounts have been specified. We want to navigate to the "add account" 1585 // activity that will handle the intent returned by the MailAppProvider 1586 1587 // If the MailAppProvider believes that all accounts have been loaded, and the 1588 // account list is still empty, we want to prompt the user to add an account 1589 final Bundle extras = data.getExtras(); 1590 final boolean accountsLoaded = 1591 extras.getInt(AccountCursorExtraKeys.ACCOUNTS_LOADED) != 0; 1592 1593 if (accountsLoaded) { 1594 final Intent noAccountIntent = MailAppProvider.getNoAccountIntent(mContext); 1595 if (noAccountIntent != null) { 1596 mActivity.startActivityForResult(noAccountIntent, 1597 ADD_ACCOUNT_REQUEST_CODE); 1598 } 1599 } 1600 } else { 1601 final boolean accountListUpdated = accountsUpdated(data); 1602 if (!isLoaderInitialized || accountListUpdated) { 1603 isLoaderInitialized = updateAccounts(data); 1604 } 1605 } 1606 break; 1607 case LOADER_ACCOUNT_UPDATE_CURSOR: 1608 // We have gotten an update for current account. 1609 1610 // Make sure that this is an update for the current account 1611 if (data != null && data.moveToFirst()) { 1612 final Account updatedAccount = new Account(data); 1613 1614 if (updatedAccount.uri.equals(mAccount.uri)) { 1615 // Keep a reference to the previous settings object 1616 final Settings previousSettings = mAccount.settings; 1617 1618 // Update the controller's reference to the current account 1619 mAccount = updatedAccount; 1620 LogUtils.d(LOG_TAG, "AbstractActivityController.onLoadFinished(): " 1621 + "mAccount = %s", mAccount.uri); 1622 1623 // Only notify about a settings change if something differs 1624 if (!Objects.equal(mAccount.settings, previousSettings)) { 1625 mAccountObservers.notifyChanged(); 1626 } 1627 1628 // Got an update for the current account 1629 final boolean inWaitingMode = inWaitMode(); 1630 if (!updatedAccount.isAccountIntialized() && !inWaitingMode) { 1631 // Transition to waiting mode 1632 showWaitForInitialization(); 1633 } else if (updatedAccount.isAccountIntialized()) { 1634 if (inWaitingMode) { 1635 // Dismiss waiting mode 1636 hideWaitForInitialization(); 1637 } 1638 } else if (!updatedAccount.isAccountIntialized() && inWaitingMode) { 1639 // Update the WaitFragment's account object 1640 updateWaitMode(); 1641 } 1642 } else { 1643 LogUtils.e(LOG_TAG, "Got update for account: %s with current account: %s", 1644 updatedAccount.uri, mAccount.uri); 1645 // We need to restart the loader, so the correct account information will 1646 // be returned 1647 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR); 1648 } 1649 } 1650 break; 1651 case LOADER_FOLDER_CURSOR: 1652 // Check status of the cursor. 1653 if (data != null && data.moveToFirst()) { 1654 final Folder folder = new Folder(data); 1655 LogUtils.d(LOG_TAG, "FOLDER STATUS = %d", folder.syncStatus); 1656 1657 mFolder = folder; 1658 mFolderObservable.notifyChanged(); 1659 1660 } else { 1661 LogUtils.d(LOG_TAG, "Unable to get the folder %s", 1662 mFolder != null ? mAccount.name : ""); 1663 } 1664 break; 1665 case LOADER_RECENT_FOLDERS: 1666 // No recent folders and we are running on a phone? Populate the default recents. 1667 if (data != null && data.getCount() == 0 && !Utils.useTabletUI(mContext)) { 1668 final class PopulateDefault extends AsyncTask<Uri, Void, Void> { 1669 @Override 1670 protected Void doInBackground(Uri... uri) { 1671 // Asking for an update on the URI and ignore the result. 1672 final ContentResolver resolver = mContext.getContentResolver(); 1673 resolver.update(uri[0], null, null, null); 1674 return null; 1675 } 1676 } 1677 final Uri uri = mAccount.defaultRecentFolderListUri; 1678 LogUtils.v(LOG_TAG, "Default recents at %s", uri); 1679 new PopulateDefault().execute(uri); 1680 break; 1681 } 1682 LogUtils.v(LOG_TAG, "Reading recent folders from the cursor."); 1683 mRecentFolderList.loadFromUiProvider(data); 1684 mActionBarView.requestRecentFoldersAndRedraw(); 1685 break; 1686 case LOADER_ACCOUNT_INBOX: 1687 if (data != null && !data.isClosed() && data.moveToFirst()) { 1688 Folder inbox = new Folder(data); 1689 onFolderChanged(inbox); 1690 // Just want to get the inbox, don't care about updates to it 1691 // as this will be tracked by the folder change listener. 1692 mActivity.getLoaderManager().destroyLoader(LOADER_ACCOUNT_INBOX); 1693 } else { 1694 LogUtils.d(LOG_TAG, "Unable to get the account inbox for account %s", 1695 mAccount != null ? mAccount.name : ""); 1696 } 1697 break; 1698 case LOADER_SEARCH: 1699 data.moveToFirst(); 1700 Folder search = new Folder(data); 1701 updateFolder(search); 1702 mConvListContext = ConversationListContext.forSearchQuery(mAccount, mFolder, 1703 mActivity.getIntent() 1704 .getStringExtra(UIProvider.SearchQueryParameters.QUERY)); 1705 showConversationList(mConvListContext); 1706 mActivity.invalidateOptionsMenu(); 1707 mActivity.getLoaderManager().destroyLoader(LOADER_SEARCH); 1708 break; 1709 } 1710 } 1711 1712 /** 1713 * Destructive actions on Conversations. This class should only be created by controllers, and 1714 * clients should only require {@link DestructiveAction}s, not specific implementations of the. 1715 * Only the controllers should know what kind of destructive actions are being created. 1716 */ 1717 public class ConversationAction implements DestructiveAction { 1718 /** 1719 * The action to be performed. This is specified as the resource ID of the menu item 1720 * corresponding to this action: R.id.delete, R.id.report_spam, etc. 1721 */ 1722 private final int mAction; 1723 /** The action will act upon these conversations */ 1724 private final Collection<Conversation> mTarget; 1725 /** Whether this destructive action has already been performed */ 1726 private boolean mCompleted; 1727 /** Whether this is an action on the currently selected set. */ 1728 private final boolean mIsSelectedSet; 1729 1730 /** 1731 * Create a listener object. action is one of four constants: R.id.y_button (archive), 1732 * R.id.delete , R.id.mute, and R.id.report_spam. 1733 * @param action 1734 * @param target Conversation that we want to apply the action to. 1735 * @param isBatch whether the conversations are in the currently selected batch set. 1736 */ 1737 public ConversationAction(int action, Collection<Conversation> target, boolean isBatch) { 1738 mAction = action; 1739 mTarget = ImmutableList.copyOf(target); 1740 mIsSelectedSet = isBatch; 1741 } 1742 1743 /** 1744 * The action common to child classes. This performs the action specified in the constructor 1745 * on the conversations given here. 1746 */ 1747 @Override 1748 public void performAction() { 1749 if (isPerformed()) { 1750 return; 1751 } 1752 boolean undoEnabled = mAccount.supportsCapability(AccountCapabilities.UNDO); 1753 1754 // Are we destroying the currently shown conversation? Show the next one. 1755 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)){ 1756 LogUtils.d(LOG_TAG, "ConversationAction.performAction():" 1757 + "\nmTarget=%s\nCurrent=%s", 1758 Conversation.toString(mTarget), mCurrentConversation); 1759 } 1760 1761 switch (mAction) { 1762 case R.id.archive: 1763 LogUtils.d(LOG_TAG, "Archiving"); 1764 mConversationListCursor.archive(mContext, mTarget); 1765 break; 1766 case R.id.delete: 1767 LogUtils.d(LOG_TAG, "Deleting"); 1768 mConversationListCursor.delete(mContext, mTarget); 1769 if (mFolder.supportsCapability(FolderCapabilities.DELETE_ACTION_FINAL)) { 1770 undoEnabled = false; 1771 } 1772 break; 1773 case R.id.mute: 1774 LogUtils.d(LOG_TAG, "Muting"); 1775 if (mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)) { 1776 for (Conversation c : mTarget) { 1777 c.localDeleteOnUpdate = true; 1778 } 1779 } 1780 mConversationListCursor.mute(mContext, mTarget); 1781 break; 1782 case R.id.report_spam: 1783 LogUtils.d(LOG_TAG, "Reporting spam"); 1784 mConversationListCursor.reportSpam(mContext, mTarget); 1785 break; 1786 case R.id.mark_not_spam: 1787 LogUtils.d(LOG_TAG, "Marking not spam"); 1788 mConversationListCursor.reportNotSpam(mContext, mTarget); 1789 break; 1790 case R.id.report_phishing: 1791 LogUtils.d(LOG_TAG, "Reporting phishing"); 1792 mConversationListCursor.reportPhishing(mContext, mTarget); 1793 break; 1794 case R.id.remove_star: 1795 LogUtils.d(LOG_TAG, "Removing star"); 1796 // Star removal is destructive in the Starred folder. 1797 mConversationListCursor.updateBoolean(mContext, mTarget, 1798 ConversationColumns.STARRED, false); 1799 break; 1800 case R.id.mark_not_important: 1801 LogUtils.d(LOG_TAG, "Marking not-important"); 1802 // Marking not important is destructive in a mailbox 1803 // containing only important messages 1804 if (mFolder != null && mFolder.isImportantOnly()) { 1805 for (Conversation conv : mTarget) { 1806 conv.localDeleteOnUpdate = true; 1807 } 1808 } 1809 mConversationListCursor.updateInt(mContext, mTarget, 1810 ConversationColumns.PRIORITY, UIProvider.ConversationPriority.LOW); 1811 break; 1812 } 1813 if (undoEnabled) { 1814 onUndoAvailable(new ToastBarOperation(mTarget.size(), mAction, 1815 ToastBarOperation.UNDO)); 1816 } 1817 refreshConversationList(); 1818 if (mIsSelectedSet) { 1819 mSelectedSet.clear(); 1820 } 1821 } 1822 1823 /** 1824 * Returns true if this action has been performed, false otherwise. 1825 * 1826 */ 1827 private synchronized boolean isPerformed() { 1828 if (mCompleted) { 1829 return true; 1830 } 1831 mCompleted = true; 1832 return false; 1833 } 1834 } 1835 1836 /** 1837 * Get a destructive action for a menu action. 1838 * This is a temporary method, to control the profusion of {@link DestructiveAction} classes 1839 * that are created. Please do not copy this paradigm. 1840 * @param action the resource ID of the menu action: R.id.delete, for example 1841 * @param target the conversations to act upon. 1842 * @return a {@link DestructiveAction} that performs the specified action. 1843 */ 1844 private final DestructiveAction getAction(int action, Collection<Conversation> target) { 1845 final DestructiveAction da = new ConversationAction(action, target, false); 1846 registerDestructiveAction(da); 1847 return da; 1848 } 1849 1850 // Called from the FolderSelectionDialog after a user is done selecting folders to assign the 1851 // conversations to. 1852 @Override 1853 public final void assignFolder(Collection<FolderOperation> folderOps, 1854 Collection<Conversation> target, boolean batch, boolean showUndo) { 1855 // Actions are destructive only when the current folder can be assigned 1856 // to (which is the same as being able to un-assign a conversation from the folder) and 1857 // when the list of folders contains the current folder. 1858 final boolean isDestructive = mFolder 1859 .supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 1860 && FolderOperation.isDestructive(folderOps, mFolder); 1861 LogUtils.d(LOG_TAG, "onFolderChangesCommit: isDestructive = %b", isDestructive); 1862 if (isDestructive) { 1863 for (final Conversation c : target) { 1864 c.localDeleteOnUpdate = true; 1865 } 1866 } 1867 final DestructiveAction folderChange = getFolderChange(target, folderOps, isDestructive, 1868 batch, showUndo); 1869 // Update the UI elements depending no their visibility and availability 1870 // TODO(viki): Consolidate this into a single method requestDelete. 1871 if (isDestructive) { 1872 delete(target, folderChange); 1873 } else { 1874 requestUpdate(target, folderChange); 1875 } 1876 } 1877 1878 @Override 1879 public final void onRefreshRequired() { 1880 if (isAnimating()) { 1881 LogUtils.d(LOG_TAG, "onRefreshRequired: delay until animating done"); 1882 return; 1883 } 1884 // Refresh the query in the background 1885 if (mConversationListCursor.isRefreshRequired()) { 1886 mConversationListCursor.refresh(); 1887 } 1888 } 1889 1890 private boolean isAnimating() { 1891 boolean isAnimating = false; 1892 ConversationListFragment convListFragment = getConversationListFragment(); 1893 if (convListFragment != null) { 1894 AnimatedAdapter adapter = convListFragment.getAnimatedAdapter(); 1895 if (adapter != null) { 1896 isAnimating = adapter.isAnimating(); 1897 } 1898 } 1899 return isAnimating; 1900 } 1901 1902 /** 1903 * Called when the {@link ConversationCursor} is changed or has new data in it. 1904 * <p> 1905 * {@inheritDoc} 1906 */ 1907 @Override 1908 public final void onRefreshReady() { 1909 if (!isAnimating()) { 1910 // Swap cursors 1911 mConversationListCursor.sync(); 1912 } 1913 mTracker.onCursorUpdated(); 1914 } 1915 1916 @Override 1917 public final void onDataSetChanged() { 1918 updateConversationListFragment(); 1919 mConversationListObservable.notifyChanged(); 1920 } 1921 1922 /** 1923 * If the Conversation List Fragment is visible, updates the fragment. 1924 */ 1925 private final void updateConversationListFragment() { 1926 final ConversationListFragment convList = getConversationListFragment(); 1927 if (convList != null) { 1928 refreshConversationList(); 1929 if (convList.isVisible()) { 1930 Utils.setConversationCursorVisibility(mConversationListCursor, true); 1931 } 1932 } 1933 } 1934 1935 /** 1936 * This class handles throttled refresh of the conversation list 1937 */ 1938 static class RefreshTimerTask extends TimerTask { 1939 final Handler mHandler; 1940 final AbstractActivityController mController; 1941 1942 RefreshTimerTask(AbstractActivityController controller, Handler handler) { 1943 mHandler = handler; 1944 mController = controller; 1945 } 1946 1947 @Override 1948 public void run() { 1949 mHandler.post(new Runnable() { 1950 @Override 1951 public void run() { 1952 LogUtils.d(LOG_TAG, "Delay done... calling onRefreshRequired"); 1953 mController.onRefreshRequired(); 1954 }}); 1955 } 1956 } 1957 1958 /** 1959 * Cancel the refresh task, if it's running 1960 */ 1961 private void cancelRefreshTask () { 1962 if (mConversationListRefreshTask != null) { 1963 mConversationListRefreshTask.cancel(); 1964 mConversationListRefreshTask = null; 1965 } 1966 } 1967 1968 @Override 1969 public void onAnimationEnd(AnimatedAdapter animatedAdapter) { 1970 if (mConversationListCursor.isRefreshReady()) { 1971 LogUtils.d(LOG_TAG, "Stop scrolling: try sync"); 1972 onRefreshReady(); 1973 } 1974 1975 if (mConversationListCursor.isRefreshRequired()) { 1976 LogUtils.d(LOG_TAG, "Stop scrolling: refresh"); 1977 mConversationListCursor.refresh(); 1978 } 1979 } 1980 1981 @Override 1982 public void onSetEmpty() { 1983 } 1984 1985 @Override 1986 public void onSetPopulated(ConversationSelectionSet set) { 1987 final ConversationListFragment convList = getConversationListFragment(); 1988 if (convList == null) { 1989 return; 1990 } 1991 mCabActionMenu = new SelectedConversationsActionMenu(mActivity, set, mFolder, 1992 (SwipeableListView) convList.getListView()); 1993 enableCabMode(); 1994 } 1995 1996 @Override 1997 public void onSetChanged(ConversationSelectionSet set) { 1998 // Do nothing. We don't care about changes to the set. 1999 } 2000 2001 @Override 2002 public ConversationSelectionSet getSelectedSet() { 2003 return mSelectedSet; 2004 } 2005 2006 /** 2007 * Disable the Contextual Action Bar (CAB). The selected set is not changed. 2008 */ 2009 protected void disableCabMode() { 2010 // Commit any previous destructive actions when entering/ exiting CAB mode. 2011 commitDestructiveActions(); 2012 if (mCabActionMenu != null) { 2013 mCabActionMenu.deactivate(); 2014 } 2015 } 2016 2017 /** 2018 * Re-enable the CAB menu if required. The selection set is not changed. 2019 */ 2020 protected void enableCabMode() { 2021 // Commit any previous destructive actions when entering/ exiting CAB mode. 2022 commitDestructiveActions(); 2023 if (mCabActionMenu != null) { 2024 mCabActionMenu.activate(); 2025 } 2026 } 2027 2028 /** 2029 * Unselect conversations and exit CAB mode. 2030 */ 2031 protected final void exitCabMode() { 2032 mSelectedSet.clear(); 2033 } 2034 2035 @Override 2036 public void startSearch() { 2037 if (mAccount == null) { 2038 // We cannot search if there is no account. Drop the request to the floor. 2039 LogUtils.d(LOG_TAG, "AbstractActivityController.startSearch(): null account"); 2040 return; 2041 } 2042 if (mAccount.supportsCapability(UIProvider.AccountCapabilities.LOCAL_SEARCH) 2043 | mAccount.supportsCapability(UIProvider.AccountCapabilities.SERVER_SEARCH)) { 2044 onSearchRequested(mActionBarView.getQuery()); 2045 } else { 2046 Toast.makeText(mActivity.getActivityContext(), mActivity.getActivityContext() 2047 .getString(R.string.search_unsupported), Toast.LENGTH_SHORT).show(); 2048 } 2049 } 2050 2051 @Override 2052 public void exitSearchMode() { 2053 if (mViewMode.getMode() == ViewMode.SEARCH_RESULTS_LIST) { 2054 mActivity.finish(); 2055 } 2056 } 2057 2058 /** 2059 * Supports dragging conversations to a folder. 2060 */ 2061 @Override 2062 public boolean supportsDrag(DragEvent event, Folder folder) { 2063 return (folder != null 2064 && event != null 2065 && event.getClipDescription() != null 2066 && folder.supportsCapability 2067 (UIProvider.FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 2068 && folder.supportsCapability 2069 (UIProvider.FolderCapabilities.CAN_HOLD_MAIL) 2070 && !mFolder.uri.equals(folder.uri)); 2071 } 2072 2073 /** 2074 * Handles dropping conversations to a folder. 2075 */ 2076 @Override 2077 public void handleDrop(DragEvent event, final Folder folder) { 2078 if (!supportsDrag(event, folder)) { 2079 return; 2080 } 2081 final Collection<Conversation> conversations = mSelectedSet.values(); 2082 final Collection<FolderOperation> dropTarget = FolderOperation.listOf(new FolderOperation( 2083 folder, true)); 2084 // Drag and drop is destructive: we remove conversations from the 2085 // current folder. 2086 final DestructiveAction action = getFolderChange(conversations, dropTarget, true, true, 2087 true); 2088 delete(conversations, action); 2089 } 2090 2091 @Override 2092 public void onTouchEvent(MotionEvent event) { 2093 if (event.getAction() == MotionEvent.ACTION_DOWN) { 2094 if (mToastBar != null && !mToastBar.isEventInToastBar(event)) { 2095 mToastBar.hide(true); 2096 } 2097 } 2098 } 2099 2100 @Override 2101 public void onConversationSeen(Conversation conv) { 2102 mPagerController.onConversationSeen(conv); 2103 } 2104 2105 private class ConversationListLoaderCallbacks implements 2106 LoaderManager.LoaderCallbacks<ConversationCursor> { 2107 2108 @Override 2109 public Loader<ConversationCursor> onCreateLoader(int id, Bundle args) { 2110 Loader<ConversationCursor> result = new ConversationCursorLoader((Activity) mActivity, 2111 mAccount, mFolder.conversationListUri, mFolder.name); 2112 return result; 2113 } 2114 2115 @Override 2116 public void onLoadFinished(Loader<ConversationCursor> loader, ConversationCursor data) { 2117 LogUtils.d(LOG_TAG, "IN AAC.ConversationCursor.onLoadFinished, data=%s loader=%s", 2118 data, loader); 2119 // Clear our all pending destructive actions before swapping the conversation cursor 2120 destroyPending(null); 2121 mConversationListCursor = data; 2122 mConversationListCursor.addListener(AbstractActivityController.this); 2123 2124 mTracker.onCursorUpdated(); 2125 2126 mConversationListObservable.notifyChanged(); 2127 // Register the AbstractActivityController as a listener to changes in 2128 // data in the cursor. 2129 final ConversationListFragment convList = getConversationListFragment(); 2130 if (convList != null) { 2131 convList.onCursorUpdated(); 2132 2133 if (convList.isVisible()) { 2134 // The conversation list is visible. 2135 Utils.setConversationCursorVisibility(mConversationListCursor, true); 2136 } 2137 } 2138 // Shown for search results in two-pane mode only. 2139 if (shouldShowFirstConversation()) { 2140 if (mConversationListCursor.getCount() > 0) { 2141 mConversationListCursor.moveToPosition(0); 2142 final Conversation conv = new Conversation(mConversationListCursor); 2143 conv.position = 0; 2144 onConversationSelected(conv); 2145 } 2146 } 2147 } 2148 2149 @Override 2150 public void onLoaderReset(Loader<ConversationCursor> loader) { 2151 LogUtils.d(LOG_TAG, "IN AAC.ConversationCursor.onLoaderReset, data=%s loader=%s", 2152 mConversationListCursor, loader); 2153 2154 if (mConversationListCursor != null) { 2155 // Unregister the listener 2156 mConversationListCursor.removeListener(AbstractActivityController.this); 2157 mConversationListCursor = null; 2158 2159 // Inform anyone who is interested about the change 2160 mTracker.onCursorUpdated(); 2161 mConversationListObservable.notifyChanged(); 2162 2163 final ConversationListFragment convList = getConversationListFragment(); 2164 if (convList != null) { 2165 convList.onCursorUpdated(); 2166 } 2167 } 2168 } 2169 } 2170 2171 /** 2172 * Destroy the pending {@link DestructiveAction} till now and assign the given action as the 2173 * next destructive action.. 2174 * @param nextAction the next destructive action to be performed. This can be null. 2175 */ 2176 private final void destroyPending(DestructiveAction nextAction) { 2177 // If there is a pending action, perform that first. 2178 if (mPendingDestruction != null) { 2179 mPendingDestruction.performAction(); 2180 } 2181 mPendingDestruction = nextAction; 2182 } 2183 2184 /** 2185 * Register a destructive action with the controller. This performs the previous destructive 2186 * action as a side effect. This method is final because we don't want the child classes to 2187 * embellish this method any more. 2188 * @param action 2189 */ 2190 private final void registerDestructiveAction(DestructiveAction action) { 2191 // TODO(viki): This is not a good idea. The best solution is for clients to request a 2192 // destructive action from the controller and for the controller to own the action. This is 2193 // a half-way solution while refactoring DestructiveAction. 2194 destroyPending(action); 2195 return; 2196 } 2197 2198 @Override 2199 public final DestructiveAction getBatchAction(int action) { 2200 final DestructiveAction da = new ConversationAction(action, mSelectedSet.values(), true); 2201 registerDestructiveAction(da); 2202 return da; 2203 } 2204 2205 @Override 2206 public final DestructiveAction getDeferredBatchAction(int action) { 2207 final DestructiveAction da = new ConversationAction(action, mSelectedSet.values(), true); 2208 return da; 2209 } 2210 2211 /** 2212 * Class to change the folders that are assigned to a set of conversations. This is destructive 2213 * because the user can remove the current folder from the conversation, in which case it has 2214 * to be animated away from the current folder. 2215 */ 2216 private class FolderDestruction implements DestructiveAction { 2217 private final Collection<Conversation> mTarget; 2218 private final ArrayList<FolderOperation> mFolderOps = new ArrayList<FolderOperation>(); 2219 private final boolean mIsDestructive; 2220 /** Whether this destructive action has already been performed */ 2221 private boolean mCompleted; 2222 private boolean mIsSelectedSet; 2223 private boolean mShowUndo; 2224 private int mAction; 2225 2226 /** 2227 * Create a new folder destruction object to act on the given conversations. 2228 * @param target 2229 */ 2230 private FolderDestruction(final Collection<Conversation> target, 2231 final Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 2232 boolean showUndo, int action) { 2233 mTarget = ImmutableList.copyOf(target); 2234 mFolderOps.addAll(folders); 2235 mIsDestructive = isDestructive; 2236 mIsSelectedSet = isBatch; 2237 mShowUndo = showUndo; 2238 mAction = action; 2239 } 2240 2241 @Override 2242 public void performAction() { 2243 if (isPerformed()) { 2244 return; 2245 } 2246 if (mIsDestructive && mShowUndo) { 2247 ToastBarOperation undoOp = new ToastBarOperation(mTarget.size(), 2248 mAction, ToastBarOperation.UNDO); 2249 onUndoAvailable(undoOp); 2250 } 2251 // For each conversation, for each operation, add/ remove the 2252 // appropriate folders. 2253 for (Conversation target : mTarget) { 2254 HashMap<Uri, Folder> targetFolders = Folder 2255 .hashMapForFolders(target.getRawFolders()); 2256 if (mIsDestructive) { 2257 target.localDeleteOnUpdate = true; 2258 } 2259 for (FolderOperation op : mFolderOps) { 2260 if (op.mAdd) { 2261 targetFolders.put(op.mFolder.uri, op.mFolder); 2262 } else { 2263 targetFolders.remove(op.mFolder.uri); 2264 } 2265 } 2266 target.setRawFolders(Folder.getSerializedFolderString(targetFolders.values())); 2267 mConversationListCursor.updateString(mContext, Conversation.listOf(target), 2268 Conversation.UPDATE_FOLDER_COLUMN, target.getRawFoldersString()); 2269 } 2270 refreshConversationList(); 2271 if (mIsSelectedSet) { 2272 mSelectedSet.clear(); 2273 } 2274 } 2275 2276 /** 2277 * Returns true if this action has been performed, false otherwise. 2278 * 2279 */ 2280 private synchronized boolean isPerformed() { 2281 if (mCompleted) { 2282 return true; 2283 } 2284 mCompleted = true; 2285 return false; 2286 } 2287 } 2288 2289 private final DestructiveAction getFolderChange(Collection<Conversation> target, 2290 Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 2291 boolean showUndo) { 2292 final DestructiveAction da = new FolderDestruction(target, folders, isDestructive, isBatch, 2293 showUndo, R.id.change_folder); 2294 registerDestructiveAction(da); 2295 return da; 2296 } 2297 2298 @Override 2299 public final DestructiveAction getDeferredRemoveFolder(Collection<Conversation> target, 2300 Folder toRemove, boolean isDestructive, boolean isBatch, 2301 boolean showUndo) { 2302 Collection<FolderOperation> folderOps = new ArrayList<FolderOperation>(); 2303 folderOps.add(new FolderOperation(toRemove, false)); 2304 return new FolderDestruction(target, folderOps, isDestructive, isBatch, 2305 showUndo, R.id.remove_folder); 2306 } 2307 2308 private final DestructiveAction getRemoveFolder(Collection<Conversation> target, 2309 Folder toRemove, boolean isDestructive, boolean isBatch, 2310 boolean showUndo) { 2311 Collection<FolderOperation> folderOps = new ArrayList<FolderOperation>(); 2312 folderOps.add(new FolderOperation(toRemove, false)); 2313 DestructiveAction da = new FolderDestruction(target, folderOps, isDestructive, isBatch, 2314 showUndo, R.id.remove_folder); 2315 registerDestructiveAction(da); 2316 return da; 2317 } 2318 2319 @Override 2320 public final void refreshConversationList() { 2321 final ConversationListFragment convList = getConversationListFragment(); 2322 if (convList == null) { 2323 return; 2324 } 2325 convList.requestListRefresh(); 2326 } 2327 2328 protected final ActionClickedListener getUndoClickedListener( 2329 final AnimatedAdapter listAdapter) { 2330 return new ActionClickedListener() { 2331 @Override 2332 public void onActionClicked() { 2333 if (mAccount.undoUri != null) { 2334 // NOTE: We might want undo to return the messages affected, in which case 2335 // the resulting cursor might be interesting... 2336 // TODO: Use UIProvider.SEQUENCE_QUERY_PARAMETER to indicate the set of 2337 // commands to undo 2338 if (mConversationListCursor != null) { 2339 mConversationListCursor.undo( 2340 mActivity.getActivityContext(), mAccount.undoUri); 2341 } 2342 if (listAdapter != null) { 2343 listAdapter.setUndo(true); 2344 } 2345 } 2346 } 2347 }; 2348 } 2349 2350 protected final void showErrorToast(final Folder folder, boolean replaceVisibleToast) { 2351 mToastBar.setConversationMode(false); 2352 2353 ActionClickedListener listener = null; 2354 int actionTextResourceId; 2355 final int lastSyncResult = folder.lastSyncResult; 2356 switch (lastSyncResult) { 2357 case UIProvider.LastSyncResult.CONNECTION_ERROR: 2358 listener = getRetryClickedListener(folder); 2359 actionTextResourceId = R.string.retry; 2360 break; 2361 case UIProvider.LastSyncResult.AUTH_ERROR: 2362 listener = getSignInClickedListener(); 2363 actionTextResourceId = R.string.signin; 2364 break; 2365 case UIProvider.LastSyncResult.SECURITY_ERROR: 2366 return; // Currently we do nothing for security errors. 2367 case UIProvider.LastSyncResult.STORAGE_ERROR: 2368 listener = getStorageErrorClickedListener(); 2369 actionTextResourceId = R.string.info; 2370 break; 2371 case UIProvider.LastSyncResult.INTERNAL_ERROR: 2372 listener = getInternalErrorClickedListener(); 2373 actionTextResourceId = R.string.report; 2374 break; 2375 default: 2376 return; 2377 } 2378 mToastBar.show( 2379 listener, 2380 R.drawable.ic_alert_white, 2381 Utils.getSyncStatusText(mActivity.getActivityContext(), 2382 lastSyncResult), 2383 false, /* showActionIcon */ 2384 actionTextResourceId, 2385 replaceVisibleToast, 2386 new ToastBarOperation(1, 0, ToastBarOperation.ERROR)); 2387 } 2388 2389 private ActionClickedListener getRetryClickedListener(final Folder folder) { 2390 return new ActionClickedListener() { 2391 @Override 2392 public void onActionClicked() { 2393 final Uri uri = folder.refreshUri; 2394 2395 if (uri != null) { 2396 if (mFolderSyncTask != null) { 2397 mFolderSyncTask.cancel(true); 2398 } 2399 mFolderSyncTask = new AsyncRefreshTask(mActivity.getActivityContext(), uri); 2400 mFolderSyncTask.execute(); 2401 } 2402 } 2403 }; 2404 } 2405 2406 private ActionClickedListener getSignInClickedListener() { 2407 return new ActionClickedListener() { 2408 @Override 2409 public void onActionClicked() { 2410 // TODO - have pressing Sign-in actually 2411 // allow the user to update their credentials 2412 // Needs to also be done in ConversationListFooterView 2413 } 2414 }; 2415 } 2416 2417 private ActionClickedListener getStorageErrorClickedListener() { 2418 return new ActionClickedListener() { 2419 @Override 2420 public void onActionClicked() { 2421 DialogFragment fragment = (DialogFragment) 2422 mFragmentManager.findFragmentByTag(SYNC_ERROR_DIALOG_FRAGMENT_TAG); 2423 if (fragment == null) { 2424 fragment = SyncErrorDialogFragment.newInstance(); 2425 } 2426 fragment.show(mFragmentManager, SYNC_ERROR_DIALOG_FRAGMENT_TAG); 2427 } 2428 }; 2429 } 2430 2431 private ActionClickedListener getInternalErrorClickedListener() { 2432 return new ActionClickedListener() { 2433 @Override 2434 public void onActionClicked() { 2435 // TODO - have pressing report actually do something 2436 // Needs to also be done in ConversationListFooterView 2437 } 2438 }; 2439 } 2440} 2441