diff --git a/src/com/hippo/ehviewer/cache/DiskLruCache.java b/src/com/hippo/ehviewer/cache/DiskLruCache.java
new file mode 100644
index 000000000..f351fc180
--- /dev/null
+++ b/src/com/hippo/ehviewer/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.hippo.ehviewer.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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * - When an entry is being created it is necessary to supply
+ * a full set of values; the empty value should be used as a placeholder if
+ * necessary.
+ *
- When an entry is being edited, it is not necessary to
+ * supply data for every value; values default to their previous value.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 lruEntries = new LinkedHashMap(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[] 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());
+
+ private final Callable cleanupCallable = new Callable() {
+ @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 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(lruEntries.values())) {
+ if (entry.currentEditor != null) {
+ entry.currentEditor.abort();
+ }
+ }
+ trimToSize();
+ journalWriter.close();
+ journalWriter = null;
+ }
+
+ private void trimToSize() throws IOException {
+ while (size > maxSize) {
+ // Map.Entry toEvict = lruEntries.eldest();
+ final Map.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/hippo/ehviewer/cache/ImageCache.java b/src/com/hippo/ehviewer/cache/ImageCache.java
new file mode 100644
index 000000000..0692c2b0b
--- /dev/null
+++ b/src/com/hippo/ehviewer/cache/ImageCache.java
@@ -0,0 +1,778 @@
+/*
+ * Copyright (C) 2012 Andrew Neal 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.hippo.ehviewer.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 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 com.hippo.ehviewer.util.Util;
+
+/**
+ * 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;
+
+ 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 ImageCache
+ *
+ * @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) {
+ Util.execute(false, new AsyncTask() {
+
+ @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) {
+ if (data == null || bitmap == null) {
+ return;
+ }
+
+ // Add to memory cache
+ addBitmapToMemCache(data, bitmap);
+
+ // Add to disk cache
+ if (mDiskCache != null) {
+ final String key = hashKeyForDisk(data);
+ OutputStream out = null;
+ try {
+ final DiskLruCache.Snapshot snapshot = mDiskCache.get(key);
+ if (snapshot == null) {
+ 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();
+ }
+ } else {
+ snapshot.getInputStream(DISK_CACHE_INDEX).close();
+ }
+ } catch (final IOException e) {
+ 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) {
+ if (data == null || bitmap == null) {
+ return;
+ }
+ // Add to memory cache
+ if (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() {
+ Util.execute(false, new AsyncTask() {
+
+ @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() {
+ Util.execute(false, new AsyncTask() {
+
+ @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() {
+ Util.execute(false, new AsyncTask() {
+
+ @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();
+ }
+ }
+ }
+ }
+
+ 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;
+ }
+
+ /**
+ * 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 {
+
+ /**
+ * Constructor of MemoryCache
+ *
+ * @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/hippo/ehviewer/cache/LruCache.java b/src/com/hippo/ehviewer/cache/LruCache.java
new file mode 100644
index 000000000..448e8a059
--- /dev/null
+++ b/src/com/hippo/ehviewer/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.hippo.ehviewer.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 {
+
+ private final LinkedHashMap 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(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 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.
+ *
+ * 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.
+ *
+ * The method is called without synchronization: other threads may access
+ * the cache while this method is executing.
+ *
+ * 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.
+ *
+ * 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 snapshot() {
+ return new LinkedHashMap(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/hippo/ehviewer/util/Util.java b/src/com/hippo/ehviewer/util/Util.java
index 5135557ce..fcd94833d 100644
--- a/src/com/hippo/ehviewer/util/Util.java
+++ b/src/com/hippo/ehviewer/util/Util.java
@@ -30,6 +30,8 @@ import java.util.Locale;
import android.annotation.SuppressLint;
import android.content.SharedPreferences;
+import android.os.AsyncTask;
+import android.os.Build;
public class Util {
@SuppressWarnings("unused")
@@ -223,4 +225,27 @@ public class Util {
}
return null;
}
+
+ /**
+ * Execute an {@link AsyncTask} on a thread pool
+ *
+ * @param forceSerial True to force the task to run in serial order
+ * @param task Task to execute
+ * @param args Optional arguments to pass to
+ * {@link AsyncTask#execute(Object[])}
+ * @param Task argument type
+ */
+ @SuppressLint("NewApi")
+ public static void execute(final boolean forceSerial, final AsyncTask task,
+ final T... args) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.DONUT) {
+ throw new UnsupportedOperationException(
+ "This class can only be used on API 4 and newer.");
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB || forceSerial) {
+ task.execute(args);
+ } else {
+ task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, args);
+ }
+ }
}