• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2012 Google Inc.
3  * Licensed to The Android Open Source Project.
4  *
5  * Licensed under the Apache License, Version 2.0 (the "License");
6  * you may not use this file except in compliance with the License.
7  * You may obtain a copy of the License at
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
11  * Unless required by applicable law or agreed to in writing, software
12  * distributed under the License is distributed on an "AS IS" BASIS,
13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  * See the License for the specific language governing permissions and
15  * limitations under the License.
16  */
17 
18 package com.android.mail.ui;
19 
20 import android.animation.LayoutTransition;
21 import android.app.Activity;
22 import android.app.Fragment;
23 import android.app.LoaderManager;
24 import android.content.res.Resources;
25 import android.database.DataSetObserver;
26 import android.os.Bundle;
27 import android.os.Handler;
28 import android.os.Parcelable;
29 import android.support.annotation.IdRes;
30 import android.support.v4.widget.SwipeRefreshLayout.OnRefreshListener;
31 import android.view.KeyEvent;
32 import android.view.LayoutInflater;
33 import android.view.View;
34 import android.view.ViewGroup;
35 import android.widget.AdapterView;
36 import android.widget.AdapterView.OnItemLongClickListener;
37 import android.widget.ListView;
38 import android.widget.TextView;
39 
40 import com.android.mail.ConversationListContext;
41 import com.android.mail.R;
42 import com.android.mail.analytics.Analytics;
43 import com.android.mail.analytics.AnalyticsTimer;
44 import com.android.mail.browse.ConversationCursor;
45 import com.android.mail.browse.ConversationItemView;
46 import com.android.mail.browse.ConversationItemViewModel;
47 import com.android.mail.browse.ConversationListFooterView;
48 import com.android.mail.browse.ToggleableItem;
49 import com.android.mail.providers.Account;
50 import com.android.mail.providers.AccountObserver;
51 import com.android.mail.providers.Conversation;
52 import com.android.mail.providers.Folder;
53 import com.android.mail.providers.FolderObserver;
54 import com.android.mail.providers.Settings;
55 import com.android.mail.providers.UIProvider;
56 import com.android.mail.providers.UIProvider.AccountCapabilities;
57 import com.android.mail.providers.UIProvider.ConversationListIcon;
58 import com.android.mail.providers.UIProvider.FolderCapabilities;
59 import com.android.mail.providers.UIProvider.Swipe;
60 import com.android.mail.ui.SwipeableListView.ListItemSwipedListener;
61 import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener;
62 import com.android.mail.ui.SwipeableListView.SwipeListener;
63 import com.android.mail.ui.ViewMode.ModeChangeListener;
64 import com.android.mail.utils.LogTag;
65 import com.android.mail.utils.LogUtils;
66 import com.android.mail.utils.Utils;
67 import com.google.common.collect.ImmutableList;
68 
69 import java.util.Collection;
70 import java.util.List;
71 
72 import static android.view.View.OnKeyListener;
73 
74 /**
75  * The conversation list UI component.
76  */
77 public 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
showListView()167     private void showListView() {
168         mListView.setVisibility(View.VISIBLE);
169         mEmptyView.setVisibility(View.INVISIBLE);
170         mLoadingView.setVisibility(View.INVISIBLE);
171     }
172 
showEmptyView()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 
showLoadingView()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      */
ConversationListFragment()226     public ConversationListFragment() {
227         super();
228     }
229 
230     @Override
onBeginSwipe()231     public void onBeginSwipe() {
232         mSwipeRefreshWidget.setEnabled(false);
233     }
234 
235     @Override
onEndSwipe()236     public void onEndSwipe() {
237         mSwipeRefreshWidget.setEnabled(true);
238     }
239 
240     private class ConversationCursorObserver extends DataSetObserver {
241         @Override
onChanged()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      */
newInstance(ConversationListContext viewContext)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      */
updateSearchResultHeader(int count)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
onActivityCreated(Bundle savedState)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      */
getDefaultChoiceMode(boolean isTablet)400     private final static int getDefaultChoiceMode(boolean isTablet) {
401         return isTablet ? ListView.CHOICE_MODE_SINGLE : ListView.CHOICE_MODE_NONE;
402     }
403 
getAnimatedAdapter()404     public AnimatedAdapter getAnimatedAdapter() {
405         return mListAdapter;
406     }
407 
408     @Override
onCreate(Bundle savedState)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
toString()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
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState)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      */
setChoiceMode(int choiceMode)494     private final void setChoiceMode(int choiceMode) {
495         mListView.setChoiceMode(choiceMode);
496     }
497 
498     /**
499      * Tell the list to select nothing.
500      */
setChoiceNone()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      */
revertChoiceMode()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
onDestroy()522     public void onDestroy() {
523         super.onDestroy();
524     }
525 
526     @Override
onDestroyView()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
onItemLongClick(AdapterView<?> parent, View view, int position, long id)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
onItemClick(AdapterView<?> adapterView, View view, int position, long id)596     public void onItemClick(AdapterView<?> adapterView, View view, int position, long id) {
597         onListItemSelected(view, position);
598     }
599 
onListItemSelected(View view, int position)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
onKey(View view, int keyCode, KeyEvent keyEvent)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
onResume()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
onPause()676     public void onPause() {
677         super.onPause();
678 
679         mSelectedSet.removeObserver(mConversationSetObserver);
680 
681         saveLastScrolledPosition();
682     }
683 
684     @Override
onSaveInstanceState(Bundle outState)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
onStart()698     public void onStart() {
699         super.onStart();
700         mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL);
701         Analytics.getInstance().sendView("ConversationListFragment");
702     }
703 
704     @Override
onStop()705     public void onStop() {
706         super.onStop();
707         mHandler.removeCallbacks(mUpdateTimestampsRunnable);
708     }
709 
710     @Override
onViewModeChanged(int newMode)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 
isAnimating()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 
clearChoicesAndActivated()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      */
showList()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      */
viewConversation(final int position)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      */
setSelected(final int cursorPosition, boolean different)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      */
setRawSelected(final int position, final boolean different)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      */
getConversationListCursor()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      */
requestListRefresh()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      */
requestDelete(int actionId, final Collection<Conversation> conversations, final DestructiveAction action)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 
onFolderUpdated(Folder folder)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      */
onConversationListStatusUpdated()928     public void onConversationListStatusUpdated() {
929         // Also change the cursor here.
930         onCursorUpdated();
931 
932         if (isCursorReadyToShow() && mCanTakeDownLoadingView) {
933             hideLoadingViewAndShowContents();
934         }
935     }
936 
hideLoadingViewAndShowContents()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 
setSwipeAction()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      */
onCursorUpdated()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 
commitDestructiveActions(boolean animate)1057     public void commitDestructiveActions(boolean animate) {
1058         if (mListView != null) {
1059             mListView.commitDestructiveActions(animate);
1060 
1061         }
1062     }
1063 
1064     @Override
onListItemSwiped(Collection<Conversation> conversations)1065     public void onListItemSwiped(Collection<Conversation> conversations) {
1066         mUpdater.showNextConversation(conversations);
1067     }
1068 
checkSyncStatus()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      */
showSyncStatusBar()1084     protected void showSyncStatusBar() {
1085         mSwipeRefreshWidget.setRefreshing(true);
1086     }
1087 
1088     /**
1089      * Clears all items in the list.
1090      */
clear()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 
saveLastScrolledPosition()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 
restoreLastScrolledPosition()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
onRefresh()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      */
updateAnalyticsData(ConversationCursor newCursor)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      */
isCursorReadyToShow()1210     private boolean isCursorReadyToShow() {
1211         return ConversationCursor.isCursorReadyToShow(getConversationListCursor());
1212     }
1213 
getListView()1214     public ListView getListView() {
1215         return mListView;
1216     }
1217 
setNextFocusLeftId(@dRes int id)1218     public void setNextFocusLeftId(@IdRes int id) {
1219         mNextFocusLeftId = id;
1220         if (mListView != null) {
1221             mListView.setNextFocusLeftId(mNextFocusLeftId);
1222         }
1223     }
1224 }
1225