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