Use Drawables for senders image. Less animation jank, less GC jank, less memory.

Protip: When testing sender images, go to your Sent folder. Tons of senders
images appear because you are most likely to talk to people in your address
book.

Rewrite UI to use modular Drawables. It's much easier to understand, since
Drawables with different functionality can be composed.

Flipping animation is 10x smoother. Handles quick double-tap on senders image
correctly.

Letter tiles are not allocated bitmaps anymore. They simply draw a rect and a
letter to the canvas.

We are no longer allocating large bitmaps for every list item. We reuse Bitmaps
whenever possible the same way that attachment previews do.

Much smaller 339KB cache since we don't share with attachment previews anymore,
just enough to fit 10 off-screen contact images.

Bug: 10429228
Change-Id: I463b63520d881eefe3974dccf295366831adaf9e
diff --git a/proguard.flags b/proguard.flags
index e0b2c23..7bab921 100644
--- a/proguard.flags
+++ b/proguard.flags
@@ -60,7 +60,6 @@
 
 -keepclasseswithmembers class com.android.mail.browse.ConversationItemView {
   *** setAnimatedHeightFraction(...);
-  *** setPhotoFlipFraction(...);
 }
 
 -keepclasseswithmembers class com.android.mail.ui.MailActivity {
diff --git a/res/values/colors.xml b/res/values/colors.xml
index 2693db7..0cfb9b2 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -93,10 +93,10 @@
         <item>#ad62a7</item>
     </array>
     <color name="letter_tile_default_color">#d66161</color>
-
     <color name="letter_tile_font_color">#ffffff</color>
-
     <color name="tile_divider_color">#ffffff</color>
+    <!-- Color.GRAY -->
+    <color name="checkmark_tile_background_color">#ff888888</color>
 
     <!-- Teaser colors -->
 
diff --git a/res/values/constants.xml b/res/values/constants.xml
index c238bf7..2f6cfc0 100644
--- a/res/values/constants.xml
+++ b/res/values/constants.xml
@@ -119,5 +119,5 @@
     <integer name="ap_overflow_max_count">99</integer>
 
     <!-- Duration of the animations for entering/exiting CAB mode -->
-    <integer name="conv_item_view_cab_anim_duration">350</integer>
+    <integer name="conv_item_view_cab_anim_duration">250</integer>
 </resources>
diff --git a/src/com/android/bitmap/AltBitmapCache.java b/src/com/android/bitmap/AltBitmapCache.java
index fb8e915..f519c7b 100644
--- a/src/com/android/bitmap/AltBitmapCache.java
+++ b/src/com/android/bitmap/AltBitmapCache.java
@@ -16,25 +16,40 @@
 
 package com.android.bitmap;
 
+import com.android.bitmap.DecodeTask.Request;
+import com.android.bitmap.ReusableBitmap.NullReusableBitmap;
 import com.android.mail.utils.LogTag;
 import com.android.mail.utils.LogUtils;
+import com.android.mail.utils.LruCache;
 
 /**
  * This subclass provides custom pool behavior. The pool can be set to block on {@link #poll()} if
  * nothing can be returned. This is useful if you know you will incur high costs upon receiving
  * nothing from the pool, and you do not want to incur those costs at the critical moment when the
  * UI is animating.
+ *
+ * This subclass provides custom cache behavior. Null values can be cached. Later,
+ * when the same key is used to retrieve the value, a {@link NullReusableBitmap} singleton will
+ * be returned.
  */
-public class AltBitmapCache extends AltPooledCache<DecodeTask.Request, ReusableBitmap>
+public class AltBitmapCache extends AltPooledCache<Request, ReusableBitmap>
         implements BitmapCache {
     private boolean mBlocking = false;
     private final Object mLock = new Object();
 
+    private final LruCache<Request, Void> mNullRequests;
+
     private final static boolean DEBUG = false;
     private final static String TAG = LogTag.getLogTag();
 
-    public AltBitmapCache(final int targetSizeBytes, final float nonPooledFraction) {
+    private int mDecodeWidth;
+    private int mDecodeHeight;
+
+    public AltBitmapCache(final int targetSizeBytes, final float nonPooledFraction,
+            final int nullCapacity) {
         super(targetSizeBytes, nonPooledFraction);
+
+        mNullRequests = new LruCache<Request, Void>(nullCapacity);
     }
 
     /**
@@ -78,7 +93,7 @@
                         LogUtils.d(TAG, "AltBitmapCache: %s notified",
                                 Thread.currentThread().getName());
                     }
-                } catch (InterruptedException e) {
+                } catch (InterruptedException ignored) {
                 }
                 Trace.endSection();
             }
@@ -95,4 +110,50 @@
             mLock.notify();
         }
     }
+
+    @Override
+    public ReusableBitmap get(final Request key, final boolean incrementRefCount) {
+        if (mNullRequests.containsKey(key)) {
+            return NullReusableBitmap.getInstance();
+        }
+        return super.get(key, incrementRefCount);
+    }
+
+    @Override
+    public ReusableBitmap put(final Request key, final ReusableBitmap value) {
+        if (value == null || value == NullReusableBitmap.getInstance()) {
+            mNullRequests.put(key, null);
+            return null;
+        }
+
+        // Do not allow the pool to be filled with bitmaps that are of the wrong dimensions.
+        if (mDecodeWidth > value.bmp.getWidth() || mDecodeHeight > value.bmp.getHeight()) {
+            if (DEBUG) {
+                LogUtils.d(TAG, "Discarding ReusableBitmap size %d x %d for cache size %d x %d.",
+                        value.bmp.getWidth(), value.bmp.getHeight(), mDecodeWidth, mDecodeHeight);
+            }
+            return null;
+        }
+
+        return super.put(key, value);
+    }
+
+    @Override
+    public void setPoolDimensions(final int decodeWidth, final int decodeHeight) {
+        if (mDecodeWidth < decodeWidth || mDecodeHeight < decodeHeight) {
+            clear();
+            mDecodeWidth = decodeWidth;
+            mDecodeHeight = decodeHeight;
+        }
+    }
+
+    @Override
+    public int getDecodeWidth() {
+        return mDecodeWidth;
+    }
+
+    @Override
+    public int getDecodeHeight() {
+        return mDecodeHeight;
+    }
 }
diff --git a/src/com/android/bitmap/AltPooledCache.java b/src/com/android/bitmap/AltPooledCache.java
index 0a7d7b4..03a6c50 100644
--- a/src/com/android/bitmap/AltPooledCache.java
+++ b/src/com/android/bitmap/AltPooledCache.java
@@ -67,6 +67,7 @@
 
     @Override
     public V get(K key, boolean incrementRefCount) {
+        Trace.beginSection("cache get");
         synchronized (mCache) {
             V result = mCache.get(key);
             if (result == null && mNonPooledCache != null) {
@@ -75,12 +76,14 @@
             if (incrementRefCount && result != null) {
                 result.acquireReference();
             }
+            Trace.endSection();
             return result;
         }
     }
 
     @Override
     public V put(K key, V value) {
+        Trace.beginSection("cache put");
         synchronized (mCache) {
             final V prev;
             if (value.isEligibleForPooling()) {
@@ -90,22 +93,27 @@
             } else {
                 prev = null;
             }
+            Trace.endSection();
             return prev;
         }
     }
 
     @Override
     public void offer(V value) {
+        Trace.beginSection("pool offer");
         if (value.getRefCount() != 0 || !value.isEligibleForPooling()) {
             throw new IllegalArgumentException("unexpected offer of an invalid object: " + value);
         }
         mPool.offer(value);
+        Trace.endSection();
     }
 
     @Override
     public V poll() {
+        Trace.beginSection("pool poll");
         final V pooled = mPool.poll();
         if (pooled != null) {
+            Trace.endSection();
             return pooled;
         }
 
@@ -131,11 +139,13 @@
                 if (DEBUG) System.err.println(
                         "POOL SCAVENGE FAILED, cache not fully warm yet. szDelta="
                         + (mTargetSize-unrefSize));
+                Trace.endSection();
                 return null;
             } else {
                 mCache.remove(eldestUnref.getKey());
                 if (DEBUG) System.err.println("POOL SCAVENGE SUCCESS, oldKey="
                         + eldestUnref.getKey());
+                Trace.endSection();
                 return eldestUnref.getValue();
             }
         }
@@ -209,4 +219,9 @@
 
     }
 
+    @Override
+    public void clear() {
+        mCache.clear();
+        mPool.clear();
+    }
 }
diff --git a/src/com/android/bitmap/BitmapCache.java b/src/com/android/bitmap/BitmapCache.java
index d671c17..fc76c3a 100644
--- a/src/com/android/bitmap/BitmapCache.java
+++ b/src/com/android/bitmap/BitmapCache.java
@@ -19,4 +19,7 @@
 public interface BitmapCache extends PooledCache<DecodeTask.Request, ReusableBitmap> {
 
     void setBlocking(boolean blocking);
+    void setPoolDimensions(int decodeWidth, int decodeHeight);
+    int getDecodeWidth();
+    int getDecodeHeight();
 }
diff --git a/src/com/android/bitmap/DecodeTask.java b/src/com/android/bitmap/DecodeTask.java
index 8fa11f1..d7a3cab 100644
--- a/src/com/android/bitmap/DecodeTask.java
+++ b/src/com/android/bitmap/DecodeTask.java
@@ -66,8 +66,6 @@
          * may have been preempted by the scheduler or queued up by a bottlenecked executor.
          * <p>
          * N.B. this method runs on the UI thread.
-         *
-         * @param key
          */
         void onDecodeBegin(Request key);
         void onDecodeComplete(Request key, ReusableBitmap result);
@@ -87,13 +85,17 @@
 
     @Override
     protected ReusableBitmap doInBackground(Void... params) {
+        // enqueue the 'onDecodeBegin' signal on the main thread
+        publishProgress();
+
+        return decode();
+    }
+
+    public ReusableBitmap decode() {
         if (isCancelled()) {
             return null;
         }
 
-        // enqueue the 'onDecodeBegin' signal on the main thread
-        publishProgress();
-
         ReusableBitmap result = null;
         AssetFileDescriptor fd = null;
         InputStream in = null;
@@ -146,7 +148,7 @@
                     try {
                         // Close the temporary file descriptor.
                         in.close();
-                    } catch (IOException ex) {
+                    } catch (IOException ignored) {
                     }
                 }
             } else {
@@ -253,6 +255,7 @@
                 }
             }
 
+            //noinspection PointlessBooleanExpression
             if (!CROP_DURING_DECODE || (decodeResult == null && !isCancelled())) {
                 try {
                     Trace.beginSection("decode" + mOpts.inSampleSize);
@@ -314,13 +317,13 @@
             if (fd != null) {
                 try {
                     fd.close();
-                } catch (IOException e) {
+                } catch (IOException ignored) {
                 }
             }
             if (in != null) {
                 try {
                     in.close();
-                } catch (IOException e) {
+                } catch (IOException ignored) {
                 }
             }
             if (result != null) {
@@ -395,7 +398,7 @@
         } else {
             try {
                 in.close();
-            } catch (IOException ex) {
+            } catch (IOException ignored) {
             }
             in = mKey.createInputStream();
         }
@@ -421,6 +424,7 @@
         // round to the nearest power of two, or just truncate
         final boolean stricter = true;
 
+        //noinspection ConstantConditions
         if (stricter) {
             result = (int) Math.pow(2, (int) (0.5 + (Math.log(sz) / Math.log(2))));
         } else {
diff --git a/src/com/android/bitmap/PooledCache.java b/src/com/android/bitmap/PooledCache.java
index 62381b8..6d6684f 100644
--- a/src/com/android/bitmap/PooledCache.java
+++ b/src/com/android/bitmap/PooledCache.java
@@ -24,4 +24,14 @@
     V poll();
     String toDebugString();
 
+    /**
+     * Purge existing Poolables from the pool+cache. Usually, this is done when situations
+     * change and the items in the pool+cache are no longer appropriate. For example,
+     * if the layout changes, the pool+cache may need to hold larger bitmaps.
+     *
+     * <p/>
+     * The existing Poolables will be garbage collected when they are no longer being referenced
+     * by other objects.
+     */
+    void clear();
 }
diff --git a/src/com/android/bitmap/ReusableBitmap.java b/src/com/android/bitmap/ReusableBitmap.java
index 2e0199f..dde9bd1 100644
--- a/src/com/android/bitmap/ReusableBitmap.java
+++ b/src/com/android/bitmap/ReusableBitmap.java
@@ -115,4 +115,37 @@
         return sb.toString();
     }
 
+    /**
+     * Singleton class to represent a null Bitmap. We don't want to just use a regular
+     * ReusableBitmap with a null bmp field because that will render that ReusableBitmap useless
+     * and unable to be used by another decode process.
+     */
+    public final static class NullReusableBitmap extends ReusableBitmap {
+        private static NullReusableBitmap sInstance;
+
+        /**
+         * Get a singleton.
+         */
+        public static NullReusableBitmap getInstance() {
+            if (sInstance == null) {
+                sInstance = new NullReusableBitmap();
+            }
+            return sInstance;
+        }
+
+        private NullReusableBitmap() {
+            super(null /* bmp */, false /* reusable */);
+        }
+
+        @Override
+        public int getByteCount() {
+            return 0;
+        }
+
+        @Override
+        public void releaseReference() { }
+
+        @Override
+        public void acquireReference() { }
+    }
 }
diff --git a/src/com/android/mail/ContactInfo.java b/src/com/android/mail/ContactInfo.java
index b7cecb8..a59dc62 100644
--- a/src/com/android/mail/ContactInfo.java
+++ b/src/com/android/mail/ContactInfo.java
@@ -47,6 +47,6 @@
 
     @Override
     public String toString() {
-        return "{status=" + status + " photo=" + photo + "}";
+        return "{status=" + status + " photo=" + (photo != null ? photo : photoBytes) + "}";
     }
 }
diff --git a/src/com/android/mail/SenderInfoLoader.java b/src/com/android/mail/SenderInfoLoader.java
index 5d01ad4..c69e934 100644
--- a/src/com/android/mail/SenderInfoLoader.java
+++ b/src/com/android/mail/SenderInfoLoader.java
@@ -17,6 +17,7 @@
 
 package com.android.mail;
 
+import com.android.bitmap.Trace;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 
@@ -99,40 +100,53 @@
     /**
      * Loads contact photos from the ContentProvider.
      * @param resolver {@link ContentResolver} to use in queries to the ContentProvider.
-     * @param senderSet The email addresses of the sender images to return.
+     * @param emails The email addresses of the sender images to return.
      * @param decodeBitmaps If {@code true}, decode the bitmaps and put them into
      *                      {@link ContactInfo}. Otherwise, just put the raw bytes of the photo
      *                      into the {@link ContactInfo}.
-     * @return A mapping of email addresses to {@link ContactInfo}s. The {@link ContactInfo} will
-     * contain either a byte array or an actual decoded bitmap for the sender image.
+     * @return A mapping of email to {@link ContactInfo}. How to interpret the map:
+     * <ul>
+     *     <li>The email is missing from the key set or maps to null - The email was skipped. Try
+     *     again.</li>
+     *     <li>Either {@link ContactInfo#photoBytes} or {@link ContactInfo#photo} is non-null -
+     *     Photo loaded successfully.</li>
+     *     <li>Both {@link ContactInfo#photoBytes} and {@link ContactInfo#photo} are null -
+     *     Photo load failed.</li>
+     * </ul>
      */
     public static ImmutableMap<String, ContactInfo> loadContactPhotos(
-            final ContentResolver resolver, final Set<String> senderSet,
-            final boolean decodeBitmaps) {
+            final ContentResolver resolver, final Set<String> emails, final boolean decodeBitmaps) {
+        Trace.beginSection("load contact photos util");
         Cursor cursor = null;
 
+        Trace.beginSection("build first query");
         Map<String, ContactInfo> results = Maps.newHashMap();
 
         // temporary structures
         Map<Long, Pair<String, ContactInfo>> photoIdMap = Maps.newHashMap();
         ArrayList<String> photoIdsAsStrings = new ArrayList<String>();
-        ArrayList<String> senders = getTruncatedQueryParams(senderSet);
+        ArrayList<String> emailsList = getTruncatedQueryParams(emails);
 
         // Build first query
         StringBuilder query = new StringBuilder()
                 .append(Data.MIMETYPE).append("='").append(Email.CONTENT_ITEM_TYPE)
                 .append("' AND ").append(Email.DATA).append(" IN (");
-        appendQuestionMarks(query, senders);
+        appendQuestionMarks(query, emailsList);
         query.append(')');
+        Trace.endSection();
 
         try {
+            Trace.beginSection("query 1");
             cursor = resolver.query(Data.CONTENT_URI, DATA_COLS,
-                    query.toString(), toStringArray(senders), null /* sortOrder */);
+                    query.toString(), toStringArray(emailsList), null /* sortOrder */);
+            Trace.endSection();
 
             if (cursor == null) {
+                Trace.endSection();
                 return null;
             }
 
+            Trace.beginSection("get photo id");
             int i = -1;
             while (cursor.moveToPosition(++i)) {
                 String email = cursor.getString(DATA_EMAIL_COLUMN);
@@ -154,11 +168,23 @@
                 results.put(email, result);
             }
             cursor.close();
+            Trace.endSection();
+
+            // Put empty ContactInfo for all the emails that didn't map to a contact.
+            // This allows us to differentiate between lookup failed,
+            // and lookup skipped (truncated above).
+            for (String email : emailsList) {
+                if (!results.containsKey(email)) {
+                    results.put(email, new ContactInfo(null, null));
+                }
+            }
 
             if (photoIdsAsStrings.isEmpty()) {
+                Trace.endSection();
                 return ImmutableMap.copyOf(results);
             }
 
+            Trace.beginSection("build second query");
             // Build second query: photoIDs->blobs
             // based on photo batch-select code in ContactPhotoManager
             photoIdsAsStrings = getTruncatedQueryParams(photoIdsAsStrings);
@@ -166,14 +192,19 @@
             query.append(Photo._ID).append(" IN (");
             appendQuestionMarks(query, photoIdsAsStrings);
             query.append(')');
+            Trace.endSection();
 
+            Trace.beginSection("query 2");
             cursor = resolver.query(Data.CONTENT_URI, PHOTO_COLS,
                     query.toString(), toStringArray(photoIdsAsStrings), null /* sortOrder */);
+            Trace.endSection();
 
             if (cursor == null) {
+                Trace.endSection();
                 return ImmutableMap.copyOf(results);
             }
 
+            Trace.beginSection("get photo blob");
             i = -1;
             while (cursor.moveToPosition(++i)) {
                 byte[] photoBytes = cursor.getBlob(PHOTO_PHOTO_COLUMN);
@@ -187,7 +218,9 @@
                 ContactInfo prevResult = prev.second;
 
                 if (decodeBitmaps) {
+                    Trace.beginSection("decode bitmap");
                     Bitmap photo = BitmapFactory.decodeByteArray(photoBytes, 0, photoBytes.length);
+                    Trace.endSection();
                     // overwrite existing photo-less result
                     results.put(email,
                             new ContactInfo(prevResult.contactUri, prevResult.status, photo));
@@ -197,12 +230,14 @@
                             prevResult.contactUri, prevResult.status, photoBytes));
                 }
             }
+            Trace.endSection();
         } finally {
             if (cursor != null) {
                 cursor.close();
             }
         }
 
+        Trace.endSection();
         return ImmutableMap.copyOf(results);
     }
 
diff --git a/src/com/android/mail/bitmap/AttachmentDrawable.java b/src/com/android/mail/bitmap/AttachmentDrawable.java
index 0252455..49e4008 100644
--- a/src/com/android/mail/bitmap/AttachmentDrawable.java
+++ b/src/com/android/mail/bitmap/AttachmentDrawable.java
@@ -147,11 +147,13 @@
         // requests for different renditions of the same attachment
         final boolean onlyRenditionChange = (mCurrKey != null && mCurrKey.matches(key));
 
+        Trace.beginSection("release reference");
         if (mBitmap != null && !onlyRenditionChange) {
             mBitmap.releaseReference();
 //            System.out.println("view.bind() decremented ref to old bitmap: " + mBitmap);
             mBitmap = null;
         }
