/*
* Copyright (C) 2014 Fastboot Mobile, LLC.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of
* the License, or (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program;
* if not, see <http://www.gnu.org/licenses>.
*/
package com.fastbootmobile.encore.art;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.BitmapDrawable;
import android.util.Log;
import android.util.LruCache;
import com.fastbootmobile.encore.app.R;
import com.fastbootmobile.encore.utils.ImageUtils;
import com.fastbootmobile.encore.utils.SettingsKeys;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Image cache in the cache directory on internal storage
*/
@SuppressWarnings("SynchronizeOnNonFinalField")
public class ImageCache {
private static final String TAG = "ImageCache";
private static final ImageCache INSTANCE = new ImageCache();
private static final long EXPIRATION_TIME = TimeUnit.DAYS.toMillis(7);
private static final boolean USE_MEMORY_CACHE = true;
private final ArrayList<String> mEntries;
private File mCacheDir;
private Bitmap mDefaultArt;
private final LruCache<String, RecyclingBitmapDrawable> mMemoryCache;
private Set<SoftReference<Bitmap>> mReusableBitmaps;
/**
* @return The default instance
*/
public static ImageCache getDefault() {
return INSTANCE;
}
/**
* Default constructor, creates an LRU cache of the specified size
*/
public ImageCache() {
mEntries = new ArrayList<>();
// A third of the max heap memory, or 39MB, whichever is lowest
final int memoryCacheSize = Math.min(30000,
(int) (Runtime.getRuntime().maxMemory() / 1024 / 3));
Log.d(TAG, "Maximum image cache memory: " + memoryCacheSize + " KB (maxMemory=" + (Runtime.getRuntime().maxMemory() / 1024) + "KB)");
// We create a set of reusable bitmaps that can be
// populated into the inBitmap field of BitmapFactory.Options. Note that the set is
// of SoftReferences which will actually not be very effective due to the garbage
// collector being aggressive clearing Soft/SoftReferences. A better approach
// would be to use a strongly references bitmaps, however this would require some
// balancing of memory usage between this set and the bitmap LruCache. It would also
// require knowledge of the expected size of the bitmaps. From Honeycomb to JellyBean
// the size would need to be precise, from KitKat onward the size would just need to
// be the upper bound (due to changes in how inBitmap can re-use bitmaps).
mReusableBitmaps =
Collections.synchronizedSet(new HashSet<SoftReference<Bitmap>>());
if (USE_MEMORY_CACHE) {
mMemoryCache = new LruCache<String, RecyclingBitmapDrawable>(memoryCacheSize) {
@Override
protected int sizeOf(String key, RecyclingBitmapDrawable value) {
final int bitmapSize = ImageUtils.getBitmapSize(value) / 1024;
return bitmapSize == 0 ? 1 : bitmapSize;
}
@Override
protected void entryRemoved(boolean evicted, String key,
final RecyclingBitmapDrawable oldBitmap, RecyclingBitmapDrawable newBitmap) {
if (newBitmap != null) {
newBitmap.setIsCached(true);
}
oldBitmap.setIsCached(false);
synchronized (mReusableBitmaps) {
mReusableBitmaps.add(new SoftReference<>(oldBitmap.getBitmap()));
}
}
};
} else {
mMemoryCache = null;
}
}
/**
* Initializes the memory cache. Creates the cache directory and load the existing entries.
* @param ctx A valid context
*/
public void initialize(Context ctx) {
mCacheDir = new File(ctx.getCacheDir(), "albumart");
if (!mCacheDir.exists() && !mCacheDir.mkdir()) {
Log.e(TAG, "Cannot mkdir the cache dir " + mCacheDir.getPath());
}
File[] entries = mCacheDir.listFiles();
if (entries != null) {
for (File entry : entries) {
if (System.currentTimeMillis() - entry.lastModified() > EXPIRATION_TIME
&& entry.getName().contains("playlist")) {
// Expire playlist art regularly
if (!entry.delete()) {
// Couldn't delete the art... Let's load it anyway then
mEntries.add(entry.getName());
}
} else {
mEntries.add(entry.getName());
}
}
}
mDefaultArt = ((BitmapDrawable) ctx.getResources()
.getDrawable(R.drawable.album_placeholder)).getBitmap();
SharedPreferences prefs = ctx.getSharedPreferences(SettingsKeys.PREF_SETTINGS, 0);
AlbumArtCache.CREATIVE_COMMONS = prefs.getBoolean(SettingsKeys.KEY_FREE_ART, false);
}
/**
* Clears the cache (both memory and disk)
*/
public void clear() {
if (USE_MEMORY_CACHE) {
synchronized (mMemoryCache) {
mMemoryCache.evictAll();
}
}
synchronized (mEntries) {
mEntries.clear();
}
File[] cacheFiles = mCacheDir.listFiles();
for (File file : cacheFiles) {
if (!file.delete()) {
Log.e(TAG, "Cannot delete " + file.getPath());
}
}
}
/**
* Clears the memory cache
*/
public void evictAll() {
AlbumArtHelper.clearAlbumArtRequests();
if (USE_MEMORY_CACHE) {
synchronized (mMemoryCache) {
mMemoryCache.evictAll();
}
}
mReusableBitmaps.clear();
}
/**
* Returns whether or not the provided key is currently available in memory
* @param key The key to check
* @return true if the image is available in memory
*/
public boolean hasInMemory(final String key) {
if (USE_MEMORY_CACHE) {
RecyclingBitmapDrawable bmp;
bmp = mMemoryCache.get(sanitizeKey(key));
return bmp != null;
} else {
return false;
}
}
/**
* Returns whether or not the provided key is currently available on disk
* @param key The key to check
* @return true if the image is cached on the disk (well, flash storage)
*/
public boolean hasOnDisk(final String key) {
synchronized (mEntries) {
return mEntries.contains(sanitizeKey(key));
}
}
/**
* Returns the image from the cache (either memory or disk)
* @param key The key of the image to get
* @return A bitmap corresponding to the key, or null if it's not in the cache
*/
public RecyclingBitmapDrawable get(final Resources res , final String key, final int reqSz) {
if (key == null) {
return null;
}
final String cleanKey = sanitizeKey(key);
boolean contains;
synchronized (mEntries) {
contains = mEntries.contains(cleanKey);
}
if (contains) {
RecyclingBitmapDrawable item;
synchronized (mMemoryCache) {
// Check if we have it in memory
item = USE_MEMORY_CACHE ? mMemoryCache.get(cleanKey + '_' + reqSz) : null;
}
if (item == null) {
final String filePath = mCacheDir.getAbsolutePath() + "/" + cleanKey;
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true;
BitmapFactory.decodeFile(filePath, opts);
opts.inJustDecodeBounds = false;
ImageUtils.addInBitmapOptions(opts, this, reqSz, opts.outWidth, opts.outHeight);
try {
Bitmap bmp = BitmapFactory.decodeFile(filePath, opts);
if (bmp != null) {
item = new RecyclingBitmapDrawable(res, bmp);
if (USE_MEMORY_CACHE) {
mMemoryCache.put(cleanKey + '_' + reqSz, item);
}
} else {
mEntries.remove(cleanKey);
File f = new File(filePath);
if (!f.delete()) {
Log.e(TAG, "Cannot delete corrupted art at " + filePath);
}
}
} catch (OutOfMemoryError e) {
Log.e(TAG, "OutOfMemory when decoding input file", e);
return null;
}
}
return item;
} else {
return null;
}
}
/**
* @param options - BitmapFactory.Options with out* options populated
* @return Bitmap that case be used for inBitmap
*/
public Bitmap getBitmapFromReusableSet(BitmapFactory.Options options) {
Bitmap bitmap = null;
if (mReusableBitmaps != null && !mReusableBitmaps.isEmpty()) {
synchronized (mReusableBitmaps) {
final Iterator<SoftReference<Bitmap>> iterator = mReusableBitmaps.iterator();
Bitmap item;
while (iterator.hasNext()) {
item = iterator.next().get();
if (null != item && item.isMutable()) {
// Check to see it the item can be used for inBitmap
if (ImageUtils.canUseForInBitmap(item, options)) {
bitmap = item;
// Remove from reusable set so it can't be used again
iterator.remove();
break;
}
} else {
// Remove from the set if the reference has been cleared.
iterator.remove();
}
}
}
}
return bitmap;
}
/**
* Stores the image as JPEG in the cache
* @param key The key of the image to put
* @param bmp The bitmap to put (or null to put the default art)
*/
public RecyclingBitmapDrawable put(final Resources res, final String key, final Bitmap bmp) {
RecyclingBitmapDrawable rcb = new RecyclingBitmapDrawable(res, bmp);
put(res, key, rcb, false);
return rcb;
}
/**
* Stores the image as JPEG in the cache
* @param key The key of the image to put
* @param bmp The bitmap to put (or null to put the default art)
*/
public void put(final Resources res, final String key, final RecyclingBitmapDrawable bmp) {
put(res, key, bmp, false);
}
/**
* Stores the image as either JPEG or PNG in the cache
* @param key The key of the image to put
* @param bmp The bitmap to put (or null to put the default art)
* @param asPNG True to store as PNG, false to store as JPEG
*/
public RecyclingBitmapDrawable put(final Resources res, final String key, Bitmap bmp, final boolean asPNG) {
RecyclingBitmapDrawable rcb = new RecyclingBitmapDrawable(res, bmp);
put(res, key, rcb, asPNG);
return rcb;
}
/**
* Stores the image as either JPEG or PNG in the cache
* @param key The key of the image to put
* @param bmp The bitmap to put (or null to put the default art)
* @param asPNG True to store as PNG, false to store as JPEG
*/
public void put(final Resources res, final String key, RecyclingBitmapDrawable bmp, final boolean asPNG) {
boolean isDefaultArt = false;
final String cleanKey = sanitizeKey(key);
if (bmp == null) {
bmp = new RecyclingBitmapDrawable(res, mDefaultArt.copy(mDefaultArt.getConfig(), false));
isDefaultArt = true;
}
if (USE_MEMORY_CACHE) {
mMemoryCache.put(cleanKey, bmp);
}
if (!isDefaultArt) {
try {
FileOutputStream out = new FileOutputStream(mCacheDir.getAbsolutePath() + "/" + cleanKey);
Bitmap bitmap = bmp.getBitmap();
boolean shouldRecycle = false;
final float maxSize = 800;
if (bitmap.getWidth() > maxSize && bitmap.getHeight() > maxSize) {
float ratio = (bitmap.getWidth() < bitmap.getHeight()) ?
bitmap.getWidth() / maxSize : bitmap.getHeight() / maxSize;
final int sWidth = (int) (bitmap.getWidth() / ratio);
final int sHeight = (int) (bitmap.getHeight() / ratio);
bitmap = Bitmap.createScaledBitmap(bitmap, sWidth, sHeight, true);
shouldRecycle = true;
Log.d(TAG, "Rescaled to " + sWidth + "x" + sHeight);
}
bitmap.compress(asPNG ? Bitmap.CompressFormat.PNG : Bitmap.CompressFormat.JPEG, 90, out);
out.close();
if (shouldRecycle) {
// Scaled image will be used on reload
bitmap.recycle();
}
} catch (IOException e) {
Log.e(TAG, "Unable to write the file to cache", e);
}
synchronized (mEntries) {
mEntries.add(cleanKey);
}
}
}
/**
* Sanitizes the key to remove out unwanted characters
* @return A sanitized copy of the key
*/
private String sanitizeKey(String key) {
return key.replaceAll("\\W", "_");
}
}