package com.vaguehope.onosendai.images;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLConnection;
import java.util.List;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.support.v4.util.LruCache;
import com.vaguehope.onosendai.C;
import com.vaguehope.onosendai.model.MetaType;
import com.vaguehope.onosendai.model.Tweet;
import com.vaguehope.onosendai.storage.DbInterface;
import com.vaguehope.onosendai.util.ExcpetionHelper;
import com.vaguehope.onosendai.util.HashHelper;
import com.vaguehope.onosendai.util.HttpHelper.HttpStreamHandler;
import com.vaguehope.onosendai.util.IoHelper;
import com.vaguehope.onosendai.util.IoHelper.CopyProgressListener;
import com.vaguehope.onosendai.util.LogWrapper;
import com.vaguehope.onosendai.util.StringHelper;
import com.vaguehope.onosendai.util.SyncMgr;
public class HybridBitmapCache {
private static final String META_EXT = ".txt";
public interface LoadListener {
void onContentLengthToLoad (long contentLength);
void onContentLengthToFetch (long contentLength);
void onContentFetching (int bytesFetched, int contentLength);
}
private static final int BASE_HEX = 16;
static final LogWrapper LOG = new LogWrapper("HC");
private final MemoryBitmapCache<String> memCache;
private final int maxMemCacheEntrySize;
private final LruCache<String, String> failuresCache;
private final File baseDir;
private final SyncMgr syncMgr = new SyncMgr();
private final ImageLoadRequestManager reqMgr = new ImageLoadRequestManager();
public HybridBitmapCache (final Context context, final int maxMemorySizeBytes) {
this.memCache = maxMemorySizeBytes > 0 ? new MemoryBitmapCache<String>(maxMemorySizeBytes) : null;
this.failuresCache = new LruCache<String, String>(100); // TODO extract constant.
this.baseDir = getBaseDir(context);
if (!this.baseDir.exists() && !this.baseDir.mkdirs()) throw new IllegalStateException("Failed to create cache directory: " + this.baseDir.getAbsolutePath());
this.maxMemCacheEntrySize = (int) (maxMemorySizeBytes * C.MAX_MEMORY_IMAGE_CACHE_ENTRY_SIZE_RATIO);
LOG.i("in memory cache: total %s bytes, max entry %s bytes.", maxMemorySizeBytes, this.maxMemCacheEntrySize);
}
public void forget (final String key) throws IOException {
if (this.memCache != null) this.memCache.remove(key);
this.failuresCache.remove(key);
final File file = keyToFile(key);
if (file.exists() && !file.delete() && file.exists()) {
throw new IOException(String.format("Failed to rm cache file %s.", file.getAbsolutePath()));
}
}
public String getFailure(final String key) {
return this.failuresCache.get(key);
}
public Bitmap quickGet (final String key) {
if (this.memCache == null) return null;
return this.memCache.get(key);
}
/**
* If a file is returned the image is cached.
* This does NOT refresh the file time stamp.
*/
public File getCachedFile (final String key) {
if (key == null) return null;
final File file = keyToFile(key);
if (!file.exists()) return null;
return file;
}
/**
* Check if the cache has this file and mark it as just been used if so.
* Returns true if file exists.
*/
public boolean touchFileIfExists (final String key) {
final File f = getCachedFile(key);
if (f != null) {
refreshFileTimestamp(f);
return true;
}
return false;
}
/**
* @return null if image is not in cache.
* @throws UnrederableException
* if image is in cache but can not be rendered.
*/
public Bitmap get (final String key, final int reqWidth, final LoadListener listener) throws UnrederableException {
Bitmap bmp = this.memCache != null ? this.memCache.get(key) : null;
if (bmp == null) bmp = getFromDisc(key, reqWidth, listener);
return bmp;
}
public HttpStreamHandler<Bitmap> fromHttp (final String key, final int reqWidth, final LoadListener listener) {
return new DiscCacheHandler(this, key, reqWidth, listener);
}
/**
* This specifically does not return the decoded bitmap.
* It is for background prefetching.
* It always returns null;
*/
public HttpStreamHandler<?> fromHttp (final String key) {
return new DiscCacheHandler(this, key);
}
public void clean () {
if (this.memCache != null) this.memCache.evictAll();
this.failuresCache.evictAll();
this.reqMgr.clear();
}
protected Bitmap getFromDisc (final String key, final int reqWidth, final LoadListener listener) throws UnrederableException {
if (key == null) return null;
final File file = keyToFile(key);
if (!file.exists()) return null;
final long fileLength = file.length();
if (fileLength < 1) return null;
if (listener != null) listener.onContentLengthToLoad(fileLength);
final Bitmap bmp = decodeBitmap(file, reqWidth);
if (bmp == null) {
final UnrederableException unEx = new UnrederableException(file);
cacheFailureInMemory(key, unEx);
throw unEx;
}
if (this.memCache != null && MemoryBitmapCache.bmpByteCount(bmp) <= this.maxMemCacheEntrySize) this.memCache.put(key, bmp);
refreshFileTimestamp(file);
return bmp;
}
protected void cacheFailureInMemory (final String key, final Exception ex) {
this.failuresCache.put(key, ExcpetionHelper.veryShortMessage(ex));
}
protected File keyToFile (final String key) {
return new File(this.baseDir, HashHelper.md5String(key).toString(BASE_HEX));
}
protected File tempFile (final String key) throws IOException {
return File.createTempFile(HashHelper.md5String(key).toString(BASE_HEX), ".part", this.baseDir);
}
public SyncMgr getSyncMgr () {
return this.syncMgr;
}
public ImageLoadRequestManager getReqMgr () {
return this.reqMgr;
}
private static void refreshFileTimestamp (final File f) {
final long now = System.currentTimeMillis();
final long lastModified = f.lastModified();
if (lastModified != 0) {
if (now - lastModified > C.IMAGE_DISC_CACHE_TOUCH_AFTER_MILLIS && !f.setLastModified(now)) {
LOG.w("Failed to update last modified date for '%s'.", f.getAbsolutePath());
}
}
else {
LOG.w("Failed to read last modified date for '%s'.", f.getAbsolutePath());
}
}
public static void cleanCacheDir (final Context context, final DbInterface db) {
final File dir = getBaseDir(context);
if (!dir.exists()) return;
final long now = System.currentTimeMillis();
long bytesFreed = 0L;
for (final File f : dir.listFiles()) {
final boolean isMetaFile;
{
final File file = metaFileToFile(f);
if (file != null && file.exists()) continue;
isMetaFile = file != null;
}
if (now - f.lastModified() > C.IMAGE_DISC_CACHE_EXPIRY_MILLIS) {
if(!isMetaFile && isImageInUse(f, db)) {
refreshFileTimestamp(f);
continue;
}
final long fLength = f.length();
if (f.delete()) {
bytesFreed += fLength;
}
else {
LOG.w("Failed to delete expired file: '%s'.", f.getAbsolutePath());
}
}
}
LOG.i("Freed %s bytes of cached image files.", bytesFreed);
}
private static boolean isImageInUse (final File file, final DbInterface db) {
final String key = readKeyFromMetaFile(file);
if (!StringHelper.isEmpty(key)) {
final List<Tweet> metas = db.findTweetsWithMeta(MetaType.MEDIA, key, 1);
if (metas != null && metas.size() > 0) return true;
final List<Tweet> tweets = db.findTweetsWithAvatarUrl(key, 1);
if (tweets != null && tweets.size() > 0) return true;
}
return false;
}
private static File getBaseDir (final Context context) {
return new File(context.getCacheDir(), "images");
}
private static Bitmap decodeBitmap (final File file, final int reqWidth) {
if (reqWidth < 1) return BitmapFactory.decodeFile(file.getAbsolutePath());
final BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true;
BitmapFactory.decodeFile(file.getAbsolutePath(), opts);
final int srcWidth = opts.outWidth;
final int srcHeight = opts.outHeight;
opts.inSampleSize = calculateInSampleSize(srcWidth, srcHeight, reqWidth, C.MAX_IMAGE_SIZE_BYTES);
//LOG.i("Decoding '%s' reqWidth=%s srcWidth=%s sampleSize=%s.", file.getAbsolutePath(), reqWidth, srcWidth, opts.inSampleSize);
opts.inJustDecodeBounds = false;
try {
return BitmapFactory.decodeFile(file.getAbsolutePath(), opts);
}
catch (final OutOfMemoryError e) {
final int oldSampleSize = opts.inSampleSize;
opts.inSampleSize *= 2;
LOG.w("OOM decoding '%s' reqWidth=%s srcWidth=%s sampleSize=%s, retrying with sampleSize=%s...",
file.getAbsolutePath(), reqWidth, srcWidth, oldSampleSize, opts.inSampleSize);
return BitmapFactory.decodeFile(file.getAbsolutePath(), opts);
}
}
private static int calculateInSampleSize (final int srcWidth, final int srcHeight, final int reqWidth, final int maxSize) {
int inSampleSize = 1;
if (srcWidth > reqWidth || estimateSize(srcWidth, srcHeight, inSampleSize) > maxSize) {
while ((srcWidth / 2) / inSampleSize >= reqWidth || estimateSize(srcWidth, srcHeight, inSampleSize) > maxSize) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
private static int estimateSize (final int w, final int h, final int inSampleSize) {
return (w * h * 8) / inSampleSize;
}
private static File fileToMetaFile (final File file) {
if (file == null) return null;
return new File(file.getAbsolutePath() + META_EXT);
}
/**
* Returns null if not a meta file.
*/
private static File metaFileToFile (final File metaFile) {
if (metaFile == null) return null;
final String path = metaFile.getAbsolutePath();
if (!path.endsWith(META_EXT)) return null;
return new File(path.substring(0, path.length() - META_EXT.length()));
}
protected static void writeMetaFile(final File file, final String key) throws IOException {
final File metaFile = fileToMetaFile(file);
IoHelper.stringToFile(key, metaFile);
}
public static String readKeyFromMetaFileName(final Context context, final String fileName) {
final File file = new File(getBaseDir(context), fileName);
return readKeyFromMetaFile(file);
}
/**
* Return null if not found or read fails.
*/
private static String readKeyFromMetaFile(final File file) {
final File metaFile = fileToMetaFile(file);
if (!metaFile.exists()) return null;
try {
final String meta = IoHelper.fileToString(metaFile);
return StringHelper.firstLine(meta);
}
catch (final IOException e) {
LOG.w("Failed to read meta file for %s: %s", file, ExcpetionHelper.causeTrace(e));
return null;
}
}
private static class DiscCacheHandler implements HttpStreamHandler<Bitmap> {
private final HybridBitmapCache cache;
private final String key;
private final int reqWidth;
private final LoadListener listener;
private boolean decodeBitmap;
public DiscCacheHandler (final HybridBitmapCache cache, final String key) {
this(cache, key, 0, null);
this.decodeBitmap = false;
}
public DiscCacheHandler (final HybridBitmapCache cache, final String key, final int reqWidth, final LoadListener listener) {
this.cache = cache;
this.key = key;
this.reqWidth = reqWidth;
this.listener = listener;
this.decodeBitmap = true;
}
@Override
public void onError (final Exception e) {
this.cache.cacheFailureInMemory(this.key, e);
}
@Override
public Bitmap handleStream (final URLConnection connection, final InputStream is, final int contentLength) throws IOException {
if (this.listener != null) this.listener.onContentLengthToFetch(contentLength);
final File tmpFile = this.cache.tempFile(this.key);
final OutputStream os = new FileOutputStream(tmpFile);
try {
final long bytesCopied;
if (this.listener != null) {
bytesCopied = IoHelper.copyWithProgress(is, os, new FetchProgressListener(contentLength, this.listener));
}
else {
bytesCopied = IoHelper.copy(is, os);
}
if (bytesCopied < 1L) throw new IOException(String.format("%s bytes returned.", bytesCopied));
}
catch (final IOException e) {
if (!tmpFile.delete()) LOG.e("Failed to delete incomplete download '" + tmpFile.getAbsolutePath() + "': " + e.toString());
throw e;
}
finally {
os.close();
}
final File file = this.cache.keyToFile(this.key);
if (!tmpFile.renameTo(file)) throw new IOException(String.format("Failed to mv tmp file %s to %s.", tmpFile.getAbsolutePath(), file.getAbsolutePath()));
writeMetaFile(file, this.key);
if (this.decodeBitmap) return this.cache.getFromDisc(this.key, this.reqWidth, this.listener);
return null;
}
}
private static class FetchProgressListener implements CopyProgressListener {
private final int contentLength;
private final int updateStep;
private final LoadListener listener;
private int lastUpdateBytesCopied = 0;
public FetchProgressListener (final int contentLength, final LoadListener listener) {
this.contentLength = contentLength;
this.updateStep = Math.max(contentLength / 100, 10240);
this.listener = listener;
}
@Override
public void onCopyProgress (final int bytesCopied) {
if (bytesCopied - this.lastUpdateBytesCopied >= this.updateStep) {
this.lastUpdateBytesCopied = bytesCopied;
this.listener.onContentFetching(bytesCopied, this.contentLength);
}
}
}
}