blob: 02524552d28dd4eecb1a4a48eae14a4e7dcad141 [file] [log] [blame]
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.
* <p>
* 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<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_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;
}
}
}