diff --git a/README.md b/README.md
index 8fd3979..6bd8216 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@ Image Cache
===========
An image download-and-cacher that also knows how to efficiently generate
-and retrieve thumbnails of various sizes.
+and retrieve thumbnails of various sizes.
Features
--------
@@ -12,6 +12,8 @@ Features
* automatic generation and caching of multiple sizes of images based on one
downloaded asset
* provides a disk cache as well as a memory cache
+* automatic disk cache management; no setup necessary, but parameters can be
+ fine-tuned if desired
* designed to work with your existing setup: no extending a custom application
or activity needed
* cursor adapter supports multiple image fields for each ImageView; skips
@@ -21,8 +23,8 @@ Features
Using
-----
-Please see the test/ directory for both a simple example of using it as well as
-some unit tests. When running the application in test/ make sure to run it as
+Please see the `test/` directory for both a simple example of using it as well as
+some unit tests. When running the application in `test/` make sure to run it as
an Android activity if you want to see the demo.
Both the unit tests and the interactive test load some images from our lab's servers.
@@ -31,7 +33,7 @@ License
=======
MEL Android Image Cache
-Copyright (C) 2011-2012 [MIT Mobile Experience Lab][mel]
+Copyright (C) 2011-2013 [MIT Mobile Experience Lab][mel]
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
diff --git a/res/values/ids.xml b/res/values/ids.xml
index 5c8f24f..3a09717 100644
--- a/res/values/ids.xml
+++ b/res/values/ids.xml
@@ -1,7 +1,9 @@
+
+
\ No newline at end of file
diff --git a/src/edu/mit/mobile/android/imagecache/DiskCache.java b/src/edu/mit/mobile/android/imagecache/DiskCache.java
index 79317fd..f159de2 100644
--- a/src/edu/mit/mobile/android/imagecache/DiskCache.java
+++ b/src/edu/mit/mobile/android/imagecache/DiskCache.java
@@ -1,7 +1,7 @@
package edu.mit.mobile.android.imagecache;
/*
- * Copyright (C) 2011 MIT Mobile Experience Lab
+ * Copyright (C) 2011-2013 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
@@ -28,11 +28,35 @@
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
-
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import android.os.Build;
+import android.os.StatFs;
import android.util.Log;
/**
+ *
* A simple disk cache.
+ *
+ *
+ *
+ * By default, the maximum size of the cache is automatically set based on the amount of free space
+ * available to the cache. Alternatively, a fixed size can be specified using
+ * {@link #setCacheMaxSize(long)}.
+ *
+ *
+ *
+ * By default, the cache will automatically maintain its size by periodically checking to see if it
+ * estimates that a trim is needed and if it is, proceeding to running {@link #trim()} on a worker
+ * thread. This feature can be controlled by {@link #setAutoTrimFrequency(int)}.
+ *
*
* @author Steve Pomeroy
*
@@ -41,15 +65,53 @@
* @param
* the value that will be stored to disk
*/
-// TODO add automatic cache cleanup so low disk conditions can be met
public abstract class DiskCache {
private static final String TAG = "DiskCache";
+ /**
+ * Automatically determines the maximum size of the cache based on available free space.
+ */
+ public static final int AUTO_MAX_CACHE_SIZE = 0;
+
+ /**
+ * The default number of cache hits before {@link #trim()} is automatically triggered. See
+ * {@link #setAutoTrimFrequency(int)}.
+ */
+ public static final int DEFAULT_AUTO_TRIM_FREQUENCY = 10;
+
+ /**
+ * Pass to {@link #setAutoTrimFrequency(int)} to disable automatic trimming. See {@link #trim()}
+ * .
+ */
+ public static final int AUTO_TRIM_DISABLED = 0;
+
+ // /////////////////////////////////////////////
+
+ private long mMaxDiskUsage = AUTO_MAX_CACHE_SIZE;
+
private MessageDigest hash;
private final File mCacheBase;
private final String mCachePrefix, mCacheSuffix;
+ private final ConcurrentLinkedQueue mQueue = new ConcurrentLinkedQueue();
+
+ /**
+ * In auto max cache mode, the maximum is set to the total free space divided by this amount.
+ */
+ private static final int AUTO_MAX_CACHE_SIZE_DIVISOR = 10;
+
+ private int mAutoTrimFrequency = DEFAULT_AUTO_TRIM_FREQUENCY;
+
+ private final ThreadPoolExecutor mExecutor = new ThreadPoolExecutor(1, 5, 60, TimeUnit.SECONDS,
+ new LinkedBlockingQueue());
+
+ private int mAutoTrimHitCount = 1;
+
+ private long mEstimatedDiskUsage;
+
+ private long mEstimatedFreeSpace;
+
/**
* Creates a new disk cache with no cachePrefix or cacheSuffix
*
@@ -86,6 +148,71 @@ public DiskCache(File cacheBase, String cachePrefix, String cacheSuffix) {
throw re;
}
}
+
+ updateDiskUsageInBg();
+ }
+
+ /**
+ * Sets the maximum size of the cache, in bytes. The default is to automatically manage the max
+ * size based on the available disk space. This can be explicitly set by passing this
+ * {@link #AUTO_MAX_CACHE_SIZE}.
+ *
+ * @param maxSize
+ * maximum size of the cache, in bytes.
+ */
+ public void setCacheMaxSize(long maxSize) {
+ mMaxDiskUsage = maxSize;
+ }
+
+ /**
+ * After this many puts, if it looks like there's a low space condition, {@link #trim()} will
+ * automatically be called.
+ *
+ * @param autoTrimFrequency
+ * Set to {@link #AUTO_TRIM_DISABLED} to turn off auto trim. The default is
+ * {@link #DEFAULT_AUTO_TRIM_FREQUENCY}.
+ */
+ public void setAutoTrimFrequency(int autoTrimFrequency) {
+ mAutoTrimFrequency = autoTrimFrequency;
+ }
+
+ /**
+ * Updates cached estimates on the
+ */
+ private void updateDiskUsageEstimates() {
+ final long diskUsage = getCacheDiskUsage();
+
+ final long availableSpace = getFreeSpace();
+
+ synchronized (this) {
+ mEstimatedDiskUsage = diskUsage;
+ mEstimatedFreeSpace = availableSpace;
+ }
+ }
+
+ private void updateDiskUsageInBg() {
+ mExecutor.execute(new Runnable() {
+
+ @Override
+ public void run() {
+ updateDiskUsageEstimates();
+ }
+ });
+ }
+
+ /**
+ * Gets the amount of space free on the cache volume.
+ *
+ * @return free space in bytes.
+ */
+ private long getFreeSpace() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) {
+ return mCacheBase.getUsableSpace();
+ } else {
+ // maybe make singleton
+ final StatFs stat = new StatFs(mCacheBase.getAbsolutePath());
+ return (long) stat.getAvailableBlocks() * (long) stat.getBlockSize();
+ }
}
/**
@@ -108,12 +235,18 @@ protected File getFile(K key) {
* @param value
* the data to be written to disk.
*/
- public synchronized void put(K key, V value) throws IOException, FileNotFoundException {
+ public final synchronized void put(K key, V value) throws IOException, FileNotFoundException {
final File saveHere = getFile(key);
final OutputStream os = new FileOutputStream(saveHere);
toDisk(key, value, os);
os.close();
+
+ mEstimatedDiskUsage += saveHere.length();
+
+ touchEntry(saveHere);
+
+ autotrim();
}
/**
@@ -126,7 +259,7 @@ public synchronized void put(K key, V value) throws IOException, FileNotFoundExc
* @throws IOException
* @throws FileNotFoundException
*/
- public void putRaw(K key, InputStream value) throws IOException, FileNotFoundException {
+ public final void putRaw(K key, InputStream value) throws IOException, FileNotFoundException {
final File saveHere = getFile(key);
@@ -152,6 +285,61 @@ public void putRaw(K key, InputStream value) throws IOException, FileNotFoundExc
tempFile.delete();
}
}
+ if (allGood) {
+ mEstimatedDiskUsage += saveHere.length();
+
+ touchEntry(saveHere);
+
+ autotrim();
+ }
+ }
+
+ /**
+ * Puts the key at the end of the queue, removing it if it's already present. This will cause it
+ * to be removed last when {@link #trim()} is called.
+ *
+ * @param cacheFile
+ */
+ private void touchEntry(File cacheFile) {
+ if (mQueue.contains(cacheFile)) {
+ mQueue.remove(cacheFile);
+ }
+ mQueue.add(cacheFile);
+ }
+
+ /**
+ * Marks the given key as accessed recently. This will deprioritize it from automatically being
+ * purged upon {@link #trim()}.
+ *
+ * @param key
+ */
+ protected void touchKey(K key) {
+ touchEntry(getFile(key));
+ }
+
+ /**
+ * Call this every time you may be able to start a trim in the background. This implicitly runs
+ * {@link #updateDiskUsageInBg()} each time it's called.
+ */
+ private void autotrim() {
+ if (mAutoTrimFrequency == 0) {
+ return;
+ }
+
+ mAutoTrimHitCount = (mAutoTrimHitCount + 1) % mAutoTrimFrequency;
+
+ if (mAutoTrimHitCount == 0
+ && mEstimatedDiskUsage > Math.min(mEstimatedFreeSpace, mMaxDiskUsage)) {
+
+ mExecutor.execute(new Runnable() {
+ @Override
+ public void run() {
+ trim();
+ }
+ });
+ }
+
+ updateDiskUsageInBg();
}
/**
@@ -178,7 +366,7 @@ static public void inputStreamToOutputStream(InputStream is, OutputStream os)
* @param key
* @return The value for key or null if the key doesn't map to any existing entries.
*/
- public synchronized V get(K key) throws IOException {
+ public final synchronized V get(K key) throws IOException {
final File readFrom = getFile(key);
if (!readFrom.exists()) {
@@ -188,6 +376,9 @@ public synchronized V get(K key) throws IOException {
final InputStream is = new FileInputStream(readFrom);
final V out = fromDisk(key, is);
is.close();
+
+ touchEntry(readFrom);
+
return out;
}
@@ -197,7 +388,7 @@ public synchronized V get(K key) throws IOException {
* @param key
* @return true if the disk cache contains the given key
*/
- public synchronized boolean contains(K key) {
+ public final synchronized boolean contains(K key) {
final File readFrom = getFile(key);
return readFrom.exists();
@@ -216,8 +407,38 @@ public synchronized boolean clear(K key) {
if (!readFrom.exists()) {
return true;
}
+ final long size = readFrom.length();
+
+ final boolean success = readFrom.delete();
+
+ if (success) {
+ mEstimatedDiskUsage -= size;
+ }
- return readFrom.delete();
+ return success;
+ }
+
+ /**
+ * Removes the item from the disk cache.
+ *
+ * @param cacheFile
+ * @return true if the cached item has been removed or was already removed, false if it was not
+ * able to be removed.
+ */
+ private synchronized boolean clear(File cacheFile) {
+
+ if (!cacheFile.exists()) {
+ return true;
+ }
+ final long size = cacheFile.length();
+
+ final boolean success = cacheFile.delete();
+
+ if (success) {
+ mEstimatedDiskUsage -= size;
+ }
+
+ return success;
}
/**
@@ -233,7 +454,6 @@ public synchronized boolean clear() {
for (final File cacheFile : mCacheBase.listFiles(mCacheFileFilter)) {
if (!cacheFile.delete()) {
- // throw new IOException("cannot delete cache file");
Log.e(TAG, "error deleting " + cacheFile);
success = false;
}
@@ -242,12 +462,32 @@ public synchronized boolean clear() {
}
/**
- * @return the size of the cache as it is on disk.
+ * @return the number of files in the cache
+ * @deprecated please use {@link #getCacheEntryCount()} or {@link #getCacheDiskUsage()} instead.
*/
+ @Deprecated
public int getCacheSize() {
+ return getCacheEntryCount();
+ }
+
+ /**
+ * @return the number of files in the cache as it is on disk.
+ */
+ public int getCacheEntryCount() {
return mCacheBase.listFiles(mCacheFileFilter).length;
}
+ /**
+ * @return the size of the cache in bytes, as it is on disk.
+ */
+ public long getCacheDiskUsage() {
+ long usage = 0;
+ for (final File cacheFile : mCacheBase.listFiles(mCacheFileFilter)) {
+ usage += cacheFile.length();
+ }
+ return usage;
+ }
+
private final CacheFileFilter mCacheFileFilter = new CacheFileFilter();
private class CacheFileFilter implements FileFilter {
@@ -259,6 +499,90 @@ public boolean accept(File pathname) {
}
};
+ private final Comparator mLastModifiedOldestFirstComparator = new Comparator() {
+
+ @Override
+ public int compare(File lhs, File rhs) {
+ return Long.valueOf(lhs.lastModified()).compareTo(rhs.lastModified());
+ }
+ };
+
+ /**
+ * Clears out cache entries in order to reduce the on-disk usage to the desired maximum size.
+ * This is a somewhat expensive operation, so it should be done on a background thread.
+ *
+ * @return the number of bytes worth of files that were trimmed.
+ * @see #setCacheMaxSize(long)
+ */
+ public synchronized long trim() {
+
+ long desiredSize;
+ final long freeSpace = getFreeSpace();
+
+ if (mMaxDiskUsage > 0) {
+ desiredSize = mMaxDiskUsage;
+ } else {
+ desiredSize = getFreeSpace() / AUTO_MAX_CACHE_SIZE_DIVISOR;
+ }
+
+ desiredSize = Math.min(freeSpace, desiredSize);
+
+ final long sizeToTrim = Math.max(0, getCacheDiskUsage() - desiredSize);
+
+ if (sizeToTrim == 0) {
+ return 0;
+ }
+
+ long trimmed = 0;
+
+ final List sorted = Arrays.asList(mCacheBase.listFiles(mCacheFileFilter));
+ Collections.sort(sorted, mLastModifiedOldestFirstComparator);
+
+ // first clear out any files that aren't in the queue
+ for (final File cacheFile : sorted) {
+ if (mQueue.contains(cacheFile)) {
+ continue;
+ }
+
+ final long size = cacheFile.length();
+ if (clear(cacheFile)) {
+ trimmed += size;
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "trimmed unqueued " + cacheFile.getName() + " from cache.");
+ }
+ }
+
+ if (trimmed >= sizeToTrim) {
+ break;
+ }
+ }
+
+ while (trimmed < sizeToTrim && !mQueue.isEmpty()) {
+ final File cacheFile = mQueue.poll();
+
+ // shouldn't happen due to the check above, but just in case...
+ if (cacheFile == null) {
+ break;
+ }
+
+ final long size = cacheFile.length();
+
+ if (clear(cacheFile)) {
+ trimmed += size;
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "trimmed " + cacheFile.getName() + " from cache.");
+ }
+ } else {
+ Log.e(TAG, "error deleting " + cacheFile);
+ }
+ }
+
+ if (BuildConfig.DEBUG) {
+ Log.d(TAG, "trimmed a total of " + trimmed + " bytes from cache.");
+ }
+ return trimmed;
+ }
+
/**
* Implement this to do the actual disk writing. Do not close the OutputStream; it will be
* closed for you.
@@ -287,6 +611,8 @@ public boolean accept(File pathname) {
*/
public String hash(K key) {
final byte[] ba;
+
+ // MessageDigest isn't threadsafe, so we need to ensure it doesn't tread on itself.
synchronized (hash) {
hash.update(key.toString().getBytes());
ba = hash.digest();
diff --git a/src/edu/mit/mobile/android/imagecache/ImageCache.java b/src/edu/mit/mobile/android/imagecache/ImageCache.java
index 9b8d781..bdceb13 100644
--- a/src/edu/mit/mobile/android/imagecache/ImageCache.java
+++ b/src/edu/mit/mobile/android/imagecache/ImageCache.java
@@ -48,8 +48,10 @@
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;
@@ -62,6 +64,7 @@
import android.os.Handler;
import android.os.Message;
import android.util.Log;
+import android.util.SparseArray;
import android.widget.ImageView;
/**
@@ -99,7 +102,7 @@ public class ImageCache extends DiskCache {
private DrawableMemCache mMemCache = new DrawableMemCache(DEFAULT_CACHE_SIZE);
- private Long mIDCounter = (long) 0;
+ private Integer mIDCounter = 0;
private static ImageCache mInstance;
@@ -108,8 +111,11 @@ public class ImageCache extends DiskCache {
private final ThreadPoolExecutor mExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE,
MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS,
new PriorityBlockingQueue());
- private final Map jobs = Collections
- .synchronizedMap(new HashMap());
+
+ // ignored as SparseArray isn't thread-safe
+ @SuppressLint("UseSparseArrays")
+ private final Map jobs = Collections
+ .synchronizedMap(new HashMap());
private final HttpClient hc;
@@ -166,7 +172,11 @@ public static ImageCache getInstance(Context context) {
*/
public ImageCache(Context context, CompressFormat format, int quality) {
super(context.getCacheDir(), null, getExtension(format));
- hc = getHttpClient();
+ if (USE_APACHE_NC) {
+ hc = getHttpClient();
+ } else {
+ hc = null;
+ }
mRes = context.getResources();
@@ -228,7 +238,7 @@ private static String getExtension(CompressFormat format) {
*
* @return a new unique ID
*/
- public long getNewID() {
+ public int getNewID() {
synchronized (mIDCounter) {
return mIDCounter++;
}
@@ -282,6 +292,8 @@ private HttpClient getHttpClient() {
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));
@@ -312,8 +324,8 @@ private HttpClient getHttpClient() {
/**
*
* Registers an {@link OnImageLoadListener} with the cache. When an image is loaded
- * asynchronously either directly by way of {@link #scheduleLoadImage(long, Uri, int, int)} or
- * indirectly by {@link #loadImage(long, Uri, int, int)}, any registered listeners will get
+ * 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.
*
*
@@ -343,14 +355,14 @@ public void unregisterOnImageLoadListener(OnImageLoadListener onImageLoadListene
}
private class LoadResult {
- public LoadResult(long id, Uri image, Drawable drawable) {
+ public LoadResult(int id, Uri image, Drawable drawable) {
this.id = id;
this.drawable = drawable;
this.image = image;
}
final Uri image;
- final long id;
+ final int id;
final Drawable drawable;
}
@@ -376,6 +388,7 @@ public Drawable getDrawable(String key) {
if (DEBUG) {
Log.d(TAG, "mem cache hit for key " + key);
}
+ touchKey(key);
return img;
}
@@ -396,7 +409,7 @@ public void putDrawable(String key, Drawable 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(long, Uri, int, int)}
+ * use, see {@link #loadImage(int, Uri, int, int)}
*
* @param uri
* @param width
@@ -459,6 +472,8 @@ public Drawable getImage(Uri uri, int width, int height) throws ClientProtocolEx
}
}
+ private final SparseArray mKeyCache = new SparseArray();
+
/**
* Returns an opaque cache key representing the given uri, width and height.
*
@@ -471,8 +486,16 @@ public Drawable getImage(Uri uri, int width, int height) throws ClientProtocolEx
* @return a cache key unique to the given parameters
*/
public String getKey(Uri uri, int width, int height) {
- return uri.buildUpon().appendQueryParameter("width", String.valueOf(width))
- .appendQueryParameter("height", String.valueOf(height)).build().toString();
+ // 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
@@ -481,6 +504,8 @@ public synchronized boolean clear() {
mMemCache.evictAll();
+ mKeyCache.clear();
+
return success;
}
@@ -494,13 +519,13 @@ public synchronized boolean clear(String key) {
}
private class ImageLoadTask implements Runnable, Comparable {
- private final long id;
+ private final int id;
private final Uri uri;
private final int width;
private final int height;
private final long when = System.nanoTime();
- public ImageLoadTask(long id, Uri image, int width, int height) {
+ public ImageLoadTask(int id, Uri image, int width, int height) {
this.id = id;
this.uri = image;
this.width = width;
@@ -569,7 +594,7 @@ private void oomClear() {
* @return the cached bitmap if it's available immediately or null if it needs to be loaded
* asynchronously.
*/
- public Drawable loadImage(long id, Uri image, int width, int height) throws IOException {
+ public Drawable loadImage(int id, Uri image, int width, int height) throws IOException {
if (DEBUG) {
Log.d(TAG, "loadImage(" + id + ", " + image + ", " + width + ", " + height + ")");
}
@@ -584,6 +609,21 @@ public Drawable loadImage(long id, Uri image, int width, int height) throws IOEx
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.
@@ -600,7 +640,7 @@ public Drawable loadImage(long id, Uri image, int width, int height) throws IOEx
* @param height
* the maximum height of the resulting image
*/
- public void scheduleLoadImage(long id, Uri image, int width, int height) {
+ public void scheduleLoadImage(int id, Uri image, int width, int height) {
if (DEBUG) {
Log.d(TAG, "executing new ImageLoadTask in background...");
}
@@ -610,6 +650,19 @@ public void scheduleLoadImage(long id, Uri image, int width, int height) {
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.
*
@@ -619,7 +672,7 @@ public void cancelLoads() {
mExecutor.getQueue().clear();
}
- public void cancel(long id) {
+ public void cancel(int id) {
synchronized (jobs) {
final Runnable job = jobs.get(id);
if (job != null) {
@@ -632,6 +685,16 @@ public void cancel(long 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
*
@@ -767,13 +830,13 @@ public interface OnImageLoadListener {
* Called when the image has been loaded and scaled.
*
* @param id
- * the ID provided by {@link ImageCache#loadImage(long, Uri, int, int)} or
- * {@link ImageCache#scheduleLoadImage(long, Uri, int, int)}
+ * 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(long id, Uri imageUri, Drawable image);
+ public void onImageLoaded(int id, Uri imageUri, Drawable image);
}
}
diff --git a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java
index dcea8a8..d098311 100644
--- a/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java
+++ b/src/edu/mit/mobile/android/imagecache/ImageLoaderAdapter.java
@@ -1,7 +1,7 @@
package edu.mit.mobile.android.imagecache;
/*
- * Copyright (C) 2011-2012 MIT Mobile Experience Lab
+ * Copyright (C) 2011-2013 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
@@ -19,8 +19,8 @@
*/
import java.io.IOException;
import java.lang.ref.SoftReference;
-import java.util.HashMap;
+import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.net.Uri;
@@ -39,11 +39,11 @@
*
*
*
- * To use, pass in a ListAdapter that generates ImageViews in the layout hierarchy of getView().
- * ImageViews are searched for using the IDs specified in imageViewIDs. When found,
- * {@link ImageView#getTag()} is called and should return a {@link Uri} referencing a local or
- * remote image. See {@link ImageCache#loadImage(long, Uri, int, int)} for details on the types of
- * URIs and images supported.
+ * To use, pass in a {@link ListAdapter} that generates {@link ImageView}s in the layout hierarchy
+ * of getView(). ImageViews are searched for using the IDs specified in {@code imageViewIDs}. When
+ * found, {@link ImageView#getTag(R.id.ic__uri)} is called and should return a {@link Uri}
+ * referencing a local or remote image. See {@link ImageCache#loadImage(int, Uri, int, int)} for
+ * details on the types of URIs and images supported.
*
*
* @author Steve Pomeroy
@@ -52,21 +52,46 @@
public class ImageLoaderAdapter extends AdapterWrapper implements ImageCache.OnImageLoadListener {
private static final String TAG = ImageLoaderAdapter.class.getSimpleName();
- private final HashMap> mImageViewsToLoad = new HashMap>();
+ /**
+ * The unit specified is in pixels
+ */
+ public static final int UNIT_PX = 0;
+
+ /**
+ * The unit specified is in density-independent pixels (DIP)
+ */
+ public static final int UNIT_DIP = 1;
+
+ // //////////////////////////////////////////////
+ // / private
+ // //////////////////////////////////////////////
+
+ private final SparseArray> mImageViewsToLoad = new SparseArray>();
private final int[] mImageViewIDs;
private final ImageCache mCache;
private final int mDefaultWidth, mDefaultHeight;
- private final SparseArray mViewDimensionCache = new SparseArray();
+ private final boolean mAutosize;
- public static final int UNIT_PX = 0, UNIT_DIP = 1;
+ private final SparseArray mViewDimensionCache;
+
+ // ///////////////////////////////////////////////
/**
+ * Like the
+ * {@link #ImageLoaderAdapter(Context, ListAdapter, ImageCache, int[], int, int, int, boolean)}
+ * constructor with a default of {@code true} for autosize.
+ *
* @param context
+ * a context for getting the display density. You don't need to worry about this
+ * class holding on to a reference to this: it's only used in the constructor.
* @param wrapped
+ * the adapter that's wrapped. See {@link ImageLoaderAdapter} for the requirements of
+ * using this adapter wrapper.
* @param cache
+ * an instance of your image cache. This can be shared with the process.
* @param imageViewIDs
* a list of resource IDs matching the ImageViews that should be scanned and loaded.
* @param defaultWidth
@@ -76,16 +101,53 @@ public class ImageLoaderAdapter extends AdapterWrapper implements ImageCache.OnI
* the default maximum height, in the specified unit. This size will be used if the
* size cannot be obtained from the view.
* @param unit
- * one of UNIT_PX or UNIT_DIP
+ * one of {@link #UNIT_PX} or {@link #UNIT_DIP}
*/
public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache,
int[] imageViewIDs, int defaultWidth, int defaultHeight, int unit) {
+ this(context, wrapped, cache, imageViewIDs, defaultWidth, defaultHeight, unit, true);
+ }
+
+ /**
+ * @param context
+ * a context for getting the display density. You don't need to worry about this
+ * class holding on to a reference to this: it's only used in the constructor.
+ * @param wrapped
+ * the adapter that's wrapped. See {@link ImageLoaderAdapter} for the requirements of
+ * using this adapter wrapper.
+ * @param cache
+ * an instance of your image cache. This can be shared with the process.
+ * @param imageViewIDs
+ * a list of resource IDs matching the ImageViews that should be scanned and loaded.
+ * @param defaultWidth
+ * the default maximum width, in the specified unit. This size will be used if the
+ * size cannot be obtained from the view.
+ * @param defaultHeight
+ * the default maximum height, in the specified unit. This size will be used if the
+ * size cannot be obtained from the view.
+ * @param unit
+ * one of {@link #UNIT_PX} or {@link #UNIT_DIP}
+ * @param autosize
+ * if true, the view's dimensions will be cached the first time it's loaded and an
+ * image of the appropriate size will be requested the next time an image is loaded.
+ * False uses defaultWidth and defaultHeight only.
+ */
+ public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache,
+ int[] imageViewIDs, int defaultWidth, int defaultHeight, int unit, boolean autosize) {
super(wrapped);
mImageViewIDs = imageViewIDs;
mCache = cache;
mCache.registerOnImageLoadListener(this);
+ mAutosize = autosize;
+
+ if (autosize) {
+ mViewDimensionCache = new SparseArray();
+ } else {
+ mViewDimensionCache = null;
+ }
+
switch (unit) {
case UNIT_PX:
mDefaultHeight = defaultHeight;
@@ -106,8 +168,13 @@ public ImageLoaderAdapter(Context context, ListAdapter wrapped, ImageCache cache
}
/**
+ * Constructs a new adapter with a default unit of pixels.
+ *
* @param wrapped
+ * the adapter that's wrapped. See {@link ImageLoaderAdapter} for the requirements of
+ * using this adapter wrapper.
* @param cache
+ * an instance of your image cache. This can be shared with the process.
* @param imageViewIDs
* a list of resource IDs matching the ImageViews that should be scan
* @param width
@@ -122,21 +189,33 @@ public ImageLoaderAdapter(ListAdapter wrapped, ImageCache cache, int[] imageView
@Override
protected void finalize() throws Throwable {
- // TODO this should probably be in its own method, so it can be called in onPause / onResume
- mCache.unregisterOnImageLoadListener(this);
+ unregisterOnImageLoadListener();
super.finalize();
}
+ /**
+ * This can be called from your {@link Activity#onResume()} method.
+ */
+ public void registerOnImageLoadListener() {
+ mCache.registerOnImageLoadListener(this);
+ }
+
+ /**
+ * This can be called from your {@link Activity#onPause()} method.
+ */
+ public void unregisterOnImageLoadListener() {
+ mCache.unregisterOnImageLoadListener(this);
+ }
+
@Override
public View getView(int position, View convertView, ViewGroup parent) {
final View v = super.getView(position, convertView, parent);
-
for (final int id : mImageViewIDs) {
if (convertView != null) {
final ImageView iv = (ImageView) convertView.findViewById(id);
if (iv != null) {
- final Long tagId = (Long) iv.getTag(R.id.ic__load_id);
+ final Integer tagId = (Integer) iv.getTag(R.id.ic__load_id);
if (tagId != null) {
mCache.cancel(tagId);
}
@@ -147,51 +226,62 @@ public View getView(int position, View convertView, ViewGroup parent) {
if (iv == null) {
continue;
}
- ViewDimensionCache mViewDimension = mViewDimensionCache.get(id);
- if (mViewDimension == null) {
- final int w = iv.getMeasuredWidth();
- final int h = iv.getMeasuredHeight();
- if (w > 0 && h > 0) {
- mViewDimension = new ViewDimensionCache();
- mViewDimension.width = w;
- mViewDimension.height = h;
- mViewDimensionCache.put(id, mViewDimension);
- }
- }
final Uri tag = (Uri) iv.getTag(R.id.ic__uri);
- if (tag != null) {
- final long imageID = mCache.getNewID();
- iv.setTag(R.id.ic__load_id, imageID);
- // attempt to bypass all the loading machinery to get the image loaded as quickly
- // as possible
- Drawable d = null;
- try {
- if (mViewDimension != null && mViewDimension.width > 0
- && mViewDimension.height > 0) {
- d = mCache.loadImage(imageID, tag, mViewDimension.width,
- mViewDimension.height);
- } else {
- d = mCache.loadImage(imageID, tag, mDefaultWidth, mDefaultHeight);
+ // short circuit if there's no tag
+ if (tag == null) {
+ continue;
+ }
+
+ ViewDimensionCache viewDimension = null;
+
+ if (mAutosize) {
+ viewDimension = mViewDimensionCache.get(id);
+ if (viewDimension == null) {
+ final int w = iv.getMeasuredWidth();
+ final int h = iv.getMeasuredHeight();
+ if (w > 0 && h > 0) {
+ viewDimension = new ViewDimensionCache();
+ viewDimension.width = w;
+ viewDimension.height = h;
+ mViewDimensionCache.put(id, viewDimension);
}
- } catch (final IOException e) {
- e.printStackTrace();
}
- if (d != null) {
- iv.setImageDrawable(d);
+ }
+
+ final int imageID = mCache.getNewID();
+
+ // ic__load_id is used to keep track of what load ID is associated with what
+ // particular ImageView
+
+ iv.setTag(R.id.ic__load_id, imageID);
+ // attempt to bypass all the loading machinery to get the image loaded as quickly
+ // as possible
+ Drawable d = null;
+ try {
+ if (viewDimension != null && viewDimension.width > 0 && viewDimension.height > 0) {
+ d = mCache.loadImage(imageID, tag, viewDimension.width, viewDimension.height);
} else {
- if (ImageCache.DEBUG) {
- Log.d(TAG, "scheduling load with ID: " + imageID + "; URI;" + tag);
- }
- mImageViewsToLoad.put(imageID, new SoftReference(iv));
+ d = mCache.loadImage(imageID, tag, mDefaultWidth, mDefaultHeight);
}
+ } catch (final IOException e) {
+ e.printStackTrace();
}
+ if (d != null) {
+ iv.setImageDrawable(d);
+ } else {
+ if (ImageCache.DEBUG) {
+ Log.d(TAG, "scheduling load with ID: " + imageID + "; URI;" + tag);
+ }
+ mImageViewsToLoad.put(imageID, new SoftReference(iv));
+ }
+
}
return v;
}
@Override
- public void onImageLoaded(long id, Uri imageUri, Drawable image) {
+ public void onImageLoaded(int id, Uri imageUri, Drawable image) {
final SoftReference ivRef = mImageViewsToLoad.get(id);
if (ivRef == null) {
return;
diff --git a/test/res/menu/main_menu.xml b/test/res/menu/main_menu.xml
index ac8b64e..6c62d53 100644
--- a/test/res/menu/main_menu.xml
+++ b/test/res/menu/main_menu.xml
@@ -6,6 +6,11 @@
android:icon="@android:drawable/ic_menu_delete"
android:showAsAction="ifRoom"
android:title="Clear Cache"/>
+
- 0);
assertTrue(bmp.getWidth() > 0);
// call it again, ensure we overwrite
imc.put(key01, bmp);
- assertEquals(1, imc.getCacheSize());
+ assertEquals(1, imc.getCacheEntryCount());
bmpResult = imc.get(key01);
assertNotNull(bmpResult);
@@ -100,27 +103,32 @@ public void testGetPut() throws IOException {
testClear();
}
- private void assertBitmapMaxSize(int maxExpectedWidth, int maxExpectedHeight, Drawable actual){
+ private void assertBitmapMaxSize(int maxExpectedWidth, int maxExpectedHeight, Drawable actual) {
assertTrue(maxExpectedWidth >= actual.getIntrinsicWidth());
assertTrue(maxExpectedHeight >= actual.getIntrinsicHeight());
}
- private void assertBitmapMinSize(int minExpectedWidth, int minExpectedHeight, Drawable actual){
+ private void assertBitmapMinSize(int minExpectedWidth, int minExpectedHeight, Drawable actual) {
assertTrue(minExpectedWidth <= actual.getIntrinsicWidth());
assertTrue(minExpectedHeight <= actual.getIntrinsicHeight());
}
- private void assertBitmapEqual(Bitmap expected, Bitmap actual){
+ private void assertBitmapEqual(Bitmap expected, Bitmap actual) {
assertEquals(expected.getHeight(), actual.getHeight());
assertEquals(expected.getWidth(), actual.getWidth());
}
static final int LOCAL_SCALE_SIZE = 100;
- public void testLocalFileLoad() throws IOException, ImageCacheException {
- testClear();
+ /**
+ * Loads a file from the assets and saves it to a public location.
+ *
+ * @return
+ * @throws IOException
+ */
+ private Uri loadLocalFile() throws IOException {
final String testfile = "logo_locast.png";
final Context contextInst = getInstrumentation().getContext();
@@ -133,10 +141,10 @@ public void testLocalFileLoad() throws IOException, ImageCacheException {
assertNotNull(fos);
- int read=0;
+ int read = 0;
final byte[] bytes = new byte[1024];
- while((read = is.read(bytes))!= -1){
+ while ((read = is.read(bytes)) != -1) {
fos.write(bytes, 0, read);
}
@@ -148,6 +156,13 @@ public void testLocalFileLoad() throws IOException, ImageCacheException {
final Uri fileUri = Uri.fromFile(outFile);
assertNotNull(fileUri);
+ return fileUri;
+ }
+
+ public void testLocalFileLoad() throws IOException, ImageCacheException {
+ testClear();
+
+ final Uri fileUri = loadLocalFile();
final Drawable img = imc.getImage(fileUri, LOCAL_SCALE_SIZE, LOCAL_SCALE_SIZE);
@@ -155,16 +170,68 @@ public void testLocalFileLoad() throws IOException, ImageCacheException {
// the thumbnails produced by this aren't precisely the size we request, due to efficiencies
// in decoding the image.
- assertBitmapMaxSize(LOCAL_SCALE_SIZE*2, LOCAL_SCALE_SIZE*2, img);
+ assertBitmapMaxSize(LOCAL_SCALE_SIZE * 2, LOCAL_SCALE_SIZE * 2, img);
- assertBitmapMinSize(LOCAL_SCALE_SIZE/2, LOCAL_SCALE_SIZE/2, img);
+ assertBitmapMinSize(LOCAL_SCALE_SIZE / 2, LOCAL_SCALE_SIZE / 2, img);
}
- private final int NET_SCALE_SIZE = 100;
+ @LargeTest
+ public void testTrim() throws IOException, ImageCacheException {
+ testClear();
+
+ final Uri localFile = loadLocalFile();
+
+ imc.setAutoTrimFrequency(0);
+
+ final int maxSize = 150;
+ final int minSize = 50;
+ final int entryCount = maxSize - minSize + 1 /* includes max size */;
+
+ for (int i = minSize; i <= maxSize; i++) {
+ final Drawable img = imc.getImage(localFile, i, i);
+
+ assertNotNull(img);
+ }
+
+ assertEquals(entryCount, imc.getCacheEntryCount());
- private void testNetworkLoad(Uri uri) throws IOException, ImageCacheException{
+ // cause a cache hit on the first item.
+ imc.get(imc.getKey(localFile, minSize, minSize));
+
+ final long diskUsage = imc.getCacheDiskUsage();
+
+ assertTrue("Disk usage isn't reasonable", diskUsage > 1000 && diskUsage < 10 * 1024 * 1024);
+
+ // actual disk usage should be around 479100
+
+ final long cacheSize = 300 * 1024 /* kilo */;
+ imc.setCacheMaxSize(cacheSize);
+
+ final long trimmed = imc.trim();
+
+ assertTrue("no bytes were trimmed", trimmed > 0);
+
+ assertTrue("disk usage hasn't changed", diskUsage != imc.getCacheDiskUsage());
+
+ assertTrue("disk usage is larger than desired max size",
+ imc.getCacheDiskUsage() < cacheSize);
+
+ assertTrue("entry count wasn't reduced", imc.getCacheEntryCount() < entryCount);
+
+ // this should have the earliest access time, so it should be trimmed first
+ assertFalse("second entry wasn't trimmed",
+ imc.contains(imc.getKey(localFile, minSize + 1, minSize + 1)));
+
+ // this has the most recent creation date, so it should be trimmed last
+ assertTrue("last entry was trimmed", imc.contains(imc.getKey(localFile, maxSize, maxSize)));
+
+
+ }
+
+ private final int NET_SCALE_SIZE = 100;
+ private void testNetworkLoad(Uri uri) throws IOException, ImageCacheException {
// ensure we don't have it in the cache
final String origKey = imc.getKey(uri);
@@ -177,9 +244,9 @@ private void testNetworkLoad(Uri uri) throws IOException, ImageCacheException{
assertNotNull(img);
- assertBitmapMaxSize(NET_SCALE_SIZE*2, NET_SCALE_SIZE*2, img);
+ assertBitmapMaxSize(NET_SCALE_SIZE * 2, NET_SCALE_SIZE * 2, img);
- assertBitmapMinSize(NET_SCALE_SIZE/2, NET_SCALE_SIZE/2, img);
+ assertBitmapMinSize(NET_SCALE_SIZE / 2, NET_SCALE_SIZE / 2, img);
// ensure that it's stored in the disk cache
assertNotNull(imc.get(origKey));
@@ -193,7 +260,8 @@ public void testNetworkLoad() throws ClientProtocolException, IOException, Image
testNetworkLoad(Uri.parse("http://mobile-server.mit.edu/~stevep/logo_start_locast1.png"));
}
- public void testNetworkLoadLarge() throws ClientProtocolException, IOException, ImageCacheException {
+ public void testNetworkLoadLarge() throws ClientProtocolException, IOException,
+ ImageCacheException {
testClear();
testNetworkLoad(Uri.parse("http://mobile-server.mit.edu/~stevep/large_logo.png"));
diff --git a/test/src/edu/mit/mobile/android/imagecache/test/InteractiveDemo.java b/test/src/edu/mit/mobile/android/imagecache/test/InteractiveDemo.java
index 55e45fc..c939436 100644
--- a/test/src/edu/mit/mobile/android/imagecache/test/InteractiveDemo.java
+++ b/test/src/edu/mit/mobile/android/imagecache/test/InteractiveDemo.java
@@ -1,6 +1,7 @@
package edu.mit.mobile.android.imagecache.test;
+
/*
- * Copyright (C) 2011 MIT Mobile Experience Lab
+ * Copyright (C) 2011-2013 MIT Mobile Experience Lab
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
@@ -22,6 +23,7 @@
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Gallery;
+import android.widget.Toast;
import edu.mit.mobile.android.imagecache.ImageCache;
@SuppressWarnings("deprecation")
@@ -31,7 +33,6 @@ public class InteractiveDemo extends ListActivity {
private final TestData mTestData = new TestData();
-
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -40,6 +41,7 @@ public void onCreate(Bundle savedInstanceState) {
final Gallery gallery = (Gallery) findViewById(R.id.gallery);
mCache = ImageCache.getInstance(this);
+ mCache.setCacheMaxSize(1 * 1024 /* mega */* 1024 /* kilo */);
initData();
@@ -119,12 +121,27 @@ private void initData() {
}
+ private void trim() {
+ final long trimmed = mCache.trim();
+ Toast.makeText(this, trimmed + " byte(s) trimmed.", Toast.LENGTH_LONG).show();
+ }
+
+ private void clear() {
+ mCache.clear();
+ Toast.makeText(this, "Cache cleared.", Toast.LENGTH_LONG).show();
+ }
+
@Override
public boolean onOptionsItemSelected(MenuItem item) {
- switch (item.getItemId()){
- case R.id.clear:
- mCache.clear();
- return true;
+ switch (item.getItemId()) {
+ case R.id.clear:
+ clear();
+ return true;
+
+ case R.id.trim:
+
+ trim();
+ return true;
case R.id.grid:
startActivity(new Intent(this, ConcurrencyTest.class));