• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2012 Google Inc.
3  * Licensed to The Android Open Source Project.
4  *
5  * Licensed under the Apache License, Version 2.0 (the "License");
6  * you may not use this file except in compliance with the License.
7  * You may obtain a copy of the License at
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
11  * Unless required by applicable law or agreed to in writing, software
12  * distributed under the License is distributed on an "AS IS" BASIS,
13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  * See the License for the specific language governing permissions and
15  * limitations under the License.
16  */
17 
18 package com.android.mail.ui;
19 
20 import android.app.Activity;
21 import android.app.ListFragment;
22 import android.app.LoaderManager;
23 import android.content.Context;
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.text.format.DateUtils;
30 import android.view.LayoutInflater;
31 import android.view.View;
32 import android.view.ViewGroup;
33 import android.view.ViewGroup.MarginLayoutParams;
34 import android.widget.AdapterView;
35 import android.widget.AdapterView.OnItemLongClickListener;
36 import android.widget.ListView;
37 import android.widget.TextView;
38 
39 import com.android.mail.ConversationListContext;
40 import com.android.mail.R;
41 import com.android.mail.analytics.Analytics;
42 import com.android.mail.browse.ConversationCursor;
43 import com.android.mail.browse.ConversationItemView;
44 import com.android.mail.browse.ConversationItemViewModel;
45 import com.android.mail.browse.ConversationListFooterView;
46 import com.android.mail.browse.ToggleableItem;
47 import com.android.mail.providers.Account;
48 import com.android.mail.providers.AccountObserver;
49 import com.android.mail.providers.Conversation;
50 import com.android.mail.providers.Folder;
51 import com.android.mail.providers.FolderObserver;
52 import com.android.mail.providers.Settings;
53 import com.android.mail.providers.UIProvider;
54 import com.android.mail.providers.UIProvider.AccountCapabilities;
55 import com.android.mail.providers.UIProvider.ConversationListIcon;
56 import com.android.mail.providers.UIProvider.FolderCapabilities;
57 import com.android.mail.providers.UIProvider.FolderType;
58 import com.android.mail.providers.UIProvider.Swipe;
59 import com.android.mail.ui.AnimatedAdapter.ConversationListListener;
60 import com.android.mail.ui.SwipeableListView.ListItemSwipedListener;
61 import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener;
62 import com.android.mail.ui.ViewMode.ModeChangeListener;
63 import com.android.mail.utils.LogTag;
64 import com.android.mail.utils.LogUtils;
65 import com.android.mail.utils.Utils;
66 import com.google.common.collect.ImmutableList;
67 
68 import java.util.Collection;
69 import java.util.List;
70 
71 /**
72  * The conversation list UI component.
73  */
74 public final class ConversationListFragment extends ListFragment implements
75         OnItemLongClickListener, ModeChangeListener, ListItemSwipedListener {
76     /** Key used to pass data to {@link ConversationListFragment}. */
77     private static final String CONVERSATION_LIST_KEY = "conversation-list";
78     /** Key used to keep track of the scroll state of the list. */
79     private static final String LIST_STATE_KEY = "list-state";
80 
81     private static final String LOG_TAG = LogTag.getLogTag();
82     /** Key used to save the ListView choice mode, since ListView doesn't save it automatically! */
83     private static final String CHOICE_MODE_KEY = "choice-mode-key";
84 
85     // True if we are on a tablet device
86     private static boolean mTabletDevice;
87 
88     /**
89      * Frequency of update of timestamps. Initialized in
90      * {@link #onCreate(Bundle)} and final afterwards.
91      */
92     private static int TIMESTAMP_UPDATE_INTERVAL = 0;
93 
94     private static long NO_NEW_MESSAGE_DURATION = 1 * DateUtils.SECOND_IN_MILLIS;
95 
96     private ControllableActivity mActivity;
97 
98     // Control state.
99     private ConversationListCallbacks mCallbacks;
100 
101     private final Handler mHandler = new Handler();
102 
103     private ConversationListView mConversationListView;
104 
105     // The internal view objects.
106     private SwipeableListView mListView;
107 
108     private TextView mSearchResultCountTextView;
109     private TextView mSearchStatusTextView;
110 
111     private View mSearchStatusView;
112 
113     /**
114      * Current Account being viewed
115      */
116     private Account mAccount;
117     /**
118      * Current folder being viewed.
119      */
120     private Folder mFolder;
121 
122     /**
123      * A simple method to update the timestamps of conversations periodically.
124      */
125     private Runnable mUpdateTimestampsRunnable = null;
126 
127     private ConversationListContext mViewContext;
128 
129     private AnimatedAdapter mListAdapter;
130 
131     private ConversationListFooterView mFooterView;
132     private View mEmptyView;
133     private ErrorListener mErrorListener;
134     private FolderObserver mFolderObserver;
135     private DataSetObserver mConversationCursorObserver;
136 
137     private ConversationSelectionSet mSelectedSet;
138     private final AccountObserver mAccountObserver = new AccountObserver() {
139         @Override
140         public void onChanged(Account newAccount) {
141             mAccount = newAccount;
142             setSwipeAction();
143         }
144     };
145     private ConversationUpdater mUpdater;
146     /** Hash of the Conversation Cursor we last obtained from the controller. */
147     private int mConversationCursorHash;
148 
149     /** Duration, in milliseconds, of the CAB mode (peek icon) animation. */
150     private static long sSelectionModeAnimationDuration = -1;
151     /** The time at which we last exited CAB mode. */
152     private long mSelectionModeExitedTimestamp = -1;
153 
154     /**
155      * If <code>true</code>, we have restored (or attempted to restore) the list's scroll position
156      * from when we were last on this conversation list.
157      */
158     private boolean mScrollPositionRestored = false;
159 
160     /**
161      * Constructor needs to be public to handle orientation changes and activity
162      * lifecycle events.
163      */
ConversationListFragment()164     public ConversationListFragment() {
165         super();
166     }
167 
168     private class ConversationCursorObserver extends DataSetObserver {
169         @Override
onChanged()170         public void onChanged() {
171             onConversationListStatusUpdated();
172         }
173     }
174 
175     /**
176      * Creates a new instance of {@link ConversationListFragment}, initialized
177      * to display conversation list context.
178      */
newInstance(ConversationListContext viewContext)179     public static ConversationListFragment newInstance(ConversationListContext viewContext) {
180         final ConversationListFragment fragment = new ConversationListFragment();
181         final Bundle args = new Bundle(1);
182         args.putBundle(CONVERSATION_LIST_KEY, viewContext.toBundle());
183         fragment.setArguments(args);
184         return fragment;
185     }
186 
187     /**
188      * Show the header if the current conversation list is showing search
189      * results.
190      */
configureSearchResultHeader()191     void configureSearchResultHeader() {
192         if (mActivity == null) {
193             return;
194         }
195         // Only show the header if the context is for a search result
196         final Resources res = getResources();
197         final boolean showHeader = ConversationListContext.isSearchResult(mViewContext);
198         // TODO(viki): This code contains intimate understanding of the view.
199         // Much of this logic
200         // needs to reside in a separate class that handles the text view in
201         // isolation. Then,
202         // that logic can be reused in other fragments.
203         if (showHeader) {
204             mSearchStatusTextView.setText(res.getString(R.string.search_results_searching_header));
205             // Initially reset the count
206             mSearchResultCountTextView.setText("");
207         }
208         mSearchStatusView.setVisibility(showHeader ? View.VISIBLE : View.GONE);
209         int marginTop = showHeader ? (int) res.getDimension(R.dimen.notification_view_height) : 0;
210         MarginLayoutParams layoutParams = (MarginLayoutParams) mListView.getLayoutParams();
211         layoutParams.topMargin = marginTop;
212         mListView.setLayoutParams(layoutParams);
213     }
214 
215     /**
216      * Show the header if the current conversation list is showing search
217      * results.
218      */
updateSearchResultHeader(int count)219     private void updateSearchResultHeader(int count) {
220         if (mActivity == null) {
221             return;
222         }
223         // Only show the header if the context is for a search result
224         final Resources res = getResources();
225         final boolean showHeader = ConversationListContext.isSearchResult(mViewContext);
226         if (showHeader) {
227             mSearchStatusTextView.setText(res.getString(R.string.search_results_header));
228             mSearchResultCountTextView
229                     .setText(res.getString(R.string.search_results_loaded, count));
230         }
231     }
232 
233     /**
234      * Initializes all internal state for a rendering.
235      */
initializeUiForFirstDisplay()236     private void initializeUiForFirstDisplay() {
237         // TODO(mindyp): find some way to make the notification container more
238         // re-usable.
239         // TODO(viki): refactor according to comment in
240         // configureSearchResultHandler()
241         mSearchStatusView = mActivity.findViewById(R.id.search_status_view);
242         mSearchStatusTextView = (TextView) mActivity.findViewById(R.id.search_status_text_view);
243         mSearchResultCountTextView = (TextView) mActivity
244                 .findViewById(R.id.search_result_count_view);
245     }
246 
247     @Override
onActivityCreated(Bundle savedState)248     public void onActivityCreated(Bundle savedState) {
249         super.onActivityCreated(savedState);
250 
251         if (sSelectionModeAnimationDuration < 0) {
252             sSelectionModeAnimationDuration = getResources().getInteger(
253                     R.integer.conv_item_view_cab_anim_duration);
254         }
255 
256         // Strictly speaking, we get back an android.app.Activity from
257         // getActivity. However, the
258         // only activity creating a ConversationListContext is a MailActivity
259         // which is of type
260         // ControllableActivity, so this cast should be safe. If this cast
261         // fails, some other
262         // activity is creating ConversationListFragments. This activity must be
263         // of type
264         // ControllableActivity.
265         final Activity activity = getActivity();
266         if (!(activity instanceof ControllableActivity)) {
267             LogUtils.e(LOG_TAG, "ConversationListFragment expects only a ControllableActivity to"
268                     + "create it. Cannot proceed.");
269         }
270         mActivity = (ControllableActivity) activity;
271         // Since we now have a controllable activity, load the account from it,
272         // and register for
273         // future account changes.
274         mAccount = mAccountObserver.initialize(mActivity.getAccountController());
275         mCallbacks = mActivity.getListHandler();
276         mErrorListener = mActivity.getErrorListener();
277         // Start off with the current state of the folder being viewed.
278         Context activityContext = mActivity.getActivityContext();
279         mFooterView = (ConversationListFooterView) LayoutInflater.from(
280                 activityContext).inflate(R.layout.conversation_list_footer_view,
281                 null);
282         mFooterView.setClickListener(mActivity);
283         mConversationListView.setActivity(mActivity);
284         final ConversationCursor conversationCursor = getConversationListCursor();
285         final LoaderManager manager = getLoaderManager();
286 
287         // TODO: These special views are always created, doesn't matter whether they will
288         // be shown or not, as we add more views this will get more expensive. Given these are
289         // tips that are only shown once to the user, we should consider creating these on demand.
290         final ConversationListHelper helper = mActivity.getConversationListHelper();
291         final List<ConversationSpecialItemView> specialItemViews = helper != null ?
292                 ImmutableList.copyOf(helper.makeConversationListSpecialViews(
293                         activity, mActivity, mAccount))
294                 : null;
295         if (specialItemViews != null) {
296             // Attach to the LoaderManager
297             for (final ConversationSpecialItemView view : specialItemViews) {
298                 view.bindFragment(manager, savedState);
299             }
300         }
301 
302         mListAdapter = new AnimatedAdapter(mActivity.getApplicationContext(), conversationCursor,
303                 mActivity.getSelectedSet(), mActivity, mConversationListListener, mListView,
304                 specialItemViews);
305         mListAdapter.addFooter(mFooterView);
306         mListView.setAdapter(mListAdapter);
307         mSelectedSet = mActivity.getSelectedSet();
308         mListView.setSelectionSet(mSelectedSet);
309         mListAdapter.setFooterVisibility(false);
310         mFolderObserver = new FolderObserver(){
311             @Override
312             public void onChanged(Folder newFolder) {
313                 onFolderUpdated(newFolder);
314             }
315         };
316         mFolderObserver.initialize(mActivity.getFolderController());
317         mConversationCursorObserver = new ConversationCursorObserver();
318         mUpdater = mActivity.getConversationUpdater();
319         mUpdater.registerConversationListObserver(mConversationCursorObserver);
320         mTabletDevice = Utils.useTabletUI(mActivity.getApplicationContext().getResources());
321         initializeUiForFirstDisplay();
322         configureSearchResultHeader();
323         // The onViewModeChanged callback doesn't get called when the mode
324         // object is created, so
325         // force setting the mode manually this time around.
326         onViewModeChanged(mActivity.getViewMode().getMode());
327         mActivity.getViewMode().addListener(this);
328 
329         if (mActivity.isFinishing()) {
330             // Activity is finishing, just bail.
331             return;
332         }
333         mConversationCursorHash = (conversationCursor == null) ? 0 : conversationCursor.hashCode();
334         // Belt and suspenders here; make sure we do any necessary sync of the
335         // ConversationCursor
336         if (conversationCursor != null && conversationCursor.isRefreshReady()) {
337             conversationCursor.sync();
338         }
339 
340         // On a phone we never highlight a conversation, so the default is to select none.
341         // On a tablet, we highlight a SINGLE conversation in landscape conversation view.
342         int choice = getDefaultChoiceMode(mTabletDevice);
343         if (savedState != null) {
344             // Restore the choice mode if it was set earlier, or NONE if creating a fresh view.
345             // Choice mode here represents the current conversation only. CAB mode does not rely on
346             // the platform: checked state is a local variable {@link ConversationItemView#mChecked}
347             choice = savedState.getInt(CHOICE_MODE_KEY, choice);
348             if (savedState.containsKey(LIST_STATE_KEY)) {
349                 // TODO: find a better way to unset the selected item when restoring
350                 mListView.clearChoices();
351             }
352         }
353         setChoiceMode(choice);
354 
355         // Show list and start loading list.
356         showList();
357         ToastBarOperation pendingOp = mActivity.getPendingToastOperation();
358         if (pendingOp != null) {
359             // Clear the pending operation
360             mActivity.setPendingToastOperation(null);
361             mActivity.onUndoAvailable(pendingOp);
362         }
363     }
364 
365     /**
366      * Returns the default choice mode for the list based on whether the list is displayed on tablet
367      * or not.
368      * @param isTablet
369      * @return
370      */
getDefaultChoiceMode(boolean isTablet)371     private final static int getDefaultChoiceMode(boolean isTablet) {
372         return isTablet ? ListView.CHOICE_MODE_SINGLE : ListView.CHOICE_MODE_NONE;
373     }
374 
getAnimatedAdapter()375     public AnimatedAdapter getAnimatedAdapter() {
376         return mListAdapter;
377     }
378 
379     @Override
onCreate(Bundle savedState)380     public void onCreate(Bundle savedState) {
381         super.onCreate(savedState);
382 
383         // Initialize fragment constants from resources
384         final Resources res = getResources();
385         TIMESTAMP_UPDATE_INTERVAL = res.getInteger(R.integer.timestamp_update_interval);
386         mUpdateTimestampsRunnable = new Runnable() {
387             @Override
388             public void run() {
389                 mListView.invalidateViews();
390                 mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL);
391             }
392         };
393 
394         // Get the context from the arguments
395         final Bundle args = getArguments();
396         mViewContext = ConversationListContext.forBundle(args.getBundle(CONVERSATION_LIST_KEY));
397         mAccount = mViewContext.account;
398 
399         setRetainInstance(false);
400     }
401 
402     @Override
toString()403     public String toString() {
404         final String s = super.toString();
405         if (mViewContext == null) {
406             return s;
407         }
408         final StringBuilder sb = new StringBuilder(s);
409         sb.setLength(sb.length() - 1);
410         sb.append(" mListAdapter=");
411         sb.append(mListAdapter);
412         sb.append(" folder=");
413         sb.append(mViewContext.folder);
414         sb.append("}");
415         return sb.toString();
416     }
417 
418     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState)419     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
420         View rootView = inflater.inflate(R.layout.conversation_list, null);
421         mEmptyView = rootView.findViewById(R.id.empty_view);
422         mConversationListView =
423                 (ConversationListView) rootView.findViewById(R.id.conversation_list);
424         mConversationListView.setConversationContext(mViewContext);
425         mListView = (SwipeableListView) rootView.findViewById(android.R.id.list);
426         mListView.setHeaderDividersEnabled(false);
427         mListView.setOnItemLongClickListener(this);
428         mListView.enableSwipe(mAccount.supportsCapability(AccountCapabilities.UNDO));
429         mListView.setSwipedListener(this);
430 
431         if (savedState != null && savedState.containsKey(LIST_STATE_KEY)) {
432             mListView.onRestoreInstanceState(savedState.getParcelable(LIST_STATE_KEY));
433         }
434 
435         return rootView;
436     }
437 
438     /**
439      * Sets the choice mode of the list view
440      * @param choiceMode ListView#
441      */
setChoiceMode(int choiceMode)442     private final void setChoiceMode(int choiceMode) {
443         mListView.setChoiceMode(choiceMode);
444     }
445 
446     /**
447      * Tell the list to select nothing.
448      */
setChoiceNone()449     public final void setChoiceNone() {
450         // On a phone, the default choice mode is already none, so nothing to do.
451         if (!mTabletDevice) {
452             return;
453         }
454         clearChoicesAndActivated();
455         setChoiceMode(ListView.CHOICE_MODE_NONE);
456     }
457 
458     /**
459      * Tell the list to get out of selecting none.
460      */
revertChoiceMode()461     public final void revertChoiceMode() {
462         // On a phone, the default choice mode is always none, so nothing to do.
463         if (!mTabletDevice) {
464             return;
465         }
466         setChoiceMode(getDefaultChoiceMode(mTabletDevice));
467     }
468 
469     @Override
onDestroy()470     public void onDestroy() {
471         super.onDestroy();
472     }
473 
474     @Override
onDestroyView()475     public void onDestroyView() {
476 
477         // Clear the list's adapter
478         mListAdapter.destroy();
479         mListView.setAdapter(null);
480 
481         mActivity.getViewMode().removeListener(this);
482         if (mFolderObserver != null) {
483             mFolderObserver.unregisterAndDestroy();
484             mFolderObserver = null;
485         }
486         if (mConversationCursorObserver != null) {
487             mUpdater.unregisterConversationListObserver(mConversationCursorObserver);
488             mConversationCursorObserver = null;
489         }
490         mAccountObserver.unregisterAndDestroy();
491         getAnimatedAdapter().cleanup();
492         super.onDestroyView();
493     }
494 
495     /**
496      * There are three binary variables, which determine what we do with a
497      * message. checkbEnabled: Whether check boxes are enabled or not (forced
498      * true on tablet) cabModeOn: Whether CAB mode is currently on or not.
499      * pressType: long or short tap (There is a third possibility: phone or
500      * tablet, but they have <em>identical</em> behavior) The matrix of
501      * possibilities is:
502      * <p>
503      * Long tap: Always toggle selection of conversation. If CAB mode is not
504      * started, then start it.
505      * <pre>
506      *              | Checkboxes | No Checkboxes
507      *    ----------+------------+---------------
508      *    CAB mode  |   Select   |     Select
509      *    List mode |   Select   |     Select
510      *
511      * </pre>
512      *
513      * Reference: http://b/issue?id=6392199
514      * <p>
515      * {@inheritDoc}
516      */
517     @Override
onItemLongClick(AdapterView<?> parent, View view, int position, long id)518     public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
519         // Ignore anything that is not a conversation item. Could be a footer.
520         if (!(view instanceof ConversationItemView)) {
521             return false;
522         }
523         return ((ConversationItemView) view).toggleSelectedStateOrBeginDrag();
524     }
525 
526     /**
527      * See the comment for
528      * {@link #onItemLongClick(AdapterView, View, int, long)}.
529      * <p>
530      * Short tap behavior:
531      *
532      * <pre>
533      *              | Checkboxes | No Checkboxes
534      *    ----------+------------+---------------
535      *    CAB mode  |    Peek    |     Select
536      *    List mode |    Peek    |      Peek
537      * </pre>
538      *
539      * Reference: http://b/issue?id=6392199
540      * <p>
541      * {@inheritDoc}
542      */
543     @Override
onListItemClick(ListView l, View view, int position, long id)544     public void onListItemClick(ListView l, View view, int position, long id) {
545         if (view instanceof ToggleableItem) {
546             final boolean showSenderImage =
547                     (mAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE);
548             final boolean inCabMode = !mSelectedSet.isEmpty();
549             if (!showSenderImage && inCabMode) {
550                 ((ToggleableItem) view).toggleSelectedState();
551             } else {
552                 if (inCabMode) {
553                     // this is a peek.
554                     Analytics.getInstance().sendEvent("peek", null, null, mSelectedSet.size());
555                 }
556                 viewConversation(position);
557             }
558         } else {
559             // Ignore anything that is not a conversation item. Could be a footer.
560             // If we are using a keyboard, the highlighted item is the parent;
561             // otherwise, this is a direct call from the ConverationItemView
562             return;
563         }
564         // When a new list item is clicked, commit any existing leave behind
565         // items. Wait until we have opened the desired conversation to cause
566         // any position changes.
567         commitDestructiveActions(Utils.useTabletUI(mActivity.getActivityContext().getResources()));
568     }
569 
570     @Override
onResume()571     public void onResume() {
572         super.onResume();
573 
574         final ConversationCursor conversationCursor = getConversationListCursor();
575         if (conversationCursor != null) {
576             conversationCursor.handleNotificationActions();
577 
578             restoreLastScrolledPosition();
579         }
580 
581         mSelectedSet.addObserver(mConversationSetObserver);
582     }
583 
584     @Override
onPause()585     public void onPause() {
586         super.onPause();
587 
588         mSelectedSet.removeObserver(mConversationSetObserver);
589 
590         saveLastScrolledPosition();
591     }
592 
593     @Override
onSaveInstanceState(Bundle outState)594     public void onSaveInstanceState(Bundle outState) {
595         super.onSaveInstanceState(outState);
596         if (mListView != null) {
597             outState.putParcelable(LIST_STATE_KEY, mListView.onSaveInstanceState());
598             outState.putInt(CHOICE_MODE_KEY, mListView.getChoiceMode());
599         }
600 
601         if (mListAdapter != null) {
602             mListAdapter.saveSpecialItemInstanceState(outState);
603         }
604     }
605 
606     @Override
onStart()607     public void onStart() {
608         super.onStart();
609         mHandler.postDelayed(mUpdateTimestampsRunnable, TIMESTAMP_UPDATE_INTERVAL);
610         Analytics.getInstance().sendView(getClass().getName());
611     }
612 
613     @Override
onStop()614     public void onStop() {
615         super.onStop();
616         mHandler.removeCallbacks(mUpdateTimestampsRunnable);
617     }
618 
619     @Override
onViewModeChanged(int newMode)620     public void onViewModeChanged(int newMode) {
621         if (mTabletDevice) {
622             if (ViewMode.isListMode(newMode)) {
623                 // There are no selected conversations when in conversation list mode.
624                 clearChoicesAndActivated();
625             }
626         }
627         if (mFooterView != null) {
628             mFooterView.onViewModeChanged(newMode);
629         }
630     }
631 
isAnimating()632     public boolean isAnimating() {
633         final AnimatedAdapter adapter = getAnimatedAdapter();
634         return (adapter != null && adapter.isAnimating()) ||
635                 (mListView != null && mListView.isScrolling());
636     }
637 
clearChoicesAndActivated()638     private void clearChoicesAndActivated() {
639         final int currentSelected = mListView.getCheckedItemPosition();
640         if (currentSelected != ListView.INVALID_POSITION) {
641             mListView.setItemChecked(mListView.getCheckedItemPosition(), false);
642         }
643     }
644 
645     /**
646      * Handles a request to show a new conversation list, either from a search
647      * query or for viewing a folder. This will initiate a data load, and hence
648      * must be called on the UI thread.
649      */
showList()650     private void showList() {
651         mListView.setEmptyView(null);
652         onFolderUpdated(mActivity.getFolderController().getFolder());
653         onConversationListStatusUpdated();
654     }
655 
656     /**
657      * View the message at the given position.
658      *
659      * @param position The position of the conversation in the list (as opposed to its position
660      *        in the cursor)
661      */
viewConversation(final int position)662     private void viewConversation(final int position) {
663         LogUtils.d(LOG_TAG, "ConversationListFragment.viewConversation(%d)", position);
664 
665         final ConversationCursor cursor =
666                 (ConversationCursor) getAnimatedAdapter().getItem(position);
667 
668         if (cursor == null) {
669             LogUtils.e(LOG_TAG,
670                     "unable to open conv at cursor pos=%s cursor=%s getPositionOffset=%s",
671                     position, cursor, getAnimatedAdapter().getPositionOffset(position));
672             return;
673         }
674 
675         final Conversation conv = cursor.getConversation();
676         /*
677          * The cursor position may be different than the position method parameter because of
678          * special views in the list.
679          */
680         conv.position = cursor.getPosition();
681         setSelected(conv.position, true);
682         mCallbacks.onConversationSelected(conv, false /* inLoaderCallbacks */);
683     }
684 
685     private final ConversationListListener mConversationListListener =
686             new ConversationListListener() {
687         @Override
688         public boolean isExitingSelectionMode() {
689             return System.currentTimeMillis() <
690                     (mSelectionModeExitedTimestamp + sSelectionModeAnimationDuration);
691         }
692     };
693 
694     /**
695      * Sets the selected conversation to the position given here.
696      * @param cursorPosition The position of the conversation in the cursor (as opposed to
697      * in the list)
698      * @param different if the currently selected conversation is different from the one provided
699      * here.  This is a difference in conversations, not a difference in positions. For example, a
700      * conversation at position 2 can move to position 4 as a result of new mail.
701      */
setSelected(final int cursorPosition, boolean different)702     public void setSelected(final int cursorPosition, boolean different) {
703         if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) {
704             return;
705         }
706 
707         final int position =
708                 cursorPosition + getAnimatedAdapter().getPositionOffset(cursorPosition);
709 
710         setRawSelected(position, different);
711     }
712 
713     /**
714      * Sets the selected conversation to the position given here.
715      * @param position The position of the item in the list
716      * @param different if the currently selected conversation is different from the one provided
717      * here.  This is a difference in conversations, not a difference in positions. For example, a
718      * conversation at position 2 can move to position 4 as a result of new mail.
719      */
setRawSelected(final int position, final boolean different)720     public void setRawSelected(final int position, final boolean different) {
721         if (mListView.getChoiceMode() == ListView.CHOICE_MODE_NONE) {
722             return;
723         }
724 
725         if (different) {
726             mListView.smoothScrollToPosition(position);
727         }
728         mListView.setItemChecked(position, true);
729     }
730 
731     /**
732      * Returns the cursor associated with the conversation list.
733      * @return
734      */
getConversationListCursor()735     private ConversationCursor getConversationListCursor() {
736         return mCallbacks != null ? mCallbacks.getConversationListCursor() : null;
737     }
738 
739     /**
740      * Request a refresh of the list. No sync is carried out and none is
741      * promised.
742      */
requestListRefresh()743     public void requestListRefresh() {
744         mListAdapter.notifyDataSetChanged();
745     }
746 
747     /**
748      * Change the UI to delete the conversations provided and then call the
749      * {@link DestructiveAction} provided here <b>after</b> the UI has been
750      * updated.
751      * @param conversations
752      * @param action
753      */
requestDelete(int actionId, final Collection<Conversation> conversations, final DestructiveAction action)754     public void requestDelete(int actionId, final Collection<Conversation> conversations,
755             final DestructiveAction action) {
756         for (Conversation conv : conversations) {
757             conv.localDeleteOnUpdate = true;
758         }
759         final ListItemsRemovedListener listener = new ListItemsRemovedListener() {
760             @Override
761             public void onListItemsRemoved() {
762                 action.performAction();
763             }
764         };
765         final SwipeableListView listView = (SwipeableListView) getListView();
766         if (listView.getSwipeAction() == actionId) {
767             if (!listView.destroyItems(conversations, listener)) {
768                 // The listView failed to destroy the items, perform the action manually
769                 LogUtils.e(LOG_TAG, "ConversationListFragment.requestDelete: " +
770                         "listView failed to destroy items.");
771                 action.performAction();
772             }
773             return;
774         }
775         // Delete the local delete items (all for now) and when done,
776         // update...
777         mListAdapter.delete(conversations, listener);
778     }
779 
onFolderUpdated(Folder folder)780     public void onFolderUpdated(Folder folder) {
781         mFolder = folder;
782         setSwipeAction();
783         if (mFolder == null) {
784             return;
785         }
786         mListAdapter.setFolder(mFolder);
787         mFooterView.setFolder(mFolder);
788         if (!mFolder.wasSyncSuccessful()) {
789             mErrorListener.onError(mFolder, false);
790         }
791 
792         // Notify of changes to the Folder.
793         onFolderStatusUpdated();
794 
795         // Blow away conversation items cache.
796         ConversationItemViewModel.onFolderUpdated(mFolder);
797     }
798 
799     /**
800      * Updates the footer visibility and updates the conversation cursor
801      */
onConversationListStatusUpdated()802     public void onConversationListStatusUpdated() {
803         final ConversationCursor cursor = getConversationListCursor();
804         final boolean showFooter = mFooterView.updateStatus(cursor);
805         // Update the folder status, in case the cursor could affect it.
806         onFolderStatusUpdated();
807         mListAdapter.setFooterVisibility(showFooter);
808 
809         // Also change the cursor here.
810         onCursorUpdated();
811     }
812 
onFolderStatusUpdated()813     private void onFolderStatusUpdated() {
814         // Update the sync status bar with sync results if needed
815         checkSyncStatus();
816 
817         final ConversationCursor cursor = getConversationListCursor();
818         Bundle extras = cursor != null ? cursor.getExtras() : Bundle.EMPTY;
819         int errorStatus = extras.containsKey(UIProvider.CursorExtraKeys.EXTRA_ERROR) ?
820                 extras.getInt(UIProvider.CursorExtraKeys.EXTRA_ERROR)
821                 : UIProvider.LastSyncResult.SUCCESS;
822         int cursorStatus = extras.getInt(UIProvider.CursorExtraKeys.EXTRA_STATUS);
823         // We want to update the UI with this information if either we are loaded or complete, or
824         // we have a folder with a non-0 count.
825         final int folderCount = mFolder != null ? mFolder.totalCount : 0;
826         if (errorStatus == UIProvider.LastSyncResult.SUCCESS
827                 && (cursorStatus == UIProvider.CursorStatus.LOADED
828                 || cursorStatus == UIProvider.CursorStatus.COMPLETE) || folderCount > 0) {
829             updateSearchResultHeader(folderCount);
830             if (folderCount == 0) {
831                 mListView.setEmptyView(mEmptyView);
832             }
833         }
834     }
835 
setSwipeAction()836     private void setSwipeAction() {
837         int swipeSetting = Settings.getSwipeSetting(mAccount.settings);
838         if (swipeSetting == Swipe.DISABLED
839                 || !mAccount.supportsCapability(AccountCapabilities.UNDO)
840                 || (mFolder != null && mFolder.isTrash())) {
841             mListView.enableSwipe(false);
842         } else {
843             final int action;
844             mListView.enableSwipe(true);
845             if (ConversationListContext.isSearchResult(mViewContext)
846                     || (mFolder != null && mFolder.isType(FolderType.SPAM))) {
847                 action = R.id.delete;
848             } else if (mFolder == null) {
849                 action = R.id.remove_folder;
850             } else {
851                 // We have enough information to respect user settings.
852                 switch (swipeSetting) {
853                     case Swipe.ARCHIVE:
854                         if (mAccount.supportsCapability(AccountCapabilities.ARCHIVE)) {
855                             if (mFolder.supportsCapability(FolderCapabilities.ARCHIVE)) {
856                                 action = R.id.archive;
857                                 break;
858                             } else if (mFolder.supportsCapability
859                                     (FolderCapabilities.CAN_ACCEPT_MOVED_MESSAGES)) {
860                                 action = R.id.remove_folder;
861                                 break;
862                             }
863                         }
864 
865                         /*
866                          * If we get here, we don't support archive, on either the account or the
867                          * folder, so we want to fall through into the delete case.
868                          */
869                         //$FALL-THROUGH$
870                     case Swipe.DELETE:
871                     default:
872                         action = R.id.delete;
873                         break;
874                 }
875             }
876             mListView.setSwipeAction(action);
877         }
878         mListView.setCurrentAccount(mAccount);
879         mListView.setCurrentFolder(mFolder);
880     }
881 
882     /**
883      * Changes the conversation cursor in the list and sets selected position if none is set.
884      */
onCursorUpdated()885     private void onCursorUpdated() {
886         if (mCallbacks == null || mListAdapter == null) {
887             return;
888         }
889         // Check against the previous cursor here and see if they are the same. If they are, then
890         // do a notifyDataSetChanged.
891         final ConversationCursor newCursor = mCallbacks.getConversationListCursor();
892 
893         if (newCursor == null && mListAdapter.getCursor() != null) {
894             // We're losing our cursor, so save our scroll position
895             saveLastScrolledPosition();
896         }
897 
898         mListAdapter.swapCursor(newCursor);
899         // When the conversation cursor is *updated*, we get back the same instance. In that
900         // situation, CursorAdapter.swapCursor() silently returns, without forcing a
901         // notifyDataSetChanged(). So let's force a call to notifyDataSetChanged, since an updated
902         // cursor means that the dataset has changed.
903         final int newCursorHash = (newCursor == null) ? 0 : newCursor.hashCode();
904         if (mConversationCursorHash == newCursorHash && mConversationCursorHash != 0) {
905             mListAdapter.notifyDataSetChanged();
906         }
907         mConversationCursorHash = newCursorHash;
908 
909         if (newCursor != null && newCursor.getCount() > 0) {
910             newCursor.markContentsSeen();
911             restoreLastScrolledPosition();
912         }
913 
914         // If a current conversation is available, and none is selected in the list, then ask
915         // the list to select the current conversation.
916         final Conversation conv = mCallbacks.getCurrentConversation();
917         if (conv != null) {
918             if (mListView.getChoiceMode() != ListView.CHOICE_MODE_NONE
919                     && mListView.getCheckedItemPosition() == -1) {
920                 setSelected(conv.position, true);
921             }
922         }
923     }
924 
commitDestructiveActions(boolean animate)925     public void commitDestructiveActions(boolean animate) {
926         if (mListView != null) {
927             mListView.commitDestructiveActions(animate);
928 
929         }
930     }
931 
932     @Override
onListItemSwiped(Collection<Conversation> conversations)933     public void onListItemSwiped(Collection<Conversation> conversations) {
934         mUpdater.showNextConversation(conversations);
935     }
936 
checkSyncStatus()937     private void checkSyncStatus() {
938         if (mFolder != null && mFolder.isSyncInProgress()) {
939             LogUtils.d(LOG_TAG, "CLF.checkSyncStatus still syncing");
940             // Still syncing, ignore
941         } else {
942             // Finished syncing:
943             LogUtils.d(LOG_TAG, "CLF.checkSyncStatus done syncing");
944             mConversationListView.onSyncFinished();
945         }
946     }
947 
948     /**
949      * Displays the indefinite progress bar indicating a sync is in progress.  This
950      * should only be called if user manually requested a sync, and not for background syncs.
951      */
showSyncStatusBar()952     protected void showSyncStatusBar() {
953         mConversationListView.showSyncStatusBar();
954     }
955 
956     /**
957      * Clears all items in the list.
958      */
clear()959     public void clear() {
960         mListView.setAdapter(null);
961     }
962 
963     private final ConversationSetObserver mConversationSetObserver = new ConversationSetObserver() {
964         @Override
965         public void onSetPopulated(final ConversationSelectionSet set) {
966             // Do nothing
967         }
968 
969         @Override
970         public void onSetEmpty() {
971             mSelectionModeExitedTimestamp = System.currentTimeMillis();
972         }
973 
974         @Override
975         public void onSetChanged(final ConversationSelectionSet set) {
976             // Do nothing
977         }
978     };
979 
saveLastScrolledPosition()980     private void saveLastScrolledPosition() {
981         if (mListAdapter.getCursor() == null) {
982             // If you save your scroll position in an empty list, you're gonna have a bad time
983             return;
984         }
985 
986         final Parcelable savedState = mListView.onSaveInstanceState();
987 
988         mActivity.getListHandler().setConversationListScrollPosition(
989                 mFolder.conversationListUri.toString(), savedState);
990     }
991 
restoreLastScrolledPosition()992     private void restoreLastScrolledPosition() {
993         // Scroll to our previous position, if necessary
994         if (!mScrollPositionRestored && mFolder != null) {
995             final String key = mFolder.conversationListUri.toString();
996             final Parcelable savedState = mActivity.getListHandler()
997                     .getConversationListScrollPosition(key);
998             if (savedState != null) {
999                 mListView.onRestoreInstanceState(savedState);
1000             }
1001             mScrollPositionRestored = true;
1002         }
1003     }
1004 }
1005