1 /* 2 * Copyright (C) 2012 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.mms.util; 18 19 import android.content.Context; 20 import android.graphics.Bitmap; 21 import android.graphics.Bitmap.Config; 22 import android.graphics.BitmapFactory; 23 import android.graphics.BitmapFactory.Options; 24 import android.graphics.Canvas; 25 import android.graphics.Paint; 26 import android.media.MediaMetadataRetriever; 27 import android.net.Uri; 28 import android.util.Log; 29 30 import com.android.mms.LogTag; 31 import com.android.mms.R; 32 import com.android.mms.TempFileProvider; 33 import com.android.mms.ui.UriImage; 34 import com.android.mms.util.ImageCacheService.ImageData; 35 36 import java.io.ByteArrayOutputStream; 37 import java.io.Closeable; 38 import java.io.FileNotFoundException; 39 import java.io.InputStream; 40 import java.util.Set; 41 42 /** 43 * Primary {@link ThumbnailManager} implementation used by {@link MessagingApplication}. 44 * <p> 45 * Public methods should only be used from a single thread (typically the UI 46 * thread). Callbacks will be invoked on the thread where the ThumbnailManager 47 * was instantiated. 48 * <p> 49 * Uses a thread-pool ExecutorService instead of AsyncTasks since clients may 50 * request lots of pdus around the same time, and AsyncTask may reject tasks 51 * in that case and has no way of bounding the number of threads used by those 52 * tasks. 53 * <p> 54 * ThumbnailManager is used to asynchronously load pictures and create thumbnails. The thumbnails 55 * are stored in a local cache with SoftReferences. Once a thumbnail is loaded, it will call the 56 * passed in callback with the result. If a thumbnail is immediately available in the cache, 57 * the callback will be called immediately as well. 58 * 59 * Based on BooksImageManager by Virgil King. 60 */ 61 public class ThumbnailManager extends BackgroundLoaderManager { 62 private static final String TAG = LogTag.TAG; 63 64 private static final boolean DEBUG_DISABLE_CACHE = false; 65 private static final boolean DEBUG_DISABLE_CALLBACK = false; 66 private static final boolean DEBUG_DISABLE_LOAD = false; 67 private static final boolean DEBUG_LONG_WAIT = false; 68 69 private static final int COMPRESS_JPEG_QUALITY = 90; 70 71 private final SimpleCache<Uri, Bitmap> mThumbnailCache; 72 private final Context mContext; 73 private ImageCacheService mImageCacheService; 74 private static Bitmap mEmptyImageBitmap; 75 private static Bitmap mEmptyVideoBitmap; 76 77 // NOTE: These type numbers are stored in the image cache, so it should not 78 // not be changed without resetting the cache. 79 public static final int TYPE_THUMBNAIL = 1; 80 public static final int TYPE_MICROTHUMBNAIL = 2; 81 82 public static final int THUMBNAIL_TARGET_SIZE = 640; 83 84 public ThumbnailManager(final Context context) { 85 super(context); 86 87 mThumbnailCache = new SimpleCache<Uri, Bitmap>(8, 16, 0.75f, true); 88 mContext = context; 89 90 mEmptyImageBitmap = BitmapFactory.decodeResource(context.getResources(), 91 R.drawable.ic_missing_thumbnail_picture); 92 93 mEmptyVideoBitmap = BitmapFactory.decodeResource(context.getResources(), 94 R.drawable.ic_missing_thumbnail_video); 95 } 96 97 /** 98 * getThumbnail must be called on the same thread that created ThumbnailManager. This is 99 * normally the UI thread. 100 * @param uri the uri of the image 101 * @param width the original full width of the image 102 * @param height the original full height of the image 103 * @param callback the callback to call when the thumbnail is fully loaded 104 * @return 105 */ 106 public ItemLoadedFuture getThumbnail(Uri uri, 107 final ItemLoadedCallback<ImageLoaded> callback) { 108 return getThumbnail(uri, false, callback); 109 } 110 111 /** 112 * getVideoThumbnail must be called on the same thread that created ThumbnailManager. This is 113 * normally the UI thread. 114 * @param uri the uri of the image 115 * @param callback the callback to call when the thumbnail is fully loaded 116 * @return 117 */ 118 public ItemLoadedFuture getVideoThumbnail(Uri uri, 119 final ItemLoadedCallback<ImageLoaded> callback) { 120 return getThumbnail(uri, true, callback); 121 } 122 123 private ItemLoadedFuture getThumbnail(Uri uri, boolean isVideo, 124 final ItemLoadedCallback<ImageLoaded> callback) { 125 if (uri == null) { 126 throw new NullPointerException(); 127 } 128 129 final Bitmap thumbnail = DEBUG_DISABLE_CACHE ? null : mThumbnailCache.get(uri); 130 131 final boolean thumbnailExists = (thumbnail != null); 132 final boolean taskExists = mPendingTaskUris.contains(uri); 133 final boolean newTaskRequired = !thumbnailExists && !taskExists; 134 final boolean callbackRequired = (callback != null); 135 136 if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { 137 Log.v(TAG, "getThumbnail mThumbnailCache.get for uri: " + uri + " thumbnail: " + 138 thumbnail + " callback: " + callback + " thumbnailExists: " + 139 thumbnailExists + " taskExists: " + taskExists + 140 " newTaskRequired: " + newTaskRequired + 141 " callbackRequired: " + callbackRequired); 142 } 143 144 if (thumbnailExists) { 145 if (callbackRequired && !DEBUG_DISABLE_CALLBACK) { 146 ImageLoaded imageLoaded = new ImageLoaded(thumbnail, isVideo); 147 callback.onItemLoaded(imageLoaded, null); 148 } 149 return new NullItemLoadedFuture(); 150 } 151 152 if (callbackRequired) { 153 addCallback(uri, callback); 154 } 155 156 if (newTaskRequired) { 157 mPendingTaskUris.add(uri); 158 Runnable task = new ThumbnailTask(uri, isVideo); 159 mExecutor.execute(task); 160 } 161 return new ItemLoadedFuture() { 162 private boolean mIsDone; 163 164 @Override 165 public void cancel(Uri uri) { 166 cancelCallback(callback); 167 removeThumbnail(uri); // if the thumbnail is half loaded, force a reload next time 168 } 169 170 @Override 171 public void setIsDone(boolean done) { 172 mIsDone = done; 173 } 174 175 @Override 176 public boolean isDone() { 177 return mIsDone; 178 } 179 }; 180 } 181 182 @Override 183 public synchronized void clear() { 184 super.clear(); 185 186 mThumbnailCache.clear(); // clear in-memory cache 187 clearBackingStore(); // clear on-disk cache 188 } 189 190 // Delete the on-disk cache, but leave the in-memory cache intact 191 public synchronized void clearBackingStore() { 192 if (mImageCacheService == null) { 193 // No need to call getImageCacheService() to renew the instance if it's null. 194 // It's enough to only delete the image cache files for the sake of safety. 195 CacheManager.clear(mContext); 196 } else { 197 getImageCacheService().clear(); 198 199 // force a re-init the next time getImageCacheService requested 200 mImageCacheService = null; 201 } 202 } 203 204 public void removeThumbnail(Uri uri) { 205 if (Log.isLoggable(TAG, Log.DEBUG)) { 206 Log.d(TAG, "removeThumbnail: " + uri); 207 } 208 if (uri != null) { 209 mThumbnailCache.remove(uri); 210 } 211 } 212 213 @Override 214 public String getTag() { 215 return TAG; 216 } 217 218 private synchronized ImageCacheService getImageCacheService() { 219 if (mImageCacheService == null) { 220 mImageCacheService = new ImageCacheService(mContext); 221 } 222 return mImageCacheService; 223 } 224 225 public class ThumbnailTask implements Runnable { 226 private final Uri mUri; 227 private final boolean mIsVideo; 228 229 public ThumbnailTask(Uri uri, boolean isVideo) { 230 if (uri == null) { 231 throw new NullPointerException(); 232 } 233 mUri = uri; 234 mIsVideo = isVideo; 235 } 236 237 /** {@inheritDoc} */ 238 @Override 239 public void run() { 240 if (DEBUG_DISABLE_LOAD) { 241 return; 242 } 243 if (DEBUG_LONG_WAIT) { 244 try { 245 Thread.sleep(10000); 246 } catch (InterruptedException e) { 247 } 248 } 249 250 Bitmap bitmap = null; 251 try { 252 bitmap = getBitmap(mIsVideo); 253 } catch (IllegalArgumentException e) { 254 Log.e(TAG, "Couldn't load bitmap for " + mUri, e); 255 } catch (OutOfMemoryError e) { 256 Log.e(TAG, "Couldn't load bitmap for " + mUri, e); 257 } 258 final Bitmap resultBitmap = bitmap; 259 260 mCallbackHandler.post(new Runnable() { 261 @Override 262 public void run() { 263 final Set<ItemLoadedCallback> callbacks = mCallbacks.get(mUri); 264 if (callbacks != null) { 265 Bitmap bitmap = resultBitmap == null ? 266 (mIsVideo ? mEmptyVideoBitmap : mEmptyImageBitmap) 267 : resultBitmap; 268 269 // Make a copy so that the callback can unregister itself 270 for (final ItemLoadedCallback<ImageLoaded> callback : asList(callbacks)) { 271 if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { 272 Log.d(TAG, "Invoking item loaded callback " + callback); 273 } 274 if (!DEBUG_DISABLE_CALLBACK) { 275 ImageLoaded imageLoaded = new ImageLoaded(bitmap, mIsVideo); 276 callback.onItemLoaded(imageLoaded, null); 277 } 278 } 279 } else { 280 if (Log.isLoggable(TAG, Log.DEBUG)) { 281 Log.d(TAG, "No image callback!"); 282 } 283 } 284 285 // Add the bitmap to the soft cache if the load succeeded. Don't cache the 286 // stand-ins for empty bitmaps. 287 if (resultBitmap != null) { 288 mThumbnailCache.put(mUri, resultBitmap); 289 if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { 290 Log.v(TAG, "in callback runnable: bitmap uri: " + mUri + 291 " width: " + resultBitmap.getWidth() + " height: " + 292 resultBitmap.getHeight() + " size: " + 293 resultBitmap.getByteCount()); 294 } 295 } 296 297 mCallbacks.remove(mUri); 298 mPendingTaskUris.remove(mUri); 299 300 if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { 301 Log.d(TAG, "Image task for " + mUri + "exiting " + mPendingTaskUris.size() 302 + " remain"); 303 } 304 } 305 }); 306 } 307 308 private Bitmap getBitmap(boolean isVideo) { 309 ImageCacheService cacheService = getImageCacheService(); 310 311 UriImage uriImage = new UriImage(mContext, mUri); 312 String path = uriImage.getPath(); 313 314 if (path == null) { 315 return null; 316 } 317 318 // We never want to store thumbnails of temp files in the thumbnail cache on disk 319 // because those temp filenames are recycled (and reused when capturing images 320 // or videos). 321 boolean isTempFile = TempFileProvider.isTempFile(path); 322 323 ImageData data = null; 324 if (!isTempFile) { 325 data = cacheService.getImageData(path, TYPE_THUMBNAIL); 326 } 327 328 if (data != null) { 329 BitmapFactory.Options options = new BitmapFactory.Options(); 330 options.inPreferredConfig = Bitmap.Config.ARGB_8888; 331 Bitmap bitmap = requestDecode(data.mData, 332 data.mOffset, data.mData.length - data.mOffset, options); 333 if (bitmap == null) { 334 Log.w(TAG, "decode cached failed " + path); 335 } 336 return bitmap; 337 } else { 338 Bitmap bitmap; 339 if (isVideo) { 340 bitmap = getVideoBitmap(); 341 } else { 342 bitmap = onDecodeOriginal(mUri, TYPE_THUMBNAIL); 343 } 344 if (bitmap == null) { 345 Log.w(TAG, "decode orig failed " + path); 346 return null; 347 } 348 349 bitmap = resizeDownBySideLength(bitmap, THUMBNAIL_TARGET_SIZE, true); 350 351 if (!isTempFile) { 352 byte[] array = compressBitmap(bitmap); 353 cacheService.putImageData(path, TYPE_THUMBNAIL, array); 354 } 355 return bitmap; 356 } 357 } 358 359 private Bitmap getVideoBitmap() { 360 MediaMetadataRetriever retriever = new MediaMetadataRetriever(); 361 try { 362 retriever.setDataSource(mContext, mUri); 363 return retriever.getFrameAtTime(-1); 364 } catch (RuntimeException ex) { 365 // Assume this is a corrupt video file. 366 } finally { 367 try { 368 retriever.release(); 369 } catch (RuntimeException ex) { 370 // Ignore failures while cleaning up. 371 } 372 } 373 return null; 374 } 375 376 private byte[] compressBitmap(Bitmap bitmap) { 377 ByteArrayOutputStream os = new ByteArrayOutputStream(); 378 bitmap.compress(Bitmap.CompressFormat.JPEG, 379 COMPRESS_JPEG_QUALITY, os); 380 return os.toByteArray(); 381 } 382 383 private Bitmap requestDecode(byte[] bytes, int offset, 384 int length, Options options) { 385 if (options == null) { 386 options = new Options(); 387 } 388 return ensureGLCompatibleBitmap( 389 BitmapFactory.decodeByteArray(bytes, offset, length, options)); 390 } 391 392 private Bitmap resizeDownBySideLength( 393 Bitmap bitmap, int maxLength, boolean recycle) { 394 int srcWidth = bitmap.getWidth(); 395 int srcHeight = bitmap.getHeight(); 396 float scale = Math.min( 397 (float) maxLength / srcWidth, (float) maxLength / srcHeight); 398 if (scale >= 1.0f) return bitmap; 399 return resizeBitmapByScale(bitmap, scale, recycle); 400 } 401 402 private Bitmap resizeBitmapByScale( 403 Bitmap bitmap, float scale, boolean recycle) { 404 int width = Math.round(bitmap.getWidth() * scale); 405 int height = Math.round(bitmap.getHeight() * scale); 406 if (width == bitmap.getWidth() 407 && height == bitmap.getHeight()) return bitmap; 408 Bitmap target = Bitmap.createBitmap(width, height, getConfig(bitmap)); 409 Canvas canvas = new Canvas(target); 410 canvas.scale(scale, scale); 411 Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.DITHER_FLAG); 412 canvas.drawBitmap(bitmap, 0, 0, paint); 413 if (recycle) bitmap.recycle(); 414 return target; 415 } 416 417 private Bitmap.Config getConfig(Bitmap bitmap) { 418 Bitmap.Config config = bitmap.getConfig(); 419 if (config == null) { 420 config = Bitmap.Config.ARGB_8888; 421 } 422 return config; 423 } 424 425 // TODO: This function should not be called directly from 426 // DecodeUtils.requestDecode(...), since we don't have the knowledge 427 // if the bitmap will be uploaded to GL. 428 private Bitmap ensureGLCompatibleBitmap(Bitmap bitmap) { 429 if (bitmap == null || bitmap.getConfig() != null) return bitmap; 430 Bitmap newBitmap = bitmap.copy(Config.ARGB_8888, false); 431 bitmap.recycle(); 432 return newBitmap; 433 } 434 435 private Bitmap onDecodeOriginal(Uri uri, int type) { 436 BitmapFactory.Options options = new BitmapFactory.Options(); 437 options.inPreferredConfig = Bitmap.Config.ARGB_8888; 438 439 return requestDecode(uri, options, THUMBNAIL_TARGET_SIZE); 440 } 441 442 private void closeSilently(Closeable c) { 443 if (c == null) return; 444 try { 445 c.close(); 446 } catch (Throwable t) { 447 Log.w(TAG, "close fail", t); 448 } 449 } 450 451 private Bitmap requestDecode(final Uri uri, Options options, int targetSize) { 452 if (options == null) options = new Options(); 453 454 InputStream inputStream; 455 try { 456 inputStream = mContext.getContentResolver().openInputStream(uri); 457 } catch (FileNotFoundException e) { 458 Log.e(TAG, "Can't open uri: " + uri, e); 459 return null; 460 } 461 462 options.inJustDecodeBounds = true; 463 BitmapFactory.decodeStream(inputStream, null, options); 464 closeSilently(inputStream); 465 466 // No way to reset the stream. Have to open it again :-( 467 try { 468 inputStream = mContext.getContentResolver().openInputStream(uri); 469 } catch (FileNotFoundException e) { 470 Log.e(TAG, "Can't open uri: " + uri, e); 471 return null; 472 } 473 474 options.inSampleSize = computeSampleSizeLarger( 475 options.outWidth, options.outHeight, targetSize); 476 options.inJustDecodeBounds = false; 477 478 Bitmap result = BitmapFactory.decodeStream(inputStream, null, options); 479 closeSilently(inputStream); 480 481 if (result == null) { 482 return null; 483 } 484 485 // We need to resize down if the decoder does not support inSampleSize. 486 // (For example, GIF images.) 487 result = resizeDownIfTooBig(result, targetSize, true); 488 result = ensureGLCompatibleBitmap(result); 489 490 int orientation = UriImage.getOrientation(mContext, uri); 491 // Rotate the bitmap if we need to. 492 if (result != null && orientation != 0) { 493 result = UriImage.rotateBitmap(result, orientation); 494 } 495 return result; 496 } 497 498 // This computes a sample size which makes the longer side at least 499 // minSideLength long. If that's not possible, return 1. 500 private int computeSampleSizeLarger(int w, int h, 501 int minSideLength) { 502 int initialSize = Math.max(w / minSideLength, h / minSideLength); 503 if (initialSize <= 1) return 1; 504 505 return initialSize <= 8 506 ? prevPowerOf2(initialSize) 507 : initialSize / 8 * 8; 508 } 509 510 // Returns the previous power of two. 511 // Returns the input if it is already power of 2. 512 // Throws IllegalArgumentException if the input is <= 0 513 private int prevPowerOf2(int n) { 514 if (n <= 0) throw new IllegalArgumentException(); 515 return Integer.highestOneBit(n); 516 } 517 518 // Resize the bitmap if each side is >= targetSize * 2 519 private Bitmap resizeDownIfTooBig( 520 Bitmap bitmap, int targetSize, boolean recycle) { 521 int srcWidth = bitmap.getWidth(); 522 int srcHeight = bitmap.getHeight(); 523 float scale = Math.max( 524 (float) targetSize / srcWidth, (float) targetSize / srcHeight); 525 if (scale > 0.5f) return bitmap; 526 return resizeBitmapByScale(bitmap, scale, recycle); 527 } 528 } 529 530 public static class ImageLoaded { 531 public final Bitmap mBitmap; 532 public final boolean mIsVideo; 533 534 public ImageLoaded(Bitmap bitmap, boolean isVideo) { 535 mBitmap = bitmap; 536 mIsVideo = isVideo; 537 } 538 } 539 } 540