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 java.io.ByteArrayOutputStream; 20 import java.io.Closeable; 21 import java.io.FileNotFoundException; 22 import java.io.InputStream; 23 import java.util.Set; 24 25 import android.content.Context; 26 import android.graphics.Bitmap; 27 import android.graphics.Bitmap.Config; 28 import android.graphics.BitmapFactory; 29 import android.graphics.BitmapFactory.Options; 30 import android.graphics.Canvas; 31 import android.graphics.Paint; 32 import android.media.MediaMetadataRetriever; 33 import android.net.Uri; 34 import android.util.Log; 35 36 import com.android.mms.LogTag; 37 import com.android.mms.R; 38 import com.android.mms.TempFileProvider; 39 import com.android.mms.ui.UriImage; 40 import com.android.mms.util.ImageCacheService.ImageData; 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 = "ThumbnailManager"; 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 } 256 final Bitmap resultBitmap = bitmap; 257 258 mCallbackHandler.post(new Runnable() { 259 @Override 260 public void run() { 261 final Set<ItemLoadedCallback> callbacks = mCallbacks.get(mUri); 262 if (callbacks != null) { 263 Bitmap bitmap = resultBitmap == null ? 264 (mIsVideo ? mEmptyVideoBitmap : mEmptyImageBitmap) 265 : resultBitmap; 266 267 // Make a copy so that the callback can unregister itself 268 for (final ItemLoadedCallback<ImageLoaded> callback : asList(callbacks)) { 269 if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { 270 Log.d(TAG, "Invoking item loaded callback " + callback); 271 } 272 if (!DEBUG_DISABLE_CALLBACK) { 273 ImageLoaded imageLoaded = new ImageLoaded(bitmap, mIsVideo); 274 callback.onItemLoaded(imageLoaded, null); 275 } 276 } 277 } else { 278 if (Log.isLoggable(TAG, Log.DEBUG)) { 279 Log.d(TAG, "No image callback!"); 280 } 281 } 282 283 // Add the bitmap to the soft cache if the load succeeded. Don't cache the 284 // stand-ins for empty bitmaps. 285 if (resultBitmap != null) { 286 mThumbnailCache.put(mUri, resultBitmap); 287 if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { 288 Log.v(TAG, "in callback runnable: bitmap uri: " + mUri + 289 " width: " + resultBitmap.getWidth() + " height: " + 290 resultBitmap.getHeight() + " size: " + 291 resultBitmap.getByteCount()); 292 } 293 } 294 295 mCallbacks.remove(mUri); 296 mPendingTaskUris.remove(mUri); 297 298 if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { 299 Log.d(TAG, "Image task for " + mUri + "exiting " + mPendingTaskUris.size() 300 + " remain"); 301 } 302 } 303 }); 304 } 305 306 private Bitmap getBitmap(boolean isVideo) { 307 ImageCacheService cacheService = getImageCacheService(); 308 309 UriImage uriImage = new UriImage(mContext, mUri); 310 String path = uriImage.getPath(); 311 312 if (path == null) { 313 return null; 314 } 315 316 // We never want to store thumbnails of temp files in the thumbnail cache on disk 317 // because those temp filenames are recycled (and reused when capturing images 318 // or videos). 319 boolean isTempFile = TempFileProvider.isTempFile(path); 320 321 ImageData data = null; 322 if (!isTempFile) { 323 data = cacheService.getImageData(path, TYPE_THUMBNAIL); 324 } 325 326 if (data != null) { 327 BitmapFactory.Options options = new BitmapFactory.Options(); 328 options.inPreferredConfig = Bitmap.Config.ARGB_8888; 329 Bitmap bitmap = requestDecode(data.mData, 330 data.mOffset, data.mData.length - data.mOffset, options); 331 if (bitmap == null) { 332 Log.w(TAG, "decode cached failed " + path); 333 } 334 return bitmap; 335 } else { 336 Bitmap bitmap; 337 if (isVideo) { 338 bitmap = getVideoBitmap(); 339 } else { 340 bitmap = onDecodeOriginal(mUri, TYPE_THUMBNAIL); 341 } 342 if (bitmap == null) { 343 Log.w(TAG, "decode orig failed " + path); 344 return null; 345 } 346 347 bitmap = resizeDownBySideLength(bitmap, THUMBNAIL_TARGET_SIZE, true); 348 349 if (!isTempFile) { 350 byte[] array = compressBitmap(bitmap); 351 cacheService.putImageData(path, TYPE_THUMBNAIL, array); 352 } 353 return bitmap; 354 } 355 } 356 357 private Bitmap getVideoBitmap() { 358 MediaMetadataRetriever retriever = new MediaMetadataRetriever(); 359 try { 360 retriever.setDataSource(mContext, mUri); 361 return retriever.getFrameAtTime(-1); 362 } catch (RuntimeException ex) { 363 // Assume this is a corrupt video file. 364 } finally { 365 try { 366 retriever.release(); 367 } catch (RuntimeException ex) { 368 // Ignore failures while cleaning up. 369 } 370 } 371 return null; 372 } 373 374 private byte[] compressBitmap(Bitmap bitmap) { 375 ByteArrayOutputStream os = new ByteArrayOutputStream(); 376 bitmap.compress(Bitmap.CompressFormat.JPEG, 377 COMPRESS_JPEG_QUALITY, os); 378 return os.toByteArray(); 379 } 380 381 private Bitmap requestDecode(byte[] bytes, int offset, 382 int length, Options options) { 383 if (options == null) { 384 options = new Options(); 385 } 386 return ensureGLCompatibleBitmap( 387 BitmapFactory.decodeByteArray(bytes, offset, length, options)); 388 } 389 390 private Bitmap resizeDownBySideLength( 391 Bitmap bitmap, int maxLength, boolean recycle) { 392 int srcWidth = bitmap.getWidth(); 393 int srcHeight = bitmap.getHeight(); 394 float scale = Math.min( 395 (float) maxLength / srcWidth, (float) maxLength / srcHeight); 396 if (scale >= 1.0f) return bitmap; 397 return resizeBitmapByScale(bitmap, scale, recycle); 398 } 399 400 private Bitmap resizeBitmapByScale( 401 Bitmap bitmap, float scale, boolean recycle) { 402 int width = Math.round(bitmap.getWidth() * scale); 403 int height = Math.round(bitmap.getHeight() * scale); 404 if (width == bitmap.getWidth() 405 && height == bitmap.getHeight()) return bitmap; 406 Bitmap target = Bitmap.createBitmap(width, height, getConfig(bitmap)); 407 Canvas canvas = new Canvas(target); 408 canvas.scale(scale, scale); 409 Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.DITHER_FLAG); 410 canvas.drawBitmap(bitmap, 0, 0, paint); 411 if (recycle) bitmap.recycle(); 412 return target; 413 } 414 415 private Bitmap.Config getConfig(Bitmap bitmap) { 416 Bitmap.Config config = bitmap.getConfig(); 417 if (config == null) { 418 config = Bitmap.Config.ARGB_8888; 419 } 420 return config; 421 } 422 423 // TODO: This function should not be called directly from 424 // DecodeUtils.requestDecode(...), since we don't have the knowledge 425 // if the bitmap will be uploaded to GL. 426 private Bitmap ensureGLCompatibleBitmap(Bitmap bitmap) { 427 if (bitmap == null || bitmap.getConfig() != null) return bitmap; 428 Bitmap newBitmap = bitmap.copy(Config.ARGB_8888, false); 429 bitmap.recycle(); 430 return newBitmap; 431 } 432 433 private Bitmap onDecodeOriginal(Uri uri, int type) { 434 BitmapFactory.Options options = new BitmapFactory.Options(); 435 options.inPreferredConfig = Bitmap.Config.ARGB_8888; 436 437 return requestDecode(uri, options, THUMBNAIL_TARGET_SIZE); 438 } 439 440 private void closeSilently(Closeable c) { 441 if (c == null) return; 442 try { 443 c.close(); 444 } catch (Throwable t) { 445 Log.w(TAG, "close fail", t); 446 } 447 } 448 449 private Bitmap requestDecode(final Uri uri, Options options, int targetSize) { 450 if (options == null) options = new Options(); 451 452 InputStream inputStream; 453 try { 454 inputStream = mContext.getContentResolver().openInputStream(uri); 455 } catch (FileNotFoundException e) { 456 Log.e(TAG, "Can't open uri: " + uri, e); 457 return null; 458 } 459 460 options.inJustDecodeBounds = true; 461 BitmapFactory.decodeStream(inputStream, null, options); 462 closeSilently(inputStream); 463 464 // No way to reset the stream. Have to open it again :-( 465 try { 466 inputStream = mContext.getContentResolver().openInputStream(uri); 467 } catch (FileNotFoundException e) { 468 Log.e(TAG, "Can't open uri: " + uri, e); 469 return null; 470 } 471 472 options.inSampleSize = computeSampleSizeLarger( 473 options.outWidth, options.outHeight, targetSize); 474 options.inJustDecodeBounds = false; 475 476 Bitmap result = BitmapFactory.decodeStream(inputStream, null, options); 477 closeSilently(inputStream); 478 479 if (result == null) { 480 return null; 481 } 482 483 // We need to resize down if the decoder does not support inSampleSize. 484 // (For example, GIF images.) 485 result = resizeDownIfTooBig(result, targetSize, true); 486 return ensureGLCompatibleBitmap(result); 487 } 488 489 // This computes a sample size which makes the longer side at least 490 // minSideLength long. If that's not possible, return 1. 491 private int computeSampleSizeLarger(int w, int h, 492 int minSideLength) { 493 int initialSize = Math.max(w / minSideLength, h / minSideLength); 494 if (initialSize <= 1) return 1; 495 496 return initialSize <= 8 497 ? prevPowerOf2(initialSize) 498 : initialSize / 8 * 8; 499 } 500 501 // Returns the previous power of two. 502 // Returns the input if it is already power of 2. 503 // Throws IllegalArgumentException if the input is <= 0 504 private int prevPowerOf2(int n) { 505 if (n <= 0) throw new IllegalArgumentException(); 506 return Integer.highestOneBit(n); 507 } 508 509 // Resize the bitmap if each side is >= targetSize * 2 510 private Bitmap resizeDownIfTooBig( 511 Bitmap bitmap, int targetSize, boolean recycle) { 512 int srcWidth = bitmap.getWidth(); 513 int srcHeight = bitmap.getHeight(); 514 float scale = Math.max( 515 (float) targetSize / srcWidth, (float) targetSize / srcHeight); 516 if (scale > 0.5f) return bitmap; 517 return resizeBitmapByScale(bitmap, scale, recycle); 518 } 519 520 } 521 522 public static class ImageLoaded { 523 public final Bitmap mBitmap; 524 public final boolean mIsVideo; 525 526 public ImageLoaded(Bitmap bitmap, boolean isVideo) { 527 mBitmap = bitmap; 528 mIsVideo = isVideo; 529 } 530 } 531 } 532