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 @SuppressLint("UseSparseArrays") 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); } }