├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── LICENSE-examples ├── PATENTS ├── README.md ├── build.gradle ├── demo ├── .gitignore ├── README.md ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── instagram │ │ └── igdiskcache │ │ └── demo │ │ ├── cache │ │ ├── BitmapCache.java │ │ ├── ImageLoader.java │ │ └── Utils.java │ │ └── ui │ │ ├── ImageListAdapter.java │ │ ├── ImageListFragment.java │ │ └── MainActivity.java │ └── res │ ├── drawable │ ├── empty_photo.png │ └── ic_launcher.png │ ├── layout │ ├── activity_main.xml │ ├── image_row_item.xml │ └── recycler_view_frag.xml │ └── values │ ├── colors.xml │ ├── dimens.xml │ ├── strings.xml │ └── styles.xml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── igdiskcache ├── .gitignore ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ └── java │ │ └── com │ │ └── instagram │ │ └── igdiskcache │ │ ├── EditorOutputStream.java │ │ ├── Entry.java │ │ ├── IgDiskCache.java │ │ ├── Journal.java │ │ ├── OptionalStream.java │ │ └── SnapshotInputStream.java │ └── test │ └── java │ └── com │ └── instagram │ └── igdiskcache │ ├── IgDiskCacheTest.java │ ├── JournalTest.java │ └── RobolectricBaseTest.java ├── release.gradle └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | .idea 4 | /local.properties 5 | .DS_Store 6 | /build 7 | /captures -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | branches: 2 | only: 3 | - master 4 | 5 | language: android 6 | 7 | android: 8 | components: 9 | - build-tools-21.1.2 10 | - android-21 11 | - doc-21 12 | licenses: 13 | - /android-sdk-license-[0-9a-f]{8}/ 14 | 15 | script: 16 | - ./gradlew clean testDebugUnitTest --info 17 | 18 | jdk: 19 | - oraclejdk7 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ig-disk-cache 2 | We want to make contributing to this project as easy and transparent as 3 | possible. 4 | 5 | 7 | 8 | ## Pull Requests 9 | We actively welcome your pull requests. 10 | 11 | 1. Fork the repo and create your branch from `master`. 12 | 2. If you've added code that should be tested, add tests. 13 | 3. If you've changed APIs, update the documentation. 14 | 4. Ensure the test suite passes. 15 | 5. Make sure your code lints. 16 | 6. If you haven't already, complete the Contributor License Agreement ("CLA"). 17 | 18 | ## Contributor License Agreement ("CLA") 19 | In order to accept your pull request, we need you to submit a CLA. You only need 20 | to do this once to work on any of Facebook's open source projects. 21 | 22 | Complete your CLA here: 23 | 24 | ## Issues 25 | We use GitHub issues to track public bugs. Please ensure your description is 26 | clear and has sufficient instructions to be able to reproduce the issue. 27 | 28 | Facebook has a [bounty program](https://www.facebook.com/whitehat/) for the safe 29 | disclosure of security bugs. In those cases, please go through the process 30 | outlined on that page and do not file a public issue. 31 | 32 | ## Coding Style 33 | * 2 spaces for indentation rather than tabs 34 | * 80 character line length 35 | 36 | ## License 37 | By contributing to ig-disk-cache, you agree that your contributions will be licensed 38 | under its BSD license. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | 3 | For ig-disk-cache 4 | 5 | Copyright (c) 2016-present, Facebook, Inc. All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, 8 | are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | * Neither the name Facebook nor the names of its contributors may be used to 18 | endorse or promote products derived from this software without specific 19 | prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 22 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 23 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 25 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 26 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 27 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 28 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /LICENSE-examples: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-present, Facebook, Inc. All rights reserved. 2 | 3 | The examples provided by Facebook are for non-commercial testing and evaluation 4 | purposes only. Facebook reserves all rights not expressly granted. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 7 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 8 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 9 | FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 10 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 11 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /PATENTS: -------------------------------------------------------------------------------- 1 | Additional Grant of Patent Rights Version 2 2 | 3 | "Software" means the ig-disk-cache software distributed by Facebook, Inc. 4 | 5 | Facebook, Inc. ("Facebook") hereby grants to each recipient of the Software 6 | ("you") a perpetual, worldwide, royalty-free, non-exclusive, irrevocable 7 | (subject to the termination provision below) license under any Necessary 8 | Claims, to make, have made, use, sell, offer to sell, import, and otherwise 9 | transfer the Software. For avoidance of doubt, no license is granted under 10 | Facebook’s rights in any patent claims that are infringed by (i) modifications 11 | to the Software made by you or any third party or (ii) the Software in 12 | combination with any software or other technology. 13 | 14 | The license granted hereunder will terminate, automatically and without notice, 15 | if you (or any of your subsidiaries, corporate affiliates or agents) initiate 16 | directly or indirectly, or take a direct financial interest in, any Patent 17 | Assertion: (i) against Facebook or any of its subsidiaries or corporate 18 | affiliates, (ii) against any party if such Patent Assertion arises in whole or 19 | in part from any software, technology, product or service of Facebook or any of 20 | its subsidiaries or corporate affiliates, or (iii) against any party relating 21 | to the Software. Notwithstanding the foregoing, if Facebook or any of its 22 | subsidiaries or corporate affiliates files a lawsuit alleging patent 23 | infringement against you in the first instance, and you respond by filing a 24 | patent infringement counterclaim in that lawsuit against that party that is 25 | unrelated to the Software, the license granted hereunder will not terminate 26 | under section (i) of this paragraph due to such counterclaim. 27 | 28 | A "Necessary Claim" is a claim of a patent owned by Facebook that is 29 | necessarily infringed by the Software standing alone. 30 | 31 | A "Patent Assertion" is any lawsuit or other action alleging direct, indirect, 32 | or contributory infringement or inducement to infringe any patent, including a 33 | cross-claim or counterclaim. 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IgDiskCache 2 | 3 | [![Build Status][build-status-svg]][build-status-link] 4 | [![Maven Central][maven-svg]][maven-link] 5 | [![License][license-svg]][license-link] 6 | 7 | 8 | Exception handling is always a cumbersome but unavoidable part of dealing with disk cache on Android. Complex error handling not only makes your code hard to understand, but also prone to developer errors. IgDiskCache is a fault-tolerant Android disk cache library that helps simplify the error handling logic and makes your file caching code cleaner and much easier to maintain. 9 | 10 | For more Instagram engineering updates and shared insights, please visit the [Instagram Engineering blog][eng-blog]. 11 | 12 | # Getting Started 13 | 14 | 15 | ## Usage 16 | 17 | ### Initialization 18 | 19 | - When initializing the IgDiskCache, we can limit the number of bytes, and the number of file entries that can be stored in the cache. We can also use our own serialExecutor to handle Journal logging tasks. 20 | 21 | - For the following cases, the class constructor will return a stub instance of the IgDiskCache: cache directory is NULL or not accessible, maxCacheSizeInBytes or maxFileCount is invalid. 22 | 23 | - A non-UI thread assertion is introduced before the IgDiskCache initialization to prevent running expensive disk IO operations on the UI thread. 24 | 25 | - Note: the cache limits are not strict: the cache may temporarily exceed the cache size or file count limit when waiting for the least-recently-used files to be removed. 26 | 27 | ``` java 28 | IgDiskCache(File directory) 29 | IgDiskCache(File directory, long maxCacheSizeInBytes) 30 | IgDiskCache(File directory, long maxCacheSizeInBytes, int maxFileCount) 31 | IgDiskCache(File directory, long maxCacheSizeInBytes, int maxFileCount, Executor serialExecutor) 32 | 33 | mDiskCache = new IgDiskCache(cacheDir, maxCacheSizeInBytes); 34 | ``` 35 | 36 | ### Writing 37 | 38 | - Call **edit(key)** to get an outputStream for the cache entry. The cache key must match the regex **[a-z0-9_-]{1,120}**. The method will return an **OptionalStream**. 39 | 40 | - If the cache is a stub instance or the cache entry is not available for editing, the **edit** operation will return an **OptionalStream.absent()** instead. 41 | 42 | - If an error occurs while writing to an **EditorOutputStream**, the operation will fail silently without throwing IOExceptions. The partial change will not be committed to the cache, and the stale entry with the same key (if exist) will also be discarded. 43 | 44 | - After editing, instead of **close()** the output stream, the **EditorOutputStream** need to either **commit()** or **abort()** the change. 45 | 46 | - If we try to edit the same cache entry from two different places at the same time, an **IllegalStateException** will be thrown to notify the developer there's a race condition. 47 | 48 | ``` java 49 | OptionalStream outputStream = mDiskCache.edit(key); 50 | if (outputStream.isPresent()) { 51 | try { 52 | writeFileToStream(outputStream.get()); 53 | outputStream.get().commit(); 54 | } finally { 55 | outputStream.get().abortUnlessCommitted(); 56 | } 57 | } 58 | ``` 59 | 60 | ### Reading 61 | 62 | - Call **has(key)** to know if the cache entry associated with a certain key exists and ready-for-read. The method will return **True** if the entry is available, is not currently under editing, and not corrupted because of the previous writing failure. 63 | 64 | - Call **get(key)** to get an inputStream of the cache entry using a cache key. The method will return an **OptionalStream**. 65 | 66 | - If the cache is a stub instance, the file entry is not available, or the file entry is still under editing, an **OptionalStream.absent()** will be returned. 67 | 68 | - If any error occurs while reading from the SnapshotInputStream, **IOExceptions** will still be thrown out as normal FileInputStream does. 69 | 70 | - Similar to FileInputStream, use **close()** to close the **SnapshotInputStream** after use. 71 | 72 | ``` java 73 | OptionalStream inputStream = mDiskCache.get(key); 74 | if (inputStream.isPresent()) { 75 | try { 76 | readFromInputStream(inputStream.get()); 77 | } finally { 78 | inputStream.get().close(); 79 | } 80 | } 81 | ``` 82 | 83 | ### Closing 84 | - Request the disk cache to trim to size or file count. 85 | 86 | ``` java 87 | mDiskCache.flush(); 88 | ``` 89 | 90 | - Finish using the disk cache, you could use **close()** to close the cache (note: **close()** can only be called from non-UI thread): 91 | 92 | ``` java 93 | mDiskCache.close(); 94 | ``` 95 | 96 | ## Compile a AAR 97 | 98 | ``` 99 | ./gradlew clean assembleRelease 100 | ``` 101 | Outputs can be found in igdiskcache/build/outputs/ 102 | 103 | ## Run the Tests 104 | ``` 105 | ./gradlew clean test 106 | ``` 107 | 108 | # Download 109 | 110 | ## Maven 111 | 112 | ``` xml 113 | 114 | com.instagram.igdiskcache 115 | ig-disk-cache 116 | 1.0.0 117 | aar 118 | 119 | ``` 120 | 121 | ## Gradle 122 | 123 | ``` groovy 124 | dependencies { 125 | compile 'com.instagram.igdiskcache:ig-disk-cache:1.0.0@aar' 126 | } 127 | ``` 128 | 129 | # Other Instagram Android Projects 130 | - [ig-json-parser for Android][ig-json-parser-link] 131 | 132 | 133 | # License 134 | 135 | ``` 136 | Copyright (c) 2016-present, Facebook, Inc. 137 | All rights reserved. 138 | 139 | This source code is licensed under the BSD-style license found in the 140 | LICENSE file in the root directory of this source tree. An additional grant 141 | of patent rights can be found in the PATENTS file in the same directory. 142 | ``` 143 | 144 | [eng-blog]: http://engineering.instagram.com/ 145 | 146 | [build-status-svg]: https://travis-ci.org/Instagram/ig-disk-cache.svg 147 | [build-status-link]: https://travis-ci.org/Instagram/ig-disk-cache 148 | [maven-svg]: https://maven-badges.herokuapp.com/maven-central/com.instagram.igdiskcache/ig-disk-cache/badge.svg?style=flat 149 | [maven-link]: https://maven-badges.herokuapp.com/maven-central/com.instagram.igdiskcache/ig-disk-cache 150 | 151 | [ig-json-parser-link]: https://github.com/Instagram/ig-json-parser 152 | 153 | [license-svg]: https://img.shields.io/badge/license-BSD-lightgrey.svg 154 | [license-link]: https://github.com/Instagram/ig-disk-cache/blob/master/LICENSE 155 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | } 5 | dependencies { 6 | classpath 'com.android.tools.build:gradle:2.1.0' 7 | } 8 | } 9 | 10 | allprojects { 11 | repositories { 12 | mavenCentral() 13 | } 14 | } 15 | 16 | task clean(type: Delete) { 17 | delete rootProject.buildDir 18 | } -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # IgDiskCache Demo 2 | 3 | This is a simple image feed demo explaining how to use IgDiskCache in a real application. The demo uses IgDiskCache to cache the raw image files (downloaded directly from the network), and the resized Bitmaps after decode. 4 | 5 | **/cache** is a simple image loading library build on top of IgDiskCache. It handles image loading asynchronously, cache the raw image to disk, and also provide an option to cache the resized **Bitmap** in memory and on disk using **BitmapCache**. 6 | 7 | **/ui** uses a RecyclerView to show image feed on the demo app. 8 | 9 | ### Run the demo 10 | ``` sh 11 | # Build the demo app from the 12 | gradle clean installdebug 13 | # Run the demo on device 14 | adb shell am start -n "com.instagram.igdiskcache.demo/com.instagram.igdiskcache.demo.ui.MainActivity" 15 | ``` 16 | 17 | -------------------------------------------------------------------------------- /demo/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 21 5 | buildToolsVersion "21.1.2" 6 | 7 | defaultConfig { 8 | applicationId "com.instagram.igdiskcache.demo" 9 | minSdkVersion 19 10 | targetSdkVersion 21 11 | versionCode 1 12 | versionName "1.0" 13 | } 14 | buildTypes { 15 | release { 16 | minifyEnabled false 17 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 18 | } 19 | } 20 | } 21 | 22 | dependencies { 23 | compile project(':igdiskcache') 24 | testCompile 'junit:junit:4.12' 25 | compile 'com.android.support:appcompat-v7:21.0.3' 26 | compile 'com.android.support:recyclerview-v7:21.0.3' 27 | } 28 | -------------------------------------------------------------------------------- /demo/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /Users/jimmyzhang/android-sdk-macosx/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | -------------------------------------------------------------------------------- /demo/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /demo/src/main/java/com/instagram/igdiskcache/demo/cache/BitmapCache.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the license found in the 6 | * LICENSE-examples file in the root directory of this source tree. 7 | */ 8 | 9 | package com.instagram.igdiskcache.demo.cache; 10 | 11 | import android.content.Context; 12 | import android.graphics.Bitmap; 13 | import android.graphics.BitmapFactory; 14 | import android.support.v4.util.LruCache; 15 | import android.util.Log; 16 | 17 | import com.instagram.igdiskcache.EditorOutputStream; 18 | import com.instagram.igdiskcache.IgDiskCache; 19 | import com.instagram.igdiskcache.OptionalStream; 20 | import com.instagram.igdiskcache.SnapshotInputStream; 21 | 22 | import java.io.File; 23 | import java.io.FileDescriptor; 24 | import java.io.IOException; 25 | 26 | /** 27 | * Bitmap cache for saving Bitmap in memory and on disk. 28 | */ 29 | public class BitmapCache { 30 | private static final String TAG = BitmapCache.class.getSimpleName(); 31 | private static final String DISK_CACHE_DIR = "bitmap"; 32 | private static final int DEFAULT_MEM_CACHE_CAP = 10; 33 | private static final int DEFAULT_DISK_CACHE_SIZE = 1024 * 1024 * 100; // 100MB 34 | private static final int DEFAULT_DISK_CACHE_SIZE_PERCENT = 10; // 10% of free disk space 35 | 36 | private Context mContext; 37 | private IgDiskCache mDiskCache; 38 | private LruCache mMemoryCache; 39 | 40 | public BitmapCache(Context context) { 41 | mContext = context; 42 | mMemoryCache = new LruCache<>(DEFAULT_MEM_CACHE_CAP); 43 | } 44 | 45 | /** 46 | * Cache Bitmap both in memory and on disk. The bitmap will be compressed into JPEG for disk 47 | * storage. 48 | */ 49 | public void addBitmapToCache(String key, Bitmap bitmap) { 50 | if (key == null || bitmap == null) { 51 | return; 52 | } 53 | mMemoryCache.put(key, bitmap); 54 | if (!getDiskCache().has(key)) { 55 | OptionalStream output = getDiskCache().edit(key); 56 | if (output.isPresent()) { 57 | try { 58 | bitmap.compress(Bitmap.CompressFormat.JPEG, 70, output.get()); 59 | output.get().commit(); 60 | } catch (Exception e) { 61 | Log.e(TAG, "addBitmapToCache - " + e); 62 | } finally { 63 | output.get().abortUnlessCommitted(); 64 | } 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * Get the decoded Bitmap from memory cache 71 | */ 72 | public Bitmap getBitmapFromMemCache(String key) { 73 | return mMemoryCache.get(key); 74 | } 75 | 76 | /** 77 | * Get the decoded Bitmap from disk cache 78 | */ 79 | public Bitmap getBitmapFromDiskCache(String key) { 80 | Bitmap bitmap = null; 81 | OptionalStream input = getDiskCache().get(key); 82 | if (input.isPresent()) { 83 | try { 84 | FileDescriptor fd = input.get().getFD(); 85 | bitmap = BitmapFactory.decodeFileDescriptor(fd); 86 | } catch (IOException e) { 87 | Log.e(TAG, "getBitmapFromDiskCache - " + e); 88 | } finally { 89 | Utils.closeQuietly(input.get()); 90 | } 91 | } 92 | return bitmap; 93 | } 94 | 95 | /** 96 | * Flush the disk cache used for storing Bitmaps 97 | */ 98 | public void flush() { 99 | getDiskCache().flush(); 100 | } 101 | 102 | /** 103 | * Close the disk cache used for storing Bitmaps 104 | */ 105 | public void close() { 106 | getDiskCache().close(); 107 | } 108 | 109 | private IgDiskCache getDiskCache() { 110 | // lazy initialization of IgDiskCache to avoid calling it from the main UI thread. 111 | if (mDiskCache == null) { 112 | File cacheDir = Utils.getCacheDirectory(mContext, DISK_CACHE_DIR); 113 | mDiskCache = new IgDiskCache( 114 | cacheDir, 115 | Utils.getCacheSizeInBytes( 116 | cacheDir, 117 | DEFAULT_DISK_CACHE_SIZE_PERCENT / 100, 118 | DEFAULT_DISK_CACHE_SIZE) 119 | ); 120 | } 121 | return mDiskCache; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /demo/src/main/java/com/instagram/igdiskcache/demo/cache/ImageLoader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the license found in the 6 | * LICENSE-examples file in the root directory of this source tree. 7 | */ 8 | 9 | package com.instagram.igdiskcache.demo.cache; 10 | 11 | import android.content.Context; 12 | import android.content.res.Resources; 13 | import android.graphics.Bitmap; 14 | import android.graphics.BitmapFactory; 15 | import android.os.AsyncTask; 16 | import android.util.Log; 17 | import android.widget.ImageView; 18 | 19 | import com.instagram.igdiskcache.EditorOutputStream; 20 | import com.instagram.igdiskcache.IgDiskCache; 21 | import com.instagram.igdiskcache.OptionalStream; 22 | import com.instagram.igdiskcache.SnapshotInputStream; 23 | 24 | import java.io.BufferedInputStream; 25 | import java.io.BufferedOutputStream; 26 | import java.io.FileDescriptor; 27 | import java.io.IOException; 28 | import java.io.OutputStream; 29 | import java.lang.ref.WeakReference; 30 | import java.net.HttpURLConnection; 31 | import java.net.URL; 32 | import java.util.concurrent.LinkedBlockingQueue; 33 | import java.util.concurrent.ThreadPoolExecutor; 34 | import java.util.concurrent.TimeUnit; 35 | 36 | /** 37 | * A simple asynchronous image loader class 38 | * It caches the raw file downloaded from the network using {@link IgDiskCache}, and also provide 39 | * an option to use a {@link BitmapCache} for resized bitmaps. 40 | * Note: To make the code simple and readable, the loader class doesn't handle loading priorities or 41 | * task canceling. 42 | */ 43 | 44 | public class ImageLoader { 45 | private static final String TAG = ImageLoader.class.getSimpleName(); 46 | private static final String DOWNLOAD_CACHE_DIR = "http"; 47 | private static final int IO_BUFFER_SIZE = 8 * 1024; 48 | private static final int MAX_THREAD_POOL_SIZE = 2; 49 | private static final Object DECODE_LOCK = new Object(); 50 | 51 | private BitmapCache mBitmapCache; 52 | private Context mContext; 53 | private IgDiskCache mDownloadCache; 54 | private Resources mResources; 55 | private ThreadPoolExecutor mThreadPoolExecutor; 56 | 57 | private static ImageLoader sInstance; 58 | 59 | public static ImageLoader getInstance() { 60 | return sInstance; 61 | } 62 | 63 | public static void init(Context context, boolean enableBitmapCache) { 64 | sInstance = new ImageLoader(context, enableBitmapCache); 65 | } 66 | 67 | abstract class Callback { 68 | protected WeakReference mImageView; 69 | 70 | public Callback(ImageView imageView) { 71 | mImageView = new WeakReference<>(imageView); 72 | } 73 | 74 | abstract void onSuccess(Bitmap bitmap); 75 | 76 | abstract void onFail(); 77 | } 78 | 79 | private ImageLoader(final Context context, boolean enableBitmapCache) { 80 | mContext = context; 81 | mResources = context.getResources(); 82 | mThreadPoolExecutor = new ThreadPoolExecutor( 83 | 0, 84 | MAX_THREAD_POOL_SIZE, 85 | 60L, 86 | TimeUnit.SECONDS, 87 | new LinkedBlockingQueue()); 88 | if (enableBitmapCache) { 89 | mBitmapCache = new BitmapCache(context); 90 | } 91 | } 92 | 93 | /** 94 | * Load an image asynchronously into a ImageView. The raw image file and the decoded Bitmap can be 95 | * cached locally in memory and on disk. 96 | * @param url image URL 97 | * @param target target ImageView the image will load to 98 | * @param height image height 99 | * @param width image width 100 | * @param loadingResId resource id of the loading indicator image 101 | */ 102 | public void loadImage( 103 | String url, 104 | ImageView target, 105 | int height, 106 | int width, 107 | int loadingResId) { 108 | Bitmap value = null; 109 | if (mBitmapCache != null) { 110 | value = mBitmapCache.getBitmapFromMemCache(getKeyForBitmapCache(url, height, width)); 111 | } 112 | if (value != null) { 113 | target.setImageBitmap(value); 114 | } else { 115 | target.setImageDrawable(mResources.getDrawable(loadingResId)); 116 | final BitmapWorkerTask task = new BitmapWorkerTask( 117 | url, 118 | height, 119 | width, 120 | new Callback(target) { 121 | @Override 122 | public void onSuccess(Bitmap bitmap) { 123 | if (bitmap != null && mImageView.get() != null) { 124 | mImageView.get().setImageBitmap(bitmap); 125 | } 126 | } 127 | 128 | @Override 129 | public void onFail() { 130 | Log.e(TAG, "fail to load image."); 131 | } 132 | }); 133 | task.executeOnExecutor(mThreadPoolExecutor); 134 | } 135 | } 136 | 137 | private class BitmapWorkerTask extends AsyncTask { 138 | private String mUrl; 139 | private int mHeight, mWidth; 140 | private Callback mCallback; 141 | 142 | public BitmapWorkerTask(String url, int height, int width, Callback callback) { 143 | mUrl = url; 144 | mHeight = height; 145 | mWidth = width; 146 | mCallback = callback; 147 | } 148 | 149 | @Override 150 | protected Bitmap doInBackground(Void... params) { 151 | Bitmap bitmap = null; 152 | if (mBitmapCache != null) { 153 | bitmap = mBitmapCache.getBitmapFromDiskCache(getKeyForBitmapCache(mUrl, mHeight, mWidth)); 154 | } 155 | if (bitmap == null) { 156 | bitmap = loadBitmap(mUrl, mHeight, mWidth); 157 | } 158 | return bitmap; 159 | } 160 | 161 | @Override 162 | protected void onPostExecute(Bitmap bitmap) { 163 | if (bitmap != null) { 164 | mCallback.onSuccess(bitmap); 165 | if (mBitmapCache != null) { 166 | mBitmapCache.addBitmapToCache(getKeyForBitmapCache(mUrl, mHeight, mWidth), bitmap); 167 | } 168 | } else { 169 | mCallback.onFail(); 170 | } 171 | } 172 | } 173 | 174 | /** 175 | * Close the caches after use. 176 | */ 177 | public void close() { 178 | mThreadPoolExecutor.execute(new Runnable() { 179 | @Override 180 | public void run() { 181 | if (mBitmapCache != null) { 182 | mBitmapCache.close(); 183 | } 184 | if (mDownloadCache != null) { 185 | mDownloadCache.close(); 186 | } 187 | } 188 | }); 189 | } 190 | 191 | private Bitmap loadBitmap(String url, int height, int width) { 192 | String key = Integer.toHexString(url.hashCode()); 193 | OptionalStream input = getDownloadCache().get(key); 194 | if (!input.isPresent()) { 195 | OptionalStream output = getDownloadCache().edit(key); 196 | if (output.isPresent()) { 197 | if (downloadUrlToStream(url, output.get())) { 198 | output.get().commit(); 199 | } else { 200 | output.get().abort(); 201 | } 202 | } 203 | input = getDownloadCache().get(key); 204 | } 205 | 206 | Bitmap bitmap = null; 207 | if (input.isPresent()) { 208 | try { 209 | FileDescriptor fileDescriptor = input.get().getFD(); 210 | if (fileDescriptor != null) { 211 | synchronized (DECODE_LOCK) { 212 | Bitmap tmp = BitmapFactory.decodeFileDescriptor(fileDescriptor); 213 | bitmap = Bitmap.createScaledBitmap(tmp, width, height, false); 214 | tmp.recycle(); 215 | } 216 | } 217 | } catch (IOException e) { 218 | Log.e(TAG, "loadBitmap - " + e); 219 | } finally { 220 | Utils.closeQuietly(input.get()); 221 | } 222 | } 223 | return bitmap; 224 | } 225 | 226 | private IgDiskCache getDownloadCache() { 227 | // lazy initialization of IgDiskCache to avoid calling it from the main UI thread. 228 | if (mDownloadCache == null) { 229 | mDownloadCache = new IgDiskCache(Utils.getCacheDirectory(mContext, DOWNLOAD_CACHE_DIR)); 230 | } 231 | return mDownloadCache; 232 | } 233 | 234 | private static boolean downloadUrlToStream(String urlString, OutputStream outputStream) { 235 | HttpURLConnection urlConnection = null; 236 | BufferedOutputStream out = null; 237 | BufferedInputStream in = null; 238 | try { 239 | final URL url = new URL(urlString); 240 | urlConnection = (HttpURLConnection) url.openConnection(); 241 | in = new BufferedInputStream(urlConnection.getInputStream(), IO_BUFFER_SIZE); 242 | out = new BufferedOutputStream(outputStream, IO_BUFFER_SIZE); 243 | 244 | int b; 245 | while ((b = in.read()) != -1) { 246 | out.write(b); 247 | } 248 | return true; 249 | } catch (final IOException e) { 250 | Log.e(TAG, "Error in downloadBitmap - " + e); 251 | } finally { 252 | if (urlConnection != null) { 253 | urlConnection.disconnect(); 254 | } 255 | Utils.closeQuietly(out); 256 | Utils.closeQuietly(in); 257 | } 258 | return false; 259 | } 260 | 261 | private static String getKeyForBitmapCache(String url, int height, int width) { 262 | String str = url + "-" + Integer.toString(height) + "-" + Integer.toString(width); 263 | return Integer.toHexString(str.hashCode()); 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /demo/src/main/java/com/instagram/igdiskcache/demo/cache/Utils.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the license found in the 6 | * LICENSE-examples file in the root directory of this source tree. 7 | */ 8 | 9 | package com.instagram.igdiskcache.demo.cache; 10 | 11 | import android.content.Context; 12 | import android.os.Environment; 13 | import android.os.StatFs; 14 | 15 | import java.io.Closeable; 16 | import java.io.File; 17 | import java.io.IOException; 18 | 19 | public class Utils { 20 | /** 21 | * Helper method to calculate the proper size limit of a cache instance. 22 | */ 23 | public static long getCacheSizeInBytes(File dir, float cacheSizePercent, long maxSizeInBytes) { 24 | if (dir == null || (!dir.exists() && !dir.mkdir())) { 25 | return 0; 26 | } 27 | try { 28 | StatFs stat = new StatFs(dir.getPath()); 29 | long totalBytes = stat.getBlockCountLong() * stat.getBlockSizeLong(); 30 | long freeBytes = stat.getAvailableBlocksLong() * stat.getBlockSizeLong(); 31 | long desiredBytes = Math.min((long) (totalBytes * cacheSizePercent), maxSizeInBytes); 32 | // If free space is less than desired, use half of the free disk space instead. 33 | desiredBytes = (desiredBytes > freeBytes) ? freeBytes / 2 : desiredBytes; 34 | return desiredBytes; 35 | } catch (IllegalArgumentException e) { 36 | return 0; 37 | } 38 | } 39 | 40 | /** 41 | * Helper method to initiate cache directory. It will return the cache directory in File format, 42 | * or NULL if the directory path is invalid or not accessible. 43 | */ 44 | public static File getCacheDirectory(final Context context, final String path) { 45 | File cacheDir = null; 46 | if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { 47 | try { 48 | cacheDir = context.getExternalCacheDir(); 49 | } catch (NullPointerException e) { 50 | // Fallback to use internal storage if external storage isn't available. 51 | } 52 | } 53 | if (cacheDir == null) { 54 | cacheDir = context.getCacheDir(); 55 | } 56 | return (cacheDir != null && path != null) ? new File(cacheDir, path) : null; 57 | } 58 | 59 | /** 60 | * Helper method to close a Closeable (e.g. InputStream/OutputStream) quietly without throwing any 61 | * additional IOExceptions. 62 | */ 63 | public static void closeQuietly(Closeable closeable) { 64 | if (closeable != null) { 65 | try { 66 | closeable.close(); 67 | } catch (IOException e) { 68 | // Handle the IOException quietly. 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /demo/src/main/java/com/instagram/igdiskcache/demo/ui/ImageListAdapter.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the license found in the 6 | * LICENSE-examples file in the root directory of this source tree. 7 | */ 8 | 9 | package com.instagram.igdiskcache.demo.ui; 10 | 11 | import android.support.v7.widget.RecyclerView; 12 | import android.view.LayoutInflater; 13 | import android.view.View; 14 | import android.view.ViewGroup; 15 | import android.widget.ImageView; 16 | 17 | import com.instagram.igdiskcache.demo.R; 18 | import com.instagram.igdiskcache.demo.cache.ImageLoader; 19 | 20 | /** 21 | * ImageListAdapter is the place where images are loaded into the target ImageView asynchronously. 22 | * Modify the {@link ImageListAdapter#IMAGE_HEIGHT} and {@link ImageListAdapter#IMAGE_WIDTH} to 23 | * resize the images. 24 | */ 25 | public class ImageListAdapter extends RecyclerView.Adapter { 26 | private static int IMAGE_HEIGHT = 700; 27 | private static int IMAGE_WIDTH = 700; 28 | private String[] mDataSet; 29 | 30 | public static class ViewHolder extends RecyclerView.ViewHolder { 31 | private final ImageView imageView; 32 | 33 | public ViewHolder(View v) { 34 | super(v); 35 | imageView = (ImageView) v.findViewById(R.id.imageView); 36 | } 37 | 38 | public ImageView getImageView() { 39 | return imageView; 40 | } 41 | } 42 | 43 | public ImageListAdapter(String[] dataSet) { 44 | mDataSet = dataSet; 45 | } 46 | 47 | @Override 48 | public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { 49 | View v = LayoutInflater.from(viewGroup.getContext()) 50 | .inflate(R.layout.image_row_item, viewGroup, false); 51 | return new ViewHolder(v); 52 | } 53 | 54 | @Override 55 | public void onBindViewHolder(ViewHolder viewHolder, final int position) { 56 | ImageLoader.getInstance().loadImage( 57 | mDataSet[position], 58 | viewHolder.getImageView(), 59 | IMAGE_HEIGHT, 60 | IMAGE_WIDTH, 61 | R.drawable.empty_photo); 62 | } 63 | 64 | @Override 65 | public int getItemCount() { 66 | return mDataSet.length; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /demo/src/main/java/com/instagram/igdiskcache/demo/ui/ImageListFragment.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the license found in the 6 | * LICENSE-examples file in the root directory of this source tree. 7 | */ 8 | 9 | package com.instagram.igdiskcache.demo.ui; 10 | 11 | import android.os.Bundle; 12 | import android.support.v4.app.Fragment; 13 | import android.support.v7.widget.LinearLayoutManager; 14 | import android.support.v7.widget.RecyclerView; 15 | import android.view.LayoutInflater; 16 | import android.view.View; 17 | import android.view.ViewGroup; 18 | 19 | import com.instagram.igdiskcache.demo.R; 20 | 21 | /** 22 | * Main Fragment for the demo app. We use a RecyclerView here to hold all the image feeds. 23 | */ 24 | public class ImageListFragment extends Fragment { 25 | private static final String TAG = ImageListFragment.class.getSimpleName(); 26 | protected final static String[] DATASET = { 27 | "https://www.instagram.com/p/cUS7ingBXj/media/?size=l", 28 | "https://www.instagram.com/p/c5b9AmgBTC/media/?size=l", 29 | "https://www.instagram.com/p/dJZZNTgBeY/media/?size=l", 30 | "https://www.instagram.com/p/f_CjHlABew/media/?size=l", 31 | "https://www.instagram.com/p/fkqGqjgBUT/media/?size=l", 32 | "https://www.instagram.com/p/fIvDbNABQL/media/?size=l", 33 | "https://www.instagram.com/p/edLmLPgBfu/media/?size=l", 34 | "https://www.instagram.com/p/d-SMOoABUO/media/?size=l", 35 | "https://www.instagram.com/p/Q0W-IgABed/media/?size=l", 36 | "https://www.instagram.com/p/RWB-x0gBbI/media/?size=l", 37 | "https://www.instagram.com/p/QdYwipABY-/media/?size=l", 38 | "https://www.instagram.com/p/NrUyZqgBVL/media/?size=l", 39 | "https://www.instagram.com/p/LR6OstgBfh/media/?size=l", 40 | "https://www.instagram.com/p/Kweua8ABR9/media/?size=l", 41 | "https://www.instagram.com/p/lnIqM/media/?size=l", 42 | "https://www.instagram.com/p/9W24rxgBYt/media/?size=l", 43 | "https://www.instagram.com/p/9vjk6iABbt/media/?size=l", 44 | "https://www.instagram.com/p/-DPpr3gBcb/media/?size=l", 45 | "https://www.instagram.com/p/BCzC1ApABS2/media/?size=l", 46 | "https://www.instagram.com/p/BDG1o9lgBWm/media/?size=l", 47 | "https://www.instagram.com/p/BD8M6S8ABR_/media/?size=l", 48 | "https://www.instagram.com/p/4nzjYdABST/media/?size=l" 49 | }; 50 | protected RecyclerView mRecyclerView; 51 | protected ImageListAdapter mAdapter; 52 | @Override 53 | public View onCreateView(LayoutInflater inflater, ViewGroup container, 54 | Bundle savedInstanceState) { 55 | View rootView = inflater.inflate(R.layout.recycler_view_frag, container, false); 56 | rootView.setTag(TAG); 57 | mRecyclerView = (RecyclerView) rootView.findViewById(R.id.recyclerView); 58 | mAdapter = new ImageListAdapter(DATASET); 59 | mRecyclerView.setAdapter(mAdapter); 60 | mRecyclerView.setLayoutManager(new LinearLayoutManager(getActivity())); 61 | return rootView; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /demo/src/main/java/com/instagram/igdiskcache/demo/ui/MainActivity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the license found in the 6 | * LICENSE-examples file in the root directory of this source tree. 7 | */ 8 | 9 | package com.instagram.igdiskcache.demo.ui; 10 | 11 | import android.content.Context; 12 | import android.support.v4.app.FragmentActivity; 13 | import android.support.v4.app.FragmentTransaction; 14 | import android.os.Bundle; 15 | 16 | import com.instagram.igdiskcache.demo.R; 17 | import com.instagram.igdiskcache.demo.cache.ImageLoader; 18 | 19 | /** 20 | * A simple image feed demo for IgDiskCache 21 | * Modify the {@link ImageLoader#init(Context, boolean)} settings to enable/disable the BitmapCache. 22 | */ 23 | public class MainActivity extends FragmentActivity { 24 | @Override 25 | protected void onCreate(Bundle savedInstanceState) { 26 | super.onCreate(savedInstanceState); 27 | ImageLoader.init( 28 | getApplicationContext(), 29 | true /*enableBitmapCache*/ 30 | ); 31 | setContentView(R.layout.activity_main); 32 | FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); 33 | ImageListFragment fragment = new ImageListFragment(); 34 | transaction.replace(R.id.sample_content_fragment, fragment); 35 | transaction.commit(); 36 | } 37 | 38 | @Override 39 | protected void onDestroy() { 40 | super.onDestroy(); 41 | ImageLoader.getInstance().close(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /demo/src/main/res/drawable/empty_photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookarchive/ig-disk-cache/dcdc1f1aace70a8dbf25bd69969b3c8d38f83cf4/demo/src/main/res/drawable/empty_photo.png -------------------------------------------------------------------------------- /demo/src/main/res/drawable/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookarchive/ig-disk-cache/dcdc1f1aace70a8dbf25bd69969b3c8d38f83cf4/demo/src/main/res/drawable/ic_launcher.png -------------------------------------------------------------------------------- /demo/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/image_row_item.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 11 | -------------------------------------------------------------------------------- /demo/src/main/res/layout/recycler_view_frag.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /demo/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #3F51B5 4 | #303F9F 5 | #FF4081 6 | 7 | -------------------------------------------------------------------------------- /demo/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 16dp 3 | 16dp 4 | 500dp 5 | 700dp 6 | 7 | -------------------------------------------------------------------------------- /demo/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | IgDiskCache Demo 3 | 4 | -------------------------------------------------------------------------------- /demo/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | VERSION_NAME=1.0.0-SNAPSHOT 2 | GROUP=com.instagram.igdiskcache -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/facebookarchive/ig-disk-cache/dcdc1f1aace70a8dbf25bd69969b3c8d38f83cf4/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Dec 28 10:00:20 PST 2015 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-2.10-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=`basename "$0"` 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn ( ) { 19 | echo "$*" 20 | } 21 | 22 | die ( ) { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "`uname`" in 34 | CYGWIN* ) 35 | cygwin=true 36 | ;; 37 | Darwin* ) 38 | darwin=true 39 | ;; 40 | MINGW* ) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # Attempt to set APP_HOME 46 | # Resolve links: $0 may be a link 47 | PRG="$0" 48 | # Need this for relative symlinks. 49 | while [ -h "$PRG" ] ; do 50 | ls=`ls -ld "$PRG"` 51 | link=`expr "$ls" : '.*-> \(.*\)$'` 52 | if expr "$link" : '/.*' > /dev/null; then 53 | PRG="$link" 54 | else 55 | PRG=`dirname "$PRG"`"/$link" 56 | fi 57 | done 58 | SAVED="`pwd`" 59 | cd "`dirname \"$PRG\"`/" >/dev/null 60 | APP_HOME="`pwd -P`" 61 | cd "$SAVED" >/dev/null 62 | 63 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 64 | 65 | # Determine the Java command to use to start the JVM. 66 | if [ -n "$JAVA_HOME" ] ; then 67 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 68 | # IBM's JDK on AIX uses strange locations for the executables 69 | JAVACMD="$JAVA_HOME/jre/sh/java" 70 | else 71 | JAVACMD="$JAVA_HOME/bin/java" 72 | fi 73 | if [ ! -x "$JAVACMD" ] ; then 74 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 75 | 76 | Please set the JAVA_HOME variable in your environment to match the 77 | location of your Java installation." 78 | fi 79 | else 80 | JAVACMD="java" 81 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 82 | 83 | Please set the JAVA_HOME variable in your environment to match the 84 | location of your Java installation." 85 | fi 86 | 87 | # Increase the maximum file descriptors if we can. 88 | if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then 89 | MAX_FD_LIMIT=`ulimit -H -n` 90 | if [ $? -eq 0 ] ; then 91 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 92 | MAX_FD="$MAX_FD_LIMIT" 93 | fi 94 | ulimit -n $MAX_FD 95 | if [ $? -ne 0 ] ; then 96 | warn "Could not set maximum file descriptor limit: $MAX_FD" 97 | fi 98 | else 99 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 100 | fi 101 | fi 102 | 103 | # For Darwin, add options to specify how the application appears in the dock 104 | if $darwin; then 105 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 106 | fi 107 | 108 | # For Cygwin, switch paths to Windows format before running java 109 | if $cygwin ; then 110 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 111 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 112 | JAVACMD=`cygpath --unix "$JAVACMD"` 113 | 114 | # We build the pattern for arguments to be converted via cygpath 115 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 116 | SEP="" 117 | for dir in $ROOTDIRSRAW ; do 118 | ROOTDIRS="$ROOTDIRS$SEP$dir" 119 | SEP="|" 120 | done 121 | OURCYGPATTERN="(^($ROOTDIRS))" 122 | # Add a user-defined pattern to the cygpath arguments 123 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 124 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 125 | fi 126 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 127 | i=0 128 | for arg in "$@" ; do 129 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 130 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 131 | 132 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 133 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 134 | else 135 | eval `echo args$i`="\"$arg\"" 136 | fi 137 | i=$((i+1)) 138 | done 139 | case $i in 140 | (0) set -- ;; 141 | (1) set -- "$args0" ;; 142 | (2) set -- "$args0" "$args1" ;; 143 | (3) set -- "$args0" "$args1" "$args2" ;; 144 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 145 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 146 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 147 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 148 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 149 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 150 | esac 151 | fi 152 | 153 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 154 | function splitJvmOpts() { 155 | JVM_OPTS=("$@") 156 | } 157 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 158 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 159 | 160 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 161 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /igdiskcache/.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | /build 3 | 4 | -------------------------------------------------------------------------------- /igdiskcache/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | 3 | android { 4 | compileSdkVersion 21 5 | buildToolsVersion "21.1.2" 6 | 7 | defaultConfig { 8 | minSdkVersion 14 9 | targetSdkVersion 21 10 | versionCode 1 11 | versionName "1.0" 12 | } 13 | compileOptions { 14 | sourceCompatibility JavaVersion.VERSION_1_7 15 | targetCompatibility JavaVersion.VERSION_1_7 16 | } 17 | } 18 | 19 | apply from: rootProject.file('release.gradle') 20 | 21 | dependencies { 22 | testCompile 'org.easytesting:fest-assert-core:2.0M10' 23 | testCompile 'org.robolectric:robolectric:3.0' 24 | testCompile 'org.powermock:powermock-api-mockito:1.6.4' 25 | testCompile 'org.powermock:powermock-classloading-xstream:1.6.4' 26 | testCompile 'org.powermock:powermock-module-junit4:1.6.4' 27 | testCompile 'org.powermock:powermock-module-junit4-rule:1.6.4' 28 | } 29 | -------------------------------------------------------------------------------- /igdiskcache/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /igdiskcache/src/main/java/com/instagram/igdiskcache/EditorOutputStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | 10 | package com.instagram.igdiskcache; 11 | 12 | import java.io.FileNotFoundException; 13 | import java.io.FileOutputStream; 14 | import java.io.IOException; 15 | 16 | /** 17 | * OutputStream used for writing data into the disk cache Entry. If you need to use 18 | * BufferedOutputStream, you might want to wrap the EditorOutputStream up with the 19 | * BufferedOutputStream yourself. 20 | *

