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