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