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.Context; 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.DecodeAggregator; import com.android.bitmap.DecodeTask; import com.android.bitmap.DecodeTask.Request; import com.android.bitmap.ReusableBitmap; import com.android.bitmap.Trace; import com.android.mail.R; import com.android.mail.browse.ConversationItemViewCoordinates; import com.android.mail.ui.SwipeableListView; import com.android.mail.utils.LogUtils; import com.android.mail.utils.RectUtils; 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. *

* The actual bitmap decode work is handled by {@link DecodeTask}. */ public class AttachmentDrawable extends Drawable implements DecodeTask.BitmapView, Drawable.Callback, Runnable, Parallaxable, DecodeAggregator.Callback { private ImageAttachmentRequest mCurrKey; private ReusableBitmap mBitmap; private final BitmapCache mCache; private final DecodeAggregator mDecodeAggregator; private DecodeTask mTask; private int mDecodeWidth; private int mDecodeHeight; private int mLoadState = LOAD_STATE_UNINITIALIZED; private float mParallaxFraction = 0.5f; private float mParallaxSpeedMultiplier; // 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()); 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_YET_LOADED = 1; private static final int LOAD_STATE_LOADING = 2; private static final int LOAD_STATE_LOADED = 3; private static final int LOAD_STATE_FAILED = 4; 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 final String LOG_TAG = "AttachPreview"; public AttachmentDrawable(final Resources res, final BitmapCache cache, final DecodeAggregator decodeAggregator, final ConversationItemViewCoordinates coordinates, final Drawable placeholder, final Drawable progress) { mCoordinates = coordinates; mDensity = res.getDisplayMetrics().density; mCache = cache; this.mDecodeAggregator = decodeAggregator; 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 setDecodeDimensions(int w, int h) { mDecodeWidth = w; mDecodeHeight = h; } public void setParallaxSpeedMultiplier(final float parallaxSpeedMultiplier) { mParallaxSpeedMultiplier = parallaxSpeedMultiplier; } public void showStaticPlaceholder() { setLoadState(LOAD_STATE_FAILED); } public void unbind() { setImage(null); } public void bind(Context context, String lookupUri, int rendition) { final Rect bounds = getBounds(); if (bounds.isEmpty()) { throw new IllegalStateException("AttachmentDrawable must have bounds set before bind"); } setImage(new ImageAttachmentRequest(context, lookupUri, rendition, bounds.width())); } private void setImage(final ImageAttachmentRequest key) { if (mCurrKey != null && mCurrKey.equals(key)) { return; } Trace.beginSection("set image"); // avoid visual state transitions when the existing request and the new one are just // requests for different renditions of the same attachment final boolean onlyRenditionChange = (mCurrKey != null && mCurrKey.matches(key)); if (mBitmap != null && !onlyRenditionChange) { mBitmap.releaseReference(); // System.out.println("view.bind() decremented ref to old bitmap: " + mBitmap); mBitmap = null; } if (mCurrKey != null && SwipeableListView.ENABLE_ATTACHMENT_DECODE_AGGREGATOR) { mDecodeAggregator.forget(mCurrKey); } mCurrKey = key; if (mTask != null) { mTask.cancel(); mTask = null; } mHandler.removeCallbacks(this); // start from a clean slate on every bind // this allows the initial transition to be specially instantaneous, so e.g. a cache hit // doesn't unnecessarily trigger a fade-in setLoadState(LOAD_STATE_UNINITIALIZED); if (key == null) { Trace.endSection(); return; } // find cached entry here and skip decode if found. final ReusableBitmap cached = mCache.get(key, true /* incrementRefCount */); if (cached != null) { setBitmap(cached); LogUtils.d(LOG_TAG, "CACHE HIT key=%s", mCurrKey); } else { decode(!onlyRenditionChange); if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { LogUtils.d(LOG_TAG, "CACHE MISS key=%s\ncache=%s", mCurrKey, mCache.toDebugString()); } } Trace.endSection(); } @Override public void setParallaxFraction(float fraction) { mParallaxFraction = fraction; } @Override public void draw(final 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, Integer.MAX_VALUE, mParallaxFraction, false /* absoluteFraction */, mParallaxSpeedMultiplier, mSrcRect); final int orientation = mBitmap.getOrientation(); // calculateCroppedSrcRect() gave us the source rectangle "as if" the orientation has // been corrected. We need to decode the uncorrected source rectangle. Calculate true // coordinates. RectUtils.rotateRectForOrientation(orientation, new Rect(0, 0, mBitmap.getLogicalWidth(), mBitmap.getLogicalHeight()), mSrcRect); // We may need to rotate the canvas, so we also have to rotate the bounds. final Rect rotatedBounds = new Rect(bounds); RectUtils.rotateRect(orientation, bounds.centerX(), bounds.centerY(), rotatedBounds); // Rotate the canvas. canvas.save(); canvas.rotate(orientation, bounds.centerX(), bounds.centerY()); canvas.drawBitmap(mBitmap.bmp, mSrcRect, rotatedBounds, mPaint); canvas.restore(); } // 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(final Request key) { if (SwipeableListView.ENABLE_ATTACHMENT_DECODE_AGGREGATOR) { mDecodeAggregator.expect(key, this); } else { onBecomeFirstExpected(key); } } @Override public void onBecomeFirstExpected(final 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_YET_LOADED) { setLoadState(LOAD_STATE_LOADING); } } @Override public void onDecodeComplete(final Request key, final ReusableBitmap result) { if (SwipeableListView.ENABLE_ATTACHMENT_DECODE_AGGREGATOR) { mDecodeAggregator.execute(key, new Runnable() { @Override public void run() { onDecodeCompleteImpl(key, result); } @Override public String toString() { return "DONE"; } }); } else { onDecodeCompleteImpl(key, result); } } private void onDecodeCompleteImpl(final Request key, final 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(); } } } @Override public void onDecodeCancel(final Request key) { if (SwipeableListView.ENABLE_ATTACHMENT_DECODE_AGGREGATOR) { mDecodeAggregator.forget(key); } } private void setBitmap(ReusableBitmap bmp) { if (mBitmap != null && mBitmap != bmp) { mBitmap.releaseReference(); } mBitmap = bmp; setLoadState((bmp != null) ? LOAD_STATE_LOADED : LOAD_STATE_FAILED); invalidateSelf(); } private void decode(boolean executeStateChange) { final int w; final int bufferW; final int bufferH; if (mCurrKey == null) { return; } Trace.beginSection("decode"); if (LIMIT_BITMAP_DENSITY) { final float scale = Math.min(1f, (float) MAX_BITMAP_DENSITY / DisplayMetrics.DENSITY_DEFAULT / mDensity); w = (int) (mCurrKey.mDestW * scale); bufferW = (int) (mDecodeWidth * scale); bufferH = (int) (mDecodeHeight * scale); } else { w = mCurrKey.mDestW; bufferW = mDecodeWidth; bufferH = mDecodeHeight; } if (w == 0 || bufferH == 0) { Trace.endSection(); return; } // System.out.println("ITEM " + this + " w=" + w + " h=" + bufferH + " key=" + mCurrKey); if (mTask != null) { mTask.cancel(); } if (executeStateChange) { setLoadState(LOAD_STATE_NOT_YET_LOADED); } mTask = new DecodeTask(mCurrKey, w, bufferH, bufferW, bufferH, this, mCache); mTask.executeOnExecutor(EXECUTOR); Trace.endSection(); } private void setLoadState(int loadState) { LogUtils.v(LOG_TAG, "IN AD.setState. old=%s new=%s key=%s this=%s", mLoadState, loadState, mCurrKey, this); if (mLoadState == loadState) { LogUtils.v(LOG_TAG, "OUT no-op AD.setState"); return; } Trace.beginSection("set load state"); switch (loadState) { // This state differs from LOADED in that the subsequent state transition away from // UNINITIALIZED will not have a fancy transition. This allows list item binds to // cached data to take immediate effect without unnecessary whizzery. case LOAD_STATE_UNINITIALIZED: mPlaceholder.reset(); mProgress.reset(); break; case LOAD_STATE_NOT_YET_LOADED: mPlaceholder.setPulseEnabled(true); 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; case LOAD_STATE_FAILED: mPlaceholder.setPulseEnabled(false); mPlaceholder.setVisible(true); mProgress.setVisible(false); break; } Trace.endSection(); mLoadState = loadState; LogUtils.v(LOG_TAG, "OUT stateful AD.setState. new=%s placeholder=%s progress=%s", loadState, mPlaceholder.isVisible(), mProgress.isVisible()); } @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; private boolean mPulseEnabled = true; private float mPulseAlphaFraction = 1f; 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) { mPulseAlphaFraction = ((Integer) animation.getAnimatedValue()) / 255f; setInnerAlpha(getCurrentAlpha()); } }); mFadeOutAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { stopPulsing(); } }); } @Override public void setInnerAlpha(final int alpha) { super.setInnerAlpha((int) (alpha * mPulseAlphaFraction)); } public void setPulseEnabled(boolean enabled) { mPulseEnabled = enabled; if (!mPulseEnabled) { stopPulsing(); } } private void stopPulsing() { if (mPulseAnimator != null) { mPulseAnimator.cancel(); mPulseAlphaFraction = 1f; setInnerAlpha(getCurrentAlpha()); } } @Override public boolean setVisible(boolean visible) { final boolean changed = super.setVisible(visible); if (changed) { if (isVisible()) { // start if (mPulseAnimator != null && mPulseEnabled) { mPulseAnimator.start(); } } else { // can't cancel the pulsing yet-- wait for the fade-out animation to end // one exception: if alpha is already zero, there is no fade-out, so stop now if (getCurrentAlpha() == 0) { stopPulsing(); } } } 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 // one exception: if alpha is already zero, there is no fade-out, so stop now if (getCurrentAlpha() == 0 && mRotateAnimator != null) { mRotateAnimator.cancel(); } } } return changed; } } }