| /* |
| * Copyright (C) 2012 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.mms.util; |
| |
| import java.io.ByteArrayOutputStream; |
| import java.io.Closeable; |
| import java.io.FileNotFoundException; |
| import java.io.InputStream; |
| import java.util.Set; |
| |
| import android.content.Context; |
| import android.graphics.Bitmap; |
| import android.graphics.Bitmap.Config; |
| import android.graphics.BitmapFactory; |
| import android.graphics.BitmapFactory.Options; |
| import android.graphics.Canvas; |
| import android.graphics.Paint; |
| import android.media.MediaMetadataRetriever; |
| import android.net.Uri; |
| import android.util.Log; |
| |
| import com.android.mms.LogTag; |
| import com.android.mms.R; |
| import com.android.mms.TempFileProvider; |
| import com.android.mms.ui.UriImage; |
| import com.android.mms.util.ImageCacheService.ImageData; |
| |
| /** |
| * Primary {@link ThumbnailManager} implementation used by {@link MessagingApplication}. |
| * <p> |
| * Public methods should only be used from a single thread (typically the UI |
| * thread). Callbacks will be invoked on the thread where the ThumbnailManager |
| * was instantiated. |
| * <p> |
| * Uses a thread-pool ExecutorService instead of AsyncTasks since clients may |
| * request lots of pdus around the same time, and AsyncTask may reject tasks |
| * in that case and has no way of bounding the number of threads used by those |
| * tasks. |
| * <p> |
| * ThumbnailManager is used to asynchronously load pictures and create thumbnails. The thumbnails |
| * are stored in a local cache with SoftReferences. Once a thumbnail is loaded, it will call the |
| * passed in callback with the result. If a thumbnail is immediately available in the cache, |
| * the callback will be called immediately as well. |
| * |
| * Based on BooksImageManager by Virgil King. |
| */ |
| public class ThumbnailManager extends BackgroundLoaderManager { |
| private static final String TAG = "ThumbnailManager"; |
| |
| private static final boolean DEBUG_DISABLE_CACHE = false; |
| private static final boolean DEBUG_DISABLE_CALLBACK = false; |
| private static final boolean DEBUG_DISABLE_LOAD = false; |
| private static final boolean DEBUG_LONG_WAIT = false; |
| |
| private static final int COMPRESS_JPEG_QUALITY = 90; |
| |
| private final SimpleCache<Uri, Bitmap> mThumbnailCache; |
| private final Context mContext; |
| private ImageCacheService mImageCacheService; |
| private static Bitmap mEmptyImageBitmap; |
| private static Bitmap mEmptyVideoBitmap; |
| |
| // NOTE: These type numbers are stored in the image cache, so it should not |
| // not be changed without resetting the cache. |
| public static final int TYPE_THUMBNAIL = 1; |
| public static final int TYPE_MICROTHUMBNAIL = 2; |
| |
| public static final int THUMBNAIL_TARGET_SIZE = 640; |
| |
| public ThumbnailManager(final Context context) { |
| super(context); |
| |
| mThumbnailCache = new SimpleCache<Uri, Bitmap>(8, 16, 0.75f, true); |
| mContext = context; |
| |
| mEmptyImageBitmap = BitmapFactory.decodeResource(context.getResources(), |
| R.drawable.ic_missing_thumbnail_picture); |
| |
| mEmptyVideoBitmap = BitmapFactory.decodeResource(context.getResources(), |
| R.drawable.ic_missing_thumbnail_video); |
| } |
| |
| /** |
| * getThumbnail must be called on the same thread that created ThumbnailManager. This is |
| * normally the UI thread. |
| * @param uri the uri of the image |
| * @param width the original full width of the image |
| * @param height the original full height of the image |
| * @param callback the callback to call when the thumbnail is fully loaded |
| * @return |
| */ |
| public ItemLoadedFuture getThumbnail(Uri uri, |
| final ItemLoadedCallback<ImageLoaded> callback) { |
| return getThumbnail(uri, false, callback); |
| } |
| |
| /** |
| * getVideoThumbnail must be called on the same thread that created ThumbnailManager. This is |
| * normally the UI thread. |
| * @param uri the uri of the image |
| * @param callback the callback to call when the thumbnail is fully loaded |
| * @return |
| */ |
| public ItemLoadedFuture getVideoThumbnail(Uri uri, |
| final ItemLoadedCallback<ImageLoaded> callback) { |
| return getThumbnail(uri, true, callback); |
| } |
| |
| private ItemLoadedFuture getThumbnail(Uri uri, boolean isVideo, |
| final ItemLoadedCallback<ImageLoaded> callback) { |
| if (uri == null) { |
| throw new NullPointerException(); |
| } |
| |
| final Bitmap thumbnail = DEBUG_DISABLE_CACHE ? null : mThumbnailCache.get(uri); |
| |
| final boolean thumbnailExists = (thumbnail != null); |
| final boolean taskExists = mPendingTaskUris.contains(uri); |
| final boolean newTaskRequired = !thumbnailExists && !taskExists; |
| final boolean callbackRequired = (callback != null); |
| |
| if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { |
| Log.v(TAG, "getThumbnail mThumbnailCache.get for uri: " + uri + " thumbnail: " + |
| thumbnail + " callback: " + callback + " thumbnailExists: " + |
| thumbnailExists + " taskExists: " + taskExists + |
| " newTaskRequired: " + newTaskRequired + |
| " callbackRequired: " + callbackRequired); |
| } |
| |
| if (thumbnailExists) { |
| if (callbackRequired && !DEBUG_DISABLE_CALLBACK) { |
| ImageLoaded imageLoaded = new ImageLoaded(thumbnail, isVideo); |
| callback.onItemLoaded(imageLoaded, null); |
| } |
| return new NullItemLoadedFuture(); |
| } |
| |
| if (callbackRequired) { |
| addCallback(uri, callback); |
| } |
| |
| if (newTaskRequired) { |
| mPendingTaskUris.add(uri); |
| Runnable task = new ThumbnailTask(uri, isVideo); |
| mExecutor.execute(task); |
| } |
| return new ItemLoadedFuture() { |
| private boolean mIsDone; |
| |
| @Override |
| public void cancel(Uri uri) { |
| cancelCallback(callback); |
| removeThumbnail(uri); // if the thumbnail is half loaded, force a reload next time |
| } |
| |
| @Override |
| public void setIsDone(boolean done) { |
| mIsDone = done; |
| } |
| |
| @Override |
| public boolean isDone() { |
| return mIsDone; |
| } |
| }; |
| } |
| |
| @Override |
| public synchronized void clear() { |
| super.clear(); |
| |
| mThumbnailCache.clear(); // clear in-memory cache |
| clearBackingStore(); // clear on-disk cache |
| } |
| |
| // Delete the on-disk cache, but leave the in-memory cache intact |
| public synchronized void clearBackingStore() { |
| if (mImageCacheService == null) { |
| // No need to call getImageCacheService() to renew the instance if it's null. |
| // It's enough to only delete the image cache files for the sake of safety. |
| CacheManager.clear(mContext); |
| } else { |
| getImageCacheService().clear(); |
| |
| // force a re-init the next time getImageCacheService requested |
| mImageCacheService = null; |
| } |
| } |
| |
| public void removeThumbnail(Uri uri) { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "removeThumbnail: " + uri); |
| } |
| if (uri != null) { |
| mThumbnailCache.remove(uri); |
| } |
| } |
| |
| @Override |
| public String getTag() { |
| return TAG; |
| } |
| |
| private synchronized ImageCacheService getImageCacheService() { |
| if (mImageCacheService == null) { |
| mImageCacheService = new ImageCacheService(mContext); |
| } |
| return mImageCacheService; |
| } |
| |
| public class ThumbnailTask implements Runnable { |
| private final Uri mUri; |
| private final boolean mIsVideo; |
| |
| public ThumbnailTask(Uri uri, boolean isVideo) { |
| if (uri == null) { |
| throw new NullPointerException(); |
| } |
| mUri = uri; |
| mIsVideo = isVideo; |
| } |
| |
| /** {@inheritDoc} */ |
| @Override |
| public void run() { |
| if (DEBUG_DISABLE_LOAD) { |
| return; |
| } |
| if (DEBUG_LONG_WAIT) { |
| try { |
| Thread.sleep(10000); |
| } catch (InterruptedException e) { |
| } |
| } |
| |
| Bitmap bitmap = null; |
| try { |
| bitmap = getBitmap(mIsVideo); |
| } catch (IllegalArgumentException e) { |
| Log.e(TAG, "Couldn't load bitmap for " + mUri, e); |
| } |
| final Bitmap resultBitmap = bitmap; |
| |
| mCallbackHandler.post(new Runnable() { |
| @Override |
| public void run() { |
| final Set<ItemLoadedCallback> callbacks = mCallbacks.get(mUri); |
| if (callbacks != null) { |
| Bitmap bitmap = resultBitmap == null ? |
| (mIsVideo ? mEmptyVideoBitmap : mEmptyImageBitmap) |
| : resultBitmap; |
| |
| // Make a copy so that the callback can unregister itself |
| for (final ItemLoadedCallback<ImageLoaded> callback : asList(callbacks)) { |
| if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { |
| Log.d(TAG, "Invoking item loaded callback " + callback); |
| } |
| if (!DEBUG_DISABLE_CALLBACK) { |
| ImageLoaded imageLoaded = new ImageLoaded(bitmap, mIsVideo); |
| callback.onItemLoaded(imageLoaded, null); |
| } |
| } |
| } else { |
| if (Log.isLoggable(TAG, Log.DEBUG)) { |
| Log.d(TAG, "No image callback!"); |
| } |
| } |
| |
| // Add the bitmap to the soft cache if the load succeeded. Don't cache the |
| // stand-ins for empty bitmaps. |
| if (resultBitmap != null) { |
| mThumbnailCache.put(mUri, resultBitmap); |
| if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { |
| Log.v(TAG, "in callback runnable: bitmap uri: " + mUri + |
| " width: " + resultBitmap.getWidth() + " height: " + |
| resultBitmap.getHeight() + " size: " + |
| resultBitmap.getByteCount()); |
| } |
| } |
| |
| mCallbacks.remove(mUri); |
| mPendingTaskUris.remove(mUri); |
| |
| if (Log.isLoggable(LogTag.THUMBNAIL_CACHE, Log.DEBUG)) { |
| Log.d(TAG, "Image task for " + mUri + "exiting " + mPendingTaskUris.size() |
| + " remain"); |
| } |
| } |
| }); |
| } |
| |
| private Bitmap getBitmap(boolean isVideo) { |
| ImageCacheService cacheService = getImageCacheService(); |
| |
| UriImage uriImage = new UriImage(mContext, mUri); |
| String path = uriImage.getPath(); |
| |
| if (path == null) { |
| return null; |
| } |
| |
| // We never want to store thumbnails of temp files in the thumbnail cache on disk |
| // because those temp filenames are recycled (and reused when capturing images |
| // or videos). |
| boolean isTempFile = TempFileProvider.isTempFile(path); |
| |
| ImageData data = null; |
| if (!isTempFile) { |
| data = cacheService.getImageData(path, TYPE_THUMBNAIL); |
| } |
| |
| if (data != null) { |
| BitmapFactory.Options options = new BitmapFactory.Options(); |
| options.inPreferredConfig = Bitmap.Config.ARGB_8888; |
| Bitmap bitmap = requestDecode(data.mData, |
| data.mOffset, data.mData.length - data.mOffset, options); |
| if (bitmap == null) { |
| Log.w(TAG, "decode cached failed " + path); |
| } |
| return bitmap; |
| } else { |
| Bitmap bitmap; |
| if (isVideo) { |
| bitmap = getVideoBitmap(); |
| } else { |
| bitmap = onDecodeOriginal(mUri, TYPE_THUMBNAIL); |
| } |
| if (bitmap == null) { |
| Log.w(TAG, "decode orig failed " + path); |
| return null; |
| } |
| |
| bitmap = resizeDownBySideLength(bitmap, THUMBNAIL_TARGET_SIZE, true); |
| |
| if (!isTempFile) { |
| byte[] array = compressBitmap(bitmap); |
| cacheService.putImageData(path, TYPE_THUMBNAIL, array); |
| } |
| return bitmap; |
| } |
| } |
| |
| private Bitmap getVideoBitmap() { |
| MediaMetadataRetriever retriever = new MediaMetadataRetriever(); |
| try { |
| retriever.setDataSource(mContext, mUri); |
| return retriever.getFrameAtTime(-1); |
| } catch (RuntimeException ex) { |
| // Assume this is a corrupt video file. |
| } finally { |
| try { |
| retriever.release(); |
| } catch (RuntimeException ex) { |
| // Ignore failures while cleaning up. |
| } |
| } |
| return null; |
| } |
| |
| private byte[] compressBitmap(Bitmap bitmap) { |
| ByteArrayOutputStream os = new ByteArrayOutputStream(); |
| bitmap.compress(Bitmap.CompressFormat.JPEG, |
| COMPRESS_JPEG_QUALITY, os); |
| return os.toByteArray(); |
| } |
| |
| private Bitmap requestDecode(byte[] bytes, int offset, |
| int length, Options options) { |
| if (options == null) { |
| options = new Options(); |
| } |
| return ensureGLCompatibleBitmap( |
| BitmapFactory.decodeByteArray(bytes, offset, length, options)); |
| } |
| |
| private Bitmap resizeDownBySideLength( |
| Bitmap bitmap, int maxLength, boolean recycle) { |
| int srcWidth = bitmap.getWidth(); |
| int srcHeight = bitmap.getHeight(); |
| float scale = Math.min( |
| (float) maxLength / srcWidth, (float) maxLength / srcHeight); |
| if (scale >= 1.0f) return bitmap; |
| return resizeBitmapByScale(bitmap, scale, recycle); |
| } |
| |
| private Bitmap resizeBitmapByScale( |
| Bitmap bitmap, float scale, boolean recycle) { |
| int width = Math.round(bitmap.getWidth() * scale); |
| int height = Math.round(bitmap.getHeight() * scale); |
| if (width == bitmap.getWidth() |
| && height == bitmap.getHeight()) return bitmap; |
| Bitmap target = Bitmap.createBitmap(width, height, getConfig(bitmap)); |
| Canvas canvas = new Canvas(target); |
| canvas.scale(scale, scale); |
| Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.DITHER_FLAG); |
| canvas.drawBitmap(bitmap, 0, 0, paint); |
| if (recycle) bitmap.recycle(); |
| return target; |
| } |
| |
| private Bitmap.Config getConfig(Bitmap bitmap) { |
| Bitmap.Config config = bitmap.getConfig(); |
| if (config == null) { |
| config = Bitmap.Config.ARGB_8888; |
| } |
| return config; |
| } |
| |
| // TODO: This function should not be called directly from |
| // DecodeUtils.requestDecode(...), since we don't have the knowledge |
| // if the bitmap will be uploaded to GL. |
| private Bitmap ensureGLCompatibleBitmap(Bitmap bitmap) { |
| if (bitmap == null || bitmap.getConfig() != null) return bitmap; |
| Bitmap newBitmap = bitmap.copy(Config.ARGB_8888, false); |
| bitmap.recycle(); |
| return newBitmap; |
| } |
| |
| private Bitmap onDecodeOriginal(Uri uri, int type) { |
| BitmapFactory.Options options = new BitmapFactory.Options(); |
| options.inPreferredConfig = Bitmap.Config.ARGB_8888; |
| |
| return requestDecode(uri, options, THUMBNAIL_TARGET_SIZE); |
| } |
| |
| private void closeSilently(Closeable c) { |
| if (c == null) return; |
| try { |
| c.close(); |
| } catch (Throwable t) { |
| Log.w(TAG, "close fail", t); |
| } |
| } |
| |
| private Bitmap requestDecode(final Uri uri, Options options, int targetSize) { |
| if (options == null) options = new Options(); |
| |
| InputStream inputStream; |
| try { |
| inputStream = mContext.getContentResolver().openInputStream(uri); |
| } catch (FileNotFoundException e) { |
| Log.e(TAG, "Can't open uri: " + uri, e); |
| return null; |
| } |
| |
| options.inJustDecodeBounds = true; |
| BitmapFactory.decodeStream(inputStream, null, options); |
| closeSilently(inputStream); |
| |
| // No way to reset the stream. Have to open it again :-( |
| try { |
| inputStream = mContext.getContentResolver().openInputStream(uri); |
| } catch (FileNotFoundException e) { |
| Log.e(TAG, "Can't open uri: " + uri, e); |
| return null; |
| } |
| |
| options.inSampleSize = computeSampleSizeLarger( |
| options.outWidth, options.outHeight, targetSize); |
| options.inJustDecodeBounds = false; |
| |
| Bitmap result = BitmapFactory.decodeStream(inputStream, null, options); |
| closeSilently(inputStream); |
| |
| if (result == null) { |
| return null; |
| } |
| |
| // We need to resize down if the decoder does not support inSampleSize. |
| // (For example, GIF images.) |
| result = resizeDownIfTooBig(result, targetSize, true); |
| return ensureGLCompatibleBitmap(result); |
| } |
| |
| // This computes a sample size which makes the longer side at least |
| // minSideLength long. If that's not possible, return 1. |
| private int computeSampleSizeLarger(int w, int h, |
| int minSideLength) { |
| int initialSize = Math.max(w / minSideLength, h / minSideLength); |
| if (initialSize <= 1) return 1; |
| |
| return initialSize <= 8 |
| ? prevPowerOf2(initialSize) |
| : initialSize / 8 * 8; |
| } |
| |
| // Returns the previous power of two. |
| // Returns the input if it is already power of 2. |
| // Throws IllegalArgumentException if the input is <= 0 |
| private int prevPowerOf2(int n) { |
| if (n <= 0) throw new IllegalArgumentException(); |
| return Integer.highestOneBit(n); |
| } |
| |
| // Resize the bitmap if each side is >= targetSize * 2 |
| private Bitmap resizeDownIfTooBig( |
| Bitmap bitmap, int targetSize, boolean recycle) { |
| int srcWidth = bitmap.getWidth(); |
| int srcHeight = bitmap.getHeight(); |
| float scale = Math.max( |
| (float) targetSize / srcWidth, (float) targetSize / srcHeight); |
| if (scale > 0.5f) return bitmap; |
| return resizeBitmapByScale(bitmap, scale, recycle); |
| } |
| |
| } |
| |
| public static class ImageLoaded { |
| public final Bitmap mBitmap; |
| public final boolean mIsVideo; |
| |
| public ImageLoaded(Bitmap bitmap, boolean isVideo) { |
| mBitmap = bitmap; |
| mIsVideo = isVideo; |
| } |
| } |
| } |