+        Trace.endSection();
         if (mCurrKey != null && SwipeableListView.ENABLE_ATTACHMENT_DECODE_AGGREGATOR) {
             mDecodeAggregator.forget(mCurrKey);
         }
@@ -169,6 +171,7 @@
         setLoadState(LOAD_STATE_UNINITIALIZED);
 
         if (key == null) {
+            invalidateSelf();
             Trace.endSection();
             return;
         }
@@ -200,7 +203,7 @@
             return;
         }
 
-        if (mBitmap != null) {
+        if (mBitmap != null && mBitmap.bmp != null) {
             BitmapUtils
                     .calculateCroppedSrcRect(mBitmap.getLogicalWidth(), mBitmap.getLogicalHeight(),
                             bounds.width(), bounds.height(),
diff --git a/src/com/android/mail/bitmap/AttachmentGridDrawable.java b/src/com/android/mail/bitmap/AttachmentGridDrawable.java
index f5d28ed..c1e5789 100644
--- a/src/com/android/mail/bitmap/AttachmentGridDrawable.java
+++ b/src/com/android/mail/bitmap/AttachmentGridDrawable.java
@@ -2,6 +2,7 @@
 
 import android.content.res.Resources;
 import android.graphics.Canvas;
+import android.graphics.ColorFilter;
 import android.graphics.Paint;
 import android.graphics.Paint.Align;
 import android.graphics.Rect;
@@ -47,7 +48,7 @@
     }
 
     @Override
-    protected AttachmentDrawable createDivisionDrawable() {
+    protected AttachmentDrawable createDivisionDrawable(final int i) {
         final AttachmentDrawable result = new AttachmentDrawable(mResources, mCache,
                 mDecodeAggregator, mCoordinates, mPlaceholder, mProgress);
         return result;
@@ -105,8 +106,24 @@
     }
 
     @Override
+    public void setAlpha(final int alpha) {
+        super.setAlpha(alpha);
+        final int old = mPaint.getAlpha();
+        mPaint.setAlpha(alpha);
+        if (alpha != old) {
+            invalidateSelf();
+        }
+    }
+
+    @Override
+    public void setColorFilter(final ColorFilter cf) {
+        super.setColorFilter(cf);
+        mPaint.setColorFilter(cf);
+        invalidateSelf();
+    }
+
+    @Override
     public void setParallaxFraction(float fraction) {
         mParallaxFraction = fraction;
     }
-
 }
diff --git a/src/com/android/mail/bitmap/CompositeDrawable.java b/src/com/android/mail/bitmap/CompositeDrawable.java
index e25bfc7..e372bb5 100644
--- a/src/com/android/mail/bitmap/CompositeDrawable.java
+++ b/src/com/android/mail/bitmap/CompositeDrawable.java
@@ -21,11 +21,13 @@
 public abstract class CompositeDrawable<T extends Drawable> extends Drawable
         implements Drawable.Callback {
 
+    public static final int MAX_COMPOSITE_DRAWABLES = 4;
+
     protected final List<T> mDrawables;
     protected int mCount;
 
     public CompositeDrawable(int maxDivisions) {
-        if (maxDivisions >= 4) {
+        if (maxDivisions > MAX_COMPOSITE_DRAWABLES) {
             throw new IllegalArgumentException("CompositeDrawable only supports 4 divisions");
         }
         mDrawables = new ArrayList<T>(maxDivisions);
@@ -35,7 +37,7 @@
         mCount = 0;
     }
 
-    protected abstract T createDivisionDrawable();
+    protected abstract T createDivisionDrawable(final int i);
 
     public void setCount(int count) {
         // zero out the composite bounds, which will propagate to the division drawables
@@ -56,7 +58,7 @@
         T result = mDrawables.get(i);
         if (result == null) {
             Trace.beginSection("create division drawable");
-            result = createDivisionDrawable();
+            result = createDivisionDrawable(i);
             mDrawables.set(i, result);
             result.setCallback(this);
             // Make sure drawables created after the bounds were already set have their bounds
@@ -109,6 +111,11 @@
 
     @Override
     public void draw(Canvas canvas) {
+        final Rect bounds = getBounds();
+        if (!isVisible() || bounds.isEmpty()) {
+            return;
+        }
+
         for (int i = 0; i < mCount; i++) {
             mDrawables.get(i).draw(canvas);
         }
@@ -132,10 +139,7 @@
     public int getOpacity() {
         int opacity = PixelFormat.OPAQUE;
         for (int i = 0; i < mCount; i++) {
-            if (mDrawables.get(i).getOpacity() != PixelFormat.OPAQUE) {
-                opacity = PixelFormat.TRANSLUCENT;
-                break;
-            }
+            opacity = resolveOpacity(opacity, mDrawables.get(i).getOpacity());
         }
         return opacity;
     }
diff --git a/src/com/android/mail/bitmap/ContactCheckableGridDrawable.java b/src/com/android/mail/bitmap/ContactCheckableGridDrawable.java
new file mode 100644
index 0000000..134439d
--- /dev/null
+++ b/src/com/android/mail/bitmap/ContactCheckableGridDrawable.java
@@ -0,0 +1,241 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.mail.bitmap;
+
+import android.animation.ValueAnimator;
+import android.animation.ValueAnimator.AnimatorUpdateListener;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+
+import com.android.bitmap.BitmapCache;
+import com.android.mail.R.color;
+import com.android.mail.R.drawable;
+
+/**
+ * Custom FlipDrawable which has a {@link ContactGridDrawable} on the front,
+ * and a {@link CheckmarkDrawable} on the back.
+ */
+public class ContactCheckableGridDrawable extends FlipDrawable implements AnimatorUpdateListener {
+
+    private final ContactGridDrawable mContactGridDrawable;
+    private final CheckmarkDrawable mCheckmarkDrawable;
+
+    private final ValueAnimator mCheckmarkScaleAnimator;
+    private final ValueAnimator mCheckmarkAlphaAnimator;
+
+    private static final int POST_FLIP_DURATION_MS = 150;
+
+    private static final float CHECKMARK_SCALE_BEGIN_VALUE = 0.2f;
+    private static final float CHECKMARK_ALPHA_BEGIN_VALUE = 0f;
+
+    /** Must be <= 1f since the animation value is used as a percentage. */
+    private static final float END_VALUE = 1f;
+
+    public ContactCheckableGridDrawable(final Resources res, final int flipDurationMs) {
+        super(new ContactGridDrawable(res), new CheckmarkDrawable(res), flipDurationMs,
+                0 /* preFlipDurationMs */, POST_FLIP_DURATION_MS);
+
+        mContactGridDrawable = (ContactGridDrawable) mFront;
+        mCheckmarkDrawable = (CheckmarkDrawable) mBack;
+
+        // We will create checkmark animations that are synchronized with the flipping animation.
+        // The entire delay + duration of the checkmark animation needs to equal the entire
+        // duration of the flip animation (where delay is 0).
+
+        // The checkmark animation is in effect only when the back drawable is being shown.
+        // For the flip animation duration    <pre>[_][]|[][_]<post>
+        // The checkmark animation will be    |--delay--|-duration-|
+
+        // Need delay to skip the first half of the flip duration.
+        final long animationDelay = mPreFlipDurationMs + mFlipDurationMs / 2;
+        // Actual duration is the second half of the flip duration.
+        final long animationDuration = mFlipDurationMs / 2 + mPostFlipDurationMs;
+
+        mCheckmarkScaleAnimator = ValueAnimator.ofFloat(CHECKMARK_SCALE_BEGIN_VALUE, END_VALUE)
+                .setDuration(animationDuration);
+        mCheckmarkScaleAnimator.setStartDelay(animationDelay);
+        mCheckmarkScaleAnimator.addUpdateListener(this);
+
+        mCheckmarkAlphaAnimator = ValueAnimator.ofFloat(CHECKMARK_ALPHA_BEGIN_VALUE, END_VALUE)
+                .setDuration(animationDuration);
+        mCheckmarkAlphaAnimator.setStartDelay(animationDelay);
+        mCheckmarkAlphaAnimator.addUpdateListener(this);
+    }
+
+    @Override
+    public void reset(final boolean side) {
+        super.reset(side);
+        if (mCheckmarkScaleAnimator == null) {
+            // Call from super's constructor. Not yet initialized.
+            return;
+        }
+        mCheckmarkScaleAnimator.cancel();
+        mCheckmarkAlphaAnimator.cancel();
+        mCheckmarkDrawable.setScaleAnimatorValue(side ? CHECKMARK_SCALE_BEGIN_VALUE : END_VALUE);
+        mCheckmarkDrawable.setAlphaAnimatorValue(side ? CHECKMARK_ALPHA_BEGIN_VALUE : END_VALUE);
+    }
+
+    @Override
+    public void flip() {
+        super.flip();
+        // Keep the checkmark animators in sync with the flip animator.
+        if (mCheckmarkScaleAnimator.isStarted()) {
+            mCheckmarkScaleAnimator.reverse();
+            mCheckmarkAlphaAnimator.reverse();
+        } else {
+            if (!getSideFlippingTowards() /* front to back */) {
+                mCheckmarkScaleAnimator.start();
+                mCheckmarkAlphaAnimator.start();
+            } else /* back to front */ {
+                mCheckmarkScaleAnimator.reverse();
+                mCheckmarkAlphaAnimator.reverse();
+            }
+        }
+    }
+
+    public ContactDrawable getOrCreateDrawable(final int i) {
+        return mContactGridDrawable.getOrCreateDrawable(i);
+    }
+
+    public void setBitmapCache(final BitmapCache cache) {
+        mContactGridDrawable.setBitmapCache(cache);
+    }
+
+    public void setContactResolver(final ContactResolver contactResolver) {
+        mContactGridDrawable.setContactResolver(contactResolver);
+    }
+
+    public int getCount() {
+        return mContactGridDrawable.getCount();
+    }
+
+    public void setCount(final int count) {
+        mContactGridDrawable.setCount(count);
+        // Side effect needs to happen here too.
+        setBounds(0, 0, 0, 0);
+    }
+
+    @Override
+    public void onAnimationUpdate(final ValueAnimator animation) {
+        //noinspection ConstantConditions
+        final float value = (Float) animation.getAnimatedValue();
+
+        if (animation == mCheckmarkScaleAnimator) {
+            mCheckmarkDrawable.setScaleAnimatorValue(value);
+        } else if (animation == mCheckmarkAlphaAnimator) {
+            mCheckmarkDrawable.setAlphaAnimatorValue(value);
+        }
+    }
+
+    /**
+     * Meant to be used as the with a FlipDrawable. The animator driving this Drawable should be
+     * more or less in sync with the containing FlipDrawable's flip animator.
+     */
+    private static class CheckmarkDrawable extends Drawable {
+
+        private static Bitmap CHECKMARK;
+        private static int sBackgroundColor;
+
+        private final Paint mPaint;
+
+        private float mScaleFraction;
+        private float mAlphaFraction;
+
+        private static final Matrix sMatrix = new Matrix();
+
+        public CheckmarkDrawable(final Resources res) {
+            if (CHECKMARK == null) {
+                CHECKMARK = BitmapFactory.decodeResource(res, drawable.ic_avatar_check);
+                sBackgroundColor = res.getColor(color.checkmark_tile_background_color);
+            }
+            mPaint = new Paint();
+            mPaint.setAntiAlias(true);
+            mPaint.setFilterBitmap(true);
+            mPaint.setColor(sBackgroundColor);
+        }
+
+        @Override
+        public void draw(final Canvas canvas) {
+            final Rect bounds = getBounds();
+            if (!isVisible() || bounds.isEmpty()) {
+                return;
+            }
+
+            canvas.drawRect(getBounds(), mPaint);
+
+            // Scale the checkmark.
+            sMatrix.reset();
+            sMatrix.setScale(mScaleFraction, mScaleFraction, CHECKMARK.getWidth() / 2,
+                    CHECKMARK.getHeight() / 2);
+            sMatrix.postTranslate(bounds.centerX() - CHECKMARK.getWidth() / 2,
+                    bounds.centerY() - CHECKMARK.getHeight() / 2);
+
+            // Fade the checkmark.
+            final int oldAlpha = mPaint.getAlpha();
+            // Interpolate the alpha.
+            mPaint.setAlpha((int) (oldAlpha * mAlphaFraction));
+            canvas.drawBitmap(CHECKMARK, sMatrix, mPaint);
+            // Restore the alpha.
+            mPaint.setAlpha(oldAlpha);
+        }
+
+        @Override
+        public void setAlpha(final int alpha) {
+            mPaint.setAlpha(alpha);
+        }
+
+        @Override
+        public void setColorFilter(final ColorFilter cf) {
+            mPaint.setColorFilter(cf);
+        }
+
+        @Override
+        public int getOpacity() {
+            // Always a gray background.
+            return PixelFormat.OPAQUE;
+        }
+
+        /**
+         * Set value as a fraction from 0f to 1f.
+         */
+        public void setScaleAnimatorValue(final float value) {
+            final float old = mScaleFraction;
+            mScaleFraction = value;
+            if (old != mScaleFraction) {
+                invalidateSelf();
+            }
+        }
+
+        /**
+         * Set value as a fraction from 0f to 1f.
+         */
+        public void setAlphaAnimatorValue(final float value) {
+            final float old = mAlphaFraction;
+            mAlphaFraction = value;
+            if (old != mAlphaFraction) {
+                invalidateSelf();
+            }
+        }
+    }
+}
diff --git a/src/com/android/mail/bitmap/ContactDrawable.java b/src/com/android/mail/bitmap/ContactDrawable.java
new file mode 100644
index 0000000..34aa682
--- /dev/null
+++ b/src/com/android/mail/bitmap/ContactDrawable.java
@@ -0,0 +1,252 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.mail.bitmap;
+
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.Paint.Align;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+
+import com.android.bitmap.BitmapCache;
+import com.android.bitmap.DecodeTask.Request;
+import com.android.bitmap.ReusableBitmap;
+import com.android.mail.R;
+
+/**
+ * A drawable that encapsulates all the functionality needed to display a contact image,
+ * including request creation/cancelling and data unbinding/re-binding. While no contact images
+ * can be shown, a default letter tile will be shown instead.
+ *
+ * <p/>
+ * The actual contact resolving and decoding is handled by {@link ContactResolver}.
+ */
+public class ContactDrawable extends Drawable {
+
+    private final BitmapCache mCache;
+    private final ContactResolver mContactResolver;
+
+    private ContactRequest mContactRequest;
+    private ReusableBitmap mBitmap;
+    private final Paint mPaint;
+    private int mScale;
+
+    /** Letter tile */
+    private static TypedArray sColors;
+    private static int sDefaultColor;
+    private static int sTileLetterFontSize;
+    private static int sTileLetterFontSizeSmall;
+    private static int sTileFontColor;
+    private static Bitmap DEFAULT_AVATAR;
+    /** Reusable components to avoid new allocations */
+    private static final Paint sPaint = new Paint();
+    private static final Rect sRect = new Rect();
+    private static final char[] sFirstChar = new char[1];
+
+    /** This should match the total number of colors defined in colors.xml for letter_tile_color */
+    private static final int NUM_OF_TILE_COLORS = 8;
+
+    public ContactDrawable(final Resources res, final BitmapCache cache,
+            final ContactResolver contactResolver) {
+        mCache = cache;
+        mContactResolver = contactResolver;
+        mPaint = new Paint();
+        mPaint.setFilterBitmap(true);
+        mPaint.setDither(true);
+
+        if (sColors == null) {
+            sColors = res.obtainTypedArray(R.array.letter_tile_colors);
+            sDefaultColor = res.getColor(R.color.letter_tile_default_color);
+            sTileLetterFontSize = res.getDimensionPixelSize(R.dimen.tile_letter_font_size);
+            sTileLetterFontSizeSmall = res
+                    .getDimensionPixelSize(R.dimen.tile_letter_font_size_small);
+            sTileFontColor = res.getColor(R.color.letter_tile_font_color);
+            DEFAULT_AVATAR = BitmapFactory.decodeResource(res, R.drawable.ic_generic_man);
+
+            sPaint.setTypeface(Typeface.create("sans-serif-light", Typeface.NORMAL));
+            sPaint.setTextAlign(Align.CENTER);
+            sPaint.setAntiAlias(true);
+        }
+    }
+
+    @Override
+    public void draw(final Canvas canvas) {
+        final Rect bounds = getBounds();
+        if (!isVisible() || bounds.isEmpty()) {
+            return;
+        }
+
+        if (mBitmap != null && mBitmap.bmp != null) {
+            // Draw sender image.
+            drawBitmap(mBitmap.bmp, mBitmap.getLogicalWidth(), mBitmap.getLogicalHeight(), canvas);
+        } else {
+            // Draw letter tile.
+            drawLetterTile(canvas);
+        }
+    }
+
+    /**
+     * Draw the bitmap onto the canvas at the current bounds taking into account the current scale.
+     */
+    private void drawBitmap(final Bitmap bitmap, final int width, final int height,
+            final Canvas canvas) {
+        final Rect bounds = getBounds();
+        
+        if (mScale != ContactGridDrawable.SCALE_TYPE_HALF) {
+            sRect.set(0, 0, width, height);
+        } else {
+            // For skinny bounds, draw the middle two quarters.
+            sRect.set(width / 4, 0, width / 4 * 3, height);
+        }
+        canvas.drawBitmap(bitmap, sRect, bounds, mPaint);
+    }
+
+    private void drawLetterTile(final Canvas canvas) {
+        if (mContactRequest == null) {
+            return;
+        }
+
+        // Draw background color.
+        final String email = mContactRequest.getEmail();
+        sPaint.setColor(pickColor(email));
+        sPaint.setAlpha(mPaint.getAlpha());
+        canvas.drawRect(getBounds(), sPaint);
+
+        // Draw letter/digit or generic avatar.
+        final String displayName = mContactRequest.getDisplayName();
+        final char firstChar = displayName.charAt(0);
+        final Rect bounds = getBounds();
+        if (isEnglishLetterOrDigit(firstChar)) {
+            // Draw letter or digit.
+            sFirstChar[0] = Character.toUpperCase(firstChar);
+            sPaint.setTextSize(mScale == ContactGridDrawable.SCALE_TYPE_ONE ? sTileLetterFontSize
+                    : sTileLetterFontSizeSmall);
+            sPaint.getTextBounds(sFirstChar, 0, 1, sRect);
+            sPaint.setColor(sTileFontColor);
+            canvas.drawText(sFirstChar, 0, 1, bounds.centerX(),
+                    bounds.centerY() + sRect.height() / 2, sPaint);
+        } else {
+            drawBitmap(DEFAULT_AVATAR, DEFAULT_AVATAR.getWidth(), DEFAULT_AVATAR.getHeight(),
+                    canvas);
+        }
+    }
+
+    private static int pickColor(final String email) {
+        // String.hashCode() implementation is not supposed to change across java versions, so
+        // this should guarantee the same email address always maps to the same color.
+        // The email should already have been normalized by the ContactRequest.
+        final int color = Math.abs(email.hashCode()) % NUM_OF_TILE_COLORS;
+        return sColors.getColor(color, sDefaultColor);
+    }
+
+    private static boolean isEnglishLetterOrDigit(final char c) {
+        return ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z') || ('0' <= c && c <= '9');
+    }
+
+    @Override
+    public void setAlpha(final int alpha) {
+        mPaint.setAlpha(alpha);
+    }
+
+    @Override
+    public void setColorFilter(final ColorFilter cf) {
+        mPaint.setColorFilter(cf);
+    }
+
+    @Override
+    public int getOpacity() {
+        return 0;
+    }
+
+    public void setDecodeDimensions(final int decodeWidth, final int decodeHeight) {
+        mCache.setPoolDimensions(decodeWidth, decodeHeight);
+    }
+
+    public void setScale(final int scale) {
+        mScale = scale;
+    }
+
+    public void unbind() {
+        setImage(null);
+    }
+
+    public void bind(final String name, final String email) {
+        setImage(new ContactRequest(name, email));
+    }
+
+    private void setImage(final ContactRequest contactRequest) {
+        if (mContactRequest != null && mContactRequest.equals(contactRequest)) {
+            return;
+        }
+
+        if (mBitmap != null) {
+            mBitmap.releaseReference();
+            mBitmap = null;
+        }
+
+        mContactResolver.remove(mContactRequest, this);
+        mContactRequest = contactRequest;
+
+        if (contactRequest == null) {
+            invalidateSelf();
+            return;
+        }
+
+        final ReusableBitmap cached = mCache.get(contactRequest, true /* incrementRefCount */);
+        if (cached != null) {
+            setBitmap(cached);
+        } else {
+            decode();
+        }
+    }
+
+    private void setBitmap(final ReusableBitmap bmp) {
+        if (mBitmap != null && mBitmap != bmp) {
+            mBitmap.releaseReference();
+        }
+        mBitmap = bmp;
+        invalidateSelf();
+    }
+
+    private void decode() {
+        if (mContactRequest == null) {
+            return;
+        }
+        // Add to batch.
+        mContactResolver.add(mContactRequest, this);
+    }
+
+    public void onDecodeComplete(final Request key, final ReusableBitmap result) {
+        final ContactRequest request = (ContactRequest) key;
+        // Remove from batch.
+        mContactResolver.remove(request, this);
+        if (request.equals(mContactRequest)) {
+            setBitmap(result);
+        } else {
+            // if the requests don't match (i.e. this request is stale), decrement the
+            // ref count to allow the bitmap to be pooled
+            if (result != null) {
+                result.releaseReference();
+            }
+        }
+    }
+}
diff --git a/src/com/android/mail/bitmap/ContactGridDrawable.java b/src/com/android/mail/bitmap/ContactGridDrawable.java
new file mode 100644
index 0000000..590f806
--- /dev/null
+++ b/src/com/android/mail/bitmap/ContactGridDrawable.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.mail.bitmap;
+
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.Rect;
+
+import com.android.bitmap.BitmapCache;
+import com.android.mail.R;
+
+/**
+ * A 2x2 grid of contact drawables. Adds horizontal and vertical dividers.
+ */
+public class ContactGridDrawable extends CompositeDrawable<ContactDrawable> {
+
+    public static final int SCALE_TYPE_ONE = 0;
+    public static final int SCALE_TYPE_HALF = 1;
+    public static final int SCALE_TYPE_QUARTER = 2;
+
+    private static final int MAX_CONTACTS_COUNT = 4;
+
+    private ContactResolver mContactResolver;
+    private BitmapCache mCache;
+    private final Resources mRes;
+    private Paint mPaint;
+
+    private static int sDividerWidth = -1;
+    private static int sDividerColor;
+
+    public ContactGridDrawable(final Resources res) {
+        super(MAX_CONTACTS_COUNT);
+
+        if (sDividerWidth == -1) {
+            sDividerWidth = res.getDimensionPixelSize(R.dimen.tile_divider_width);
+            sDividerColor = res.getColor(R.color.tile_divider_color);
+        }
+
+        mRes = res;
+        mPaint = new Paint();
+        mPaint.setStrokeWidth(sDividerWidth);
+        mPaint.setColor(sDividerColor);
+    }
+
+    @Override
+    protected ContactDrawable createDivisionDrawable(final int i) {
+        final ContactDrawable drawable = new ContactDrawable(mRes, mCache, mContactResolver);
+        drawable.setScale(calculateScale(i));
+        return drawable;
+    }
+
+    @Override
+    public void setCount(final int count) {
+        super.setCount(count);
+
+        for (int i = 0; i < mCount; i++) {
+            final ContactDrawable drawable = mDrawables.get(i);
+            if (drawable != null) {
+                drawable.setScale(calculateScale(i));
+            }
+        }
+    }
+
+    /**
+     * Given which section a drawable is in, calculate its scale based on the current total count.
+     * @param i The section, indexed by 0.
+     */
+    private int calculateScale(final int i) {
+        switch (mCount) {
+            case 1:
+                // 1 bitmap: passthrough
+                return SCALE_TYPE_ONE;
+            case 2:
+                // 2 bitmaps split vertically
+                return SCALE_TYPE_HALF;
+            case 3:
+                // 1st is tall on the left, 2nd/3rd stacked vertically on the right
+                return i == 0 ? SCALE_TYPE_HALF : SCALE_TYPE_QUARTER;
+            case 4:
+                // 4 bitmaps in a 2x2 grid
+                return SCALE_TYPE_QUARTER;
+            default:
+                return SCALE_TYPE_ONE;
+        }
+    }
+
+    @Override
+    public void draw(final Canvas canvas) {
+        super.draw(canvas);
+
+        final Rect bounds = getBounds();
+        if (!isVisible() || bounds.isEmpty()) {
+            return;
+        }
+
+        // Draw horizontal and vertical dividers.
+        switch (mCount) {
+            case 1:
+                // 1 bitmap: passthrough
+                break;
+            case 2:
+                // 2 bitmaps split vertically
+                canvas.drawLine(bounds.centerX(), bounds.top, bounds.centerX(), bounds.bottom,
+                        mPaint);
+                break;
+            case 3:
+                // 1st is tall on the left, 2nd/3rd stacked vertically on the right
+                canvas.drawLine(bounds.centerX(), bounds.top, bounds.centerX(), bounds.bottom,
+                        mPaint);
+                canvas.drawLine(bounds.centerX(), bounds.centerY(), bounds.right, bounds.centerY(),
+                        mPaint);
+                break;
+            case 4:
+                // 4 bitmaps in a 2x2 grid
+                canvas.drawLine(bounds.centerX(), bounds.top, bounds.centerX(), bounds.bottom,
+                        mPaint);
+                canvas.drawLine(bounds.left, bounds.centerY(), bounds.right, bounds.centerY(),
+                        mPaint);
+                break;
+        }
+    }
+
+    @Override
+    public void setAlpha(final int alpha) {
+        super.setAlpha(alpha);
+        final int old = mPaint.getAlpha();
+        mPaint.setAlpha(alpha);
+        if (alpha != old) {
+            invalidateSelf();
+        }
+    }
+
+    @Override
+    public void setColorFilter(final ColorFilter cf) {
+        super.setColorFilter(cf);
+        mPaint.setColorFilter(cf);
+        invalidateSelf();
+    }
+
+    public void setBitmapCache(final BitmapCache cache) {
+        mCache = cache;
+    }
+
+    public void setContactResolver(final ContactResolver contactResolver) {
+        mContactResolver = contactResolver;
+    }
+}
diff --git a/src/com/android/mail/bitmap/ContactRequest.java b/src/com/android/mail/bitmap/ContactRequest.java
new file mode 100644
index 0000000..b0bd2a0
--- /dev/null
+++ b/src/com/android/mail/bitmap/ContactRequest.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.mail.bitmap;
+
+import android.content.res.AssetFileDescriptor;
+import android.text.TextUtils;
+
+import com.android.bitmap.DecodeTask;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A request object for contact images. ContactRequests have a destination because multiple
+ * ContactRequests can share the same decoded data.
+ */
+public class ContactRequest implements DecodeTask.Request {
+
+    private final String mName;
+    private final String mEmail;
+
+    public byte[] bytes;
+
+    public ContactRequest(final String name, final String email) {
+        mName = name;
+        mEmail = normalizeEmail(email);
+    }
+
+    private String normalizeEmail(final String email) {
+        if (TextUtils.isEmpty(email)) {
+            throw new IllegalArgumentException("Email must not be empty.");
+        }
+        // todo: b/10258788
+        return email;
+    }
+
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+
+        final ContactRequest that = (ContactRequest) o;
+
+        // Only count email, so we can pull results out of the cache that are from other contact
+        // requests.
+        //noinspection RedundantIfStatement
+        if (mEmail != null ? !mEmail.equals(that.mEmail) : that.mEmail != null) {
+            return false;
+        }
+
+        return true;
+    }
+
+    @Override
+    public int hashCode() {
+        // Only count email, so we can pull results out of the cache that are from other contact
+        // requests.
+        return mEmail != null ? mEmail.hashCode() : 0;
+    }
+
+    @Override
+    public String toString() {
+        return "[" + super.toString() + " mName=" + mName + " mEmail=" + mEmail + "]";
+    }
+
+    @Override
+    public AssetFileDescriptor createFd() throws IOException {
+        return null;
+    }
+
+    @Override
+    public InputStream createInputStream() throws IOException {
+        return new ByteArrayInputStream(bytes);
+    }
+
+    @Override
+    public boolean hasOrientationExif() throws IOException {
+        return false;
+    }
+
+    public String getEmail() {
+        return mEmail;
+    }
+
+    public String getDisplayName() {
+        return !TextUtils.isEmpty(mName) ? mName : mEmail;
+    }
+
+    /**
+     * This ContactRequest wrapper provides implementations of equals() and hashcode() that
+     * include the destination. We need to put multiple ContactRequests in a set,
+     * but its implementations of equals() and hashcode() don't include the destination.
+     */
+    public static class ContactRequestHolder {
+
+        public final ContactRequest contactRequest;
+        public final ContactDrawable destination;
+
+        public ContactRequestHolder(final ContactRequest contactRequest,
+                final ContactDrawable destination) {
+            this.contactRequest = contactRequest;
+            this.destination = destination;
+        }
+
+        @Override
+        public boolean equals(final Object o) {
+            if (this == o) {
+                return true;
+            }
+            if (o == null || getClass() != o.getClass()) {
+                return false;
+            }
+
+            final ContactRequestHolder that = (ContactRequestHolder) o;
+
+            if (contactRequest != null ? !contactRequest.equals(that.contactRequest)
+                    : that.contactRequest != null) {
+                return false;
+            }
+            //noinspection RedundantIfStatement
+            if (destination != null ? !destination.equals(that.destination)
+                    : that.destination != null) {
+                return false;
+            }
+
+            return true;
+        }
+
+        @Override
+        public int hashCode() {
+            int result = contactRequest != null ? contactRequest.hashCode() : 0;
+            result = 31 * result + (destination != null ? destination.hashCode() : 0);
+            return result;
+        }
+
+        @Override
+        public String toString() {
+            return contactRequest.toString();
+        }
+
+        public String getEmail() {
+            return contactRequest.getEmail();
+        }
+
+        public String getDisplayName() {
+            return contactRequest.getDisplayName();
+        }
+    }
+}
diff --git a/src/com/android/mail/bitmap/ContactResolver.java b/src/com/android/mail/bitmap/ContactResolver.java
new file mode 100644
index 0000000..b3d1fd5
--- /dev/null
+++ b/src/com/android/mail/bitmap/ContactResolver.java
@@ -0,0 +1,260 @@
+/*
+ * Copyright (C) 2013 Google Inc.
+ * Licensed to The Android Open Source Project.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mail.bitmap;
+
+import android.content.ContentResolver;
+import android.os.AsyncTask;
+import android.os.AsyncTask.Status;
+import android.os.Handler;
+
+import com.android.bitmap.BitmapCache;
+import com.android.bitmap.DecodeTask;
+import com.android.bitmap.ReusableBitmap;
+import com.android.ex.photo.util.Trace;
+import com.android.mail.ContactInfo;
+import com.android.mail.SenderInfoLoader;
+import com.android.mail.bitmap.ContactRequest.ContactRequestHolder;
+import com.android.mail.utils.LogTag;
+import com.android.mail.utils.LogUtils;
+import com.google.common.collect.ImmutableMap;
+
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Batches up ContactRequests so we can efficiently query the contacts provider. Kicks off a
+ * ContactResolverTask to query for contact images in the background.
+ */
+public class ContactResolver implements Runnable {
+
+    private static final String TAG = LogTag.getLogTag();
+
+    private final ContentResolver mResolver;
+    private final BitmapCache mCache;
+    /** Insertion ordered set allows us to work from the top down. */
+    private final LinkedHashSet<ContactRequestHolder> mBatch;
+
+    private final Handler mHandler = new Handler();
+    private ContactResolverTask mTask;
+
+
+    /** Size 1 pool mostly to make systrace output traces on one line. */
+    private static final Executor SMALL_POOL_EXECUTOR = new ThreadPoolExecutor(1, 1,
+            1, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
+    private static final Executor EXECUTOR = SMALL_POOL_EXECUTOR;
+
+    public ContactResolver(final ContentResolver resolver, final BitmapCache cache) {
+        mResolver = resolver;
+        mCache = cache;
+        mBatch = new LinkedHashSet<ContactRequestHolder>();
+    }
+
+    @Override
+    public void run() {
+        // Start to process a new batch.
+        if (mBatch.isEmpty()) {
+            return;
+        }
+
+        if (mTask != null && mTask.getStatus() == Status.RUNNING) {
+            LogUtils.d(TAG, "ContactResolver << batch skip");
+            return;
+        }
+
+        Trace.beginSection("ContactResolver run");
+        LogUtils.d(TAG, "ContactResolver >> batch start");
+
+        // Make a copy of the batch.
+        LinkedHashSet<ContactRequestHolder> batch = new LinkedHashSet<ContactRequestHolder>(mBatch);
+
+        if (mTask != null) {
+            mTask.cancel(true);
+        }
+
+        mTask = new ContactResolverTask(batch, mResolver, mCache, this);
+        mTask.executeOnExecutor(EXECUTOR);
+        Trace.endSection();
+    }
+
+    public void add(final ContactRequest request, final ContactDrawable drawable) {
+        mBatch.add(new ContactRequestHolder(request, drawable));
+        notifyBatchReady();
+    }
+
+    public void remove(final ContactRequest request, final ContactDrawable drawable) {
+        mBatch.remove(new ContactRequestHolder(request, drawable));
+    }
+
+    /**
+     * A layout pass traverses the whole tree during a single iteration of the event loop. That
+     * means that every ContactDrawable on the screen will add its ContactRequest to the batch in
+     * a single iteration of the event loop.
+     *
+     * <p/>
+     * We take advantage of this by posting a Runnable (happens to be this object) at the end of
+     * the event queue. Every time something is added to the batch as part of the same layout pass,
+     * the Runnable is moved to the back of the queue. When the next layout pass occurs,
+     * it is placed in the event loop behind this Runnable. That allows us to process the batch
+     * that was added previously.
+     */
+    private void notifyBatchReady() {
+        LogUtils.d(TAG, "ContactResolver  > batch   %d", mBatch.size());
+        mHandler.removeCallbacks(this);
+        mHandler.post(this);
+    }
+
+    /**
+     * This is not a very traditional AsyncTask, in the sense that we do not care about what gets
+     * returned in doInBackground(). Instead, we signal traditional "return values" through
+     * publishProgress().
+     *
+     * <p/>
+     * The reason we do this is because this task is responsible for decoding an entire batch of
+     * ContactRequests. But, we do not want to have to wait to decode all of them before updating
+     * any views. So we must do all the work in doInBackground(),
+     * but upon finishing each individual task, we need to jump out to the UI thread and update
+     * that view.
+     */
+    private static class ContactResolverTask extends AsyncTask<Void, Result, Void> {
+
+        private final Set<ContactRequestHolder> mContactRequests;
+        private final ContentResolver mResolver;
+        private final BitmapCache mCache;
+        private final ContactResolver mCallback;
+
+        public ContactResolverTask(final Set<ContactRequestHolder> contactRequests,
+                final ContentResolver resolver, final BitmapCache cache,
+                final ContactResolver callback) {
+            mContactRequests = contactRequests;
+            mResolver = resolver;
+            mCache = cache;
+            mCallback = callback;
+        }
+
+        @Override
+        protected Void doInBackground(final Void... params) {
+            Trace.beginSection("set up");
+            final Set<String> emails = new HashSet<String>(mContactRequests.size());
+            for (ContactRequestHolder request : mContactRequests) {
+                final String email = request.getEmail();
+                emails.add(email);
+            }
+            Trace.endSection();
+
+            Trace.beginSection("load contact photo bytes");
+            // Query the contacts provider for the current batch of emails.
+            ImmutableMap<String, ContactInfo> contactInfos = SenderInfoLoader
+                    .loadContactPhotos(mResolver, emails, false /* decodeBitmaps */);
+            Trace.endSection();
+
+            for (ContactRequestHolder request : mContactRequests) {
+                Trace.beginSection("decode");
+                final String email = request.getEmail();
+                if (contactInfos == null) {
+                    // Query failed.
+                    LogUtils.d(TAG, "ContactResolver -- failed  %s", email);
+                    publishProgress(new Result(request, null));
+                    Trace.endSection();
+                    continue;
+                }
+
+                final ContactInfo contactInfo = contactInfos.get(email);
+                if (contactInfo == null) {
+                    // Request skipped. Try again next batch.
+                    LogUtils.d(TAG, "ContactResolver  = skipped %s", email);
+                    Trace.endSection();
+                    continue;
+                }
+
+                // Query attempted.
+                final byte[] photo = contactInfo.photoBytes;
+                if (photo == null) {
+                    // No photo bytes found.
+                    LogUtils.d(TAG, "ContactResolver -- failed  %s", email);
+                    publishProgress(new Result(request, null));
+                    Trace.endSection();
+                    continue;
+                }
+
+                // Query succeeded. Photo bytes found.
+                request.contactRequest.bytes = photo;
+
+                // Start decode.
+                LogUtils.d(TAG, "ContactResolver ++ found   %s", email);
+                final ReusableBitmap result;
+                final int width = mCache.getDecodeWidth();
+                final int height = mCache.getDecodeHeight();
+                // Synchronously decode the photo bytes. We are already in a background
+                // thread, and we want decodes to finish in order. The decodes are blazing
+                // fast so we don't need to kick off multiple threads.
+                result = new DecodeTask(request.contactRequest, width, height, width, height, null,
+                        mCache).decode();
+                request.contactRequest.bytes = null;
+
+                // Decode success.
+                publishProgress(new Result(request, result));
+                Trace.endSection();
+            }
+
+            return null;
+        }
+
+        /**
+         * We use progress updates to jump to the UI thread so we can decode the batch
+         * incrementally.
+         */
+        @Override
+        protected void onProgressUpdate(final Result... values) {
+            final ContactRequestHolder request = values[0].request;
+            final ReusableBitmap bitmap = values[0].bitmap;
+
+            // DecodeTask does not add null results to the cache.
+            if (bitmap == null) {
+                // Cache null result.
+                mCache.put(request.contactRequest, null);
+            }
+
+            request.destination.onDecodeComplete(request.contactRequest, bitmap);
+        }
+
+        @Override
+        protected void onPostExecute(final Void aVoid) {
+            // Batch completed. Start next batch.
+            mCallback.notifyBatchReady();
+        }
+    }
+
+    /**
+     * Wrapper for the ContactRequest and its decoded bitmap. This class is used to pass results
+     * to onProgressUpdate().
+     */
+    private static class Result {
+        public final ContactRequestHolder request;
+        public final ReusableBitmap bitmap;
+
+        private Result(final ContactRequestHolder request, final ReusableBitmap bitmap) {
+            this.request = request;
+            this.bitmap = bitmap;
+        }
+    }
+}
diff --git a/src/com/android/mail/bitmap/FlipDrawable.java b/src/com/android/mail/bitmap/FlipDrawable.java
new file mode 100644
index 0000000..6cc7b26
--- /dev/null
+++ b/src/com/android/mail/bitmap/FlipDrawable.java
@@ -0,0 +1,269 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.mail.bitmap;
+
+import android.animation.ValueAnimator;
+import android.animation.ValueAnimator.AnimatorUpdateListener;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+
+import com.android.mail.utils.LogUtils;
+
+/**
+ * A drawable that wraps two other drawables and allows flipping between them. The flipping
+ * animation is a 2D rotation around the y axis.
+ *
+ * <p/>
+ * The 3 durations are: (best viewed in documentation form)
+ * <pre>
+ * &lt;pre&gt;[_][]|[][_]&lt;post&gt;
+ *   |       |       |
+ *   V       V       V
+ * &lt;pre>&lt;   flip  &gt;&lt;post&gt;
+ * </pre>
+ */
+public class FlipDrawable extends Drawable implements Drawable.Callback {
+
+    /**
+     * The inner drawables.
+     */
+    protected final Drawable mFront;
+    protected final Drawable mBack;
+
+    protected final int mFlipDurationMs;
+    protected final int mPreFlipDurationMs;
+    protected final int mPostFlipDurationMs;
+    private final ValueAnimator mFlipAnimator;
+
+    private static final float END_VALUE = 2f;
+
+    /**
+     * From 0f to END_VALUE. Determines the flip progress between mFront and mBack. 0f means
+     * mFront is fully shown, while END_VALUE means mBack is fully shown.
+     */
+    private float mFlipFraction = 0f;
+
+    /**
+     * True if flipping towards front, false if flipping towards back.
+     */
+    private boolean mFlipToSide = true;
+
+    /**
+     * Create a new FlipDrawable. The front is fully shown by default.
+     *
+     * <p/>
+     * The 3 durations are: (best viewed in documentation form)
+     * <pre>
+     * &lt;pre&gt;[_][]|[][_]&lt;post&gt;
+     *   |       |       |
+     *   V       V       V
+     * &lt;pre>&lt;   flip  &gt;&lt;post&gt;
+     * </pre>
+     *
+     * @param front              The front drawable.
+     * @param back               The back drawable.
+     * @param flipDurationMs     The duration of the actual flip. This duration includes both
+     *                           animating away one side and showing the other.
+     * @param preFlipDurationMs  The duration before the actual flip begins. Subclasses can use this
+     *                           to add flourish.
+     * @param postFlipDurationMs The duration after the actual flip begins. Subclasses can use this
+     *                           to add flourish.
+     */
+    public FlipDrawable(final Drawable front, final Drawable back, final int flipDurationMs,
+            final int preFlipDurationMs, final int postFlipDurationMs) {
+        if (front == null || back == null) {
+            throw new IllegalArgumentException("Front and back drawables must not be null.");
+        }
+        mFront = front;
+        mBack = back;
+
+        mFront.setCallback(this);
+        mBack.setCallback(this);
+
+        mFlipDurationMs = flipDurationMs;
+        mPreFlipDurationMs = preFlipDurationMs;
+        mPostFlipDurationMs = postFlipDurationMs;
+
+        mFlipAnimator = ValueAnimator.ofFloat(0f, END_VALUE)
+                .setDuration(mPreFlipDurationMs + mFlipDurationMs + mPostFlipDurationMs);
+        mFlipAnimator.addUpdateListener(new AnimatorUpdateListener() {
+            @Override
+            public void onAnimationUpdate(final ValueAnimator animation) {
+                final float old = mFlipFraction;
+                //noinspection ConstantConditions
+                mFlipFraction = (Float) animation.getAnimatedValue();
+                if (old != mFlipFraction) {
+                    invalidateSelf();
+                }
+            }
+        });
+
+        reset(true);
+    }
+
+    @Override
+    protected void onBoundsChange(final Rect bounds) {
+        super.onBoundsChange(bounds);
+        if (bounds.isEmpty()) {
+            mFront.setBounds(0, 0, 0, 0);
+            mBack.setBounds(0, 0, 0, 0);
+        } else {
+            mFront.setBounds(bounds);
+            mBack.setBounds(bounds);
+        }
+    }
+
+    @Override
+    public void draw(final Canvas canvas) {
+        final Rect bounds = getBounds();
+        if (!isVisible() || bounds.isEmpty()) {
+            return;
+        }
+
+        final Drawable inner = getSideShown() /* == front */ ? mFront : mBack;
+
+        final float totalDurationMs = mPreFlipDurationMs + mFlipDurationMs + mPostFlipDurationMs;
+
+        final float scaleX;
+        if (mFlipFraction / 2 <= mPreFlipDurationMs / totalDurationMs) {
+            // During pre-flip.
+            scaleX = 1;
+        } else if (mFlipFraction / 2 >= (totalDurationMs - mPostFlipDurationMs) / totalDurationMs) {
+            // During post-flip.
+            scaleX = 1;
+        } else {
+            // During flip.
+            final float flipFraction = mFlipFraction / 2;
+            final float flipMiddle = (mPreFlipDurationMs / totalDurationMs
+                    + (totalDurationMs - mPostFlipDurationMs) / totalDurationMs) / 2;
+            final float distFraction = Math.abs(flipFraction - flipMiddle);
+            final float multiplier = 1 / (flipMiddle - (mPreFlipDurationMs / totalDurationMs));
+            scaleX = distFraction * multiplier;
+        }
+
+        canvas.save();
+        // The flip is a simple 1 dimensional scale.
+        canvas.scale(scaleX, 1, bounds.exactCenterX(), bounds.exactCenterY());
+        inner.draw(canvas);
+        canvas.restore();
+    }
+
+    @Override
+    public void setAlpha(final int alpha) {
+        mFront.setAlpha(alpha);
+        mBack.setAlpha(alpha);
+    }
+
+    @Override
+    public void setColorFilter(final ColorFilter cf) {
+        mFront.setColorFilter(cf);
+        mBack.setColorFilter(cf);
+    }
+
+    @Override
+    public int getOpacity() {
+        return resolveOpacity(mFront.getOpacity(), mBack.getOpacity());
+    }
+
+    @Override
+    protected boolean onLevelChange(final int level) {
+        return mFront.setLevel(level) || mBack.setLevel(level);
+    }
+
+    @Override
+    public void invalidateDrawable(final Drawable who) {
+        invalidateSelf();
+    }
+
+    @Override
+    public void scheduleDrawable(final Drawable who, final Runnable what, final long when) {
+        scheduleSelf(what, when);
+    }
+
+    @Override
+    public void unscheduleDrawable(final Drawable who, final Runnable what) {
+        unscheduleSelf(what);
+    }
+
+    /**
+     * Stop animating the flip and reset to one side.
+     * @param side Pass true if reset to front, false if reset to back.
+     */
+    public void reset(final boolean side) {
+        final float old = mFlipFraction;
+        mFlipAnimator.cancel();
+        mFlipFraction = side ? 0f : 2f;
+        mFlipToSide = side;
+        if (mFlipFraction != old) {
+            invalidateSelf();
+        }
+    }
+
+    /**
+     * Returns true if the front is shown. Returns false if the back is shown.
+     */
+    public boolean getSideShown() {
+        final float totalDurationMs = mPreFlipDurationMs + mFlipDurationMs + mPostFlipDurationMs;
+        final float middleFraction = (mPreFlipDurationMs / totalDurationMs
+                + (totalDurationMs - mPostFlipDurationMs) / totalDurationMs) / 2;
+        return mFlipFraction / 2 < middleFraction;
+    }
+
+    /**
+     * Returns true if the front is being flipped towards. Returns false if the back is being
+     * flipped towards.
+     */
+    public boolean getSideFlippingTowards() {
+        return mFlipToSide;
+    }
+
+    /**
+     * Starts an animated flip to the other side. If a flip animation is currently started,
+     * it will be reversed.
+     */
+    public void flip() {
+        mFlipToSide = !mFlipToSide;
+        if (mFlipAnimator.isStarted()) {
+            mFlipAnimator.reverse();
+        } else {
+            if (!mFlipToSide /* front to back */) {
+                mFlipAnimator.start();
+            } else /* back to front */ {
+                mFlipAnimator.reverse();
+            }
+        }
+    }
+
+    /**
+     * Start an animated flip to a side. This works regardless of whether a flip animation is
+     * currently started.
+     * @param side Pass true if flip to front, false if flip to back.
+     */
+    public void flipTo(final boolean side) {
+        if (mFlipToSide != side) {
+            flip();
+        }
+    }
+
+    /**
+     * Returns whether flipping is in progress.
+     */
+    public boolean isFlipping() {
+        return mFlipAnimator.isStarted();
+    }
+}
diff --git a/src/com/android/mail/browse/ConversationItemView.java b/src/com/android/mail/browse/ConversationItemView.java
index 26c2241..06613e3 100644
--- a/src/com/android/mail/browse/ConversationItemView.java
+++ b/src/com/android/mail/browse/ConversationItemView.java
@@ -18,8 +18,6 @@
 package com.android.mail.browse;
 
 import android.animation.Animator;
-import android.animation.Animator.AnimatorListener;
-import android.animation.AnimatorListenerAdapter;
 import android.animation.AnimatorSet;
 import android.animation.ObjectAnimator;
 import android.content.ClipData;
@@ -31,7 +29,6 @@
 import android.graphics.Canvas;
 import android.graphics.Color;
 import android.graphics.LinearGradient;
-import android.graphics.Matrix;
 import android.graphics.Paint;
 import android.graphics.Point;
 import android.graphics.Rect;
@@ -60,7 +57,6 @@
 import android.view.ViewGroup;
 import android.view.ViewParent;
 import android.view.animation.DecelerateInterpolator;
-import android.view.animation.LinearInterpolator;
 import android.widget.AbsListView;
 import android.widget.AbsListView.OnScrollListener;
 import android.widget.TextView;
@@ -72,11 +68,10 @@
 import com.android.mail.analytics.Analytics;
 import com.android.mail.bitmap.AttachmentDrawable;
 import com.android.mail.bitmap.AttachmentGridDrawable;
+import com.android.mail.bitmap.ContactCheckableGridDrawable;
+import com.android.mail.bitmap.ContactDrawable;
 import com.android.mail.browse.ConversationItemViewModel.SenderFragment;
 import com.android.mail.perf.Timer;
-import com.android.mail.photomanager.ContactPhotoManager;
-import com.android.mail.photomanager.ContactPhotoManager.ContactIdentifier;
-import com.android.mail.photomanager.PhotoManager.PhotoIdentifier;
 import com.android.mail.providers.Address;
 import com.android.mail.providers.Attachment;
 import com.android.mail.providers.Conversation;
@@ -87,9 +82,9 @@
 import com.android.mail.providers.UIProvider.ConversationListIcon;
 import com.android.mail.providers.UIProvider.FolderType;
 import com.android.mail.ui.AnimatedAdapter;
-import com.android.mail.ui.AnimatedAdapter.ConversationListListener;
 import com.android.mail.ui.ControllableActivity;
 import com.android.mail.ui.ConversationSelectionSet;
+import com.android.mail.ui.ConversationSetObserver;
 import com.android.mail.ui.DividedImageCanvas;
 import com.android.mail.ui.DividedImageCanvas.InvalidateCallback;
 import com.android.mail.ui.FolderDisplayer;
@@ -102,13 +97,12 @@
 import com.android.mail.utils.LogUtils;
 import com.android.mail.utils.Utils;
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.Lists;
 
 import java.util.ArrayList;
-import java.util.List;
 
 public class ConversationItemView extends View
-        implements SwipeableItemView, ToggleableItem, InvalidateCallback, OnScrollListener {
+        implements SwipeableItemView, ToggleableItem, InvalidateCallback, OnScrollListener,
+        ConversationSetObserver {
 
     // Timer.
     private static int sLayoutCount = 0;
@@ -125,7 +119,6 @@
     // Static bitmaps.
     private static Bitmap STAR_OFF;
     private static Bitmap STAR_ON;
-    private static Bitmap CHECK;
     private static Bitmap ATTACHMENT;
     private static Bitmap ONLY_TO_ME;
     private static Bitmap TO_ME_AND_OTHERS;
@@ -201,15 +194,12 @@
     private float mAnimatedHeightFraction = 1.0f;
     private final String mAccount;
     private ControllableActivity mActivity;
-    private ConversationListListener mConversationListListener;
     private final TextView mSubjectTextView;
     private final TextView mSendersTextView;
     private int mGadgetMode;
     private boolean mAttachmentPreviewsEnabled;
     private boolean mParallaxSpeedAlternative;
     private boolean mParallaxDirectionAlternative;
-    private final DividedImageCanvas mContactImagesHolder;
-    private static ContactPhotoManager sContactPhotoManager;
 
     private static int sFoldersLeftPadding;
     private static TextAppearanceSpan sSubjectTextUnreadSpan;
@@ -219,19 +209,9 @@
     private static int sScrollSlop;
     private static CharacterStyle sActivatedTextSpan;
 
+    private final ContactCheckableGridDrawable mSendersImageView;
     private final AttachmentGridDrawable mAttachmentsView;
 
-    private final Matrix mPhotoFlipMatrix = new Matrix();
-    private final Matrix mCheckMatrix = new Matrix();
-
-    private final CabAnimator mPhotoFlipAnimator;
-
-    /**
-     * The conversation id, if this conversation was selected the last time we were in a selection
-     * mode. This is reset after any animations complete upon exiting the selection mode.
-     */
-    private long mLastSelectedId = -1;
-
     /** The resource id of the color to use to override the background. */
     private int mBackgroundOverrideResId = -1;
     /** The bitmap to use, or <code>null</code> for the default */
@@ -259,19 +239,6 @@
         sCheckBackgroundPaint.setColor(Color.GRAY);
     }
 
-    public static void setScrollStateChanged(final int scrollState) {
-        if (sContactPhotoManager == null) {
-            return;
-        }
-        final boolean flinging = scrollState == OnScrollListener.SCROLL_STATE_FLING;
-
-        if (flinging) {
-            sContactPhotoManager.pause();
-        } else {
-            sContactPhotoManager.resume();
-        }
-    }
-
     /**
      * Handles displaying folders in a conversation header view.
      */
@@ -422,7 +389,6 @@
             // Initialize static bitmaps.
             STAR_OFF = BitmapFactory.decodeResource(res, R.drawable.ic_btn_star_off);
             STAR_ON = BitmapFactory.decodeResource(res, R.drawable.ic_btn_star_on);
-            CHECK = BitmapFactory.decodeResource(res, R.drawable.ic_avatar_check);
             ATTACHMENT = BitmapFactory.decodeResource(res, R.drawable.ic_attachment_holo_light);
             ONLY_TO_ME = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_double);
             TO_ME_AND_OTHERS = BitmapFactory.decodeResource(res, R.drawable.ic_email_caret_single);
@@ -469,24 +435,10 @@
             sOverflowCountFormat = res.getString(string.ap_overflow_format);
             sScrollSlop = res.getInteger(R.integer.swipeScrollSlop);
             sFoldersLeftPadding = res.getDimensionPixelOffset(R.dimen.folders_left_padding);
-            sContactPhotoManager = ContactPhotoManager.createContactPhotoManager(context);
             sOverflowCountMax = res.getInteger(integer.ap_overflow_max_count);
-            sCabAnimationDuration =
-                    res.getInteger(R.integer.conv_item_view_cab_anim_duration);
+            sCabAnimationDuration = res.getInteger(R.integer.conv_item_view_cab_anim_duration);
         }
 
-        mPhotoFlipAnimator = new CabAnimator("photoFlipFraction", 0, 2,
-                sCabAnimationDuration) {
-            @Override
-            public void invalidateArea() {
-                final int left = mCoordinates.contactImagesX;
-                final int right = left + mContactImagesHolder.getWidth();
-                final int top = mCoordinates.contactImagesY;
-                final int bottom = top + mContactImagesHolder.getHeight();
-                invalidate(left, top, right, bottom);
-            }
-        };
-
         mSendersTextView = new TextView(mContext);
         mSendersTextView.setIncludeFontPadding(false);
 
@@ -494,27 +446,16 @@
         mSubjectTextView.setEllipsize(TextUtils.TruncateAt.END);
         mSubjectTextView.setIncludeFontPadding(false);
 
-        mContactImagesHolder = new DividedImageCanvas(context, new InvalidateCallback() {
-            @Override
-            public void invalidate() {
-                if (mCoordinates == null) {
-                    return;
-                }
-                ConversationItemView.this.invalidate(mCoordinates.contactImagesX,
-                        mCoordinates.contactImagesY,
-                        mCoordinates.contactImagesX + mCoordinates.contactImagesWidth,
-                        mCoordinates.contactImagesY + mCoordinates.contactImagesHeight);
-            }
-        });
-
         mAttachmentsView = new AttachmentGridDrawable(res, PLACEHOLDER, PROGRESS_BAR);
         mAttachmentsView.setCallback(this);
 
+        mSendersImageView = new ContactCheckableGridDrawable(res, sCabAnimationDuration);
+        mSendersImageView.setCallback(this);
+
         Utils.traceEndSection();
     }
 
     public void bind(final Conversation conversation, final ControllableActivity activity,
-            final ConversationListListener conversationListListener,
             final ConversationSelectionSet set, final Folder folder,
             final int checkboxOrSenderImage, final boolean showAttachmentPreviews,
             final boolean parallaxSpeedAlternative, final boolean parallaxDirectionAlternative,
@@ -522,31 +463,28 @@
             final AnimatedAdapter adapter) {
         Utils.traceBeginSection("CIVC.bind");
         bind(ConversationItemViewModel.forConversation(mAccount, conversation), activity,
-                conversationListListener, null /* conversationItemAreaClickListener */, set, folder,
-                checkboxOrSenderImage, showAttachmentPreviews, parallaxSpeedAlternative,
-                parallaxDirectionAlternative, swipeEnabled, priorityArrowEnabled, adapter,
-                -1 /* backgroundOverrideResId */,
-                null /* photoBitmap */);
+                null /* conversationItemAreaClickListener */,
+                set, folder, checkboxOrSenderImage, showAttachmentPreviews,
+                parallaxSpeedAlternative, parallaxDirectionAlternative, swipeEnabled,
+                priorityArrowEnabled, adapter, -1 /* backgroundOverrideResId */, null /* photoBitmap */);
         Utils.traceEndSection();
     }
 
     public void bindAd(final ConversationItemViewModel conversationItemViewModel,
             final ControllableActivity activity,
-            final ConversationListListener conversationListListener,
             final ConversationItemAreaClickListener conversationItemAreaClickListener,
             final Folder folder, final int checkboxOrSenderImage, final AnimatedAdapter adapter,
             final int backgroundOverrideResId, final Bitmap photoBitmap) {
         Utils.traceBeginSection("CIVC.bindAd");
-        bind(conversationItemViewModel, activity, conversationListListener,
-                conversationItemAreaClickListener, null /* set */, folder, checkboxOrSenderImage,
-                false /* attachment previews */, false /* parallax */, false /* parallax */,
-                true /* swipeEnabled */, false /* priorityArrowEnabled */, adapter,
-                backgroundOverrideResId, photoBitmap);
+        bind(conversationItemViewModel, activity, conversationItemAreaClickListener, null /* set */,
+                folder, checkboxOrSenderImage, false /* attachment previews */,
+                false /* parallax */, false /* parallax */, true /* swipeEnabled */,
+                false /* priorityArrowEnabled */,
+                adapter, backgroundOverrideResId, photoBitmap);
         Utils.traceEndSection();
     }
 
     private void bind(final ConversationItemViewModel header, final ControllableActivity activity,
-            final ConversationListListener conversationListListener,
             final ConversationItemAreaClickListener conversationItemAreaClickListener,
             final ConversationSelectionSet set, final Folder folder,
             final int checkboxOrSenderImage, final boolean showAttachmentPreviews,
@@ -558,28 +496,25 @@
         mConversationItemAreaClickListener = conversationItemAreaClickListener;
 
         if (mHeader != null) {
+            Utils.traceBeginSection("unbind");
+            final boolean newlyBound = header.conversation.id != mHeader.conversation.id;
             // If this was previously bound to a different conversation, remove any contact photo
             // manager requests.
-            if (header.conversation.id != mHeader.conversation.id ||
-                    (mHeader.displayableSenderNames != null && !mHeader.displayableSenderNames
-                    .equals(header.displayableSenderNames))) {
-                ArrayList<String> divisionIds = mContactImagesHolder.getDivisionIds();
-                if (divisionIds != null) {
-                    mContactImagesHolder.reset();
-                    for (int pos = 0; pos < divisionIds.size(); pos++) {
-                        sContactPhotoManager.removePhoto(ContactPhotoManager.generateHash(
-                                mContactImagesHolder, pos, divisionIds.get(pos)));
-                    }
+            if (newlyBound || (mHeader.displayableSenderNames != null && !mHeader
+                    .displayableSenderNames.equals(
+                            header.displayableSenderNames))) {
+                for (int i = 0; i < mSendersImageView.getCount(); i++) {
+                    mSendersImageView.getOrCreateDrawable(i).unbind();
                 }
+                mSendersImageView.setCount(0);
             }
 
             // If this was previously bound to a different conversation,
             // remove any attachment preview manager requests.
-            if (header.conversation.id != mHeader.conversation.id
-                    || header.conversation.attachmentPreviewsCount
-                            != mHeader.conversation.attachmentPreviewsCount
-                    || !header.conversation.getAttachmentPreviewUris()
-                            .equals(mHeader.conversation.getAttachmentPreviewUris())) {
+            if (newlyBound || header.conversation.attachmentPreviewsCount
+                    != mHeader.conversation.attachmentPreviewsCount || !header.conversation
+                    .getAttachmentPreviewUris().equals(
+                            mHeader.conversation.getAttachmentPreviewUris())) {
 
                 // unbind the attachments view (releasing bitmap references)
                 // (this also cancels all async tasks)
@@ -590,22 +525,29 @@
                 mAttachmentsView.setCount(0);
             }
 
-            if (header.conversation.id != mHeader.conversation.id) {
+            if (newlyBound) {
                 // Stop the photo flip animation
-                mPhotoFlipAnimator.stopAnimation();
+                final boolean showSenders = !isSelected();
+                mSendersImageView.reset(showSenders);
             }
+            Utils.traceEndSection();
         }
         mCoordinates = null;
         mHeader = header;
         mActivity = activity;
-        mConversationListListener = conversationListListener;
         mSelectedConversationSet = set;
+        mSelectedConversationSet.addObserver(this);
         mDisplayedFolder = folder;
         mStarEnabled = folder != null && !folder.isTrash();
         mSwipeEnabled = swipeEnabled;
         mAdapter = adapter;
-        mAttachmentsView.setBitmapCache(mAdapter.getBitmapCache());
-        mAttachmentsView.setDecodeAggregator(mAdapter.getDecodeAggregator());
+
+        Utils.traceBeginSection("drawables");
+        mAttachmentsView.setBitmapCache(mAdapter.getAttachmentPreviewsCache());
+        mAttachmentsView.setDecodeAggregator(mAdapter.getAttachmentPreviewsDecodeAggregator());
+        mSendersImageView.setBitmapCache(mAdapter.getSendersImagesCache());
+        mSendersImageView.setContactResolver(mAdapter.getContactResolver());
+        Utils.traceEndSection();
 
         if (checkboxOrSenderImage == ConversationListIcon.SENDER_IMAGE) {
             mGadgetMode = ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO;
@@ -617,12 +559,14 @@
         mParallaxSpeedAlternative = parallaxSpeedAlternative;
         mParallaxDirectionAlternative = parallaxDirectionAlternative;
 
+        Utils.traceBeginSection("folder displayer");
         // Initialize folder displayer.
         if (mHeader.folderDisplayer == null) {
             mHeader.folderDisplayer = new ConversationItemFolderDisplayer(mContext);
         } else {
             mHeader.folderDisplayer.reset();
         }
+        Utils.traceEndSection();
 
         final int ignoreFolderType;
         if (mDisplayedFolder.isInbox()) {
@@ -631,16 +575,21 @@
             ignoreFolderType = -1;
         }
 
+        Utils.traceBeginSection("load folders");
         mHeader.folderDisplayer.loadConversationFolders(mHeader.conversation,
                 mDisplayedFolder.folderUri, ignoreFolderType);
+        Utils.traceEndSection();
 
         if (mHeader.dateOverrideText == null) {
+            Utils.traceBeginSection("relative time");
             mHeader.dateText = DateUtils.getRelativeTimeSpanString(mContext,
                     mHeader.conversation.dateMs);
+            Utils.traceEndSection();
         } else {
             mHeader.dateText = mHeader.dateOverrideText;
         }
 
+        Utils.traceBeginSection("config setup");
         mConfig = new ConversationItemViewCoordinates.Config()
             .withGadget(mGadgetMode)
             .withAttachmentPreviews(getAttachmentPreviewsMode());
@@ -653,6 +602,7 @@
         if (mHeader.conversation.color != 0) {
             mConfig.showColorBlock();
         }
+
         // Personal level.
         mHeader.personalLevelBitmap = null;
         if (true) { // TODO: hook this up to a setting
@@ -674,19 +624,23 @@
         if (mHeader.personalLevelBitmap != null) {
             mConfig.showPersonalIndicator();
         }
+        Utils.traceEndSection();
 
+        Utils.traceBeginSection("overflow");
         final int overflowCount = Math.min(getOverflowCount(), sOverflowCountMax);
         mHeader.overflowText = (overflowCount > 0) ?
                 String.format(sOverflowCountFormat, overflowCount) : null;
-
         mAttachmentsView.setOverflowText(mHeader.overflowText);
+        Utils.traceEndSection();
 
+        Utils.traceBeginSection("content description");
         setContentDescription();
+        Utils.traceEndSection();
         requestLayout();
     }
 
     @Override
-    public void invalidateDrawable(Drawable who) {
+    public void invalidateDrawable(final Drawable who) {
         boolean handled = false;
         if (mCoordinates != null) {
             if (mAttachmentsView.equals(who)) {
@@ -694,6 +648,11 @@
                 r.offset(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY);
                 ConversationItemView.this.invalidate(r.left, r.top, r.right, r.bottom);
                 handled = true;
+            } else if (mSendersImageView.equals(who)) {
+                final Rect r = new Rect(who.getBounds());
+                r.offset(mCoordinates.contactImagesX, mCoordinates.contactImagesY);
+                ConversationItemView.this.invalidate(r.left, r.top, r.right, r.bottom);
+                handled = true;
             }
         }
         if (!handled) {
@@ -767,12 +726,14 @@
         Utils.traceEndSection();
 
         // Subject.
+        Utils.traceBeginSection("subject");
         createSubject(mHeader.unread);
 
         if (!mHeader.isLayoutValid()) {
             setContentDescription();
         }
         mHeader.validate();
+        Utils.traceEndSection();
 
         pauseTimer(PERF_TAG_LAYOUT);
         if (sTimer != null && ++sLayoutCount >= PERF_LAYOUT_ITERATIONS) {
@@ -922,34 +883,36 @@
     // FIXME(ath): maybe move this to bind(). the only dependency on layout is on tile W/H, which
     // is immutable.
     private void loadSenderImages() {
-        if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO
-                && mHeader.displayableSenderEmails != null
-                && mHeader.displayableSenderEmails.size() > 0) {
-            if (mCoordinates.contactImagesWidth <= 0 || mCoordinates.contactImagesHeight <= 0) {
-                LogUtils.w(LOG_TAG,
-                        "Contact image width(%d) or height(%d) is 0 for mode: (%d).",
-                        mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight,
-                        mCoordinates.getMode());
-                return;
-            }
-
-            int size = mHeader.displayableSenderEmails.size();
-            final List<Object> keys = Lists.newArrayListWithCapacity(size);
-            for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < size; i++) {
-                keys.add(mHeader.displayableSenderEmails.get(i));
-            }
-
-            mContactImagesHolder.setDimensions(mCoordinates.contactImagesWidth,
-                    mCoordinates.contactImagesHeight);
-            mContactImagesHolder.setDivisionIds(keys);
-            String emailAddress;
-            for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < size; i++) {
-                emailAddress = mHeader.displayableSenderEmails.get(i);
-                PhotoIdentifier photoIdentifier = new ContactIdentifier(
-                        mHeader.displayableSenderNames.get(i), emailAddress, i);
-                sContactPhotoManager.loadThumbnail(photoIdentifier, mContactImagesHolder);
-            }
+        if (mGadgetMode != ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO
+                || mHeader.displayableSenderEmails == null
+                || mHeader.displayableSenderEmails.size() <= 0) {
+            return;
         }
+        if (mCoordinates.contactImagesWidth <= 0 || mCoordinates.contactImagesHeight <= 0) {
+            LogUtils.w(LOG_TAG,
+                    "Contact image width(%d) or height(%d) is 0 for mode: (%d).",
+                    mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight,
+                    mCoordinates.getMode());
+            return;
+        }
+
+        Utils.traceBeginSection("load sender images");
+        final int count = mHeader.displayableSenderEmails.size();
+
+        mSendersImageView.setCount(count);
+        mSendersImageView
+                .setBounds(0, 0, mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight);
+
+        for (int i = 0; i < DividedImageCanvas.MAX_DIVISIONS && i < count; i++) {
+            Utils.traceBeginSection("load single sender image");
+            final ContactDrawable drawable = mSendersImageView.getOrCreateDrawable(i);
+            drawable.setDecodeDimensions(mCoordinates.contactImagesWidth,
+                    mCoordinates.contactImagesHeight);
+            drawable.bind(mHeader.displayableSenderNames.get(i),
+                    mHeader.displayableSenderEmails.get(i));
+            Utils.traceEndSection();
+        }
+        Utils.traceEndSection();
     }
 
     private void loadAttachmentPreviews() {
@@ -1397,7 +1360,9 @@
         // Contact photo
         if (mGadgetMode == ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO) {
             canvas.save();
-            drawContactImageArea(canvas);
+            Utils.traceBeginSection("draw senders image");
+            drawSendersImage(canvas);
+            Utils.traceEndSection();
             canvas.restore();
         }
 
@@ -1506,113 +1471,19 @@
         Utils.traceEndSection();
     }
 
-    /**
-     * Draws the contact images or check, in the correct animated state.
-     */
-    private void drawContactImageArea(final Canvas canvas) {
-        if (isSelected()) {
-            mLastSelectedId = mHeader.conversation.id;
-
-            // Since this is selected, we draw the checkbox if the animation is not running, or if
-            // it's running, and is past the half-way point
-            if (mPhotoFlipAnimator.getValue() > 1 || !mPhotoFlipAnimator.isStarted()) {
-                // Flash in the check
-                drawCheckbox(canvas);
-            } else {
-                // Flip out the contact photo
-                drawContactImages(canvas);
-            }
-        } else {
-            if ((mConversationListListener.isExitingSelectionMode()
-                    && mLastSelectedId == mHeader.conversation.id)
-                    || mPhotoFlipAnimator.isStarted()) {
-                // Animate back to the photo
-                if (!mPhotoFlipAnimator.isStarted()) {
-                    mPhotoFlipAnimator.startAnimation(true /* reverse */);
-                }
-
-                if (mPhotoFlipAnimator.getValue() > 1) {
-                    // Flash out the check
-                    drawCheckbox(canvas);
-                } else {
-                    // Flip in the contact photo
-                    drawContactImages(canvas);
-                }
-            } else {
-                mLastSelectedId = -1; // We don't care anymore
-                mPhotoFlipAnimator.stopAnimation(); // It's not running, but we want to reset state
-
-                // Contact photos
-                drawContactImages(canvas);
-            }
+    private void drawSendersImage(final Canvas canvas) {
+        if (!mSendersImageView.isFlipping()) {
+            final boolean showSenders = !isSelected();
+            mSendersImageView.reset(showSenders);
         }
-    }
-
-    private void drawContactImages(final Canvas canvas) {
-        // mPhotoFlipFraction goes from 0 to 1
-        final float value = mPhotoFlipAnimator.getValue();
-
-        final float scale = 1f - value;
-        final float xOffset = mContactImagesHolder.getWidth() * value / 2;
-
-        mPhotoFlipMatrix.reset();
-        mPhotoFlipMatrix.postScale(scale, 1);
-
-        final float x = mCoordinates.contactImagesX + xOffset;
-        final float y = mCoordinates.contactImagesY;
-
-        canvas.translate(x, y);
-
+        canvas.translate(mCoordinates.contactImagesX, mCoordinates.contactImagesY);
         if (mPhotoBitmap == null) {
-            mContactImagesHolder.draw(canvas, mPhotoFlipMatrix);
+            mSendersImageView.draw(canvas);
         } else {
             canvas.drawBitmap(mPhotoBitmap, null, mPhotoRect, sPaint);
         }
     }
 
-    private void drawCheckbox(final Canvas canvas) {
-        // mPhotoFlipFraction goes from 1 to 2
-
-        // Draw the background
-        canvas.save();
-        canvas.translate(mCoordinates.contactImagesX, mCoordinates.contactImagesY);
-        canvas.drawRect(0, 0, mCoordinates.contactImagesWidth, mCoordinates.contactImagesHeight,
-                sCheckBackgroundPaint);
-        canvas.restore();
-
-        final int x = mCoordinates.contactImagesX
-                + (mCoordinates.contactImagesWidth - CHECK.getWidth()) / 2;
-        final int y = mCoordinates.contactImagesY
-                + (mCoordinates.contactImagesHeight - CHECK.getHeight()) / 2;
-
-        final float value = mPhotoFlipAnimator.getValue();
-        final float scale;
-
-        if (!mPhotoFlipAnimator.isStarted()) {
-            // We're not animating
-            scale = 1;
-        } else if (value < 1.9) {
-            // 1.0 to 1.9 will scale 0 to 1
-            scale = (value - 1f) / 0.9f;
-        } else if (value < 1.95) {
-            // 1.9 to 1.95 will scale 1 to 19/18
-            scale = (value - 1f) / 0.9f;
-        } else {
-            // 1.95 to 2.0 will scale 19/18 to 1
-            scale = (0.95f - (value - 1.95f)) / 0.9f;
-        }
-
-        final float xOffset = CHECK.getWidth() * (1f - scale) / 2f;
-        final float yOffset = CHECK.getHeight() * (1f - scale) / 2f;
-
-        mCheckMatrix.reset();
-        mCheckMatrix.postScale(scale, scale);
-
-        canvas.translate(x + xOffset, y + yOffset);
-
-        canvas.drawBitmap(CHECK, mCheckMatrix, sPaint);
-    }
-
     private void drawAttachmentPreviews(Canvas canvas) {
         canvas.translate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY);
         final float fraction;
@@ -1691,13 +1562,13 @@
         return toggleSelectedState(null);
     }
 
-    private boolean toggleSelectedState(String sourceOpt) {
+    private boolean toggleSelectedState(final String sourceOpt) {
         if (mHeader != null && mHeader.conversation != null && mSelectedConversationSet != null) {
             mSelected = !mSelected;
             setSelected(mSelected);
-            Conversation conv = mHeader.conversation;
+            final Conversation conv = mHeader.conversation;
             // Set the list position of this item in the conversation
-            SwipeableListView listView = getListView();
+            final SwipeableListView listView = getListView();
 
             try {
                 conv.position = mSelected && listView != null ? listView.getPositionForView(this)
@@ -1716,9 +1587,8 @@
                 listView.commitDestructiveActions(true);
             }
 
-            final boolean reverse = !mSelected;
-            mPhotoFlipAnimator.startAnimation(reverse);
-            mPhotoFlipAnimator.invalidateArea();
+            final boolean front = !mSelected;
+            mSendersImageView.flipTo(front);
 
             // We update the background after the checked state has changed
             // now that we have a selected background asset. Setting the background
@@ -1732,6 +1602,17 @@
         return false;
     }
 
+    @Override
+    public void onSetEmpty() {
+        mSendersImageView.flipTo(true);
+    }
+
+    @Override
+    public void onSetPopulated(final ConversationSelectionSet set) { }
+
+    @Override
+    public void onSetChanged(final ConversationSelectionSet set) { }
+
     /**
      * Toggle the star on this view and update the conversation.
      */
@@ -2179,124 +2060,6 @@
         return sScrollSlop;
     }
 
-    private abstract class CabAnimator {
-        private ObjectAnimator mAnimator = null;
-
-        private final String mPropertyName;
-
-        private float mValue;
-
-        private final float mStartValue;
-        private final float mEndValue;
-
-        private final long mDuration;
-
-        private boolean mReversing = false;
-
-        public CabAnimator(final String propertyName, final float startValue, final float endValue,
-                final long duration) {
-            mPropertyName = propertyName;
-
-            mStartValue = startValue;
-            mEndValue = endValue;
-
-            mDuration = duration;
-        }
-
-        private ObjectAnimator createAnimator() {
-            final ObjectAnimator animator = ObjectAnimator.ofFloat(ConversationItemView.this,
-                    mPropertyName, mStartValue, mEndValue);
-            animator.setDuration(mDuration);
-            animator.setInterpolator(new LinearInterpolator());
-            animator.addListener(new AnimatorListenerAdapter() {
-                @Override
-                public void onAnimationEnd(final Animator animation) {
-                    invalidateArea();
-                }
-            });
-            animator.addListener(mAnimatorListener);
-            return animator;
-        }
-
-        private final AnimatorListener mAnimatorListener = new AnimatorListener() {
-            @Override
-            public void onAnimationStart(final Animator animation) {
-                // Do nothing
-            }
-
-            @Override
-            public void onAnimationEnd(final Animator animation) {
-                if (mReversing) {
-                    mReversing = false;
-                    // We no longer want to track whether we were last selected,
-                    // since we no longer are selected
-                    mLastSelectedId = -1;
-                }
-            }
-
-            @Override
-            public void onAnimationCancel(final Animator animation) {
-                // Do nothing
-            }
-
-            @Override
-            public void onAnimationRepeat(final Animator animation) {
-                // Do nothing
-            }
-        };
-
-        public abstract void invalidateArea();
-
-        public void setValue(final float fraction) {
-            if (mValue == fraction) {
-                return;
-            }
-            mValue = fraction;
-            invalidateArea();
-        }
-
-        public float getValue() {
-            return mValue;
-        }
-
-        /**
-         * @param reverse <code>true</code> to animate in reverse
-         */
-        public void startAnimation(final boolean reverse) {
-            if (mAnimator != null) {
-                mAnimator.cancel();
-            }
-
-            mAnimator = createAnimator();
-            mReversing = reverse;
-
-            if (reverse) {
-                mAnimator.reverse();
-            } else {
-                mAnimator.start();
-            }
-        }
-
-        public void stopAnimation() {
-            if (mAnimator != null) {
-                mAnimator.cancel();
-                mAnimator = null;
-            }
-
-            mReversing = false;
-
-            setValue(0);
-        }
-
-        public boolean isStarted() {
-            return mAnimator != null && mAnimator.isStarted();
-        }
-    }
-
-    public void setPhotoFlipFraction(final float fraction) {
-        mPhotoFlipAnimator.setValue(fraction);
-    }
-
     public String getAccount() {
         return mAccount;
     }
diff --git a/src/com/android/mail/browse/SwipeableConversationItemView.java b/src/com/android/mail/browse/SwipeableConversationItemView.java
index cd17c2f..919a5aa 100644
--- a/src/com/android/mail/browse/SwipeableConversationItemView.java
+++ b/src/com/android/mail/browse/SwipeableConversationItemView.java
@@ -28,7 +28,6 @@
 import com.android.mail.providers.Conversation;
 import com.android.mail.providers.Folder;
 import com.android.mail.ui.AnimatedAdapter;
-import com.android.mail.ui.AnimatedAdapter.ConversationListListener;
 import com.android.mail.ui.ControllableActivity;
 import com.android.mail.ui.ConversationSelectionSet;
 
@@ -56,15 +55,14 @@
     }
 
     public void bind(final Conversation conversation, final ControllableActivity activity,
-            final ConversationListListener conversationListListener,
             final ConversationSelectionSet set, final Folder folder,
             final int checkboxOrSenderImage, final boolean showAttachmentPreviews,
             final boolean parallaxSpeedAlternative, final boolean parallaxDirectionAlternative,
             final boolean swipeEnabled, final boolean priorityArrowsEnabled,
             final AnimatedAdapter animatedAdapter) {
-        mConversationItemView.bind(conversation, activity, conversationListListener, set, folder,
-                checkboxOrSenderImage, showAttachmentPreviews, parallaxSpeedAlternative,
-                parallaxDirectionAlternative, swipeEnabled, priorityArrowsEnabled, animatedAdapter);
+        mConversationItemView.bind(conversation, activity, set, folder, checkboxOrSenderImage,
+                showAttachmentPreviews, parallaxSpeedAlternative, parallaxDirectionAlternative,
+                swipeEnabled, priorityArrowsEnabled, animatedAdapter);
     }
 
     public void startUndoAnimation(AnimatorListener listener, boolean swipe) {
diff --git a/src/com/android/mail/photomanager/ContactPhotoManager.java b/src/com/android/mail/photomanager/ContactPhotoManager.java
deleted file mode 100644
index 06184b0..0000000
--- a/src/com/android/mail/photomanager/ContactPhotoManager.java
+++ /dev/null
@@ -1,218 +0,0 @@
-/*
- * Copyright (C) 2012 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.mail.photomanager;
-
-import android.content.ContentResolver;
-import android.content.Context;
-import android.text.TextUtils;
-import android.util.LruCache;
-
-import com.android.mail.ContactInfo;
-import com.android.mail.SenderInfoLoader;
-import com.android.mail.ui.ImageCanvas;
-import com.android.mail.utils.LogUtils;
-import com.google.common.base.Objects;
-import com.google.common.collect.ImmutableMap;
-
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-
-/**
- * Asynchronously loads contact photos and maintains a cache of photos.
- */
-public class ContactPhotoManager extends PhotoManager {
-    public static final String CONTACT_PHOTO_SERVICE = "contactPhotos";
-
-    /**
-     * An LRU cache for photo ids mapped to contact addresses.
-     */
-    private final LruCache<String, Long> mPhotoIdCache;
-    private final LetterTileProvider mLetterTileProvider;
-
-    /** Cache size for {@link #mPhotoIdCache}. Starting with 500 entries. */
-    private static final int PHOTO_ID_CACHE_SIZE = 500;
-
-    /**
-     * Requests the singleton instance with data bound from the available authenticators. This
-     * method can safely be called from the UI thread.
-     */
-    public static ContactPhotoManager getInstance(Context context) {
-        Context applicationContext = context.getApplicationContext();
-        ContactPhotoManager service =
-                (ContactPhotoManager) applicationContext.getSystemService(CONTACT_PHOTO_SERVICE);
-        if (service == null) {
-            service = createContactPhotoManager(applicationContext);
-            LogUtils.e(TAG, "No contact photo service in context: " + applicationContext);
-        }
-        return service;
-    }
-
-    public static synchronized ContactPhotoManager createContactPhotoManager(Context context) {
-        return new ContactPhotoManager(context);
-    }
-
-    public static int generateHash(ImageCanvas view, int pos, Object key) {
-        return Objects.hashCode(view, pos, key);
-    }
-
-    private ContactPhotoManager(Context context) {
-        super(context);
-        mPhotoIdCache = new LruCache<String, Long>(PHOTO_ID_CACHE_SIZE);
-        mLetterTileProvider = new LetterTileProvider(context);
-    }
-
-    @Override
-    protected DefaultImageProvider getDefaultImageProvider() {
-        return mLetterTileProvider;
-    }
-
-    @Override
-    protected int getHash(PhotoIdentifier id, ImageCanvas view) {
-        final ContactIdentifier contactId = (ContactIdentifier) id;
-        return generateHash(view, contactId.pos, contactId.getKey());
-    }
-
-    @Override
-    protected PhotoLoaderThread getLoaderThread(ContentResolver contentResolver) {
-        return new ContactPhotoLoaderThread(contentResolver);
-    }
-
-    @Override
-    public void clear() {
-        super.clear();
-        mPhotoIdCache.evictAll();
-    }
-
-    public static class ContactIdentifier extends PhotoIdentifier {
-        public final String name;
-        public final String emailAddress;
-        public final int pos;
-
-        public ContactIdentifier(String name, String emailAddress, int pos) {
-            this.name = name;
-            this.emailAddress = emailAddress;
-            this.pos = pos;
-        }
-
-        @Override
-        public boolean isValid() {
-            return !TextUtils.isEmpty(emailAddress);
-        }
-
-        @Override
-        public Object getKey() {
-            return emailAddress;
-        }
-
-        @Override
-        public int hashCode() {
-            int hash = 17;
-            hash = 31 * hash + (emailAddress != null ? emailAddress.hashCode() : 0);
-            hash = 31 * hash + (name != null ? name.hashCode() : 0);
-            hash = 31 * hash + pos;
-            return hash;
-        }
-
-        @Override
-        public boolean equals(Object obj) {
-            if (this == obj)
-                return true;
-            if (obj == null)
-                return false;
-            if (getClass() != obj.getClass())
-                return false;
-            ContactIdentifier other = (ContactIdentifier) obj;
-            return Objects.equal(emailAddress, other.emailAddress)
-                    && Objects.equal(name, other.name) && Objects.equal(pos, other.pos);
-        }
-
-        @Override
-        public String toString() {
-            final StringBuilder sb = new StringBuilder("{");
-            sb.append(super.toString());
-            sb.append(" name=");
-            sb.append(name);
-            sb.append(" email=");
-            sb.append(emailAddress);
-            sb.append(" pos=");
-            sb.append(pos);
-            sb.append("}");
-            return sb.toString();
-        }
-
-        @Override
-        public int compareTo(PhotoIdentifier another) {
-            return 0;
-        }
-    }
-
-    public class ContactPhotoLoaderThread extends PhotoLoaderThread {
-        public ContactPhotoLoaderThread(ContentResolver resolver) {
-            super(resolver);
-        }
-
-        @Override
-        protected Map<String, BitmapHolder> loadPhotos(Collection<Request> requests) {
-            Map<String, BitmapHolder> photos = new HashMap<String, BitmapHolder>(requests.size());
-
-            Set<String> addresses = new HashSet<String>();
-            Set<Long> photoIds = new HashSet<Long>();
-            HashMap<Long, String> photoIdMap = new HashMap<Long, String>();
-
-            Long match;
-            String emailAddress;
-            for (Request request : requests) {
-                emailAddress = (String) request.getKey();
-                match = mPhotoIdCache.get(emailAddress);
-                if (match != null) {
-                    photoIds.add(match);
-                    photoIdMap.put(match, emailAddress);
-                } else {
-                    addresses.add(emailAddress);
-                }
-            }
-
-            // get the Map of email addresses to ContactInfo
-            ImmutableMap<String, ContactInfo> emailAddressToContactInfoMap =
-                    SenderInfoLoader.loadContactPhotos(
-                    getResolver(), addresses, false /* decodeBitmaps */);
-
-            // Put all entries into photos map: a mapping of email addresses to photoBytes.
-            // If there is no ContactInfo, it means we couldn't get a photo for this
-            // address so just put null in for the bytes so that the crazy caching
-            // works properly and we don't get an infinite loop of GC churn.
-            if (emailAddressToContactInfoMap != null) {
-                for (final String address : addresses) {
-                    final ContactInfo info = emailAddressToContactInfoMap.get(address);
-                    photos.put(address,
-                            new BitmapHolder(info != null ? info.photoBytes : null, -1, -1));
-                }
-            } else {
-                // Still need to set a null result for all addresses, otherwise we end
-                // up in the loop where photo manager attempts to load these again.
-                for (final String address: addresses) {
-                    photos.put(address, new BitmapHolder(null, -1, -1));
-                }
-            }
-
-            return photos;
-        }
-    }
-}
diff --git a/src/com/android/mail/photomanager/LetterTileProvider.java b/src/com/android/mail/photomanager/LetterTileProvider.java
index bc28223..0cb7505 100644
--- a/src/com/android/mail/photomanager/LetterTileProvider.java
+++ b/src/com/android/mail/photomanager/LetterTileProvider.java
@@ -29,12 +29,8 @@
 import android.text.TextUtils;
 
 import com.android.mail.R;
-import com.android.mail.photomanager.ContactPhotoManager.ContactIdentifier;
-import com.android.mail.photomanager.PhotoManager.DefaultImageProvider;
-import com.android.mail.photomanager.PhotoManager.PhotoIdentifier;
-import com.android.mail.ui.DividedImageCanvas;
-import com.android.mail.ui.ImageCanvas;
 import com.android.mail.ui.ImageCanvas.Dimensions;
+import com.android.mail.utils.BitmapUtil;
 import com.android.mail.utils.LogTag;
 import com.android.mail.utils.LogUtils;
 
@@ -46,7 +42,8 @@
  * tile. If there is no English alphabet character (or digit), it creates a
  * bitmap with the default contact avatar.
  */
-public class LetterTileProvider implements DefaultImageProvider {
+@Deprecated
+public class LetterTileProvider {
     private static final String TAG = LogTag.getLogTag();
     private final Bitmap mDefaultBitmap;
     private final Bitmap[] mBitmapBackgroundCache;
@@ -89,30 +86,6 @@
         mDefaultColor = res.getColor(R.color.letter_tile_default_color);
     }
 
-    @Override
-    public void applyDefaultImage(PhotoIdentifier id, ImageCanvas view, int extent) {
-        ContactIdentifier contactIdentifier = (ContactIdentifier) id;
-        DividedImageCanvas dividedImageView = (DividedImageCanvas) view;
-
-        final String displayName = contactIdentifier.name;
-        final String address = (String) contactIdentifier.getKey();
-
-        // don't apply again if existing letter is there (and valid)
-        if (dividedImageView.hasImageFor(address)) {
-            return;
-        }
-
-        dividedImageView.getDesiredDimensions(address, mDims);
-
-        final Bitmap bitmap = getLetterTile(mDims, displayName, address);
-
-        if (bitmap == null) {
-            return;
-        }
-
-        dividedImageView.addDivisionImage(bitmap, address);
-    }
-
     public Bitmap getLetterTile(final Dimensions dimensions, final String displayName,
             final String address) {
         final String display = !TextUtils.isEmpty(displayName) ? displayName : address;
diff --git a/src/com/android/mail/photomanager/MemInfoReader.java b/src/com/android/mail/photomanager/MemInfoReader.java
deleted file mode 100644
index f53c60c..0000000
--- a/src/com/android/mail/photomanager/MemInfoReader.java
+++ /dev/null
@@ -1,109 +0,0 @@
-/*
- * Copyright (C) 2012 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.mail.photomanager;
-
-import android.os.StrictMode;
-
-import java.io.FileInputStream;
-
-public class MemInfoReader {
-    byte[] mBuffer = new byte[1024];
-
-    private long mTotalSize;
-    private long mFreeSize;
-    private long mCachedSize;
-
-    private static boolean matchText(byte[] buffer, int index, String text) {
-        int N = text.length();
-        if ((index + N) >= buffer.length) {
-            return false;
-        }
-        for (int i = 0; i < N; i++) {
-            if (buffer[index + i] != text.charAt(i)) {
-                return false;
-            }
-        }
-        return true;
-    }
-
-    private static long extractMemValue(byte[] buffer, int index) {
-        while (index < buffer.length && buffer[index] != '\n') {
-            if (buffer[index] >= '0' && buffer[index] <= '9') {
-                int start = index;
-                index++;
-                while (index < buffer.length && buffer[index] >= '0' && buffer[index] <= '9') {
-                    index++;
-                }
-                String str = new String(buffer, 0, start, index - start);
-                return ((long) Integer.parseInt(str)) * 1024;
-            }
-            index++;
-        }
-        return 0;
-    }
-
-    public void readMemInfo() {
-        // Permit disk reads here, as /proc/meminfo isn't really "on
-        // disk" and should be fast. TODO: make BlockGuard ignore
-        // /proc/ and /sys/ files perhaps?
-        StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads();
-        try {
-            mTotalSize = 0;
-            mFreeSize = 0;
-            mCachedSize = 0;
-            FileInputStream is = new FileInputStream("/proc/meminfo");
-            int len = is.read(mBuffer);
-            is.close();
-            final int BUFLEN = mBuffer.length;
-            int count = 0;
-            for (int i = 0; i < len && count < 3; i++) {
-                if (matchText(mBuffer, i, "MemTotal")) {
-                    i += 8;
-                    mTotalSize = extractMemValue(mBuffer, i);
-                    count++;
-                } else if (matchText(mBuffer, i, "MemFree")) {
-                    i += 7;
-                    mFreeSize = extractMemValue(mBuffer, i);
-                    count++;
-                } else if (matchText(mBuffer, i, "Cached")) {
-                    i += 6;
-                    mCachedSize = extractMemValue(mBuffer, i);
-                    count++;
-                }
-                while (i < BUFLEN && mBuffer[i] != '\n') {
-                    i++;
-                }
-            }
-        } catch (java.io.FileNotFoundException e) {
-        } catch (java.io.IOException e) {
-        } finally {
-            StrictMode.setThreadPolicy(savedPolicy);
-        }
-    }
-
-    public long getTotalSize() {
-        return mTotalSize;
-    }
-
-    public long getFreeSize() {
-        return mFreeSize;
-    }
-
-    public long getCachedSize() {
-        return mCachedSize;
-    }
-}
diff --git a/src/com/android/mail/photomanager/MemoryUtils.java b/src/com/android/mail/photomanager/MemoryUtils.java
deleted file mode 100644
index 4992026..0000000
--- a/src/com/android/mail/photomanager/MemoryUtils.java
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright (C) 2012 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.mail.photomanager;
-
-public class MemoryUtils {
-    private MemoryUtils() {
-    }
-
-    public static final int LARGE_RAM_THRESHOLD = 640 * 1024 * 1024;
-    private static long sTotalMemorySize = -1;
-
-    public static long getTotalMemorySize() {
-        if (sTotalMemorySize < 0) {
-            MemInfoReader reader = new MemInfoReader();
-            reader.readMemInfo();
-
-            // getTotalSize() returns the "MemTotal" value from /proc/meminfo.
-            // Because the linux kernel doesn't see all the RAM on the system
-            // (e.g. GPU takes some),
-            // this is usually smaller than the actual RAM size.
-            sTotalMemorySize = reader.getTotalSize();
-        }
-        return sTotalMemorySize;
-    }
-}
diff --git a/src/com/android/mail/photomanager/PhotoManager.java b/src/com/android/mail/photomanager/PhotoManager.java
deleted file mode 100644
index 94f809d..0000000
--- a/src/com/android/mail/photomanager/PhotoManager.java
+++ /dev/null
@@ -1,993 +0,0 @@
-/*
- * Copyright (C) 2013 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.mail.photomanager;
-
-import android.content.ComponentCallbacks2;
-import android.content.ContentResolver;
-import android.content.Context;
-import android.content.res.Configuration;
-import android.graphics.Bitmap;
-import android.os.Handler;
-import android.os.Handler.Callback;
-import android.os.HandlerThread;
-import android.os.Message;
-import android.os.Process;
-import android.util.LruCache;
-
-import com.android.mail.ui.ImageCanvas;
-import com.android.mail.utils.LogUtils;
-import com.android.mail.utils.Utils;
-import com.google.common.base.Objects;
-import com.google.common.collect.Lists;
-
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.PriorityQueue;
-import java.util.concurrent.atomic.AtomicInteger;
-
-/**
- * Asynchronously loads photos and maintains a cache of photos
- */
-public abstract class PhotoManager implements ComponentCallbacks2, Callback {
-    /**
-     * Get the default image provider that draws while the photo is being
-     * loaded.
-     */
-    protected abstract DefaultImageProvider getDefaultImageProvider();
-
-    /**
-     * Generate a hashcode unique to each request.
-     */
-    protected abstract int getHash(PhotoIdentifier id, ImageCanvas view);
-
-    /**
-     * Return a specific implementation of PhotoLoaderThread.
-     */
-    protected abstract PhotoLoaderThread getLoaderThread(ContentResolver contentResolver);
-
-    /**
-     * Subclasses can implement this method to alert callbacks that images finished loading.
-     * @param request The original request made.
-     * @param success True if we successfully loaded the image from cache. False if we fell back
-     *                to the default image.
-     */
-    protected void onImageDrawn(final Request request, final boolean success) {
-        // Subclasses can choose to do something about this
-    }
-
-    /**
-     * Subclasses can implement this method to alert callbacks that images started loading.
-     * @param request The original request made.
-     */
-    protected void onImageLoadStarted(final Request request) {
-        // Subclasses can choose to do something about this
-    }
-
-    /**
-     * Subclasses can implement this method to determine whether a previously loaded bitmap can
-     * be reused for a new canvas size.
-     * @param prevWidth The width of the previously loaded bitmap.
-     * @param prevHeight The height of the previously loaded bitmap.
-     * @param newWidth The width of the canvas this request is drawing on.
-     * @param newHeight The height of the canvas this request is drawing on.
-     * @return
-     */
-    protected boolean isSizeCompatible(int prevWidth, int prevHeight, int newWidth, int newHeight) {
-        return true;
-    }
-
-    protected final Context getContext() {
-        return mContext;
-    }
-
-    static final String TAG = "PhotoManager";
-    static final boolean DEBUG = false; // Don't submit with true
-    static final boolean DEBUG_SIZES = false; // Don't submit with true
-
-    private static final String LOADER_THREAD_NAME = "PhotoLoader";
-
-    /**
-     * Type of message sent by the UI thread to itself to indicate that some photos
-     * need to be loaded.
-     */
-    private static final int MESSAGE_REQUEST_LOADING = 1;
-
-    /**
-     * Type of message sent by the loader thread to indicate that some photos have
-     * been loaded.
-     */
-    private static final int MESSAGE_PHOTOS_LOADED = 2;
-
-    /**
-     * Type of message sent by the loader thread to indicate that
-     */
-    private static final int MESSAGE_PHOTO_LOADING = 3;
-
-    public interface DefaultImageProvider {
-        /**
-         * Applies the default avatar to the DividedImageView. Extent is an
-         * indicator for the size (width or height). If darkTheme is set, the
-         * avatar is one that looks better on dark background
-         * @param id
-         */
-        public void applyDefaultImage(PhotoIdentifier id, ImageCanvas view, int extent);
-    }
-
-    /**
-     * Maintains the state of a particular photo.
-     */
-    protected static class BitmapHolder {
-        byte[] bytes;
-        int width;
-        int height;
-
-        volatile boolean fresh;
-
-        public BitmapHolder(byte[] bytes, int width, int height) {
-            this.bytes = bytes;
-            this.width = width;
-            this.height = height;
-            this.fresh = true;
-        }
-
-        @Override
-        public String toString() {
-            final StringBuilder sb = new StringBuilder("{");
-            sb.append(super.toString());
-            sb.append(" bytes=");
-            sb.append(bytes);
-            sb.append(" size=");
-            sb.append(bytes == null ? 0 : bytes.length);
-            sb.append(" width=");
-            sb.append(width);
-            sb.append(" height=");
-            sb.append(height);
-            sb.append(" fresh=");
-            sb.append(fresh);
-            sb.append("}");
-            return sb.toString();
-        }
-    }
-
-    // todo:ath caches should be member vars
-    /**
-     * An LRU cache for bitmap holders. The cache contains bytes for photos just
-     * as they come from the database. Each holder has a soft reference to the
-     * actual bitmap. The keys are decided by the implementation.
-     */
-    private static final LruCache<Object, BitmapHolder> sBitmapHolderCache;
-
-    /**
-     * Level 2 LRU cache for bitmaps. This is a smaller cache that holds
-     * the most recently used bitmaps to save time on decoding
-     * them from bytes (the bytes are stored in {@link #sBitmapHolderCache}.
-     * The keys are decided by the implementation.
-     */
-    private static final LruCache<BitmapIdentifier, Bitmap> sBitmapCache;
-
-    /** Cache size for {@link #sBitmapHolderCache} for devices with "large" RAM. */
-    private static final int HOLDER_CACHE_SIZE = 2000000;
-
-    /** Cache size for {@link #sBitmapCache} for devices with "large" RAM. */
-    private static final int BITMAP_CACHE_SIZE = 1024 * 1024 * 8; // 8MB
-
-    /** For debug: How many times we had to reload cached photo for a stale entry */
-    private static final AtomicInteger sStaleCacheOverwrite = new AtomicInteger();
-
-    /** For debug: How many times we had to reload cached photo for a fresh entry.  Should be 0. */
-    private static final AtomicInteger sFreshCacheOverwrite = new AtomicInteger();
-
-    static {
-        final float cacheSizeAdjustment =
-                (MemoryUtils.getTotalMemorySize() >= MemoryUtils.LARGE_RAM_THRESHOLD) ?
-                        1.0f : 0.5f;
-        final int holderCacheSize = (int) (cacheSizeAdjustment * HOLDER_CACHE_SIZE);
-        sBitmapHolderCache = new LruCache<Object, BitmapHolder>(holderCacheSize) {
-            @Override protected int sizeOf(Object key, BitmapHolder value) {
-                return value.bytes != null ? value.bytes.length : 0;
-            }
-
-            @Override protected void entryRemoved(
-                    boolean evicted, Object key, BitmapHolder oldValue, BitmapHolder newValue) {
-                if (DEBUG) dumpStats();
-            }
-        };
-        final int bitmapCacheSize = (int) (cacheSizeAdjustment * BITMAP_CACHE_SIZE);
-        sBitmapCache = new LruCache<BitmapIdentifier, Bitmap>(bitmapCacheSize) {
-            @Override protected int sizeOf(BitmapIdentifier key, Bitmap value) {
-                return value.getByteCount();
-            }
-
-            @Override protected void entryRemoved(
-                    boolean evicted, BitmapIdentifier key, Bitmap oldValue, Bitmap newValue) {
-                if (DEBUG) dumpStats();
-            }
-        };
-        LogUtils.i(TAG, "Cache adj: " + cacheSizeAdjustment);
-        if (DEBUG) {
-            LogUtils.d(TAG, "Cache size: " + btk(sBitmapHolderCache.maxSize())
-                    + " + " + btk(sBitmapCache.maxSize()));
-        }
-    }
-
-    /**
-     * A map from ImageCanvas hashcode to the corresponding photo ID or uri,
-     * encapsulated in a request. The request may swapped out before the photo
-     * loading request is started.
-     */
-    private final Map<Integer, Request> mPendingRequests = Collections.synchronizedMap(
-            new HashMap<Integer, Request>());
-
-    /**
-     * Handler for messages sent to the UI thread.
-     */
-    private final Handler mMainThreadHandler = new Handler(this);
-
-    /**
-     * Thread responsible for loading photos from the database. Created upon
-     * the first request.
-     */
-    private PhotoLoaderThread mLoaderThread;
-
-    /**
-     * A gate to make sure we only send one instance of MESSAGE_PHOTOS_NEEDED at a time.
-     */
-    private boolean mLoadingRequested;
-
-    /**
-     * Flag indicating if the image loading is paused.
-     */
-    private boolean mPaused;
-
-    private final Context mContext;
-
-    public PhotoManager(Context context) {
-        mContext = context;
-    }
-
-    public void loadThumbnail(PhotoIdentifier id, ImageCanvas view) {
-        loadThumbnail(id, view, null);
-    }
-
-    /**
-     * Load an image
-     *
-     * @param dimensions    Preferred dimensions
-     */
-    public void loadThumbnail(final PhotoIdentifier id, final ImageCanvas view,
-            final ImageCanvas.Dimensions dimensions) {
-        Utils.traceBeginSection("Load thumbnail");
-        final DefaultImageProvider defaultProvider = getDefaultImageProvider();
-        final Request request = new Request(id, defaultProvider, view, dimensions);
-        final int hashCode = request.hashCode();
-
-        if (!id.isValid()) {
-            // No photo is needed
-            request.applyDefaultImage();
-            onImageDrawn(request, false);
-            mPendingRequests.remove(hashCode);
-        } else if (mPendingRequests.containsKey(hashCode)) {
-            LogUtils.d(TAG, "load request dropped for %s", id);
-        } else {
-            if (DEBUG) LogUtils.v(TAG, "loadPhoto request: %s", id.getKey());
-            loadPhoto(hashCode, request);
-        }
-        Utils.traceEndSection();
-    }
-
-    private void loadPhoto(int hashCode, Request request) {
-        if (DEBUG) {
-            LogUtils.v(TAG, "NEW IMAGE REQUEST key=%s r=%s thread=%s",
-                    request.getKey(),
-                    request,
-                    Thread.currentThread());
-        }
-
-        boolean loaded = loadCachedPhoto(request, false);
-        if (loaded) {
-            if (DEBUG) {
-                LogUtils.v(TAG, "image request, cache hit. request queue size=%s",
-                        mPendingRequests.size());
-            }
-        } else {
-            if (DEBUG) {
-                LogUtils.d(TAG, "image request, cache miss: key=%s", request.getKey());
-            }
-            mPendingRequests.put(hashCode, request);
-            if (!mPaused) {
-                // Send a request to start loading photos
-                requestLoading();
-            }
-        }
-    }
-
-    /**
-     * Remove photo from the supplied image view. This also cancels current pending load request
-     * inside this photo manager.
-     */
-    public void removePhoto(int hashcode) {
-        Request r = mPendingRequests.remove(hashcode);
-        if (r != null) {
-            LogUtils.d(TAG, "removed request %s", r.getKey());
-        }
-    }
-
-    private void ensureLoaderThread() {
-        if (mLoaderThread == null) {
-            mLoaderThread = getLoaderThread(mContext.getContentResolver());
-            mLoaderThread.start();
-        }
-    }
-
-    /**
-     * Checks if the photo is present in cache.  If so, sets the photo on the view.
-     *
-     * @param request                   Determines which image to load from cache.
-     * @param afterLoaderThreadFinished Pass true if calling after the LoaderThread has run. Pass
-     *                                  false if the Loader Thread hasn't made any attempts to
-     *                                  load images yet.
-     * @return false if the photo needs to be (re)loaded from the provider.
-     */
-    private boolean loadCachedPhoto(final Request request,
-            final boolean afterLoaderThreadFinished) {
-        Utils.traceBeginSection("Load cached photo");
-        final Bitmap cached = getCachedPhoto(request.bitmapKey);
-        if (cached != null) {
-            if (DEBUG) {
-                LogUtils.v(TAG, "%s, key=%s decodedSize=%s thread=%s",
-                        afterLoaderThreadFinished ? "DECODED IMG READ"
-                                : "DECODED IMG CACHE HIT",
-                        request.getKey(),
-                        cached.getByteCount(),
-                        Thread.currentThread());
-            }
-            if (request.getView().getGeneration() == request.viewGeneration) {
-                request.getView().drawImage(cached, request.getKey());
-                onImageDrawn(request, true);
-            }
-            Utils.traceEndSection();
-            return true;
-        }
-
-        // We couldn't load the requested image, so try to load a replacement.
-        // This removes the flicker from SIMPLE to BEST transition.
-        final Object replacementKey = request.getPhotoIdentifier().getKeyToShowInsteadOfDefault();
-        if (replacementKey != null) {
-            final BitmapIdentifier replacementBitmapKey = new BitmapIdentifier(replacementKey,
-                    request.bitmapKey.w, request.bitmapKey.h);
-            final Bitmap cachedReplacement = getCachedPhoto(replacementBitmapKey);
-            if (cachedReplacement != null) {
-                if (DEBUG) {
-                    LogUtils.v(TAG, "%s, key=%s decodedSize=%s thread=%s",
-                            afterLoaderThreadFinished ? "DECODED IMG READ"
-                                    : "DECODED IMG CACHE HIT",
-                            replacementKey,
-                            cachedReplacement.getByteCount(),
-                            Thread.currentThread());
-                }
-                if (request.getView().getGeneration() == request.viewGeneration) {
-                    request.getView().drawImage(cachedReplacement, request.getKey());
-                    onImageDrawn(request, true);
-                }
-                Utils.traceEndSection();
-                return false;
-            }
-        }
-
-        // We couldn't load any image, so draw a default image
-        request.applyDefaultImage();
-
-        final BitmapHolder holder = sBitmapHolderCache.get(request.getKey());
-        // Check if we loaded null bytes, which means we meant to not draw anything.
-        if (holder != null && holder.bytes == null) {
-            onImageDrawn(request, holder.fresh);
-            Utils.traceEndSection();
-            return holder.fresh;
-        }
-        Utils.traceEndSection();
-        return false;
-    }
-
-    /**
-     * Takes care of retrieving the Bitmap from both the decoded and holder caches.
-     */
-    private static Bitmap getCachedPhoto(BitmapIdentifier bitmapKey) {
-        Utils.traceBeginSection("Get cached photo");
-        final Bitmap cached = sBitmapCache.get(bitmapKey);
-        Utils.traceEndSection();
-        return cached;
-    }
-
-    /**
-     * Temporarily stops loading photos from the database.
-     */
-    public void pause() {
-        LogUtils.d(TAG, "%s paused.", getClass().getName());
-        mPaused = true;
-    }
-
-    /**
-     * Resumes loading photos from the database.
-     */
-    public void resume() {
-        LogUtils.d(TAG, "%s resumed.", getClass().getName());
-        mPaused = false;
-        if (DEBUG) dumpStats();
-        if (!mPendingRequests.isEmpty()) {
-            requestLoading();
-        }
-    }
-
-    /**
-     * Sends a message to this thread itself to start loading images.  If the current
-     * view contains multiple image views, all of those image views will get a chance
-     * to request their respective photos before any of those requests are executed.
-     * This allows us to load images in bulk.
-     */
-    private void requestLoading() {
-        if (!mLoadingRequested) {
-            mLoadingRequested = true;
-            mMainThreadHandler.sendEmptyMessage(MESSAGE_REQUEST_LOADING);
-        }
-    }
-
-    /**
-     * Processes requests on the main thread.
-     */
-    @Override
-    public boolean handleMessage(final Message msg) {
-        switch (msg.what) {
-            case MESSAGE_REQUEST_LOADING: {
-                mLoadingRequested = false;
-                if (!mPaused) {
-                    ensureLoaderThread();
-                    mLoaderThread.requestLoading();
-                }
-                return true;
-            }
-
-            case MESSAGE_PHOTOS_LOADED: {
-                processLoadedImages();
-                if (DEBUG) dumpStats();
-                return true;
-            }
-
-            case MESSAGE_PHOTO_LOADING: {
-                final int hashcode = msg.arg1;
-                final Request request = mPendingRequests.get(hashcode);
-                onImageLoadStarted(request);
-                return true;
-            }
-        }
-        return false;
-    }
-
-    /**
-     * Goes over pending loading requests and displays loaded photos.  If some of the
-     * photos still haven't been loaded, sends another request for image loading.
-     */
-    private void processLoadedImages() {
-        Utils.traceBeginSection("process loaded images");
-        final List<Integer> toRemove = Lists.newArrayList();
-        for (final Integer hash : mPendingRequests.keySet()) {
-            final Request request = mPendingRequests.get(hash);
-            final boolean loaded = loadCachedPhoto(request, true);
-            // Request can go through multiple attempts if the LoaderThread fails to load any
-            // images for it, or if the images it loads are evicted from the cache before we
-            // could access them in the main thread.
-            if (loaded || request.attempts > 2) {
-                toRemove.add(hash);
-            }
-        }
-        for (final Integer key : toRemove) {
-            mPendingRequests.remove(key);
-        }
-
-        if (!mPaused && !mPendingRequests.isEmpty()) {
-            LogUtils.d(TAG, "Finished loading batch. %d still have to be loaded.",
-                    mPendingRequests.size());
-            requestLoading();
-        }
-        Utils.traceEndSection();
-    }
-
-    /**
-     * Stores the supplied bitmap in cache.
-     */
-    private static void cacheBitmapHolder(final String cacheKey, final BitmapHolder holder) {
-        if (DEBUG) {
-            BitmapHolder prev = sBitmapHolderCache.get(cacheKey);
-            if (prev != null && prev.bytes != null) {
-                LogUtils.d(TAG, "Overwriting cache: key=" + cacheKey
-                        + (prev.fresh ? " FRESH" : " stale"));
-                if (prev.fresh) {
-                    sFreshCacheOverwrite.incrementAndGet();
-                } else {
-                    sStaleCacheOverwrite.incrementAndGet();
-                }
-            }
-            LogUtils.d(TAG, "Caching data: key=" + cacheKey + ", "
-                    + (holder.bytes == null ? "<null>" : btk(holder.bytes.length)));
-        }
-
-        sBitmapHolderCache.put(cacheKey, holder);
-    }
-
-    protected static void cacheBitmap(final BitmapIdentifier bitmapKey, final Bitmap bitmap) {
-        sBitmapCache.put(bitmapKey, bitmap);
-    }
-
-    // ComponentCallbacks2
-    @Override
-    public void onConfigurationChanged(Configuration newConfig) {
-    }
-
-    // ComponentCallbacks2
-    @Override
-    public void onLowMemory() {
-    }
-
-    // ComponentCallbacks2
-    @Override
-    public void onTrimMemory(int level) {
-        if (DEBUG) LogUtils.d(TAG, "onTrimMemory: " + level);
-        if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {
-            // Clear the caches.  Note all pending requests will be removed too.
-            clear();
-        }
-    }
-
-    public void clear() {
-        if (DEBUG) LogUtils.d(TAG, "clear");
-        mPendingRequests.clear();
-        sBitmapHolderCache.evictAll();
-        sBitmapCache.evictAll();
-    }
-
-    /**
-     * Dump cache stats on logcat.
-     */
-    private static void dumpStats() {
-        if (!DEBUG) {
-            return;
-        }
-        int numHolders = 0;
-        int rawBytes = 0;
-        int bitmapBytes = 0;
-        int numBitmaps = 0;
-        for (BitmapHolder h : sBitmapHolderCache.snapshot().values()) {
-            numHolders++;
-            if (h.bytes != null) {
-                rawBytes += h.bytes.length;
-                numBitmaps++;
-            }
-        }
-        LogUtils.d(TAG,
-                "L1: " + btk(rawBytes) + " + " + btk(bitmapBytes) + " = "
-                        + btk(rawBytes + bitmapBytes) + ", " + numHolders + " holders, "
-                        + numBitmaps + " bitmaps, avg: " + btk(safeDiv(rawBytes, numBitmaps)));
-        LogUtils.d(TAG, "L1 Stats: %s, overwrite: fresh=%s stale=%s", sBitmapHolderCache,
-                sFreshCacheOverwrite.get(), sStaleCacheOverwrite.get());
-
-        numBitmaps = 0;
-        bitmapBytes = 0;
-        for (Bitmap b : sBitmapCache.snapshot().values()) {
-            numBitmaps++;
-            bitmapBytes += b.getByteCount();
-        }
-        LogUtils.d(TAG, "L2: " + btk(bitmapBytes) + ", " + numBitmaps + " bitmaps" + ", avg: "
-                + btk(safeDiv(bitmapBytes, numBitmaps)));
-        // We don't get from L2 cache, so L2 stats is meaningless.
-    }
-
-    /** Converts bytes to K bytes, rounding up.  Used only for debug log. */
-    private static String btk(int bytes) {
-        return ((bytes + 1023) / 1024) + "K";
-    }
-
-    private static final int safeDiv(int dividend, int divisor) {
-        return (divisor  == 0) ? 0 : (dividend / divisor);
-    }
-
-    public static abstract class PhotoIdentifier implements Comparable<PhotoIdentifier> {
-        /**
-         * If this returns false, the PhotoManager will not attempt to load the
-         * bitmap. Instead, the default image provider will be used.
-         */
-        public abstract boolean isValid();
-
-        /**
-         * Identifies this request.
-         */
-        public abstract Object getKey();
-
-        /**
-         * Replacement key to try to load from cache instead of drawing the default image. This
-         * is useful when we've already loaded a SIMPLE rendition, and are now loading the BEST
-         * rendition. We want the BEST image to appear seamlessly on top of the existing SIMPLE
-         * image.
-         */
-        public Object getKeyToShowInsteadOfDefault() {
-            return null;
-        }
-    }
-
-    /**
-     * The thread that performs loading of photos from the database.
-     */
-    protected abstract class PhotoLoaderThread extends HandlerThread implements Callback {
-
-        /**
-         * Return photos mapped from {@link Request#getKey()} to the photo for
-         * that request.
-         */
-        protected abstract Map<String, BitmapHolder> loadPhotos(Collection<Request> requests);
-
-        private static final int MESSAGE_LOAD_PHOTOS = 0;
-
-        private final ContentResolver mResolver;
-
-        private Handler mLoaderThreadHandler;
-
-        public PhotoLoaderThread(ContentResolver resolver) {
-            super(LOADER_THREAD_NAME, Process.THREAD_PRIORITY_BACKGROUND);
-            mResolver = resolver;
-        }
-
-        protected ContentResolver getResolver() {
-            return mResolver;
-        }
-
-        public void ensureHandler() {
-            if (mLoaderThreadHandler == null) {
-                mLoaderThreadHandler = new Handler(getLooper(), this);
-            }
-        }
-
-        /**
-         * Sends a message to this thread to load requested photos.  Cancels a preloading
-         * request, if any: we don't want preloading to impede loading of the photos
-         * we need to display now.
-         */
-        public void requestLoading() {
-            ensureHandler();
-            mLoaderThreadHandler.sendEmptyMessage(MESSAGE_LOAD_PHOTOS);
-        }
-
-        /**
-         * Receives the above message, loads photos and then sends a message
-         * to the main thread to process them.
-         */
-        @Override
-        public boolean handleMessage(Message msg) {
-            switch (msg.what) {
-                case MESSAGE_LOAD_PHOTOS:
-                    loadPhotosInBackground();
-                    break;
-            }
-            return true;
-        }
-
-        /**
-         * Subclasses may specify the maximum number of requests to be given at a time to
-         * #loadPhotos(). For batch count N, the UI will be updated with up to N images at a time.
-         *
-         * @return A positive integer if you would like to limit the number of
-         *         items in a single batch.
-         */
-        protected int getMaxBatchCount() {
-            return -1;
-        }
-
-        private void loadPhotosInBackground() {
-            Utils.traceBeginSection("pre processing");
-            final Collection<Request> loadRequests = new HashSet<PhotoManager.Request>();
-            final Collection<Request> decodeRequests = new HashSet<PhotoManager.Request>();
-            final PriorityQueue<Request> requests;
-            synchronized (mPendingRequests) {
-                requests = new PriorityQueue<Request>(mPendingRequests.values());
-            }
-
-            int batchCount = 0;
-            int maxBatchCount = getMaxBatchCount();
-            while (!requests.isEmpty()) {
-                Request request = requests.poll();
-                final BitmapHolder holder = sBitmapHolderCache
-                        .get(request.getKey());
-                if (holder == null || holder.bytes == null || !holder.fresh || !isSizeCompatible(
-                        holder.width, holder.height, request.bitmapKey.w, request.bitmapKey.h)) {
-                    loadRequests.add(request);
-                    decodeRequests.add(request);
-                    batchCount++;
-
-                    final Message msg = Message.obtain();
-                    msg.what = MESSAGE_PHOTO_LOADING;
-                    msg.arg1 = request.hashCode();
-                    mMainThreadHandler.sendMessage(msg);
-                } else {
-                    // Even if the image load is already done, this particular decode configuration
-                    // may not yet have run. Be sure to add it to the queue.
-                    if (sBitmapCache.get(request.bitmapKey) == null) {
-                        decodeRequests.add(request);
-                    }
-                }
-                request.attempts++;
-                if (maxBatchCount > 0 && batchCount >= maxBatchCount) {
-                    break;
-                }
-            }
-            Utils.traceEndSection();
-
-            Utils.traceBeginSection("load photos");
-            // Ask subclass to do the actual loading
-            final Map<String, BitmapHolder> photosMap = loadPhotos(loadRequests);
-            Utils.traceEndSection();
-
-            if (DEBUG) {
-                LogUtils.d(TAG,
-                        "worker thread completed read request batch. inputN=%s outputN=%s",
-                        loadRequests.size(),
-                        photosMap.size());
-            }
-            Utils.traceBeginSection("post processing");
-            for (String cacheKey : photosMap.keySet()) {
-                if (DEBUG) {
-                    LogUtils.d(TAG,
-                            "worker thread completed read request key=%s byteCount=%s thread=%s",
-                            cacheKey,
-                            photosMap.get(cacheKey) == null ? 0
-                                    : photosMap.get(cacheKey).bytes.length,
-                            Thread.currentThread());
-                }
-                cacheBitmapHolder(cacheKey, photosMap.get(cacheKey));
-            }
-
-            for (Request r : decodeRequests) {
-                if (sBitmapCache.get(r.bitmapKey) != null) {
-                    continue;
-                }
-
-                final Object cacheKey = r.getKey();
-                final BitmapHolder holder = sBitmapHolderCache.get(cacheKey);
-                if (holder == null || holder.bytes == null || !holder.fresh || !isSizeCompatible(
-                        holder.width, holder.height, r.bitmapKey.w, r.bitmapKey.h)) {
-                    continue;
-                }
-
-                final int w = r.bitmapKey.w;
-                final int h = r.bitmapKey.h;
-                final byte[] src = holder.bytes;
-
-                if (w == 0 || h == 0) {
-                    LogUtils.e(TAG, new Error(), "bad dimensions for request=%s w/h=%s/%s",
-                            r, w, h);
-                }
-
-                final Bitmap decoded = BitmapUtil.decodeByteArrayWithCenterCrop(src, w, h);
-                if (DEBUG) {
-                    LogUtils.i(TAG,
-                            "worker thread completed decode bmpKey=%s decoded=%s holder=%s",
-                            r.bitmapKey, decoded, holder);
-                }
-
-                if (decoded != null) {
-                    cacheBitmap(r.bitmapKey, decoded);
-                }
-            }
-            Utils.traceEndSection();
-
-            mMainThreadHandler.sendEmptyMessage(MESSAGE_PHOTOS_LOADED);
-        }
-
-        protected String createInQuery(String value, int itemCount) {
-            // Build first query
-            StringBuilder query = new StringBuilder().append(value + " IN (");
-            appendQuestionMarks(query, itemCount);
-            query.append(')');
-            return query.toString();
-        }
-
-        protected void appendQuestionMarks(StringBuilder query, int itemCount) {
-            boolean first = true;
-            for (int i = 0; i < itemCount; i++) {
-                if (first) {
-                    first = false;
-                } else {
-                    query.append(',');
-                }
-                query.append('?');
-            }
-        }
-    }
-
-    /**
-     * An object to uniquely identify a combination of (Request + decoded size). Multiple requests
-     * may require the same src image, but want to decode it into different sizes.
-     */
-    public static final class BitmapIdentifier {
-        public final Object key;
-        public final int w;
-        public final int h;
-
-        // OK to be static as long as all Requests are created on the same
-        // thread
-        private static final ImageCanvas.Dimensions sWorkDims = new ImageCanvas.Dimensions();
-
-        public static BitmapIdentifier getBitmapKey(PhotoIdentifier id, ImageCanvas view,
-                ImageCanvas.Dimensions dimensions) {
-            final int width;
-            final int height;
-            if (dimensions != null) {
-                width = dimensions.width;
-                height = dimensions.height;
-            } else {
-                view.getDesiredDimensions(id.getKey(), sWorkDims);
-                width = sWorkDims.width;
-                height = sWorkDims.height;
-            }
-            return new BitmapIdentifier(id.getKey(), width, height);
-        }
-
-        public BitmapIdentifier(Object key, int w, int h) {
-            this.key = key;
-            this.w = w;
-            this.h = h;
-        }
-
-        @Override
-        public int hashCode() {
-            int hash = 19;
-            hash = 31 * hash + key.hashCode();
-            hash = 31 * hash + w;
-            hash = 31 * hash + h;
-            return hash;
-        }
-
-        @Override
-        public boolean equals(Object obj) {
-            if (obj == null || obj.getClass() != getClass()) {
-                return false;
-            } else if (obj == this) {
-                return true;
-            }
-            final BitmapIdentifier o = (BitmapIdentifier) obj;
-            return Objects.equal(key, o.key) && w == o.w && h == o.h;
-        }
-
-        @Override
-        public String toString() {
-            final StringBuilder sb = new StringBuilder("{");
-            sb.append(super.toString());
-            sb.append(" key=");
-            sb.append(key);
-            sb.append(" w=");
-            sb.append(w);
-            sb.append(" h=");
-            sb.append(h);
-            sb.append("}");
-            return sb.toString();
-        }
-    }
-
-    /**
-     * A holder for a contact photo request.
-     */
-    public final class Request implements Comparable<Request> {
-        private final int mRequestedExtent;
-        private final DefaultImageProvider mDefaultProvider;
-        private final PhotoIdentifier mPhotoIdentifier;
-        private final ImageCanvas mView;
-        public final BitmapIdentifier bitmapKey;
-        public final int viewGeneration;
-        public int attempts;
-
-        private Request(final PhotoIdentifier photoIdentifier,
-                final DefaultImageProvider defaultProvider, final ImageCanvas view,
-                final ImageCanvas.Dimensions dimensions) {
-            mPhotoIdentifier = photoIdentifier;
-            mRequestedExtent = -1;
-            mDefaultProvider = defaultProvider;
-            mView = view;
-            viewGeneration = view.getGeneration();
-
-            bitmapKey = BitmapIdentifier.getBitmapKey(photoIdentifier, mView, dimensions);
-        }
-
-        public ImageCanvas getView() {
-            return mView;
-        }
-
-        public PhotoIdentifier getPhotoIdentifier() {
-            return mPhotoIdentifier;
-        }
-
-        /**
-         * @see PhotoIdentifier#getKey()
-         */
-        public Object getKey() {
-            return mPhotoIdentifier.getKey();
-        }
-
-        @Override
-        public int hashCode() {
-            return getHash(mPhotoIdentifier, mView);
-        }
-
-        @Override
-        public boolean equals(Object obj) {
-            if (this == obj) return true;
-            if (obj == null) return false;
-            if (getClass() != obj.getClass()) return false;
-            final Request that = (Request) obj;
-            if (mRequestedExtent != that.mRequestedExtent) return false;
-            if (!Objects.equal(mPhotoIdentifier, that.mPhotoIdentifier)) return false;
-            if (!Objects.equal(mView, that.mView)) return false;
-            // Don't compare equality of mDarkTheme because it is only used in the default contact
-            // photo case. When the contact does have a photo, the contact photo is the same
-            // regardless of mDarkTheme, so we shouldn't need to put the photo request on the queue
-            // twice.
-            return true;
-        }
-
-        @Override
-        public String toString() {
-            final StringBuilder sb = new StringBuilder("{");
-            sb.append(super.toString());
-            sb.append(" key=");
-            sb.append(getKey());
-            sb.append(" id=");
-            sb.append(mPhotoIdentifier);
-            sb.append(" mView=");
-            sb.append(mView);
-            sb.append(" mExtent=");
-            sb.append(mRequestedExtent);
-            sb.append(" bitmapKey=");
-            sb.append(bitmapKey);
-            sb.append(" viewGeneration=");
-            sb.append(viewGeneration);
-            sb.append("}");
-            return sb.toString();
-        }
-
-        public void applyDefaultImage() {
-            if (mView.getGeneration() != viewGeneration) {
-                // This can legitimately happen when an ImageCanvas is reused and re-purposed to
-                // house a new set of images (e.g. by ListView recycling).
-                // Ignore this now-stale request.
-                if (DEBUG) {
-                    LogUtils.d(TAG,
-                            "ImageCanvas skipping applyDefaultImage; no longer contains" +
-                            " item=%s canvas=%s", getKey(), mView);
-                }
-            }
-            mDefaultProvider.applyDefaultImage(mPhotoIdentifier, mView, mRequestedExtent);
-        }
-
-        @Override
-        public int compareTo(Request another) {
-            // Hold off on loading Requests which have failed before so it don't hold up others
-            if (attempts - another.attempts != 0) {
-                return attempts - another.attempts;
-            }
-            return mPhotoIdentifier.compareTo(another.mPhotoIdentifier);
-        }
-    }
-}
diff --git a/src/com/android/mail/ui/AnimatedAdapter.java b/src/com/android/mail/ui/AnimatedAdapter.java
index fad7b3a..d47ea9c 100644
--- a/src/com/android/mail/ui/AnimatedAdapter.java
+++ b/src/com/android/mail/ui/AnimatedAdapter.java
@@ -40,6 +40,7 @@
 import com.android.bitmap.DecodeAggregator;
 import com.android.mail.R;
 import com.android.mail.analytics.Analytics;
+import com.android.mail.bitmap.ContactResolver;
 import com.android.mail.browse.ConversationCursor;
 import com.android.mail.browse.ConversationItemView;
 import com.android.mail.browse.ConversationItemViewCoordinates.CoordinatesCache;
@@ -93,17 +94,6 @@
     private final Handler mHandler;
     protected long mLastLeaveBehind = -1;
 
-    private final BitmapCache mBitmapCache;
-    private final DecodeAggregator mDecodeAggregator;
-
-    public interface ConversationListListener {
-        /**
-         * @return <code>true</code> if the list is just exiting selection mode (so animations may
-         * be required), <code>false</code> otherwise
-         */
-        boolean isExitingSelectionMode();
-    }
-
     private final AnimatorListener mAnimatorListener = new AnimatorListenerAdapter() {
 
         @Override
@@ -184,7 +174,6 @@
     /** True if priority inbox markers are enabled, false otherwise. */
     private boolean mPriorityMarkersEnabled;
     private final ControllableActivity mActivity;
-    private final ConversationListListener mConversationListListener;
     private final AccountObserver mAccountListener = new AccountObserver() {
         @Override
         public void onChanged(Account newAccount) {
@@ -250,31 +239,43 @@
     private static final String LOG_TAG = LogTag.getLogTag();
     private static final int INCREASE_WAIT_COUNT = 2;
 
-    private static final int BITMAP_CACHE_TARGET_SIZE_BYTES = 0; // TODO: enable cache
+    private final BitmapCache mAttachmentPreviewsCache;
+    private final DecodeAggregator mAttachmentPreviewsDecodeAggregator;
+    private final BitmapCache mSendersImagesCache;
+    private final ContactResolver mContactResolver;
+
+    private static final int ATTACHMENT_PREVIEWS_CACHE_TARGET_SIZE_BYTES = 0; // TODO: enable cache
+    /** 339KB cache fits 10 bitmaps at 33856 bytes each. */
+    private static final int SENDERS_IMAGES_CACHE_TARGET_SIZE_BYTES = 1024 * 339;
     /**
      * This is the fractional portion of the total cache size above that's dedicated to non-pooled
      * bitmaps. (This is basically the portion of cache dedicated to GIFs.)
      */
-    private static final float BITMAP_CACHE_NON_POOLED_FRACTION = 0.1f;
+    private static final float ATTACHMENT_PREVIEWS_CACHE_NON_POOLED_FRACTION = 0.1f;
+    private static final float SENDERS_IMAGES_PREVIEWS_CACHE_NON_POOLED_FRACTION = 0f;
+    /** Each string has upper estimate of 50 bytes, so this cache would be 5KB. */
+    private static final int SENDERS_IMAGES_PREVIEWS_CACHE_NULL_CAPACITY = 100;
 
     public AnimatedAdapter(Context context, ConversationCursor cursor,
             ConversationSelectionSet batch, ControllableActivity activity,
-            final ConversationListListener conversationListListener, SwipeableListView listView,
-            final List<ConversationSpecialItemView> specialViews,
+            SwipeableListView listView, final List<ConversationSpecialItemView> specialViews,
             final ObjectCursor<Folder> childFolders) {
         super(context, -1, cursor, UIProvider.CONVERSATION_PROJECTION, null, 0);
         mContext = context;
         mBatchConversations = batch;
         setAccount(mAccountListener.initialize(activity.getAccountController()));
         mActivity = activity;
-        mConversationListListener = conversationListListener;
         mShowFooter = false;
         mListView = listView;
         mFolderViews = getNestedFolders(childFolders);
 
-        mBitmapCache = new AltBitmapCache(BITMAP_CACHE_TARGET_SIZE_BYTES,
-                BITMAP_CACHE_NON_POOLED_FRACTION);
-        mDecodeAggregator = new DecodeAggregator();
+        mAttachmentPreviewsCache = new AltBitmapCache(ATTACHMENT_PREVIEWS_CACHE_TARGET_SIZE_BYTES,
+                ATTACHMENT_PREVIEWS_CACHE_NON_POOLED_FRACTION, 0);
+        mAttachmentPreviewsDecodeAggregator = new DecodeAggregator();
+        mSendersImagesCache = new AltBitmapCache(SENDERS_IMAGES_CACHE_TARGET_SIZE_BYTES,
+                SENDERS_IMAGES_PREVIEWS_CACHE_NON_POOLED_FRACTION,
+                SENDERS_IMAGES_PREVIEWS_CACHE_NULL_CAPACITY);
+        mContactResolver = new ContactResolver(mContext.getContentResolver(), mSendersImagesCache);
 
         mHandler = new Handler();
         if (sDismissAllShortDelay == -1) {
@@ -415,10 +416,10 @@
         if (view == null) {
             view = new SwipeableConversationItemView(context, mAccount.name);
         }
-        view.bind(conv, mActivity, mConversationListListener, mBatchConversations, mFolder,
-                getCheckboxSetting(), getAttachmentPreviewsSetting(),
-                getParallaxSpeedAlternativeSetting(), getParallaxDirectionAlternativeSetting(),
-                mSwipeEnabled, mPriorityMarkersEnabled, this);
+        view.bind(conv, mActivity, mBatchConversations, mFolder, getCheckboxSetting(),
+                getAttachmentPreviewsSetting(), getParallaxSpeedAlternativeSetting(),
+                getParallaxDirectionAlternativeSetting(), mSwipeEnabled, mPriorityMarkersEnabled,
+                this);
         return view;
     }
 
@@ -803,10 +804,10 @@
         SwipeableConversationItemView view = (SwipeableConversationItemView) super.getView(
                 position, null, parent);
         view.reset();
-        view.bind(conversation, mActivity, mConversationListListener, mBatchConversations, mFolder,
-                getCheckboxSetting(), getAttachmentPreviewsSetting(),
-                getParallaxSpeedAlternativeSetting(), getParallaxDirectionAlternativeSetting(),
-                mSwipeEnabled, mPriorityMarkersEnabled, this);
+        view.bind(conversation, mActivity, mBatchConversations, mFolder, getCheckboxSetting(),
+                getAttachmentPreviewsSetting(), getParallaxSpeedAlternativeSetting(),
+                getParallaxDirectionAlternativeSetting(), mSwipeEnabled, mPriorityMarkersEnabled,
+                this);
         mAnimatingViews.put(conversation.id, view);
         return view;
     }
@@ -1117,12 +1118,20 @@
         return oldCursor;
     }
 
-    public BitmapCache getBitmapCache() {
-        return mBitmapCache;
+    public BitmapCache getAttachmentPreviewsCache() {
+        return mAttachmentPreviewsCache;
     }
 
-    public DecodeAggregator getDecodeAggregator() {
-        return mDecodeAggregator;
+    public DecodeAggregator getAttachmentPreviewsDecodeAggregator() {
+        return mAttachmentPreviewsDecodeAggregator;
+    }
+
+    public BitmapCache getSendersImagesCache() {
+        return mSendersImagesCache;
+    }
+
+    public ContactResolver getContactResolver() {
+        return mContactResolver;
     }
 
     /**
@@ -1178,7 +1187,7 @@
 
     public void onScrollStateChanged(final int scrollState) {
         final boolean scrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE;
-        mBitmapCache.setBlocking(scrolling);
+        mAttachmentPreviewsCache.setBlocking(scrolling);
     }
 
     public int getViewMode() {
diff --git a/src/com/android/mail/ui/ConversationListFragment.java b/src/com/android/mail/ui/ConversationListFragment.java
index 32b8b3d..20a7c9f 100644
--- a/src/com/android/mail/ui/ConversationListFragment.java
+++ b/src/com/android/mail/ui/ConversationListFragment.java
@@ -60,7 +60,6 @@
 import com.android.mail.providers.UIProvider.FolderCapabilities;
 import com.android.mail.providers.UIProvider.FolderType;
 import com.android.mail.providers.UIProvider.Swipe;
-import com.android.mail.ui.AnimatedAdapter.ConversationListListener;
 import com.android.mail.ui.SwipeableListView.ListItemSwipedListener;
 import com.android.mail.ui.SwipeableListView.ListItemsRemovedListener;
 import com.android.mail.ui.ViewMode.ModeChangeListener;
@@ -358,8 +357,7 @@
         }
 
         mListAdapter = new AnimatedAdapter(mActivity.getApplicationContext(), conversationCursor,
-                mActivity.getSelectedSet(), mActivity, mConversationListListener, mListView,
-                specialItemViews, null);
+                mActivity.getSelectedSet(), mActivity, mListView, specialItemViews, null);
         mListAdapter.addFooter(mFooterView);
         mListView.setAdapter(mListAdapter);
         mSelectedSet = mActivity.getSelectedSet();
@@ -743,15 +741,6 @@
         mCallbacks.onConversationSelected(conv, false /* inLoaderCallbacks */);
     }
 
-    private final ConversationListListener mConversationListListener =
-            new ConversationListListener() {
-        @Override
-        public boolean isExitingSelectionMode() {
-            return System.currentTimeMillis() <
-                    (mSelectionModeExitedTimestamp + sSelectionModeAnimationDuration);
-        }
-    };
-
     /**
      * Sets the selected conversation to the position given here.
      * @param cursorPosition The position of the conversation in the cursor (as opposed to
diff --git a/src/com/android/mail/ui/SwipeableListView.java b/src/com/android/mail/ui/SwipeableListView.java
index 627a375..2c0ca14 100644
--- a/src/com/android/mail/ui/SwipeableListView.java
+++ b/src/com/android/mail/ui/SwipeableListView.java
@@ -419,7 +419,6 @@
             if (adapter != null) {
                 adapter.onScrollStateChanged(scrollState);
             }
-            ConversationItemView.setScrollStateChanged(scrollState);
         }
     }
 
diff --git a/src/com/android/mail/photomanager/BitmapUtil.java b/src/com/android/mail/utils/BitmapUtil.java
similarity index 93%
rename from src/com/android/mail/photomanager/BitmapUtil.java
rename to src/com/android/mail/utils/BitmapUtil.java
index 9c2ab2b..6f61f56 100644
--- a/src/com/android/mail/photomanager/BitmapUtil.java
+++ b/src/com/android/mail/utils/BitmapUtil.java
@@ -14,18 +14,17 @@
  * limitations under the License.
  */
 
-package com.android.mail.photomanager;
+package com.android.mail.utils;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
 import android.graphics.Matrix;
 
-import com.android.mail.utils.LogUtils;
-
 /**
  * Provides static functions to decode bitmaps at the optimal size
  */
 public class BitmapUtil {
 
+    private static final String TAG = LogTag.getLogTag();
     private static final boolean DEBUG = false;
 
     private BitmapUtil() {
@@ -53,7 +52,7 @@
             opts.inJustDecodeBounds = false;
             return BitmapFactory.decodeByteArray(src, 0, src.length, opts);
         } catch (Throwable t) {
-            LogUtils.w(PhotoManager.TAG, t, "unable to decode image");
+            LogUtils.w(TAG, t, "BitmapUtils unable to decode image");
             return null;
         }
     }
@@ -73,7 +72,7 @@
             return centerCrop(decoded, w, h);
 
         } catch (Throwable t) {
-            LogUtils.w(PhotoManager.TAG, t, "unable to crop image");
+            LogUtils.w(TAG, t, "BitmapUtils unable to crop image");
             return null;
         }
     }
@@ -161,13 +160,13 @@
         final Bitmap cropped = Bitmap.createBitmap(src, srcX, srcY, srcCroppedW, srcCroppedH, m,
                 true /* filter */);
 
-        if (DEBUG) LogUtils.i(PhotoManager.TAG,
-                "IN centerCrop, srcW/H=%s/%s desiredW/H=%s/%s srcX/Y=%s/%s" +
+        if (DEBUG) LogUtils.i(TAG,
+                "BitmapUtils IN centerCrop, srcW/H=%s/%s desiredW/H=%s/%s srcX/Y=%s/%s" +
                 " innerW/H=%s/%s scale=%s resultW/H=%s/%s",
                 srcWidth, srcHeight, w, h, srcX, srcY, srcCroppedW, srcCroppedH, scale,
                 cropped.getWidth(), cropped.getHeight());
         if (DEBUG && (w != cropped.getWidth() || h != cropped.getHeight())) {
-            LogUtils.e(PhotoManager.TAG, new Error(), "last center crop violated assumptions.");
+            LogUtils.e(TAG, new Error(), "BitmapUtils last center crop violated assumptions.");
         }
 
         return cropped;