Home | History | Annotate | Download | only in util
      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