1 package com.android.mail.bitmap; 2 3 import android.animation.Animator; 4 import android.animation.AnimatorListenerAdapter; 5 import android.animation.ValueAnimator; 6 import android.animation.ValueAnimator.AnimatorUpdateListener; 7 import android.content.Context; 8 import android.content.res.Resources; 9 import android.graphics.Canvas; 10 import android.graphics.ColorFilter; 11 import android.graphics.Paint; 12 import android.graphics.PixelFormat; 13 import android.graphics.Rect; 14 import android.graphics.drawable.Drawable; 15 import android.os.Handler; 16 import android.util.DisplayMetrics; 17 import android.view.animation.LinearInterpolator; 18 19 import com.android.bitmap.BitmapCache; 20 import com.android.bitmap.BitmapUtils; 21 import com.android.bitmap.DecodeAggregator; 22 import com.android.bitmap.DecodeTask; 23 import com.android.bitmap.DecodeTask.Request; 24 import com.android.bitmap.ReusableBitmap; 25 import com.android.bitmap.Trace; 26 import com.android.mail.R; 27 import com.android.mail.browse.ConversationItemViewCoordinates; 28 import com.android.mail.ui.SwipeableListView; 29 import com.android.mail.utils.LogUtils; 30 import com.android.mail.utils.RectUtils; 31 32 import java.util.concurrent.Executor; 33 import java.util.concurrent.LinkedBlockingQueue; 34 import java.util.concurrent.ThreadPoolExecutor; 35 import 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 */ 44 public 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