Final UI for Attachment Previews.

Placeholder pulsates for images not yet loaded.
One images loads at a time, that one image will display a spinning progressbar
after a delay.
Progress bar fixes to ensure it is rotates smoothly.
Clear section before drawing to it so transparent images look right.
Avoid PhotoManager load loop.

Bug: 9745486
Bug: 9816053
Change-Id: I2e65b3e3484d6da47d4e2523404dc745b99dd04c
diff --git a/res/drawable-hdpi/ic_attachment_load.png b/res/drawable-hdpi/ic_attachment_load.png
new file mode 100644
index 0000000..7b05b48
--- /dev/null
+++ b/res/drawable-hdpi/ic_attachment_load.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_spinner_inner_holo.png b/res/drawable-hdpi/ic_spinner_inner_holo.png
new file mode 100644
index 0000000..e461e74
--- /dev/null
+++ b/res/drawable-hdpi/ic_spinner_inner_holo.png
Binary files differ
diff --git a/res/drawable-hdpi/ic_spinner_outer_holo.png b/res/drawable-hdpi/ic_spinner_outer_holo.png
new file mode 100644
index 0000000..d495aa8
--- /dev/null
+++ b/res/drawable-hdpi/ic_spinner_outer_holo.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_attachment_load.png b/res/drawable-mdpi/ic_attachment_load.png
new file mode 100644
index 0000000..752149d
--- /dev/null
+++ b/res/drawable-mdpi/ic_attachment_load.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_spinner_inner_holo.png b/res/drawable-mdpi/ic_spinner_inner_holo.png
new file mode 100644
index 0000000..0c06981
--- /dev/null
+++ b/res/drawable-mdpi/ic_spinner_inner_holo.png
Binary files differ
diff --git a/res/drawable-mdpi/ic_spinner_outer_holo.png b/res/drawable-mdpi/ic_spinner_outer_holo.png
new file mode 100644
index 0000000..46061db
--- /dev/null
+++ b/res/drawable-mdpi/ic_spinner_outer_holo.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_attachment_load.png b/res/drawable-xhdpi/ic_attachment_load.png
new file mode 100644
index 0000000..92ed99b
--- /dev/null
+++ b/res/drawable-xhdpi/ic_attachment_load.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_spinner_inner_holo.png b/res/drawable-xhdpi/ic_spinner_inner_holo.png
new file mode 100644
index 0000000..273e8e8
--- /dev/null
+++ b/res/drawable-xhdpi/ic_spinner_inner_holo.png
Binary files differ
diff --git a/res/drawable-xhdpi/ic_spinner_outer_holo.png b/res/drawable-xhdpi/ic_spinner_outer_holo.png
new file mode 100644
index 0000000..568e9eb
--- /dev/null
+++ b/res/drawable-xhdpi/ic_spinner_outer_holo.png
Binary files differ
diff --git a/res/drawable/progress_holo.xml b/res/drawable/progress_holo.xml
new file mode 100644
index 0000000..d4ad733
--- /dev/null
+++ b/res/drawable/progress_holo.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2013, The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item>
+        <rotate
+                android:drawable="@drawable/ic_spinner_outer_holo"
+                android:pivotX="50%"
+                android:pivotY="50%"
+                android:fromDegrees="0"
+                android:toDegrees="1080" />
+    </item>
+    <item>
+        <rotate
+                android:drawable="@drawable/ic_spinner_inner_holo"
+                android:pivotX="50%"
+                android:pivotY="50%"
+                android:fromDegrees="720"
+                android:toDegrees="0" />
+    </item>
+</layer-list>
\ No newline at end of file
diff --git a/res/layout/conversation_attachment_previews.xml b/res/layout/conversation_attachment_previews.xml
index eb7ce25..7887e49 100644
--- a/res/layout/conversation_attachment_previews.xml
+++ b/res/layout/conversation_attachment_previews.xml
@@ -1,5 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 
+<!-- The height is set programmatically set in CIVC -->
 <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
         android:id="@+id/attachment_previews"
         android:layout_width="match_parent"
@@ -8,7 +9,7 @@
         android:layout_marginLeft="@dimen/attachment_preview_margin_side"
         android:layout_marginRight="@dimen/attachment_preview_margin_side"
         android:visibility="gone" >
-    <!--todo:markwei get font color, typeface, and size from channah-->
+    <!-- Use dips for textSize since we want the badge to be a fixed size. -->
     <TextView
             android:id="@+id/ap_overflow"
             android:layout_width="@dimen/ap_overflow_count_diameter"
@@ -18,12 +19,17 @@
             android:layout_gravity="bottom|right"
             android:includeFontPadding="false"
             android:textStyle="bold"
