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