mirror of https://github.com/tasks/tasks
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.
631 lines
18 KiB
Java
631 lines
18 KiB
Java
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;
|
|
|
|
/**
|
|
* <p>
|
|
* A simple disk cache.
|
|
* </p>
|
|
*
|
|
* <p>
|
|
* 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)}.
|
|
* </p>
|
|
*
|
|
* <p>
|
|
* 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)}.
|
|
* </p>
|
|
*
|
|
* @author <a href="mailto:spomeroy@mit.edu">Steve Pomeroy</a>
|
|
*
|
|
* @param <K>
|
|
* the key to store/retrieve the value
|
|
* @param <V>
|
|
* the value that will be stored to disk
|
|
*/
|
|
public abstract class DiskCache<K, V> {
|
|
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<File> mQueue = new ConcurrentLinkedQueue<File>();
|
|
|
|
/**
|
|
* 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<Runnable>());
|
|
|
|
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<File> mLastModifiedOldestFirstComparator = new Comparator<File>() {
|
|
|
|
@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<File> 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;
|
|
|
|
}
|
|
}
|