From 36aa37359bf925b629d31c0c68c3cbe4b0858fdb Mon Sep 17 00:00:00 2001 From: tarsin Date: Mon, 13 Jun 2022 12:39:34 +0800 Subject: [PATCH] Inline hippo's beerbelly * No more dependencies spam thanks --- app/build.gradle | 1 - .../java/com/hippo/beerbelly/BeerBelly.java | 495 +++++++++ .../com/hippo/beerbelly/DiskLruCache.java | 950 ++++++++++++++++++ .../java/com/hippo/beerbelly/LruCache.java | 360 +++++++ .../com/hippo/beerbelly/SimpleDiskCache.java | 522 ++++++++++ .../com/hippo/beerbelly/StrictLineReader.java | 199 ++++ .../main/java/com/hippo/beerbelly/Util.java | 101 ++ 7 files changed, 2627 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/hippo/beerbelly/BeerBelly.java create mode 100644 app/src/main/java/com/hippo/beerbelly/DiskLruCache.java create mode 100644 app/src/main/java/com/hippo/beerbelly/LruCache.java create mode 100644 app/src/main/java/com/hippo/beerbelly/SimpleDiskCache.java create mode 100644 app/src/main/java/com/hippo/beerbelly/StrictLineReader.java create mode 100644 app/src/main/java/com/hippo/beerbelly/Util.java diff --git a/app/build.gradle b/app/build.gradle index 092fd20ec..8833ae37e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -105,7 +105,6 @@ dependencies { implementation 'com.drakeet.drawer:drawer:1.0.3' implementation 'com.google.android.material:material:1.7.0-alpha02' implementation 'com.getkeepsafe.taptargetview:taptargetview:1.13.3' - implementation 'com.github.seven332:beerbelly:0.1.4' implementation 'com.github.seven332:streampipe:0.1.0' implementation 'com.github.seven332:tuxiang:0.1.6' implementation 'com.github.seven332:unifile:9ec57bcd8f' diff --git a/app/src/main/java/com/hippo/beerbelly/BeerBelly.java b/app/src/main/java/com/hippo/beerbelly/BeerBelly.java new file mode 100644 index 000000000..90ed5c81b --- /dev/null +++ b/app/src/main/java/com/hippo/beerbelly/BeerBelly.java @@ -0,0 +1,495 @@ +/* + * Copyright (C) 2015-2016 Hippo Seven + * + * 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.beerbelly; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hippo.streampipe.InputStreamPipe; +import com.hippo.streampipe.OutputStreamPipe; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public abstract class BeerBelly { + + private static final String TAG = BeerBelly.class.getSimpleName(); + + @Nullable + private MemoryCache mMemoryCache; + @Nullable + private DiskCache mDiskCache; + + private final boolean mHasMemoryCache; + private final boolean mHasDiskCache; + + public BeerBelly(BeerBellyParams params) { + params.isValid(); + mHasMemoryCache = params.hasMemoryCache; + mHasDiskCache = params.hasDiskCache; + + if (mHasMemoryCache) { + initMemoryCache(params.memoryCacheMaxSize); + } + + if (mHasDiskCache) { + initDiskCache(params.diskCacheDir, params.diskCacheMaxSize); + } + } + + private void initMemoryCache(int maxSize) { + mMemoryCache = new MemoryCache<>(maxSize, this); + } + + private void initDiskCache(File cacheDir, int maxSize) { + // Set up disk cache + try { + mDiskCache = new DiskCache<>(cacheDir, maxSize, this); + } catch (IOException e) { + Log.e(TAG, "Can't create disk cache", e); + } + } + + protected abstract int sizeOf(String key, V value); + + protected abstract void memoryEntryAdded(V value); + + protected abstract void memoryEntryRemoved(boolean evicted, String key, V oldValue, V newValue); + + protected abstract V read(@NonNull InputStreamPipe isPipe); + + protected abstract boolean write(OutputStream os, V value); + + @Nullable + public LruCache getMemoryCache() { + return mMemoryCache; + } + + @Nullable + public SimpleDiskCache getDiskCache() { + if (mDiskCache != null) { + return mDiskCache.mDiskCache; + } else { + return null; + } + } + + /** + * Check if have memory cache + * + * @return true if have memory cache + */ + public boolean hasMemoryCache() { + return mHasMemoryCache; + } + + /** + * Check if have disk cache + * + * @return true if have disk cache + */ + public boolean hasDiskCache() { + return mHasDiskCache; + } + + /** + * Get memory cache usage size + * + * @return {@code -1} for no memory cache + */ + public int memorySize() { + if (mHasMemoryCache && mMemoryCache != null) { + return mMemoryCache.size(); + } else { + return -1; + } + } + + /** + * Get memory cache max size + * + * @return {@code -1} for no memory cache + */ + public int memoryMaxSize() { + if (mHasMemoryCache && mMemoryCache != null) { + return mMemoryCache.maxSize(); + } else { + return -1; + } + } + + /** + * Get disk cache usage size + * + * @return {@code -1L} for no memory cache + */ + public long diskSize() { + if (mHasDiskCache && mDiskCache != null) { + return mDiskCache.size(); + } else { + return -1L; + } + } + + /** + * Get disk cache max size + * + * @return {@code -1L} for no memory cache + */ + public long diskMaxSize() { + if (mHasDiskCache && mDiskCache != null) { + return mDiskCache.getMaxSize(); + } else { + return -1L; + } + } + + /** + * Get value from memory cache + * + * @param key the key to get value + * @return the value you get, null for miss or no memory cache + */ + public V getFromMemory(@NonNull String key) { + if (mHasMemoryCache && mMemoryCache != null) { + return mMemoryCache.get(key); + } else { + return null; + } + } + + /** + * Get value from disk cache. Override {@link #read(InputStreamPipe)} to do it + * + * @param key the key to get value + * @return the value you get, null for miss or no memory cache or get error + */ + public V getFromDisk(@NonNull String key) { + if (mHasDiskCache && mDiskCache != null) { + return mDiskCache.get(key); + } else { + return null; + } + } + + /** + * Get value from memory cache and disk cache. If miss in memory cache and + * get in disk cache, it will put value from disk cache to memory cache. + * + * @param key the key to get value + * @return the value you get + */ + public V get(@NonNull String key) { + V value = getFromMemory(key); + + if (value != null) { + // Get it in memory cache + return value; + } + + value = getFromDisk(key); + + if (value != null) { + // Get it in disk cache + putToMemory(key, value); + return value; + } + + return null; + } + + /** + * Put value to memory cache + * + * @param key the key + * @param value the value + * @return false if no memory cache + */ + public boolean putToMemory(@NonNull String key, @NonNull V value) { + if (mHasMemoryCache && mMemoryCache != null) { + memoryEntryAdded(value); + mMemoryCache.put(key, value); + return true; + } else { + return false; + } + } + + /** + * Put value to disk cache + * + * @param key the key + * @param value the value + * @return false if no disk cache or get error + */ + public boolean putToDisk(@NonNull String key, @NonNull V value) { + if (mHasDiskCache && mDiskCache != null) { + return mDiskCache.put(key, value); + } else { + return false; + } + } + + /** + * Put value to memory cache and disk cache + * + * @param key the key + * @param value the value + */ + public void put(@NonNull String key, @NonNull V value) { + putToMemory(key, value); + putToDisk(key, value); + } + + public void removeFromMemory(@NonNull String key) { + if (mHasMemoryCache && mMemoryCache != null) { + mMemoryCache.remove(key); + } + } + + public void removeFromDisk(@NonNull String key) { + if (mHasDiskCache && mDiskCache != null) { + mDiskCache.remove(key); + } + } + + public void remove(@NonNull String key) { + removeFromMemory(key); + removeFromDisk(key); + } + + public boolean pullFromDiskCache(@NonNull String key, @NonNull OutputStream os) { + if (mHasDiskCache && mDiskCache != null) { + return mDiskCache.pull(key, os); + } else { + return false; + } + } + + /** + * + * @param key the key + * @param is the input stream to store + * @return false if no disk cache or get error + */ + public boolean putRawToDisk(@NonNull String key, @NonNull InputStream is) { + if (mHasDiskCache && mDiskCache != null) { + return mDiskCache.putRaw(key, is); + } else { + return false; + } + } + + /** + * Evicts all of the items from the memory cache + */ + public void clearMemory() { + if (mHasMemoryCache && mMemoryCache != null) { + mMemoryCache.evictAll(); + } + } + + /** + * Clear disk cache + */ + public void clearDisk() { + if (mHasDiskCache && mDiskCache != null) { + mDiskCache.clear(); + } + } + + public void flush() { + if (mHasDiskCache && mDiskCache != null) { + mDiskCache.flush(); + } + } + + /** + * 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 clear() { + clearMemory(); + clearDisk(); + } + + /** + * A holder class that contains cache parameters. + */ + public static class BeerBellyParams { + + /** + * is memory cache available + */ + public boolean hasMemoryCache = false; + /** + * the maximum number of bytes the memory cache should use to store + */ + public int memoryCacheMaxSize = 0; + /** + * is disk cache available + */ + public boolean hasDiskCache = false; + /** + * the dir to store disk cache + */ + public File diskCacheDir = null; + /** + * the maximum number of bytes the disk cache should use to store + */ + public int diskCacheMaxSize = 0; + + /** + * Check BeerBellyParams is valid + */ + public void isValid() throws IllegalStateException { + if (!hasMemoryCache && !hasDiskCache) { + throw new IllegalStateException("No memory cache and no disk cache. What can I do for you?"); + } + + if (hasMemoryCache && memoryCacheMaxSize <= 0) { + throw new IllegalStateException("Memory cache max size must be bigger than 0."); + } + + if (hasDiskCache) { + if (diskCacheDir == null) { + throw new IllegalStateException("Disk cache dir can't be null."); + } + if (diskCacheMaxSize <= 0) { + throw new IllegalStateException("Disk cache max size must be bigger than 0."); + } + } + } + } + + private class MemoryCache extends LruCache { + + public BeerBelly mParent; + + public MemoryCache(int maxSize, BeerBelly parent) { + super(maxSize); + mParent = parent; + } + + @Override + protected int sizeOf(String key, E value) { + return mParent.sizeOf(key, value); + } + + @Override + protected void entryRemoved(boolean evicted, String key, E oldValue, E newValue) { + mParent.memoryEntryRemoved(evicted, key, oldValue, newValue); + } + } + + private static class DiskCache { + + private static final int IO_BUFFER_SIZE = 8 * 1024; + + private final SimpleDiskCache mDiskCache; + private final BeerBelly mParent; + + private final File mCacheDir; + private final int mMaxSize; + + public DiskCache(File cacheDir, int size, BeerBelly parent) throws IOException { + mDiskCache = new SimpleDiskCache(cacheDir, size); + mParent = parent; + + mCacheDir = cacheDir; + mMaxSize = size; + } + + public File getCacheDir() { + return mCacheDir; + } + + public int getMaxSize() { + return mMaxSize; + } + + public long size() { + return mDiskCache.size(); + } + + public void flush() { + mDiskCache.flush(); + } + + public void clear() { + mDiskCache.clear(); + } + + public E get(String key) { + InputStreamPipe isPipe = mDiskCache.getInputStreamPipe(key); + if (isPipe == null) { + return null; + } else { + return mParent.read(isPipe); + } + } + + public boolean put(String key, E value) { + OutputStreamPipe osPipe = mDiskCache.getOutputStreamPipe(key); + BufferedOutputStream buffOut = null; + try { + osPipe.obtain(); + OutputStream os = osPipe.open(); + buffOut = new BufferedOutputStream(os, IO_BUFFER_SIZE); + return mParent.write(os, value); + } catch (IOException e) { + return false; + } finally { + Util.closeQuietly(buffOut); + osPipe.close(); + osPipe.release(); + } + } + + public boolean putRaw(String key, InputStream is) { + return mDiskCache.put(key, is); + } + + public void remove(String key) { + mDiskCache.remove(key); + } + + public boolean pull(@NonNull String key, @NonNull OutputStream os) { + InputStreamPipe isPipe = mDiskCache.getInputStreamPipe(key); + if (isPipe == null) { + return false; + } else { + try { + isPipe.obtain(); + InputStream is = isPipe.open(); + Util.copy(is, os); + return true; + } catch (IOException e) { + return false; + } finally { + isPipe.close(); + isPipe.release(); + } + } + } + } +} diff --git a/app/src/main/java/com/hippo/beerbelly/DiskLruCache.java b/app/src/main/java/com/hippo/beerbelly/DiskLruCache.java new file mode 100644 index 000000000..175537d43 --- /dev/null +++ b/app/src/main/java/com/hippo/beerbelly/DiskLruCache.java @@ -0,0 +1,950 @@ +/* + * 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.beerbelly; + +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.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 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. Each key must match + * the regex [a-z0-9_-]{1,120}. 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_TEMP = "journal.tmp"; + static final String JOURNAL_FILE_BACKUP = "journal.bkp"; + static final String MAGIC = "libcore.io.DiskLruCache"; + static final String VERSION_1 = "1"; + static final long ANY_SEQUENCE_NUMBER = -1; + static final String STRING_KEY_PATTERN = "[a-z0-9_-]{1,120}"; + static final Pattern LEGAL_KEY_PATTERN = Pattern.compile(STRING_KEY_PATTERN); + private static final String CLEAN = "CLEAN"; + private static final String DIRTY = "DIRTY"; + private static final String REMOVE = "REMOVE"; + private static final String READ = "READ"; + + /* + * 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 File journalFileBackup; + private final int appVersion; + private 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; + + /** This cache uses a single background thread to evict entries. */ + final ThreadPoolExecutor executorService = + new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); + private final Callable cleanupCallable = new Callable<>() { + public Void call() throws Exception { + synchronized (DiskLruCache.this) { + if (journalWriter == null) { + return null; // Closed. + } + trimToSize(); + if (journalRebuildRequired()) { + rebuildJournal(); + redundantOpCount = 0; + } + } + return null; + } + }; + + private DiskLruCache(File directory, int appVersion, int valueCount, long maxSize) { + this.directory = directory; + this.appVersion = appVersion; + this.journalFile = new File(directory, JOURNAL_FILE); + this.journalFileTmp = new File(directory, JOURNAL_FILE_TEMP); + this.journalFileBackup = new File(directory, JOURNAL_FILE_BACKUP); + 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 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(File directory, int appVersion, int valueCount, long maxSize) + throws IOException { + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize <= 0"); + } + if (valueCount <= 0) { + throw new IllegalArgumentException("valueCount <= 0"); + } + + // If a bkp file exists, use it instead. + File backupFile = new File(directory, JOURNAL_FILE_BACKUP); + if (backupFile.exists()) { + File journalFile = new File(directory, JOURNAL_FILE); + // If journal file also exists just delete backup file. + if (journalFile.exists()) { + backupFile.delete(); + } else { + renameTo(backupFile, journalFile, false); + } + } + + // 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(); + return cache; + } catch (IOException journalIsCorrupt) { + System.out + .println("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 { + StrictLineReader reader = new StrictLineReader(new FileInputStream(journalFile), Util.US_ASCII); + try { + String magic = reader.readLine(); + String version = reader.readLine(); + String appVersionString = reader.readLine(); + String valueCountString = reader.readLine(); + String blank = reader.readLine(); + 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 + "]"); + } + + int lineCount = 0; + while (true) { + try { + readJournalLine(reader.readLine()); + lineCount++; + } catch (EOFException endOfJournal) { + break; + } + } + redundantOpCount = lineCount - lruEntries.size(); + + // If we ended on a truncated line, rebuild the journal before appending to it. + if (reader.hasUnterminatedLine()) { + rebuildJournal(); + } else { + journalWriter = new BufferedWriter(new OutputStreamWriter( + new FileOutputStream(journalFile, true), Util.US_ASCII)); + } + } finally { + Util.closeQuietly(reader); + } + } + + private void readJournalLine(String line) throws IOException { + int firstSpace = line.indexOf(' '); + if (firstSpace == -1) { + throw new IOException("unexpected journal line: " + line); + } + + int keyBegin = firstSpace + 1; + int secondSpace = line.indexOf(' ', keyBegin); + final String key; + if (secondSpace == -1) { + key = line.substring(keyBegin); + if (firstSpace == REMOVE.length() && line.startsWith(REMOVE)) { + lruEntries.remove(key); + return; + } + } else { + key = line.substring(keyBegin, secondSpace); + } + + Entry entry = lruEntries.get(key); + if (entry == null) { + entry = new Entry(key); + lruEntries.put(key, entry); + } + + if (secondSpace != -1 && firstSpace == CLEAN.length() && line.startsWith(CLEAN)) { + String[] parts = line.substring(secondSpace + 1).split(" "); + entry.readable = true; + entry.currentEditor = null; + entry.setLengths(parts); + } else if (secondSpace == -1 && firstSpace == DIRTY.length() && line.startsWith(DIRTY)) { + entry.currentEditor = new Editor(entry); + } else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) { + // 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 (Iterator i = lruEntries.values().iterator(); i.hasNext(); ) { + 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(); + } + + try (Writer writer = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII))) { + 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 (Entry entry : lruEntries.values()) { + if (entry.currentEditor != null) { + writer.write(DIRTY + ' ' + entry.key + '\n'); + } else { + writer.write(CLEAN + ' ' + entry.key + entry.getLengths() + '\n'); + } + } + } + + if (journalFile.exists()) { + renameTo(journalFile, journalFileBackup, true); + } + renameTo(journalFileTmp, journalFile, false); + journalFileBackup.delete(); + + journalWriter = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(journalFile, true), Util.US_ASCII)); + } + + private static void deleteIfExists(File file) throws IOException { + if (file.exists() && !file.delete()) { + throw new IOException(); + } + } + + private static void renameTo(File from, File to, boolean deleteDestination) throws IOException { + if (deleteDestination) { + deleteIfExists(to); + } + if (!from.renameTo(to)) { + throw new IOException(); + } + } + + /** + * Return {@code true} if it contains the key + */ + public synchronized boolean contain(String key) { + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + return entry != null && entry.readable; + } + + /** + * 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(String key) throws IOException { + checkNotClosed(); + validateKey(key); + 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. + InputStream[] ins = new InputStream[valueCount]; + try { + for (int i = 0; i < valueCount; i++) { + ins[i] = new FileInputStream(entry.getCleanFile(i)); + } + } catch (FileNotFoundException e) { + // A file must have been deleted manually! + for (int i = 0; i < valueCount; i++) { + if (ins[i] != null) { + Util.closeQuietly(ins[i]); + } else { + break; + } + } + return null; + } + + redundantOpCount++; + journalWriter.append(READ + ' ').append(key).append(String.valueOf('\n')); + if (journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + + return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths); + } + + /** + * Returns an editor for the entry named {@code key}, or null if another + * edit is in progress. + */ + public Editor edit(String key) throws IOException { + return edit(key, ANY_SEQUENCE_NUMBER); + } + + private synchronized Editor edit(String key, 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. + } + + 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 synchronized long getMaxSize() { + return maxSize; + } + + /** + * Changes the maximum number of bytes the cache can store and queues a job + * to trim the existing store, if necessary. + */ + public synchronized void setMaxSize(long maxSize) { + this.maxSize = maxSize; + executorService.submit(cleanupCallable); + } + + /** + * 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(Editor editor, boolean success) throws IOException { + 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 (!editor.written[i]) { + editor.abort(); + throw new IllegalStateException("Newly created entry didn't create value for index " + i); + } + if (!entry.getDirtyFile(i).exists()) { + editor.abort(); + return; + } + } + } + + for (int i = 0; i < valueCount; i++) { + File dirty = entry.getDirtyFile(i); + if (success) { + if (dirty.exists()) { + File clean = entry.getCleanFile(i); + dirty.renameTo(clean); + long oldLength = entry.lengths[i]; + 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'); + } + journalWriter.flush(); + + 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 redundantOpCompactThreshold = 2000; + return redundantOpCount >= redundantOpCompactThreshold // + && 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(String key) throws IOException { + checkNotClosed(); + validateKey(key); + Entry entry = lruEntries.get(key); + if (entry == null || entry.currentEditor != null) { + return false; + } + + for (int i = 0; i < valueCount; i++) { + File file = entry.getCleanFile(i); + if (file.exists() && !file.delete()) { + throw new IOException("failed to delete " + file); + } + size -= entry.lengths[i]; + entry.lengths[i] = 0; + } + + redundantOpCount++; + journalWriter.append(REMOVE + ' ').append(key).append(String.valueOf('\n')); + lruEntries.remove(key); + + if (journalRebuildRequired()) { + executorService.submit(cleanupCallable); + } + + return true; + } + + /** Returns true if this cache has been closed. */ + public synchronized 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. */ + public synchronized void close() throws IOException { + if (journalWriter == null) { + return; // Already closed. + } + for (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.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(); + Util.deleteContents(directory); + } + + private void validateKey(String key) { + Matcher matcher = LEGAL_KEY_PATTERN.matcher(key); + if (!matcher.matches()) { + throw new IllegalArgumentException("keys must match regex " + + STRING_KEY_PATTERN + ": \"" + key + "\""); + } + } + + private static String inputStreamToString(InputStream in) throws IOException { + return Util.readFully(new InputStreamReader(in, Util.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 final long[] lengths; + + private Snapshot(String key, long sequenceNumber, InputStream[] ins, long[] lengths) { + this.key = key; + this.sequenceNumber = sequenceNumber; + this.ins = ins; + this.lengths = lengths; + } + + /** + * 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(int index) { + return ins[index]; + } + + /** Returns the string value for {@code index}. */ + public String getString(int index) throws IOException { + return inputStreamToString(getInputStream(index)); + } + + /** Returns the byte length of the value for {@code index}. */ + public long getLength(int index) { + return lengths[index]; + } + + public void close() { + for (InputStream in : ins) { + Util.closeQuietly(in); + } + } + } + + private static final OutputStream NULL_OUTPUT_STREAM = new OutputStream() { + @Override + public void write(int b) { + // Eat all writes silently. Nom nom. + } + }; + + /** Edits the values for an entry. */ + public final class Editor { + private final Entry entry; + private final boolean[] written; + private boolean hasErrors; + private boolean committed; + + private Editor(Entry entry) { + this.entry = entry; + this.written = (entry.readable) ? null : new boolean[valueCount]; + } + + /** + * Returns an unbuffered input stream to read the last committed value, + * or null if no value has been committed. + */ + public InputStream newInputStream(int index) { + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + if (!entry.readable) { + return null; + } + try { + return new FileInputStream(entry.getCleanFile(index)); + } catch (FileNotFoundException e) { + return null; + } + } + } + + /** + * Returns the last committed value as a string, or null if no value + * has been committed. + */ + public String getString(int index) throws IOException { + 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(int index) throws IOException { + if (index < 0 || index >= valueCount) { + throw new IllegalArgumentException("Expected index " + index + " to " + + "be greater than 0 and less than the maximum value count " + + "of " + valueCount); + } + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); + } + if (!entry.readable) { + written[index] = true; + } + File dirtyFile = entry.getDirtyFile(index); + FileOutputStream outputStream; + try { + outputStream = new FileOutputStream(dirtyFile); + } catch (FileNotFoundException e) { + // Attempt to recreate the cache directory. + directory.mkdirs(); + try { + outputStream = new FileOutputStream(dirtyFile); + } catch (FileNotFoundException e2) { + // We are unable to recover. Silently eat the writes. + return NULL_OUTPUT_STREAM; + } + } + return new FaultHidingOutputStream(outputStream); + } + } + + /** Sets the value at {@code index} to {@code value}. */ + public void set(int index, String value) throws IOException { + Writer writer = null; + try { + writer = new OutputStreamWriter(newOutputStream(index), Util.UTF_8); + writer.write(value); + } finally { + Util.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); + } + committed = 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); + } + + public void abortUnlessCommitted() { + if (!committed) { + try { + abort(); + } catch (IOException ignored) { + } + } + } + + private class FaultHidingOutputStream extends FilterOutputStream { + private FaultHidingOutputStream(OutputStream out) { + super(out); + } + + @Override public void write(int oneByte) { + try { + out.write(oneByte); + } catch (IOException e) { + hasErrors = true; + } + } + + @Override public void write(byte[] buffer, int offset, int length) { + try { + out.write(buffer, offset, length); + } catch (IOException e) { + hasErrors = true; + } + } + + @Override public void close() { + try { + out.close(); + } catch (IOException e) { + hasErrors = true; + } + } + + @Override public void flush() { + try { + out.flush(); + } catch (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(String key) { + this.key = key; + this.lengths = new long[valueCount]; + } + + public String getLengths() { + StringBuilder result = new StringBuilder(); + for (long size : lengths) { + result.append(' ').append(size); + } + return result.toString(); + } + + /** Set lengths using decimal numbers like "10123". */ + private void setLengths(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 (NumberFormatException e) { + throw invalidLengths(strings); + } + } + + private IOException invalidLengths(String[] strings) throws IOException { + throw new IOException("unexpected journal line: " + java.util.Arrays.toString(strings)); + } + + public File getCleanFile(int i) { + return new File(directory, key + "." + i); + } + + public File getDirtyFile(int i) { + return new File(directory, key + "." + i + ".tmp"); + } + } +} diff --git a/app/src/main/java/com/hippo/beerbelly/LruCache.java b/app/src/main/java/com/hippo/beerbelly/LruCache.java new file mode 100644 index 000000000..5800473fd --- /dev/null +++ b/app/src/main/java/com/hippo/beerbelly/LruCache.java @@ -0,0 +1,360 @@ +/* + * 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.beerbelly; + +import android.annotation.SuppressLint; + +import androidx.annotation.NonNull; + +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; + + /** Size of this cache in units. Not necessarily the number of elements. */ + private int size; + private int maxSize; + + 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(int maxSize) { + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize <= 0"); + } + this.maxSize = maxSize; + this.map = new LinkedHashMap<>(0, 0.75f, true); + } + + /** + * Sets the size of the cache. + * + * @param maxSize The new maximum size. + */ + public void resize(int maxSize) { + if (maxSize <= 0) { + throw new IllegalArgumentException("maxSize <= 0"); + } + + synchronized (this) { + this.maxSize = maxSize; + } + trimToSize(maxSize); + } + + /** + * Close the cache. If a value is put, it will be removed at once. + * Resize to reopen the cache. + */ + public void close() { + synchronized (this) { + this.maxSize = -1; + } + trimToSize(maxSize); + } + + /** + * 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(K key) { + if (key == null) { + throw new NullPointerException("key == null"); + } + + V mapValue; + synchronized (this) { + mapValue = map.get(key); + if (mapValue != null) { + hitCount++; + return mapValue; + } + 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. + */ + + V createdValue = create(key); + if (createdValue == null) { + return null; + } + + synchronized (this) { + createCount++; + mapValue = map.put(key, createdValue); + + if (mapValue != null) { + // There was a conflict so undo that last put + map.put(key, mapValue); + } else { + 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(K key, V value) { + if (key == null || value == null) { + throw new NullPointerException("key == null || value == null"); + } + + V previous; + synchronized (this) { + putCount++; + size += safeSizeOf(key, value); + previous = map.put(key, value); + if (previous != null) { + size -= safeSizeOf(key, previous); + } + } + + if (previous != null) { + entryRemoved(false, key, previous, value); + } + + trimToSize(maxSize); + return previous; + } + + /** + * Remove the eldest entries until the total of remaining entries is at or + * below the requested size. + * + * @param maxSize the maximum size of the cache before returning. May be -1 + * to evict even 0-sized elements. + */ + public void trimToSize(int maxSize) { + while (true) { + K key; + V value; + synchronized (this) { + if (size < 0 || (map.isEmpty() && size != 0)) { + throw new IllegalStateException(getClass().getName() + + ".sizeOf() is reporting inconsistent results!"); + } + + if (size <= maxSize || map.isEmpty()) { + break; + } + + Map.Entry toEvict = map.entrySet().iterator().next(); + key = toEvict.getKey(); + value = toEvict.getValue(); + map.remove(key); + size -= safeSizeOf(key, value); + 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(K key) { + if (key == null) { + throw new NullPointerException("key == null"); + } + + V previous; + synchronized (this) { + previous = map.remove(key); + if (previous != null) { + 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(boolean evicted, K key, V oldValue, 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(K key) { + return null; + } + + private int safeSizeOf(K key, V value) { + 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(K key, 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 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 maxSize; + } + + /** + * Returns the number of times {@link #get} returned a value that was + * already present in the cache. + */ + public synchronized final int hitCount() { + return hitCount; + } + + /** + * Returns the number of times {@link #get} returned null or required a new + * value to be created. + */ + public synchronized final int missCount() { + return missCount; + } + + /** + * Returns the number of times {@link #create(Object)} returned a value. + */ + public synchronized final int createCount() { + return createCount; + } + + /** + * Returns the number of times {@link #put} was called. + */ + public synchronized final int putCount() { + return putCount; + } + + /** + * Returns the number of values that have been evicted. + */ + public synchronized final int evictionCount() { + return 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<>(map); + } + + @NonNull + @SuppressLint("DefaultLocale") + @Override public synchronized final String toString() { + int accesses = hitCount + missCount; + int hitPercent = accesses != 0 ? (100 * hitCount / accesses) : 0; + return String.format("LruCache[maxSize=%d,hits=%d,misses=%d,hitRate=%d%%]", + maxSize, hitCount, missCount, hitPercent); + } +} diff --git a/app/src/main/java/com/hippo/beerbelly/SimpleDiskCache.java b/app/src/main/java/com/hippo/beerbelly/SimpleDiskCache.java new file mode 100644 index 000000000..dcdcf7bad --- /dev/null +++ b/app/src/main/java/com/hippo/beerbelly/SimpleDiskCache.java @@ -0,0 +1,522 @@ +/* + * Copyright 2015-2016 Hippo Seven + * + * 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.beerbelly; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.hippo.streampipe.InputStreamPipe; +import com.hippo.streampipe.OutputStreamPipe; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public class SimpleDiskCache { + + private static final String TAG = SimpleDiskCache.class.getSimpleName(); + + /** + * Disk cache is free + */ + private static final int STATE_DISK_CACHE_NONE = 0; + + /** + * Disk cache is flushing or clearing + */ + private static final int STATE_DISK_CACHE_IN_USE = 1; + + /** + * Disk cache is reading or writing + */ + private static final int STATE_DISK_CACHE_BUSY = 2; + + private final File mCacheDir; + private final int mSize; + + private int mDiskCacheState; + + @Nullable + private DiskLruCache mDiskLruCache; + @NonNull + private final Map mLockMap = new HashMap<>(); + @NonNull + private final LockPool mLockPool = new LockPool(); + + public SimpleDiskCache(File cacheDir, int size) { + mCacheDir = cacheDir; + mSize = size; + + try { + init(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public synchronized boolean isValid() { + return mDiskLruCache != null; + } + + private synchronized void init() throws IOException { + if (!isValid()) { + mDiskLruCache = DiskLruCache.open(mCacheDir, 1, 1, mSize); + } + } + + public synchronized long size() { + if (null != mDiskLruCache) { + return mDiskLruCache.size(); + } else { + return -1L; + } + } + + public synchronized void flush() { + // Wait for cache available + while (mDiskCacheState != STATE_DISK_CACHE_NONE) { + try { + wait(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + mDiskCacheState = STATE_DISK_CACHE_BUSY; + + if (null != mDiskLruCache) { + try { + mDiskLruCache.flush(); + } catch (IOException e) { + Log.e(TAG, "SimpleDiskCache flush", e); + } + } + + mDiskCacheState = STATE_DISK_CACHE_NONE; + notifyAll(); + } + + public synchronized boolean clear() { + boolean result = true; + if (null == mDiskLruCache) { + return false; + } + + // Wait for cache available + while (mDiskCacheState != STATE_DISK_CACHE_NONE) { + try { + wait(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + mDiskCacheState = STATE_DISK_CACHE_BUSY; + + try { + mDiskLruCache.delete(); + } catch (IOException e) { + Log.e(TAG, "SimpleDiskCache clearCache", e); + } + mDiskLruCache = null; + try { + init(); + } catch (IOException e) { + Log.e(TAG, "SimpleDiskCache init", e); + result = false; + } + + mDiskCacheState = STATE_DISK_CACHE_NONE; + notifyAll(); + return result; + } + + private synchronized CounterLock obtainLock(String key) { + // Wait for clear over + while (mDiskCacheState == STATE_DISK_CACHE_BUSY) { + try { + wait(); + } catch (InterruptedException e) { + // Empty + } + } + mDiskCacheState = STATE_DISK_CACHE_IN_USE; + + // Get lock from key + CounterLock lock; + lock = mLockMap.get(key); + if (lock == null) { + lock = mLockPool.get(); + mLockMap.put(key, lock); + } + lock.obtain(); + + return lock; + } + + private synchronized void releaseLock(String key, CounterLock lock) { + lock.release(); + if (lock.isFree()) { + mLockMap.remove(key); + mLockPool.push(lock); + } + + if (mLockMap.isEmpty()) { + mDiskCacheState = STATE_DISK_CACHE_NONE; + notifyAll(); + } + } + + public boolean contain(@NonNull String key) { + String diskKey = hashKeyForDisk(key); + CounterLock lock = obtainLock(diskKey); + if (null == mDiskLruCache) { + releaseLock(diskKey, lock); + return false; + } + lock.readLock().lock(); + + boolean result = mDiskLruCache.contain(diskKey); + + lock.readLock().unlock(); + releaseLock(diskKey, lock); + + return result; + } + + public boolean remove(@NonNull String key) { + String diskKey = hashKeyForDisk(key); + CounterLock lock = obtainLock(diskKey); + if (null == mDiskLruCache) { + releaseLock(diskKey, lock); + return false; + } + lock.writeLock().lock(); + + boolean result; + try { + result = mDiskLruCache.remove(diskKey); + } catch (IOException e) { + e.printStackTrace(); + result = false; + } + + lock.writeLock().unlock(); + releaseLock(diskKey, lock); + + return result; + } + + public boolean put(@NonNull String key, @NonNull InputStream is) { + String diskKey = hashKeyForDisk(key); + CounterLock lock = obtainLock(diskKey); + if (null == mDiskLruCache) { + releaseLock(diskKey, lock); + return false; + } + lock.writeLock().lock(); + + boolean result = false; + if (isValid()) { + result = putToDisk(diskKey, is); + } + + lock.writeLock().unlock(); + releaseLock(diskKey, lock); + + return result; + } + + private boolean putToDisk(String key, InputStream is) { + DiskLruCache.Editor editor = null; + OutputStream os = null; + boolean completeEdit = false; + try { + //noinspection ConstantConditions + editor = mDiskLruCache.edit(key); + if (editor == null) { + // The editor is in progress + return false; + } + + os = editor.newOutputStream(0); + if (os != null) { + Util.copy(is, os); + completeEdit = true; + editor.commit(); + return true; + } else { + // Can't get OutputStream + completeEdit = true; + editor.abort(); + return false; + } + } catch (IOException e) { + Util.closeQuietly(os); + try { + if (!completeEdit && editor != null) { + editor.abort(); + } + } catch (IOException ignored) { + } + return false; + } + } + + /** + * @param key the key of the target + * @return the InputStreamPipe, null for missing + */ + @Nullable + public InputStreamPipe getInputStreamPipe(@NonNull String key) { + if (contain(key)) { + String diskKey = hashKeyForDisk(key); + return new CacheInputStreamPipe(diskKey); + } else { + return null; + } + } + + @NonNull + public OutputStreamPipe getOutputStreamPipe(@NonNull String key) { + String diskKey = hashKeyForDisk(key); + return new CacheOutputStreamPipe(diskKey); + } + + /** + * 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 + */ + private static 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 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(); + } + + private class CacheInputStreamPipe implements InputStreamPipe { + + private final String mKey; + private CounterLock mLock; + private DiskLruCache.Snapshot mCurrentSnapshot; + + private CacheInputStreamPipe(String key) { + mKey = key; + } + + @Override + public void obtain() { + if (mLock == null) { + mLock = obtainLock(mKey); + mLock.readLock().lock(); + } + } + + @Override + public void release() { + if (mCurrentSnapshot != null) { + throw new IllegalStateException("Please close it first"); + } + + if (mLock != null) { + mLock.readLock().unlock(); + releaseLock(mKey, mLock); + mLock = null; + } + } + + @Override + public @NonNull InputStream open() throws IOException { + if (mLock == null) { + throw new IllegalStateException("Please obtain it first"); + } + if (mCurrentSnapshot != null) { + throw new IllegalStateException("Please close it before reopen"); + } + if (null == mDiskLruCache) { + throw new IOException("Can't find disk lru cache"); + } + + DiskLruCache.Snapshot snapshot; + snapshot = mDiskLruCache.get(mKey); + if (snapshot == null) { + throw new IOException("Miss the key " + mKey); + } + mCurrentSnapshot = snapshot; + return snapshot.getInputStream(0); + } + + @Override + public void close() { + Util.closeQuietly(mCurrentSnapshot); + mCurrentSnapshot = null; + } + } + + private class CacheOutputStreamPipe implements OutputStreamPipe { + + private final String mKey; + private CounterLock mLock; + private DiskLruCache.Editor mCurrentEditor; + + private CacheOutputStreamPipe(String key) { + mKey = key; + } + + @Override + public void obtain() { + if (mLock == null) { + mLock = obtainLock(mKey); + mLock.writeLock().lock(); + } + } + + @Override + public void release() { + if (mCurrentEditor != null) { + throw new IllegalStateException("Please close it first"); + } + + if (mLock != null) { + mLock.writeLock().unlock(); + releaseLock(mKey, mLock); + mLock = null; + } + } + + @Override + public @NonNull OutputStream open() throws IOException { + if (mLock == null) { + throw new IllegalStateException("Please obtain it first"); + } + if (mCurrentEditor != null) { + throw new IllegalStateException("Please close it before reopen"); + } + if (null == mDiskLruCache) { + throw new IOException("Can't find disk lru cache"); + } + + DiskLruCache.Editor editor = mDiskLruCache.edit(mKey); + if (editor == null) { + throw new IOException("Miss the key " + mKey); + } + + OutputStream os; + try { + os = editor.newOutputStream(0); + mCurrentEditor = editor; + return os; + } catch (IOException e) { + // Get trouble + try { + editor.abort(); + } catch (IOException ex) { + // Ignore + } + throw e; + } + } + + @Override + public void close() { + if (mCurrentEditor != null) { + try { + mCurrentEditor.commit(); + } catch (IOException e) { + // Empty + } + mCurrentEditor = null; + } + } + } + + private class CounterLock extends ReentrantReadWriteLock { + + private int mCount; + + public boolean isFree() { + return mCount == 0; + } + + public void release() { + if (--mCount < 0) { + throw new IllegalStateException("Release time is more than occupy time"); + } + } + + public void obtain() { + ++mCount; + } + } + + private class LockPool { + + private static final int MAX_SIZE = 10; + + private final CounterLock[] mArray = new CounterLock[MAX_SIZE]; + private final int mMaxSize = MAX_SIZE; + private int mSize; + + public void push(CounterLock lock) { + if (lock != null && mSize < mMaxSize) { + mArray[mSize++] = lock; + } + } + + public CounterLock get() { + if (mSize > 0) { + CounterLock lock = mArray[--mSize]; + mArray[mSize] = null; + return lock; + } else { + return new CounterLock(); + } + } + } +} diff --git a/app/src/main/java/com/hippo/beerbelly/StrictLineReader.java b/app/src/main/java/com/hippo/beerbelly/StrictLineReader.java new file mode 100644 index 000000000..ea7995c73 --- /dev/null +++ b/app/src/main/java/com/hippo/beerbelly/StrictLineReader.java @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2012 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.beerbelly; + +import androidx.annotation.NonNull; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; + +/** + * Buffers input from an {@link InputStream} for reading lines. + * + *

This class is used for buffered reading of lines. For purposes of this class, a line ends + * with "\n" or "\r\n". End of input is reported by throwing {@code EOFException}. Unterminated + * line at end of input is invalid and will be ignored, the caller may use {@code + * hasUnterminatedLine()} to detect it after catching the {@code EOFException}. + * + *

This class is intended for reading input that strictly consists of lines, such as line-based + * cache entries or cache journal. Unlike the {@link java.io.BufferedReader} which in conjunction + * with {@link java.io.InputStreamReader} provides similar functionality, this class uses different + * end-of-input reporting and a more restrictive definition of a line. + * + *

This class supports only charsets that encode '\r' and '\n' as a single byte with value 13 + * and 10, respectively, and the representation of no other character contains these values. + * We currently check in constructor that the charset is one of US-ASCII, UTF-8 and ISO-8859-1. + * The default charset is US_ASCII. + */ +class StrictLineReader implements Closeable { + private static final byte CR = (byte) '\r'; + private static final byte LF = (byte) '\n'; + + private final InputStream in; + private final Charset charset; + + /* + * Buffered data is stored in {@code buf}. As long as no exception occurs, 0 <= pos <= end + * and the data in the range [pos, end) is buffered for reading. At end of input, if there is + * an unterminated line, we set end == -1, otherwise end == pos. If the underlying + * {@code InputStream} throws an {@code IOException}, end may remain as either pos or -1. + */ + private byte[] buf; + private int pos; + private int end; + + /** + * Constructs a new {@code LineReader} with the specified charset and the default capacity. + * + * @param in the {@code InputStream} to read data from. + * @param charset the charset used to decode data. Only US-ASCII, UTF-8 and ISO-8859-1 are + * supported. + * @throws NullPointerException if {@code in} or {@code charset} is null. + * @throws IllegalArgumentException if the specified charset is not supported. + */ + public StrictLineReader(InputStream in, Charset charset) { + this(in, 8192, charset); + } + + /** + * Constructs a new {@code LineReader} with the specified capacity and charset. + * + * @param in the {@code InputStream} to read data from. + * @param capacity the capacity of the buffer. + * @param charset the charset used to decode data. Only US-ASCII, UTF-8 and ISO-8859-1 are + * supported. + * @throws NullPointerException if {@code in} or {@code charset} is null. + * @throws IllegalArgumentException if {@code capacity} is negative or zero + * or the specified charset is not supported. + */ + public StrictLineReader(InputStream in, int capacity, Charset charset) { + if (in == null || charset == null) { + throw new NullPointerException(); + } + if (capacity < 0) { + throw new IllegalArgumentException("capacity <= 0"); + } + if (!(charset.equals(Util.US_ASCII))) { + throw new IllegalArgumentException("Unsupported encoding"); + } + + this.in = in; + this.charset = charset; + buf = new byte[capacity]; + } + + /** + * Closes the reader by closing the underlying {@code InputStream} and + * marking this reader as closed. + * + * @throws IOException for errors when closing the underlying {@code InputStream}. + */ + public void close() throws IOException { + synchronized (in) { + if (buf != null) { + buf = null; + in.close(); + } + } + } + + /** + * Reads the next line. A line ends with {@code "\n"} or {@code "\r\n"}, + * this end of line marker is not included in the result. + * + * @return the next line from the input. + * @throws IOException for underlying {@code InputStream} errors. + * @throws EOFException for the end of source stream. + */ + public String readLine() throws IOException { + synchronized (in) { + if (buf == null) { + throw new IOException("LineReader is closed"); + } + + // Read more data if we are at the end of the buffered data. + // Though it's an error to read after an exception, we will let {@code fillBuf()} + // throw again if that happens; thus we need to handle end == -1 as well as end == pos. + if (pos >= end) { + fillBuf(); + } + // Try to find LF in the buffered data and return the line if successful. + for (int i = pos; i != end; ++i) { + if (buf[i] == LF) { + int lineEnd = (i != pos && buf[i - 1] == CR) ? i - 1 : i; + String res = new String(buf, pos, lineEnd - pos, charset.name()); + pos = i + 1; + return res; + } + } + + // Let's anticipate up to 80 characters on top of those already read. + ByteArrayOutputStream out = new ByteArrayOutputStream(end - pos + 80) { + @NonNull + @Override + public String toString() { + int length = (count > 0 && buf[count - 1] == CR) ? count - 1 : count; + try { + return new String(buf, 0, length, charset.name()); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); // Since we control the charset this will never happen. + } + } + }; + + while (true) { + out.write(buf, pos, end - pos); + // Mark unterminated line in case fillBuf throws EOFException or IOException. + end = -1; + fillBuf(); + // Try to find LF in the buffered data and return the line if successful. + for (int i = pos; i != end; ++i) { + if (buf[i] == LF) { + if (i != pos) { + out.write(buf, pos, i - pos); + } + pos = i + 1; + return out.toString(); + } + } + } + } + } + + public boolean hasUnterminatedLine() { + return end == -1; + } + + /** + * Reads new input data into the buffer. Call only with pos == end or end == -1, + * depending on the desired outcome if the function throws. + */ + private void fillBuf() throws IOException { + int result = in.read(buf, 0, buf.length); + if (result == -1) { + throw new EOFException(); + } + pos = 0; + end = result; + } +} + diff --git a/app/src/main/java/com/hippo/beerbelly/Util.java b/app/src/main/java/com/hippo/beerbelly/Util.java new file mode 100644 index 000000000..4f2258e3d --- /dev/null +++ b/app/src/main/java/com/hippo/beerbelly/Util.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2010 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.beerbelly; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.StringWriter; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/** Junk drawer of utility methods. */ +final class Util { + static final Charset US_ASCII = StandardCharsets.US_ASCII; + static final Charset UTF_8 = StandardCharsets.UTF_8; + + private Util() { + } + + static String readFully(Reader reader) throws IOException { + try (reader) { + StringWriter writer = new StringWriter(); + char[] buffer = new char[1024]; + int count; + while ((count = reader.read(buffer)) != -1) { + writer.write(buffer, 0, count); + } + return writer.toString(); + } + } + + /** + * Deletes the contents of {@code dir}. Throws an IOException if any file + * could not be deleted, or if {@code dir} is not a readable directory. + */ + static void deleteContents(File dir) throws IOException { + File[] files = dir.listFiles(); + if (files == null) { + throw new IOException("not a readable directory: " + dir); + } + for (File file : files) { + if (file.isDirectory()) { + deleteContents(file); + } + if (!file.delete()) { + throw new IOException("failed to delete file: " + file); + } + } + } + + static void closeQuietly(/*Auto*/Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } + } + + private static final int EOF = -1; + private static final int DEFAULT_BUFFER_SIZE = 1024 * 4; + + /** + * Copy bytes from an InputStream to an + * OutputStream. + * + * @param input the InputStream + * @param output the OutputStream + * @return the number of bytes copied + */ + public static long copy(InputStream input, OutputStream output) + throws IOException { + byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; + long count = 0; + int n; + while (EOF != (n = input.read(buffer))) { + output.write(buffer, 0, n); + count += n; + } + return count; + } +}