blob: 3834168d8e8d4e489bd2cfd9a7e3477f1e2c90a0 [file] [log] [blame]
/*
* 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;
}
}
}