1package com.android.mail.bitmap;
2
3import android.animation.Animator;
4import android.animation.AnimatorListenerAdapter;
5import android.animation.ValueAnimator;
6import android.animation.ValueAnimator.AnimatorUpdateListener;
7import android.content.Context;
8import android.content.res.Resources;
9import android.graphics.Canvas;
10import android.graphics.ColorFilter;
11import android.graphics.Paint;
12import android.graphics.PixelFormat;
13import android.graphics.Rect;
14import android.graphics.drawable.Drawable;
15import android.os.Handler;
16import android.util.DisplayMetrics;
17import android.view.animation.LinearInterpolator;
18
19import com.android.bitmap.BitmapCache;
20import com.android.bitmap.BitmapUtils;
21import com.android.bitmap.DecodeAggregator;
22import com.android.bitmap.DecodeTask;
23import com.android.bitmap.DecodeTask.Request;
24import com.android.bitmap.ReusableBitmap;
25import com.android.bitmap.Trace;
26import com.android.mail.R;
27import com.android.mail.browse.ConversationItemViewCoordinates;
28import com.android.mail.ui.SwipeableListView;
29import com.android.mail.utils.LogUtils;
30import com.android.mail.utils.RectUtils;
31
32import java.util.concurrent.Executor;
33import java.util.concurrent.LinkedBlockingQueue;
34import java.util.concurrent.ThreadPoolExecutor;
35import java.util.concurrent.TimeUnit;
36
37/**
38 * This class encapsulates all functionality needed to display a single image attachment thumbnail,
39 * including request creation/cancelling, data unbinding and re-binding, and fancy animations
40 * to draw upon state changes.
41 * <p>
42 * The actual bitmap decode work is handled by {@link DecodeTask}.
43 */
44public class AttachmentDrawable extends Drawable implements DecodeTask.BitmapView,
45        Drawable.Callback, Runnable, Parallaxable, DecodeAggregator.Callback {
46
47    private ImageAttachmentRequest mCurrKey;
48    private ReusableBitmap mBitmap;
49    private final BitmapCache mCache;
50    private final DecodeAggregator mDecodeAggregator;
51    private DecodeTask mTask;
52    private int mDecodeWidth;
53    private int mDecodeHeight;
54    private int mLoadState = LOAD_STATE_UNINITIALIZED;
55    private float mParallaxFraction = 0.5f;
56    private float mParallaxSpeedMultiplier;
57
58    // each attachment gets its own placeholder and progress indicator, to be shown, hidden,
59    // and animated based on Drawable#setVisible() changes, which are in turn driven by
60    // #setLoadState().
61    private Placeholder mPlaceholder;
62    private Progress mProgress;
63
64    private static final Executor SMALL_POOL_EXECUTOR = new ThreadPoolExecutor(4, 4,
65            1, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>());
66
67    private static final Executor EXECUTOR = SMALL_POOL_EXECUTOR;
68
69    private static final boolean LIMIT_BITMAP_DENSITY = true;
70
71    private static final int MAX_BITMAP_DENSITY = DisplayMetrics.DENSITY_HIGH;
72
73    private static final int LOAD_STATE_UNINITIALIZED = 0;
74    private static final int LOAD_STATE_NOT_YET_LOADED = 1;
75    private static final int LOAD_STATE_LOADING = 2;
76    private static final int LOAD_STATE_LOADED = 3;
77    private static final int LOAD_STATE_FAILED = 4;
78
79    private final ConversationItemViewCoordinates mCoordinates;
80    private final float mDensity;
81    private final int mProgressDelayMs;
82    private final Paint mPaint = new Paint();
83    private final Rect mSrcRect = new Rect();
84    private final Handler mHandler = new Handler();
85
86    public final String LOG_TAG = "AttachPreview";
87
88    public AttachmentDrawable(final Resources res, final BitmapCache cache,
89            final DecodeAggregator decodeAggregator,
90            final ConversationItemViewCoordinates coordinates, final Drawable placeholder,
91            final Drawable progress) {
92        mCoordinates = coordinates;
93        mDensity = res.getDisplayMetrics().density;
94        mCache = cache;
95        this.mDecodeAggregator = decodeAggregator;
96        mPaint.setFilterBitmap(true);
97
98        final int fadeOutDurationMs = res.getInteger(R.integer.ap_fade_animation_duration);
99        final int tileColor = res.getColor(R.color.ap_background_color);
100        mProgressDelayMs = res.getInteger(R.integer.ap_progress_animation_delay);
101
102        mPlaceholder = new Placeholder(placeholder.getConstantState().newDrawable(res), res,
103                coordinates, fadeOutDurationMs, tileColor);
104        mPlaceholder.setCallback(this);
105
106        mProgress = new Progress(progress.getConstantState().newDrawable(res), res,
107                coordinates, fadeOutDurationMs, tileColor);
108        mProgress.setCallback(this);
109    }
110
111    public DecodeTask.Request getKey() {
112        return mCurrKey;
113    }
114
115    public void setDecodeDimensions(int w, int h) {
116        mDecodeWidth = w;
117        mDecodeHeight = h;
118    }
119
120    public void setParallaxSpeedMultiplier(final float parallaxSpeedMultiplier) {
121        mParallaxSpeedMultiplier = parallaxSpeedMultiplier;
122    }
123
124    public void showStaticPlaceholder() {
125        setLoadState(LOAD_STATE_FAILED);
126    }
127
128    public void unbind() {
129        setImage(null);
130    }
131
132    public void bind(Context context, String lookupUri, int rendition) {
133        final Rect bounds = getBounds();
134        if (bounds.isEmpty()) {
135            throw new IllegalStateException("AttachmentDrawable must have bounds set before bind");
136        }
137        setImage(new ImageAttachmentRequest(context, lookupUri, rendition, bounds.width()));
138    }
139
140    private void setImage(final ImageAttachmentRequest key) {
141        if (mCurrKey != null && mCurrKey.equals(key)) {
142            return;
143        }
144
145        Trace.beginSection("set image");
146        // avoid visual state transitions when the existing request and the new one are just
147        // requests for different renditions of the same attachment
148        final boolean onlyRenditionChange = (mCurrKey != null && mCurrKey.matches(key));
149
150        if (mBitmap != null && !onlyRenditionChange) {
151            mBitmap.releaseReference();
152//            System.out.println("view.bind() decremented ref to old bitmap: " + mBitmap);
153            mBitmap = null;
154        }
155        if (mCurrKey != null && SwipeableListView.ENABLE_ATTACHMENT_DECODE_AGGREGATOR) {
156            mDecodeAggregator.forget(mCurrKey);
157        }
158        mCurrKey = key;
159
160        if (mTask != null) {
161            mTask.cancel();
162            mTask = null;
163        }
164
165        mHandler.removeCallbacks(this);
166        // start from a clean slate on every bind
167        // this allows the initial transition to be specially instantaneous, so e.g. a cache hit
168        // doesn't unnecessarily trigger a fade-in
169        setLoadState(LOAD_STATE_UNINITIALIZED);
170
171        if (key == null) {
172            Trace.endSection();
173            return;
174        }
175
176        // find cached entry here and skip decode if found.
177        final ReusableBitmap cached = mCache.get(key, true /* incrementRefCount */);
178        if (cached != null) {
179            setBitmap(cached);
180            LogUtils.d(LOG_TAG, "CACHE HIT key=%s", mCurrKey);
181        } else {
182            decode(!onlyRenditionChange);
183            if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
184                LogUtils.d(LOG_TAG, "CACHE MISS key=%s\ncache=%s",
185                        mCurrKey, mCache.toDebugString());
186            }
187        }
188        Trace.endSection();
189    }
190
191    @Override
192    public void setParallaxFraction(float fraction) {
193        mParallaxFraction = fraction;
194    }
195
196    @Override
197    public void draw(final Canvas canvas) {
198        final Rect bounds = getBounds();
199        if (bounds.isEmpty()) {
200            return;
201        }
202
203        if (mBitmap != null) {
204            BitmapUtils
205                    .calculateCroppedSrcRect(mBitmap.getLogicalWidth(), mBitmap.getLogicalHeight(),
206                            bounds.width(), bounds.height(),
207                            mCoordinates.attachmentPreviewsDecodeHeight, Integer.MAX_VALUE,
208                            mParallaxFraction, false /* absoluteFraction */,
209                            mParallaxSpeedMultiplier, mSrcRect);
210
211            final int orientation = mBitmap.getOrientation();
212            // calculateCroppedSrcRect() gave us the source rectangle "as if" the orientation has
213            // been corrected. We need to decode the uncorrected source rectangle. Calculate true
214            // coordinates.
215            RectUtils.rotateRectForOrientation(orientation,
216                    new Rect(0, 0, mBitmap.getLogicalWidth(), mBitmap.getLogicalHeight()),
217                    mSrcRect);
218
219            // We may need to rotate the canvas, so we also have to rotate the bounds.
220            final Rect rotatedBounds = new Rect(bounds);
221            RectUtils.rotateRect(orientation, bounds.centerX(), bounds.centerY(), rotatedBounds);
222
223            // Rotate the canvas.
224            canvas.save();
225            canvas.rotate(orientation, bounds.centerX(), bounds.centerY());
226            canvas.drawBitmap(mBitmap.bmp, mSrcRect, rotatedBounds, mPaint);
227            canvas.restore();
228        }
229
230        // Draw the two possible overlay layers in reverse-priority order.
231        // (each layer will no-op the draw when appropriate)
232        // This ordering means cross-fade transitions are just fade-outs of each layer.
233        mProgress.draw(canvas);
234        mPlaceholder.draw(canvas);
235    }
236
237    @Override
238    public void setAlpha(int alpha) {
239        final int old = mPaint.getAlpha();
240        mPaint.setAlpha(alpha);
241        mPlaceholder.setAlpha(alpha);
242        mProgress.setAlpha(alpha);
243        if (alpha != old) {
244            invalidateSelf();
245        }
246    }
247
248    @Override
249    public void setColorFilter(ColorFilter cf) {
250        mPaint.setColorFilter(cf);
251        mPlaceholder.setColorFilter(cf);
252        mProgress.setColorFilter(cf);
253        invalidateSelf();
254    }
255
256    @Override
257    public int getOpacity() {
258        return (mBitmap != null && (mBitmap.bmp.hasAlpha() || mPaint.getAlpha() < 255)) ?
259                PixelFormat.TRANSLUCENT : PixelFormat.OPAQUE;
260    }
261
262    @Override
263    protected void onBoundsChange(Rect bounds) {
264        super.onBoundsChange(bounds);
265
266        mPlaceholder.setBounds(bounds);
267        mProgress.setBounds(bounds);
268    }
269
270    @Override
271    public void onDecodeBegin(final Request key) {
272        if (SwipeableListView.ENABLE_ATTACHMENT_DECODE_AGGREGATOR) {
273            mDecodeAggregator.expect(key, this);
274        } else {
275            onBecomeFirstExpected(key);
276        }
277    }
278
279    @Override
280    public void onBecomeFirstExpected(final Request key) {
281        if (!key.equals(mCurrKey)) {
282            return;
283        }
284        // normally, we'd transition to the LOADING state now, but we want to delay that a bit
285        // to minimize excess occurrences of the rotating spinner
286        mHandler.postDelayed(this, mProgressDelayMs);
287    }
288
289    @Override
290    public void run() {
291        if (mLoadState == LOAD_STATE_NOT_YET_LOADED) {
292            setLoadState(LOAD_STATE_LOADING);
293        }
294    }
295
296    @Override
297    public void onDecodeComplete(final Request key, final ReusableBitmap result) {
298        if (SwipeableListView.ENABLE_ATTACHMENT_DECODE_AGGREGATOR) {
299            mDecodeAggregator.execute(key, new Runnable() {
300                @Override
301                public void run() {
302                    onDecodeCompleteImpl(key, result);
303                }
304
305                @Override
306                public String toString() {
307                    return "DONE";
308                }
309            });
310        } else {
311            onDecodeCompleteImpl(key, result);
312        }
313    }
314
315    private void onDecodeCompleteImpl(final Request key, final ReusableBitmap result) {
316        if (key.equals(mCurrKey)) {
317            setBitmap(result);
318        } else {
319            // if the requests don't match (i.e. this request is stale), decrement the
320            // ref count to allow the bitmap to be pooled
321            if (result != null) {
322                result.releaseReference();
323            }
324        }
325    }
326
327    @Override
328    public void onDecodeCancel(final Request key) {
329        if (SwipeableListView.ENABLE_ATTACHMENT_DECODE_AGGREGATOR) {
330            mDecodeAggregator.forget(key);
331        }
332    }
333
334    private void setBitmap(ReusableBitmap bmp) {
335        if (mBitmap != null && mBitmap != bmp) {
336            mBitmap.releaseReference();
337        }
338        mBitmap = bmp;
339        setLoadState((bmp != null) ? LOAD_STATE_LOADED : LOAD_STATE_FAILED);
340        invalidateSelf();
341    }
342
343    private void decode(boolean executeStateChange) {
344        final int w;
345        final int bufferW;
346        final int bufferH;
347
348        if (mCurrKey == null) {
349            return;
350        }
351
352        Trace.beginSection("decode");
353        if (LIMIT_BITMAP_DENSITY) {
354            final float scale =
355                    Math.min(1f, (float) MAX_BITMAP_DENSITY / DisplayMetrics.DENSITY_DEFAULT
356                            / mDensity);
357            w = (int) (mCurrKey.mDestW * scale);
358            bufferW = (int) (mDecodeWidth * scale);
359            bufferH = (int) (mDecodeHeight * scale);
360        } else {
361            w = mCurrKey.mDestW;
362            bufferW = mDecodeWidth;
363            bufferH = mDecodeHeight;
364        }
365
366        if (w == 0 || bufferH == 0) {
367            Trace.endSection();
368            return;
369        }
370//        System.out.println("ITEM " + this + " w=" + w + " h=" + bufferH + " key=" + mCurrKey);
371        if (mTask != null) {
372            mTask.cancel();
373        }
374        if (executeStateChange) {
375            setLoadState(LOAD_STATE_NOT_YET_LOADED);
376        }
377        mTask = new DecodeTask(mCurrKey, w, bufferH, bufferW, bufferH, this, mCache);
378        mTask.executeOnExecutor(EXECUTOR);
379        Trace.endSection();
380    }
381
382    private void setLoadState(int loadState) {
383        LogUtils.v(LOG_TAG, "IN AD.setState. old=%s new=%s key=%s this=%s", mLoadState, loadState,
384                mCurrKey, this);
385        if (mLoadState == loadState) {
386            LogUtils.v(LOG_TAG, "OUT no-op AD.setState");
387            return;
388        }
389
390        Trace.beginSection("set load state");
391        switch (loadState) {
392            // This state differs from LOADED in that the subsequent state transition away from
393            // UNINITIALIZED will not have a fancy transition. This allows list item binds to
394            // cached data to take immediate effect without unnecessary whizzery.
395            case LOAD_STATE_UNINITIALIZED:
396                mPlaceholder.reset();
397                mProgress.reset();
398                break;
399            case LOAD_STATE_NOT_YET_LOADED:
400                mPlaceholder.setPulseEnabled(true);
401                mPlaceholder.setVisible(true);
402                mProgress.setVisible(false);
403                break;
404            case LOAD_STATE_LOADING:
405                mPlaceholder.setVisible(false);
406                mProgress.setVisible(true);
407                break;
408            case LOAD_STATE_LOADED:
409                mPlaceholder.setVisible(false);
410                mProgress.setVisible(false);
411                break;
412            case LOAD_STATE_FAILED:
413                mPlaceholder.setPulseEnabled(false);
414                mPlaceholder.setVisible(true);
415                mProgress.setVisible(false);
416                break;
417        }
418        Trace.endSection();
419
420        mLoadState = loadState;
421        LogUtils.v(LOG_TAG, "OUT stateful AD.setState. new=%s placeholder=%s progress=%s",
422                loadState, mPlaceholder.isVisible(), mProgress.isVisible());
423    }
424
425    @Override
426    public void invalidateDrawable(Drawable who) {
427        invalidateSelf();
428    }
429
430    @Override
431    public void scheduleDrawable(Drawable who, Runnable what, long when) {
432        scheduleSelf(what, when);
433    }
434
435    @Override
436    public void unscheduleDrawable(Drawable who, Runnable what) {
437        unscheduleSelf(what);
438    }
439
440    private static class Placeholder extends TileDrawable {
441
442        private final ValueAnimator mPulseAnimator;
443        private boolean mPulseEnabled = true;
444        private float mPulseAlphaFraction = 1f;
445
446        public Placeholder(Drawable placeholder, Resources res,
447                ConversationItemViewCoordinates coordinates, int fadeOutDurationMs,
448                int tileColor) {
449            super(placeholder, coordinates.placeholderWidth, coordinates.placeholderHeight,
450                    tileColor, fadeOutDurationMs);
451            mPulseAnimator = ValueAnimator.ofInt(55, 255)
452                    .setDuration(res.getInteger(R.integer.ap_placeholder_animation_duration));
453            mPulseAnimator.setRepeatCount(ValueAnimator.INFINITE);
454            mPulseAnimator.setRepeatMode(ValueAnimator.REVERSE);
455            mPulseAnimator.addUpdateListener(new AnimatorUpdateListener() {
456                @Override
457                public void onAnimationUpdate(ValueAnimator animation) {
458                    mPulseAlphaFraction = ((Integer) animation.getAnimatedValue()) / 255f;
459                    setInnerAlpha(getCurrentAlpha());
460                }
461            });
462            mFadeOutAnimator.addListener(new AnimatorListenerAdapter() {
463                @Override
464                public void onAnimationEnd(Animator animation) {
465                    stopPulsing();
466                }
467            });
468        }
469
470        @Override
471        public void setInnerAlpha(final int alpha) {
472            super.setInnerAlpha((int) (alpha * mPulseAlphaFraction));
473        }
474
475        public void setPulseEnabled(boolean enabled) {
476            mPulseEnabled = enabled;
477            if (!mPulseEnabled) {
478                stopPulsing();
479            }
480        }
481
482        private void stopPulsing() {
483            if (mPulseAnimator != null) {
484                mPulseAnimator.cancel();
485                mPulseAlphaFraction = 1f;
486                setInnerAlpha(getCurrentAlpha());
487            }
488        }
489
490        @Override
491        public boolean setVisible(boolean visible) {
492            final boolean changed = super.setVisible(visible);
493            if (changed) {
494                if (isVisible()) {
495                    // start
496                    if (mPulseAnimator != null && mPulseEnabled) {
497                        mPulseAnimator.start();
498                    }
499                } else {
500                    // can't cancel the pulsing yet-- wait for the fade-out animation to end
501                    // one exception: if alpha is already zero, there is no fade-out, so stop now
502                    if (getCurrentAlpha() == 0) {
503                        stopPulsing();
504                    }
505                }
506            }
507            return changed;
508        }
509
510    }
511
512    private static class Progress extends TileDrawable {
513
514        private final ValueAnimator mRotateAnimator;
515
516        public Progress(Drawable progress, Resources res,
517                ConversationItemViewCoordinates coordinates, int fadeOutDurationMs,
518                int tileColor) {
519            super(progress, coordinates.progressBarWidth, coordinates.progressBarHeight,
520                    tileColor, fadeOutDurationMs);
521
522            mRotateAnimator = ValueAnimator.ofInt(0, 10000)
523                    .setDuration(res.getInteger(R.integer.ap_progress_animation_duration));
524            mRotateAnimator.setInterpolator(new LinearInterpolator());
525            mRotateAnimator.setRepeatCount(ValueAnimator.INFINITE);
526            mRotateAnimator.addUpdateListener(new AnimatorUpdateListener() {
527                @Override
528                public void onAnimationUpdate(ValueAnimator animation) {
529                    setLevel((Integer) animation.getAnimatedValue());
530                }
531            });
532            mFadeOutAnimator.addListener(new AnimatorListenerAdapter() {
533                @Override
534                public void onAnimationEnd(Animator animation) {
535                    if (mRotateAnimator != null) {
536                        mRotateAnimator.cancel();
537                    }
538                }
539            });
540        }
541
542        @Override
543        public boolean setVisible(boolean visible) {
544            final boolean changed = super.setVisible(visible);
545            if (changed) {
546                if (isVisible()) {
547                    if (mRotateAnimator != null) {
548                        mRotateAnimator.start();
549                    }
550                } else {
551                    // can't cancel the rotate yet-- wait for the fade-out animation to end
552                    // one exception: if alpha is already zero, there is no fade-out, so stop now
553                    if (getCurrentAlpha() == 0 && mRotateAnimator != null) {
554                        mRotateAnimator.cancel();
555                    }
556                }
557            }
558            return changed;
559        }
560
561    }
562}
563