new allocation-minimizing bitmap decoder

Use a new image decoding and caching framework. This one maximizes
bitmap reuse by decoding into fixed-sized Bitmaps and relying on
BitmapRegionDecoder to only decode a desired region. The ultimate goal
is to allocate memory less often, as each large allocation triggers
GC jank.

The overall request/response abstraction and threading model are now
cleaner, and caching is no longer a required fixture. Multi-core
decoding is now supported, with a currently fixed thread pool size of 4.

Attachment images are the only client for now, but contact photos will
switch to this framework next.

Bug: 9566006
Change-Id: I7c437941fd984cc0038da8f0ffd1df1a9ced4dd3
diff --git a/proguard.flags b/proguard.flags
index 0485533..6bcae61 100644
--- a/proguard.flags
+++ b/proguard.flags
@@ -60,9 +60,6 @@
 
 -keepclasseswithmembers class com.android.mail.browse.ConversationItemView {
   *** setAnimatedHeightFraction(...);
-  *** setAnimatedProgressFraction(...);
-  *** setAnimatedFadeFraction0(...);
-  *** setAnimatedFadeFraction1(...);
   *** setPhotoFlipFraction(...);
 }
 
diff --git a/res/values/colors.xml b/res/values/colors.xml
index 291048d..b47d8b3 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -33,7 +33,7 @@
     <color name="message_info_text_color">@color/gray_text_color</color>
     <color name="subject_text_color">#333333</color>
     <color name="ap_background_color">#fff1f1f2</color>
-    <color name="ap_overflow_badge_color">#eeeeeeee</color>
+    <color name="ap_overflow_badge_color">#cceeeeee</color>
     <color name="ap_overflow_text_color">#ff4f4c4c</color>
     <!-- a 'checked' item is in the conversation selection set. also the 'pressed' color. -->
     <!-- this is holo_blue_light @ 20% opacity -->
diff --git a/res/values/constants.xml b/res/values/constants.xml
index f0c15b5..68f9286 100644
--- a/res/values/constants.xml
+++ b/res/values/constants.xml
@@ -120,7 +120,7 @@
     <!-- Duration of fade in/out animation for attachment previews -->
     <integer name="ap_fade_animation_duration">500</integer>
     <!-- Duration of placeholder pulse animation for attachment previews -->
-    <integer name="ap_placeholder_animation_duration">2000</integer>
+    <integer name="ap_placeholder_animation_duration">1000</integer>
     <!-- Delay before showing progress bar animations for attachment previews that are loading -->
     <integer name="ap_progress_animation_delay">2000</integer>
     <!-- Max overflow count to show for attachment previews -->
