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