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