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
17package com.android.mms.util;
18
19import android.content.Context;
20import android.graphics.Bitmap;
21import android.graphics.Bitmap.Config;
22import android.graphics.BitmapFactory;
23import android.graphics.BitmapFactory.Options;
24import android.graphics.Canvas;
25import android.graphics.Paint;
26import android.media.MediaMetadataRetriever;
27import android.net.Uri;
28import android.util.Log;
29
30import com.android.mms.LogTag;
31import com.android.mms.R;
32import com.android.mms.TempFileProvider;
33import com.android.mms.ui.UriImage;
34import com.android.mms.util.ImageCacheService.ImageData;
35
36import java.io.ByteArrayOutputStream;
37import java.io.Closeable;
38import java.io.FileNotFoundException;
39import java.io.InputStream;
40import 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 */
61public 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