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. + *

+ * 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); + } + } }