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 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