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