/* * 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.graphics.Matrix; 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(); int orientation = uriImage.getOrientation(); Log.w(TAG, "Orientation requested for thumbnail = " + orientation); 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); } if (orientation != 0) { Matrix m = new Matrix(); UriImage.endowTransformMatrix(m, 1, orientation); bitmap = Bitmap.createBitmap(bitmap,0, 0, bitmap.getWidth(), bitmap.getHeight(), m, false); } 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, orientation, 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)); } // @param orientation: After resizing also rotate private Bitmap resizeDownBySideLength( Bitmap bitmap, int maxLength, int orientation, boolean recycle) { int srcWidth = bitmap.getWidth(); int srcHeight = bitmap.getHeight(); float scale = Math.min( (float) maxLength / srcWidth, (float) maxLength / srcHeight); if (scale >= 1.0f && orientation == 0) return bitmap; Log.w(TAG, "resizeDownBySideLength, orientation = " + orientation); return resizeBitmapByScale(bitmap, Math.min(scale, 1.0f), orientation, recycle); } // @param orientation: After resizing also rotate private Bitmap resizeBitmapByScale( Bitmap bitmap, float scale, int orientation, boolean recycle) { Matrix m = new Matrix(); UriImage.endowTransformMatrix(m,scale,orientation); int width = Math.round(bitmap.getWidth() * scale); int height = Math.round(bitmap.getHeight() * scale); if (width == bitmap.getWidth() && height == bitmap.getHeight() && orientation ==0) return bitmap; Log.w(TAG, "resizeBitmapByScale, orientation = " + orientation + " scale = " + scale); Bitmap target = Bitmap.createBitmap(bitmap,0,0,bitmap.getWidth(),bitmap.getHeight(),m,false); 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, 0, recycle); } } public static class ImageLoaded { public final Bitmap mBitmap; public final boolean mIsVideo; public ImageLoaded(Bitmap bitmap, boolean isVideo) { mBitmap = bitmap; mIsVideo = isVideo; } } }