diff --git a/src/com/android/bitmap/AltBitmapCache.java b/src/com/android/bitmap/AltBitmapCache.java
new file mode 100644
index 0000000..c5770f2
--- /dev/null
+++ b/src/com/android/bitmap/AltBitmapCache.java
@@ -0,0 +1,15 @@
+package com.android.bitmap;
+
+public class AltBitmapCache extends AltPooledCache<DecodeTask.Request, ReusableBitmap>
+        implements BitmapCache {
+
+    public AltBitmapCache(int targetSizeBytes) {
+        super(targetSizeBytes);
+    }
+
+    @Override
+    protected int sizeOf(ReusableBitmap value) {
+        return value.getByteCount();
+    }
+
+}
diff --git a/src/com/android/bitmap/AltPooledCache.java b/src/com/android/bitmap/AltPooledCache.java
new file mode 100644
index 0000000..1f0d050
--- /dev/null
+++ b/src/com/android/bitmap/AltPooledCache.java
@@ -0,0 +1,131 @@
+package com.android.bitmap;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.LinkedBlockingQueue;
+
+
+/**
+ * An alternative implementation of a pool+cache. This implementation only counts
+ * unreferenced objects in its size calculation. Internally, it never evicts from
+ * its cache, and instead {@link #poll()} is allowed to return unreferenced cache
+ * entries.
+ * <p>
+ * You would only use this kind of cache if your objects are interchangeable and
+ * have significant allocation cost, and if your memory footprint is somewhat
+ * flexible.
+ * <p>
+ * Because this class only counts unreferenced objects toward targetSize,
+ * it will have a total memory footprint of:
+ * <code>(targetSize) + (# of threads concurrently writing to cache) +
+ * (total size of still-referenced entries)</code>
+ *
+ */
+public class AltPooledCache<K, V extends RefCountable> implements PooledCache<K, V> {
+
+    private final LinkedHashMap<K, V> mCache;
+    private final LinkedBlockingQueue<V> mPool;
+    private final int mTargetSize;
+
+    /**
+     * @param targetSize not exactly a max size in practice
+     */
+    public AltPooledCache(int targetSize) {
+        mCache = new LinkedHashMap<K, V>(0, 0.75f, true);
+        mPool = new LinkedBlockingQueue<V>();
+        mTargetSize = targetSize;
+    }
+
+    @Override
+    public V get(K key) {
+        synchronized (mCache) {
+            return mCache.get(key);
+        }
+    }
+
+    @Override
+    public V put(K key, V value) {
+        synchronized (mCache) {
+            return mCache.put(key, value);
+        }
+    }
+
+    @Override
+    public void offer(V value) {
+        if (value.getRefCount() != 0) {
+            throw new IllegalArgumentException("unexpected offer of a referenced object: " + value);
+        }
+        mPool.offer(value);
+    }
+
+    @Override
+    public V poll() {
+        final V pooled = mPool.poll();
+        if (pooled != null) {
+            return pooled;
+        }
+
+        synchronized (mCache) {
+            int unrefSize = 0;
+            Map.Entry<K, V> eldestUnref = null;
+            for (Map.Entry<K, V> entry : mCache.entrySet()) {
+                final V value = entry.getValue();
+                if (value.getRefCount() > 0) {
+                    continue;
+                }
+                if (eldestUnref == null) {
+                    eldestUnref = entry;
+                }
+                unrefSize += sizeOf(value);
+                if (unrefSize > mTargetSize) {
+                    break;
+                }
+            }
+            // only return a scavenged cache entry if the cache has enough
+            // eligible (unreferenced) items
+            if (unrefSize <= mTargetSize) {
+                return null;
+            } else {
+                mCache.remove(eldestUnref.getKey());
+                return eldestUnref.getValue();
+            }
+        }
+    }
+
+    protected int sizeOf(V value) {
+        return 1;
+    }
+
+    @Override
+    public String toDebugString() {
+        final StringBuilder sb = new StringBuilder("[");
+        sb.append(super.toString());
+        int size = 0;
+        synchronized (mCache) {
+            sb.append(" poolCount=");
+            sb.append(mPool.size());
+            sb.append(" cacheSize=");
+            sb.append(mCache.size());
+            sb.append("\n---------------------");
+            for (V val : mPool) {
+                size += sizeOf(val);
+                sb.append("\n\tpool item: ");
+                sb.append(val);
+            }
+            sb.append("\n---------------------");
+            for (Map.Entry<K, V> item : mCache.entrySet()) {
+                final V val = item.getValue();
+                sb.append("\n\tcache key=");
+                sb.append(item.getKey());
+                sb.append(" val=");
+                sb.append(val);
+                size += sizeOf(val);
+            }
+            sb.append("\n---------------------");
+            sb.append("\nTOTAL SIZE=" + size);
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+
+}
diff --git a/src/com/android/bitmap/BitmapCache.java b/src/com/android/bitmap/BitmapCache.java
new file mode 100644
index 0000000..38279bb
--- /dev/null
+++ b/src/com/android/bitmap/BitmapCache.java
@@ -0,0 +1,5 @@
+package com.android.bitmap;
+
+public interface BitmapCache extends PooledCache<DecodeTask.Request, ReusableBitmap> {
+
+}
diff --git a/src/com/android/bitmap/BitmapUtils.java b/src/com/android/bitmap/BitmapUtils.java
new file mode 100644
index 0000000..72ef233
--- /dev/null
+++ b/src/com/android/bitmap/BitmapUtils.java
@@ -0,0 +1,62 @@
+package com.android.bitmap;
+
+import android.graphics.Rect;
+
+public abstract class BitmapUtils {
+
+    public static void calculateCroppedSrcRect(int srcW, int srcH, int dstW, int dstH,
+            int dstSliceH, float verticalSliceFraction, Rect outRect) {
+        calculateCroppedSrcRect(srcW, srcH, dstW, dstH, dstSliceH, Integer.MAX_VALUE,
+                verticalSliceFraction, outRect);
+    }
+
+    public static void calculateCroppedSrcRect(int srcW, int srcH, int dstW, int dstH,
+            int sampleSize, Rect outRect) {
+        calculateCroppedSrcRect(srcW, srcH, dstW, dstH, dstH, sampleSize, 0.5f, outRect);
+    }
+
+    /**
+     * Calculate a center-crop rectangle for the given input and output
+     * parameters. The output rectangle to use is written in the given outRect.
+     *
+     * @param srcW the source width
+     * @param srcH the source height
+     * @param dstW the destination width
+     * @param dstH the destination height
+     * @param dstSliceH the height extent (in destination coordinates) to
+     *            exclude when cropping. You would typically pass dstH, unless
+     *            you are trying to normalize different items to the same
+     *            vertical crop range.
+     * @param sampleSize a scaling factor that rect calculation will only use if
+     *            it's more aggressive than regular scaling
+     * @param verticalSliceFraction the vertical center point for the crop rect,
+     *            from [0.0, 1.0]. To perform a vertically centered crop, use
+     *            0.5.
+     * @param outRect a Rect to write the resulting crop coordinates into
+     */
+    public static void calculateCroppedSrcRect(int srcW, int srcH, int dstW, int dstH,
+            int dstSliceH, int sampleSize, float verticalSliceFraction, Rect outRect) {
+        if (sampleSize < 1) {
+            sampleSize = 1;
+        }
+        final float regularScale = Math.min(
+                (float) srcW / dstW,
+                (float) srcH / dstH);
+
+        final float scale = Math.min(sampleSize, regularScale);
+
+        final int srcCroppedW = Math.round(dstW * scale);
+        final int srcCroppedH = Math.round(dstH * scale);
+        final int srcCroppedSliceH = Math.round(dstSliceH * scale);
+
+        outRect.left = (srcW - srcCroppedW) / 2;
+        outRect.right = outRect.left + srcCroppedW;
+
+        final int centerV = Math.round(
+                (srcH - srcCroppedSliceH) * verticalSliceFraction + srcCroppedH / 2);
+
+        outRect.top = centerV - srcCroppedH / 2;
+        outRect.bottom = outRect.top + srcCroppedH;
+    }
+
+}
diff --git a/src/com/android/bitmap/DecodeTask.java b/src/com/android/bitmap/DecodeTask.java
new file mode 100644
index 0000000..b679945
--- /dev/null
+++ b/src/com/android/bitmap/DecodeTask.java
@@ -0,0 +1,321 @@
+package com.android.bitmap;
+
+import android.content.res.AssetFileDescriptor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.BitmapRegionDecoder;
+import android.graphics.Rect;
+import android.os.AsyncTask;
+
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Decodes an image from either a file descriptor or input stream on a worker thread. After the
+ * decode is complete, even if the task is cancelled, the result is placed in the given cache.
+ * A {@link BitmapView} client may be notified on decode begin and completion.
+ * <p>
+ * This class uses {@link BitmapRegionDecoder} when possible to minimize unnecessary decoding
+ * and allow bitmap reuse on Jellybean 4.1 and later.
+ * <p>
+ * FIXME: add GIF support when {@link BitmapRegionDecoder} fails to decode it. Because BitmapFactory
+ * is typically too picky to support bitmap reuse, we must also mark decoded result as not eligible
+ * for pooling as it would be sized to be an exact fit for that GIF.
+ *
+ */
+public class DecodeTask extends AsyncTask<Void, Void, ReusableBitmap> {
+
+    private final Request mKey;
+    private final int mDestW;
+    private final int mDestH;
+    private final BitmapView mView;
+    private final BitmapCache mCache;
+    private final BitmapFactory.Options mOpts = new BitmapFactory.Options();
+
+    private ReusableBitmap mInBitmap = null;
+
+    private static final boolean CROP_DURING_DECODE = true;
+
+    private static final boolean DEBUG = false;
+
+    /**
+     * The decode task uses this class to get input to decode. You must implement at least one of
+     * {@link #createFd()} or {@link #createInputStream()}. {@link DecodeTask} will prioritize
+     * {@link #createFd()} before falling back to {@link #createInputStream()}.
+     * <p>
+     * When {@link DecodeTask} is used in conjunction with a {@link BitmapCache}, objects of this
+     * type will also serve as cache keys to fetch cached data.
+     */
+    public interface Request {
+        AssetFileDescriptor createFd() throws IOException;
+        InputStream createInputStream() throws IOException;
+    }
+
+    /**
+     * Callback interface for clients to be notified of decode state changes and completion.
+     */
+    public interface BitmapView {
+        /**
+         * Notifies that the async task's work is about to begin. Up until this point, the task
+         * 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);
+    }
+
+    public DecodeTask(Request key, int w, int h, BitmapView view, BitmapCache cache) {
+        mKey = key;
+        mDestW = w;
+        mDestH = h;
+        mView = view;
+        mCache = cache;
+    }
+
+    @Override
+    protected ReusableBitmap doInBackground(Void... params) {
+        if (isCancelled()) {
+            return null;
+        }
+
+        // enqueue the 'onDecodeBegin' signal on the main thread
+        publishProgress();
+
+        Trace.beginSection("decodeBounds");
+        ReusableBitmap result = null;
+        AssetFileDescriptor fd = null;
+        InputStream in = null;
+        try {
+            fd = mKey.createFd();
+            if (fd == null) {
+                in = mKey.createInputStream();
+                if (in != null && !in.markSupported()) {
+                    throw new IllegalArgumentException("input stream must support reset()");
+                }
+            }
+
+            mOpts.inJustDecodeBounds = true;
+            if (fd != null) {
+                BitmapFactory.decodeFileDescriptor(fd.getFileDescriptor(), null, mOpts);
+            } else {
+                BitmapFactory.decodeStream(in, null, mOpts);
+            }
+
+            if (isCancelled()) {
+                return null;
+            }
+            Trace.endSection();
+            final int srcW = mOpts.outWidth;
+            final int srcH = mOpts.outHeight;
+
+            mOpts.inJustDecodeBounds = false;
+            mOpts.inMutable = true;
+            mOpts.inSampleSize = calculateSampleSize(srcW, srcH, mDestW, mDestH);
+            if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) {
+                mInBitmap = mCache.poll();
+                if (mInBitmap == null) {
+                    if (DEBUG) System.err.println(
+                            "decode thread wants a bitmap. cache dump:\n" + mCache.toDebugString());
+                    mInBitmap = new ReusableBitmap(
+                            Bitmap.createBitmap(mDestW, mDestH, Bitmap.Config.ARGB_8888));
+                    if (DEBUG) System.err.println("*** allocated new bitmap in decode thread: "
+                            + mInBitmap + " key=" + mKey);
+                } else {
+                    if (DEBUG) System.out.println("*** reusing existing bitmap in decode thread: "
+                            + mInBitmap + " key=" + mKey);
+
+                }
+                mOpts.inBitmap = mInBitmap.bmp;
+            }
+
+            if (isCancelled()) {
+                return null;
+            }
+
+            Bitmap decodeResult = null;
+
+            if (in != null) {
+                in.reset();
+            }
+            final Rect srcRect = new Rect();
+            if (CROP_DURING_DECODE) {
+                try {
+                    Trace.beginSection("decodeCropped" + mOpts.inSampleSize);
+                    decodeResult = decodeCropped(fd, in, srcRect);
+                } catch (IOException e) {
+                    // fall through to below and try again with the non-cropping decoder
+                    e.printStackTrace();
+                } finally {
+                    Trace.endSection();
+                }
+            }
+
+            if (!CROP_DURING_DECODE || (decodeResult == null && !isCancelled())) {
+                try {
+                    Trace.beginSection("decode" + mOpts.inSampleSize);
+                    decodeResult = decode(fd, in);
+                } catch (IllegalArgumentException e) {
+                    System.err.println("decode failed: reason='" + e.getMessage() + "' ss="
+                            + mOpts.inSampleSize);
+
+                    if (mOpts.inSampleSize > 1) {
+                        // try again with ss=1
+                        mOpts.inSampleSize = 1;
+                        decodeResult = decode(fd, in);
+                    }
+                } finally {
+                    Trace.endSection();
+                }
+            }
+
+            if (!isCancelled() && decodeResult != null) {
+                if (mInBitmap != null) {
+                    result = mInBitmap;
+                    // srcRect is non-empty when using the cropping BitmapRegionDecoder codepath
+                    if (!srcRect.isEmpty()) {
+                        result.setLogicalWidth((srcRect.right - srcRect.left) / mOpts.inSampleSize);
+                        result.setLogicalHeight(
+                                (srcRect.bottom - srcRect.top) / mOpts.inSampleSize);
+                    } else {
+                        result.setLogicalWidth(mOpts.outWidth);
+                        result.setLogicalHeight(mOpts.outHeight);
+                    }
+                } else {
+                    // no mInBitmap means no pooling
+                    result = new ReusableBitmap(decodeResult);
+                    result.setLogicalWidth(decodeResult.getWidth());
+                    result.setLogicalHeight(decodeResult.getHeight());
+                }
+                // System.out.println("*** async task decoded fd=" + mUri +
+                // " to" +
+                // " sz=" + (decodeResult.getByteCount() >> 10) + "KB dstW/H=" +
+                // result.getLogicalWidth() +
+                // "/" + result.getLogicalHeight() + " srcW/H=" + srcW + "/" +
+                // srcH + " ss=" +
+                // mOpts.inSampleSize + " mutable=" + decodeResult.isMutable() +
+                // " matchesInBitmap=" + (mOpts.inBitmap == decodeResult));
+            } else {
+                // System.out.println("*** async task cancelled decode of fd=" +
+                // mUri);
+            }
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            if (fd != null) {
+                try {
+                    fd.close();
+                } catch (IOException e) {
+                    e.printStackTrace();
+                }
+            }
+            if (in != null) {
+                try {
+                    in.close();
+                } catch (IOException e) {
+                    e.printStackTrace();
+                }
+            }
+            if (result != null) {
+                result.acquireReference();
+                mCache.put(mKey, result);
+                if (DEBUG) System.out.println("placed result in cache: key=" + mKey + " bmp="
+                        + result + " cancelled=" + isCancelled());
+            } else if (isCancelled() && mInBitmap != null) {
+                if (DEBUG) System.out.println("placing cancelled bitmap in pool: key=" + mKey
+                        + " bmp=" + mInBitmap);
+                mCache.offer(mInBitmap);
+            }
+        }
+        return result;
+    }
+
+    private Bitmap decodeCropped(AssetFileDescriptor fd, InputStream in, Rect outSrcRect)
+            throws IOException {
+        final BitmapRegionDecoder brd;
+        if (fd != null) {
+            brd = BitmapRegionDecoder.newInstance(fd.getFileDescriptor(),
+                    true /* shareable */);
+        } else {
+            brd = BitmapRegionDecoder.newInstance(in,
+                    true /* shareable */);
+        }
+        if (isCancelled()) {
+            brd.recycle();
+            return null;
+        }
+
+        final int srcW = mOpts.outWidth;
+        final int srcH = mOpts.outHeight;
+
+        // Trace.beginSection("DecodeRegionGetDimens");
+        // final int tmpw = brd.getWidth();
+        // final int tmph = brd.getHeight();
+        // Trace.endSection();
+
+        BitmapUtils.calculateCroppedSrcRect(srcW, srcH, mDestW, mDestH, mOpts.inSampleSize,
+                outSrcRect);
+        // System.out.println("rect for uri=" + uri + " is: " + outSrcRect);
+        final Bitmap result = brd.decodeRegion(outSrcRect, mOpts);
+        brd.recycle();
+        return result;
+    }
+
+    private Bitmap decode(AssetFileDescriptor fd, InputStream in) {
+        final Bitmap result;
+        if (fd != null) {
+            result = BitmapFactory.decodeFileDescriptor(fd.getFileDescriptor(), null, mOpts);
+        } else {
+            result = BitmapFactory.decodeStream(in, null, mOpts);
+        }
+        return result;
+    }
+
+    private int calculateSampleSize(int srcW, int srcH, int destW, int destH) {
+        int result;
+
+        final float sz = Math.min((float) srcW / destW, (float) srcH / destH);
+
+        // round to the nearest power of two, or just truncate
+        final boolean stricter = true;
+
+        if (stricter) {
+            result = (int) Math.pow(2, (int) (0.5 + (Math.log(sz) / Math.log(2))));
+        } else {
+            result = (int) sz;
+        }
+        return Math.max(1, result);
+    }
+
+    public void cancel() {
+        cancel(true);
+        mOpts.requestCancelDecode();
+    }
+
+    @Override
+    protected void onProgressUpdate(Void... values) {
+        mView.onDecodeBegin(mKey);
+    }
+
+    @Override
+    public void onPostExecute(ReusableBitmap result) {
+        mView.onDecodeComplete(mKey, result);
+    }
+
+    @Override
+    protected void onCancelled(ReusableBitmap result) {
+        if (result == null) {
+            return;
+        }
+
+        result.releaseReference();
+        if (mInBitmap == null) {
+            // not reusing bitmaps: can recycle immediately
+            result.bmp.recycle();
+        }
+    }
+
+}
diff --git a/src/com/android/bitmap/PooledCache.java b/src/com/android/bitmap/PooledCache.java
new file mode 100644
index 0000000..d1fd409
--- /dev/null
+++ b/src/com/android/bitmap/PooledCache.java
@@ -0,0 +1,11 @@
+package com.android.bitmap;
+
+public interface PooledCache<K, V> {
+
+    V get(K key);
+    V put(K key, V value);
+    void offer(V scrapValue);
+    V poll();
+    String toDebugString();
+
+}
diff --git a/src/com/android/bitmap/RefCountable.java b/src/com/android/bitmap/RefCountable.java
new file mode 100644
index 0000000..97b0711
--- /dev/null
+++ b/src/com/android/bitmap/RefCountable.java
@@ -0,0 +1,7 @@
+package com.android.bitmap;
+
+public interface RefCountable {
+    void acquireReference();
+    void releaseReference();
+    int getRefCount();
+}
diff --git a/src/com/android/bitmap/ReusableBitmap.java b/src/com/android/bitmap/ReusableBitmap.java
new file mode 100644
index 0000000..dff42f3
--- /dev/null
+++ b/src/com/android/bitmap/ReusableBitmap.java
@@ -0,0 +1,79 @@
+package com.android.bitmap;
+
+import android.graphics.Bitmap;
+
+/**
+ * A simple bitmap wrapper. Currently supports reference counting and logical width/height
+ * (which may differ from a bitmap's reported width/height due to bitmap reuse).
+ */
+public class ReusableBitmap implements RefCountable {
+
+    public final Bitmap bmp;
+    private int mWidth;
+    private int mHeight;
+    private int mRefCount = 0;
+
+    public ReusableBitmap(Bitmap bitmap) {
+        bmp = bitmap;
+    }
+
+    public void setLogicalWidth(int w) {
+        mWidth = w;
+    }
+
+    public void setLogicalHeight(int h) {
+        mHeight = h;
+    }
+
+    public int getLogicalWidth() {
+        return mWidth;
+    }
+
+    public int getLogicalHeight() {
+        return mHeight;
+    }
+
+    public int getByteCount() {
+        return bmp.getByteCount();
+    }
+
+    @Override
+    public void acquireReference() {
+        mRefCount++;
+    }
+
+    @Override
+    public void releaseReference() {
+        if (mRefCount == 0) {
+            throw new IllegalStateException();
+        }
+        mRefCount--;
+    }
+
+    @Override
+    public int getRefCount() {
+        return mRefCount;
+    }
+
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder("[");
+        sb.append(super.toString());
+        sb.append(" refCount=");
+        sb.append(mRefCount);
+        sb.append(" bmp=");
+        sb.append(bmp);
+        sb.append(" logicalW/H=");
+        sb.append(mWidth);
+        sb.append("/");
+        sb.append(mHeight);
+        if (bmp != null) {
+            sb.append(" sz=");
+            sb.append(bmp.getByteCount() >> 10);
+            sb.append("KB");
+        }
+        sb.append("]");
+        return sb.toString();
+    }
+
+}
diff --git a/src/com/android/bitmap/Trace.java b/src/com/android/bitmap/Trace.java
new file mode 100644
index 0000000..8bfe6b8
--- /dev/null
+++ b/src/com/android/bitmap/Trace.java
@@ -0,0 +1,35 @@
+package com.android.bitmap;
+
+import android.os.Build;
+
+/**
+ * Stand-in for {@link android.os.Trace}.
+ */
+public abstract class Trace {
+
+    /**
+     * Begins systrace tracing for a given tag. No-op on unsupported platform versions.
+     *
+     * @param tag systrace tag to use
+     *
+     * @see android.os.Trace#beginSection(String)
+     */
+    public static void beginSection(String tag) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
+            android.os.Trace.beginSection(tag);
+        }
+    }
+
+    /**
+     * Ends systrace tracing for the most recently begun section. No-op on unsupported platform
+     * versions.
+     *
+     * @see android.os.Trace#endSection()
+     */
+    public static void endSection() {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
+            android.os.Trace.endSection();
+        }
+    }
+
+}
diff --git a/src/com/android/mail/bitmap/AttachmentDrawable.java b/src/com/android/mail/bitmap/AttachmentDrawable.java
new file mode 100644
index 0000000..bf2e1df
--- /dev/null
+++ b/src/com/android/mail/bitmap/AttachmentDrawable.java
@@ -0,0 +1,399 @@
+package com.android.mail.bitmap;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.ValueAnimator;
+import android.animation.ValueAnimator.AnimatorUpdateListener;
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.os.Handler;
+import android.util.DisplayMetrics;
+import android.view.animation.LinearInterpolator;
+
+import com.android.bitmap.BitmapCache;
+import com.android.bitmap.BitmapUtils;
+import com.android.bitmap.DecodeTask;
+import com.android.bitmap.DecodeTask.Request;
+import com.android.bitmap.ReusableBitmap;
+import com.android.mail.R;
+import com.android.mail.browse.ConversationItemViewCoordinates;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * This class encapsulates all functionality needed to display a single image attachment thumbnail,
+ * including request creation/cancelling, data unbinding and re-binding, and fancy animations
+ * to draw upon state changes.
+ * <p>
+ * The actual bitmap decode work is handled by {@link DecodeTask}.
+ */
+public class AttachmentDrawable extends Drawable implements DecodeTask.BitmapView,
+        Drawable.Callback, Runnable, Parallaxable {
+
+    private DecodeTask.Request mCurrKey;
+    private ReusableBitmap mBitmap;
+    private final BitmapCache mCache;
+    private DecodeTask mTask;
+    private int mDecodeWidth;
+    private int mDecodeHeight;
+    private int mLoadState = LOAD_STATE_UNINITIALIZED;
+    private float mParallaxFraction = 0.5f;
+
+    // each attachment gets its own placeholder and progress indicator, to be shown, hidden,
+    // and animated based on Drawable#setVisible() changes, which are in turn driven by
+    // #setLoadState().
+    private Placeholder mPlaceholder;
+    private Progress mProgress;
+
+    private static final Executor SMALL_POOL_EXECUTOR = new ThreadPoolExecutor(4, 4,
+            1, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
+
+    private static final Executor EXECUTOR = SMALL_POOL_EXECUTOR;
+
+    private static final boolean LIMIT_BITMAP_DENSITY = true;
+
+    private static final int MAX_BITMAP_DENSITY = DisplayMetrics.DENSITY_HIGH;
+
+    private static final int LOAD_STATE_UNINITIALIZED = 0;
+    private static final int LOAD_STATE_NOT_LOADED = 1;
+    private static final int LOAD_STATE_LOADING = 2;
+    private static final int LOAD_STATE_LOADED = 3;
+
+    private final ConversationItemViewCoordinates mCoordinates;
+    private final float mDensity;
+    private final int mProgressDelayMs;
+    private final Paint mPaint = new Paint();
+    private final Rect mSrcRect = new Rect();
+    private final Handler mHandler = new Handler();
+
+    public AttachmentDrawable(Resources res, BitmapCache cache,
+            ConversationItemViewCoordinates coordinates, Drawable placeholder, Drawable progress) {
+        mCoordinates = coordinates;
+        mDensity = res.getDisplayMetrics().density;
+        mCache = cache;
+        mPaint.setFilterBitmap(true);
+
+        final int fadeOutDurationMs = res.getInteger(R.integer.ap_fade_animation_duration);
+        final int tileColor = res.getColor(R.color.ap_background_color);
+        mProgressDelayMs = res.getInteger(R.integer.ap_progress_animation_delay);
+
+        mPlaceholder = new Placeholder(placeholder.getConstantState().newDrawable(res), res,
+                coordinates, fadeOutDurationMs, tileColor);
+        mPlaceholder.setCallback(this);
+
+        mProgress = new Progress(progress.getConstantState().newDrawable(res), res,
+                coordinates, fadeOutDurationMs, tileColor);
+        mProgress.setCallback(this);
+    }
+
+    public DecodeTask.Request getKey() {
+        return mCurrKey;
+    }
+
+    public void setDecodeWidth(int width) {
+        mDecodeWidth = width;
+    }
+
+    public void setDecodeHeight(int height) {
+        mDecodeHeight = height;
+    }
+
+    public void setImage(DecodeTask.Request key) {
+        if (mCurrKey != null && mCurrKey.equals(key)) {
+            return;
+        }
+        if (mBitmap != null) {
+            mBitmap.releaseReference();
+//            System.out.println("view.bind() decremented ref to old bitmap: " + mBitmap);
+            mBitmap = null;
+        }
+        mCurrKey = key;
+
+        if (mTask != null) {
+            mTask.cancel();
+            mTask = null;
+        }
+
+        mHandler.removeCallbacks(this);
+        setLoadState(LOAD_STATE_UNINITIALIZED);
+
+        if (key == null) {
+            return;
+        }
+
+        // find cached entry here and skip decode if found.
+        final ReusableBitmap cached = mCache.get(key);
+        if (cached != null) {
+            cached.acquireReference();
+            setBitmap(cached);
+            setLoadState(LOAD_STATE_LOADED);
+        } else {
+            decode();
+        }
+    }
+
+    @Override
+    public void setParallaxFraction(float fraction) {
+        mParallaxFraction = fraction;
+    }
+
+    @Override
+    public void draw(Canvas canvas) {
+        final Rect bounds = getBounds();
+        if (bounds.isEmpty()) {
+            return;
+        }
+
+        if (mBitmap != null) {
+            BitmapUtils.calculateCroppedSrcRect(mBitmap.getLogicalWidth(),
+                    mBitmap.getLogicalHeight(), bounds.width(), bounds.height(),
+                    mCoordinates.attachmentPreviewsDecodeHeight, mParallaxFraction, mSrcRect);
+            canvas.drawBitmap(mBitmap.bmp, mSrcRect, bounds, mPaint);
+        }
+
+        // Draw the two possible overlay layers in reverse-priority order.
+        // (each layer will no-op the draw when appropriate)
+        // This ordering means cross-fade transitions are just fade-outs of each layer.
+        mProgress.draw(canvas);
+        mPlaceholder.draw(canvas);
+    }
+
+    @Override
+    public void setAlpha(int alpha) {
+        final int old = mPaint.getAlpha();
+        mPaint.setAlpha(alpha);
+        mPlaceholder.setAlpha(alpha);
+        mProgress.setAlpha(alpha);
+        if (alpha != old) {
+            invalidateSelf();
+        }
+    }
+
+    @Override
+    public void setColorFilter(ColorFilter cf) {
+        mPaint.setColorFilter(cf);
+        mPlaceholder.setColorFilter(cf);
+        mProgress.setColorFilter(cf);
+        invalidateSelf();
+    }
+
+    @Override
+    public int getOpacity() {
+        return (mBitmap != null && (mBitmap.bmp.hasAlpha() || mPaint.getAlpha() < 255)) ?
+                PixelFormat.TRANSLUCENT : PixelFormat.OPAQUE;
+    }
+
+    @Override
+    protected void onBoundsChange(Rect bounds) {
+        super.onBoundsChange(bounds);
+
+        mPlaceholder.setBounds(bounds);
+        mProgress.setBounds(bounds);
+    }
+
+    @Override
+    public void onDecodeBegin(Request key) {
+        if (!key.equals(mCurrKey)) {
+            return;
+        }
+        // normally, we'd transition to the LOADING state now, but we want to delay that a bit
+        // to minimize excess occurrences of the rotating spinner
+        mHandler.postDelayed(this, mProgressDelayMs);
+    }
+
+    @Override
+    public void run() {
+        if (mLoadState == LOAD_STATE_NOT_LOADED) {
+            setLoadState(LOAD_STATE_LOADING);
+        }
+    }
+
+    @Override
+    public void onDecodeComplete(Request key, ReusableBitmap result) {
+        if (key.equals(mCurrKey)) {
+            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();
+            }
+        }
+    }
+
+    private void setBitmap(ReusableBitmap bmp) {
+        if (mBitmap != null && mBitmap != bmp) {
+            mBitmap.releaseReference();
+        }
+        mBitmap = bmp;
+        setLoadState(LOAD_STATE_LOADED);
+        invalidateSelf();
+    }
+
+    private void decode() {
+        final int w;
+        final int h;
+
+        if (LIMIT_BITMAP_DENSITY) {
+            final float scale =
+                    Math.min(1f, (float) MAX_BITMAP_DENSITY / DisplayMetrics.DENSITY_DEFAULT
+                            / mDensity);
+            w = (int) (mDecodeWidth * scale);
+            h = (int) (mDecodeHeight * scale);
+        } else {
+            w = mDecodeWidth;
+            h = mDecodeHeight;
+        }
+
+        if (w == 0 || h == 0 || mCurrKey == null) {
+            return;
+        }
+//        System.out.println("ITEM " + this + " w=" + w + " h=" + h);
+        if (mTask != null) {
+            mTask.cancel();
+        }
+        setLoadState(LOAD_STATE_NOT_LOADED);
+        mTask = new DecodeTask(mCurrKey, w, h, this, mCache);
+        mTask.executeOnExecutor(EXECUTOR);
+    }
+
+    private void setLoadState(int loadState) {
+        if (mLoadState == loadState) {
+            return;
+        }
+
+        switch (loadState) {
+            case LOAD_STATE_UNINITIALIZED:
+                mPlaceholder.reset();
+                mProgress.reset();
+                break;
+            case LOAD_STATE_NOT_LOADED:
+                mPlaceholder.setVisible(true);
+                mProgress.setVisible(false);
+                break;
+            case LOAD_STATE_LOADING:
+                mPlaceholder.setVisible(false);
+                mProgress.setVisible(true);
+                break;
+            case LOAD_STATE_LOADED:
+                mPlaceholder.setVisible(false);
+                mProgress.setVisible(false);
+                break;
+        }
+
+        mLoadState = loadState;
+    }
+
+    @Override
+    public void invalidateDrawable(Drawable who) {
+        invalidateSelf();
+    }
+
+    @Override
+    public void scheduleDrawable(Drawable who, Runnable what, long when) {
+        scheduleSelf(what, when);
+    }
+
+    @Override
+    public void unscheduleDrawable(Drawable who, Runnable what) {
+        unscheduleSelf(what);
+    }
+
+    private static class Placeholder extends TileDrawable {
+
+        private final ValueAnimator mPulseAnimator;
+
+        public Placeholder(Drawable placeholder, Resources res,
+                ConversationItemViewCoordinates coordinates, int fadeOutDurationMs,
+                int tileColor) {
+            super(placeholder, coordinates.placeholderWidth, coordinates.placeholderHeight,
+                    tileColor, fadeOutDurationMs);
+            mPulseAnimator = ValueAnimator.ofInt(55, 255)
+                    .setDuration(res.getInteger(R.integer.ap_placeholder_animation_duration));
+            mPulseAnimator.setRepeatCount(ValueAnimator.INFINITE);
+            mPulseAnimator.setRepeatMode(ValueAnimator.REVERSE);
+            mPulseAnimator.addUpdateListener(new AnimatorUpdateListener() {
+                @Override
+                public void onAnimationUpdate(ValueAnimator animation) {
+                    setInnerAlpha((Integer) animation.getAnimatedValue());
+                }
+            });
+        }
+
+        @Override
+        public boolean setVisible(boolean visible) {
+            final boolean changed = super.setVisible(visible);
+            if (changed) {
+                if (isVisible()) {
+                    // start
+                    if (mPulseAnimator != null) {
+                        mPulseAnimator.start();
+                    }
+                } else {
+                    // stop
+                    if (mPulseAnimator != null) {
+                        mPulseAnimator.cancel();
+                    }
+                }
+            }
+            return changed;
+        }
+
+    }
+
+    private static class Progress extends TileDrawable {
+
+        private final ValueAnimator mRotateAnimator;
+
+        public Progress(Drawable progress, Resources res,
+                ConversationItemViewCoordinates coordinates, int fadeOutDurationMs,
+                int tileColor) {
+            super(progress, coordinates.progressBarWidth, coordinates.progressBarHeight,
+                    tileColor, fadeOutDurationMs);
+
+            mRotateAnimator = ValueAnimator.ofInt(0, 10000)
+                    .setDuration(res.getInteger(R.integer.ap_progress_animation_duration));
+            mRotateAnimator.setInterpolator(new LinearInterpolator());
+            mRotateAnimator.setRepeatCount(ValueAnimator.INFINITE);
+            mRotateAnimator.addUpdateListener(new AnimatorUpdateListener() {
+                @Override
+                public void onAnimationUpdate(ValueAnimator animation) {
+                    setLevel((Integer) animation.getAnimatedValue());
+                }
+            });
+            mFadeOutAnimator.addListener(new AnimatorListenerAdapter() {
+                @Override
+                public void onAnimationEnd(Animator animation) {
+                    if (mRotateAnimator != null) {
+                        mRotateAnimator.cancel();
+                    }
+                }
+            });
+        }
+
+        @Override
+        public boolean setVisible(boolean visible) {
+            final boolean changed = super.setVisible(visible);
+            if (changed) {
+                if (isVisible()) {
+                    if (mRotateAnimator != null) {
+                        mRotateAnimator.start();
+                    }
+                } else {
+                    // can't cancel the rotate yet-- wait for the fade-out animation to end
+                }
+            }
+            return changed;
+        }
+
+    }
+
+}
diff --git a/src/com/android/mail/bitmap/AttachmentGridDrawable.java b/src/com/android/mail/bitmap/AttachmentGridDrawable.java
new file mode 100644
index 0000000..fc58472
--- /dev/null
+++ b/src/com/android/mail/bitmap/AttachmentGridDrawable.java
@@ -0,0 +1,118 @@
+package com.android.mail.bitmap;
+
+import android.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Paint.Align;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+
+import com.android.bitmap.BitmapCache;
+import com.android.mail.R;
+import com.android.mail.browse.ConversationItemViewCoordinates;
+
+
+/**
+ * A 2x1 grid of attachment drawables. Supports showing a small "+N" badge in the corner.
+ */
+public class AttachmentGridDrawable extends CompositeDrawable<AttachmentDrawable>
+        implements Parallaxable {
+
+    public static final int MAX_VISIBLE_ATTACHMENT_COUNT = 2;
+
+    private BitmapCache mCache;
+    private int mDecodeWidth;
+    private int mDecodeHeight;
+    private String mOverflowText;
+    private ConversationItemViewCoordinates mCoordinates;
+    private float mParallaxFraction = 0.5f;
+
+    private final Resources mResources;
+    private final Drawable mPlaceholder;
+    private final Drawable mProgress;
+    private final int mOverflowTextColor;
+    private final int mOverflowBadgeColor;
+    private final Paint mPaint = new Paint();
+    private final Rect mRect = new Rect();
+
+    public AttachmentGridDrawable(Resources res, Drawable placeholder, Drawable progress) {
+        super(MAX_VISIBLE_ATTACHMENT_COUNT);
+        mResources = res;
+        mPlaceholder = placeholder;
+        mProgress = progress;
+        mOverflowTextColor = res.getColor(R.color.ap_overflow_text_color);
+        mOverflowBadgeColor = res.getColor(R.color.ap_overflow_badge_color);
+
+        mPaint.setAntiAlias(true);
+    }
+
+    @Override
+    protected AttachmentDrawable createDivisionDrawable() {
+        final AttachmentDrawable result = new AttachmentDrawable(mResources, mCache, mCoordinates,
+                mPlaceholder, mProgress);
+        result.setDecodeWidth(mDecodeWidth);
+        result.setDecodeHeight(mDecodeHeight);
+        return result;
+    }
+
+    public void setBitmapCache(BitmapCache cache) {
+        mCache = cache;
+    }
+
+    public void setDecodeWidth(int width) {
+        mDecodeWidth = width;
+    }
+
+    public void setDecodeHeight(int height) {
+        mDecodeHeight = height;
+    }
+
+    public void setOverflowText(String text) {
+        mOverflowText = text;
+        layoutOverflowBadge();
+    }
+
+    public void setCoordinates(ConversationItemViewCoordinates coordinates) {
+        mCoordinates = coordinates;
+        layoutOverflowBadge();
+    }
+
+    private void layoutOverflowBadge() {
+        if (mCoordinates == null || mOverflowText == null) {
+            return;
+        }
+        mPaint.setTextSize(mCoordinates.overflowFontSize);
+        mPaint.setTypeface(mCoordinates.overflowTypeface);
+        mPaint.setTextAlign(Align.CENTER);
+        mPaint.getTextBounds(mOverflowText, 0, mOverflowText.length(), mRect);
+    }
+
+    @Override
+    public void draw(Canvas canvas) {
+        for (int i = 0; i < mCount; i++) {
+            mDrawables.get(i).setParallaxFraction(mParallaxFraction);
+        }
+
+        super.draw(canvas);
+
+        // Overflow badge and count
+        if (mOverflowText != null && mCoordinates != null) {
+            final float radius = mCoordinates.overflowDiameter / 2f;
+            // transform item-level coordinates into local drawable coordinate space
+            final float x = mCoordinates.overflowXEnd - mCoordinates.attachmentPreviewsX - radius;
+            final float y = mCoordinates.overflowYEnd - mCoordinates.attachmentPreviewsY - radius;
+
+            mPaint.setColor(mOverflowBadgeColor);
+            canvas.drawCircle(x, y, radius, mPaint);
+
+            mPaint.setColor(mOverflowTextColor);
+            canvas.drawText(mOverflowText, x, y + (mRect.height() / 2f), mPaint);
+        }
+    }
+
+    @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
new file mode 100644
index 0000000..cca1103
--- /dev/null
+++ b/src/com/android/mail/bitmap/CompositeDrawable.java
@@ -0,0 +1,143 @@
+package com.android.mail.bitmap;
+
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A drawable that contains up to 4 other smaller drawables in a regular grid. This class attempts
+ * to reuse inner drawables when feasible to promote object reuse. This design goal makes the API
+ * to reuse an instance a little awkward: you must first zero out the count, then set it to a new
+ * value, then populate the entries with {@link #getOrCreateDrawable(int)} and a drawable subclass
+ * bind() method of your design.
+ */
+public abstract class CompositeDrawable<T extends Drawable> extends Drawable
+        implements Drawable.Callback {
+
+    protected final List<T> mDrawables;
+    protected int mCount;
+
+    public CompositeDrawable(int maxDivisions) {
+        if (maxDivisions >= 4) {
+            throw new IllegalArgumentException("CompositeDrawable only supports 4 divisions");
+        }
+        mDrawables = new ArrayList<T>(maxDivisions);
+        for (int i = 0; i < maxDivisions; i++) {
+            mDrawables.add(i, null);
+        }
+        mCount = 0;
+    }
+
+    protected abstract T createDivisionDrawable();
+
+    public void setCount(int count) {
+        // zero out the composite bounds, which will propagate to the division drawables
+        // this invalidates any old division bounds, which may change with the count
+        setBounds(0, 0, 0, 0);
+        mCount = count;
+    }
+
+    public int getCount() {
+        return mCount;
+    }
+
+    public T getOrCreateDrawable(int i) {
+        if (i >= mCount) {
+            throw new IllegalArgumentException("bad index: " + i);
+        }
+
+        T result = mDrawables.get(i);
+        if (result == null) {
+            result = createDivisionDrawable();
+            mDrawables.set(i, result);
+            result.setCallback(this);
+        }
+        return result;
+    }
+
+    @Override
+    protected void onBoundsChange(Rect bounds) {
+        final int w = bounds.width();
+        final int h = bounds.height();
+        final int mw = w / 2;
+        final int mh = h / 2;
+        switch (mCount) {
+            case 1:
+                // 1 bitmap: passthrough
+                mDrawables.get(0).setBounds(bounds);
+                break;
+            case 2:
+                // 2 bitmaps split vertically
+                mDrawables.get(0).setBounds(0, 0, mw, h);
+                mDrawables.get(1).setBounds(mw, 0, w, h);
+                break;
+            case 3:
+                // 1st is tall on the left, 2nd/3rd stacked vertically on the right
+                mDrawables.get(0).setBounds(0, 0, mw, h);
+                mDrawables.get(1).setBounds(mw, 0, w, mh);
+                mDrawables.get(2).setBounds(mw, mh, w, h);
+                break;
+            case 4:
+                // 4 bitmaps in a 2x2 grid
+                mDrawables.get(0).setBounds(0, 0, mw, mh);
+                mDrawables.get(1).setBounds(mw, 0, w, mh);
+                mDrawables.get(2).setBounds(0, mh, mw, h);
+                mDrawables.get(3).setBounds(mw, mh, w, h);
+                break;
+        }
+    }
+
+    @Override
+    public void draw(Canvas canvas) {
+        for (int i = 0; i < mCount; i++) {
+            mDrawables.get(i).draw(canvas);
+        }
+    }
+
+    @Override
+    public void setAlpha(int alpha) {
+        for (int i = 0; i < mCount; i++) {
+            mDrawables.get(i).setAlpha(alpha);
+        }
+    }
+
+    @Override
+    public void setColorFilter(ColorFilter cf) {
+        for (int i = 0; i < mCount; i++) {
+            mDrawables.get(i).setColorFilter(cf);
+        }
+    }
+
+    @Override
+    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;
+            }
+        }
+        return opacity;
+    }
+
+    @Override
+    public void invalidateDrawable(Drawable who) {
+        invalidateSelf();
+    }
+
+    @Override
+    public void scheduleDrawable(Drawable who, Runnable what, long when) {
+        scheduleSelf(what, when);
+    }
+
+    @Override
+    public void unscheduleDrawable(Drawable who, Runnable what) {
+        unscheduleSelf(what);
+    }
+
+}
diff --git a/src/com/android/mail/bitmap/ImageAttachmentRequest.java b/src/com/android/mail/bitmap/ImageAttachmentRequest.java
new file mode 100644
index 0000000..8645b48
--- /dev/null
+++ b/src/com/android/mail/bitmap/ImageAttachmentRequest.java
@@ -0,0 +1,83 @@
+package com.android.mail.bitmap;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.res.AssetFileDescriptor;
+import android.database.Cursor;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import com.android.bitmap.DecodeTask;
+import com.android.mail.providers.Attachment;
+import com.android.mail.providers.UIProvider;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A request object for image attachment previews.
+ * <p>
+ * All requests are the same size to promote bitmap reuse.
+ */
+public class ImageAttachmentRequest implements DecodeTask.Request {
+    private final Context mContext;
+    private final String lookupUri;
+
+    public ImageAttachmentRequest(Context context, String lookupUri) {
+        mContext = context;
+        this.lookupUri = lookupUri;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o == null || !(o instanceof ImageAttachmentRequest)) {
+            return false;
+        }
+        final ImageAttachmentRequest other = (ImageAttachmentRequest) o;
+        return TextUtils.equals(lookupUri, other.lookupUri);
+    }
+
+    @Override
+    public int hashCode() {
+        int hash = 17;
+        hash += 31 * hash + lookupUri.hashCode();
+        return hash;
+    }
+
+    @Override
+    public String toString() {
+        final StringBuilder sb = new StringBuilder("[");
+        sb.append(super.toString());
+        sb.append(" uri=");
+        sb.append(lookupUri);
+        sb.append("]");
+        return sb.toString();
+    }
+
+    @Override
+    public AssetFileDescriptor createFd() throws IOException {
+        AssetFileDescriptor result = null;
+        Cursor cursor = null;
+        final ContentResolver cr = mContext.getContentResolver();
+        try {
+            cursor = cr.query(Uri.parse(lookupUri), UIProvider.ATTACHMENT_PROJECTION, null, null,
+                    null);
+            if (cursor != null && cursor.moveToFirst()) {
+                final Attachment a = new Attachment(cursor);
+                // TODO: rendition support
+                result = cr.openAssetFileDescriptor(a.contentUri, "r");
+            }
+        } finally {
+            if (cursor != null) {
+                cursor.close();
+            }
+        }
+        return result;
+    }
+
+    @Override
+    public InputStream createInputStream() {
+        return null;
+    }
+
+}
diff --git a/src/com/android/mail/bitmap/Parallaxable.java b/src/com/android/mail/bitmap/Parallaxable.java
new file mode 100644
index 0000000..1e93e1a
--- /dev/null
+++ b/src/com/android/mail/bitmap/Parallaxable.java
@@ -0,0 +1,15 @@
+package com.android.mail.bitmap;
+
+import android.graphics.drawable.Drawable;
+
+/**
+ * {@link Drawable}s that support a parallax effect when drawing should
+ * implement this interface to receive the current parallax fraction to use when
+ * drawing.
+ */
+public interface Parallaxable {
+    /**
+     * @param fraction the vertical center point for the viewport, in the range [0,1]
+     */
+    void setParallaxFraction(float fraction);
+}
\ No newline at end of file
diff --git a/src/com/android/mail/bitmap/TileDrawable.java b/src/com/android/mail/bitmap/TileDrawable.java
new file mode 100644
index 0000000..af0bf05
--- /dev/null
+++ b/src/com/android/mail/bitmap/TileDrawable.java
@@ -0,0 +1,147 @@
+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.Paint;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+
+/**
+ * A drawable that wraps another drawable and places it in the center of this space. This drawable
+ * allows a background color for the "tile", and has a fade-out transition when
+ * {@link #setVisible(boolean, boolean)} indicates that it is no longer visible.
+ */
+public class TileDrawable extends Drawable implements Drawable.Callback {
+
+    private final Paint mPaint = new Paint();
+    private final Drawable mInner;
+    private final int mInnerWidth;
+    private final int mInnerHeight;
+
+    protected final ValueAnimator mFadeOutAnimator;
+
+    public TileDrawable(Drawable inner, int innerWidth, int innerHeight,
+            int backgroundColor, int fadeOutDurationMs) {
+        mInner = inner.mutate();
+        mInnerWidth = innerWidth;
+        mInnerHeight = innerHeight;
+        mPaint.setColor(backgroundColor);
+        mInner.setCallback(this);
+
+        mFadeOutAnimator = ValueAnimator.ofInt(255, 0)
+                .setDuration(fadeOutDurationMs);
+        mFadeOutAnimator.addUpdateListener(new AnimatorUpdateListener() {
+            @Override
+            public void onAnimationUpdate(ValueAnimator animation) {
+                setAlpha((Integer) animation.getAnimatedValue());
+            }
+        });
+
+        reset();
+    }
+
+    public void reset() {
+        setAlpha(0);
+        setVisible(false);
+    }
+
+    @Override
+    protected void onBoundsChange(Rect bounds) {
+        super.onBoundsChange(bounds);
+
+        if (bounds.isEmpty()) {
+            mInner.setBounds(0, 0, 0, 0);
+        } else {
+            final int l = bounds.left + (bounds.width() / 2) - (mInnerWidth / 2);
+            final int t = bounds.top + (bounds.height() / 2) - (mInnerHeight / 2);
+            mInner.setBounds(l, t, l + mInnerWidth, t + mInnerHeight);
+        }
+    }
+
+    @Override
+    public void draw(Canvas canvas) {
+        if (!isVisible() && mPaint.getAlpha() == 0) {
+            return;
+        }
+        canvas.drawRect(getBounds(), mPaint);
+        mInner.draw(canvas);
+    }
+
+    @Override
+    public void setAlpha(int alpha) {
+        final int old = mPaint.getAlpha();
+        mPaint.setAlpha(alpha);
+        setInnerAlpha(alpha);
+        if (alpha != old) {
+            invalidateSelf();
+        }
+    }
+
+    @Override
+    public void setColorFilter(ColorFilter cf) {
+        mPaint.setColorFilter(cf);
+        mInner.setColorFilter(cf);
+    }
+
+    @Override
+    public int getOpacity() {
+        return 0;
+    }
+
+    public boolean setVisible(boolean visible) {
+        return setVisible(visible, true /* dontcare */);
+    }
+
+    @Override
+    public boolean setVisible(boolean visible, boolean restart) {
+        mInner.setVisible(visible, restart);
+        final boolean changed = super.setVisible(visible, restart);
+        if (changed) {
+            if (isVisible()) {
+                // pop in (no-op)
+                // the transition will still be smooth if the previous state's layer fades out
+                mFadeOutAnimator.cancel();
+                setAlpha(255);
+            } else {
+                // fade out
+                if (mPaint.getAlpha() == 255 && !getBounds().isEmpty()) {
+                    mFadeOutAnimator.start();
+                }
+            }
+        }
+        return changed;
+    }
+
+    @Override
+    protected boolean onLevelChange(int level) {
+        return mInner.setLevel(level);
+    }
+
+    /**
+     * Changes the alpha on just the inner wrapped drawable.
+     *
+     * TODO: maybe use another property to control the inner alpha indepedently of this wrapper's
+     * alpha.
+     */
+    public void setInnerAlpha(int alpha) {
+        mInner.setAlpha(alpha);
+    }
+
+    @Override
+    public void invalidateDrawable(Drawable who) {
+        invalidateSelf();
+    }
+
+    @Override
+    public void scheduleDrawable(Drawable who, Runnable what, long when) {
+        scheduleSelf(what, when);
+    }
+
+    @Override
+    public void unscheduleDrawable(Drawable who, Runnable what) {
+        unscheduleSelf(what);
+    }
+
+}
diff --git a/src/com/android/mail/browse/ConversationItemView.java b/src/com/android/mail/browse/ConversationItemView.java
index 6f3d819..0d14180 100644
--- a/src/com/android/mail/browse/ConversationItemView.java
+++ b/src/com/android/mail/browse/ConversationItemView.java
@@ -37,7 +37,6 @@
 import android.graphics.Shader;
 import android.graphics.Typeface;
 import android.graphics.drawable.Drawable;
