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