-            android:textSize="12sp"/>
-    <!--todo:markwei get actual spinner asset from channah-->
+            android:textSize="10dp"/>
+    <ImageView
+            android:id="@+id/ap_placeholder"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:src="@drawable/ic_attachment_load" />
     <ImageView
             android:id="@+id/ap_progress_bar"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_gravity="center"
-            android:src="@drawable/spinner_holo" />
+            android:src="@drawable/ic_spinner_inner_holo" />
 </FrameLayout>
\ No newline at end of file
diff --git a/res/values/colors.xml b/res/values/colors.xml
index 5af4a3d..3f6ccff 100644
--- a/res/values/colors.xml
+++ b/res/values/colors.xml
@@ -32,9 +32,8 @@
     <color name="date_text_color">@color/dark_gray_text_color</color>
     <color name="message_info_text_color">@color/gray_text_color</color>
     <color name="subject_text_color">#333333</color>
-    <!--todo:markwei get overflow badge and count color from channah-->
-    <color name="ap_overflow_badge_color">#aaeeeeee</color>
-    <color name="ap_overflow_text_color">@android:color/tertiary_text_light</color>
+    <color name="ap_overflow_badge_color">#eeeeeeee</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 -->
     <color name="checked_item_background_color">#cfe9f3</color>
diff --git a/res/values/constants.xml b/res/values/constants.xml
index 517ae8d..4cf9399 100644
--- a/res/values/constants.xml
+++ b/res/values/constants.xml
@@ -115,6 +115,12 @@
     <!-- Number of menu items to hide from the ActionBar by subtracting from actionbar_max_items in non-cab mode -->
     <integer name="actionbar_hidden_non_cab_items_no_physical_button">1</integer>
 
+    <!-- Duration of progress bar animation for attachment previews -->
+    <integer name="ap_progress_animation_duration">4000</integer>
+    <!-- Duration of placeholder pulse animation for attachment previews -->
+    <integer name="ap_placeholder_animation_duration">2000</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 -->
     <integer name="ap_overflow_max_count">99</integer>
 </resources>
diff --git a/res/values/dimen.xml b/res/values/dimen.xml
index 22fca75..d4e3372 100644
--- a/res/values/dimen.xml
+++ b/res/values/dimen.xml
@@ -127,9 +127,7 @@
     <dimen name="attachment_preview_margin_bottom">4dp</dimen>
     <dimen name="attachment_preview_margin_bottom_wide">8dp</dimen>
 
-    <!--todo:markwei get the diameter from channah-->
     <dimen name="ap_overflow_count_diameter">20dp</dimen>
-    <!--todo:markwei channah wanted 8dp but only 4dp fits read previews-->
     <dimen name="ap_margin_side">4dp</dimen>
 
     <dimen name="folder_minimum_width">48dip</dimen>
diff --git a/src/com/android/mail/browse/ConversationItemView.java b/src/com/android/mail/browse/ConversationItemView.java
index a56a95b..c6da7ab 100644
--- a/src/com/android/mail/browse/ConversationItemView.java
+++ b/src/com/android/mail/browse/ConversationItemView.java
@@ -36,6 +36,7 @@
 import android.graphics.Typeface;
 import android.graphics.drawable.Drawable;
 import android.net.Uri;
+import android.os.SystemClock;
 import android.text.Layout.Alignment;
 import android.text.Spannable;
 import android.text.SpannableString;
@@ -57,8 +58,11 @@
 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.OnScrollListener;
 import android.widget.TextView;
 
 import com.android.mail.R;
@@ -75,6 +79,7 @@
 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;
@@ -135,7 +140,8 @@
     private static Bitmap STATE_CALENDAR_INVITE;
     private static Bitmap VISIBLE_CONVERSATION_CARET;
     private static Drawable RIGHT_EDGE_TABLET;
-    private static Bitmap PROGRESS_BAR;
+    private static Bitmap PLACEHOLDER;
+    private static Drawable PROGRESS_BAR;
 
     private static String sSendersSplitToken;
     private static String sElidedPaddingToken;
@@ -155,8 +161,10 @@
     private static int sShrinkAnimationDuration;
     private static int sSlideAnimationDuration;
     private static int sAnimatingBackgroundColor;
-    // todo:markwei get duration from channah
     private static int sProgressAnimationDuration;
+    private static float sPlaceholderAnimationDurationRatio;
+    private static int sProgressAnimationDelay;
+    private static Interpolator sPulseAnimationInterpolator;
     private static int sOverflowCountMax;
 
     // Static paints.
