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