1 /******************************************************************************* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 *******************************************************************************/ 17 18 package com.android.mail.ui; 19 20 import android.animation.ValueAnimator; 21 import android.app.Activity; 22 import android.app.AlertDialog; 23 import android.app.Dialog; 24 import android.app.DialogFragment; 25 import android.app.Fragment; 26 import android.app.FragmentManager; 27 import android.app.LoaderManager; 28 import android.app.SearchManager; 29 import android.content.ContentProviderOperation; 30 import android.content.ContentResolver; 31 import android.content.ContentValues; 32 import android.content.Context; 33 import android.content.DialogInterface; 34 import android.content.DialogInterface.OnClickListener; 35 import android.content.Intent; 36 import android.content.Loader; 37 import android.content.res.Configuration; 38 import android.content.res.Resources; 39 import android.database.Cursor; 40 import android.database.DataSetObservable; 41 import android.database.DataSetObserver; 42 import android.database.Observable; 43 import android.net.Uri; 44 import android.os.AsyncTask; 45 import android.os.Bundle; 46 import android.os.Handler; 47 import android.os.Parcelable; 48 import android.os.SystemClock; 49 import android.speech.RecognizerIntent; 50 import android.support.v4.widget.DrawerLayout; 51 import android.support.v7.app.ActionBar; 52 import android.support.v7.app.ActionBarDrawerToggle; 53 import android.view.Gravity; 54 import android.view.KeyEvent; 55 import android.view.Menu; 56 import android.view.MenuInflater; 57 import android.view.MenuItem; 58 import android.view.MotionEvent; 59 import android.view.View; 60 import android.widget.ListView; 61 import android.widget.Toast; 62 63 import com.android.mail.ConversationListContext; 64 import com.android.mail.MailLogService; 65 import com.android.mail.R; 66 import com.android.mail.analytics.Analytics; 67 import com.android.mail.analytics.AnalyticsTimer; 68 import com.android.mail.browse.ConfirmDialogFragment; 69 import com.android.mail.browse.ConversationCursor; 70 import com.android.mail.browse.ConversationCursor.ConversationOperation; 71 import com.android.mail.browse.ConversationItemViewModel; 72 import com.android.mail.browse.ConversationMessage; 73 import com.android.mail.browse.ConversationPagerAdapter; 74 import com.android.mail.browse.ConversationPagerController; 75 import com.android.mail.browse.SelectedConversationsActionMenu; 76 import com.android.mail.browse.SyncErrorDialogFragment; 77 import com.android.mail.browse.UndoCallback; 78 import com.android.mail.compose.ComposeActivity; 79 import com.android.mail.content.CursorCreator; 80 import com.android.mail.content.ObjectCursor; 81 import com.android.mail.content.ObjectCursorLoader; 82 import com.android.mail.providers.Account; 83 import com.android.mail.providers.Conversation; 84 import com.android.mail.providers.ConversationInfo; 85 import com.android.mail.providers.Folder; 86 import com.android.mail.providers.FolderWatcher; 87 import com.android.mail.providers.MailAppProvider; 88 import com.android.mail.providers.Settings; 89 import com.android.mail.providers.UIProvider; 90 import com.android.mail.providers.UIProvider.AccountCapabilities; 91 import com.android.mail.providers.UIProvider.AccountCursorExtraKeys; 92 import com.android.mail.providers.UIProvider.AutoAdvance; 93 import com.android.mail.providers.UIProvider.ConversationColumns; 94 import com.android.mail.providers.UIProvider.ConversationOperations; 95 import com.android.mail.providers.UIProvider.FolderCapabilities; 96 import com.android.mail.providers.UIProvider.FolderType; 97 import com.android.mail.ui.ActionableToastBar.ActionClickedListener; 98 import com.android.mail.utils.ContentProviderTask; 99 import com.android.mail.utils.DrawIdler; 100 import com.android.mail.utils.LogTag; 101 import com.android.mail.utils.LogUtils; 102 import com.android.mail.utils.MailObservable; 103 import com.android.mail.utils.NotificationActionUtils; 104 import com.android.mail.utils.Utils; 105 import com.android.mail.utils.VeiledAddressMatcher; 106 import com.google.common.base.Objects; 107 import com.google.common.collect.ImmutableList; 108 import com.google.common.collect.Lists; 109 import com.google.common.collect.Sets; 110 111 import java.util.ArrayList; 112 import java.util.Arrays; 113 import java.util.Collection; 114 import java.util.Collections; 115 import java.util.HashMap; 116 import java.util.List; 117 import java.util.Set; 118 import java.util.TimerTask; 119 120 121 /** 122 * This is an abstract implementation of the Activity Controller. This class 123 * knows how to respond to menu items, state changes, layout changes, etc. It 124 * weaves together the views and listeners, dispatching actions to the 125 * respective underlying classes. 126 * <p> 127 * Even though this class is abstract, it should provide default implementations 128 * for most, if not all the methods in the ActivityController interface. This 129 * makes the task of the subclasses easier: OnePaneActivityController and 130 * TwoPaneActivityController can be concise when the common functionality is in 131 * AbstractActivityController. 132 * </p> 133 * <p> 134 * In the Gmail codebase, this was called BaseActivityController 135 * </p> 136 */ 137 public abstract class AbstractActivityController implements ActivityController, 138 EmptyFolderDialogFragment.EmptyFolderDialogFragmentListener, View.OnClickListener { 139 // Keys for serialization of various information in Bundles. 140 /** Tag for {@link #mAccount} */ 141 private static final String SAVED_ACCOUNT = "saved-account"; 142 /** Tag for {@link #mFolder} */ 143 private static final String SAVED_FOLDER = "saved-folder"; 144 /** Tag for {@link #mCurrentConversation} */ 145 private static final String SAVED_CONVERSATION = "saved-conversation"; 146 /** Tag for {@link #mCheckedSet} */ 147 private static final String SAVED_SELECTED_SET = "saved-selected-set"; 148 /** Tag for {@link ActionableToastBar#getOperation()} */ 149 private static final String SAVED_TOAST_BAR_OP = "saved-toast-bar-op"; 150 /** Tag for {@link #mFolderListFolder} */ 151 private static final String SAVED_HIERARCHICAL_FOLDER = "saved-hierarchical-folder"; 152 /** Tag for {@link ConversationListContext#searchQuery} */ 153 private static final String SAVED_QUERY = "saved-query"; 154 /** Tag for {@link #mDialogAction} */ 155 private static final String SAVED_ACTION = "saved-action"; 156 /** Tag for {@link #mDialogFromSelectedSet} */ 157 private static final String SAVED_ACTION_FROM_SELECTED = "saved-action-from-selected"; 158 /** Tag for {@link #mDetachedConvUri} */ 159 private static final String SAVED_DETACHED_CONV_URI = "saved-detached-conv-uri"; 160 /** Key to store {@link #mInbox}. */ 161 private static final String SAVED_INBOX_KEY = "m-inbox"; 162 /** Key to store {@link #mConversationListScrollPositions} */ 163 private static final String SAVED_CONVERSATION_LIST_SCROLL_POSITIONS = 164 "saved-conversation-list-scroll-positions"; 165 166 /** Tag used when loading a wait fragment */ 167 protected static final String TAG_WAIT = "wait-fragment"; 168 /** Tag used when loading a conversation list fragment. */ 169 public static final String TAG_CONVERSATION_LIST = "tag-conversation-list"; 170 /** Tag used when loading a custom fragment. */ 171 protected static final String TAG_CUSTOM_FRAGMENT = "tag-custom-fragment"; 172 173 /** Key to store an account in a bundle */ 174 private final String BUNDLE_ACCOUNT_KEY = "account"; 175 /** Key to store a folder in a bundle */ 176 private final String BUNDLE_FOLDER_KEY = "folder"; 177 /** 178 * Key to set a flag for the ConversationCursorLoader to ignore any 179 * initial load limit that may be set by the Account. Instead, 180 * perform a full load instead of the full-stage load. 181 */ 182 private final String BUNDLE_IGNORE_INITIAL_CONVERSATION_LIMIT_KEY = 183 "ignore-initial-conversation-limit"; 184 185 protected Account mAccount; 186 protected Folder mFolder; 187 protected Folder mInbox; 188 /** True when {@link #mFolder} is first shown to the user. */ 189 private boolean mFolderChanged = false; 190 protected ActionBarController mActionBarController; 191 protected final MailActivity mActivity; 192 protected final Context mContext; 193 private final FragmentManager mFragmentManager; 194 protected final RecentFolderList mRecentFolderList; 195 protected ConversationListContext mConvListContext; 196 protected Conversation mCurrentConversation; 197 protected MaterialSearchViewController mSearchViewController; 198 /** 199 * The hash of {@link #mCurrentConversation} in detached mode. 0 if we are not in detached mode. 200 */ 201 private Uri mDetachedConvUri; 202 203 /** A map of {@link Folder} {@link Uri} to scroll position in the conversation list. */ 204 private final Bundle mConversationListScrollPositions = new Bundle(); 205 206 /** A {@link android.content.BroadcastReceiver} that suppresses new e-mail notifications. */ 207 private SuppressNotificationReceiver mNewEmailReceiver = null; 208 209 /** Handler for all our local runnables. */ 210 protected Handler mHandler = new Handler(); 211 212 /** 213 * The current mode of the application. All changes in mode are initiated by 214 * the activity controller. View mode changes are propagated to classes that 215 * attach themselves as listeners of view mode changes. 216 */ 217 protected final ViewMode mViewMode; 218 protected ContentResolver mResolver; 219 protected boolean mHaveAccountList = false; 220 private AsyncRefreshTask mAsyncRefreshTask; 221 222 private boolean mDestroyed; 223 224 /** True if running on tablet */ 225 private final boolean mIsTablet; 226 227 /** 228 * Are we in a point in the Activity/Fragment lifecycle where it's safe to execute fragment 229 * transactions? (including back stack manipulation) 230 * <p> 231 * Per docs in {@link FragmentManager#beginTransaction()}, this flag starts out true, switches 232 * to false after {@link Activity#onSaveInstanceState}, and becomes true again in both onStart 233 * and onResume. 234 */ 235 private boolean mSafeToModifyFragments = true; 236 237 private final Set<Uri> mCurrentAccountUris = Sets.newHashSet(); 238 protected ConversationCursor mConversationListCursor; 239 private final DataSetObservable mConversationListObservable = new MailObservable("List"); 240 241 /** Runnable that checks the logging level to enable/disable the logging service. */ 242 private Runnable mLogServiceChecker = null; 243 /** List of all accounts currently known to the controller. This is never null. */ 244 private Account[] mAllAccounts = new Account[0]; 245 246 private FolderWatcher mFolderWatcher; 247 248 private boolean mIgnoreInitialConversationLimit; 249 250 /** 251 * Interface for actions that are deferred until after a load completes. This is for handling 252 * user actions which affect cursors (e.g. marking messages read or unread) that happen before 253 * that cursor is loaded. 254 */ 255 private interface LoadFinishedCallback { 256 void onLoadFinished(); 257 } 258 259 /** The deferred actions to execute when mConversationListCursor load completes. */ 260 private final ArrayList<LoadFinishedCallback> mConversationListLoadFinishedCallbacks = 261 new ArrayList<LoadFinishedCallback>(); 262 263 private RefreshTimerTask mConversationListRefreshTask; 264 265 /** Listeners that are interested in changes to the current account. */ 266 private final DataSetObservable mAccountObservers = new MailObservable("Account"); 267 /** Listeners that are interested in changes to the recent folders. */ 268 private final DataSetObservable mRecentFolderObservers = new MailObservable("RecentFolder"); 269 /** Listeners that are interested in changes to the list of all accounts. */ 270 private final DataSetObservable mAllAccountObservers = new MailObservable("AllAccounts"); 271 /** Listeners that are interested in changes to the current folder. */ 272 private final DataSetObservable mFolderObservable = new MailObservable("CurrentFolder"); 273 /** Listeners that are interested in changes to the Folder or Account selection */ 274 private final DataSetObservable mFolderOrAccountObservers = 275 new MailObservable("FolderOrAccount"); 276 277 /** 278 * Selected conversations, if any. 279 */ 280 private final ConversationCheckedSet mCheckedSet = new ConversationCheckedSet(); 281 282 private final int mFolderItemUpdateDelayMs; 283 284 /** Keeps track of selected and unselected conversations */ 285 final protected ConversationPositionTracker mTracker; 286 287 /** 288 * Action menu associated with the selected set. 289 */ 290 SelectedConversationsActionMenu mCabActionMenu; 291 292 /** The compose button floating over the conversation/search lists */ 293 protected View mFloatingComposeButton; 294 protected ActionableToastBar mToastBar; 295 protected ConversationPagerController mPagerController; 296 297 // This is split out from the general loader dispatcher because its loader doesn't return a 298 // basic Cursor 299 /** Handles loader callbacks to create a convesation cursor. */ 300 private final ConversationListLoaderCallbacks mListCursorCallbacks = 301 new ConversationListLoaderCallbacks(); 302 303 /** Object that listens to all LoaderCallbacks that result in {@link Folder} creation. */ 304 private final FolderLoads mFolderCallbacks = new FolderLoads(); 305 /** Object that listens to all LoaderCallbacks that result in {@link Account} creation. */ 306 private final AccountLoads mAccountCallbacks = new AccountLoads(); 307 308 /** 309 * Matched addresses that must be shielded from users because they are temporary. Even though 310 * this is instantiated from settings, this matcher is valid for all accounts, and is expected 311 * to live past the life of an account. 312 */ 313 private final VeiledAddressMatcher mVeiledMatcher; 314 315 protected static final String LOG_TAG = LogTag.getLogTag(); 316 317 // Loader constants: Accounts 318 /** 319 * The list of accounts. This loader is started early in the application life-cycle since 320 * the list of accounts is central to all other data the application needs: unread counts for 321 * folders, critical UI settings like show/hide checkboxes, ... 322 * The loader is started when the application is created: both in 323 * {@link #onCreate(Bundle)} and in {@link #onActivityResult(int, int, Intent)}. It is never 324 * destroyed since the cursor is needed through the life of the application. When the list of 325 * accounts changes, we notify {@link #mAllAccountObservers}. 326 */ 327 private static final int LOADER_ACCOUNT_CURSOR = 0; 328 329 /** 330 * The current account. This loader is started when we have an account. The mail application 331 * <b>needs</b> a valid account to function. As soon as we set {@link #mAccount}, 332 * we start a loader to observe for changes on the current account. 333 * The loader is always restarted when an account is set in {@link #setAccount(Account)}. 334 * When the current account object changes, we notify {@link #mAccountObservers}. 335 * A possible performance improvement would be to listen purely on 336 * {@link #LOADER_ACCOUNT_CURSOR}. The current account is guaranteed to be in the list, 337 * and would avoid two updates when a single setting on the current account changes. 338 */ 339 private static final int LOADER_ACCOUNT_UPDATE_CURSOR = 1; 340 341 // Loader constants: Conversations 342 343 /** The conversation cursor over the current conversation list. This loader provides 344 * a cursor over conversation entries from a folder to display a conversation 345 * list. 346 * This loader is started when the user switches folders (in {@link #updateFolder(Folder)}, 347 * or when the controller is told that a folder/account change is imminent 348 * (in {@link #preloadConvList(Account, Folder)}. The loader is maintained for the life of 349 * the current folder. When the user switches folders, the old loader is destroyed and a new 350 * one is created. 351 * 352 * When the conversation list changes, we notify {@link #mConversationListObservable}. 353 */ 354 private static final int LOADER_CONVERSATION_LIST = 10; 355 356 // Loader constants: misc 357 /** 358 * The loader that determines whether the Warm welcome tour should be displayed for the user. 359 */ 360 public static final int LOADER_WELCOME_TOUR = 20; 361 362 /** 363 * The load which loads accounts for the welcome tour. 364 */ 365 public static final int LOADER_WELCOME_TOUR_ACCOUNTS = 21; 366 367 // Loader constants: Folders 368 369 /** The current folder. This loader watches for updates to the current folder in a manner 370 * analogous to the {@link #LOADER_ACCOUNT_UPDATE_CURSOR}. Updates to the current folder 371 * might be due to server-side changes (unread count), or local changes (sync window or sync 372 * status change). 373 * The change of current folder calls {@link #updateFolder(Folder)}. 374 * This is responsible for restarting a loader using the URI of the provided folder. When the 375 * loader returns, the current folder is updated and consumers, if any, are notified. 376 * When the current folder changes, we notify {@link #mFolderObservable} 377 */ 378 private static final int LOADER_FOLDER_CURSOR = 30; 379 380 /** 381 * The list of recent folders. Recent folders are shown in the DrawerFragment. The recent 382 * folders are tied to the current account being viewed. When the account is changed, 383 * we restart this loader to retrieve the recent accounts. Recents are pre-populated for 384 * phones historically, when they were displayed in the spinner. On the tablet, 385 * they showed in the {@link FolderListFragment} and were not-populated. The code to 386 * pre-populate the recents is somewhat convoluted: when the loader returns a short list of 387 * recent folders, it issues an update on the Recent Folder URI. The underlying provider then 388 * does the appropriate thing to populate recent folders, and notify of a change on the cursor. 389 * Recent folders are needed for the life of the current account. 390 * When the recent folders change, we notify {@link #mRecentFolderObservers}. 391 */ 392 private static final int LOADER_RECENT_FOLDERS = 31; 393 /** 394 * The primary inbox for the current account. The mechanism to load the default inbox for the 395 * current account is (sadly) different from loading other folders. The method 396 * {@link #loadAccountInbox()} is called, and it restarts this loader. When the loader returns 397 * a valid cursor, we create a folder, call {@link #onFolderChanged{Folder)} eventually 398 * calling {@link #updateFolder(Folder)} which starts a loader {@link #LOADER_FOLDER_CURSOR} 399 * over the current folder. 400 * When we have a valid cursor, we destroy this loader, This convoluted flow is historical. 401 */ 402 private static final int LOADER_ACCOUNT_INBOX = 32; 403 404 /** 405 * The fake folder of search results for a term. When we search for a term, 406 * a new activity is created with {@link Intent#ACTION_SEARCH}. For this new activity, 407 * we start a loader which returns conversations that match the user-provided query. 408 * We destroy the loader when we obtain a valid cursor since subsequent searches will create 409 * a new activity. 410 */ 411 private static final int LOADER_SEARCH = 33; 412 /** 413 * The initial folder at app start. When the application is launched from an intent that 414 * specifies the initial folder (notifications/widgets/shortcuts), 415 * then we extract the folder URI from the intent, but we cannot trust the folder object. Since 416 * shortcuts and widgets persist past application update, they might have incorrect 417 * information encoded in them. So, to obtain a {@link Folder} object from a {@link Uri}, 418 * we need to start another loader. Upon obtaining a valid cursor, the loader is destroyed. 419 * An additional complication arises if we have to view a specific conversation within this 420 * folder. This is the case when launching the app from a single conversation notification 421 * or tapping on a specific conversation in the widget. In these cases, the conversation is 422 * saved in {@link #mConversationToShow} and is retrieved when the loader returns. 423 */ 424 public static final int LOADER_FIRST_FOLDER = 34; 425 426 /** 427 * Guaranteed to be the last loader ID used by the activity. Loaders are owned by Activity or 428 * fragments, and within an activity, loader IDs need to be unique. A hack to ensure that the 429 * {@link FolderWatcher} can create its folder loaders without clashing with the IDs of those 430 * of the {@link AbstractActivityController}. Currently, the {@link FolderWatcher} is the only 431 * other class that uses this activity's LoaderManager. If another class needs activity-level 432 * loaders, consider consolidating the loaders in a central location: a UI-less fragment 433 * perhaps. 434 */ 435 public static final int LAST_LOADER_ID = 35; 436 437 /** 438 * Guaranteed to be the last loader ID used by the Fragment. Loaders are owned by Activity or 439 * fragments, and within an activity, loader IDs need to be unique. Currently, 440 * SectionedInboxTeaserView is the only class that uses the 441 * {@link ConversationListFragment}'s LoaderManager. 442 */ 443 public static final int LAST_FRAGMENT_LOADER_ID = 1000; 444 445 /** Code returned after an account has been added. */ 446 private static final int ADD_ACCOUNT_REQUEST_CODE = 1; 447 /** Code returned when the user has to enter the new password on an existing account. */ 448 private static final int REAUTHENTICATE_REQUEST_CODE = 2; 449 /** Code returned when the previous activity needs to navigate to a different folder 450 * or account */ 451 private static final int CHANGE_NAVIGATION_REQUEST_CODE = 3; 452 453 /** Code returned from voice search intent */ 454 public static final int VOICE_SEARCH_REQUEST_CODE = 4; 455 456 public static final String EXTRA_FOLDER = "extra-folder"; 457 public static final String EXTRA_ACCOUNT = "extra-account"; 458 459 /** The pending destructive action to be carried out before swapping the conversation cursor.*/ 460 private DestructiveAction mPendingDestruction; 461 protected AsyncRefreshTask mFolderSyncTask; 462 private Folder mFolderListFolder; 463 private final int mShowUndoBarDelay; 464 private boolean mRecentsDataUpdated; 465 /** A wait fragment we added, if any. */ 466 private WaitFragment mWaitFragment; 467 /** True if we have results from a search query */ 468 protected boolean mHaveSearchResults = false; 469 /** If a confirmation dialog is being show, the listener for the positive action. */ 470 private OnClickListener mDialogListener; 471 /** 472 * If a confirmation dialog is being show, the resource of the action: R.id.delete, etc. This 473 * is used to create a new {@link #mDialogListener} on orientation changes. 474 */ 475 private int mDialogAction = -1; 476 /** 477 * If a confirmation dialog is being shown, this is true if the dialog acts on the selected set 478 * and false if it acts on the currently selected conversation 479 */ 480 private boolean mDialogFromSelectedSet; 481 482 /** Which conversation to show, if started from widget/notification. */ 483 private Conversation mConversationToShow = null; 484 485 /** 486 * A temporary reference to the pending destructive action that was deferred due to an 487 * auto-advance transition in progress. 488 * <p> 489 * In detail: when auto-advance triggers a mode change, we must wait until the transition 490 * completes before executing the destructive action to ensure a smooth mode change transition. 491 * This member variable houses the pending destructive action work to be run upon completion. 492 */ 493 private Runnable mAutoAdvanceOp = null; 494 495 protected DrawerLayout mDrawerContainer; 496 protected View mDrawerPullout; 497 protected ActionBarDrawerToggle mDrawerToggle; 498 499 protected ListView mListViewForAnimating; 500 protected boolean mHasNewAccountOrFolder; 501 private boolean mConversationListLoadFinishedIgnored; 502 private final MailDrawerListener mDrawerListener = new MailDrawerListener(); 503 private boolean mHideMenuItems; 504 505 private final DrawIdler mDrawIdler = new DrawIdler(); 506 507 public static final String SYNC_ERROR_DIALOG_FRAGMENT_TAG = "SyncErrorDialogFragment"; 508 509 private final DataSetObserver mUndoNotificationObserver = new DataSetObserver() { 510 @Override 511 public void onChanged() { 512 super.onChanged(); 513 514 if (mConversationListCursor != null) { 515 mConversationListCursor.handleNotificationActions(); 516 } 517 } 518 }; 519 520 private final HomeButtonListener mHomeButtonListener = new HomeButtonListener(); 521 522 public AbstractActivityController(MailActivity activity, ViewMode viewMode) { 523 mActivity = activity; 524 mFragmentManager = mActivity.getFragmentManager(); 525 mViewMode = viewMode; 526 mContext = activity.getApplicationContext(); 527 mRecentFolderList = new RecentFolderList(mContext); 528 mTracker = new ConversationPositionTracker(this); 529 // Allow the fragment to observe changes to its own selection set. No other object is 530 // aware of the selected set. 531 mCheckedSet.addObserver(this); 532 533 final Resources r = mContext.getResources(); 534 mFolderItemUpdateDelayMs = r.getInteger(R.integer.folder_item_refresh_delay_ms); 535 mShowUndoBarDelay = r.getInteger(R.integer.show_undo_bar_delay_ms); 536 mVeiledMatcher = VeiledAddressMatcher.newInstance(activity.getResources()); 537 mIsTablet = Utils.useTabletUI(r); 538 mConversationListLoadFinishedIgnored = false; 539 } 540 541 @Override 542 public final String toString() { 543 final StringBuilder sb = new StringBuilder(super.toString()); 544 sb.append("{"); 545 sb.append("mCurrentConversation="); 546 sb.append(mCurrentConversation); 547 appendToString(sb); 548 sb.append("}"); 549 return sb.toString(); 550 } 551 552 protected void appendToString(StringBuilder sb) {} 553 554 public Account getCurrentAccount() { 555 return mAccount; 556 } 557 558 public ConversationListContext getCurrentListContext() { 559 return mConvListContext; 560 } 561 562 @Override 563 public final ConversationCursor getConversationListCursor() { 564 return mConversationListCursor; 565 } 566 567 /** 568 * Check if the fragment is attached to an activity and has a root view. 569 * @param in fragment to be checked 570 * @return true if the fragment is valid, false otherwise 571 */ 572 private static boolean isValidFragment(Fragment in) { 573 return !(in == null || in.getActivity() == null || in.getView() == null); 574 } 575 576 /** 577 * Get the conversation list fragment for this activity. If the conversation list fragment is 578 * not attached, this method returns null. 579 * 580 * Caution! This method returns the {@link ConversationListFragment} after the fragment has been 581 * added, <b>and</b> after the {@link FragmentManager} has run through its queue to add the 582 * fragment. There is a non-trivial amount of time after the fragment is instantiated and before 583 * this call returns a non-null value, depending on the {@link FragmentManager}. If you 584 * need the fragment immediately after adding it, consider making the fragment an observer of 585 * the controller and perform the task immediately on {@link Fragment#onActivityCreated(Bundle)} 586 */ 587 protected ConversationListFragment getConversationListFragment() { 588 final Fragment fragment = mFragmentManager.findFragmentByTag(TAG_CONVERSATION_LIST); 589 if (isValidFragment(fragment)) { 590 return (ConversationListFragment) fragment; 591 } 592 return null; 593 } 594 595 /** 596 * Returns the folder list fragment attached with this activity. If no such fragment is attached 597 * this method returns null. 598 * 599 * Caution! This method returns the {@link FolderListFragment} after the fragment has been 600 * added, <b>and</b> after the {@link FragmentManager} has run through its queue to add the 601 * fragment. There is a non-trivial amount of time after the fragment is instantiated and before 602 * this call returns a non-null value, depending on the {@link FragmentManager}. If you 603 * need the fragment immediately after adding it, consider making the fragment an observer of 604 * the controller and perform the task immediately on {@link Fragment#onActivityCreated(Bundle)} 605 */ 606 protected FolderListFragment getFolderListFragment() { 607 final String drawerPulloutTag = mActivity.getString(R.string.drawer_pullout_tag); 608 final Fragment fragment = mFragmentManager.findFragmentByTag(drawerPulloutTag); 609 if (isValidFragment(fragment)) { 610 return (FolderListFragment) fragment; 611 } 612 return null; 613 } 614 615 /** 616 * Initialize the action bar. This is not visible to OnePaneController and 617 * TwoPaneController so they cannot override this behavior. 618 */ 619 private void initializeActionBar() { 620 final ActionBar actionBar = mActivity.getSupportActionBar(); 621 if (actionBar == null) { 622 return; 623 } 624 625 mActionBarController = new ActionBarController(mContext); 626 mActionBarController.initialize(mActivity, this, actionBar); 627 actionBar.setShowHideAnimationEnabled(false); 628 629 // init the action bar to allow the 'up' affordance. 630 // any configurations that disallow 'up' should do that later. 631 mActionBarController.setBackButton(); 632 } 633 634 /** 635 * Attach the action bar to the activity. 636 */ 637 private void attachActionBar() { 638 final ActionBar actionBar = mActivity.getSupportActionBar(); 639 if (actionBar != null) { 640 // Show a title 641 final int mask = ActionBar.DISPLAY_SHOW_TITLE | ActionBar.DISPLAY_SHOW_HOME; 642 actionBar.setDisplayOptions(mask, mask); 643 mActionBarController.setViewModeController(mViewMode); 644 } 645 } 646 647 /** 648 * Returns whether the conversation list fragment is visible or not. 649 * Different layouts will have their own notion on the visibility of 650 * fragments, so this method needs to be overriden. 651 * 652 */ 653 protected abstract boolean isConversationListVisible(); 654 655 /** 656 * If required, starts wait mode for the current account. 657 */ 658 final void perhapsEnterWaitMode() { 659 // If the account is not initialized, then show the wait fragment, since nothing can be 660 // shown. 661 if (mAccount.isAccountInitializationRequired()) { 662 showWaitForInitialization(); 663 return; 664 } 665 666 final boolean inWaitingMode = inWaitMode(); 667 final boolean isSyncRequired = mAccount.isAccountSyncRequired(); 668 if (isSyncRequired) { 669 if (inWaitingMode) { 670 // Update the WaitFragment's account object 671 updateWaitMode(); 672 } else { 673 // Transition to waiting mode 674 showWaitForInitialization(); 675 } 676 } else if (inWaitingMode) { 677 // Dismiss waiting mode 678 hideWaitForInitialization(); 679 } 680 } 681 682 @Override 683 public void switchToDefaultInboxOrChangeAccount(Account account) { 684 LogUtils.d(LOG_TAG, "AAC.switchToDefaultAccount(%s)", account); 685 if (mViewMode.isSearchMode()) { 686 // We are in an activity on top of the main navigation activity. 687 // We need to return to it with a result code that indicates it should navigate to 688 // a different folder. 689 final Intent intent = new Intent(); 690 intent.putExtra(AbstractActivityController.EXTRA_ACCOUNT, account); 691 mActivity.setResult(Activity.RESULT_OK, intent); 692 mActivity.finish(); 693 return; 694 } 695 final boolean firstLoad = mAccount == null; 696 final boolean switchToDefaultInbox = !firstLoad && account.uri.equals(mAccount.uri); 697 // If the active account has been clicked in the drawer, go to default inbox 698 if (switchToDefaultInbox) { 699 loadAccountInbox(); 700 return; 701 } 702 changeAccount(account); 703 } 704 705 public void changeAccount(Account account) { 706 LogUtils.d(LOG_TAG, "AAC.changeAccount(%s)", account); 707 // Is the account or account settings different from the existing account? 708 final boolean firstLoad = mAccount == null; 709 final boolean accountChanged = firstLoad || !account.uri.equals(mAccount.uri); 710 711 // If nothing has changed, return early without wasting any more time. 712 if (!accountChanged && !account.settingsDiffer(mAccount)) { 713 return; 714 } 715 // We also don't want to do anything if the new account is null 716 if (account == null) { 717 LogUtils.e(LOG_TAG, "AAC.changeAccount(null) called."); 718 return; 719 } 720 final String emailAddress = account.getEmailAddress(); 721 mHandler.post(new Runnable() { 722 @Override 723 public void run() { 724 MailActivity.setNfcMessage(emailAddress); 725 } 726 }); 727 if (accountChanged) { 728 commitDestructiveActions(false); 729 } 730 731 // Change the account here 732 setAccount(account); 733 // And carry out associated actions. 734 cancelRefreshTask(); 735 if (accountChanged) { 736 loadAccountInbox(); 737 } 738 // Check if we need to force setting up an account before proceeding. 739 if (mAccount != null && !Uri.EMPTY.equals(mAccount.settings.setupIntentUri)) { 740 // Launch the intent! 741 final Intent intent = new Intent(Intent.ACTION_EDIT); 742 743 intent.setPackage(mContext.getPackageName()); 744 intent.setData(mAccount.settings.setupIntentUri); 745 746 mActivity.startActivity(intent); 747 } 748 } 749 750 /** 751 * Adds a listener interested in change in the current account. If a class is storing a 752 * reference to the current account, it should listen on changes, so it can receive updates to 753 * settings. Must happen in the UI thread. 754 */ 755 @Override 756 public void registerAccountObserver(DataSetObserver obs) { 757 mAccountObservers.registerObserver(obs); 758 } 759 760 /** 761 * Removes a listener from receiving current account changes. 762 * Must happen in the UI thread. 763 */ 764 @Override 765 public void unregisterAccountObserver(DataSetObserver obs) { 766 mAccountObservers.unregisterObserver(obs); 767 } 768 769 @Override 770 public void registerAllAccountObserver(DataSetObserver observer) { 771 mAllAccountObservers.registerObserver(observer); 772 } 773 774 @Override 775 public void unregisterAllAccountObserver(DataSetObserver observer) { 776 mAllAccountObservers.unregisterObserver(observer); 777 } 778 779 @Override 780 public Account[] getAllAccounts() { 781 return mAllAccounts; 782 } 783 784 @Override 785 public Account getAccount() { 786 return mAccount; 787 } 788 789 @Override 790 public void registerFolderOrAccountChangedObserver(final DataSetObserver observer) { 791 mFolderOrAccountObservers.registerObserver(observer); 792 } 793 794 @Override 795 public void unregisterFolderOrAccountChangedObserver(final DataSetObserver observer) { 796 mFolderOrAccountObservers.unregisterObserver(observer); 797 } 798 799 /** 800 * If the drawer is open, the function locks the drawer to the closed, thereby sliding in 801 * the drawer to the left edge, disabling events, and refreshing it once it's either closed 802 * or put in an idle state. 803 */ 804 @Override 805 public void closeDrawer(final boolean hasNewFolderOrAccount, Account nextAccount, 806 Folder nextFolder) { 807 if (!isDrawerEnabled()) { 808 if (hasNewFolderOrAccount) { 809 mFolderOrAccountObservers.notifyChanged(); 810 } 811 return; 812 } 813 // If there are no new folders or accounts to switch to, just close the drawer 814 if (!hasNewFolderOrAccount) { 815 mDrawerContainer.closeDrawers(); 816 return; 817 } 818 // Otherwise, start preloading the conversation list for the new folder. 819 if (nextFolder != null) { 820 preloadConvList(nextAccount, nextFolder); 821 } 822 // Remember if the conversation list view is animating 823 final ConversationListFragment conversationList = getConversationListFragment(); 824 if (conversationList != null) { 825 mListViewForAnimating = conversationList.getListView(); 826 } else { 827 // There is no conversation list to animate, so just set it to null 828 mListViewForAnimating = null; 829 } 830 831 if (mDrawerContainer.isDrawerOpen(mDrawerPullout)) { 832 // Lets the drawer listener update the drawer contents and notify the FolderListFragment 833 mHasNewAccountOrFolder = true; 834 mDrawerContainer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); 835 } else { 836 // Drawer is already closed, notify observers that is the case. 837 if (hasNewFolderOrAccount) { 838 mFolderOrAccountObservers.notifyChanged(); 839 } 840 } 841 } 842 843 /** 844 * Load the conversation list early for the given folder. This happens when some UI element 845 * (usually the drawer) instructs the controller that an account change or folder change is 846 * imminent. While the UI element is animating, the controller can preload the conversation 847 * list for the default inbox of the account provided here or to the folder provided here. 848 * 849 * @param nextAccount The account which the app will switch to shortly, possibly null. 850 * @param nextFolder The folder which the app will switch to shortly, possibly null. 851 */ 852 protected void preloadConvList(Account nextAccount, Folder nextFolder) { 853 // Fire off the conversation list loader for this account already with a fake 854 // listener. 855 final Bundle args = new Bundle(2); 856 if (nextAccount != null) { 857 args.putParcelable(BUNDLE_ACCOUNT_KEY, nextAccount); 858 } else { 859 args.putParcelable(BUNDLE_ACCOUNT_KEY, mAccount); 860 } 861 if (nextFolder != null) { 862 args.putParcelable(BUNDLE_FOLDER_KEY, nextFolder); 863 } else { 864 LogUtils.e(LOG_TAG, new Error(), "AAC.preloadConvList(): Got an empty folder"); 865 } 866 mFolder = null; 867 final LoaderManager lm = mActivity.getLoaderManager(); 868 lm.destroyLoader(LOADER_CONVERSATION_LIST); 869 lm.initLoader(LOADER_CONVERSATION_LIST, args, mListCursorCallbacks); 870 } 871 872 /** 873 * Initiates the async request to create a fake search folder, which returns conversations that 874 * match the query term provided by the user. Returns immediately. 875 * @param intent Intent that the app was started with. This intent contains the search query. 876 */ 877 private void fetchSearchFolder(Intent intent) { 878 final Bundle args = new Bundle(1); 879 args.putString(ConversationListContext.EXTRA_SEARCH_QUERY, intent 880 .getStringExtra(ConversationListContext.EXTRA_SEARCH_QUERY)); 881 mActivity.getLoaderManager().restartLoader(LOADER_SEARCH, args, mFolderCallbacks); 882 } 883 884 protected void onFolderChanged(Folder folder, final boolean force) { 885 if (isDrawerEnabled()) { 886 /** If the folder doesn't exist, or its parent URI is empty, 887 * this is not a child folder */ 888 final boolean isTopLevel = Folder.isRoot(folder); 889 final int mode = mViewMode.getMode(); 890 updateDrawerIndicator(mode, isTopLevel); 891 mDrawerContainer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED); 892 893 mDrawerContainer.closeDrawers(); 894 } 895 896 if (mFolder == null || !mFolder.equals(folder)) { 897 // We are actually changing the folder, so exit cab mode 898 exitCabMode(); 899 } 900 901 final String query; 902 if (folder != null && folder.isType(FolderType.SEARCH)) { 903 query = mConvListContext.searchQuery; 904 } else { 905 query = null; 906 } 907 908 changeFolder(folder, query, force); 909 } 910 911 /** 912 * Sets the folder state without changing view mode and without creating a list fragment, if 913 * possible. 914 * @param folder the folder whose list of conversations are to be shown 915 * @param query the query string for a list of conversations matching a search 916 */ 917 private void setListContext(Folder folder, String query) { 918 updateFolder(folder); 919 if (query != null) { 920 mConvListContext = ConversationListContext.forSearchQuery(mAccount, mFolder, query); 921 } else { 922 mConvListContext = ConversationListContext.forFolder(mAccount, mFolder); 923 } 924 cancelRefreshTask(); 925 } 926 927 /** 928 * Changes the folder to the value provided here. This causes the view mode to change. 929 * @param folder the folder to change to 930 * @param query if non-null, this represents the search string that the folder represents. 931 * @param force <code>true</code> to force a folder change, <code>false</code> to disallow 932 * changing to the current folder 933 */ 934 private void changeFolder(Folder folder, String query, final boolean force) { 935 if (!Objects.equal(mFolder, folder)) { 936 commitDestructiveActions(false); 937 } 938 if (folder != null && (!folder.equals(mFolder) || force) 939 || (mViewMode.getMode() != ViewMode.CONVERSATION_LIST)) { 940 setListContext(folder, query); 941 showConversationList(mConvListContext); 942 // Touch the current folder: it is different, and it has been accessed. 943 if (mFolder != null) { 944 mRecentFolderList.touchFolder(mFolder, mAccount); 945 } 946 } 947 resetActionBarIcon(); 948 } 949 950 @Override 951 public void onFolderSelected(Folder folder) { 952 onFolderChanged(folder, false /* force */); 953 } 954 955 /** 956 * Adds a listener interested in change in the recent folders. If a class is storing a 957 * reference to the recent folders, it should listen on changes, so it can receive updates. 958 * Must happen in the UI thread. 959 */ 960 @Override 961 public void registerRecentFolderObserver(DataSetObserver obs) { 962 mRecentFolderObservers.registerObserver(obs); 963 } 964 965 /** 966 * Removes a listener from receiving recent folder changes. 967 * Must happen in the UI thread. 968 */ 969 @Override 970 public void unregisterRecentFolderObserver(DataSetObserver obs) { 971 mRecentFolderObservers.unregisterObserver(obs); 972 } 973 974 @Override 975 public RecentFolderList getRecentFolders() { 976 return mRecentFolderList; 977 } 978 979 /** 980 * Load the default inbox associated with the current account. 981 */ 982 protected void loadAccountInbox() { 983 boolean handled = false; 984 if (mFolderWatcher != null) { 985 final Folder inbox = mFolderWatcher.getDefaultInbox(mAccount); 986 if (inbox != null) { 987 onFolderChanged(inbox, false /* force */); 988 handled = true; 989 } 990 } 991 if (!handled) { 992 LogUtils.d(LOG_TAG, "Starting a LOADER_ACCOUNT_INBOX for %s", mAccount); 993 restartOptionalLoader(LOADER_ACCOUNT_INBOX, mFolderCallbacks, Bundle.EMPTY); 994 } 995 final int mode = mViewMode.getMode(); 996 if (mode == ViewMode.UNKNOWN || mode == ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION) { 997 mViewMode.enterConversationListMode(); 998 } 999 } 1000 1001 @Override 1002 public void setFolderWatcher(FolderWatcher watcher) { 1003 mFolderWatcher = watcher; 1004 } 1005 1006 /** 1007 * Marks the {@link #mFolderChanged} value if the newFolder is different from the existing 1008 * {@link #mFolder}. This should be called immediately <b>before</b> assigning newFolder to 1009 * mFolder. 1010 * @param newFolder the new folder we are switching to. 1011 */ 1012 private void setHasFolderChanged(final Folder newFolder) { 1013 // We should never try to assign a null folder. But in the rare event that we do, we should 1014 // only set the bit when we have a valid folder, and null is not valid. 1015 if (newFolder == null) { 1016 return; 1017 } 1018 // If the previous folder was null, or if the two folders represent different data, then we 1019 // consider that the folder has changed. 1020 if (mFolder == null || !newFolder.equals(mFolder)) { 1021 mFolderChanged = true; 1022 } 1023 } 1024 1025 /** 1026 * Sets the current folder if it is different from the object provided here. This method does 1027 * NOT notify the folder observers that a change has happened. Observers are notified when we 1028 * get an updated folder from the loaders, which will happen as a consequence of this method 1029 * (since this method starts/restarts the loaders). 1030 * @param folder The folder to assign 1031 */ 1032 private void updateFolder(Folder folder) { 1033 if (folder == null || !folder.isInitialized()) { 1034 LogUtils.e(LOG_TAG, new Error(), "AAC.setFolder(%s): Bad input", folder); 1035 return; 1036 } 1037 if (folder.equals(mFolder)) { 1038 LogUtils.d(LOG_TAG, "AAC.setFolder(%s): Input matches mFolder", folder); 1039 return; 1040 } 1041 final boolean wasNull = mFolder == null; 1042 LogUtils.d(LOG_TAG, "AbstractActivityController.setFolder(%s)", folder.name); 1043 final LoaderManager lm = mActivity.getLoaderManager(); 1044 // updateFolder is called from AAC.onLoadFinished() on folder changes. We need to 1045 // ensure that the folder is different from the previous folder before marking the 1046 // folder changed. 1047 setHasFolderChanged(folder); 1048 mFolder = folder; 1049 1050 // We do not need to notify folder observers yet. Instead we start the loaders and 1051 // when the load finishes, we will get an updated folder. Then, we notify the 1052 // folderObservers in onLoadFinished. 1053 mActionBarController.setFolder(mFolder); 1054 1055 // Only when we switch from one folder to another do we want to restart the 1056 // folder and conversation list loaders (to trigger onCreateLoader). 1057 // The first time this runs when the activity is [re-]initialized, we want to re-use the 1058 // previous loader's instance and data upon configuration change (e.g. rotation). 1059 // If there was not already an instance of the loader, init it. 1060 if (lm.getLoader(LOADER_FOLDER_CURSOR) == null) { 1061 lm.initLoader(LOADER_FOLDER_CURSOR, Bundle.EMPTY, mFolderCallbacks); 1062 } else { 1063 lm.restartLoader(LOADER_FOLDER_CURSOR, Bundle.EMPTY, mFolderCallbacks); 1064 } 1065 if (!wasNull && lm.getLoader(LOADER_CONVERSATION_LIST) != null) { 1066 // If there was an existing folder AND we have changed 1067 // folders, we want to restart the loader to get the information 1068 // for the newly selected folder 1069 lm.destroyLoader(LOADER_CONVERSATION_LIST); 1070 } 1071 final Bundle args = new Bundle(2); 1072 args.putParcelable(BUNDLE_ACCOUNT_KEY, mAccount); 1073 args.putParcelable(BUNDLE_FOLDER_KEY, mFolder); 1074 args.putBoolean(BUNDLE_IGNORE_INITIAL_CONVERSATION_LIMIT_KEY, 1075 mIgnoreInitialConversationLimit); 1076 mIgnoreInitialConversationLimit = false; 1077 lm.initLoader(LOADER_CONVERSATION_LIST, args, mListCursorCallbacks); 1078 } 1079 1080 @Override 1081 public Folder getFolder() { 1082 return mFolder; 1083 } 1084 1085 @Override 1086 public Folder getHierarchyFolder() { 1087 return mFolderListFolder; 1088 } 1089 1090 /** 1091 * Set the folder currently selected in the folder selection hierarchy fragments. 1092 */ 1093 protected void setHierarchyFolder(Folder folder) { 1094 mFolderListFolder = folder; 1095 } 1096 1097 /** 1098 * The mail activity calls other activities for two specific reasons: 1099 * <ul> 1100 * <li>To add an account. And receives the result {@link #ADD_ACCOUNT_REQUEST_CODE}</li> 1101 * <li>To update the password on a current account. The result {@link 1102 * #REAUTHENTICATE_REQUEST_CODE} is received.</li> 1103 * </ul> 1104 * @param requestCode 1105 * @param resultCode 1106 * @param data 1107 */ 1108 @Override 1109 public void onActivityResult(int requestCode, int resultCode, Intent data) { 1110 switch (requestCode) { 1111 case ADD_ACCOUNT_REQUEST_CODE: 1112 // We were waiting for the user to create an account 1113 if (resultCode == Activity.RESULT_OK) { 1114 // restart the loader to get the updated list of accounts 1115 mActivity.getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, Bundle.EMPTY, 1116 mAccountCallbacks); 1117 } else { 1118 // The user failed to create an account, just exit the app 1119 mActivity.finish(); 1120 } 1121 break; 1122 case REAUTHENTICATE_REQUEST_CODE: 1123 if (resultCode == Activity.RESULT_OK) { 1124 // The user successfully authenticated, attempt to refresh the list 1125 final Uri refreshUri = mFolder != null ? mFolder.refreshUri : null; 1126 if (refreshUri != null) { 1127 startAsyncRefreshTask(refreshUri); 1128 } 1129 } 1130 break; 1131 case CHANGE_NAVIGATION_REQUEST_CODE: 1132 if (ViewMode.isSearchMode(mViewMode.getMode())) { 1133 mActivity.setResult(resultCode, data); 1134 mActivity.finish(); 1135 } else if (resultCode == Activity.RESULT_OK && data != null) { 1136 // We have have received a result that indicates we need to navigate to a 1137 // different folder or account. This happens if someone navigates using the 1138 // drawer on the search results activity. 1139 final Folder folder = data.getParcelableExtra(EXTRA_FOLDER); 1140 final Account account = data.getParcelableExtra(EXTRA_ACCOUNT); 1141 if (folder != null) { 1142 onFolderSelected(folder); 1143 mViewMode.enterConversationListMode(); 1144 } else if (account != null) { 1145 switchToDefaultInboxOrChangeAccount(account); 1146 mViewMode.enterConversationListMode(); 1147 } 1148 } 1149 break; 1150 case VOICE_SEARCH_REQUEST_CODE: 1151 if (resultCode == Activity.RESULT_OK) { 1152 final ArrayList<String> matches = data.getStringArrayListExtra( 1153 RecognizerIntent.EXTRA_RESULTS); 1154 if (!matches.isEmpty()) { 1155 // not sure how dependable the API is, but it's all we have. 1156 // take the top choice. 1157 mSearchViewController.onSearchPerformed(matches.get(0)); 1158 } 1159 } 1160 break; 1161 } 1162 } 1163 1164 /** 1165 * Inform the conversation cursor that there has been a visibility change. 1166 * @param visible true if the conversation list is visible, false otherwise. 1167 */ 1168 protected synchronized void informCursorVisiblity(boolean visible) { 1169 if (mConversationListCursor != null) { 1170 Utils.setConversationCursorVisibility(mConversationListCursor, visible, mFolderChanged); 1171 // We have informed the cursor. Subsequent visibility changes should not tell it that 1172 // the folder has changed. 1173 mFolderChanged = false; 1174 } 1175 } 1176 1177 @Override 1178 public void onConversationListVisibilityChanged(boolean visible) { 1179 mFloatingComposeButton.setVisibility( 1180 !ViewMode.isSearchMode(mViewMode.getMode()) && visible ? View.VISIBLE : View.GONE); 1181 1182 informCursorVisiblity(visible); 1183 commitAutoAdvanceOperation(); 1184 1185 // Notify special views 1186 final ConversationListFragment convListFragment = getConversationListFragment(); 1187 if (convListFragment != null && convListFragment.getAnimatedAdapter() != null) { 1188 convListFragment.getAnimatedAdapter().onConversationListVisibilityChanged(visible); 1189 } 1190 } 1191 1192 /** 1193 * Called when a conversation is visible. Child classes must call the super class implementation 1194 * before performing local computation. 1195 */ 1196 @Override 1197 public void onConversationVisibilityChanged(boolean visible) { 1198 commitAutoAdvanceOperation(); 1199 } 1200 1201 /** 1202 * Commits any pending destructive action that was earlier deferred by an auto-advance 1203 * mode-change transition. 1204 */ 1205 private void commitAutoAdvanceOperation() { 1206 if (mAutoAdvanceOp != null) { 1207 mAutoAdvanceOp.run(); 1208 mAutoAdvanceOp = null; 1209 } 1210 } 1211 1212 /** 1213 * Initialize development time logging. This can potentially log a lot of PII, and we don't want 1214 * to turn it on for shipped versions. 1215 */ 1216 private void initializeDevLoggingService() { 1217 if (!MailLogService.DEBUG_ENABLED) { 1218 return; 1219 } 1220 // Check every 5 minutes. 1221 final int WAIT_TIME = 5 * 60 * 1000; 1222 // Start a runnable that periodically checks the log level and starts/stops the service. 1223 mLogServiceChecker = new Runnable() { 1224 /** True if currently logging. */ 1225 private boolean mCurrentlyLogging = false; 1226 1227 /** 1228 * If the logging level has been changed since the previous run, start or stop the 1229 * service. 1230 */ 1231 private void startOrStopService() { 1232 // If the log level is already high, start the service. 1233 final Intent i = new Intent(mContext, MailLogService.class); 1234 final boolean loggingEnabled = MailLogService.isLoggingLevelHighEnough(); 1235 if (mCurrentlyLogging == loggingEnabled) { 1236 // No change since previous run, just return; 1237 return; 1238 } 1239 if (loggingEnabled) { 1240 LogUtils.e(LOG_TAG, "Starting MailLogService"); 1241 mContext.startService(i); 1242 } else { 1243 LogUtils.e(LOG_TAG, "Stopping MailLogService"); 1244 mContext.stopService(i); 1245 } 1246 mCurrentlyLogging = loggingEnabled; 1247 } 1248 1249 @Override 1250 public void run() { 1251 startOrStopService(); 1252 mHandler.postDelayed(this, WAIT_TIME); 1253 } 1254 }; 1255 // Start the runnable right away. 1256 mHandler.post(mLogServiceChecker); 1257 } 1258 1259 /** 1260 * The application can be started from the following entry points: 1261 * <ul> 1262 * <li>Launcher: you tap on the Gmail icon in the launcher. This is what most users think of 1263 * as Starting the app.</li> 1264 * <li>Shortcut: Users can make a shortcut to take them directly to a label.</li> 1265 * <li>Widget: Shows the contents of a synced label, and allows: 1266 * <ul> 1267 * <li>Viewing the list (tapping on the title)</li> 1268 * <li>Composing a new message (tapping on the new message icon in the title. This 1269 * launches the {@link ComposeActivity}. 1270 * </li> 1271 * <li>Viewing a single message (tapping on a list element)</li> 1272 * </ul> 1273 * 1274 * </li> 1275 * <li>Tapping on a notification: 1276 * <ul> 1277 * <li>Shows message list if more than one message</li> 1278 * <li>Shows the conversation if the notification is for a single message</li> 1279 * </ul> 1280 * </li> 1281 * <li>...and most importantly, the activity life cycle can tear down the application and 1282 * restart it: 1283 * <ul> 1284 * <li>Rotate the application: it is destroyed and recreated.</li> 1285 * <li>Navigate away, and return from recent applications.</li> 1286 * </ul> 1287 * </li> 1288 * <li>Add a new account: fires off an intent to add an account, 1289 * and returns in {@link #onActivityResult(int, int, android.content.Intent)} .</li> 1290 * <li>Re-authenticate your account: again returns in onActivityResult().</li> 1291 * <li>Composing can happen from many entry points: third party applications fire off an 1292 * intent to compose email, and launch directly into the {@link ComposeActivity} 1293 * .</li> 1294 * </ul> 1295 * {@inheritDoc} 1296 */ 1297 @Override 1298 public void onCreate(Bundle savedState) { 1299 initializeActionBar(); 1300 initializeDevLoggingService(); 1301 // Allow shortcut keys to function for the ActionBar and menus. 1302 mActivity.setDefaultKeyMode(Activity.DEFAULT_KEYS_SHORTCUT); 1303 mResolver = mActivity.getContentResolver(); 1304 mNewEmailReceiver = new SuppressNotificationReceiver(); 1305 mRecentFolderList.initialize(mActivity); 1306 mVeiledMatcher.initialize(this); 1307 1308 mFloatingComposeButton = mActivity.findViewById(R.id.compose_button); 1309 mFloatingComposeButton.setOnClickListener(this); 1310 1311 if (isDrawerEnabled()) { 1312 mDrawerToggle = new ActionBarDrawerToggle(mActivity, mDrawerContainer, 1313 R.string.drawer_open, R.string.drawer_close); 1314 mDrawerContainer.setDrawerListener(mDrawerListener); 1315 mDrawerContainer.setDrawerShadow( 1316 mContext.getResources().getDrawable(R.drawable.drawer_shadow), Gravity.START); 1317 1318 // Disable default drawer indicator as we are setting the drawer indicator icons. 1319 // TODO(shahrk): Once we can disable/enable drawer animation, go back to using 1320 // drawer indicators. 1321 mDrawerToggle.setDrawerIndicatorEnabled(false); 1322 mDrawerToggle.setHomeAsUpIndicator(R.drawable.ic_menu_wht_24dp); 1323 } else { 1324 final ActionBar ab = mActivity.getSupportActionBar(); 1325 ab.setHomeAsUpIndicator(R.drawable.ic_menu_wht_24dp); 1326 ab.setHomeActionContentDescription(R.string.drawer_open); 1327 ab.setDisplayHomeAsUpEnabled(true); 1328 } 1329 1330 // All the individual UI components listen for ViewMode changes. This 1331 // simplifies the amount of logic in the AbstractActivityController, but increases the 1332 // possibility of timing-related bugs. 1333 mViewMode.addListener(this); 1334 mPagerController = new ConversationPagerController(mActivity, this); 1335 mToastBar = findActionableToastBar(mActivity); 1336 attachActionBar(); 1337 1338 mDrawIdler.setRootView(mActivity.getWindow().getDecorView()); 1339 1340 final Intent intent = mActivity.getIntent(); 1341 1342 mSearchViewController = new MaterialSearchViewController(mActivity, this, intent, 1343 savedState); 1344 addConversationListLayoutListener(mSearchViewController); 1345 1346 // Immediately handle a clean launch with intent, and any state restoration 1347 // that does not rely on restored fragments or loader data 1348 // any state restoration that relies on those can be done later in 1349 // onRestoreInstanceState, once fragments are up and loader data is re-delivered 1350 if (savedState != null) { 1351 if (savedState.containsKey(SAVED_ACCOUNT)) { 1352 setAccount((Account) savedState.getParcelable(SAVED_ACCOUNT)); 1353 } 1354 if (savedState.containsKey(SAVED_FOLDER)) { 1355 final Folder folder = savedState.getParcelable(SAVED_FOLDER); 1356 final String query = savedState.getString(SAVED_QUERY, null); 1357 setListContext(folder, query); 1358 } 1359 if (savedState.containsKey(SAVED_ACTION)) { 1360 mDialogAction = savedState.getInt(SAVED_ACTION); 1361 } 1362 mDialogFromSelectedSet = savedState.getBoolean(SAVED_ACTION_FROM_SELECTED, false); 1363 mViewMode.handleRestore(savedState); 1364 } else if (intent != null) { 1365 handleIntent(intent); 1366 } 1367 // Create the accounts loader; this loads the account switch spinner. 1368 mActivity.getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, Bundle.EMPTY, 1369 mAccountCallbacks); 1370 } 1371 1372 /** 1373 * @param activity the activity that has been inflated 1374 * @return the Actionable Toast Bar defined within the activity 1375 */ 1376 protected ActionableToastBar findActionableToastBar(MailActivity activity) { 1377 return (ActionableToastBar) activity.findViewById(R.id.toast_bar); 1378 } 1379 1380 @Override 1381 public void onPostCreate(Bundle savedState) { 1382 if (!isDrawerEnabled()) { 1383 return; 1384 } 1385 // Sync the toggle state after onRestoreInstanceState has occurred. 1386 mDrawerToggle.syncState(); 1387 1388 mHideMenuItems = isDrawerEnabled() && mDrawerContainer.isDrawerOpen(mDrawerPullout); 1389 } 1390 1391 @Override 1392 public void onConfigurationChanged(Configuration newConfig) { 1393 if (isDrawerEnabled()) { 1394 mDrawerToggle.onConfigurationChanged(newConfig); 1395 } 1396 } 1397 1398 /** 1399 * This controller listens for clicks on items in the floating action bar. 1400 * 1401 * @param view the item that was clicked in the floating action bar 1402 */ 1403 @Override 1404 public void onClick(View view) { 1405 final int viewId = view.getId(); 1406 if (viewId == R.id.compose_button) { 1407 ComposeActivity.compose(mActivity.getActivityContext(), getAccount()); 1408 } else if (viewId == android.R.id.home) { 1409 // TODO: b/16627877 1410 handleUpPress(); 1411 } 1412 } 1413 1414 /** 1415 * If drawer is open/visible (even partially), close it. 1416 */ 1417 protected void closeDrawerIfOpen() { 1418 if (!isDrawerEnabled()) { 1419 return; 1420 } 1421 if(mDrawerContainer.isDrawerOpen(mDrawerPullout)) { 1422 mDrawerContainer.closeDrawers(); 1423 } 1424 } 1425 1426 @Override 1427 public void onStart() { 1428 mSafeToModifyFragments = true; 1429 1430 NotificationActionUtils.registerUndoNotificationObserver(mUndoNotificationObserver); 1431 1432 if (mViewMode.getMode() != ViewMode.UNKNOWN) { 1433 Analytics.getInstance().sendView("MainActivity" + mViewMode.toString()); 1434 } 1435 } 1436 1437 @Override 1438 public void onRestart() { 1439 final DialogFragment fragment = (DialogFragment) 1440 mFragmentManager.findFragmentByTag(SYNC_ERROR_DIALOG_FRAGMENT_TAG); 1441 if (fragment != null) { 1442 fragment.dismiss(); 1443 } 1444 // When the user places the app in the background by pressing "home", 1445 // dismiss the toast bar. However, since there is no way to determine if 1446 // home was pressed, just dismiss any existing toast bar when restarting 1447 // the app. 1448 if (mToastBar != null) { 1449 mToastBar.hide(false, false /* actionClicked */); 1450 } 1451 } 1452 1453 @Override 1454 public Dialog onCreateDialog(int id, Bundle bundle) { 1455 return null; 1456 } 1457 1458 @Override 1459 public final boolean onCreateOptionsMenu(Menu menu) { 1460 if (mViewMode.isAdMode()) { 1461 return false; 1462 } 1463 final MenuInflater inflater = mActivity.getMenuInflater(); 1464 inflater.inflate(mActionBarController.getOptionsMenuId(), menu); 1465 mActionBarController.onCreateOptionsMenu(menu); 1466 return true; 1467 } 1468 1469 @Override 1470 public final boolean onKeyDown(int keyCode, KeyEvent event) { 1471 return false; 1472 } 1473 1474 public abstract boolean doesActionChangeConversationListVisibility(int action); 1475 1476 /** 1477 * Helper function that determines if we should associate an undo callback with 1478 * the current menu action item 1479 * @param actionId the id of the action 1480 * @return the appropriate callback handler, or null if not applicable 1481 */ 1482 private UndoCallback getUndoCallbackForDestructiveActionsWithAutoAdvance( 1483 int actionId, final Conversation conv) { 1484 // We associated the undoCallback if the user is going to perform an action on the current 1485 // conversation, causing the current conversation to be removed from view and replacing it 1486 // with another (via Auto Advance). The undoCallback will bring the removed conversation 1487 // back into the view if the action is undone. 1488 final Collection<Conversation> convCol = Conversation.listOf(conv); 1489 final boolean isApplicableForReshow = mAccount != null && 1490 mAccount.settings != null && 1491 mTracker != null && 1492 // ensure that we will show another conversation due to Auto Advance 1493 mTracker.getNextConversation( 1494 mAccount.settings.getAutoAdvanceSetting(), convCol) != null && 1495 // ensure that we are performing the action from conversation view 1496 isCurrentConversationInView(convCol) && 1497 // check for the appropriate destructive actions 1498 doesActionRemoveCurrentConversationFromView(actionId); 1499 return (isApplicableForReshow) ? 1500 new UndoCallback() { 1501 @Override 1502 public void performUndoCallback() { 1503 showConversation(conv); 1504 } 1505 } : null; 1506 } 1507 1508 /** 1509 * Check if the provided action will remove the active conversation from view 1510 * @param actionId the applied action 1511 * @return true if it will remove the conversation from view, false otherwise 1512 */ 1513 private boolean doesActionRemoveCurrentConversationFromView(int actionId) { 1514 return actionId == R.id.archive || 1515 actionId == R.id.delete || 1516 actionId == R.id.discard_outbox || 1517 actionId == R.id.remove_folder || 1518 actionId == R.id.report_spam || 1519 actionId == R.id.report_phishing || 1520 actionId == R.id.move_to; 1521 } 1522 1523 @Override 1524 public boolean onOptionsItemSelected(MenuItem item) { 1525 1526 /* 1527 * The action bar home/up action should open or close the drawer. 1528 * mDrawerToggle will take care of this. 1529 */ 1530 if (isDrawerEnabled() && mDrawerToggle.onOptionsItemSelected(item)) { 1531 Analytics.getInstance().sendEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, "drawer_toggle", 1532 null, 0); 1533 return true; 1534 } 1535 1536 Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, 1537 item.getItemId(), "action_bar/" + mViewMode.getModeString(), 0); 1538 1539 final int id = item.getItemId(); 1540 LogUtils.d(LOG_TAG, "AbstractController.onOptionsItemSelected(%d) called.", id); 1541 /** This is NOT a batch action. */ 1542 final boolean isBatch = false; 1543 final Collection<Conversation> target = Conversation.listOf(mCurrentConversation); 1544 final Settings settings = (mAccount == null) ? null : mAccount.settings; 1545 // The user is choosing a new action; commit whatever they had been 1546 // doing before. Don't animate if we are launching a new screen. 1547 commitDestructiveActions(!doesActionChangeConversationListVisibility(id)); 1548 final UndoCallback undoCallback = getUndoCallbackForDestructiveActionsWithAutoAdvance( 1549 id, mCurrentConversation); 1550 1551 // Menu items that are targetted, only perform if there actually is a target and the 1552 // cursor is showing the target in the list. 1553 boolean handled = false; 1554 if (target.size() > 0 && 1555 ConversationCursor.isCursorReadyToShow(getConversationListCursor())) { 1556 handled = true; 1557 if (id == R.id.archive) { 1558 final boolean showDialog = (settings != null && settings.confirmArchive); 1559 confirmAndDelete(id, target, showDialog, R.plurals.confirm_archive_conversation, 1560 undoCallback); 1561 } else if (id == R.id.remove_folder) { 1562 delete(R.id.remove_folder, target, 1563 getDeferredRemoveFolder(target, mFolder, true, isBatch, true, undoCallback), 1564 isBatch); 1565 } else if (id == R.id.delete) { 1566 final boolean showDialog = (settings != null && settings.confirmDelete); 1567 confirmAndDelete(id, target, showDialog, R.plurals.confirm_delete_conversation, 1568 undoCallback); 1569 } else if (id == R.id.discard_drafts) { 1570 // drafts are lost forever, so always confirm 1571 confirmAndDelete(id, target, true /* showDialog */, 1572 R.plurals.confirm_discard_drafts_conversation, undoCallback); 1573 } else if (id == R.id.discard_outbox) { 1574 // discard in outbox means we discard the failed message and save them in drafts 1575 delete(id, target, getDeferredAction(id, target, isBatch, undoCallback), isBatch); 1576 } else if (id == R.id.mark_important) { 1577 updateConversation(Conversation.listOf(mCurrentConversation), 1578 ConversationColumns.PRIORITY, UIProvider.ConversationPriority.HIGH); 1579 } else if (id == R.id.mark_not_important) { 1580 if (mFolder != null && mFolder.isImportantOnly()) { 1581 delete(R.id.mark_not_important, target, 1582 getDeferredAction(R.id.mark_not_important, target, isBatch, undoCallback), 1583 isBatch); 1584 } else { 1585 updateConversation(target, ConversationColumns.PRIORITY, 1586 UIProvider.ConversationPriority.LOW); 1587 } 1588 } else if (id == R.id.mute) { 1589 delete(R.id.mute, target, getDeferredAction(R.id.mute, target, isBatch, undoCallback), 1590 isBatch); 1591 } else if (id == R.id.report_spam) { 1592 delete(R.id.report_spam, target, 1593 getDeferredAction(R.id.report_spam, target, isBatch, undoCallback), 1594 isBatch); 1595 } else if (id == R.id.mark_not_spam) { 1596 // Currently, since spam messages are only shown in list with 1597 // other spam messages, 1598 // marking a message not as spam is a destructive action 1599 delete(R.id.mark_not_spam, target, 1600 getDeferredAction(R.id.mark_not_spam, target, isBatch, undoCallback), 1601 isBatch); 1602 } else if (id == R.id.report_phishing) { 1603 delete(R.id.report_phishing, target, 1604 getDeferredAction(R.id.report_phishing, target, isBatch, undoCallback), 1605 isBatch); 1606 } else if (id == R.id.move_to || id == R.id.change_folders) { 1607 final FolderSelectionDialog dialog = FolderSelectionDialog.getInstance(mAccount, 1608 target, isBatch, mFolder, id == R.id.move_to); 1609 if (dialog != null) { 1610 dialog.show(mActivity.getFragmentManager(), null); 1611 } 1612 } else if (id == R.id.move_to_inbox) { 1613 new AsyncTask<Void, Void, Folder>() { 1614 @Override 1615 protected Folder doInBackground(final Void... params) { 1616 // Get the "move to" inbox 1617 return Utils.getFolder(mContext, mAccount.settings.moveToInbox, 1618 true /* allowHidden */); 1619 } 1620 1621 @Override 1622 protected void onPostExecute(final Folder moveToInbox) { 1623 final List<FolderOperation> ops = Lists.newArrayListWithCapacity(1); 1624 // Add inbox 1625 ops.add(new FolderOperation(moveToInbox, true)); 1626 assignFolder(ops, target, true, true /* showUndo */, false /* isMoveTo */); 1627 } 1628 }.execute((Void[]) null); 1629 } else { 1630 handled = false; 1631 } 1632 } 1633 1634 // Not handled by the targetted menu items, check the general ones. 1635 if (!handled) { 1636 handled = true; 1637 if (id == android.R.id.home) { 1638 handleUpPress(); 1639 } else if (id == R.id.compose) { 1640 ComposeActivity.compose(mActivity.getActivityContext(), mAccount); 1641 } else if (id == R.id.refresh) { 1642 requestFolderRefresh(); 1643 } else if (id == R.id.toggle_drawer) { 1644 toggleDrawerState(); 1645 } else if (id == R.id.settings) { 1646 Utils.showSettings(mActivity.getActivityContext(), mAccount); 1647 } else if (id == R.id.help_info_menu_item) { 1648 mActivity.showHelp(mAccount, mViewMode.getMode()); 1649 } else if (id == R.id.empty_trash) { 1650 showEmptyDialog(); 1651 } else if (id == R.id.empty_spam) { 1652 showEmptyDialog(); 1653 } else if (id == R.id.search) { 1654 mSearchViewController.showSearchActionBar( 1655 MaterialSearchViewController.SEARCH_VIEW_STATE_VISIBLE); 1656 } else { 1657 handled = false; 1658 } 1659 } 1660 1661 // If the controller didn't handle this event, check the CAB menu if it's active. 1662 // This is necessary because keyboard shortcuts don't seem to check CAB menus. 1663 if (!handled && mCabActionMenu != null && mCabActionMenu.isActivated() && 1664 mCabActionMenu.onActionItemClicked(item)) { 1665 handled = true; 1666 } 1667 1668 return handled; 1669 } 1670 1671 /** 1672 * Opens an {@link EmptyFolderDialogFragment} for the current folder. 1673 */ 1674 private void showEmptyDialog() { 1675 if (mFolder != null) { 1676 final EmptyFolderDialogFragment fragment = 1677 EmptyFolderDialogFragment.newInstance(mFolder.totalCount, mFolder.type); 1678 fragment.setListener(this); 1679 fragment.show(mActivity.getFragmentManager(), EmptyFolderDialogFragment.FRAGMENT_TAG); 1680 } 1681 } 1682 1683 @Override 1684 public void onFolderEmptied() { 1685 emptyFolder(); 1686 } 1687 1688 /** 1689 * Performs the work of emptying the currently visible folder. 1690 */ 1691 private void emptyFolder() { 1692 if (mConversationListCursor != null) { 1693 mConversationListCursor.emptyFolder(); 1694 } 1695 } 1696 1697 private void attachEmptyFolderDialogFragmentListener() { 1698 final EmptyFolderDialogFragment fragment = 1699 (EmptyFolderDialogFragment) mActivity.getFragmentManager() 1700 .findFragmentByTag(EmptyFolderDialogFragment.FRAGMENT_TAG); 1701 1702 if (fragment != null) { 1703 fragment.setListener(this); 1704 } 1705 } 1706 1707 /** 1708 * Toggles the drawer pullout. If it was open (Fully extended), the 1709 * drawer will be closed. Otherwise, the drawer will be opened. This should 1710 * only be called when used with a toggle item. Other cases should be handled 1711 * explicitly with just closeDrawers() or openDrawer(View drawerView); 1712 */ 1713 protected void toggleDrawerState() { 1714 if (!isDrawerEnabled()) { 1715 return; 1716 } 1717 if(mDrawerContainer.isDrawerOpen(mDrawerPullout)) { 1718 mDrawerContainer.closeDrawers(); 1719 } else { 1720 mDrawerContainer.openDrawer(mDrawerPullout); 1721 } 1722 } 1723 1724 @Override 1725 public final boolean onBackPressed() { 1726 if (isDrawerEnabled() && mDrawerContainer.isDrawerVisible(mDrawerPullout)) { 1727 mDrawerContainer.closeDrawers(); 1728 return true; 1729 } else if (mSearchViewController.handleBackPress()) { 1730 return true; 1731 // If we're in CAB mode, let the activity handle onBackPressed. 1732 // It will handle closing CAB mode for us. 1733 } else if (mCabActionMenu != null && mCabActionMenu.isActivated()) { 1734 return false; 1735 } 1736 1737 return handleBackPress(); 1738 } 1739 1740 protected abstract boolean handleBackPress(); 1741 1742 protected abstract boolean handleUpPress(); 1743 1744 @Override 1745 public void updateConversation(Collection<Conversation> target, ContentValues values) { 1746 mConversationListCursor.updateValues(target, values); 1747 refreshConversationList(); 1748 } 1749 1750 @Override 1751 public void updateConversation(Collection <Conversation> target, String columnName, 1752 boolean value) { 1753 mConversationListCursor.updateBoolean(target, columnName, value); 1754 refreshConversationList(); 1755 } 1756 1757 @Override 1758 public void updateConversation(Collection <Conversation> target, String columnName, 1759 int value) { 1760 mConversationListCursor.updateInt(target, columnName, value); 1761 refreshConversationList(); 1762 } 1763 1764 @Override 1765 public void updateConversation(Collection <Conversation> target, String columnName, 1766 String value) { 1767 mConversationListCursor.updateString(target, columnName, value); 1768 refreshConversationList(); 1769 } 1770 1771 @Override 1772 public void markConversationMessagesUnread(final Conversation conv, 1773 final Set<Uri> unreadMessageUris, final byte[] originalConversationInfo) { 1774 onPreMarkUnread(); 1775 1776 // locally mark conversation unread (the provider is supposed to propagate message unread 1777 // to conversation unread) 1778 conv.read = false; 1779 if (mConversationListCursor == null) { 1780 LogUtils.d(LOG_TAG, "markConversationMessagesUnread(id=%d), deferring", conv.id); 1781 1782 mConversationListLoadFinishedCallbacks.add(new LoadFinishedCallback() { 1783 @Override 1784 public void onLoadFinished() { 1785 doMarkConversationMessagesUnread(conv, unreadMessageUris, 1786 originalConversationInfo); 1787 } 1788 }); 1789 } else { 1790 LogUtils.d(LOG_TAG, "markConversationMessagesUnread(id=%d), performing", conv.id); 1791 doMarkConversationMessagesUnread(conv, unreadMessageUris, originalConversationInfo); 1792 } 1793 } 1794 1795 /** 1796 * Hook to do stuff before actually marking a conversation unread (only called from within 1797 * conversation view). Most configurations do the default behavior of popping out of 1798 * CV to go back to TL. 1799 * 1800 */ 1801 protected void onPreMarkUnread() { 1802 // The only caller of this method is the conversation view, from where marking unread should 1803 // take you back to list mode in most cases. Two-pane view is the exception. 1804 showConversation(null); 1805 } 1806 1807 private void doMarkConversationMessagesUnread(Conversation conv, Set<Uri> unreadMessageUris, 1808 byte[] originalConversationInfo) { 1809 // Only do a granular 'mark unread' if a subset of messages are unread 1810 final int unreadCount = (unreadMessageUris == null) ? 0 : unreadMessageUris.size(); 1811 final int numMessages = conv.getNumMessages(); 1812 final boolean subsetIsUnread = (numMessages > 1 && unreadCount > 0 1813 && unreadCount < numMessages); 1814 1815 LogUtils.d(LOG_TAG, "markConversationMessagesUnread(conv=%s)" 1816 + ", numMessages=%d, unreadCount=%d, subsetIsUnread=%b", 1817 conv, numMessages, unreadCount, subsetIsUnread); 1818 if (!subsetIsUnread) { 1819 // Conversations are neither marked read, nor viewed, and we don't want to show 1820 // the next conversation. 1821 LogUtils.d(LOG_TAG, ". . doing full mark unread"); 1822 markConversationsRead(Collections.singletonList(conv), false, false, false); 1823 } else { 1824 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 1825 final ConversationInfo info = ConversationInfo.fromBlob(originalConversationInfo); 1826 LogUtils.d(LOG_TAG, ". . doing subset mark unread, originalConversationInfo = %s", 1827 info); 1828 } 1829 mConversationListCursor.setConversationColumn(conv.uri, ConversationColumns.READ, 0); 1830 1831 // Locally update conversation's conversationInfo to revert to original version 1832 if (originalConversationInfo != null) { 1833 mConversationListCursor.setConversationColumn(conv.uri, 1834 ConversationColumns.CONVERSATION_INFO, originalConversationInfo); 1835 } 1836 1837 // applyBatch with each CPO as an UPDATE op on each affected message uri 1838 final ArrayList<ContentProviderOperation> ops = Lists.newArrayList(); 1839 String authority = null; 1840 for (Uri messageUri : unreadMessageUris) { 1841 if (authority == null) { 1842 authority = messageUri.getAuthority(); 1843 } 1844 ops.add(ContentProviderOperation.newUpdate(messageUri) 1845 .withValue(UIProvider.MessageColumns.READ, 0) 1846 .build()); 1847 LogUtils.d(LOG_TAG, ". . Adding op: read=0, uri=%s", messageUri); 1848 } 1849 LogUtils.d(LOG_TAG, ". . operations = %s", ops); 1850 new ContentProviderTask() { 1851 @Override 1852 protected void onPostExecute(Result result) { 1853 if (result.exception != null) { 1854 LogUtils.e(LOG_TAG, result.exception, "ContentProviderTask() ERROR."); 1855 } else { 1856 LogUtils.d(LOG_TAG, "ContentProviderTask(): success %s", 1857 Arrays.toString(result.results)); 1858 } 1859 } 1860 }.run(mResolver, authority, ops); 1861 } 1862 } 1863 1864 /** 1865 * Mark a single conversation 'seen', which is a combination of 'viewed' and 'read'. In some 1866 * configurations (peek mode), this operation may be prevented and the method will return false. 1867 * 1868 * @param conv the conversation to mark seen 1869 * @return true if the operation was a success 1870 */ 1871 @Override 1872 public boolean markConversationSeen(Conversation conv) { 1873 if (isCurrentConversationJustPeeking()) { 1874 LogUtils.i(LOG_TAG, "AAC is in peek mode, not marking seen. conv=%s", conv); 1875 return false; 1876 } else { 1877 markConversationsRead(Arrays.asList(conv), true /* read */, true /* viewed */); 1878 return true; 1879 } 1880 } 1881 1882 @Override 1883 public void markConversationsRead(final Collection<Conversation> targets, final boolean read, 1884 final boolean viewed) { 1885 LogUtils.d(LOG_TAG, "markConversationsRead(targets=%s)", targets.toArray()); 1886 1887 if (mConversationListCursor == null) { 1888 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { 1889 LogUtils.d(LOG_TAG, "markConversationsRead(targets=%s), deferring", 1890 targets.toArray()); 1891 } 1892 mConversationListLoadFinishedCallbacks.add(new LoadFinishedCallback() { 1893 @Override 1894 public void onLoadFinished() { 1895 markConversationsRead(targets, read, viewed, true); 1896 } 1897 }); 1898 } else { 1899 // We want to show the next conversation if we are marking unread. 1900 markConversationsRead(targets, read, viewed, true); 1901 } 1902 } 1903 1904 private void markConversationsRead(final Collection<Conversation> targets, final boolean read, 1905 final boolean markViewed, final boolean showNext) { 1906 LogUtils.d(LOG_TAG, "performing markConversationsRead"); 1907 // Auto-advance if requested and the current conversation is being marked unread 1908 if (showNext && !read) { 1909 final Runnable operation = new Runnable() { 1910 @Override 1911 public void run() { 1912 markConversationsRead(targets, read, markViewed, showNext); 1913 } 1914 }; 1915 1916 if (!showNextConversation(targets, operation)) { 1917 // This method will be called again if the user selects an autoadvance option 1918 return; 1919 } 1920 } 1921 1922 final int size = targets.size(); 1923 final List<ConversationOperation> opList = new ArrayList<ConversationOperation>(size); 1924 for (final Conversation target : targets) { 1925 final ContentValues value = new ContentValues(4); 1926 value.put(ConversationColumns.READ, read); 1927 1928 // We never want to mark unseen here, but we do want to mark it seen 1929 if (read || markViewed) { 1930 value.put(ConversationColumns.SEEN, Boolean.TRUE); 1931 } 1932 1933 // The mark read/unread/viewed operations do not show an undo bar 1934 value.put(ConversationOperations.Parameters.SUPPRESS_UNDO, true); 1935 if (markViewed) { 1936 value.put(ConversationColumns.VIEWED, true); 1937 } 1938 final ConversationInfo info = target.conversationInfo; 1939 final boolean changed = info.markRead(read); 1940 if (changed) { 1941 value.put(ConversationColumns.CONVERSATION_INFO, info.toBlob()); 1942 } 1943 opList.add(mConversationListCursor.getOperationForConversation( 1944 target, ConversationOperation.UPDATE, value)); 1945 // Update the local conversation objects so they immediately change state. 1946 target.read = read; 1947 if (markViewed) { 1948 target.markViewed(); 1949 } 1950 } 1951 mConversationListCursor.updateBulkValues(opList); 1952 } 1953 1954 /** 1955 * Auto-advance to a different conversation if the currently visible conversation in 1956 * conversation mode is affected (deleted, marked unread, etc.). 1957 * 1958 * <p>Does nothing if outside of conversation mode.</p> 1959 * 1960 * @param target the set of conversations being deleted/marked unread 1961 */ 1962 @Override 1963 public void showNextConversation(final Collection<Conversation> target) { 1964 showNextConversation(target, null); 1965 } 1966 1967 /** 1968 * Helper function to determine if the provided set of conversations is in view 1969 * @param target set of conversations that we are interested in 1970 * @return true if they are in view, false otherwise 1971 */ 1972 private boolean isCurrentConversationInView(final Collection<Conversation> target) { 1973 final int viewMode = mViewMode.getMode(); 1974 return (viewMode == ViewMode.CONVERSATION 1975 || viewMode == ViewMode.SEARCH_RESULTS_CONVERSATION) 1976 && Conversation.contains(target, mCurrentConversation); 1977 } 1978 1979 /** 1980 * Auto-advance to a different conversation if the currently visible conversation in 1981 * conversation mode is affected (deleted, marked unread, etc.). 1982 * 1983 * <p>Does nothing if outside of conversation mode.</p> 1984 * <p> 1985 * Clients may pass an operation to execute on the target that this method will run after 1986 * auto-advance is complete. The operation, if provided, may run immediately, or it may run 1987 * later, or not at all. Reasons it may run later include: 1988 * <ul> 1989 * <li>the auto-advance setting is uninitialized and we need to wait for the user to set it</li> 1990 * <li>auto-advance in this configuration requires a mode change, and we need to wait for the 1991 * mode change transition to finish</li> 1992 * </ul> 1993 * <p>If the current conversation is not in the target collection, this method will do nothing, 1994 * and will not execute the operation. 1995 * 1996 * @param target the set of conversations being deleted/marked unread 1997 * @param operation (optional) the operation to execute after advancing 1998 * @return <code>false</code> if this method handled or will execute the operation, 1999 * <code>true</code> otherwise. 2000 */ 2001 private boolean showNextConversation(final Collection<Conversation> target, 2002 final Runnable operation) { 2003 if (isCurrentConversationInView(target)) { 2004 final int autoAdvanceSetting = mAccount.settings.getAutoAdvanceSetting(); 2005 2006 // If we don't have one set, but we're here, just take the default 2007 final int autoAdvance = (autoAdvanceSetting == AutoAdvance.UNSET) ? 2008 AutoAdvance.DEFAULT : autoAdvanceSetting; 2009 2010 // Set mAutoAdvanceOp *before* showConversation() to ensure that it runs when the 2011 // transition doesn't run (i.e. it "completes" immediately). 2012 mAutoAdvanceOp = operation; 2013 doShowNextConversation(target, autoAdvance); 2014 return (mAutoAdvanceOp == null); 2015 } 2016 2017 return true; 2018 } 2019 2020 /** 2021 * Do the actual work of selecting a next conversation to show and showing it. Two-pane 2022 * overrides this in landscape to prefer peeking rather than staring at an empty CV pane when 2023 * auto-advance=LIST. 2024 * 2025 * @param target conversations being destroyed, of which the current convo is one 2026 * @param autoAdvance auto-advance pref value 2027 */ 2028 protected void doShowNextConversation(Collection<Conversation> target, int autoAdvance) { 2029 final Conversation next = mTracker.getNextConversation(autoAdvance, target); 2030 LogUtils.d(LOG_TAG, "showNextConversation: showing %s next.", next); 2031 showConversation(next); 2032 } 2033 2034 @Override 2035 public void starMessage(ConversationMessage msg, boolean starred) { 2036 if (msg.starred == starred) { 2037 return; 2038 } 2039 2040 msg.setStarredInConversation(starred); 2041 2042 // locally propagate the change to the owning conversation 2043 // (figure the provider will properly propagate the change when it commits it) 2044 // 2045 // when unstarring, only propagate the change if this was the only message starred 2046 final boolean conversationStarred = starred || msg.isConversationStarred(); 2047 final Conversation conv = msg.getConversation(); 2048 if (conversationStarred != conv.starred) { 2049 conv.starred = conversationStarred; 2050 mConversationListCursor.setConversationColumn(conv.uri, 2051 ConversationColumns.STARRED, conversationStarred); 2052 } 2053 2054 final ContentValues values = new ContentValues(1); 2055 values.put(UIProvider.MessageColumns.STARRED, starred ? 1 : 0); 2056 2057 new ContentProviderTask.UpdateTask() { 2058 @Override 2059 protected void onPostExecute(Result result) { 2060 // TODO: handle errors? 2061 } 2062 }.run(mResolver, msg.uri, values, null /* selection*/, null /* selectionArgs */); 2063 } 2064 2065 @Override 2066 public void requestFolderRefresh() { 2067 if (mFolder == null) { 2068 return; 2069 } 2070 final ConversationListFragment convList = getConversationListFragment(); 2071 if (convList == null) { 2072 // This could happen if this account is in initial sync (user 2073 // is seeing the "your mail will appear shortly" message) 2074 return; 2075 } 2076 convList.showSyncStatusBar(); 2077 2078 if (mAsyncRefreshTask != null) { 2079 mAsyncRefreshTask.cancel(true); 2080 } 2081 mAsyncRefreshTask = new AsyncRefreshTask(mContext, mFolder.refreshUri); 2082 mAsyncRefreshTask.execute(); 2083 } 2084 2085 /** 2086 * Confirm (based on user's settings) and delete a conversation from the conversation list and 2087 * from the database. 2088 * @param actionId the ID of the menu item that caused the delete: R.id.delete, R.id.archive... 2089 * @param target the conversations to act upon 2090 * @param showDialog true if a confirmation dialog is to be shown, false otherwise. 2091 * @param confirmResource the resource ID of the string that is shown in the confirmation dialog 2092 */ 2093 private void confirmAndDelete(int actionId, final Collection<Conversation> target, 2094 boolean showDialog, int confirmResource, UndoCallback undoCallback) { 2095 final boolean isBatch = false; 2096 if (showDialog) { 2097 makeDialogListener(actionId, isBatch, undoCallback); 2098 final CharSequence message = Utils.formatPlural(mContext, confirmResource, 2099 target.size()); 2100 final ConfirmDialogFragment c = ConfirmDialogFragment.newInstance(message); 2101 c.displayDialog(mActivity.getFragmentManager()); 2102 } else { 2103 delete(0, target, getDeferredAction(actionId, target, isBatch, undoCallback), isBatch); 2104 } 2105 } 2106 2107 @Override 2108 public void delete(final int actionId, final Collection<Conversation> target, 2109 final DestructiveAction action, final boolean isBatch) { 2110 // Order of events is critical! The Conversation View Fragment must be 2111 // notified of the next conversation with showConversation(next) *before* the 2112 // conversation list 2113 // fragment has a chance to delete the conversation, animating it away. 2114 2115 // Update the conversation fragment if the current conversation is 2116 // deleted. 2117 final Runnable operation = new Runnable() { 2118 @Override 2119 public void run() { 2120 delete(actionId, target, action, isBatch); 2121 } 2122 }; 2123 2124 showNextConversation(target, operation); 2125 2126 // If the conversation is in the selected set, remove it from the set. 2127 // Batch selections are cleared in the end of the action, so not done for batch actions. 2128 if (!isBatch) { 2129 for (final Conversation conv : target) { 2130 if (mCheckedSet.contains(conv)) { 2131 mCheckedSet.toggle(conv); 2132 } 2133 } 2134 } 2135 // The conversation list deletes and performs the action if it exists. 2136 final ConversationListFragment convListFragment = getConversationListFragment(); 2137 if (convListFragment != null) { 2138 LogUtils.i(LOG_TAG, "AAC.requestDelete: ListFragment is handling delete."); 2139 convListFragment.requestDelete(actionId, target, action); 2140 return; 2141 } 2142 // No visible UI element handled it on our behalf. Perform the action 2143 // ourself. 2144 LogUtils.i(LOG_TAG, "ACC.requestDelete: performing remove action ourselves"); 2145 action.performAction(); 2146 } 2147 2148 /** 2149 * Requests that the action be performed and the UI state is updated to reflect the new change. 2150 * @param action the action to be performed, specified as a menu id: R.id.archive, ... 2151 */ 2152 private void requestUpdate(final DestructiveAction action) { 2153 action.performAction(); 2154 refreshConversationList(); 2155 } 2156 2157 @Override 2158 public void onPrepareDialog(int id, Dialog dialog, Bundle bundle) { 2159 // TODO(viki): Auto-generated method stub 2160 } 2161 2162 @Override 2163 public void onPrepareOptionsMenu(Menu menu) { 2164 mActionBarController.onPrepareOptionsMenu(menu); 2165 } 2166 2167 @Override 2168 public void onPause() { 2169 mHaveAccountList = false; 2170 enableNotifications(); 2171 } 2172 2173 @Override 2174 public void onResume() { 2175 // Register the receiver that will prevent the status receiver from 2176 // displaying its notification icon as long as we're running. 2177 // The SupressNotificationReceiver will block the broadcast if we're looking at the folder 2178 // that the notification was received for. 2179 disableNotifications(); 2180 2181 mSafeToModifyFragments = true; 2182 2183 attachEmptyFolderDialogFragmentListener(); 2184 2185 // Invalidating the options menu so that when we make changes in settings, 2186 // the changes will always be updated in the action bar/options menu/ 2187 mActivity.invalidateOptionsMenu(); 2188 } 2189 2190 @Override 2191 public void onSaveInstanceState(Bundle outState) { 2192 mViewMode.handleSaveInstanceState(outState); 2193 if (mAccount != null) { 2194 outState.putParcelable(SAVED_ACCOUNT, mAccount); 2195 } 2196 if (mFolder != null) { 2197 outState.putParcelable(SAVED_FOLDER, mFolder); 2198 } 2199 // If this is a search activity, let's store the search query term as well. 2200 if (ConversationListContext.isSearchResult(mConvListContext)) { 2201 outState.putString(SAVED_QUERY, mConvListContext.searchQuery); 2202 } 2203 if (mCurrentConversation != null && mViewMode.isConversationMode()) { 2204 outState.putParcelable(SAVED_CONVERSATION, mCurrentConversation); 2205 } 2206 if (!mCheckedSet.isEmpty()) { 2207 outState.putParcelable(SAVED_SELECTED_SET, mCheckedSet); 2208 } 2209 if (mToastBar.getVisibility() == View.VISIBLE) { 2210 outState.putParcelable(SAVED_TOAST_BAR_OP, mToastBar.getOperation()); 2211 } 2212 final ConversationListFragment convListFragment = getConversationListFragment(); 2213 if (convListFragment != null) { 2214 convListFragment.getAnimatedAdapter().onSaveInstanceState(outState); 2215 } 2216 // If there is a dialog being shown, save the state so we can create a listener for it. 2217 if (mDialogAction != -1) { 2218 outState.putInt(SAVED_ACTION, mDialogAction); 2219 outState.putBoolean(SAVED_ACTION_FROM_SELECTED, mDialogFromSelectedSet); 2220 } 2221 if (mDetachedConvUri != null) { 2222 outState.putParcelable(SAVED_DETACHED_CONV_URI, mDetachedConvUri); 2223 } 2224 2225 outState.putParcelable(SAVED_HIERARCHICAL_FOLDER, mFolderListFolder); 2226 mSafeToModifyFragments = false; 2227 2228 outState.putParcelable(SAVED_INBOX_KEY, mInbox); 2229 2230 outState.putBundle(SAVED_CONVERSATION_LIST_SCROLL_POSITIONS, 2231 mConversationListScrollPositions); 2232 2233 mSearchViewController.saveState(outState); 2234 } 2235 2236 /** 2237 * @see #mSafeToModifyFragments 2238 */ 2239 protected boolean safeToModifyFragments() { 2240 return mSafeToModifyFragments; 2241 } 2242 2243 @Override 2244 public void executeSearch(String query) { 2245 AnalyticsTimer.getInstance().trackStart(AnalyticsTimer.SEARCH_TO_LIST); 2246 Intent intent = new Intent(); 2247 intent.setAction(Intent.ACTION_SEARCH); 2248 intent.putExtra(ConversationListContext.EXTRA_SEARCH_QUERY, query); 2249 intent.putExtra(Utils.EXTRA_ACCOUNT, mAccount); 2250 intent.setComponent(mActivity.getComponentName()); 2251 mSearchViewController.showSearchActionBar( 2252 MaterialSearchViewController.SEARCH_VIEW_STATE_GONE); 2253 // Call startActivityForResult here so we can tell if we have navigated to a different folder 2254 // or account from search results. 2255 mActivity.startActivityForResult(intent, CHANGE_NAVIGATION_REQUEST_CODE); 2256 } 2257 2258 @Override 2259 public void onStop() { 2260 NotificationActionUtils.unregisterUndoNotificationObserver(mUndoNotificationObserver); 2261 } 2262 2263 @Override 2264 public void onDestroy() { 2265 // stop listening to the cursor on e.g. configuration changes 2266 if (mConversationListCursor != null) { 2267 mConversationListCursor.removeListener(this); 2268 } 2269 mDrawIdler.setListener(null); 2270 mDrawIdler.setRootView(null); 2271 // unregister the ViewPager's observer on the conversation cursor 2272 mPagerController.onDestroy(); 2273 mActionBarController.onDestroy(); 2274 mRecentFolderList.destroy(); 2275 mDestroyed = true; 2276 mHandler.removeCallbacks(mLogServiceChecker); 2277 mLogServiceChecker = null; 2278 mSearchViewController.onDestroy(); 2279 } 2280 2281 /** 2282 * Set the Action Bar icon according to the mode. The Action Bar icon can contain a back button 2283 * or not. The individual controller is responsible for changing the icon based on the mode. 2284 */ 2285 protected abstract void resetActionBarIcon(); 2286 2287 /** 2288 * {@inheritDoc} Subclasses must override this to listen to mode changes 2289 * from the ViewMode. Subclasses <b>must</b> call the parent's 2290 * onViewModeChanged since the parent will handle common state changes. 2291 */ 2292 @Override 2293 public void onViewModeChanged(int newMode) { 2294 // When we step away from the conversation mode, we don't have a current conversation 2295 // anymore. Let's blank it out so clients calling getCurrentConversation are not misled. 2296 if (!ViewMode.isConversationMode(newMode)) { 2297 setCurrentConversation(null); 2298 } 2299 2300 // If the viewmode is not set, preserve existing icon. 2301 if (newMode != ViewMode.UNKNOWN) { 2302 resetActionBarIcon(); 2303 } 2304 2305 if (isDrawerEnabled()) { 2306 /** If the folder doesn't exist, or its parent URI is empty, 2307 * this is not a child folder */ 2308 final boolean isTopLevel = Folder.isRoot(mFolder); 2309 updateDrawerIndicator(newMode, isTopLevel); 2310 mDrawerContainer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED); 2311 closeDrawerIfOpen(); 2312 } 2313 } 2314 2315 /** 2316 * Update the drawer indicator to either be the burger or the back arrow. 2317 * @param viewMode the current view mode 2318 * @param isTopLevel true if the current folder is not a child 2319 */ 2320 private void updateDrawerIndicator(final int viewMode, final boolean isTopLevel) { 2321 // Show burger if we're either in conversation list or folder list mode. 2322 if (isDrawerEnabled() && !ViewMode.isSearchMode(viewMode) 2323 && (viewMode == ViewMode.CONVERSATION_LIST && isTopLevel)) { 2324 mDrawerToggle.setHomeAsUpIndicator(R.drawable.ic_menu_wht_24dp); 2325 2326 // Otherwise, show the back arrow for the indicator. 2327 } else { 2328 mDrawerToggle.setHomeAsUpIndicator(R.drawable.ic_arrow_back_wht_24dp_with_rtl); 2329 } 2330 } 2331 2332 public void disablePagerUpdates() { 2333 mPagerController.stopListening(); 2334 } 2335 2336 public boolean isDestroyed() { 2337 return mDestroyed; 2338 } 2339 2340 @Override 2341 public void commitDestructiveActions(boolean animate) { 2342 ConversationListFragment fragment = getConversationListFragment(); 2343 if (fragment != null) { 2344 fragment.commitDestructiveActions(animate); 2345 } 2346 } 2347 2348 @Override 2349 public void onWindowFocusChanged(boolean hasFocus) { 2350 final ConversationListFragment convList = getConversationListFragment(); 2351 // hasFocus already ensures that the window is in focus, so we don't need to call 2352 // AAC.isFragmentVisible(convList) here. 2353 if (hasFocus && convList != null && convList.isVisible()) { 2354 // The conversation list is visible. 2355 informCursorVisiblity(true); 2356 } 2357 } 2358 2359 /** 2360 * Set the account, and carry out all the account-related changes that rely on this. 2361 * @param account new account to set to. 2362 */ 2363 private void setAccount(Account account) { 2364 if (account == null) { 2365 LogUtils.w(LOG_TAG, new Error(), 2366 "AAC ignoring null (presumably invalid) account restoration"); 2367 return; 2368 } 2369 LogUtils.d(LOG_TAG, "AbstractActivityController.setAccount(): account = %s", account.uri); 2370 mAccount = account; 2371 2372 Analytics.getInstance().setEmail(account.getEmailAddress(), account.getType()); 2373 2374 // Only change AAC state here. Do *not* modify any other object's state. The object 2375 // should listen on account changes. 2376 restartOptionalLoader(LOADER_RECENT_FOLDERS, mFolderCallbacks, Bundle.EMPTY); 2377 mActivity.invalidateOptionsMenu(); 2378 disableNotificationsOnAccountChange(mAccount); 2379 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR, mAccountCallbacks, Bundle.EMPTY); 2380 // The Mail instance can be null during test runs. 2381 final MailAppProvider instance = MailAppProvider.getInstance(); 2382 if (instance != null) { 2383 instance.setLastViewedAccount(mAccount.uri.toString()); 2384 } 2385 if (account.settings == null) { 2386 LogUtils.w(LOG_TAG, new Error(), "AAC ignoring account with null settings."); 2387 return; 2388 } 2389 mAccountObservers.notifyChanged(); 2390 perhapsEnterWaitMode(); 2391 } 2392 2393 /** 2394 * Restore the state from the previous bundle. Subclasses should call this 2395 * method from the parent class, since it performs important UI 2396 * initialization. 2397 * 2398 * @param savedState previous state 2399 */ 2400 @Override 2401 public void onRestoreInstanceState(Bundle savedState) { 2402 mDetachedConvUri = savedState.getParcelable(SAVED_DETACHED_CONV_URI); 2403 if (savedState.containsKey(SAVED_CONVERSATION)) { 2404 // Open the conversation. 2405 final Conversation conversation = savedState.getParcelable(SAVED_CONVERSATION); 2406 restoreConversation(conversation); 2407 } 2408 2409 if (savedState.containsKey(SAVED_TOAST_BAR_OP)) { 2410 ToastBarOperation op = savedState.getParcelable(SAVED_TOAST_BAR_OP); 2411 if (op != null) { 2412 if (op.getType() == ToastBarOperation.UNDO) { 2413 onUndoAvailable(op); 2414 } else if (op.getType() == ToastBarOperation.ERROR) { 2415 onError(mFolder, true); 2416 } 2417 } 2418 } 2419 mFolderListFolder = savedState.getParcelable(SAVED_HIERARCHICAL_FOLDER); 2420 final ConversationListFragment convListFragment = getConversationListFragment(); 2421 if (convListFragment != null) { 2422 convListFragment.getAnimatedAdapter().onRestoreInstanceState(savedState); 2423 } 2424 /* 2425 * Restore the state of selected conversations. This needs to be done after the correct mode 2426 * is set and the action bar is fully initialized. If not, several key pieces of state 2427 * information will be missing, and the split views may not be initialized correctly. 2428 */ 2429 restoreSelectedConversations(savedState); 2430 // Order is important!!! 2431 // The dialog listener needs to happen *after* the selected set is restored. 2432 2433 // If there has been an orientation change, and we need to recreate the listener for the 2434 // confirm dialog fragment (delete/archive/...), then do it here. 2435 if (mDialogAction != -1) { 2436 makeDialogListener(mDialogAction, mDialogFromSelectedSet, 2437 getUndoCallbackForDestructiveActionsWithAutoAdvance( 2438 mDialogAction, mCurrentConversation)); 2439 } 2440 2441 mInbox = savedState.getParcelable(SAVED_INBOX_KEY); 2442 2443 mConversationListScrollPositions.clear(); 2444 mConversationListScrollPositions.putAll( 2445 savedState.getBundle(SAVED_CONVERSATION_LIST_SCROLL_POSITIONS)); 2446 } 2447 2448 /** 2449 * Handle an intent to open the app. This method is called only when there is no saved state, 2450 * so we need to set state that wasn't set before. It is correct to change the viewmode here 2451 * since it has not been previously set. 2452 * 2453 * This method is called for a subset of the reasons mentioned in 2454 * {@link #onCreate(android.os.Bundle)}. Notably, this is called when launching the app from 2455 * notifications, widgets, and shortcuts. 2456 * @param intent intent passed to the activity. 2457 */ 2458 private void handleIntent(Intent intent) { 2459 LogUtils.d(LOG_TAG, "IN AAC.handleIntent. action=%s", intent.getAction()); 2460 if (Intent.ACTION_VIEW.equals(intent.getAction())) { 2461 if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) { 2462 setAccount(Account.newInstance(intent.getStringExtra(Utils.EXTRA_ACCOUNT))); 2463 } 2464 if (mAccount == null) { 2465 return; 2466 } 2467 final boolean isConversationMode = intent.hasExtra(Utils.EXTRA_CONVERSATION); 2468 2469 if (intent.getBooleanExtra(Utils.EXTRA_FROM_NOTIFICATION, false)) { 2470 Analytics.getInstance().setEmail(mAccount.getEmailAddress(), mAccount.getType()); 2471 Analytics.getInstance().sendEvent("notification_click", 2472 isConversationMode ? "conversation" : "conversation_list", null, 0); 2473 } 2474 2475 if (isConversationMode && mViewMode.getMode() == ViewMode.UNKNOWN) { 2476 mViewMode.enterConversationMode(); 2477 } else { 2478 mViewMode.enterConversationListMode(); 2479 } 2480 // Put the folder and conversation, and ask the loader to create this folder. 2481 final Bundle args = new Bundle(); 2482 2483 final Uri folderUri; 2484 if (intent.hasExtra(Utils.EXTRA_FOLDER_URI)) { 2485 folderUri = intent.getParcelableExtra(Utils.EXTRA_FOLDER_URI); 2486 } else if (intent.hasExtra(Utils.EXTRA_FOLDER)) { 2487 final Folder folder = 2488 Folder.fromString(intent.getStringExtra(Utils.EXTRA_FOLDER)); 2489 folderUri = folder.folderUri.fullUri; 2490 } else { 2491 final Bundle extras = intent.getExtras(); 2492 LogUtils.d(LOG_TAG, "Couldn't find a folder URI in the extras: %s", 2493 extras == null ? "null" : extras.toString()); 2494 folderUri = mAccount.settings.defaultInbox; 2495 } 2496 2497 // Check if we should load all conversations instead of using 2498 // the default behavior which loads an initial subset. 2499 mIgnoreInitialConversationLimit = 2500 intent.getBooleanExtra(Utils.EXTRA_IGNORE_INITIAL_CONVERSATION_LIMIT, false); 2501 2502 args.putParcelable(Utils.EXTRA_FOLDER_URI, folderUri); 2503 args.putParcelable(Utils.EXTRA_CONVERSATION, 2504 intent.getParcelableExtra(Utils.EXTRA_CONVERSATION)); 2505 restartOptionalLoader(LOADER_FIRST_FOLDER, mFolderCallbacks, args); 2506 } else if (Intent.ACTION_SEARCH.equals(intent.getAction())) { 2507 if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) { 2508 mHaveSearchResults = false; 2509 // Save this search query for future suggestions 2510 final String query = intent.getStringExtra(SearchManager.QUERY); 2511 mSearchViewController.saveRecentQuery(query); 2512 setAccount((Account) intent.getParcelableExtra(Utils.EXTRA_ACCOUNT)); 2513 fetchSearchFolder(intent); 2514 if (shouldEnterSearchConvMode()) { 2515 mViewMode.enterSearchResultsConversationMode(); 2516 } else { 2517 mViewMode.enterSearchResultsListMode(); 2518 } 2519 } else { 2520 LogUtils.e(LOG_TAG, "Missing account extra from search intent. Finishing"); 2521 mActivity.finish(); 2522 } 2523 } 2524 if (mAccount != null) { 2525 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR, mAccountCallbacks, Bundle.EMPTY); 2526 } 2527 } 2528 2529 /** 2530 * Returns true if we should enter conversation mode with search. 2531 */ 2532 protected final boolean shouldEnterSearchConvMode() { 2533 return mHaveSearchResults && shouldShowFirstConversation(); 2534 } 2535 2536 /** 2537 * Copy any selected conversations stored in the saved bundle into our selection set, 2538 * triggering {@link ConversationSetObserver} callbacks as our selection set changes. 2539 * 2540 */ 2541 private void restoreSelectedConversations(Bundle savedState) { 2542 if (savedState == null) { 2543 mCheckedSet.clear(); 2544 return; 2545 } 2546 final ConversationCheckedSet selectedSet = savedState.getParcelable(SAVED_SELECTED_SET); 2547 if (selectedSet == null || selectedSet.isEmpty()) { 2548 mCheckedSet.clear(); 2549 return; 2550 } 2551 2552 // putAll will take care of calling our registered onSetPopulated method 2553 mCheckedSet.putAll(selectedSet); 2554 } 2555 2556 protected void restoreConversation(Conversation conversation) { 2557 if (conversation != null && conversation.position < 0) { 2558 // Set the position to 0 on this conversation, as we don't know where it is 2559 // in the list 2560 conversation.position = 0; 2561 } 2562 showConversation(conversation); 2563 } 2564 2565 /** 2566 * Show the conversation provided in the arguments. It is safe to pass a null conversation 2567 * object, which is a signal to back out of conversation view mode. 2568 * Child classes must call super.showConversation() <b>before</b> their own implementations. 2569 * @param conversation the conversation to be shown, or null if we want to back out to list 2570 * mode. 2571 * onLoadFinished(Loader, Cursor) on any callback. 2572 */ 2573 protected void showConversation(Conversation conversation) { 2574 showConversation(conversation, false /* shouldAnimate */); 2575 } 2576 2577 /** 2578 * Helper method to allow for conversation view animation control. Implementing classes should 2579 * directly override this to handle the animation. 2580 * @param conversation 2581 * @param shouldAnimate true if we want to animate the conversation in, false otherwise 2582 */ 2583 protected void showConversation(Conversation conversation, boolean shouldAnimate) { 2584 showConversationWithPeek(conversation, false /* peek */); 2585 } 2586 2587 protected void showConversationWithPeek(Conversation conversation, boolean peek) { 2588 if (conversation != null) { 2589 Utils.sConvLoadTimer.start(); 2590 } 2591 2592 MailLogService.log("AbstractActivityController", "showConversation(%s)", conversation); 2593 // Set the current conversation just in case it wasn't already set. 2594 setCurrentConversation(conversation); 2595 } 2596 2597 /** 2598 * Show the wait for account initialization mode. 2599 * Children can override this method, but they must call super.showWaitForInitialization(). 2600 */ 2601 protected void showWaitForInitialization() { 2602 mViewMode.enterWaitingForInitializationMode(); 2603 mWaitFragment = WaitFragment.newInstance(mAccount, true /* expectingMessages */); 2604 } 2605 2606 private void updateWaitMode() { 2607 final FragmentManager manager = mActivity.getFragmentManager(); 2608 final WaitFragment waitFragment = 2609 (WaitFragment)manager.findFragmentByTag(TAG_WAIT); 2610 if (waitFragment != null) { 2611 waitFragment.updateAccount(mAccount); 2612 } 2613 } 2614 2615 /** 2616 * Remove the "Waiting for Initialization" fragment. Child classes are free to override this 2617 * method, though they must call the parent implementation <b>after</b> they do anything. 2618 */ 2619 protected void hideWaitForInitialization() { 2620 mWaitFragment = null; 2621 } 2622 2623 /** 2624 * Use the instance variable and the wait fragment's tag to get the wait fragment. This is 2625 * far superior to using the value of mWaitFragment, which might be invalid or might refer 2626 * to a fragment after it has been destroyed. 2627 * @return a wait fragment that is already attached to the activity, if one exists 2628 */ 2629 protected final WaitFragment getWaitFragment() { 2630 final FragmentManager manager = mActivity.getFragmentManager(); 2631 final WaitFragment waitFrag = (WaitFragment) manager.findFragmentByTag(TAG_WAIT); 2632 if (waitFrag != null) { 2633 // The Fragment Manager knows better, so use its instance. 2634 mWaitFragment = waitFrag; 2635 } 2636 return mWaitFragment; 2637 } 2638 2639 /** 2640 * Returns true if we are waiting for the account to sync, and cannot show any folders or 2641 * conversation for the current account yet. 2642 */ 2643 private boolean inWaitMode() { 2644 final WaitFragment waitFragment = getWaitFragment(); 2645 if (waitFragment != null) { 2646 final Account fragmentAccount = waitFragment.getAccount(); 2647 return fragmentAccount != null && fragmentAccount.uri.equals(mAccount.uri) && 2648 mViewMode.getMode() == ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION; 2649 } 2650 return false; 2651 } 2652 2653 /** 2654 * Show the conversation List with the list context provided here. On certain layouts, this 2655 * might show more than just the conversation list. For instance, on tablets this might show 2656 * the conversations along with the conversation list. 2657 * @param listContext context providing information on what conversation list to display. 2658 */ 2659 protected abstract void showConversationList(ConversationListContext listContext); 2660 2661 @Override 2662 public void onConversationSelected(Conversation conversation, boolean inLoaderCallbacks) { 2663 final ConversationListFragment convListFragment = getConversationListFragment(); 2664 if (convListFragment != null && convListFragment.getAnimatedAdapter() != null) { 2665 convListFragment.getAnimatedAdapter().onConversationSelected(); 2666 } 2667 // Only animate destructive actions if we are going to be showing the 2668 // conversation list when we show the next conversation. 2669 commitDestructiveActions(mIsTablet); 2670 showConversation(conversation, true /* shouldAnimate */); 2671 } 2672 2673 @Override 2674 public final void onCabModeEntered() { 2675 final ConversationListFragment convListFragment = getConversationListFragment(); 2676 if (convListFragment != null && convListFragment.getAnimatedAdapter() != null) { 2677 convListFragment.getAnimatedAdapter().onCabModeEntered(); 2678 } 2679 } 2680 2681 @Override 2682 public final void onCabModeExited() { 2683 final ConversationListFragment convListFragment = getConversationListFragment(); 2684 if (convListFragment != null && convListFragment.getAnimatedAdapter() != null) { 2685 convListFragment.getAnimatedAdapter().onCabModeExited(); 2686 } 2687 } 2688 2689 @Override 2690 public Conversation getCurrentConversation() { 2691 return mCurrentConversation; 2692 } 2693 2694 /** 2695 * Set the current conversation. This is the conversation on which all actions are performed. 2696 * Do not modify mCurrentConversation except through this method, which makes it easy to 2697 * perform common actions associated with changing the current conversation. 2698 * @param conversation new conversation to view. Passing null indicates that we are backing 2699 * out to conversation list mode. 2700 */ 2701 @Override 2702 public void setCurrentConversation(Conversation conversation) { 2703 // The controller should come out of detached mode if a new conversation is viewed, or if 2704 // we are going back to conversation list mode. 2705 if (mDetachedConvUri != null && (conversation == null 2706 || !mDetachedConvUri.equals(conversation.uri))) { 2707 clearDetachedMode(); 2708 } 2709 2710 // Must happen *before* setting mCurrentConversation because this sets 2711 // conversation.position if a cursor is available. 2712 mTracker.initialize(conversation); 2713 mCurrentConversation = conversation; 2714 2715 if (mCurrentConversation != null) { 2716 mActionBarController.setCurrentConversation(mCurrentConversation); 2717 mActivity.invalidateOptionsMenu(); 2718 } 2719 } 2720 2721 /** 2722 * Invoked by {@link ConversationPagerAdapter} when a new page in the ViewPager is selected. 2723 * 2724 * @param conversation the conversation of the now currently visible fragment 2725 * 2726 */ 2727 @Override 2728 public void onConversationViewSwitched(Conversation conversation) { 2729 setCurrentConversation(conversation); 2730 } 2731 2732 @Override 2733 public boolean isCurrentConversationJustPeeking() { 2734 return false; 2735 } 2736 2737 /** 2738 * {@link LoaderManager} currently has a bug in 2739 * {@link LoaderManager#restartLoader(int, Bundle, android.app.LoaderManager.LoaderCallbacks)} 2740 * where, if a previous onCreateLoader returned a null loader, this method will NPE. Work around 2741 * this bug by destroying any loaders that may have been created as null (essentially because 2742 * they are optional loads, and may not apply to a particular account). 2743 * <p> 2744 * A simple null check before restarting a loader will not work, because that would not 2745 * give the controller a chance to invalidate UI corresponding the prior loader result. 2746 * 2747 * @param id loader ID to safely restart 2748 * @param handler the LoaderCallback which will handle this loader ID. 2749 * @param args arguments, if any, to be passed to the loader. Use {@link Bundle#EMPTY} if no 2750 * arguments need to be specified. 2751 */ 2752 private void restartOptionalLoader(int id, LoaderManager.LoaderCallbacks handler, Bundle args) { 2753 final LoaderManager lm = mActivity.getLoaderManager(); 2754 lm.destroyLoader(id); 2755 lm.restartLoader(id, args, handler); 2756 } 2757 2758 @Override 2759 public void registerConversationListObserver(DataSetObserver observer) { 2760 mConversationListObservable.registerObserver(observer); 2761 } 2762 2763 @Override 2764 public void unregisterConversationListObserver(DataSetObserver observer) { 2765 try { 2766 mConversationListObservable.unregisterObserver(observer); 2767 } catch (IllegalStateException e) { 2768 // Log instead of crash 2769 LogUtils.e(LOG_TAG, e, "unregisterConversationListObserver called for an observer that " 2770 + "hasn't been registered"); 2771 } 2772 } 2773 2774 @Override 2775 public void registerFolderObserver(DataSetObserver observer) { 2776 mFolderObservable.registerObserver(observer); 2777 } 2778 2779 @Override 2780 public void unregisterFolderObserver(DataSetObserver observer) { 2781 try { 2782 mFolderObservable.unregisterObserver(observer); 2783 } catch (IllegalStateException e) { 2784 // Log instead of crash 2785 LogUtils.e(LOG_TAG, e, "unregisterFolderObserver called for an observer that " 2786 + "hasn't been registered"); 2787 } 2788 } 2789 2790 @Override 2791 public void registerConversationLoadedObserver(DataSetObserver observer) { 2792 mPagerController.registerConversationLoadedObserver(observer); 2793 } 2794 2795 @Override 2796 public void unregisterConversationLoadedObserver(DataSetObserver observer) { 2797 try { 2798 mPagerController.unregisterConversationLoadedObserver(observer); 2799 } catch (IllegalStateException e) { 2800 // Log instead of crash 2801 LogUtils.e(LOG_TAG, e, "unregisterConversationLoadedObserver called for an observer " 2802 + "that hasn't been registered"); 2803 } 2804 } 2805 2806 /** 2807 * Returns true if the number of accounts is different, or if the current account has 2808 * changed. This method is meant to filter frequent changes to the list of 2809 * accounts, and only return true if the new list is substantially different from the existing 2810 * list. Returning true is safe here, it leads to more work in creating the 2811 * same account list again. 2812 * @param accountCursor the cursor which points to all the accounts. 2813 * @return true if the number of accounts is changed or current account missing from the list. 2814 */ 2815 private boolean accountsUpdated(ObjectCursor<Account> accountCursor) { 2816 // Check to see if the current account hasn't been set, or the account cursor is empty 2817 if (mAccount == null || !accountCursor.moveToFirst()) { 2818 return true; 2819 } 2820 2821 // Check to see if the number of accounts are different, from the number we saw on the last 2822 // updated 2823 if (mCurrentAccountUris.size() != accountCursor.getCount()) { 2824 return true; 2825 } 2826 2827 // Check to see if the account list is different or if the current account is not found in 2828 // the cursor. 2829 boolean foundCurrentAccount = false; 2830 do { 2831 final Account account = accountCursor.getModel(); 2832 if (!foundCurrentAccount && mAccount.uri.equals(account.uri)) { 2833 if (mAccount.settingsDiffer(account)) { 2834 // Settings changed, and we don't need to look any further. 2835 return true; 2836 } 2837 foundCurrentAccount = true; 2838 } 2839 // Is there a new account that we do not know about? 2840 if (!mCurrentAccountUris.contains(account.uri)) { 2841 return true; 2842 } 2843 } while (accountCursor.moveToNext()); 2844 2845 // As long as we found the current account, the list hasn't been updated 2846 return !foundCurrentAccount; 2847 } 2848 2849 /** 2850 * Updates accounts for the app. If the current account is missing, the first 2851 * account in the list is set to the current account (we <em>have</em> to choose something). 2852 * 2853 * @param accounts cursor into the AccountCache 2854 * @return true if the update was successful, false otherwise 2855 */ 2856 private boolean updateAccounts(ObjectCursor<Account> accounts) { 2857 if (accounts == null || !accounts.moveToFirst()) { 2858 return false; 2859 } 2860 2861 final Account[] allAccounts = Account.getAllAccounts(accounts); 2862 // A match for the current account's URI in the list of accounts. 2863 Account currentFromList = null; 2864 2865 // Save the uris for the accounts and find the current account in the updated cursor. 2866 mCurrentAccountUris.clear(); 2867 for (final Account account : allAccounts) { 2868 LogUtils.d(LOG_TAG, "updateAccounts(%s)", account); 2869 mCurrentAccountUris.add(account.uri); 2870 if (mAccount != null && account.uri.equals(mAccount.uri)) { 2871 currentFromList = account; 2872 } 2873 } 2874 2875 // 1. current account is already set and is in allAccounts: 2876 // 1a. It has changed -> load the updated account. 2877 // 1b. It is unchanged -> no-op 2878 // 2. current account is set and is not in allAccounts -> pick first (acct was deleted?) 2879 // 3. saved preference has an account -> pick that one 2880 // 4. otherwise just pick first 2881 2882 boolean accountChanged = false; 2883 /// Assume case 4, initialize to first account, and see if we can find anything better. 2884 Account newAccount = allAccounts[0]; 2885 if (currentFromList != null) { 2886 // Case 1: Current account exists but has changed 2887 if (!currentFromList.equals(mAccount)) { 2888 newAccount = currentFromList; 2889 accountChanged = true; 2890 } 2891 // Case 1b: else, current account is unchanged: nothing to do. 2892 } else { 2893 // Case 2: Current account is not in allAccounts, the account needs to change. 2894 accountChanged = true; 2895 if (mAccount == null) { 2896 // Case 3: Check for last viewed account, and check if it exists in the list. 2897 final String lastAccountUri = MailAppProvider.getInstance().getLastViewedAccount(); 2898 if (lastAccountUri != null) { 2899 for (final Account account : allAccounts) { 2900 if (lastAccountUri.equals(account.uri.toString())) { 2901 newAccount = account; 2902 break; 2903 } 2904 } 2905 } 2906 } 2907 } 2908 if (accountChanged) { 2909 changeAccount(newAccount); 2910 } 2911 2912 // Whether we have updated the current account or not, we need to update the list of 2913 // accounts in the ActionBar. 2914 mAllAccounts = allAccounts; 2915 mAllAccountObservers.notifyChanged(); 2916 return (allAccounts.length > 0); 2917 } 2918 2919 private void disableNotifications() { 2920 mNewEmailReceiver.activate(mContext, this); 2921 } 2922 2923 private void enableNotifications() { 2924 mNewEmailReceiver.deactivate(); 2925 } 2926 2927 private void disableNotificationsOnAccountChange(Account account) { 2928 // If the new mail suppression receiver is activated for a different account, we want to 2929 // activate it for the new account. 2930 if (mNewEmailReceiver.activated() && 2931 !mNewEmailReceiver.notificationsDisabledForAccount(account)) { 2932 // Deactivate the current receiver, otherwise multiple receivers may be registered. 2933 mNewEmailReceiver.deactivate(); 2934 mNewEmailReceiver.activate(mContext, this); 2935 } 2936 } 2937 2938 /** 2939 * Destructive actions on Conversations. This class should only be created by controllers, and 2940 * clients should only require {@link DestructiveAction}s, not specific implementations of the. 2941 * Only the controllers should know what kind of destructive actions are being created. 2942 */ 2943 public class ConversationAction implements DestructiveAction { 2944 /** 2945 * The action to be performed. This is specified as the resource ID of the menu item 2946 * corresponding to this action: R.id.delete, R.id.report_spam, etc. 2947 */ 2948 private final int mAction; 2949 /** The action will act upon these conversations */ 2950 private final Collection<Conversation> mTarget; 2951 /** Whether this destructive action has already been performed */ 2952 private boolean mCompleted; 2953 /** Whether this is an action on the currently selected set. */ 2954 private final boolean mIsSelectedSet; 2955 2956 private UndoCallback mCallback; 2957 2958 /** 2959 * Create a listener object. 2960 * @param action action is one of four constants: R.id.y_button (archive), 2961 * R.id.delete , R.id.mute, and R.id.report_spam. 2962 * @param target Conversation that we want to apply the action to. 2963 * @param isBatch whether the conversations are in the currently selected batch set. 2964 */ 2965 public ConversationAction(int action, Collection<Conversation> target, boolean isBatch) { 2966 mAction = action; 2967 mTarget = ImmutableList.copyOf(target); 2968 mIsSelectedSet = isBatch; 2969 } 2970 2971 @Override 2972 public void setUndoCallback(UndoCallback undoCallback) { 2973 mCallback = undoCallback; 2974 } 2975 2976 /** 2977 * The action common to child classes. This performs the action specified in the constructor 2978 * on the conversations given here. 2979 */ 2980 @Override 2981 public void performAction() { 2982 if (isPerformed()) { 2983 return; 2984 } 2985 boolean undoEnabled = mAccount.supportsCapability(AccountCapabilities.UNDO); 2986 2987 // Are we destroying the currently shown conversation? Show the next one. 2988 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)){ 2989 LogUtils.d(LOG_TAG, "ConversationAction.performAction():" 2990 + "\nmTarget=%s\nCurrent=%s", 2991 Conversation.toString(mTarget), mCurrentConversation); 2992 } 2993 2994 if (mConversationListCursor == null) { 2995 LogUtils.e(LOG_TAG, "null ConversationCursor in ConversationAction.performAction():" 2996 + "\nmTarget=%s\nCurrent=%s", 2997 Conversation.toString(mTarget), mCurrentConversation); 2998 return; 2999 } 3000 3001 if (mAction == R.id.archive) { 3002 LogUtils.d(LOG_TAG, "Archiving"); 3003 mConversationListCursor.archive(mTarget, mCallback); 3004 } else if (mAction == R.id.delete) { 3005 LogUtils.d(LOG_TAG, "Deleting"); 3006 mConversationListCursor.delete(mTarget, mCallback); 3007 if (mFolder.supportsCapability(FolderCapabilities.DELETE_ACTION_FINAL)) { 3008 undoEnabled = false; 3009 } 3010 } else if (mAction == R.id.mute) { 3011 LogUtils.d(LOG_TAG, "Muting"); 3012 if (mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)) { 3013 for (Conversation c : mTarget) { 3014 c.localDeleteOnUpdate = true; 3015 } 3016 } 3017 mConversationListCursor.mute(mTarget, mCallback); 3018 } else if (mAction == R.id.report_spam) { 3019 LogUtils.d(LOG_TAG, "Reporting spam"); 3020 mConversationListCursor.reportSpam(mTarget, mCallback); 3021 } else if (mAction == R.id.mark_not_spam) { 3022 LogUtils.d(LOG_TAG, "Marking not spam"); 3023 mConversationListCursor.reportNotSpam(mTarget, mCallback); 3024 } else if (mAction == R.id.report_phishing) { 3025 LogUtils.d(LOG_TAG, "Reporting phishing"); 3026 mConversationListCursor.reportPhishing(mTarget, mCallback); 3027 } else if (mAction == R.id.remove_star) { 3028 LogUtils.d(LOG_TAG, "Removing star"); 3029 // Star removal is destructive in the Starred folder. 3030 mConversationListCursor.updateBoolean(mTarget, ConversationColumns.STARRED, 3031 false); 3032 } else if (mAction == R.id.mark_not_important) { 3033 LogUtils.d(LOG_TAG, "Marking not-important"); 3034 // Marking not important is destructive in a mailbox 3035 // containing only important messages 3036 if (mFolder != null && mFolder.isImportantOnly()) { 3037 for (Conversation conv : mTarget) { 3038 conv.localDeleteOnUpdate = true; 3039 } 3040 } 3041 mConversationListCursor.updateInt(mTarget, ConversationColumns.PRIORITY, 3042 UIProvider.ConversationPriority.LOW); 3043 } else if (mAction == R.id.discard_drafts) { 3044 LogUtils.d(LOG_TAG, "Discarding draft messages"); 3045 // Discarding draft messages is destructive in a "draft" mailbox 3046 if (mFolder != null && mFolder.isDraft()) { 3047 for (Conversation conv : mTarget) { 3048 conv.localDeleteOnUpdate = true; 3049 } 3050 } 3051 mConversationListCursor.discardDrafts(mTarget); 3052 // We don't support undoing discarding drafts 3053 undoEnabled = false; 3054 } else if (mAction == R.id.discard_outbox) { 3055 LogUtils.d(LOG_TAG, "Discarding failed messages in Outbox"); 3056 mConversationListCursor.moveFailedIntoDrafts(mTarget); 3057 undoEnabled = false; 3058 } 3059 if (undoEnabled && mTarget.size() > 0) { 3060 mHandler.postDelayed(new Runnable() { 3061 @Override 3062 public void run() { 3063 onUndoAvailable(new ToastBarOperation(mTarget.size(), mAction, 3064 ToastBarOperation.UNDO, mIsSelectedSet, mFolder)); 3065 } 3066 }, mShowUndoBarDelay); 3067 } 3068 refreshConversationList(); 3069 if (mIsSelectedSet) { 3070 mCheckedSet.clear(); 3071 } 3072 } 3073 3074 /** 3075 * Returns true if this action has been performed, false otherwise. 3076 * 3077 */ 3078 private synchronized boolean isPerformed() { 3079 if (mCompleted) { 3080 return true; 3081 } 3082 mCompleted = true; 3083 return false; 3084 } 3085 } 3086 3087 // Called from the FolderSelectionDialog after a user is done selecting folders to assign the 3088 // conversations to. 3089 @Override 3090 public final void assignFolder(Collection<FolderOperation> folderOps, 3091 Collection<Conversation> target, boolean batch, boolean showUndo, 3092 final boolean isMoveTo) { 3093 // Actions are destructive only when the current folder can be un-assigned from and 3094 // when the list of folders contains the current folder. 3095 final boolean isDestructive = mFolder 3096 .supportsCapability(FolderCapabilities.ALLOWS_REMOVE_CONVERSATION) 3097 && FolderOperation.isDestructive(folderOps, mFolder); 3098 LogUtils.d(LOG_TAG, "onFolderChangesCommit: isDestructive = %b", isDestructive); 3099 if (isDestructive) { 3100 for (final Conversation c : target) { 3101 c.localDeleteOnUpdate = true; 3102 } 3103 } 3104 final DestructiveAction folderChange; 3105 final UndoCallback undoCallback = isMoveTo ? 3106 getUndoCallbackForDestructiveActionsWithAutoAdvance(R.id.move_to, 3107 mCurrentConversation) 3108 : null; 3109 // Update the UI elements depending no their visibility and availability 3110 // TODO(viki): Consolidate this into a single method requestDelete. 3111 if (isDestructive) { 3112 /* 3113 * If this is a MOVE operation, we want the action folder to be the destination folder. 3114 * Otherwise, we want it to be the current folder. 3115 * 3116 * A set of folder operations is a move if there are exactly two operations: an add and 3117 * a remove. 3118 */ 3119 final Folder actionFolder; 3120 if (folderOps.size() != 2) { 3121 actionFolder = mFolder; 3122 } else { 3123 Folder addedFolder = null; 3124 boolean hasRemove = false; 3125 for (final FolderOperation folderOperation : folderOps) { 3126 if (folderOperation.mAdd) { 3127 addedFolder = folderOperation.mFolder; 3128 } else { 3129 hasRemove = true; 3130 } 3131 } 3132 3133 if (hasRemove && addedFolder != null) { 3134 actionFolder = addedFolder; 3135 } else { 3136 actionFolder = mFolder; 3137 } 3138 } 3139 3140 folderChange = getDeferredFolderChange(target, folderOps, isDestructive, 3141 batch, showUndo, isMoveTo, actionFolder, undoCallback); 3142 delete(0, target, folderChange, batch); 3143 } else { 3144 folderChange = getFolderChange(target, folderOps, isDestructive, 3145 batch, showUndo, false /* isMoveTo */, mFolder, undoCallback); 3146 requestUpdate(folderChange); 3147 } 3148 } 3149 3150 @Override 3151 public final void onRefreshRequired() { 3152 if (isAnimating()) { 3153 final ConversationListFragment f = getConversationListFragment(); 3154 LogUtils.w(ConversationCursor.LOG_TAG, 3155 "onRefreshRequired: delay until animating done. cursor=%s adapter=%s", 3156 mConversationListCursor, (f != null) ? f.getAnimatedAdapter() : null); 3157 return; 3158 } 3159 // Refresh the query in the background 3160 if (mConversationListCursor.isRefreshRequired()) { 3161 mConversationListCursor.refresh(); 3162 } 3163 } 3164 3165 @Override 3166 public boolean isAnimating() { 3167 boolean isAnimating = false; 3168 ConversationListFragment convListFragment = getConversationListFragment(); 3169 if (convListFragment != null) { 3170 isAnimating = convListFragment.isAnimating(); 3171 } 3172 return isAnimating; 3173 } 3174 3175 /** 3176 * Called when the {@link ConversationCursor} is changed or has new data in it. 3177 * <p> 3178 * {@inheritDoc} 3179 */ 3180 @Override 3181 public final void onRefreshReady() { 3182 LogUtils.d(LOG_TAG, "Received refresh ready callback for folder %s", 3183 mFolder != null ? mFolder.id : "-1"); 3184 3185 if (mDestroyed) { 3186 LogUtils.i(LOG_TAG, "ignoring onRefreshReady on destroyed AAC"); 3187 return; 3188 } 3189 3190 if (!isAnimating()) { 3191 // Swap cursors 3192 mConversationListCursor.sync(); 3193 } else { 3194 // (CLF guaranteed to be non-null due to check in isAnimating) 3195 LogUtils.w(LOG_TAG, 3196 "AAC.onRefreshReady suppressing sync() due to animation. cursor=%s aa=%s", 3197 mConversationListCursor, getConversationListFragment().getAnimatedAdapter()); 3198 } 3199 mTracker.onCursorUpdated(); 3200 perhapsShowFirstConversation(); 3201 } 3202 3203 @Override 3204 public final void onDataSetChanged() { 3205 updateConversationListFragment(); 3206 mConversationListObservable.notifyChanged(); 3207 mCheckedSet.validateAgainstCursor(mConversationListCursor); 3208 } 3209 3210 /** 3211 * If the Conversation List Fragment is visible, updates the fragment. 3212 */ 3213 private void updateConversationListFragment() { 3214 final ConversationListFragment convList = getConversationListFragment(); 3215 if (convList != null) { 3216 refreshConversationList(); 3217 if (isFragmentVisible(convList)) { 3218 informCursorVisiblity(true); 3219 } 3220 } 3221 } 3222 3223 /** 3224 * This class handles throttled refresh of the conversation list 3225 */ 3226 static class RefreshTimerTask extends TimerTask { 3227 final Handler mHandler; 3228 final AbstractActivityController mController; 3229 3230 RefreshTimerTask(AbstractActivityController controller, Handler handler) { 3231 mHandler = handler; 3232 mController = controller; 3233 } 3234 3235 @Override 3236 public void run() { 3237 mHandler.post(new Runnable() { 3238 @Override 3239 public void run() { 3240 LogUtils.d(LOG_TAG, "Delay done... calling onRefreshRequired"); 3241 mController.onRefreshRequired(); 3242 }}); 3243 } 3244 } 3245 3246 /** 3247 * Cancel the refresh task, if it's running 3248 */ 3249 private void cancelRefreshTask () { 3250 if (mConversationListRefreshTask != null) { 3251 mConversationListRefreshTask.cancel(); 3252 mConversationListRefreshTask = null; 3253 } 3254 } 3255 3256 @Override 3257 public void onAnimationEnd(AnimatedAdapter animatedAdapter) { 3258 if (animatedAdapter != null) { 3259 LogUtils.i(LOG_TAG, "AAC.onAnimationEnd. cursor=%s adapter=%s", mConversationListCursor, 3260 animatedAdapter); 3261 } 3262 if (mConversationListCursor == null) { 3263 LogUtils.e(LOG_TAG, "null ConversationCursor in onAnimationEnd"); 3264 return; 3265 } 3266 if (mConversationListCursor.isRefreshReady()) { 3267 LogUtils.i(ConversationCursor.LOG_TAG, "Stopped animating: try sync"); 3268 onRefreshReady(); 3269 } 3270 3271 if (mConversationListCursor.isRefreshRequired()) { 3272 LogUtils.i(ConversationCursor.LOG_TAG, "Stopped animating: refresh"); 3273 mConversationListCursor.refresh(); 3274 } 3275 if (mRecentsDataUpdated) { 3276 mRecentsDataUpdated = false; 3277 mRecentFolderObservers.notifyChanged(); 3278 } 3279 } 3280 3281 @Override 3282 public void onSetEmpty() { 3283 // There are no selected conversations. Ensure that the listener and its associated actions 3284 // are blanked out. 3285 setListener(null, -1); 3286 } 3287 3288 @Override 3289 public void onSetPopulated(ConversationCheckedSet set) { 3290 mCabActionMenu = new SelectedConversationsActionMenu(mActivity, set, mFolder); 3291 if (mViewMode.isListMode() || (mIsTablet && mViewMode.isConversationMode())) { 3292 enableCabMode(); 3293 } 3294 } 3295 3296 @Override 3297 public void onSetChanged(ConversationCheckedSet set) { 3298 // Do nothing. We don't care about changes to the set. 3299 } 3300 3301 @Override 3302 public ConversationCheckedSet getCheckedSet() { 3303 return mCheckedSet; 3304 } 3305 3306 /** 3307 * Disable the Contextual Action Bar (CAB). The selected set is not changed. 3308 */ 3309 protected void disableCabMode() { 3310 // Commit any previous destructive actions when entering/ exiting CAB mode. 3311 commitDestructiveActions(true); 3312 if (mCabActionMenu != null) { 3313 mCabActionMenu.deactivate(); 3314 } 3315 } 3316 3317 /** 3318 * Re-enable the CAB menu if required. The selection set is not changed. 3319 */ 3320 protected void enableCabMode() { 3321 if (mCabActionMenu != null && 3322 !(isDrawerEnabled() && mDrawerContainer.isDrawerOpen(mDrawerPullout))) { 3323 mCabActionMenu.activate(); 3324 } 3325 } 3326 3327 /** 3328 * Re-enable CAB mode only if we have an active selection 3329 */ 3330 protected void maybeEnableCabMode() { 3331 if (!mCheckedSet.isEmpty()) { 3332 if (mCabActionMenu != null) { 3333 mCabActionMenu.activate(); 3334 } 3335 } 3336 } 3337 3338 /** 3339 * Unselect conversations and exit CAB mode. 3340 */ 3341 protected final void exitCabMode() { 3342 mCheckedSet.clear(); 3343 } 3344 3345 @Override 3346 public void startSearch() { 3347 if (mAccount == null) { 3348 // We cannot search if there is no account. Drop the request to the floor. 3349 LogUtils.d(LOG_TAG, "AbstractActivityController.startSearch(): null account"); 3350 return; 3351 } 3352 if (mAccount.supportsSearch()) { 3353 mSearchViewController.showSearchActionBar( 3354 MaterialSearchViewController.SEARCH_VIEW_STATE_VISIBLE); 3355 } else { 3356 Toast.makeText(mActivity.getActivityContext(), mActivity.getActivityContext() 3357 .getString(R.string.search_unsupported), Toast.LENGTH_SHORT).show(); 3358 } 3359 } 3360 3361 @Override 3362 public void onTouchEvent(MotionEvent event) { 3363 if (event.getAction() == MotionEvent.ACTION_DOWN) { 3364 if (mToastBar != null && !mToastBar.isEventInToastBar(event)) { 3365 // if the toast bar is still animating, ignore this attempt to hide it 3366 if (mToastBar.isAnimating()) { 3367 return; 3368 } 3369 3370 // if the toast bar has not been seen long enough, ignore this attempt to hide it 3371 if (mToastBar.cannotBeHidden()) { 3372 return; 3373 } 3374 3375 // hide the toast bar 3376 mToastBar.hide(true /* animated */, false /* actionClicked */); 3377 } 3378 } 3379 } 3380 3381 @Override 3382 public void onConversationSeen() { 3383 mPagerController.onConversationSeen(); 3384 } 3385 3386 @Override 3387 public boolean isInitialConversationLoading() { 3388 return mPagerController.isInitialConversationLoading(); 3389 } 3390 3391 /** 3392 * Check if the fragment given here is visible. Checking {@link Fragment#isVisible()} is 3393 * insufficient because that doesn't check if the window is currently in focus or not. 3394 */ 3395 private boolean isFragmentVisible(Fragment in) { 3396 return in != null && in.isVisible() && mActivity.hasWindowFocus(); 3397 } 3398 3399 /** 3400 * This class handles callbacks that create a {@link ConversationCursor}. 3401 */ 3402 private class ConversationListLoaderCallbacks implements 3403 LoaderManager.LoaderCallbacks<ConversationCursor> { 3404 3405 @Override 3406 public Loader<ConversationCursor> onCreateLoader(int id, Bundle args) { 3407 final Account account = args.getParcelable(BUNDLE_ACCOUNT_KEY); 3408 final Folder folder = args.getParcelable(BUNDLE_FOLDER_KEY); 3409 final boolean ignoreInitialConversationLimit = 3410 args.getBoolean(BUNDLE_IGNORE_INITIAL_CONVERSATION_LIMIT_KEY, false); 3411 if (account == null || folder == null) { 3412 return null; 3413 } 3414 return new ConversationCursorLoader(mActivity, account, 3415 folder.conversationListUri, folder.getTypeDescription(), 3416 ignoreInitialConversationLimit); 3417 } 3418 3419 @Override 3420 public void onLoadFinished(Loader<ConversationCursor> loader, ConversationCursor data) { 3421 LogUtils.d(LOG_TAG, 3422 "IN AAC.ConversationCursor.onLoadFinished, data=%s loader=%s this=%s", 3423 data, loader, this); 3424 if (isDestroyed()) { 3425 return; 3426 } 3427 if (isDrawerEnabled() && mDrawerListener.getDrawerState() != DrawerLayout.STATE_IDLE) { 3428 LogUtils.d(LOG_TAG, "ConversationListLoaderCallbacks.onLoadFinished: ignoring."); 3429 mConversationListLoadFinishedIgnored = true; 3430 return; 3431 } 3432 // Clear our all pending destructive actions before swapping the conversation cursor 3433 destroyPending(null); 3434 mConversationListCursor = data; 3435 mConversationListCursor.addListener(AbstractActivityController.this); 3436 mDrawIdler.setListener(mConversationListCursor); 3437 mTracker.onCursorUpdated(); 3438 mConversationListObservable.notifyChanged(); 3439 // Handle actions that were deferred until after the conversation list was loaded. 3440 for (LoadFinishedCallback callback : mConversationListLoadFinishedCallbacks) { 3441 callback.onLoadFinished(); 3442 } 3443 mConversationListLoadFinishedCallbacks.clear(); 3444 3445 final ConversationListFragment convList = getConversationListFragment(); 3446 if (isFragmentVisible(convList)) { 3447 // The conversation list is already listening to list changes and gets notified 3448 // in the mConversationListObservable.notifyChanged() line above. We only need to 3449 // check and inform the cursor of the change in visibility here. 3450 informCursorVisiblity(true); 3451 } 3452 perhapsShowFirstConversation(); 3453 } 3454 3455 @Override 3456 public void onLoaderReset(Loader<ConversationCursor> loader) { 3457 LogUtils.d(LOG_TAG, 3458 "IN AAC.ConversationCursor.onLoaderReset, data=%s loader=%s this=%s", 3459 mConversationListCursor, loader, this); 3460 3461 if (mConversationListCursor != null) { 3462 // Unregister the listener 3463 mConversationListCursor.removeListener(AbstractActivityController.this); 3464 mDrawIdler.setListener(null); 3465 mConversationListCursor = null; 3466 3467 // Inform anyone who is interested about the change 3468 mTracker.onCursorUpdated(); 3469 mConversationListObservable.notifyChanged(); 3470 } 3471 } 3472 } 3473 3474 /** 3475 * Class to perform {@link LoaderManager.LoaderCallbacks} for creating {@link Folder} objects. 3476 */ 3477 private class FolderLoads implements LoaderManager.LoaderCallbacks<ObjectCursor<Folder>> { 3478 @Override 3479 public Loader<ObjectCursor<Folder>> onCreateLoader(int id, Bundle args) { 3480 final String[] everything = UIProvider.FOLDERS_PROJECTION; 3481 switch (id) { 3482 case LOADER_FOLDER_CURSOR: 3483 LogUtils.d(LOG_TAG, "LOADER_FOLDER_CURSOR created"); 3484 final ObjectCursorLoader<Folder> loader = new 3485 ObjectCursorLoader<Folder>( 3486 mContext, mFolder.folderUri.fullUri, everything, Folder.FACTORY); 3487 loader.setUpdateThrottle(mFolderItemUpdateDelayMs); 3488 return loader; 3489 case LOADER_RECENT_FOLDERS: 3490 LogUtils.d(LOG_TAG, "LOADER_RECENT_FOLDERS created"); 3491 if (mAccount != null && mAccount.recentFolderListUri != null 3492 && !mAccount.recentFolderListUri.equals(Uri.EMPTY)) { 3493 return new ObjectCursorLoader<Folder>(mContext, 3494 mAccount.recentFolderListUri, everything, Folder.FACTORY); 3495 } 3496 break; 3497 case LOADER_ACCOUNT_INBOX: 3498 LogUtils.d(LOG_TAG, "LOADER_ACCOUNT_INBOX created"); 3499 final Uri defaultInbox = Settings.getDefaultInboxUri(mAccount.settings); 3500 final Uri inboxUri = defaultInbox.equals(Uri.EMPTY) ? 3501 mAccount.folderListUri : defaultInbox; 3502 LogUtils.d(LOG_TAG, "Loading the default inbox: %s", inboxUri); 3503 if (inboxUri != null) { 3504 return new ObjectCursorLoader<Folder>(mContext, inboxUri, 3505 everything, Folder.FACTORY); 3506 } 3507 break; 3508 case LOADER_SEARCH: 3509 LogUtils.d(LOG_TAG, "LOADER_SEARCH created"); 3510 return Folder.forSearchResults(mAccount, 3511 args.getString(ConversationListContext.EXTRA_SEARCH_QUERY), 3512 // We can just use current time as a unique identifier for this search 3513 Long.toString(SystemClock.uptimeMillis()), 3514 mActivity.getActivityContext()); 3515 case LOADER_FIRST_FOLDER: 3516 LogUtils.d(LOG_TAG, "LOADER_FIRST_FOLDER created"); 3517 final Uri folderUri = args.getParcelable(Utils.EXTRA_FOLDER_URI); 3518 mConversationToShow = args.getParcelable(Utils.EXTRA_CONVERSATION); 3519 if (mConversationToShow != null && mConversationToShow.position < 0){ 3520 mConversationToShow.position = 0; 3521 } 3522 return new ObjectCursorLoader<Folder>(mContext, folderUri, 3523 everything, Folder.FACTORY); 3524 default: 3525 LogUtils.wtf(LOG_TAG, "FolderLoads.onCreateLoader(%d) for invalid id", id); 3526 return null; 3527 } 3528 return null; 3529 } 3530 3531 @Override 3532 public void onLoadFinished(Loader<ObjectCursor<Folder>> loader, ObjectCursor<Folder> data) { 3533 if (data == null) { 3534 LogUtils.e(LOG_TAG, "Received null cursor from loader id: %d", loader.getId()); 3535 } 3536 if (isDestroyed()) { 3537 return; 3538 } 3539 switch (loader.getId()) { 3540 case LOADER_FOLDER_CURSOR: 3541 if (data != null && data.moveToFirst()) { 3542 final Folder folder = data.getModel(); 3543 setHasFolderChanged(folder); 3544 mFolder = folder; 3545 mFolderObservable.notifyChanged(); 3546 } else { 3547 LogUtils.d(LOG_TAG, "Unable to get the folder %s", 3548 mFolder != null ? mFolder.name : ""); 3549 } 3550 break; 3551 case LOADER_RECENT_FOLDERS: 3552 // Few recent folders and we are running on a phone? Populate the default 3553 // recents. The number of default recent folders is at least 2: every provider 3554 // has at least two folders, and the recent folder count never decreases. 3555 // Having a single recent folder is an erroneous case, and we can gracefully 3556 // recover by populating default recents. The default recents will not stomp on 3557 // the existing value: it will be shown in addition to the default folders: 3558 // the max number of recent folders is more than 1+num(defaultRecents). 3559 if (data != null && data.getCount() <= 1 && !mIsTablet) { 3560 final class PopulateDefault extends AsyncTask<Uri, Void, Void> { 3561 @Override 3562 protected Void doInBackground(Uri... uri) { 3563 // Asking for an update on the URI and ignore the result. 3564 final ContentResolver resolver = mContext.getContentResolver(); 3565 resolver.update(uri[0], null, null, null); 3566 return null; 3567 } 3568 } 3569 final Uri uri = mAccount.defaultRecentFolderListUri; 3570 LogUtils.v(LOG_TAG, "Default recents at %s", uri); 3571 new PopulateDefault().execute(uri); 3572 break; 3573 } 3574 LogUtils.v(LOG_TAG, "Reading recent folders from the cursor."); 3575 mRecentFolderList.loadFromUiProvider(data); 3576 if (isAnimating()) { 3577 mRecentsDataUpdated = true; 3578 } else { 3579 mRecentFolderObservers.notifyChanged(); 3580 } 3581 break; 3582 case LOADER_ACCOUNT_INBOX: 3583 if (data != null && !data.isClosed() && data.moveToFirst()) { 3584 final Folder inbox = data.getModel(); 3585 onFolderChanged(inbox, false /* force */); 3586 // Just want to get the inbox, don't care about updates to it 3587 // as this will be tracked by the folder change listener. 3588 mActivity.getLoaderManager().destroyLoader(LOADER_ACCOUNT_INBOX); 3589 } else { 3590 LogUtils.d(LOG_TAG, "Unable to get the account inbox for account %s", 3591 mAccount != null ? mAccount.getEmailAddress() : ""); 3592 } 3593 break; 3594 case LOADER_SEARCH: 3595 if (data != null && data.getCount() > 0) { 3596 data.moveToFirst(); 3597 final Folder search = data.getModel(); 3598 updateFolder(search); 3599 mConvListContext = ConversationListContext.forSearchQuery(mAccount, mFolder, 3600 mActivity.getIntent() 3601 .getStringExtra(UIProvider.SearchQueryParameters.QUERY)); 3602 showConversationList(mConvListContext); 3603 mActivity.invalidateOptionsMenu(); 3604 mHaveSearchResults = search.totalCount > 0; 3605 mActivity.getLoaderManager().destroyLoader(LOADER_SEARCH); 3606 } else { 3607 LogUtils.e(LOG_TAG, "Null/empty cursor returned by LOADER_SEARCH loader"); 3608 } 3609 break; 3610 case LOADER_FIRST_FOLDER: 3611 if (data == null || data.getCount() <=0 || !data.moveToFirst()) { 3612 return; 3613 } 3614 final Folder folder = data.getModel(); 3615 boolean handled = false; 3616 if (folder != null) { 3617 onFolderChanged(folder, false /* force */); 3618 handled = true; 3619 } 3620 if (mConversationToShow != null) { 3621 // Open the conversation. 3622 showConversation(mConversationToShow); 3623 handled = true; 3624 } 3625 if (!handled) { 3626 // We have an account, but nothing else: load the default inbox. 3627 loadAccountInbox(); 3628 } 3629 mConversationToShow = null; 3630 // And don't run this anymore. 3631 mActivity.getLoaderManager().destroyLoader(LOADER_FIRST_FOLDER); 3632 break; 3633 } 3634 } 3635 3636 @Override 3637 public void onLoaderReset(Loader<ObjectCursor<Folder>> loader) { 3638 } 3639 } 3640 3641 /** 3642 * Class to perform {@link LoaderManager.LoaderCallbacks} for creating {@link Account} objects. 3643 */ 3644 private class AccountLoads implements LoaderManager.LoaderCallbacks<ObjectCursor<Account>> { 3645 final String[] mProjection = UIProvider.ACCOUNTS_PROJECTION; 3646 final CursorCreator<Account> mFactory = Account.FACTORY; 3647 3648 @Override 3649 public Loader<ObjectCursor<Account>> onCreateLoader(int id, Bundle args) { 3650 switch (id) { 3651 case LOADER_ACCOUNT_CURSOR: 3652 LogUtils.d(LOG_TAG, "LOADER_ACCOUNT_CURSOR created"); 3653 return new ObjectCursorLoader<Account>(mContext, 3654 MailAppProvider.getAccountsUri(), mProjection, mFactory); 3655 case LOADER_ACCOUNT_UPDATE_CURSOR: 3656 LogUtils.d(LOG_TAG, "LOADER_ACCOUNT_UPDATE_CURSOR created"); 3657 return new ObjectCursorLoader<Account>(mContext, mAccount.uri, mProjection, 3658 mFactory); 3659 default: 3660 LogUtils.wtf(LOG_TAG, "Got an id (%d) that I cannot create!", id); 3661 break; 3662 } 3663 return null; 3664 } 3665 3666 @Override 3667 public void onLoadFinished(Loader<ObjectCursor<Account>> loader, 3668 ObjectCursor<Account> data) { 3669 if (data == null) { 3670 LogUtils.e(LOG_TAG, "Received null cursor from loader id: %d", loader.getId()); 3671 } 3672 if (isDestroyed()) { 3673 return; 3674 } 3675 switch (loader.getId()) { 3676 case LOADER_ACCOUNT_CURSOR: 3677 // We have received an update on the list of accounts. 3678 if (data == null) { 3679 // Nothing useful to do if we have no valid data. 3680 break; 3681 } 3682 final long count = data.getCount(); 3683 if (count == 0) { 3684 // If an empty cursor is returned, the MailAppProvider is indicating that 3685 // no accounts have been specified. We want to navigate to the 3686 // "add account" activity that will handle the intent returned by the 3687 // MailAppProvider 3688 3689 // If the MailAppProvider believes that all accounts have been loaded, 3690 // and the account list is still empty, we want to prompt the user to add 3691 // an account. 3692 final Bundle extras = data.getExtras(); 3693 final boolean accountsLoaded = 3694 extras.getInt(AccountCursorExtraKeys.ACCOUNTS_LOADED) != 0; 3695 3696 if (accountsLoaded) { 3697 final Intent noAccountIntent = MailAppProvider.getNoAccountIntent 3698 (mContext); 3699 if (noAccountIntent != null) { 3700 mActivity.startActivityForResult(noAccountIntent, 3701 ADD_ACCOUNT_REQUEST_CODE); 3702 } 3703 } 3704 } else { 3705 final boolean accountListUpdated = accountsUpdated(data); 3706 if (!mHaveAccountList || accountListUpdated) { 3707 mHaveAccountList = updateAccounts(data); 3708 } 3709 Analytics.getInstance().setCustomDimension(Analytics.CD_INDEX_ACCOUNT_COUNT, 3710 Long.toString(count)); 3711 } 3712 break; 3713 case LOADER_ACCOUNT_UPDATE_CURSOR: 3714 // We have received an update for current account. 3715 if (data != null && data.moveToFirst()) { 3716 final Account updatedAccount = data.getModel(); 3717 // Make sure that this is an update for the current account 3718 if (updatedAccount.uri.equals(mAccount.uri)) { 3719 final Settings previousSettings = mAccount.settings; 3720 3721 // Update the controller's reference to the current account 3722 mAccount = updatedAccount; 3723 LogUtils.d(LOG_TAG, "AbstractActivityController.onLoadFinished(): " 3724 + "mAccount = %s", mAccount.uri); 3725 3726 // Only notify about a settings change if something differs 3727 if (!Objects.equal(mAccount.settings, previousSettings)) { 3728 mAccountObservers.notifyChanged(); 3729 } 3730 perhapsEnterWaitMode(); 3731 } else { 3732 LogUtils.e(LOG_TAG, "Got update for account: %s with current account:" 3733 + " %s", updatedAccount.uri, mAccount.uri); 3734 // We need to restart the loader, so the correct account information 3735 // will be returned. 3736 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR, this, Bundle.EMPTY); 3737 } 3738 } 3739 break; 3740 } 3741 } 3742 3743 @Override 3744 public void onLoaderReset(Loader<ObjectCursor<Account>> loader) { 3745 // Do nothing. In onLoadFinished() we copy the relevant data from the cursor. 3746 } 3747 } 3748 3749 /** 3750 * Updates controller state based on search results and shows first conversation if required. 3751 * Be sure to call the super-implementation if overriding. 3752 */ 3753 protected void perhapsShowFirstConversation() { 3754 mHaveSearchResults = Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction()) 3755 && mConversationListCursor.getCount() > 0; 3756 } 3757 3758 /** 3759 * Destroy the pending {@link DestructiveAction} till now and assign the given action as the 3760 * next destructive action.. 3761 * @param nextAction the next destructive action to be performed. This can be null. 3762 */ 3763 private void destroyPending(DestructiveAction nextAction) { 3764 // If there is a pending action, perform that first. 3765 if (mPendingDestruction != null) { 3766 mPendingDestruction.performAction(); 3767 } 3768 mPendingDestruction = nextAction; 3769 } 3770 3771 /** 3772 * Register a destructive action with the controller. This performs the previous destructive 3773 * action as a side effect. This method is final because we don't want the child classes to 3774 * embellish this method any more. 3775 * @param action the action to register. 3776 */ 3777 private void registerDestructiveAction(DestructiveAction action) { 3778 // TODO(viki): This is not a good idea. The best solution is for clients to request a 3779 // destructive action from the controller and for the controller to own the action. This is 3780 // a half-way solution while refactoring DestructiveAction. 3781 destroyPending(action); 3782 } 3783 3784 @Override 3785 public final DestructiveAction getBatchAction(int action, UndoCallback undoCallback) { 3786 final DestructiveAction da = new ConversationAction(action, mCheckedSet.values(), true); 3787 da.setUndoCallback(undoCallback); 3788 registerDestructiveAction(da); 3789 return da; 3790 } 3791 3792 @Override 3793 public final DestructiveAction getDeferredBatchAction(int action, UndoCallback undoCallback) { 3794 return getDeferredAction(action, mCheckedSet.values(), true, undoCallback); 3795 } 3796 3797 /** 3798 * Get a destructive action for a menu action. This is a temporary method, 3799 * to control the profusion of {@link DestructiveAction} classes that are 3800 * created. Please do not copy this paradigm. 3801 * @param action the resource ID of the menu action: R.id.delete, for 3802 * example 3803 * @param target the conversations to act upon. 3804 * @return a {@link DestructiveAction} that performs the specified action. 3805 */ 3806 private DestructiveAction getDeferredAction(int action, Collection<Conversation> target, 3807 boolean batch, UndoCallback callback) { 3808 ConversationAction cAction = new ConversationAction(action, target, batch); 3809 cAction.setUndoCallback(callback); 3810 return cAction; 3811 } 3812 3813 /** 3814 * Class to change the folders that are assigned to a set of conversations. This is destructive 3815 * because the user can remove the current folder from the conversation, in which case it has 3816 * to be animated away from the current folder. 3817 */ 3818 private class FolderDestruction implements DestructiveAction { 3819 private final Collection<Conversation> mTarget; 3820 private final ArrayList<FolderOperation> mFolderOps = new ArrayList<FolderOperation>(); 3821 private final boolean mIsDestructive; 3822 /** Whether this destructive action has already been performed */ 3823 private boolean mCompleted; 3824 private final boolean mIsSelectedSet; 3825 private final boolean mShowUndo; 3826 private final int mAction; 3827 private final Folder mActionFolder; 3828 3829 private UndoCallback mUndoCallback; 3830 3831 /** 3832 * Create a new folder destruction object to act on the given conversations. 3833 * @param target conversations to act upon. 3834 * @param actionFolder the {@link Folder} being acted upon, used for displaying the undo bar 3835 */ 3836 private FolderDestruction(final Collection<Conversation> target, 3837 final Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 3838 boolean showUndo, int action, final Folder actionFolder) { 3839 mTarget = ImmutableList.copyOf(target); 3840 mFolderOps.addAll(folders); 3841 mIsDestructive = isDestructive; 3842 mIsSelectedSet = isBatch; 3843 mShowUndo = showUndo; 3844 mAction = action; 3845 mActionFolder = actionFolder; 3846 } 3847 3848 @Override 3849 public void setUndoCallback(UndoCallback undoCallback) { 3850 mUndoCallback = undoCallback; 3851 } 3852 3853 @Override 3854 public void performAction() { 3855 if (isPerformed()) { 3856 return; 3857 } 3858 if (mIsDestructive && mShowUndo && mTarget.size() > 0) { 3859 ToastBarOperation undoOp = new ToastBarOperation(mTarget.size(), mAction, 3860 ToastBarOperation.UNDO, mIsSelectedSet, mActionFolder); 3861 onUndoAvailable(undoOp); 3862 } 3863 // For each conversation, for each operation, add/ remove the 3864 // appropriate folders. 3865 ArrayList<ConversationOperation> ops = new ArrayList<ConversationOperation>(); 3866 ArrayList<Uri> folderUris; 3867 ArrayList<Boolean> adds; 3868 for (Conversation target : mTarget) { 3869 HashMap<Uri, Folder> targetFolders = Folder.hashMapForFolders(target 3870 .getRawFolders()); 3871 folderUris = new ArrayList<Uri>(); 3872 adds = new ArrayList<Boolean>(); 3873 if (mIsDestructive) { 3874 target.localDeleteOnUpdate = true; 3875 } 3876 for (FolderOperation op : mFolderOps) { 3877 folderUris.add(op.mFolder.folderUri.fullUri); 3878 adds.add(op.mAdd ? Boolean.TRUE : Boolean.FALSE); 3879 if (op.mAdd) { 3880 targetFolders.put(op.mFolder.folderUri.fullUri, op.mFolder); 3881 } else { 3882 targetFolders.remove(op.mFolder.folderUri.fullUri); 3883 } 3884 } 3885 ops.add(mConversationListCursor.getConversationFolderOperation(target, 3886 folderUris, adds, targetFolders.values(), mUndoCallback)); 3887 } 3888 if (mConversationListCursor != null) { 3889 mConversationListCursor.updateBulkValues(ops); 3890 } 3891 refreshConversationList(); 3892 if (mIsSelectedSet) { 3893 mCheckedSet.clear(); 3894 } 3895 } 3896 3897 /** 3898 * Returns true if this action has been performed, false otherwise. 3899 * 3900 */ 3901 private synchronized boolean isPerformed() { 3902 if (mCompleted) { 3903 return true; 3904 } 3905 mCompleted = true; 3906 return false; 3907 } 3908 } 3909 3910 public final DestructiveAction getFolderChange(Collection<Conversation> target, 3911 Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 3912 boolean showUndo, final boolean isMoveTo, final Folder actionFolder, 3913 UndoCallback undoCallback) { 3914 final DestructiveAction da = getDeferredFolderChange(target, folders, isDestructive, 3915 isBatch, showUndo, isMoveTo, actionFolder, undoCallback); 3916 registerDestructiveAction(da); 3917 return da; 3918 } 3919 3920 public final DestructiveAction getDeferredFolderChange(Collection<Conversation> target, 3921 Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 3922 boolean showUndo, final boolean isMoveTo, final Folder actionFolder, 3923 UndoCallback undoCallback) { 3924 final DestructiveAction fd = new FolderDestruction(target, folders, isDestructive, isBatch, 3925 showUndo, isMoveTo ? R.id.move_folder : R.id.change_folders, actionFolder); 3926 fd.setUndoCallback(undoCallback); 3927 return fd; 3928 } 3929 3930 @Override 3931 public final DestructiveAction getDeferredRemoveFolder(Collection<Conversation> target, 3932 Folder toRemove, boolean isDestructive, boolean isBatch, 3933 boolean showUndo, UndoCallback undoCallback) { 3934 Collection<FolderOperation> folderOps = new ArrayList<FolderOperation>(); 3935 folderOps.add(new FolderOperation(toRemove, false)); 3936 final DestructiveAction da = new FolderDestruction(target, folderOps, isDestructive, isBatch, 3937 showUndo, R.id.remove_folder, mFolder); 3938 da.setUndoCallback(undoCallback); 3939 return da; 3940 } 3941 3942 @Override 3943 public final void refreshConversationList() { 3944 final ConversationListFragment convList = getConversationListFragment(); 3945 if (convList == null) { 3946 return; 3947 } 3948 convList.requestListRefresh(); 3949 } 3950 3951 protected final ActionClickedListener getUndoClickedListener( 3952 final AnimatedAdapter listAdapter) { 3953 return new ActionClickedListener() { 3954 @Override 3955 public void onActionClicked(Context context) { 3956 if (mAccount.undoUri != null) { 3957 // NOTE: We might want undo to return the messages affected, in which case 3958 // the resulting cursor might be interesting... 3959 // TODO: Use UIProvider.SEQUENCE_QUERY_PARAMETER to indicate the set of 3960 // commands to undo 3961 if (mConversationListCursor != null) { 3962 mConversationListCursor.undo( 3963 mActivity.getActivityContext(), mAccount.undoUri); 3964 } 3965 if (listAdapter != null) { 3966 listAdapter.setUndo(true); 3967 } 3968 } 3969 } 3970 }; 3971 } 3972 3973 /** 3974 * Shows an error toast in the bottom when a folder was not fetched successfully. 3975 * @param folder the folder which could not be fetched. 3976 * @param replaceVisibleToast if true, this should replace any currently visible toast. 3977 */ 3978 protected final void showErrorToast(final Folder folder, boolean replaceVisibleToast) { 3979 3980 final ActionClickedListener listener; 3981 final int actionTextResourceId; 3982 final int lastSyncResult = folder.lastSyncResult; 3983 switch (UIProvider.getResultFromLastSyncResult(lastSyncResult)) { 3984 case UIProvider.LastSyncResult.CONNECTION_ERROR: 3985 // The sync status that caused this failure. 3986 final int syncStatus = UIProvider.getStatusFromLastSyncResult(lastSyncResult); 3987 // Show: User explicitly pressed the refresh button and there is no connection 3988 // Show: The first time the user enters the app and there is no connection 3989 // TODO(viki): Implement this. 3990 // Reference: http://b/7202801 3991 final boolean showToast = (syncStatus & UIProvider.SyncStatus.USER_REFRESH) != 0; 3992 // Don't show: Already in the app; user switches to a synced label 3993 // Don't show: In a live label and a background sync fails 3994 final boolean avoidToast = !showToast && (folder.syncWindow > 0 3995 || (syncStatus & UIProvider.SyncStatus.BACKGROUND_SYNC) != 0); 3996 if (avoidToast) { 3997 return; 3998 } 3999 listener = getRetryClickedListener(folder); 4000 actionTextResourceId = R.string.retry; 4001 break; 4002 case UIProvider.LastSyncResult.AUTH_ERROR: 4003 listener = getSignInClickedListener(); 4004 actionTextResourceId = R.string.signin; 4005 break; 4006 case UIProvider.LastSyncResult.SECURITY_ERROR: 4007 return; // Currently we do nothing for security errors. 4008 case UIProvider.LastSyncResult.STORAGE_ERROR: 4009 listener = getStorageErrorClickedListener(); 4010 actionTextResourceId = R.string.info; 4011 break; 4012 case UIProvider.LastSyncResult.INTERNAL_ERROR: 4013 listener = getInternalErrorClickedListener(); 4014 actionTextResourceId = R.string.report; 4015 break; 4016 default: 4017 return; 4018 } 4019 mToastBar.show(listener, 4020 Utils.getSyncStatusText(mActivity.getActivityContext(), lastSyncResult), 4021 actionTextResourceId, 4022 replaceVisibleToast, 4023 true /* autohide */, 4024 new ToastBarOperation(1, 0, ToastBarOperation.ERROR, false, folder)); 4025 } 4026 4027 private ActionClickedListener getRetryClickedListener(final Folder folder) { 4028 return new ActionClickedListener() { 4029 @Override 4030 public void onActionClicked(Context context) { 4031 final Uri uri = folder.refreshUri; 4032 4033 if (uri != null) { 4034 startAsyncRefreshTask(uri); 4035 } 4036 } 4037 }; 4038 } 4039 4040 private ActionClickedListener getSignInClickedListener() { 4041 return new ActionClickedListener() { 4042 @Override 4043 public void onActionClicked(Context context) { 4044 promptUserForAuthentication(mAccount); 4045 } 4046 }; 4047 } 4048 4049 private ActionClickedListener getStorageErrorClickedListener() { 4050 return new ActionClickedListener() { 4051 @Override 4052 public void onActionClicked(Context context) { 4053 showStorageErrorDialog(); 4054 } 4055 }; 4056 } 4057 4058 private void showStorageErrorDialog() { 4059 DialogFragment fragment = (DialogFragment) 4060 mFragmentManager.findFragmentByTag(SYNC_ERROR_DIALOG_FRAGMENT_TAG); 4061 if (fragment == null) { 4062 fragment = SyncErrorDialogFragment.newInstance(); 4063 } 4064 fragment.show(mFragmentManager, SYNC_ERROR_DIALOG_FRAGMENT_TAG); 4065 } 4066 4067 private ActionClickedListener getInternalErrorClickedListener() { 4068 return new ActionClickedListener() { 4069 @Override 4070 public void onActionClicked(Context context) { 4071 Utils.sendFeedback(mActivity, mAccount, true /* reportingProblem */); 4072 } 4073 }; 4074 } 4075 4076 @Override 4077 public void onFooterViewLoadMoreClick(Folder folder) { 4078 if (folder != null && folder.loadMoreUri != null) { 4079 startAsyncRefreshTask(folder.loadMoreUri); 4080 } 4081 } 4082 4083 private void startAsyncRefreshTask(Uri uri) { 4084 if (mFolderSyncTask != null) { 4085 mFolderSyncTask.cancel(true); 4086 } 4087 mFolderSyncTask = new AsyncRefreshTask(mActivity.getActivityContext(), uri); 4088 mFolderSyncTask.execute(); 4089 } 4090 4091 private void promptUserForAuthentication(Account account) { 4092 if (account != null && !Utils.isEmpty(account.reauthenticationIntentUri)) { 4093 final Intent authenticationIntent = 4094 new Intent(Intent.ACTION_VIEW, account.reauthenticationIntentUri); 4095 mActivity.startActivityForResult(authenticationIntent, REAUTHENTICATE_REQUEST_CODE); 4096 } 4097 } 4098 4099 @Override 4100 public void onAccessibilityStateChanged() { 4101 // Clear the cache of objects. 4102 ConversationItemViewModel.onAccessibilityUpdated(); 4103 // Re-render the list if it exists. 4104 final ConversationListFragment frag = getConversationListFragment(); 4105 if (frag != null) { 4106 AnimatedAdapter adapter = frag.getAnimatedAdapter(); 4107 if (adapter != null) { 4108 adapter.notifyDataSetInvalidated(); 4109 } 4110 } 4111 } 4112 4113 @Override 4114 public void makeDialogListener (final int action, final boolean isBatch, 4115 UndoCallback undoCallback) { 4116 final Collection<Conversation> target; 4117 if (isBatch) { 4118 target = mCheckedSet.values(); 4119 } else { 4120 LogUtils.d(LOG_TAG, "Will act upon %s", mCurrentConversation); 4121 target = Conversation.listOf(mCurrentConversation); 4122 } 4123 final DestructiveAction destructiveAction = getDeferredAction(action, target, isBatch, 4124 undoCallback); 4125 mDialogAction = action; 4126 mDialogFromSelectedSet = isBatch; 4127 mDialogListener = new AlertDialog.OnClickListener() { 4128 @Override 4129 public void onClick(DialogInterface dialog, int which) { 4130 delete(action, target, destructiveAction, isBatch); 4131 // Afterwards, let's remove references to the listener and the action. 4132 setListener(null, -1); 4133 } 4134 }; 4135 } 4136 4137 @Override 4138 public AlertDialog.OnClickListener getListener() { 4139 return mDialogListener; 4140 } 4141 4142 /** 4143 * Sets the listener for the positive action on a confirmation dialog. Since only a single 4144 * confirmation dialog can be shown, this overwrites the previous listener. It is safe to 4145 * unset the listener; in which case action should be set to -1. 4146 * @param listener the listener that will perform the task for this dialog's positive action. 4147 * @param action the action that created this dialog. 4148 */ 4149 private void setListener(AlertDialog.OnClickListener listener, final int action){ 4150 mDialogListener = listener; 4151 mDialogAction = action; 4152 } 4153 4154 @Override 4155 public VeiledAddressMatcher getVeiledAddressMatcher() { 4156 return mVeiledMatcher; 4157 } 4158 4159 @Override 4160 public void setDetachedMode() { 4161 // Tell the conversation list not to select anything. 4162 final ConversationListFragment frag = getConversationListFragment(); 4163 if (frag != null) { 4164 frag.setChoiceNone(); 4165 } else if (mIsTablet) { 4166 // How did we ever land here? Detached mode, and no CLF on tablet??? 4167 LogUtils.e(LOG_TAG, "AAC.setDetachedMode(): CLF = null!"); 4168 } 4169 mDetachedConvUri = mCurrentConversation.uri; 4170 } 4171 4172 private void clearDetachedMode() { 4173 // Tell the conversation list to go back to its usual selection behavior. 4174 final ConversationListFragment frag = getConversationListFragment(); 4175 if (frag != null) { 4176 frag.revertChoiceMode(); 4177 } else if (mIsTablet) { 4178 // How did we ever land here? Detached mode, and no CLF on tablet??? 4179 LogUtils.e(LOG_TAG, "AAC.clearDetachedMode(): CLF = null on tablet!"); 4180 } 4181 mDetachedConvUri = null; 4182 } 4183 4184 @Override 4185 public boolean shouldPreventListSwipesEntirely