/* Copyright (c) 2009 Matthias Kaeppler
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.droidfu.cachefu;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Collection;
import java.util.Date;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import android.content.Context;
import android.os.Environment;
import android.util.Log;
import com.github.droidfu.com.google.common.collect.MapMaker;
import com.github.droidfu.support.StringSupport;
/**
* <p>
* A simple 2-level cache consisting of a small and fast in-memory cache (1st level cache) and an
* (optional) slower but bigger disk cache (2nd level cache). For disk caching, either the
* application's cache directory or the SD card can be used. Please note that in the case of the app
* cache dir, Android may at any point decide to wipe that entire directory if it runs low on
* internal storage. The SD card cache <i>must</i> be managed by the application, e.g. by calling
* {@link #wipe} whenever the app quits.
* </p>
* <p>
* When pulling from the cache, it will first attempt to load the data from memory. If that fails,
* it will try to load it from disk (assuming disk caching is enabled). If that succeeds, the data
* will be put in the in-memory cache and returned (read-through). Otherwise it's a cache miss.
* </p>
* <p>
* Pushes to the cache are always write-through (i.e. the data will be stored both on disk, if disk
* caching is enabled, and in memory).
* </p>
*
* @author Matthias Kaeppler
*/
public abstract class AbstractCache<KeyT, ValT> implements Map<KeyT, ValT> {
public static final int DISK_CACHE_INTERNAL = 0;
public static final int DISK_CACHE_SDCARD = 1;
private static final String LOG_TAG = "MyCC98[CacheFu]";
private boolean isDiskCacheEnabled;
protected String diskCacheDirectory;
private ConcurrentMap<KeyT, ValT> cache;
private String name;
private long expirationInMinutes;
/**
* Creates a new cache instance.
*
* @param name
* a human readable identifier for this cache. Note that this value will be used to
* derive a directory name if the disk cache is enabled, so don't get too creative
* here (camel case names work great)
* @param initialCapacity
* the initial element size of the cache
* @param expirationInMinutes
* time in minutes after which elements will be purged from the cache
* @param maxConcurrentThreads
* how many threads you think may at once access the cache; this need not be an exact
* number, but it helps in fragmenting the cache properly
*/
public AbstractCache(String name, int initialCapacity, long expirationInMinutes,
int maxConcurrentThreads) {
this.name = name;
this.expirationInMinutes = expirationInMinutes;
MapMaker mapMaker = new MapMaker();
mapMaker.initialCapacity(initialCapacity);
mapMaker.expiration(expirationInMinutes * 60, TimeUnit.SECONDS);
mapMaker.concurrencyLevel(maxConcurrentThreads);
mapMaker.softValues();
this.cache = mapMaker.makeMap();
}
/**
* Sanitize disk cache. Remove files which are older than expirationInMinutes.
*/
private void sanitizeDiskCache() {
File[] cachedFiles = new File(diskCacheDirectory).listFiles();
if (cachedFiles == null) {
return;
}
for (File f : cachedFiles) {
// if file older than expirationInMinutes, remove it
long lastModified = f.lastModified();
Date now = new Date();
long ageInMinutes = ((now.getTime() - lastModified) / (1000*60));
if (ageInMinutes >= expirationInMinutes) {
Log.d(name, "DISK cache expiration for file " + f.toString());
f.delete();
}
}
}
/**
* Enable caching to the phone's internal storage or SD card.
*
* @param context
* the current context
* @param storageDevice
* where to store the cached files, either {@link #DISK_CACHE_INTERNAL} or
* {@link #DISK_CACHE_SDCARD})
* @return
*/
public boolean enableDiskCache(Context context, int storageDevice) {
Context appContext = context.getApplicationContext();
String rootDir = null;
if (storageDevice == DISK_CACHE_SDCARD
&& Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState())) {
// SD-card available
rootDir = Environment.getExternalStorageDirectory().getAbsolutePath() + "/Android/data/"
+ appContext.getPackageName() + "/cache";
} else {
File internalCacheDir = appContext.getCacheDir();
// apparently on some configurations this can come back as null
if (internalCacheDir == null) {
return (isDiskCacheEnabled = false);
}
rootDir = internalCacheDir.getAbsolutePath();
}
setRootDir(rootDir);
File outFile = new File(diskCacheDirectory);
if (outFile.mkdirs()) {
File nomedia = new File(diskCacheDirectory, ".nomedia");
try {
nomedia.createNewFile();
} catch (IOException e) {
Log.e(LOG_TAG, "Failed creating .nomedia file");
}
}
isDiskCacheEnabled = outFile.exists();
if (!isDiskCacheEnabled) {
Log.w(LOG_TAG, "Failed creating disk cache directory " + diskCacheDirectory);
} else {
Log.d(name, "enabled write through to " + diskCacheDirectory);
// sanitize disk cache
Log.d(name, "sanitize DISK cache");
sanitizeDiskCache();
}
return isDiskCacheEnabled;
}
private void setRootDir(String rootDir) {
this.diskCacheDirectory = rootDir + "/cachefu/"
+ StringSupport.underscore(name.replaceAll("\\s", ""));
}
/**
* Only meaningful if disk caching is enabled. See {@link #enableDiskCache}.
*
* @return the full absolute path to the directory where files are cached, if the disk cache is
* enabled, otherwise null
*/
public String getDiskCacheDirectory() {
return diskCacheDirectory;
}
/**
* Only meaningful if disk caching is enabled. See {@link #enableDiskCache}. Turns a cache key
* into the file name that will be used to persist the value to disk. Subclasses must implement
* this.
*
* @param key
* the cache key
* @return the file name
*/
public abstract String getFileNameForKey(KeyT key);
/**
* Only meaningful if disk caching is enabled. See {@link #enableDiskCache}. Restores a value
* previously persisted to the disk cache.
*
* @param file
* the file holding the cached value
* @return the cached value
* @throws IOException
*/
protected abstract ValT readValueFromDisk(File file) throws IOException;
/**
* Only meaningful if disk caching is enabled. See {@link #enableDiskCache}. Persists a value to
* the disk cache.
*
* @param ostream
* the file output stream (buffered).
* @param value
* the cache value to persist
* @throws IOException
*/
protected abstract void writeValueToDisk(File file, ValT value) throws IOException;
private void cacheToDisk(KeyT key, ValT value) {
File file = new File(diskCacheDirectory + "/" + getFileNameForKey(key));
try {
file.createNewFile();
//file.deleteOnExit();
writeValueToDisk(file, value);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
private File getFileForKey(KeyT key) {
return new File(diskCacheDirectory + "/" + getFileNameForKey(key));
}
/**
* Reads a value from the cache by probing the in-memory cache, and if enabled and the in-memory
* probe was a miss, the disk cache.
*
* @param elementKey
* the cache key
* @return the cached value, or null if element was not cached
*/
@SuppressWarnings("unchecked")
public synchronized ValT get(Object elementKey) {
KeyT key = (KeyT) elementKey;
ValT value = cache.get(key);
if (value != null) {
// memory hit
Log.d(name, "MEM cache hit for " + key.toString());
return value;
}
// memory miss, try reading from disk
File file = getFileForKey(key);
if (file.exists()) {
// if file older than expirationInMinutes, remove it
long lastModified = file.lastModified();
Date now = new Date();
long ageInMinutes = ((now.getTime() - lastModified) / (1000*60));
if (ageInMinutes >= expirationInMinutes) {
Log.d(name, "DISK cache expiration for file " + file.toString());
file.delete();
return null;
}
// disk hit
Log.d(name, "DISK cache hit for " + key.toString());
try {
value = readValueFromDisk(file);
} catch (IOException e) {
// treat decoding errors as a cache miss
e.printStackTrace();
return null;
}
if (value == null) {
return null;
}
cache.put(key, value);
return value;
}
// cache miss
return null;
}
/**
* Writes an element to the cache. NOTE: If disk caching is enabled, this will write through to
* the disk, which may introduce a performance penalty.
*/
public synchronized ValT put(KeyT key, ValT value) {
if (isDiskCacheEnabled) {
cacheToDisk(key, value);
}
return cache.put(key, value);
}
public synchronized void putAll(Map<? extends KeyT, ? extends ValT> t) {
throw new UnsupportedOperationException();
}
/**
* Checks if a value is present in the cache. If the disk cached is enabled, this will also
* check whether the value has been persisted to disk.
*
* @param key
* the cache key
* @return true if the value is cached in memory or on disk, false otherwise
*/
@SuppressWarnings("unchecked")
public synchronized boolean containsKey(Object key) {
return cache.containsKey(key) || (isDiskCacheEnabled && getFileForKey((KeyT) key).exists());
}
/**
* Checks if a value is present in the in-memory cache. This method ignores the disk cache.
*
* @param key
* the cache key
* @return true if the value is currently hold in memory, false otherwise
*/
public synchronized boolean containsKeyInMemory(Object key) {
return cache.containsKey(key);
}
/**
* Checks if the given value is currently hold in memory.
*/
public synchronized boolean containsValue(Object value) {
return cache.containsValue(value);
}
@SuppressWarnings("unchecked")
public synchronized ValT remove(Object key) {
ValT value = removeKey(key);
if (isDiskCacheEnabled) {
File cachedValue = getFileForKey((KeyT) key);
if (cachedValue.exists()) {
cachedValue.delete();
}
}
return value;
}
// Forced key expiration
public ValT removeKey(Object key) {
return cache.remove(key);
}
public Set<KeyT> keySet() {
return cache.keySet();
}
public Set<Map.Entry<KeyT, ValT>> entrySet() {
return cache.entrySet();
}
public synchronized int size() {
return cache.size();
}
public synchronized boolean isEmpty() {
return cache.isEmpty();
}
public boolean isDiskCacheEnabled() {
return isDiskCacheEnabled;
}
/**
*
* @param rootDir
* a folder name to enable caching or null to disable it.
*/
public void setDiskCacheEnabled(String rootDir) {
if (rootDir != null && rootDir.length() > 0) {
setRootDir(rootDir);
this.isDiskCacheEnabled = true;
} else {
this.isDiskCacheEnabled = false;
}
}
public synchronized void clear() {
cache.clear();
if (isDiskCacheEnabled) {
File[] cachedFiles = new File(diskCacheDirectory).listFiles();
if (cachedFiles == null) {
return;
}
for (File f : cachedFiles) {
f.delete();
}
}
Log.d(LOG_TAG, "Cache cleared");
}
public Collection<ValT> values() {
return cache.values();
}
}