-import android.os.SystemClock;
 import android.text.Layout.Alignment;
 import android.text.Spannable;
 import android.text.SpannableString;
@@ -59,27 +58,22 @@
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.ViewParent;
-import android.view.animation.AccelerateDecelerateInterpolator;
 import android.view.animation.DecelerateInterpolator;
-import android.view.animation.Interpolator;
 import android.view.animation.LinearInterpolator;
+import android.widget.AbsListView;
 import android.widget.AbsListView.OnScrollListener;
 import android.widget.TextView;
 
 import com.android.mail.R;
-import com.android.mail.R.color;
 import com.android.mail.R.drawable;
 import com.android.mail.R.integer;
 import com.android.mail.R.string;
+import com.android.mail.bitmap.AttachmentGridDrawable;
+import com.android.mail.bitmap.ImageAttachmentRequest;
 import com.android.mail.browse.ConversationItemViewModel.SenderFragment;
 import com.android.mail.perf.Timer;
-import com.android.mail.photomanager.AttachmentPreviewsManager;
-import com.android.mail.photomanager.AttachmentPreviewsManager.AttachmentPreviewsDividedImageCanvas;
-import com.android.mail.photomanager.AttachmentPreviewsManager.AttachmentPreviewsManagerCallback;
 import com.android.mail.photomanager.ContactPhotoManager;
 import com.android.mail.photomanager.ContactPhotoManager.ContactIdentifier;
