diff --git a/app/src/main/java/com/hippo/ehviewer/AppContext.java b/app/src/main/java/com/hippo/ehviewer/AppContext.java index fbdc521bf..f15a3912b 100644 --- a/app/src/main/java/com/hippo/ehviewer/AppContext.java +++ b/app/src/main/java/com/hippo/ehviewer/AppContext.java @@ -23,12 +23,12 @@ import android.content.pm.PackageManager.NameNotFoundException; import android.graphics.Typeface; import android.view.ContextThemeWrapper; -import com.hippo.ehviewer.cache.ImageCache; import com.hippo.ehviewer.data.Data; import com.hippo.ehviewer.ehclient.EhClient; import com.hippo.ehviewer.ehclient.EhInfo; import com.hippo.ehviewer.ehclient.ExDownloaderManager; import com.hippo.ehviewer.network.HttpHelper; +import com.hippo.ehviewer.cache.ImageCache; import com.hippo.ehviewer.util.Config; import com.hippo.ehviewer.util.Crash; import com.hippo.ehviewer.util.EhUtils; @@ -148,7 +148,7 @@ public class AppContext extends Application implements UncaughtExceptionHandler */ @Override public void onLowMemory() { - ImageCache.getInstance(this).evictAll(); + ImageCache.getImageCache(this).evictAll(); super.onLowMemory(); } diff --git a/app/src/main/java/com/hippo/ehviewer/ImageLoader.java b/app/src/main/java/com/hippo/ehviewer/ImageLoader.java index f5198c0f9..da1c1cdde 100644 --- a/app/src/main/java/com/hippo/ehviewer/ImageLoader.java +++ b/app/src/main/java/com/hippo/ehviewer/ImageLoader.java @@ -19,8 +19,9 @@ package com.hippo.ehviewer; import android.content.Context; import android.graphics.Bitmap; -import com.hippo.ehviewer.cache.ImageCache; import com.hippo.ehviewer.network.HttpHelper; +import com.hippo.ehviewer.cache.AnyCache; +import com.hippo.ehviewer.cache.ImageCache; import com.hippo.ehviewer.util.BgThread; import java.util.Stack; @@ -52,7 +53,7 @@ public class ImageLoader { private final Context mContext; private final Stack mLoadTasks; - private final ImageCache mImageCache; + private final AnyCache mImageCache; private final ImageDownloader mImageDownloader; private ImageLoader(Context context) { @@ -60,7 +61,7 @@ public class ImageLoader { mImageDownloader = new ImageDownloader(); mContext = context; - mImageCache = ImageCache.getInstance(mContext); + mImageCache = ImageCache.getImageCache(mContext); new BgThread(new LoadFromCacheTask()).start(); } @@ -110,7 +111,7 @@ public class ImageLoader { int[] state = new int[1]; String key = loadTask.key; - loadTask.bitmap = mImageCache.getCachedBitmap(key, state); + loadTask.bitmap = mImageCache.get(key); loadTask.state = state[0]; if (loadTask.bitmap != null) @@ -158,7 +159,7 @@ public class ImageLoader { // TODO use proxy to get image loadTask.bitmap = httpHelper.getImage(loadTask.url); if (loadTask.bitmap != null) { - mImageCache.addBitmapToCache(loadTask.key, loadTask.bitmap); + mImageCache.put(loadTask.key, loadTask.bitmap); loadTask.state = STATE_FROM_NETWORK; } else { loadTask.state = STATE_NONE; diff --git a/app/src/main/java/com/hippo/ehviewer/cache/AnyCache.java b/app/src/main/java/com/hippo/ehviewer/cache/AnyCache.java new file mode 100644 index 000000000..5c58e1755 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/cache/AnyCache.java @@ -0,0 +1,405 @@ +/* + * Copyright (C) 2015 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.ehviewer.cache; + +import android.os.Looper; +import android.support.v4.util.LruCache; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public class AnyCache { + + private LruCache mMemoryCache; + private DiskCache mDiskCache; + + /** + * Used to temporarily pause the disk cache while scrolling + */ + public boolean mPauseDiskAccess = false; + private final Object mPauseLock = new Object(); + + private AnyCache(Builder builder) { + if (builder.mMemoryCacheMaxSize > 0 && builder.mMemoryCacheHelper != null) { + mMemoryCache = new MemoryCahce( + builder.mMemoryCacheMaxSize, builder.mMemoryCacheHelper); + } + if (builder.mDiskCacheDir != null && builder.mDiskCacheMaxSize > 0 && + builder.mDiskCacheHelper != null) { + try { + mDiskCache = new DiskCache( + builder.mDiskCacheDir, builder.mDiskCacheMaxSize, builder.mDiskCacheHelper); + } catch (IOException e) { + // Can't create disk cache + } + } + } + + /** + * Check if have memory cache + * + * @return true if have memory cache + */ + public boolean hasMemoryCache() { + return mMemoryCache != null; + } + + /** + * Check if have disk cache + * + * @return true if have disk cache + */ + public boolean hasDiskCache() { + return mDiskCache != null; + } + + /** + * 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(String key) { + if (mMemoryCache != null) { + return mMemoryCache.get(key); + } else { + return null; + } + } + + /** + * Get value from memory cache + * + * @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(String key) { + if (mDiskCache != null) { + // Wait for pause + waitUntilUnpaused(); + + String diskKey = hashKeyForDisk(key); + return mDiskCache.get(diskKey); + } 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(String key) { + V value; + if (mMemoryCache != null) { + value = mMemoryCache.get(key); + if (value != null) { + return value; + } + } + + if (mDiskCache != null) { + // Wait for pause + waitUntilUnpaused(); + + String diskKey = hashKeyForDisk(key); + value = mDiskCache.get(diskKey); + // Put value to memory cach + if (value != null && mMemoryCache != null) { + mMemoryCache.put(key, value); + } + return value; + } else { + return null; + } + } + + /** + * Put value to memory cache + * + * @param key the key + * @param value the value + * @return false if no memory cache + */ + public boolean putToMemory(String key, V value) { + if (mMemoryCache != null) { + 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(String key, V value) { + if (mDiskCache != null) { + // Wait for pause + waitUntilUnpaused(); + + String diskKey = hashKeyForDisk(key); + return mDiskCache.put(diskKey, value); + } else { + return false; + } + } + + /** + * Put value to memory cache and disk cache + * + * @param key the key + * @param value the value + */ + public void put(String key, V value) { + if (mMemoryCache != null) { + mMemoryCache.put(key, value); + } + + if (mDiskCache != null) { + // Wait for pause + waitUntilUnpaused(); + + String diskKey = hashKeyForDisk(key); + mDiskCache.put(diskKey, value); + } + } + + /** + * 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 + } + } + } + } + } + + /** + * 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 (mMemoryCache != null) { + mMemoryCache.evictAll(); + } + System.gc(); + } + + /** + * 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(); + } + + public static class Builder { + + private int mMemoryCacheMaxSize; + private MemoryCacheHelper mMemoryCacheHelper; + + private File mDiskCacheDir; + private int mDiskCacheMaxSize; + private DiskCacheHelper mDiskCacheHelper; + + public Builder setMemoryCache(int maxSize, MemoryCacheHelper helper) { + mMemoryCacheMaxSize = maxSize; + mMemoryCacheHelper = helper; + return this; + } + + /** + * Set this dir to store disk cache. + * + * @param cacheDir this dir to store disk cache, null for no disk cache + * @return the Builder + */ + public Builder setDiskCache(File cacheDir, int maxSize, DiskCacheHelper helper) { + mDiskCacheDir = cacheDir; + mDiskCacheMaxSize = maxSize; + mDiskCacheHelper = helper; + return this; + } + + public AnyCache build() { + return new AnyCache(this); + } + } + + public class MemoryCahce extends LruCache { + + private MemoryCacheHelper mHelper; + + public MemoryCahce(int maxSize, MemoryCacheHelper helper) { + super(maxSize); + mHelper = helper; + } + + @Override + protected int sizeOf(String key, E value) { + return mHelper.sizeOf(key, value); + } + } + + public abstract static class MemoryCacheHelper { + public abstract int sizeOf(String key, E value); + } + + public static class DiskCache { + + private static final int IO_BUFFER_SIZE = 8 * 1024; + + public DiskLruCache mDiskLruCache; + public DiskCacheHelper mHelper; + + public DiskCache(File cacheDir, int size, DiskCacheHelper helper) throws IOException { + mDiskLruCache = DiskLruCache.open(cacheDir, 1, 1, size); + mHelper = helper; + } + + public E get(String key) { + DiskLruCache.Snapshot snapshot = null; + try { + snapshot = mDiskLruCache.get(key); + if ( snapshot == null ) { + // Miss + return null; + } + + final InputStream in = snapshot.getInputStream(0); + if (in != null) { + final BufferedInputStream buffIn = + new BufferedInputStream(in, IO_BUFFER_SIZE); + return mHelper.get(buffIn); + } else { + // Can't get InputStream + return null; + } + } catch (IOException e) { + // e.printStackTrace(); + return null; + } finally { + Util.closeQuietly(snapshot); + } + } + + public boolean put(String key, E value) { + DiskLruCache.Editor editor = null; + OutputStream os = null; + try { + editor = mDiskLruCache.edit(key); + if (editor == null) { + // The editor is in progress + return false; + } + + os = editor.newOutputStream(0); + if (os != null) { + final BufferedOutputStream buffOut = + new BufferedOutputStream(os, IO_BUFFER_SIZE); + boolean result = mHelper.put(buffOut, value); + mDiskLruCache.flush(); + editor.commit(); + return result; + } else { + // Can't get OutputStream + editor.abort(); + return false; + } + } catch (IOException e) { + Util.closeQuietly(os); + try { + if (editor != null) { + editor.abort(); + } + } catch (IOException ignored) { + } + return false; + } + } + } + + public abstract static class DiskCacheHelper { + public abstract E get(InputStream is); + + public abstract boolean put(OutputStream os, E value); + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/cache/DiskLruCache.java b/app/src/main/java/com/hippo/ehviewer/cache/DiskLruCache.java index f351fc180..49cf7519d 100644 --- a/app/src/main/java/com/hippo/ehviewer/cache/DiskLruCache.java +++ b/app/src/main/java/com/hippo/ehviewer/cache/DiskLruCache.java @@ -1,17 +1,21 @@ /* - * 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. + * 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; @@ -19,951 +23,921 @@ 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; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** - ****************************************************************************** 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 + * 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. + *
  • 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 + * + *

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

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; + 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. + * 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 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; - private final File journalFile; + /** + * 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; - 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(); + /** 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. } - if (start < 0 || start > originalLength) { - throw new ArrayIndexOutOfBoundsException(); + trimToSize(); + if (journalRebuildRequired()) { + rebuildJournal(); + redundantOpCount = 0; } - 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; + } + 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"); } - /** - * 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(); - } + // 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); + } } - /** - * 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(); + // 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(); + } } - private void readJournal() throws IOException { - final InputStream in = new BufferedInputStream(new FileInputStream(journalFile), - IO_BUFFER_SIZE); + // 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 { - 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); + 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); } - 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'); + 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); + } - if (journalRebuildRequired()) { - executorService.submit(cleanupCallable); + 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]; } - - 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"); + } 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(); + } + + Writer writer = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(journalFileTmp), Util.US_ASCII)); + try { + 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'); + } + } + } finally { + writer.close(); + } + + 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(); + } + } + + /** + * 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 + ' ' + key + '\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 + ' ' + key + '\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; } /** - * Force buffered operations to the filesystem. + * 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 synchronized void flush() throws IOException { - checkNotClosed(); - trimToSize(); - journalWriter.flush(); + public Editor edit() throws IOException { + return DiskLruCache.this.edit(key, sequenceNumber); } - /** - * Closes this cache. Stored values will remain on the filesystem. - */ + /** 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 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; + public void write(int b) throws IOException { + // Eat all writes silently. Nom nom. } + }; - private void trimToSize() throws IOException { - while (size > maxSize) { - // Map.Entry toEvict = lruEntries.eldest(); - final Map.Entry toEvict = lruEntries.entrySet().iterator().next(); - remove(toEvict.getKey()); - } + /** 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]; } /** - * 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. + * Returns an unbuffered input stream to read the last committed value, + * or null if no value has been committed. */ - 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 - + "\""); + public InputStream newInputStream(int index) throws IOException { + synchronized (DiskLruCache.this) { + if (entry.currentEditor != this) { + throw new IllegalStateException(); } - } - - private static String inputStreamToString(final InputStream in) throws IOException { - return readFully(new InputStreamReader(in, UTF_8)); + if (!entry.readable) { + return null; + } + try { + return new FileInputStream(entry.getCleanFile(index)); + } catch (FileNotFoundException e) { + return null; + } + } } /** - * A snapshot of the values for an entry. + * Returns the last committed value as a string, or null if no value + * has been committed. */ - 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); - } - } + public String getString(int index) throws IOException { + InputStream in = newInputStream(index); + return in != null ? inputStreamToString(in) : null; } /** - * Edits the values for an entry. + * 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 final class Editor { - private final Entry entry; - - private boolean hasErrors; - - private Editor(final Entry entry) { - this.entry = entry; + 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(); } - - /** - * 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)); - } + if (!entry.readable) { + written[index] = true; } - - /** - * 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; - } - } + 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); + } } - 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"); - } + /** 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() throws IOException { + 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/ehviewer/cache/ImageCache.java b/app/src/main/java/com/hippo/ehviewer/cache/ImageCache.java index f7bf74c5e..a763eceeb 100644 --- a/app/src/main/java/com/hippo/ehviewer/cache/ImageCache.java +++ b/app/src/main/java/com/hippo/ehviewer/cache/ImageCache.java @@ -1,560 +1,83 @@ /* - * 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. + * Copyright (C) 2015 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.ehviewer.cache; +import android.app.ActivityManager; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Build; + 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 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.Context; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.Bitmap.CompressFormat; -import android.graphics.BitmapFactory; -import android.os.AsyncTask; -import android.os.Bundle; -import android.os.Environment; -import android.os.Looper; -import android.support.v4.app.FragmentActivity; -import android.util.Log; +public class ImageCache { -import com.hippo.ehviewer.util.Utils; - -/** - * This class holds the memory and disk bitmap caches. - */ -public final class ImageCache { - - private static final String TAG = ImageCache.class.getSimpleName(); + private static final String TAG = "ImageCache"; /** * 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; + private static final Bitmap.CompressFormat COMPRESS_FORMAT = Bitmap.CompressFormat.JPEG; /** * Image compression quality */ private static final int COMPRESS_QUALITY = 98; - /** - * LRU cache - */ - private MemoryCache mLruCache; + private static AnyCache sImageCache; - /** - * Disk LRU cache - */ - private DiskLruCache mDiskCache; + public static AnyCache getImageCache(Context context) { + if (sImageCache == null) { + AnyCache.Builder builder = new AnyCache.Builder(); - private static ImageCache sInstance; + final ActivityManager activityManager = (ActivityManager)context + .getSystemService(Context.ACTIVITY_SERVICE); + final int memoryCacheMax = Math.round(MEM_CACHE_DIVIDER * activityManager.getMemoryClass() + * 1024 * 1024); + builder.setMemoryCache(memoryCacheMax, new BitmapMemoryCacheHelper()); - /** - * Used to temporarily pause the disk cache while scrolling - */ - public boolean mPauseDiskAccess = false; - private final Object mPauseLock = new Object(); - - /** - * 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) { - Utils.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; - } - } + builder.setDiskCache(diskCacheDir, 20 * 1024 * 1024, new BitmapDiskCacheHelper()); } + + sImageCache = builder.build(); } + return sImageCache; } /** - * Sets up the Lru cache + * Get the external app cache directory * * @param context The {@link Context} to use + * @return The external cache directory */ - @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; - } - - public static final int STATE_NONE = 0; - public static final int STATE_FROM_MEMORY = 1; - public static final int STATE_FROM_DISK = 2; - - - /** - * 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, int[] state) { - if (data == null) { - return null; - } - Bitmap cachedImage = getBitmapFromMemCache(data); - if (cachedImage == null) { - cachedImage = getBitmapFromDiskCache(data); - if (cachedImage != null) { - addBitmapToMemCache(data, cachedImage); - if (state != null) - state[0] = STATE_FROM_DISK; - } else { - if (state != null) - state[0] = STATE_NONE; - } - } else { - if (state != null) - state[0] = STATE_FROM_MEMORY; - } - return cachedImage; - } - - /** - * flush() is called to synchronize up other methods that are accessing the - * cache first - */ - public void flush() { - Utils.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() { - Utils.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() { - Utils.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; + public static File getExternalCacheDir(final Context context) { + return context.getExternalCacheDir(); } /** @@ -565,157 +88,35 @@ public final class ImageCache { * directory * @return The cache directory */ - public static final File getDiskCacheDir(final Context context, final String uniqueName) { + public static 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(); + ? 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'); + private static class BitmapMemoryCacheHelper extends AnyCache.MemoryCacheHelper { + @Override + public int sizeOf(String key, android.graphics.Bitmap bitmap) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return bitmap.getAllocationByteCount(); + } else { + return bitmap.getByteCount(); } - 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} - */ + private static class BitmapDiskCacheHelper extends AnyCache.DiskCacheHelper { @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - // Make sure this Fragment is retained over a configuration change - setRetainInstance(true); + public Bitmap get(InputStream is) { + return BitmapFactory.decodeStream(is); } - /** - * 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); + public boolean put(OutputStream os, Bitmap value) { + value.compress(COMPRESS_FORMAT, COMPRESS_QUALITY, os); + return true; } - } - } diff --git a/app/src/main/java/com/hippo/ehviewer/cache/LruCache.java b/app/src/main/java/com/hippo/ehviewer/cache/LruCache.java deleted file mode 100644 index 448e8a059..000000000 --- a/app/src/main/java/com/hippo/ehviewer/cache/LruCache.java +++ /dev/null @@ -1,333 +0,0 @@ -/* - * 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/app/src/main/java/com/hippo/ehviewer/cache/StrictLineReader.java b/app/src/main/java/com/hippo/ehviewer/cache/StrictLineReader.java new file mode 100644 index 000000000..e16559b25 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/cache/StrictLineReader.java @@ -0,0 +1,196 @@ +/* + * 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.ehviewer.cache; + +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) { + @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/ehviewer/cache/Util.java b/app/src/main/java/com/hippo/ehviewer/cache/Util.java new file mode 100644 index 000000000..f3bdb4507 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/cache/Util.java @@ -0,0 +1,77 @@ +/* + * 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.ehviewer.cache; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.io.StringWriter; +import java.nio.charset.Charset; + +/** Junk drawer of utility methods. */ +final class Util { + static final Charset US_ASCII = Charset.forName("US-ASCII"); + static final Charset UTF_8 = Charset.forName("UTF-8"); + + private Util() { + } + + static String readFully(Reader reader) throws IOException { + try { + 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(); + } finally { + reader.close(); + } + } + + /** + * 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) { + } + } + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/ui/AbsActivity.java b/app/src/main/java/com/hippo/ehviewer/ui/AbsActivity.java index 12ac4463c..91a090ea9 100644 --- a/app/src/main/java/com/hippo/ehviewer/ui/AbsActivity.java +++ b/app/src/main/java/com/hippo/ehviewer/ui/AbsActivity.java @@ -19,7 +19,6 @@ package com.hippo.ehviewer.ui; import android.app.Activity; import com.google.analytics.tracking.android.EasyTracker; -import com.hippo.ehviewer.cache.ImageCache; import com.hippo.ehviewer.util.Config; import com.hippo.ehviewer.util.Ui; @@ -46,7 +45,5 @@ public abstract class AbsActivity extends Activity { if (Config.getAllowAnalyics()) EasyTracker.getInstance(this).activityStop(this); - - ImageCache.getInstance(this).flush(); } } diff --git a/app/src/main/java/com/hippo/ehviewer/ui/GalleryDetailActivity.java b/app/src/main/java/com/hippo/ehviewer/ui/GalleryDetailActivity.java index 2c484e7e6..d0f8e651f 100644 --- a/app/src/main/java/com/hippo/ehviewer/ui/GalleryDetailActivity.java +++ b/app/src/main/java/com/hippo/ehviewer/ui/GalleryDetailActivity.java @@ -27,6 +27,7 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.res.ColorStateList; import android.content.res.Resources; +import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; @@ -56,7 +57,6 @@ import com.hippo.ehviewer.AppContext; import com.hippo.ehviewer.ImageLoader; import com.hippo.ehviewer.R; import com.hippo.ehviewer.app.MaterialAlertDialog; -import com.hippo.ehviewer.cache.ImageCache; import com.hippo.ehviewer.data.ApiGalleryDetail; import com.hippo.ehviewer.data.Comment; import com.hippo.ehviewer.data.Data; @@ -75,6 +75,8 @@ import com.hippo.ehviewer.drawable.OvalDrawable; import com.hippo.ehviewer.effect.ripple.RippleSalon; import com.hippo.ehviewer.ehclient.DetailUrlParser; import com.hippo.ehviewer.ehclient.EhClient; +import com.hippo.ehviewer.cache.AnyCache; +import com.hippo.ehviewer.cache.ImageCache; import com.hippo.ehviewer.service.DownloadService; import com.hippo.ehviewer.util.Config; import com.hippo.ehviewer.util.Constants; @@ -1159,7 +1161,7 @@ public class GalleryDetailActivity extends AbsTranslucentActivity @Override public void onScrollStateChanged(ResponedScrollView view, int state) { if (view == mDetailScroll) { - ImageCache imageCache = ImageCache.getInstance(this); + AnyCache imageCache = ImageCache.getImageCache(this); if (state == ResponedScrollView.SCROLL_START) imageCache.setPauseDiskCache(true); else diff --git a/app/src/main/java/com/hippo/ehviewer/widget/GalleryListView.java b/app/src/main/java/com/hippo/ehviewer/widget/GalleryListView.java index c5fd47df6..ef203f7ee 100644 --- a/app/src/main/java/com/hippo/ehviewer/widget/GalleryListView.java +++ b/app/src/main/java/com/hippo/ehviewer/widget/GalleryListView.java @@ -16,12 +16,10 @@ package com.hippo.ehviewer.widget; -import java.util.ArrayList; -import java.util.List; - import android.annotation.TargetApi; import android.content.Context; import android.content.res.Configuration; +import android.graphics.Bitmap; import android.os.Build; import android.support.v7.widget.DefaultItemAnimator; import android.support.v7.widget.RecyclerView; @@ -36,19 +34,23 @@ import android.widget.TextView; import com.hippo.ehviewer.AppHandler; import com.hippo.ehviewer.ImageLoader; import com.hippo.ehviewer.R; -import com.hippo.ehviewer.cache.ImageCache; import com.hippo.ehviewer.cardview.CardViewSalon; import com.hippo.ehviewer.data.GalleryInfo; import com.hippo.ehviewer.ehclient.ListParser; +import com.hippo.ehviewer.cache.ImageCache; import com.hippo.ehviewer.util.Config; import com.hippo.ehviewer.util.Ui; import com.hippo.ehviewer.util.ViewUtils; import com.hippo.ehviewer.widget.recyclerview.EasyRecyclerView; -import com.hippo.ehviewer.widget.recyclerview.MarginItemDecoration; import com.hippo.ehviewer.widget.recyclerview.EasyRecyclerView.FooterAdapter; +import com.hippo.ehviewer.widget.recyclerview.MarginItemDecoration; +import com.hippo.ehviewer.widget.refreshlayout.RefreshLayout; -public class GalleryListView extends FrameLayout implements RefreshLayout.OnFooterRefreshListener, - RefreshLayout.OnRefreshListener { +import java.util.ArrayList; +import java.util.List; + +public class GalleryListView extends FrameLayout implements RefreshLayout.OnHeaderRefreshListener, + RefreshLayout.OnFooterRefreshListener { private static final String TAG = GalleryListView.class.getSimpleName(); @@ -112,6 +114,8 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot private int mListDetailThumbWidth; private int mListDetailThumbHeight; + private int mOldPaddingBottom; + public GalleryListView(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs, 0, 0); @@ -130,13 +134,13 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { mContext = context; - mGiList = new ArrayList(); + mGiList = new ArrayList<>(); mListener = new OnGetGalleryListListener(); LayoutInflater.from(mContext).inflate(R.layout.gallery_list_view, this); mRefreshLayout = (RefreshLayout) findViewById(R.id.refresh_layout); - mEasyRecyclerView = mRefreshLayout.getEasyRecyclerView(); + mEasyRecyclerView = (EasyRecyclerView) mRefreshLayout.findViewById(R.id.easy_recycler_view); mRefreshTextView = (RefreshTextView) findViewById(R.id.refresh_text); mAdapter = new GalleryAdapter(mContext, mGiList); @@ -158,18 +162,24 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot } }); - mRefreshLayout.setColorSchemeResources( + mRefreshLayout.setHeaderEnable(true); + mRefreshLayout.setFooterEnable(true); + mRefreshLayout.setHeaderColorSchemeResources( R.color.refresh_color_1, R.color.refresh_color_2, R.color.refresh_color_3, - R.color.refresh_color_4); + R.color.refresh_color_4 + ); + mRefreshLayout.setFooterColorSchemeResources( + R.color.refresh_color_1, + R.color.refresh_color_2, + R.color.refresh_color_3, + R.color.refresh_color_4 + ); mRefreshLayout.setOnHeaderRefreshListener(this); mRefreshLayout.setOnFooterRefreshListener(this); - mRefreshLayout.setFooterString( - mContext.getString(R.string.footer_loading), - mContext.getString(R.string.footer_loaded), - mContext.getString(R.string.footer_fail)); - mRefreshLayout.addFooterView(); + + mOldPaddingBottom = mEasyRecyclerView.getPaddingBottom(); // Caculate gallery detail height mInflater = LayoutInflater.from(mContext); @@ -226,7 +236,7 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot public void setPaddingTopAndBottom(int top, int bottom) { setPadding(getPaddingLeft(), top, getPaddingRight(), getPaddingBottom()); mEasyRecyclerView.setPadding(mEasyRecyclerView.getPaddingLeft(), mEasyRecyclerView.getPaddingTop(), - mEasyRecyclerView.getPaddingRight(), mEasyRecyclerView.getPaddingBottom() + bottom); + mEasyRecyclerView.getPaddingRight(), mOldPaddingBottom + bottom); } private void setItemMargin(int margin) { @@ -237,15 +247,15 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot } public void setEnabledHeader(boolean enabled) { - mRefreshLayout.setEnabledHeader(enabled); + mRefreshLayout.setHeaderEnable(enabled); } public void setEnabledFooter(boolean enabled) { - mRefreshLayout.setEnabledFooter(enabled); + mRefreshLayout.setFooterEnable(enabled); } public void setHeaderRefreshing(boolean refreshing) { - mRefreshLayout.setRefreshing(refreshing); + mRefreshLayout.setHeaderRefreshing(refreshing); } public void setOnItemClickListener(EasyRecyclerView.OnItemClickListener l) { @@ -294,7 +304,7 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot } @Override - public void onRefresh() { + public void onHeaderRefresh() { // It is invokened by user pull, so no need to if (mFirstPage > 0) { getPrePage(true); @@ -327,7 +337,7 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot private void setGallerysLayout() { if (mRefreshLayout.getVisibility() == View.VISIBLE) { if (!isFootRefresh) - mRefreshLayout.setRefreshing(true); + mRefreshLayout.setHeaderRefreshing(true); mRefreshTextView.setVisibility(View.GONE); } else { mRefreshLayout.setVisibility(View.GONE); @@ -541,7 +551,8 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot } } - mRefreshLayout.setAnyRefreshComplete(true); + mRefreshLayout.setHeaderRefreshing(false); + mRefreshLayout.setFooterRefreshing(false); } @Override @@ -572,7 +583,8 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot mRefreshTextView.setVisibility(View.GONE); MaterialToast.showToast(eMsg); } - mRefreshLayout.setAnyRefreshComplete(false); + mRefreshLayout.setHeaderRefreshing(false); + mRefreshLayout.setFooterRefreshing(false); } } @@ -602,17 +614,17 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot public void onScrollStateChanged(RecyclerView recyclerView, int newState) { switch (newState) { case RecyclerView.SCROLL_STATE_DRAGGING: - ImageCache.getInstance(mContext).setPauseDiskCache(true); + ImageCache.getImageCache(mContext).setPauseDiskCache(true); break; case RecyclerView.SCROLL_STATE_IDLE: - ImageCache.getInstance(mContext).setPauseDiskCache(false); + ImageCache.getImageCache(mContext).setPauseDiskCache(false); break; } } @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { - mRefreshLayout.onScrolled(recyclerView, dx, dy); + // mRefreshLayout.onScrolled(recyclerView, dx, dy); int itemCount = mLayoutManager.getChildCount(); int firstVisibleItem = mLayoutManager.findFirstVisibleItemPositions(mFirstPositionTemp)[0]; @@ -733,12 +745,18 @@ public class GalleryListView extends FrameLayout implements RefreshLayout.OnFoot int position) { GalleryInfo gi = mGiList.get(position); final LoadImageView thumb = holder.thumb; - if (!String.valueOf(gi.gid).equals(thumb.getKey())) { + final String key = String.valueOf(gi.gid); + if (!key.equals(thumb.getKey())) { // Set new thumb thumb.setImageDrawable(null); - thumb.setLoadInfo(gi.thumb, String.valueOf(gi.gid)); - mImageLoader.add(gi.thumb, String.valueOf(gi.gid), - new LoadImageView.SimpleImageGetListener(thumb).setFixScaleType(true)); + thumb.setLoadInfo(gi.thumb, key); + Bitmap b = ImageCache.getImageCache(getContext()).getFromMemory(key); + if (b != null) { + thumb.setContextImage(b, true, false); + } else { + mImageLoader.add(gi.thumb, String.valueOf(gi.gid), + new LoadImageView.SimpleImageGetListener(thumb).setFixScaleType(true)); + } } // Set simple language TextView simpleLanguage = holder.simpleLanguage; diff --git a/app/src/main/java/com/hippo/ehviewer/widget/LoadImageView.java b/app/src/main/java/com/hippo/ehviewer/widget/LoadImageView.java index 7554f947c..0762633aa 100644 --- a/app/src/main/java/com/hippo/ehviewer/widget/LoadImageView.java +++ b/app/src/main/java/com/hippo/ehviewer/widget/LoadImageView.java @@ -96,9 +96,19 @@ public class LoadImageView extends FixedAspectImageView { * Load target bmp, set progressive animation * @param bmp */ - public void setContextImage(Bitmap bmp, int state) { + public void setContextImage(Bitmap bmp, boolean fixScaleType, boolean transit) { + if (fixScaleType) { + float aspectRatio = (float)bmp.getHeight() / bmp.getWidth(); + if (aspectRatio < 1.7 && aspectRatio > 1.3) + setScaleType(ImageView.ScaleType.CENTER_CROP); + else + setScaleType(ImageView.ScaleType.FIT_CENTER); + } else { + setScaleType(ImageView.ScaleType.FIT_CENTER); + } + setImageBitmap(bmp); - if (state != ImageLoader.STATE_FROM_MEMORY) { + if (transit) { final Drawable drawable = getDrawable(); DrawableTransition.transit(drawable, false, 1000); } @@ -134,16 +144,8 @@ public class LoadImageView extends FixedAspectImageView { public void onGetImage(String key, Bitmap bmp, int state) { if (isVaild(key) && mLiv.getState() == LoadImageView.LOADING) { if (bmp != null) { - if (mFixScaleType) { - float aspectRatio = (float)bmp.getHeight() / bmp.getWidth(); - if (aspectRatio < 1.7 && aspectRatio > 1.3) - mLiv.setScaleType(ImageView.ScaleType.CENTER_CROP); - else - mLiv.setScaleType(ImageView.ScaleType.FIT_CENTER); - } - if (mTransitabled) - mLiv.setContextImage(bmp, state); + mLiv.setContextImage(bmp, mFixScaleType, true); else mLiv.setImageBitmap(bmp); mLiv.setState(LoadImageView.LOADED); diff --git a/app/src/main/java/com/hippo/ehviewer/widget/RefreshLayout.java b/app/src/main/java/com/hippo/ehviewer/widget/RefreshLayout.java deleted file mode 100644 index 5420a825b..000000000 --- a/app/src/main/java/com/hippo/ehviewer/widget/RefreshLayout.java +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright (C) 2014 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.ehviewer.widget; - -import android.content.Context; -import android.support.v4.widget.SwipeRefreshLayout; -import android.support.v7.widget.RecyclerView; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.TextView; - -import com.hippo.ehviewer.R; -import com.hippo.ehviewer.widget.recyclerview.EasyRecyclerView; - -public class RefreshLayout extends SwipeRefreshLayout { - - private final static int FOOTER_REFRESHING = 0; - private final static int FOOTER_SUCCESS = 1; - private final static int FOOTER_FAIL = 2; - - private EasyRecyclerView mEasyRecyclerView; - private View mFooterView; - - private TextView mFooterTipTextView; - private View mFooterProgressBar; - private int mFooterState = FOOTER_SUCCESS; - private OnFooterRefreshListener mFooterRefreshListener; - - // Footer String to show - private String mFooterRefreshStr; - private String mFooterSuccessStr; - private String mFooterFailStr; - - private boolean mIsEnabledHeader = true; - private boolean mIsEnabledFooter = true; - - public RefreshLayout(Context context) { - super(context); - init(context, null); - } - - public RefreshLayout(Context context, AttributeSet attrs) { - super(context, attrs); - init(context, attrs); - } - - private void init(Context context, AttributeSet attrs) { - if (attrs == null) - mEasyRecyclerView = new EasyRecyclerView(context); - else - mEasyRecyclerView = new EasyRecyclerView(context, attrs); - mEasyRecyclerView.setId(R.id.easy_recycler_view); - addView(mEasyRecyclerView); - - // Footer view - mFooterView = LayoutInflater.from(context).inflate(R.layout.pull_list_view_footer, null); - mFooterTipTextView = (TextView) mFooterView.findViewById(R.id.footer_tip_text); - mFooterProgressBar = mFooterView.findViewById(R.id.footer_progressBar); - mFooterView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - if (!isRefreshing() && mFooterState == FOOTER_FAIL) - footerRefresh(); - } - }); - } - - public EasyRecyclerView getEasyRecyclerView() { - return mEasyRecyclerView; - } - - public void addFooterView() { - ((EasyRecyclerView.FooterAdapter) mEasyRecyclerView.getAdapter()).setFooterView(mFooterView); - } - - public void removeFooterView() { - ((EasyRecyclerView.FooterAdapter) mEasyRecyclerView.getAdapter()).setFooterView(null); - } - - public void setOnHeaderRefreshListener(OnRefreshListener l) { - super.setOnRefreshListener(l); - } - - public void setOnFooterRefreshListener(OnFooterRefreshListener l) { - mFooterRefreshListener = l; - } - - private void footerRefresh() { - if (mFooterRefreshListener.onFooterRefresh()) { - mFooterState = FOOTER_REFRESHING; - changeFooterViewByState(); - - // Disable header refresh - if (mIsEnabledHeader) - setEnabled(false); - } - } - - /** - * Set footer UI String - * - * @param refreshStr - * @param successStr - * @param failStr - */ - public void setFooterString(String refreshStr, String successStr, String failStr) { - mFooterRefreshStr = refreshStr; - mFooterSuccessStr = successStr; - mFooterFailStr = failStr; - - switch (mFooterState) { - case FOOTER_REFRESHING: - mFooterTipTextView.setText(mFooterRefreshStr); - break; - case FOOTER_SUCCESS: - mFooterTipTextView.setText(mFooterSuccessStr); - break; - case FOOTER_FAIL: - mFooterTipTextView.setText(mFooterFailStr); - break; - } - } - - /** - * Refresh footer UI - */ - private void changeFooterViewByState() { - switch (mFooterState) { - case FOOTER_REFRESHING: - mFooterTipTextView.setText(mFooterRefreshStr); - mFooterProgressBar.setVisibility(View.VISIBLE); - break; - case FOOTER_SUCCESS: - mFooterTipTextView.setText(mFooterSuccessStr); - mFooterProgressBar.setVisibility(View.GONE); - break; - case FOOTER_FAIL: - mFooterTipTextView.setText(mFooterFailStr); - mFooterProgressBar.setVisibility(View.GONE); - break; - } - } - - - - public void setHeaderRefreshComplete() { - super.setRefreshing(false); - } - - public void setFooterRefreshComplete(boolean isSuccess) { - if (isSuccess) - mFooterState = FOOTER_SUCCESS; - else - mFooterState = FOOTER_FAIL; - changeFooterViewByState(); - - // enable header refresh - if (mIsEnabledHeader) - setEnabled(true); - } - - public void setAnyRefreshComplete() { - setAnyRefreshComplete(true); - } - - public void setAnyRefreshComplete(boolean isSuccess) { - setHeaderRefreshComplete(); - setFooterRefreshComplete(isSuccess); - } - - /** - * @return True if actionbar is refreshing - */ - public boolean isHeaderRefreshing() { - return super.isRefreshing(); - } - - /** - * @return True if footer is refreshing - */ - public boolean isFooterRefreshing() { - return mFooterState == FOOTER_REFRESHING; - } - - /** - * @return True if actionbar or footer is refreshing - */ - @Override - public boolean isRefreshing() { - return (mIsEnabledHeader ? isHeaderRefreshing() : false) - | (mIsEnabledFooter ? isFooterRefreshing() : false); - } - - public void onScrolled(RecyclerView recyclerView, int dx, int dy) { - if (mIsEnabledFooter && mFooterView.isShown() && mFooterState != FOOTER_FAIL) - footerRefresh(); - } - - public void setEnabledHeader(boolean enabled) { - if (mIsEnabledHeader == enabled) - return; - - mIsEnabledHeader = enabled; - setEnabled(enabled); - } - - public void setEnabledFooter(boolean enabled) { - if (mIsEnabledFooter == enabled) - return; - - if (mIsEnabledFooter = enabled) - addFooterView(); - else - removeFooterView(); - } - - public interface OnFooterRefreshListener { - /** - * @return True if this refresh action is vaild - */ - boolean onFooterRefresh(); - } -} diff --git a/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/BakedBezierInterpolator.java b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/BakedBezierInterpolator.java new file mode 100644 index 000000000..1130594a0 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/BakedBezierInterpolator.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2013 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.widget.refreshlayout; + +import android.view.animation.Interpolator; + +/** + * A pre-baked bezier-curved interpolator for indeterminate progress animations. + */ +final class BakedBezierInterpolator implements Interpolator { + private static final BakedBezierInterpolator INSTANCE = new BakedBezierInterpolator(); + + public final static BakedBezierInterpolator getInstance() { + return INSTANCE; + } + + /** + * Use getInstance instead of instantiating. + */ + private BakedBezierInterpolator() { + super(); + } + + /** + * Lookup table values. + * Generated using a Bezier curve from (0,0) to (1,1) with control points: + * P0 (0,0) + * P1 (0.4, 0) + * P2 (0.2, 1.0) + * P3 (1.0, 1.0) + * + * Values sampled with x at regular intervals between 0 and 1. + */ + private static final float[] VALUES = new float[] { + 0.0f, 0.0002f, 0.0009f, 0.0019f, 0.0036f, 0.0059f, 0.0086f, 0.0119f, 0.0157f, 0.0209f, + 0.0257f, 0.0321f, 0.0392f, 0.0469f, 0.0566f, 0.0656f, 0.0768f, 0.0887f, 0.1033f, 0.1186f, + 0.1349f, 0.1519f, 0.1696f, 0.1928f, 0.2121f, 0.237f, 0.2627f, 0.2892f, 0.3109f, 0.3386f, + 0.3667f, 0.3952f, 0.4241f, 0.4474f, 0.4766f, 0.5f, 0.5234f, 0.5468f, 0.5701f, 0.5933f, + 0.6134f, 0.6333f, 0.6531f, 0.6698f, 0.6891f, 0.7054f, 0.7214f, 0.7346f, 0.7502f, 0.763f, + 0.7756f, 0.7879f, 0.8f, 0.8107f, 0.8212f, 0.8326f, 0.8415f, 0.8503f, 0.8588f, 0.8672f, + 0.8754f, 0.8833f, 0.8911f, 0.8977f, 0.9041f, 0.9113f, 0.9165f, 0.9232f, 0.9281f, 0.9328f, + 0.9382f, 0.9434f, 0.9476f, 0.9518f, 0.9557f, 0.9596f, 0.9632f, 0.9662f, 0.9695f, 0.9722f, + 0.9753f, 0.9777f, 0.9805f, 0.9826f, 0.9847f, 0.9866f, 0.9884f, 0.9901f, 0.9917f, 0.9931f, + 0.9944f, 0.9955f, 0.9964f, 0.9973f, 0.9981f, 0.9986f, 0.9992f, 0.9995f, 0.9998f, 1.0f, 1.0f + }; + + private static final float STEP_SIZE = 1.0f / (VALUES.length - 1); + + @Override + public float getInterpolation(float input) { + if (input >= 1.0f) { + return 1.0f; + } + + if (input <= 0f) { + return 0f; + } + + int position = Math.min( + (int)(input * (VALUES.length - 1)), + VALUES.length - 2); + + float quantized = position * STEP_SIZE; + float difference = input - quantized; + float weight = difference / STEP_SIZE; + + return VALUES[position] + weight * (VALUES[position + 1] - VALUES[position]); + } + +} diff --git a/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/CircleImageView.java b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/CircleImageView.java new file mode 100644 index 000000000..de0376b39 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/CircleImageView.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2014 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.widget.refreshlayout; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.RadialGradient; +import android.graphics.Shader; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.support.v4.view.ViewCompat; +import android.view.animation.Animation; +import android.widget.ImageView; + +/** + * Private class created to work around issues with AnimationListeners being + * called before the animation is actually complete and support shadows on older + * platforms. + * + * @hide + */ +class CircleImageView extends ImageView { + + private static final int KEY_SHADOW_COLOR = 0x1E000000; + private static final int FILL_SHADOW_COLOR = 0x3D000000; + // PX + private static final float X_OFFSET = 0f; + private static final float Y_OFFSET = 1.75f; + private static final float SHADOW_RADIUS = 3.5f; + private static final int SHADOW_ELEVATION = 4; + + private Animation.AnimationListener mListener; + private int mShadowRadius; + + public CircleImageView(Context context, int color, final float radius) { + super(context); + final float density = getContext().getResources().getDisplayMetrics().density; + final int diameter = (int) (radius * density * 2); + final int shadowYOffset = (int) (density * Y_OFFSET); + final int shadowXOffset = (int) (density * X_OFFSET); + + mShadowRadius = (int) (density * SHADOW_RADIUS); + + ShapeDrawable circle; + if (elevationSupported()) { + circle = new ShapeDrawable(new OvalShape()); + ViewCompat.setElevation(this, SHADOW_ELEVATION * density); + } else { + OvalShape oval = new OvalShadow(mShadowRadius, diameter); + circle = new ShapeDrawable(oval); + ViewCompat.setLayerType(this, ViewCompat.LAYER_TYPE_SOFTWARE, circle.getPaint()); + circle.getPaint().setShadowLayer(mShadowRadius, shadowXOffset, shadowYOffset, + KEY_SHADOW_COLOR); + final int padding = mShadowRadius; + // set padding so the inner image sits correctly within the shadow. + setPadding(padding, padding, padding, padding); + } + circle.getPaint().setColor(color); + setBackgroundDrawable(circle); + } + + private boolean elevationSupported() { + return android.os.Build.VERSION.SDK_INT >= 21; + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (!elevationSupported()) { + setMeasuredDimension(getMeasuredWidth() + mShadowRadius*2, getMeasuredHeight() + + mShadowRadius*2); + } + } + + public void setAnimationListener(Animation.AnimationListener listener) { + mListener = listener; + } + + @Override + public void onAnimationStart() { + super.onAnimationStart(); + if (mListener != null) { + mListener.onAnimationStart(getAnimation()); + } + } + + @Override + public void onAnimationEnd() { + super.onAnimationEnd(); + if (mListener != null) { + mListener.onAnimationEnd(getAnimation()); + } + } + + /** + * Update the background color of the circle image view. + * + * @param colorRes Id of a color resource. + */ + public void setBackgroundColorRes(int colorRes) { + setBackgroundColor(getContext().getResources().getColor(colorRes)); + } + + @Override + public void setBackgroundColor(int color) { + if (getBackground() instanceof ShapeDrawable) { + ((ShapeDrawable) getBackground()).getPaint().setColor(color); + } + } + + private class OvalShadow extends OvalShape { + private RadialGradient mRadialGradient; + private int mShadowRadius; + private Paint mShadowPaint; + private int mCircleDiameter; + + public OvalShadow(int shadowRadius, int circleDiameter) { + super(); + mShadowPaint = new Paint(); + mShadowRadius = shadowRadius; + mCircleDiameter = circleDiameter; + mRadialGradient = new RadialGradient(mCircleDiameter / 2, mCircleDiameter / 2, + mShadowRadius, new int[] { + FILL_SHADOW_COLOR, Color.TRANSPARENT + }, null, Shader.TileMode.CLAMP); + mShadowPaint.setShader(mRadialGradient); + } + + @Override + public void draw(Canvas canvas, Paint paint) { + final int viewWidth = CircleImageView.this.getWidth(); + final int viewHeight = CircleImageView.this.getHeight(); + canvas.drawCircle(viewWidth / 2, viewHeight / 2, (mCircleDiameter / 2 + mShadowRadius), + mShadowPaint); + canvas.drawCircle(viewWidth / 2, viewHeight / 2, (mCircleDiameter / 2), paint); + } + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/MaterialProgressDrawable.java b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/MaterialProgressDrawable.java new file mode 100644 index 000000000..cd5710b87 --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/MaterialProgressDrawable.java @@ -0,0 +1,714 @@ +/* + * Copyright (C) 2014 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.widget.refreshlayout; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.graphics.Path; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.support.annotation.IntDef; +import android.support.annotation.NonNull; +import android.util.DisplayMetrics; +import android.view.View; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.view.animation.Animation; +import android.view.animation.Interpolator; +import android.view.animation.LinearInterpolator; +import android.view.animation.Transformation; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; + +/** + * Fancy progress indicator for Material theme. + * + * @hide + */ +class MaterialProgressDrawable extends Drawable implements Animatable { + private static final Interpolator LINEAR_INTERPOLATOR = new LinearInterpolator(); + private static final Interpolator END_CURVE_INTERPOLATOR = new EndCurveInterpolator(); + private static final Interpolator START_CURVE_INTERPOLATOR = new StartCurveInterpolator(); + private static final Interpolator EASE_INTERPOLATOR = new AccelerateDecelerateInterpolator(); + + @Retention(RetentionPolicy.CLASS) + @IntDef({LARGE, DEFAULT}) + public @interface ProgressDrawableSize {} + // Maps to ProgressBar.Large style + static final int LARGE = 0; + // Maps to ProgressBar default style + static final int DEFAULT = 1; + + // Maps to ProgressBar default style + private static final int CIRCLE_DIAMETER = 40; + private static final float CENTER_RADIUS = 8.75f; //should add up to 10 when + stroke_width + private static final float STROKE_WIDTH = 2.5f; + + // Maps to ProgressBar.Large style + private static final int CIRCLE_DIAMETER_LARGE = 56; + private static final float CENTER_RADIUS_LARGE = 12.5f; + private static final float STROKE_WIDTH_LARGE = 3f; + + private final int[] COLORS = new int[] { + Color.BLACK + }; + + /** The duration of a single progress spin in milliseconds. */ + private static final int ANIMATION_DURATION = 1000 * 80 / 60; + + /** The number of points in the progress "star". */ + private static final float NUM_POINTS = 5f; + /** The list of animators operating on this drawable. */ + private final ArrayList mAnimators = new ArrayList(); + + /** The indicator ring, used to manage animation state. */ + private final Ring mRing; + + /** Canvas rotation in degrees. */ + private float mRotation; + + /** Layout info for the arrowhead in dp */ + private static final int ARROW_WIDTH = 10; + private static final int ARROW_HEIGHT = 5; + private static final float ARROW_OFFSET_ANGLE = 5; + + /** Layout info for the arrowhead for the large spinner in dp */ + private static final int ARROW_WIDTH_LARGE = 12; + private static final int ARROW_HEIGHT_LARGE = 6; + private static final float MAX_PROGRESS_ARC = .8f; + + private Resources mResources; + private View mParent; + private Animation mAnimation; + private float mRotationCount; + private double mWidth; + private double mHeight; + boolean mFinishing; + + public MaterialProgressDrawable(Context context, View parent) { + mParent = parent; + mResources = context.getResources(); + + mRing = new Ring(mCallback); + mRing.setColors(COLORS); + + updateSizes(DEFAULT); + setupAnimators(); + } + + private void setSizeParameters(double progressCircleWidth, double progressCircleHeight, + double centerRadius, double strokeWidth, float arrowWidth, float arrowHeight) { + final Ring ring = mRing; + final DisplayMetrics metrics = mResources.getDisplayMetrics(); + final float screenDensity = metrics.density; + + mWidth = progressCircleWidth * screenDensity; + mHeight = progressCircleHeight * screenDensity; + ring.setStrokeWidth((float) strokeWidth * screenDensity); + ring.setCenterRadius(centerRadius * screenDensity); + ring.setColorIndex(0); + ring.setArrowDimensions(arrowWidth * screenDensity, arrowHeight * screenDensity); + ring.setInsets((int) mWidth, (int) mHeight); + } + + /** + * Set the overall size for the progress spinner. This updates the radius + * and stroke width of the ring. + * + * @param size One of {@link #LARGE} or + * {@link #DEFAULT} + */ + public void updateSizes(@ProgressDrawableSize int size) { + if (size == LARGE) { + setSizeParameters(CIRCLE_DIAMETER_LARGE, CIRCLE_DIAMETER_LARGE, CENTER_RADIUS_LARGE, + STROKE_WIDTH_LARGE, ARROW_WIDTH_LARGE, ARROW_HEIGHT_LARGE); + } else { + setSizeParameters(CIRCLE_DIAMETER, CIRCLE_DIAMETER, CENTER_RADIUS, STROKE_WIDTH, + ARROW_WIDTH, ARROW_HEIGHT); + } + } + + /** + * @param show Set to true to display the arrowhead on the progress spinner. + */ + public void showArrow(boolean show) { + mRing.setShowArrow(show); + } + + /** + * @param scale Set the scale of the arrowhead for the spinner. + */ + public void setArrowScale(float scale) { + mRing.setArrowScale(scale); + } + + /** + * Set the start and end trim for the progress spinner arc. + * + * @param startAngle start angle + * @param endAngle end angle + */ + public void setStartEndTrim(float startAngle, float endAngle) { + mRing.setStartTrim(startAngle); + mRing.setEndTrim(endAngle); + } + + /** + * Set the amount of rotation to apply to the progress spinner. + * + * @param rotation Rotation is from [0..1] + */ + public void setProgressRotation(float rotation) { + mRing.setRotation(rotation); + } + + /** + * Update the background color of the circle image view. + */ + public void setBackgroundColor(int color) { + mRing.setBackgroundColor(color); + } + + /** + * Set the colors used in the progress animation from color resources. + * The first color will also be the color of the bar that grows in response + * to a user swipe gesture. + * + * @param colors + */ + public void setColorSchemeColors(int... colors) { + mRing.setColors(colors); + mRing.setColorIndex(0); + } + + @Override + public int getIntrinsicHeight() { + return (int) mHeight; + } + + @Override + public int getIntrinsicWidth() { + return (int) mWidth; + } + + @Override + public void draw(Canvas c) { + final Rect bounds = getBounds(); + final int saveCount = c.save(); + c.rotate(mRotation, bounds.exactCenterX(), bounds.exactCenterY()); + mRing.draw(c, bounds); + c.restoreToCount(saveCount); + } + + @Override + public void setAlpha(int alpha) { + mRing.setAlpha(alpha); + } + + public int getAlpha() { + return mRing.getAlpha(); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + mRing.setColorFilter(colorFilter); + } + + @SuppressWarnings("unused") + void setRotation(float rotation) { + mRotation = rotation; + invalidateSelf(); + } + + @SuppressWarnings("unused") + private float getRotation() { + return mRotation; + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + @Override + public boolean isRunning() { + final ArrayList animators = mAnimators; + final int N = animators.size(); + for (int i = 0; i < N; i++) { + final Animation animator = animators.get(i); + if (animator.hasStarted() && !animator.hasEnded()) { + return true; + } + } + return false; + } + + @Override + public void start() { + mAnimation.reset(); + mRing.storeOriginals(); + // Already showing some part of the ring + if (mRing.getEndTrim() != mRing.getStartTrim()) { + mFinishing = true; + mAnimation.setDuration(ANIMATION_DURATION/2); + mParent.startAnimation(mAnimation); + } else { + mRing.setColorIndex(0); + mRing.resetOriginals(); + mAnimation.setDuration(ANIMATION_DURATION); + mParent.startAnimation(mAnimation); + } + } + + @Override + public void stop() { + mParent.clearAnimation(); + setRotation(0); + mRing.setShowArrow(false); + mRing.setColorIndex(0); + mRing.resetOriginals(); + } + + private void applyFinishTranslation(float interpolatedTime, Ring ring) { + // shrink back down and complete a full rotation before + // starting other circles + // Rotation goes between [0..1]. + float targetRotation = (float) (Math.floor(ring.getStartingRotation() / MAX_PROGRESS_ARC) + + 1f); + final float startTrim = ring.getStartingStartTrim() + + (ring.getStartingEndTrim() - ring.getStartingStartTrim()) * interpolatedTime; + ring.setStartTrim(startTrim); + final float rotation = ring.getStartingRotation() + + ((targetRotation - ring.getStartingRotation()) * interpolatedTime); + ring.setRotation(rotation); + } + + private void setupAnimators() { + final Ring ring = mRing; + final Animation animation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + if (mFinishing) { + applyFinishTranslation(interpolatedTime, ring); + } else { + // The minProgressArc is calculated from 0 to create an + // angle that + // matches the stroke width. + final float minProgressArc = (float) Math.toRadians( + ring.getStrokeWidth() / (2 * Math.PI * ring.getCenterRadius())); + final float startingEndTrim = ring.getStartingEndTrim(); + final float startingTrim = ring.getStartingStartTrim(); + final float startingRotation = ring.getStartingRotation(); + + // Offset the minProgressArc to where the endTrim is + // located. + final float minArc = MAX_PROGRESS_ARC - minProgressArc; + final float endTrim = startingEndTrim + (minArc + * START_CURVE_INTERPOLATOR.getInterpolation(interpolatedTime)); + ring.setEndTrim(endTrim); + + final float startTrim = startingTrim + (MAX_PROGRESS_ARC + * END_CURVE_INTERPOLATOR.getInterpolation(interpolatedTime)); + ring.setStartTrim(startTrim); + + final float rotation = startingRotation + (0.25f * interpolatedTime); + ring.setRotation(rotation); + + float groupRotation = ((720.0f / NUM_POINTS) * interpolatedTime) + + (720.0f * (mRotationCount / NUM_POINTS)); + setRotation(groupRotation); + } + } + }; + animation.setRepeatCount(Animation.INFINITE); + animation.setRepeatMode(Animation.RESTART); + animation.setInterpolator(LINEAR_INTERPOLATOR); + animation.setAnimationListener(new Animation.AnimationListener() { + + @Override + public void onAnimationStart(Animation animation) { + mRotationCount = 0; + } + + @Override + public void onAnimationEnd(Animation animation) { + // do nothing + } + + @Override + public void onAnimationRepeat(Animation animation) { + ring.storeOriginals(); + ring.goToNextColor(); + ring.setStartTrim(ring.getEndTrim()); + if (mFinishing) { + // finished closing the last ring from the swipe gesture; go + // into progress mode + mFinishing = false; + animation.setDuration(ANIMATION_DURATION); + ring.setShowArrow(false); + } else { + mRotationCount = (mRotationCount + 1) % (NUM_POINTS); + } + } + }); + mAnimation = animation; + } + + private final Callback mCallback = new Callback() { + @Override + public void invalidateDrawable(Drawable d) { + invalidateSelf(); + } + + @Override + public void scheduleDrawable(Drawable d, Runnable what, long when) { + scheduleSelf(what, when); + } + + @Override + public void unscheduleDrawable(Drawable d, Runnable what) { + unscheduleSelf(what); + } + }; + + private static class Ring { + private final RectF mTempBounds = new RectF(); + private final Paint mPaint = new Paint(); + private final Paint mArrowPaint = new Paint(); + + private final Callback mCallback; + + private float mStartTrim = 0.0f; + private float mEndTrim = 0.0f; + private float mRotation = 0.0f; + private float mStrokeWidth = 5.0f; + private float mStrokeInset = 2.5f; + + private int[] mColors; + // mColorIndex represents the offset into the available mColors that the + // progress circle should currently display. As the progress circle is + // animating, the mColorIndex moves by one to the next available color. + private int mColorIndex; + private float mStartingStartTrim; + private float mStartingEndTrim; + private float mStartingRotation; + private boolean mShowArrow; + private Path mArrow; + private float mArrowScale; + private double mRingCenterRadius; + private int mArrowWidth; + private int mArrowHeight; + private int mAlpha; + private final Paint mCirclePaint = new Paint(); + private int mBackgroundColor; + + public Ring(Callback callback) { + mCallback = callback; + + mPaint.setStrokeCap(Paint.Cap.SQUARE); + mPaint.setAntiAlias(true); + mPaint.setStyle(Style.STROKE); + + mArrowPaint.setStyle(Style.FILL); + mArrowPaint.setAntiAlias(true); + } + + public void setBackgroundColor(int color) { + mBackgroundColor = color; + } + + /** + * Set the dimensions of the arrowhead. + * + * @param width Width of the hypotenuse of the arrow head + * @param height Height of the arrow point + */ + public void setArrowDimensions(float width, float height) { + mArrowWidth = (int) width; + mArrowHeight = (int) height; + } + + /** + * Draw the progress spinner + */ + public void draw(Canvas c, Rect bounds) { + final RectF arcBounds = mTempBounds; + arcBounds.set(bounds); + arcBounds.inset(mStrokeInset, mStrokeInset); + + final float startAngle = (mStartTrim + mRotation) * 360; + final float endAngle = (mEndTrim + mRotation) * 360; + float sweepAngle = endAngle - startAngle; + + mPaint.setColor(mColors[mColorIndex]); + c.drawArc(arcBounds, startAngle, sweepAngle, false, mPaint); + + drawTriangle(c, startAngle, sweepAngle, bounds); + + if (mAlpha < 255) { + mCirclePaint.setColor(mBackgroundColor); + mCirclePaint.setAlpha(255 - mAlpha); + c.drawCircle(bounds.exactCenterX(), bounds.exactCenterY(), bounds.width() / 2, + mCirclePaint); + } + } + + private void drawTriangle(Canvas c, float startAngle, float sweepAngle, Rect bounds) { + if (mShowArrow) { + if (mArrow == null) { + mArrow = new Path(); + mArrow.setFillType(Path.FillType.EVEN_ODD); + } else { + mArrow.reset(); + } + + // Adjust the position of the triangle so that it is inset as + // much as the arc, but also centered on the arc. + float inset = (int) mStrokeInset / 2 * mArrowScale; + float x = (float) (mRingCenterRadius * Math.cos(0) + bounds.exactCenterX()); + float y = (float) (mRingCenterRadius * Math.sin(0) + bounds.exactCenterY()); + + // Update the path each time. This works around an issue in SKIA + // where concatenating a rotation matrix to a scale matrix + // ignored a starting negative rotation. This appears to have + // been fixed as of API 21. + mArrow.moveTo(0, 0); + mArrow.lineTo(mArrowWidth * mArrowScale, 0); + mArrow.lineTo((mArrowWidth * mArrowScale / 2), (mArrowHeight + * mArrowScale)); + mArrow.offset(x - inset, y); + mArrow.close(); + // draw a triangle + mArrowPaint.setColor(mColors[mColorIndex]); + c.rotate(startAngle + sweepAngle - ARROW_OFFSET_ANGLE, bounds.exactCenterX(), + bounds.exactCenterY()); + c.drawPath(mArrow, mArrowPaint); + } + } + + /** + * Set the colors the progress spinner alternates between. + * + * @param colors Array of integers describing the colors. Must be non-null. + */ + public void setColors(@NonNull int[] colors) { + mColors = colors; + // if colors are reset, make sure to reset the color index as well + setColorIndex(0); + } + + /** + * @param index Index into the color array of the color to display in + * the progress spinner. + */ + public void setColorIndex(int index) { + mColorIndex = index; + } + + /** + * Proceed to the next available ring color. This will automatically + * wrap back to the beginning of colors. + */ + public void goToNextColor() { + mColorIndex = (mColorIndex + 1) % (mColors.length); + } + + public void setColorFilter(ColorFilter filter) { + mPaint.setColorFilter(filter); + invalidateSelf(); + } + + /** + * @param alpha Set the alpha of the progress spinner and associated arrowhead. + */ + public void setAlpha(int alpha) { + mAlpha = alpha; + } + + /** + * @return Current alpha of the progress spinner and arrowhead. + */ + public int getAlpha() { + return mAlpha; + } + + /** + * @param strokeWidth Set the stroke width of the progress spinner in pixels. + */ + public void setStrokeWidth(float strokeWidth) { + mStrokeWidth = strokeWidth; + mPaint.setStrokeWidth(strokeWidth); + invalidateSelf(); + } + + @SuppressWarnings("unused") + public float getStrokeWidth() { + return mStrokeWidth; + } + + @SuppressWarnings("unused") + public void setStartTrim(float startTrim) { + mStartTrim = startTrim; + invalidateSelf(); + } + + @SuppressWarnings("unused") + public float getStartTrim() { + return mStartTrim; + } + + public float getStartingStartTrim() { + return mStartingStartTrim; + } + + public float getStartingEndTrim() { + return mStartingEndTrim; + } + + @SuppressWarnings("unused") + public void setEndTrim(float endTrim) { + mEndTrim = endTrim; + invalidateSelf(); + } + + @SuppressWarnings("unused") + public float getEndTrim() { + return mEndTrim; + } + + @SuppressWarnings("unused") + public void setRotation(float rotation) { + mRotation = rotation; + invalidateSelf(); + } + + @SuppressWarnings("unused") + public float getRotation() { + return mRotation; + } + + public void setInsets(int width, int height) { + final float minEdge = (float) Math.min(width, height); + float insets; + if (mRingCenterRadius <= 0 || minEdge < 0) { + insets = (float) Math.ceil(mStrokeWidth / 2.0f); + } else { + insets = (float) (minEdge / 2.0f - mRingCenterRadius); + } + mStrokeInset = insets; + } + + @SuppressWarnings("unused") + public float getInsets() { + return mStrokeInset; + } + + /** + * @param centerRadius Inner radius in px of the circle the progress + * spinner arc traces. + */ + public void setCenterRadius(double centerRadius) { + mRingCenterRadius = centerRadius; + } + + public double getCenterRadius() { + return mRingCenterRadius; + } + + /** + * @param show Set to true to show the arrow head on the progress spinner. + */ + public void setShowArrow(boolean show) { + if (mShowArrow != show) { + mShowArrow = show; + invalidateSelf(); + } + } + + /** + * @param scale Set the scale of the arrowhead for the spinner. + */ + public void setArrowScale(float scale) { + if (scale != mArrowScale) { + mArrowScale = scale; + invalidateSelf(); + } + } + + /** + * @return The amount the progress spinner is currently rotated, between [0..1]. + */ + public float getStartingRotation() { + return mStartingRotation; + } + + /** + * If the start / end trim are offset to begin with, store them so that + * animation starts from that offset. + */ + public void storeOriginals() { + mStartingStartTrim = mStartTrim; + mStartingEndTrim = mEndTrim; + mStartingRotation = mRotation; + } + + /** + * Reset the progress spinner to default rotation, start and end angles. + */ + public void resetOriginals() { + mStartingStartTrim = 0; + mStartingEndTrim = 0; + mStartingRotation = 0; + setStartTrim(0); + setEndTrim(0); + setRotation(0); + } + + private void invalidateSelf() { + mCallback.invalidateDrawable(null); + } + } + + /** + * Squishes the interpolation curve into the second half of the animation. + */ + private static class EndCurveInterpolator extends AccelerateDecelerateInterpolator { + @Override + public float getInterpolation(float input) { + return super.getInterpolation(Math.max(0, (input - 0.5f) * 2.0f)); + } + } + + /** + * Squishes the interpolation curve into the first half of the animation. + */ + private static class StartCurveInterpolator extends AccelerateDecelerateInterpolator { + @Override + public float getInterpolation(float input) { + return super.getInterpolation(Math.min(1, input * 2.0f)); + } + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/RefreshLayout.java b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/RefreshLayout.java new file mode 100644 index 000000000..2781abb1e --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/RefreshLayout.java @@ -0,0 +1,1140 @@ +/* + * Copyright (C) 2013 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.widget.refreshlayout; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.os.Build; +import android.support.v4.view.MotionEventCompat; +import android.support.v4.view.ViewCompat; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.animation.Animation; +import android.view.animation.Animation.AnimationListener; +import android.view.animation.DecelerateInterpolator; +import android.view.animation.Transformation; +import android.widget.AbsListView; + +/** + * The SwipeRefreshLayout should be used whenever the user can refresh the + * contents of a view via a vertical swipe gesture. The activity that + * instantiates this view should add an OnRefreshListener to be notified + * whenever the swipe to refresh gesture is completed. The SwipeRefreshLayout + * will notify the listener each and every time the gesture is completed again; + * the listener is responsible for correctly determining when to actually + * initiate a refresh of its content. If the listener determines there should + * not be a refresh, it must call setRefreshing(false) to cancel any visual + * indication of a refresh. If an activity wishes to show just the progress + * animation, it should call setRefreshing(true). To disable the gesture and + * progress animation, call setEnabled(false) on the view. + *

+ * This layout should be made the parent of the view that will be refreshed as a + * result of the gesture and can only support one direct child. This view will + * also be made the target of the gesture and will be forced to match both the + * width and the height supplied in this layout. The SwipeRefreshLayout does not + * provide accessibility events; instead, a menu item must be provided to allow + * refresh of the content wherever this gesture is used. + *

+ */ +public class RefreshLayout extends ViewGroup { + // Maps to ProgressBar.Large style + public static final int LARGE = MaterialProgressDrawable.LARGE; + // Maps to ProgressBar default style + public static final int DEFAULT = MaterialProgressDrawable.DEFAULT; + + private static final String LOG_TAG = RefreshLayout.class.getSimpleName(); + + private static final int MAX_ALPHA = 255; + private static final int STARTING_PROGRESS_ALPHA = (int) (.3f * MAX_ALPHA); + + private static final int CIRCLE_DIAMETER = 40; + private static final int CIRCLE_DIAMETER_LARGE = 56; + + private static final float DECELERATE_INTERPOLATION_FACTOR = 2f; + private static final int INVALID_POINTER = -1; + private static final float DRAG_RATE = .5f; + + // Max amount of circle that can be filled by progress during swipe gesture, + // where 1.0 is a full circle + private static final float MAX_PROGRESS_ANGLE = .8f; + + private static final int SCALE_DOWN_DURATION = 150; + + private static final int ALPHA_ANIMATION_DURATION = 300; + + private static final int ANIMATE_TO_TRIGGER_DURATION = 200; + + private static final int ANIMATE_TO_START_DURATION = 200; + + // Default background for the progress spinner + private static final int CIRCLE_BG_LIGHT = 0xFFFAFAFA; + // Default offset in dips from the top of the view to where the progress spinner should stop + private static final int DEFAULT_CIRCLE_TARGET = 64; + + private static final float PROGRESS_BAR_HEIGHT = 4; + + private View mTarget; // the target of the gesture + private OnHeaderRefreshListener mHeaderListener; + private boolean mHeaderRefreshing = false; + private boolean mHeaderEnable = true; + private int mTouchSlop; + private float mTotalDragDistance = -1; + private int mMediumAnimationDuration; + private int mCurrentTargetOffsetTop; + // Whether or not the starting offset has been determined. + private boolean mOriginalOffsetCalculated = false; + + private float mInitialMotionY; + private boolean mIsBeingDragged; + private int mActivePointerId = INVALID_POINTER; + // Whether this item is scaled up rather than clipped + private boolean mScale; + + private final DecelerateInterpolator mDecelerateInterpolator; + private static final int[] LAYOUT_ATTRS = new int[] { + android.R.attr.enabled + }; + + private CircleImageView mCircleView; + private int mCircleViewIndex = -1; + + protected int mFrom; + + private float mStartingScale; + + protected int mOriginalOffsetTop; + + private MaterialProgressDrawable mProgress; + + private Animation mScaleAnimation; + + private Animation mScaleDownAnimation; + + private Animation mAlphaStartAnimation; + + private Animation mAlphaMaxAnimation; + + private Animation mScaleDownToStartAnimation; + + private float mSpinnerFinalOffset; + + private boolean mNotify; + + private int mCircleWidth; + + private int mCircleHeight; + + // Whether the client has set a custom starting position; + private boolean mUsingCustomStart; + + private SwipeProgressBar mProgressBar; //the thing that shows footer progress is going + private OnFooterRefreshListener mFooterListener; + private boolean mFooterRefreshing = false; + private boolean mFooterEnable = true; + private int mProgressBarHeight; + private float mCurrPercentage = 0; + + private ScrollDetecter mScrollDetecter; + + private AnimationListener mRefreshListener = new AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + if (mHeaderRefreshing) { + // Make sure the progress view is fully visible + mProgress.setAlpha(MAX_ALPHA); + mProgress.start(); + if (mNotify) { + if (mHeaderListener != null) { + mHeaderListener.onHeaderRefresh(); + } + } + } else { + mProgress.stop(); + mCircleView.setVisibility(View.GONE); + setColorViewAlpha(MAX_ALPHA); + // Return the circle to its start position + if (mScale) { + setAnimationProgress(0 /* animation complete and view is hidden */); + } else { + setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCurrentTargetOffsetTop, + true /* requires update */); + } + } + mCurrentTargetOffsetTop = mCircleView.getTop(); + } + }; + + private void setColorViewAlpha(int targetAlpha) { + mCircleView.getBackground().setAlpha(targetAlpha); + mProgress.setAlpha(targetAlpha); + } + + /** + * The refresh indicator starting and resting position is always positioned + * near the top of the refreshing content. This position is a consistent + * location, but can be adjusted in either direction based on whether or not + * there is a toolbar or actionbar present. + * + * @param scale Set to true if there is no view at a higher z-order than + * where the progress spinner is set to appear. + * @param start The offset in pixels from the top of this view at which the + * progress spinner should appear. + * @param end The offset in pixels from the top of this view at which the + * progress spinner should come to rest after a successful swipe + * gesture. + */ + public void setProgressViewOffset(boolean scale, int start, int end) { + mScale = scale; + mCircleView.setVisibility(View.GONE); + mOriginalOffsetTop = mCurrentTargetOffsetTop = start; + mSpinnerFinalOffset = end; + mUsingCustomStart = true; + mCircleView.invalidate(); + } + + /** + * The refresh indicator resting position is always positioned near the top + * of the refreshing content. This position is a consistent location, but + * can be adjusted in either direction based on whether or not there is a + * toolbar or actionbar present. + * + * @param scale Set to true if there is no view at a higher z-order than + * where the progress spinner is set to appear. + * @param end The offset in pixels from the top of this view at which the + * progress spinner should come to rest after a successful swipe + * gesture. + */ + public void setProgressViewEndTarget(boolean scale, int end) { + mSpinnerFinalOffset = end; + mScale = scale; + mCircleView.invalidate(); + } + + /** + * One of DEFAULT, or LARGE. + */ + public void setSize(int size) { + if (size != MaterialProgressDrawable.LARGE && size != MaterialProgressDrawable.DEFAULT) { + return; + } + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + if (size == MaterialProgressDrawable.LARGE) { + mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER_LARGE * metrics.density); + } else { + mCircleHeight = mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density); + } + // force the bounds of the progress circle inside the circle view to + // update by setting it to null before updating its size and then + // re-setting it + mCircleView.setImageDrawable(null); + mProgress.updateSizes(size); + mCircleView.setImageDrawable(mProgress); + } + + /** + * Simple constructor to use when creating a SwipeRefreshLayout from code. + * + * @param context + */ + public RefreshLayout(Context context) { + this(context, (AttributeSet) null); + } + + /** + * Simple constructor to use when creating a SwipeRefreshLayout from code. + * Add view as child + * + * @param context + * @param view + */ + public RefreshLayout(Context context, View view) { + this(context, (AttributeSet) null); + + LayoutParams lp = new LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + addView(view, 0, lp); + } + + + /** + * Constructor that is called when inflating SwipeRefreshLayout from XML. + * + * @param context + * @param attrs + */ + public RefreshLayout(Context context, AttributeSet attrs) { + super(context, attrs); + + mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + + mMediumAnimationDuration = getResources().getInteger( + android.R.integer.config_mediumAnimTime); + + setWillNotDraw(false); + mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR); + + /* + final TypedArray a = context.obtainStyledAttributes(attrs, LAYOUT_ATTRS); + setEnabled(a.getBoolean(0, true)); + a.recycle(); + */ + + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + mCircleWidth = (int) (CIRCLE_DIAMETER * metrics.density); + mCircleHeight = (int) (CIRCLE_DIAMETER * metrics.density); + createHeaderProgressView(); + + mProgressBarHeight = (int) (metrics.density * PROGRESS_BAR_HEIGHT); + mProgressBar = new SwipeProgressBar(this); + + ViewCompat.setChildrenDrawingOrderEnabled(this, true); + // the absolute offset has to take into account that the circle starts at an offset + mSpinnerFinalOffset = DEFAULT_CIRCLE_TARGET * metrics.density; + mTotalDragDistance = mSpinnerFinalOffset; + } + + private ViewTreeObserver.OnGlobalLayoutListener mOnGlobalLayoutListener = + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + if (getVisibility() != View.VISIBLE) { + return; + } + + ensureTarget(); + + if (isEnabled() && !mHeaderRefreshing && !mFooterRefreshing && + mFooterEnable && isAlmostBottom()) { + if (mFooterListener == null || mFooterListener.onFooterRefresh()) { + setFooterRefreshing(true); + } + } + } + }; + + public void onAttachedToWindow() { + getViewTreeObserver().addOnGlobalLayoutListener(mOnGlobalLayoutListener); + } + + public void onDetachedFromWindow() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + getViewTreeObserver().removeOnGlobalLayoutListener(mOnGlobalLayoutListener); + } + } + + @Override + protected int getChildDrawingOrder(int childCount, int i) { + if (mCircleViewIndex < 0) { + return i; + } else if (i == childCount - 1) { + // Draw the selected child last + return mCircleViewIndex; + } else if (i >= mCircleViewIndex) { + // Move the children after the selected child earlier one + return i + 1; + } else { + // Keep the children before the selected child the same + return i; + } + } + + private void createHeaderProgressView() { + mCircleView = new CircleImageView(getContext(), CIRCLE_BG_LIGHT, CIRCLE_DIAMETER/2); + mProgress = new MaterialProgressDrawable(getContext(), this); + mProgress.setBackgroundColor(CIRCLE_BG_LIGHT); + mCircleView.setImageDrawable(mProgress); + mCircleView.setVisibility(View.GONE); + addView(mCircleView); + } + + public void setScrollDetecter(ScrollDetecter sd) { + mScrollDetecter = sd; + } + + /** + * Set the listener to be notified when a refresh is triggered via the swipe + * gesture. + */ + public void setOnHeaderRefreshListener(OnHeaderRefreshListener listener) { + mHeaderListener = listener; + } + + /** + * Set the listener to be notified when it is almost bottom. + */ + public void setOnFooterRefreshListener(OnFooterRefreshListener listener) { + mFooterListener = listener; + } + + /** + * Pre API 11, alpha is used to make the progress circle appear instead of scale. + */ + private boolean isAlphaUsedForScale() { + return Build.VERSION.SDK_INT < 11; + } + + public void setTriggerPercentage(float percent) { + if (percent == 0f) { + // No-op. A null trigger means it's uninitialized, and setting it to zero-percent + // means we're trying to reset state, so there's nothing to reset in this case. + mCurrPercentage = 0; + return; + } + mCurrPercentage = percent; + mProgressBar.setTriggerPercentage(percent); + } + + /** + * Notify the widget that refresh state has changed. Do not call this when + * refresh is triggered by a swipe gesture. + * + * @param refreshing Whether or not the view should show refresh progress. + */ + public void setHeaderRefreshing(boolean refreshing) { + if (refreshing && mFooterRefreshing) { + return; + } + + if (refreshing && mHeaderRefreshing != refreshing) { + // scale and show + mHeaderRefreshing = refreshing; + int endTarget = 0; + if (!mUsingCustomStart) { + endTarget = (int) (mSpinnerFinalOffset + mOriginalOffsetTop); + } else { + endTarget = (int) mSpinnerFinalOffset; + } + setTargetOffsetTopAndBottom(endTarget - mCurrentTargetOffsetTop, + true /* requires update */); + mNotify = false; + startScaleUpAnimation(mRefreshListener); + } else { + setHeaderRefreshing(refreshing, false /* notify */); + } + } + + public void setFooterRefreshing(boolean refreshing) { + if (refreshing && mHeaderRefreshing) { + return; + } + + if (mFooterRefreshing != refreshing) { + mFooterRefreshing = refreshing; + if (refreshing) { + mProgressBar.start(); + } else { + mProgressBar.stop(); + } + } + } + + private void startScaleUpAnimation(AnimationListener listener) { + mCircleView.setVisibility(View.VISIBLE); + if (Build.VERSION.SDK_INT >= 11) { + // Pre API 11, alpha is used in place of scale up to show the + // progress circle appearing. + // Don't adjust the alpha during appearance otherwise. + mProgress.setAlpha(MAX_ALPHA); + } + mScaleAnimation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + setAnimationProgress(interpolatedTime); + } + }; + mScaleAnimation.setDuration(mMediumAnimationDuration); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mScaleAnimation); + } + + /** + * Pre API 11, this does an alpha animation. + * @param progress + */ + private void setAnimationProgress(float progress) { + if (isAlphaUsedForScale()) { + setColorViewAlpha((int) (progress * MAX_ALPHA)); + } else { + ViewCompat.setScaleX(mCircleView, progress); + ViewCompat.setScaleY(mCircleView, progress); + } + } + + private void setHeaderRefreshing(boolean refreshing, final boolean notify) { + if (mHeaderRefreshing != refreshing) { + mNotify = notify; + ensureTarget(); + mHeaderRefreshing = refreshing; + if (mHeaderRefreshing) { + animateOffsetToCorrectPosition(mCurrentTargetOffsetTop, mRefreshListener); + } else { + startScaleDownAnimation(mRefreshListener); + } + } + } + + private void startScaleDownAnimation(AnimationListener listener) { + mScaleDownAnimation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + setAnimationProgress(1 - interpolatedTime); + } + }; + mScaleDownAnimation.setDuration(SCALE_DOWN_DURATION); + mCircleView.setAnimationListener(listener); + mCircleView.clearAnimation(); + mCircleView.startAnimation(mScaleDownAnimation); + } + + private void startProgressAlphaStartAnimation() { + mAlphaStartAnimation = startAlphaAnimation(mProgress.getAlpha(), STARTING_PROGRESS_ALPHA); + } + + private void startProgressAlphaMaxAnimation() { + mAlphaMaxAnimation = startAlphaAnimation(mProgress.getAlpha(), MAX_ALPHA); + } + + private Animation startAlphaAnimation(final int startingAlpha, final int endingAlpha) { + // Pre API 11, alpha is used in place of scale. Don't also use it to + // show the trigger point. + if (mScale && isAlphaUsedForScale()) { + return null; + } + Animation alpha = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + mProgress + .setAlpha((int) (startingAlpha+ ((endingAlpha - startingAlpha) + * interpolatedTime))); + } + }; + alpha.setDuration(ALPHA_ANIMATION_DURATION); + // Clear out the previous animation listeners. + mCircleView.setAnimationListener(null); + mCircleView.clearAnimation(); + mCircleView.startAnimation(alpha); + return alpha; + } + + /** + * Set the background color of the progress spinner disc. + * + * @param colorRes Resource id of the color. + */ + public void setProgressBackgroundColor(int colorRes) { + mCircleView.setBackgroundColor(colorRes); + mProgress.setBackgroundColor(getResources().getColor(colorRes)); + } + + /** + * @deprecated Use {@link #setHeaderColorSchemeResources(int...)} + */ + @Deprecated + public void setHeaderColorScheme(int... colors) { + setHeaderColorSchemeResources(colors); + } + + /** + * Set the color resources used in the progress animation from color resources. + * The first color will also be the color of the bar that grows in response + * to a user swipe gesture. + * + * @param colorResIds + */ + public void setHeaderColorSchemeResources(int... colorResIds) { + final Resources res = getResources(); + int[] colorRes = new int[colorResIds.length]; + for (int i = 0; i < colorResIds.length; i++) { + colorRes[i] = res.getColor(colorResIds[i]); + } + setHeaderColorSchemeColors(colorRes); + } + + /** + * Set the colors used in the progress animation. The first + * color will also be the color of the bar that grows in response to a user + * swipe gesture. + * + * @param colors + */ + public void setHeaderColorSchemeColors(int... colors) { + ensureTarget(); + mProgress.setColorSchemeColors(colors); + } + + /** + * @deprecated Use {@link #setFooterColorSchemeResources(int, int, int, int)} + */ + @Deprecated + public void setFooterColorScheme(int colorRes1, int colorRes2, int colorRes3, int colorRes4) { + setFooterColorSchemeResources(colorRes1, colorRes2, colorRes3, colorRes4); + } + + /** + * Set the four colors used in the progress animation from color resources. + * The first color will also be the color of the bar that grows in response + * to a user swipe gesture. + */ + public void setFooterColorSchemeResources(int colorRes1, int colorRes2, int colorRes3, + int colorRes4) { + final Resources res = getResources(); + setFooterColorSchemeColors(res.getColor(colorRes1), res.getColor(colorRes2), + res.getColor(colorRes3), res.getColor(colorRes4)); + } + + /** + * Set the four colors used in the progress animation. The first color will + * also be the color of the bar that grows in response to a user swipe + * gesture. + */ + public void setFooterColorSchemeColors(int color1, int color2, int color3, int color4) { + ensureTarget(); + mProgressBar.setColorScheme(color1, color2, color3, color4); + } + + public void setHeaderEnable(boolean headerEnable) { + mHeaderEnable = headerEnable; + if (headerEnable == false && mHeaderRefreshing == true) { + setHeaderRefreshing(false); + } + } + + public void setFooterEnable(boolean footerEnable) { + mFooterEnable = footerEnable; + if (footerEnable == false && mFooterRefreshing == true) { + setFooterRefreshing(false); + } + } + + /** + * @return isHeaderRefreshing() | isFooterRefreshing() + */ + public boolean isRefreshing() { + return mHeaderRefreshing | mFooterRefreshing; + } + + /** + * @return whether RefreshLayout is actively showing refresh progress. + */ + public boolean isHeaderRefreshing() { + return mHeaderRefreshing; + } + + /** + * @return whether footer job is actively doing + */ + public boolean isFooterRefreshing() { + return mFooterRefreshing; + } + + private void ensureTarget() { + // Don't bother getting the parent height if the parent hasn't been laid + // out yet. + if (mTarget == null) { + for (int i = 0; i < getChildCount(); i++) { + View child = getChildAt(i); + if (!child.equals(mCircleView)) { + mTarget = child; + break; + } + } + } + } + + /** + * Set the distance to trigger a sync in dips + * + * @param distance + */ + public void setDistanceToTriggerSync(int distance) { + mTotalDragDistance = distance; + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + mProgressBar.draw(canvas); + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + final int width = getMeasuredWidth(); + final int height = getMeasuredHeight(); + if (getChildCount() == 0) { + return; + } + if (mTarget == null) { + ensureTarget(); + } + if (mTarget == null) { + return; + } + final View child = mTarget; + final int childLeft = getPaddingLeft(); + final int childTop = getPaddingTop(); + final int childWidth = width - getPaddingLeft() - getPaddingRight(); + final int childHeight = height - getPaddingTop() - getPaddingBottom(); + child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight); + int circleWidth = mCircleView.getMeasuredWidth(); + int circleHeight = mCircleView.getMeasuredHeight(); + mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop, + (width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight); + + mProgressBar.setBounds(0, height - mProgressBarHeight, width, height); + } + + @Override + public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (mTarget == null) { + ensureTarget(); + } + if (mTarget == null) { + return; + } + mTarget.measure(MeasureSpec.makeMeasureSpec( + getMeasuredWidth() - getPaddingLeft() - getPaddingRight(), + MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec( + getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY)); + mCircleView.measure(MeasureSpec.makeMeasureSpec(mCircleWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(mCircleHeight, MeasureSpec.EXACTLY)); + if (!mUsingCustomStart && !mOriginalOffsetCalculated) { + mOriginalOffsetCalculated = true; + mCurrentTargetOffsetTop = mOriginalOffsetTop = -mCircleView.getMeasuredHeight(); + } + mCircleViewIndex = -1; + // Get the index of the circleview. + for (int index = 0; index < getChildCount(); index++) { + if (getChildAt(index) == mCircleView) { + mCircleViewIndex = index; + break; + } + } + } + + /** + * @return Whether it is possible for the child view of this layout to + * scroll up. Override this if the child view is a custom view. + */ + public boolean canChildScrollUp() { + if (mScrollDetecter != null) { + return mScrollDetecter.canChildScrollUp(); + } else { + if (Build.VERSION.SDK_INT < 14) { + if (mTarget instanceof AbsListView) { + final AbsListView absListView = (AbsListView) mTarget; + return absListView.getChildCount() > 0 + && (absListView.getFirstVisiblePosition() > 0 || absListView.getChildAt(0) + .getTop() < absListView.getPaddingTop()); + } else { + return mTarget.getScrollY() > 0; + } + } else { + return ViewCompat.canScrollVertically(mTarget, -1); + } + } + } + + /** + * @return {@code true} if child view almost scroll to bottom. + */ + public boolean isAlmostBottom() { + if (mScrollDetecter != null) { + return mScrollDetecter.isAlmostBottom(); + } else { + if (mTarget instanceof AbsListView) { + final AbsListView absListView = (AbsListView) mTarget; + return absListView.getLastVisiblePosition() >= absListView.getCount() - 1; + } else { + if (Build.VERSION.SDK_INT >= 14) { + return !ViewCompat.canScrollVertically(mTarget, 1); + } else { + return true; + } + } + } + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + ensureTarget(); + + final int action = MotionEventCompat.getActionMasked(ev); + + if (!isEnabled() || mHeaderRefreshing || mFooterRefreshing) { + return false; + } + + if (mFooterEnable && isAlmostBottom()) { + // Check footer + if (mFooterListener == null || mFooterListener.onFooterRefresh()) { + setFooterRefreshing(true); + return false; + } + } + + if (!mHeaderEnable || canChildScrollUp() || mFooterRefreshing) { + // Fail fast if we're not in a state where a swipe is possible + return false; + } + + switch (action) { + case MotionEvent.ACTION_DOWN: + setTargetOffsetTopAndBottom(mOriginalOffsetTop - mCircleView.getTop(), true); + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + mIsBeingDragged = false; + final float initialMotionY = getMotionEventY(ev, mActivePointerId); + if (initialMotionY == -1) { + return false; + } + mInitialMotionY = initialMotionY; + + case MotionEvent.ACTION_MOVE: + if (mActivePointerId == INVALID_POINTER) { + Log.e(LOG_TAG, "Got ACTION_MOVE event but don't have an active pointer id."); + return false; + } + + final float y = getMotionEventY(ev, mActivePointerId); + if (y == -1) { + return false; + } + final float yDiff = y - mInitialMotionY; + if (yDiff > mTouchSlop && !mIsBeingDragged) { + mIsBeingDragged = true; + mProgress.setAlpha(STARTING_PROGRESS_ALPHA); + } + break; + + case MotionEventCompat.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIsBeingDragged = false; + mActivePointerId = INVALID_POINTER; + break; + } + + return mIsBeingDragged; + } + + private float getMotionEventY(MotionEvent ev, int activePointerId) { + final int index = MotionEventCompat.findPointerIndex(ev, activePointerId); + if (index < 0) { + return -1; + } + return MotionEventCompat.getY(ev, index); + } + + @Override + public void requestDisallowInterceptTouchEvent(boolean b) { + // Nope. + } + + private boolean isAnimationRunning(Animation animation) { + return animation != null && animation.hasStarted() && !animation.hasEnded(); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + final int action = MotionEventCompat.getActionMasked(ev); + + if (!isEnabled() || mHeaderRefreshing || mFooterRefreshing) { + return false; + } + + if (mFooterEnable && isAlmostBottom()) { + // Check footer + if (mFooterListener == null || mFooterListener.onFooterRefresh()) { + setFooterRefreshing(true); + return false; + } + } + + if (!mHeaderEnable || canChildScrollUp() || mFooterRefreshing) { + // Fail fast if we're not in a state where a swipe is possible + return false; + } + + switch (action) { + case MotionEvent.ACTION_DOWN: + mActivePointerId = MotionEventCompat.getPointerId(ev, 0); + mIsBeingDragged = false; + break; + + case MotionEvent.ACTION_MOVE: { + final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); + if (pointerIndex < 0) { + Log.e(LOG_TAG, "Got ACTION_MOVE event but have an invalid active pointer id."); + return false; + } + + final float y = MotionEventCompat.getY(ev, pointerIndex); + final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; + if (mIsBeingDragged) { + mProgress.showArrow(true); + float originalDragPercent = overscrollTop / mTotalDragDistance; + if (originalDragPercent < 0) { + return false; + } + float dragPercent = Math.min(1f, Math.abs(originalDragPercent)); + float adjustedPercent = (float) Math.max(dragPercent - .4, 0) * 5 / 3; + float extraOS = Math.abs(overscrollTop) - mTotalDragDistance; + float slingshotDist = mUsingCustomStart ? mSpinnerFinalOffset + - mOriginalOffsetTop : mSpinnerFinalOffset; + float tensionSlingshotPercent = Math.max(0, + Math.min(extraOS, slingshotDist * 2) / slingshotDist); + float tensionPercent = (float) ((tensionSlingshotPercent / 4) - Math.pow( + (tensionSlingshotPercent / 4), 2)) * 2f; + float extraMove = (slingshotDist) * tensionPercent * 2; + + int targetY = mOriginalOffsetTop + + (int) ((slingshotDist * dragPercent) + extraMove); + // where 1.0f is a full circle + if (mCircleView.getVisibility() != View.VISIBLE) { + mCircleView.setVisibility(View.VISIBLE); + } + if (!mScale) { + ViewCompat.setScaleX(mCircleView, 1f); + ViewCompat.setScaleY(mCircleView, 1f); + } + if (overscrollTop < mTotalDragDistance) { + if (mScale) { + setAnimationProgress(overscrollTop / mTotalDragDistance); + } + if (mProgress.getAlpha() > STARTING_PROGRESS_ALPHA + && !isAnimationRunning(mAlphaStartAnimation)) { + // Animate the alpha + startProgressAlphaStartAnimation(); + } + float strokeStart = (float) (adjustedPercent * .8f); + mProgress.setStartEndTrim(0f, Math.min(MAX_PROGRESS_ANGLE, strokeStart)); + mProgress.setArrowScale(Math.min(1f, adjustedPercent)); + } else { + if (mProgress.getAlpha() < MAX_ALPHA + && !isAnimationRunning(mAlphaMaxAnimation)) { + // Animate the alpha + startProgressAlphaMaxAnimation(); + } + } + float rotation = (-0.25f + .4f * adjustedPercent + tensionPercent * 2) * .5f; + mProgress.setProgressRotation(rotation); + setTargetOffsetTopAndBottom(targetY - mCurrentTargetOffsetTop, + true /* requires update */); + } + break; + } + case MotionEventCompat.ACTION_POINTER_DOWN: { + final int index = MotionEventCompat.getActionIndex(ev); + mActivePointerId = MotionEventCompat.getPointerId(ev, index); + break; + } + + case MotionEventCompat.ACTION_POINTER_UP: + onSecondaryPointerUp(ev); + break; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: { + if (mActivePointerId == INVALID_POINTER) { + if (action == MotionEvent.ACTION_UP) { + Log.e(LOG_TAG, "Got ACTION_UP event but don't have an active pointer id."); + } + return false; + } + final int pointerIndex = MotionEventCompat.findPointerIndex(ev, mActivePointerId); + final float y = MotionEventCompat.getY(ev, pointerIndex); + final float overscrollTop = (y - mInitialMotionY) * DRAG_RATE; + mIsBeingDragged = false; + if (overscrollTop > mTotalDragDistance) { + setHeaderRefreshing(true, true /* notify */); + } else { + // cancel refresh + mHeaderRefreshing = false; + mProgress.setStartEndTrim(0f, 0f); + AnimationListener listener = null; + if (!mScale) { + listener = new AnimationListener() { + + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + if (!mScale) { + startScaleDownAnimation(null); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + }; + } + animateOffsetToStartPosition(mCurrentTargetOffsetTop, listener); + mProgress.showArrow(false); + } + mActivePointerId = INVALID_POINTER; + return false; + } + } + + return true; + } + + private void animateOffsetToCorrectPosition(int from, AnimationListener listener) { + mFrom = from; + mAnimateToCorrectPosition.reset(); + mAnimateToCorrectPosition.setDuration(ANIMATE_TO_TRIGGER_DURATION); + mAnimateToCorrectPosition.setInterpolator(mDecelerateInterpolator); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mAnimateToCorrectPosition); + } + + private void animateOffsetToStartPosition(int from, AnimationListener listener) { + if (mScale) { + // Scale the item back down + startScaleDownReturnToStartAnimation(from, listener); + } else { + mFrom = from; + mAnimateToStartPosition.reset(); + mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION); + mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mAnimateToStartPosition); + } + } + + private final Animation mAnimateToCorrectPosition = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + int targetTop = 0; + int endTarget = 0; + if (!mUsingCustomStart) { + endTarget = (int) (mSpinnerFinalOffset - Math.abs(mOriginalOffsetTop)); + } else { + endTarget = (int) mSpinnerFinalOffset; + } + targetTop = (mFrom + (int) ((endTarget - mFrom) * interpolatedTime)); + int offset = targetTop - mCircleView.getTop(); + setTargetOffsetTopAndBottom(offset, false /* requires update */); + } + }; + + private void moveToStart(float interpolatedTime) { + int targetTop = 0; + targetTop = (mFrom + (int) ((mOriginalOffsetTop - mFrom) * interpolatedTime)); + int offset = targetTop - mCircleView.getTop(); + setTargetOffsetTopAndBottom(offset, false /* requires update */); + } + + private final Animation mAnimateToStartPosition = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + moveToStart(interpolatedTime); + } + }; + + private void startScaleDownReturnToStartAnimation(int from, + AnimationListener listener) { + mFrom = from; + if (isAlphaUsedForScale()) { + mStartingScale = mProgress.getAlpha(); + } else { + mStartingScale = ViewCompat.getScaleX(mCircleView); + } + mScaleDownToStartAnimation = new Animation() { + @Override + public void applyTransformation(float interpolatedTime, Transformation t) { + float targetScale = (mStartingScale + (-mStartingScale * interpolatedTime)); + setAnimationProgress(targetScale); + moveToStart(interpolatedTime); + } + }; + mScaleDownToStartAnimation.setDuration(SCALE_DOWN_DURATION); + if (listener != null) { + mCircleView.setAnimationListener(listener); + } + mCircleView.clearAnimation(); + mCircleView.startAnimation(mScaleDownToStartAnimation); + } + + private void setTargetOffsetTopAndBottom(int offset, boolean requiresUpdate) { + mCircleView.bringToFront(); + mCircleView.offsetTopAndBottom(offset); + mCurrentTargetOffsetTop = mCircleView.getTop(); + if (requiresUpdate && Build.VERSION.SDK_INT < 11) { + invalidate(); + } + } + + private void onSecondaryPointerUp(MotionEvent ev) { + final int pointerIndex = MotionEventCompat.getActionIndex(ev); + final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex); + if (pointerId == mActivePointerId) { + // This was our active pointer going up. Choose a new + // active pointer and adjust accordingly. + final int newPointerIndex = pointerIndex == 0 ? 1 : 0; + mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex); + } + } + + /** + * Classes that wish to be notified when the swipe gesture correctly + * triggers a refresh should implement this interface. + */ + public interface OnHeaderRefreshListener { + public void onHeaderRefresh(); + } + + public interface OnFooterRefreshListener { + public boolean onFooterRefresh(); + } + + public interface ScrollDetecter { + public boolean canChildScrollUp(); + public boolean isAlmostBottom(); + } +} diff --git a/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/SwipeProgressBar.java b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/SwipeProgressBar.java new file mode 100644 index 000000000..3f4f189da --- /dev/null +++ b/app/src/main/java/com/hippo/ehviewer/widget/refreshlayout/SwipeProgressBar.java @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2013 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.widget.refreshlayout; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.support.v4.view.ViewCompat; +import android.view.View; +import android.view.animation.AnimationUtils; +import android.view.animation.Interpolator; + + +/** + * Custom progress bar that shows a cycle of colors as widening circles that + * overdraw each other. When finished, the bar is cleared from the inside out as + * the main cycle continues. Before running, this can also indicate how close + * the user is to triggering something (e.g. how far they need to pull down to + * trigger a refresh). + */ +final class SwipeProgressBar { + + // Default progress animation colors are grays. + private final static int COLOR1 = 0xB3000000; + private final static int COLOR2 = 0x80000000; + private final static int COLOR3 = 0x4d000000; + private final static int COLOR4 = 0x1a000000; + + // The duration of the animation cycle. + private static final int ANIMATION_DURATION_MS = 2000; + + // The duration of the animation to clear the bar. + private static final int FINISH_ANIMATION_DURATION_MS = 1000; + + // Interpolator for varying the speed of the animation. + private static final Interpolator INTERPOLATOR = BakedBezierInterpolator.getInstance(); + + private final Paint mPaint = new Paint(); + private final RectF mClipRect = new RectF(); + private float mTriggerPercentage; + private long mStartTime; + private long mFinishTime; + private boolean mRunning; + + // Colors used when rendering the animation, + private int mColor1; + private int mColor2; + private int mColor3; + private int mColor4; + private View mParent; + + private Rect mBounds = new Rect(); + + public SwipeProgressBar(View parent) { + mParent = parent; + mColor1 = COLOR1; + mColor2 = COLOR2; + mColor3 = COLOR3; + mColor4 = COLOR4; + } + + /** + * Set the four colors used in the progress animation. The first color will + * also be the color of the bar that grows in response to a user swipe + * gesture. + * + * @param color1 Integer representation of a color. + * @param color2 Integer representation of a color. + * @param color3 Integer representation of a color. + * @param color4 Integer representation of a color. + */ + void setColorScheme(int color1, int color2, int color3, int color4) { + mColor1 = color1; + mColor2 = color2; + mColor3 = color3; + mColor4 = color4; + } + + /** + * Update the progress the user has made toward triggering the swipe + * gesture. and use this value to update the percentage of the trigger that + * is shown. + */ + void setTriggerPercentage(float triggerPercentage) { + mTriggerPercentage = triggerPercentage; + mStartTime = 0; + ViewCompat.postInvalidateOnAnimation( + mParent, mBounds.left, mBounds.top, mBounds.right, mBounds.bottom); + } + + /** + * Start showing the progress animation. + */ + void start() { + if (!mRunning) { + mTriggerPercentage = 0; + mStartTime = AnimationUtils.currentAnimationTimeMillis(); + mRunning = true; + mParent.postInvalidate(); + } + } + + /** + * Stop showing the progress animation. + */ + void stop() { + if (mRunning) { + mTriggerPercentage = 0; + mFinishTime = AnimationUtils.currentAnimationTimeMillis(); + mRunning = false; + mParent.postInvalidate(); + } + } + + /** + * @return Return whether the progress animation is currently running. + */ + boolean isRunning() { + return mRunning || mFinishTime > 0; + } + + void draw(Canvas canvas) { + final int width = mBounds.width(); + final int height = mBounds.height(); + final int cx = width / 2; + final int cy = height / 2; + boolean drawTriggerWhileFinishing = false; + int restoreCount = canvas.save(); + canvas.clipRect(mBounds); + + if (mRunning || (mFinishTime > 0)) { + long now = AnimationUtils.currentAnimationTimeMillis(); + long elapsed = (now - mStartTime) % ANIMATION_DURATION_MS; + long iterations = (now - mStartTime) / ANIMATION_DURATION_MS; + float rawProgress = (elapsed / (ANIMATION_DURATION_MS / 100f)); + + // If we're not running anymore, that means we're running through + // the finish animation. + if (!mRunning) { + // If the finish animation is done, don't draw anything, and + // don't repost. + if ((now - mFinishTime) >= FINISH_ANIMATION_DURATION_MS) { + mFinishTime = 0; + return; + } + + // Otherwise, use a 0 opacity alpha layer to clear the animation + // from the inside out. This layer will prevent the circles from + // drawing within its bounds. + long finishElapsed = (now - mFinishTime) % FINISH_ANIMATION_DURATION_MS; + float finishProgress = (finishElapsed / (FINISH_ANIMATION_DURATION_MS / 100f)); + float pct = (finishProgress / 100f); + // Radius of the circle is half of the screen. + float clearRadius = width / 2 * INTERPOLATOR.getInterpolation(pct); + mClipRect.set(cx - clearRadius, 0, cx + clearRadius, height); + canvas.saveLayerAlpha(mClipRect, 0, 0); + // Only draw the trigger if there is a space in the center of + // this refreshing view that needs to be filled in by the + // trigger. If the progress view is just still animating, let it + // continue animating. + drawTriggerWhileFinishing = true; + } + + // First fill in with the last color that would have finished drawing. + if (iterations == 0) { + canvas.drawColor(mColor1); + } else { + if (rawProgress >= 0 && rawProgress < 25) { + canvas.drawColor(mColor4); + } else if (rawProgress >= 25 && rawProgress < 50) { + canvas.drawColor(mColor1); + } else if (rawProgress >= 50 && rawProgress < 75) { + canvas.drawColor(mColor2); + } else { + canvas.drawColor(mColor3); + } + } + + // Then draw up to 4 overlapping concentric circles of varying radii, based on how far + // along we are in the cycle. + // progress 0-50 draw mColor2 + // progress 25-75 draw mColor3 + // progress 50-100 draw mColor4 + // progress 75 (wrap to 25) draw mColor1 + if ((rawProgress >= 0 && rawProgress <= 25)) { + float pct = (((rawProgress + 25) * 2) / 100f); + drawCircle(canvas, cx, cy, mColor1, pct); + } + if (rawProgress >= 0 && rawProgress <= 50) { + float pct = ((rawProgress * 2) / 100f); + drawCircle(canvas, cx, cy, mColor2, pct); + } + if (rawProgress >= 25 && rawProgress <= 75) { + float pct = (((rawProgress - 25) * 2) / 100f); + drawCircle(canvas, cx, cy, mColor3, pct); + } + if (rawProgress >= 50 && rawProgress <= 100) { + float pct = (((rawProgress - 50) * 2) / 100f); + drawCircle(canvas, cx, cy, mColor4, pct); + } + if ((rawProgress >= 75 && rawProgress <= 100)) { + float pct = (((rawProgress - 75) * 2) / 100f); + drawCircle(canvas, cx, cy, mColor1, pct); + } + if (mTriggerPercentage > 0 && drawTriggerWhileFinishing) { + // There is some portion of trigger to draw. Restore the canvas, + // then draw the trigger. Otherwise, the trigger does not appear + // until after the bar has finished animating and appears to + // just jump in at a larger width than expected. + canvas.restoreToCount(restoreCount); + restoreCount = canvas.save(); + canvas.clipRect(mBounds); + drawTrigger(canvas, cx, cy); + } + // Keep running until we finish out the last cycle. + ViewCompat.postInvalidateOnAnimation( + mParent, mBounds.left, mBounds.top, mBounds.right, mBounds.bottom); + } else { + // Otherwise if we're in the middle of a trigger, draw that. + if (mTriggerPercentage > 0 && mTriggerPercentage <= 1.0) { + drawTrigger(canvas, cx, cy); + } + } + canvas.restoreToCount(restoreCount); + } + + private void drawTrigger(Canvas canvas, int cx, int cy) { + mPaint.setColor(mColor1); + canvas.drawCircle(cx, cy, cx * mTriggerPercentage, mPaint); + } + + /** + * Draws a circle centered in the view. + * + * @param canvas the canvas to draw on + * @param cx the center x coordinate + * @param cy the center y coordinate + * @param color the color to draw + * @param pct the percentage of the view that the circle should cover + */ + private void drawCircle(Canvas canvas, float cx, float cy, int color, float pct) { + mPaint.setColor(color); + canvas.save(); + // Go to bottom + canvas.translate(cx, mParent.getHeight() - cy); + float radiusScale = INTERPOLATOR.getInterpolation(pct); + canvas.scale(radiusScale, radiusScale); + canvas.drawCircle(0, 0, cx, mPaint); + canvas.restore(); + } + + /** + * Set the drawing bounds of this SwipeProgressBar. + */ + void setBounds(int left, int top, int right, int bottom) { + mBounds.left = left; + mBounds.top = top; + mBounds.right = right; + mBounds.bottom = bottom; + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_launcher.png b/app/src/main/res/drawable-hdpi/ic_launcher.png new file mode 100644 index 000000000..cdff1c684 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher.png b/app/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100644 index 000000000..27ba08261 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher.png b/app/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100644 index 000000000..df61d54cc Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher.png b/app/src/main/res/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..cf14a2f27 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launcher.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..d668712cf Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/layout/gallery_list_view.xml b/app/src/main/res/layout/gallery_list_view.xml index 5132b3489..016e4ad31 100644 --- a/app/src/main/res/layout/gallery_list_view.xml +++ b/app/src/main/res/layout/gallery_list_view.xml @@ -16,10 +16,17 @@ --> - + android:layout_height="match_parent"> + + + + - \ No newline at end of file +