1/*
2 * Copyright (C) 2012 Google Inc.
3 * Licensed to The Android Open Source Project.
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 *      http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18package com.android.mail.ui;
19
20import android.animation.LayoutTransition;
21import android.app.Activity;
22import android.app.Fragment;
23import android.app.LoaderManager;
24import android.content.res.Resources;
25import android.database.DataSetObserver;
26import android.os.Bundle;
27import android.os.Handler;
28import android.os.Parcelable;
29import android.support.annotation.IdRes;
30import android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener;
31import android.view.KeyEvent;
32import android.view.LayoutInflater;
33import android.view.View;
34import android.view.ViewGroup;
35import android.widget.AdapterView;
36import android.widget.AdapterView.OnItemLongClickListener;
37import android.widget.ListView;
38import android.widget.TextView;
39
40import com.android.mail.ConversationListContext;
41import com.android.mail.R;
42import com.android.mail.analytics.Analytics;
43import com.android.mail.analytics.AnalyticsTimer;
44import com.android.mail.browse.ConversationCursor;
45import com.android.mail.browse.ConversationItemView;
46import com.android.mail.browse.ConversationItemViewModel;
47import com.android.mail.browse.ConversationListFooterView;
48import com.android.mail.browse.ToggleableItem;
49import com.android.mail.providers.Account;
50import com.android.mail.providers.AccountObserver;
51import com.android.mail.providers.Conversation;
52import com.android.mail.providers.Folder;
53import com.android.mail.providers.FolderObserver;
54import com.android.mail.providers.Settings;
55import com.android.mail.providers.UIProvider;
56import com.android.mail.providers.UIProvider.AccountCapabilities;
57import com.android.mail.providers.UIProvider.ConversationListIcon;
58import com.android.mail.providers.UIProvider.FolderCapabilities;
59import com.android.mail.providers.UIProvider.Swipe;
60import com.android.mail.ui.SwipeableListView.ListItemSwipedListener;
61import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener;
62import com.android.mail.ui.SwipeableListView.SwipeListener;
63import com.android.mail.ui.ViewMode.ModeChangeListener;
64import com.android.mail.utils.LogTag;
65import com.android.mail.utils.LogUtils;
66import com.android.mail.utils.Utils;
67import com.google.common.collect.ImmutableList;
68
69import java.util.Collection;
70import java.util.List;
71
72import static android.view.View.OnKeyListener;
73
74/**
75 * The conversation list UI component.
76 */
77public final class ConversationListFragment extends Fragment implements
78        OnItemLongClickListener, ModeChangeListener, ListItemSwipedListener, OnRefreshListener,
79        SwipeListener, OnKeyListener, AdapterView.OnItemClickListener {
80    /** Key used to pass data to {@link ConversationListFragment}. */
81    private static final String CONVERSATION_LIST_KEY = "conversation-list";
82    /** Key used to keep track of the scroll state of the list. */
83    private static final String LIST_STATE_KEY = "list-state";
84
85    private static final String LOG_TAG = LogTag.getLogTag();
86    /** Key used to save the ListView choice mode, since ListView doesn't save it automatically! */
87    private static final String CHOICE_MODE_KEY = "choice-mode-key";
88
89    // True if we are on a tablet device
90    private static boolean mTabletDevice;
91
92    // Delay before displaying the loading view.
93    private static int LOADING_DELAY_MS;
94    // Minimum amount of time to keep the loading view displayed.
95    private static int MINIMUM_LOADING_DURATION;
96
97    /**
98     * Frequency of update of timestamps. Initialized in
99     * {@link #onCreate(Bundle)} and final afterwards.
100     */
101    private static int TIMESTAMP_UPDATE_INTERVAL = 0;
102
103    private ControllableActivity mActivity;
104
105    // Control state.
106    private ConversationListCallbacks mCallbacks;
107
108    private final Handler mHandler = new Handler();
109
110    // The internal view objects.
111    private SwipeableListView mListView;
112
113    private View mSearchHeaderView;
114    private TextView mSearchResultCountTextView;
115
116    /**
117     * Current Account being viewed
118     */
119    private Account mAccount;
120    /**
121     * Current folder being viewed.
122     */
123    private Folder mFolder;
124
125    /**
126     * A simple method to update the timestamps of conversations periodically.
127     */
128    private Runnable mUpdateTimestampsRunnable = null;
129
130    private ConversationListContext mViewContext;
131
132    private AnimatedAdapter mListAdapter;
133
134    private ConversationListFooterView mFooterView;
135    private ConversationListEmptyView mEmptyView;
136    private View mLoadingView;
137    private ErrorListener mErrorListener;
138    private FolderObserver mFolderObserver;
139    private DataSetObserver mConversationCursorObserver;
140
141    private ConversationSelectionSet mSelectedSet;
142    private final AccountObserver mAccountObserver = new AccountObserver() {
143        @Override
144        public void onChanged(Account newAccount) {
145            mAccount = newAccount;
146            setSwipeAction();
147        }
148    };
149    private ConversationUpdater mUpdater;
150    /** Hash of the Conversation Cursor we last obtained from the controller. */
151    private int mConversationCursorHash;
152    // The number of items in the last known ConversationCursor
153    private int mConversationCursorLastCount;
154    // State variable to keep track if we just loaded a new list, used for analytics only
155    // True if NO DATA has returned, false if we either partially or fully loaded the data
156    private boolean mInitialCursorLoading;
157
158    private @IdRes int mNextFocusLeftId;
159    // Tracks if a onKey event was initiated from the listview (received ACTION_DOWN before
160    // ACTION_UP). If not, the listview only receives ACTION_UP.
161    private boolean mKeyInitiatedFromList;
162
163    /** Duration, in milliseconds, of the CAB mode (peek icon) animation. */
164    private static long sSelectionModeAnimationDuration = -1;
165
166    // Let's ensure that we are only showing one out of the three views at once
167    private void showListView() {
168        mListView.setVisibility(View.VISIBLE);
169        mEmptyView.setVisibility(View.INVISIBLE);
170        mLoadingView.setVisibility(View.INVISIBLE);
171    }
172
173    private void showEmptyView() {
174        mEmptyView.setupEmptyView(
175                mFolder, mViewContext.searchQuery, mListAdapter.getBidiFormatter());
176        mListView.setVisibility(View.INVISIBLE);
177        mEmptyView.setVisibility(View.VISIBLE);
178        mLoadingView.setVisibility(View.INVISIBLE);
179    }
180
181    private void showLoadingView() {
182        mListView.setVisibility(View.INVISIBLE);
183        mEmptyView.setVisibility(View.INVISIBLE);
184        mLoadingView.setVisibility(View.VISIBLE);
185    }
186
187    private final Runnable mLoadingViewRunnable = new FragmentRunnable("LoadingRunnable", this) {
188        @Override
189        public void go() {
190            if (!isCursorReadyToShow()) {
191                mCanTakeDownLoadingView = false;
192                showLoadingView();
193                mHandler.removeCallbacks(mHideLoadingRunnable);
194                mHandler.postDelayed(mHideLoadingRunnable, MINIMUM_LOADING_DURATION);
195            }
196            mLoadingViewPending = false;
197        }
198    };
199
200    private final Runnable mHideLoadingRunnable = new FragmentRunnable("CancelLoading", this) {
201        @Override
202        public void go() {
203            mCanTakeDownLoadingView = true;
204            if (isCursorReadyToShow()) {
205                hideLoadingViewAndShowContents();
206            }
207        }
208    };
209
210    // Keep track of if we are waiting for the loading view. This variable is also used to check
211    // if the cursor corresponding to the current folder loaded (either partially or completely).
212    private boolean mLoadingViewPending;
213    private boolean mCanTakeDownLoadingView;
214
215    /**
216     * If <code>true</code>, we have restored (or attempted to restore) the list's scroll position
217     * from when we were last on this conversation list.
218     */
219    private boolean mScrollPositionRestored = false;
220    private MailSwipeRefreshLayout mSwipeRefreshWidget;
221
222    /**
223     * Constructor needs to be public to handle orientation changes and activity
224     * lifecycle events.
225     */
226    public ConversationListFragment() {
227        super();
228    }
229
230    @Override
231    public void onBeginSwipe() {
232        mSwipeRefreshWidget.setEnabled(false);
233    }
234
235    @Override
236    public void onEndSwipe() {
237        mSwipeRefreshWidget.setEnabled(true);
238    }
239
240    private class ConversationCursorObserver extends DataSetObserver {
241        @Override
242        public void onChanged() {
243            onConversationListStatusUpdated();
244        }
245    }
246
247    /**
248     * Creates a new instance of {@link ConversationListFragment}, initialized
249     * to display conversation list context.
250     */
251    public static ConversationListFragment newInstance(ConversationListContext viewContext) {
252        final ConversationListFragment fragment = new ConversationListFragment();
253        final Bundle args = new Bundle(1);
254        args.putBundle(CONVERSATION_LIST_KEY, viewContext.toBundle());
255        fragment.setArguments(args);
256        return fragment;
257    }
258
259    /**
260     * Show the header if the current conversation list is showing search
261     * results.
262     */
263    private void updateSearchResultHeader(int count) {
264        if (mActivity == null || mSearchHeaderView == null) {
265            return;
266        }
267        mSearchResultCountTextView.setText(
268                getResources().getString(R.string.search_results_loaded, count));
269    }
270
271    @Override
272    public void onActivityCreated(Bundle savedState) {
273        super.onActivityCreated(savedState);
274        mLoadingViewPending = false;
275        mCanTakeDownLoadingView = true;
276        if (sSelectionModeAnimationDuration < 0) {
277            sSelectionModeAnimationDuration = getResources().getInteger(
278                    R.integer.conv_item_view_cab_anim_duration);
279        }
280
281        // Strictly speaking, we get back an android.app.Activity from
282        // getActivity. However, the
283        // only activity creating a ConversationListContext is a MailActivity
284        // which is of type
285        // ControllableActivity, so this cast should be safe. If this cast
286        // fails, some other
287        // activity is creating ConversationListFragments. This activity must be
288        // of type
289        // ControllableActivity.
290        final Activity activity = getActivity();
291        if (!(activity instanceof ControllableActivity)) {
292            LogUtils.e(LOG_TAG, "ConversationListFragment expects only a ControllableActivity to"
293                    + "create it. Cannot proceed.");
294        }
295        mActivity = (ControllableActivity) activity;
296        // Since we now have a controllable activity, load the account from it,
297        // and register for
298        // future account changes.
299        mAccount = mAccountObserver.initialize(mActivity.getAccountController());
300        mCallbacks = mActivity.getListHandler();
301        mErrorListener = mActivity.getErrorListener();
302        // Start off with the current state of the folder being viewed.
303        final LayoutInflater inflater = LayoutInflater.from(mActivity.getActivityContext());
304        mFooterView = (ConversationListFooterView) inflater.inflate(
305                R.layout.conversation_list_footer_view, null);
306        mFooterView.setClickListener(mActivity);
307        final ConversationCursor conversationCursor = getConversationListCursor();
308        final LoaderManager manager = getLoaderManager();
309
310        // TODO: These special views are always created, doesn't matter whether they will
311        // be shown or not, as we add more views this will get more expensive. Given these are
312        // tips that are only shown once to the user, we should consider creating these on demand.
313        final ConversationListHelper helper = mActivity.getConversationListHelper();
314        final List<ConversationSpecialItemView> specialItemViews = helper != null ?
315                ImmutableList.copyOf(helper.makeConversationListSpecialViews(
316                        activity, mActivity, mAccount))
317                : null;
318        if (specialItemViews != null) {
319            // Attach to the LoaderManager
320            for (final ConversationSpecialItemView view : specialItemViews) {
321                view.bindFragment(manager, savedState);
322            }
323        }
324
325        mListAdapter = new AnimatedAdapter(mActivity.getApplicationContext(), conversationCursor,
326                mActivity.getSelectedSet(), mActivity, mListView, specialItemViews);
327        mListAdapter.addFooter(mFooterView);
328        // Show search result header only if we are in search mode
329        final boolean showSearchHeader = ConversationListContext.isSearchResult(mViewContext);
330        if (showSearchHeader) {
331            mSearchHeaderView = inflater.inflate(R.layout.search_results_view, null);
332            mSearchResultCountTextView = (TextView)
333                    mSearchHeaderView.findViewById(R.id.search_result_count_view);
334            mListAdapter.addHeader(mSearchHeaderView);
335        }
336
337        mListView.setAdapter(mListAdapter);
338        mSelectedSet = mActivity.getSelectedSet();
339        mListView.setSelectionSet(mSelectedSet);
340        mListAdapter.setFooterVisibility(false);
341        mFolderObserver = new FolderObserver(){
342            @Override
343            public void onChanged(Folder newFolder) {
344                onFolderUpdated(newFolder);
345            }
346        };
347        mFolderObserver.initialize(mActivity.getFolderController());
348        mConversationCursorObserver = new ConversationCursorObserver();
349        mUpdater = mActivity.getConversationUpdater();
350        mUpdater.registerConversationListObserver(mConversationCursorObserver);
351        mTabletDevice = Utils.useTabletUI(mActivity.getApplicationContext().getResources());
352        // The onViewModeChanged callback doesn't get called when the mode
353        // object is created, so
354        // force setting the mode manually this time around.
355        onViewModeChanged(mActivity.getViewMode().getMode());
356        mActivity.getViewMode().addListener(this);
357
358        if (mActivity.isFinishing()) {
359            // Activity is finishing, just bail.
360            return;
361        }
362        mConversationCursorHash = (conversationCursor == null) ? 0 : conversationCursor.hashCode();
363        // Belt and suspenders here; make sure we do any necessary sync of the
364        // ConversationCursor
365        if (conversationCursor != null && conversationCursor.isRefreshReady()) {
366            conversationCursor.sync();
367        }
368
369        // On a phone we never highlight a conversation, so the default is to select none.
370        // On a tablet, we highlight a SINGLE conversation in landscape conversation view.
371        int choice = getDefaultChoiceMode(mTabletDevice);
372        if (savedState != null) {
373            // Restore the choice mode if it was set earlier, or NONE if creating a fresh view.
374            // Choice mode here represents the current conversation only. CAB mode does not rely on
375            // the platform: checked state is a local variable {@link ConversationItemView#mChecked}
376            choice = savedState.getInt(CHOICE_MODE_KEY, choice);
377            if (savedState.containsKey(LIST_STATE_KEY)) {
378                // TODO: find a better way to unset the selected item when restoring
379                mListView.clearChoices();
380            }
381        }
382        setChoiceMode(choice);
383
384        // Show list and start loading list.
385        showList();
386        ToastBarOperation pendingOp = mActivity.getPendingToastOperation();
387        if (pendingOp != null) {
388            // Clear the pending operation
389            mActivity.setPendingToastOperation(null);
390            mActivity.onUndoAvailable(pendingOp);
391        }
392    }
393
394    /**
395     * Returns the default choice mode for the list based on whether the list is displayed on tablet
396     * or not.
397     * @param isTablet
398     * @return
399     */
400    private final static int getDefaultChoiceMode(boolean isTablet) {
401        return isTablet ? ListView.CHOICE_MODE_SINGLE : ListView.CHOICE_MODE_NONE;
402    }
403
404    public AnimatedAdapter getAnimatedAdapter() {
405        return mListAdapter;
406    }
407
408    @Override
409    public void onCreate(Bundle savedState) {
410        super.onCreate(savedState);
411
412        // Initialize fragment constants from resources
413        final Resources res = getResources();
414        TIMESTAMP_UPDATE_INTERVAL = res.getInteger(R.integer.timestamp_update_interval);
415        LOADING_DELAY_MS = res.getInteger(R.integer.conversationview_show_loading_delay);
416        MINIMUM_LOADING_DURATION = res.getInteger(R.integer.conversationview_min_show_loading);
417        mUpdateTimestampsRunnable = new Runnable() {
418            @Override
419            public void run() {
420                mListView.invalidateViews();
421                mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL);
422            }
423        };
424
425        // Get the context from the arguments
426        final Bundle args = getArguments();
427        mViewContext = ConversationListContext.forBundle(args.getBundle(CONVERSATION_LIST_KEY));
428        mAccount = mViewContext.account;
429
430        setRetainInstance(false);
431    }
432
433    @Override
434    public String toString() {
435        final String s = super.toString();
436        if (mViewContext == null) {
437            return s;
438        }
439        final StringBuilder sb = new StringBuilder(s);
440        sb.setLength(sb.length() - 1);
441        sb.append(" mListAdapter=");
442        sb.append(mListAdapter);
443        sb.append(" folder=");
444        sb.append(mViewContext.folder);
445        sb.append("}");
446        return sb.toString();
447    }
448
449    @Override
450    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
451        View rootView = inflater.inflate(R.layout.conversation_list, null);
452        mEmptyView = (ConversationListEmptyView) rootView.findViewById(R.id.empty_view);
453        mLoadingView = rootView.findViewById(R.id.background_view);
454        mLoadingView.setVisibility(View.GONE);
455        mLoadingView.findViewById(R.id.loading_progress).setVisibility(View.VISIBLE);
456        mListView = (SwipeableListView) rootView.findViewById(R.id.conversation_list_view);
457        mListView.setHeaderDividersEnabled(false);
458        mListView.setOnItemLongClickListener(this);
459        mListView.enableSwipe(mAccount.supportsCapability(AccountCapabilities.UNDO));
460        mListView.setListItemSwipedListener(this);
461        mListView.setSwipeListener(this);
462        mListView.setOnKeyListener(this);
463        mListView.setOnItemClickListener(this);
464        if (mNextFocusLeftId != 0) {
465            mListView.setNextFocusLeftId(mNextFocusLeftId);
466        }
467
468        // enable animateOnLayout (equivalent of setLayoutTransition) only for >=JB (b/14302062)
469        if (Utils.isRunningJellybeanOrLater()) {
470            ((ViewGroup) rootView.findViewById(R.id.conversation_list_parent_frame))
471                    .setLayoutTransition(new LayoutTransition());
472        }
473
474        // By default let's show the list view
475        showListView();
476
477        if (savedState != null && savedState.containsKey(LIST_STATE_KEY)) {
478            mListView.onRestoreInstanceState(savedState.getParcelable(LIST_STATE_KEY));
479        }
480        mSwipeRefreshWidget =
481                (MailSwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_widget);
482        mSwipeRefreshWidget.setColorScheme(R.color.swipe_refresh_color1,
483                R.color.swipe_refresh_color2,
484                R.color.swipe_refresh_color3, R.color.swipe_refresh_color4);
485        mSwipeRefreshWidget.setOnRefreshListener(this);
486        mSwipeRefreshWidget.setScrollableChild(mListView);
487
488        return rootView;
489    }
490
491    /**
492     * Sets the choice mode of the list view
493     */
494    private final void setChoiceMode(int choiceMode) {
495        mListView.setChoiceMode(choiceMode);
496    }
497
498    /**
499     * Tell the list to select nothing.
500     */
501    public final void setChoiceNone() {
502        // On a phone, the default choice mode is already none, so nothing to do.
503        if (!mTabletDevice) {
504            return;
505        }
506        clearChoicesAndActivated();
507        setChoiceMode(ListView.CHOICE_MODE_NONE);
508    }
509
510    /**
511     * Tell the list to get out of selecting none.
512     */
513    public final void revertChoiceMode() {
514        // On a phone, the default choice mode is always none, so nothing to do.
515        if (!mTabletDevice) {
516            return;
517        }
518        setChoiceMode(getDefaultChoiceMode(mTabletDevice));
519    }
520
521    @Override
522    public void onDestroy() {
523        super.onDestroy();
524    }
525
526    @Override
527    public void onDestroyView() {
528
529        // Clear the list's adapter
530        mListAdapter.destroy();
531        mListView.setAdapter(null);
532
533        mActivity.getViewMode().removeListener(this);
534        if (mFolderObserver != null) {
535            mFolderObserver.unregisterAndDestroy();
536            mFolderObserver = null;
537        }
538        if (mConversationCursorObserver != null) {
539            mUpdater.unregisterConversationListObserver(mConversationCursorObserver);
540            mConversationCursorObserver = null;
541        }
542        mAccountObserver.unregisterAndDestroy();
543        getAnimatedAdapter().cleanup();
544        super.onDestroyView();
545    }
546
547    /**
548     * There are three binary variables, which determine what we do with a
549     * message. checkbEnabled: Whether check boxes are enabled or not (forced
550     * true on tablet) cabModeOn: Whether CAB mode is currently on or not.
551     * pressType: long or short tap (There is a third possibility: phone or
552     * tablet, but they have <em>identical</em> behavior) The matrix of
553     * possibilities is:
554     * <p>
555     * Long tap: Always toggle selection of conversation. If CAB mode is not
556     * started, then start it.
557     * <pre>
558     *              | Checkboxes | No Checkboxes
559     *    ----------+------------+---------------
560     *    CAB mode  |   Select   |     Select
561     *    List mode |   Select   |     Select
562     *
563     * </pre>
564     *
565     * Reference: http://b/issue?id=6392199
566     * <p>
567     * {@inheritDoc}
568     */
569    @Override
570    public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
571        // Ignore anything that is not a conversation item. Could be a footer.
572        if (!(view instanceof ConversationItemView)) {
573            return false;
574        }
575        return ((ConversationItemView) view).toggleSelectedStateOrBeginDrag();
576    }
577
578    /**
579     * See the comment for
580     * {@link #onItemLongClick(AdapterView, View, int, long)}.
581     * <p>
582     * Short tap behavior:
583     *
584     * <pre>
585     *              | Checkboxes | No Checkboxes
586     *    ----------+------------+---------------
587     *    CAB mode  |    Peek    |     Select
588     *    List mode |    Peek    |      Peek
589     * </pre>
590     *
591     * Reference: http://b/issue?id=6392199
592     * <p>
593     * {@inheritDoc}
594     */
595    @Override
596    public void onItemClick(AdapterView<?> adapterView, View view, int position, long id) {
597        onListItemSelected(view, position);
598    }
599
600    private void onListItemSelected(View view, int position) {
601        if (view instanceof ToggleableItem) {
602            final boolean showSenderImage =
603                    (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE);
604            final boolean inCabMode = !mSelectedSet.isEmpty();
605            if (!showSenderImage && inCabMode) {
606                ((ToggleableItem) view).toggleSelectedState();
607            } else {
608                if (inCabMode) {
609                    // this is a peek.
610                    Analytics.getInstance().sendEvent("peek", null, null, mSelectedSet.size());
611                }
612                AnalyticsTimer.getInstance().trackStart(AnalyticsTimer.OPEN_CONV_VIEW_FROM_LIST);
613                viewConversation(position);
614            }
615        } else {
616            // Ignore anything that is not a conversation item. Could be a footer.
617            // If we are using a keyboard, the highlighted item is the parent;
618            // otherwise, this is a direct call from the ConverationItemView
619            return;
620        }
621        // When a new list item is clicked, commit any existing leave behind
622        // items. Wait until we have opened the desired conversation to cause
623        // any position changes.
624        commitDestructiveActions(Utils.useTabletUI(mActivity.getActivityContext().getResources()));
625    }
626
627    @Override
628    public boolean onKey(View view, int keyCode, KeyEvent keyEvent) {
629        SwipeableListView list = (SwipeableListView) view;
630        // Don't need to handle ENTER because it's auto-handled as a "click".
631        if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
632            if (keyEvent.getAction() == KeyEvent.ACTION_UP) {
633                if (mKeyInitiatedFromList) {
634                    onListItemSelected(list.getSelectedView(), list.getSelectedItemPosition());
635                }
636                mKeyInitiatedFromList = false;
637            } else if (keyEvent.getAction() == KeyEvent.ACTION_DOWN) {
638                mKeyInitiatedFromList = true;
639            }
640            return true;
641        } else if (keyEvent.getAction() == KeyEvent.ACTION_UP) {
642            if (keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) {
643                final int position = list.getSelectedItemPosition();
644                final Object item = getAnimatedAdapter().getItem(position);
645                if (item != null && item instanceof ConversationCursor) {
646                    final Conversation conv = ((ConversationCursor) item).getConversation();
647                    mCallbacks.onConversationFocused(conv);
648                }
649            }
650        }
651        return false;
652    }
653
654    @Override
655    public void onResume() {
656        super.onResume();
657
658        if (!isCursorReadyToShow()) {
659            // If the cursor got reset, let's reset the analytics state variable and show the list
660            // view since we are waiting for load again
661            mInitialCursorLoading = true;
662            showListView();
663        }
664
665        final ConversationCursor conversationCursor = getConversationListCursor();
666        if (conversationCursor != null) {
667            conversationCursor.handleNotificationActions();
668
669            restoreLastScrolledPosition();
670        }
671
672        mSelectedSet.addObserver(mConversationSetObserver);
673    }
674
675    @Override
676    public void onPause() {
677        super.onPause();
678
679        mSelectedSet.removeObserver(mConversationSetObserver);
680
681        saveLastScrolledPosition();
682    }
683
684    @Override
685    public void onSaveInstanceState(Bundle outState) {
686        super.onSaveInstanceState(outState);
687        if (mListView != null) {
688            outState.putParcelable(LIST_STATE_KEY, mListView.onSaveInstanceState());
689            outState.putInt(CHOICE_MODE_KEY, mListView.getChoiceMode());
690        }
691
692        if (mListAdapter != null) {
693            mListAdapter.saveSpecialItemInstanceState(outState);
694        }
695    }
696
697    @Override
698    public void onStart() {
699        super.onStart();
700        mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL);
701        Analytics.getInstance().sendView("ConversationListFragment");
702    }
703
704    @Override
705    public void onStop() {
706        super.onStop();
707        mHandler.removeCallbacks(mUpdateTimestampsRunnable);
708    }
709
710    @Override
711    public void onViewModeChanged(int newMode) {
712        if (mTabletDevice) {
713            if (ViewMode.isListMode(newMode)) {
714                // There are no selected conversations when in conversation list mode.
715                clearChoicesAndActivated();
716            }
717        }
718        if (mFooterView != null) {
719            mFooterView.onViewModeChanged(newMode);
720        }
721
722        // Set default navigation
723        if (ViewMode.isListMode(newMode)) {
724            mListView.setNextFocusRightId(R.id.conversation_list_view);
725            mListView.requestFocus();
726        } else if (ViewMode.isConversationMode(newMode)) {
727            // This would only happen in two_pane
728            mListView.setNextFocusRightId(R.id.conversation_pager);
729        }
730    }
731
732    public boolean isAnimating() {
733        final AnimatedAdapter adapter = getAnimatedAdapter();
734        if (adapter != null && adapter.isAnimating()) {
735            return true;
736        }
737        final boolean isScrolling = (mListView != null && mListView.isScrolling());
738        if (isScrolling) {
739            LogUtils.i(LOG_TAG, "CLF.isAnimating=true due to scrolling");
740        }
741        return isScrolling;
742    }
743
744    private void clearChoicesAndActivated() {
745        final int currentSelected = mListView.getCheckedItemPosition();
746        if (currentSelected != ListView.INVALID_POSITION) {
747            mListView.setItemChecked(mListView.getCheckedItemPosition(), false);
748        }
749    }
750
751    /**
752     * Handles a request to show a new conversation list, either from a search
753     * query or for viewing a folder. This will initiate a data load, and hence
754     * must be called on the UI thread.
755     */
756    private void showList() {
757        mInitialCursorLoading = true;
758        onFolderUpdated(mActivity.getFolderController().getFolder());
759        onConversationListStatusUpdated();
760
761        // try to get an order-of-magnitude sense for message count within folders
762        // (N.B. this count currently isn't working for search folders, since their counts stream
763        // in over time in pieces.)
764        final Folder f = mViewContext.folder;
765        if (f != null) {
766            final long countLog;
767            if (f.totalCount > 0) {
768                countLog = (long) Math.log10(f.totalCount);
769            } else {
770                countLog = 0;
771            }
772            Analytics.getInstance().sendEvent("view_folder", f.getTypeDescription(),
773                    Long.toString(countLog), f.totalCount);
774        }
775    }
776
777    /**
778     * View the message at the given position.
779     *
780     * @param position The position of the conversation in the list (as opposed to its position
781     *        in the cursor)
782     */
783    private void viewConversation(final int position) {
784        LogUtils.d(LOG_TAG, "ConversationListFragment.viewConversation(%d)", position);
785
786        final ConversationCursor cursor =
787                (ConversationCursor) getAnimatedAdapter().getItem(position);
788
789        if (cursor == null) {
790            LogUtils.e(LOG_TAG,
791                    "unable to open conv at cursor pos=%s cursor=%s getPositionOffset=%s",
792                    position, cursor, getAnimatedAdapter().getPositionOffset(position));
793            return;
794        }
795
796        final Conversation conv = cursor.getConversation();
797        /*
798         * The cursor position may be different than the position method parameter because of
799         * special views in the list.
800         */
801        conv.position = cursor.getPosition();
802        setSelected(conv.position, true);
803        mCallbacks.onConversationSelected(conv, false /* inLoaderCallbacks */);
804    }
805
806    /**
807     * Sets the selected conversation to the position given here.
808     * @param cursorPosition The position of the conversation in the cursor (as opposed to
809     * in the list)
810     * @param different if the currently selected conversation is different from the one provided
811     * here.  This is a difference in conversations, not a difference in positions. For example, a
812     * conversation at position 2 can move to position 4 as a result of new mail.
813     */
814    public void setSelected(final int cursorPosition, boolean different) {
815        if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) {
816            return;
817        }
818
819        final int position =
820                cursorPosition + getAnimatedAdapter().getPositionOffset(cursorPosition);
821
822        setRawSelected(position, different);
823    }
824
825    /**
826     * Sets the selected conversation to the position given here.
827     * @param position The position of the item in the list
828     * @param different if the currently selected conversation is different from the one provided
829     * here.  This is a difference in conversations, not a difference in positions. For example, a
830     * conversation at position 2 can move to position 4 as a result of new mail.
831     */
832    public void setRawSelected(final int position, final boolean different) {
833        if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) {
834            return;
835        }
836
837        if (different) {
838            mListView.smoothScrollToPosition(position);
839        }
840        mListView.setItemChecked(position, true);
841    }
842
843    /**
844     * Returns the cursor associated with the conversation list.
845     * @return
846     */
847    private ConversationCursor getConversationListCursor() {
848        return mCallbacks != null ? mCallbacks.getConversationListCursor() : null;
849    }
850
851    /**
852     * Request a refresh of the list. No sync is carried out and none is
853     * promised.
854     */
855    public void requestListRefresh() {
856        mListAdapter.notifyDataSetChanged();
857    }
858
859    /**
860     * Change the UI to delete the conversations provided and then call the
861     * {@link DestructiveAction} provided here <b>after</b> the UI has been
862     * updated.
863     * @param conversations
864     * @param action
865     */
866    public void requestDelete(int actionId, final Collection<Conversation> conversations,
867            final DestructiveAction action) {
868        for (Conversation conv : conversations) {
869            conv.localDeleteOnUpdate = true;
870        }
871        final ListItemsRemovedListener listener = new ListItemsRemovedListener() {
872            @Override
873            public void onListItemsRemoved() {
874                action.performAction();
875            }
876        };
877        if (mListView.getSwipeAction() == actionId) {
878            if (!mListView.destroyItems(conversations, listener)) {
879                // The listView failed to destroy the items, perform the action manually
880                LogUtils.e(LOG_TAG, "ConversationListFragment.requestDelete: " +
881                        "listView failed to destroy items.");
882                action.performAction();
883            }
884            return;
885        }
886        // Delete the local delete items (all for now) and when done,
887        // update...
888        mListAdapter.delete(conversations, listener);
889    }
890
891    public void onFolderUpdated(Folder folder) {
892        if (!isCursorReadyToShow()) {
893            // Wait a bit before showing either the empty or loading view. If the messages are
894            // actually local, it's disorienting to see this appear on every folder transition.
895            // If they aren't, then it will likely take more than 200 milliseconds to load, and
896            // then we'll see the loading view.
897            if (!mLoadingViewPending) {
898                mHandler.postDelayed(mLoadingViewRunnable, LOADING_DELAY_MS);
899                mLoadingViewPending = true;
900            }
901        }
902
903        mFolder = folder;
904        setSwipeAction();
905
906        // Update enabled state of swipe to refresh.
907        mSwipeRefreshWidget.setEnabled(!ConversationListContext.isSearchResult(mViewContext));
908
909        if (mFolder == null) {
910            return;
911        }
912        mListAdapter.setFolder(mFolder);
913        mFooterView.setFolder(mFolder);
914        if (!mFolder.wasSyncSuccessful()) {
915            mErrorListener.onError(mFolder, false);
916        }
917
918        // Update the sync status bar with sync results if needed
919        checkSyncStatus();
920
921        // Blow away conversation items cache.
922        ConversationItemViewModel.onFolderUpdated(mFolder);
923    }
924
925    /**
926     * Updates the footer visibility and updates the conversation cursor
927     */
928    public void onConversationListStatusUpdated() {
929        // Also change the cursor here.
930        onCursorUpdated();
931
932        if (isCursorReadyToShow() && mCanTakeDownLoadingView) {
933            hideLoadingViewAndShowContents();
934        }
935    }
936
937    private void hideLoadingViewAndShowContents() {
938        final ConversationCursor cursor = getConversationListCursor();
939        final boolean showFooter = mFooterView.updateStatus(cursor);
940        // Update the sync status bar with sync results if needed
941        checkSyncStatus();
942        mListAdapter.setFooterVisibility(showFooter);
943        mLoadingViewPending = false;
944        mHandler.removeCallbacks(mLoadingViewRunnable);
945
946        // Even though cursor might be empty, the list adapter might have teasers/footers.
947        // So we check the list adapter count if the cursor is fully/partially loaded.
948        if (cursor != null && ConversationCursor.isCursorReadyToShow(cursor) &&
949                mListAdapter.getCount() == 0) {
950            showEmptyView();
951        } else {
952            showListView();
953        }
954    }
955
956    private void setSwipeAction() {
957        int swipeSetting = Settings.getSwipeSetting(mAccount.settings);
958        if (swipeSetting == Swipe.DISABLED
959                || !mAccount.supportsCapability(AccountCapabilities.UNDO)
960                || (mFolder != null && mFolder.isTrash())) {
961            mListView.enableSwipe(false);
962        } else {
963            final int action;
964            mListView.enableSwipe(true);
965            if (mFolder == null) {
966                action = R.id.remove_folder;
967            } else {
968                switch (swipeSetting) {
969                    // Try to respect user's setting as best as we can and default to doing nothing
970                    case Swipe.DELETE:
971                        // Delete in Outbox means discard failed message and put it in draft
972                        if (mFolder.isType(UIProvider.FolderType.OUTBOX)) {
973                            action = R.id.discard_outbox;
974                        } else {
975                            action = R.id.delete;
976                        }
977                        break;
978                    case Swipe.ARCHIVE:
979                        // Special case spam since it shouldn't remove spam folder label on swipe
980                        if (mAccount.supportsCapability(AccountCapabilities.ARCHIVE)
981                                && !mFolder.isSpam()) {
982                            if (mFolder.supportsCapability(FolderCapabilities.ARCHIVE)) {
983                                action = R.id.archive;
984                                break;
985                            } else if (mFolder.supportsCapability
986                                    (FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)) {
987                                action = R.id.remove_folder;
988                                break;
989                            }
990                        }
991
992                        /*
993                         * If we get here, we don't support archive, on either the account or the
994                         * folder, so we want to fall through to swipe doing nothing
995                         */
996                        //$FALL-THROUGH$
997                    default:
998                        mListView.enableSwipe(false);
999                        action = 0; // Use default value so setSwipeAction essentially has no effect
1000                        break;
1001                }
1002            }
1003            mListView.setSwipeAction(action);
1004        }
1005        mListView.setCurrentAccount(mAccount);
1006        mListView.setCurrentFolder(mFolder);
1007    }
1008
1009    /**
1010     * Changes the conversation cursor in the list and sets selected position if none is set.
1011     */
1012    private void onCursorUpdated() {
1013        if (mCallbacks == null || mListAdapter == null) {
1014            return;
1015        }
1016        // Check against the previous cursor here and see if they are the same. If they are, then
1017        // do a notifyDataSetChanged.
1018        final ConversationCursor newCursor = mCallbacks.getConversationListCursor();
1019
1020        if (newCursor == null && mListAdapter.getCursor() != null) {
1021            // We're losing our cursor, so save our scroll position
1022            saveLastScrolledPosition();
1023        }
1024
1025        mListAdapter.swapCursor(newCursor);
1026        // When the conversation cursor is *updated*, we get back the same instance. In that
1027        // situation, CursorAdapter.swapCursor() silently returns, without forcing a
1028        // notifyDataSetChanged(). So let's force a call to notifyDataSetChanged, since an updated
1029        // cursor means that the dataset has changed.
1030        final int newCursorHash = (newCursor == null) ? 0 : newCursor.hashCode();
1031        if (mConversationCursorHash == newCursorHash && mConversationCursorHash != 0) {
1032            mListAdapter.notifyDataSetChanged();
1033        }
1034        mConversationCursorHash = newCursorHash;
1035
1036        updateAnalyticsData(newCursor);
1037        if (newCursor != null) {
1038            final int newCursorCount = newCursor.getCount();
1039            updateSearchResultHeader(newCursorCount);
1040            if (newCursorCount > 0) {
1041                newCursor.markContentsSeen();
1042                restoreLastScrolledPosition();
1043            }
1044        }
1045
1046        // If a current conversation is available, and none is selected in the list, then ask
1047        // the list to select the current conversation.
1048        final Conversation conv = mCallbacks.getCurrentConversation();
1049        if (conv != null) {
1050            if (mListView.getChoiceMode() != ListView.CHOICE_MODE_NONE
1051                    && mListView.getCheckedItemPosition() == -1) {
1052                setSelected(conv.position, true);
1053            }
1054        }
1055    }
1056
1057    public void commitDestructiveActions(boolean animate) {
1058        if (mListView != null) {
1059            mListView.commitDestructiveActions(animate);
1060
1061        }
1062    }
1063
1064    @Override
1065    public void onListItemSwiped(Collection<Conversation> conversations) {
1066        mUpdater.showNextConversation(conversations);
1067    }
1068
1069    private void checkSyncStatus() {
1070        if (mFolder != null && mFolder.isSyncInProgress()) {
1071            LogUtils.d(LOG_TAG, "CLF.checkSyncStatus still syncing");
1072            // Still syncing, ignore
1073        } else {
1074            // Finished syncing:
1075            LogUtils.d(LOG_TAG, "CLF.checkSyncStatus done syncing");
1076            mSwipeRefreshWidget.setRefreshing(false);
1077        }
1078    }
1079
1080    /**
1081     * Displays the indefinite progress bar indicating a sync is in progress.  This
1082     * should only be called if user manually requested a sync, and not for background syncs.
1083     */
1084    protected void showSyncStatusBar() {
1085        mSwipeRefreshWidget.setRefreshing(true);
1086    }
1087
1088    /**
1089     * Clears all items in the list.
1090     */
1091    public void clear() {
1092        mListView.setAdapter(null);
1093    }
1094
1095    private final ConversationSetObserver mConversationSetObserver = new ConversationSetObserver() {
1096        @Override
1097        public void onSetPopulated(final ConversationSelectionSet set) {
1098            // Disable the swipe to refresh widget.
1099            mSwipeRefreshWidget.setEnabled(false);
1100        }
1101
1102        @Override
1103        public void onSetEmpty() {
1104            mSwipeRefreshWidget.setEnabled(true);
1105        }
1106
1107        @Override
1108        public void onSetChanged(final ConversationSelectionSet set) {
1109            // Do nothing
1110        }
1111    };
1112
1113    private void saveLastScrolledPosition() {
1114        if (mListAdapter.getCursor() == null) {
1115            // If you save your scroll position in an empty list, you're gonna have a bad time
1116            return;
1117        }
1118
1119        final Parcelable savedState = mListView.onSaveInstanceState();
1120
1121        mActivity.getListHandler().setConversationListScrollPosition(
1122                mFolder.conversationListUri.toString(), savedState);
1123    }
1124
1125    private void restoreLastScrolledPosition() {
1126        // Scroll to our previous position, if necessary
1127        if (!mScrollPositionRestored && mFolder != null) {
1128            final String key = mFolder.conversationListUri.toString();
1129            final Parcelable savedState = mActivity.getListHandler()
1130                    .getConversationListScrollPosition(key);
1131            if (savedState != null) {
1132                mListView.onRestoreInstanceState(savedState);
1133            }
1134            mScrollPositionRestored = true;
1135        }
1136    }
1137
1138    /* (non-Javadoc)
1139     * @see android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener#onRefresh()
1140     */
1141    @Override
1142    public void onRefresh() {
1143        Analytics.getInstance().sendEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, "swipe_refresh", null,
1144                0);
1145
1146        // This will call back to showSyncStatusBar():
1147        mActivity.getFolderController().requestFolderRefresh();
1148
1149        // Clear list adapter state out of an abundance of caution.
1150        // There is a class of bugs where an animation that should have finished doesn't (maybe
1151        // it didn't start, or it didn't finish), and the list gets stuck pretty much forever.
1152        // Clearing the state here is in line with user expectation for 'refresh'.
1153        getAnimatedAdapter().clearAnimationState();
1154        // possibly act on the now-cleared state
1155        mActivity.onAnimationEnd(mListAdapter);
1156    }
1157
1158    /**
1159     * Extracted function that handles Analytics state and logging updates for each new cursor
1160     * @param newCursor the new cursor pointer
1161     */
1162    private void updateAnalyticsData(ConversationCursor newCursor) {
1163        if (newCursor != null) {
1164            // Check if the initial data returned yet
1165            if (mInitialCursorLoading) {
1166                // This marks the very first time the cursor with the data the user sees returned.
1167                // We either have a cursor in LOADING state with cursor's count > 0, OR the cursor
1168                // completed loading.
1169                // Use this point to log the appropriate timing information that depends on when
1170                // the conversation list view finishes loading
1171                if (isCursorReadyToShow()) {
1172                    if (newCursor.getCount() == 0) {
1173                        Analytics.getInstance().sendEvent("empty_state", "post_label_change",
1174                                mFolder.getTypeDescription(), 0);
1175                    }
1176                    AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.COLD_START_LAUNCHER,
1177                            true /* isDestructive */, "cold_start_to_list", "from_launcher", null);
1178                    // Don't need null checks because the activity, controller, and folder cannot
1179                    // be null in this case
1180                    if (mActivity.getFolderController().getFolder().isSearch()) {
1181                        AnalyticsTimer.getInstance().logDuration(AnalyticsTimer.SEARCH_TO_LIST,
1182                                true /* isDestructive */, "search_to_list", null, null);
1183                    }
1184
1185                    mInitialCursorLoading = false;
1186                }
1187            } else {
1188                // Log the appropriate events that happen after the initial cursor is loaded
1189                if (newCursor.getCount() == 0 && mConversationCursorLastCount > 0) {
1190                    Analytics.getInstance().sendEvent("empty_state", "post_delete",
1191                            mFolder.getTypeDescription(), 0);
1192                }
1193            }
1194
1195            // We save the count here because for folders that are empty, multiple successful
1196            // cursor loads will occur with size of 0. Thus we don't want to emit any false
1197            // positive post_delete events.
1198            mConversationCursorLastCount = newCursor.getCount();
1199        } else {
1200            mConversationCursorLastCount = 0;
1201        }
1202    }
1203
1204    /**
1205     * Helper function to determine if the current cursor is ready to populate the UI
1206     * Since we extracted the functionality into a static function in ConversationCursor,
1207     * this function remains for the sole purpose of readability.
1208     * @return
1209     */
1210    private boolean isCursorReadyToShow() {
1211        return ConversationCursor.isCursorReadyToShow(getConversationListCursor());
1212    }
1213
1214    public ListView getListView() {
1215        return mListView;
1216    }
1217
1218    public void setNextFocusLeftId(@IdRes int id) {
1219        mNextFocusLeftId = id;
1220        if (mListView != null) {
1221            mListView.setNextFocusLeftId(mNextFocusLeftId);
1222        }
1223    }
1224}
1225