-import com.android.mail.photomanager.AttachmentPreviewsManager.AttachmentPreviewIdentifier;
-import com.android.mail.photomanager.PhotoManager;
 import com.android.mail.photomanager.PhotoManager.PhotoIdentifier;
 import com.android.mail.providers.Address;
 import com.android.mail.providers.Attachment;
@@ -97,7 +91,6 @@
 import com.android.mail.ui.DividedImageCanvas;
 import com.android.mail.ui.DividedImageCanvas.InvalidateCallback;
 import com.android.mail.ui.FolderDisplayer;
-import com.android.mail.ui.ImageCanvas;
 import com.android.mail.ui.SwipeableItemView;
 import com.android.mail.ui.SwipeableListView;
 import com.android.mail.ui.ViewMode;
@@ -112,8 +105,8 @@
 import java.util.ArrayList;
 import java.util.List;
 
-public class ConversationItemView extends View implements SwipeableItemView, ToggleableItem,
-        InvalidateCallback, AttachmentPreviewsManagerCallback {
+public class ConversationItemView extends View
+        implements SwipeableItemView, ToggleableItem, InvalidateCallback, OnScrollListener {
 
     // Timer.
     private static int sLayoutCount = 0;
@@ -143,7 +136,7 @@
     private static Bitmap STATE_CALENDAR_INVITE;
     private static Bitmap VISIBLE_CONVERSATION_CARET;
     private static Drawable RIGHT_EDGE_TABLET;
-    private static Bitmap PLACEHOLDER;
+    private static Drawable PLACEHOLDER;
     private static Drawable PROGRESS_BAR;
 
     private static String sSendersSplitToken;
@@ -154,18 +147,10 @@
     private static int sSendersTextColorRead;
     private static int sSendersTextColorUnread;
     private static int sDateTextColor;
-    private static int sAttachmentPreviewsBackgroundColor;
-    private static int sOverflowBadgeColor;
-    private static int sOverflowTextColor;
     private static int sStarTouchSlop;
     private static int sSenderImageTouchSlop;
     private static int sShrinkAnimationDuration;
     private static int sSlideAnimationDuration;
-    private static int sProgressAnimationDuration;
-    private static int sFadeAnimationDuration;
-    private static float sPlaceholderAnimationDurationRatio;
-    private static int sProgressAnimationDelay;
-    private static Interpolator sPulseAnimationInterpolator;
     private static int sOverflowCountMax;
     private static int sCabAnimationDuration;
 
@@ -174,8 +159,6 @@
     private static final TextPaint sFoldersPaint = new TextPaint();
     private static final Paint sCheckBackgroundPaint = new Paint();
 
-    private static final Rect sRect = new Rect();
-
     // Backgrounds for different states.
     private final SparseArray<Drawable> mBackgrounds = new SparseArray<Drawable>();
 
@@ -187,8 +170,6 @@
     private int mDateX;
     private int mPaperclipX;
     private int mSendersWidth;
-    private int mOverflowX;
-    private int mOverflowY;
 
     /** Whether we are on a tablet device or not */
     private final boolean mTabletDevice;
@@ -232,25 +213,8 @@
     private static int sScrollSlop;
     private static CharacterStyle sActivatedTextSpan;
 
-    private final AttachmentPreviewsDividedImageCanvas mAttachmentPreviewsCanvas;
-    private static AttachmentPreviewsManager sAttachmentPreviewsManager;
-    /**
-     * Animates the mAnimatedProgressFraction field to make the progress bars spin. Cancelling
-     * this animator does not remove the progress bars.
-     */
-    private final ObjectAnimator mProgressAnimator;
-    private final ObjectAnimator mFadeAnimator0;
-    private final ObjectAnimator mFadeAnimator1;
-    private long mProgressAnimatorCancelledTime;
-    /** Range from 0.0f to 1.0f. */
-    private float mAnimatedProgressFraction;
-    private float mAnimatedFadeFraction0;
-    private float mAnimatedFadeFraction1;
-    private int[] mImageLoadStatuses = new int[0];
-    private boolean mShowProgressBar;
-    private final Runnable mCancelProgressAnimatorRunnable;
-    private final Runnable mSetShowProgressBarRunnable0;
-    private final Runnable mSetShowProgressBarRunnable1;
+    private final AttachmentGridDrawable mAttachmentsView;
+
     private static final boolean CONVLIST_ATTACHMENT_PREVIEWS_ENABLED = true;
 
     private final Matrix mPhotoFlipMatrix = new Matrix();
@@ -278,11 +242,7 @@
         final boolean scrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE;
         final boolean flinging = scrollState == OnScrollListener.SCROLL_STATE_FLING;
 
-        if (scrolling) {
-            sAttachmentPreviewsManager.pause();
-        } else {
-            sAttachmentPreviewsManager.resume();
-        }
+        // TODO: add support for yielding attachment bitmap allocation if/when needed
 
         if (flinging) {
             sContactPhotoManager.pause();
@@ -462,7 +422,7 @@
             VISIBLE_CONVERSATION_CARET = BitmapFactory.decodeResource(res,
                     R.drawable.ic_carrot_holo);
             RIGHT_EDGE_TABLET = res.getDrawable(R.drawable.list_edge_tablet);
-            PLACEHOLDER = BitmapFactory.decodeResource(res, drawable.ic_attachment_load);
+            PLACEHOLDER = res.getDrawable(drawable.ic_attachment_load);
             PROGRESS_BAR = res.getDrawable(drawable.progress_holo);
 
             // Initialize colors.
@@ -479,9 +439,6 @@
             sSnippetTextReadSpan =
                     new ForegroundColorSpan(res.getColor(R.color.snippet_text_color_read));
             sDateTextColor = res.getColor(R.color.date_text_color);
-            sAttachmentPreviewsBackgroundColor = res.getColor(color.ap_background_color);
-            sOverflowBadgeColor = res.getColor(color.ap_overflow_badge_color);
-            sOverflowTextColor = res.getColor(color.ap_overflow_text_color);
             sStarTouchSlop = res.getDimensionPixelSize(R.dimen.star_touch_slop);
             sSenderImageTouchSlop = res.getDimensionPixelSize(R.dimen.sender_image_touch_slop);
             sShrinkAnimationDuration = res.getInteger(R.integer.shrink_animation_duration);
@@ -493,15 +450,6 @@
             sScrollSlop = res.getInteger(R.integer.swipeScrollSlop);
             sFoldersLeftPadding = res.getDimensionPixelOffset(R.dimen.folders_left_padding);
             sContactPhotoManager = ContactPhotoManager.createContactPhotoManager(context);
-            sAttachmentPreviewsManager = new AttachmentPreviewsManager(context);
-            sProgressAnimationDuration = res.getInteger(integer.ap_progress_animation_duration);
-            sFadeAnimationDuration = res.getInteger(integer.ap_fade_animation_duration);
-            final int placeholderAnimationDuration = res
-                    .getInteger(integer.ap_placeholder_animation_duration);
-            sPlaceholderAnimationDurationRatio = sProgressAnimationDuration
-                    / placeholderAnimationDuration;
-            sProgressAnimationDelay = res.getInteger(integer.ap_progress_animation_delay);
-            sPulseAnimationInterpolator = new AccelerateDecelerateInterpolator();
             sOverflowCountMax = res.getInteger(integer.ap_overflow_max_count);
             sCabAnimationDuration =
                     res.getInteger(R.integer.conv_item_view_cab_anim_duration);
@@ -538,64 +486,10 @@
                         mCoordinates.contactImagesY + mCoordinates.contactImagesHeight);
             }
         });
