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