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