-        mAttachmentPreviewsCanvas = new AttachmentPreviewsDividedImageCanvas(context,
-                new InvalidateCallback() {
-                    @Override
-                    public void invalidate() {
-                        if (mCoordinates == null) {
-                            return;
-                        }
-                        ConversationItemView.this.invalidate(
-                                mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY,
-                                mCoordinates.attachmentPreviewsX
-                                        + mCoordinates.attachmentPreviewsWidth,
-                                mCoordinates.attachmentPreviewsY
-                                        + mCoordinates.attachmentPreviewsHeight);
-                    }
-                });
 
-        mProgressAnimator = createProgressAnimator();
-        mFadeAnimator0 = createFadeAnimator(0);
-        mFadeAnimator1 = createFadeAnimator(1);
-        mCancelProgressAnimatorRunnable = new Runnable() {
-            @Override
-            public void run() {
-                if (mProgressAnimator.isStarted() && areAllImagesLoaded()) {
-                    LogUtils.v(LOG_TAG, "progress animator: << stopped");
-                    mProgressAnimator.cancel();
-                }
-            }
-        };
-        mSetShowProgressBarRunnable0 = new Runnable() {
-            @Override
-            public void run() {
-                if (mImageLoadStatuses.length <= 0
-                        || mImageLoadStatuses[0] != PhotoManager.STATUS_LOADING) {
-                    return;
-                }
-                LogUtils.v(LOG_TAG, "progress bar 0: >>> set to true");
-                mShowProgressBar = true;
-                if (mFadeAnimator0.isStarted()) {
-                    mFadeAnimator0.cancel();
-                }
-                mFadeAnimator0.start();
-            }
-        };
-        mSetShowProgressBarRunnable1 = new Runnable() {
-            @Override
-            public void run() {
-                if (mImageLoadStatuses.length <= 1
-                        || mImageLoadStatuses[1] != PhotoManager.STATUS_LOADING) {
-                    return;
-                }
-                LogUtils.v(LOG_TAG, "progress bar 1: >>> set to true");
-                mShowProgressBar = true;
-                if (mFadeAnimator1.isStarted()) {
-                    mFadeAnimator1.cancel();
-                }
-                mFadeAnimator1.start();
-            }
-        };
+        mAttachmentsView = new AttachmentGridDrawable(res, PLACEHOLDER, PROGRESS_BAR);
+        mAttachmentsView.setCallback(this);
+
         Utils.traceEndSection();
     }
 
@@ -614,7 +508,6 @@
             final ConversationListListener conversationListListener,
             ConversationSelectionSet set, Folder folder, int checkboxOrSenderImage,
             boolean swipeEnabled, boolean priorityArrowEnabled, AnimatedAdapter adapter) {
-        boolean attachmentPreviewsChanged = false;
         if (mHeader != null) {
             // If this was previously bound to a different conversation, remove any contact photo
             // manager requests.
@@ -638,21 +531,14 @@
                             != mHeader.conversation.attachmentPreviewsCount
                     || !header.conversation.getAttachmentPreviewUris()
                             .equals(mHeader.conversation.getAttachmentPreviewUris())) {
-                attachmentPreviewsChanged = true;
-                ArrayList<String> divisionIds = mAttachmentPreviewsCanvas.getDivisionIds();
-                if (divisionIds != null) {
-                    mAttachmentPreviewsCanvas.reset();
-                    for (int pos = 0; pos < divisionIds.size(); pos++) {
-                        String uri = divisionIds.get(pos);
-                        for (int rendition : AttachmentRendition.PREFERRED_RENDITIONS) {
-                            AttachmentPreviewIdentifier id = new AttachmentPreviewIdentifier(uri,
-                                    rendition, 0, 0);
-                            sAttachmentPreviewsManager
-                                    .removePhoto(AttachmentPreviewsManager.generateHash(
-                                            mAttachmentPreviewsCanvas, id.getKey()));
-                        }
-                    }
+
+                // unbind the attachments view (releasing bitmap references)
+                // (this also cancels all async tasks)
+                for (int i = 0, len = mAttachmentsView.getCount(); i < len; i++) {
+                    mAttachmentsView.getOrCreateDrawable(i).setImage(null);
                 }
+                // reset the grid, as the newly bound item may have a different attachment count
+                mAttachmentsView.setCount(0);
             }
         }
         mCoordinates = null;
@@ -664,10 +550,7 @@
         mStarEnabled = folder != null && !folder.isTrash();
         mSwipeEnabled = swipeEnabled;
         mAdapter = adapter;
-        final int attachmentPreviewsSize = mHeader.conversation.getAttachmentPreviewUris().size();
-        if (attachmentPreviewsChanged || mImageLoadStatuses.length != attachmentPreviewsSize) {
-            mImageLoadStatuses = new int[attachmentPreviewsSize];
-        }
+        mAttachmentsView.setBitmapCache(mAdapter.getBitmapCache());
 
         if (checkboxOrSenderImage == ConversationListIcon.SENDER_IMAGE) {
             mGadgetMode = ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO;
@@ -729,13 +612,32 @@
             mConfig.showPersonalIndicator();
         }
 
-        int overflowCount = Math.min(getOverflowCount(), sOverflowCountMax);
-        mHeader.overflowText = String.format(sOverflowCountFormat, overflowCount);
+        final int overflowCount = Math.min(getOverflowCount(), sOverflowCountMax);
+        mHeader.overflowText = (overflowCount > 0) ?
+                String.format(sOverflowCountFormat, overflowCount) : null;
+
+        mAttachmentsView.setOverflowText(mHeader.overflowText);
 
         setContentDescription();
         requestLayout();
     }
 
+    @Override
+    public void invalidateDrawable(Drawable who) {
+        boolean handled = false;
+        if (mCoordinates != null) {
+            if (mAttachmentsView.equals(who)) {
+                final Rect r = new Rect(who.getBounds());
+                r.offset(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY);
+                ConversationItemView.this.invalidate(r.left, r.top, r.right, r.bottom);
+                handled = true;
+            }
+        }
+        if (!handled) {
+            super.invalidateDrawable(who);
+        }
+    }
+
     /**
      * Get the Conversation object associated with this view.
      */
@@ -916,10 +818,6 @@
                 && !mHeader.conversation.getAttachmentPreviewUris().isEmpty();
     }
 
-    private boolean getOverflowCountVisible() {
-        return isAttachmentPreviewsEnabled() && getOverflowCount() > 0;
-    }
-
     private int getOverflowCount() {
         return mHeader.conversation.attachmentPreviewsCount - mHeader.conversation
                 .getAttachmentPreviewUris().size();
@@ -969,9 +867,6 @@
     }
 
     private void loadAttachmentPreviews() {
-        if (!isAttachmentPreviewsEnabled()) {
-            return;
-        }
         if (mCoordinates.attachmentPreviewsWidth <= 0
                 || mCoordinates.attachmentPreviewsHeight <= 0) {
             LogUtils.w(LOG_TAG,
@@ -994,11 +889,22 @@
         final int displayCount = Math.min(attachmentUris.size(), DividedImageCanvas.MAX_DIVISIONS);
         Utils.traceEndSection();
 
-        final List<AttachmentPreviewIdentifier> ids = Lists.newArrayListWithCapacity(displayCount);
-        final List<Object> keys = Lists.newArrayListWithCapacity(displayCount);
-        // First pass: Create and set the rendition on each load request
+        mAttachmentsView.setCoordinates(mCoordinates);
+        mAttachmentsView.setCount(displayCount);
+        mAttachmentsView.setDecodeWidth(mCoordinates.attachmentPreviewsWidth);
+        final int decodeHeight;
+        // if parallax is enabled, increase the desired vertical size of attachment bitmaps
+        // so we have extra pixels to scroll within
+        if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) {
+            decodeHeight = Math.round(mCoordinates.attachmentPreviewsDecodeHeight
+                    * SwipeableListView.ATTACHMENT_PARALLAX_MULTIPLIER);
+        } else {
+            decodeHeight = mCoordinates.attachmentPreviewsDecodeHeight;
+        }
+        mAttachmentsView.setDecodeHeight(decodeHeight);
+
         for (int i = 0; i < displayCount; i++) {
-            Utils.traceBeginSection("finding rendition of attachment preview");
+            Utils.traceBeginSection("setup single attachment preview");
             final String uri = attachmentUris.get(i);
 
             // Find the rendition to load based on availability.
@@ -1015,164 +921,21 @@
                 }
             }
 
-            final AttachmentPreviewIdentifier photoIdentifier = new AttachmentPreviewIdentifier(uri,
-                    bestAvailableRendition, mHeader.conversation.id, i);
-            ids.add(photoIdentifier);
-            keys.add(photoIdentifier.getKey());
+            // TODO: support renditions
+            LogUtils.d(LOG_TAG, "creating/setting drawable region in CIV=%s canvas=%s", this,
+                    mAttachmentsView);
+            mAttachmentsView.getOrCreateDrawable(i)
+                .setImage(new ImageAttachmentRequest(getContext(), uri));
+
             Utils.traceEndSection();
         }
 
-        Utils.traceBeginSection("preparing divided image canvas");
-        // Prepare the canvas.
-        mAttachmentPreviewsCanvas.setDimensions(mCoordinates.attachmentPreviewsWidth,
+        mAttachmentsView.setBounds(0, 0, mCoordinates.attachmentPreviewsWidth,
                 mCoordinates.attachmentPreviewsHeight);
-        mAttachmentPreviewsCanvas.setDivisionIds(keys);
-        Utils.traceEndSection();
-
-        // Second pass: Find the dimensions to load and start the load request
-        final ImageCanvas.Dimensions canvasDimens = new ImageCanvas.Dimensions();
-        for (int i = 0; i < displayCount; i++) {
-            Utils.traceBeginSection("finding dimensions");
-            final PhotoIdentifier photoIdentifier = ids.get(i);
-            final Object key = keys.get(i);
-            mAttachmentPreviewsCanvas.getDesiredDimensions(key, canvasDimens);
-            Utils.traceEndSection();
-
-            Utils.traceBeginSection("start animator");
-            if (!mProgressAnimator.isStarted()) {
-                LogUtils.v(LOG_TAG, "progress animator: >> started");
-                // Reduce progress bar stutter caused by reset()/bind() being called multiple
-                // times.
-                final long time = SystemClock.uptimeMillis();
-                final long dt = time - mProgressAnimatorCancelledTime;
-                float passedFraction = 0;
-                if (mProgressAnimatorCancelledTime != 0 && dt > 0) {
-                    mProgressAnimatorCancelledTime = 0;
-                    passedFraction = (float) dt / sProgressAnimationDuration % 1.0f;
-                    LogUtils.v(LOG_TAG, "progress animator: correction for dt %d, fraction %f",
-                            dt, passedFraction);
-                }
-                removeCallbacks(mCancelProgressAnimatorRunnable);
-                mProgressAnimator.start();
-                // Wow.. this must be called after start().
-                mProgressAnimator.setCurrentPlayTime((long) (sProgressAnimationDuration * (
-                        (mAnimatedProgressFraction + passedFraction) % 1.0f)));
-            }
-            Utils.traceEndSection();
-
-            Utils.traceBeginSection("start load");
-            LogUtils.d(LOG_TAG, "loadAttachmentPreviews: start loading %s", photoIdentifier);
-            sAttachmentPreviewsManager
-                    .loadThumbnail(photoIdentifier, mAttachmentPreviewsCanvas, canvasDimens, this);
-            Utils.traceEndSection();
-        }
 
         Utils.traceEndSection();
     }
 
