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 
ThumbnailManager(final Context context)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      */
getThumbnail(Uri uri, final ItemLoadedCallback<ImageLoaded> callback)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      */
getVideoThumbnail(Uri uri, final ItemLoadedCallback<ImageLoaded> callback)118     public ItemLoadedFuture getVideoThumbnail(Uri uri,
119             final ItemLoadedCallback<ImageLoaded> callback) {
120         return getThumbnail(uri, true, callback);
121     }
122 
getThumbnail(Uri uri, boolean isVideo, final ItemLoadedCallback<ImageLoaded> callback)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
clear()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
clearBackingStore()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 
removeThumbnail(Uri uri)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
getTag()214     public String getTag() {
215         return TAG;
216     }
217 
getImageCacheService()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 
ThumbnailTask(Uri uri, boolean isVideo)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
run()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 
getBitmap(boolean isVideo)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 
getVideoBitmap()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 
compressBitmap(Bitmap bitmap)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 
requestDecode(byte[] bytes, int offset, int length, Options options)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 
resizeDownBySideLength( Bitmap bitmap, int maxLength, boolean recycle)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 
resizeBitmapByScale( Bitmap bitmap, float scale, boolean recycle)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 
getConfig(Bitmap bitmap)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.
ensureGLCompatibleBitmap(Bitmap bitmap)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 
onDecodeOriginal(Uri uri, int type)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 
closeSilently(Closeable c)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 
requestDecode(final Uri uri, Options options, int targetSize)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.
computeSampleSizeLarger(int w, int h, int minSideLength)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
prevPowerOf2(int n)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
resizeDownIfTooBig( Bitmap bitmap, int targetSize, boolean recycle)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 
ImageLoaded(Bitmap bitmap, boolean isVideo)534         public ImageLoaded(Bitmap bitmap, boolean isVideo) {
535             mBitmap = bitmap;
536             mIsVideo = isVideo;
537         }
538     }
539 }
540