• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2012 Google Inc.
3  * Licensed to The Android Open Source Project.
4  *
5  * Licensed under the Apache License, Version 2.0 (the "License");
6  * you may not use this file except in compliance with the License.
7  * You may obtain a copy of the License at
8  *
9  *      http://www.apache.org/licenses/LICENSE-2.0
10  *
11  * Unless required by applicable law or agreed to in writing, software
12  * distributed under the License is distributed on an "AS IS" BASIS,
13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14  * See the License for the specific language governing permissions and
15  * limitations under the License.
16  */
17 
18 package com.android.mail.ui;
19 
20 import android.animation.Animator;
21 import android.animation.Animator.AnimatorListener;
22 import android.animation.AnimatorListenerAdapter;
23 import android.animation.AnimatorSet;
24 import android.animation.ObjectAnimator;
25 import android.content.Context;
26 import android.content.res.Resources;
27 import android.database.Cursor;
28 import android.os.Bundle;
29 import android.os.Handler;
30 import android.os.Looper;
31 import android.support.v4.text.BidiFormatter;
32 import android.util.SparseArray;
33 import android.view.LayoutInflater;
34 import android.view.View;
35 import android.view.ViewGroup;
36 import android.widget.SimpleCursorAdapter;
37 
38 import com.android.bitmap.BitmapCache;
39 import com.android.mail.R;
40 import com.android.mail.analytics.Analytics;
41 import com.android.mail.bitmap.ContactResolver;
42 import com.android.mail.browse.ConversationCursor;
43 import com.android.mail.browse.ConversationItemView;
44 import com.android.mail.browse.ConversationItemViewCoordinates.CoordinatesCache;
45 import com.android.mail.browse.SwipeableConversationItemView;
46 import com.android.mail.providers.Account;
47 import com.android.mail.providers.AccountObserver;
48 import com.android.mail.providers.Conversation;
49 import com.android.mail.providers.Folder;
50 import com.android.mail.providers.UIProvider;
51 import com.android.mail.providers.UIProvider.ConversationListIcon;
52 import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener;
53 import com.android.mail.utils.LogTag;
54 import com.android.mail.utils.LogUtils;
55 import com.android.mail.utils.Utils;
56 import com.google.common.collect.Lists;
57 import com.google.common.collect.Maps;
58 
59 import java.util.ArrayList;
60 import java.util.Collection;
61 import java.util.HashMap;
62 import java.util.HashSet;
63 import java.util.Iterator;
64 import java.util.List;
65 import java.util.Map.Entry;
66 
67 public class AnimatedAdapter extends SimpleCursorAdapter {
68     private static int sDismissAllShortDelay = -1;
69     private static int sDismissAllLongDelay = -1;
70     private static final String LAST_DELETING_ITEMS = "last_deleting_items";
71     private static final String LEAVE_BEHIND_ITEM_DATA = "leave_behind_item_data";
72     private static final String LEAVE_BEHIND_ITEM_ID = "leave_behind_item_id";
73     private final static int TYPE_VIEW_CONVERSATION = 0;
74     private final static int TYPE_VIEW_FOOTER = 1;
75     private final static int TYPE_VIEW_HEADER = 2;
76     private final static int TYPE_VIEW_DONT_RECYCLE = -1;
77     private final HashSet<Long> mDeletingItems = new HashSet<Long>();
78     private final ArrayList<Long> mLastDeletingItems = new ArrayList<Long>();
79     private final HashSet<Long> mUndoingItems = new HashSet<Long>();
80     private final HashSet<Long> mSwipeDeletingItems = new HashSet<Long>();
81     private final HashSet<Long> mSwipeUndoingItems = new HashSet<Long>();
82     private final HashMap<Long, SwipeableConversationItemView> mAnimatingViews =
83             new HashMap<Long, SwipeableConversationItemView>();
84     private final HashMap<Long, LeaveBehindItem> mFadeLeaveBehindItems =
85             new HashMap<Long, LeaveBehindItem>();
86     /** The current account */
87     private Account mAccount;
88     private final Context mContext;
89     private final ConversationSelectionSet mBatchConversations;
90     private Runnable mCountDown;
91     private final Handler mHandler;
92     protected long mLastLeaveBehind = -1;
93 
94     private final AnimatorListener mAnimatorListener = new AnimatorListenerAdapter() {
95 
96         @Override
97         public void onAnimationStart(Animator animation) {
98             if (!mUndoingItems.isEmpty()) {
99                 mDeletingItems.clear();
100                 mLastDeletingItems.clear();
101                 mSwipeDeletingItems.clear();
102             }
103         }
104 
105         @Override
106         public void onAnimationEnd(Animator animation) {
107             Object obj;
108             if (animation instanceof AnimatorSet) {
109                 AnimatorSet set = (AnimatorSet) animation;
110                 obj = ((ObjectAnimator) set.getChildAnimations().get(0)).getTarget();
111             } else {
112                 obj = ((ObjectAnimator) animation).getTarget();
113             }
114             updateAnimatingConversationItems(obj, mSwipeDeletingItems);
115             updateAnimatingConversationItems(obj, mDeletingItems);
116             updateAnimatingConversationItems(obj, mSwipeUndoingItems);
117             updateAnimatingConversationItems(obj, mUndoingItems);
118             if (hasFadeLeaveBehinds() && obj instanceof LeaveBehindItem) {
119                 LeaveBehindItem objItem = (LeaveBehindItem) obj;
120                 clearLeaveBehind(objItem.getConversationId());
121                 objItem.commit();
122                 if (!hasFadeLeaveBehinds()) {
123                     // Cancel any existing animations on the remaining leave behind
124                     // item and start fading in text immediately.
125                     LeaveBehindItem item = getLastLeaveBehindItem();
126                     if (item != null) {
127                         boolean cancelled = item.cancelFadeInTextAnimationIfNotStarted();
128                         if (cancelled) {
129                             item.startFadeInTextAnimation(0 /* delay start */);
130                         }
131                     }
132                 }
133                 // The view types have changed, since the animating views are gone.
134                 notifyDataSetChanged();
135             }
136 
137             if (!isAnimating()) {
138                 mActivity.onAnimationEnd(AnimatedAdapter.this);
139             }
140         }
141 
142     };
143 
144     /**
145      * The next action to perform. Do not read or write this. All accesses should
146      * be in {@link #performAndSetNextAction(SwipeableListView.ListItemsRemovedListener)} which
147      * commits the previous action, if any.
148      */
149     private ListItemsRemovedListener mPendingDestruction;
150 
151     /**
152      * A destructive action that refreshes the list and performs no other action.
153      */
154     private final ListItemsRemovedListener mRefreshAction = new ListItemsRemovedListener() {
155         @Override
156         public void onListItemsRemoved() {
157             notifyDataSetChanged();
158         }
159     };
160 
161     public interface Listener {
onAnimationEnd(AnimatedAdapter adapter)162         void onAnimationEnd(AnimatedAdapter adapter);
163     }
164 
165     private View mFooter;
166     private boolean mShowFooter;
167     private List<View> mHeaders = Lists.newArrayList();
168     private Folder mFolder;
169     private final SwipeableListView mListView;
170     private boolean mSwipeEnabled;
171     private final HashMap<Long, LeaveBehindItem> mLeaveBehindItems = Maps.newHashMap();
172     /** True if importance markers are enabled, false otherwise. */
173     private boolean mImportanceMarkersEnabled;
174     /**
175      * True if chevrons (personal level indicators) should be shown:
176      * an arrow ( › ) by messages sent to my address (not a mailing list),
177      * and a double arrow ( » ) by messages sent only to me.
178      */
179     private boolean mShowChevronsEnabled;
180     private final ControllableActivity mActivity;
181     private final AccountObserver mAccountListener = new AccountObserver() {
182         @Override
183         public void onChanged(Account newAccount) {
184             if (setAccount(newAccount)) {
185                 notifyDataSetChanged();
186             }
187         }
188     };
189 
190     /**
191      * A list of all views that are not conversations. These include temporary views from
192      * {@link #mFleetingViews}.
193      */
194     private final SparseArray<ConversationSpecialItemView> mSpecialViews;
195 
196     private final CoordinatesCache mCoordinatesCache = new CoordinatesCache();
197 
198     /**
199      * Temporary views insert at specific positions relative to conversations. These can be
200      * related to showing new features (on-boarding) or showing information about new mailboxes
201      * that have been added by the system.
202      */
203     private final List<ConversationSpecialItemView> mFleetingViews;
204 
205     private final BidiFormatter mBidiFormatter = BidiFormatter.getInstance();
206 
207     /**
208      * @return <code>true</code> if a relevant part of the account has changed, <code>false</code>
209      *         otherwise
210      */
setAccount(Account newAccount)211     private boolean setAccount(Account newAccount) {
212         final boolean accountChanged;
213         if (mAccount != null && mAccount.uri.equals(newAccount.uri)
214                 && mAccount.settings.importanceMarkersEnabled ==
215                         newAccount.settings.importanceMarkersEnabled
216                 && mAccount.supportsCapability(UIProvider.AccountCapabilities.UNDO) ==
217                         newAccount.supportsCapability(UIProvider.AccountCapabilities.UNDO)
218                 && mAccount.settings.convListIcon == newAccount.settings.convListIcon) {
219             accountChanged = false;
220         } else {
221             accountChanged = true;
222         }
223 
224         mAccount = newAccount;
225         mImportanceMarkersEnabled = mAccount.settings.importanceMarkersEnabled;
226         mShowChevronsEnabled = mAccount.settings.showChevronsEnabled;
227         mSwipeEnabled = mAccount.supportsCapability(UIProvider.AccountCapabilities.UNDO);
228 
229         Analytics.getInstance().setCustomDimension(Analytics.CD_INDEX_SENDER_IMAGES_ENABLED, Boolean
230                 .toString(newAccount.settings.convListIcon == ConversationListIcon.SENDER_IMAGE));
231         Analytics.getInstance().setCustomDimension(Analytics.CD_INDEX_REPLY_ALL_SETTING,
232                 (newAccount.settings.replyBehavior == UIProvider.DefaultReplyBehavior.REPLY)
233                 ? "reply"
234                 : "reply_all");
235         Analytics.getInstance().setCustomDimension(Analytics.CD_INDEX_AUTO_ADVANCE,
236                 UIProvider.AutoAdvance.getAutoAdvanceStr(
237                         newAccount.settings.getAutoAdvanceSetting()));
238 
239         return accountChanged;
240     }
241 
242     private static final String LOG_TAG = LogTag.getLogTag();
243     private static final int INCREASE_WAIT_COUNT = 2;
244 
245     private final BitmapCache mSendersImagesCache;
246     private final ContactResolver mContactResolver;
247 
AnimatedAdapter(Context context, ConversationCursor cursor, ConversationSelectionSet batch, ControllableActivity activity, SwipeableListView listView, final List<ConversationSpecialItemView> specialViews)248     public AnimatedAdapter(Context context, ConversationCursor cursor,
249             ConversationSelectionSet batch, ControllableActivity activity,
250             SwipeableListView listView, final List<ConversationSpecialItemView> specialViews) {
251         super(context, -1, cursor, UIProvider.CONVERSATION_PROJECTION, null, 0);
252         mContext = context;
253         mBatchConversations = batch;
254         setAccount(mAccountListener.initialize(activity.getAccountController()));
255         mActivity = activity;
256         mShowFooter = false;
257         mListView = listView;
258 
259         mSendersImagesCache = mActivity.getSenderImageCache();
260 
261         mContactResolver =
262                 mActivity.getContactResolver(mContext.getContentResolver(), mSendersImagesCache);
263 
264         mHandler = new Handler();
265         if (sDismissAllShortDelay == -1) {
266             final Resources r = context.getResources();
267             sDismissAllShortDelay = r.getInteger(R.integer.dismiss_all_leavebehinds_short_delay);
268             sDismissAllLongDelay = r.getInteger(R.integer.dismiss_all_leavebehinds_long_delay);
269         }
270         if (specialViews != null) {
271             mFleetingViews = new ArrayList<ConversationSpecialItemView>(specialViews);
272         } else {
273             mFleetingViews = new ArrayList<ConversationSpecialItemView>(0);
274         }
275         /** Total number of special views */
276         final int size = mFleetingViews.size();
277         mSpecialViews = new SparseArray<ConversationSpecialItemView>(size);
278 
279         // Set the adapter in teaser views.
280         for (final ConversationSpecialItemView view : mFleetingViews) {
281             view.setAdapter(this);
282         }
283         updateSpecialViews();
284     }
285 
cancelDismissCounter()286     public void cancelDismissCounter() {
287         cancelLeaveBehindFadeInAnimation();
288         mHandler.removeCallbacks(mCountDown);
289     }
290 
startDismissCounter()291     public void startDismissCounter() {
292         if (mLeaveBehindItems.size() > INCREASE_WAIT_COUNT) {
293             mHandler.postDelayed(mCountDown, sDismissAllLongDelay);
294         } else {
295             mHandler.postDelayed(mCountDown, sDismissAllShortDelay);
296         }
297     }
298 
destroy()299     public final void destroy() {
300         // Set a null cursor in the adapter
301         swapCursor(null);
302         mAccountListener.unregisterAndDestroy();
303     }
304 
305     @Override
getCount()306     public int getCount() {
307         // mSpecialViews only contains the views that are currently being displayed
308         final int specialViewCount = mSpecialViews.size();
309 
310         return super.getCount() + specialViewCount +
311                 (mShowFooter ? 1 : 0) + mHeaders.size();
312     }
313 
314     /**
315      * Add a conversation to the undo set, but only if its deletion is still cached. If the
316      * deletion has already been written through and the cursor doesn't have it anymore, we can't
317      * handle it here, and should instead rely on the cursor refresh to restore the item.
318      * @param item id for the conversation that is being undeleted.
319      * @return true if the conversation is still cached and therefore we will handle the undo.
320      */
addUndoingItem(final long item)321     private boolean addUndoingItem(final long item) {
322         if (getConversationCursor().getUnderlyingPosition(item) >= 0) {
323             mUndoingItems.add(item);
324             return true;
325         }
326         return false;
327     }
328 
setUndo(boolean undo)329     public void setUndo(boolean undo) {
330         if (undo) {
331             boolean itemAdded = false;
332             if (!mLastDeletingItems.isEmpty()) {
333                 for (Long item : mLastDeletingItems) {
334                     itemAdded |= addUndoingItem(item);
335                 }
336                 mLastDeletingItems.clear();
337             }
338             if (mLastLeaveBehind != -1) {
339                 itemAdded |= addUndoingItem(mLastLeaveBehind);
340                 mLastLeaveBehind = -1;
341             }
342             // Start animation, only if we're handling the undo.
343             if (itemAdded) {
344                 notifyDataSetChanged();
345                 performAndSetNextAction(mRefreshAction);
346             }
347         }
348     }
349 
setSwipeUndo(boolean undo)350     public void setSwipeUndo(boolean undo) {
351         if (undo) {
352             if (!mLastDeletingItems.isEmpty()) {
353                 mSwipeUndoingItems.addAll(mLastDeletingItems);
354                 mLastDeletingItems.clear();
355             }
356             if (mLastLeaveBehind != -1) {
357                 mSwipeUndoingItems.add(mLastLeaveBehind);
358                 mLastLeaveBehind = -1;
359             }
360             // Start animation
361             notifyDataSetChanged();
362             performAndSetNextAction(mRefreshAction);
363         }
364     }
365 
createConversationItemView(SwipeableConversationItemView view, Context context, Conversation conv)366     public View createConversationItemView(SwipeableConversationItemView view, Context context,
367             Conversation conv) {
368         if (view == null) {
369             view = new SwipeableConversationItemView(context, mAccount.getEmailAddress());
370         }
371         view.bind(conv, mActivity, mBatchConversations, mFolder, getCheckboxSetting(),
372                 mSwipeEnabled, mImportanceMarkersEnabled, mShowChevronsEnabled, this);
373         return view;
374     }
375 
376     @Override
hasStableIds()377     public boolean hasStableIds() {
378         return true;
379     }
380 
381     @Override
getViewTypeCount()382     public int getViewTypeCount() {
383         // TYPE_VIEW_CONVERSATION, TYPE_VIEW_DELETING, TYPE_VIEW_UNDOING, and
384         // TYPE_VIEW_FOOTER, TYPE_VIEW_LEAVEBEHIND.
385         return 5;
386     }
387 
388     @Override
getItemViewType(int position)389     public int getItemViewType(int position) {
390         // Try to recycle views.
391         if (mHeaders.size() > position) {
392             return TYPE_VIEW_HEADER;
393         } else if (mShowFooter && position == getCount() - 1) {
394             return TYPE_VIEW_FOOTER;
395         } else if (hasLeaveBehinds() || isAnimating()) {
396             // Setting as type -1 means the recycler won't take this view and
397             // return it in get view. This is a bit of a "hammer" in that it
398             // won't let even safe views be recycled here,
399             // but its safer and cheaper than trying to determine individual
400             // types. In a future release, use position/id map to try to make
401             // this cleaner / faster to determine if the view is animating.
402             return TYPE_VIEW_DONT_RECYCLE;
403         } else if (mSpecialViews.get(getSpecialViewsPos(position)) != null) {
404             // Don't recycle the special views
405             return TYPE_VIEW_DONT_RECYCLE;
406         }
407         return TYPE_VIEW_CONVERSATION;
408     }
409 
410     /**
411      * Deletes the selected conversations from the conversation list view with a
412      * translation and then a shrink. These conversations <b>must</b> have their
413      * {@link Conversation#position} set to the position of these conversations
414      * among the list. This will only remove the element from the list. The job
415      * of deleting the actual element is left to the the listener. This listener
416      * will be called when the animations are complete and is required to delete
417      * the conversation.
418      * @param conversations
419      * @param listener
420      */
swipeDelete(Collection<Conversation> conversations, ListItemsRemovedListener listener)421     public void swipeDelete(Collection<Conversation> conversations,
422             ListItemsRemovedListener listener) {
423         delete(conversations, listener, mSwipeDeletingItems);
424     }
425 
426 
427     /**
428      * Deletes the selected conversations from the conversation list view by
429      * shrinking them away. These conversations <b>must</b> have their
430      * {@link Conversation#position} set to the position of these conversations
431      * among the list. This will only remove the element from the list. The job
432      * of deleting the actual element is left to the the listener. This listener
433      * will be called when the animations are complete and is required to delete
434      * the conversation.
435      * @param conversations
436      * @param listener
437      */
delete(Collection<Conversation> conversations, ListItemsRemovedListener listener)438     public void delete(Collection<Conversation> conversations, ListItemsRemovedListener listener) {
439         delete(conversations, listener, mDeletingItems);
440     }
441 
delete(Collection<Conversation> conversations, ListItemsRemovedListener listener, HashSet<Long> list)442     private void delete(Collection<Conversation> conversations, ListItemsRemovedListener listener,
443             HashSet<Long> list) {
444         // Clear out any remaining items and add the new ones
445         mLastDeletingItems.clear();
446         // Since we are deleting new items, clear any remaining undo items
447         mUndoingItems.clear();
448 
449         final int startPosition = mListView.getFirstVisiblePosition();
450         final int endPosition = mListView.getLastVisiblePosition();
451 
452         // Only animate visible items
453         for (Conversation c: conversations) {
454             if (c.position >= startPosition && c.position <= endPosition) {
455                 mLastDeletingItems.add(c.id);
456                 list.add(c.id);
457             }
458         }
459 
460         if (list.isEmpty()) {
461             // If we have no deleted items on screen, skip the animation
462             listener.onListItemsRemoved();
463             // If we have an action queued up, perform it
464             performAndSetNextAction(null);
465         } else {
466             performAndSetNextAction(listener);
467         }
468         notifyDataSetChanged();
469     }
470 
471     @Override
getView(int position, View convertView, ViewGroup parent)472     public View getView(int position, View convertView, ViewGroup parent) {
473         if (mHeaders.size() > position) {
474             return mHeaders.get(position);
475         } else if (mShowFooter && position == getCount() - 1) {
476             return mFooter;
477         }
478 
479         // Check if this is a special view
480         final ConversationSpecialItemView specialView = mSpecialViews.get(
481                 getSpecialViewsPos(position));
482         if (specialView != null) {
483             specialView.onGetView();
484             return (View) specialView;
485         }
486 
487         Utils.traceBeginSection("AA.getView");
488 
489         final ConversationCursor cursor = (ConversationCursor) getItem(position);
490         final Conversation conv = cursor.getConversation();
491 
492         // Notify the provider of this change in the position of Conversation cursor
493         cursor.notifyUIPositionChange();
494 
495         if (isPositionUndoing(conv.id)) {
496             return getUndoingView(position - getPositionOffset(position), conv, parent,
497                     false /* don't show swipe background */);
498         } if (isPositionUndoingSwipe(conv.id)) {
499             return getUndoingView(position - getPositionOffset(position), conv, parent,
500                     true /* show swipe background */);
501         } else if (isPositionDeleting(conv.id)) {
502             return getDeletingView(position - getPositionOffset(position), conv, parent, false);
503         } else if (isPositionSwipeDeleting(conv.id)) {
504             return getDeletingView(position - getPositionOffset(position), conv, parent, true);
505         }
506         if (hasFadeLeaveBehinds()) {
507             if(isPositionFadeLeaveBehind(conv)) {
508                 LeaveBehindItem fade  = getFadeLeaveBehindItem(position, conv);
509                 fade.startShrinkAnimation(mAnimatorListener);
510                 Utils.traceEndSection();
511                 return fade;
512             }
513         }
514         if (hasLeaveBehinds()) {
515             if (isPositionLeaveBehind(conv)) {
516                 final LeaveBehindItem fadeIn = getLeaveBehindItem(conv);
517                 if (conv.id == mLastLeaveBehind) {
518                     // If it looks like the person is doing a lot of rapid
519                     // swipes, wait patiently before animating
520                     if (mLeaveBehindItems.size() > INCREASE_WAIT_COUNT) {
521                         if (fadeIn.isAnimating()) {
522                             fadeIn.increaseFadeInDelay(sDismissAllLongDelay);
523                         } else {
524                             fadeIn.startFadeInTextAnimation(sDismissAllLongDelay);
525                         }
526                     } else {
527                         // Otherwise, assume they are just doing 1 and wait less time
528                         fadeIn.startFadeInTextAnimation(sDismissAllShortDelay /* delay start */);
529                     }
530                 }
531                 Utils.traceEndSection();
532                 return fadeIn;
533             }
534         }
535 
536         if (convertView != null && !(convertView instanceof SwipeableConversationItemView)) {
537             LogUtils.w(LOG_TAG, "Incorrect convert view received; nulling it out");
538             convertView = newView(mContext, cursor, parent);
539         } else if (convertView != null) {
540             ((SwipeableConversationItemView) convertView).reset();
541         }
542         final View v = createConversationItemView((SwipeableConversationItemView) convertView,
543                 mContext, conv);
544         Utils.traceEndSection();
545         return v;
546     }
547 
hasLeaveBehinds()548     private boolean hasLeaveBehinds() {
549         return !mLeaveBehindItems.isEmpty();
550     }
551 
hasFadeLeaveBehinds()552     private boolean hasFadeLeaveBehinds() {
553         return !mFadeLeaveBehindItems.isEmpty();
554     }
555 
setupLeaveBehind(Conversation target, ToastBarOperation undoOp, int deletedRow, int viewHeight)556     public LeaveBehindItem setupLeaveBehind(Conversation target, ToastBarOperation undoOp,
557             int deletedRow, int viewHeight) {
558         cancelLeaveBehindFadeInAnimation();
559         mLastLeaveBehind = target.id;
560         fadeOutLeaveBehindItems();
561 
562         final LeaveBehindItem leaveBehind = (LeaveBehindItem) LayoutInflater.from(mContext)
563                 .inflate(R.layout.swipe_leavebehind, mListView, false);
564         leaveBehind.bind(deletedRow, mAccount, this, undoOp, target, mFolder, viewHeight);
565         mLeaveBehindItems.put(target.id, leaveBehind);
566         mLastDeletingItems.add(target.id);
567         return leaveBehind;
568     }
569 
fadeOutSpecificLeaveBehindItem(long id)570     public void fadeOutSpecificLeaveBehindItem(long id) {
571         if (mLastLeaveBehind == id) {
572             mLastLeaveBehind = -1;
573         }
574         startFadeOutLeaveBehindItemsAnimations();
575     }
576 
577     // This should kick off a timer such that there is a minimum time each item
578     // shows up before being dismissed. That way if the user is swiping away
579     // items in rapid succession, their finger position is maintained.
fadeOutLeaveBehindItems()580     public void fadeOutLeaveBehindItems() {
581         if (mCountDown == null) {
582             mCountDown = new Runnable() {
583                 @Override
584                 public void run() {
585                     startFadeOutLeaveBehindItemsAnimations();
586                 }
587             };
588         } else {
589             mHandler.removeCallbacks(mCountDown);
590         }
591         // Clear all the text since these are no longer clickable
592         Iterator<Entry<Long, LeaveBehindItem>> i = mLeaveBehindItems.entrySet().iterator();
593         LeaveBehindItem item;
594         while (i.hasNext()) {
595             item = i.next().getValue();
596             Conversation conv = item.getData();
597             if (mLastLeaveBehind == -1 || conv.id != mLastLeaveBehind) {
598                 item.cancelFadeInTextAnimation();
599                 item.makeInert();
600             }
601         }
602         startDismissCounter();
603     }
604 
startFadeOutLeaveBehindItemsAnimations()605     protected void startFadeOutLeaveBehindItemsAnimations() {
606         final int startPosition = mListView.getFirstVisiblePosition();
607         final int endPosition = mListView.getLastVisiblePosition();
608 
609         if (hasLeaveBehinds()) {
610             // If the item is visible, fade it out. Otherwise, just remove
611             // it.
612             Iterator<Entry<Long, LeaveBehindItem>> i = mLeaveBehindItems.entrySet().iterator();
613             LeaveBehindItem item;
614             while (i.hasNext()) {
615                 item = i.next().getValue();
616                 Conversation conv = item.getData();
617                 if (mLastLeaveBehind == -1 || conv.id != mLastLeaveBehind) {
618                     if (conv.position >= startPosition && conv.position <= endPosition) {
619                         mFadeLeaveBehindItems.put(conv.id, item);
620                     } else {
621                         item.commit();
622                     }
623                     i.remove();
624                 }
625             }
626             cancelLeaveBehindFadeInAnimation();
627         }
628         if (!mLastDeletingItems.isEmpty()) {
629             mLastDeletingItems.clear();
630         }
631         notifyDataSetChanged();
632     }
633 
cancelLeaveBehindFadeInAnimation()634     private void cancelLeaveBehindFadeInAnimation() {
635         LeaveBehindItem leaveBehind = getLastLeaveBehindItem();
636         if (leaveBehind != null) {
637             leaveBehind.cancelFadeInTextAnimation();
638         }
639     }
640 
getCoordinatesCache()641     public CoordinatesCache getCoordinatesCache() {
642         return mCoordinatesCache;
643     }
644 
getBidiFormatter()645     public BidiFormatter getBidiFormatter() {
646         return mBidiFormatter;
647     }
648 
getListView()649     public SwipeableListView getListView() {
650         return mListView;
651     }
652 
commitLeaveBehindItems(boolean animate)653     public void commitLeaveBehindItems(boolean animate) {
654         // Remove any previously existing leave behinds.
655         boolean changed = false;
656         if (hasLeaveBehinds()) {
657             for (LeaveBehindItem item : mLeaveBehindItems.values()) {
658                 if (animate) {
659                     mFadeLeaveBehindItems.put(item.getConversationId(), item);
660                 } else {
661                     item.commit();
662                 }
663             }
664             changed = true;
665             mLastLeaveBehind = -1;
666             mLeaveBehindItems.clear();
667         }
668         if (hasFadeLeaveBehinds() && !animate) {
669             // Find any fading leave behind items and commit them all, too.
670             for (LeaveBehindItem item : mFadeLeaveBehindItems.values()) {
671                 item.commit();
672             }
673             mFadeLeaveBehindItems.clear();
674             changed = true;
675         }
676         if (!mLastDeletingItems.isEmpty()) {
677             mLastDeletingItems.clear();
678             changed = true;
679         }
680 
681         for (final ConversationSpecialItemView view : mFleetingViews) {
682             if (view.commitLeaveBehindItem()) {
683                 changed = true;
684             }
685         }
686 
687         if (changed) {
688             notifyDataSetChanged();
689         }
690     }
691 
getLeaveBehindItem(Conversation target)692     private LeaveBehindItem getLeaveBehindItem(Conversation target) {
693         return mLeaveBehindItems.get(target.id);
694     }
695 
getFadeLeaveBehindItem(int position, Conversation target)696     private LeaveBehindItem getFadeLeaveBehindItem(int position, Conversation target) {
697         return mFadeLeaveBehindItems.get(target.id);
698     }
699 
700     @Override
getItemId(int position)701     public long getItemId(int position) {
702         if ((mHeaders.size() > position) || (mShowFooter && position == getCount() - 1)) {
703             return -1;
704         }
705 
706         final ConversationSpecialItemView specialView = mSpecialViews.get(
707                 getSpecialViewsPos(position));
708         if (specialView != null) {
709             // TODO(skennedy) We probably want something better than this
710             return specialView.hashCode();
711         }
712 
713         final int cursorPos = position - getPositionOffset(position);
714         // advance the cursor to the right position and read the cached conversation, if present
715         //
716         // (no need to have CursorAdapter check mDataValid because in our incarnation without
717         // FLAG_REGISTER_CONTENT_OBSERVER, mDataValid is effectively identical to mCursor being
718         // non-null)
719         final ConversationCursor cursor = getConversationCursor();
720         if (cursor != null && cursor.moveToPosition(cursorPos)) {
721             final Conversation conv = cursor.getCachedConversation();
722             if (conv != null) {
723                 return conv.id;
724             }
725         }
726         return super.getItemId(cursorPos);
727     }
728 
729     /**
730      * @param position The position in the cursor
731      */
getDeletingView(int position, Conversation conversation, ViewGroup parent, boolean swipe)732     private View getDeletingView(int position, Conversation conversation, ViewGroup parent,
733             boolean swipe) {
734         conversation.position = position;
735         SwipeableConversationItemView deletingView = mAnimatingViews.get(conversation.id);
736         if (deletingView == null) {
737             // The undo animation consists of fading in the conversation that
738             // had been destroyed.
739             deletingView = newConversationItemView(position, parent, conversation);
740             deletingView.startDeleteAnimation(mAnimatorListener, swipe);
741         }
742         return deletingView;
743     }
744 
745     /**
746      * @param position The position in the cursor
747      */
getUndoingView(int position, Conversation conv, ViewGroup parent, boolean swipe)748     private View getUndoingView(int position, Conversation conv, ViewGroup parent, boolean swipe) {
749         conv.position = position;
750         SwipeableConversationItemView undoView = mAnimatingViews.get(conv.id);
751         if (undoView == null) {
752             // The undo animation consists of fading in the conversation that
753             // had been destroyed.
754             undoView = newConversationItemView(position, parent, conv);
755             undoView.startUndoAnimation(mAnimatorListener, swipe);
756         }
757         return undoView;
758     }
759 
760     @Override
newView(Context context, Cursor cursor, ViewGroup parent)761     public View newView(Context context, Cursor cursor, ViewGroup parent) {
762         return new SwipeableConversationItemView(context, mAccount.getEmailAddress());
763     }
764 
765     @Override
bindView(View view, Context context, Cursor cursor)766     public void bindView(View view, Context context, Cursor cursor) {
767         // no-op. we only get here from newConversationItemView(), which will immediately bind
768         // on its own.
769     }
770 
newConversationItemView(int position, ViewGroup parent, Conversation conversation)771     private SwipeableConversationItemView newConversationItemView(int position, ViewGroup parent,
772             Conversation conversation) {
773         SwipeableConversationItemView view = (SwipeableConversationItemView) super.getView(
774                 position, null, parent);
775         view.reset();
776         view.bind(conversation, mActivity, mBatchConversations, mFolder, getCheckboxSetting(),
777                 mSwipeEnabled, mImportanceMarkersEnabled, mShowChevronsEnabled, this);
778         mAnimatingViews.put(conversation.id, view);
779         return view;
780     }
781 
getCheckboxSetting()782     private int getCheckboxSetting() {
783         return mAccount != null ? mAccount.settings.convListIcon :
784             ConversationListIcon.DEFAULT;
785     }
786 
787     @Override
getItem(int position)788     public Object getItem(int position) {
789         final ConversationSpecialItemView specialView = mSpecialViews.get(
790                 getSpecialViewsPos(position));
791         if (mHeaders.size() > position) {
792             return mHeaders.get(position);
793         } else if (mShowFooter && position == getCount() - 1) {
794             return mFooter;
795         } else if (specialView != null) {
796             return specialView;
797         }
798         return super.getItem(position - getPositionOffset(position));
799     }
800 
isPositionDeleting(long id)801     private boolean isPositionDeleting(long id) {
802         return mDeletingItems.contains(id);
803     }
804 
isPositionSwipeDeleting(long id)805     private boolean isPositionSwipeDeleting(long id) {
806         return mSwipeDeletingItems.contains(id);
807     }
808 
isPositionUndoing(long id)809     private boolean isPositionUndoing(long id) {
810         return mUndoingItems.contains(id);
811     }
812 
isPositionUndoingSwipe(long id)813     private boolean isPositionUndoingSwipe(long id) {
814         return mSwipeUndoingItems.contains(id);
815     }
816 
isPositionLeaveBehind(Conversation conv)817     private boolean isPositionLeaveBehind(Conversation conv) {
818         return hasLeaveBehinds()
819                 && mLeaveBehindItems.containsKey(conv.id)
820                 && conv.isMostlyDead();
821     }
822 
isPositionFadeLeaveBehind(Conversation conv)823     private boolean isPositionFadeLeaveBehind(Conversation conv) {
824         return hasFadeLeaveBehinds()
825                 && mFadeLeaveBehindItems.containsKey(conv.id)
826                 && conv.isMostlyDead();
827     }
828 
829     /**
830      * Performs the pending destruction, if any and assigns the next pending action.
831      * @param next The next action that is to be performed, possibly null (if no next action is
832      * needed).
833      */
performAndSetNextAction(ListItemsRemovedListener next)834     private void performAndSetNextAction(ListItemsRemovedListener next) {
835         if (mPendingDestruction != null) {
836             mPendingDestruction.onListItemsRemoved();
837         }
838         mPendingDestruction = next;
839     }
840 
updateAnimatingConversationItems(Object obj, HashSet<Long> items)841     private void updateAnimatingConversationItems(Object obj, HashSet<Long> items) {
842         if (!items.isEmpty()) {
843             if (obj instanceof ConversationItemView) {
844                 final ConversationItemView target = (ConversationItemView) obj;
845                 final long id = target.getConversation().id;
846                 items.remove(id);
847                 mAnimatingViews.remove(id);
848                 if (items.isEmpty()) {
849                     performAndSetNextAction(null);
850                     notifyDataSetChanged();
851                 }
852             }
853         }
854     }
855 
856     @Override
areAllItemsEnabled()857     public boolean areAllItemsEnabled() {
858         // The animating items and some special views are not enabled.
859         return false;
860     }
861 
862     @Override
isEnabled(final int position)863     public boolean isEnabled(final int position) {
864         final ConversationSpecialItemView view = mSpecialViews.get(position);
865         if (view != null) {
866             final boolean enabled = view.acceptsUserTaps();
867             LogUtils.d(LOG_TAG, "AA.isEnabled(%d) = %b", position, enabled);
868             return enabled;
869         }
870         return !isPositionDeleting(position) && !isPositionUndoing(position);
871     }
872 
setFooterVisibility(boolean show)873     public void setFooterVisibility(boolean show) {
874         if (mShowFooter != show) {
875             mShowFooter = show;
876             notifyDataSetChanged();
877         }
878     }
879 
addFooter(View footerView)880     public void addFooter(View footerView) {
881         mFooter = footerView;
882     }
883 
addHeader(View headerView)884     public void addHeader(View headerView) {
885         mHeaders.add(headerView);
886     }
887 
setFolder(Folder folder)888     public void setFolder(Folder folder) {
889         mFolder = folder;
890     }
891 
clearLeaveBehind(long itemId)892     public void clearLeaveBehind(long itemId) {
893         if (hasLeaveBehinds() && mLeaveBehindItems.containsKey(itemId)) {
894             mLeaveBehindItems.remove(itemId);
895         } else if (hasFadeLeaveBehinds()) {
896             mFadeLeaveBehindItems.remove(itemId);
897         } else {
898             LogUtils.d(LOG_TAG, "Trying to clear a non-existant leave behind");
899         }
900         if (mLastLeaveBehind == itemId) {
901             mLastLeaveBehind = -1;
902         }
903     }
904 
onSaveInstanceState(Bundle outState)905     public void onSaveInstanceState(Bundle outState) {
906         long[] lastDeleting = new long[mLastDeletingItems.size()];
907         for (int i = 0; i < lastDeleting.length; i++) {
908             lastDeleting[i] = mLastDeletingItems.get(i);
909         }
910         outState.putLongArray(LAST_DELETING_ITEMS, lastDeleting);
911         if (hasLeaveBehinds()) {
912             if (mLastLeaveBehind != -1) {
913                 outState.putParcelable(LEAVE_BEHIND_ITEM_DATA,
914                         mLeaveBehindItems.get(mLastLeaveBehind).getLeaveBehindData());
915                 outState.putLong(LEAVE_BEHIND_ITEM_ID, mLastLeaveBehind);
916             }
917             for (LeaveBehindItem item : mLeaveBehindItems.values()) {
918                 if (mLastLeaveBehind == -1 || item.getData().id != mLastLeaveBehind) {
919                     item.commit();
920                 }
921             }
922         }
923     }
924 
onRestoreInstanceState(Bundle outState)925     public void onRestoreInstanceState(Bundle outState) {
926         if (outState.containsKey(LAST_DELETING_ITEMS)) {
927             final long[] lastDeleting = outState.getLongArray(LAST_DELETING_ITEMS);
928             for (final long aLastDeleting : lastDeleting) {
929                 mLastDeletingItems.add(aLastDeleting);
930             }
931         }
932         if (outState.containsKey(LEAVE_BEHIND_ITEM_DATA)) {
933             LeaveBehindData left =
934                     (LeaveBehindData) outState.getParcelable(LEAVE_BEHIND_ITEM_DATA);
935             mLeaveBehindItems.put(outState.getLong(LEAVE_BEHIND_ITEM_ID),
936                     setupLeaveBehind(left.data, left.op, left.data.position, left.height));
937         }
938     }
939 
940     /**
941      * Return if the adapter is in the process of animating anything.
942      */
isAnimating()943     public boolean isAnimating() {
944         return !mUndoingItems.isEmpty()
945                 || !mSwipeUndoingItems.isEmpty()
946                 || hasFadeLeaveBehinds()
947                 || !mDeletingItems.isEmpty()
948                 || !mSwipeDeletingItems.isEmpty();
949     }
950 
951     /**
952      * Forcibly clear any internal state that would cause {@link #isAnimating()} to return true.
953      * Call this in times of desperation, when you really, really want to trash state and just
954      * start over.
955      */
clearAnimationState()956     public void clearAnimationState() {
957         if (!isAnimating()) {
958             return;
959         }
960 
961         mUndoingItems.clear();
962         mSwipeUndoingItems.clear();
963         mFadeLeaveBehindItems.clear();
964         mDeletingItems.clear();
965         mSwipeDeletingItems.clear();
966         mAnimatingViews.clear();
967         LogUtils.w(LOG_TAG, "AA.clearAnimationState forcibly cleared state, this=%s", this);
968     }
969 
970     @Override
toString()971     public String toString() {
972         final StringBuilder sb = new StringBuilder("{");
973         sb.append(super.toString());
974         sb.append(" mUndoingItems=");
975         sb.append(mUndoingItems);
976         sb.append(" mSwipeUndoingItems=");
977         sb.append(mSwipeUndoingItems);
978         sb.append(" mDeletingItems=");
979         sb.append(mDeletingItems);
980         sb.append(" mSwipeDeletingItems=");
981         sb.append(mSwipeDeletingItems);
982         sb.append(" mLeaveBehindItems=");
983         sb.append(mLeaveBehindItems);
984         sb.append(" mFadeLeaveBehindItems=");
985         sb.append(mFadeLeaveBehindItems);
986         sb.append(" mLastDeletingItems=");
987         sb.append(mLastDeletingItems);
988         sb.append(" mAnimatingViews=");
989         sb.append(mAnimatingViews);
990         sb.append(" mPendingDestruction=");
991         sb.append(mPendingDestruction);
992         sb.append("}");
993         return sb.toString();
994     }
995 
996     /**
997      * Get the ConversationCursor associated with this adapter.
998      */
getConversationCursor()999     public ConversationCursor getConversationCursor() {
1000         return (ConversationCursor) getCursor();
1001     }
1002 
1003     /**
1004      * Get the currently visible leave behind item.
1005      */
getLastLeaveBehindItem()1006     public LeaveBehindItem getLastLeaveBehindItem() {
1007         if (mLastLeaveBehind != -1) {
1008             return mLeaveBehindItems.get(mLastLeaveBehind);
1009         }
1010         return null;
1011     }
1012 
1013     /**
1014      * Cancel fading out the text displayed in the leave behind item currently
1015      * shown.
1016      */
cancelFadeOutLastLeaveBehindItemText()1017     public void cancelFadeOutLastLeaveBehindItemText() {
1018         LeaveBehindItem item = getLastLeaveBehindItem();
1019         if (item != null) {
1020             item.cancelFadeOutText();
1021         }
1022     }
1023 
1024     /**
1025      * Updates special (non-conversation view) when {@link #mFleetingViews} changed
1026      */
updateSpecialViews()1027     private void updateSpecialViews() {
1028         // We recreate all the special views using mFleetingViews.
1029         mSpecialViews.clear();
1030 
1031         // If the conversation cursor hasn't finished loading, hide all special views
1032         if (!ConversationCursor.isCursorReadyToShow(getConversationCursor())) {
1033             return;
1034         }
1035 
1036         // Fleeting (temporary) views specify a position, which is 0-indexed and
1037         // has to be adjusted for the number of fleeting views above it.
1038         for (final ConversationSpecialItemView specialView : mFleetingViews) {
1039             specialView.onUpdate(mFolder, getConversationCursor());
1040 
1041             if (specialView.getShouldDisplayInList()) {
1042                 // If the special view asks for position 0, it wants to be at the top.
1043                 int position = (specialView.getPosition());
1044 
1045                 // insert the special view into the position, but if there is
1046                 // already an item occupying that position, move that item back
1047                 // one position, and repeat
1048                 ConversationSpecialItemView insert = specialView;
1049                 while (insert != null) {
1050                     final ConversationSpecialItemView kickedOut = mSpecialViews.get(position);
1051                     mSpecialViews.put(position, insert);
1052                     insert = kickedOut;
1053                     position++;
1054                 }
1055             }
1056         }
1057     }
1058 
1059     /**
1060      * Gets the position of the specified {@link ConversationSpecialItemView}, as determined by
1061      * the adapter.
1062      *
1063      * @return The position in the list, or a negative value if it could not be found
1064      */
getSpecialViewPosition(final ConversationSpecialItemView view)1065     public int getSpecialViewPosition(final ConversationSpecialItemView view) {
1066         return mSpecialViews.indexOfValue(view);
1067     }
1068 
1069     @Override
notifyDataSetChanged()1070     public void notifyDataSetChanged() {
1071         // This may be a temporary catch for a problem, or we may leave it here.
1072         // b/9527863
1073         if (Looper.getMainLooper() != Looper.myLooper()) {
1074             LogUtils.wtf(LOG_TAG, "notifyDataSetChanged() called off the main thread");
1075         }
1076 
1077         updateSpecialViews();
1078         super.notifyDataSetChanged();
1079     }
1080 
1081     @Override
changeCursor(final Cursor cursor)1082     public void changeCursor(final Cursor cursor) {
1083         super.changeCursor(cursor);
1084         updateSpecialViews();
1085     }
1086 
1087     @Override
changeCursorAndColumns(final Cursor c, final String[] from, final int[] to)1088     public void changeCursorAndColumns(final Cursor c, final String[] from, final int[] to) {
1089         super.changeCursorAndColumns(c, from, to);
1090         updateSpecialViews();
1091     }
1092 
1093     @Override
swapCursor(final Cursor c)1094     public Cursor swapCursor(final Cursor c) {
1095         final Cursor oldCursor = super.swapCursor(c);
1096         updateSpecialViews();
1097 
1098         return oldCursor;
1099     }
1100 
getSendersImagesCache()1101     public BitmapCache getSendersImagesCache() {
1102         return mSendersImagesCache;
1103     }
1104 
getContactResolver()1105     public ContactResolver getContactResolver() {
1106         return mContactResolver;
1107     }
1108 
1109     /**
1110      * Gets the offset for the given position in the underlying cursor, based on any special views
1111      * that may be above it.
1112      */
getPositionOffset(int position)1113     public int getPositionOffset(int position) {
1114         int viewsAbove = mHeaders.size();
1115 
1116         position -= viewsAbove;
1117         for (int i = 0, size = mSpecialViews.size(); i < size; i++) {
1118             final int bidPosition = mSpecialViews.keyAt(i);
1119             // If the view bid for a position above the cursor position,
1120             // it is above the conversation.
1121             if (bidPosition <= position) {
1122                 viewsAbove++;
1123             }
1124         }
1125 
1126         return viewsAbove;
1127     }
1128 
1129     /**
1130      * Gets the correct position for special views given the number of headers we have.
1131      */
getSpecialViewsPos(final int position)1132     private int getSpecialViewsPos(final int position) {
1133         return position - mHeaders.size();
1134     }
1135 
cleanup()1136     public void cleanup() {
1137         // Clean up teaser views.
1138         for (final ConversationSpecialItemView view : mFleetingViews) {
1139             view.cleanup();
1140         }
1141     }
1142 
onConversationSelected()1143     public void onConversationSelected() {
1144         // Notify teaser views.
1145         for (final ConversationSpecialItemView specialView : mFleetingViews) {
1146             specialView.onConversationSelected();
1147         }
1148     }
1149 
onCabModeEntered()1150     public void onCabModeEntered() {
1151         for (final ConversationSpecialItemView specialView : mFleetingViews) {
1152             specialView.onCabModeEntered();
1153         }
1154     }
1155 
onCabModeExited()1156     public void onCabModeExited() {
1157         for (final ConversationSpecialItemView specialView : mFleetingViews) {
1158             specialView.onCabModeExited();
1159         }
1160     }
1161 
onConversationListVisibilityChanged(final boolean visible)1162     public void onConversationListVisibilityChanged(final boolean visible) {
1163         for (final ConversationSpecialItemView specialView : mFleetingViews) {
1164             specialView.onConversationListVisibilityChanged(visible);
1165         }
1166     }
1167 
getViewMode()1168     public int getViewMode() {
1169         return mActivity.getViewMode().getMode();
1170     }
1171 
isInCabMode()1172     public boolean isInCabMode() {
1173         // If we have conversation in our selected set, we're in CAB mode
1174         return !mBatchConversations.isEmpty();
1175     }
1176 
saveSpecialItemInstanceState(final Bundle outState)1177     public void saveSpecialItemInstanceState(final Bundle outState) {
1178         for (final ConversationSpecialItemView specialView : mFleetingViews) {
1179             specialView.saveInstanceState(outState);
1180         }
1181     }
1182 }
1183