AbstractActivityController.java revision b39aaf53a555c1046ef31b3fecf15d086acca013
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.DialogInterface.OnClickListener; 37import android.content.Intent; 38import android.content.Loader; 39import android.content.res.Resources; 40import android.database.Cursor; 41import android.database.DataSetObservable; 42import android.database.DataSetObserver; 43import android.net.Uri; 44import android.os.AsyncTask; 45import android.os.Bundle; 46import android.os.Handler; 47import android.provider.SearchRecentSuggestions; 48import android.view.DragEvent; 49import android.view.KeyEvent; 50import android.view.LayoutInflater; 51import android.view.Menu; 52import android.view.MenuInflater; 53import android.view.MenuItem; 54import android.view.MotionEvent; 55import android.view.View; 56import android.widget.Toast; 57 58import com.android.mail.ConversationListContext; 59import com.android.mail.MailLogService; 60import com.android.mail.R; 61import com.android.mail.browse.ConfirmDialogFragment; 62import com.android.mail.browse.ConversationCursor; 63import com.android.mail.browse.ConversationCursor.ConversationOperation; 64import com.android.mail.browse.ConversationItemViewModel; 65import com.android.mail.browse.ConversationPagerController; 66import com.android.mail.browse.MessageCursor.ConversationMessage; 67import com.android.mail.browse.SelectedConversationsActionMenu; 68import com.android.mail.browse.SyncErrorDialogFragment; 69import com.android.mail.compose.ComposeActivity; 70import com.android.mail.providers.Account; 71import com.android.mail.providers.Conversation; 72import com.android.mail.providers.ConversationInfo; 73import com.android.mail.providers.Folder; 74import com.android.mail.providers.FolderWatcher; 75import com.android.mail.providers.MailAppProvider; 76import com.android.mail.providers.Settings; 77import com.android.mail.providers.SuggestionsProvider; 78import com.android.mail.providers.UIProvider; 79import com.android.mail.providers.UIProvider.AccountCapabilities; 80import com.android.mail.providers.UIProvider.AccountColumns; 81import com.android.mail.providers.UIProvider.AccountCursorExtraKeys; 82import com.android.mail.providers.UIProvider.AutoAdvance; 83import com.android.mail.providers.UIProvider.ConversationColumns; 84import com.android.mail.providers.UIProvider.ConversationOperations; 85import com.android.mail.providers.UIProvider.FolderCapabilities; 86import com.android.mail.ui.ActionableToastBar.ActionClickedListener; 87import com.android.mail.utils.ContentProviderTask; 88import com.android.mail.utils.LogTag; 89import com.android.mail.utils.LogUtils; 90import com.android.mail.utils.NotificationActionUtils; 91import com.android.mail.utils.Utils; 92import com.android.mail.utils.VeiledAddressMatcher; 93 94import com.google.common.base.Objects; 95import com.google.common.collect.ImmutableList; 96import com.google.common.collect.Lists; 97import com.google.common.collect.Sets; 98 99import java.util.ArrayList; 100import java.util.Arrays; 101import java.util.Collection; 102import java.util.Collections; 103import java.util.Deque; 104import java.util.HashMap; 105import java.util.List; 106import java.util.Set; 107import java.util.TimerTask; 108 109 110/** 111 * This is an abstract implementation of the Activity Controller. This class 112 * knows how to respond to menu items, state changes, layout changes, etc. It 113 * weaves together the views and listeners, dispatching actions to the 114 * respective underlying classes. 115 * <p> 116 * Even though this class is abstract, it should provide default implementations 117 * for most, if not all the methods in the ActivityController interface. This 118 * makes the task of the subclasses easier: OnePaneActivityController and 119 * TwoPaneActivityController can be concise when the common functionality is in 120 * AbstractActivityController. 121 * </p> 122 * <p> 123 * In the Gmail codebase, this was called BaseActivityController 124 * </p> 125 */ 126public abstract class AbstractActivityController implements ActivityController { 127 // Keys for serialization of various information in Bundles. 128 /** Tag for {@link #mAccount} */ 129 private static final String SAVED_ACCOUNT = "saved-account"; 130 /** Tag for {@link #mFolder} */ 131 private static final String SAVED_FOLDER = "saved-folder"; 132 /** Tag for {@link #mCurrentConversation} */ 133 private static final String SAVED_CONVERSATION = "saved-conversation"; 134 /** Tag for {@link #mSelectedSet} */ 135 private static final String SAVED_SELECTED_SET = "saved-selected-set"; 136 /** Tag for {@link ActionableToastBar#getOperation()} */ 137 private static final String SAVED_TOAST_BAR_OP = "saved-toast-bar-op"; 138 /** Tag for {@link #mFolderListFolder} */ 139 private static final String SAVED_HIERARCHICAL_FOLDER = "saved-hierarchical-folder"; 140 /** Tag for {@link ConversationListContext#searchQuery} */ 141 private static final String SAVED_QUERY = "saved-query"; 142 /** Tag for {@link #mDialogAction} */ 143 private static final String SAVED_ACTION = "saved-action"; 144 /** Tag for {@link #mDialogFromSelectedSet} */ 145 private static final String SAVED_ACTION_FROM_SELECTED = "saved-action-from-selected"; 146 /** Tag for {@link #mDetachedConvUri} */ 147 private static final String SAVED_DETACHED_CONV_URI = "saved-detached-conv-uri"; 148 149 /** Tag used when loading a wait fragment */ 150 protected static final String TAG_WAIT = "wait-fragment"; 151 /** Tag used when loading a conversation list fragment. */ 152 public static final String TAG_CONVERSATION_LIST = "tag-conversation-list"; 153 /** Tag used when loading a folder list fragment. */ 154 protected static final String TAG_FOLDER_LIST = "tag-folder-list"; 155 156 protected Account mAccount; 157 protected Folder mFolder; 158 /** True when {@link #mFolder} is first shown to the user. */ 159 private boolean mFolderChanged = false; 160 protected MailActionBarView mActionBarView; 161 protected final ControllableActivity mActivity; 162 protected final Context mContext; 163 private final FragmentManager mFragmentManager; 164 protected final RecentFolderList mRecentFolderList; 165 protected ConversationListContext mConvListContext; 166 protected Conversation mCurrentConversation; 167 /** 168 * The hash of {@link #mCurrentConversation} in detached mode. 0 if we are not in detached mode. 169 */ 170 private Uri mDetachedConvUri; 171 172 /** A {@link android.content.BroadcastReceiver} that suppresses new e-mail notifications. */ 173 private SuppressNotificationReceiver mNewEmailReceiver = null; 174 175 /** Handler for all our local runnables. */ 176 protected Handler mHandler = new Handler(); 177 178 /** 179 * The current mode of the application. All changes in mode are initiated by 180 * the activity controller. View mode changes are propagated to classes that 181 * attach themselves as listeners of view mode changes. 182 */ 183 protected final ViewMode mViewMode; 184 protected ContentResolver mResolver; 185 protected boolean isLoaderInitialized = false; 186 private AsyncRefreshTask mAsyncRefreshTask; 187 188 private boolean mDestroyed; 189 190 /** True if running on tablet */ 191 private final boolean mIsTablet; 192 193 /** 194 * Are we in a point in the Activity/Fragment lifecycle where it's safe to execute fragment 195 * transactions? (including back stack manipulation) 196 * <p> 197 * Per docs in {@link FragmentManager#beginTransaction()}, this flag starts out true, switches 198 * to false after {@link Activity#onSaveInstanceState}, and becomes true again in both onStart 199 * and onResume. 200 */ 201 private boolean mSafeToModifyFragments = true; 202 203 private final Set<Uri> mCurrentAccountUris = Sets.newHashSet(); 204 protected ConversationCursor mConversationListCursor; 205 private final DataSetObservable mConversationListObservable = new DataSetObservable() { 206 @Override 207 public void registerObserver(DataSetObserver observer) { 208 final int count = mObservers.size(); 209 super.registerObserver(observer); 210 LogUtils.d(LOG_TAG, "IN AAC.register(List)Observer: %s before=%d after=%d", observer, 211 count, mObservers.size()); 212 } 213 @Override 214 public void unregisterObserver(DataSetObserver observer) { 215 final int count = mObservers.size(); 216 super.unregisterObserver(observer); 217 LogUtils.d(LOG_TAG, "IN AAC.unregister(List)Observer: %s before=%d after=%d", observer, 218 count, mObservers.size()); 219 } 220 }; 221 222 /** Runnable that checks the logging level to enable/disable the logging service. */ 223 private Runnable mLogServiceChecker = null; 224 225 /** 226 * Interface for actions that are deferred until after a load completes. This is for handling 227 * user actions which affect cursors (e.g. marking messages read or unread) that happen before 228 * that cursor is loaded. 229 */ 230 private interface LoadFinishedCallback { 231 void onLoadFinished(); 232 } 233 234 /** The deferred actions to execute when mConversationListCursor load completes. */ 235 private final ArrayList<LoadFinishedCallback> mConversationListLoadFinishedCallbacks = 236 new ArrayList<LoadFinishedCallback>(); 237 238 private RefreshTimerTask mConversationListRefreshTask; 239 240 /** Listeners that are interested in changes to the current account. */ 241 private final DataSetObservable mAccountObservers = new DataSetObservable() { 242 @Override 243 public void registerObserver(DataSetObserver observer) { 244 final int count = mObservers.size(); 245 super.registerObserver(observer); 246 LogUtils.d(LOG_TAG, "IN AAC.register(Account)Observer: %s before=%d after=%d", 247 observer, count, mObservers.size()); 248 } 249 @Override 250 public void unregisterObserver(DataSetObserver observer) { 251 final int count = mObservers.size(); 252 super.unregisterObserver(observer); 253 LogUtils.d(LOG_TAG, "IN AAC.unregister(Account)Observer: %s before=%d after=%d", 254 observer, count, mObservers.size()); 255 } 256 }; 257 258 /** Listeners that are interested in changes to the recent folders. */ 259 private final DataSetObservable mRecentFolderObservers = new DataSetObservable() { 260 @Override 261 public void registerObserver(DataSetObserver observer) { 262 final int count = mObservers.size(); 263 super.registerObserver(observer); 264 LogUtils.d(LOG_TAG, "IN AAC.register(RecentFolder)Observer: %s before=%d after=%d", 265 observer, count, mObservers.size()); 266 } 267 @Override 268 public void unregisterObserver(DataSetObserver observer) { 269 final int count = mObservers.size(); 270 super.unregisterObserver(observer); 271 LogUtils.d(LOG_TAG, "IN AAC.unregister(RecentFolder)Observer: %s before=%d after=%d", 272 observer, count, mObservers.size()); 273 } 274 }; 275 276 /** 277 * Selected conversations, if any. 278 */ 279 private final ConversationSelectionSet mSelectedSet = new ConversationSelectionSet(); 280 281 private final int mFolderItemUpdateDelayMs; 282 283 /** Keeps track of selected and unselected conversations */ 284 final protected ConversationPositionTracker mTracker; 285 286 /** 287 * Action menu associated with the selected set. 288 */ 289 SelectedConversationsActionMenu mCabActionMenu; 290 protected ActionableToastBar mToastBar; 291 protected ConversationPagerController mPagerController; 292 293 // this is split out from the general loader dispatcher because its loader doesn't return a 294 // basic Cursor 295 private final ConversationListLoaderCallbacks mListCursorCallbacks = 296 new ConversationListLoaderCallbacks(); 297 298 private final DataSetObservable mFolderObservable = new DataSetObservable(); 299 300 /** 301 * Matched addresses that must be shielded from users because they are temporary. Even though 302 * this is instantiated from settings, this matcher is valid for all accounts, and is expected 303 * to live past the life of an account. 304 */ 305 private final VeiledAddressMatcher mVeiledMatcher; 306 307 protected static final String LOG_TAG = LogTag.getLogTag(); 308 /** Constants used to differentiate between the types of loaders. */ 309 private static final int LOADER_ACCOUNT_CURSOR = 0; 310 private static final int LOADER_FOLDER_CURSOR = 2; 311 private static final int LOADER_RECENT_FOLDERS = 3; 312 private static final int LOADER_CONVERSATION_LIST = 4; 313 private static final int LOADER_ACCOUNT_INBOX = 5; 314 private static final int LOADER_SEARCH = 6; 315 private static final int LOADER_ACCOUNT_UPDATE_CURSOR = 7; 316 /** 317 * Guaranteed to be the last loader ID used by the activity. Loaders are owned by Activity or 318 * fragments, and within an activity, loader IDs need to be unique. A hack to ensure that the 319 * {@link FolderWatcher} can create its folder loaders without clashing with the IDs of those 320 * of the {@link AbstractActivityController}. Currently, the {@link FolderWatcher} is the only 321 * other class that uses this activity's LoaderManager. If another class needs activity-level 322 * loaders, consider consolidating the loaders in a central location: a UI-less fragment 323 * perhaps. 324 */ 325 public static final int LAST_LOADER_ID = 100; 326 327 private static final int ADD_ACCOUNT_REQUEST_CODE = 1; 328 private static final int REAUTHENTICATE_REQUEST_CODE = 2; 329 330 /** The pending destructive action to be carried out before swapping the conversation cursor.*/ 331 private DestructiveAction mPendingDestruction; 332 protected AsyncRefreshTask mFolderSyncTask; 333 private Folder mFolderListFolder; 334 private boolean mIsDragHappening; 335 private int mShowUndoBarDelay; 336 private boolean mRecentsDataUpdated; 337 /** A wait fragment we added, if any. */ 338 private WaitFragment mWaitFragment; 339 /** True if we have results from a search query */ 340 private boolean mHaveSearchResults = false; 341 /** If a confirmation dialog is being show, the listener for the positive action. */ 342 private OnClickListener mDialogListener; 343 /** 344 * If a confirmation dialog is being show, the resource of the action: R.id.delete, etc. This 345 * is used to create a new {@link #mDialogListener} on orientation changes. 346 */ 347 private int mDialogAction = -1; 348 /** 349 * If a confirmation dialog is being shown, this is true if the dialog acts on the selected set 350 * and false if it acts on the currently selected conversation 351 */ 352 private boolean mDialogFromSelectedSet; 353 354 private final Deque<UpOrBackHandler> mUpOrBackHandlers = Lists.newLinkedList(); 355 356 public static final String SYNC_ERROR_DIALOG_FRAGMENT_TAG = "SyncErrorDialogFragment"; 357 358 private final DataSetObserver mUndoNotificationObserver = new DataSetObserver() { 359 @Override 360 public void onChanged() { 361 super.onChanged(); 362 363 if (mConversationListCursor != null) { 364 mConversationListCursor.handleNotificationActions(); 365 } 366 } 367 }; 368 369 public AbstractActivityController(MailActivity activity, ViewMode viewMode) { 370 mActivity = activity; 371 mFragmentManager = mActivity.getFragmentManager(); 372 mViewMode = viewMode; 373 mContext = activity.getApplicationContext(); 374 mRecentFolderList = new RecentFolderList(mContext); 375 mTracker = new ConversationPositionTracker(this); 376 // Allow the fragment to observe changes to its own selection set. No other object is 377 // aware of the selected set. 378 mSelectedSet.addObserver(this); 379 380 final Resources r = mContext.getResources(); 381 mFolderItemUpdateDelayMs = r.getInteger(R.integer.folder_item_refresh_delay_ms); 382 mShowUndoBarDelay = r.getInteger(R.integer.show_undo_bar_delay_ms); 383 mVeiledMatcher = VeiledAddressMatcher.newInstance(activity.getResources()); 384 mIsTablet = Utils.useTabletUI(r); 385 } 386 387 @Override 388 public Account getCurrentAccount() { 389 return mAccount; 390 } 391 392 @Override 393 public ConversationListContext getCurrentListContext() { 394 return mConvListContext; 395 } 396 397 @Override 398 public String getHelpContext() { 399 final int mode = mViewMode.getMode(); 400 final int helpContextResId; 401 switch (mode) { 402 case ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION: 403 helpContextResId = R.string.wait_help_context; 404 break; 405 default: 406 helpContextResId = R.string.main_help_context; 407 } 408 return mContext.getString(helpContextResId); 409 } 410 411 @Override 412 public final ConversationCursor getConversationListCursor() { 413 return mConversationListCursor; 414 } 415 416 /** 417 * Check if the fragment is attached to an activity and has a root view. 418 * @param in fragment to be checked 419 * @return true if the fragment is valid, false otherwise 420 */ 421 private static boolean isValidFragment(Fragment in) { 422 return !(in == null || in.getActivity() == null || in.getView() == null); 423 } 424 425 /** 426 * Get the conversation list fragment for this activity. If the conversation list fragment is 427 * not attached, this method returns null. 428 * 429 * Caution! This method returns the {@link ConversationListFragment} after the fragment has been 430 * added, <b>and</b> after the {@link FragmentManager} has run through its queue to add the 431 * fragment. There is a non-trivial amount of time after the fragment is instantiated and before 432 * this call returns a non-null value, depending on the {@link FragmentManager}. If you 433 * need the fragment immediately after adding it, consider making the fragment an observer of 434 * the controller and perform the task immediately on {@link Fragment#onActivityCreated(Bundle)} 435 */ 436 protected ConversationListFragment getConversationListFragment() { 437 final Fragment fragment = mFragmentManager.findFragmentByTag(TAG_CONVERSATION_LIST); 438 if (isValidFragment(fragment)) { 439 return (ConversationListFragment) fragment; 440 } 441 return null; 442 } 443 444 /** 445 * Returns the folder list fragment attached with this activity. If no such fragment is attached 446 * this method returns null. 447 * 448 * Caution! This method returns the {@link FolderListFragment} after the fragment has been 449 * added, <b>and</b> after the {@link FragmentManager} has run through its queue to add the 450 * fragment. There is a non-trivial amount of time after the fragment is instantiated and before 451 * this call returns a non-null value, depending on the {@link FragmentManager}. If you 452 * need the fragment immediately after adding it, consider making the fragment an observer of 453 * the controller and perform the task immediately on {@link Fragment#onActivityCreated(Bundle)} 454 */ 455 protected FolderListFragment getFolderListFragment() { 456 final Fragment fragment = mFragmentManager.findFragmentByTag(TAG_FOLDER_LIST); 457 if (isValidFragment(fragment)) { 458 return (FolderListFragment) fragment; 459 } 460 return null; 461 } 462 463 /** 464 * Initialize the action bar. This is not visible to OnePaneController and 465 * TwoPaneController so they cannot override this behavior. 466 */ 467 private void initializeActionBar() { 468 final ActionBar actionBar = mActivity.getActionBar(); 469 if (actionBar == null) { 470 return; 471 } 472 473 // be sure to inherit from the ActionBar theme when inflating 474 final LayoutInflater inflater = LayoutInflater.from(actionBar.getThemedContext()); 475 final boolean isSearch = mActivity.getIntent() != null 476 && Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction()); 477 mActionBarView = (MailActionBarView) inflater.inflate( 478 isSearch ? R.layout.search_actionbar_view : R.layout.actionbar_view, null); 479 mActionBarView.initialize(mActivity, this, actionBar); 480 } 481 482 /** 483 * Attach the action bar to the activity. 484 */ 485 private void attachActionBar() { 486 final ActionBar actionBar = mActivity.getActionBar(); 487 if (actionBar != null && mActionBarView != null) { 488 actionBar.setCustomView(mActionBarView, new ActionBar.LayoutParams( 489 LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); 490 // Show a custom view and home icon, but remove the title 491 final int mask = ActionBar.DISPLAY_SHOW_CUSTOM | ActionBar.DISPLAY_SHOW_TITLE 492 | ActionBar.DISPLAY_SHOW_HOME; 493 final int enabled = ActionBar.DISPLAY_SHOW_CUSTOM | ActionBar.DISPLAY_SHOW_HOME; 494 actionBar.setDisplayOptions(enabled, mask); 495 mActionBarView.attach(); 496 } 497 mViewMode.addListener(mActionBarView); 498 } 499 500 /** 501 * Returns whether the conversation list fragment is visible or not. 502 * Different layouts will have their own notion on the visibility of 503 * fragments, so this method needs to be overriden. 504 * 505 */ 506 protected abstract boolean isConversationListVisible(); 507 508 /** 509 * If required, starts wait mode for the current account. 510 */ 511 final void perhapsEnterWaitMode() { 512 // If the account is not initialized, then show the wait fragment, since nothing can be 513 // shown. 514 if (mAccount.isAccountInitializationRequired()) { 515 showWaitForInitialization(); 516 return; 517 } 518 519 final boolean inWaitingMode = inWaitMode(); 520 final boolean isSyncRequired = mAccount.isAccountSyncRequired(); 521 if (isSyncRequired) { 522 if (inWaitingMode) { 523 // Update the WaitFragment's account object 524 updateWaitMode(); 525 } else { 526 // Transition to waiting mode 527 showWaitForInitialization(); 528 } 529 } else if (inWaitingMode) { 530 // Dismiss waiting mode 531 hideWaitForInitialization(); 532 } 533 } 534 535 @Override 536 public void onAccountChanged(Account account) { 537 // Is the account or account settings different from the existing account? 538 final boolean firstLoad = mAccount == null; 539 final boolean accountChanged = firstLoad || !account.uri.equals(mAccount.uri); 540 // If nothing has changed, return early without wasting any more time. 541 if (!accountChanged && !account.settingsDiffer(mAccount)) { 542 return; 543 } 544 // We also don't want to do anything if the new account is null 545 if (account == null) { 546 LogUtils.e(LOG_TAG, "AAC.onAccountChanged(null) called."); 547 return; 548 } 549 final String accountName = account.name; 550 mHandler.post(new Runnable() { 551 @Override 552 public void run() { 553 MailActivity.setNfcMessage(accountName); 554 } 555 }); 556 if (accountChanged) { 557 commitDestructiveActions(false); 558 } 559 // Change the account here 560 setAccount(account); 561 // And carry out associated actions. 562 cancelRefreshTask(); 563 if (accountChanged) { 564 loadAccountInbox(); 565 } 566 // Check if we need to force setting up an account before proceeding. 567 if (mAccount != null && !Uri.EMPTY.equals(mAccount.settings.setupIntentUri)) { 568 // Launch the intent! 569 final Intent intent = new Intent(Intent.ACTION_EDIT); 570 intent.setData(mAccount.settings.setupIntentUri); 571 mActivity.startActivity(intent); 572 } 573 } 574 575 /** 576 * Adds a listener interested in change in the current account. If a class is storing a 577 * reference to the current account, it should listen on changes, so it can receive updates to 578 * settings. Must happen in the UI thread. 579 */ 580 @Override 581 public void registerAccountObserver(DataSetObserver obs) { 582 mAccountObservers.registerObserver(obs); 583 } 584 585 /** 586 * Removes a listener from receiving current account changes. 587 * Must happen in the UI thread. 588 */ 589 @Override 590 public void unregisterAccountObserver(DataSetObserver obs) { 591 mAccountObservers.unregisterObserver(obs); 592 } 593 594 @Override 595 public Account getAccount() { 596 return mAccount; 597 } 598 599 private void fetchSearchFolder(Intent intent) { 600 final Bundle args = new Bundle(); 601 args.putString(ConversationListContext.EXTRA_SEARCH_QUERY, intent 602 .getStringExtra(ConversationListContext.EXTRA_SEARCH_QUERY)); 603 mActivity.getLoaderManager().restartLoader(LOADER_SEARCH, args, this); 604 } 605 606 @Override 607 public void onFolderChanged(Folder folder) { 608 changeFolder(folder, null); 609 } 610 611 /** 612 * Sets the folder state without changing view mode and without creating a list fragment, if 613 * possible. 614 * @param folder the folder whose list of conversations are to be shown 615 * @param query the query string for a list of conversations matching a search 616 */ 617 private void setListContext(Folder folder, String query) { 618 updateFolder(folder); 619 if (query != null) { 620 mConvListContext = ConversationListContext.forSearchQuery(mAccount, mFolder, query); 621 } else { 622 mConvListContext = ConversationListContext.forFolder(mAccount, mFolder); 623 } 624 cancelRefreshTask(); 625 } 626 627 /** 628 * Changes the folder to the value provided here. This causes the view mode to change. 629 * @param folder the folder to change to 630 * @param query if non-null, this represents the search string that the folder represents. 631 */ 632 private void changeFolder(Folder folder, String query) { 633 if (!Objects.equal(mFolder, folder)) { 634 commitDestructiveActions(false); 635 } 636 if (folder != null && !folder.equals(mFolder) 637 || (mViewMode.getMode() != ViewMode.CONVERSATION_LIST)) { 638 setListContext(folder, query); 639 showConversationList(mConvListContext); 640 } 641 resetActionBarIcon(); 642 } 643 644 @Override 645 public void onFolderSelected(Folder folder) { 646 onFolderChanged(folder); 647 } 648 649 /** 650 * Update the recent folders. This only needs to be done once when accessing a new folder. 651 */ 652 private void updateRecentFolderList() { 653 if (mFolder != null) { 654 mRecentFolderList.touchFolder(mFolder, mAccount); 655 } 656 } 657 658 /** 659 * Adds a listener interested in change in the recent folders. If a class is storing a 660 * reference to the recent folders, it should listen on changes, so it can receive updates. 661 * Must happen in the UI thread. 662 */ 663 @Override 664 public void registerRecentFolderObserver(DataSetObserver obs) { 665 mRecentFolderObservers.registerObserver(obs); 666 } 667 668 /** 669 * Removes a listener from receiving recent folder changes. 670 * Must happen in the UI thread. 671 */ 672 @Override 673 public void unregisterRecentFolderObserver(DataSetObserver obs) { 674 mRecentFolderObservers.unregisterObserver(obs); 675 } 676 677 @Override 678 public RecentFolderList getRecentFolders() { 679 return mRecentFolderList; 680 } 681 682 // TODO(mindyp): set this up to store a copy of the folder as a transient 683 // field in the account. 684 @Override 685 public void loadAccountInbox() { 686 restartOptionalLoader(LOADER_ACCOUNT_INBOX); 687 } 688 689 /** 690 * Marks the {@link #mFolderChanged} value if the newFolder is different from the existing 691 * {@link #mFolder}. This should be called immediately <b>before</b> assigning newFolder to 692 * mFolder. 693 * @param newFolder the new folder we are switching to. 694 */ 695 private void setHasFolderChanged(final Folder newFolder) { 696 // We should never try to assign a null folder. But in the rare event that we do, we should 697 // only set the bit when we have a valid folder, and null is not valid. 698 if (newFolder == null) { 699 return; 700 } 701 // If the previous folder was null, or if the two folders represent different data, then we 702 // consider that the folder has changed. 703 if (mFolder == null || !newFolder.uri.equals(mFolder.uri)) { 704 mFolderChanged = true; 705 } 706 } 707 708 /** 709 * Sets the current folder if it is different from the object provided here. This method does 710 * NOT notify the folder observers that a change has happened. Observers are notified when we 711 * get an updated folder from the loaders, which will happen as a consequence of this method 712 * (since this method starts/restarts the loaders). 713 * @param folder The folder to assign 714 */ 715 private void updateFolder(Folder folder) { 716 if (folder == null || !folder.isInitialized()) { 717 LogUtils.e(LOG_TAG, new Error(), "AAC.setFolder(%s): Bad input", folder); 718 return; 719 } 720 if (folder.equals(mFolder)) { 721 LogUtils.d(LOG_TAG, "AAC.setFolder(%s): Input matches mFolder", folder); 722 return; 723 } 724 final boolean wasNull = mFolder == null; 725 LogUtils.d(LOG_TAG, "AbstractActivityController.setFolder(%s)", folder.name); 726 final LoaderManager lm = mActivity.getLoaderManager(); 727 // updateFolder is called from AAC.onLoadFinished() on folder changes. We need to 728 // ensure that the folder is different from the previous folder before marking the 729 // folder changed. 730 setHasFolderChanged(folder); 731 mFolder = folder; 732 733 // We do not need to notify folder observers yet. Instead we start the loaders and 734 // when the load finishes, we will get an updated folder. Then, we notify the 735 // folderObservers in onLoadFinished. 736 mActionBarView.setFolder(mFolder); 737 738 // Only when we switch from one folder to another do we want to restart the 739 // folder and conversation list loaders (to trigger onCreateLoader). 740 // The first time this runs when the activity is [re-]initialized, we want to re-use the 741 // previous loader's instance and data upon configuration change (e.g. rotation). 742 // If there was not already an instance of the loader, init it. 743 if (lm.getLoader(LOADER_FOLDER_CURSOR) == null) { 744 lm.initLoader(LOADER_FOLDER_CURSOR, null, this); 745 } else { 746 lm.restartLoader(LOADER_FOLDER_CURSOR, null, this); 747 } 748 // In this case, we are starting from no folder, which would occur 749 // the first time the app was launched or on orientation changes. 750 // We want to attach to an existing loader, if available. 751 if (wasNull || lm.getLoader(LOADER_CONVERSATION_LIST) == null) { 752 lm.initLoader(LOADER_CONVERSATION_LIST, null, mListCursorCallbacks); 753 } else { 754 // However, if there was an existing folder AND we have changed 755 // folders, we want to restart the loader to get the information 756 // for the newly selected folder 757 lm.destroyLoader(LOADER_CONVERSATION_LIST); 758 lm.initLoader(LOADER_CONVERSATION_LIST, null, mListCursorCallbacks); 759 } 760 } 761 762 @Override 763 public Folder getFolder() { 764 return mFolder; 765 } 766 767 @Override 768 public Folder getHierarchyFolder() { 769 return mFolderListFolder; 770 } 771 772 @Override 773 public void setHierarchyFolder(Folder folder) { 774 mFolderListFolder = folder; 775 } 776 777 @Override 778 public void onActivityResult(int requestCode, int resultCode, Intent data) { 779 switch (requestCode) { 780 case ADD_ACCOUNT_REQUEST_CODE: 781 // We were waiting for the user to create an account 782 if (resultCode == Activity.RESULT_OK) { 783 // restart the loader to get the updated list of accounts 784 mActivity.getLoaderManager().initLoader( 785 LOADER_ACCOUNT_CURSOR, null, this); 786 } else { 787 // The user failed to create an account, just exit the app 788 mActivity.finish(); 789 } 790 break; 791 case REAUTHENTICATE_REQUEST_CODE: 792 if (resultCode == Activity.RESULT_OK) { 793 // The user successfully authenticated, attempt to refresh the list 794 final Uri refreshUri = mFolder != null ? mFolder.refreshUri : null; 795 if (refreshUri != null) { 796 startAsyncRefreshTask(refreshUri); 797 } 798 } 799 break; 800 } 801 } 802 803 /** 804 * Inform the conversation cursor that there has been a visibility change. 805 * @param visible true if the conversation list is visible, false otherwise. 806 */ 807 protected synchronized void informCursorVisiblity(boolean visible) { 808 if (mConversationListCursor != null) { 809 Utils.setConversationCursorVisibility(mConversationListCursor, visible, mFolderChanged); 810 // We have informed the cursor. Subsequent visibility changes should not tell it that 811 // the folder has changed. 812 mFolderChanged = false; 813 } 814 } 815 816 @Override 817 public void onConversationListVisibilityChanged(boolean visible) { 818 informCursorVisiblity(visible); 819 } 820 821 /** 822 * Called when a conversation is visible. Child classes must call the super class implementation 823 * before performing local computation. 824 */ 825 @Override 826 public void onConversationVisibilityChanged(boolean visible) { 827 } 828 829 /** 830 * Initialize development time logging. This can potentially log a lot of PII, and we don't want 831 * to turn it on for shipped versions. 832 */ 833 private void initializeDevLoggingService() { 834 if (!MailLogService.DEBUG_ENABLED) { 835 return; 836 } 837 // Check every 5 minutes. 838 final int WAIT_TIME = 5 * 60 * 1000; 839 // Start a runnable that periodically checks the log level and starts/stops the service. 840 mLogServiceChecker = new Runnable() { 841 /** True if currently logging. */ 842 private boolean mCurrentlyLogging = false; 843 844 /** 845 * If the logging level has been changed since the previous run, start or stop the 846 * service. 847 */ 848 private void startOrStopService() { 849 // If the log level is already high, start the service. 850 final Intent i = new Intent(mContext, MailLogService.class); 851 final boolean loggingEnabled = MailLogService.isLoggingLevelHighEnough(); 852 if (mCurrentlyLogging == loggingEnabled) { 853 // No change since previous run, just return; 854 return; 855 } 856 if (loggingEnabled) { 857 LogUtils.e(LOG_TAG, "Starting MailLogService"); 858 mContext.startService(i); 859 } else { 860 LogUtils.e(LOG_TAG, "Stopping MailLogService"); 861 mContext.stopService(i); 862 } 863 mCurrentlyLogging = loggingEnabled; 864 } 865 866 @Override 867 public void run() { 868 startOrStopService(); 869 mHandler.postDelayed(this, WAIT_TIME); 870 } 871 }; 872 // Start the runnable right away. 873 mHandler.post(mLogServiceChecker); 874 } 875 876 @Override 877 public boolean onCreate(Bundle savedState) { 878 initializeActionBar(); 879 initializeDevLoggingService(); 880 // Allow shortcut keys to function for the ActionBar and menus. 881 mActivity.setDefaultKeyMode(Activity.DEFAULT_KEYS_SHORTCUT); 882 mResolver = mActivity.getContentResolver(); 883 mNewEmailReceiver = new SuppressNotificationReceiver(); 884 mRecentFolderList.initialize(mActivity); 885 mVeiledMatcher.initialize(this); 886 887 // All the individual UI components listen for ViewMode changes. This 888 // simplifies the amount of logic in the AbstractActivityController, but increases the 889 // possibility of timing-related bugs. 890 mViewMode.addListener(this); 891 mPagerController = new ConversationPagerController(mActivity, this); 892 mToastBar = (ActionableToastBar) mActivity.findViewById(R.id.toast_bar); 893 attachActionBar(); 894 FolderSelectionDialog.setDialogDismissed(); 895 896 final Intent intent = mActivity.getIntent(); 897 // Immediately handle a clean launch with intent, and any state restoration 898 // that does not rely on restored fragments or loader data 899 // any state restoration that relies on those can be done later in 900 // onRestoreInstanceState, once fragments are up and loader data is re-delivered 901 if (savedState != null) { 902 if (savedState.containsKey(SAVED_ACCOUNT)) { 903 setAccount((Account) savedState.getParcelable(SAVED_ACCOUNT)); 904 } 905 if (savedState.containsKey(SAVED_FOLDER)) { 906 final Folder folder = savedState.getParcelable(SAVED_FOLDER); 907 final String query = savedState.getString(SAVED_QUERY, null); 908 setListContext(folder, query); 909 } 910 if (savedState.containsKey(SAVED_ACTION)) { 911 mDialogAction = savedState.getInt(SAVED_ACTION); 912 } 913 mDialogFromSelectedSet = savedState.getBoolean(SAVED_ACTION_FROM_SELECTED, false); 914 mViewMode.handleRestore(savedState); 915 } else if (intent != null) { 916 handleIntent(intent); 917 } 918 // Create the accounts loader; this loads the account switch spinner. 919 mActivity.getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this); 920 return true; 921 } 922 923 @Override 924 public void onStart() { 925 mSafeToModifyFragments = true; 926 927 NotificationActionUtils.registerUndoNotificationObserver(mUndoNotificationObserver); 928 } 929 930 @Override 931 public void onRestart() { 932 DialogFragment fragment = (DialogFragment) 933 mFragmentManager.findFragmentByTag(SYNC_ERROR_DIALOG_FRAGMENT_TAG); 934 if (fragment != null) { 935 fragment.dismiss(); 936 } 937 // When the user places the app in the background by pressing "home", 938 // dismiss the toast bar. However, since there is no way to determine if 939 // home was pressed, just dismiss any existing toast bar when restarting 940 // the app. 941 if (mToastBar != null) { 942 mToastBar.hide(false); 943 } 944 } 945 946 @Override 947 public Dialog onCreateDialog(int id, Bundle bundle) { 948 return null; 949 } 950 951 @Override 952 public final boolean onCreateOptionsMenu(Menu menu) { 953 final MenuInflater inflater = mActivity.getMenuInflater(); 954 inflater.inflate(mActionBarView.getOptionsMenuId(), menu); 955 mActionBarView.onCreateOptionsMenu(menu); 956 return true; 957 } 958 959 @Override 960 public final boolean onKeyDown(int keyCode, KeyEvent event) { 961 return false; 962 } 963 964 public abstract boolean doesActionChangeConversationListVisibility(int action); 965 966 @Override 967 public final boolean onOptionsItemSelected(MenuItem item) { 968 final int id = item.getItemId(); 969 LogUtils.d(LOG_TAG, "AbstractController.onOptionsItemSelected(%d) called.", id); 970 boolean handled = true; 971 final Collection<Conversation> target = Conversation.listOf(mCurrentConversation); 972 final Settings settings = (mAccount == null) ? null : mAccount.settings; 973 // The user is choosing a new action; commit whatever they had been 974 // doing before. Don't animate if we are launching a new screen. 975 commitDestructiveActions(!doesActionChangeConversationListVisibility(id)); 976 switch (id) { 977 case R.id.archive: { 978 final boolean showDialog = (settings != null && settings.confirmArchive); 979 confirmAndDelete(id, target, showDialog, R.plurals.confirm_archive_conversation); 980 break; 981 } 982 case R.id.remove_folder: 983 delete(R.id.remove_folder, target, 984 getDeferredRemoveFolder(target, mFolder, true, false, true)); 985 break; 986 case R.id.delete: { 987 final boolean showDialog = (settings != null && settings.confirmDelete); 988 confirmAndDelete(id, target, showDialog, R.plurals.confirm_delete_conversation); 989 break; 990 } 991 case R.id.discard_drafts: { 992 final boolean showDialog = (settings != null && settings.confirmDelete); 993 confirmAndDelete(id, target, showDialog, 994 R.plurals.confirm_discard_drafts_conversation); 995 break; 996 } 997 case R.id.mark_important: 998 updateConversation(Conversation.listOf(mCurrentConversation), 999 ConversationColumns.PRIORITY, UIProvider.ConversationPriority.HIGH); 1000 break; 1001 case R.id.mark_not_important: 1002 if (mFolder != null && mFolder.isImportantOnly()) { 1003 delete(R.id.mark_not_important, target, 1004 getDeferredAction(R.id.mark_not_important, target, false)); 1005 } else { 1006 updateConversation(Conversation.listOf(mCurrentConversation), 1007 ConversationColumns.PRIORITY, UIProvider.ConversationPriority.LOW); 1008 } 1009 break; 1010 case R.id.mute: 1011 delete(R.id.mute, target, getDeferredAction(R.id.mute, target, false)); 1012 break; 1013 case R.id.report_spam: 1014 delete(R.id.report_spam, target, 1015 getDeferredAction(R.id.report_spam, target, false)); 1016 break; 1017 case R.id.mark_not_spam: 1018 // Currently, since spam messages are only shown in list with 1019 // other spam messages, 1020 // marking a message not as spam is a destructive action 1021 delete(R.id.mark_not_spam, target, 1022 getDeferredAction(R.id.mark_not_spam, target, false)); 1023 break; 1024 case R.id.report_phishing: 1025 delete(R.id.report_phishing, target, 1026 getDeferredAction(R.id.report_phishing, target, false)); 1027 break; 1028 case android.R.id.home: 1029 onUpPressed(); 1030 break; 1031 case R.id.compose: 1032 ComposeActivity.compose(mActivity.getActivityContext(), mAccount); 1033 break; 1034 case R.id.show_all_folders: 1035 showFolderList(); 1036 break; 1037 case R.id.refresh: 1038 requestFolderRefresh(); 1039 break; 1040 case R.id.settings: 1041 Utils.showSettings(mActivity.getActivityContext(), mAccount); 1042 break; 1043 case R.id.folder_options: 1044 Utils.showFolderSettings(mActivity.getActivityContext(), mAccount, mFolder); 1045 break; 1046 case R.id.help_info_menu_item: 1047 Utils.showHelp(mActivity.getActivityContext(), mAccount, getHelpContext()); 1048 break; 1049 case R.id.feedback_menu_item: 1050 Utils.sendFeedback(mActivity, mAccount, false); 1051 break; 1052 case R.id.manage_folders_item: 1053 Utils.showManageFolder(mActivity.getActivityContext(), mAccount); 1054 break; 1055 case R.id.move_to: 1056 /* fall through */ 1057 case R.id.change_folder: 1058 final FolderSelectionDialog dialog = FolderSelectionDialog.getInstance( 1059 mActivity.getActivityContext(), mAccount, this, 1060 Conversation.listOf(mCurrentConversation), false, mFolder, 1061 id == R.id.move_to); 1062 if (dialog != null) { 1063 dialog.show(); 1064 } 1065 break; 1066 default: 1067 handled = false; 1068 break; 1069 } 1070 return handled; 1071 } 1072 1073 @Override 1074 public final boolean onUpPressed() { 1075 for (UpOrBackHandler h : mUpOrBackHandlers) { 1076 if (h.onUpPressed()) { 1077 return true; 1078 } 1079 } 1080 return handleUpPress(); 1081 } 1082 1083 @Override 1084 public final boolean onBackPressed() { 1085 for (UpOrBackHandler h : mUpOrBackHandlers) { 1086 if (h.onBackPressed()) { 1087 return true; 1088 } 1089 } 1090 return handleBackPress(); 1091 } 1092 1093 protected abstract boolean handleBackPress(); 1094 protected abstract boolean handleUpPress(); 1095 1096 @Override 1097 public void addUpOrBackHandler(UpOrBackHandler handler) { 1098 if (mUpOrBackHandlers.contains(handler)) { 1099 return; 1100 } 1101 mUpOrBackHandlers.addFirst(handler); 1102 } 1103 1104 @Override 1105 public void removeUpOrBackHandler(UpOrBackHandler handler) { 1106 mUpOrBackHandlers.remove(handler); 1107 } 1108 1109 @Override 1110 public void updateConversation(Collection<Conversation> target, ContentValues values) { 1111 mConversationListCursor.updateValues(mContext, target, values); 1112 refreshConversationList(); 1113 } 1114 1115 @Override 1116 public void updateConversation(Collection <Conversation> target, String columnName, 1117 boolean value) { 1118 mConversationListCursor.updateBoolean(mContext, target, columnName, value); 1119 refreshConversationList(); 1120 } 1121 1122 @Override 1123 public void updateConversation(Collection <Conversation> target, String columnName, 1124 int value) { 1125 mConversationListCursor.updateInt(mContext, target, columnName, value); 1126 refreshConversationList(); 1127 } 1128 1129 @Override 1130 public void updateConversation(Collection <Conversation> target, String columnName, 1131 String value) { 1132 mConversationListCursor.updateString(mContext, target, columnName, value); 1133 refreshConversationList(); 1134 } 1135 1136 @Override 1137 public void markConversationMessagesUnread(final Conversation conv, 1138 final Set<Uri> unreadMessageUris, final byte[] originalConversationInfo) { 1139 // The only caller of this method is the conversation view, from where marking unread should 1140 // *always* take you back to list mode. 1141 showConversation(null); 1142 1143 // locally mark conversation unread (the provider is supposed to propagate message unread 1144 // to conversation unread) 1145 conv.read = false; 1146 if (mConversationListCursor == null) { 1147 LogUtils.d(LOG_TAG, "markConversationMessagesUnread(id=%d), deferring", conv.id); 1148 1149 mConversationListLoadFinishedCallbacks.add(new LoadFinishedCallback() { 1150 @Override 1151 public void onLoadFinished() { 1152 doMarkConversationMessagesUnread(conv, unreadMessageUris, 1153 originalConversationInfo); 1154 } 1155 }); 1156 } else { 1157 LogUtils.d(LOG_TAG, "markConversationMessagesUnread(id=%d), performing", conv.id); 1158 doMarkConversationMessagesUnread(conv, unreadMessageUris, originalConversationInfo); 1159 } 1160 } 1161 1162 private void doMarkConversationMessagesUnread(Conversation conv, Set<Uri> unreadMessageUris, 1163 byte[] originalConversationInfo) { 1164 // Only do a granular 'mark unread' if a subset of messages are unread 1165 final int unreadCount = (unreadMessageUris == null) ? 0 : unreadMessageUris.size(); 1166 final int numMessages = conv.getNumMessages(); 1167 final boolean subsetIsUnread = (numMessages > 1 && unreadCount > 0 1168 && unreadCount < numMessages); 1169 1170 LogUtils.d(LOG_TAG, "markConversationMessagesUnread(conv=%s)" 1171 + ", numMessages=%d, unreadCount=%d, subsetIsUnread=%b", 1172 conv, numMessages, unreadCount, subsetIsUnread); 1173 if (!subsetIsUnread) { 1174 // Conversations are neither marked read, nor viewed, and we don't want to show 1175 // the next conversation. 1176 LogUtils.d(LOG_TAG, ". . doing full mark unread"); 1177 markConversationsRead(Collections.singletonList(conv), false, false, false); 1178 } else { 1179 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 1180 final ConversationInfo info = ConversationInfo.fromBlob(originalConversationInfo); 1181 LogUtils.d(LOG_TAG, ". . doing subset mark unread, originalConversationInfo = %s", 1182 info); 1183 } 1184 mConversationListCursor.setConversationColumn(conv.uri, ConversationColumns.READ, 0); 1185 1186 // Locally update conversation's conversationInfo to revert to original version 1187 if (originalConversationInfo != null) { 1188 mConversationListCursor.setConversationColumn(conv.uri, 1189 ConversationColumns.CONVERSATION_INFO, originalConversationInfo); 1190 } 1191 1192 // applyBatch with each CPO as an UPDATE op on each affected message uri 1193 final ArrayList<ContentProviderOperation> ops = Lists.newArrayList(); 1194 String authority = null; 1195 for (Uri messageUri : unreadMessageUris) { 1196 if (authority == null) { 1197 authority = messageUri.getAuthority(); 1198 } 1199 ops.add(ContentProviderOperation.newUpdate(messageUri) 1200 .withValue(UIProvider.MessageColumns.READ, 0) 1201 .build()); 1202 LogUtils.d(LOG_TAG, ". . Adding op: read=0, uri=%s", messageUri); 1203 } 1204 LogUtils.d(LOG_TAG, ". . operations = %s", ops); 1205 new ContentProviderTask() { 1206 @Override 1207 protected void onPostExecute(Result result) { 1208 if (result.exception != null) { 1209 LogUtils.e(LOG_TAG, result.exception, "ContentProviderTask() ERROR."); 1210 } else { 1211 LogUtils.d(LOG_TAG, "ContentProviderTask(): success %s", 1212 Arrays.toString(result.results)); 1213 } 1214 } 1215 }.run(mResolver, authority, ops); 1216 } 1217 } 1218 1219 @Override 1220 public void markConversationsRead(final Collection<Conversation> targets, final boolean read, 1221 final boolean viewed) { 1222 if (mConversationListCursor == null) { 1223 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 1224 LogUtils.d(LOG_TAG, "markConversationsRead(targets=%s), deferring", 1225 targets.toArray()); 1226 } 1227 mConversationListLoadFinishedCallbacks.add(new LoadFinishedCallback() { 1228 @Override 1229 public void onLoadFinished() { 1230 markConversationsRead(targets, read, viewed, true); 1231 } 1232 }); 1233 } else { 1234 // We want to show the next conversation if we are marking unread. 1235 markConversationsRead(targets, read, viewed, true); 1236 } 1237 } 1238 1239 private void markConversationsRead(final Collection<Conversation> targets, final boolean read, 1240 final boolean markViewed, final boolean showNext) { 1241 LogUtils.d(LOG_TAG, "performing markConversationsRead"); 1242 // Auto-advance if requested and the current conversation is being marked unread 1243 if (showNext && !read) { 1244 final Runnable operation = new Runnable() { 1245 @Override 1246 public void run() { 1247 markConversationsRead(targets, read, markViewed, showNext); 1248 } 1249 }; 1250 1251 if (!showNextConversation(targets, operation)) { 1252 // This method will be called again if the user selects an autoadvance option 1253 return; 1254 } 1255 } 1256 1257 final int size = targets.size(); 1258 final List<ConversationOperation> opList = new ArrayList<ConversationOperation>(size); 1259 for (final Conversation target : targets) { 1260 final ContentValues value = new ContentValues(); 1261 value.put(ConversationColumns.READ, read); 1262 1263 // We never want to mark unseen here, but we do want to mark it seen 1264 if (read || markViewed) { 1265 value.put(ConversationColumns.SEEN, Boolean.TRUE); 1266 } 1267 1268 // The mark read/unread/viewed operations do not show an undo bar 1269 value.put(ConversationOperations.Parameters.SUPPRESS_UNDO, true); 1270 if (markViewed) { 1271 value.put(ConversationColumns.VIEWED, true); 1272 } 1273 final ConversationInfo info = target.conversationInfo; 1274 if (info != null) { 1275 boolean changed = info.markRead(read); 1276 if (changed) { 1277 value.put(ConversationColumns.CONVERSATION_INFO, info.toBlob()); 1278 } 1279 } 1280 opList.add(mConversationListCursor.getOperationForConversation( 1281 target, ConversationOperation.UPDATE, value)); 1282 // Update the local conversation objects so they immediately change state. 1283 target.read = read; 1284 if (markViewed) { 1285 target.markViewed(); 1286 } 1287 } 1288 mConversationListCursor.updateBulkValues(mContext, opList); 1289 } 1290 1291 /** 1292 * Auto-advance to a different conversation if the currently visible conversation in 1293 * conversation mode is affected (deleted, marked unread, etc.). 1294 * 1295 * <p>Does nothing if outside of conversation mode.</p> 1296 * 1297 * @param target the set of conversations being deleted/marked unread 1298 */ 1299 @Override 1300 public void showNextConversation(final Collection<Conversation> target) { 1301 showNextConversation(target, null); 1302 } 1303 1304 /** 1305 * Auto-advance to a different conversation if the currently visible conversation in 1306 * conversation mode is affected (deleted, marked unread, etc.). 1307 * 1308 * <p>Does nothing if outside of conversation mode.</p> 1309 * 1310 * @param target the set of conversations being deleted/marked unread 1311 * @param operation if auto-advance setting is unset, this operation is run after the user 1312 * is prompted to select a setting. 1313 * @return <code>false</code> if we aborted because the user has not yet specified a default 1314 * action, <code>true</code> otherwise 1315 */ 1316 private boolean showNextConversation(final Collection<Conversation> target, 1317 final Runnable operation) { 1318 final int viewMode = mViewMode.getMode(); 1319 final boolean currentConversationInView = (viewMode == ViewMode.CONVERSATION 1320 || viewMode == ViewMode.SEARCH_RESULTS_CONVERSATION) 1321 && Conversation.contains(target, mCurrentConversation); 1322 1323 if (currentConversationInView) { 1324 final int autoAdvanceSetting = mAccount.settings.getAutoAdvanceSetting(); 1325 1326 if (autoAdvanceSetting == AutoAdvance.UNSET && mIsTablet) { 1327 displayAutoAdvanceDialogAndPerformAction(operation); 1328 return false; 1329 } else { 1330 // If we don't have one set, but we're here, just take the default 1331 final int autoAdvance = (autoAdvanceSetting == AutoAdvance.UNSET) ? 1332 AutoAdvance.DEFAULT : autoAdvanceSetting; 1333 1334 final Conversation next = mTracker.getNextConversation(autoAdvance, target); 1335 LogUtils.d(LOG_TAG, "showNextConversation: showing %s next.", next); 1336 showConversation(next); 1337 return true; 1338 } 1339 } 1340 1341 return true; 1342 } 1343 1344 /** 1345 * Displays a the auto-advance dialog, and when the user makes a selection, the preference is 1346 * stored, and the specified operation is run. 1347 */ 1348 private void displayAutoAdvanceDialogAndPerformAction(final Runnable operation) { 1349 final String[] autoAdvanceDisplayOptions = 1350 mContext.getResources().getStringArray(R.array.prefEntries_autoAdvance); 1351 final String[] autoAdvanceOptionValues = 1352 mContext.getResources().getStringArray(R.array.prefValues_autoAdvance); 1353 1354 final String defaultValue = mContext.getString(R.string.prefDefault_autoAdvance); 1355 int initialIndex = 0; 1356 for (int i = 0; i < autoAdvanceOptionValues.length; i++) { 1357 if (defaultValue.equals(autoAdvanceOptionValues[i])) { 1358 initialIndex = i; 1359 break; 1360 } 1361 } 1362 1363 final DialogInterface.OnClickListener listClickListener = 1364 new DialogInterface.OnClickListener() { 1365 @Override 1366 public void onClick(DialogInterface dialog, int whichItem) { 1367 final String autoAdvanceValue = autoAdvanceOptionValues[whichItem]; 1368 final int autoAdvanceValueInt = 1369 UIProvider.AutoAdvance.getAutoAdvanceInt(autoAdvanceValue); 1370 mAccount.settings.setAutoAdvanceSetting(autoAdvanceValueInt); 1371 1372 // Save the user's setting 1373 final ContentValues values = new ContentValues(1); 1374 values.put(AccountColumns.SettingsColumns.AUTO_ADVANCE, autoAdvanceValue); 1375 1376 final ContentResolver resolver = mContext.getContentResolver(); 1377 resolver.update(mAccount.updateSettingsUri, values, null, null); 1378 1379 // Dismiss the dialog, as clicking the items in the list doesn't close the 1380 // dialog. 1381 dialog.dismiss(); 1382 if (operation != null) { 1383 operation.run(); 1384 } 1385 } 1386 }; 1387 1388 new AlertDialog.Builder(mActivity.getActivityContext()).setTitle( 1389 R.string.auto_advance_help_title) 1390 .setSingleChoiceItems(autoAdvanceDisplayOptions, initialIndex, listClickListener) 1391 .setPositiveButton(null, null) 1392 .create() 1393 .show(); 1394 } 1395 1396 @Override 1397 public void starMessage(ConversationMessage msg, boolean starred) { 1398 if (msg.starred == starred) { 1399 return; 1400 } 1401 1402 msg.starred = starred; 1403 1404 // locally propagate the change to the owning conversation 1405 // (figure the provider will properly propagate the change when it commits it) 1406 // 1407 // when unstarring, only propagate the change if this was the only message starred 1408 final boolean conversationStarred = starred || msg.isConversationStarred(); 1409 final Conversation conv = msg.getConversation(); 1410 if (conversationStarred != conv.starred) { 1411 conv.starred = conversationStarred; 1412 mConversationListCursor.setConversationColumn(conv.uri, 1413 ConversationColumns.STARRED, conversationStarred); 1414 } 1415 1416 final ContentValues values = new ContentValues(1); 1417 values.put(UIProvider.MessageColumns.STARRED, starred ? 1 : 0); 1418 1419 new ContentProviderTask.UpdateTask() { 1420 @Override 1421 protected void onPostExecute(Result result) { 1422 // TODO: handle errors? 1423 } 1424 }.run(mResolver, msg.uri, values, null /* selection*/, null /* selectionArgs */); 1425 } 1426 1427 private void requestFolderRefresh() { 1428 if (mFolder != null) { 1429 if (mAsyncRefreshTask != null) { 1430 mAsyncRefreshTask.cancel(true); 1431 } 1432 mAsyncRefreshTask = new AsyncRefreshTask(mContext, mFolder.refreshUri); 1433 mAsyncRefreshTask.execute(); 1434 } 1435 } 1436 1437 /** 1438 * Confirm (based on user's settings) and delete a conversation from the conversation list and 1439 * from the database. 1440 * @param actionId the ID of the menu item that caused the delete: R.id.delete, R.id.archive... 1441 * @param target the conversations to act upon 1442 * @param showDialog true if a confirmation dialog is to be shown, false otherwise. 1443 * @param confirmResource the resource ID of the string that is shown in the confirmation dialog 1444 */ 1445 private void confirmAndDelete(int actionId, final Collection<Conversation> target, 1446 boolean showDialog, int confirmResource) { 1447 if (showDialog) { 1448 makeDialogListener(actionId, false); 1449 final CharSequence message = Utils.formatPlural(mContext, confirmResource, 1450 target.size()); 1451 final ConfirmDialogFragment c = ConfirmDialogFragment.newInstance(message); 1452 c.displayDialog(mActivity.getFragmentManager()); 1453 } else { 1454 delete(0, target, getDeferredAction(actionId, target, false)); 1455 } 1456 } 1457 1458 @Override 1459 public void delete(final int actionId, final Collection<Conversation> target, 1460 final DestructiveAction action) { 1461 // Order of events is critical! The Conversation View Fragment must be 1462 // notified of the next conversation with showConversation(next) *before* the 1463 // conversation list 1464 // fragment has a chance to delete the conversation, animating it away. 1465 1466 // Update the conversation fragment if the current conversation is 1467 // deleted. 1468 final Runnable operation = new Runnable() { 1469 @Override 1470 public void run() { 1471 delete(actionId, target, action); 1472 } 1473 }; 1474 1475 if (!showNextConversation(target, operation)) { 1476 // This method will be called again if the user selects an autoadvance option 1477 return; 1478 } 1479 1480 // The conversation list deletes and performs the action if it exists. 1481 final ConversationListFragment convListFragment = getConversationListFragment(); 1482 if (convListFragment != null) { 1483 LogUtils.d(LOG_TAG, "AAC.requestDelete: ListFragment is handling delete."); 1484 convListFragment.requestDelete(actionId, target, action); 1485 return; 1486 } 1487 // No visible UI element handled it on our behalf. Perform the action 1488 // ourself. 1489 action.performAction(); 1490 } 1491 1492 /** 1493 * Requests that the action be performed and the UI state is updated to reflect the new change. 1494 * @param action the action to be performed, specified as a menu id: R.id.archive, ... 1495 */ 1496 private void requestUpdate(final DestructiveAction action) { 1497 action.performAction(); 1498 refreshConversationList(); 1499 } 1500 1501 @Override 1502 public void onPrepareDialog(int id, Dialog dialog, Bundle bundle) { 1503 // TODO(viki): Auto-generated method stub 1504 } 1505 1506 @Override 1507 public boolean onPrepareOptionsMenu(Menu menu) { 1508 return mActionBarView.onPrepareOptionsMenu(menu); 1509 } 1510 1511 @Override 1512 public void onPause() { 1513 isLoaderInitialized = false; 1514 enableNotifications(); 1515 } 1516 1517 @Override 1518 public void onResume() { 1519 // Register the receiver that will prevent the status receiver from 1520 // displaying its notification icon as long as we're running. 1521 // The SupressNotificationReceiver will block the broadcast if we're looking at the folder 1522 // that the notification was received for. 1523 disableNotifications(); 1524 1525 mSafeToModifyFragments = true; 1526 } 1527 1528 @Override 1529 public void onSaveInstanceState(Bundle outState) { 1530 mViewMode.handleSaveInstanceState(outState); 1531 if (mAccount != null) { 1532 outState.putParcelable(SAVED_ACCOUNT, mAccount); 1533 } 1534 if (mFolder != null) { 1535 outState.putParcelable(SAVED_FOLDER, mFolder); 1536 } 1537 // If this is a search activity, let's store the search query term as well. 1538 if (ConversationListContext.isSearchResult(mConvListContext)) { 1539 outState.putString(SAVED_QUERY, mConvListContext.searchQuery); 1540 } 1541 if (mCurrentConversation != null && mViewMode.isConversationMode()) { 1542 outState.putParcelable(SAVED_CONVERSATION, mCurrentConversation); 1543 } 1544 if (!mSelectedSet.isEmpty()) { 1545 outState.putParcelable(SAVED_SELECTED_SET, mSelectedSet); 1546 } 1547 if (mToastBar.getVisibility() == View.VISIBLE) { 1548 outState.putParcelable(SAVED_TOAST_BAR_OP, mToastBar.getOperation()); 1549 } 1550 final ConversationListFragment convListFragment = getConversationListFragment(); 1551 if (convListFragment != null) { 1552 convListFragment.getAnimatedAdapter().onSaveInstanceState(outState); 1553 } 1554 // If there is a dialog being shown, save the state so we can create a listener for it. 1555 if (mDialogAction != -1) { 1556 outState.putInt(SAVED_ACTION, mDialogAction); 1557 outState.putBoolean(SAVED_ACTION_FROM_SELECTED, mDialogFromSelectedSet); 1558 } 1559 if (mDetachedConvUri != null) { 1560 outState.putParcelable(SAVED_DETACHED_CONV_URI, mDetachedConvUri); 1561 } 1562 mSafeToModifyFragments = false; 1563 outState.putParcelable(SAVED_HIERARCHICAL_FOLDER, mFolderListFolder); 1564 } 1565 1566 /** 1567 * @see #mSafeToModifyFragments 1568 */ 1569 protected boolean safeToModifyFragments() { 1570 return mSafeToModifyFragments; 1571 } 1572 1573 @Override 1574 public void executeSearch(String query) { 1575 Intent intent = new Intent(); 1576 intent.setAction(Intent.ACTION_SEARCH); 1577 intent.putExtra(ConversationListContext.EXTRA_SEARCH_QUERY, query); 1578 intent.putExtra(Utils.EXTRA_ACCOUNT, mAccount); 1579 intent.setComponent(mActivity.getComponentName()); 1580 mActionBarView.collapseSearch(); 1581 mActivity.startActivity(intent); 1582 } 1583 1584 @Override 1585 public void onStop() { 1586 NotificationActionUtils.unregisterUndoNotificationObserver(mUndoNotificationObserver); 1587 } 1588 1589 @Override 1590 public void onDestroy() { 1591 // stop listening to the cursor on e.g. configuration changes 1592 if (mConversationListCursor != null) { 1593 mConversationListCursor.removeListener(this); 1594 } 1595 // unregister the ViewPager's observer on the conversation cursor 1596 mPagerController.onDestroy(); 1597 mActionBarView.onDestroy(); 1598 mRecentFolderList.destroy(); 1599 mDestroyed = true; 1600 mHandler.removeCallbacks(mLogServiceChecker); 1601 mLogServiceChecker = null; 1602 } 1603 1604 /** 1605 * Set the Action Bar icon according to the mode. The Action Bar icon can contain a back button 1606 * or not. The individual controller is responsible for changing the icon based on the mode. 1607 */ 1608 protected abstract void resetActionBarIcon(); 1609 1610 /** 1611 * {@inheritDoc} Subclasses must override this to listen to mode changes 1612 * from the ViewMode. Subclasses <b>must</b> call the parent's 1613 * onViewModeChanged since the parent will handle common state changes. 1614 */ 1615 @Override 1616 public void onViewModeChanged(int newMode) { 1617 // When we step away from the conversation mode, we don't have a current conversation 1618 // anymore. Let's blank it out so clients calling getCurrentConversation are not misled. 1619 if (!ViewMode.isConversationMode(newMode)) { 1620 setCurrentConversation(null); 1621 } 1622 // If the viewmode is not set, preserve existing icon. 1623 if (newMode != ViewMode.UNKNOWN) { 1624 resetActionBarIcon(); 1625 } 1626 } 1627 1628 public void disablePagerUpdates() { 1629 mPagerController.stopListening(); 1630 } 1631 1632 public boolean isDestroyed() { 1633 return mDestroyed; 1634 } 1635 1636 @Override 1637 public void commitDestructiveActions(boolean animate) { 1638 ConversationListFragment fragment = getConversationListFragment(); 1639 if (fragment != null) { 1640 fragment.commitDestructiveActions(animate); 1641 } 1642 } 1643 1644 @Override 1645 public void onWindowFocusChanged(boolean hasFocus) { 1646 final ConversationListFragment convList = getConversationListFragment(); 1647 // hasFocus already ensures that the window is in focus, so we don't need to call 1648 // AAC.isFragmentVisible(convList) here. 1649 if (hasFocus && convList != null && convList.isVisible()) { 1650 // The conversation list is visible. 1651 informCursorVisiblity(true); 1652 } 1653 } 1654 1655 /** 1656 * Set the account, and carry out all the account-related changes that rely on this. 1657 * @param account new account to set to. 1658 */ 1659 private void setAccount(Account account) { 1660 if (account == null) { 1661 LogUtils.w(LOG_TAG, new Error(), 1662 "AAC ignoring null (presumably invalid) account restoration"); 1663 return; 1664 } 1665 LogUtils.d(LOG_TAG, "AbstractActivityController.setAccount(): account = %s", account.uri); 1666 mAccount = account; 1667 // Only change AAC state here. Do *not* modify any other object's state. The object 1668 // should listen on account changes. 1669 restartOptionalLoader(LOADER_RECENT_FOLDERS); 1670 mActivity.invalidateOptionsMenu(); 1671 disableNotificationsOnAccountChange(mAccount); 1672 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR); 1673 // The Mail instance can be null during test runs. 1674 final MailAppProvider instance = MailAppProvider.getInstance(); 1675 if (instance != null) { 1676 instance.setLastViewedAccount(mAccount.uri.toString()); 1677 } 1678 if (account.settings == null) { 1679 LogUtils.w(LOG_TAG, new Error(), "AAC ignoring account with null settings."); 1680 return; 1681 } 1682 mAccountObservers.notifyChanged(); 1683 perhapsEnterWaitMode(); 1684 } 1685 1686 /** 1687 * Restore the state from the previous bundle. Subclasses should call this 1688 * method from the parent class, since it performs important UI 1689 * initialization. 1690 * 1691 * @param savedState previous state 1692 */ 1693 @Override 1694 public void onRestoreInstanceState(Bundle savedState) { 1695 mDetachedConvUri = savedState.getParcelable(SAVED_DETACHED_CONV_URI); 1696 if (savedState.containsKey(SAVED_CONVERSATION)) { 1697 // Open the conversation. 1698 final Conversation conversation = savedState.getParcelable(SAVED_CONVERSATION); 1699 if (conversation != null && conversation.position < 0) { 1700 // Set the position to 0 on this conversation, as we don't know where it is 1701 // in the list 1702 conversation.position = 0; 1703 } 1704 showConversation(conversation); 1705 } 1706 1707 if (savedState.containsKey(SAVED_TOAST_BAR_OP)) { 1708 ToastBarOperation op = savedState.getParcelable(SAVED_TOAST_BAR_OP); 1709 if (op != null) { 1710 if (op.getType() == ToastBarOperation.UNDO) { 1711 onUndoAvailable(op); 1712 } else if (op.getType() == ToastBarOperation.ERROR) { 1713 onError(mFolder, true); 1714 } 1715 } 1716 } 1717 mFolderListFolder = savedState.getParcelable(SAVED_HIERARCHICAL_FOLDER); 1718 final ConversationListFragment convListFragment = getConversationListFragment(); 1719 if (convListFragment != null) { 1720 convListFragment.getAnimatedAdapter().onRestoreInstanceState(savedState); 1721 } 1722 /* 1723 * Restore the state of selected conversations. This needs to be done after the correct mode 1724 * is set and the action bar is fully initialized. If not, several key pieces of state 1725 * information will be missing, and the split views may not be initialized correctly. 1726 */ 1727 restoreSelectedConversations(savedState); 1728 // Order is important!!! 1729 // The dialog listener needs to happen *after* the selected set is restored. 1730 1731 // If there has been an orientation change, and we need to recreate the listener for the 1732 // confirm dialog fragment (delete/archive/...), then do it here. 1733 if (mDialogAction != -1) { 1734 makeDialogListener(mDialogAction, mDialogFromSelectedSet); 1735 } 1736 } 1737 1738 /** 1739 * Handle an intent to open the app. This method is called only when there is no saved state, 1740 * so we need to set state that wasn't set before. It is correct to change the viewmode here 1741 * since it has not been previously set. 1742 * @param intent intent passed to the activity. 1743 */ 1744 private void handleIntent(Intent intent) { 1745 if (Intent.ACTION_VIEW.equals(intent.getAction())) { 1746 if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) { 1747 setAccount(Account.newinstance(intent.getStringExtra(Utils.EXTRA_ACCOUNT))); 1748 } 1749 if (mAccount == null) { 1750 return; 1751 } 1752 final boolean isConversationMode = intent.hasExtra(Utils.EXTRA_CONVERSATION); 1753 if (isConversationMode && mViewMode.getMode() == ViewMode.UNKNOWN) { 1754 mViewMode.enterConversationMode(); 1755 } else { 1756 mViewMode.enterConversationListMode(); 1757 } 1758 1759 new FolderChangedAsyncTask(mContext, intent).execute((Void[]) null); 1760 } else if (Intent.ACTION_SEARCH.equals(intent.getAction())) { 1761 if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) { 1762 mHaveSearchResults = false; 1763 // Save this search query for future suggestions. 1764 final String query = intent.getStringExtra(SearchManager.QUERY); 1765 final String authority = mContext.getString(R.string.suggestions_authority); 1766 final SearchRecentSuggestions suggestions = new SearchRecentSuggestions( 1767 mContext, authority, SuggestionsProvider.MODE); 1768 suggestions.saveRecentQuery(query, null); 1769 setAccount((Account) intent.getParcelableExtra(Utils.EXTRA_ACCOUNT)); 1770 fetchSearchFolder(intent); 1771 if (shouldEnterSearchConvMode()) { 1772 mViewMode.enterSearchResultsConversationMode(); 1773 } else { 1774 mViewMode.enterSearchResultsListMode(); 1775 } 1776 } else { 1777 LogUtils.e(LOG_TAG, "Missing account extra from search intent. Finishing"); 1778 mActivity.finish(); 1779 } 1780 } 1781 if (mAccount != null) { 1782 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR); 1783 } 1784 } 1785 1786 private class FolderChangedAsyncTask extends AsyncTask<Void, Void, Folder> { 1787 private final Context mContext; 1788 private final Intent mIntent; 1789 1790 public FolderChangedAsyncTask(final Context context, final Intent intent) { 1791 mContext = context; 1792 mIntent = intent; 1793 } 1794 1795 @Override 1796 protected Folder doInBackground(final Void... params) { 1797 final Uri folderUri = mIntent.getParcelableExtra(Utils.EXTRA_FOLDER_URI); 1798 1799 final Cursor folderCursor = 1800 mContext.getContentResolver().query(folderUri, UIProvider.FOLDERS_PROJECTION, 1801 null, null, null); 1802 1803 Folder folder = null; 1804 1805 try { 1806 if (folderCursor.moveToFirst()) { 1807 folder = new Folder(folderCursor); 1808 } 1809 } finally { 1810 folderCursor.close(); 1811 } 1812 1813 return folder; 1814 } 1815 1816 @Override 1817 protected void onPostExecute(final Folder folder) { 1818 boolean handled = false; 1819 1820 if (folder != null) { 1821 onFolderChanged(folder); 1822 handled = true; 1823 } 1824 1825 final boolean isConversationMode = mIntent.hasExtra(Utils.EXTRA_CONVERSATION); 1826 1827 if (isConversationMode) { 1828 // Open the conversation. 1829 LogUtils.d(LOG_TAG, "SHOW THE CONVERSATION at %s", 1830 mIntent.getParcelableExtra(Utils.EXTRA_CONVERSATION)); 1831 final Conversation conversation = 1832 mIntent.getParcelableExtra(Utils.EXTRA_CONVERSATION); 1833 if (conversation != null && conversation.position < 0) { 1834 // Set the position to 0 on this conversation, as we don't know where it is 1835 // in the list 1836 conversation.position = 0; 1837 } 1838 showConversation(conversation); 1839 handled = true; 1840 } 1841 1842 if (!handled) { 1843 // We have an account, but nothing else: load the default inbox. 1844 loadAccountInbox(); 1845 } 1846 } 1847 } 1848 1849 /** 1850 * Returns true if we should enter conversation mode with search. 1851 */ 1852 protected final boolean shouldEnterSearchConvMode() { 1853 return mHaveSearchResults && Utils.showTwoPaneSearchResults(mActivity.getActivityContext()); 1854 } 1855 1856 /** 1857 * Copy any selected conversations stored in the saved bundle into our selection set, 1858 * triggering {@link ConversationSetObserver} callbacks as our selection set changes. 1859 * 1860 */ 1861 private void restoreSelectedConversations(Bundle savedState) { 1862 if (savedState == null) { 1863 mSelectedSet.clear(); 1864 return; 1865 } 1866 final ConversationSelectionSet selectedSet = savedState.getParcelable(SAVED_SELECTED_SET); 1867 if (selectedSet == null || selectedSet.isEmpty()) { 1868 mSelectedSet.clear(); 1869 return; 1870 } 1871 1872 // putAll will take care of calling our registered onSetPopulated method 1873 mSelectedSet.putAll(selectedSet); 1874 } 1875 1876 @Override 1877 public SubjectDisplayChanger getSubjectDisplayChanger() { 1878 return mActionBarView; 1879 } 1880 1881 private void showConversation(Conversation conversation) { 1882 showConversation(conversation, false /* inLoaderCallbacks */); 1883 } 1884 1885 /** 1886 * Show the conversation provided in the arguments. It is safe to pass a null conversation 1887 * object, which is a signal to back out of conversation view mode. 1888 * Child classes must call super.showConversation() <b>before</b> their own implementations. 1889 * @param conversation the conversation to be shown, or null if we want to back out to list 1890 * mode. 1891 * @param inLoaderCallbacks true if the method is called as a result of 1892 * {@link #onLoadFinished(Loader, Cursor)} 1893 */ 1894 protected void showConversation(Conversation conversation, boolean inLoaderCallbacks) { 1895 if (conversation != null) { 1896 Utils.sConvLoadTimer.start(); 1897 } 1898 1899 MailLogService.log("AbstractActivityController", "showConversation(" + conversation + " )" 1900 + ""); 1901 // Set the current conversation just in case it wasn't already set. 1902 setCurrentConversation(conversation); 1903 // Add the folder that we were viewing to the recent folders list. 1904 // TODO: this may need to be fine tuned. If this is the signal that is indicating that 1905 // the list is shown to the user, this could fire in one pane if the user goes directly 1906 // to a conversation 1907 updateRecentFolderList(); 1908 } 1909 1910 /** 1911 * Children can override this method, but they must call super.showWaitForInitialization(). 1912 * {@inheritDoc} 1913 */ 1914 @Override 1915 public void showWaitForInitialization() { 1916 mViewMode.enterWaitingForInitializationMode(); 1917 mWaitFragment = WaitFragment.newInstance(mAccount); 1918 } 1919 1920 private void updateWaitMode() { 1921 final FragmentManager manager = mActivity.getFragmentManager(); 1922 final WaitFragment waitFragment = 1923 (WaitFragment)manager.findFragmentByTag(TAG_WAIT); 1924 if (waitFragment != null) { 1925 waitFragment.updateAccount(mAccount); 1926 } 1927 } 1928 1929 /** 1930 * Remove the "Waiting for Initialization" fragment. Child classes are free to override this 1931 * method, though they must call the parent implementation <b>after</b> they do anything. 1932 */ 1933 protected void hideWaitForInitialization() { 1934 mWaitFragment = null; 1935 } 1936 1937 /** 1938 * Use the instance variable and the wait fragment's tag to get the wait fragment. This is 1939 * far superior to using the value of mWaitFragment, which might be invalid or might refer 1940 * to a fragment after it has been destroyed. 1941 * @return a wait fragment that is already attached to the activity, if one exists 1942 */ 1943 protected final WaitFragment getWaitFragment() { 1944 final FragmentManager manager = mActivity.getFragmentManager(); 1945 final WaitFragment waitFrag = (WaitFragment) manager.findFragmentByTag(TAG_WAIT); 1946 if (waitFrag != null) { 1947 // The Fragment Manager knows better, so use its instance. 1948 mWaitFragment = waitFrag; 1949 } 1950 return mWaitFragment; 1951 } 1952 1953 /** 1954 * Returns true if we are waiting for the account to sync, and cannot show any folders or 1955 * conversation for the current account yet. 1956 */ 1957 private boolean inWaitMode() { 1958 final WaitFragment waitFragment = getWaitFragment(); 1959 if (waitFragment != null) { 1960 final Account fragmentAccount = waitFragment.getAccount(); 1961 return fragmentAccount != null && fragmentAccount.uri.equals(mAccount.uri) && 1962 mViewMode.getMode() == ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION; 1963 } 1964 return false; 1965 } 1966 1967 /** 1968 * Children can override this method, but they must call super.showConversationList(). 1969 * {@inheritDoc} 1970 */ 1971 @Override 1972 public void showConversationList(ConversationListContext listContext) { 1973 } 1974 1975 @Override 1976 public final void onConversationSelected(Conversation conversation, boolean inLoaderCallbacks) { 1977 // Only animate destructive actions if we are going to be showing the 1978 // conversation list when we show the next conversation. 1979 commitDestructiveActions(mIsTablet); 1980 showConversation(conversation, inLoaderCallbacks); 1981 } 1982 1983 @Override 1984 public Conversation getCurrentConversation() { 1985 return mCurrentConversation; 1986 } 1987 1988 /** 1989 * Set the current conversation. This is the conversation on which all actions are performed. 1990 * Do not modify mCurrentConversation except through this method, which makes it easy to 1991 * perform common actions associated with changing the current conversation. 1992 * @param conversation new conversation to view. Passing null indicates that we are backing 1993 * out to conversation list mode. 1994 */ 1995 @Override 1996 public void setCurrentConversation(Conversation conversation) { 1997 // The controller should come out of detached mode if a new conversation is viewed, or if 1998 // we are going back to conversation list mode. 1999 if (mDetachedConvUri != null && (conversation == null 2000 || !mDetachedConvUri.equals(conversation.uri))) { 2001 clearDetachedMode(); 2002 } 2003 2004 // Must happen *before* setting mCurrentConversation because this sets 2005 // conversation.position if a cursor is available. 2006 mTracker.initialize(conversation); 2007 mCurrentConversation = conversation; 2008 2009 if (mCurrentConversation != null) { 2010 mActionBarView.setCurrentConversation(mCurrentConversation); 2011 mActionBarView.setSubject(mCurrentConversation.subject); 2012 mActivity.invalidateOptionsMenu(); 2013 } 2014 } 2015 2016 /** 2017 * {@inheritDoc} 2018 */ 2019 @Override 2020 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 2021 switch (id) { 2022 case LOADER_ACCOUNT_CURSOR: 2023 return new CursorLoader(mContext, MailAppProvider.getAccountsUri(), 2024 UIProvider.ACCOUNTS_PROJECTION, null, null, null); 2025 case LOADER_FOLDER_CURSOR: 2026 final CursorLoader loader = new CursorLoader(mContext, mFolder.uri, 2027 UIProvider.FOLDERS_PROJECTION, null, null, null); 2028 loader.setUpdateThrottle(mFolderItemUpdateDelayMs); 2029 return loader; 2030 case LOADER_RECENT_FOLDERS: 2031 if (mAccount != null && mAccount.recentFolderListUri != null) { 2032 return new CursorLoader(mContext, mAccount.recentFolderListUri, 2033 UIProvider.FOLDERS_PROJECTION, null, null, null); 2034 } 2035 break; 2036 case LOADER_ACCOUNT_INBOX: 2037 final Uri defaultInbox = Settings.getDefaultInboxUri(mAccount.settings); 2038 final Uri inboxUri = defaultInbox.equals(Uri.EMPTY) ? 2039 mAccount.folderListUri : defaultInbox; 2040 LogUtils.d(LOG_TAG, "Loading the default inbox: %s", inboxUri); 2041 if (inboxUri != null) { 2042 return new CursorLoader(mContext, inboxUri, UIProvider.FOLDERS_PROJECTION, null, 2043 null, null); 2044 } 2045 break; 2046 case LOADER_SEARCH: 2047 return Folder.forSearchResults(mAccount, 2048 args.getString(ConversationListContext.EXTRA_SEARCH_QUERY), 2049 mActivity.getActivityContext()); 2050 case LOADER_ACCOUNT_UPDATE_CURSOR: 2051 return new CursorLoader(mContext, mAccount.uri, UIProvider.ACCOUNTS_PROJECTION, 2052 null, null, null); 2053 default: 2054 LogUtils.wtf(LOG_TAG, "Loader returned unexpected id: %d", id); 2055 } 2056 return null; 2057 } 2058 2059 @Override 2060 public void onLoaderReset(Loader<Cursor> loader) { 2061 2062 } 2063 2064 /** 2065 * {@link LoaderManager} currently has a bug in 2066 * {@link LoaderManager#restartLoader(int, Bundle, android.app.LoaderManager.LoaderCallbacks)} 2067 * where, if a previous onCreateLoader returned a null loader, this method will NPE. Work around 2068 * this bug by destroying any loaders that may have been created as null (essentially because 2069 * they are optional loads, and may not apply to a particular account). 2070 * <p> 2071 * A simple null check before restarting a loader will not work, because that would not 2072 * give the controller a chance to invalidate UI corresponding the prior loader result. 2073 * 2074 * @param id loader ID to safely restart 2075 */ 2076 private void restartOptionalLoader(int id) { 2077 final LoaderManager lm = mActivity.getLoaderManager(); 2078 lm.destroyLoader(id); 2079 lm.restartLoader(id, Bundle.EMPTY, this); 2080 } 2081 2082 @Override 2083 public void registerConversationListObserver(DataSetObserver observer) { 2084 mConversationListObservable.registerObserver(observer); 2085 } 2086 2087 @Override 2088 public void unregisterConversationListObserver(DataSetObserver observer) { 2089 try { 2090 mConversationListObservable.unregisterObserver(observer); 2091 } catch (IllegalStateException e) { 2092 // Log instead of crash 2093 LogUtils.e(LOG_TAG, e, "unregisterConversationListObserver called for an observer that " 2094 + "hasn't been registered"); 2095 } 2096 } 2097 2098 @Override 2099 public void registerFolderObserver(DataSetObserver observer) { 2100 mFolderObservable.registerObserver(observer); 2101 } 2102 2103 @Override 2104 public void unregisterFolderObserver(DataSetObserver observer) { 2105 try { 2106 mFolderObservable.unregisterObserver(observer); 2107 } catch (IllegalStateException e) { 2108 // Log instead of crash 2109 LogUtils.e(LOG_TAG, e, "unregisterFolderObserver called for an observer that " 2110 + "hasn't been registered"); 2111 } 2112 } 2113 2114 @Override 2115 public void registerConversationLoadedObserver(DataSetObserver observer) { 2116 mPagerController.registerConversationLoadedObserver(observer); 2117 } 2118 2119 @Override 2120 public void unregisterConversationLoadedObserver(DataSetObserver observer) { 2121 try { 2122 mPagerController.unregisterConversationLoadedObserver(observer); 2123 } catch (IllegalStateException e) { 2124 // Log instead of crash 2125 LogUtils.e(LOG_TAG, e, "unregisterConversationLoadedObserver called for an observer " 2126 + "that hasn't been registered"); 2127 } 2128 } 2129 2130 /** 2131 * Returns true if the number of accounts is different, or if the current account has been 2132 * removed from the device 2133 * @param accountCursor the cursor which points to all the accounts. 2134 * @return true if the number of accounts is changed or current account missing from the list. 2135 */ 2136 private boolean accountsUpdated(Cursor accountCursor) { 2137 // Check to see if the current account hasn't been set, or the account cursor is empty 2138 if (mAccount == null || !accountCursor.moveToFirst()) { 2139 return true; 2140 } 2141 2142 // Check to see if the number of accounts are different, from the number we saw on the last 2143 // updated 2144 if (mCurrentAccountUris.size() != accountCursor.getCount()) { 2145 return true; 2146 } 2147 2148 // Check to see if the account list is different or if the current account is not found in 2149 // the cursor. 2150 boolean foundCurrentAccount = false; 2151 do { 2152 final Uri accountUri = Uri.parse(accountCursor.getString( 2153 accountCursor.getColumnIndex(UIProvider.AccountColumns.URI))); 2154 if (!foundCurrentAccount && mAccount.uri.equals(accountUri)) { 2155 foundCurrentAccount = true; 2156 } 2157 // Is there a new account that we do not know about? 2158 if (!mCurrentAccountUris.contains(accountUri)) { 2159 return true; 2160 } 2161 } while (accountCursor.moveToNext()); 2162 2163 // As long as we found the current account, the list hasn't been updated 2164 return !foundCurrentAccount; 2165 } 2166 2167 /** 2168 * Updates accounts for the app. If the current account is missing, the first 2169 * account in the list is set to the current account (we <em>have</em> to choose something). 2170 * 2171 * @param accounts cursor into the AccountCache 2172 * @return true if the update was successful, false otherwise 2173 */ 2174 private boolean updateAccounts(Cursor accounts) { 2175 if (accounts == null || !accounts.moveToFirst()) { 2176 return false; 2177 } 2178 2179 final Account[] allAccounts = Account.getAllAccounts(accounts); 2180 // A match for the current account's URI in the list of accounts. 2181 Account currentFromList = null; 2182 2183 // Save the uris for the accounts and find the current account in the updated cursor. 2184 mCurrentAccountUris.clear(); 2185 for (final Account account : allAccounts) { 2186 LogUtils.d(LOG_TAG, "updateAccounts(%s)", account); 2187 mCurrentAccountUris.add(account.uri); 2188 if (mAccount != null && account.uri.equals(mAccount.uri)) { 2189 currentFromList = account; 2190 } 2191 } 2192 2193 // 1. current account is already set and is in allAccounts: 2194 // 1a. It has changed -> load the updated account. 2195 // 2b. It is unchanged -> no-op 2196 // 2. current account is set and is not in allAccounts -> pick first (acct was deleted?) 2197 // 3. saved preference has an account -> pick that one 2198 // 4. otherwise just pick first 2199 2200 boolean accountChanged = false; 2201 /// Assume case 4, initialize to first account, and see if we can find anything better. 2202 Account newAccount = allAccounts[0]; 2203 if (currentFromList != null) { 2204 // Case 1: Current account exists but has changed 2205 if (!currentFromList.equals(mAccount)) { 2206 newAccount = currentFromList; 2207 accountChanged = true; 2208 } 2209 // Case 1b: else, current account is unchanged: nothing to do. 2210 } else { 2211 // Case 2: Current account is not in allAccounts, the account needs to change. 2212 accountChanged = true; 2213 if (mAccount == null) { 2214 // Case 3: Check for last viewed account, and check if it exists in the list. 2215 final String lastAccountUri = MailAppProvider.getInstance().getLastViewedAccount(); 2216 if (lastAccountUri != null) { 2217 for (final Account account : allAccounts) { 2218 if (lastAccountUri.equals(account.uri.toString())) { 2219 newAccount = account; 2220 break; 2221 } 2222 } 2223 } 2224 } 2225 } 2226 if (accountChanged) { 2227 onAccountChanged(newAccount); 2228 } 2229 // Whether we have updated the current account or not, we need to update the list of 2230 // accounts in the ActionBar. 2231 mActionBarView.setAccounts(allAccounts); 2232 return (allAccounts.length > 0); 2233 } 2234 2235 private void disableNotifications() { 2236 mNewEmailReceiver.activate(mContext, this); 2237 } 2238 2239 private void enableNotifications() { 2240 mNewEmailReceiver.deactivate(); 2241 } 2242 2243 private void disableNotificationsOnAccountChange(Account account) { 2244 // If the new mail suppression receiver is activated for a different account, we want to 2245 // activate it for the new account. 2246 if (mNewEmailReceiver.activated() && 2247 !mNewEmailReceiver.notificationsDisabledForAccount(account)) { 2248 // Deactivate the current receiver, otherwise multiple receivers may be registered. 2249 mNewEmailReceiver.deactivate(); 2250 mNewEmailReceiver.activate(mContext, this); 2251 } 2252 } 2253 2254 /** 2255 * {@inheritDoc} 2256 */ 2257 @Override 2258 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 2259 // We want to reinitialize only if we haven't ever been initialized, or 2260 // if the current account has vanished. 2261 if (data == null) { 2262 LogUtils.e(LOG_TAG, "Received null cursor from loader id: %d", loader.getId()); 2263 } 2264 switch (loader.getId()) { 2265 case LOADER_ACCOUNT_CURSOR: 2266 if (data == null) { 2267 // Nothing useful to do if we have no valid data. 2268 break; 2269 } 2270 if (data.getCount() == 0) { 2271 // If an empty cursor is returned, the MailAppProvider is indicating that 2272 // no accounts have been specified. We want to navigate to the "add account" 2273 // activity that will handle the intent returned by the MailAppProvider 2274 2275 // If the MailAppProvider believes that all accounts have been loaded, and the 2276 // account list is still empty, we want to prompt the user to add an account 2277 final Bundle extras = data.getExtras(); 2278 final boolean accountsLoaded = 2279 extras.getInt(AccountCursorExtraKeys.ACCOUNTS_LOADED) != 0; 2280 2281 if (accountsLoaded) { 2282 final Intent noAccountIntent = MailAppProvider.getNoAccountIntent(mContext); 2283 if (noAccountIntent != null) { 2284 mActivity.startActivityForResult(noAccountIntent, 2285 ADD_ACCOUNT_REQUEST_CODE); 2286 } 2287 } 2288 } else { 2289 final boolean accountListUpdated = accountsUpdated(data); 2290 if (!isLoaderInitialized || accountListUpdated) { 2291 isLoaderInitialized = updateAccounts(data); 2292 } 2293 } 2294 break; 2295 case LOADER_ACCOUNT_UPDATE_CURSOR: 2296 // We have gotten an update for current account. 2297 2298 // Make sure that this is an update for the current account 2299 if (data != null && data.moveToFirst()) { 2300 final Account updatedAccount = new Account(data); 2301 2302 if (updatedAccount.uri.equals(mAccount.uri)) { 2303 // Keep a reference to the previous settings object 2304 final Settings previousSettings = mAccount.settings; 2305 2306 // Update the controller's reference to the current account 2307 mAccount = updatedAccount; 2308 LogUtils.d(LOG_TAG, "AbstractActivityController.onLoadFinished(): " 2309 + "mAccount = %s", mAccount.uri); 2310 2311 // Only notify about a settings change if something differs 2312 if (!Objects.equal(mAccount.settings, previousSettings)) { 2313 mAccountObservers.notifyChanged(); 2314 } 2315 perhapsEnterWaitMode(); 2316 } else { 2317 LogUtils.e(LOG_TAG, "Got update for account: %s with current account: %s", 2318 updatedAccount.uri, mAccount.uri); 2319 // We need to restart the loader, so the correct account information will 2320 // be returned 2321 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR); 2322 } 2323 } 2324 break; 2325 case LOADER_FOLDER_CURSOR: 2326 // Check status of the cursor. 2327 if (data != null && data.moveToFirst()) { 2328 final Folder folder = new Folder(data); 2329 LogUtils.d(LOG_TAG, "FOLDER STATUS = %d", folder.syncStatus); 2330 setHasFolderChanged(folder); 2331 mFolder = folder; 2332 mFolderObservable.notifyChanged(); 2333 } else { 2334 LogUtils.d(LOG_TAG, "Unable to get the folder %s", 2335 mFolder != null ? mAccount.name : ""); 2336 } 2337 break; 2338 case LOADER_RECENT_FOLDERS: 2339 // Few recent folders and we are running on a phone? Populate the default recents. 2340 // The number of default recent folders is at least 2: every provider has at 2341 // least two folders, and the recent folder count never decreases. Having a single 2342 // recent folder is an erroneous case, and we can gracefully recover by populating 2343 // default recents. The default recents will not stomp on the existing value: it 2344 // will be shown in addition to the default folders: the max number of recent 2345 // folders is more than 1+num(defaultRecents). 2346 if (data != null && data.getCount() <= 1 && !mIsTablet) { 2347 final class PopulateDefault extends AsyncTask<Uri, Void, Void> { 2348 @Override 2349 protected Void doInBackground(Uri... uri) { 2350 // Asking for an update on the URI and ignore the result. 2351 final ContentResolver resolver = mContext.getContentResolver(); 2352 resolver.update(uri[0], null, null, null); 2353 return null; 2354 } 2355 } 2356 final Uri uri = mAccount.defaultRecentFolderListUri; 2357 LogUtils.v(LOG_TAG, "Default recents at %s", uri); 2358 new PopulateDefault().execute(uri); 2359 break; 2360 } 2361 LogUtils.v(LOG_TAG, "Reading recent folders from the cursor."); 2362 loadRecentFolders(data); 2363 break; 2364 case LOADER_ACCOUNT_INBOX: 2365 if (data != null && !data.isClosed() && data.moveToFirst()) { 2366 Folder inbox = new Folder(data); 2367 onFolderChanged(inbox); 2368 // Just want to get the inbox, don't care about updates to it 2369 // as this will be tracked by the folder change listener. 2370 mActivity.getLoaderManager().destroyLoader(LOADER_ACCOUNT_INBOX); 2371 } else { 2372 LogUtils.d(LOG_TAG, "Unable to get the account inbox for account %s", 2373 mAccount != null ? mAccount.name : ""); 2374 } 2375 break; 2376 case LOADER_SEARCH: 2377 if (data != null && data.getCount() > 0) { 2378 data.moveToFirst(); 2379 final Folder search = new Folder(data); 2380 updateFolder(search); 2381 mConvListContext = ConversationListContext.forSearchQuery(mAccount, mFolder, 2382 mActivity.getIntent() 2383 .getStringExtra(UIProvider.SearchQueryParameters.QUERY)); 2384 showConversationList(mConvListContext); 2385 mActivity.invalidateOptionsMenu(); 2386 mHaveSearchResults = search.totalCount > 0; 2387 mActivity.getLoaderManager().destroyLoader(LOADER_SEARCH); 2388 } else { 2389 LogUtils.e(LOG_TAG, "Null or empty cursor returned by LOADER_SEARCH loader"); 2390 } 2391 break; 2392 } 2393 } 2394 2395 2396 /** 2397 * Destructive actions on Conversations. This class should only be created by controllers, and 2398 * clients should only require {@link DestructiveAction}s, not specific implementations of the. 2399 * Only the controllers should know what kind of destructive actions are being created. 2400 */ 2401 public class ConversationAction implements DestructiveAction { 2402 /** 2403 * The action to be performed. This is specified as the resource ID of the menu item 2404 * corresponding to this action: R.id.delete, R.id.report_spam, etc. 2405 */ 2406 private final int mAction; 2407 /** The action will act upon these conversations */ 2408 private final Collection<Conversation> mTarget; 2409 /** Whether this destructive action has already been performed */ 2410 private boolean mCompleted; 2411 /** Whether this is an action on the currently selected set. */ 2412 private final boolean mIsSelectedSet; 2413 2414 /** 2415 * Create a listener object. 2416 * @param action action is one of four constants: R.id.y_button (archive), 2417 * R.id.delete , R.id.mute, and R.id.report_spam. 2418 * @param target Conversation that we want to apply the action to. 2419 * @param isBatch whether the conversations are in the currently selected batch set. 2420 */ 2421 public ConversationAction(int action, Collection<Conversation> target, boolean isBatch) { 2422 mAction = action; 2423 mTarget = ImmutableList.copyOf(target); 2424 mIsSelectedSet = isBatch; 2425 } 2426 2427 /** 2428 * The action common to child classes. This performs the action specified in the constructor 2429 * on the conversations given here. 2430 */ 2431 @Override 2432 public void performAction() { 2433 if (isPerformed()) { 2434 return; 2435 } 2436 boolean undoEnabled = mAccount.supportsCapability(AccountCapabilities.UNDO); 2437 2438 // Are we destroying the currently shown conversation? Show the next one. 2439 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)){ 2440 LogUtils.d(LOG_TAG, "ConversationAction.performAction():" 2441 + "\nmTarget=%s\nCurrent=%s", 2442 Conversation.toString(mTarget), mCurrentConversation); 2443 } 2444 2445 if (mConversationListCursor == null) { 2446 LogUtils.e(LOG_TAG, "null ConversationCursor in ConversationAction.performAction():" 2447 + "\nmTarget=%s\nCurrent=%s", 2448 Conversation.toString(mTarget), mCurrentConversation); 2449 return; 2450 } 2451 2452 switch (mAction) { 2453 case R.id.archive: 2454 LogUtils.d(LOG_TAG, "Archiving"); 2455 mConversationListCursor.archive(mContext, mTarget); 2456 break; 2457 case R.id.delete: 2458 LogUtils.d(LOG_TAG, "Deleting"); 2459 mConversationListCursor.delete(mContext, mTarget); 2460 if (mFolder.supportsCapability(FolderCapabilities.DELETE_ACTION_FINAL)) { 2461 undoEnabled = false; 2462 } 2463 break; 2464 case R.id.mute: 2465 LogUtils.d(LOG_TAG, "Muting"); 2466 if (mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)) { 2467 for (Conversation c : mTarget) { 2468 c.localDeleteOnUpdate = true; 2469 } 2470 } 2471 mConversationListCursor.mute(mContext, mTarget); 2472 break; 2473 case R.id.report_spam: 2474 LogUtils.d(LOG_TAG, "Reporting spam"); 2475 mConversationListCursor.reportSpam(mContext, mTarget); 2476 break; 2477 case R.id.mark_not_spam: 2478 LogUtils.d(LOG_TAG, "Marking not spam"); 2479 mConversationListCursor.reportNotSpam(mContext, mTarget); 2480 break; 2481 case R.id.report_phishing: 2482 LogUtils.d(LOG_TAG, "Reporting phishing"); 2483 mConversationListCursor.reportPhishing(mContext, mTarget); 2484 break; 2485 case R.id.remove_star: 2486 LogUtils.d(LOG_TAG, "Removing star"); 2487 // Star removal is destructive in the Starred folder. 2488 mConversationListCursor.updateBoolean(mContext, mTarget, 2489 ConversationColumns.STARRED, false); 2490 break; 2491 case R.id.mark_not_important: 2492 LogUtils.d(LOG_TAG, "Marking not-important"); 2493 // Marking not important is destructive in a mailbox 2494 // containing only important messages 2495 if (mFolder != null && mFolder.isImportantOnly()) { 2496 for (Conversation conv : mTarget) { 2497 conv.localDeleteOnUpdate = true; 2498 } 2499 } 2500 mConversationListCursor.updateInt(mContext, mTarget, 2501 ConversationColumns.PRIORITY, UIProvider.ConversationPriority.LOW); 2502 break; 2503 case R.id.discard_drafts: 2504 LogUtils.d(LOG_TAG, "Discarding draft messages"); 2505 // Discarding draft messages is destructive in a "draft" mailbox 2506 if (mFolder != null && mFolder.isDraft()) { 2507 for (Conversation conv : mTarget) { 2508 conv.localDeleteOnUpdate = true; 2509 } 2510 } 2511 mConversationListCursor.discardDrafts(mContext, mTarget); 2512 // We don't support undoing discarding drafts 2513 undoEnabled = false; 2514 break; 2515 } 2516 if (undoEnabled) { 2517 mHandler.postDelayed(new Runnable() { 2518 @Override 2519 public void run() { 2520 onUndoAvailable(new ToastBarOperation(mTarget.size(), mAction, 2521 ToastBarOperation.UNDO, mIsSelectedSet)); 2522 } 2523 }, mShowUndoBarDelay); 2524 } 2525 refreshConversationList(); 2526 if (mIsSelectedSet) { 2527 mSelectedSet.clear(); 2528 } 2529 } 2530 2531 /** 2532 * Returns true if this action has been performed, false otherwise. 2533 * 2534 */ 2535 private synchronized boolean isPerformed() { 2536 if (mCompleted) { 2537 return true; 2538 } 2539 mCompleted = true; 2540 return false; 2541 } 2542 } 2543 2544 // Called from the FolderSelectionDialog after a user is done selecting folders to assign the 2545 // conversations to. 2546 @Override 2547 public final void assignFolder(Collection<FolderOperation> folderOps, 2548 Collection<Conversation> target, boolean batch, boolean showUndo) { 2549 // Actions are destructive only when the current folder can be assigned 2550 // to (which is the same as being able to un-assign a conversation from the folder) and 2551 // when the list of folders contains the current folder. 2552 final boolean isDestructive = mFolder 2553 .supportsCapability(FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 2554 && FolderOperation.isDestructive(folderOps, mFolder); 2555 LogUtils.d(LOG_TAG, "onFolderChangesCommit: isDestructive = %b", isDestructive); 2556 if (isDestructive) { 2557 for (final Conversation c : target) { 2558 c.localDeleteOnUpdate = true; 2559 } 2560 } 2561 final DestructiveAction folderChange; 2562 // Update the UI elements depending no their visibility and availability 2563 // TODO(viki): Consolidate this into a single method requestDelete. 2564 if (isDestructive) { 2565 folderChange = getDeferredFolderChange(target, folderOps, isDestructive, 2566 batch, showUndo); 2567 delete(0, target, folderChange); 2568 } else { 2569 folderChange = getFolderChange(target, folderOps, isDestructive, 2570 batch, showUndo); 2571 requestUpdate(folderChange); 2572 } 2573 } 2574 2575 @Override 2576 public final void onRefreshRequired() { 2577 if (isAnimating() || isDragging()) { 2578 LogUtils.d(LOG_TAG, "onRefreshRequired: delay until animating done"); 2579 return; 2580 } 2581 // Refresh the query in the background 2582 if (mConversationListCursor.isRefreshRequired()) { 2583 mConversationListCursor.refresh(); 2584 } 2585 } 2586 2587 @Override 2588 public void startDragMode() { 2589 mIsDragHappening = true; 2590 } 2591 2592 @Override 2593 public void stopDragMode() { 2594 mIsDragHappening = false; 2595 if (mConversationListCursor.isRefreshReady()) { 2596 LogUtils.d(LOG_TAG, "Stopped animating: try sync"); 2597 onRefreshReady(); 2598 } 2599 2600 if (mConversationListCursor.isRefreshRequired()) { 2601 LogUtils.d(LOG_TAG, "Stopped animating: refresh"); 2602 mConversationListCursor.refresh(); 2603 } 2604 } 2605 2606 private boolean isDragging() { 2607 return mIsDragHappening; 2608 } 2609 2610 @Override 2611 public boolean isAnimating() { 2612 boolean isAnimating = false; 2613 ConversationListFragment convListFragment = getConversationListFragment(); 2614 if (convListFragment != null) { 2615 AnimatedAdapter adapter = convListFragment.getAnimatedAdapter(); 2616 if (adapter != null) { 2617 isAnimating = adapter.isAnimating(); 2618 } 2619 } 2620 return isAnimating; 2621 } 2622 2623 /** 2624 * Called when the {@link ConversationCursor} is changed or has new data in it. 2625 * <p> 2626 * {@inheritDoc} 2627 */ 2628 @Override 2629 public final void onRefreshReady() { 2630 LogUtils.d(LOG_TAG, "Received refresh ready callback for folder %s", 2631 mFolder != null ? mFolder.id : "-1"); 2632 2633 if (mDestroyed) { 2634 LogUtils.i(LOG_TAG, "ignoring onRefreshReady on destroyed AAC"); 2635 return; 2636 } 2637 2638 if (!isAnimating()) { 2639 // Swap cursors 2640 mConversationListCursor.sync(); 2641 } 2642 mTracker.onCursorUpdated(); 2643 perhapsShowFirstSearchResult(); 2644 } 2645 2646 @Override 2647 public final void onDataSetChanged() { 2648 updateConversationListFragment(); 2649 mConversationListObservable.notifyChanged(); 2650 mSelectedSet.validateAgainstCursor(mConversationListCursor); 2651 } 2652 2653 /** 2654 * If the Conversation List Fragment is visible, updates the fragment. 2655 */ 2656 private void updateConversationListFragment() { 2657 final ConversationListFragment convList = getConversationListFragment(); 2658 if (convList != null) { 2659 refreshConversationList(); 2660 if (isFragmentVisible(convList)) { 2661 informCursorVisiblity(true); 2662 } 2663 } 2664 } 2665 2666 /** 2667 * This class handles throttled refresh of the conversation list 2668 */ 2669 static class RefreshTimerTask extends TimerTask { 2670 final Handler mHandler; 2671 final AbstractActivityController mController; 2672 2673 RefreshTimerTask(AbstractActivityController controller, Handler handler) { 2674 mHandler = handler; 2675 mController = controller; 2676 } 2677 2678 @Override 2679 public void run() { 2680 mHandler.post(new Runnable() { 2681 @Override 2682 public void run() { 2683 LogUtils.d(LOG_TAG, "Delay done... calling onRefreshRequired"); 2684 mController.onRefreshRequired(); 2685 }}); 2686 } 2687 } 2688 2689 /** 2690 * Cancel the refresh task, if it's running 2691 */ 2692 private void cancelRefreshTask () { 2693 if (mConversationListRefreshTask != null) { 2694 mConversationListRefreshTask.cancel(); 2695 mConversationListRefreshTask = null; 2696 } 2697 } 2698 2699 private void loadRecentFolders(Cursor data) { 2700 mRecentFolderList.loadFromUiProvider(data); 2701 if (isAnimating()) { 2702 mRecentsDataUpdated = true; 2703 } else { 2704 mRecentFolderObservers.notifyChanged(); 2705 } 2706 } 2707 2708 @Override 2709 public void onAnimationEnd(AnimatedAdapter animatedAdapter) { 2710 if (mConversationListCursor == null) { 2711 LogUtils.e(LOG_TAG, "null ConversationCursor in onAnimationEnd"); 2712 return; 2713 } 2714 if (mConversationListCursor.isRefreshReady()) { 2715 LogUtils.d(LOG_TAG, "Stopped animating: try sync"); 2716 onRefreshReady(); 2717 } 2718 2719 if (mConversationListCursor.isRefreshRequired()) { 2720 LogUtils.d(LOG_TAG, "Stopped animating: refresh"); 2721 mConversationListCursor.refresh(); 2722 } 2723 if (mRecentsDataUpdated) { 2724 mRecentsDataUpdated = false; 2725 mRecentFolderObservers.notifyChanged(); 2726 } 2727 FolderListFragment frag = this.getFolderListFragment(); 2728 if (frag != null) { 2729 frag.onAnimationEnd(); 2730 } 2731 } 2732 2733 @Override 2734 public void onSetEmpty() { 2735 // There are no selected conversations. Ensure that the listener and its associated actions 2736 // are blanked out. 2737 setListener(null, -1); 2738 } 2739 2740 @Override 2741 public void onSetPopulated(ConversationSelectionSet set) { 2742 mCabActionMenu = new SelectedConversationsActionMenu(mActivity, set, mFolder); 2743 if (mViewMode.isListMode() || (mIsTablet && mViewMode.isConversationMode())) { 2744 enableCabMode(); 2745 } 2746 } 2747 2748 @Override 2749 public void onSetChanged(ConversationSelectionSet set) { 2750 // Do nothing. We don't care about changes to the set. 2751 } 2752 2753 @Override 2754 public ConversationSelectionSet getSelectedSet() { 2755 return mSelectedSet; 2756 } 2757 2758 /** 2759 * Disable the Contextual Action Bar (CAB). The selected set is not changed. 2760 */ 2761 protected void disableCabMode() { 2762 // Commit any previous destructive actions when entering/ exiting CAB mode. 2763 commitDestructiveActions(true); 2764 if (mCabActionMenu != null) { 2765 mCabActionMenu.deactivate(); 2766 } 2767 } 2768 2769 /** 2770 * Re-enable the CAB menu if required. The selection set is not changed. 2771 */ 2772 protected void enableCabMode() { 2773 if (mCabActionMenu != null) { 2774 mCabActionMenu.activate(); 2775 } 2776 } 2777 2778 /** 2779 * Unselect conversations and exit CAB mode. 2780 */ 2781 protected final void exitCabMode() { 2782 mSelectedSet.clear(); 2783 } 2784 2785 @Override 2786 public void startSearch() { 2787 if (mAccount == null) { 2788 // We cannot search if there is no account. Drop the request to the floor. 2789 LogUtils.d(LOG_TAG, "AbstractActivityController.startSearch(): null account"); 2790 return; 2791 } 2792 if (mAccount.supportsCapability(UIProvider.AccountCapabilities.LOCAL_SEARCH) 2793 || mAccount.supportsCapability(UIProvider.AccountCapabilities.SERVER_SEARCH)) { 2794 mActionBarView.expandSearch(); 2795 } else { 2796 Toast.makeText(mActivity.getActivityContext(), mActivity.getActivityContext() 2797 .getString(R.string.search_unsupported), Toast.LENGTH_SHORT).show(); 2798 } 2799 } 2800 2801 @Override 2802 public void exitSearchMode() { 2803 if (mViewMode.getMode() == ViewMode.SEARCH_RESULTS_LIST) { 2804 mActivity.finish(); 2805 } 2806 } 2807 2808 /** 2809 * Supports dragging conversations to a folder. 2810 */ 2811 @Override 2812 public boolean supportsDrag(DragEvent event, Folder folder) { 2813 return (folder != null 2814 && event != null 2815 && event.getClipDescription() != null 2816 && folder.supportsCapability 2817 (UIProvider.FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 2818 && folder.supportsCapability 2819 (UIProvider.FolderCapabilities.CAN_HOLD_MAIL) 2820 && !mFolder.uri.equals(folder.uri)); 2821 } 2822 2823 /** 2824 * Handles dropping conversations to a folder. 2825 */ 2826 @Override 2827 public void handleDrop(DragEvent event, final Folder folder) { 2828 if (!supportsDrag(event, folder)) { 2829 return; 2830 } 2831 if (folder.type == UIProvider.FolderType.STARRED) { 2832 // Moving a conversation to the starred folder adds the star and 2833 // removes the current label 2834 handleDropInStarred(folder); 2835 return; 2836 } 2837 if (mFolder.type == UIProvider.FolderType.STARRED) { 2838 handleDragFromStarred(folder); 2839 return; 2840 } 2841 final ArrayList<FolderOperation> dragDropOperations = new ArrayList<FolderOperation>(); 2842 final Collection<Conversation> conversations = mSelectedSet.values(); 2843 // Add the drop target folder. 2844 dragDropOperations.add(new FolderOperation(folder, true)); 2845 // Remove the current folder unless the user is viewing "all". 2846 // That operation should just add the new folder. 2847 boolean isDestructive = !mFolder.isViewAll() 2848 && mFolder.supportsCapability 2849 (UIProvider.FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES); 2850 if (isDestructive) { 2851 dragDropOperations.add(new FolderOperation(mFolder, false)); 2852 } 2853 // Drag and drop is destructive: we remove conversations from the 2854 // current folder. 2855 final DestructiveAction action = getFolderChange(conversations, dragDropOperations, 2856 isDestructive, true, true); 2857 if (isDestructive) { 2858 delete(0, conversations, action); 2859 } else { 2860 action.performAction(); 2861 } 2862 } 2863 2864 private void handleDragFromStarred(Folder folder) { 2865 final Collection<Conversation> conversations = mSelectedSet.values(); 2866 // The conversation list deletes and performs the action if it exists. 2867 final ConversationListFragment convListFragment = getConversationListFragment(); 2868 // There should always be a convlistfragment, or the user could not have 2869 // dragged/ dropped conversations. 2870 if (convListFragment != null) { 2871 LogUtils.d(LOG_TAG, "AAC.requestDelete: ListFragment is handling delete."); 2872 ArrayList<ConversationOperation> ops = new ArrayList<ConversationOperation>(); 2873 ArrayList<Uri> folderUris; 2874 ArrayList<Boolean> adds; 2875 for (Conversation target : conversations) { 2876 folderUris = new ArrayList<Uri>(); 2877 adds = new ArrayList<Boolean>(); 2878 folderUris.add(folder.uri); 2879 adds.add(Boolean.TRUE); 2880 final HashMap<Uri, Folder> targetFolders = 2881 Folder.hashMapForFolders(target.getRawFolders()); 2882 targetFolders.put(folder.uri, folder); 2883 ops.add(mConversationListCursor.getConversationFolderOperation(target, 2884 folderUris, adds, targetFolders.values())); 2885 } 2886 if (mConversationListCursor != null) { 2887 mConversationListCursor.updateBulkValues(mContext, ops); 2888 } 2889 refreshConversationList(); 2890 mSelectedSet.clear(); 2891 } 2892 } 2893 2894 private void handleDropInStarred(Folder folder) { 2895 final Collection<Conversation> conversations = mSelectedSet.values(); 2896 // The conversation list deletes and performs the action if it exists. 2897 final ConversationListFragment convListFragment = getConversationListFragment(); 2898 // There should always be a convlistfragment, or the user could not have 2899 // dragged/ dropped conversations. 2900 if (convListFragment != null) { 2901 LogUtils.d(LOG_TAG, "AAC.requestDelete: ListFragment is handling delete."); 2902 convListFragment.requestDelete(R.id.change_folder, conversations, 2903 new DroppedInStarredAction(conversations, mFolder, folder)); 2904 } 2905 } 2906 2907 // When dragging conversations to the starred folder, remove from the 2908 // original folder and add a star 2909 private class DroppedInStarredAction implements DestructiveAction { 2910 private Collection<Conversation> mConversations; 2911 private Folder mInitialFolder; 2912 private Folder mStarred; 2913 2914 public DroppedInStarredAction(Collection<Conversation> conversations, Folder initialFolder, 2915 Folder starredFolder) { 2916 mConversations = conversations; 2917 mInitialFolder = initialFolder; 2918 mStarred = starredFolder; 2919 } 2920 2921 @Override 2922 public void performAction() { 2923 ToastBarOperation undoOp = new ToastBarOperation(mConversations.size(), 2924 R.id.change_folder, ToastBarOperation.UNDO, true); 2925 onUndoAvailable(undoOp); 2926 ArrayList<ConversationOperation> ops = new ArrayList<ConversationOperation>(); 2927 ContentValues values = new ContentValues(); 2928 ArrayList<Uri> folderUris; 2929 ArrayList<Boolean> adds; 2930 ConversationOperation operation; 2931 for (Conversation target : mConversations) { 2932 folderUris = new ArrayList<Uri>(); 2933 adds = new ArrayList<Boolean>(); 2934 folderUris.add(mStarred.uri); 2935 adds.add(Boolean.TRUE); 2936 folderUris.add(mInitialFolder.uri); 2937 adds.add(Boolean.FALSE); 2938 final HashMap<Uri, Folder> targetFolders = 2939 Folder.hashMapForFolders(target.getRawFolders()); 2940 targetFolders.put(mStarred.uri, mStarred); 2941 targetFolders.remove(mInitialFolder.uri); 2942 values.put(ConversationColumns.STARRED, true); 2943 operation = mConversationListCursor.getConversationFolderOperation(target, 2944 folderUris, adds, targetFolders.values(), values); 2945 ops.add(operation); 2946 } 2947 if (mConversationListCursor != null) { 2948 mConversationListCursor.updateBulkValues(mContext, ops); 2949 } 2950 refreshConversationList(); 2951 mSelectedSet.clear(); 2952 } 2953 } 2954 2955 @Override 2956 public void onTouchEvent(MotionEvent event) { 2957 if (event.getAction() == MotionEvent.ACTION_DOWN) { 2958 if (mToastBar != null && !mToastBar.isEventInToastBar(event)) { 2959 hideOrRepositionToastBar(true); 2960 } 2961 } 2962 } 2963 2964 protected abstract void hideOrRepositionToastBar(boolean animated); 2965 2966 @Override 2967 public void onConversationSeen(Conversation conv) { 2968 mPagerController.onConversationSeen(conv); 2969 } 2970 2971 @Override 2972 public boolean isInitialConversationLoading() { 2973 return mPagerController.isInitialConversationLoading(); 2974 } 2975 2976 /** 2977 * Check if the fragment given here is visible. Checking {@link Fragment#isVisible()} is 2978 * insufficient because that doesn't check if the window is currently in focus or not. 2979 */ 2980 private boolean isFragmentVisible(Fragment in) { 2981 return in != null && in.isVisible() && mActivity.hasWindowFocus(); 2982 } 2983 2984 private class ConversationListLoaderCallbacks implements 2985 LoaderManager.LoaderCallbacks<ConversationCursor> { 2986 2987 @Override 2988 public Loader<ConversationCursor> onCreateLoader(int id, Bundle args) { 2989 return new ConversationCursorLoader((Activity) mActivity, 2990 mAccount, mFolder.conversationListUri, mFolder.name); 2991 } 2992 2993 @Override 2994 public void onLoadFinished(Loader<ConversationCursor> loader, ConversationCursor data) { 2995 LogUtils.d(LOG_TAG, "IN AAC.ConversationCursor.onLoadFinished, data=%s loader=%s", 2996 data, loader); 2997 // Clear our all pending destructive actions before swapping the conversation cursor 2998 destroyPending(null); 2999 mConversationListCursor = data; 3000 mConversationListCursor.addListener(AbstractActivityController.this); 3001 mTracker.onCursorUpdated(); 3002 mConversationListObservable.notifyChanged(); 3003 // Handle actions that were deferred until after the conversation list was loaded. 3004 for (LoadFinishedCallback callback : mConversationListLoadFinishedCallbacks) { 3005 callback.onLoadFinished(); 3006 } 3007 mConversationListLoadFinishedCallbacks.clear(); 3008 3009 final ConversationListFragment convList = getConversationListFragment(); 3010 if (isFragmentVisible(convList)) { 3011 // The conversation list is already listening to list changes and gets notified 3012 // in the mConversationListObservable.notifyChanged() line above. We only need to 3013 // check and inform the cursor of the change in visibility here. 3014 informCursorVisiblity(true); 3015 } 3016 perhapsShowFirstSearchResult(); 3017 } 3018 3019 @Override 3020 public void onLoaderReset(Loader<ConversationCursor> loader) { 3021 LogUtils.d(LOG_TAG, "IN AAC.ConversationCursor.onLoaderReset, data=%s loader=%s", 3022 mConversationListCursor, loader); 3023 3024 if (mConversationListCursor != null) { 3025 // Unregister the listener 3026 mConversationListCursor.removeListener(AbstractActivityController.this); 3027 mConversationListCursor = null; 3028 3029 // Inform anyone who is interested about the change 3030 mTracker.onCursorUpdated(); 3031 mConversationListObservable.notifyChanged(); 3032 } 3033 } 3034 } 3035 3036 /** 3037 * Updates controller state based on search results and shows first conversation if required. 3038 */ 3039 private void perhapsShowFirstSearchResult() { 3040 if (mCurrentConversation == null) { 3041 // Shown for search results in two-pane mode only. 3042 mHaveSearchResults = Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction()) 3043 && mConversationListCursor.getCount() > 0; 3044 if (!shouldShowFirstConversation()) { 3045 return; 3046 } 3047 mConversationListCursor.moveToPosition(0); 3048 final Conversation conv = new Conversation(mConversationListCursor); 3049 conv.position = 0; 3050 onConversationSelected(conv, true /* checkSafeToModifyFragments */); 3051 } 3052 } 3053 3054 /** 3055 * Destroy the pending {@link DestructiveAction} till now and assign the given action as the 3056 * next destructive action.. 3057 * @param nextAction the next destructive action to be performed. This can be null. 3058 */ 3059 private void destroyPending(DestructiveAction nextAction) { 3060 // If there is a pending action, perform that first. 3061 if (mPendingDestruction != null) { 3062 mPendingDestruction.performAction(); 3063 } 3064 mPendingDestruction = nextAction; 3065 } 3066 3067 /** 3068 * Register a destructive action with the controller. This performs the previous destructive 3069 * action as a side effect. This method is final because we don't want the child classes to 3070 * embellish this method any more. 3071 * @param action the action to register. 3072 */ 3073 private void registerDestructiveAction(DestructiveAction action) { 3074 // TODO(viki): This is not a good idea. The best solution is for clients to request a 3075 // destructive action from the controller and for the controller to own the action. This is 3076 // a half-way solution while refactoring DestructiveAction. 3077 destroyPending(action); 3078 } 3079 3080 @Override 3081 public final DestructiveAction getBatchAction(int action) { 3082 final DestructiveAction da = new ConversationAction(action, mSelectedSet.values(), true); 3083 registerDestructiveAction(da); 3084 return da; 3085 } 3086 3087 @Override 3088 public final DestructiveAction getDeferredBatchAction(int action) { 3089 return getDeferredAction(action, mSelectedSet.values(), true); 3090 } 3091 3092 /** 3093 * Get a destructive action for a menu action. This is a temporary method, 3094 * to control the profusion of {@link DestructiveAction} classes that are 3095 * created. Please do not copy this paradigm. 3096 * @param action the resource ID of the menu action: R.id.delete, for 3097 * example 3098 * @param target the conversations to act upon. 3099 * @return a {@link DestructiveAction} that performs the specified action. 3100 */ 3101 private DestructiveAction getDeferredAction(int action, Collection<Conversation> target, 3102 boolean batch) { 3103 return new ConversationAction(action, target, batch); 3104 } 3105 3106 /** 3107 * Class to change the folders that are assigned to a set of conversations. This is destructive 3108 * because the user can remove the current folder from the conversation, in which case it has 3109 * to be animated away from the current folder. 3110 */ 3111 private class FolderDestruction implements DestructiveAction { 3112 private final Collection<Conversation> mTarget; 3113 private final ArrayList<FolderOperation> mFolderOps = new ArrayList<FolderOperation>(); 3114 private final boolean mIsDestructive; 3115 /** Whether this destructive action has already been performed */ 3116 private boolean mCompleted; 3117 private boolean mIsSelectedSet; 3118 private boolean mShowUndo; 3119 private int mAction; 3120 3121 /** 3122 * Create a new folder destruction object to act on the given conversations. 3123 * @param target conversations to act upon. 3124 */ 3125 private FolderDestruction(final Collection<Conversation> target, 3126 final Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 3127 boolean showUndo, int action) { 3128 mTarget = ImmutableList.copyOf(target); 3129 mFolderOps.addAll(folders); 3130 mIsDestructive = isDestructive; 3131 mIsSelectedSet = isBatch; 3132 mShowUndo = showUndo; 3133 mAction = action; 3134 } 3135 3136 @Override 3137 public void performAction() { 3138 if (isPerformed()) { 3139 return; 3140 } 3141 if (mIsDestructive && mShowUndo) { 3142 ToastBarOperation undoOp = new ToastBarOperation(mTarget.size(), mAction, 3143 ToastBarOperation.UNDO, mIsSelectedSet); 3144 onUndoAvailable(undoOp); 3145 } 3146 // For each conversation, for each operation, add/ remove the 3147 // appropriate folders. 3148 ArrayList<ConversationOperation> ops = new ArrayList<ConversationOperation>(); 3149 ArrayList<Uri> folderUris; 3150 ArrayList<Boolean> adds; 3151 for (Conversation target : mTarget) { 3152 HashMap<Uri, Folder> targetFolders = Folder.hashMapForFolders(target 3153 .getRawFolders()); 3154 folderUris = new ArrayList<Uri>(); 3155 adds = new ArrayList<Boolean>(); 3156 if (mIsDestructive) { 3157 target.localDeleteOnUpdate = true; 3158 } 3159 for (FolderOperation op : mFolderOps) { 3160 folderUris.add(op.mFolder.uri); 3161 adds.add(op.mAdd ? Boolean.TRUE : Boolean.FALSE); 3162 if (op.mAdd) { 3163 targetFolders.put(op.mFolder.uri, op.mFolder); 3164 } else { 3165 targetFolders.remove(op.mFolder.uri); 3166 } 3167 } 3168 ops.add(mConversationListCursor.getConversationFolderOperation(target, 3169 folderUris, adds, targetFolders.values())); 3170 } 3171 if (mConversationListCursor != null) { 3172 mConversationListCursor.updateBulkValues(mContext, ops); 3173 } 3174 refreshConversationList(); 3175 if (mIsSelectedSet) { 3176 mSelectedSet.clear(); 3177 } 3178 } 3179 3180 /** 3181 * Returns true if this action has been performed, false otherwise. 3182 * 3183 */ 3184 private synchronized boolean isPerformed() { 3185 if (mCompleted) { 3186 return true; 3187 } 3188 mCompleted = true; 3189 return false; 3190 } 3191 } 3192 3193 public final DestructiveAction getFolderChange(Collection<Conversation> target, 3194 Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 3195 boolean showUndo) { 3196 final DestructiveAction da = getDeferredFolderChange(target, folders, isDestructive, 3197 isBatch, showUndo); 3198 registerDestructiveAction(da); 3199 return da; 3200 } 3201 3202 public final DestructiveAction getDeferredFolderChange(Collection<Conversation> target, 3203 Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 3204 boolean showUndo) { 3205 return new FolderDestruction(target, folders, isDestructive, isBatch, 3206 showUndo, R.id.change_folder); 3207 } 3208 3209 @Override 3210 public final DestructiveAction getDeferredRemoveFolder(Collection<Conversation> target, 3211 Folder toRemove, boolean isDestructive, boolean isBatch, 3212 boolean showUndo) { 3213 Collection<FolderOperation> folderOps = new ArrayList<FolderOperation>(); 3214 folderOps.add(new FolderOperation(toRemove, false)); 3215 return new FolderDestruction(target, folderOps, isDestructive, isBatch, 3216 showUndo, R.id.remove_folder); 3217 } 3218 3219 @Override 3220 public final void refreshConversationList() { 3221 final ConversationListFragment convList = getConversationListFragment(); 3222 if (convList == null) { 3223 return; 3224 } 3225 convList.requestListRefresh(); 3226 } 3227 3228 protected final ActionClickedListener getUndoClickedListener( 3229 final AnimatedAdapter listAdapter) { 3230 return new ActionClickedListener() { 3231 @Override 3232 public void onActionClicked() { 3233 if (mAccount.undoUri != null) { 3234 // NOTE: We might want undo to return the messages affected, in which case 3235 // the resulting cursor might be interesting... 3236 // TODO: Use UIProvider.SEQUENCE_QUERY_PARAMETER to indicate the set of 3237 // commands to undo 3238 if (mConversationListCursor != null) { 3239 mConversationListCursor.undo( 3240 mActivity.getActivityContext(), mAccount.undoUri); 3241 } 3242 if (listAdapter != null) { 3243 listAdapter.setUndo(true); 3244 } 3245 } 3246 } 3247 }; 3248 } 3249 3250 /** 3251 * Shows an error toast in the bottom when a folder was not fetched successfully. 3252 * @param folder the folder which could not be fetched. 3253 * @param replaceVisibleToast if true, this should replace any currently visible toast. 3254 */ 3255 protected final void showErrorToast(final Folder folder, boolean replaceVisibleToast) { 3256 mToastBar.setConversationMode(false); 3257 3258 final ActionClickedListener listener; 3259 final int actionTextResourceId; 3260 final int lastSyncResult = folder.lastSyncResult; 3261 switch (lastSyncResult & 0x0f) { 3262 case UIProvider.LastSyncResult.CONNECTION_ERROR: 3263 // The sync request that caused this failure. 3264 final int syncRequest = lastSyncResult >> 4; 3265 // Show: User explicitly pressed the refresh button and there is no connection 3266 // Show: The first time the user enters the app and there is no connection 3267 // TODO(viki): Implement this. 3268 // Reference: http://b/7202801 3269 final boolean showToast = (syncRequest & UIProvider.SyncStatus.USER_REFRESH) != 0; 3270 // Don't show: Already in the app; user switches to a synced label 3271 // Don't show: In a live label and a background sync fails 3272 final boolean avoidToast = !showToast && (folder.syncWindow > 0 3273 || (syncRequest & UIProvider.SyncStatus.BACKGROUND_SYNC) != 0); 3274 if (avoidToast) { 3275 return; 3276 } 3277 listener = getRetryClickedListener(folder); 3278 actionTextResourceId = R.string.retry; 3279 break; 3280 case UIProvider.LastSyncResult.AUTH_ERROR: 3281 listener = getSignInClickedListener(); 3282 actionTextResourceId = R.string.signin; 3283 break; 3284 case UIProvider.LastSyncResult.SECURITY_ERROR: 3285 return; // Currently we do nothing for security errors. 3286 case UIProvider.LastSyncResult.STORAGE_ERROR: 3287 listener = getStorageErrorClickedListener(); 3288 actionTextResourceId = R.string.info; 3289 break; 3290 case UIProvider.LastSyncResult.INTERNAL_ERROR: 3291 listener = getInternalErrorClickedListener(); 3292 actionTextResourceId = R.string.report; 3293 break; 3294 default: 3295 return; 3296 } 3297 mToastBar.show(listener, 3298 R.drawable.ic_alert_white, 3299 Utils.getSyncStatusText(mActivity.getActivityContext(), lastSyncResult), 3300 false, /* showActionIcon */ 3301 actionTextResourceId, 3302 replaceVisibleToast, 3303 new ToastBarOperation(1, 0, ToastBarOperation.ERROR, false)); 3304 } 3305 3306 private ActionClickedListener getRetryClickedListener(final Folder folder) { 3307 return new ActionClickedListener() { 3308 @Override 3309 public void onActionClicked() { 3310 final Uri uri = folder.refreshUri; 3311 3312 if (uri != null) { 3313 startAsyncRefreshTask(uri); 3314 } 3315 } 3316 }; 3317 } 3318 3319 private ActionClickedListener getSignInClickedListener() { 3320 return new ActionClickedListener() { 3321 @Override 3322 public void onActionClicked() { 3323 promptUserForAuthentication(mAccount); 3324 } 3325 }; 3326 } 3327 3328 private ActionClickedListener getStorageErrorClickedListener() { 3329 return new ActionClickedListener() { 3330 @Override 3331 public void onActionClicked() { 3332 showStorageErrorDialog(); 3333 } 3334 }; 3335 } 3336 3337 private void showStorageErrorDialog() { 3338 DialogFragment fragment = (DialogFragment) 3339 mFragmentManager.findFragmentByTag(SYNC_ERROR_DIALOG_FRAGMENT_TAG); 3340 if (fragment == null) { 3341 fragment = SyncErrorDialogFragment.newInstance(); 3342 } 3343 fragment.show(mFragmentManager, SYNC_ERROR_DIALOG_FRAGMENT_TAG); 3344 } 3345 3346 private ActionClickedListener getInternalErrorClickedListener() { 3347 return new ActionClickedListener() { 3348 @Override 3349 public void onActionClicked() { 3350 Utils.sendFeedback(mActivity, mAccount, true /* reportingProblem */); 3351 } 3352 }; 3353 } 3354 3355 @Override 3356 public void onFooterViewErrorActionClick(Folder folder, int errorStatus) { 3357 Uri uri = null; 3358 switch (errorStatus) { 3359 case UIProvider.LastSyncResult.CONNECTION_ERROR: 3360 if (folder != null && folder.refreshUri != null) { 3361 uri = folder.refreshUri; 3362 } 3363 break; 3364 case UIProvider.LastSyncResult.AUTH_ERROR: 3365 promptUserForAuthentication(mAccount); 3366 return; 3367 case UIProvider.LastSyncResult.SECURITY_ERROR: 3368 return; // Currently we do nothing for security errors. 3369 case UIProvider.LastSyncResult.STORAGE_ERROR: 3370 showStorageErrorDialog(); 3371 return; 3372 case UIProvider.LastSyncResult.INTERNAL_ERROR: 3373 Utils.sendFeedback(mActivity, mAccount, true /* reportingProblem */); 3374 return; 3375 default: 3376 return; 3377 } 3378 3379 if (uri != null) { 3380 startAsyncRefreshTask(uri); 3381 } 3382 } 3383 3384 @Override 3385 public void onFooterViewLoadMoreClick(Folder folder) { 3386 if (folder != null && folder.loadMoreUri != null) { 3387 startAsyncRefreshTask(folder.loadMoreUri); 3388 } 3389 } 3390 3391 private void startAsyncRefreshTask(Uri uri) { 3392 if (mFolderSyncTask != null) { 3393 mFolderSyncTask.cancel(true); 3394 } 3395 mFolderSyncTask = new AsyncRefreshTask(mActivity.getActivityContext(), uri); 3396 mFolderSyncTask.execute(); 3397 } 3398 3399 private void promptUserForAuthentication(Account account) { 3400 if (account != null && !Utils.isEmpty(account.reauthenticationIntentUri)) { 3401 final Intent authenticationIntent = 3402 new Intent(Intent.ACTION_VIEW, account.reauthenticationIntentUri); 3403 mActivity.startActivityForResult(authenticationIntent, REAUTHENTICATE_REQUEST_CODE); 3404 } 3405 } 3406 3407 @Override 3408 public void onAccessibilityStateChanged() { 3409 // Clear the cache of objects. 3410 ConversationItemViewModel.onAccessibilityUpdated(); 3411 // Re-render the list if it exists. 3412 final ConversationListFragment frag = getConversationListFragment(); 3413 if (frag != null) { 3414 AnimatedAdapter adapter = frag.getAnimatedAdapter(); 3415 if (adapter != null) { 3416 adapter.notifyDataSetInvalidated(); 3417 } 3418 } 3419 } 3420 3421 @Override 3422 public void makeDialogListener (final int action, boolean isBatch) { 3423 final Collection<Conversation> target; 3424 if (isBatch) { 3425 target = mSelectedSet.values(); 3426 } else { 3427 LogUtils.d(LOG_TAG, "Will act upon %s", mCurrentConversation); 3428 target = Conversation.listOf(mCurrentConversation); 3429 } 3430 final DestructiveAction destructiveAction = getDeferredAction(action, target, isBatch); 3431 mDialogAction = action; 3432 mDialogFromSelectedSet = isBatch; 3433 mDialogListener = new AlertDialog.OnClickListener() { 3434 @Override 3435 public void onClick(DialogInterface dialog, int which) { 3436 delete(action, target, destructiveAction); 3437 // Afterwards, let's remove references to the listener and the action. 3438 setListener(null, -1); 3439 } 3440 }; 3441 } 3442 3443 @Override 3444 public AlertDialog.OnClickListener getListener() { 3445 return mDialogListener; 3446 } 3447 3448 /** 3449 * Sets the listener for the positive action on a confirmation dialog. Since only a single 3450 * confirmation dialog can be shown, this overwrites the previous listener. It is safe to 3451 * unset the listener; in which case action should be set to -1. 3452 * @param listener the listener that will perform the task for this dialog's positive action. 3453 * @param action the action that created this dialog. 3454 */ 3455 private void setListener(AlertDialog.OnClickListener listener, final int action){ 3456 mDialogListener = listener; 3457 mDialogAction = action; 3458 } 3459 3460 @Override 3461 public VeiledAddressMatcher getVeiledAddressMatcher() { 3462 return mVeiledMatcher; 3463 } 3464 3465 @Override 3466 public void setDetachedMode() { 3467 // Tell the conversation list not to select anything. 3468 final ConversationListFragment frag = getConversationListFragment(); 3469 if (frag != null) { 3470 frag.setChoiceNone(); 3471 } else if (mIsTablet) { 3472 // How did we ever land here? Detached mode, and no CLF on tablet??? 3473 LogUtils.e(LOG_TAG, "AAC.setDetachedMode(): CLF = null!"); 3474 } 3475 mDetachedConvUri = mCurrentConversation.uri; 3476 } 3477 3478 private void clearDetachedMode() { 3479 // Tell the conversation list to go back to its usual selection behavior. 3480 final ConversationListFragment frag = getConversationListFragment(); 3481 if (frag != null) { 3482 frag.revertChoiceMode(); 3483 } else if (mIsTablet) { 3484 // How did we ever land here? Detached mode, and no CLF on tablet??? 3485 LogUtils.e(LOG_TAG, "AAC.clearDetachedMode(): CLF = null on tablet!"); 3486 } 3487 mDetachedConvUri = null; 3488 } 3489 3490} 3491