After edit, instead of {@link #close()} the EditorOutputStream, the OutputStream need to 21 | * {@link #commit()} to write the change to cache, or {@link #abort()} to discard the change. 22 | *

All EditorOutputStream should be committed or aborted after use to prevent resource leak. 23 | */ 24 | public final class EditorOutputStream extends FileOutputStream { 25 | private IgDiskCache mCache; 26 | private Entry mEntry; 27 | private boolean mHasErrors; 28 | private boolean mIsClosed; 29 | 30 | /* package */ EditorOutputStream(Entry entry, IgDiskCache cache) throws FileNotFoundException { 31 | super(entry.getDirtyFile()); 32 | mCache = cache; 33 | mEntry = entry; 34 | mHasErrors = false; 35 | } 36 | 37 | /** 38 | * Commit change to disk cache. 39 | * @return true if the change is successfully committed to disk cache. In case of IOExceptions, 40 | * the method will return false instead of throwing out the IOExceptions. 41 | */ 42 | public synchronized boolean commit() { 43 | checkNotClosedOrEditingConcurrently(); 44 | close(); 45 | mIsClosed = true; 46 | if (mHasErrors) { 47 | mCache.abortEdit(mEntry); 48 | mCache.remove(mEntry.getKey()); // Previous entry is stale. 49 | return false; 50 | } else { 51 | mCache.commitEdit(mEntry); 52 | return true; 53 | } 54 | } 55 | 56 | /** 57 | * Abort the change made to the EditorOutputStream. 58 | */ 59 | public synchronized void abort() { 60 | checkNotClosedOrEditingConcurrently(); 61 | close(); 62 | mIsClosed = true; 63 | mCache.abortEdit(mEntry); 64 | } 65 | 66 | /** 67 | * Abort the change if it is not already committed. This is commonly used in the {@code finally} 68 | * block of error try-cache to make sure the EditorOutputStream is properly closed. 69 | */ 70 | public synchronized void abortUnlessCommitted() { 71 | if (!mIsClosed) { 72 | abort(); 73 | } 74 | } 75 | 76 | @Override 77 | public void write(byte[] buffer) { 78 | try { 79 | super.write(buffer); 80 | } catch (IOException e) { 81 | mHasErrors = true; 82 | } 83 | } 84 | 85 | @Override 86 | public void write(byte[] buffer, int byteOffset, int byteCount) { 87 | try { 88 | super.write(buffer, byteOffset, byteCount); 89 | } catch (IOException e) { 90 | mHasErrors = true; 91 | } 92 | } 93 | 94 | /** 95 | * Deprecated, should use {@link #commit()} or {@link #abort()} instead. 96 | */ 97 | @Deprecated 98 | @Override 99 | public void close() { 100 | try { 101 | super.close(); 102 | } catch (IOException e) { 103 | mHasErrors = true; 104 | } 105 | } 106 | 107 | @Override 108 | public void flush() { 109 | try { 110 | super.flush(); 111 | } catch (IOException e) { 112 | mHasErrors = true; 113 | } 114 | } 115 | 116 | private void checkNotClosedOrEditingConcurrently() { 117 | if (mIsClosed) { 118 | throw new IllegalStateException( 119 | "Try to operate on an EditorOutputStream that is already closed"); 120 | } 121 | if (mEntry.getCurrentEditorStream() != this) { 122 | throw new IllegalStateException( 123 | "Two editors trying to write to the same cached file"); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /igdiskcache/src/main/java/com/instagram/igdiskcache/Entry.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | 10 | package com.instagram.igdiskcache; 11 | 12 | import java.io.File; 13 | 14 | /** 15 | * Disk cache Entry. 16 | */ 17 | /* package */ final class Entry { 18 | /* package */ static final String CLEAN_FILE_EXTENSION = ".clean"; 19 | /* package */ static final String DIRTY_FILE_EXTENSION = ".tmp"; 20 | private final File mDirectory; 21 | private final String mKey; 22 | private long mLengthInBytes; 23 | private boolean mIsReadable; 24 | private EditorOutputStream mCurrentEditorStream; 25 | 26 | /* package */ Entry(File directory, String key) { 27 | mDirectory = directory; 28 | mKey = key; 29 | mLengthInBytes = 0; 30 | mIsReadable = false; 31 | } 32 | 33 | /* package */ File getCleanFile() { 34 | return new File(mDirectory, mKey + CLEAN_FILE_EXTENSION); 35 | } 36 | 37 | /* package */ File getDirtyFile() { 38 | return new File(mDirectory, mKey + DIRTY_FILE_EXTENSION); 39 | } 40 | 41 | /* package */ synchronized long getLengthInBytes() { 42 | return mLengthInBytes; 43 | } 44 | 45 | /* package */ synchronized boolean isReadable() { 46 | return mIsReadable; 47 | } 48 | 49 | /* package */ synchronized EditorOutputStream getCurrentEditorStream() { 50 | return mCurrentEditorStream; 51 | } 52 | 53 | /* package */ synchronized void setCurrentEditorStream(EditorOutputStream currentEditorStream) { 54 | mCurrentEditorStream = currentEditorStream; 55 | } 56 | 57 | /* package */ String getKey() { 58 | return mKey; 59 | } 60 | 61 | /* package */ synchronized void markAsPublished(long newLength) { 62 | mLengthInBytes = newLength; 63 | mCurrentEditorStream = null; 64 | mIsReadable = true; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /igdiskcache/src/main/java/com/instagram/igdiskcache/IgDiskCache.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | 10 | package com.instagram.igdiskcache; 11 | 12 | import android.os.AsyncTask; 13 | import android.os.Looper; 14 | 15 | import java.io.File; 16 | import java.io.FileNotFoundException; 17 | import java.io.IOException; 18 | import java.util.ArrayList; 19 | import java.util.Iterator; 20 | import java.util.LinkedHashMap; 21 | import java.util.LinkedList; 22 | import java.util.List; 23 | import java.util.Locale; 24 | import java.util.Map; 25 | import java.util.NoSuchElementException; 26 | import java.util.concurrent.Executor; 27 | import java.util.concurrent.LinkedBlockingQueue; 28 | import java.util.concurrent.ThreadPoolExecutor; 29 | import java.util.concurrent.TimeUnit; 30 | import java.util.concurrent.atomic.AtomicLong; 31 | import java.util.regex.Matcher; 32 | import java.util.regex.Pattern; 33 | 34 | /** 35 | * Disk cache that uses a bounded amount of space and with a maximum number of entries on the 36 | * filesystem. Each disk cache entry is identified with a string key (each key must match the 37 | * regex [a-z0-9_-]{1,120}.) 38 | * 39 | *

The cache stores its data in a directory on the filesystem. This directory must be exclusive 40 | * to the cache; the cache may delete or overwrite files from its directory. It is an error for 41 | * multiple processes to use the same cache directory at the same time. 42 | * 43 | *

This cache limits the number of bytes, and the number of entries that it will store on 44 | * the filesystem. When the number of entries or stored bytes exceeds the limit, the cache will 45 | * remove entries in the background until the limits are satisfied. The limits are not strict: 46 | * the cache may temporarily exceed the limits while waiting for files to be deleted. 47 | * 48 | *

Clients call {@link #get} to read a snapshot of an entry. The read will observe the value 49 | * at the time that {@link #get} was called. Updates and removals after the call do not impact 50 | * ongoing read. Reading from a disk cache entry: 51 | *

 52 |  *   {@code
 53 |  *      OptionalStream inputStream = getDiskLruCache().get(key);
 54 |  *      if (inputStream.isPresent()) {
 55 |  *        try {
 56 |  *          readFromInputStream(inputStream.get());
 57 |  *        } finally {
 58 |  *          inputStream.close();
 59 |  *        }
 60 |  *      }
 61 |  *   }
 62 |  * 
63 | * 64 | *

Clients call {@link #edit} to create or update the values of an entry. An entry may have 65 | * only one editor at one time; if a value is not available to be edited then {@link #edit} will 66 | * return OptionalStream.absent().If an error occurs while writing a cache value, the edit will fail 67 | * silently without throwing IOExceptions. 68 | * The {@link EditorOutputStream} need to be either commit() or abort() after editing. 69 | * Writing to a disk cache entry: 70 | *

 71 |  *   {@code
 72 |  *      OptionalStream output = getStorage().edit(key);
 73 |  *      if (output.isPresent()) {
 74 |  *        output.get().write(data);
 75 |  *        output.get().commit();
 76 |  *      }
 77 |  *   }
 78 |  * 
79 | * 80 | *

This class will silently handle most of the IOExceptions. If files are missing from the 81 | * filesystem, the corresponding entries will be dropped from the cache. 82 | * 83 | *

Note: IgDiskCache should never be initialized or closed from the UI Thread. 84 | */ 85 | public final class IgDiskCache { 86 | private static final String STRING_KEY_PATTERN = "[a-z0-9_-]{1,120}"; 87 | private static final Pattern LEGAL_KEY_PATTERN = Pattern.compile(STRING_KEY_PATTERN); 88 | private static final long DEFAULT_MAX_SIZE = 1024 * 1024 * 30; // maximum 30 megs in size 89 | private static final int DEFAULT_MAX_COUNT = 1000; // maximum 1000 files 90 | private static final ThreadPoolExecutor DISK_CACHE_EXECUTOR = 91 | new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue()); 92 | static final File FAKE_CACHE_DIRECTORY = new File("/dev/null"); 93 | 94 | private final File mDirectory; 95 | private final Object mDiskCacheLock = new Object(); 96 | private final Object mRemoveRetryLock = new Object(); 97 | // Guarded by mDiskCacheLock 98 | private final LinkedHashMap mLruEntries; 99 | // Guarded by mRemoveRetryLock 100 | private final List mRemoveRetryList; 101 | private final AtomicLong mSizeInBytes = new AtomicLong(); 102 | private final Journal mJournal; 103 | private int mMaxCount; 104 | private long mMaxSizeInBytes; 105 | private int mMissCount; 106 | private int mHitCount; 107 | 108 | private final Runnable mTrimRunnable = new Runnable() { 109 | @Override 110 | public void run() { 111 | if (mSizeInBytes.get() > mMaxSizeInBytes || count() > mMaxCount) { 112 | trimToSizeAndCount(); 113 | } 114 | } 115 | }; 116 | 117 | /** 118 | * Disk Cache initialization. 119 | * @param directory directory for disk cache. 120 | */ 121 | public IgDiskCache(File directory) { 122 | this(directory, DEFAULT_MAX_SIZE, DEFAULT_MAX_COUNT, AsyncTask.SERIAL_EXECUTOR); 123 | } 124 | 125 | /** 126 | * Disk Cache initialization. 127 | * @param directory directory for disk cache. 128 | * @param serialExecutor Serial Executor for {@link Journal} logging. 129 | */ 130 | public IgDiskCache(File directory, Executor serialExecutor) { 131 | this(directory, DEFAULT_MAX_SIZE, DEFAULT_MAX_COUNT, serialExecutor); 132 | } 133 | 134 | /** 135 | * Disk Cache initialization. 136 | * @param directory directory for disk cache. 137 | * @param maxSizeInBytes limit for the disk cache size (in bytes) 138 | */ 139 | public IgDiskCache(File directory, long maxSizeInBytes) { 140 | this(directory, maxSizeInBytes, DEFAULT_MAX_COUNT, AsyncTask.SERIAL_EXECUTOR); 141 | } 142 | 143 | /** 144 | * Disk Cache initialization. 145 | * @param directory directory for disk cache. 146 | * @param maxSizeInBytes limit for the disk cache size (in bytes). 147 | * @param serialExecutor Serial Executor for {@link Journal} logging. 148 | */ 149 | public IgDiskCache(File directory, long maxSizeInBytes, Executor serialExecutor) { 150 | this(directory, maxSizeInBytes, DEFAULT_MAX_COUNT, serialExecutor); 151 | } 152 | 153 | /** 154 | * Disk Cache initialization. 155 | * @param directory directory for disk cache. 156 | * @param maxSizeInBytes limit for the disk cache size (in bytes). 157 | * @param maxCount limit for the number of entries that can be stored in the cache. 158 | */ 159 | public IgDiskCache(File directory, long maxSizeInBytes, int maxCount) { 160 | this(directory, maxSizeInBytes, maxCount, AsyncTask.SERIAL_EXECUTOR); 161 | } 162 | 163 | /** 164 | * Disk Cache initialization. 165 | * @param directory directory for disk cache. 166 | * @param maxSizeInBytes limit for the disk cache size (in bytes). 167 | * @param maxCount limit for the number of entries that can be stored in the cache. 168 | * @param serialExecutor Serial Executor for {@link Journal} logging. 169 | */ 170 | public IgDiskCache(File directory, long maxSizeInBytes, int maxCount, Executor serialExecutor) { 171 | assertOnNonUIThread(); 172 | mDirectory = (directory == null) ? FAKE_CACHE_DIRECTORY : directory; 173 | mMaxCount = maxCount; 174 | mMaxSizeInBytes = maxSizeInBytes; 175 | mRemoveRetryList = new LinkedList<>(); 176 | mSizeInBytes.set(0); 177 | mMissCount = 0; 178 | mHitCount = 0; 179 | mJournal = new Journal(mDirectory, this, serialExecutor); 180 | mLruEntries = new LinkedHashMap<>(0, 0.75f, true); 181 | LinkedHashMap cachedEntries = mJournal.retrieveEntriesFromJournal(); 182 | if (cachedEntries == null) { 183 | mDirectory.mkdirs(); //will try to recreate the directory the next time we edit. 184 | mJournal.rebuild(); 185 | } else { 186 | mLruEntries.putAll(cachedEntries); 187 | for (Entry entry : mLruEntries.values()) { 188 | mSizeInBytes.getAndAdd(entry.getLengthInBytes()); 189 | } 190 | } 191 | } 192 | 193 | /** 194 | * Check if a Entry with the given key exists in the disk cache. 195 | * @throws IllegalArgumentException if key is not valid. 196 | */ 197 | public boolean has(String key) { 198 | validateKey(key); 199 | Entry entry; 200 | synchronized (mDiskCacheLock) { 201 | entry = mLruEntries.get(key); 202 | } 203 | return entry != null && entry.isReadable() && entry.getCleanFile().exists(); 204 | } 205 | 206 | /** 207 | * Get the {@link SnapshotInputStream} of the Entry with the given key. If the Entry doesn't 208 | * exists or the file system is not accessible, an OptionalStream.absent() will be returned. 209 | * @throws IllegalArgumentException if key is not valid. 210 | */ 211 | public OptionalStream get(String key) { 212 | validateKey(key); 213 | Entry entry; 214 | synchronized (mDiskCacheLock) { 215 | entry = mLruEntries.get(key); 216 | } 217 | if (entry == null || !entry.isReadable()) { 218 | mMissCount++; 219 | return OptionalStream.absent(); 220 | } else { 221 | mHitCount++; 222 | try { 223 | return OptionalStream.of(new SnapshotInputStream(entry)); 224 | } catch (IOException e) { 225 | return OptionalStream.absent(); 226 | } 227 | } 228 | } 229 | 230 | /** 231 | * Get the {@link EditorOutputStream} of the Entry with the given key. If the Entry doesn't 232 | * exists or the file system is not accessible, an OptionalStream.absent() will be returned. 233 | * @throws IllegalArgumentException if key is not valid. 234 | * @throws IllegalStateException if require edit on an entry that is currently under edit. 235 | */ 236 | public OptionalStream edit(String key) { 237 | validateKey(key); 238 | if (mMaxSizeInBytes == 0 || mMaxCount == 0 || FAKE_CACHE_DIRECTORY.equals(mDirectory)) { 239 | return OptionalStream.absent(); 240 | } else { 241 | Entry entry; 242 | synchronized (mDiskCacheLock) { 243 | entry = mLruEntries.get(key); 244 | } 245 | if (entry == null) { 246 | entry = new Entry(mDirectory, key); 247 | synchronized (mDiskCacheLock) { 248 | mLruEntries.put(key, entry); 249 | } 250 | } else if (entry.getCurrentEditorStream() != null) { 251 | throw new IllegalStateException( 252 | "Trying to edit a disk cache entry while another edit is in progress."); 253 | } 254 | mJournal.logDirtyFileUpdate(key); 255 | return getOutputStream(entry); 256 | } 257 | } 258 | 259 | private synchronized OptionalStream getOutputStream(Entry entry) { 260 | if (entry.getCurrentEditorStream() != null) { 261 | throw new IllegalStateException( 262 | "Trying to edit a disk cache entry while another edit is in progress."); 263 | } 264 | EditorOutputStream outputStream; 265 | try { 266 | outputStream = new EditorOutputStream(entry, this); 267 | } catch (FileNotFoundException e) { 268 | // Attempt to recreate the cache directory, no need to handle the mkdirs return result. 269 | mDirectory.mkdirs(); 270 | try { 271 | outputStream = new EditorOutputStream(entry, this); 272 | } catch (FileNotFoundException e2) { 273 | return OptionalStream.absent(); 274 | } 275 | } 276 | entry.setCurrentEditorStream(outputStream); 277 | return OptionalStream.of(outputStream); 278 | } 279 | 280 | /** 281 | * Remove the Entry with the given key from cache. 282 | * If the Entry is still under edit, EditorOutputStream need to be committed/aborted before 283 | * removing. 284 | * @throws IllegalArgumentException if key is not valid. 285 | */ 286 | public void remove(String key) throws IllegalStateException { 287 | validateKey(key); 288 | Entry entry; 289 | synchronized (mDiskCacheLock) { 290 | entry = mLruEntries.remove(key); 291 | } 292 | if (entry != null) { 293 | if (entry.getCurrentEditorStream() != null) { 294 | throw new IllegalStateException( 295 | "trying to remove a disk cache entry that is still under edit."); 296 | } 297 | File file = entry.getCleanFile(); 298 | if (!file.exists() || file.delete()) { 299 | mSizeInBytes.getAndAdd(-entry.getLengthInBytes()); 300 | } else { 301 | synchronized (mRemoveRetryLock) { 302 | mRemoveRetryList.add(entry); 303 | } 304 | } 305 | } 306 | } 307 | 308 | /** 309 | * Instantly trim the cache to size and count, and rebuild the cache journal if trim happens. 310 | */ 311 | public void flush() { 312 | trimToSizeAndCount(); 313 | mJournal.rebuildIfNeeded(); 314 | } 315 | 316 | /** 317 | * Close IgDiskCache and make sure the journal is updated on close. This could only be called from 318 | * non-UI thread. 319 | */ 320 | public void close() { 321 | assertOnNonUIThread(); 322 | trimToSizeAndCount(); 323 | mJournal.rebuild(); 324 | } 325 | 326 | /** 327 | * Get the directory of the current IgDiskCache, return null if it's a stub cache instance. 328 | */ 329 | public File getDirectory() { 330 | return FAKE_CACHE_DIRECTORY.equals(mDirectory) ? null : mDirectory; 331 | } 332 | 333 | /** 334 | * Get the limit for the disk cache size (in bytes). 335 | */ 336 | public long getMaxSizeInBytes() { 337 | return mMaxSizeInBytes; 338 | } 339 | 340 | /** 341 | * Get the limit for the number of entries that can be stored in the cache. 342 | */ 343 | public int getMaxCount() { 344 | return mMaxCount; 345 | } 346 | 347 | /** 348 | * Set the limit for the disk cache size (in bytes). 349 | */ 350 | public void setMaxSizeInBytes(long maxSizeInBytes) { 351 | mMaxSizeInBytes = maxSizeInBytes; 352 | DISK_CACHE_EXECUTOR.execute(mTrimRunnable); 353 | } 354 | 355 | /** 356 | * Get disk cache's current size in bytes. 357 | */ 358 | public long size() { 359 | return mSizeInBytes.get(); 360 | } 361 | 362 | /** 363 | * Get disk cache's entry count. 364 | */ 365 | public int count() { 366 | synchronized (mDiskCacheLock) { 367 | return mLruEntries.size(); 368 | } 369 | } 370 | 371 | /** 372 | * Get the disk cache's hit rate in the from of a String: 373 | * IgDiskCache[mMaxSizeInBytes=...,hits=...,misses=...,hitRate=...%] 374 | */ 375 | public final String getHitRateString() { 376 | int accesses = mHitCount + mMissCount; 377 | int hitPercent = accesses != 0 ? (100 * mHitCount / accesses) : 0; 378 | return String.format( 379 | Locale.US, 380 | "IgDiskCache[mMaxSizeInBytes=%d,hits=%d,misses=%d,hitRate=%d%%]", 381 | mMaxSizeInBytes, 382 | mHitCount, 383 | mMissCount, 384 | hitPercent); 385 | } 386 | 387 | private void removeFilesForRemoveRetryList() { 388 | synchronized (mRemoveRetryLock) { 389 | Iterator iterator = mRemoveRetryList.listIterator(); 390 | while (iterator.hasNext()) { 391 | Entry entry = iterator.next(); 392 | if (entry != null) { 393 | File file = entry.getCleanFile(); 394 | if (file.exists() && file.delete()) { 395 | mSizeInBytes.getAndAdd(-entry.getLengthInBytes()); 396 | iterator.remove(); 397 | } 398 | } 399 | } 400 | } 401 | } 402 | 403 | private void trimToSizeAndCount() { 404 | removeFilesForRemoveRetryList(); 405 | synchronized (mDiskCacheLock) { 406 | while (mSizeInBytes.get() > mMaxSizeInBytes || mLruEntries.size() > mMaxCount) { 407 | try { 408 | Map.Entry toEvict = mLruEntries.entrySet().iterator().next(); 409 | remove(toEvict.getKey()); 410 | } catch (IllegalStateException | NoSuchElementException ignored) { 411 | // If the Entry is still under edit or the set is empty, 412 | // keep the Entry without throwing out any Exceptions. 413 | } 414 | } 415 | } 416 | } 417 | 418 | private static void validateKey(String key) { 419 | Matcher matcher = LEGAL_KEY_PATTERN.matcher(key); 420 | if (!matcher.matches()) { 421 | throw new IllegalArgumentException( 422 | "keys must match regex " + STRING_KEY_PATTERN + ": \"" + key + "\""); 423 | } 424 | } 425 | 426 | /* package */ void commitEdit(Entry entry) { 427 | File dirty = entry.getDirtyFile(); 428 | if (!dirty.exists()) { 429 | entry.setCurrentEditorStream(null); 430 | updateEntry(entry); 431 | } else { 432 | File clean = entry.getCleanFile(); 433 | if (dirty.renameTo(clean)) { 434 | long oldLength = entry.getLengthInBytes(); 435 | long newLength = clean.length(); 436 | entry.markAsPublished(newLength); 437 | mSizeInBytes.getAndAdd(newLength - oldLength); 438 | updateEntry(entry); 439 | } else { 440 | abortEdit(entry); 441 | remove(entry.getKey()); 442 | } 443 | } 444 | } 445 | 446 | /* package */ void abortEdit(Entry entry) { 447 | File dirty = entry.getDirtyFile(); 448 | if (dirty.exists()) { 449 | dirty.delete(); // No need to handle the fail case. Ignore the return. 450 | } 451 | entry.setCurrentEditorStream(null); 452 | updateEntry(entry); 453 | } 454 | 455 | private void updateEntry(Entry entry) { 456 | if (entry.isReadable()) { 457 | mJournal.logCleanFileUpdate(entry.getKey(), entry.getLengthInBytes()); 458 | } else { 459 | synchronized (mDiskCacheLock) { 460 | mLruEntries.remove(entry.getKey()); 461 | } 462 | } 463 | if (mSizeInBytes.get() > mMaxSizeInBytes || count() > mMaxCount) { 464 | DISK_CACHE_EXECUTOR.execute(mTrimRunnable); 465 | } 466 | } 467 | 468 | /* package */ ArrayList getEntryCollection() { 469 | synchronized (mDiskCacheLock) { 470 | return new ArrayList<>(mLruEntries.values()); 471 | } 472 | } 473 | 474 | private static void assertOnNonUIThread() throws IllegalStateException { 475 | if (Looper.getMainLooper().getThread() == Thread.currentThread()) { 476 | throw new IllegalStateException("This operation can't be run on UI thread."); 477 | } 478 | } 479 | } 480 | 481 | -------------------------------------------------------------------------------- /igdiskcache/src/main/java/com/instagram/igdiskcache/Journal.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | 10 | package com.instagram.igdiskcache; 11 | 12 | import android.annotation.SuppressLint; 13 | 14 | import java.io.BufferedReader; 15 | import java.io.BufferedWriter; 16 | import java.io.Closeable; 17 | import java.io.File; 18 | import java.io.FileOutputStream; 19 | import java.io.FileReader; 20 | import java.io.IOException; 21 | import java.io.OutputStreamWriter; 22 | import java.io.Writer; 23 | import java.nio.charset.Charset; 24 | import java.util.ArrayList; 25 | import java.util.HashMap; 26 | import java.util.HashSet; 27 | import java.util.LinkedHashMap; 28 | import java.util.Set; 29 | import java.util.concurrent.Executor; 30 | 31 | /** 32 | * This cache uses a journal file named "journal" to record the state of a cache entry on disk. 33 | * A typical journal file looks like this: 34 | * 35 | *

 36 |  *    CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832
 37 |  *    DIRTY 335c4c6028171cfddfbaae1a9c313c52
 38 |  *    CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934
 39 |  *    DIRTY 3400330d1dfc7f3f7f4b8d4d803dfcf6
 40 |  * 
41 | * 42 | *

Each line contains space-separated values: a state, a key, and a optional state-specific 43 | * value representing the length of the entry data in bytes. 44 | * 45 | *

  • 46 | * o DIRTY lines track that an entry is actively being created or updated. Every successful 47 | * DIRTY action should be followed by a CLEAN action. DIRTY lines without a matching CLEAN 48 | * indicate that temporary files may need to be deleted next time the cache got opened.
  • 49 | *
  • 50 | * o CLEAN lines track a cache entry that has been successfully published, Entry key is followed 51 | * by the lengths of the Entry data in bytes.
  • 52 | *
53 | * 54 | *

The journal file is appended to as cache operations occur. The journal may occasionally be 55 | * compacted when the number of lines inside the journal exceeds the rebuild threshold. 56 | * A temporary file named "journal.tmp" will be used during compaction; that file will be deleted 57 | * if it exists when the cache is re-opened. 58 | */ 59 | 60 | /* package */ class Journal { 61 | 62 | static final String JOURNAL_FILE = "journal"; 63 | static final String JOURNAL_FILE_TEMP = "journal.tmp"; 64 | static final String JOURNAL_FILE_BACKUP = "journal.bkp"; 65 | static final Charset US_ASCII = Charset.forName("US-ASCII"); 66 | 67 | private static final String TAG = Journal.class.getSimpleName(); 68 | private static final String CLEAN_ENTRY_PREFIX = "CLEAN"; 69 | private static final String DIRTY_ENTRY_PREFIX = "DIRTY"; 70 | private static final int JOURNAL_REBUILD_THRESHOLD = 1000; 71 | 72 | private final File mDirectory; 73 | private final File mJournalFile; 74 | private final File mJournalFileTmp; 75 | private final File mJournalFileBackup; 76 | private final IgDiskCache mCache; 77 | private final Executor mExecutor; 78 | 79 | private Writer mJournalWriter; 80 | private int mLineCount; 81 | 82 | @SuppressLint("EmptyCatchBlock") 83 | class WriteToJournalRunnable implements Runnable { 84 | final String mLine; 85 | public WriteToJournalRunnable(String line) { 86 | this.mLine = line; 87 | } 88 | @Override 89 | public void run() { 90 | try { 91 | if (mJournalWriter != null) { 92 | mJournalWriter.write(mLine); 93 | mJournalWriter.flush(); 94 | mLineCount++; 95 | rebuildIfNeeded(); 96 | } 97 | } catch (IOException ignored) { 98 | } 99 | } 100 | } 101 | 102 | /* package */ Journal(File directory, IgDiskCache cache, Executor executor) { 103 | mJournalFile = new File(directory, JOURNAL_FILE); 104 | mJournalFileTmp = new File(directory, JOURNAL_FILE_TEMP); 105 | mJournalFileBackup = new File(directory, JOURNAL_FILE_BACKUP); 106 | mDirectory = directory; 107 | mCache = cache; 108 | mExecutor = executor; 109 | mLineCount = 0; 110 | } 111 | 112 | @SuppressLint("EmptyCatchBlock") 113 | /* package */ LinkedHashMap retrieveEntriesFromJournal() { 114 | maybeSwitchToBackupJournalFile(mDirectory); 115 | File journalFile = new File(mDirectory, JOURNAL_FILE); 116 | if (journalFile.exists()) { 117 | LinkedHashMap lruEntries = new LinkedHashMap<>(); 118 | BufferedReader reader = null; 119 | try { 120 | reader = new BufferedReader(new FileReader(journalFile)); 121 | boolean journalIsCorrupted = false; 122 | Set dirtyEntryKeySet = new HashSet<>(); 123 | String line; 124 | while ((line = reader.readLine()) != null) { 125 | String[] lineParts = line.split(" "); 126 | String state = lineParts[0]; 127 | String key = lineParts[1]; 128 | if (CLEAN_ENTRY_PREFIX.equals(state) && lineParts.length == 3) { 129 | Entry entry = lruEntries.get(key); 130 | if (entry == null) { 131 | entry = new Entry(mDirectory, key); 132 | lruEntries.put(key, entry); 133 | } 134 | entry.markAsPublished(Long.parseLong(lineParts[2])); 135 | dirtyEntryKeySet.remove(key); 136 | } else if (DIRTY_ENTRY_PREFIX.equals(state) && lineParts.length == 2) { 137 | dirtyEntryKeySet.add(key); 138 | } else { 139 | journalIsCorrupted = true; 140 | break; 141 | } 142 | mLineCount++; 143 | } 144 | if (!journalIsCorrupted) { 145 | for (String key : dirtyEntryKeySet) { 146 | Entry entry = lruEntries.get(key); 147 | if (entry != null) { 148 | deleteFileIfExists(entry.getCleanFile()); 149 | deleteFileIfExists(entry.getDirtyFile()); 150 | } 151 | lruEntries.remove(key); 152 | } 153 | createJournalWriter(); 154 | return lruEntries; 155 | } 156 | } catch (IOException | IndexOutOfBoundsException | NumberFormatException ignored) { 157 | // Journal is corrupted or IOException occurs while reading the journal. 158 | } finally { 159 | closeQuietly(reader); 160 | } 161 | } 162 | deleteUntrackedFiles(mDirectory); 163 | return null; 164 | } 165 | 166 | private static void maybeSwitchToBackupJournalFile(File directory) { 167 | File backupFile = new File(directory, JOURNAL_FILE_BACKUP); 168 | if (backupFile.exists()) { 169 | File journalFile = new File(directory, JOURNAL_FILE); 170 | // If journal file also exists just delete backup file. 171 | if (journalFile.exists()) { 172 | backupFile.delete(); //no need to handle the delete fail case, ignore the return value. 173 | } else { 174 | backupFile.renameTo(journalFile); 175 | } 176 | } 177 | } 178 | 179 | private void createJournalWriter() { 180 | try { 181 | mJournalWriter = new BufferedWriter( 182 | new OutputStreamWriter(new FileOutputStream(mJournalFile, true), US_ASCII)); 183 | } catch (IOException e) { 184 | closeQuietly(mJournalWriter); 185 | mJournalWriter = null; 186 | } 187 | } 188 | 189 | /** 190 | * Creates a new journal that omits redundant journal entries. This replaces the current journal 191 | * if it exists. 192 | */ 193 | @SuppressLint("EmptyCatchBlock") 194 | /* package */ void rebuild() { 195 | if (mJournalWriter != null) { 196 | closeQuietly(mJournalWriter); 197 | } 198 | Writer writer = null; 199 | try { 200 | ArrayList entries = mCache.getEntryCollection(); 201 | mLineCount = entries.size(); 202 | writer = new BufferedWriter( 203 | new OutputStreamWriter(new FileOutputStream(mJournalFileTmp), US_ASCII)); 204 | 205 | for (Entry entry : entries) { 206 | if (entry.isReadable()) { 207 | writer.write(CLEAN_ENTRY_PREFIX + ' ' + entry.getKey() + ' ' + 208 | String.valueOf(entry.getLengthInBytes()) + '\n'); 209 | } else { 210 | writer.write(DIRTY_ENTRY_PREFIX + ' ' + entry.getKey() + '\n'); 211 | } 212 | } 213 | writer.flush(); 214 | if (mJournalFile.exists()) { 215 | mJournalFile.renameTo(mJournalFileBackup); 216 | } 217 | mJournalFileTmp.renameTo(mJournalFile); 218 | createJournalWriter(); 219 | mJournalFileBackup.delete(); 220 | } catch (IOException ignored) { 221 | } finally { 222 | closeQuietly(writer); 223 | } 224 | } 225 | 226 | /* package */ void logDirtyFileUpdate(String key) { 227 | mExecutor.execute(new WriteToJournalRunnable(DIRTY_ENTRY_PREFIX + ' ' + key + '\n')); 228 | } 229 | 230 | /* package */ void logCleanFileUpdate(String key, long length) { 231 | mExecutor.execute( 232 | new WriteToJournalRunnable( 233 | CLEAN_ENTRY_PREFIX + ' ' + key + ' ' + String.valueOf(length) + '\n')); 234 | } 235 | 236 | /* package */ void rebuildIfNeeded() { 237 | if (mLineCount > JOURNAL_REBUILD_THRESHOLD) { 238 | mExecutor.execute( 239 | new Runnable() { 240 | @Override 241 | public void run() { 242 | if (mLineCount > JOURNAL_REBUILD_THRESHOLD) { 243 | rebuild(); 244 | } 245 | } 246 | }); 247 | } 248 | } 249 | 250 | static void closeQuietly(Closeable closeable) { 251 | if (closeable != null) { 252 | try { 253 | closeable.close(); 254 | } catch (IOException e) { 255 | // Handle the IOException quietly. 256 | } 257 | } 258 | } 259 | 260 | private static void deleteUntrackedFiles(File dir) { 261 | if (dir != null && dir.exists()) { 262 | File[] files = dir.listFiles(); 263 | if (files != null) { 264 | for (File file : files) { 265 | String name = file.getName(); 266 | if (name.endsWith(Entry.CLEAN_FILE_EXTENSION) || 267 | name.endsWith(Entry.DIRTY_FILE_EXTENSION)) { 268 | deleteFileIfExists(file); 269 | } 270 | } 271 | } 272 | } 273 | } 274 | 275 | private static void deleteFileIfExists(File file) { 276 | if (file.exists()) { 277 | file.delete(); 278 | } 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /igdiskcache/src/main/java/com/instagram/igdiskcache/OptionalStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | 10 | package com.instagram.igdiskcache; 11 | 12 | public class OptionalStream { 13 | private T mFileStream = null; 14 | 15 | private OptionalStream() { 16 | } 17 | 18 | private OptionalStream(T fileStream) { 19 | mFileStream = fileStream; 20 | } 21 | 22 | /** 23 | * Check if the {@link T} object is available inside the OptionalStream wrapper. 24 | */ 25 | public boolean isPresent() { 26 | return mFileStream != null; 27 | } 28 | 29 | /** 30 | * Get the {@link T} object from the OptionalStream wrapper. 31 | * Should call {@link OptionalStream#isPresent()} first to make sure the {@link T} object is 32 | * available. 33 | */ 34 | public T get() { 35 | if (isPresent()) { 36 | return mFileStream; 37 | } else { 38 | throw new IllegalStateException("OptionalStream.get() cannot be called on an absent value"); 39 | } 40 | } 41 | 42 | /** 43 | * The stub {@link OptionalStream} object. 44 | */ 45 | public static OptionalStream absent() { 46 | return new OptionalStream<>(); 47 | } 48 | 49 | /** 50 | * Create a {@link OptionalStream} wrapper using the {@link T} object. 51 | */ 52 | public static OptionalStream of(T fileStream) { 53 | return new OptionalStream<>(fileStream); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /igdiskcache/src/main/java/com/instagram/igdiskcache/SnapshotInputStream.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | 10 | package com.instagram.igdiskcache; 11 | 12 | import java.io.FileInputStream; 13 | import java.io.FileNotFoundException; 14 | 15 | /** 16 | * InputStream used for reading data out of the disk cache Entry. 17 | * All SnapshotInputStream need to {@link #close()} after use to prevent resource leak. 18 | */ 19 | public final class SnapshotInputStream extends FileInputStream { 20 | private final long mLengthInBytes; 21 | private final String mPath; 22 | 23 | /* package */ SnapshotInputStream(Entry entry) 24 | throws FileNotFoundException { 25 | super(entry.getCleanFile()); 26 | mLengthInBytes = entry.getLengthInBytes(); 27 | mPath = entry.getCleanFile().getAbsolutePath(); 28 | } 29 | 30 | /** 31 | * Get the disk cache entry's length (in bytes). 32 | */ 33 | public long getLengthInBytes() { 34 | return mLengthInBytes; 35 | } 36 | 37 | /** 38 | * Get file absolute path 39 | */ 40 | public String getPath() { 41 | return mPath; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /igdiskcache/src/test/java/com/instagram/igdiskcache/IgDiskCacheTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.instagram.igdiskcache; 10 | 11 | import java.io.File; 12 | import java.io.FileReader; 13 | import java.io.FileWriter; 14 | import java.io.IOException; 15 | import java.io.InputStreamReader; 16 | import java.io.OutputStreamWriter; 17 | import java.io.Reader; 18 | import java.io.StringWriter; 19 | import java.io.Writer; 20 | import java.nio.charset.Charset; 21 | import java.util.concurrent.Callable; 22 | import java.util.concurrent.CountDownLatch; 23 | import java.util.concurrent.Future; 24 | import java.util.concurrent.LinkedBlockingDeque; 25 | import java.util.concurrent.ThreadPoolExecutor; 26 | import java.util.concurrent.TimeUnit; 27 | 28 | import android.annotation.SuppressLint; 29 | import android.os.Looper; 30 | 31 | import org.junit.Before; 32 | import org.junit.Test; 33 | import org.junit.Rule; 34 | import org.junit.rules.TemporaryFolder; 35 | import org.powermock.core.classloader.annotations.PrepareForTest; 36 | 37 | import static com.instagram.igdiskcache.Journal.JOURNAL_FILE; 38 | import static org.fest.assertions.api.Assertions.assertThat; 39 | import static org.junit.Assert.fail; 40 | import static org.powermock.api.mockito.PowerMockito.mock; 41 | import static org.powermock.api.mockito.PowerMockito.spy; 42 | import static org.powermock.api.mockito.PowerMockito.when; 43 | 44 | @PrepareForTest({Looper.class}) 45 | public final class IgDiskCacheTest extends RobolectricBaseTest { 46 | private static final Charset US_ASCII = Charset.forName("US-ASCII"); 47 | private File mCacheDir; 48 | private File mJournalFile; 49 | private IgDiskCache mCache; 50 | 51 | @Rule 52 | public TemporaryFolder tempDir = new TemporaryFolder(); 53 | 54 | @Before 55 | public void setUp() throws Exception { 56 | mCacheDir = tempDir.newFolder("IgDiskCacheTest"); 57 | mJournalFile = new File(mCacheDir, JOURNAL_FILE); 58 | for (File file : mCacheDir.listFiles()) { 59 | file.delete(); 60 | } 61 | Looper looper = mock(Looper.class); 62 | when(looper.getThread()).thenReturn(mock(Thread.class)); 63 | spy(Looper.class); 64 | when(Looper.getMainLooper()).thenReturn(looper); 65 | mCache = new IgDiskCache(mCacheDir, Integer.MAX_VALUE); 66 | } 67 | 68 | @SuppressLint("DeadVariable") 69 | @Test 70 | public void validateKey() throws Exception { 71 | String key = null; 72 | try { 73 | key = "has_space "; 74 | mCache.edit(key); 75 | fail("Exepcting an IllegalArgumentException as the key was invalid."); 76 | } catch (IllegalArgumentException iae) { 77 | assertThat(iae.getMessage()).isEqualTo( 78 | "keys must match regex [a-z0-9_-]{1,120}: \"" + key + "\""); 79 | } 80 | try { 81 | key = "has_CR\r"; 82 | mCache.edit(key); 83 | fail("Exepcting an IllegalArgumentException as the key was invalid."); 84 | } catch (IllegalArgumentException iae) { 85 | assertThat(iae.getMessage()).isEqualTo( 86 | "keys must match regex [a-z0-9_-]{1,120}: \"" + key + "\""); 87 | } 88 | try { 89 | key = "has_LF\n"; 90 | mCache.edit(key); 91 | fail("Exepcting an IllegalArgumentException as the key was invalid."); 92 | } catch (IllegalArgumentException iae) { 93 | assertThat(iae.getMessage()).isEqualTo( 94 | "keys must match regex [a-z0-9_-]{1,120}: \"" + key + "\""); 95 | } 96 | try { 97 | key = "has_invalid/"; 98 | mCache.edit(key); 99 | fail("Exepcting an IllegalArgumentException as the key was invalid."); 100 | } catch (IllegalArgumentException iae) { 101 | assertThat(iae.getMessage()).isEqualTo( 102 | "keys must match regex [a-z0-9_-]{1,120}: \"" + key + "\""); 103 | } 104 | try { 105 | key = "has_invalid\u2603"; 106 | mCache.edit(key); 107 | fail("Exepcting an IllegalArgumentException as the key was invalid."); 108 | } catch (IllegalArgumentException iae) { 109 | assertThat(iae.getMessage()).isEqualTo( 110 | "keys must match regex [a-z0-9_-]{1,120}: \"" + key + "\""); 111 | } 112 | try { 113 | key = "this_is_way_too_long_this_is_way_too_long_this_is_way_too_long_" 114 | + "this_is_way_too_long_this_is_way_too_long_this_is_way_too_long"; 115 | mCache.edit(key); 116 | fail("Exepcting an IllegalArgumentException as the key was too long."); 117 | } catch (IllegalArgumentException iae) { 118 | assertThat(iae.getMessage()).isEqualTo( 119 | "keys must match regex [a-z0-9_-]{1,120}: \"" + key + "\""); 120 | } 121 | 122 | // Exactly 120. 123 | key = "0123456789012345678901234567890123456789012345678901234567890123456789" 124 | + "01234567890123456789012345678901234567890123456789"; 125 | abortOptionalOutputStream(mCache.edit(key)); 126 | // Contains all valid characters. 127 | key = "abcdefghijklmnopqrstuvwxyz_0123456789"; 128 | abortOptionalOutputStream(mCache.edit(key)); 129 | // Contains dash. 130 | key = "-20384573948576"; 131 | abortOptionalOutputStream(mCache.edit(key)); 132 | } 133 | 134 | private static void abortOptionalOutputStream(OptionalStream optional) { 135 | if (optional.isPresent()) { 136 | optional.get().abort(); 137 | } 138 | } 139 | 140 | @Test 141 | public void writeAndReadEntry() throws Exception { 142 | set(mCache, "k1", "ABC"); 143 | assertValue(mCache, "k1", "ABC"); 144 | } 145 | 146 | @Test 147 | public void readAndWriteEntryAfterCacheReOpen() throws Exception { 148 | set(mCache, "k1", "A"); 149 | IgDiskCache cache2 = new IgDiskCache(mCacheDir, Integer.MAX_VALUE); 150 | assertValue(cache2, "k1", "A"); 151 | } 152 | 153 | @Test 154 | public void cannotOperateOnEditAfterCommit() throws Exception { 155 | OptionalStream out1 = mCache.edit("k1"); 156 | assertThat(out1.isPresent()); 157 | writeToOutputStream(out1.get(), "AB"); 158 | out1.get().commit(); 159 | try { 160 | writeToOutputStream(out1.get(), "CDE"); 161 | out1.get().abort(); 162 | fail(); 163 | } catch (IllegalStateException e) { 164 | assertThat( 165 | e.getMessage().equals("Try to operate on an EditorOutputStream that is already closed")); 166 | } 167 | try { 168 | writeToOutputStream(out1.get(), "CDE"); 169 | out1.get().commit(); 170 | fail(); 171 | } catch (IllegalStateException e) { 172 | assertThat( 173 | e.getMessage().equals("Try to operate on an EditorOutputStream that is already closed")); 174 | } 175 | } 176 | 177 | @Test 178 | public void cannotOperateOnEditAfterAbort() throws Exception { 179 | OptionalStream out1 = mCache.edit("k1"); 180 | assertThat(out1.isPresent()); 181 | writeToOutputStream(out1.get(), "AB"); 182 | out1.get().abort(); 183 | try { 184 | writeToOutputStream(out1.get(), "CDE"); 185 | out1.get().abort(); 186 | fail(); 187 | } catch (IllegalStateException e) { 188 | assertThat( 189 | e.getMessage().equals("Try to operate on an EditorOutputStream that is already closed")); 190 | } 191 | try { 192 | writeToOutputStream(out1.get(), "CDE"); 193 | out1.get().commit(); 194 | fail(); 195 | } catch (IllegalStateException e) { 196 | assertThat( 197 | e.getMessage().equals("Try to operate on an EditorOutputStream that is already closed")); 198 | } 199 | } 200 | 201 | @Test 202 | public void explicitRemoveAppliedToDiskImmediately() throws Exception { 203 | set(mCache, "k1", "ABC"); 204 | File k1 = getCleanFile("k1"); 205 | assertThat(readFile(k1)).isEqualTo("ABC"); 206 | mCache.remove("k1"); 207 | assertThat(k1.exists()).isFalse(); 208 | } 209 | 210 | @Test 211 | public void readAndWriteOverlapsMaintainConsistency() throws Exception { 212 | set(mCache, "k1", "AAaa"); 213 | 214 | OptionalStream in1 = mCache.get("k1"); 215 | assertThat(in1.isPresent()); 216 | assertThat(in1.get().read()).isEqualTo('A'); 217 | assertThat(in1.get().read()).isEqualTo('A'); 218 | 219 | set(mCache, "k1", "CCcc"); 220 | OptionalStream in2 = mCache.get("k1"); 221 | assertThat(in2.isPresent()); 222 | assertThat(readFromInputStream(in2.get())).isEqualTo("CCcc"); 223 | assertThat(in2.get().getLengthInBytes()).isEqualTo(4); 224 | in2.get().close(); 225 | 226 | assertThat(in1.get().read()).isEqualTo('a'); 227 | assertThat(in1.get().read()).isEqualTo('a'); 228 | assertThat(in1.get().getLengthInBytes()).isEqualTo(4); 229 | in1.get().close(); 230 | } 231 | 232 | @Test 233 | public void openWithDirtyKeyDeletesAllFilesForThatKey() throws Exception { 234 | File cleanFile = getCleanFile("k1"); 235 | File dirtyFile = getDirtyFile("k1"); 236 | writeFile(cleanFile, "A"); 237 | writeFile(dirtyFile, "D"); 238 | createJournal("CLEAN k1 1", "DIRTY k1"); 239 | mCache = new IgDiskCache(mCacheDir, Integer.MAX_VALUE); 240 | 241 | assertThat(cleanFile.exists()).isFalse(); 242 | assertThat(dirtyFile.exists()).isFalse(); 243 | assertThat(mCache.get("k1").isPresent()).isFalse(); 244 | } 245 | 246 | @Test 247 | public void openWithInvalidJournalLineClearsDirectory() throws Exception { 248 | generateSomeGarbageFiles(); 249 | createJournal("CLEAN k1 1 1", "BOGUS"); 250 | mCache = new IgDiskCache(mCacheDir, Integer.MAX_VALUE); 251 | assertGarbageFilesAllDeleted(); 252 | assertThat(mCache.get("k1").isPresent()).isFalse(); 253 | } 254 | 255 | @Test 256 | public void openWithInvalidFileSizeClearsDirectory() throws Exception { 257 | generateSomeGarbageFiles(); 258 | createJournal("CLEAN k1 0000x001"); 259 | mCache = new IgDiskCache(mCacheDir, Integer.MAX_VALUE); 260 | assertGarbageFilesAllDeleted(); 261 | assertThat(mCache.get("k1").isPresent()).isFalse(); 262 | } 263 | 264 | @Test 265 | public void openWithTooManyFileSizesClearsDirectory() throws Exception { 266 | generateSomeGarbageFiles(); 267 | createJournal("CLEAN k1 1 1 1"); 268 | mCache = new IgDiskCache(mCacheDir, Integer.MAX_VALUE); 269 | assertGarbageFilesAllDeleted(); 270 | assertThat(mCache.get("k1").isPresent()).isFalse(); 271 | } 272 | 273 | @SuppressLint("EmptyCatchBlock") 274 | @Test 275 | public void keyWithSpaceNotPermitted() throws Exception { 276 | try { 277 | mCache.edit("my key"); 278 | fail(); 279 | } catch (IllegalArgumentException expected) { 280 | } 281 | } 282 | 283 | @SuppressLint("EmptyCatchBlock") 284 | @Test 285 | public void keyWithNewlineNotPermitted() throws Exception { 286 | try { 287 | mCache.edit("my\nkey"); 288 | fail(); 289 | } catch (IllegalArgumentException expected) { 290 | } 291 | } 292 | 293 | @SuppressLint("EmptyCatchBlock") 294 | @Test 295 | public void keyWithCarriageReturnNotPermitted() throws Exception { 296 | try { 297 | mCache.edit("my\rkey"); 298 | fail(); 299 | } catch (IllegalArgumentException expected) { 300 | } 301 | } 302 | 303 | @SuppressLint("EmptyCatchBlock") 304 | @Test 305 | public void nullKeyThrows() throws Exception { 306 | try { 307 | mCache.edit(null); 308 | fail(); 309 | } catch (NullPointerException expected) { 310 | } 311 | } 312 | 313 | @Test 314 | public void growMaxSize() throws Exception { 315 | mCache = new IgDiskCache(mCacheDir, 7); 316 | set(mCache, "a", "aaa"); // size 3 317 | set(mCache, "b", "bbbb"); // size 4 318 | mCache.setMaxSizeInBytes(20); 319 | set(mCache, "c", "c"); // size 8 320 | assertThat(mCache.size()).isEqualTo(8); 321 | } 322 | 323 | @Test 324 | public void evictOnInsert() throws Exception { 325 | mCache = new IgDiskCache(mCacheDir, 7); 326 | 327 | set(mCache, "a", "aaa"); // size 3 328 | set(mCache, "b", "bbbb"); // size 4 329 | assertThat(mCache.size()).isEqualTo(7); 330 | 331 | // Cause the size to grow to 8 should evict 'A'. 332 | set(mCache, "c", "c"); 333 | mCache.flush(); 334 | assertThat(mCache.size()).isEqualTo(5); 335 | assertAbsent(mCache, "a"); 336 | assertValue(mCache, "b", "bbbb"); 337 | assertValue(mCache, "c", "c"); 338 | 339 | // Causing the size to grow to 6 should evict nothing. 340 | set(mCache, "d", "d"); 341 | mCache.flush(); 342 | assertThat(mCache.size()).isEqualTo(6); 343 | assertAbsent(mCache, "a"); 344 | assertValue(mCache, "b", "bbbb"); 345 | assertValue(mCache, "c", "c"); 346 | assertValue(mCache, "d", "d"); 347 | 348 | // Causing the size to grow to 12 should evict 'B' and 'C'. 349 | set(mCache, "e", "eeeeee"); 350 | mCache.flush(); 351 | assertThat(mCache.size()).isEqualTo(7); 352 | assertAbsent(mCache, "a"); 353 | assertAbsent(mCache, "b"); 354 | assertAbsent(mCache, "c"); 355 | assertValue(mCache, "d", "d"); 356 | assertValue(mCache, "e", "eeeeee"); 357 | } 358 | 359 | @Test 360 | public void evictOnUpdate() throws Exception { 361 | mCache = new IgDiskCache(mCacheDir, 7); 362 | 363 | set(mCache, "a", "aa"); // size 2 364 | set(mCache, "b", "bb"); // size 2 365 | set(mCache, "c", "cc"); // size 2 366 | assertThat(mCache.size()).isEqualTo(6); 367 | 368 | // Causing the size to grow to 8 should evict 'a'. 369 | set(mCache, "b", "bbbb"); 370 | mCache.flush(); 371 | assertThat(mCache.size()).isEqualTo(6); 372 | assertAbsent(mCache, "a"); 373 | assertValue(mCache, "b", "bbbb"); 374 | assertValue(mCache, "c", "cc"); 375 | } 376 | 377 | @Test 378 | public void evictionHonorsLruFromCurrentSession() throws Exception { 379 | mCache = new IgDiskCache(mCacheDir, 5); 380 | set(mCache, "a", "a"); 381 | set(mCache, "b", "b"); 382 | set(mCache, "c", "c"); 383 | set(mCache, "d", "d"); 384 | set(mCache, "e", "e"); 385 | mCache.get("b").get().close(); // 'B' is now least recently used. 386 | 387 | // Causing the size to grow to 6 should evict 'A'. 388 | set(mCache, "f", "f"); 389 | // Causing the size to grow to 6 should evict 'C'. 390 | set(mCache, "g", "g"); 391 | mCache.flush(); 392 | assertThat(mCache.size()).isEqualTo(5); 393 | assertValue(mCache, "b", "b"); 394 | assertValue(mCache, "d", "d"); 395 | assertValue(mCache, "e", "e"); 396 | assertValue(mCache, "f", "f"); 397 | assertAbsent(mCache, "a"); 398 | assertAbsent(mCache, "c"); 399 | } 400 | 401 | @Test 402 | public void evictionHonorsLruFromPreviousSession() throws Exception { 403 | set(mCache, "a", "a"); 404 | set(mCache, "b", "b"); 405 | set(mCache, "c", "c"); 406 | set(mCache, "d", "d"); 407 | set(mCache, "e", "e"); 408 | set(mCache, "f", "f"); 409 | mCache.get("b").get().close(); // 'B' is now least recently used. 410 | assertThat(mCache.size()).isEqualTo(6); 411 | mCache.close(); 412 | mCache = new IgDiskCache(mCacheDir, 5); 413 | set(mCache, "g", "g"); 414 | set(mCache, "h", "h"); 415 | mCache.flush(); 416 | assertThat(mCache.size()).isEqualTo(5); 417 | assertAbsent(mCache, "a"); 418 | assertValue(mCache, "b", "b"); 419 | assertAbsent(mCache, "c"); 420 | assertValue(mCache, "d", "d"); 421 | assertValue(mCache, "e", "e"); 422 | assertValue(mCache, "f", "f"); 423 | assertValue(mCache, "g", "g"); 424 | } 425 | 426 | @Test 427 | public void cacheSingleValueOfSizeGreaterThanMaxSize() throws Exception { 428 | mCache = new IgDiskCache(mCacheDir, 5); 429 | set(mCache, "a", "aaaaaa"); // size=6 430 | mCache.flush(); 431 | assertAbsent(mCache, "a"); 432 | } 433 | 434 | @Test 435 | public void removeAbsentElement() throws Exception { 436 | mCache.remove("a"); 437 | } 438 | 439 | @Test 440 | public void openCreatesDirectoryIfNecessary() throws Exception { 441 | File dir = tempDir.newFolder("testOpenCreatesDirectoryIfNecessary"); 442 | mCache = new IgDiskCache(dir, Integer.MAX_VALUE); 443 | set(mCache, "a", "a"); 444 | assertThat(new File(dir, "a.clean").exists()).isTrue(); 445 | assertThat(new File(dir, "journal").exists()).isTrue(); 446 | } 447 | 448 | @Test 449 | public void fileDeletedExternally() throws Exception { 450 | set(mCache, "a", "a"); 451 | getCleanFile("a").delete(); 452 | assertThat(mCache.get("a").isPresent()).isFalse(); 453 | } 454 | 455 | @SuppressLint("DeadVariable") 456 | @Test 457 | public void editSameVersion() throws Exception { 458 | set(mCache, "a", "a"); 459 | SnapshotInputStream snapshot = mCache.get("a").get(); 460 | EditorOutputStream editor = mCache.edit("a").get(); 461 | writeToOutputStream(editor, "a2"); 462 | editor.commit(); 463 | assertValue(mCache, "a", "a2"); 464 | } 465 | 466 | @Test 467 | public void editSinceEvicted() throws Exception { 468 | mCache = new IgDiskCache(mCacheDir, 7); 469 | set(mCache, "a", "aaa"); // size 3 470 | set(mCache, "b", "bbb"); // size 6 471 | set(mCache, "c", "ccc"); // size 9; will evict 'a' 472 | mCache.flush(); 473 | assertThat(mCache.get("a").isPresent()).isFalse(); 474 | } 475 | 476 | 477 | @Test 478 | public void editSinceEvictedAndRecreated() throws Exception { 479 | mCache = new IgDiskCache(mCacheDir, 7); 480 | set(mCache, "a", "aaa"); // size 3 481 | set(mCache, "b", "bbb"); // size 6 482 | set(mCache, "c", "ccc"); // size 9; will evict 'a' 483 | set(mCache, "a", "aaaa"); // size 10; will evict 'b' 484 | mCache.flush(); 485 | assertThat(mCache.get("a").isPresent()); 486 | assertThat(mCache.get("b").isPresent()).isFalse(); 487 | } 488 | 489 | @Test 490 | public void aggressiveClearingHandlesWrite() throws Exception { 491 | deletePathRecursively(mCacheDir); 492 | set(mCache, "a", "a"); 493 | assertValue(mCache, "a", "a"); 494 | } 495 | 496 | @Test 497 | public void aggressiveClearingHandlesEdit() throws Exception { 498 | set(mCache, "a", "a"); 499 | EditorOutputStream a = mCache.edit("a").get(); 500 | deletePathRecursively(mCacheDir); 501 | writeToOutputStream(a, "a2"); 502 | a.commit(); 503 | } 504 | 505 | @Test 506 | public void removeHandlesMissingFile() throws Exception { 507 | set(mCache, "a", "a"); 508 | getCleanFile("a").delete(); 509 | mCache.remove("a"); 510 | } 511 | 512 | @Test 513 | public void aggressiveClearingHandlesPartialEdit() throws Exception { 514 | set(mCache, "a", "a"); 515 | set(mCache, "b", "b"); 516 | EditorOutputStream a = mCache.edit("a").get(); 517 | writeToOutputStream(a, "a1"); 518 | deletePathRecursively(mCacheDir); 519 | a.commit(); 520 | assertThat(mCache.get("a").isPresent()).isFalse(); 521 | } 522 | 523 | @Test 524 | public void aggressiveClearingHandlesRead() throws Exception { 525 | deletePathRecursively(mCacheDir); 526 | assertThat(mCache.get("a").isPresent()).isFalse(); 527 | } 528 | 529 | @Test 530 | public void editSameEntry() throws Exception { 531 | try { 532 | mCache.edit("k1"); 533 | mCache.edit("k1"); 534 | fail(); 535 | } catch (IllegalStateException expected) { 536 | assertThat(expected.getMessage() 537 | .equals("Trying to edit a disk cache entry while another edit is in progress.")); 538 | } 539 | } 540 | 541 | @SuppressLint("EmptyCatchBlock") 542 | @Test 543 | public void editSameEntryConcurrently() throws Exception { 544 | final CountDownLatch countDown = new CountDownLatch(1); 545 | final ThreadPoolExecutor executor = 546 | new ThreadPoolExecutor(1, 1, 1, TimeUnit.SECONDS, new LinkedBlockingDeque()); 547 | final String key = "k1"; 548 | Future future = executor.submit( 549 | new Callable() { 550 | @Override 551 | public Boolean call() throws Exception { 552 | try { 553 | countDown.countDown(); 554 | mCache.edit(key); 555 | } catch (IllegalStateException expected) { 556 | return Boolean.TRUE; 557 | } 558 | return Boolean.FALSE; 559 | } 560 | }); 561 | countDown.await(); 562 | boolean exception2 = false; 563 | try { 564 | mCache.edit(key); 565 | } catch (IllegalStateException expected) { 566 | exception2 = true; 567 | } 568 | boolean exception1 = future.get(); 569 | assertThat(exception1 ^ exception2); //one of these two should be true. 570 | } 571 | 572 | @Test 573 | public void createCacheWithNullDirectory() throws Exception { 574 | mCache = new IgDiskCache(null); 575 | assertEmptyNoOpCache(); 576 | } 577 | 578 | @Test 579 | public void createCacheWithZeroMaxSize() throws Exception { 580 | mCache = new IgDiskCache(mCacheDir, 0); 581 | assertEmptyNoOpCache(); 582 | } 583 | 584 | @Test 585 | public void createCacheWithZeroMaxCount() throws Exception { 586 | mCache = new IgDiskCache(mCacheDir, 10, 0); 587 | assertEmptyNoOpCache(); 588 | } 589 | 590 | private void assertEmptyNoOpCache() throws Exception { 591 | assertThat(mCache.edit("k1").isPresent()).isFalse(); 592 | assertThat(mCache.get("k1").isPresent()).isFalse(); 593 | mCache.remove("k1"); 594 | mCache.flush(); 595 | File journal = new File(IgDiskCache.FAKE_CACHE_DIRECTORY.getPath(), JOURNAL_FILE); 596 | assertThat(journal.exists()).isFalse(); 597 | } 598 | 599 | private void createJournal(String... bodyLines) throws Exception { 600 | Writer writer = new FileWriter(mJournalFile); 601 | for (String line : bodyLines) { 602 | writer.write(line); 603 | writer.write('\n'); 604 | } 605 | writer.flush(); 606 | writer.close(); 607 | } 608 | 609 | private File getCleanFile(String key) { 610 | return new File(mCacheDir, key + Entry.CLEAN_FILE_EXTENSION); 611 | } 612 | 613 | private File getDirtyFile(String key) { 614 | return new File(mCacheDir, key + Entry.DIRTY_FILE_EXTENSION); 615 | } 616 | 617 | private static String readFile(File file) throws Exception { 618 | Reader reader = new FileReader(file); 619 | StringWriter writer = new StringWriter(); 620 | char[] buffer = new char[1024]; 621 | int count; 622 | while ((count = reader.read(buffer)) != -1) { 623 | writer.write(buffer, 0, count); 624 | } 625 | reader.close(); 626 | return writer.toString(); 627 | } 628 | 629 | public static void writeFile(File file, String content) throws Exception { 630 | FileWriter writer = new FileWriter(file); 631 | writer.write(content); 632 | writer.close(); 633 | } 634 | 635 | private void generateSomeGarbageFiles() throws Exception { 636 | File dir1 = new File(mCacheDir, "dir1"); 637 | writeFile(getCleanFile("g1"), "A"); 638 | writeFile(getCleanFile("g2"), "B"); 639 | writeFile(getCleanFile("g2"), "C"); 640 | writeFile(new File(mCacheDir, "otherFile0.tmp"), "E"); 641 | writeFile(new File(mCacheDir, "otherFile1.clean"), "F"); 642 | dir1.mkdir(); 643 | } 644 | 645 | private void assertGarbageFilesAllDeleted() throws Exception { 646 | assertThat(getCleanFile("g1")).doesNotExist(); 647 | assertThat(getCleanFile("g2")).doesNotExist(); 648 | assertThat(new File(mCacheDir, "otherFile0.tmp")).doesNotExist(); 649 | assertThat(new File(mCacheDir, "otherFile1.clean")).doesNotExist(); 650 | } 651 | 652 | static void set(IgDiskCache cache, String key, String value) throws Exception { 653 | OptionalStream out = cache.edit(key); 654 | if (out.isPresent()) { 655 | writeToOutputStream(out.get(), value); 656 | out.get().commit(); 657 | } else { 658 | fail(); 659 | } 660 | } 661 | 662 | private void assertAbsent(IgDiskCache cache, String key) throws Exception { 663 | OptionalStream in = cache.get(key); 664 | if (in.isPresent()) { 665 | in.get().close(); 666 | fail(); 667 | } 668 | assertThat(getCleanFile(key)).doesNotExist(); 669 | assertThat(getDirtyFile(key)).doesNotExist(); 670 | } 671 | 672 | private void assertValue(IgDiskCache cache, String key, String value) throws Exception { 673 | OptionalStream in = cache.get(key); 674 | if (in.isPresent()) { 675 | assertThat(readFromInputStream(in.get())).isEqualTo(value); 676 | assertThat(getCleanFile(key)).exists(); 677 | in.get().close(); 678 | } 679 | } 680 | 681 | static String readFromInputStream(SnapshotInputStream in) { 682 | Reader reader = null; 683 | try { 684 | reader = new InputStreamReader(in, US_ASCII); 685 | StringWriter writer = new StringWriter(); 686 | char[] buffer = new char[1024]; 687 | int count; 688 | while ((count = reader.read(buffer)) != -1) { 689 | writer.write(buffer, 0, count); 690 | } 691 | return writer.toString(); 692 | } catch (IOException e) { 693 | return null; 694 | } finally { 695 | Journal.closeQuietly(reader); 696 | } 697 | } 698 | 699 | @SuppressLint("EmptyCatchBlock") 700 | static void writeToOutputStream(EditorOutputStream out, String str) { 701 | Writer writer = null; 702 | try { 703 | writer = new OutputStreamWriter(out, US_ASCII); 704 | writer.write(str); 705 | } catch (IOException ignored) { 706 | } finally { 707 | Journal.closeQuietly(writer); 708 | } 709 | } 710 | 711 | static void deletePathRecursively(File directory) { 712 | if (directory != null) { 713 | if (directory.isDirectory()) { 714 | for (File child : directory.listFiles()) { 715 | deletePathRecursively(child); 716 | } 717 | } 718 | directory.delete(); 719 | } 720 | } 721 | } 722 | -------------------------------------------------------------------------------- /igdiskcache/src/test/java/com/instagram/igdiskcache/JournalTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.instagram.igdiskcache; 10 | 11 | import java.io.BufferedInputStream; 12 | import java.io.BufferedOutputStream; 13 | import java.io.BufferedReader; 14 | import java.io.File; 15 | import java.io.FileInputStream; 16 | import java.io.FileOutputStream; 17 | import java.io.FileReader; 18 | import java.io.FileWriter; 19 | import java.io.IOException; 20 | import java.util.ArrayList; 21 | import java.util.Arrays; 22 | import java.util.LinkedHashMap; 23 | import java.util.List; 24 | import java.util.concurrent.Callable; 25 | import java.util.concurrent.LinkedBlockingQueue; 26 | import java.util.concurrent.ThreadPoolExecutor; 27 | import java.util.concurrent.TimeUnit; 28 | 29 | import android.annotation.SuppressLint; 30 | import android.os.Looper; 31 | 32 | import org.junit.Before; 33 | import org.junit.Rule; 34 | import org.junit.Test; 35 | import org.junit.rules.TemporaryFolder; 36 | import org.powermock.core.classloader.annotations.PrepareForTest; 37 | 38 | import static com.instagram.igdiskcache.Journal.JOURNAL_FILE; 39 | import static com.instagram.igdiskcache.Journal.JOURNAL_FILE_BACKUP; 40 | import static org.fest.assertions.api.Assertions.assertThat; 41 | import static org.powermock.api.mockito.PowerMockito.mock; 42 | import static org.powermock.api.mockito.PowerMockito.spy; 43 | import static org.powermock.api.mockito.PowerMockito.when; 44 | 45 | 46 | @PrepareForTest({Looper.class}) 47 | public class JournalTest extends RobolectricBaseTest { 48 | private File mCacheDir; 49 | private File mJournalFile; 50 | private File mJournalBkpFile; 51 | private IgDiskCache mCache; 52 | private Journal mJournal; 53 | private ThreadPoolExecutor mExecutor; 54 | 55 | @Rule 56 | public TemporaryFolder tempDir = new TemporaryFolder(); 57 | 58 | @Before 59 | public void setUp() throws Exception { 60 | mCacheDir = tempDir.newFolder("IgDiskCacheTest"); 61 | mJournalFile = new File(mCacheDir, JOURNAL_FILE); 62 | mJournalBkpFile = new File(mCacheDir, JOURNAL_FILE_BACKUP); 63 | for (File file : mCacheDir.listFiles()) { 64 | file.delete(); 65 | } 66 | mExecutor = 67 | new ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue()); 68 | Looper looper = mock(Looper.class); 69 | when(looper.getThread()).thenReturn(mock(Thread.class)); 70 | spy(Looper.class); 71 | when(Looper.getMainLooper()).thenReturn(looper); 72 | mCache = new IgDiskCache(mCacheDir, Integer.MAX_VALUE); 73 | mJournal = new Journal(mCacheDir, mCache, mExecutor); 74 | } 75 | 76 | @Test 77 | public void retrieveEntriesFromJournal() throws Exception { 78 | FileWriter writer = new FileWriter(mJournalFile); 79 | writer.write("DIRTY k1\nCLEAN k1 12\n"); 80 | writer.close(); 81 | LinkedHashMap entries = mJournal.retrieveEntriesFromJournal(); 82 | assertThat(entries.size()).isEqualTo(1); 83 | assertThat(entries.get("k1")).isNotEqualTo(null); 84 | assertThat(entries.get("k1").isReadable()); 85 | } 86 | 87 | @Test 88 | public void retrieveEntriesFromJournalBkp() throws Exception { 89 | mJournalFile.delete(); 90 | FileWriter writer = new FileWriter(mJournalBkpFile); 91 | writer.write("DIRTY k1\nCLEAN k1 12\n"); 92 | writer.close(); 93 | LinkedHashMap entries = mJournal.retrieveEntriesFromJournal(); 94 | assertThat(entries.size()).isEqualTo(1); 95 | assertThat(entries.get("k1")).isNotEqualTo(null); 96 | assertThat(entries.get("k1").isReadable()); 97 | } 98 | 99 | @Test 100 | public void logCleanFileUpdateInJournal() throws Exception { 101 | mJournal.rebuild(); 102 | mJournal.logCleanFileUpdate("k1", 12); 103 | assertJournalEqualsAsync("CLEAN k1 12"); 104 | } 105 | 106 | @Test 107 | public void logDirtyFileUpdateInJournal() throws Exception { 108 | mJournal.rebuild(); 109 | mJournal.logDirtyFileUpdate("k1"); 110 | assertJournalEqualsAsync("DIRTY k1"); 111 | } 112 | 113 | @Test 114 | public void journalShouldBeEmptyForEmptyCache() throws Exception { 115 | assertJournalEquals(); 116 | } 117 | 118 | @Test 119 | public void journalWithEditAndPublish() throws Exception { 120 | OptionalStream out = mCache.edit("k1"); 121 | assertThat(out.isPresent()); 122 | assertJournalEquals("DIRTY k1"); // DIRTY must always be flushed. 123 | IgDiskCacheTest.writeToOutputStream(out.get(), "AB"); 124 | out.get().commit(); 125 | assertJournalEqualsAsync("DIRTY k1", "CLEAN k1 2"); 126 | } 127 | 128 | 129 | @Test 130 | public void revertedNewFileIsRemoveInJournal() throws Exception { 131 | OptionalStream out = mCache.edit("k1"); 132 | assertThat(out.isPresent()); 133 | assertJournalEquals("DIRTY k1"); // DIRTY must always be flushed. 134 | IgDiskCacheTest.writeToOutputStream(out.get(), "AB"); 135 | out.get().abort(); 136 | assertJournalEqualsAsync("DIRTY k1"); 137 | } 138 | 139 | @Test 140 | public void unterminatedEditIsDirtyOnClose() throws Exception { 141 | OptionalStream out = mCache.edit("k1"); 142 | assertThat(out.isPresent()); 143 | IgDiskCacheTest.writeToOutputStream(out.get(), "AB"); 144 | mCache.flush(); 145 | assertJournalEqualsAsync("DIRTY k1"); 146 | } 147 | 148 | @Test 149 | public void journalWithEditAndPublishAndRead() throws Exception { 150 | OptionalStream out1 = mCache.edit("k1"); 151 | assertThat(out1.isPresent()); 152 | IgDiskCacheTest.writeToOutputStream(out1.get(), "AB"); 153 | out1.get().commit(); 154 | OptionalStream out2 = mCache.edit("k2"); 155 | assertThat(out2.isPresent()); 156 | IgDiskCacheTest.writeToOutputStream(out2.get(), "DEF"); 157 | out2.get().commit(); 158 | OptionalStream in = mCache.get("k1"); 159 | assertThat(in.isPresent()); 160 | in.get().close(); 161 | assertJournalEqualsAsync("DIRTY k1", "CLEAN k1 2", "DIRTY k2", "CLEAN k2 3"); 162 | } 163 | 164 | @Test 165 | public void rebuildJournalOnRepeatedEdits() throws Exception { 166 | long lastJournalLength = 0; 167 | while (true) { 168 | long journalLength = mJournalFile.length(); 169 | IgDiskCacheTest.set(mCache, "a", "a"); 170 | IgDiskCacheTest.set(mCache, "b", "b"); 171 | if (journalLength < lastJournalLength) { 172 | System.out 173 | .printf( 174 | "Journal compacted from %s bytes to %s bytes\n", 175 | lastJournalLength, 176 | journalLength); 177 | break; 178 | } 179 | lastJournalLength = journalLength; 180 | } 181 | assertValue("a", "a"); 182 | assertValue("b", "b"); 183 | } 184 | 185 | @Test 186 | public void restoreBackupFile() throws Exception { 187 | IgDiskCacheTest.set(mCache, "k1", "ABC"); 188 | mCache.flush(); 189 | 190 | assertThat(mJournalFile.renameTo(mJournalBkpFile)).isTrue(); 191 | assertThat(mJournalFile.exists()).isFalse(); 192 | assertThat(mJournalBkpFile.exists()); 193 | 194 | mCache = new IgDiskCache(mCacheDir, Integer.MAX_VALUE); 195 | SnapshotInputStream snapshot = mCache.get("k1").get(); 196 | assertThat(IgDiskCacheTest.readFromInputStream(snapshot)).isEqualTo("ABC"); 197 | assertThat(snapshot.getLengthInBytes()).isEqualTo(3); 198 | 199 | assertThat(mJournalBkpFile.exists()).isFalse(); 200 | assertThat(mJournalFile.exists()).isTrue(); 201 | } 202 | 203 | @Test 204 | public void journalFileIsPreferredOverBackupFile() throws Exception { 205 | IgDiskCacheTest.set(mCache, "k1", "ABC"); 206 | copyFile(mJournalFile, mJournalBkpFile); 207 | IgDiskCacheTest.set(mCache, "k2", "F"); 208 | 209 | assertThat(mJournalFile.exists()).isTrue(); 210 | assertThat(mJournalBkpFile.exists()).isTrue(); 211 | 212 | mCache = new IgDiskCache(mCacheDir, Integer.MAX_VALUE); 213 | 214 | SnapshotInputStream snapshotA = mCache.get("k1").get(); 215 | assertThat(IgDiskCacheTest.readFromInputStream(snapshotA)).isEqualTo("ABC"); 216 | assertThat(snapshotA.getLengthInBytes()).isEqualTo(3); 217 | 218 | SnapshotInputStream snapshotB = mCache.get("k2").get(); 219 | assertThat(IgDiskCacheTest.readFromInputStream(snapshotB)).isEqualTo("F"); 220 | assertThat(snapshotB.getLengthInBytes()).isEqualTo(1); 221 | 222 | assertThat(mJournalBkpFile.exists()).isFalse(); 223 | assertThat(mJournalFile.exists()).isTrue(); 224 | } 225 | 226 | private void assertJournalEquals(String... expectedBodyLines) throws Exception { 227 | List expectedLines = new ArrayList(); 228 | expectedLines.addAll(Arrays.asList(expectedBodyLines)); 229 | assertThat(readJournalLines()).isEqualTo(expectedLines); 230 | } 231 | 232 | @SuppressLint("BadCatchBlock") 233 | private void assertJournalEqualsAsync(final String... expectedBodyLines) throws Exception { 234 | Callable assertTask = new Callable() { 235 | @Override 236 | public Boolean call() { 237 | try { 238 | assertJournalEquals(expectedBodyLines); 239 | } catch (Exception e) { 240 | return false; 241 | } 242 | return true; 243 | } 244 | }; 245 | assertThat(mExecutor.submit(assertTask).get().booleanValue()); 246 | } 247 | 248 | private List readJournalLines() throws Exception { 249 | List result = new ArrayList(); 250 | BufferedReader reader = new BufferedReader(new FileReader(mJournalFile)); 251 | String line; 252 | while ((line = reader.readLine()) != null) { 253 | result.add(line); 254 | } 255 | reader.close(); 256 | return result; 257 | } 258 | 259 | private void assertValue(String key, String value) throws Exception { 260 | OptionalStream in = mCache.get(key); 261 | if (in.isPresent()) { 262 | assertThat(IgDiskCacheTest.readFromInputStream(in.get())).isEqualTo(value); 263 | assertThat(new File(mCacheDir, key + Entry.CLEAN_FILE_EXTENSION)).exists(); 264 | in.get().close(); 265 | } 266 | } 267 | 268 | static void copyFile(File from, File to) { 269 | BufferedInputStream inputStream = null; 270 | BufferedOutputStream outputStream = null; 271 | try { 272 | inputStream = new BufferedInputStream(new FileInputStream(from)); 273 | outputStream = new BufferedOutputStream(new FileOutputStream(to)); 274 | byte[] buffer = new byte[1024]; 275 | int length; 276 | while ((length = inputStream.read(buffer)) > 0) { 277 | outputStream.write(buffer, 0, length); 278 | } 279 | } catch (IOException e) { 280 | // Handle Exception quietly 281 | } finally { 282 | Journal.closeQuietly(inputStream); 283 | Journal.closeQuietly(outputStream); 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /igdiskcache/src/test/java/com/instagram/igdiskcache/RobolectricBaseTest.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2016-present, Facebook, Inc. 3 | * All rights reserved. 4 | * 5 | * This source code is licensed under the BSD-style license found in the 6 | * LICENSE file in the root directory of this source tree. An additional grant 7 | * of patent rights can be found in the PATENTS file in the same directory. 8 | */ 9 | package com.instagram.igdiskcache; 10 | 11 | import org.junit.Rule; 12 | import org.junit.runner.RunWith; 13 | import org.powermock.core.classloader.annotations.PowerMockIgnore; 14 | import org.powermock.modules.junit4.PowerMockRunner; 15 | import org.powermock.modules.junit4.PowerMockRunnerDelegate; 16 | import org.powermock.modules.junit4.rule.PowerMockRule; 17 | import org.robolectric.RobolectricGradleTestRunner; 18 | import org.robolectric.annotation.Config; 19 | 20 | /** 21 | * Base class for all Robolectric test in this project. 22 | */ 23 | @RunWith(PowerMockRunner.class) 24 | @PowerMockRunnerDelegate(RobolectricGradleTestRunner.class) 25 | @Config(constants = BuildConfig.class, manifest = Config.NONE, sdk = 21) 26 | @PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*"}) 27 | public abstract class RobolectricBaseTest { 28 | @Rule 29 | public PowerMockRule rule = new PowerMockRule(); 30 | } 31 | -------------------------------------------------------------------------------- /release.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'maven' 2 | apply plugin: 'signing' 3 | 4 | def isReleaseBuild() { 5 | return VERSION_NAME.contains("SNAPSHOT") == false 6 | } 7 | 8 | def getRepositoryUrl() { 9 | return hasProperty('repositoryUrl') ? property('repositoryUrl') : "https://oss.sonatype.org/service/local/staging/deploy/maven2/" 10 | } 11 | 12 | def getRepositoryUsername() { 13 | return hasProperty('repositoryUsername') ? property('repositoryUsername') : "" 14 | } 15 | 16 | def getRepositoryPassword() { 17 | return hasProperty('repositoryPassword') ? property('repositoryPassword') : "" 18 | } 19 | 20 | def configureIgDiskCachePom(def pom) { 21 | pom.whenConfigured { 22 | applyOptionalDeps it, getOptionalDeps() 23 | } 24 | pom.project { 25 | name 'Ig Disk Cache' 26 | artifactId 'ig-disk-cache' 27 | packaging 'aar' 28 | description 'Ig-Disk-Cache library for Android' 29 | url 'https://github.com/instagram/ig-disk-cache' 30 | 31 | scm { 32 | url 'https://github.com/instagram/ig-disk-cache.git' 33 | connection 'scm:git:https://github.com/instagram/ig-disk-cache.git' 34 | developerConnection 'scm:git:git@github.com:instagram/ig-disk-cache.git' 35 | } 36 | 37 | licenses { 38 | license { 39 | name 'BSD License' 40 | url 'https://github.com/instagram/ig-disk-cache/blob/master/LICENSE' 41 | distribution 'repo' 42 | } 43 | } 44 | 45 | developers { 46 | developer { 47 | id 'instagram' 48 | name 'Instagram' 49 | } 50 | } 51 | } 52 | } 53 | 54 | // Hack to modify the resulting pom's dependencies to use 55 | // true where appropriate. 56 | def applyOptionalDeps(def pom, def optionalDeps) { 57 | pom.dependencies.each { dep -> 58 | def artifactLabel = dep.groupId + ':' + dep.artifactId 59 | if (optionalDeps.contains(artifactLabel)) { 60 | dep.optional = true 61 | } 62 | } 63 | } 64 | 65 | def getOptionalDeps() { 66 | if (hasProperty('POM_OPTIONAL_DEPS')) { 67 | return property('POM_OPTIONAL_DEPS').split(',') as Set 68 | } else { 69 | return [] 70 | } 71 | } 72 | 73 | afterEvaluate { project -> 74 | task androidJavadoc(type: Javadoc) { 75 | source = android.sourceSets.main.java.srcDirs 76 | classpath += files(android.bootClasspath) 77 | if (JavaVersion.current().isJava8Compatible()) { 78 | options.addStringOption('Xdoclint:none', '-quiet') 79 | } 80 | } 81 | 82 | task androidJavadocJar(type: Jar, dependsOn: androidJavadoc) { 83 | classifier = 'javadoc' 84 | from androidJavadoc.destinationDir 85 | } 86 | 87 | task androidSourcesJar(type: Jar) { 88 | classifier = 'sources' 89 | from android.sourceSets.main.java.srcDirs 90 | } 91 | 92 | android.libraryVariants.all { variant -> 93 | def name = variant.name.capitalize() 94 | task "jar${name}"(type: Jar, dependsOn: variant.javaCompile) { 95 | from variant.javaCompile.destinationDir 96 | } 97 | } 98 | 99 | artifacts { 100 | archives androidJavadocJar 101 | archives androidSourcesJar 102 | archives jarRelease 103 | } 104 | 105 | version = VERSION_NAME 106 | group = GROUP 107 | 108 | signing { 109 | required { isReleaseBuild() && gradle.taskGraph.hasTask("uploadArchives") } 110 | sign configurations.archives 111 | } 112 | 113 | uploadArchives { 114 | configuration = configurations.archives 115 | repositories.mavenDeployer { 116 | beforeDeployment { 117 | MavenDeployment deployment -> signing.signPom(deployment) 118 | } 119 | 120 | repository(url: getRepositoryUrl()) { 121 | authentication( 122 | userName: getRepositoryUsername(), 123 | password: getRepositoryPassword()) 124 | } 125 | 126 | configureIgDiskCachePom pom 127 | } 128 | } 129 | 130 | task installArchives(type: Upload) { 131 | configuration = configurations.archives 132 | repositories { 133 | mavenDeployer { 134 | repository url: "file://${System.properties['user.home']}/.m2/repository" 135 | configureIgDiskCachePom pom 136 | } 137 | } 138 | } 139 | } -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':igdiskcache', ':demo' 2 | --------------------------------------------------------------------------------