package edu.mit.mobile.android.imagecache;
/*
* 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
* 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.FileFilter;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
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;
import com.todoroo.astrid.utility.Constants;
/**
*
* 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
*
* @param
* the key to store/retrieve the value
* @param
* the value that will be stored to disk
*/
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
*
* @param cacheBase
*/
public DiskCache(File cacheBase) {
this(cacheBase, null, null);
}
/**
* Creates a new disk cache.
*
* @param cacheBase
* The base directory within which all the cache files will be stored.
* @param cachePrefix
* If you want a prefix to the filenames, place one here. Otherwise, pass null.
* @param cacheSuffix
* A suffix to the cache filename. Null is also ok here.
*/
public DiskCache(File cacheBase, String cachePrefix, String cacheSuffix) {
mCacheBase = cacheBase;
mCachePrefix = cachePrefix;
mCacheSuffix = cacheSuffix;
try {
hash = MessageDigest.getInstance("SHA-1");
} catch (final NoSuchAlgorithmException e) {
try {
hash = MessageDigest.getInstance("MD5");
} catch (final NoSuchAlgorithmException e2) {
final RuntimeException re = new RuntimeException("No available hashing algorithm");
re.initCause(e2);
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();
}
}
/**
* Gets the cache filename for the given key.
*
* @param key
* @return
*/
protected File getFile(K key) {
return new File(mCacheBase, (mCachePrefix != null ? mCachePrefix : "") + hash(key)
+ (mCacheSuffix != null ? mCacheSuffix : ""));
}
/**
* Writes the value stored in the cache to disk by calling
* {@link #toDisk(Object, Object, OutputStream)}.
*
* @param key
* The key to find the value.
* @param value
* the data to be written to disk.
*/
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();
}
/**
* Writes the contents of the InputStream straight to disk. It is the caller's responsibility to
* ensure it's the same type as what would be written with
* {@link #toDisk(Object, Object, OutputStream)}
*
* @param key
* @param value
* @throws IOException
* @throws FileNotFoundException
*/
public final void putRaw(K key, InputStream value) throws IOException, FileNotFoundException {
final File saveHere = getFile(key);
final File tempFile = new File(saveHere.getAbsolutePath() + ".temp");
boolean allGood = false;
try {
final OutputStream os = new FileOutputStream(tempFile);
inputStreamToOutputStream(value, os);
os.close();
synchronized (this) {
// overwrite
saveHere.delete();
tempFile.renameTo(saveHere);
}
allGood = true;
} finally {
// clean up on any exception
if (!allGood) {
saveHere.delete();
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();
}
/**
* Reads from an inputstream, dumps to an outputstream
*
* @param is
* @param os
* @throws IOException
*/
static public void inputStreamToOutputStream(InputStream is, OutputStream os)
throws IOException {
final int bufsize = 8196 * 10;
final byte[] cbuf = new byte[bufsize];
for (int readBytes = is.read(cbuf, 0, bufsize); readBytes > 0; readBytes = is.read(cbuf, 0,
bufsize)) {
os.write(cbuf, 0, readBytes);
}
}
/**
* Reads the value from disk using {@link #fromDisk(Object, InputStream)}.
*
* @param key
* @return The value for key or null if the key doesn't map to any existing entries.
*/
public final synchronized V get(K key) throws IOException {
final File readFrom = getFile(key);
if (!readFrom.exists()) {
return null;
}
final InputStream is = new FileInputStream(readFrom);
final V out = fromDisk(key, is);
is.close();
touchEntry(readFrom);
return out;
}
/**
* Checks the disk cache for a given key.
*
* @param key
* @return true if the disk cache contains the given key
*/
public final synchronized boolean contains(K key) {
final File readFrom = getFile(key);
return readFrom.exists();
}
/**
* Removes the item from the disk cache.
*
* @param key
* @return true if the cached item has been removed or was already removed, false if it was not
* able to be removed.
*/
public synchronized boolean clear(K key) {
final File readFrom = getFile(key);
if (!readFrom.exists()) {
return true;
}
final long size = readFrom.length();
final boolean success = readFrom.delete();
if (success) {
mEstimatedDiskUsage -= size;
}
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;
}
/**
* Clears the cache files from disk.
*
* Note: this only clears files that match the given prefix/suffix.
*
* @return true if the operation succeeded without error. It is possible that it will fail and
* the cache ends up being partially cleared.
*/
public synchronized boolean clear() {
boolean success = true;
for (final File cacheFile : mCacheBase.listFiles(mCacheFileFilter)) {
if (!cacheFile.delete()) {
Log.e(TAG, "error deleting " + cacheFile);
success = false;
}
}
return success;
}
/**
* @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 {
@Override
public boolean accept(File pathname) {
final String path = pathname.getName();
return (mCachePrefix != null ? path.startsWith(mCachePrefix) : true)
&& (mCacheSuffix != null ? path.endsWith(mCacheSuffix) : true);
}
};
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 (Constants.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 (Constants.DEBUG) {
Log.d(TAG, "trimmed " + cacheFile.getName() + " from cache.");
}
} else {
Log.e(TAG, "error deleting " + cacheFile);
}
}
if (Constants.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.
*
* @param key
* @param in
* @param out
*/
protected abstract void toDisk(K key, V in, OutputStream out);
/**
* Implement this to do the actual disk reading.
*
* @param key
* @param in
* @return a new instance of {@link V} containing the contents of in.
*/
protected abstract V fromDisk(K key, InputStream in);
/**
* Using the key's {@link Object#toString() toString()} method, generates a string suitable for
* using as a filename.
*
* @param key
* @return a string uniquely representing the the key.
*/
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();
}
final BigInteger bi = new BigInteger(1, ba);
final String result = bi.toString(16);
if (result.length() % 2 != 0) {
return "0" + result;
}
return result;
}
}