AbstractActivityController.java revision cc4a037b71a60cb912b7fbf25805c54179d75ff0
1/******************************************************************************* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 *******************************************************************************/ 17 18package com.android.mail.ui; 19 20import android.animation.ValueAnimator; 21import android.app.Activity; 22import android.app.AlertDialog; 23import android.app.Dialog; 24import android.app.DialogFragment; 25import android.app.Fragment; 26import android.app.FragmentManager; 27import android.app.LoaderManager; 28import android.app.SearchManager; 29import android.content.ContentProviderOperation; 30import android.content.ContentResolver; 31import android.content.ContentValues; 32import android.content.Context; 33import android.content.DialogInterface; 34import android.content.DialogInterface.OnClickListener; 35import android.content.Intent; 36import android.content.Loader; 37import android.content.res.Configuration; 38import android.content.res.Resources; 39import android.database.Cursor; 40import android.database.DataSetObservable; 41import android.database.DataSetObserver; 42import android.database.Observable; 43import android.net.Uri; 44import android.os.AsyncTask; 45import android.os.Bundle; 46import android.os.Handler; 47import android.os.Parcelable; 48import android.os.SystemClock; 49import android.provider.SearchRecentSuggestions; 50import android.support.v4.app.ActionBarDrawerToggle; 51import android.support.v4.widget.DrawerLayout; 52import android.support.v7.app.ActionBar; 53import android.view.DragEvent; 54import android.view.Gravity; 55import android.view.KeyEvent; 56import android.view.Menu; 57import android.view.MenuInflater; 58import android.view.MenuItem; 59import android.view.MotionEvent; 60import android.view.View; 61import android.widget.ListView; 62import android.widget.Toast; 63 64import com.android.mail.ConversationListContext; 65import com.android.mail.MailLogService; 66import com.android.mail.R; 67import com.android.mail.analytics.Analytics; 68import com.android.mail.analytics.AnalyticsTimer; 69import com.android.mail.analytics.AnalyticsUtils; 70import com.android.mail.browse.ConfirmDialogFragment; 71import com.android.mail.browse.ConversationCursor; 72import com.android.mail.browse.ConversationCursor.ConversationOperation; 73import com.android.mail.browse.ConversationItemViewModel; 74import com.android.mail.browse.ConversationMessage; 75import com.android.mail.browse.ConversationPagerController; 76import com.android.mail.browse.SelectedConversationsActionMenu; 77import com.android.mail.browse.SyncErrorDialogFragment; 78import com.android.mail.browse.UndoCallback; 79import com.android.mail.compose.ComposeActivity; 80import com.android.mail.content.CursorCreator; 81import com.android.mail.content.ObjectCursor; 82import com.android.mail.content.ObjectCursorLoader; 83import com.android.mail.providers.Account; 84import com.android.mail.providers.Conversation; 85import com.android.mail.providers.ConversationInfo; 86import com.android.mail.providers.Folder; 87import com.android.mail.providers.FolderWatcher; 88import com.android.mail.providers.MailAppProvider; 89import com.android.mail.providers.Settings; 90import com.android.mail.providers.SuggestionsProvider; 91import com.android.mail.providers.UIProvider; 92import com.android.mail.providers.UIProvider.AccountCapabilities; 93import com.android.mail.providers.UIProvider.AccountCursorExtraKeys; 94import com.android.mail.providers.UIProvider.AutoAdvance; 95import com.android.mail.providers.UIProvider.ConversationColumns; 96import com.android.mail.providers.UIProvider.ConversationOperations; 97import com.android.mail.providers.UIProvider.FolderCapabilities; 98import com.android.mail.providers.UIProvider.FolderType; 99import com.android.mail.ui.ActionableToastBar.ActionClickedListener; 100import com.android.mail.utils.ContentProviderTask; 101import com.android.mail.utils.DrawIdler; 102import com.android.mail.utils.LogTag; 103import com.android.mail.utils.LogUtils; 104import com.android.mail.utils.MailObservable; 105import com.android.mail.utils.NotificationActionUtils; 106import com.android.mail.utils.Utils; 107import com.android.mail.utils.VeiledAddressMatcher; 108import com.google.common.base.Objects; 109import com.google.common.collect.ImmutableList; 110import com.google.common.collect.Lists; 111import com.google.common.collect.Sets; 112 113import java.util.ArrayList; 114import java.util.Arrays; 115import java.util.Collection; 116import java.util.Collections; 117import java.util.HashMap; 118import java.util.List; 119import java.util.Set; 120import 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 */ 139public 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 { 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 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 541 public Account getCurrentAccount() { 542 return mAccount; 543 } 544 545 @Override 546 public ConversationListContext getCurrentListContext() { 547 return mConvListContext; 548 } 549 550 @Override 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 */ 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 */ 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 */ 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 */ 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 */ 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 */ 644 protected abstract boolean isConversationListVisible(); 645 646 /** 647 * If required, starts wait mode for the current account. 648 */ 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 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 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_EMAIL_PROVIDER, 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 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 757 public void unregisterAccountObserver(DataSetObserver obs) { 758 mAccountObservers.unregisterObserver(obs); 759 } 760 761 @Override 762 public void registerAllAccountObserver(DataSetObserver observer) { 763 mAllAccountObservers.registerObserver(observer); 764 } 765 766 @Override 767 public void unregisterAllAccountObserver(DataSetObserver observer) { 768 mAllAccountObservers.unregisterObserver(observer); 769 } 770 771 @Override 772 public Account[] getAllAccounts() { 773 return mAllAccounts; 774 } 775 776 @Override 777 public Account getAccount() { 778 return mAccount; 779 } 780 781 @Override 782 public void registerFolderOrAccountChangedObserver(final DataSetObserver observer) { 783 mFolderOrAccountObservers.registerObserver(observer); 784 } 785 786 @Override 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 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 */ 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 */ 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 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 */ 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 */ 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 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 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 962 public void unregisterRecentFolderObserver(DataSetObserver obs) { 963 mRecentFolderObservers.unregisterObserver(obs); 964 } 965 966 @Override 967 public RecentFolderList getRecentFolders() { 968 return mRecentFolderList; 969 } 970 971 @Override 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 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 */ 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 */ 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 1071 public Folder getFolder() { 1072 return mFolder; 1073 } 1074 1075 @Override 1076 public Folder getHierarchyFolder() { 1077 return mFolderListFolder; 1078 } 1079 1080 @Override 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 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 */ 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 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 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 */ 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 */ 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 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 */ 1340 protected ActionableToastBar findActionableToastBar(MailActivity activity) { 1341 return (ActionableToastBar) activity.findViewById(R.id.toast_bar); 1342 } 1343 1344 @Override 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 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 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 */ 1381 protected void closeDrawerIfOpen() { 1382 if (!isDrawerEnabled()) { 1383 return; 1384 } 1385 if(mDrawerContainer.isDrawerOpen(mDrawerPullout)) { 1386 mDrawerContainer.closeDrawers(); 1387 } 1388 } 1389 1390 @Override 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 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 1418 public Dialog onCreateDialog(int id, Bundle bundle) { 1419 return null; 1420 } 1421 1422 @Override 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 1434 public final boolean onKeyDown(int keyCode, KeyEvent event) { 1435 return false; 1436 } 1437 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 */ 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( 2366 Analytics.CD_INDEX_ACCOUNT_EMAIL_PROVIDER, 2367 AnalyticsUtils.getAccountTypeForAccount(mAccount.getEmailAddress())); 2368 Analytics.getInstance().sendEvent("notification_click", 2369 isConversationMode ? "conversation" : "conversation_list", null, 0); 2370 } 2371 2372 if (isConversationMode && mViewMode.getMode() == ViewMode.UNKNOWN) { 2373 mViewMode.enterConversationMode(); 2374 } else { 2375 mViewMode.enterConversationListMode(); 2376 } 2377 // Put the folder and conversation, and ask the loader to create this folder. 2378 final Bundle args = new Bundle(); 2379 2380 final Uri folderUri; 2381 if (intent.hasExtra(Utils.EXTRA_FOLDER_URI)) { 2382 folderUri = intent.getParcelableExtra(Utils.EXTRA_FOLDER_URI); 2383 } else if (intent.hasExtra(Utils.EXTRA_FOLDER)) { 2384 final Folder folder = 2385 Folder.fromString(intent.getStringExtra(Utils.EXTRA_FOLDER)); 2386 folderUri = folder.folderUri.fullUri; 2387 } else { 2388 final Bundle extras = intent.getExtras(); 2389 LogUtils.d(LOG_TAG, "Couldn't find a folder URI in the extras: %s", 2390 extras == null ? "null" : extras.toString()); 2391 folderUri = mAccount.settings.defaultInbox; 2392 } 2393 2394 // Check if we should load all conversations instead of using 2395 // the default behavior which loads an initial subset. 2396 mIgnoreInitialConversationLimit = 2397 intent.getBooleanExtra(Utils.EXTRA_IGNORE_INITIAL_CONVERSATION_LIMIT, false); 2398 2399 args.putParcelable(Utils.EXTRA_FOLDER_URI, folderUri); 2400 args.putParcelable(Utils.EXTRA_CONVERSATION, 2401 intent.getParcelableExtra(Utils.EXTRA_CONVERSATION)); 2402 restartOptionalLoader(LOADER_FIRST_FOLDER, mFolderCallbacks, args); 2403 } else if (Intent.ACTION_SEARCH.equals(intent.getAction())) { 2404 if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) { 2405 mHaveSearchResults = false; 2406 // Save this search query for future suggestions. 2407 final String query = intent.getStringExtra(SearchManager.QUERY); 2408 final String authority = mContext.getString(R.string.suggestions_authority); 2409 final SearchRecentSuggestions suggestions = new SearchRecentSuggestions( 2410 mContext, authority, SuggestionsProvider.MODE); 2411 suggestions.saveRecentQuery(query, null); 2412 setAccount((Account) intent.getParcelableExtra(Utils.EXTRA_ACCOUNT)); 2413 fetchSearchFolder(intent); 2414 if (shouldEnterSearchConvMode()) { 2415 mViewMode.enterSearchResultsConversationMode(); 2416 } else { 2417 mViewMode.enterSearchResultsListMode(); 2418 } 2419 } else { 2420 LogUtils.e(LOG_TAG, "Missing account extra from search intent. Finishing"); 2421 mActivity.finish(); 2422 } 2423 } 2424 if (mAccount != null) { 2425 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR, mAccountCallbacks, Bundle.EMPTY); 2426 } 2427 } 2428 2429 /** 2430 * Returns true if we should enter conversation mode with search. 2431 */ 2432 protected final boolean shouldEnterSearchConvMode() { 2433 return mHaveSearchResults && Utils.showTwoPaneSearchResults(mActivity.getActivityContext()); 2434 } 2435 2436 /** 2437 * Copy any selected conversations stored in the saved bundle into our selection set, 2438 * triggering {@link ConversationSetObserver} callbacks as our selection set changes. 2439 * 2440 */ 2441 private void restoreSelectedConversations(Bundle savedState) { 2442 if (savedState == null) { 2443 mSelectedSet.clear(); 2444 return; 2445 } 2446 final ConversationSelectionSet selectedSet = savedState.getParcelable(SAVED_SELECTED_SET); 2447 if (selectedSet == null || selectedSet.isEmpty()) { 2448 mSelectedSet.clear(); 2449 return; 2450 } 2451 2452 // putAll will take care of calling our registered onSetPopulated method 2453 mSelectedSet.putAll(selectedSet); 2454 } 2455 2456 /** 2457 * Show the conversation provided in the arguments. It is safe to pass a null conversation 2458 * object, which is a signal to back out of conversation view mode. 2459 * Child classes must call super.showConversation() <b>before</b> their own implementations. 2460 * @param conversation the conversation to be shown, or null if we want to back out to list 2461 * mode. 2462 * onLoadFinished(Loader, Cursor) on any callback. 2463 */ 2464 protected void showConversation(Conversation conversation) { 2465 showConversation(conversation, true /* markAsRead */); 2466 } 2467 2468 protected void showConversation(Conversation conversation, boolean markAsRead) { 2469 if (conversation != null) { 2470 Utils.sConvLoadTimer.start(); 2471 } 2472 2473 MailLogService.log("AbstractActivityController", "showConversation(%s)", conversation); 2474 // Set the current conversation just in case it wasn't already set. 2475 setCurrentConversation(conversation); 2476 } 2477 2478 /** 2479 * Children can override this method, but they must call super.showWaitForInitialization(). 2480 * {@inheritDoc} 2481 */ 2482 @Override 2483 public void showWaitForInitialization() { 2484 mViewMode.enterWaitingForInitializationMode(); 2485 mWaitFragment = WaitFragment.newInstance(mAccount, true /* expectingMessages */); 2486 } 2487 2488 private void updateWaitMode() { 2489 final FragmentManager manager = mActivity.getFragmentManager(); 2490 final WaitFragment waitFragment = 2491 (WaitFragment)manager.findFragmentByTag(TAG_WAIT); 2492 if (waitFragment != null) { 2493 waitFragment.updateAccount(mAccount); 2494 } 2495 } 2496 2497 /** 2498 * Remove the "Waiting for Initialization" fragment. Child classes are free to override this 2499 * method, though they must call the parent implementation <b>after</b> they do anything. 2500 */ 2501 protected void hideWaitForInitialization() { 2502 mWaitFragment = null; 2503 } 2504 2505 /** 2506 * Use the instance variable and the wait fragment's tag to get the wait fragment. This is 2507 * far superior to using the value of mWaitFragment, which might be invalid or might refer 2508 * to a fragment after it has been destroyed. 2509 * @return a wait fragment that is already attached to the activity, if one exists 2510 */ 2511 protected final WaitFragment getWaitFragment() { 2512 final FragmentManager manager = mActivity.getFragmentManager(); 2513 final WaitFragment waitFrag = (WaitFragment) manager.findFragmentByTag(TAG_WAIT); 2514 if (waitFrag != null) { 2515 // The Fragment Manager knows better, so use its instance. 2516 mWaitFragment = waitFrag; 2517 } 2518 return mWaitFragment; 2519 } 2520 2521 /** 2522 * Returns true if we are waiting for the account to sync, and cannot show any folders or 2523 * conversation for the current account yet. 2524 */ 2525 private boolean inWaitMode() { 2526 final WaitFragment waitFragment = getWaitFragment(); 2527 if (waitFragment != null) { 2528 final Account fragmentAccount = waitFragment.getAccount(); 2529 return fragmentAccount != null && fragmentAccount.uri.equals(mAccount.uri) && 2530 mViewMode.getMode() == ViewMode.WAITING_FOR_ACCOUNT_INITIALIZATION; 2531 } 2532 return false; 2533 } 2534 2535 /** 2536 * Children can override this method, but they must call super.showConversationList(). 2537 * {@inheritDoc} 2538 */ 2539 @Override 2540 public void showConversationList(ConversationListContext listContext) { 2541 } 2542 2543 @Override 2544 public void onConversationSelected(Conversation conversation, boolean inLoaderCallbacks) { 2545 final ConversationListFragment convListFragment = getConversationListFragment(); 2546 if (convListFragment != null && convListFragment.getAnimatedAdapter() != null) { 2547 convListFragment.getAnimatedAdapter().onConversationSelected(); 2548 } 2549 // Only animate destructive actions if we are going to be showing the 2550 // conversation list when we show the next conversation. 2551 commitDestructiveActions(mIsTablet); 2552 showConversation(conversation); 2553 } 2554 2555 @Override 2556 public final void onCabModeEntered() { 2557 final ConversationListFragment convListFragment = getConversationListFragment(); 2558 if (convListFragment != null && convListFragment.getAnimatedAdapter() != null) { 2559 convListFragment.getAnimatedAdapter().onCabModeEntered(); 2560 } 2561 } 2562 2563 @Override 2564 public final void onCabModeExited() { 2565 final ConversationListFragment convListFragment = getConversationListFragment(); 2566 if (convListFragment != null && convListFragment.getAnimatedAdapter() != null) { 2567 convListFragment.getAnimatedAdapter().onCabModeExited(); 2568 } 2569 } 2570 2571 @Override 2572 public Conversation getCurrentConversation() { 2573 return mCurrentConversation; 2574 } 2575 2576 /** 2577 * Set the current conversation. This is the conversation on which all actions are performed. 2578 * Do not modify mCurrentConversation except through this method, which makes it easy to 2579 * perform common actions associated with changing the current conversation. 2580 * @param conversation new conversation to view. Passing null indicates that we are backing 2581 * out to conversation list mode. 2582 */ 2583 @Override 2584 public void setCurrentConversation(Conversation conversation) { 2585 // The controller should come out of detached mode if a new conversation is viewed, or if 2586 // we are going back to conversation list mode. 2587 if (mDetachedConvUri != null && (conversation == null 2588 || !mDetachedConvUri.equals(conversation.uri))) { 2589 clearDetachedMode(); 2590 } 2591 2592 // Must happen *before* setting mCurrentConversation because this sets 2593 // conversation.position if a cursor is available. 2594 mTracker.initialize(conversation); 2595 mCurrentConversation = conversation; 2596 2597 if (mCurrentConversation != null) { 2598 mActionBarController.setCurrentConversation(mCurrentConversation); 2599 mActivity.invalidateOptionsMenu(); 2600 } 2601 } 2602 2603 /** 2604 * {@link LoaderManager} currently has a bug in 2605 * {@link LoaderManager#restartLoader(int, Bundle, android.app.LoaderManager.LoaderCallbacks)} 2606 * where, if a previous onCreateLoader returned a null loader, this method will NPE. Work around 2607 * this bug by destroying any loaders that may have been created as null (essentially because 2608 * they are optional loads, and may not apply to a particular account). 2609 * <p> 2610 * A simple null check before restarting a loader will not work, because that would not 2611 * give the controller a chance to invalidate UI corresponding the prior loader result. 2612 * 2613 * @param id loader ID to safely restart 2614 * @param handler the LoaderCallback which will handle this loader ID. 2615 * @param args arguments, if any, to be passed to the loader. Use {@link Bundle#EMPTY} if no 2616 * arguments need to be specified. 2617 */ 2618 private void restartOptionalLoader(int id, LoaderManager.LoaderCallbacks handler, Bundle args) { 2619 final LoaderManager lm = mActivity.getLoaderManager(); 2620 lm.destroyLoader(id); 2621 lm.restartLoader(id, args, handler); 2622 } 2623 2624 @Override 2625 public void registerConversationListObserver(DataSetObserver observer) { 2626 mConversationListObservable.registerObserver(observer); 2627 } 2628 2629 @Override 2630 public void unregisterConversationListObserver(DataSetObserver observer) { 2631 try { 2632 mConversationListObservable.unregisterObserver(observer); 2633 } catch (IllegalStateException e) { 2634 // Log instead of crash 2635 LogUtils.e(LOG_TAG, e, "unregisterConversationListObserver called for an observer that " 2636 + "hasn't been registered"); 2637 } 2638 } 2639 2640 @Override 2641 public void registerFolderObserver(DataSetObserver observer) { 2642 mFolderObservable.registerObserver(observer); 2643 } 2644 2645 @Override 2646 public void unregisterFolderObserver(DataSetObserver observer) { 2647 try { 2648 mFolderObservable.unregisterObserver(observer); 2649 } catch (IllegalStateException e) { 2650 // Log instead of crash 2651 LogUtils.e(LOG_TAG, e, "unregisterFolderObserver called for an observer that " 2652 + "hasn't been registered"); 2653 } 2654 } 2655 2656 @Override 2657 public void registerConversationLoadedObserver(DataSetObserver observer) { 2658 mPagerController.registerConversationLoadedObserver(observer); 2659 } 2660 2661 @Override 2662 public void unregisterConversationLoadedObserver(DataSetObserver observer) { 2663 try { 2664 mPagerController.unregisterConversationLoadedObserver(observer); 2665 } catch (IllegalStateException e) { 2666 // Log instead of crash 2667 LogUtils.e(LOG_TAG, e, "unregisterConversationLoadedObserver called for an observer " 2668 + "that hasn't been registered"); 2669 } 2670 } 2671 2672 /** 2673 * Returns true if the number of accounts is different, or if the current account has 2674 * changed. This method is meant to filter frequent changes to the list of 2675 * accounts, and only return true if the new list is substantially different from the existing 2676 * list. Returning true is safe here, it leads to more work in creating the 2677 * same account list again. 2678 * @param accountCursor the cursor which points to all the accounts. 2679 * @return true if the number of accounts is changed or current account missing from the list. 2680 */ 2681 private boolean accountsUpdated(ObjectCursor<Account> accountCursor) { 2682 // Check to see if the current account hasn't been set, or the account cursor is empty 2683 if (mAccount == null || !accountCursor.moveToFirst()) { 2684 return true; 2685 } 2686 2687 // Check to see if the number of accounts are different, from the number we saw on the last 2688 // updated 2689 if (mCurrentAccountUris.size() != accountCursor.getCount()) { 2690 return true; 2691 } 2692 2693 // Check to see if the account list is different or if the current account is not found in 2694 // the cursor. 2695 boolean foundCurrentAccount = false; 2696 do { 2697 final Account account = accountCursor.getModel(); 2698 if (!foundCurrentAccount && mAccount.uri.equals(account.uri)) { 2699 if (mAccount.settingsDiffer(account)) { 2700 // Settings changed, and we don't need to look any further. 2701 return true; 2702 } 2703 foundCurrentAccount = true; 2704 } 2705 // Is there a new account that we do not know about? 2706 if (!mCurrentAccountUris.contains(account.uri)) { 2707 return true; 2708 } 2709 } while (accountCursor.moveToNext()); 2710 2711 // As long as we found the current account, the list hasn't been updated 2712 return !foundCurrentAccount; 2713 } 2714 2715 /** 2716 * Updates accounts for the app. If the current account is missing, the first 2717 * account in the list is set to the current account (we <em>have</em> to choose something). 2718 * 2719 * @param accounts cursor into the AccountCache 2720 * @return true if the update was successful, false otherwise 2721 */ 2722 private boolean updateAccounts(ObjectCursor<Account> accounts) { 2723 if (accounts == null || !accounts.moveToFirst()) { 2724 return false; 2725 } 2726 2727 final Account[] allAccounts = Account.getAllAccounts(accounts); 2728 // A match for the current account's URI in the list of accounts. 2729 Account currentFromList = null; 2730 2731 // Save the uris for the accounts and find the current account in the updated cursor. 2732 mCurrentAccountUris.clear(); 2733 for (final Account account : allAccounts) { 2734 LogUtils.d(LOG_TAG, "updateAccounts(%s)", account); 2735 mCurrentAccountUris.add(account.uri); 2736 if (mAccount != null && account.uri.equals(mAccount.uri)) { 2737 currentFromList = account; 2738 } 2739 } 2740 2741 // 1. current account is already set and is in allAccounts: 2742 // 1a. It has changed -> load the updated account. 2743 // 2b. It is unchanged -> no-op 2744 // 2. current account is set and is not in allAccounts -> pick first (acct was deleted?) 2745 // 3. saved preference has an account -> pick that one 2746 // 4. otherwise just pick first 2747 2748 boolean accountChanged = false; 2749 /// Assume case 4, initialize to first account, and see if we can find anything better. 2750 Account newAccount = allAccounts[0]; 2751 if (currentFromList != null) { 2752 // Case 1: Current account exists but has changed 2753 if (!currentFromList.equals(mAccount)) { 2754 newAccount = currentFromList; 2755 accountChanged = true; 2756 } 2757 // Case 1b: else, current account is unchanged: nothing to do. 2758 } else { 2759 // Case 2: Current account is not in allAccounts, the account needs to change. 2760 accountChanged = true; 2761 if (mAccount == null) { 2762 // Case 3: Check for last viewed account, and check if it exists in the list. 2763 final String lastAccountUri = MailAppProvider.getInstance().getLastViewedAccount(); 2764 if (lastAccountUri != null) { 2765 for (final Account account : allAccounts) { 2766 if (lastAccountUri.equals(account.uri.toString())) { 2767 newAccount = account; 2768 break; 2769 } 2770 } 2771 } 2772 } 2773 } 2774 if (accountChanged) { 2775 changeAccount(newAccount); 2776 } 2777 2778 // Whether we have updated the current account or not, we need to update the list of 2779 // accounts in the ActionBar. 2780 mAllAccounts = allAccounts; 2781 mAllAccountObservers.notifyChanged(); 2782 return (allAccounts.length > 0); 2783 } 2784 2785 private void disableNotifications() { 2786 mNewEmailReceiver.activate(mContext, this); 2787 } 2788 2789 private void enableNotifications() { 2790 mNewEmailReceiver.deactivate(); 2791 } 2792 2793 private void disableNotificationsOnAccountChange(Account account) { 2794 // If the new mail suppression receiver is activated for a different account, we want to 2795 // activate it for the new account. 2796 if (mNewEmailReceiver.activated() && 2797 !mNewEmailReceiver.notificationsDisabledForAccount(account)) { 2798 // Deactivate the current receiver, otherwise multiple receivers may be registered. 2799 mNewEmailReceiver.deactivate(); 2800 mNewEmailReceiver.activate(mContext, this); 2801 } 2802 } 2803 2804 /** 2805 * Destructive actions on Conversations. This class should only be created by controllers, and 2806 * clients should only require {@link DestructiveAction}s, not specific implementations of the. 2807 * Only the controllers should know what kind of destructive actions are being created. 2808 */ 2809 public class ConversationAction implements DestructiveAction { 2810 /** 2811 * The action to be performed. This is specified as the resource ID of the menu item 2812 * corresponding to this action: R.id.delete, R.id.report_spam, etc. 2813 */ 2814 private final int mAction; 2815 /** The action will act upon these conversations */ 2816 private final Collection<Conversation> mTarget; 2817 /** Whether this destructive action has already been performed */ 2818 private boolean mCompleted; 2819 /** Whether this is an action on the currently selected set. */ 2820 private final boolean mIsSelectedSet; 2821 2822 private UndoCallback mCallback; 2823 2824 /** 2825 * Create a listener object. 2826 * @param action action is one of four constants: R.id.y_button (archive), 2827 * R.id.delete , R.id.mute, and R.id.report_spam. 2828 * @param target Conversation that we want to apply the action to. 2829 * @param isBatch whether the conversations are in the currently selected batch set. 2830 */ 2831 public ConversationAction(int action, Collection<Conversation> target, boolean isBatch) { 2832 mAction = action; 2833 mTarget = ImmutableList.copyOf(target); 2834 mIsSelectedSet = isBatch; 2835 } 2836 2837 @Override 2838 public void setUndoCallback(UndoCallback undoCallback) { 2839 mCallback = undoCallback; 2840 } 2841 2842 /** 2843 * The action common to child classes. This performs the action specified in the constructor 2844 * on the conversations given here. 2845 */ 2846 @Override 2847 public void performAction() { 2848 if (isPerformed()) { 2849 return; 2850 } 2851 boolean undoEnabled = mAccount.supportsCapability(AccountCapabilities.UNDO); 2852 2853 // Are we destroying the currently shown conversation? Show the next one. 2854 if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)){ 2855 LogUtils.d(LOG_TAG, "ConversationAction.performAction():" 2856 + "\nmTarget=%s\nCurrent=%s", 2857 Conversation.toString(mTarget), mCurrentConversation); 2858 } 2859 2860 if (mConversationListCursor == null) { 2861 LogUtils.e(LOG_TAG, "null ConversationCursor in ConversationAction.performAction():" 2862 + "\nmTarget=%s\nCurrent=%s", 2863 Conversation.toString(mTarget), mCurrentConversation); 2864 return; 2865 } 2866 2867 if (mAction == R.id.archive) { 2868 LogUtils.d(LOG_TAG, "Archiving"); 2869 mConversationListCursor.archive(mTarget, mCallback); 2870 } else if (mAction == R.id.delete) { 2871 LogUtils.d(LOG_TAG, "Deleting"); 2872 mConversationListCursor.delete(mTarget, mCallback); 2873 if (mFolder.supportsCapability(FolderCapabilities.DELETE_ACTION_FINAL)) { 2874 undoEnabled = false; 2875 } 2876 } else if (mAction == R.id.mute) { 2877 LogUtils.d(LOG_TAG, "Muting"); 2878 if (mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)) { 2879 for (Conversation c : mTarget) { 2880 c.localDeleteOnUpdate = true; 2881 } 2882 } 2883 mConversationListCursor.mute(mTarget, mCallback); 2884 } else if (mAction == R.id.report_spam) { 2885 LogUtils.d(LOG_TAG, "Reporting spam"); 2886 mConversationListCursor.reportSpam(mTarget, mCallback); 2887 } else if (mAction == R.id.mark_not_spam) { 2888 LogUtils.d(LOG_TAG, "Marking not spam"); 2889 mConversationListCursor.reportNotSpam(mTarget, mCallback); 2890 } else if (mAction == R.id.report_phishing) { 2891 LogUtils.d(LOG_TAG, "Reporting phishing"); 2892 mConversationListCursor.reportPhishing(mTarget, mCallback); 2893 } else if (mAction == R.id.remove_star) { 2894 LogUtils.d(LOG_TAG, "Removing star"); 2895 // Star removal is destructive in the Starred folder. 2896 mConversationListCursor.updateBoolean(mTarget, ConversationColumns.STARRED, 2897 false); 2898 } else if (mAction == R.id.mark_not_important) { 2899 LogUtils.d(LOG_TAG, "Marking not-important"); 2900 // Marking not important is destructive in a mailbox 2901 // containing only important messages 2902 if (mFolder != null && mFolder.isImportantOnly()) { 2903 for (Conversation conv : mTarget) { 2904 conv.localDeleteOnUpdate = true; 2905 } 2906 } 2907 mConversationListCursor.updateInt(mTarget, ConversationColumns.PRIORITY, 2908 UIProvider.ConversationPriority.LOW); 2909 } else if (mAction == R.id.discard_drafts) { 2910 LogUtils.d(LOG_TAG, "Discarding draft messages"); 2911 // Discarding draft messages is destructive in a "draft" mailbox 2912 if (mFolder != null && mFolder.isDraft()) { 2913 for (Conversation conv : mTarget) { 2914 conv.localDeleteOnUpdate = true; 2915 } 2916 } 2917 mConversationListCursor.discardDrafts(mTarget); 2918 // We don't support undoing discarding drafts 2919 undoEnabled = false; 2920 } else if (mAction == R.id.discard_outbox) { 2921 LogUtils.d(LOG_TAG, "Discarding failed messages in Outbox"); 2922 mConversationListCursor.moveFailedIntoDrafts(mTarget); 2923 undoEnabled = false; 2924 } 2925 if (undoEnabled) { 2926 mHandler.postDelayed(new Runnable() { 2927 @Override 2928 public void run() { 2929 onUndoAvailable(new ToastBarOperation(mTarget.size(), mAction, 2930 ToastBarOperation.UNDO, mIsSelectedSet, mFolder)); 2931 } 2932 }, mShowUndoBarDelay); 2933 } 2934 refreshConversationList(); 2935 if (mIsSelectedSet) { 2936 mSelectedSet.clear(); 2937 } 2938 } 2939 2940 /** 2941 * Returns true if this action has been performed, false otherwise. 2942 * 2943 */ 2944 private synchronized boolean isPerformed() { 2945 if (mCompleted) { 2946 return true; 2947 } 2948 mCompleted = true; 2949 return false; 2950 } 2951 } 2952 2953 // Called from the FolderSelectionDialog after a user is done selecting folders to assign the 2954 // conversations to. 2955 @Override 2956 public final void assignFolder(Collection<FolderOperation> folderOps, 2957 Collection<Conversation> target, boolean batch, boolean showUndo, 2958 final boolean isMoveTo) { 2959 // Actions are destructive only when the current folder can be un-assigned from and 2960 // when the list of folders contains the current folder. 2961 final boolean isDestructive = mFolder 2962 .supportsCapability(FolderCapabilities.ALLOWS_REMOVE_CONVERSATION) 2963 && FolderOperation.isDestructive(folderOps, mFolder); 2964 LogUtils.d(LOG_TAG, "onFolderChangesCommit: isDestructive = %b", isDestructive); 2965 if (isDestructive) { 2966 for (final Conversation c : target) { 2967 c.localDeleteOnUpdate = true; 2968 } 2969 } 2970 final DestructiveAction folderChange; 2971 final UndoCallback undoCallback = isMoveTo ? 2972 getUndoCallbackForDestructiveActionsWithAutoAdvance(R.id.move_to, 2973 mCurrentConversation) 2974 : null; 2975 // Update the UI elements depending no their visibility and availability 2976 // TODO(viki): Consolidate this into a single method requestDelete. 2977 if (isDestructive) { 2978 /* 2979 * If this is a MOVE operation, we want the action folder to be the destination folder. 2980 * Otherwise, we want it to be the current folder. 2981 * 2982 * A set of folder operations is a move if there are exactly two operations: an add and 2983 * a remove. 2984 */ 2985 final Folder actionFolder; 2986 if (folderOps.size() != 2) { 2987 actionFolder = mFolder; 2988 } else { 2989 Folder addedFolder = null; 2990 boolean hasRemove = false; 2991 for (final FolderOperation folderOperation : folderOps) { 2992 if (folderOperation.mAdd) { 2993 addedFolder = folderOperation.mFolder; 2994 } else { 2995 hasRemove = true; 2996 } 2997 } 2998 2999 if (hasRemove && addedFolder != null) { 3000 actionFolder = addedFolder; 3001 } else { 3002 actionFolder = mFolder; 3003 } 3004 } 3005 3006 folderChange = getDeferredFolderChange(target, folderOps, isDestructive, 3007 batch, showUndo, isMoveTo, actionFolder, undoCallback); 3008 delete(0, target, folderChange, batch); 3009 } else { 3010 folderChange = getFolderChange(target, folderOps, isDestructive, 3011 batch, showUndo, false /* isMoveTo */, mFolder, undoCallback); 3012 requestUpdate(folderChange); 3013 } 3014 } 3015 3016 @Override 3017 public final void onRefreshRequired() { 3018 if (isAnimating() || isDragging()) { 3019 final ConversationListFragment f = getConversationListFragment(); 3020 LogUtils.w(ConversationCursor.LOG_TAG, 3021 "onRefreshRequired: delay until animating done. cursor=%s adapter=%s", 3022 mConversationListCursor, (f != null) ? f.getAnimatedAdapter() : null); 3023 return; 3024 } 3025 // Refresh the query in the background 3026 if (mConversationListCursor.isRefreshRequired()) { 3027 mConversationListCursor.refresh(); 3028 } 3029 } 3030 3031 @Override 3032 public void startDragMode() { 3033 mIsDragHappening = true; 3034 } 3035 3036 @Override 3037 public void stopDragMode() { 3038 mIsDragHappening = false; 3039 if (mConversationListCursor.isRefreshReady()) { 3040 LogUtils.i(ConversationCursor.LOG_TAG, "Stopped dragging: try sync"); 3041 onRefreshReady(); 3042 } 3043 3044 if (mConversationListCursor.isRefreshRequired()) { 3045 LogUtils.i(ConversationCursor.LOG_TAG, "Stopped dragging: refresh"); 3046 mConversationListCursor.refresh(); 3047 } 3048 } 3049 3050 private boolean isDragging() { 3051 return mIsDragHappening; 3052 } 3053 3054 @Override 3055 public boolean isAnimating() { 3056 boolean isAnimating = false; 3057 ConversationListFragment convListFragment = getConversationListFragment(); 3058 if (convListFragment != null) { 3059 isAnimating = convListFragment.isAnimating(); 3060 } 3061 return isAnimating; 3062 } 3063 3064 /** 3065 * Called when the {@link ConversationCursor} is changed or has new data in it. 3066 * <p> 3067 * {@inheritDoc} 3068 */ 3069 @Override 3070 public final void onRefreshReady() { 3071 LogUtils.d(LOG_TAG, "Received refresh ready callback for folder %s", 3072 mFolder != null ? mFolder.id : "-1"); 3073 3074 if (mDestroyed) { 3075 LogUtils.i(LOG_TAG, "ignoring onRefreshReady on destroyed AAC"); 3076 return; 3077 } 3078 3079 if (!isAnimating()) { 3080 // Swap cursors 3081 mConversationListCursor.sync(); 3082 } else { 3083 // (CLF guaranteed to be non-null due to check in isAnimating) 3084 LogUtils.w(LOG_TAG, 3085 "AAC.onRefreshReady suppressing sync() due to animation. cursor=%s aa=%s", 3086 mConversationListCursor, getConversationListFragment().getAnimatedAdapter()); 3087 } 3088 mTracker.onCursorUpdated(); 3089 perhapsShowFirstSearchResult(); 3090 } 3091 3092 @Override 3093 public final void onDataSetChanged() { 3094 updateConversationListFragment(); 3095 mConversationListObservable.notifyChanged(); 3096 mSelectedSet.validateAgainstCursor(mConversationListCursor); 3097 } 3098 3099 /** 3100 * If the Conversation List Fragment is visible, updates the fragment. 3101 */ 3102 private void updateConversationListFragment() { 3103 final ConversationListFragment convList = getConversationListFragment(); 3104 if (convList != null) { 3105 refreshConversationList(); 3106 if (isFragmentVisible(convList)) { 3107 informCursorVisiblity(true); 3108 } 3109 } 3110 } 3111 3112 /** 3113 * This class handles throttled refresh of the conversation list 3114 */ 3115 static class RefreshTimerTask extends TimerTask { 3116 final Handler mHandler; 3117 final AbstractActivityController mController; 3118 3119 RefreshTimerTask(AbstractActivityController controller, Handler handler) { 3120 mHandler = handler; 3121 mController = controller; 3122 } 3123 3124 @Override 3125 public void run() { 3126 mHandler.post(new Runnable() { 3127 @Override 3128 public void run() { 3129 LogUtils.d(LOG_TAG, "Delay done... calling onRefreshRequired"); 3130 mController.onRefreshRequired(); 3131 }}); 3132 } 3133 } 3134 3135 /** 3136 * Cancel the refresh task, if it's running 3137 */ 3138 private void cancelRefreshTask () { 3139 if (mConversationListRefreshTask != null) { 3140 mConversationListRefreshTask.cancel(); 3141 mConversationListRefreshTask = null; 3142 } 3143 } 3144 3145 @Override 3146 public void onAnimationEnd(AnimatedAdapter animatedAdapter) { 3147 if (animatedAdapter != null) { 3148 LogUtils.i(LOG_TAG, "AAC.onAnimationEnd. cursor=%s adapter=%s", mConversationListCursor, 3149 animatedAdapter); 3150 } 3151 if (mConversationListCursor == null) { 3152 LogUtils.e(LOG_TAG, "null ConversationCursor in onAnimationEnd"); 3153 return; 3154 } 3155 if (mConversationListCursor.isRefreshReady()) { 3156 LogUtils.i(ConversationCursor.LOG_TAG, "Stopped animating: try sync"); 3157 onRefreshReady(); 3158 } 3159 3160 if (mConversationListCursor.isRefreshRequired()) { 3161 LogUtils.i(ConversationCursor.LOG_TAG, "Stopped animating: refresh"); 3162 mConversationListCursor.refresh(); 3163 } 3164 if (mRecentsDataUpdated) { 3165 mRecentsDataUpdated = false; 3166 mRecentFolderObservers.notifyChanged(); 3167 } 3168 } 3169 3170 @Override 3171 public void onSetEmpty() { 3172 // There are no selected conversations. Ensure that the listener and its associated actions 3173 // are blanked out. 3174 setListener(null, -1); 3175 } 3176 3177 @Override 3178 public void onSetPopulated(ConversationSelectionSet set) { 3179 mCabActionMenu = new SelectedConversationsActionMenu(mActivity, set, mFolder); 3180 if (mViewMode.isListMode() || (mIsTablet && mViewMode.isConversationMode())) { 3181 enableCabMode(); 3182 } 3183 } 3184 3185 @Override 3186 public void onSetChanged(ConversationSelectionSet set) { 3187 // Do nothing. We don't care about changes to the set. 3188 } 3189 3190 @Override 3191 public ConversationSelectionSet getSelectedSet() { 3192 return mSelectedSet; 3193 } 3194 3195 /** 3196 * Disable the Contextual Action Bar (CAB). The selected set is not changed. 3197 */ 3198 protected void disableCabMode() { 3199 // Commit any previous destructive actions when entering/ exiting CAB mode. 3200 commitDestructiveActions(true); 3201 if (mCabActionMenu != null) { 3202 mCabActionMenu.deactivate(); 3203 } 3204 } 3205 3206 /** 3207 * Re-enable the CAB menu if required. The selection set is not changed. 3208 */ 3209 protected void enableCabMode() { 3210 if (mCabActionMenu != null && 3211 !(isDrawerEnabled() && mDrawerContainer.isDrawerOpen(mDrawerPullout))) { 3212 mCabActionMenu.activate(); 3213 } 3214 } 3215 3216 /** 3217 * Re-enable CAB mode only if we have an active selection 3218 */ 3219 protected void maybeEnableCabMode() { 3220 if (!mSelectedSet.isEmpty()) { 3221 if (mCabActionMenu != null) { 3222 mCabActionMenu.activate(); 3223 } 3224 } 3225 } 3226 3227 /** 3228 * Unselect conversations and exit CAB mode. 3229 */ 3230 protected final void exitCabMode() { 3231 mSelectedSet.clear(); 3232 } 3233 3234 @Override 3235 public void startSearch() { 3236 if (mAccount == null) { 3237 // We cannot search if there is no account. Drop the request to the floor. 3238 LogUtils.d(LOG_TAG, "AbstractActivityController.startSearch(): null account"); 3239 return; 3240 } 3241 if (mAccount.supportsSearch()) { 3242 mActionBarController.expandSearch(); 3243 } else { 3244 Toast.makeText(mActivity.getActivityContext(), mActivity.getActivityContext() 3245 .getString(R.string.search_unsupported), Toast.LENGTH_SHORT).show(); 3246 } 3247 } 3248 3249 @Override 3250 public void exitSearchMode() { 3251 if (mViewMode.getMode() == ViewMode.SEARCH_RESULTS_LIST) { 3252 mActivity.finish(); 3253 } 3254 } 3255 3256 /** 3257 * Supports dragging conversations to a folder. 3258 */ 3259 @Override 3260 public boolean supportsDrag(DragEvent event, Folder folder) { 3261 return (folder != null 3262 && event != null 3263 && event.getClipDescription() != null 3264 && folder.supportsCapability 3265 (UIProvider.FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES) 3266 && !mFolder.equals(folder)); 3267 } 3268 3269 /** 3270 * Handles dropping conversations to a folder. 3271 */ 3272 @Override 3273 public void handleDrop(DragEvent event, final Folder folder) { 3274 if (!supportsDrag(event, folder)) { 3275 return; 3276 } 3277 if (folder.isType(UIProvider.FolderType.STARRED)) { 3278 // Moving a conversation to the starred folder adds the star and 3279 // removes the current label 3280 handleDropInStarred(folder); 3281 return; 3282 } 3283 if (mFolder.isType(UIProvider.FolderType.STARRED)) { 3284 handleDragFromStarred(folder); 3285 return; 3286 } 3287 final ArrayList<FolderOperation> dragDropOperations = new ArrayList<FolderOperation>(); 3288 final Collection<Conversation> conversations = mSelectedSet.values(); 3289 // Add the drop target folder. 3290 dragDropOperations.add(new FolderOperation(folder, true)); 3291 // Remove the current folder unless the user is viewing "all". 3292 // That operation should just add the new folder. 3293 boolean isDestructive = !mFolder.isViewAll() 3294 && mFolder.supportsCapability 3295 (UIProvider.FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES); 3296 if (isDestructive) { 3297 dragDropOperations.add(new FolderOperation(mFolder, false)); 3298 } 3299 // Drag and drop is destructive: we remove conversations from the 3300 // current folder. 3301 final DestructiveAction action = 3302 getFolderChange(conversations, dragDropOperations, isDestructive, 3303 true /* isBatch */, true /* showUndo */, true /* isMoveTo */, folder, 3304 null /* undoCallback */); 3305 if (isDestructive) { 3306 delete(0, conversations, action, true); 3307 } else { 3308 action.performAction(); 3309 } 3310 } 3311 3312 private void handleDragFromStarred(Folder folder) { 3313 final Collection<Conversation> conversations = mSelectedSet.values(); 3314 // The conversation list deletes and performs the action if it exists. 3315 final ConversationListFragment convListFragment = getConversationListFragment(); 3316 // There should always be a convlistfragment, or the user could not have 3317 // dragged/ dropped conversations. 3318 if (convListFragment != null) { 3319 LogUtils.d(LOG_TAG, "AAC.requestDelete: ListFragment is handling delete."); 3320 ArrayList<ConversationOperation> ops = new ArrayList<ConversationOperation>(); 3321 ArrayList<Uri> folderUris; 3322 ArrayList<Boolean> adds; 3323 for (Conversation target : conversations) { 3324 folderUris = new ArrayList<Uri>(); 3325 adds = new ArrayList<Boolean>(); 3326 folderUris.add(folder.folderUri.fullUri); 3327 adds.add(Boolean.TRUE); 3328 final HashMap<Uri, Folder> targetFolders = 3329 Folder.hashMapForFolders(target.getRawFolders()); 3330 targetFolders.put(folder.folderUri.fullUri, folder); 3331 ops.add(mConversationListCursor.getConversationFolderOperation(target, 3332 folderUris, adds, targetFolders.values())); 3333 } 3334 if (mConversationListCursor != null) { 3335 mConversationListCursor.updateBulkValues(ops); 3336 } 3337 refreshConversationList(); 3338 mSelectedSet.clear(); 3339 } 3340 } 3341 3342 private void handleDropInStarred(Folder folder) { 3343 final Collection<Conversation> conversations = mSelectedSet.values(); 3344 // The conversation list deletes and performs the action if it exists. 3345 final ConversationListFragment convListFragment = getConversationListFragment(); 3346 // There should always be a convlistfragment, or the user could not have 3347 // dragged/ dropped conversations. 3348 if (convListFragment != null) { 3349 LogUtils.d(LOG_TAG, "AAC.requestDelete: ListFragment is handling delete."); 3350 convListFragment.requestDelete(R.id.change_folders, conversations, 3351 new DroppedInStarredAction(conversations, mFolder, folder)); 3352 } 3353 } 3354 3355 // When dragging conversations to the starred folder, remove from the 3356 // original folder and add a star 3357 private class DroppedInStarredAction implements DestructiveAction { 3358 private final Collection<Conversation> mConversations; 3359 private final Folder mInitialFolder; 3360 private final Folder mStarred; 3361 3362 public DroppedInStarredAction(Collection<Conversation> conversations, Folder initialFolder, 3363 Folder starredFolder) { 3364 mConversations = conversations; 3365 mInitialFolder = initialFolder; 3366 mStarred = starredFolder; 3367 } 3368 3369 @Override 3370 public void setUndoCallback(UndoCallback undoCallback) { 3371 return; // currently not applicable 3372 } 3373 3374 @Override 3375 public void performAction() { 3376 ToastBarOperation undoOp = new ToastBarOperation(mConversations.size(), 3377 R.id.change_folders, ToastBarOperation.UNDO, true /* batch */, mInitialFolder); 3378 onUndoAvailable(undoOp); 3379 ArrayList<ConversationOperation> ops = new ArrayList<ConversationOperation>(); 3380 ContentValues values = new ContentValues(); 3381 ArrayList<Uri> folderUris; 3382 ArrayList<Boolean> adds; 3383 ConversationOperation operation; 3384 for (Conversation target : mConversations) { 3385 folderUris = new ArrayList<Uri>(); 3386 adds = new ArrayList<Boolean>(); 3387 folderUris.add(mStarred.folderUri.fullUri); 3388 adds.add(Boolean.TRUE); 3389 folderUris.add(mInitialFolder.folderUri.fullUri); 3390 adds.add(Boolean.FALSE); 3391 final HashMap<Uri, Folder> targetFolders = 3392 Folder.hashMapForFolders(target.getRawFolders()); 3393 targetFolders.put(mStarred.folderUri.fullUri, mStarred); 3394 targetFolders.remove(mInitialFolder.folderUri.fullUri); 3395 values.put(ConversationColumns.STARRED, true); 3396 operation = mConversationListCursor.getConversationFolderOperation(target, 3397 folderUris, adds, targetFolders.values(), values); 3398 ops.add(operation); 3399 } 3400 if (mConversationListCursor != null) { 3401 mConversationListCursor.updateBulkValues(ops); 3402 } 3403 refreshConversationList(); 3404 mSelectedSet.clear(); 3405 } 3406 } 3407 3408 @Override 3409 public void onTouchEvent(MotionEvent event) { 3410 if (event.getAction() == MotionEvent.ACTION_DOWN) { 3411 if (mToastBar != null && !mToastBar.isEventInToastBar(event)) { 3412 // if the toast bar is still animating, ignore this attempt to hide it 3413 if (mToastBar.isAnimating()) { 3414 return; 3415 } 3416 3417 // if the toast bar has not been seen long enough, ignore this attempt to hide it 3418 if (mToastBar.cannotBeHidden()) { 3419 return; 3420 } 3421 3422 // hide the toast bar 3423 mToastBar.hide(true /* animated */, false /* actionClicked */); 3424 } 3425 } 3426 } 3427 3428 @Override 3429 public void onConversationSeen() { 3430 mPagerController.onConversationSeen(); 3431 } 3432 3433 @Override 3434 public boolean isInitialConversationLoading() { 3435 return mPagerController.isInitialConversationLoading(); 3436 } 3437 3438 /** 3439 * Check if the fragment given here is visible. Checking {@link Fragment#isVisible()} is 3440 * insufficient because that doesn't check if the window is currently in focus or not. 3441 */ 3442 private boolean isFragmentVisible(Fragment in) { 3443 return in != null && in.isVisible() && mActivity.hasWindowFocus(); 3444 } 3445 3446 /** 3447 * This class handles callbacks that create a {@link ConversationCursor}. 3448 */ 3449 private class ConversationListLoaderCallbacks implements 3450 LoaderManager.LoaderCallbacks<ConversationCursor> { 3451 3452 @Override 3453 public Loader<ConversationCursor> onCreateLoader(int id, Bundle args) { 3454 final Account account = args.getParcelable(BUNDLE_ACCOUNT_KEY); 3455 final Folder folder = args.getParcelable(BUNDLE_FOLDER_KEY); 3456 final boolean ignoreInitialConversationLimit = 3457 args.getBoolean(BUNDLE_IGNORE_INITIAL_CONVERSATION_LIMIT_KEY, false); 3458 if (account == null || folder == null) { 3459 return null; 3460 } 3461 return new ConversationCursorLoader(mActivity, account, 3462 folder.conversationListUri, folder.getTypeDescription(), 3463 ignoreInitialConversationLimit); 3464 } 3465 3466 @Override 3467 public void onLoadFinished(Loader<ConversationCursor> loader, ConversationCursor data) { 3468 LogUtils.d(LOG_TAG, 3469 "IN AAC.ConversationCursor.onLoadFinished, data=%s loader=%s this=%s", 3470 data, loader, this); 3471 if (isDrawerEnabled() && mDrawerListener.getDrawerState() != DrawerLayout.STATE_IDLE) { 3472 LogUtils.d(LOG_TAG, "ConversationListLoaderCallbacks.onLoadFinished: ignoring."); 3473 mConversationListLoadFinishedIgnored = true; 3474 return; 3475 } 3476 // Clear our all pending destructive actions before swapping the conversation cursor 3477 destroyPending(null); 3478 mConversationListCursor = data; 3479 mConversationListCursor.addListener(AbstractActivityController.this); 3480 mDrawIdler.setListener(mConversationListCursor); 3481 mTracker.onCursorUpdated(); 3482 mConversationListObservable.notifyChanged(); 3483 // Handle actions that were deferred until after the conversation list was loaded. 3484 for (LoadFinishedCallback callback : mConversationListLoadFinishedCallbacks) { 3485 callback.onLoadFinished(); 3486 } 3487 mConversationListLoadFinishedCallbacks.clear(); 3488 3489 final ConversationListFragment convList = getConversationListFragment(); 3490 if (isFragmentVisible(convList)) { 3491 // The conversation list is already listening to list changes and gets notified 3492 // in the mConversationListObservable.notifyChanged() line above. We only need to 3493 // check and inform the cursor of the change in visibility here. 3494 informCursorVisiblity(true); 3495 } 3496 perhapsShowFirstSearchResult(); 3497 } 3498 3499 @Override 3500 public void onLoaderReset(Loader<ConversationCursor> loader) { 3501 LogUtils.d(LOG_TAG, 3502 "IN AAC.ConversationCursor.onLoaderReset, data=%s loader=%s this=%s", 3503 mConversationListCursor, loader, this); 3504 3505 if (mConversationListCursor != null) { 3506 // Unregister the listener 3507 mConversationListCursor.removeListener(AbstractActivityController.this); 3508 mDrawIdler.setListener(null); 3509 mConversationListCursor = null; 3510 3511 // Inform anyone who is interested about the change 3512 mTracker.onCursorUpdated(); 3513 mConversationListObservable.notifyChanged(); 3514 } 3515 } 3516 } 3517 3518 /** 3519 * Class to perform {@link LoaderManager.LoaderCallbacks} for creating {@link Folder} objects. 3520 */ 3521 private class FolderLoads implements LoaderManager.LoaderCallbacks<ObjectCursor<Folder>> { 3522 @Override 3523 public Loader<ObjectCursor<Folder>> onCreateLoader(int id, Bundle args) { 3524 final String[] everything = UIProvider.FOLDERS_PROJECTION; 3525 switch (id) { 3526 case LOADER_FOLDER_CURSOR: 3527 LogUtils.d(LOG_TAG, "LOADER_FOLDER_CURSOR created"); 3528 final ObjectCursorLoader<Folder> loader = new 3529 ObjectCursorLoader<Folder>( 3530 mContext, mFolder.folderUri.fullUri, everything, Folder.FACTORY); 3531 loader.setUpdateThrottle(mFolderItemUpdateDelayMs); 3532 return loader; 3533 case LOADER_RECENT_FOLDERS: 3534 LogUtils.d(LOG_TAG, "LOADER_RECENT_FOLDERS created"); 3535 if (mAccount != null && mAccount.recentFolderListUri != null 3536 && !mAccount.recentFolderListUri.equals(Uri.EMPTY)) { 3537 return new ObjectCursorLoader<Folder>(mContext, 3538 mAccount.recentFolderListUri, everything, Folder.FACTORY); 3539 } 3540 break; 3541 case LOADER_ACCOUNT_INBOX: 3542 LogUtils.d(LOG_TAG, "LOADER_ACCOUNT_INBOX created"); 3543 final Uri defaultInbox = Settings.getDefaultInboxUri(mAccount.settings); 3544 final Uri inboxUri = defaultInbox.equals(Uri.EMPTY) ? 3545 mAccount.folderListUri : defaultInbox; 3546 LogUtils.d(LOG_TAG, "Loading the default inbox: %s", inboxUri); 3547 if (inboxUri != null) { 3548 return new ObjectCursorLoader<Folder>(mContext, inboxUri, 3549 everything, Folder.FACTORY); 3550 } 3551 break; 3552 case LOADER_SEARCH: 3553 LogUtils.d(LOG_TAG, "LOADER_SEARCH created"); 3554 return Folder.forSearchResults(mAccount, 3555 args.getString(ConversationListContext.EXTRA_SEARCH_QUERY), 3556 // We can just use current time as a unique identifier for this search 3557 Long.toString(SystemClock.uptimeMillis()), 3558 mActivity.getActivityContext()); 3559 case LOADER_FIRST_FOLDER: 3560 LogUtils.d(LOG_TAG, "LOADER_FIRST_FOLDER created"); 3561 final Uri folderUri = args.getParcelable(Utils.EXTRA_FOLDER_URI); 3562 mConversationToShow = args.getParcelable(Utils.EXTRA_CONVERSATION); 3563 if (mConversationToShow != null && mConversationToShow.position < 0){ 3564 mConversationToShow.position = 0; 3565 } 3566 return new ObjectCursorLoader<Folder>(mContext, folderUri, 3567 everything, Folder.FACTORY); 3568 default: 3569 LogUtils.wtf(LOG_TAG, "FolderLoads.onCreateLoader(%d) for invalid id", id); 3570 return null; 3571 } 3572 return null; 3573 } 3574 3575 @Override 3576 public void onLoadFinished(Loader<ObjectCursor<Folder>> loader, ObjectCursor<Folder> data) { 3577 if (data == null) { 3578 LogUtils.e(LOG_TAG, "Received null cursor from loader id: %d", loader.getId()); 3579 } 3580 switch (loader.getId()) { 3581 case LOADER_FOLDER_CURSOR: 3582 if (data != null && data.moveToFirst()) { 3583 final Folder folder = data.getModel(); 3584 setHasFolderChanged(folder); 3585 mFolder = folder; 3586 mFolderObservable.notifyChanged(); 3587 } else { 3588 LogUtils.d(LOG_TAG, "Unable to get the folder %s", 3589 mFolder != null ? mFolder.name : ""); 3590 } 3591 break; 3592 case LOADER_RECENT_FOLDERS: 3593 // Few recent folders and we are running on a phone? Populate the default 3594 // recents. The number of default recent folders is at least 2: every provider 3595 // has at least two folders, and the recent folder count never decreases. 3596 // Having a single recent folder is an erroneous case, and we can gracefully 3597 // recover by populating default recents. The default recents will not stomp on 3598 // the existing value: it will be shown in addition to the default folders: 3599 // the max number of recent folders is more than 1+num(defaultRecents). 3600 if (data != null && data.getCount() <= 1 && !mIsTablet) { 3601 final class PopulateDefault extends AsyncTask<Uri, Void, Void> { 3602 @Override 3603 protected Void doInBackground(Uri... uri) { 3604 // Asking for an update on the URI and ignore the result. 3605 final ContentResolver resolver = mContext.getContentResolver(); 3606 resolver.update(uri[0], null, null, null); 3607 return null; 3608 } 3609 } 3610 final Uri uri = mAccount.defaultRecentFolderListUri; 3611 LogUtils.v(LOG_TAG, "Default recents at %s", uri); 3612 new PopulateDefault().execute(uri); 3613 break; 3614 } 3615 LogUtils.v(LOG_TAG, "Reading recent folders from the cursor."); 3616 mRecentFolderList.loadFromUiProvider(data); 3617 if (isAnimating()) { 3618 mRecentsDataUpdated = true; 3619 } else { 3620 mRecentFolderObservers.notifyChanged(); 3621 } 3622 break; 3623 case LOADER_ACCOUNT_INBOX: 3624 if (data != null && !data.isClosed() && data.moveToFirst()) { 3625 final Folder inbox = data.getModel(); 3626 onFolderChanged(inbox, false /* force */); 3627 // Just want to get the inbox, don't care about updates to it 3628 // as this will be tracked by the folder change listener. 3629 mActivity.getLoaderManager().destroyLoader(LOADER_ACCOUNT_INBOX); 3630 } else { 3631 LogUtils.d(LOG_TAG, "Unable to get the account inbox for account %s", 3632 mAccount != null ? mAccount.getEmailAddress() : ""); 3633 } 3634 break; 3635 case LOADER_SEARCH: 3636 if (data != null && data.getCount() > 0) { 3637 data.moveToFirst(); 3638 final Folder search = data.getModel(); 3639 updateFolder(search); 3640 mConvListContext = ConversationListContext.forSearchQuery(mAccount, mFolder, 3641 mActivity.getIntent() 3642 .getStringExtra(UIProvider.SearchQueryParameters.QUERY)); 3643 showConversationList(mConvListContext); 3644 mActivity.invalidateOptionsMenu(); 3645 mHaveSearchResults = search.totalCount > 0; 3646 mActivity.getLoaderManager().destroyLoader(LOADER_SEARCH); 3647 } else { 3648 LogUtils.e(LOG_TAG, "Null/empty cursor returned by LOADER_SEARCH loader"); 3649 } 3650 break; 3651 case LOADER_FIRST_FOLDER: 3652 if (data == null || data.getCount() <=0 || !data.moveToFirst()) { 3653 return; 3654 } 3655 final Folder folder = data.getModel(); 3656 boolean handled = false; 3657 if (folder != null) { 3658 onFolderChanged(folder, false /* force */); 3659 handled = true; 3660 } 3661 if (mConversationToShow != null) { 3662 // Open the conversation. 3663 showConversation(mConversationToShow); 3664 handled = true; 3665 } 3666 if (!handled) { 3667 // We have an account, but nothing else: load the default inbox. 3668 loadAccountInbox(); 3669 } 3670 mConversationToShow = null; 3671 // And don't run this anymore. 3672 mActivity.getLoaderManager().destroyLoader(LOADER_FIRST_FOLDER); 3673 break; 3674 } 3675 } 3676 3677 @Override 3678 public void onLoaderReset(Loader<ObjectCursor<Folder>> loader) { 3679 } 3680 } 3681 3682 /** 3683 * Class to perform {@link LoaderManager.LoaderCallbacks} for creating {@link Account} objects. 3684 */ 3685 private class AccountLoads implements LoaderManager.LoaderCallbacks<ObjectCursor<Account>> { 3686 final String[] mProjection = UIProvider.ACCOUNTS_PROJECTION; 3687 final CursorCreator<Account> mFactory = Account.FACTORY; 3688 3689 @Override 3690 public Loader<ObjectCursor<Account>> onCreateLoader(int id, Bundle args) { 3691 switch (id) { 3692 case LOADER_ACCOUNT_CURSOR: 3693 LogUtils.d(LOG_TAG, "LOADER_ACCOUNT_CURSOR created"); 3694 return new ObjectCursorLoader<Account>(mContext, 3695 MailAppProvider.getAccountsUri(), mProjection, mFactory); 3696 case LOADER_ACCOUNT_UPDATE_CURSOR: 3697 LogUtils.d(LOG_TAG, "LOADER_ACCOUNT_UPDATE_CURSOR created"); 3698 return new ObjectCursorLoader<Account>(mContext, mAccount.uri, mProjection, 3699 mFactory); 3700 default: 3701 LogUtils.wtf(LOG_TAG, "Got an id (%d) that I cannot create!", id); 3702 break; 3703 } 3704 return null; 3705 } 3706 3707 @Override 3708 public void onLoadFinished(Loader<ObjectCursor<Account>> loader, 3709 ObjectCursor<Account> data) { 3710 if (data == null) { 3711 LogUtils.e(LOG_TAG, "Received null cursor from loader id: %d", loader.getId()); 3712 } 3713 switch (loader.getId()) { 3714 case LOADER_ACCOUNT_CURSOR: 3715 // We have received an update on the list of accounts. 3716 if (data == null) { 3717 // Nothing useful to do if we have no valid data. 3718 break; 3719 } 3720 final long count = data.getCount(); 3721 if (count == 0) { 3722 // If an empty cursor is returned, the MailAppProvider is indicating that 3723 // no accounts have been specified. We want to navigate to the 3724 // "add account" activity that will handle the intent returned by the 3725 // MailAppProvider 3726 3727 // If the MailAppProvider believes that all accounts have been loaded, 3728 // and the account list is still empty, we want to prompt the user to add 3729 // an account. 3730 final Bundle extras = data.getExtras(); 3731 final boolean accountsLoaded = 3732 extras.getInt(AccountCursorExtraKeys.ACCOUNTS_LOADED) != 0; 3733 3734 if (accountsLoaded) { 3735 final Intent noAccountIntent = MailAppProvider.getNoAccountIntent 3736 (mContext); 3737 if (noAccountIntent != null) { 3738 mActivity.startActivityForResult(noAccountIntent, 3739 ADD_ACCOUNT_REQUEST_CODE); 3740 } 3741 } 3742 } else { 3743 final boolean accountListUpdated = accountsUpdated(data); 3744 if (!mHaveAccountList || accountListUpdated) { 3745 mHaveAccountList = updateAccounts(data); 3746 } 3747 Analytics.getInstance().setCustomDimension(Analytics.CD_INDEX_ACCOUNT_COUNT, 3748 Long.toString(count)); 3749 } 3750 break; 3751 case LOADER_ACCOUNT_UPDATE_CURSOR: 3752 // We have received an update for current account. 3753 if (data != null && data.moveToFirst()) { 3754 final Account updatedAccount = data.getModel(); 3755 // Make sure that this is an update for the current account 3756 if (updatedAccount.uri.equals(mAccount.uri)) { 3757 final Settings previousSettings = mAccount.settings; 3758 3759 // Update the controller's reference to the current account 3760 mAccount = updatedAccount; 3761 LogUtils.d(LOG_TAG, "AbstractActivityController.onLoadFinished(): " 3762 + "mAccount = %s", mAccount.uri); 3763 3764 // Only notify about a settings change if something differs 3765 if (!Objects.equal(mAccount.settings, previousSettings)) { 3766 mAccountObservers.notifyChanged(); 3767 } 3768 perhapsEnterWaitMode(); 3769 perhapsStartWelcomeTour(); 3770 } else { 3771 LogUtils.e(LOG_TAG, "Got update for account: %s with current account:" 3772 + " %s", updatedAccount.uri, mAccount.uri); 3773 // We need to restart the loader, so the correct account information 3774 // will be returned. 3775 restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR, this, Bundle.EMPTY); 3776 } 3777 } 3778 break; 3779 } 3780 } 3781 3782 @Override 3783 public void onLoaderReset(Loader<ObjectCursor<Account>> loader) { 3784 // Do nothing. In onLoadFinished() we copy the relevant data from the cursor. 3785 } 3786 } 3787 3788 /** 3789 * Loads the preference that tells whether the welcome tour should be displayed, 3790 * and calls the callback with this value. 3791 * For this to function, the account must have been synced. 3792 */ 3793 private void perhapsStartWelcomeTour() { 3794 new AsyncTask<Void, Void, Boolean>() { 3795 @Override 3796 protected Boolean doInBackground(Void... params) { 3797 if (mActivity.wasLatestWelcomeTourShownOnDeviceForAllAccounts()) { 3798 // No need to go through the WelcomeStateLoader machinery. 3799 return false; 3800 } 3801 return true; 3802 } 3803 3804 @Override 3805 protected void onPostExecute(Boolean result) { 3806 if (result) { 3807 if (mAccount != null && mAccount.isAccountReady()) { 3808 LoaderManager.LoaderCallbacks<?> welcomeLoaderCallbacks = 3809 mActivity.getWelcomeCallbacks(); 3810 if (welcomeLoaderCallbacks != null) { 3811 // The callback is responsible for showing the tour when appropriate. 3812 mActivity.getLoaderManager().initLoader(LOADER_WELCOME_TOUR_ACCOUNTS, 3813 Bundle.EMPTY, welcomeLoaderCallbacks); 3814 } 3815 } 3816 } else { 3817 // User has already run this version of the app. 3818 Analytics.getInstance().setCustomDimension( 3819 Analytics.CD_INDEX_USER_RETENTION_TYPE, 3820 Analytics.CD_VALUE_USER_RETENTION_TYPE_RETURNING); 3821 } 3822 } 3823 }.execute(); 3824 } 3825 3826 /** 3827 * Updates controller state based on search results and shows first conversation if required. 3828 */ 3829 private void perhapsShowFirstSearchResult() { 3830 if (mCurrentConversation == null) { 3831 // Shown for search results in two-pane mode only. 3832 mHaveSearchResults = Intent.ACTION_SEARCH.equals(mActivity.getIntent().getAction()) 3833 && mConversationListCursor.getCount() > 0; 3834 if (!shouldShowFirstConversation()) { 3835 return; 3836 } 3837 mConversationListCursor.moveToPosition(0); 3838 final Conversation conv = new Conversation(mConversationListCursor); 3839 conv.position = 0; 3840 onConversationSelected(conv, true /* checkSafeToModifyFragments */); 3841 } 3842 } 3843 3844 /** 3845 * Destroy the pending {@link DestructiveAction} till now and assign the given action as the 3846 * next destructive action.. 3847 * @param nextAction the next destructive action to be performed. This can be null. 3848 */ 3849 private void destroyPending(DestructiveAction nextAction) { 3850 // If there is a pending action, perform that first. 3851 if (mPendingDestruction != null) { 3852 mPendingDestruction.performAction(); 3853 } 3854 mPendingDestruction = nextAction; 3855 } 3856 3857 /** 3858 * Register a destructive action with the controller. This performs the previous destructive 3859 * action as a side effect. This method is final because we don't want the child classes to 3860 * embellish this method any more. 3861 * @param action the action to register. 3862 */ 3863 private void registerDestructiveAction(DestructiveAction action) { 3864 // TODO(viki): This is not a good idea. The best solution is for clients to request a 3865 // destructive action from the controller and for the controller to own the action. This is 3866 // a half-way solution while refactoring DestructiveAction. 3867 destroyPending(action); 3868 } 3869 3870 @Override 3871 public final DestructiveAction getBatchAction(int action, UndoCallback undoCallback) { 3872 final DestructiveAction da = new ConversationAction(action, mSelectedSet.values(), true); 3873 da.setUndoCallback(undoCallback); 3874 registerDestructiveAction(da); 3875 return da; 3876 } 3877 3878 @Override 3879 public final DestructiveAction getDeferredBatchAction(int action, UndoCallback undoCallback) { 3880 return getDeferredAction(action, mSelectedSet.values(), true, undoCallback); 3881 } 3882 3883 /** 3884 * Get a destructive action for a menu action. This is a temporary method, 3885 * to control the profusion of {@link DestructiveAction} classes that are 3886 * created. Please do not copy this paradigm. 3887 * @param action the resource ID of the menu action: R.id.delete, for 3888 * example 3889 * @param target the conversations to act upon. 3890 * @return a {@link DestructiveAction} that performs the specified action. 3891 */ 3892 private DestructiveAction getDeferredAction(int action, Collection<Conversation> target, 3893 boolean batch, UndoCallback callback) { 3894 ConversationAction cAction = new ConversationAction(action, target, batch); 3895 cAction.setUndoCallback(callback); 3896 return cAction; 3897 } 3898 3899 /** 3900 * Class to change the folders that are assigned to a set of conversations. This is destructive 3901 * because the user can remove the current folder from the conversation, in which case it has 3902 * to be animated away from the current folder. 3903 */ 3904 private class FolderDestruction implements DestructiveAction { 3905 private final Collection<Conversation> mTarget; 3906 private final ArrayList<FolderOperation> mFolderOps = new ArrayList<FolderOperation>(); 3907 private final boolean mIsDestructive; 3908 /** Whether this destructive action has already been performed */ 3909 private boolean mCompleted; 3910 private final boolean mIsSelectedSet; 3911 private final boolean mShowUndo; 3912 private final int mAction; 3913 private final Folder mActionFolder; 3914 3915 private UndoCallback mUndoCallback; 3916 3917 /** 3918 * Create a new folder destruction object to act on the given conversations. 3919 * @param target conversations to act upon. 3920 * @param actionFolder the {@link Folder} being acted upon, used for displaying the undo bar 3921 */ 3922 private FolderDestruction(final Collection<Conversation> target, 3923 final Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 3924 boolean showUndo, int action, final Folder actionFolder) { 3925 mTarget = ImmutableList.copyOf(target); 3926 mFolderOps.addAll(folders); 3927 mIsDestructive = isDestructive; 3928 mIsSelectedSet = isBatch; 3929 mShowUndo = showUndo; 3930 mAction = action; 3931 mActionFolder = actionFolder; 3932 } 3933 3934 @Override 3935 public void setUndoCallback(UndoCallback undoCallback) { 3936 mUndoCallback = undoCallback; 3937 } 3938 3939 @Override 3940 public void performAction() { 3941 if (isPerformed()) { 3942 return; 3943 } 3944 if (mIsDestructive && mShowUndo) { 3945 ToastBarOperation undoOp = new ToastBarOperation(mTarget.size(), mAction, 3946 ToastBarOperation.UNDO, mIsSelectedSet, mActionFolder); 3947 onUndoAvailable(undoOp); 3948 } 3949 // For each conversation, for each operation, add/ remove the 3950 // appropriate folders. 3951 ArrayList<ConversationOperation> ops = new ArrayList<ConversationOperation>(); 3952 ArrayList<Uri> folderUris; 3953 ArrayList<Boolean> adds; 3954 for (Conversation target : mTarget) { 3955 HashMap<Uri, Folder> targetFolders = Folder.hashMapForFolders(target 3956 .getRawFolders()); 3957 folderUris = new ArrayList<Uri>(); 3958 adds = new ArrayList<Boolean>(); 3959 if (mIsDestructive) { 3960 target.localDeleteOnUpdate = true; 3961 } 3962 for (FolderOperation op : mFolderOps) { 3963 folderUris.add(op.mFolder.folderUri.fullUri); 3964 adds.add(op.mAdd ? Boolean.TRUE : Boolean.FALSE); 3965 if (op.mAdd) { 3966 targetFolders.put(op.mFolder.folderUri.fullUri, op.mFolder); 3967 } else { 3968 targetFolders.remove(op.mFolder.folderUri.fullUri); 3969 } 3970 } 3971 ops.add(mConversationListCursor.getConversationFolderOperation(target, 3972 folderUris, adds, targetFolders.values(), mUndoCallback)); 3973 } 3974 if (mConversationListCursor != null) { 3975 mConversationListCursor.updateBulkValues(ops); 3976 } 3977 refreshConversationList(); 3978 if (mIsSelectedSet) { 3979 mSelectedSet.clear(); 3980 } 3981 } 3982 3983 /** 3984 * Returns true if this action has been performed, false otherwise. 3985 * 3986 */ 3987 private synchronized boolean isPerformed() { 3988 if (mCompleted) { 3989 return true; 3990 } 3991 mCompleted = true; 3992 return false; 3993 } 3994 } 3995 3996 public final DestructiveAction getFolderChange(Collection<Conversation> target, 3997 Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 3998 boolean showUndo, final boolean isMoveTo, final Folder actionFolder, 3999 UndoCallback undoCallback) { 4000 final DestructiveAction da = getDeferredFolderChange(target, folders, isDestructive, 4001 isBatch, showUndo, isMoveTo, actionFolder, undoCallback); 4002 registerDestructiveAction(da); 4003 return da; 4004 } 4005 4006 public final DestructiveAction getDeferredFolderChange(Collection<Conversation> target, 4007 Collection<FolderOperation> folders, boolean isDestructive, boolean isBatch, 4008 boolean showUndo, final boolean isMoveTo, final Folder actionFolder, 4009 UndoCallback undoCallback) { 4010 final DestructiveAction fd = new FolderDestruction(target, folders, isDestructive, isBatch, 4011 showUndo, isMoveTo ? R.id.move_folder : R.id.change_folders, actionFolder); 4012 fd.setUndoCallback(undoCallback); 4013 return fd; 4014 } 4015 4016 @Override 4017 public final DestructiveAction getDeferredRemoveFolder(Collection<Conversation> target, 4018 Folder toRemove, boolean isDestructive, boolean isBatch, 4019 boolean showUndo, UndoCallback undoCallback) { 4020 Collection<FolderOperation> folderOps = new ArrayList<FolderOperation>(); 4021 folderOps.add(new FolderOperation(toRemove, false)); 4022 final DestructiveAction da = new FolderDestruction(target, folderOps, isDestructive, isBatch, 4023 showUndo, R.id.remove_folder, mFolder); 4024 da.setUndoCallback(undoCallback); 4025 return da; 4026 } 4027 4028 @Override 4029 public final void refreshConversationList() { 4030 final ConversationListFragment convList = getConversationListFragment(); 4031 if (convList == null) { 4032 return; 4033 } 4034 convList.requestListRefresh(); 4035 } 4036 4037 protected final ActionClickedListener getUndoClickedListener( 4038 final AnimatedAdapter listAdapter) { 4039 return new ActionClickedListener() { 4040 @Override 4041 public void onActionClicked(Context context) { 4042 if (mAccount.undoUri != null) { 4043 // NOTE: We might want undo to return the messages affected, in which case 4044 // the resulting cursor might be interesting... 4045 // TODO: Use UIProvider.SEQUENCE_QUERY_PARAMETER to indicate the set of 4046 // commands to undo 4047 if (mConversationListCursor != null) { 4048 mConversationListCursor.undo( 4049 mActivity.getActivityContext(), mAccount.undoUri); 4050 } 4051 if (listAdapter != null) { 4052 listAdapter.setUndo(true); 4053 } 4054 } 4055 } 4056 }; 4057 } 4058 4059 /** 4060 * Shows an error toast in the bottom when a folder was not fetched successfully. 4061 * @param folder the folder which could not be fetched. 4062 * @param replaceVisibleToast if true, this should replace any currently visible toast. 4063 */ 4064 protected final void showErrorToast(final Folder folder, boolean replaceVisibleToast) { 4065 4066 final ActionClickedListener listener; 4067 final int actionTextResourceId; 4068 final int lastSyncResult = folder.lastSyncResult; 4069 switch (lastSyncResult & 0x0f) { 4070 case UIProvider.LastSyncResult.CONNECTION_ERROR: 4071 // The sync request that caused this failure. 4072 final int syncRequest = lastSyncResult >> 4; 4073 // Show: User explicitly pressed the refresh button and there is no connection 4074 // Show: The first time the user enters the app and there is no connection 4075 // TODO(viki): Implement this. 4076 // Reference: http://b/7202801 4077 final boolean showToast = (syncRequest & UIProvider.SyncStatus.USER_REFRESH) != 0; 4078 // Don't show: Already in the app; user switches to a synced label 4079 // Don't show: In a live label and a background sync fails 4080 final boolean avoidToast = !showToast && (folder.syncWindow > 0 4081 || (syncRequest & UIProvider.SyncStatus.BACKGROUND_SYNC) != 0); 4082 if (avoidToast) { 4083 return; 4084 } 4085 listener = getRetryClickedListener(folder); 4086 actionTextResourceId = R.string.retry; 4087 break; 4088 case UIProvider.LastSyncResult.AUTH_ERROR: 4089 listener = getSignInClickedListener(); 4090 actionTextResourceId = R.string.signin; 4091 break; 4092 case UIProvider.LastSyncResult.SECURITY_ERROR: 4093 return; // Currently we do nothing for security errors. 4094 case UIProvider.LastSyncResult.STORAGE_ERROR: 4095 listener = getStorageErrorClickedListener(); 4096 actionTextResourceId = R.string.info; 4097 break; 4098 case UIProvider.LastSyncResult.INTERNAL_ERROR: 4099 listener = getInternalErrorClickedListener(); 4100 actionTextResourceId = R.string.report; 4101 break; 4102 default: 4103 return; 4104 } 4105 mToastBar.show(listener, 4106 Utils.getSyncStatusText(mActivity.getActivityContext(), lastSyncResult), 4107 actionTextResourceId, 4108 replaceVisibleToast, 4109 new ToastBarOperation(1, 0, ToastBarOperation.ERROR, false, folder)); 4110 } 4111 4112 private ActionClickedListener getRetryClickedListener(final Folder folder) { 4113 return new ActionClickedListener() { 4114 @Override 4115 public void onActionClicked(Context context) { 4116 final Uri uri = folder.refreshUri; 4117 4118 if (uri != null) { 4119 startAsyncRefreshTask(uri); 4120 } 4121 } 4122 }; 4123 } 4124 4125 private ActionClickedListener getSignInClickedListener() { 4126 return new ActionClickedListener() { 4127 @Override 4128 public void onActionClicked(Context context) { 4129 promptUserForAuthentication(mAccount); 4130 } 4131 }; 4132 } 4133 4134 private ActionClickedListener getStorageErrorClickedListener() { 4135 return new ActionClickedListener() { 4136 @Override 4137 public void onActionClicked(Context context) { 4138 showStorageErrorDialog(); 4139 } 4140 }; 4141 } 4142 4143 private void showStorageErrorDialog() { 4144 DialogFragment fragment = (DialogFragment) 4145 mFragmentManager.findFragmentByTag(SYNC_ERROR_DIALOG_FRAGMENT_TAG); 4146 if (fragment == null) { 4147 fragment = SyncErrorDialogFragment.newInstance(); 4148 } 4149 fragment.show(mFragmentManager, SYNC_ERROR_DIALOG_FRAGMENT_TAG); 4150 } 4151 4152 private ActionClickedListener getInternalErrorClickedListener() { 4153 return new ActionClickedListener() { 4154 @Override 4155 public void onActionClicked(Context context) { 4156 Utils.sendFeedback(mActivity, mAccount, true /* reportingProblem */); 4157 } 4158 }; 4159 } 4160 4161 @Override 4162 public void onFooterViewErrorActionClick(Folder folder, int errorStatus) { 4163 Uri uri = null; 4164 switch (errorStatus) { 4165 case UIProvider.LastSyncResult.CONNECTION_ERROR: 4166 if (folder != null && folder.refreshUri != null) { 4167 uri = folder.refreshUri; 4168 } 4169 break; 4170 case UIProvider.LastSyncResult.AUTH_ERROR: 4171 promptUserForAuthentication(mAccount); 4172 return; 4173 case UIProvider.LastSyncResult.SECURITY_ERROR: 4174 return; // Currently we do nothing for security errors. 4175 case UIProvider.LastSyncResult.STORAGE_ERROR: 4176 showStorageErrorDialog(); 4177 return; 4178 case UIProvider.LastSyncResult.INTERNAL_ERROR: 4179 Utils.sendFeedback(mActivity, mAccount, true /* reportingProblem */); 4180 return; 4181 default: 4182 return; 4183 } 4184 4185 if (uri != null) { 4186 startAsyncRefreshTask(uri); 4187 } 4188 } 4189 4190 @Override 4191 public void onFooterViewLoadMoreClick(Folder folder) { 4192 if (folder != null && folder.loadMoreUri != null) { 4193 startAsyncRefreshTask(folder.loadMoreUri); 4194 } 4195 } 4196 4197 private void startAsyncRefreshTask(Uri uri) { 4198 if (mFolderSyncTask != null) { 4199 mFolderSyncTask.cancel(true); 4200 } 4201 mFolderSyncTask = new AsyncRefreshTask(mActivity.getActivityContext(), uri); 4202 mFolderSyncTask.execute(); 4203 } 4204 4205 private void promptUserForAuthentication(Account account) { 4206 if (account != null && !Utils.isEmpty(account.reauthenticationIntentUri)) { 4207 final Intent authenticationIntent = 4208 new Intent(Intent.ACTION_VIEW, account.reauthenticationIntentUri); 4209 mActivity.startActivityForResult(authenticationIntent, REAUTHENTICATE_REQUEST_CODE); 4210 } 4211 } 4212 4213 @Override 4214 public void onAccessibilityStateChanged() { 4215 // Clear the cache of objects. 4216 ConversationItemViewModel.onAccessibilityUpdated(); 4217 // Re-render the list if it exists. 4218 final ConversationListFragment frag = getConversationListFragment(); 4219 if (frag != null) { 4220 AnimatedAdapter adapter = frag.getAnimatedAdapter(); 4221 if (adapter != null) { 4222 adapter.notifyDataSetInvalidated(); 4223 } 4224 } 4225 } 4226 4227 @Override 4228 public void makeDialogListener (final int action, final boolean isBatch, 4229 UndoCallback undoCallback) { 4230 final Collection<Conversation> target; 4231 if (isBatch) { 4232 target = mSelectedSet.values(); 4233 } else { 4234 LogUtils.d(LOG_TAG, "Will act upon %s", mCurrentConversation); 4235 target = Conversation.listOf(mCurrentConversation); 4236 } 4237 final DestructiveAction destructiveAction = getDeferredAction(action, target, isBatch, 4238 undoCallback); 4239 mDialogAction = action; 4240 mDialogFromSelectedSet = isBatch; 4241 mDialogListener = new AlertDialog.OnClickListener() { 4242 @Override 4243 public void onClick(DialogInterface dialog, int which) { 4244 delete(action, target, destructiveAction, isBatch); 4245 // Afterwards, let's remove references to the listener and the action. 4246 setListener(null, -1); 4247 } 4248 }; 4249 } 4250 4251 @Override 4252 public AlertDialog.OnClickListener getListener() { 4253 return mDialogListener; 4254 } 4255 4256 /** 4257 * Sets the listener for the positive action on a confirmation dialog. Since only a single 4258 * confirmation dialog can be shown, this overwrites the previous listener. It is safe to 4259 * unset the listener; in which case action should be set to -1. 4260 * @param listener the listener that will perform the task for this dialog's positive action. 4261 * @param action the action that created this dialog. 4262 */ 4263 private void setListener(AlertDialog.OnClickListener listener, final int action){ 4264 mDialogListener = listener; 4265 mDialogAction = action; 4266 } 4267 4268 @Override 4269 public VeiledAddressMatcher getVeiledAddressMatcher() { 4270 return mVeiledMatcher; 4271 } 4272 4273 @Override 4274 public void setDetachedMode() { 4275 // Tell the conversation list not to select anything. 4276 final ConversationListFragment frag = getConversationListFragment(); 4277 if (frag != null) { 4278 frag.setChoiceNone(); 4279 } else if (mIsTablet) { 4280 // How did we ever land here? Detached mode, and no CLF on tablet??? 4281 LogUtils.e(LOG_TAG, "AAC.setDetachedMode(): CLF = null!"); 4282 } 4283 mDetachedConvUri = mCurrentConversation.uri; 4284 } 4285 4286 private void clearDetachedMode() { 4287 // Tell the conversation list to go back to its usual selection behavior. 4288 final ConversationListFragment frag = getConversationListFragment(); 4289 if (frag != null) { 4290 frag.revertChoiceMode(); 4291 } else if (mIsTablet) { 4292 // How did we ever land here? Detached mode, and no CLF on tablet??? 4293 LogUtils.e(LOG_TAG, "AAC.clearDetachedMode(): CLF = null on tablet!"); 4294 } 4295 mDetachedConvUri = null; 4296 } 4297 4298 @Override 4299 public DrawerController getDrawerController() { 4300 return mDrawerListener; 4301 } 4302 4303 private class MailDrawerListener extends Observable<DrawerLayout.DrawerListener> 4304 implements DrawerLayout.DrawerListener, DrawerController { 4305 private int mDrawerState; 4306 private float mOldSlideOffset; 4307 4308 public MailDrawerListener() { 4309 mDrawerState = DrawerLayout.STATE_IDLE; 4310 mOldSlideOffset = 0.f; 4311 } 4312 4313 @Override 4314 public boolean isDrawerEnabled() { 4315 return AbstractActivityController.this.isDrawerEnabled(); 4316 } 4317 4318 @Override 4319 public void registerDrawerListener(DrawerLayout.DrawerListener l) { 4320 registerObserver(l); 4321 } 4322 4323 @Override 4324 public void unregisterDrawerListener(DrawerLayout.DrawerListener l) { 4325 unregisterObserver(l); 4326 } 4327 4328 @Override 4329 public boolean isDrawerOpen() { 4330 return isDrawerEnabled() && mDrawerContainer.isDrawerOpen(mDrawerPullout); 4331 } 4332 4333 @Override 4334 public boolean isDrawerVisible() { 4335 return isDrawerEnabled() && mDrawerContainer.isDrawerVisible(mDrawerPullout); 4336 } 4337 4338 @Override 4339 public void toggleDrawerState() { 4340 AbstractActivityController.this.toggleDrawerState(); 4341 } 4342 4343 @Override 4344 public void onDrawerOpened(View drawerView) { 4345 mDrawerToggle.onDrawerOpened(drawerView); 4346 4347 for (DrawerLayout.DrawerListener l : mObservers) { 4348 l.onDrawerOpened(drawerView); 4349 } 4350 } 4351 4352 @Override 4353 public void onDrawerClosed(View drawerView) { 4354 mDrawerToggle.onDrawerClosed(drawerView); 4355 if (mHasNewAccountOrFolder) { 4356 refreshDrawer(); 4357 } 4358 4359 // When closed, we want to use either the burger, or up, based on where we are 4360 final int mode = mViewMode.getMode(); 4361 final boolean isTopLevel = Folder.isRoot(mFolder); 4362 mDrawerToggle.setDrawerIndicatorEnabled(getShouldShowDrawerIndicator(mode, isTopLevel)); 4363 4364 for (DrawerLayout.DrawerListener l : mObservers) { 4365 l.onDrawerClosed(drawerView); 4366 } 4367 } 4368 4369 /** 4370 * As part of the overriden function, it will animate the alpha of the conversation list 4371 * view along with the drawer sliding when we're in the process of switching accounts or 4372 * folders. Note, this is the same amount of work done as {@link ValueAnimator#ofFloat}. 4373 */ 4374 @Override 4375 public void onDrawerSlide(View drawerView, float slideOffset) { 4376 mDrawerToggle.onDrawerSlide(drawerView, slideOffset); 4377 if (mHasNewAccountOrFolder && mListViewForAnimating != null) { 4378 mListViewForAnimating.setAlpha(slideOffset); 4379 } 4380 4381 // This code handles when to change the visibility of action items 4382 // based on drawer state. The basic logic is that right when we 4383 // open the drawer, we hide the action items. We show the action items 4384 // when the drawer closes. However, due to the animation of the drawer closing, 4385 // to make the reshowing of the action items feel right, we make the items visible 4386 // slightly sooner. 4387 // 4388 // However, to make the animating behavior work properly, we have to know whether 4389 // we're animating open or closed. Only if we're animating closed do we want to 4390 // show the action items early. We save the last slide offset so that we can compare 4391 // the current slide offset to it to determine if we're opening or closing. 4392 if (mDrawerState == DrawerLayout.STATE_SETTLING) { 4393 if (mHideMenuItems && slideOffset < 0.15f && mOldSlideOffset > slideOffset) { 4394 mHideMenuItems = false; 4395 mActivity.supportInvalidateOptionsMenu(); 4396 maybeEnableCabMode(); 4397 } else if (!mHideMenuItems && slideOffset > 0.f && mOldSlideOffset < slideOffset) { 4398 mHideMenuItems = true; 4399 mActivity.supportInvalidateOptionsMenu(); 4400 disableCabMode(); 4401 } 4402 } else { 4403 if (mHideMenuItems && Float.compare(slideOffset, 0.f) == 0) { 4404 mHideMenuItems = false; 4405 mActivity.supportInvalidateOptionsMenu(); 4406 maybeEnableCabMode(); 4407 } else if (!mHideMenuItems && slideOffset > 0.f) { 4408 mHideMenuItems = true; 4409 mActivity.supportInvalidateOptionsMenu(); 4410 disableCabMode(); 4411 } 4412 } 4413 4414 mOldSlideOffset = slideOffset; 4415 4416 // If we're sliding, we always want to show the burger 4417 mDrawerToggle.setDrawerIndicatorEnabled(true /* enable */); 4418 4419 for (DrawerLayout.DrawerListener l : mObservers) { 4420 l.onDrawerSlide(drawerView, slideOffset); 4421 } 4422 } 4423 4424 /** 4425 * This condition here should only be called when the drawer is stuck in a weird state 4426 * and doesn't register the onDrawerClosed, but shows up as idle. Make sure to refresh 4427 * and, more importantly, unlock the drawer when this is the case. 4428 */ 4429 @Override 4430 public void onDrawerStateChanged(int newState) { 4431 LogUtils.d(LOG_TAG, "AAC onDrawerStateChanged %d", newState); 4432 mDrawerState = newState; 4433 mDrawerToggle.onDrawerStateChanged(mDrawerState); 4434 4435 for (DrawerLayout.DrawerListener l : mObservers) { 4436 l.onDrawerStateChanged(newState); 4437 } 4438 4439 if (mViewMode.isSearchMode()) { 4440 return; 4441 } 4442 if (mDrawerState == DrawerLayout.STATE_IDLE) { 4443 if (mHasNewAccountOrFolder) { 4444 refreshDrawer(); 4445 } 4446 if (mConversationListLoadFinishedIgnored) { 4447 mConversationListLoadFinishedIgnored = false; 4448 final Bundle args = new Bundle(); 4449 args.putParcelable(BUNDLE_ACCOUNT_KEY, mAccount); 4450 args.putParcelable(BUNDLE_FOLDER_KEY, mFolder); 4451 mActivity.getLoaderManager().initLoader( 4452 LOADER_CONVERSATION_LIST, args, mListCursorCallbacks); 4453 } 4454 } 4455 } 4456 4457 /** 4458 * If we've reached a stable drawer state, unlock the drawer for usage, clear the 4459 * conversation list, and finish end actions. Also, make 4460 * {@link #mHasNewAccountOrFolder} false to reflect we're done changing. 4461 */ 4462 public void refreshDrawer() { 4463 mHasNewAccountOrFolder = false; 4464 mDrawerContainer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED); 4465 ConversationListFragment conversationList = getConversationListFragment(); 4466 if (conversationList != null) { 4467 conversationList.clear(); 4468 } 4469 mFolderOrAccountObservers.notifyChanged(); 4470 } 4471 4472 /** 4473 * Returns the most recent update of the {@link DrawerLayout}'s state provided 4474 * by {@link #onDrawerStateChanged(int)}. 4475 * @return The {@link DrawerLayout}'s current state. One of 4476 * {@link DrawerLayout#STATE_DRAGGING}, {@link DrawerLayout#STATE_IDLE}, 4477 * or {@link DrawerLayout#STATE_SETTLING}. 4478 */ 4479 public int getDrawerState() { 4480 return mDrawerState; 4481 } 4482 } 4483 4484 @Override 4485 public boolean isDrawerPullEnabled() { 4486 return true; 4487 } 4488 4489 @Override 4490 public boolean shouldHideMenuItems() { 4491 return mHideMenuItems; 4492 } 4493 4494 protected void navigateUpFolderHierarchy() { 4495 new AsyncTask<Void, Void, Folder>() { 4496 @Override 4497 protected Folder doInBackground(final Void... params) { 4498 if (mInbox == null) { 4499 // We don't have an inbox, but we need it 4500 final Cursor cursor = mContext.getContentResolver().query( 4501 mAccount.settings.defaultInbox, UIProvider.FOLDERS_PROJECTION, null, 4502 null, null); 4503 4504 if (cursor != null) { 4505 try { 4506 if (cursor.moveToFirst()) { 4507 mInbox = new Folder(cursor); 4508 } 4509 } finally { 4510 cursor.close(); 4511 } 4512 } 4513 } 4514 4515 // Now try to load our parent 4516 final Folder folder; 4517 4518 if (mFolder != null) { 4519 Cursor cursor = null; 4520 try { 4521 cursor = mContext.getContentResolver().query(mFolder.parent, 4522 UIProvider.FOLDERS_PROJECTION, null, null, null); 4523 4524 if (cursor == null || !cursor.moveToFirst()) { 4525 // We couldn't load the parent, so use the inbox 4526 folder = mInbox; 4527 } else { 4528 folder = new Folder(cursor); 4529 } 4530 } finally { 4531 if (cursor != null) { 4532 cursor.close(); 4533 } 4534 } 4535 } else { 4536 folder = mInbox; 4537 } 4538 4539 return folder; 4540 } 4541 4542 @Override 4543 protected void onPostExecute(final Folder result) { 4544 onFolderSelected(result); 4545 } 4546 }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, (Void[]) null); 4547 } 4548 4549 @Override 4550 public Parcelable getConversationListScrollPosition(final String folderUri) { 4551 return mConversationListScrollPositions.getParcelable(folderUri); 4552 } 4553 4554 @Override 4555 public void setConversationListScrollPosition(final String folderUri, 4556 final Parcelable savedPosition) { 4557 mConversationListScrollPositions.putParcelable(folderUri, savedPosition); 4558 } 4559 4560 @Override 4561 public View.OnClickListener getNavigationViewClickListener() { 4562 return mHomeButtonListener; 4563 } 4564 4565 // TODO: Fold this into the outer class when b/16627877 is fixed 4566 private class HomeButtonListener implements View.OnClickListener { 4567 4568 @Override 4569 public void onClick(View v) { 4570 onUpPressed(); 4571 } 4572 } 4573} 4574