-    @Override
-    public void onImageDrawn(final Object key, final boolean success) {
-        if (mHeader == null || mHeader.conversation == null) {
-            return;
-        }
-        Utils.traceBeginSection("on image drawn");
-        final String uri = AttachmentPreviewsManager.transformKeyToUri(key);
-        final int index = mHeader.conversation.getAttachmentPreviewUris().indexOf(uri);
-
-        LogUtils.v(LOG_TAG,
-                "loadAttachmentPreviews: <= onImageDrawn callback [%b] on index %d for %s", success,
-                index, key);
-        // We want to hide the spinning progress bar when we draw something.
-        onImageLoadStatusChanged(index,
-                success ? PhotoManager.STATUS_LOADED : PhotoManager.STATUS_NOT_LOADED);
-
-        if (mProgressAnimator.isStarted() && areAllImagesLoaded()) {
-            removeCallbacks(mCancelProgressAnimatorRunnable);
-            postDelayed(mCancelProgressAnimatorRunnable, sFadeAnimationDuration);
-        }
-        Utils.traceEndSection();
-    }
-
-    @Override
-    public void onImageLoadStarted(final Object key) {
-        if (mHeader == null || mHeader.conversation == null) {
-            return;
-        }
-        final String uri = AttachmentPreviewsManager.transformKeyToUri(key);
-        final int index = mHeader.conversation.getAttachmentPreviewUris().indexOf(uri);
-
-        LogUtils.v(LOG_TAG,
-                "loadAttachmentPreviews: <= onImageLoadStarted callback on index %d for %s", index,
-                key);
-        onImageLoadStatusChanged(index, PhotoManager.STATUS_LOADING);
-    }
-
-    private boolean areAllImagesLoaded() {
-        for (int i = 0; i < mImageLoadStatuses.length; i++) {
-            if (mImageLoadStatuses[i] != PhotoManager.STATUS_LOADED) {
-                return false;
-            }
-        }
-        return true;
-    }
-
-    /**
-     * Update the #mImageLoadStatuses state array with special logic.
-     * @param index Which attachment preview's state to update.
-     * @param status What the new state is.
-     */
-    private void onImageLoadStatusChanged(final int index, final int status) {
-        if (index < 0 || index >= mImageLoadStatuses.length) {
-            return;
-        }
-        final int prevStatus = mImageLoadStatuses[index];
-        if (prevStatus == status) {
-            return;
-        }
-
-        boolean changed = false;
-        switch (status) {
-            case PhotoManager.STATUS_NOT_LOADED:
-                LogUtils.v(LOG_TAG, "progress bar: <<< set to false");
-                mShowProgressBar = false;
-                // Cannot transition directly from LOADING to NOT_LOADED.
-                if (prevStatus != PhotoManager.STATUS_LOADING) {
-                    mImageLoadStatuses[index] = status;
-                    changed = true;
-                }
-                break;
-            case PhotoManager.STATUS_LOADING:
-                // All other statuses must be set to not loading.
-                for (int i = 0; i < mImageLoadStatuses.length; i++) {
-                    if (i != index && mImageLoadStatuses[i] == PhotoManager.STATUS_LOADING) {
-                        mImageLoadStatuses[i] = PhotoManager.STATUS_NOT_LOADED;
-                    }
-                }
-                mImageLoadStatuses[index] = status;
-
-                // Progress bar should only be shown after a delay
-                LogUtils.v(LOG_TAG, "progress bar: <<< set to false");
-                mShowProgressBar = false;
-                LogUtils.v(LOG_TAG, "progress bar: === start delay");
-                final Runnable setShowProgressBarRunnable = index == 0
-                        ? mSetShowProgressBarRunnable0 : mSetShowProgressBarRunnable1;
-                removeCallbacks(setShowProgressBarRunnable);
-                postDelayed(setShowProgressBarRunnable, sProgressAnimationDelay);
-                changed = true;
-                break;
-            case PhotoManager.STATUS_LOADED:
-                mImageLoadStatuses[index] = status;
-                changed = true;
-                break;
-        }
-        if (changed) {
-            final ObjectAnimator fadeAnimator = index == 0 ? mFadeAnimator0 : mFadeAnimator1;
-            if (!fadeAnimator.isStarted()) {
-                fadeAnimator.start();
-            }
-        }
-    }
-
     private static int makeExactSpecForSize(int size) {
         return MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY);
     }
@@ -1315,19 +1078,6 @@
             mSendersWidth = 0;
         }
 
-        String overflowText = mHeader.overflowText != null ? mHeader.overflowText : "";
-        sPaint.setTextSize(mCoordinates.overflowFontSize);
-        sPaint.setTypeface(mCoordinates.overflowTypeface);
-
-        sPaint.getTextBounds(overflowText, 0, overflowText.length(), sRect);
-
-        final int overflowWidth = (int) sPaint.measureText(overflowText);
-        final int overflowHeight = sRect.height();
-        mOverflowX = mCoordinates.overflowXEnd - mCoordinates.overflowDiameter / 2
-                - overflowWidth / 2;
-        mOverflowY = mCoordinates.overflowYEnd - mCoordinates.overflowDiameter / 2
-                + overflowHeight / 2;
-
         pauseTimer(PERF_TAG_CALCULATE_COORDINATES);
     }
 
@@ -1515,6 +1265,25 @@
     }
 
     @Override
+    public final void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
+            int totalItemCount) {
+        if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) {
+            final View listItemView = unwrap();
+            if (mHeader == null || mCoordinates == null || !isAttachmentPreviewsEnabled()) {
+                return;
+            }
+
+            invalidate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY,
+                    mCoordinates.attachmentPreviewsX + mCoordinates.attachmentPreviewsWidth,
+                    mCoordinates.attachmentPreviewsY + mCoordinates.attachmentPreviewsHeight);
+        }
+    }
+
+    @Override
+    public void onScrollStateChanged(AbsListView view, int scrollState) {
+    }
+
+    @Override
     protected void onDraw(Canvas canvas) {
         Utils.traceBeginSection("CIVC.draw");
 
@@ -1606,45 +1375,6 @@
             canvas.save();
             drawAttachmentPreviews(canvas);
             canvas.restore();
-
-            // Overflow badge and count
-            if (getOverflowCountVisible() && areAllImagesLoaded()) {
-                final float radius = mCoordinates.overflowDiameter / 2;
-                sPaint.setColor(sOverflowBadgeColor);
-                canvas.drawCircle(mCoordinates.overflowXEnd - radius,
-                        mCoordinates.overflowYEnd - radius, radius, sPaint);
-
-                sPaint.setTextSize(mCoordinates.overflowFontSize);
-                sPaint.setTypeface(mCoordinates.overflowTypeface);
-                sPaint.setColor(sOverflowTextColor);
-                drawText(canvas, mHeader.overflowText, mOverflowX, mOverflowY, sPaint);
-            }
-
-            // Placeholders and progress bars
-
-            // Fade from 55 -> 255 -> 55. Each cycle lasts for #sProgressAnimationDuration secs.
-            final int maxAlpha = 255, minAlpha = 55;
-            final int range = maxAlpha - minAlpha;
-            // We want the placeholder to pulse at a different rate from the progressbar to
-            // spin.
-            final float placeholderAnimFraction = mAnimatedProgressFraction
-                    * sPlaceholderAnimationDurationRatio;
-            // During the time that placeholderAnimFraction takes to go from 0 to 1, we
-            // want to go all the way to #maxAlpha and back down to #minAlpha. So from 0 to 0.5,
-            // we increase #modifiedProgress from 0 to 1, while from 0.5 to 1 we decrease
-            // accordingly from 1 to 0. Math.
-            final float modifiedProgress = -2 * Math.abs(placeholderAnimFraction - 0.5f) + 1;
-            // Make it feel like a heart beat.
-            final float interpolatedProgress = sPulseAnimationInterpolator
-                    .getInterpolation(modifiedProgress);
-            // More math.
-            final int pulseAlpha = (int) (interpolatedProgress * range + minAlpha);
-
-            final int count = mImageLoadStatuses.length;
-            for (int i = 0; i < count; i++) {
-                drawPlaceholder(canvas, i, count, pulseAlpha);
-                drawProgressBar(canvas, i, count);
-            }
         }
 
         // right-side edge effect when in tablet conversation mode and the list is not collapsed
@@ -1766,7 +1496,18 @@
 
     private void drawAttachmentPreviews(Canvas canvas) {
         canvas.translate(mCoordinates.attachmentPreviewsX, mCoordinates.attachmentPreviewsY);
-        mAttachmentPreviewsCanvas.draw(canvas);
+        final float fraction;
+        if (SwipeableListView.ENABLE_ATTACHMENT_PARALLAX) {
+            final View listView = getListView();
+            final View listItemView = unwrap();
+            fraction = 1 - (float) listItemView.getBottom()
+                    / (listView.getHeight() + listItemView.getHeight());
+        } else {
+            // center the preview crop around the 1/3 point from the top
+            fraction = 0.33f;
+        }
+        mAttachmentsView.setParallaxFraction(fraction);
+        mAttachmentsView.draw(canvas);
     }
 
     private void drawSubject(Canvas canvas) {
@@ -1779,168 +1520,6 @@
         mSendersTextView.draw(canvas);
     }
 
-    /**
-     * Draws the specified placeholder on the canvas.
-     *
-     * @param canvas The canvas to draw on.
-     * @param index  If drawing multiple placeholders, this determines which one we are drawing.
-     * @param total  Whether we are drawing multiple placeholders.
-     * @param pulseAlpha The alpha to draw this at.
-     */
-    private void drawPlaceholder(final Canvas canvas, final int index, final int total,
-            final int pulseAlpha) {
-        final int placeholderX = getAttachmentPreviewXCenter(index, total)
-                - mCoordinates.placeholderWidth / 2;
-        if (placeholderX == -1) {
-            return;
-        }
-
-        // Set alpha for crossfading effect.
-        final ObjectAnimator fadeAnimator = index == 0 ? mFadeAnimator0 : mFadeAnimator1;
-        final float animatedFadeFraction = index == 0 ? mAnimatedFadeFraction0
-                : mAnimatedFadeFraction1;
-        final boolean notLoaded = mImageLoadStatuses[index] == PhotoManager.STATUS_NOT_LOADED;
-        final boolean loading = mImageLoadStatuses[index] == PhotoManager.STATUS_LOADING;
-        final boolean loaded = mImageLoadStatuses[index] == PhotoManager.STATUS_LOADED;
-        final float fadeAlphaFraction;
-        if (notLoaded) {
-            fadeAlphaFraction = 1.0f;
-        } else if (loading && !mShowProgressBar) {
-            fadeAlphaFraction = 1.0f;
-        } else if (fadeAnimator.isStarted() && (loading && mShowProgressBar
-                || loaded && !mShowProgressBar)) {
-            // Fade to progress bar or fade to image from placeholder
-            fadeAlphaFraction = 1.0f - animatedFadeFraction;
-        } else {
-            fadeAlphaFraction = 0.0f;
-        }
-
-        if (fadeAlphaFraction == 0.0f) {
-            return;
-        }
-        // Draw background
-        drawAttachmentPreviewBackground(canvas, index, total, (int) (255 * fadeAlphaFraction));
-
-        sPaint.setAlpha((int) (pulseAlpha * fadeAlphaFraction));
-        canvas.drawBitmap(PLACEHOLDER, placeholderX, mCoordinates.placeholderY, sPaint);
-    }
-
-    /**
-     * Draws the specified progress bar on the canvas.
-     * @param canvas The canvas to draw on.
-     * @param index If drawing multiple progress bars, this determines which one we are drawing.
-     * @param total Whether we are drawing multiple progress bars.
-     */
-    private void drawProgressBar(final Canvas canvas, final int index, final int total) {
-        final int progressBarX = getAttachmentPreviewXCenter(index, total)
-                - mCoordinates.progressBarWidth / 2;
-        if (progressBarX == -1) {
-            return;
-        }
-
-        // Set alpha for crossfading effect.
-        final ObjectAnimator fadeAnimator = index == 0 ? mFadeAnimator0 : mFadeAnimator1;
-        final float animatedFadeFraction = index == 0 ? mAnimatedFadeFraction0
-                : mAnimatedFadeFraction1;
-        final boolean loading = mImageLoadStatuses[index] == PhotoManager.STATUS_LOADING;
-        final boolean loaded = mImageLoadStatuses[index] == PhotoManager.STATUS_LOADED;
-        final int fadeAlpha;
-        if (loading && mShowProgressBar) {
-            fadeAlpha = (int) (255 * animatedFadeFraction);
-        } else if (fadeAnimator.isStarted() && (loaded && mShowProgressBar)) {
-            // Fade to image from progress bar
-            fadeAlpha = (int) (255 * (1.0f - animatedFadeFraction));
-        } else {
-            fadeAlpha = 0;
-        }
-
-        if (fadeAlpha == 0) {
-            return;
-        }
-
-        // Draw background
-        drawAttachmentPreviewBackground(canvas, index, total, fadeAlpha);
-
-        // Set the level from 0 to 10000 to animate the Drawable.
-        PROGRESS_BAR.setLevel((int) (mAnimatedProgressFraction * 10000));
-        // canvas.translate() for Bitmaps, setBounds() for Drawables.
-        PROGRESS_BAR.setBounds(progressBarX, mCoordinates.progressBarY,
-                progressBarX + mCoordinates.progressBarWidth,
-                mCoordinates.progressBarY + mCoordinates.progressBarHeight);
-        PROGRESS_BAR.setAlpha(fadeAlpha);
-        PROGRESS_BAR.draw(canvas);
-    }
-    /**
-     * Draws the specified attachment previews background on the canvas.
-     * @param canvas The canvas to draw on.
-     * @param index If drawing for multiple attachment previews, this determines for which one's
-     *              background we are drawing.
-     * @param total Whether we are drawing for multiple attachment previews.
-     * @param fadeAlpha The alpha to draw this at.
-     */
-    private void drawAttachmentPreviewBackground(final Canvas canvas, final int index,
-            final int total, final int fadeAlpha) {
-        if (total == 0) {
-            return;
-        }
-        final int sectionWidth = mCoordinates.attachmentPreviewsWidth / total;
-        final int sectionX = getAttachmentPreviewX(index, total);
-        sPaint.setColor(sAttachmentPreviewsBackgroundColor);
-        sPaint.setAlpha(fadeAlpha);
-        canvas.drawRect(sectionX, mCoordinates.attachmentPreviewsY, sectionX + sectionWidth,
-                mCoordinates.attachmentPreviewsY + mCoordinates.attachmentPreviewsHeight, sPaint);
-    }
-
-    private void invalidateAttachmentPreviews() {
-        final int total = mImageLoadStatuses.length;
-        for (int index = 0; index < total; index++) {
-            invalidateAttachmentPreview(index, total);
-        }
-    }
-
-    private void invalidatePlaceholdersAndProgressBars() {
-        final int total = mImageLoadStatuses.length;
-        for (int index = 0; index < total; index++) {
-            invalidatePlaceholderAndProgressBar(index, total);
-        }
-    }
-
-    private void invalidateAttachmentPreview(final int index, final int total) {
-        invalidate(getAttachmentPreviewX(index, total), mCoordinates.attachmentPreviewsY,
-                getAttachmentPreviewX(index + 1, total),
-                mCoordinates.attachmentPreviewsY + mCoordinates.attachmentPreviewsHeight);
-    }
-
-    private void invalidatePlaceholderAndProgressBar(final int index, final int total) {
-        final int width = Math.max(mCoordinates.placeholderWidth, mCoordinates.progressBarWidth);
-        final int height = Math.max(mCoordinates.placeholderHeight, mCoordinates.progressBarHeight);
-        final int x = getAttachmentPreviewXCenter(index, total) - width / 2;
-        final int xEnd = getAttachmentPreviewXCenter(index, total) + width / 2;
-        final int yCenter = mCoordinates.attachmentPreviewsY
-                + mCoordinates.attachmentPreviewsHeight / 2;
-        final int y = yCenter - height / 2;
-        final int yEnd = yCenter + height / 2;
-
-        invalidate(x, y, xEnd, yEnd);
-    }
-
-    private int getAttachmentPreviewX(final int index, final int total) {
-        if (mCoordinates == null || total == 0) {
-            return -1;
-        }
-        final int sectionWidth = mCoordinates.attachmentPreviewsWidth / total;
-        final int sectionOffset = index * sectionWidth;
-        return mCoordinates.attachmentPreviewsX + sectionOffset;
-    }
-
-    private int getAttachmentPreviewXCenter(final int index, final int total) {
-        if (total == 0) {
-            return -1;
-        }
-        final int sectionWidth = mCoordinates.attachmentPreviewsWidth / total;
-        return getAttachmentPreviewX(index, total) + sectionWidth / 2;
-    }
-
     private Bitmap getStarBitmap() {
         return mHeader.conversation.starred ? STAR_ON : STAR_OFF;
     }
@@ -2157,9 +1736,20 @@
         return handled;
     }
 
