diff options
author | linus_lee <llee@cyngn.com> | 2014-11-20 16:39:38 -0800 |
---|---|---|
committer | linus_lee <llee@cyngn.com> | 2014-12-09 12:23:20 -0800 |
commit | 71810ebb2bf8fd792c92487fe87f9dbebefc8541 (patch) | |
tree | 42a4d11ba03a4c7af843edc0b45375b17c64053c /src/com/cyanogenmod/eleven/cache | |
parent | f199f983c9a5e2f4434b85273d1da0d609c33228 (diff) | |
download | android_packages_apps_Eleven-71810ebb2bf8fd792c92487fe87f9dbebefc8541.tar.gz android_packages_apps_Eleven-71810ebb2bf8fd792c92487fe87f9dbebefc8541.tar.bz2 android_packages_apps_Eleven-71810ebb2bf8fd792c92487fe87f9dbebefc8541.zip |
Update Eleven headers and namespace for open source
Change-Id: I82caf2ebf991998e67f546ff2ac7eaf2b30dc6be
Diffstat (limited to 'src/com/cyanogenmod/eleven/cache')
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/BitmapWorkerTask.java | 149 | ||||
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/BlurBitmapWorkerTask.java | 180 | ||||
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/DiskLruCache.java | 969 | ||||
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/ICacheListener.java | 20 | ||||
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/ImageCache.java | 827 | ||||
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/ImageFetcher.java | 322 | ||||
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/ImageWorker.java | 586 | ||||
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/LruCache.java | 333 | ||||
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/PlaylistWorkerTask.java | 383 | ||||
-rw-r--r-- | src/com/cyanogenmod/eleven/cache/SimpleBitmapWorkerTask.java | 88 |
10 files changed, 3857 insertions, 0 deletions
diff --git a/src/com/cyanogenmod/eleven/cache/BitmapWorkerTask.java b/src/com/cyanogenmod/eleven/cache/BitmapWorkerTask.java new file mode 100644 index 0000000..c7ab842 --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/BitmapWorkerTask.java @@ -0,0 +1,149 @@ +/* +* Copyright (C) 2014 The CyanogenMod 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 com.cyanogenmod.eleven.cache; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.os.AsyncTask; +import android.widget.ImageView; + +import com.cyanogenmod.eleven.cache.ImageWorker.ImageType; + +import java.lang.ref.WeakReference; + +/** + * The actual {@link android.os.AsyncTask} that will process the image. + */ +public abstract class BitmapWorkerTask<Params, Progress, Result> + extends AsyncTask<Params, Progress, Result> { + /** + * The {@link android.widget.ImageView} used to set the result + */ + protected final WeakReference<ImageView> mImageReference; + + /** + * Type of URL to download + */ + protected final ImageWorker.ImageType mImageType; + + /** + * Layer drawable used to cross fade the result from the worker + */ + protected Drawable mFromDrawable; + + protected final Context mContext; + + protected final ImageCache mImageCache; + + protected final Resources mResources; + + protected boolean mScaleImgToView; + + /** + * The key used to store cached entries + */ + public String mKey; + + /** + * Constructor of <code>BitmapWorkerTask</code> + * @param key used for caching the image + * @param imageView The {@link ImageView} to use. + * @param imageType The type of image URL to fetch for. + * @param fromDrawable what drawable to transition from + */ + public BitmapWorkerTask(final String key, final ImageView imageView, final ImageType imageType, + final Drawable fromDrawable, final Context context) { + this(key, imageView, imageType, fromDrawable, context, false); + } + + /** + * Constructor of <code>BitmapWorkerTask</code> + * @param key used for caching the image + * @param imageView The {@link ImageView} to use. + * @param imageType The type of image URL to fetch for. + * @param fromDrawable what drawable to transition from + * @param scaleImgToView flag to scale the bitmap to the image view bounds + */ + public BitmapWorkerTask(final String key, final ImageView imageView, final ImageType imageType, + final Drawable fromDrawable, final Context context, final boolean scaleImgToView) { + mKey = key; + + mContext = context; + mImageCache = ImageCache.getInstance(mContext); + mResources = mContext.getResources(); + + mImageReference = new WeakReference<ImageView>(imageView); + mImageType = imageType; + + // A transparent image (layer 0) and the new result (layer 1) + mFromDrawable = fromDrawable; + + mScaleImgToView = scaleImgToView; + } + + /** + * @return The {@link ImageView} associated with this task as long as + * the ImageView's task still points to this task as well. + * Returns null otherwise. + */ + protected ImageView getAttachedImageView() { + final ImageView imageView = mImageReference.get(); + if (imageView != null) { + final BitmapWorkerTask bitmapWorkerTask = ImageWorker.getBitmapWorkerTask(imageView); + if (this == bitmapWorkerTask) { + return imageView; + } + } + + return null; + } + + /** + * Gets the bitmap given the input params + * @param params artistName, albumName, albumId + * @return Bitmap + */ + protected Bitmap getBitmapInBackground(final String... params) { + return ImageWorker.getBitmapInBackground(mContext, mImageCache, mKey, + params[1], params[0], Long.valueOf(params[2]), mImageType); + } + + /** + * Creates a transition drawable with default parameters + * @param bitmap the bitmap to transition to + * @return the transition drawable + */ + protected TransitionDrawable createImageTransitionDrawable(final Bitmap bitmap) { + return createImageTransitionDrawable(bitmap, ImageWorker.FADE_IN_TIME, false, false); + } + + /** + * Creates a transition drawable + * @param bitmap to transition to + * @param fadeTime the time to fade in ms + * @param dither setting + * @param force force create a transition even if bitmap == null (fade to transparent) + * @return the transition drawable + */ + protected TransitionDrawable createImageTransitionDrawable(final Bitmap bitmap, + final int fadeTime, final boolean dither, final boolean force) { + return ImageWorker.createImageTransitionDrawable(mResources, mFromDrawable, bitmap, + fadeTime, dither, force); + } +} diff --git a/src/com/cyanogenmod/eleven/cache/BlurBitmapWorkerTask.java b/src/com/cyanogenmod/eleven/cache/BlurBitmapWorkerTask.java new file mode 100644 index 0000000..2fe88ea --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/BlurBitmapWorkerTask.java @@ -0,0 +1,180 @@ +/* +* Copyright (C) 2014 The CyanogenMod 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 com.cyanogenmod.eleven.cache; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.support.v8.renderscript.Allocation; +import android.support.v8.renderscript.Element; +import android.support.v8.renderscript.RenderScript; +import android.support.v8.renderscript.ScriptIntrinsicBlur; +import android.widget.ImageView; + +import com.cyanogenmod.eleven.cache.ImageWorker.ImageType; +import com.cyanogenmod.eleven.widgets.BlurScrimImage; + +import java.lang.ref.WeakReference; + +/** + * This will download the image (if needed) and create a blur and set the scrim as well on the + * BlurScrimImage + */ +public class BlurBitmapWorkerTask extends BitmapWorkerTask<String, Void, BlurBitmapWorkerTask.ResultContainer> { + // if the image is too small, the blur will look bad post scale up so we use the min size + // to scale up before bluring + private static final int MIN_BITMAP_SIZE = 500; + // number of times to run the blur + private static final int NUM_BLUR_RUNS = 8; + // 25f is the max blur radius possible + private static final float BLUR_RADIUS = 25f; + + // container for the result + public static class ResultContainer { + public TransitionDrawable mImageViewBitmapDrawable; + public int mPaletteColor; + } + + /** + * The {@link com.cyanogenmod.eleven.widgets.BlurScrimImage} used to set the result + */ + private final WeakReference<BlurScrimImage> mBlurScrimImage; + + /** + * RenderScript used to blur the image + */ + protected final RenderScript mRenderScript; + + /** + * Constructor of <code>BlurBitmapWorkerTask</code> + * @param key used for caching the image + * @param blurScrimImage The {@link BlurScrimImage} to use. + * @param imageType The type of image URL to fetch for. + * @param fromDrawable what drawable to transition from + */ + public BlurBitmapWorkerTask(final String key, final BlurScrimImage blurScrimImage, + final ImageType imageType, final Drawable fromDrawable, + final Context context, final RenderScript renderScript) { + super(key, blurScrimImage.getImageView(), imageType, fromDrawable, context); + mBlurScrimImage = new WeakReference<BlurScrimImage>(blurScrimImage); + mRenderScript = renderScript; + + // use the existing image as the drawable and if it doesn't exist fallback to transparent + mFromDrawable = blurScrimImage.getImageView().getDrawable(); + if (mFromDrawable == null) { + mFromDrawable = fromDrawable; + } + } + + /** + * {@inheritDoc} + */ + @Override + protected ResultContainer doInBackground(final String... params) { + if (isCancelled()) { + return null; + } + + Bitmap bitmap = getBitmapInBackground(params); + + ResultContainer result = new ResultContainer(); + + Bitmap output = null; + + if (bitmap != null) { + // now create the blur bitmap + Bitmap input = bitmap; + + // if the image is too small, scale it up before running through the blur + if (input.getWidth() < MIN_BITMAP_SIZE || input.getHeight() < MIN_BITMAP_SIZE) { + float multiplier = Math.max(MIN_BITMAP_SIZE / (float)input.getWidth(), + MIN_BITMAP_SIZE / (float)input.getHeight()); + input = input.createScaledBitmap(bitmap, (int)(input.getWidth() * multiplier), + (int)(input.getHeight() * multiplier), true); + // since we created a new bitmap, we can re-use the bitmap for our output + output = input; + } else { + // if we aren't creating a new bitmap, create a new output bitmap + output = Bitmap.createBitmap(input.getWidth(), input.getHeight(), input.getConfig()); + } + + // run the blur multiple times + for (int i = 0; i < NUM_BLUR_RUNS; i++) { + final Allocation inputAlloc = Allocation.createFromBitmap(mRenderScript, input); + final Allocation outputAlloc = Allocation.createTyped(mRenderScript, + inputAlloc.getType()); + final ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(mRenderScript, + Element.U8_4(mRenderScript)); + + script.setRadius(BLUR_RADIUS); + script.setInput(inputAlloc); + script.forEach(outputAlloc); + outputAlloc.copyTo(output); + + // if we run more than 1 blur, the new input should be the old output + input = output; + } + + // Set the scrim color to be 50% gray + result.mPaletteColor = 0x7f000000; + + // create the bitmap transition drawable + result.mImageViewBitmapDrawable = createImageTransitionDrawable(output, + ImageWorker.FADE_IN_TIME_SLOW, true, true); + + return result; + } + + return null; + } + + /** + * {@inheritDoc} + */ + @Override + protected void onPostExecute(ResultContainer resultContainer) { + BlurScrimImage blurScrimImage = mBlurScrimImage.get(); + if (blurScrimImage != null) { + if (resultContainer == null) { + // if we have no image, then signal the transition to the default state + blurScrimImage.transitionToDefaultState(); + } else { + // create the palette transition + TransitionDrawable paletteTransition = ImageWorker.createPaletteTransition( + blurScrimImage, + resultContainer.mPaletteColor); + + // set the transition drawable + blurScrimImage.setTransitionDrawable(resultContainer.mImageViewBitmapDrawable, + paletteTransition); + } + } + } + + /** + * {@inheritDoc} + */ + @Override + protected final ImageView getAttachedImageView() { + final BlurScrimImage blurImage = mBlurScrimImage.get(); + final BitmapWorkerTask bitmapWorkerTask = ImageWorker.getBitmapWorkerTask(blurImage); + if (this == bitmapWorkerTask) { + return blurImage.getImageView(); + } + return null; + } +}
\ No newline at end of file diff --git a/src/com/cyanogenmod/eleven/cache/DiskLruCache.java b/src/com/cyanogenmod/eleven/cache/DiskLruCache.java new file mode 100644 index 0000000..4d29ded --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/DiskLruCache.java @@ -0,0 +1,969 @@ +/* + * Copyright (C) 2011 The Android Open Source 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 com.cyanogenmod.eleven.cache; + +import java.io.BufferedInputStream; +import java.io.BufferedWriter; +import java.io.Closeable; +import java.io.EOFException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.StringWriter; +import java.io.Writer; +import java.lang.reflect.Array; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + ****************************************************************************** Taken from the JB source code, can be found in: + * libcore/luni/src/main/java/libcore/io/DiskLruCache.java or direct link: + * https: + * //android.googlesource.com/platform/libcore/+/android-4.1.1_r1/luni/src/ + * main/java/libcore/io/DiskLruCache.java A cache that uses a bounded amount of + * space on a filesystem. Each cache entry has a string key and a fixed number + * of values. Values are byte sequences, accessible as streams or files. Each + * value must be between {@code 0} and {@code Integer.MAX_VALUE} bytes in + * length. + * <p> + * The cache stores its data in a directory on the filesystem. This directory + * must be exclusive to the cache; the cache may delete or overwrite files from + * its directory. It is an error for multiple processes to use the same cache + * directory at the same time. + * <p> + * This cache limits the number of bytes that it will store on the filesystem. + * When the number of stored bytes exceeds the limit, the cache will remove + * entries in the background until the limit is satisfied. The limit is not + * strict: the cache may temporarily exceed it while waiting for files to be + * deleted. The limit does not include filesystem overhead or the cache journal + * so space-sensitive applications should set a conservative limit. + * <p> + * Clients call {@link #edit} to create or update the values of an entry. An + * entry may have only one editor at one time; if a value is not available to be + * edited then {@link #edit} will return null. + * <ul> + * <li>When an entry is being <strong>created</strong> it is necessary to supply + * a full set of values; the empty value should be used as a placeholder if + * necessary. + * <li>When an entry is being <strong>edited</strong>, it is not necessary to + * supply data for every value; values default to their previous value. + * </ul> + * Every {@link #edit} call must be matched by a call to {@link Editor#commit} + * or {@link Editor#abort}. Committing is atomic: a read observes the full set + * of values as they were before or after the commit, but never a mix of values. + * <p> + * Clients call {@link #get} to read a snapshot of an entry. The read will + * observe the value at the time that {@link #get} was called. Updates and + * removals after the call do not impact ongoing reads. + * <p> + * This class is tolerant of some I/O errors. If files are missing from the + * filesystem, the corresponding entries will be dropped from the cache. If an + * error occurs while writing a cache value, the edit will fail silently. + * Callers should handle other problems by catching {@code IOException} and + * responding appropriately. + */ +public final class DiskLruCache implements Closeable { + static final String JOURNAL_FILE = "journal"; + + static final String JOURNAL_FILE_TMP = "journal.tmp"; + + static final String MAGIC = "libcore.io.DiskLruCache"; + + static final String VERSION_1 = "1"; + + static final long ANY_SEQUENCE_NUMBER = -1; + + private static final String CLEAN = "CLEAN"; + + private static final String DIRTY = "DIRTY"; + + private static final String REMOVE = "REMOVE"; + + private static final String READ = "READ"; + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + private static final int IO_BUFFER_SIZE = 8 * 1024; + + /* + * This cache uses a journal file named "journal". A typical journal file + * looks like this: libcore.io.DiskLruCache 1 100 2 CLEAN + * 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054 DIRTY + * 335c4c6028171cfddfbaae1a9c313c52 CLEAN 335c4c6028171cfddfbaae1a9c313c52 + * 3934 2342 REMOVE 335c4c6028171cfddfbaae1a9c313c52 DIRTY + * 1ab96a171faeeee38496d8b330771a7a CLEAN 1ab96a171faeeee38496d8b330771a7a + * 1600 234 READ 335c4c6028171cfddfbaae1a9c313c52 READ + * 3400330d1dfc7f3f7f4b8d4d803dfcf6 The first five lines of the journal form + * its header. They are the constant string "libcore.io.DiskLruCache", the + * disk cache's version, the application's version, the value count, and a + * blank line. Each of the subsequent lines in the file is a record of the + * state of a cache entry. Each line contains space-separated values: a + * state, a key, and optional state-specific values. o DIRTY lines track + * that an entry is actively being created or updated. Every successful + * DIRTY action should be followed by a CLEAN or REMOVE action. DIRTY lines + * without a matching CLEAN or REMOVE indicate that temporary files may need + * to be deleted. o CLEAN lines track a cache entry that has been + * successfully published and may be read. A publish line is followed by the + * lengths of each of its values. o READ lines track accesses for LRU. o + * REMOVE lines track entries that have been deleted. The journal file is + * appended to as cache operations occur. The journal may occasionally be + * compacted by dropping redundant lines. A temporary file named + * "journal.tmp" will be used during compaction; that file should be deleted + * if it exists when the cache is opened. + */ + + private final File directory; + + private final File journalFile; + + private final File journalFileTmp; + + private final int appVersion; + + private final long maxSize; + + private final int valueCount; + + private long size = 0; + + private Writer journalWriter; + + private final LinkedHashMap<String, Entry> lruEntries = new LinkedHashMap<String, Entry>(0, + 0.75f, true); + + private int redundantOpCount; + + /** + * To differentiate between old and current snapshots, each entry is given a + * sequence number each time an edit is committed. A snapshot is stale if + * its sequence number is not equal to its entry's sequence number. + */ + private long nextSequenceNumber = 0; + + /* From java.util.Arrays */ + @SuppressWarnings("unchecked") + private static <T> T[] copyOfRange(final T[] original, final int start, final int end) { + final int originalLength = original.length; // For exception priority + // compatibility. + if (start > end) { + throw new IllegalArgumentException(); + } + if (start < 0 || start > originalLength) { + throw new ArrayIndexOutOfBoundsException(); + } + final int resultLength = end - start; + final int copyLength = Math.min(resultLength, originalLength - start); + final T[] result = (T[])Array.newInstance(original.getClass().getComponentType(), + resultLength); + System.arraycopy(original, start, result, 0, copyLength); + return result; + } + + /** + * Returns the remainder of 'reader' as a string, closing it when done. + */ + public static String readFully(final Reader reader) throws IOException { + try { + final StringWriter writer = new StringWriter(); + final char[] buffer = new char[1024]; + int count; + while ((count = reader.read(buffer)) != -1) { + writer.write(buffer, 0, count); + } + return writer.toString(); + } finally { + reader.close(); + } + } + + /** + * Returns the ASCII characters up to but not including the next "\r\n", or + * "\n". + * + * @throws java.io.EOFException if the stream is exhausted before the next + * newline character. + */ + public static String readAsciiLine(final InputStream in) throws IOException { + // TODO: support UTF-8 here instead + + final StringBuilder result = new StringBuilder(80); + while (true) { + final int c = in.read(); + if (c == -1) { + throw new EOFException(); + } else if (c == '\n') { + break; + } + + result.append((char)c); + } + final int length = result.length(); + if (length > 0 && result.charAt(length - 1) == '\r') { + result.setLength(length - 1); + } + return result.toString(); + } + + /** + * Closes 'closeable', ignoring any checked exceptions. Does nothing if + * 'closeable' is null. + */ + public static void closeQuietly(final Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (final RuntimeException rethrown) { + throw rethrown; + } catch (final Exception ignored) { + } + } + } + + /** + * Recursively delete everything in {@code dir}. + */ + // TODO: this should specify paths as Strings rather than as Files + public static void deleteContents(final File dir) throws IOException { + final File[] files = dir.listFiles(); + if (files == null) { + throw new IllegalArgumentException("not a directory: " + dir); + } + for (final File file : files) { + if (file.isDirectory()) { + deleteContents(file); + } + if (!file.delete()) { + throw new IOException("failed to delete file: " + file); + } + } + } + + /** This cache uses a single background thread to evict entries. */ + private final ExecutorService executorService = new ThreadPoolExecutor(0, 1, 60L, + TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>()); + + private final Callable<Void> cleanupCallable = new Callable<Void>() { + @Override + public Void call() throws Exception { + synchronized (DiskLruCache.this) { + if (journalWriter == null) { + return null; // closed + } + trimToSize(); + if (journalRebuildRequired()) { + rebuildJournal(); + redundantOpCount = 0; + } + } + return null; + } + }; + + private DiskLruCache(final File directory, final int appVersion, final int valueCount, + final long maxSize) { + this.directory = directory; + this.appVersion = appVersion; + journalFile = new File(directory, JOURNAL_FILE); + journalFileTmp = new File(directory, JOURNAL_FILE_TMP); + this.valueCount = valueCount; + this.maxSize = maxSize; + } + + /** + * Opens the cache in {@code directory}, creating a cache if none exists + * there. + * + * @param directory a writable directory + * @param appVersion + * @param valueCount the number of values per cache entry. Must be positive. + * @param maxSize the maximum number of bytes this cache should use to store + * @throws IOException if reading or writing the cache directory fails + */ + public static DiskLruCache open(final File directory, final int appVersion, + final int valueCount, final long maxSize) throws IOException { + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize <= 0"); + } + if (valueCount <= 0) { + throw new IllegalArgumentException("valueCount <= 0"); + } + + // prefer to pick up where we left off + DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); + if (cache.journalFile.exists()) { + try { + cache.readJournal(); + cache.processJournal(); + cache.journalWriter = new BufferedWriter(new FileWriter(cache.journalFile, true), + IO_BUFFER_SIZE); + return cache; + } catch (final IOException journalIsCorrupt) { + // System.logW("DiskLruCache " + directory + " is corrupt: " + // + journalIsCorrupt.getMessage() + ", removing"); + cache.delete(); + } + } + + // create a new empty cache + directory.mkdirs(); + cache = new DiskLruCache(directory, appVersion, valueCount, maxSize); + cache.rebuildJournal(); + return cache; + } + + private void readJournal() throws IOException { + final InputStream in = new BufferedInputStream(new FileInputStream(journalFile), + IO_BUFFER_SIZE); + try { + final String magic = readAsciiLine(in); + final String version = readAsciiLine(in); + final String appVersionString = readAsciiLine(in); + final String valueCountString = readAsciiLine(in); + final String blank = readAsciiLine(in); + if (!MAGIC.equals(magic) || !VERSION_1.equals(version) + || !Integer.toString(appVersion).equals(appVersionString) + || !Integer.toString(valueCount).equals(valueCountString) || !"".equals(blank)) { + throw new IOException("unexpected journal header: [" + magic + ", " + version + + ", " + valueCountString + ", " + blank + "]"); + } + + while (true) { + try { + readJournalLine(readAsciiLine(in)); + } catch (final EOFException endOfJournal) { + break; + } + } + } finally { + closeQuietly(in); + } + } + + private void readJournalLine(final String line) throws IOException { + final String[] parts = line.split(" "); + if (parts.length < 2) { + throw new IOException("unexpected journal line: " + line); + } + + final String key = parts[1]; + if (parts[0].equals(REMOVE) && parts.length == 2) { + lruEntries.remove(key); + return; + } + + Entry entry = lruEntries.get(key); + if (entry == null) { + entry = new Entry(key); + lruEntries.put(key, entry); + } + + if (parts[0].equals(CLEAN) && parts.length == 2 + valueCount) { + entry.readable = true; + entry.currentEditor = null; + entry.setLengths(copyOfRange(parts, 2, parts.length)); + } else if (parts[0].equals(DIRTY) && parts.length == 2) { + entry.currentEditor = new Editor(entry); + } else if (parts[0].equals(READ) && parts.length == 2) { + // this work was already done by calling lruEntries.get() + } else { + throw new IOException("unexpected journal line: " + line); + } + } + + /** + * Computes the initial size and collects garbage as a part of opening the + * cache. Dirty entries are assumed to be inconsistent and will be deleted. + */ + private void processJournal() throws IOException { + deleteIfExists(journalFileTmp); + for (final Iterator<Entry> i = lruEntries.values().iterator(); i.hasNext();) { + final Entry entry = i.next(); + if (entry.currentEditor == null) { + for (int t = 0; t < valueCount; t++) { + size += entry.lengths[t]; + } + } else { + entry.currentEditor = null; + for (int t = 0; t < valueCount; t++) { + deleteIfExists(entry.getCleanFile(t)); + deleteIfExists(entry.getDirtyFile(t)); + } + i.remove(); + } + } + } + + /** + * Creates a new journal that omits redundant information. This replaces the + * current journal if it exists. + */ + private synchronized void rebuildJournal() throws IOException { + if (journalWriter != null) { + journalWriter.close(); + } + + final Writer writer = new BufferedWriter(new FileWriter(journalFileTmp), IO_BUFFER_SIZE); + writer.write(MAGIC); + writer.write("\n"); + writer.write(VERSION_1); + writer.write("\n"); + writer.write(Integer.toString(appVersion)); + writer.write("\n"); + writer.write(Integer.toString(valueCount)); + writer.write("\n"); + writer.write("\n"); + + for (final Entry entry : lruEntries.values()) { + if (entry.currentEditor != null) { + writer.write(DIRTY + ' ' + entry.key + '\n'); + } else { + writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); + } + } + + writer.close(); + journalFileTmp.renameTo(journalFile); + journalWriter = new BufferedWriter(new FileWriter(journalFile, true), IO_BUFFER_SIZE); + } + + private static void deleteIfExists(final File file) throws IOException { + // try { + // Libcore.os.remove(file.getPath()); + // } catch (ErrnoException errnoException) { + // if (errnoException.errno != OsConstants.ENOENT) { + // throw errnoException.rethrowAsIOException(); + // } + // } + if (file.exists() && !file.delete()) { + throw new IOException(); + } + } + + /** + * Returns a snapshot of the entry named {@code key}, or null if it doesn't + * exist is not currently readable. If a value is returned, it is moved to + * the head of the LRU queue. + */ + public synchronized Snapshot get(final String key) throws IOException { + checkNotClosed(); + validateKey(key); + final Entry entry = lruEntries.get(key); + if (entry == null) { + return null; + } + + if (!entry.readable) { + return null; + } + + /* + * Open all streams eagerly to guarantee that we see a single published + * snapshot. If we opened streams lazily then the streams could come + * from different edits. + */ + final InputStream[] ins = new InputStream[valueCount]; + try { + for (int i = 0; i < valueCount; i++) { + ins[i] = new FileInputStream(entry.getCleanFile(i)); + } + } catch (final FileNotFoundException e) { + // a file must have been deleted manually! + return null; + } + + redundantOpCount++; + journalWriter.append(READ + ' ' + key + '\n'); + if (journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + + return new Snapshot(key, entry.sequenceNumber, ins); + } + + /** + * Returns an editor for the entry named {@code key}, or null if another + * edit is in progress. + */ + public Editor edit(final String key) throws IOException { + return edit(key, ANY_SEQUENCE_NUMBER); + } + + private synchronized Editor edit(final String key, final long expectedSequenceNumber) + throws IOException { + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER + && (entry == null || entry.sequenceNumber != expectedSequenceNumber)) { + return null; // snapshot is stale + } + if (entry == null) { + entry = new Entry(key); + lruEntries.put(key, entry); + } else if (entry.currentEditor != null) { + return null; // another edit is in progress + } + + final Editor editor = new Editor(entry); + entry.currentEditor = editor; + + // flush the journal before creating files to prevent file leaks + journalWriter.write(DIRTY + ' ' + key + '\n'); + journalWriter.flush(); + return editor; + } + + /** + * Returns the directory where this cache stores its data. + */ + public File getDirectory() { + return directory; + } + + /** + * Returns the maximum number of bytes that this cache should use to store + * its data. + */ + public long maxSize() { + return maxSize; + } + + /** + * Returns the number of bytes currently being used to store the values in + * this cache. This may be greater than the max size if a background + * deletion is pending. + */ + public synchronized long size() { + return size; + } + + private synchronized void completeEdit(final Editor editor, final boolean success) + throws IOException { + final Entry entry = editor.entry; + if (entry.currentEditor != editor) { + throw new IllegalStateException(); + } + + // if this edit is creating the entry for the first time, every index + // must have a value + if (success && !entry.readable) { + for (int i = 0; i < valueCount; i++) { + if (!entry.getDirtyFile(i).exists()) { + editor.abort(); + throw new IllegalStateException("edit didn't create file " + i); + } + } + } + + for (int i = 0; i < valueCount; i++) { + final File dirty = entry.getDirtyFile(i); + if (success) { + if (dirty.exists()) { + final File clean = entry.getCleanFile(i); + dirty.renameTo(clean); + final long oldLength = entry.lengths[i]; + final long newLength = clean.length(); + entry.lengths[i] = newLength; + size = size - oldLength + newLength; + } + } else { + deleteIfExists(dirty); + } + } + + redundantOpCount++; + entry.currentEditor = null; + if (entry.readable | success) { + entry.readable = true; + journalWriter.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); + if (success) { + entry.sequenceNumber = nextSequenceNumber++; + } + } else { + lruEntries.remove(entry.key); + journalWriter.write(REMOVE + ' ' + entry.key + '\n'); + } + + if (size > maxSize || journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + } + + /** + * We only rebuild the journal when it will halve the size of the journal + * and eliminate at least 2000 ops. + */ + private boolean journalRebuildRequired() { + final int REDUNDANT_OP_COMPACT_THRESHOLD = 2000; + return redundantOpCount >= REDUNDANT_OP_COMPACT_THRESHOLD + && redundantOpCount >= lruEntries.size(); + } + + /** + * Drops the entry for {@code key} if it exists and can be removed. Entries + * actively being edited cannot be removed. + * + * @return true if an entry was removed. + */ + public synchronized boolean remove(final String key) throws IOException { + checkNotClosed(); + validateKey(key); + final Entry entry = lruEntries.get(key); + if (entry == null || entry.currentEditor != null) { + return false; + } + + for (int i = 0; i < valueCount; i++) { + final File file = entry.getCleanFile(i); + if (!file.delete()) { + throw new IOException("failed to delete " + file); + } + size -= entry.lengths[i]; + entry.lengths[i] = 0; + } + + redundantOpCount++; + journalWriter.append(REMOVE + ' ' + key + '\n'); + lruEntries.remove(key); + + if (journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + + return true; + } + + /** + * Returns true if this cache has been closed. + */ + public boolean isClosed() { + return journalWriter == null; + } + + private void checkNotClosed() { + if (journalWriter == null) { + throw new IllegalStateException("cache is closed"); + } + } + + /** + * Force buffered operations to the filesystem. + */ + public synchronized void flush() throws IOException { + checkNotClosed(); + trimToSize(); + journalWriter.flush(); + } + + /** + * Closes this cache. Stored values will remain on the filesystem. + */ + @Override + public synchronized void close() throws IOException { + if (journalWriter == null) { + return; // already closed + } + for (final Entry entry : new ArrayList<Entry>(lruEntries.values())) { + if (entry.currentEditor != null) { + entry.currentEditor.abort(); + } + } + trimToSize(); + journalWriter.close(); + journalWriter = null; + } + + private void trimToSize() throws IOException { + while (size > maxSize) { + // Map.Entry<String, Entry> toEvict = lruEntries.eldest(); + final Map.Entry<String, Entry> toEvict = lruEntries.entrySet().iterator().next(); + remove(toEvict.getKey()); + } + } + + /** + * Closes the cache and deletes all of its stored values. This will delete + * all files in the cache directory including files that weren't created by + * the cache. + */ + public void delete() throws IOException { + close(); + deleteContents(directory); + } + + private void validateKey(final String key) { + if (key.contains(" ") || key.contains("\n") || key.contains("\r")) { + throw new IllegalArgumentException("keys must not contain spaces or newlines: \"" + key + + "\""); + } + } + + private static String inputStreamToString(final InputStream in) throws IOException { + return readFully(new InputStreamReader(in, UTF_8)); + } + + /** + * A snapshot of the values for an entry. + */ + public final class Snapshot implements Closeable { + private final String key; + + private final long sequenceNumber; + + private final InputStream[] ins; + + private Snapshot(final String key, final long sequenceNumber, final InputStream[] ins) { + this.key = key; + this.sequenceNumber = sequenceNumber; + this.ins = ins; + } + + /** + * Returns an editor for this snapshot's entry, or null if either the + * entry has changed since this snapshot was created or if another edit + * is in progress. + */ + public Editor edit() throws IOException { + return DiskLruCache.this.edit(key, sequenceNumber); + } + + /** + * Returns the unbuffered stream with the value for {@code index}. + */ + public InputStream getInputStream(final int index) { + return ins[index]; + } + + /** + * Returns the string value for {@code index}. + */ + public String getString(final int index) throws IOException { + return inputStreamToString(getInputStream(index)); + } + + @Override + public void close() { + for (final InputStream in : ins) { + closeQuietly(in); + } + } + } + + /** + * Edits the values for an entry. + */ + public final class Editor { + private final Entry entry; + + private boolean hasErrors; + + private Editor(final Entry entry) { + this.entry = entry; + } + + /** + * Returns an unbuffered input stream to read the last committed value, + * or null if no value has been committed. + */ + public InputStream newInputStream(final int index) throws IOException { + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + if (!entry.readable) { + return null; + } + return new FileInputStream(entry.getCleanFile(index)); + } + } + + /** + * Returns the last committed value as a string, or null if no value has + * been committed. + */ + public String getString(final int index) throws IOException { + final InputStream in = newInputStream(index); + return in != null ? inputStreamToString(in) : null; + } + + /** + * Returns a new unbuffered output stream to write the value at + * {@code index}. If the underlying output stream encounters errors when + * writing to the filesystem, this edit will be aborted when + * {@link #commit} is called. The returned output stream does not throw + * IOExceptions. + */ + public OutputStream newOutputStream(final int index) throws IOException { + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + return new FaultHidingOutputStream(new FileOutputStream(entry.getDirtyFile(index))); + } + } + + /** + * Sets the value at {@code index} to {@code value}. + */ + public void set(final int index, final String value) throws IOException { + Writer writer = null; + try { + writer = new OutputStreamWriter(newOutputStream(index), UTF_8); + writer.write(value); + } finally { + closeQuietly(writer); + } + } + + /** + * Commits this edit so it is visible to readers. This releases the edit + * lock so another edit may be started on the same key. + */ + public void commit() throws IOException { + if (hasErrors) { + completeEdit(this, false); + remove(entry.key); // the previous entry is stale + } else { + completeEdit(this, true); + } + } + + /** + * Aborts this edit. This releases the edit lock so another edit may be + * started on the same key. + */ + public void abort() throws IOException { + completeEdit(this, false); + } + + private class FaultHidingOutputStream extends FilterOutputStream { + private FaultHidingOutputStream(final OutputStream out) { + super(out); + } + + @Override + public void write(final int oneByte) { + try { + out.write(oneByte); + } catch (final IOException e) { + hasErrors = true; + } + } + + @Override + public void write(final byte[] buffer, final int offset, final int length) { + try { + out.write(buffer, offset, length); + } catch (final IOException e) { + hasErrors = true; + } + } + + @Override + public void close() { + try { + out.close(); + } catch (final IOException e) { + hasErrors = true; + } + } + + @Override + public void flush() { + try { + out.flush(); + } catch (final IOException e) { + hasErrors = true; + } + } + } + } + + private final class Entry { + private final String key; + + /** Lengths of this entry's files. */ + private final long[] lengths; + + /** True if this entry has ever been published */ + private boolean readable; + + /** The ongoing edit or null if this entry is not being edited. */ + private Editor currentEditor; + + /** + * The sequence number of the most recently committed edit to this + * entry. + */ + private long sequenceNumber; + + private Entry(final String key) { + this.key = key; + lengths = new long[valueCount]; + } + + public String getLengths() throws IOException { + final StringBuilder result = new StringBuilder(); + for (final long size : lengths) { + result.append(' ').append(size); + } + return result.toString(); + } + + /** + * Set lengths using decimal numbers like "10123". + */ + private void setLengths(final String[] strings) throws IOException { + if (strings.length != valueCount) { + throw invalidLengths(strings); + } + + try { + for (int i = 0; i < strings.length; i++) { + lengths[i] = Long.parseLong(strings[i]); + } + } catch (final NumberFormatException e) { + throw invalidLengths(strings); + } + } + + private IOException invalidLengths(final String[] strings) throws IOException { + throw new IOException("unexpected journal line: " + Arrays.toString(strings)); + } + + public File getCleanFile(final int i) { + return new File(directory, key + "." + i); + } + + public File getDirtyFile(final int i) { + return new File(directory, key + "." + i + ".tmp"); + } + } +} diff --git a/src/com/cyanogenmod/eleven/cache/ICacheListener.java b/src/com/cyanogenmod/eleven/cache/ICacheListener.java new file mode 100644 index 0000000..89b88ac --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/ICacheListener.java @@ -0,0 +1,20 @@ +/* +* Copyright (C) 2014 The CyanogenMod 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 com.cyanogenmod.eleven.cache; + +public interface ICacheListener { + void onCacheUnpaused(); +} diff --git a/src/com/cyanogenmod/eleven/cache/ImageCache.java b/src/com/cyanogenmod/eleven/cache/ImageCache.java new file mode 100644 index 0000000..0859054 --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/ImageCache.java @@ -0,0 +1,827 @@ +/* + * Copyright (C) 2012 Andrew Neal + * Copyright (C) 2014 The CyanogenMod 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 com.cyanogenmod.eleven.cache; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.Fragment; +import android.app.FragmentManager; +import android.content.ComponentCallbacks2; +import android.content.ContentUris; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Bitmap.CompressFormat; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Environment; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import com.cyanogenmod.eleven.utils.ApolloUtils; + +import java.io.File; +import java.io.FileDescriptor; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; + +/** + * This class holds the memory and disk bitmap caches. + */ +public final class ImageCache { + + private static final String TAG = ImageCache.class.getSimpleName(); + + /** + * The {@link Uri} used to retrieve album art + */ + private static final Uri mArtworkUri; + + /** + * Default memory cache size as a percent of device memory class + */ + private static final float MEM_CACHE_DIVIDER = 0.25f; + + /** + * Default disk cache size 10MB + */ + private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; + + /** + * Compression settings when writing images to disk cache + */ + private static final CompressFormat COMPRESS_FORMAT = CompressFormat.JPEG; + + /** + * Disk cache index to read from + */ + private static final int DISK_CACHE_INDEX = 0; + + /** + * Image compression quality + */ + private static final int COMPRESS_QUALITY = 98; + + /** + * LRU cache + */ + private MemoryCache mLruCache; + + /** + * Disk LRU cache + */ + private DiskLruCache mDiskCache; + + /** + * listeners to the cache state + */ + private HashSet<ICacheListener> mListeners = new HashSet<ICacheListener>(); + + private static ImageCache sInstance; + + /** + * Used to temporarily pause the disk cache while scrolling + */ + public boolean mPauseDiskAccess = false; + private Object mPauseLock = new Object(); + + static { + mArtworkUri = Uri.parse("content://media/external/audio/albumart"); + } + + /** + * Constructor of <code>ImageCache</code> + * + * @param context The {@link Context} to use + */ + public ImageCache(final Context context) { + init(context); + } + + /** + * Used to create a singleton of {@link ImageCache} + * + * @param context The {@link Context} to use + * @return A new instance of this class. + */ + public final static ImageCache getInstance(final Context context) { + if (sInstance == null) { + sInstance = new ImageCache(context.getApplicationContext()); + } + return sInstance; + } + + /** + * Initialize the cache, providing all parameters. + * + * @param context The {@link Context} to use + * @param cacheParams The cache parameters to initialize the cache + */ + private void init(final Context context) { + ApolloUtils.execute(false, new AsyncTask<Void, Void, Void>() { + + @Override + protected Void doInBackground(final Void... unused) { + // Initialize the disk cahe in a background thread + initDiskCache(context); + return null; + } + }, (Void[])null); + // Set up the memory cache + initLruCache(context); + } + + /** + * Initializes the disk cache. Note that this includes disk access so this + * should not be executed on the main/UI thread. By default an ImageCache + * does not initialize the disk cache when it is created, instead you should + * call initDiskCache() to initialize it on a background thread. + * + * @param context The {@link Context} to use + */ + private synchronized void initDiskCache(final Context context) { + // Set up disk cache + if (mDiskCache == null || mDiskCache.isClosed()) { + File diskCacheDir = getDiskCacheDir(context, TAG); + if (diskCacheDir != null) { + if (!diskCacheDir.exists()) { + diskCacheDir.mkdirs(); + } + if (getUsableSpace(diskCacheDir) > DISK_CACHE_SIZE) { + try { + mDiskCache = DiskLruCache.open(diskCacheDir, 1, 1, DISK_CACHE_SIZE); + } catch (final IOException e) { + diskCacheDir = null; + } + } + } + } + } + + /** + * Sets up the Lru cache + * + * @param context The {@link Context} to use + */ + @SuppressLint("NewApi") + public void initLruCache(final Context context) { + final ActivityManager activityManager = (ActivityManager)context + .getSystemService(Context.ACTIVITY_SERVICE); + final int lruCacheSize = Math.round(MEM_CACHE_DIVIDER * activityManager.getMemoryClass() + * 1024 * 1024); + mLruCache = new MemoryCache(lruCacheSize); + + // Release some memory as needed + context.registerComponentCallbacks(new ComponentCallbacks2() { + + /** + * {@inheritDoc} + */ + @Override + public void onTrimMemory(final int level) { + if (level >= TRIM_MEMORY_MODERATE) { + evictAll(); + } else if (level >= TRIM_MEMORY_BACKGROUND) { + mLruCache.trimToSize(mLruCache.size() / 2); + } + } + + /** + * {@inheritDoc} + */ + @Override + public void onLowMemory() { + // Nothing to do + } + + /** + * {@inheritDoc} + */ + @Override + public void onConfigurationChanged(final Configuration newConfig) { + // Nothing to do + } + }); + } + + /** + * Find and return an existing ImageCache stored in a {@link RetainFragment} + * , if not found a new one is created using the supplied params and saved + * to a {@link RetainFragment} + * + * @param activity The calling {@link FragmentActivity} + * @return An existing retained ImageCache object or a new one if one did + * not exist + */ + public static final ImageCache findOrCreateCache(final Activity activity) { + + // Search for, or create an instance of the non-UI RetainFragment + final RetainFragment retainFragment = findOrCreateRetainFragment( + activity.getFragmentManager()); + + // See if we already have an ImageCache stored in RetainFragment + ImageCache cache = (ImageCache)retainFragment.getObject(); + + // No existing ImageCache, create one and store it in RetainFragment + if (cache == null) { + cache = getInstance(activity); + retainFragment.setObject(cache); + } + return cache; + } + + /** + * Locate an existing instance of this {@link Fragment} or if not found, + * create and add it using {@link FragmentManager} + * + * @param fm The {@link FragmentManager} to use + * @return The existing instance of the {@link Fragment} or the new instance + * if just created + */ + public static final RetainFragment findOrCreateRetainFragment(final FragmentManager fm) { + // Check to see if we have retained the worker fragment + RetainFragment retainFragment = (RetainFragment)fm.findFragmentByTag(TAG); + + // If not retained, we need to create and add it + if (retainFragment == null) { + retainFragment = new RetainFragment(); + fm.beginTransaction().add(retainFragment, TAG).commit(); + } + return retainFragment; + } + + /** + * Adds a new image to the memory and disk caches + * + * @param data The key used to store the image + * @param bitmap The {@link Bitmap} to cache + */ + public void addBitmapToCache(final String data, final Bitmap bitmap) { + addBitmapToCache(data, bitmap, false); + } + + /** + * Adds a new image to the memory and disk caches + * + * @param data The key used to store the image + * @param bitmap The {@link Bitmap} to cache + * @param replace force a replace even if the bitmap exists in the cache + */ + public void addBitmapToCache(final String data, final Bitmap bitmap, final boolean replace) { + if (data == null || bitmap == null) { + return; + } + + // Add to memory cache + addBitmapToMemCache(data, bitmap, replace); + + // Add to disk cache + if (mDiskCache != null && !mDiskCache.isClosed()) { + final String key = hashKeyForDisk(data); + OutputStream out = null; + try { + final DiskLruCache.Snapshot snapshot = mDiskCache.get(key); + if (snapshot != null) { + snapshot.getInputStream(DISK_CACHE_INDEX).close(); + } + + if (snapshot == null || replace) { + final DiskLruCache.Editor editor = mDiskCache.edit(key); + if (editor != null) { + out = editor.newOutputStream(DISK_CACHE_INDEX); + bitmap.compress(COMPRESS_FORMAT, COMPRESS_QUALITY, out); + editor.commit(); + out.close(); + flush(); + } + } + } catch (final IOException e) { + Log.e(TAG, "addBitmapToCache - " + e); + } catch (final IllegalStateException e) { + // if the user clears the cache while we have an async task going we could try + // writing to the disk cache while it isn't ready. Catching here will silently + // fail instead + Log.e(TAG, "addBitmapToCache - " + e); + } finally { + try { + if (out != null) { + out.close(); + out = null; + } + } catch (final IOException e) { + Log.e(TAG, "addBitmapToCache - " + e); + } catch (final IllegalStateException e) { + Log.e(TAG, "addBitmapToCache - " + e); + } + } + } + } + + /** + * Called to add a new image to the memory cache + * + * @param data The key identifier + * @param bitmap The {@link Bitmap} to cache + */ + public void addBitmapToMemCache(final String data, final Bitmap bitmap) { + addBitmapToMemCache(data, bitmap, false); + } + + /** + * Called to add a new image to the memory cache + * + * @param data The key identifier + * @param bitmap The {@link Bitmap} to cache + * @param replace whether to force a replace if it already exists + */ + public void addBitmapToMemCache(final String data, final Bitmap bitmap, final boolean replace) { + if (data == null || bitmap == null) { + return; + } + // Add to memory cache + if (replace || getBitmapFromMemCache(data) == null) { + mLruCache.put(data, bitmap); + } + } + + /** + * Fetches a cached image from the memory cache + * + * @param data Unique identifier for which item to get + * @return The {@link Bitmap} if found in cache, null otherwise + */ + public final Bitmap getBitmapFromMemCache(final String data) { + if (data == null) { + return null; + } + if (mLruCache != null) { + final Bitmap lruBitmap = mLruCache.get(data); + if (lruBitmap != null) { + return lruBitmap; + } + } + return null; + } + + /** + * Fetches a cached image from the disk cache + * + * @param data Unique identifier for which item to get + * @return The {@link Bitmap} if found in cache, null otherwise + */ + public final Bitmap getBitmapFromDiskCache(final String data) { + if (data == null) { + return null; + } + + // Check in the memory cache here to avoid going to the disk cache less + // often + if (getBitmapFromMemCache(data) != null) { + return getBitmapFromMemCache(data); + } + + waitUntilUnpaused(); + final String key = hashKeyForDisk(data); + if (mDiskCache != null) { + InputStream inputStream = null; + try { + final DiskLruCache.Snapshot snapshot = mDiskCache.get(key); + if (snapshot != null) { + inputStream = snapshot.getInputStream(DISK_CACHE_INDEX); + if (inputStream != null) { + final Bitmap bitmap = BitmapFactory.decodeStream(inputStream); + if (bitmap != null) { + return bitmap; + } + } + } + } catch (final IOException e) { + Log.e(TAG, "getBitmapFromDiskCache - " + e); + } finally { + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (final IOException e) { + } + } + } + return null; + } + + /** + * Tries to return a cached image from memory cache before fetching from the + * disk cache + * + * @param data Unique identifier for which item to get + * @return The {@link Bitmap} if found in cache, null otherwise + */ + public final Bitmap getCachedBitmap(final String data) { + if (data == null) { + return null; + } + Bitmap cachedImage = getBitmapFromMemCache(data); + if (cachedImage == null) { + cachedImage = getBitmapFromDiskCache(data); + } + if (cachedImage != null) { + addBitmapToMemCache(data, cachedImage); + return cachedImage; + } + return null; + } + + /** + * Tries to return the album art from memory cache and disk cache, before + * calling {@code #getArtworkFromFile(Context, String)} again + * + * @param context The {@link Context} to use + * @param data The name of the album art + * @param id The ID of the album to find artwork for + * @return The artwork for an album + */ + public final Bitmap getCachedArtwork(final Context context, final String data, final long id) { + if (context == null || data == null) { + return null; + } + Bitmap cachedImage = getCachedBitmap(data); + if (cachedImage == null && id >= 0) { + cachedImage = getArtworkFromFile(context, id); + } + if (cachedImage != null) { + addBitmapToMemCache(data, cachedImage); + return cachedImage; + } + return null; + } + + /** + * Used to fetch the artwork for an album locally from the user's device + * + * @param context The {@link Context} to use + * @param albumID The ID of the album to find artwork for + * @return The artwork for an album + */ + public final Bitmap getArtworkFromFile(final Context context, final long albumId) { + if (albumId < 0) { + return null; + } + Bitmap artwork = null; + waitUntilUnpaused(); + try { + final Uri uri = ContentUris.withAppendedId(mArtworkUri, albumId); + final ParcelFileDescriptor parcelFileDescriptor = context.getContentResolver() + .openFileDescriptor(uri, "r"); + if (parcelFileDescriptor != null) { + final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); + artwork = BitmapFactory.decodeFileDescriptor(fileDescriptor); + } + } catch (final IllegalStateException e) { + // Log.e(TAG, "IllegalStateExcetpion - getArtworkFromFile - ", e); + } catch (final FileNotFoundException e) { + // Log.e(TAG, "FileNotFoundException - getArtworkFromFile - ", e); + } catch (final OutOfMemoryError evict) { + // Log.e(TAG, "OutOfMemoryError - getArtworkFromFile - ", evict); + evictAll(); + } + return artwork; + } + + /** + * flush() is called to synchronize up other methods that are accessing the + * cache first + */ + public void flush() { + ApolloUtils.execute(false, new AsyncTask<Void, Void, Void>() { + + @Override + protected Void doInBackground(final Void... unused) { + if (mDiskCache != null) { + try { + if (!mDiskCache.isClosed()) { + mDiskCache.flush(); + } + } catch (final IOException e) { + Log.e(TAG, "flush - " + e); + } + } + return null; + } + }, (Void[])null); + } + + /** + * Clears the disk and memory caches + */ + public void clearCaches() { + ApolloUtils.execute(false, new AsyncTask<Void, Void, Void>() { + + @Override + protected Void doInBackground(final Void... unused) { + // Clear the disk cache + try { + if (mDiskCache != null) { + mDiskCache.delete(); + mDiskCache = null; + } + } catch (final IOException e) { + Log.e(TAG, "clearCaches - " + e); + } + // Clear the memory cache + evictAll(); + return null; + } + }, (Void[])null); + } + + /** + * Closes the disk cache associated with this ImageCache object. Note that + * this includes disk access so this should not be executed on the main/UI + * thread. + */ + public void close() { + ApolloUtils.execute(false, new AsyncTask<Void, Void, Void>() { + + @Override + protected Void doInBackground(final Void... unused) { + if (mDiskCache != null) { + try { + if (!mDiskCache.isClosed()) { + mDiskCache.close(); + mDiskCache = null; + } + } catch (final IOException e) { + Log.e(TAG, "close - " + e); + } + } + return null; + } + }, (Void[]) null); + } + + /** + * Evicts all of the items from the memory cache and lets the system know + * now would be a good time to garbage collect + */ + public void evictAll() { + if (mLruCache != null) { + mLruCache.evictAll(); + } + System.gc(); + } + + /** + * @param key The key used to identify which cache entries to delete. + */ + public void removeFromCache(final String key) { + if (key == null) { + return; + } + // Remove the Lru entry + if (mLruCache != null) { + mLruCache.remove(key); + } + + try { + // Remove the disk entry + if (mDiskCache != null) { + mDiskCache.remove(hashKeyForDisk(key)); + } + } catch (final IOException e) { + Log.e(TAG, "remove - " + e); + } + flush(); + } + + /** + * Used to temporarily pause the disk cache while the user is scrolling to + * improve scrolling. + * + * @param pause True to temporarily pause the disk cache, false otherwise. + */ + public void setPauseDiskCache(final boolean pause) { + synchronized (mPauseLock) { + if (mPauseDiskAccess != pause) { + mPauseDiskAccess = pause; + if (!pause) { + mPauseLock.notify(); + + for (ICacheListener listener : mListeners) { + listener.onCacheUnpaused(); + } + } + } + } + } + + private void waitUntilUnpaused() { + synchronized (mPauseLock) { + if (Looper.myLooper() != Looper.getMainLooper()) { + while (mPauseDiskAccess) { + try { + mPauseLock.wait(); + } catch (InterruptedException e) { + // ignored, we'll start waiting again + } + } + } + } + } + + /** + * @return True if the user is scrolling, false otherwise. + */ + public boolean isDiskCachePaused() { + return mPauseDiskAccess; + } + + public void addCacheListener(ICacheListener listener) { + mListeners.add(listener); + } + + public void removeCacheListener(ICacheListener listener) { + mListeners.remove(listener); + } + + /** + * Get a usable cache directory (external if available, internal otherwise) + * + * @param context The {@link Context} to use + * @param uniqueName A unique directory name to append to the cache + * directory + * @return The cache directory + */ + public static final File getDiskCacheDir(final Context context, final String uniqueName) { + // getExternalCacheDir(context) returns null if external storage is not ready + final String cachePath = getExternalCacheDir(context) != null + ? getExternalCacheDir(context).getPath() + : context.getCacheDir().getPath(); + return new File(cachePath, uniqueName); + } + + /** + * Check if external storage is built-in or removable + * + * @return True if external storage is removable (like an SD card), false + * otherwise + */ + public static final boolean isExternalStorageRemovable() { + return Environment.isExternalStorageRemovable(); + } + + /** + * Get the external app cache directory + * + * @param context The {@link Context} to use + * @return The external cache directory + */ + public static final File getExternalCacheDir(final Context context) { + return context.getExternalCacheDir(); + } + + /** + * Check how much usable space is available at a given path. + * + * @param path The path to check + * @return The space available in bytes + */ + public static final long getUsableSpace(final File path) { + return path.getUsableSpace(); + } + + /** + * A hashing method that changes a string (like a URL) into a hash suitable + * for using as a disk filename. + * + * @param key The key used to store the file + */ + public static final String hashKeyForDisk(final String key) { + String cacheKey; + try { + final MessageDigest digest = MessageDigest.getInstance("MD5"); + digest.update(key.getBytes()); + cacheKey = bytesToHexString(digest.digest()); + } catch (final NoSuchAlgorithmException e) { + cacheKey = String.valueOf(key.hashCode()); + } + return cacheKey; + } + + /** + * http://stackoverflow.com/questions/332079 + * + * @param bytes The bytes to convert. + * @return A {@link String} converted from the bytes of a hashable key used + * to store a filename on the disk, to hex digits. + */ + private static final String bytesToHexString(final byte[] bytes) { + final StringBuilder builder = new StringBuilder(); + for (final byte b : bytes) { + final String hex = Integer.toHexString(0xFF & b); + if (hex.length() == 1) { + builder.append('0'); + } + builder.append(hex); + } + return builder.toString(); + } + + /** + * A simple non-UI Fragment that stores a single Object and is retained over + * configuration changes. In this sample it will be used to retain an + * {@link ImageCache} object. + */ + public static final class RetainFragment extends Fragment { + + /** + * The object to be stored + */ + private Object mObject; + + /** + * Empty constructor as per the {@link Fragment} documentation + */ + public RetainFragment() { + } + + /** + * {@inheritDoc} + */ + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // Make sure this Fragment is retained over a configuration change + setRetainInstance(true); + } + + /** + * Store a single object in this {@link Fragment} + * + * @param object The object to store + */ + public void setObject(final Object object) { + mObject = object; + } + + /** + * Get the stored object + * + * @return The stored object + */ + public Object getObject() { + return mObject; + } + } + + /** + * Used to cache images via {@link LruCache}. + */ + public static final class MemoryCache extends LruCache<String, Bitmap> { + + /** + * Constructor of <code>MemoryCache</code> + * + * @param maxSize The allowed size of the {@link LruCache} + */ + public MemoryCache(final int maxSize) { + super(maxSize); + } + + /** + * Get the size in bytes of a bitmap. + */ + public static final int getBitmapSize(final Bitmap bitmap) { + return bitmap.getByteCount(); + } + + /** + * {@inheritDoc} + */ + @Override + protected int sizeOf(final String paramString, final Bitmap paramBitmap) { + return getBitmapSize(paramBitmap); + } + + } + +} diff --git a/src/com/cyanogenmod/eleven/cache/ImageFetcher.java b/src/com/cyanogenmod/eleven/cache/ImageFetcher.java new file mode 100644 index 0000000..d6300bd --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/ImageFetcher.java @@ -0,0 +1,322 @@ +/* + * Copyright (C) 2012 Andrew Neal + * Copyright (C) 2014 The CyanogenMod 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 com.cyanogenmod.eleven.cache; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import android.widget.ImageView; +import com.cyanogenmod.eleven.Config; +import com.cyanogenmod.eleven.MusicPlaybackService; +import com.cyanogenmod.eleven.cache.PlaylistWorkerTask.PlaylistWorkerType; +import com.cyanogenmod.eleven.utils.MusicUtils; +import com.cyanogenmod.eleven.widgets.BlurScrimImage; +import com.cyanogenmod.eleven.widgets.LetterTileDrawable; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +/** + * A subclass of {@link ImageWorker} that fetches images from a URL. + */ +public class ImageFetcher extends ImageWorker { + + private static final int DEFAULT_MAX_IMAGE_HEIGHT = 1024; + + private static final int DEFAULT_MAX_IMAGE_WIDTH = 1024; + + private static ImageFetcher sInstance = null; + + /** + * Creates a new instance of {@link ImageFetcher}. + * + * @param context The {@link Context} to use. + */ + public ImageFetcher(final Context context) { + super(context); + } + + /** + * Used to create a singleton of the image fetcher + * + * @param context The {@link Context} to use + * @return A new instance of this class. + */ + public static final ImageFetcher getInstance(final Context context) { + if (sInstance == null) { + sInstance = new ImageFetcher(context.getApplicationContext()); + } + return sInstance; + } + + /** + * Loads a playlist's most played song's artist image + * @param playlistId id of the playlist + * @param imageView imageview to load into + */ + public void loadPlaylistArtistImage(final long playlistId, final ImageView imageView) { + loadPlaylistImage(playlistId, PlaylistWorkerType.Artist, imageView); + } + + /** + * Loads a playlist's most played songs into a combined image, or show 1 if not enough images + * @param playlistId id of the playlist + * @param imageView imageview to load into + */ + public void loadPlaylistCoverArtImage(final long playlistId, final ImageView imageView) { + loadPlaylistImage(playlistId, PlaylistWorkerType.CoverArt, imageView); + } + + /** + * Used to fetch album images. + */ + public void loadAlbumImage(final String artistName, final String albumName, final long albumId, + final ImageView imageView) { + loadImage(generateAlbumCacheKey(albumName, artistName), artistName, albumName, albumId, imageView, + ImageType.ALBUM); + } + + /** + * Used to fetch the current artwork. + */ + public void loadCurrentArtwork(final ImageView imageView) { + loadImage(getCurrentCacheKey(), + MusicUtils.getArtistName(), MusicUtils.getAlbumName(), MusicUtils.getCurrentAlbumId(), + imageView, ImageType.ALBUM); + } + + /** + * Used to fetch the current artwork blurred. + */ + public void loadCurrentBlurredArtwork(final BlurScrimImage image) { + loadBlurImage(getCurrentCacheKey(), + MusicUtils.getArtistName(), MusicUtils.getAlbumName(), MusicUtils.getCurrentAlbumId(), + image, ImageType.ALBUM); + } + + public static String getCurrentCacheKey() { + return generateAlbumCacheKey(MusicUtils.getAlbumName(), MusicUtils.getArtistName()); + } + + /** + * Used to fetch artist images. + */ + public void loadArtistImage(final String key, final ImageView imageView) { + loadImage(key, key, null, -1, imageView, ImageType.ARTIST); + } + + /** + * Used to fetch artist images. It also scales the image to fit the image view, if necessary. + */ + public void loadArtistImage(final String key, final ImageView imageView, boolean scaleImgToView) { + loadImage(key, key, null, -1, imageView, ImageType.ARTIST, scaleImgToView); + } + + /** + * Used to fetch the current artist image. + */ + public void loadCurrentArtistImage(final ImageView imageView) { + loadImage(MusicUtils.getArtistName(), MusicUtils.getArtistName(), null, -1, imageView, + ImageType.ARTIST); + } + + /** + * @param pause True to temporarily pause the disk cache, false otherwise. + */ + public void setPauseDiskCache(final boolean pause) { + if (mImageCache != null) { + mImageCache.setPauseDiskCache(pause); + } + } + + /** + * Clears the disk and memory caches + */ + public void clearCaches() { + if (mImageCache != null) { + mImageCache.clearCaches(); + } + + // clear the keys of images we've already downloaded + sKeys.clear(); + } + + public void addCacheListener(ICacheListener listener) { + if (mImageCache != null) { + mImageCache.addCacheListener(listener); + } + } + + public void removeCacheListener(ICacheListener listener) { + if (mImageCache != null) { + mImageCache.removeCacheListener(listener); + } + } + + /** + * @param key The key used to find the image to remove + */ + public void removeFromCache(final String key) { + if (mImageCache != null) { + mImageCache.removeFromCache(key); + } + } + + /** + * Finds cached or downloads album art. Used in {@link MusicPlaybackService} + * to set the current album art in the notification and lock screen + * + * @param albumName The name of the current album + * @param albumId The ID of the current album + * @param artistName The album artist in case we should have to download + * missing artwork + * @param smallArtwork Get the small version of the default artwork if no artwork exists + * @return The album art as an {@link Bitmap} + */ + public Bitmap getArtwork(final String albumName, final long albumId, final String artistName, + boolean smallArtwork) { + // Check the disk cache + Bitmap artwork = null; + String key = albumName; + + if (artwork == null && albumName != null && mImageCache != null) { + key = generateAlbumCacheKey(albumName, artistName); + artwork = mImageCache.getBitmapFromDiskCache(key); + } + if (artwork == null && albumId >= 0 && mImageCache != null) { + // Check for local artwork + artwork = mImageCache.getArtworkFromFile(mContext, albumId); + } + if (artwork != null) { + return artwork; + } + + return LetterTileDrawable.createDefaultBitmap(mContext, key, ImageType.ALBUM, false, + smallArtwork); + } + + /** + * Generates key used by album art cache. It needs both album name and artist name + * to let to select correct image for the case when there are two albums with the + * same artist. + * + * @param albumName The album name the cache key needs to be generated. + * @param artistName The artist name the cache key needs to be generated. + * @return + */ + public static String generateAlbumCacheKey(final String albumName, final String artistName) { + if (albumName == null || artistName == null) { + return null; + } + return new StringBuilder(albumName) + .append("_") + .append(artistName) + .append("_") + .append(Config.ALBUM_ART_SUFFIX) + .toString(); + } + + /** + * Decode and sample down a {@link Bitmap} from a Uri. + * + * @param selectedImage Uri of the Image to decode + * @return A {@link Bitmap} sampled down from the original with the same + * aspect ratio and dimensions that are equal to or greater than the + * requested width and height + */ + public static Bitmap decodeSampledBitmapFromUri(ContentResolver cr, final Uri selectedImage) { + // First decode with inJustDecodeBounds=true to check dimensions + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + + try { + InputStream input = cr.openInputStream(selectedImage); + BitmapFactory.decodeStream(input, null, options); + input.close(); + + if (options.outHeight == -1 || options.outWidth == -1) { + return null; + } + + // Calculate inSampleSize + options.inSampleSize = calculateInSampleSize(options, DEFAULT_MAX_IMAGE_WIDTH, + DEFAULT_MAX_IMAGE_HEIGHT); + + // Decode bitmap with inSampleSize set + options.inJustDecodeBounds = false; + input = cr.openInputStream(selectedImage); + return BitmapFactory.decodeStream(input, null, options); + } catch (FileNotFoundException e) { + e.printStackTrace(); + return null; + } catch (IOException e) { + e.printStackTrace(); + } + + return null; + } + + /** + * Calculate an inSampleSize for use in a + * {@link android.graphics.BitmapFactory.Options} object when decoding + * bitmaps using the decode* methods from {@link BitmapFactory}. This + * implementation calculates the closest inSampleSize that will result in + * the final decoded bitmap having a width and height equal to or larger + * than the requested width and height. This implementation does not ensure + * a power of 2 is returned for inSampleSize which can be faster when + * decoding but results in a larger bitmap which isn't as useful for caching + * purposes. + * + * @param options An options object with out* params already populated (run + * through a decode* method with inJustDecodeBounds==true + * @param reqWidth The requested width of the resulting bitmap + * @param reqHeight The requested height of the resulting bitmap + * @return The value to be used for inSampleSize + */ + public static final int calculateInSampleSize(final BitmapFactory.Options options, + final int reqWidth, final int reqHeight) { + /* Raw height and width of image */ + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + if (width > height) { + inSampleSize = Math.round((float)height / (float)reqHeight); + } else { + inSampleSize = Math.round((float)width / (float)reqWidth); + } + + // This offers some additional logic in case the image has a strange + // aspect ratio. For example, a panorama may have a much larger + // width than height. In these cases the total pixels might still + // end up being too large to fit comfortably in memory, so we should + // be more aggressive with sample down the image (=larger + // inSampleSize). + + final float totalPixels = width * height; + + /* More than 2x the requested pixels we'll sample down further */ + final float totalReqPixelsCap = reqWidth * reqHeight * 2; + + while (totalPixels / (inSampleSize * inSampleSize) > totalReqPixelsCap) { + inSampleSize++; + } + } + return inSampleSize; + } +} diff --git a/src/com/cyanogenmod/eleven/cache/ImageWorker.java b/src/com/cyanogenmod/eleven/cache/ImageWorker.java new file mode 100644 index 0000000..e8ea9d8 --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/ImageWorker.java @@ -0,0 +1,586 @@ +/* + * Copyright (C) 2012 Andrew Neal + * Copyright (C) 2014 The CyanogenMod 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 com.cyanogenmod.eleven.cache; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.support.v8.renderscript.RenderScript; +import android.text.TextUtils; +import android.view.View; +import android.widget.ImageView; + +import com.cyanogenmod.eleven.R; +import com.cyanogenmod.eleven.provider.PlaylistArtworkStore; +import com.cyanogenmod.eleven.utils.ApolloUtils; +import com.cyanogenmod.eleven.utils.ImageUtils; +import com.cyanogenmod.eleven.widgets.BlurScrimImage; +import com.cyanogenmod.eleven.cache.PlaylistWorkerTask.PlaylistWorkerType; +import com.cyanogenmod.eleven.widgets.LetterTileDrawable; + +import java.lang.ref.WeakReference; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.RejectedExecutionException; + +/** + * This class wraps up completing some arbitrary long running work when loading + * a {@link Bitmap} to an {@link ImageView}. It handles things like using a + * memory and disk cache, running the work in a background thread and setting a + * placeholder image. + */ +public abstract class ImageWorker { + + /** + * Render script + */ + public static RenderScript sRenderScript = null; + + /** + * Tracks which images we've tried to download and prevents it from trying again + * In the future we might want to throw this into a db + */ + public static Set<String> sKeys = Collections.synchronizedSet(new HashSet<String>()); + + /** + * Default transition drawable fade time + */ + public static final int FADE_IN_TIME = 200; + + /** + * Default transition drawable fade time slow + */ + public static final int FADE_IN_TIME_SLOW = 1000; + + /** + * The resources to use + */ + private final Resources mResources; + + /** + * First layer of the transition drawable + */ + private final ColorDrawable mTransparentDrawable; + + /** + * The Context to use + */ + protected Context mContext; + + /** + * Disk and memory caches + */ + protected ImageCache mImageCache; + + /** + * Constructor of <code>ImageWorker</code> + * + * @param context The {@link Context} to use + */ + protected ImageWorker(final Context context) { + mContext = context.getApplicationContext(); + + if (sRenderScript == null) { + sRenderScript = RenderScript.create(mContext); + } + + mResources = mContext.getResources(); + // Create the transparent layer for the transition drawable + mTransparentDrawable = new ColorDrawable(Color.TRANSPARENT); + } + + /** + * Set the {@link ImageCache} object to use with this ImageWorker. + * + * @param cacheCallback new {@link ImageCache} object. + */ + public void setImageCache(final ImageCache cacheCallback) { + mImageCache = cacheCallback; + } + + /** + * Closes the disk cache associated with this ImageCache object. Note that + * this includes disk access so this should not be executed on the main/UI + * thread. + */ + public void close() { + if (mImageCache != null) { + mImageCache.close(); + } + } + + /** + * flush() is called to synchronize up other methods that are accessing the + * cache first + */ + public void flush() { + if (mImageCache != null) { + mImageCache.flush(); + } + } + + /** + * Adds a new image to the memory and disk caches + * + * @param data The key used to store the image + * @param bitmap The {@link Bitmap} to cache + */ + public void addBitmapToCache(final String key, final Bitmap bitmap) { + if (mImageCache != null) { + mImageCache.addBitmapToCache(key, bitmap); + } + } + + /** + * @return A new drawable of the default artwork + */ + public Drawable getNewDrawable(ImageType imageType, String name, + String identifier) { + LetterTileDrawable letterTileDrawable = new LetterTileDrawable(mContext); + letterTileDrawable.setTileDetails(name, identifier, imageType); + letterTileDrawable.setIsCircular(false); + return letterTileDrawable; + } + + public static Bitmap getBitmapInBackground(final Context context, final ImageCache imageCache, + final String key, final String albumName, final String artistName, + final long albumId, final ImageType imageType) { + // The result + Bitmap bitmap = null; + + // First, check the disk cache for the image + if (key != null && imageCache != null) { + bitmap = imageCache.getCachedBitmap(key); + } + + // Second, if we're fetching artwork, check the device for the image + if (bitmap == null && imageType.equals(ImageType.ALBUM) && albumId >= 0 + && key != null && imageCache != null) { + bitmap = imageCache.getCachedArtwork(context, key, albumId); + } + + // Third, by now we need to download the image + if (bitmap == null && ApolloUtils.isOnline(context) && !sKeys.contains(key)) { + // Now define what the artist name, album name, and url are. + String url = ImageUtils.processImageUrl(context, artistName, albumName, imageType); + if (url != null) { + bitmap = ImageUtils.processBitmap(context, url); + } + } + + // Fourth, add the new image to the cache + if (bitmap != null && key != null && imageCache != null) { + imageCache.addBitmapToCache(key, bitmap); + } + + sKeys.add(key); + + return bitmap; + } + + /** + * Parses the drawable for instances of TransitionDrawable and breaks them open until it finds + * a drawable that isn't a transition drawable + * @param drawable to parse + * @return the target drawable that isn't a TransitionDrawable + */ + public static Drawable getTopDrawable(final Drawable drawable) { + if (drawable == null) { + return null; + } + + Drawable retDrawable = drawable; + while (retDrawable instanceof TransitionDrawable) { + TransitionDrawable transition = (TransitionDrawable) retDrawable; + retDrawable = transition.getDrawable(transition.getNumberOfLayers() - 1); + } + + return retDrawable; + } + + /** + * Creates a transition drawable to Bitmap with params + * @param resources Android Resources! + * @param fromDrawable the drawable to transition from + * @param bitmap the bitmap to transition to + * @param fadeTime the fade time in MS to fade in + * @param dither setting + * @param force force create a transition even if bitmap == null (fade to transparent) + * @return the drawable if created, null otherwise + */ + public static TransitionDrawable createImageTransitionDrawable(final Resources resources, + final Drawable fromDrawable, final Bitmap bitmap, final int fadeTime, + final boolean dither, final boolean force) { + if (bitmap != null || force) { + final Drawable[] arrayDrawable = new Drawable[2]; + arrayDrawable[0] = getTopDrawable(fromDrawable); + + // Add the transition to drawable + Drawable layerTwo; + if (bitmap != null) { + layerTwo = new BitmapDrawable(resources, bitmap); + layerTwo.setFilterBitmap(false); + layerTwo.setDither(dither); + } else { + // if no bitmap (forced) then transition to transparent + layerTwo = new ColorDrawable(Color.TRANSPARENT); + } + + arrayDrawable[1] = layerTwo; + + // Finally, return the image + final TransitionDrawable result = new TransitionDrawable(arrayDrawable); + result.setCrossFadeEnabled(true); + result.startTransition(fadeTime); + return result; + } + + return null; + } + + /** + * This will create the palette transition from the original color to the new one + * @param scrimImage the container to change the color for + * @param color the color to transition to + * @return the transition to run + */ + public static TransitionDrawable createPaletteTransition(BlurScrimImage scrimImage, int color) { + final Drawable[] arrayDrawable = new Drawable[2]; + arrayDrawable[0] = getTopDrawable(scrimImage.getBackground()); + + if (arrayDrawable[0] == null) { + arrayDrawable[0] = new ColorDrawable(Color.TRANSPARENT); + } + + arrayDrawable[1] = new ColorDrawable(color); + + // create the transition + final TransitionDrawable result = new TransitionDrawable(arrayDrawable); + result.setCrossFadeEnabled(true); + result.startTransition(FADE_IN_TIME_SLOW); + return result; + } + + /** + * Cancels and clears out any pending bitmap worker tasks on this image view + * @param image ImageView/BlurScrimImage to check + */ + public static final void cancelWork(final View image) { + Object tag = image.getTag(); + if (tag != null && tag instanceof AsyncTaskContainer) { + AsyncTaskContainer asyncTaskContainer = (AsyncTaskContainer)tag; + BitmapWorkerTask bitmapWorkerTask = asyncTaskContainer.getBitmapWorkerTask(); + if (bitmapWorkerTask != null) { + bitmapWorkerTask.cancel(false); + } + + // clear out the tag + image.setTag(null); + } + } + + /** + * Returns false if the existing async task is loading the same key value + * Returns true otherwise and also cancels the async task if one exists + */ + public static final boolean executePotentialWork(final String key, final View view) { + final AsyncTaskContainer asyncTaskContainer = getAsyncTaskContainer(view); + if (asyncTaskContainer != null) { + // we are trying to reload the same image, return false to indicate no work is needed + if (asyncTaskContainer.getKey().equals(key)) { + return false; + } + + // since we don't match, cancel the work and switch to the new worker task + cancelWork(view); + } + + return true; + } + + /** + * Used to determine if the current image drawable has an instance of + * {@link AsyncTaskContainer} + * + * @param view Any {@link View} that either is or contains an ImageView. + * @return Retrieve the AsyncTaskContainer assigned to the {@link View}. null if there is no + * such task. + */ + public static final AsyncTaskContainer getAsyncTaskContainer(final View view) { + if (view != null) { + if (view.getTag() instanceof AsyncTaskContainer) { + return (AsyncTaskContainer) view.getTag(); + } + } + + return null; + } + + /** + * Used to determine if the current image drawable has an instance of + * {@link BitmapWorkerTask}. A {@link BitmapWorkerTask} may not exist even if the {@link + * AsyncTaskContainer} does as it may have finished its work + * + * @param view Any {@link View} that either is or contains an ImageView. + * @return Retrieve the currently active work task (if any) associated with + * this {@link View}. null if there is no such task. + */ + public static final BitmapWorkerTask getBitmapWorkerTask(final View view) { + AsyncTaskContainer asyncTask = getAsyncTaskContainer(view); + if (asyncTask != null) { + return asyncTask.getBitmapWorkerTask(); + } + + return null; + } + + /** + * A custom {@link BitmapDrawable} that will be attached to the + * {@link View} which either is or contains an {@link ImageView} while the work is in progress. + * Contains a reference to the actual worker task, so that it can be stopped if a new binding is + * required, and makes sure that only the last started worker process can + * bind its result, independently of the finish order. + */ + public static final class AsyncTaskContainer { + + private final WeakReference<BitmapWorkerTask> mBitmapWorkerTaskReference; + // keep a copy of the key in case the worker task mBitmapWorkerTaskReference is released + // after completion + private String mKey; + + /** + * Constructor of <code>AsyncDrawable</code> + */ + public AsyncTaskContainer(final BitmapWorkerTask bitmapWorkerTask) { + mBitmapWorkerTaskReference = new WeakReference<BitmapWorkerTask>(bitmapWorkerTask); + mKey = bitmapWorkerTask.mKey; + } + + /** + * @return The {@link BitmapWorkerTask} associated with this drawable + */ + public BitmapWorkerTask getBitmapWorkerTask() { + return mBitmapWorkerTaskReference.get(); + } + + public String getKey() { + return mKey; + } + } + + /** + * Loads the default image into the image view given the image type + * @param imageView The {@link ImageView} + * @param imageType The type of image + */ + public void loadDefaultImage(final ImageView imageView, final ImageType imageType, + final String name, final String identifier) { + if (imageView != null) { + // if an existing letter drawable exists, re-use it + Drawable existingDrawable = imageView.getDrawable(); + if (existingDrawable != null && existingDrawable instanceof LetterTileDrawable) { + ((LetterTileDrawable)existingDrawable).setTileDetails(name, identifier, imageType); + } else { + imageView.setImageDrawable(getNewDrawable(imageType, name, + identifier)); + } + } + } + + /** + * Called to fetch the artist or album art. + * + * @param key The unique identifier for the image. + * @param artistName The artist name for the Last.fm API. + * @param albumName The album name for the Last.fm API. + * @param albumId The album art index, to check for missing artwork. + * @param imageView The {@link ImageView} used to set the cached + * {@link Bitmap}. + * @param imageType The type of image URL to fetch for. + */ + protected void loadImage(final String key, final String artistName, final String albumName, + final long albumId, final ImageView imageView, final ImageType imageType) { + + loadImage(key, artistName, albumName, albumId, imageView, imageType, false); + } + + /** + * Called to fetch the artist or album art. + * + * @param key The unique identifier for the image. + * @param artistName The artist name for the Last.fm API. + * @param albumName The album name for the Last.fm API. + * @param albumId The album art index, to check for missing artwork. + * @param imageView The {@link ImageView} used to set the cached + * {@link Bitmap}. + * @param imageType The type of image URL to fetch for. + * @param scaleImgToView config option to scale the image to the image view's dimensions + */ + protected void loadImage(final String key, final String artistName, final String albumName, + final long albumId, final ImageView imageView, + final ImageType imageType, final boolean scaleImgToView) { + + if (key == null || mImageCache == null || imageView == null) { + return; + } + + // First, check the memory for the image + final Bitmap lruBitmap = mImageCache.getBitmapFromMemCache(key); + if (lruBitmap != null) { // Bitmap found in memory cache + // scale image if necessary + if (scaleImgToView) { + imageView.setImageBitmap(ImageUtils.scaleBitmapForImageView(lruBitmap, imageView)); + } else { + imageView.setImageBitmap(lruBitmap); + } + } else { + // load the default image + if (imageType == ImageType.ARTIST) { + loadDefaultImage(imageView, imageType, artistName, key); + } else if (imageType == ImageType.ALBUM) { + // don't show letters for albums so pass in null as the display string + // because an album could have multiple artists, use the album id as the key here + loadDefaultImage(imageView, imageType, null, String.valueOf(albumId)); + } else { + // don't show letters for playlists so pass in null as the display string + loadDefaultImage(imageView, imageType, null, key); + } + + if (executePotentialWork(key, imageView) + && imageView != null && !mImageCache.isDiskCachePaused()) { + Drawable fromDrawable = imageView.getDrawable(); + if (fromDrawable == null) { + fromDrawable = mTransparentDrawable; + } + + // Otherwise run the worker task + final SimpleBitmapWorkerTask bitmapWorkerTask = new SimpleBitmapWorkerTask(key, + imageView, imageType, fromDrawable, mContext, scaleImgToView); + + final AsyncTaskContainer asyncTaskContainer = new AsyncTaskContainer(bitmapWorkerTask); + imageView.setTag(asyncTaskContainer); + try { + ApolloUtils.execute(false, bitmapWorkerTask, + artistName, albumName, String.valueOf(albumId)); + } catch (RejectedExecutionException e) { + // Executor has exhausted queue space + } + } + } + } + + + /** + * Called to fetch a playlist's top artist or cover art + * @param playlistId playlist identifier + * @param type of work to get (Artist or CoverArt) + * @param imageView to set the image to + */ + public void loadPlaylistImage(final long playlistId, final PlaylistWorkerType type, + final ImageView imageView) { + if (mImageCache == null || imageView == null) { + return; + } + + String key = null; + switch (type) { + case Artist: + key = PlaylistArtworkStore.getArtistCacheKey(playlistId); + break; + case CoverArt: + key = PlaylistArtworkStore.getCoverCacheKey(playlistId); + break; + } + + // First, check the memory for the image + final Bitmap lruBitmap = mImageCache.getBitmapFromMemCache(key); + if (lruBitmap != null) { + // Bitmap found in memory cache + imageView.setImageBitmap(lruBitmap); + } else { + // load the default image + loadDefaultImage(imageView, ImageType.PLAYLIST, null, String.valueOf(playlistId)); + } + + // even though we may have found the image in the cache, we want to check if the playlist + // has been updated, or it's been too long since the last update and change the image + // accordingly + if (executePotentialWork(key, imageView) && !mImageCache.isDiskCachePaused()) { + // since a playlist's image can change based on changes to the playlist + // set the from drawable to be the existing image (if it exists) instead of transparent + // and fade from there + Drawable fromDrawable = imageView.getDrawable(); + if (fromDrawable == null) { + fromDrawable = mTransparentDrawable; + } + + // Otherwise run the worker task + final PlaylistWorkerTask bitmapWorkerTask = new PlaylistWorkerTask(key, playlistId, type, + lruBitmap != null, imageView, fromDrawable, mContext); + final AsyncTaskContainer asyncTaskContainer = new AsyncTaskContainer(bitmapWorkerTask); + imageView.setTag(asyncTaskContainer); + try { + ApolloUtils.execute(false, bitmapWorkerTask); + } catch (RejectedExecutionException e) { + // Executor has exhausted queue space + } + } + } + + /** + * Called to fetch the blurred artist or album art. + * + * @param key The unique identifier for the image. + * @param artistName The artist name for the Last.fm API. + * @param albumName The album name for the Last.fm API. + * @param albumId The album art index, to check for missing artwork. + * @param blurScrimImage The {@link BlurScrimImage} used to set the cached + * {@link Bitmap}. + * @param imageType The type of image URL to fetch for. + */ + protected void loadBlurImage(final String key, final String artistName, final String albumName, + final long albumId, final BlurScrimImage blurScrimImage, final ImageType imageType) { + if (key == null || mImageCache == null || blurScrimImage == null) { + return; + } + + if (executePotentialWork(key, blurScrimImage) && !mImageCache.isDiskCachePaused()) { + // Otherwise run the worker task + final BlurBitmapWorkerTask blurWorkerTask = new BlurBitmapWorkerTask(key, blurScrimImage, + imageType, mTransparentDrawable, mContext, sRenderScript); + final AsyncTaskContainer asyncTaskContainer = new AsyncTaskContainer(blurWorkerTask); + blurScrimImage.setTag(asyncTaskContainer); + + try { + ApolloUtils.execute(false, blurWorkerTask, artistName, albumName, String.valueOf(albumId)); + } catch (RejectedExecutionException e) { + // Executor has exhausted queue space, show default artwork + blurScrimImage.transitionToDefaultState(); + } + } + } + + /** + * Used to define what type of image URL to fetch for, artist or album. + */ + public enum ImageType { + ARTIST, ALBUM, PLAYLIST; + } +} diff --git a/src/com/cyanogenmod/eleven/cache/LruCache.java b/src/com/cyanogenmod/eleven/cache/LruCache.java new file mode 100644 index 0000000..2144b93 --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/LruCache.java @@ -0,0 +1,333 @@ +/* + * Copyright (C) 2011 The Android Open Source 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 com.cyanogenmod.eleven.cache; + +// NOTE: upstream of this class is android.util.LruCache, changes below +// expose trimToSize() to be called externally. + +import android.annotation.SuppressLint; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Static library version of {@link android.util.LruCache}. Used to write apps + * that run on API levels prior to 12. When running on API level 12 or above, + * this implementation is still used; it does not try to switch to the + * framework's implementation. See the framework SDK documentation for a class + * overview. + */ +public class LruCache<K, V> { + + private final LinkedHashMap<K, V> map; + + private final int maxSize; + + /** Size of this cache in units. Not necessarily the number of elements. */ + private int size; + + private int putCount; + + private int createCount; + + private int evictionCount; + + private int hitCount; + + private int missCount; + + /** + * @param maxSize for caches that do not override {@link #sizeOf}, this is + * the maximum number of entries in the cache. For all other + * caches, this is the maximum sum of the sizes of the entries in + * this cache. + */ + public LruCache(final int maxSize) { + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize <= 0"); + } + this.maxSize = maxSize; + this.map = new LinkedHashMap<K, V>(0, 0.75f, true); + } + + /** + * Returns the value for {@code key} if it exists in the cache or can be + * created by {@code #create}. If a value was returned, it is moved to the + * head of the queue. This returns null if a value is not cached and cannot + * be created. + */ + public final V get(final K key) { + if (key == null) { + throw new NullPointerException("key == null"); + } + + V mapValue; + synchronized (this) { + mapValue = map.get(key); + if (mapValue != null) { + this.hitCount++; + return mapValue; + } + this.missCount++; + } + + /* + * Attempt to create a value. This may take a long time, and the map may + * be different when create() returns. If a conflicting value was added + * to the map while create() was working, we leave that value in the map + * and release the created value. + */ + + final V createdValue = create(key); + if (createdValue == null) { + return null; + } + + synchronized (this) { + this.createCount++; + mapValue = map.put(key, createdValue); + + if (mapValue != null) { + /* There was a conflict so undo that last put */ + this.map.put(key, mapValue); + } else { + this.size += safeSizeOf(key, createdValue); + } + } + + if (mapValue != null) { + entryRemoved(false, key, createdValue, mapValue); + return mapValue; + } else { + trimToSize(maxSize); + return createdValue; + } + } + + /** + * Caches {@code value} for {@code key}. The value is moved to the head of + * the queue. + * + * @return the previous value mapped by {@code key}. + */ + public final V put(final K key, final V value) { + if (key == null || value == null) { + throw new NullPointerException("key == null || value == null"); + } + + V previous; + synchronized (this) { + this.putCount++; + this.size += safeSizeOf(key, value); + previous = this.map.put(key, value); + if (previous != null) { + this.size -= safeSizeOf(key, previous); + } + } + + if (previous != null) { + entryRemoved(false, key, previous, value); + } + + trimToSize(maxSize); + return previous; + } + + /** + * @param maxSize the maximum size of the cache before returning. May be -1 + * to evict even 0-sized elements. + */ + public void trimToSize(final int maxSize) { + while (true) { + K key; + V value; + synchronized (this) { + if (this.size < 0 || this.map.isEmpty() && size != 0) { + throw new IllegalStateException(getClass().getName() + + ".sizeOf() is reporting inconsistent results!"); + } + + if (this.size <= maxSize || this.map.isEmpty()) { + break; + } + + final Map.Entry<K, V> toEvict = this.map.entrySet().iterator().next(); + key = toEvict.getKey(); + value = toEvict.getValue(); + this.map.remove(key); + this.size -= safeSizeOf(key, value); + this.evictionCount++; + } + + entryRemoved(true, key, value, null); + } + } + + /** + * Removes the entry for {@code key} if it exists. + * + * @return the previous value mapped by {@code key}. + */ + public final V remove(final K key) { + if (key == null) { + throw new NullPointerException("key == null"); + } + + V previous; + synchronized (this) { + previous = this.map.remove(key); + if (previous != null) { + this.size -= safeSizeOf(key, previous); + } + } + + if (previous != null) { + entryRemoved(false, key, previous, null); + } + + return previous; + } + + /** + * Called for entries that have been evicted or removed. This method is + * invoked when a value is evicted to make space, removed by a call to + * {@link #remove}, or replaced by a call to {@link #put}. The default + * implementation does nothing. + * <p> + * The method is called without synchronization: other threads may access + * the cache while this method is executing. + * + * @param evicted true if the entry is being removed to make space, false if + * the removal was caused by a {@link #put} or {@link #remove}. + * @param newValue the new value for {@code key}, if it exists. If non-null, + * this removal was caused by a {@link #put}. Otherwise it was + * caused by an eviction or a {@link #remove}. + */ + protected void entryRemoved(final boolean evicted, final K key, final V oldValue, + final V newValue) { + } + + /** + * Called after a cache miss to compute a value for the corresponding key. + * Returns the computed value or null if no value can be computed. The + * default implementation returns null. + * <p> + * The method is called without synchronization: other threads may access + * the cache while this method is executing. + * <p> + * If a value for {@code key} exists in the cache when this method returns, + * the created value will be released with {@link #entryRemoved} and + * discarded. This can occur when multiple threads request the same key at + * the same time (causing multiple values to be created), or when one thread + * calls {@link #put} while another is creating a value for the same key. + */ + protected V create(final K key) { + return null; + } + + private int safeSizeOf(final K key, final V value) { + final int result = sizeOf(key, value); + if (result < 0) { + throw new IllegalStateException("Negative size: " + key + "=" + value); + } + return result; + } + + /** + * Returns the size of the entry for {@code key} and {@code value} in + * user-defined units. The default implementation returns 1 so that size is + * the number of entries and max size is the maximum number of entries. + * <p> + * An entry's size must not change while it is in the cache. + */ + protected int sizeOf(final K key, final V value) { + return 1; + } + + /** + * Clear the cache, calling {@link #entryRemoved} on each removed entry. + */ + public final void evictAll() { + trimToSize(-1); // -1 will evict 0-sized elements + } + + /** + * For caches that do not override {@link #sizeOf}, this returns the number + * of entries in the cache. For all other caches, this returns the sum of + * the sizes of the entries in this cache. + */ + public synchronized final int size() { + return this.size; + } + + /** + * For caches that do not override {@link #sizeOf}, this returns the maximum + * number of entries in the cache. For all other caches, this returns the + * maximum sum of the sizes of the entries in this cache. + */ + public synchronized final int maxSize() { + return this.maxSize; + } + + /** + * Returns the number of times {@link #get} returned a value. + */ + public synchronized final int hitCount() { + return this.hitCount; + } + + /** + * Returns the number of times {@link #get} returned null or required a new + * value to be created. + */ + public synchronized final int missCount() { + return this.missCount; + } + + /** + * Returns the number of times {@link #create(Object)} returned a value. + */ + public synchronized final int createCount() { + return this.createCount; + } + + /** + * Returns the number of times {@link #put} was called. + */ + public synchronized final int putCount() { + return this.putCount; + } + + /** + * Returns the number of values that have been evicted. + */ + public synchronized final int evictionCount() { + return this.evictionCount; + } + + /** + * Returns a copy of the current contents of the cache, ordered from least + * recently accessed to most recently accessed. + */ + public synchronized final Map<K, V> snapshot() { + return new LinkedHashMap<K, V>(this.map); + } + + @SuppressLint("DefaultLocale") + @Override + public synchronized final String toString() { + final int accesses = this.hitCount + this.missCount; + final int hitPercent = accesses != 0 ? 100 * this.hitCount / accesses : 0; + return String.format("LruCache[maxSize=%d,hits=%d,misses=%d,hitRate=%d%%]", this.maxSize, + this.hitCount, this.missCount, hitPercent); + } +} diff --git a/src/com/cyanogenmod/eleven/cache/PlaylistWorkerTask.java b/src/com/cyanogenmod/eleven/cache/PlaylistWorkerTask.java new file mode 100644 index 0000000..c35ad00 --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/PlaylistWorkerTask.java @@ -0,0 +1,383 @@ +/* +* Copyright (C) 2014 The CyanogenMod 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 com.cyanogenmod.eleven.cache; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.provider.MediaStore; +import android.widget.ImageView; + +import com.cyanogenmod.eleven.R; +import com.cyanogenmod.eleven.cache.ImageWorker.ImageType; +import com.cyanogenmod.eleven.loaders.PlaylistSongLoader; +import com.cyanogenmod.eleven.loaders.SortedCursor; +import com.cyanogenmod.eleven.provider.PlaylistArtworkStore; +import com.cyanogenmod.eleven.provider.SongPlayCount; + +import java.util.ArrayList; +import java.util.HashSet; + +/** + * The playlistWorkerTask will load either the top artist image or the cover art (a combination of + * up to 4 of the top song's album images) into the designated ImageView. If not enough time has + * elapsed since the last update or if the # of songs in the playlist hasn't changed, no new images + * will be loaded. + */ +public class PlaylistWorkerTask extends BitmapWorkerTask<Void, Void, TransitionDrawable> { + // the work type + public enum PlaylistWorkerType { + Artist, CoverArt + } + + // number of images to load in the cover art + private static final int MAX_NUM_BITMAPS_TO_LOAD = 4; + + protected final long mPlaylistId; + protected final PlaylistArtworkStore mPlaylistStore; + protected final PlaylistWorkerType mWorkerType; + + // if we've found it in the cache, don't do any more logic unless enough time has elapsed or + // if the playlist has changed + protected final boolean mFoundInCache; + + // because a cached image can be loaded, we use this flag to signal to remove that default image + protected boolean mFallbackToDefaultImage; + + /** + * Constructor of <code>PlaylistWorkerTask</code> + * @param key the key of the image to store to + * @param playlistId the playlist identifier + * @param type Artist or CoverArt? + * @param foundInCache does this exist in the memory cache already + * @param imageView The {@link ImageView} to use. + * @param fromDrawable what drawable to transition from + */ + public PlaylistWorkerTask(final String key, final long playlistId, final PlaylistWorkerType type, + final boolean foundInCache, final ImageView imageView, + final Drawable fromDrawable, final Context context) { + super(key, imageView, ImageType.PLAYLIST, fromDrawable, context); + + mPlaylistId = playlistId; + mWorkerType = type; + mPlaylistStore = PlaylistArtworkStore.getInstance(mContext); + mFoundInCache = foundInCache; + mFallbackToDefaultImage = false; + } + + /** + * {@inheritDoc} + */ + @Override + protected TransitionDrawable doInBackground(final Void... params) { + if (isCancelled()) { + return null; + } + + Bitmap bitmap = null; + + // See if we need to update the image + boolean needsUpdate = false; + if (mWorkerType == PlaylistWorkerType.Artist + && mPlaylistStore.needsArtistArtUpdate(mPlaylistId)) { + needsUpdate = true; + } else if (mWorkerType == PlaylistWorkerType.CoverArt + && mPlaylistStore.needsCoverArtUpdate(mPlaylistId)) { + needsUpdate = true; + } + + // if we don't need to update and we've already found it in the cache, then return + if (!needsUpdate && mFoundInCache) { + return null; + } + + // if we didn't find it in memory cache, try the disk cache + if (!mFoundInCache) { + bitmap = mImageCache.getCachedBitmap(mKey); + } + + // if we don't need an update, return something + if (!needsUpdate) { + if (bitmap != null) { + // if we found a bitmap, return it + return createImageTransitionDrawable(bitmap); + } else { + // otherwise return null since we don't need an update + return null; + } + } + + // otherwise re-run the logic to get the bitmap + Cursor sortedCursor = null; + + try { + // get the top songs for our playlist + sortedCursor = getTopSongsForPlaylist(); + + if (isCancelled()) { + return null; + } + + if (sortedCursor == null || sortedCursor.getCount() == 0) { + // if all songs were removed from the playlist, update the last updated time + // and reset to the default art + if (mWorkerType == PlaylistWorkerType.Artist) { + // update the timestamp + mPlaylistStore.updateArtistArt(mPlaylistId); + // remove the cached image + mImageCache.removeFromCache(PlaylistArtworkStore.getArtistCacheKey(mPlaylistId)); + // revert back to default image + mFallbackToDefaultImage = true; + } else if (mWorkerType == PlaylistWorkerType.CoverArt) { + // update the timestamp + mPlaylistStore.updateCoverArt(mPlaylistId); + // remove the cached image + mImageCache.removeFromCache(PlaylistArtworkStore.getCoverCacheKey(mPlaylistId)); + // revert back to default image + mFallbackToDefaultImage = true; + } + } else if (mWorkerType == PlaylistWorkerType.Artist) { + bitmap = loadTopArtist(sortedCursor); + } else { + bitmap = loadTopSongs(sortedCursor); + } + } finally { + if (sortedCursor != null) { + sortedCursor.close(); + } + } + + // if we have a bitmap create a transition drawable + if (bitmap != null) { + return createImageTransitionDrawable(bitmap); + } + + return null; + } + + /** + * This gets the sorted cursor of the songs from a playlist based on play count + * @return Cursor containing the sorted list + */ + protected Cursor getTopSongsForPlaylist() { + Cursor playlistCursor = null; + SortedCursor sortedCursor = null; + + try { + // gets the songs in the playlist + playlistCursor = PlaylistSongLoader.makePlaylistSongCursor(mContext, mPlaylistId); + if (playlistCursor == null || !playlistCursor.moveToFirst()) { + return null; + } + + // get all the ids in the list + long[] songIds = new long[playlistCursor.getCount()]; + do { + long id = playlistCursor.getLong(playlistCursor.getColumnIndex( + MediaStore.Audio.Playlists.Members.AUDIO_ID)); + + songIds[playlistCursor.getPosition()] = id; + } while (playlistCursor.moveToNext()); + + if (isCancelled()) { + return null; + } + + // find the sorted order for the playlist based on the top songs database + long[] order = SongPlayCount.getInstance(mContext).getTopPlayedResultsForList(songIds); + + // create a new cursor that takes the playlist cursor and the sorted order + sortedCursor = new SortedCursor(playlistCursor, order, + MediaStore.Audio.Playlists.Members.AUDIO_ID); + + // since this cursor is now wrapped by SortedTracksCursor, remove the reference here + // so we don't accidentally close it in the finally loop + playlistCursor = null; + } finally { + // if we quit early from isCancelled(), close our cursor + if (playlistCursor != null) { + playlistCursor.close(); + playlistCursor = null; + } + } + + return sortedCursor; + } + + /** + * Gets the most played song's artist image + * @param sortedCursor the sorted playlist song cursor + * @return Bitmap of the artist + */ + protected Bitmap loadTopArtist(Cursor sortedCursor) { + if (sortedCursor == null || !sortedCursor.moveToFirst()) { + return null; + } + + Bitmap bitmap = null; + int artistIndex = sortedCursor.getColumnIndex(MediaStore.Audio.AudioColumns.ARTIST); + String artistName = null; + + do { + if (isCancelled()) { + return null; + } + + artistName = sortedCursor.getString(artistIndex); + // try to load the bitmap + bitmap = ImageWorker.getBitmapInBackground(mContext, mImageCache, artistName, + null, artistName, -1, ImageType.ARTIST); + } while (sortedCursor.moveToNext() && bitmap == null); + + if (bitmap == null) { + // if we can't find any artist images, try loading the top songs image + bitmap = mImageCache.getCachedBitmap( + PlaylistArtworkStore.getCoverCacheKey(mPlaylistId)); + } + + if (bitmap != null) { + // add the image to the cache + mImageCache.addBitmapToCache(mKey, bitmap, true); + } else { + mImageCache.removeFromCache(mKey); + mFallbackToDefaultImage = true; + } + + // store the fact that we ran this code into the db to prevent multiple re-runs + mPlaylistStore.updateArtistArt(mPlaylistId); + + return bitmap; + } + + /** + * Gets the Cover Art of the playlist, which is a combination of the top song's album image + * @param sortedCursor the sorted playlist song cursor + * @return Bitmap of the artist + */ + protected Bitmap loadTopSongs(Cursor sortedCursor) { + if (sortedCursor == null || !sortedCursor.moveToFirst()) { + return null; + } + + ArrayList<Bitmap> loadedBitmaps = new ArrayList<Bitmap>(MAX_NUM_BITMAPS_TO_LOAD); + + final int artistIdx = sortedCursor.getColumnIndex(MediaStore.Audio.AudioColumns.ARTIST); + final int albumIdIdx = sortedCursor.getColumnIndex(MediaStore.Audio.AudioColumns.ALBUM_ID); + final int albumIdx = sortedCursor.getColumnIndex(MediaStore.Audio.AudioColumns.ALBUM); + + Bitmap bitmap = null; + String artistName = null; + String albumName = null; + long albumId = -1; + + // create a hashset of the keys so we don't load images from the same album multiple times + HashSet<String> keys = new HashSet<String>(sortedCursor.getCount()); + + do { + if (isCancelled()) { + return null; + } + + artistName = sortedCursor.getString(artistIdx); + albumName = sortedCursor.getString(albumIdx); + albumId = sortedCursor.getLong(albumIdIdx); + + String key = ImageFetcher.generateAlbumCacheKey(albumName, artistName); + + // if we successfully added the key (ie the key didn't previously exist) + if (keys.add(key)) { + // try to load the bitmap + bitmap = ImageWorker.getBitmapInBackground(mContext, mImageCache, + key, albumName, artistName, albumId, ImageType.ALBUM); + + // if we got the bitmap, add it to the list + if (bitmap != null) { + loadedBitmaps.add(bitmap); + bitmap = null; + } + } + } while (sortedCursor.moveToNext() && loadedBitmaps.size() < MAX_NUM_BITMAPS_TO_LOAD); + + // if we found at least 1 bitmap + if (loadedBitmaps.size() > 0) { + // get the first bitmap + bitmap = loadedBitmaps.get(0); + + // if we have many bitmaps + if (loadedBitmaps.size() == MAX_NUM_BITMAPS_TO_LOAD) { + // create a combined bitmap of the 4 images + final int width = bitmap.getWidth(); + final int height = bitmap.getHeight(); + Bitmap combinedBitmap = Bitmap.createBitmap(width, height, + bitmap.getConfig()); + Canvas combinedCanvas = new Canvas(combinedBitmap); + + // top left + combinedCanvas.drawBitmap(loadedBitmaps.get(0), null, + new Rect(0, 0, width / 2, height / 2), null); + + // top right + combinedCanvas.drawBitmap(loadedBitmaps.get(1), null, + new Rect(width / 2, 0, width, height / 2), null); + + // bottom left + combinedCanvas.drawBitmap(loadedBitmaps.get(2), null, + new Rect(0, height / 2, width / 2, height), null); + + // bottom right + combinedCanvas.drawBitmap(loadedBitmaps.get(3), null, + new Rect(width / 2, height / 2, width, height), null); + + combinedCanvas.release(); + combinedCanvas = null; + bitmap = combinedBitmap; + } + } + + // store the fact that we ran this code into the db to prevent multiple re-runs + mPlaylistStore.updateCoverArt(mPlaylistId); + + if (bitmap != null) { + // add the image to the cache + mImageCache.addBitmapToCache(mKey, bitmap, true); + } else { + mImageCache.removeFromCache(mKey); + mFallbackToDefaultImage = true; + } + + return bitmap; + } + + /** + * {@inheritDoc} + */ + @Override + protected void onPostExecute(TransitionDrawable transitionDrawable) { + final ImageView imageView = getAttachedImageView(); + if (imageView != null) { + if (transitionDrawable != null) { + imageView.setImageDrawable(transitionDrawable); + } else if (mFallbackToDefaultImage) { + ImageFetcher.getInstance(mContext).loadDefaultImage(imageView, + ImageType.PLAYLIST, null, String.valueOf(mPlaylistId)); + } + } + } +} diff --git a/src/com/cyanogenmod/eleven/cache/SimpleBitmapWorkerTask.java b/src/com/cyanogenmod/eleven/cache/SimpleBitmapWorkerTask.java new file mode 100644 index 0000000..027eb7f --- /dev/null +++ b/src/com/cyanogenmod/eleven/cache/SimpleBitmapWorkerTask.java @@ -0,0 +1,88 @@ +/* +* Copyright (C) 2014 The CyanogenMod 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 com.cyanogenmod.eleven.cache; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.TransitionDrawable; +import android.widget.ImageView; +import com.cyanogenmod.eleven.cache.ImageWorker.ImageType; +import com.cyanogenmod.eleven.utils.ImageUtils; + +/** + * The actual {@link android.os.AsyncTask} that will process the image. + */ +public class SimpleBitmapWorkerTask extends BitmapWorkerTask<String, Void, TransitionDrawable> { + + /** + * Constructor of <code>BitmapWorkerTask</code> + * + * @param key the key of the image to store to + * @param imageView The {@link ImageView} to use. + * @param imageType The type of image URL to fetch for. + * @param fromDrawable what drawable to transition from + */ + public SimpleBitmapWorkerTask(final String key, final ImageView imageView, final ImageType imageType, + final Drawable fromDrawable, final Context context) { + super(key, imageView, imageType, fromDrawable, context); + } + + /** + * Constructor of <code>BitmapWorkerTask</code> + * + * @param key the key of the image to store to + * @param imageView The {@link ImageView} to use. + * @param imageType The type of image URL to fetch for. + * @param fromDrawable what drawable to transition from + * @param scaleImgToView flag to scale the bitmap to the image view bounds + */ + public SimpleBitmapWorkerTask(final String key, final ImageView imageView, final ImageType imageType, + final Drawable fromDrawable, final Context context, final boolean scaleImgToView) { + super(key, imageView, imageType, fromDrawable, context, scaleImgToView); + } + + /** + * {@inheritDoc} + */ + @Override + protected TransitionDrawable doInBackground(final String... params) { + if (isCancelled()) { + return null; + } + + final Bitmap bitmap = getBitmapInBackground(params); + if (mScaleImgToView) { + Bitmap scaledBitmap = ImageUtils.scaleBitmapForImageView(bitmap, getAttachedImageView()); + return createImageTransitionDrawable(scaledBitmap); + } + else + return createImageTransitionDrawable(bitmap); + } + + /** + * {@inheritDoc} + */ + @Override + protected void onPostExecute(TransitionDrawable transitionDrawable) { + final ImageView imageView = getAttachedImageView(); + if (transitionDrawable != null && imageView != null) { + imageView.setImageDrawable(transitionDrawable); + } else if (imageView != null) { + imageView.setImageDrawable(mFromDrawable); + } + } +} |