You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tasks/astrid/src/main/java/edu/mit/mobile/android/imagecache/ImageCache.java

842 lines
27 KiB
Java

package edu.mit.mobile.android.imagecache;
/*
* Copyright (C) 2011-2012 MIT Mobile Experience Lab
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLConnection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.StatusLine;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.HttpResponseException;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.params.ClientPNames;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.params.CoreConnectionPNames;
import org.apache.http.params.HttpParams;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Bitmap.CompressFormat;
import android.graphics.BitmapFactory;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import android.util.SparseArray;
import android.widget.ImageView;
/**
* <p>
* An image download-and-cacher that also knows how to efficiently generate thumbnails of various
* sizes.
* </p>
*
* <p>
* The cache is shared with the entire process, so make sure you
* {@link #registerOnImageLoadListener(OnImageLoadListener)} and
* {@link #unregisterOnImageLoadListener(OnImageLoadListener)} any load listeners in your
* activities.
* </p>
*
* @author <a href="mailto:spomeroy@mit.edu">Steve Pomeroy</a>
*
*/
public class ImageCache extends DiskCache<String, Bitmap> {
private static final String TAG = ImageCache.class.getSimpleName();
static final boolean DEBUG = false;
// whether to use Apache HttpClient or URL.openConnection()
private static final boolean USE_APACHE_NC = true;
// the below settings are copied from AsyncTask.java
private static final int CORE_POOL_SIZE = 5; // thread
private static final int MAXIMUM_POOL_SIZE = 128; // thread
private static final int KEEP_ALIVE_TIME = 1; // second
private final HashSet<OnImageLoadListener> mImageLoadListeners = new HashSet<ImageCache.OnImageLoadListener>();
public static final int DEFAULT_CACHE_SIZE = (24 /* MiB */* 1024 * 1024); // in bytes
private DrawableMemCache<String> mMemCache = new DrawableMemCache<String>(DEFAULT_CACHE_SIZE);
private Integer mIDCounter = 0;
private static ImageCache mInstance;
// this is a custom Executor, as we want to have the tasks loaded in FILO order. FILO works
// particularly well when scrolling with a ListView.
private final ThreadPoolExecutor mExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE,
MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS,
new PriorityBlockingQueue<Runnable>());
// ignored as SparseArray isn't thread-safe
private final Map<Integer, Runnable> jobs = Collections
.synchronizedMap(new HashMap<Integer, Runnable>());
private final HttpClient hc;
private CompressFormat mCompressFormat;
private int mQuality;
private final Resources mRes;
private static final int MSG_IMAGE_LOADED = 100;
private final KeyedLock<String> mDownloading = new KeyedLock<String>();
private static class ImageLoadHandler extends Handler {
private final ImageCache mCache;
public ImageLoadHandler(ImageCache cache) {
super();
mCache = cache;
}
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MSG_IMAGE_LOADED:
mCache.notifyListeners((LoadResult) msg.obj);
break;
}
};
}
private final ImageLoadHandler mHandler = new ImageLoadHandler(this);
// TODO make it so this is customizable on the instance level.
/**
* Gets an instance of the cache.
*
* @param context
* @return an instance of the cache
*/
public static ImageCache getInstance(Context context) {
if (mInstance == null) {
mInstance = new ImageCache(context, CompressFormat.JPEG, 85);
}
return mInstance;
}
/**
* Generally, it's best to use the shared image cache using {@link #getInstance(Context)}. Use
* this if you want to customize a cache or keep it separate.
*
* @param context
* @param format
* @param quality
*/
public ImageCache(Context context, CompressFormat format, int quality) {
super(context.getCacheDir(), null, getExtension(format));
if (USE_APACHE_NC) {
hc = getHttpClient();
} else {
hc = null;
}
mRes = context.getResources();
mCompressFormat = format;
mQuality = quality;
}
/**
* Sets the compression format for resized images.
*
* @param format
*/
public void setCompressFormat(CompressFormat format) {
mCompressFormat = format;
}
/**
* Set the image quality. Hint to the compressor, 0-100. 0 meaning compress for small size, 100
* meaning compress for max quality. Some formats, like PNG which is lossless, will ignore the
* quality setting
*
* @param quality
*/
public void setQuality(int quality) {
mQuality = quality;
}
/**
* Sets the maximum size of the memory cache. Note, this will clear the memory cache.
*
* @param maxSize
* the maximum size of the memory cache in bytes.
*/
public void setMemCacheMaxSize(int maxSize) {
mMemCache = new DrawableMemCache<String>(maxSize);
}
private static String getExtension(CompressFormat format) {
String extension;
switch (format) {
case JPEG:
extension = ".jpg";
break;
case PNG:
extension = ".png";
break;
default:
throw new IllegalArgumentException();
}
return extension;
}
/**
* If loading a number of images where you don't have a unique ID to represent the individual
* load, this can be used to generate a sequential ID.
*
* @return a new unique ID
*/
public int getNewID() {
synchronized (mIDCounter) {
return mIDCounter++;
}
}
@Override
protected Bitmap fromDisk(String key, InputStream in) {
if (DEBUG) {
Log.d(TAG, "disk cache hit for key " + key);
}
try {
final Bitmap image = BitmapFactory.decodeStream(in);
return image;
} catch (final OutOfMemoryError oom) {
oomClear();
return null;
}
}
@Override
protected void toDisk(String key, Bitmap image, OutputStream out) {
if (DEBUG) {
Log.d(TAG, "disk cache write for key " + key);
}
if (image != null) {
if (!image.compress(mCompressFormat, mQuality, out)) {
Log.e(TAG, "error writing compressed image to disk for key " + key);
}
} else {
Log.e(TAG, "Ignoring attempt to write null image to disk cache");
}
}
/**
* Gets an instance of AndroidHttpClient if the devices has it (it was introduced in 2.2), or
* falls back on a http client that should work reasonably well.
*
* @return a working instance of an HttpClient
*/
private HttpClient getHttpClient() {
HttpClient ahc;
try {
final Class<?> ahcClass = Class.forName("android.net.http.AndroidHttpClient");
final Method newInstance = ahcClass.getMethod("newInstance", String.class);
ahc = (HttpClient) newInstance.invoke(null, "ImageCache");
} catch (final ClassNotFoundException e) {
DefaultHttpClient dhc = new DefaultHttpClient();
final HttpParams params = dhc.getParams();
dhc = null;
params.setIntParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, 20 * 1000);
final SchemeRegistry registry = new SchemeRegistry();
registry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
registry.register(new Scheme("https", SSLSocketFactory.getSocketFactory(), 443));
final ThreadSafeClientConnManager manager = new ThreadSafeClientConnManager(params,
registry);
ahc = new DefaultHttpClient(manager, params);
} catch (final NoSuchMethodException e) {
final RuntimeException re = new RuntimeException("Programming error");
re.initCause(e);
throw re;
} catch (final IllegalAccessException e) {
final RuntimeException re = new RuntimeException("Programming error");
re.initCause(e);
throw re;
} catch (final InvocationTargetException e) {
final RuntimeException re = new RuntimeException("Programming error");
re.initCause(e);
throw re;
}
return ahc;
}
/**
* <p>
* Registers an {@link OnImageLoadListener} with the cache. When an image is loaded
* asynchronously either directly by way of {@link #scheduleLoadImage(int, Uri, int, int)} or
* indirectly by {@link #loadImage(int, Uri, int, int)}, any registered listeners will get
* called.
* </p>
*
* <p>
* This should probably be called from {@link Activity#onResume()}.
* </p>
*
* @param onImageLoadListener
*/
public void registerOnImageLoadListener(OnImageLoadListener onImageLoadListener) {
mImageLoadListeners.add(onImageLoadListener);
}
/**
* <p>
* Unregisters the listener with the cache. This will not cancel any pending load requests.
* </p>
*
* <p>
* This should probably be called from {@link Activity#onPause()}.
* </p>
*
* @param onImageLoadListener
*/
public void unregisterOnImageLoadListener(OnImageLoadListener onImageLoadListener) {
mImageLoadListeners.remove(onImageLoadListener);
}
private class LoadResult {
public LoadResult(int id, Uri image, Drawable drawable) {
this.id = id;
this.drawable = drawable;
this.image = image;
}
final Uri image;
final int id;
final Drawable drawable;
}
/**
* @param uri
* the image uri
* @return a key unique to the given uri
*/
public String getKey(Uri uri) {
return uri.toString();
}
/**
* Gets the given key as a drawable, retrieving it from memory cache if it's present.
*
* @param key
* a key generated by {@link #getKey(Uri)} or {@link #getKey(Uri, int, int)}
* @return the drawable if it's in the memory cache or null.
*/
public Drawable getDrawable(String key) {
final Drawable img = mMemCache.get(key);
if (img != null) {
if (DEBUG) {
Log.d(TAG, "mem cache hit for key " + key);
}
touchKey(key);
return img;
}
return null;
}
/**
* Puts a drawable into memory cache.
*
* @param key
* a key generated by {@link #getKey(Uri)} or {@link #getKey(Uri, int, int)}
* @param drawable
*/
public void putDrawable(String key, Drawable drawable) {
mMemCache.put(key, drawable);
}
/**
* A blocking call to get an image. If it's in the cache, it'll return the drawable immediately.
* Otherwise it will download, scale, and cache the image before returning it. For non-blocking
* use, see {@link #loadImage(int, Uri, int, int)}
*
* @param uri
* @param width
* @param height
* @return
* @throws ClientProtocolException
* @throws IOException
* @throws ImageCacheException
*/
public Drawable getImage(Uri uri, int width, int height) throws ClientProtocolException,
IOException, ImageCacheException {
final String scaledKey = getKey(uri, width, height);
mDownloading.lock(scaledKey);
try {
Drawable d = getDrawable(scaledKey);
if (d != null) {
return d;
}
Bitmap bmp = get(scaledKey);
if (bmp == null) {
if ("file".equals(uri.getScheme())) {
bmp = scaleLocalImage(new File(uri.getPath()), width, height);
} else {
final String sourceKey = getKey(uri);
mDownloading.lock(sourceKey);
try {
if (!contains(sourceKey)) {
downloadImage(sourceKey, uri);
}
} finally {
mDownloading.unlock(sourceKey);
}
bmp = scaleLocalImage(getFile(sourceKey), width, height);
if (bmp == null) {
clear(sourceKey);
}
}
put(scaledKey, bmp);
}
if (bmp == null) {
throw new ImageCacheException("got null bitmap from request to scale");
}
d = new BitmapDrawable(mRes, bmp);
putDrawable(scaledKey, d);
return d;
} finally {
mDownloading.unlock(scaledKey);
}
}
private final SparseArray<String> mKeyCache = new SparseArray<String>();
/**
* Returns an opaque cache key representing the given uri, width and height.
*
* @param uri
* an image uri
* @param width
* the desired image max width
* @param height
* the desired image max height
* @return a cache key unique to the given parameters
*/
public String getKey(Uri uri, int width, int height) {
// collisions are possible, but unlikely.
final int hashId = uri.hashCode() + width + height * 10000;
String key = mKeyCache.get(hashId);
if (key == null) {
key = uri.buildUpon().appendQueryParameter("width", String.valueOf(width))
.appendQueryParameter("height", String.valueOf(height)).build().toString();
mKeyCache.put(hashId, key);
}
return key;
}
@Override
public synchronized boolean clear() {
final boolean success = super.clear();
mMemCache.evictAll();
mKeyCache.clear();
return success;
}
@Override
public synchronized boolean clear(String key) {
final boolean success = super.clear(key);
mMemCache.remove(key);
return success;
}
private class ImageLoadTask implements Runnable, Comparable<ImageLoadTask> {
private final int id;
private final Uri uri;
private final int width;
private final int height;
private final long when = System.nanoTime();
public ImageLoadTask(int id, Uri image, int width, int height) {
this.id = id;
this.uri = image;
this.width = width;
this.height = height;
}
@Override
public void run() {
if (DEBUG) {
Log.d(TAG, "ImageLoadTask.doInBackground(" + id + ", " + uri + ", " + width + ", "
+ height + ")");
}
try {
final LoadResult result = new LoadResult(id, uri, getImage(uri, width, height));
synchronized (jobs) {
if (jobs.containsKey(id)) {
// Job still valid.
jobs.remove(id);
mHandler.obtainMessage(MSG_IMAGE_LOADED, result).sendToTarget();
}
}
// TODO this exception came about, no idea why:
// java.lang.IllegalArgumentException: Parser may not be null
} catch (final IllegalArgumentException e) {
Log.e(TAG, e.getLocalizedMessage(), e);
} catch (final OutOfMemoryError oom) {
oomClear();
} catch (final ClientProtocolException e) {
Log.e(TAG, e.getLocalizedMessage(), e);
} catch (final IOException e) {
Log.e(TAG, e.getLocalizedMessage(), e);
} catch (final ImageCacheException e) {
Log.e(TAG, e.getLocalizedMessage(), e);
}
}
@Override
public int compareTo(ImageLoadTask another) {
return Long.valueOf(another.when).compareTo(when);
};
}
private void oomClear() {
Log.w(TAG, "out of memory, clearing mem cache");
mMemCache.evictAll();
}
/**
* Checks the cache for an image matching the given criteria and returns it. If it isn't
* immediately available, calls {@link #scheduleLoadImage}.
*
* @param id
* An ID to keep track of image load requests. For one-off loads, this can just be
* the ID of the {@link ImageView}. Otherwise, an unique ID can be acquired using
* {@link #getNewID()}.
*
* @param image
* the image to be loaded. Can be a local file or a network resource.
* @param width
* the maximum width of the resulting image
* @param height
* the maximum height of the resulting image
* @return the cached bitmap if it's available immediately or null if it needs to be loaded
* asynchronously.
*/
public Drawable loadImage(int id, Uri image, int width, int height) throws IOException {
if (DEBUG) {
Log.d(TAG, "loadImage(" + id + ", " + image + ", " + width + ", " + height + ")");
}
final Drawable res = getDrawable(getKey(image, width, height));
if (res == null) {
if (DEBUG) {
Log.d(TAG,
"Image not found in memory cache. Scheduling load from network / disk...");
}
scheduleLoadImage(id, image, width, height);
}
return res;
}
/**
* Deprecated to make IDs ints instead of longs. See {@link #loadImage(int, Uri, int, int)}.
*
* @param id
* @param image
* @param width
* @param height
* @return
* @throws IOException
*/
@Deprecated
public Drawable loadImage(long id, Uri image, int width, int height) throws IOException {
return loadImage(id, image, width, height);
}
/**
* Schedules a load of the given image. When the image has finished loading and scaling, all
* registered {@link OnImageLoadListener}s will be called.
*
* @param id
* An ID to keep track of image load requests. For one-off loads, this can just be
* the ID of the {@link ImageView}. Otherwise, an unique ID can be acquired using
* {@link #getNewID()}.
*
* @param image
* the image to be loaded. Can be a local file or a network resource.
* @param width
* the maximum width of the resulting image
* @param height
* the maximum height of the resulting image
*/
public void scheduleLoadImage(int id, Uri image, int width, int height) {
if (DEBUG) {
Log.d(TAG, "executing new ImageLoadTask in background...");
}
final ImageLoadTask imt = new ImageLoadTask(id, image, width, height);
jobs.put(id, imt);
mExecutor.execute(imt);
}
/**
* Deprecated in favour of {@link #scheduleLoadImage(int, Uri, int, int)}.
*
* @param id
* @param image
* @param width
* @param height
*/
@Deprecated
public void scheduleLoadImage(long id, Uri image, int width, int height) {
scheduleLoadImage(id, image, width, height);
}
/**
* Cancels all the asynchronous image loads. Note: currently does not function properly.
*
*/
public void cancelLoads() {
jobs.clear();
mExecutor.getQueue().clear();
}
public void cancel(int id) {
synchronized (jobs) {
final Runnable job = jobs.get(id);
if (job != null) {
jobs.remove(id);
mExecutor.remove(job);
if (DEBUG) {
Log.d(TAG, "removed load id " + id);
}
}
}
}
/**
* Deprecated in favour of {@link #cancel(int)}.
*
* @param id
*/
@Deprecated
public void cancel(long id) {
cancel(id);
}
/**
* Blocking call to scale a local file. Scales using preserving aspect ratio
*
* @param localFile
* local image file to be scaled
* @param width
* maximum width
* @param height
* maximum height
* @return the scaled image
* @throws ClientProtocolException
* @throws IOException
*/
private static Bitmap scaleLocalImage(File localFile, int width, int height)
throws ClientProtocolException, IOException {
if (DEBUG) {
Log.d(TAG, "scaleLocalImage(" + localFile + ", " + width + ", " + height + ")");
}
if (!localFile.exists()) {
throw new IOException("local file does not exist: " + localFile);
}
if (!localFile.canRead()) {
throw new IOException("cannot read from local file: " + localFile);
}
// the below borrowed from:
// https://github.com/thest1/LazyList/blob/master/src/com/fedorvlasov/lazylist/ImageLoader.java
// decode image size
final BitmapFactory.Options o = new BitmapFactory.Options();
o.inJustDecodeBounds = true;
BitmapFactory.decodeStream(new FileInputStream(localFile), null, o);
// Find the correct scale value. It should be the power of 2.
//final int REQUIRED_WIDTH = width, REQUIRED_HEIGHT = height;
int width_tmp = o.outWidth, height_tmp = o.outHeight;
int scale = 1;
while (true) {
if (width_tmp / 2 <= width || height_tmp / 2 <= height) {
break;
}
width_tmp /= 2;
height_tmp /= 2;
scale *= 2;
}
// decode with inSampleSize
final BitmapFactory.Options o2 = new BitmapFactory.Options();
o2.inSampleSize = scale;
final Bitmap prescale = BitmapFactory
.decodeStream(new FileInputStream(localFile), null, o2);
if (prescale == null) {
Log.e(TAG, localFile + " could not be decoded");
} else if (DEBUG) {
Log.d(TAG, "Successfully completed scaling of " + localFile + " to " + width + "x"
+ height);
}
return prescale;
}
/**
* Blocking call to download an image. The image is placed directly into the disk cache at the
* given key.
*
* @param uri
* the location of the image
* @return a decoded bitmap
* @throws ClientProtocolException
* if the HTTP response code wasn't 200 or any other HTTP errors
* @throws IOException
*/
protected void downloadImage(String key, Uri uri) throws ClientProtocolException, IOException {
if (DEBUG) {
Log.d(TAG, "downloadImage(" + key + ", " + uri + ")");
}
if (USE_APACHE_NC) {
final HttpGet get = new HttpGet(uri.toString());
final HttpParams params = get.getParams();
params.setParameter(ClientPNames.HANDLE_REDIRECTS, true);
final HttpResponse hr = hc.execute(get);
final StatusLine hs = hr.getStatusLine();
if (hs.getStatusCode() != 200) {
throw new HttpResponseException(hs.getStatusCode(), hs.getReasonPhrase());
}
final HttpEntity ent = hr.getEntity();
// TODO I think this means that the source file must be a jpeg. fix this.
try {
putRaw(key, ent.getContent());
if (DEBUG) {
Log.d(TAG, "source file of " + uri + " saved to disk cache at location "
+ getFile(key).getAbsolutePath());
}
} finally {
ent.consumeContent();
}
} else {
final URLConnection con = new URL(uri.toString()).openConnection();
putRaw(key, con.getInputStream());
if (DEBUG) {
Log.d(TAG,
"source file of " + uri + " saved to disk cache at location "
+ getFile(key).getAbsolutePath());
}
}
}
private void notifyListeners(LoadResult result) {
for (final OnImageLoadListener listener : mImageLoadListeners) {
listener.onImageLoaded(result.id, result.image, result.drawable);
}
}
/**
* Implement this and register it using
* {@link ImageCache#registerOnImageLoadListener(OnImageLoadListener)} to be notified when
* asynchronous image loads have completed.
*
* @author <a href="mailto:spomeroy@mit.edu">Steve Pomeroy</a>
*
*/
public interface OnImageLoadListener {
/**
* Called when the image has been loaded and scaled.
*
* @param id
* the ID provided by {@link ImageCache#loadImage(int, Uri, int, int)} or
* {@link ImageCache#scheduleLoadImage(int, Uri, int, int)}
* @param imageUri
* the uri of the image that was originally requested
* @param image
* the loaded and scaled image
*/
public void onImageLoaded(int id, Uri imageUri, Drawable image);
}
}