@@ -228,8 +236,12 @@
      * this animator does not remove the progress bars.
      */
     private final ObjectAnimator mProgressAnimator;
+    private long mProgressAnimatorCancelledTime;
+    /** Range from 0.0f to 1.0f. */
     private float mAnimatedProgressFraction;
-    private boolean[] mImagesLoaded = new boolean[0];
+    private int[] mImagesLoaded = new int[0];
+    private boolean mShowProgressBar;
+    private Runnable mSetShowProgressBarRunnable;
     private static final boolean CONVLIST_ATTACHMENT_PREVIEWS_ENABLED = true;
 
     static {
@@ -237,18 +249,24 @@
         sFoldersPaint.setAntiAlias(true);
     }
 
-    public static void setPhotoManagersPaused(boolean shouldPause) {
+    public static void setScrollStateChanged(final int scrollState) {
         if (sContactPhotoManager == null) {
             return;
         }
+        final boolean scrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE;
+        final boolean flinging = scrollState == OnScrollListener.SCROLL_STATE_FLING;
 
-        if (shouldPause) {
-            sContactPhotoManager.pause();
+        if (scrolling) {
             sAttachmentPreviewsManager.pause();
         } else {
-            sContactPhotoManager.resume();
             sAttachmentPreviewsManager.resume();
         }
+
+        if (flinging) {
+            sContactPhotoManager.pause();
+        } else {
+            sContactPhotoManager.resume();
+        }
     }
 
     /**
@@ -429,8 +447,8 @@
             VISIBLE_CONVERSATION_CARET = BitmapFactory.decodeResource(res,
                     R.drawable.ic_carrot_holo);
             RIGHT_EDGE_TABLET = res.getDrawable(R.drawable.list_edge_tablet);
-//            todo:markwei get actual spinner asset from channah
-            PROGRESS_BAR = BitmapFactory.decodeResource(res, drawable.spinner_holo);
+            PLACEHOLDER = BitmapFactory.decodeResource(res, drawable.ic_attachment_load);
+            PROGRESS_BAR = res.getDrawable(drawable.progress_holo);
 
             // Initialize colors.
             sActivatedTextColor = res.getColor(R.color.senders_text_color_read);
@@ -462,8 +480,13 @@
             sFoldersLeftPadding = res.getDimensionPixelOffset(R.dimen.folders_left_padding);
             sContactPhotoManager = ContactPhotoManager.createContactPhotoManager(context);
             sAttachmentPreviewsManager = new AttachmentPreviewsManager(context);
-            // todo:markwei get animation duration from channah
-            sProgressAnimationDuration = 1000;
+            sProgressAnimationDuration = res.getInteger(integer.ap_progress_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);
         }
 
@@ -503,6 +526,14 @@
                 });
 
         mProgressAnimator = createProgressAnimator();
+        mSetShowProgressBarRunnable = new Runnable() {
+            @Override
+            public void run() {
+                LogUtils.v(LOG_TAG, "progress bar: >>> set to true");
+                // It's OK to set this field to true when the status is no longer LOADING.
+                mShowProgressBar = true;
+            }
+        };
         Utils.traceEndSection();
     }
 
@@ -518,6 +549,7 @@
     private void bind(ConversationItemViewModel header, ControllableActivity activity,
             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.
@@ -541,6 +573,7 @@
                             != mHeader.conversation.attachmentPreviewsCount
                     || !header.conversation.getAttachmentPreviewUris()
                             .equals(mHeader.conversation.getAttachmentPreviewUris())) {
+                attachmentPreviewsChanged = true;
                 ArrayList<String> divisionIds = mAttachmentPreviewsCanvas.getDivisionIds();
                 if (divisionIds != null) {
                     mAttachmentPreviewsCanvas.reset();
@@ -565,7 +598,10 @@
         mStarEnabled = folder != null && !folder.isTrash();
         mSwipeEnabled = swipeEnabled;
         mAdapter = adapter;
-        mImagesLoaded = new boolean[mHeader.conversation.getAttachmentPreviewUris().size()];
+        final int attachmentPreviewsSize = mHeader.conversation.getAttachmentPreviewUris().size();
+        if (attachmentPreviewsChanged || mImagesLoaded.length != attachmentPreviewsSize) {
+            mImagesLoaded = new int[attachmentPreviewsSize];
+        }
 
         if (checkboxOrSenderImage == ConversationListIcon.SENDER_IMAGE) {
             mGadgetMode = ConversationItemViewCoordinates.GADGET_CONTACT_PHOTO;
@@ -878,8 +914,6 @@
         Utils.traceBeginSection("Setup load attachment previews");
 
         LogUtils.d(LOG_TAG,
-                "loadAttachmentPreviews: ###############################################");
-        LogUtils.d(LOG_TAG,
                 "loadAttachmentPreviews: Loading attachment previews for conversation %s",
                 mHeader.conversation);
 
@@ -897,7 +931,7 @@
             final String uri = attachmentUris.get(i);
 
             // Find the rendition to load based on availability.
-            LogUtils.d(LOG_TAG, "loadAttachmentPreviews: state [BEST, SIMPLE] is [%s, %s] for %s ",
+            LogUtils.v(LOG_TAG, "loadAttachmentPreviews: state [BEST, SIMPLE] is [%s, %s] for %s ",
                     Attachment.getPreviewState(previewStates, i, AttachmentRendition.BEST),
                     Attachment.getPreviewState(previewStates, i, AttachmentRendition.SIMPLE),
                     uri);
@@ -927,59 +961,132 @@
         // 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);
-            if (i < mImagesLoaded.length) {
-                // We want to show default progress image
-                mImagesLoaded[i] = false;
-                if (!mProgressAnimator.isStarted()) {
-                    LogUtils.d(LOG_TAG, "progress animator: >> started");
-                    mProgressAnimator.setCurrentPlayTime(
-                            (long) (sProgressAnimationDuration * mAnimatedProgressFraction));
-                    mProgressAnimator.start();
+            Utils.traceEndSection();
+
+            Utils.traceBeginSection("start animator");
+            // We want to show default progress image
+            setImageLoaded(i, PhotoManager.STATUS_NOT_LOADED);
+            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);
                 }
+                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(Object key, boolean success) {
-        Utils.traceBeginSection("on image drawn");
-        String uri = AttachmentPreviewsManager.transformKeyToUri(key);
-        int index = mHeader.conversation.getAttachmentPreviewUris().indexOf(uri);
-
-        if (index < 0 || index >= mImagesLoaded.length) {
-            Utils.traceEndSection();
+    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.d(LOG_TAG,
+        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.
-        mImagesLoaded[index] = success;
+        setImageLoaded(index,
+                success ? PhotoManager.STATUS_LOADED : PhotoManager.STATUS_NOT_LOADED);
 
         if (mProgressAnimator.isStarted() && areAllImagesLoaded()) {
-            LogUtils.d(LOG_TAG, "progress animator: << stopped");
+            LogUtils.v(LOG_TAG, "progress animator: << stopped");
             mProgressAnimator.cancel();
         }
         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);
+        setImageLoaded(index, PhotoManager.STATUS_LOADING);
+    }
+
     private boolean areAllImagesLoaded() {
         for (int i = 0; i < mImagesLoaded.length; i++) {
-            if (!mImagesLoaded[i]) {
+            if (mImagesLoaded[i] != PhotoManager.STATUS_LOADED) {
                 return false;
             }
         }
         return true;
     }
 
+    /**
+     * Update the #mImagesLoaded state array with special logic.
+     * @param index Which attachment preview's state to update.
+     * @param status What the new state is.
+     */
+    private void setImageLoaded(final int index, final int status) {
+        if (index < 0 || index >= mImagesLoaded.length) {
+            return;
+        }
+        final int prevStatus = mImagesLoaded[index];
+        switch (status) {
+            case PhotoManager.STATUS_NOT_LOADED:
+                // Cannot transition directly from LOADING to NOT_LOADED.
+                if (prevStatus != PhotoManager.STATUS_LOADING) {
+                    mImagesLoaded[index] = status;
+                }
+                break;
+            case PhotoManager.STATUS_LOADING:
+                // All other statuses must be set to not loading.
+                for (int i = 0; i < mImagesLoaded.length; i++) {
+                    if (i != index && mImagesLoaded[i] == PhotoManager.STATUS_LOADING) {
+                        mImagesLoaded[i] = PhotoManager.STATUS_NOT_LOADED;
+                    }
+                }
+                mImagesLoaded[index] = status;
+
+                if (prevStatus != PhotoManager.STATUS_LOADING) {
+                    // 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");
+                    removeCallbacks(mSetShowProgressBarRunnable);
+                    postDelayed(mSetShowProgressBarRunnable, sProgressAnimationDelay);
+                }
+                break;
+            case PhotoManager.STATUS_LOADED:
+                mImagesLoaded[index] = status;
+                break;
+        }
+    }
+
     private static int makeExactSpecForSize(int size) {
         return MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY);
     }
@@ -1421,8 +1528,7 @@
 
             // Overflow badge and count
             if (getOverflowCountVisible() && areAllImagesLoaded()) {
-                float radius = mCoordinates.overflowDiameter / 2;
-                // todo:markwei get color of overflow badge from channah
+                final float radius = mCoordinates.overflowDiameter / 2;
                 sPaint.setColor(sOverflowBadgeColor);
                 canvas.drawCircle(mCoordinates.overflowXEnd - radius,
                         mCoordinates.overflowYEnd - radius, radius, sPaint);
@@ -1435,12 +1541,35 @@
 
             // Progress bar
             if (mProgressAnimator.isRunning()) {
+                // 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 alpha = (int) (interpolatedProgress * range + minAlpha);
+                sPaint.setAlpha(alpha);
+
                 final int count = mImagesLoaded.length;
                 for (int i = 0; i < count; i++) {
-                    if (!mImagesLoaded[i]) {
+                    if (mShowProgressBar && mImagesLoaded[i] == PhotoManager.STATUS_LOADING) {
+                        // status is LOADING and enough time has passed
                         canvas.save();
                         drawProgressBar(canvas, i, count);
                         canvas.restore();
+                    } else if (mImagesLoaded[i] != PhotoManager.STATUS_LOADED) {
+                        // status is either NOT_LOADED or LOADING
+                        drawPlaceholder(canvas, i, count);
                     }
                 }
             }
@@ -1484,6 +1613,21 @@
     }
 
     /**
+     * Draws the specified placeholder 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 drawPlaceholder(Canvas canvas, int index, int total) {
+        int placeholderX = getPlaceholderX(index, total);
+        if (placeholderX == -1) {
+            return;
+        }
+
+        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.
@@ -1495,18 +1639,19 @@
             return;
         }
 
-        // We want to rotate counter-clockwise, because that's the direction the asset faces
-        canvas.rotate(360 - mAnimatedProgressFraction * 360,
-                progressBarX + mCoordinates.progressBarWidth / 2,
-                mCoordinates.progressBarY + mCoordinates.progressBarHeight / 2);
-
-        canvas.drawBitmap(PROGRESS_BAR, progressBarX, mCoordinates.progressBarY, null);
+        // 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.draw(canvas);
     }
 
     /**
      * @see com.android.mail.browse.ConversationItemView#drawProgressBar
      */
-    private void invalidateProgressBar(int index, int total) {
+    private void invalidatePlaceholderAndProgressBar(int index, int total) {
         int progressBarX = getProgressBarX(index, total);
         if (progressBarX == -1) {
             return;
@@ -1515,6 +1660,25 @@
         invalidate(progressBarX, mCoordinates.progressBarY,
                 progressBarX + mCoordinates.progressBarWidth,
                 mCoordinates.progressBarY + mCoordinates.progressBarHeight);
+
+        int placeholderX = getPlaceholderX(index, total);
+        if (placeholderX == -1) {
+            return;
+        }
+
+        invalidate(placeholderX, mCoordinates.placeholderY,
+                placeholderX + mCoordinates.placeholderWidth,
+                mCoordinates.placeholderY + mCoordinates.placeholderHeight);
+    }
+
+    private int getPlaceholderX(int index, int total) {
+        if (mCoordinates == null) {
+            return -1;
+        }
+        int sectionWidth = mCoordinates.attachmentPreviewsWidth / total;
+        int sectionOffset = index * sectionWidth;
+        return mCoordinates.attachmentPreviewsX + sectionOffset + sectionWidth / 2
+                - mCoordinates.placeholderWidth / 2;
     }
 
     private int getProgressBarX(int index, int total) {
@@ -1800,9 +1964,8 @@
         setAlpha(1f);
         setTranslationX(0f);
         mAnimatedHeightFraction = 1.0f;
-        LogUtils.d(LOG_TAG, "progress animator: cancelling after %dms", sProgressAnimationDuration);
         if (mProgressAnimator.isStarted()) {
-            LogUtils.d(LOG_TAG, "progress animator: << stopped");
+            LogUtils.v(LOG_TAG, "progress animator: << stopped");
             mProgressAnimator.cancel();
         }
         Utils.traceEndSection();
@@ -1899,26 +2062,28 @@
     }
 
     private ObjectAnimator createProgressAnimator() {
-        ObjectAnimator animator = ObjectAnimator.ofFloat(this, "animatedProgressFraction", 0f, 1.0f)
-                .setDuration(sProgressAnimationDuration);
+        final ObjectAnimator animator = ObjectAnimator
+                .ofFloat(this, "animatedProgressFraction", 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(Animator animation) {
+            public void onAnimationEnd(final Animator animation) {
                 invalidateAll();
             }
 
             @Override
-            public void onAnimationCancel(Animator animation) {
+            public void onAnimationCancel(final Animator animation) {
                 invalidateAll();
+                mProgressAnimatorCancelledTime = SystemClock.uptimeMillis();
             }
 
             private void invalidateAll() {
-                int count = mHeader.conversation.getAttachmentPreviewUris().size();
+                final int count = mHeader.conversation.getAttachmentPreviewUris().size();
                 for (int i = 0; i < count; i++) {
-                    invalidateProgressBar(i, count);
+                    invalidatePlaceholderAndProgressBar(i, count);
                 }
             }
         });
@@ -1926,12 +2091,16 @@
     }
 
     // Used by animator
-    public void setAnimatedProgressFraction(float fraction) {
+    public void setAnimatedProgressFraction(final float fraction) {
+        // ObjectAnimator.cancel() sets the field to 0.0f.
+        if (fraction == 0.0f) {
+            return;
+        }
         mAnimatedProgressFraction = fraction;
         final int count = mImagesLoaded.length;
         for (int i = 0; i < count; i++) {
-            if (!mImagesLoaded[i]) {
-                invalidateProgressBar(i, count);
+            if (mImagesLoaded[i] != PhotoManager.STATUS_LOADED) {
+                invalidatePlaceholderAndProgressBar(i, count);
             }
         }
     }
diff --git a/src/com/android/mail/browse/ConversationItemViewCoordinates.java b/src/com/android/mail/browse/ConversationItemViewCoordinates.java
index 34b5596..543f636 100644
--- a/src/com/android/mail/browse/ConversationItemViewCoordinates.java
+++ b/src/com/android/mail/browse/ConversationItemViewCoordinates.java
@@ -250,6 +250,10 @@
     final float overflowFontSize;
     final Typeface overflowTypeface;
 
+    // Attachment previews placeholder
+    final int placeholderY;
+    final int placeholderWidth;
+    final int placeholderHeight;
     // Attachment previews progress bar
     final int progressBarY;
     final int progressBarWidth;
@@ -483,6 +487,12 @@
             overflowFontSize = overflow.getTextSize();
             overflowTypeface = overflow.getTypeface();
 
+            final View placeholder = view.findViewById(id.ap_placeholder);
+            placeholderWidth = placeholder.getWidth();
+            placeholderHeight = placeholder.getHeight();
+            placeholderY = attachmentPreviewsY + attachmentPreviewsHeight / 2
+                    - placeholderHeight / 2;
+
             final View progressBar = view.findViewById(id.ap_progress_bar);
             progressBarWidth = progressBar.getWidth();
             progressBarHeight = progressBar.getHeight();
@@ -498,6 +508,9 @@
             overflowDiameter = 0;
             overflowFontSize = 0;
             overflowTypeface = null;
+            placeholderY = 0;
+            placeholderWidth = 0;
+            placeholderHeight = 0;
             progressBarY = 0;
             progressBarWidth = 0;
             progressBarHeight = 0;
diff --git a/src/com/android/mail/photomanager/AttachmentPreviewsManager.java b/src/com/android/mail/photomanager/AttachmentPreviewsManager.java
index dc584f3..10149e9 100644
--- a/src/com/android/mail/photomanager/AttachmentPreviewsManager.java
+++ b/src/com/android/mail/photomanager/AttachmentPreviewsManager.java
@@ -85,6 +85,18 @@
     }
 
     @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
@@ -235,7 +247,7 @@
                 }
                 Attachment attachment = null;
                 try {
-                    LogUtils.d(TAG, "AttachmentPreviewsManager: found %d attachments for uri %s",
+                    LogUtils.v(TAG, "AttachmentPreviewsManager: found %d attachments for uri %s",
                             cursor.getCount(), uri);
                     if (cursor.moveToFirst()) {
                         attachment = new Attachment(cursor);
@@ -245,7 +257,7 @@
                 }
 
                 if (attachment == null) {
-                    LogUtils.d(TAG, "AttachmentPreviewsManager: attachment not found for uri %s",
+                    LogUtils.w(TAG, "AttachmentPreviewsManager: attachment not found for uri %s",
                             uri);
                     Utils.traceEndSection();
                     continue;
@@ -258,14 +270,14 @@
                 } else if (id.rendition == AttachmentRendition.SIMPLE) {
                     contentUri = attachment.thumbnailUri;
                 } else {
-                    LogUtils.d(TAG,
+                    LogUtils.w(TAG,
                             "AttachmentPreviewsManager: Cannot load rendition %d for uri %s",
                             id.rendition, uri);
                     Utils.traceEndSection();
                     continue;
                 }
 
-                LogUtils.d(TAG, "AttachmentPreviewsManager: attachments has contentUri %s",
+                LogUtils.v(TAG, "AttachmentPreviewsManager: attachments has contentUri %s",
                         contentUri);
                 final InputStreamFactory factory = new InputStreamFactory() {
                     @Override
@@ -348,5 +360,7 @@
     public interface AttachmentPreviewsManagerCallback {
 
         public void onImageDrawn(Object key, boolean success);
+
+        public void onImageLoadStarted(Object key);
     }
 }
diff --git a/src/com/android/mail/photomanager/PhotoManager.java b/src/com/android/mail/photomanager/PhotoManager.java
index 57d2fa1..66c1058 100644
--- a/src/com/android/mail/photomanager/PhotoManager.java
+++ b/src/com/android/mail/photomanager/PhotoManager.java
@@ -47,6 +47,9 @@
  * 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.
@@ -64,12 +67,20 @@
     protected abstract PhotoLoaderThread getLoaderThread(ContentResolver contentResolver);
 
     /**
-     * Subclasses can implement this method to alert callbacks of the images' loading progress.
+     * Subclasses can implement this method to alert callbacks that images finished loading.
      * @param request The original request made.
      * @param success True if we successfully loaded the image from cache. False if we fell back
      *                to the default image.
      */
-    protected void onImageDrawn(Request request, boolean success) {
+    protected void onImageDrawn(final Request request, final boolean success) {
+        // Subclasses can choose to do something about this
+    }
+
+    /**
+     * Subclasses can implement this method to alert callbacks that images started loading.
+     * @param request The original request made.
+     */
+    protected void onImageLoadStarted(final Request request) {
         // Subclasses can choose to do something about this
     }
 
@@ -108,6 +119,11 @@
      */
     private static final int MESSAGE_PHOTOS_LOADED = 2;
 
+    /**
+     * Type of message sent by the loader thread to indicate that
+     */
+    private static final int MESSAGE_PHOTO_LOADING = 3;
+
     public interface DefaultImageProvider {
         /**
          * Applies the default avatar to the DividedImageView. Extent is an
@@ -154,6 +170,7 @@
         }
     }
 
+    // todo:ath caches should be member vars
     /**
      * An LRU cache for bitmap holders. The cache contains bytes for photos just
      * as they come from the database. Each holder has a soft reference to the
@@ -326,13 +343,14 @@
     /**
      * Checks if the photo is present in cache.  If so, sets the photo on the view.
      *
-     * @param request Determines which image to load from cache.
+     * @param request                   Determines which image to load from cache.
      * @param afterLoaderThreadFinished Pass true if calling after the LoaderThread has run. Pass
      *                                  false if the Loader Thread hasn't made any attempts to
      *                                  load images yet.
      * @return false if the photo needs to be (re)loaded from the provider.
      */
-    private boolean loadCachedPhoto(Request request, boolean afterLoaderThreadFinished) {
+    private boolean loadCachedPhoto(final Request request,
+            final boolean afterLoaderThreadFinished) {
         Utils.traceBeginSection("Load cached photo");
         final Bitmap cached = getCachedPhoto(request.bitmapKey);
         if (cached != null) {
@@ -345,8 +363,8 @@
                         Thread.currentThread());
             }
             if (request.getView().getGeneration() == request.viewGeneration) {
-                onImageDrawn(request, true);
                 request.getView().drawImage(cached, request.getKey());
+                onImageDrawn(request, true);
             }
             Utils.traceEndSection();
             return true;
@@ -369,8 +387,8 @@
                             Thread.currentThread());
                 }
                 if (request.getView().getGeneration() == request.viewGeneration) {
-                    onImageDrawn(request, true);
                     request.getView().drawImage(cachedReplacement, request.getKey());
+                    onImageDrawn(request, true);
                 }
                 Utils.traceEndSection();
                 return false;
@@ -405,6 +423,7 @@
      * Temporarily stops loading photos from the database.
      */
     public void pause() {
+        LogUtils.d(TAG, "%s paused.", getClass().getName());
         mPaused = true;
     }
 
@@ -412,6 +431,7 @@
      * Resumes loading photos from the database.
      */
     public void resume() {
+        LogUtils.d(TAG, "%s resumed.", getClass().getName());
         mPaused = false;
         if (DEBUG) dumpStats();
         if (!mPendingRequests.isEmpty()) {
@@ -436,7 +456,7 @@
      * Processes requests on the main thread.
      */
     @Override
-    public boolean handleMessage(Message msg) {
+    public boolean handleMessage(final Message msg) {
         switch (msg.what) {
             case MESSAGE_REQUEST_LOADING: {
                 mLoadingRequested = false;
@@ -454,6 +474,15 @@
                 if (DEBUG) dumpStats();
                 return true;
             }
+
+            case MESSAGE_PHOTO_LOADING: {
+                if (!mPaused) {
+                    final int hashcode = msg.arg1;
+                    final Request request = mPendingRequests.get(hashcode);
+                    onImageLoadStarted(request);
+                }
+                return true;
+            }
         }
         return false;
     }
@@ -468,7 +497,10 @@
         for (Integer hash : mPendingRequests.keySet()) {
             Request request = mPendingRequests.get(hash);
             boolean loaded = loadCachedPhoto(request, true);
-            if (loaded) {
+            // Request can go through multiple attempts if the LoaderThread fails to load any
+            // images for it, or if the images it loads are evicted from the cache before we
+            // could access them in the main thread.
+            if (loaded || request.attempts > 2) {
                 toRemove.add(hash);
             }
         }
@@ -476,9 +508,6 @@
             mPendingRequests.remove(key);
         }
 
-        // TODO: this already seems to happen when calling loadCachedPhoto
-        //softenCache();
-
         if (!mPendingRequests.isEmpty()) {
             LogUtils.d(TAG, "Finished loading batch. %d still have to be loaded.",
                     mPendingRequests.size());
@@ -695,6 +724,11 @@
                     loadRequests.add(request);
                     decodeRequests.add(request);
                     batchCount++;
+
+                    final Message msg = Message.obtain();
+                    msg.what = MESSAGE_PHOTO_LOADING;
+                    msg.arg1 = request.hashCode();
+                    mMainThreadHandler.sendMessage(msg);
                 } else {
                     // Even if the image load is already done, this particular decode configuration
                     // may not yet have run. Be sure to add it to the queue.
@@ -702,6 +736,7 @@
                         decodeRequests.add(request);
                     }
                 }
+                request.attempts++;
                 if (maxBatchCount > 0 && batchCount >= maxBatchCount) {
                     break;
                 }
@@ -869,6 +904,7 @@
         private final ImageCanvas mView;
         public final BitmapIdentifier bitmapKey;
         public final int viewGeneration;
+        public int attempts;
 
         private Request(PhotoIdentifier photoIdentifier, DefaultImageProvider defaultProvider,
                 ImageCanvas view, ImageCanvas.Dimensions dimensions) {
@@ -953,6 +989,10 @@
 
         @Override
         public int compareTo(Request another) {
+            // Hold off on loading Requests which have failed before so it don't hold up others
+            if (attempts - another.attempts != 0) {
+                return attempts - another.attempts;
+            }
             return mPhotoIdentifier.compareTo(another.mPhotoIdentifier);
         }
     }
diff --git a/src/com/android/mail/ui/DividedImageCanvas.java b/src/com/android/mail/ui/DividedImageCanvas.java
index e40ff12..2880ea4 100644
--- a/src/com/android/mail/ui/DividedImageCanvas.java
+++ b/src/com/android/mail/ui/DividedImageCanvas.java
@@ -157,6 +157,7 @@
             // l t r b
             sSrc.set(0, srcTop, b.getWidth(), srcBottom);
             sDest.set(left, top, right, bottom);
+            mCanvas.drawRect(sDest, sClearPaint);
             mCanvas.drawBitmap(b, sSrc, sDest, sPaint);
         } else {
             // clear
@@ -372,8 +373,6 @@
      * Draw the contents of the DividedImageCanvas to the supplied canvas.
      */
     public void draw(Canvas canvas) {
-        // todo:markwei we can see the old image behind transparency regions. Should we also
-        // "clear" the canvas? ath
         if (mDividedBitmap != null && mBitmapValid) {
             canvas.drawBitmap(mDividedBitmap, 0, 0, null);
         }
diff --git a/src/com/android/mail/ui/SwipeableListView.java b/src/com/android/mail/ui/SwipeableListView.java
index 80cf6de..24189fd 100644
--- a/src/com/android/mail/ui/SwipeableListView.java
+++ b/src/com/android/mail/ui/SwipeableListView.java
@@ -373,7 +373,7 @@
     }
 
     @Override
-    public void onScrollStateChanged(AbsListView view, int scrollState) {
+    public void onScrollStateChanged(final AbsListView view, final int scrollState) {
         mScrolling = scrollState != OnScrollListener.SCROLL_STATE_IDLE;
 
         if (!mScrolling) {
@@ -387,7 +387,7 @@
         }
 
         if (SCROLL_PAUSE_ENABLE) {
-            ConversationItemView.setPhotoManagersPaused(mScrolling);
+            ConversationItemView.setScrollStateChanged(scrollState);
         }
     }