+    private SwipeableConversationItemView unwrap() {
+        final ViewParent vp = getParent();
+        if (vp == null || !(vp instanceof SwipeableConversationItemView)) {
+            return null;
+        }
+        return (SwipeableConversationItemView) vp;
+    }
+
     private SwipeableListView getListView() {
-        SwipeableListView v = (SwipeableListView) ((SwipeableConversationItemView) getParent())
-                .getListView();
+        SwipeableListView v = null;
+        final SwipeableConversationItemView wrapper = unwrap();
+        if (wrapper != null) {
+            v = (SwipeableListView) wrapper.getListView();
+        }
         if (v == null) {
             v = mAdapter.getListView();
         }
@@ -2175,10 +1765,6 @@
         setAlpha(1f);
         setTranslationX(0f);
         mAnimatedHeightFraction = 1.0f;
-        if (mProgressAnimator.isStarted()) {
-            removeCallbacks(mCancelProgressAnimatorRunnable);
-            postDelayed(mCancelProgressAnimatorRunnable, sFadeAnimationDuration);
-        }
         Utils.traceEndSection();
     }
 
@@ -2187,17 +1773,16 @@
     public void setTranslationX(float translationX) {
         super.setTranslationX(translationX);
 
-        final ViewParent vp = getParent();
-        if (vp == null || !(vp instanceof SwipeableConversationItemView)) {
-            LogUtils.w(LOG_TAG,
-                    "CIV.setTranslationX unexpected ConversationItemView parent: %s x=%s",
-                    vp, translationX);
-        }
-
         // When a list item is being swiped or animated, ensure that the hosting view has a
         // background color set. We only enable the background during the X-translation effect to
         // reduce overdraw during normal list scrolling.
-        final SwipeableConversationItemView parent = (SwipeableConversationItemView) vp;
+        final SwipeableConversationItemView parent = unwrap();
+        if (parent == null) {
+            LogUtils.w(LOG_TAG,
+                    "CIV.setTranslationX unexpected ConversationItemView parent: %s x=%s",
+                    getParent(), translationX);
+        }
+
         if (translationX != 0f) {
             parent.setBackgroundResource(R.color.swiped_bg_color);
         } else {
@@ -2272,74 +1857,6 @@
         requestLayout();
     }
 
-    private ObjectAnimator createProgressAnimator() {
-        final ObjectAnimator animator = ObjectAnimator
-                .ofFloat(this, "animatedProgressFraction", 0.0f, 1.0f).setDuration(
-                        sProgressAnimationDuration);
-        animator.setInterpolator(new LinearInterpolator());
-        animator.setRepeatCount(ObjectAnimator.INFINITE);
-        animator.setRepeatMode(ObjectAnimator.RESTART);
-        animator.addListener(new AnimatorListenerAdapter() {
-            @Override
-            public void onAnimationEnd(final Animator animation) {
-                invalidateAll();
-            }
-
-            @Override
-            public void onAnimationCancel(final Animator animation) {
-                invalidateAll();
-                mProgressAnimatorCancelledTime = SystemClock.uptimeMillis();
-            }
-
-            private void invalidateAll() {
-                invalidatePlaceholdersAndProgressBars();
-            }
-        });
-        return animator;
-    }
-
-    private ObjectAnimator createFadeAnimator(final int index) {
-        final ObjectAnimator animator = ObjectAnimator
-                .ofFloat(this, "animatedFadeFraction" + index, 0.0f, 1.0f).setDuration(
-                        sFadeAnimationDuration);
-        animator.setInterpolator(new DecelerateInterpolator());
-        animator.setRepeatCount(0);
-        animator.addListener(new AnimatorListenerAdapter() {
-            @Override
-            public void onAnimationEnd(final Animator animation) {
-                invalidateAttachmentPreview(index, mImageLoadStatuses.length);
-            }
-
-            @Override
-            public void onAnimationCancel(final Animator animation) {
-                invalidateAttachmentPreview(index, mImageLoadStatuses.length);
-            }
-        });
-        return animator;
-    }
-
-    // Used by animator
-    public void setAnimatedProgressFraction(final float fraction) {
-        // ObjectAnimator.cancel() sets the field to 0.0f.
-        if (fraction == 0.0f) {
-            return;
-        }
-        mAnimatedProgressFraction = fraction;
-        invalidatePlaceholdersAndProgressBars();
-    }
-
-    // Used by animator
-    public void setAnimatedFadeFraction0(final float fraction) {
-        mAnimatedFadeFraction0 = fraction;
-        invalidateAttachmentPreview(0, mImageLoadStatuses.length);
-    }
-
-    // Used by animator
-    public void setAnimatedFadeFraction1(final float fraction) {
-        mAnimatedFadeFraction1 = fraction;
-        invalidateAttachmentPreview(1, mImageLoadStatuses.length);
-    }
-
     @Override
     public SwipeableView getSwipeableView() {
         return SwipeableView.from(this);
diff --git a/src/com/android/mail/browse/ConversationItemViewCoordinates.java b/src/com/android/mail/browse/ConversationItemViewCoordinates.java
index 6425bfc..5d496b4 100644
--- a/src/com/android/mail/browse/ConversationItemViewCoordinates.java
+++ b/src/com/android/mail/browse/ConversationItemViewCoordinates.java
@@ -230,26 +230,27 @@
     final int contactImagesY;
 
     // Attachment previews
-    final int attachmentPreviewsX;
-    final int attachmentPreviewsY;
+    public final int attachmentPreviewsX;
+    public final int attachmentPreviewsY;
     final int attachmentPreviewsWidth;
     final int attachmentPreviewsHeight;
+    public final int attachmentPreviewsDecodeHeight;
 
     // Attachment previews overflow badge and count
-    final int overflowXEnd;
-    final int overflowYEnd;
-    final int overflowDiameter;
-    final float overflowFontSize;
-    final Typeface overflowTypeface;
+    public final int overflowXEnd;
+    public final int overflowYEnd;
+    public final int overflowDiameter;
+    public final float overflowFontSize;
+    public final Typeface overflowTypeface;
 
     // Attachment previews placeholder
     final int placeholderY;
-    final int placeholderWidth;
-    final int placeholderHeight;
+    public final int placeholderWidth;
+    public final int placeholderHeight;
     // Attachment previews progress bar
     final int progressBarY;
-    final int progressBarWidth;
-    final int progressBarHeight;
+    public final int progressBarWidth;
+    public final int progressBarHeight;
 
     /**
      * The smallest item width for which we use the "wide" layout.
@@ -297,6 +298,8 @@
             params.height = getAttachmentPreviewsHeight(context, config.getAttachmentPreviewMode());
             attachmentPreviews.setLayoutParams(params);
         }
+        attachmentPreviewsDecodeHeight = getAttachmentPreviewsHeight(context,
+                ATTACHMENT_PREVIEW_UNREAD);
 
         final TextView folders = (TextView) view.findViewById(R.id.folders);
         folders.setVisibility(config.areFoldersVisible() ? View.VISIBLE : View.GONE);
diff --git a/src/com/android/mail/browse/SwipeableConversationItemView.java b/src/com/android/mail/browse/SwipeableConversationItemView.java
index b9f2718..7396e7b 100644
--- a/src/com/android/mail/browse/SwipeableConversationItemView.java
+++ b/src/com/android/mail/browse/SwipeableConversationItemView.java
@@ -20,6 +20,8 @@
 import android.animation.Animator;
 import android.animation.Animator.AnimatorListener;
 import android.content.Context;
+import android.widget.AbsListView.OnScrollListener;
+import android.widget.AbsListView;
 import android.widget.FrameLayout;
 import android.widget.ListView;
 
@@ -30,7 +32,8 @@
 import com.android.mail.ui.ControllableActivity;
 import com.android.mail.ui.ConversationSelectionSet;
 
-public class SwipeableConversationItemView extends FrameLayout implements ToggleableItem {
+public class SwipeableConversationItemView extends FrameLayout
+        implements ToggleableItem, OnScrollListener {
 
     private final ConversationItemView mConversationItemView;
 
@@ -87,4 +90,18 @@
             mConversationItemView.toggleSelectedState();
         }
     }
+
+    @Override
+    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
+            int totalItemCount) {
+        if (mConversationItemView != null) {
+            mConversationItemView.onScroll(view, firstVisibleItem, visibleItemCount,
+                    totalItemCount);
+        }
+    }
+
+    @Override
+    public void onScrollStateChanged(AbsListView view, int scrollState) {
+    }
+
 }
diff --git a/src/com/android/mail/photomanager/AttachmentPreviewsManager.java b/src/com/android/mail/photomanager/AttachmentPreviewsManager.java
deleted file mode 100644
index c3835b3..0000000
--- a/src/com/android/mail/photomanager/AttachmentPreviewsManager.java
+++ /dev/null
@@ -1,365 +0,0 @@
-package com.android.mail.photomanager;
-
-import com.android.mail.photomanager.BitmapUtil.InputStreamFactory;
-import com.android.mail.providers.Attachment;
-import com.android.mail.providers.UIProvider;
-import com.android.mail.providers.UIProvider.AttachmentRendition;
-import com.android.mail.ui.DividedImageCanvas;
-import com.android.mail.ui.ImageCanvas.Dimensions;
-import com.android.mail.utils.Utils;
-import com.google.common.base.Objects;
-
-import android.content.ContentResolver;
-import android.content.Context;
-import android.database.Cursor;
-import android.graphics.Bitmap;
-import android.net.Uri;
-import android.text.TextUtils;
-import android.util.Pair;
-
-import com.android.mail.ui.ImageCanvas;
-import com.android.mail.utils.LogUtils;
-import com.google.common.primitives.Longs;
-
-import java.io.FileNotFoundException;
-import java.io.InputStream;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.Map;
-
-/**
- * Asynchronously loads attachment image previews and maintains a cache of
- * photos.
- */
-public class AttachmentPreviewsManager extends PhotoManager {
-
-    private static final DefaultImageProvider sDefaultImageProvider
-            = new AttachmentPreviewsDefaultProvider();
-    private final Map<Object, AttachmentPreviewsManagerCallback> mCallbacks;
-
-    public static int generateHash(ImageCanvas view, Object key) {
-        return Objects.hashCode(view, key);
-    }
-
-    public static String transformKeyToUri(Object key) {
-        return (String) ((Pair)key).second;
-    }
-
-    public AttachmentPreviewsManager(Context context) {
-        super(context);
-        mCallbacks = new HashMap<Object, AttachmentPreviewsManagerCallback>();
-    }
-
-    public void loadThumbnail(final PhotoIdentifier id, final ImageCanvas view,
-            final Dimensions dimensions, final AttachmentPreviewsManagerCallback callback) {
-        mCallbacks.put(id.getKey(), callback);
-        super.loadThumbnail(id, view, dimensions);
-    }
-
-    @Override
-    protected DefaultImageProvider getDefaultImageProvider() {
-        return sDefaultImageProvider;
-    }
-
-    @Override
-    protected int getHash(PhotoIdentifier id, ImageCanvas view) {
-        return generateHash(view, id.getKey());
-    }
-
-    @Override
-    protected PhotoLoaderThread getLoaderThread(ContentResolver contentResolver) {
-        return new AttachmentPreviewsLoaderThread(contentResolver);
-    }
-
-    @Override
-    protected void onImageDrawn(Request request, boolean success) {
-        Object key = request.getKey();
-        if (mCallbacks.containsKey(key)) {
-            AttachmentPreviewsManagerCallback callback = mCallbacks.get(key);
-            callback.onImageDrawn(request.getKey(), success);
-
-            if (success) {
-                mCallbacks.remove(key);
-            }
-        }
-    }
-
-    @Override
-    protected void onImageLoadStarted(final Request request) {
-        if (request == null) {
-            return;
-        }
-        final Object key = request.getKey();
-        if (mCallbacks.containsKey(key)) {
-            AttachmentPreviewsManagerCallback callback = mCallbacks.get(key);
-            callback.onImageLoadStarted(request.getKey());
-        }
-    }
-
-    @Override
-    protected boolean isSizeCompatible(int prevWidth, int prevHeight, int newWidth, int newHeight) {
-        float ratio = (float) newWidth / prevWidth;
-        boolean previousRequestSmaller = newWidth > prevWidth
-                || newWidth > prevWidth * ratio
-                || newHeight > prevHeight * ratio;
-        return !previousRequestSmaller;
-    }
-
-    public static class AttachmentPreviewIdentifier extends PhotoIdentifier {
-        public final String uri;
-        public final int rendition;
-        // conversationId and index used for sorting requests
-        long conversationId;
-        public int index;
-
-        /**
-         * <RENDITION, URI>
-         */
-        private Pair<Integer, String> mKey;
-
-        public AttachmentPreviewIdentifier(String uri, int rendition, long conversationId,
-                int index) {
-            this.uri = uri;
-            this.rendition = rendition;
-            this.conversationId = conversationId;
-            this.index = index;
-            mKey = new Pair<Integer, String>(rendition, uri) {
-                @Override
-                public String toString() {
-                    return "<" + first + ", " + second + ">";
-                }
-            };
-        }
-
-        @Override
-        public boolean isValid() {
-            return !TextUtils.isEmpty(uri) && rendition >= AttachmentRendition.SIMPLE;
-        }
-
-        @Override
-        public Object getKey() {
-            return mKey;
-        }
-
-        @Override
-        public Object getKeyToShowInsteadOfDefault() {
-            return new AttachmentPreviewIdentifier(uri, rendition - 1, conversationId, index)
-                    .getKey();
-        }
-
-        @Override
-        public int hashCode() {
-            int hash = 17;
-            hash = 31 * hash + (uri != null ? uri.hashCode() : 0);
-            hash = 31 * hash + rendition;
-            hash = 31 * hash + Longs.hashCode(conversationId);
-            hash = 31 * hash + index;
-            return hash;
-        }
-
-        @Override
-        public boolean equals(Object o) {
-            if (this == o) {
-                return true;
-            }
-            if (o == null || getClass() != o.getClass()) {
-                return false;
-            }
-
-            AttachmentPreviewIdentifier that = (AttachmentPreviewIdentifier) o;
-
-            if (rendition != that.rendition) {
-                return false;
-            }
-            if (uri != null ? !uri.equals(that.uri) : that.uri != null) {
-                return false;
-            }
-            if (conversationId != that.conversationId) {
-                return false;
-            }
-            if (index != that.index) {
-                return false;
-            }
-
-            return true;
-        }
-
-        @Override
-        public String toString() {
-            return mKey.toString();
-        }
-
-        @Override
-        public int compareTo(PhotoIdentifier another) {
-            if (another instanceof AttachmentPreviewIdentifier) {
-                AttachmentPreviewIdentifier anotherId = (AttachmentPreviewIdentifier) another;
-                // We want to load SIMPLE images first because they are super fast
-                if (rendition - anotherId.rendition != 0) {
-                    return rendition - anotherId.rendition;
-                }
-
-                // Load images from later messages first (later messages appear on top of the list)
-                if (anotherId.conversationId - conversationId != 0) {
-                    return (anotherId.conversationId - conversationId) > 0 ? 1 : -1;
-                }
-
-                // Load images from left to right
-                if (index - anotherId.index != 0) {
-                    return index - anotherId.index;
-                }
-
-                return 0;
-            } else {
-                return -1;
-            }
-        }
-    }
-
-    protected class AttachmentPreviewsLoaderThread extends PhotoLoaderThread {
-
-        public AttachmentPreviewsLoaderThread(ContentResolver resolver) {
-            super(resolver);
-        }
-
-        @Override
-        protected int getMaxBatchCount() {
-            return 1;
-        }
-
-        @Override
-        protected Map<String, BitmapHolder> loadPhotos(final Collection<Request> requests) {
-            final Map<String, BitmapHolder> photos = new HashMap<String, BitmapHolder>(
-                    requests.size());
-
-            LogUtils.d(TAG, "AttachmentPreviewsManager: starting batch load. Count: %d",
-                    requests.size());
-            for (final Request request : requests) {
-                Utils.traceBeginSection("Setup load photo");
-                final AttachmentPreviewIdentifier id = (AttachmentPreviewIdentifier) request
-                        .getPhotoIdentifier();
-                final Uri uri = Uri.parse(id.uri);
-                // Get the attachment for this preview
-                final Cursor cursor = getResolver()
-                        .query(uri, UIProvider.ATTACHMENT_PROJECTION, null, null, null);
-                if (cursor == null) {
-                    Utils.traceEndSection();
-                    continue;
-                }
-                Attachment attachment = null;
-                try {
-                    LogUtils.v(TAG, "AttachmentPreviewsManager: found %d attachments for uri %s",
-                            cursor.getCount(), uri);
-                    if (cursor.moveToFirst()) {
-                        attachment = new Attachment(cursor);
-                    }
-                } finally {
-                    cursor.close();
-                }
-
-                if (attachment == null) {
-                    LogUtils.w(TAG, "AttachmentPreviewsManager: attachment not found for uri %s",
-                            uri);
-                    Utils.traceEndSection();
-                    continue;
-                }
-
-                // Determine whether we load the SIMPLE or BEST image for this preview
-                final Uri contentUri;
-                if (id.rendition == UIProvider.AttachmentRendition.BEST) {
-                    contentUri = attachment.contentUri;
-                } else if (id.rendition == AttachmentRendition.SIMPLE) {
-                    contentUri = attachment.thumbnailUri;
-                } else {
-                    LogUtils.w(TAG,
-                            "AttachmentPreviewsManager: Cannot load rendition %d for uri %s",
-                            id.rendition, uri);
-                    Utils.traceEndSection();
-                    continue;
-                }
-
-                LogUtils.v(TAG, "AttachmentPreviewsManager: attachments has contentUri %s",
-                        contentUri);
-                final InputStreamFactory factory = new InputStreamFactory() {
-                    @Override
-                    public InputStream newInputStream() {
-                        try {
-                            return getResolver().openInputStream(contentUri);
-                        } catch (FileNotFoundException e) {
-                            LogUtils.e(TAG,
-                                    "AttachmentPreviewsManager: file not found for attachment %s."
-                                            + " This may be due to the attachment not being "
-                                            + "downloaded yet. But this shouldn't happen because "
-                                            + "we check the state of the attachment downloads "
-                                            + "before attempting to load it.",
-                                    contentUri);
-                            return null;
-                        }
-                    }
-                };
-                Utils.traceEndSection();
-
-                Utils.traceBeginSection("Decode stream and crop");
-                // todo:markwei read EXIF data for orientation
-                // Crop it. I've seen that in real-world situations, a 5.5MB image will be
-                // cropped down to about a 200KB image, so this is definitely worth it.
-                final Bitmap bitmap = BitmapUtil
-                        .decodeStreamWithCrop(factory, request.bitmapKey.w, request.bitmapKey.h,
-                                0.5f, 1.0f / 3);
-                Utils.traceEndSection();
-
-                if (bitmap == null) {
-                    LogUtils.w(TAG, "Unable to decode bitmap for contentUri %s", contentUri);
-                    continue;
-                }
-                cacheBitmap(request.bitmapKey, bitmap);
-                LogUtils.d(TAG,
-                        "AttachmentPreviewsManager: finished loading attachment cropped size %db",
-                        bitmap.getByteCount());
-            }
-
-            return photos;
-        }
-    }
-
-    public static class AttachmentPreviewsDividedImageCanvas extends DividedImageCanvas {
-        public AttachmentPreviewsDividedImageCanvas(Context context, InvalidateCallback callback) {
-            super(context, callback);
-        }
-
-        @Override
-        protected void drawVerticalDivider(int width, int height) {
-            return; // do not draw vertical dividers
-        }
-
-        @Override
-        protected boolean isPartialBitmapComplete() {
-            return true; // images may not be loaded at the same time
-        }
-
-        @Override
-        protected String transformKeyToDivisionId(Object key) {
-            return transformKeyToUri(key);
-        }
-    }
-
-    public static class AttachmentPreviewsDefaultProvider implements DefaultImageProvider {
-
-        /**
-         * All we need to do is clear the section. The ConversationItemView will draw the
-         * progress bar.
-         */
-        @Override
-        public void applyDefaultImage(PhotoIdentifier id, ImageCanvas view, int extent) {
-            AttachmentPreviewsDividedImageCanvas dividedImageCanvas
-                    = (AttachmentPreviewsDividedImageCanvas) view;
-            dividedImageCanvas.clearDivisionImage(id.getKey());
-        }
-    }
-
-    public interface AttachmentPreviewsManagerCallback {
-
-        public void onImageDrawn(Object key, boolean success);
-
-        public void onImageLoadStarted(Object key);
-    }
-}
diff --git a/src/com/android/mail/photomanager/BitmapUtil.java b/src/com/android/mail/photomanager/BitmapUtil.java
index f0e02ad..9c2ab2b 100644
--- a/src/com/android/mail/photomanager/BitmapUtil.java
+++ b/src/com/android/mail/photomanager/BitmapUtil.java
@@ -21,8 +21,6 @@
 
 import com.android.mail.utils.LogUtils;
 
-import java.io.InputStream;
-
 /**
  * Provides static functions to decode bitmaps at the optimal size
  */
@@ -34,71 +32,6 @@
     }
 
     /**
-     * Returns Width or Height of the picture, depending on which size is
-     * smaller. Doesn't actually decode the picture, so it is pretty efficient
-     * to run.
-     */
-    public static int getSmallerExtentFromBytes(byte[] bytes) {
-        final BitmapFactory.Options options = new BitmapFactory.Options();
-
-        // don't actually decode the picture, just return its bounds
-        options.inJustDecodeBounds = true;
-        BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
-
-        // test what the best sample size is
-        return Math.min(options.outWidth, options.outHeight);
-    }
-
-    /**
-     * Finds the optimal sampleSize for loading the picture
-     *
-     * @param originalSmallerExtent Width or height of the picture, whichever is
-     *            smaller
-     * @param targetExtent Width or height of the target view, whichever is
-     *            bigger. If either one of the parameters is 0 or smaller, no
-     *            sampling is applied
-     */
-    public static int findOptimalSampleSize(int originalSmallerExtent, int targetExtent) {
-        // If we don't know sizes, we can't do sampling.
-        if (targetExtent < 1)
-            return 1;
-        if (originalSmallerExtent < 1)
-            return 1;
-
-        // Test what the best sample size is. To do that, we find the sample
-        // size that gives us
-        // the best trade-off between resulting image size and memory
-        // requirement. We allow
-        // the down-sampled image to be 20% smaller than the target size. That
-        // way we can get around
-        // unfortunate cases where e.g. a 720 picture is requested for 362 and
-        // not down-sampled at
-        // all. Why 20%? Why not. Prove me wrong.
-        int extent = originalSmallerExtent;
-        int sampleSize = 1;
-        while ((extent >> 1) >= targetExtent * 0.8f) {
-            sampleSize <<= 1;
-            extent >>= 1;
-        }
-
-        return sampleSize;
-    }
-
-    /**
-     * Decodes the bitmap with the given sample size
-     */
-    public static Bitmap decodeBitmapFromBytes(byte[] bytes, int sampleSize) {
-        final BitmapFactory.Options options;
-        if (sampleSize <= 1) {
-            options = null;
-        } else {
-            options = new BitmapFactory.Options();
-            options.inSampleSize = sampleSize;
-        }
-        return BitmapFactory.decodeByteArray(bytes, 0, bytes.length, options);
-    }
-
-    /**
      * Decode an image into a Bitmap, using sub-sampling if the hinted dimensions call for it.
      * Does not crop to fit the hinted dimensions.
      *
@@ -124,43 +57,6 @@
             return null;
         }
     }
-    /**
-     * Decode an input stream into a Bitmap, using sub-sampling if the hinted dimensions call for
-     * it. Does not crop to fit the hinted dimensions.
-     *
-     * @param factory a factory to retrieve fresh input streams from.
-     * @param w hint width in px
-     * @param h hint height in px
-     * @return a decoded Bitmap that is not exactly sized to the hinted dimensions.
-     */
-    public static Bitmap decodeStream(InputStreamFactory factory, int w, int h) {
-        try {
-            // calculate sample size based on w/h
-            final BitmapFactory.Options opts = new BitmapFactory.Options();
-            opts.inJustDecodeBounds = true;
-            InputStream src = factory.newInputStream();
-            BitmapFactory.decodeStream(src, null, opts);
-            if (src != null) {
-                src.close();
-            }
-
-            if (opts.mCancel || opts.outWidth == -1 || opts.outHeight == -1) {
-                return null;
-            }
-
-            opts.inSampleSize = Math.min(opts.outWidth / w, opts.outHeight / h);
-            opts.inJustDecodeBounds = false;
-            src = factory.newInputStream();
-            Bitmap bitmap = BitmapFactory.decodeStream(src, null, opts);
-            if (src != null) {
-                src.close();
-            }
-            return bitmap;
-        } catch (Throwable t) {
-            LogUtils.w(PhotoManager.TAG, t, "unable to decode image");
-            return null;
-        }
-    }
 
     /**
      * Decode an image into a Bitmap, using sub-sampling if the desired dimensions call for it.
@@ -183,43 +79,6 @@
     }
 
     /**
-     * Decode an input stream into a Bitmap, using sub-sampling if the desired dimensions call
-     * for it. Also applies a center-crop a la {@link android.widget.ImageView
-     * .ScaleType#CENTER_CROP}.
-     *
-     * @param factory a factory to retrieve fresh input streams from.
-     * @param w desired width in px
-     * @param h desired height in px
-     * @param horizontalCenterPercent determines which part of the src to crop from. Range from 0
-     *                                .0f to 1.0f. The value determines which part of the src
-     *                                maps to the horizontal center of the resulting bitmap.
-     * @param verticalCenterPercent determines which part of the src to crop from. Range from 0
-     *                              .0f to 1.0f. The value determines which part of the src maps
-     *                              to the vertical center of the resulting bitmap.
-     * @return an exactly-sized decoded Bitmap that is center-cropped.
-     */
-    public static Bitmap decodeStreamWithCrop(final InputStreamFactory factory, final int w,
-            final int h, final float horizontalCenterPercent, final float verticalCenterPercent) {
-        final Bitmap decoded;
-        try {
-            decoded = decodeStream(factory, w, h);
-        } catch (Throwable t) {
-            LogUtils.w(PhotoManager.TAG, t, "unable to decode image");
-            return null;
-        }
-        try {
-            final Bitmap cropped = crop(decoded, w, h, horizontalCenterPercent,
-                    verticalCenterPercent);
-            LogUtils.d(PhotoManager.TAG, "Full decoded bitmap size %d bytes, cropped size %d bytes",
-                    decoded.getByteCount(), cropped.getByteCount());
-            return cropped;
-        } catch (Throwable t) {
-            LogUtils.w(PhotoManager.TAG, t, "unable to crop image");
-            return null;
-        }
-    }
-
-    /**
      * Returns a new Bitmap copy with a center-crop effect a la
      * {@link android.widget.ImageView.ScaleType#CENTER_CROP}. May return the input bitmap if no
      * scaling is necessary.
@@ -314,7 +173,4 @@
         return cropped;
     }
 
-    public interface InputStreamFactory {
-        InputStream newInputStream();
-    }
 }
diff --git a/src/com/android/mail/photomanager/PhotoManager.java b/src/com/android/mail/photomanager/PhotoManager.java
index 0b937cb..299f7af 100644
--- a/src/com/android/mail/photomanager/PhotoManager.java
+++ b/src/com/android/mail/photomanager/PhotoManager.java
@@ -47,9 +47,6 @@
  * Asynchronously loads photos and maintains a cache of photos
  */
 public abstract class PhotoManager implements ComponentCallbacks2, Callback {
-    public static final int STATUS_NOT_LOADED = 0;
-    public static final int STATUS_LOADING = 1;
-    public static final int STATUS_LOADED = 2;
     /**
      * Get the default image provider that draws while the photo is being
      * loaded.
diff --git a/src/com/android/mail/ui/AnimatedAdapter.java b/src/com/android/mail/ui/AnimatedAdapter.java
index 7a9a900..3d0de01 100644
--- a/src/com/android/mail/ui/AnimatedAdapter.java
+++ b/src/com/android/mail/ui/AnimatedAdapter.java
@@ -33,6 +33,8 @@
 import android.view.ViewGroup;
 import android.widget.SimpleCursorAdapter;
 
+import com.android.bitmap.AltBitmapCache;
+import com.android.bitmap.BitmapCache;
 import com.android.mail.R;
 import com.android.mail.browse.ConversationCursor;
 import com.android.mail.browse.ConversationItemView;
@@ -87,6 +89,8 @@
     private final Handler mHandler;
     protected long mLastLeaveBehind = -1;
 
+    private final BitmapCache mBitmapCache;
+
     public interface ConversationListListener {
         /**
          * @return <code>true</code> if the list is just exiting selection mode (so animations may
@@ -215,6 +219,8 @@
     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
+
     public AnimatedAdapter(Context context, ConversationCursor cursor,
             ConversationSelectionSet batch, ControllableActivity activity,
             final ConversationListListener conversationListListener, SwipeableListView listView,
@@ -230,6 +236,8 @@
         mListView = listView;
         mFolderViews = getNestedFolders(childFolders);
 
+        mBitmapCache = new AltBitmapCache(BITMAP_CACHE_TARGET_SIZE_BYTES);
+
         mHandler = new Handler();
         if (sDismissAllShortDelay == -1) {
             final Resources r = context.getResources();
@@ -1030,6 +1038,10 @@
         return oldCursor;
     }
 
+    public BitmapCache getBitmapCache() {
+        return mBitmapCache;
+    }
+
     /**
      * Gets the offset for the given position in the underlying cursor, based on any special views
      * that may be above it.
@@ -1068,4 +1080,5 @@
             specialView.onCabModeEntered();
         }
     }
+
 }
diff --git a/src/com/android/mail/ui/SwipeableListView.java b/src/com/android/mail/ui/SwipeableListView.java
index e184e41..291f64d 100644
--- a/src/com/android/mail/ui/SwipeableListView.java
+++ b/src/com/android/mail/ui/SwipeableListView.java
@@ -58,6 +58,19 @@
      */
     private final static boolean SCROLL_PAUSE_ENABLE = true;
 
+    /**
+     * Set to true to enable parallax effect for attachment previews as the scroll position varies.
+     * This effect triggers invalidations on scroll (!) and requires more memory for attachment
+     * preview bitmaps.
+     */
+    public static final boolean ENABLE_ATTACHMENT_PARALLAX = true;
+
+    /**
+     * The amount of extra vertical space to decode in attachment previews so we have image data to
+     * pan within. 1.0 implies no parallax effect.
+     */
+    public static final float ATTACHMENT_PARALLAX_MULTIPLIER = 1.5f;
+
     private ConversationSelectionSet mConvSelectionSet;
     private int mSwipeAction;
     private Account mAccount;
@@ -366,8 +379,17 @@
     }
 
     @Override
-    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
+    public final void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
             int totalItemCount) {
+        if (ENABLE_ATTACHMENT_PARALLAX) {
+            for (int i = 0, len = getChildCount(); i < len; i++) {
+                final View child = getChildAt(i);
+                if (child instanceof OnScrollListener) {
+                    ((OnScrollListener) child).onScroll(view, firstVisibleItem, visibleItemCount,
+                            totalItemCount);
+                }
+            }
+        }
     }
 
     @Override