/*
* Copyright 2013-2015 The GDG Frisbee Project
*
* 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 org.gdg.frisbee.android.cache;
import android.app.ActivityManager;
import android.content.Context;
import android.os.AsyncTask;
import android.os.Looper;
import android.os.Process;
import android.support.annotation.Nullable;
import android.support.v4.util.LruCache;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.jakewharton.disklrucache.DiskLruCache;
import org.joda.time.DateTime;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
import timber.log.Timber;
public final class ModelCache {
public static final String KEY_CHAPTER_LIST_HUB = "chapter_list_hub";
public static final String KEY_PULSE_GLOBAL = "pulse_global";
public static final String KEY_PULSE = "pulse_";
public static final String KEY_GDE_LIST = "gde_list";
public static final String KEY_FRISBEE_CONTRIBUTORS = "frisbee_contributor_list";
public static final String KEY_PERSON = "person2_";
public static final String KEY_NEWS = "news2_";
static final int DISK_CACHE_FLUSH_DELAY_SECS = 5;
private Gson mGson;
private LruCache<String, CacheItem> mMemoryCache;
private DiskLruCache mDiskCache;
// Variables which are only used when the Disk Cache is enabled
private HashMap<String, ReentrantLock> mDiskCacheEditLocks;
private ScheduledThreadPoolExecutor mDiskCacheFlusherExecutor;
private DiskCacheFlushRunnable mDiskCacheFlusherRunnable;
// Transient
private ScheduledFuture<?> mDiskCacheFuture;
ModelCache() {
mGson = new GsonBuilder()
.registerTypeAdapter(DateTime.class, new ModelCacheDateTimeDeserializer())
.registerTypeAdapter(DateTime.class, new ModelCacheDateTimeSerializer())
.create();
}
private static String transformUrlForDiskCacheKey(String url) {
try {
// Create MD5 Hash
MessageDigest digest = java.security.MessageDigest.getInstance("MD5");
digest.update(url.getBytes());
byte[] messageDigest = digest.digest();
// Create Hex String
StringBuilder hexString = new StringBuilder();
for (int i = 0; i < messageDigest.length; i++) {
hexString.append(Integer.toHexString(0xFF & messageDigest[i]));
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return "";
}
public boolean contains(String url) {
return containsInMemoryCache(url) || containsInDiskCache(url);
}
public boolean containsInDiskCache(String url) {
if (null != mDiskCache) {
checkNotOnMainThread();
try {
return null != mDiskCache.get(transformUrlForDiskCacheKey(url));
} catch (IOException e) {
e.printStackTrace();
}
}
return false;
}
public boolean containsInMemoryCache(String url) {
return null != mMemoryCache && null != mMemoryCache.get(url);
}
public void getAsync(final String url, final CacheListener mListener) {
getAsync(url, true, mListener);
}
public void getAsync(final String url, final boolean checkExpiration, final CacheListener mListener) {
new GetAsyncTask(ModelCache.this, url, checkExpiration, mListener).execute();
}
@Nullable
public Object get(String url) {
return get(url, true);
}
@Nullable
public <T> T get(String url, boolean checkExpiration) {
Timber.d("get(%s)", url);
CacheItem result;
// First try Memory Cache
result = getFromMemoryCache(url, checkExpiration);
if (null == result) {
// Memory Cache failed, so try Disk Cache
result = getFromDiskCache(url, checkExpiration);
}
if (result != null) {
//noinspection unchecked
return (T) result.getValue();
} else {
return null;
}
}
@Nullable
private CacheItem getFromDiskCache(final String url, boolean checkExpiration) {
CacheItem result = null;
if (mDiskCache == null) {
return null;
}
checkNotOnMainThread();
try {
final String key = transformUrlForDiskCacheKey(url);
DiskLruCache.Snapshot snapshot = mDiskCache.get(key);
if (null != snapshot) {
Object value = readValueFromDisk(snapshot.getInputStream(0));
DateTime expiresAt = new DateTime(readExpirationFromDisk(snapshot.getInputStream(1)));
if (value != null) {
if (checkExpiration && expiresAt.isBeforeNow()) {
mDiskCache.remove(key);
scheduleDiskCacheFlush();
} else {
result = new CacheItem(value, expiresAt);
if (null != mMemoryCache) {
mMemoryCache.put(url, result);
}
}
} else {
// If we get here, the file in the cache can't be
// decoded. Remove it and schedule a flush.
mDiskCache.remove(key);
scheduleDiskCacheFlush();
}
}
} catch (Exception e) {
Timber.e(e, "getFromDiskCache failed for key: %s", url);
remove(url);
}
return result;
}
@Nullable
private CacheItem getFromMemoryCache(final String url, boolean checkExpiration) {
CacheItem result = null;
if (null != mMemoryCache) {
synchronized (mMemoryCache) {
result = mMemoryCache.get(url);
// If we get a value, that has expired
if (null != result && result.getExpiresAt().isBeforeNow() && checkExpiration) {
mMemoryCache.remove(url);
result = null;
}
}
}
return result;
}
public boolean isDiskCacheEnabled() {
return null != mDiskCache;
}
public boolean isMemoryCacheEnabled() {
return null != mMemoryCache;
}
public void putAsync(String url,
Object obj,
DateTime expiresAt) {
putAsync(url, obj, expiresAt, null);
}
public void putAsync(String url,
Object obj,
DateTime expiresAt,
@Nullable CachePutListener onDoneListener) {
new PutAsyncTask(ModelCache.this, url, obj, expiresAt, onDoneListener).execute();
}
CacheItem put(final String url, final Object obj, DateTime expiresAt) {
if (obj == null) {
return null;
}
Timber.d("put(%s)", url);
CacheItem d = new CacheItem(obj, expiresAt);
if (null != mMemoryCache) {
mMemoryCache.put(url, d);
}
if (null != mDiskCache) {
checkNotOnMainThread();
final String key = transformUrlForDiskCacheKey(url);
final ReentrantLock lock = getLockForDiskCacheEdit(key);
lock.lock();
try {
DiskLruCache.Editor editor = mDiskCache.edit(key);
writeValueToDisk(editor.newOutputStream(0), obj);
writeExpirationToDisk(editor.newOutputStream(1), expiresAt);
editor.commit();
} catch (Exception e) {
Timber.e(e, "Error while putting %s with key:%s to ModelCache", obj.toString(), url);
} finally {
lock.unlock();
scheduleDiskCacheFlush();
}
}
return d;
}
public void removeAsync(final String url) {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... voids) {
remove(url);
return null;
}
}.execute();
}
void remove(String url) {
if (null != mMemoryCache) {
mMemoryCache.remove(url);
}
if (null != mDiskCache) {
checkNotOnMainThread();
try {
mDiskCache.remove(transformUrlForDiskCacheKey(url));
scheduleDiskCacheFlush();
} catch (Exception e) {
Timber.e(e, "Error while removing key: %s", url);
}
}
}
synchronized void setDiskCache(DiskLruCache diskCache) {
mDiskCache = diskCache;
if (null != diskCache) {
mDiskCacheEditLocks = new HashMap<>();
mDiskCacheFlusherExecutor = new ScheduledThreadPoolExecutor(1);
mDiskCacheFlusherRunnable = new DiskCacheFlushRunnable(diskCache);
}
}
void setMemoryCache(LruCache<String, CacheItem> memoryCache) {
mMemoryCache = memoryCache;
}
private ReentrantLock getLockForDiskCacheEdit(String url) {
synchronized (mDiskCacheEditLocks) {
ReentrantLock lock = mDiskCacheEditLocks.get(url);
if (null == lock) {
lock = new ReentrantLock();
mDiskCacheEditLocks.put(url, lock);
}
return lock;
}
}
private void scheduleDiskCacheFlush() {
// If we already have a flush scheduled, cancel it
if (null != mDiskCacheFuture) {
mDiskCacheFuture.cancel(false);
}
// Schedule a flush
mDiskCacheFuture = mDiskCacheFlusherExecutor
.schedule(mDiskCacheFlusherRunnable, DISK_CACHE_FLUSH_DELAY_SECS,
TimeUnit.SECONDS);
}
private void writeExpirationToDisk(OutputStream os, DateTime expiresAt) throws IOException {
DataOutputStream out = new DataOutputStream(os);
out.writeLong(expiresAt.getMillis());
out.close();
}
private void writeValueToDisk(OutputStream os, Object o) throws IOException {
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(os));
String className = o.getClass().getCanonicalName();
out.write(className + "\n");
String json = mGson.toJson(o);
out.write(json);
out.close();
}
private long readExpirationFromDisk(InputStream is) throws IOException {
DataInputStream din = new DataInputStream(is);
long expiration = din.readLong();
din.close();
return expiration;
}
private Object readValueFromDisk(InputStream is) throws IOException, ClassNotFoundException {
BufferedReader fss = new BufferedReader(new InputStreamReader(is, "UTF-8"));
String className = fss.readLine();
if (className == null) {
return null;
}
String line;
String content = "";
while ((line = fss.readLine()) != null) {
content += line;
}
fss.close();
Class<?> clazz = Class.forName(className);
return mGson.fromJson(content, clazz);
}
public interface CachePutListener {
void onPutIntoCache();
}
public interface CacheListener {
void onGet(Object item);
void onNotFound(String key);
}
public static class Builder {
static final int MEGABYTE = 1024 * 1024;
static final int DEFAULT_DISK_CACHE_MAX_SIZE_MB = 10;
private boolean mDiskCacheEnabled;
private File mDiskCacheLocation;
private long mDiskCacheMaxSize;
private boolean mMemoryCacheEnabled;
private int mMemoryCacheMaxSize;
public Builder(Context context) {
// Disk Cache is disabled by default, but it's default size is set
mDiskCacheMaxSize = DEFAULT_DISK_CACHE_MAX_SIZE_MB * MEGABYTE;
// Memory Cache is enabled by default, with a small maximum size
mMemoryCacheEnabled = true;
mMemoryCacheMaxSize = calculateMemoryCacheSize(context);
}
static int calculateMemoryCacheSize(Context context) {
ActivityManager am = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
int memoryClass = am.getMemoryClass();
// Target ~6% of the available heap.
return MEGABYTE * memoryClass / 16;
}
private static long getHeapSize() {
return Runtime.getRuntime().maxMemory();
}
public ModelCache build() {
final ModelCache cache = new ModelCache();
if (isValidOptionsForMemoryCache()) {
cache.setMemoryCache(new LruCache<String, CacheItem>(mMemoryCacheMaxSize));
}
if (isValidOptionsForDiskCache()) {
new AsyncTask<Void, Void, DiskLruCache>() {
@Override
protected DiskLruCache doInBackground(Void... params) {
try {
DiskLruCache c = DiskLruCache.open(mDiskCacheLocation, 0, 2, mDiskCacheMaxSize);
cache.setDiskCache(c);
return c;
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
@Override
protected void onPostExecute(DiskLruCache result) {
//cache.setDiskCache(result);
}
}.execute();
}
return cache;
}
/**
* Set whether the Disk Cache should be enabled. Defaults to {@code false}.
*
* @return This Builder object to allow for chaining of calls to set methods.
*/
public Builder setDiskCacheEnabled(boolean enabled) {
mDiskCacheEnabled = enabled;
return this;
}
/**
* Set the Disk Cache location. This location should be read-writeable.
*
* @return This Builder object to allow for chaining of calls to set methods.
*/
public Builder setDiskCacheLocation(File location) {
mDiskCacheLocation = location;
return this;
}
/**
* Set the maximum number of bytes the Disk Cache should use to store values. Defaults to
* {@value #DEFAULT_DISK_CACHE_MAX_SIZE_MB}MB.
*
* @return This Builder object to allow for chaining of calls to set methods.
*/
public Builder setDiskCacheMaxSize(long maxSize) {
mDiskCacheMaxSize = maxSize;
return this;
}
/**
* Set whether the Memory Cache should be enabled. Defaults to {@code true}.
*
* @return This Builder object to allow for chaining of calls to set methods.
*/
public Builder setMemoryCacheEnabled(boolean enabled) {
mMemoryCacheEnabled = enabled;
return this;
}
/**
* Set the maximum number of bytes the Memory Cache should use to store values.
* Defaults to 1/16 of the available memory.
*
* @return This Builder object to allow for chaining of calls to set methods.
*/
public Builder setMemoryCacheMaxSize(int size) {
mMemoryCacheMaxSize = size;
return this;
}
private boolean isValidOptionsForDiskCache() {
if (mDiskCacheEnabled) {
if (null == mDiskCacheLocation) {
return false;
} else if (!mDiskCacheLocation.canWrite()) {
Timber.i("Disk Cache Location is not write-able, disabling disk caching.");
return false;
}
return true;
}
return false;
}
private boolean isValidOptionsForMemoryCache() {
return mMemoryCacheEnabled && mMemoryCacheMaxSize > 0;
}
}
static final class DiskCacheFlushRunnable implements Runnable {
private final DiskLruCache mDiskCache;
public DiskCacheFlushRunnable(DiskLruCache cache) {
mDiskCache = cache;
}
public void run() {
// Make sure we're running with a background priority
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
try {
mDiskCache.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static class GetAsyncTask extends AsyncTask<Void, Void, Object> {
private ModelCache modelCache;
private CacheListener listener;
private String key;
private boolean checkExpiration;
public GetAsyncTask(ModelCache modelCache, String key, boolean checkExpiration, CacheListener listener) {
this.modelCache = modelCache;
this.key = key;
this.checkExpiration = checkExpiration;
this.listener = listener;
}
@Override
protected Object doInBackground(Void... voids) {
if (modelCache != null) {
return modelCache.get(key, checkExpiration);
}
return null;
}
@Override
protected void onPostExecute(Object o) {
if (listener == null) {
return;
}
if (o != null) {
listener.onGet(o);
} else {
listener.onNotFound(key);
}
}
}
public static class PutAsyncTask extends AsyncTask<Void, Void, Object> {
private ModelCache modelCache;
private CachePutListener onDoneListener;
private String key;
private Object obj;
private DateTime expiresAt;
public PutAsyncTask(ModelCache modelCache, String key, Object obj,
DateTime expiresAt, @Nullable CachePutListener onDoneListener) {
this.modelCache = modelCache;
this.key = key;
this.obj = obj;
this.expiresAt = expiresAt;
this.onDoneListener = onDoneListener;
}
@Override
protected Object doInBackground(Void... voids) {
if (modelCache != null) {
return modelCache.put(key, obj, expiresAt);
} else {
return null;
}
}
@Override
protected void onPostExecute(Object o) {
if (onDoneListener != null) {
onDoneListener.onPutIntoCache();
}
}
}
public class CacheItem {
private Object mValue;
private DateTime mExpiresAt;
public CacheItem(Object value, DateTime expiresAt) {
mValue = value;
mExpiresAt = expiresAt;
}
public DateTime getExpiresAt() {
return mExpiresAt;
}
public void setExpiresAt(DateTime mExpiresAt) {
this.mExpiresAt = mExpiresAt;
}
public Object getValue() {
return mValue;
}
public void setValue(Object mValue) {
this.mValue = mValue;
}
}
private static void checkNotOnMainThread() {
if (Looper.myLooper() == Looper.getMainLooper()) {
throw new IllegalStateException(
"This method should not be called from the main/UI thread.");
}
}
}