├── gradle.properties ├── settings.gradle ├── .gitignore ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .travis.yml ├── README.md ├── src ├── main │ └── java │ │ └── com │ │ └── colinrtwhite │ │ └── disklrucache │ │ ├── Extensions.kt │ │ └── DiskLruCache.kt └── test │ └── java │ └── com │ └── colinrtwhite │ └── disklrucache │ └── DiskLruCacheTest.kt ├── gradlew.bat └── gradlew /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'com.colinrtwhite' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .gradle 3 | .idea 4 | out 5 | *.iml 6 | *.ipr 7 | *.iws 8 | .env -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colinrtwhite/DiskLruCacheKt/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: java 2 | install: true 3 | sudo: false 4 | 5 | jdk: 6 | - openjdk8 7 | 8 | before_cache: 9 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock 10 | - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ 11 | 12 | cache: 13 | directories: 14 | - $HOME/.gradle/caches/ 15 | - $HOME/.gradle/wrapper/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DiskLruCacheKt 2 | 3 | [![Build Status](https://travis-ci.org/colinrtwhite/DiskLruCacheKt.svg?branch=master)](https://travis-ci.org/colinrtwhite/DiskLruCacheKt) 4 | 5 | A fork of [Jake Wharton's disk LRU cache](https://github.com/JakeWharton/DiskLruCache) translated to Kotlin and using Okio. 6 | 7 | ## Download 8 | 9 | This library is currently distributed via [JitPack](https://jitpack.io). 10 | 11 | Add it to your list of repositories: 12 | 13 | ```groovy 14 | repositories { 15 | maven { url 'https://jitpack.io' } 16 | } 17 | ``` 18 | 19 | And add the library to your list of dependencies: 20 | 21 | ```groovy 22 | dependencies { 23 | implementation 'com.github.colinrtwhite:disklrucachekt:master-SNAPSHOT' 24 | } 25 | ``` -------------------------------------------------------------------------------- /src/main/java/com/colinrtwhite/disklrucache/Extensions.kt: -------------------------------------------------------------------------------- 1 | package com.colinrtwhite.disklrucache 2 | 3 | import java.io.Closeable 4 | import java.io.File 5 | import java.io.IOException 6 | 7 | fun Closeable.closeQuietly() { 8 | try { 9 | close() 10 | } catch (rethrown: RuntimeException) { 11 | throw rethrown 12 | } catch (ignored: Exception) { 13 | } 14 | } 15 | 16 | @Throws(IOException::class) 17 | fun File.deleteIfExists() { 18 | if (exists() && !delete()) { 19 | throw IOException() 20 | } 21 | } 22 | 23 | @Throws(IOException::class) 24 | fun File.renameTo(to: File, deleteDestination: Boolean) { 25 | if (deleteDestination) { 26 | to.deleteIfExists() 27 | } 28 | if (!renameTo(to)) { 29 | throw IOException() 30 | } 31 | } 32 | 33 | /** 34 | * Deletes the contents of `dir`. Throws an IOException if any file 35 | * could not be deleted, or if `dir` is not a readable directory. 36 | */ 37 | @Throws(IOException::class) 38 | fun File.deleteDirectory() { 39 | val files = listFiles() ?: throw IOException("Not a readable directory: $this") 40 | files.forEach { file -> 41 | if (file.isDirectory) { 42 | file.deleteDirectory() 43 | } 44 | if (!file.delete()) { 45 | throw IOException("Failed to delete file: $file") 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /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 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 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 Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /src/main/java/com/colinrtwhite/disklrucache/DiskLruCache.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.colinrtwhite.disklrucache 18 | 19 | import com.colinrtwhite.disklrucache.DiskLruCache.Editor 20 | import okio.Buffer 21 | import okio.BufferedSink 22 | import okio.Sink 23 | import okio.Source 24 | import okio.appendingSink 25 | import okio.blackholeSink 26 | import okio.buffer 27 | import okio.sink 28 | import okio.source 29 | import java.io.Closeable 30 | import java.io.EOFException 31 | import java.io.File 32 | import java.io.FileNotFoundException 33 | import java.io.IOException 34 | import java.util.LinkedHashMap 35 | import java.util.concurrent.LinkedBlockingQueue 36 | import java.util.concurrent.ThreadPoolExecutor 37 | import java.util.concurrent.TimeUnit 38 | 39 | /** 40 | * A cache that uses a bounded amount of space on a filesystem. Each cache 41 | * entry has a string key and a fixed number of values. Each key must match 42 | * the regex **[a-z0-9_-]{1,120}**. Values are byte sequences, 43 | * accessible as streams or files. Each value must be between `0` and 44 | * `Integer.MAX_VALUE` bytes in length. 45 | * 46 | * 47 | * The cache stores its data in a directory on the filesystem. This 48 | * directory must be exclusive to the cache; the cache may delete or overwrite 49 | * files from its directory. It is an error for multiple processes to use the 50 | * same cache directory at the same time. 51 | * 52 | * 53 | * This cache limits the number of bytes that it will store on the 54 | * filesystem. When the number of stored bytes exceeds the limit, the cache will 55 | * remove entries in the background until the limit is satisfied. The limit is 56 | * not strict: the cache may temporarily exceed it while waiting for files to be 57 | * deleted. The limit does not include filesystem overhead or the cache 58 | * journal so space-sensitive applications should set a conservative limit. 59 | * 60 | * 61 | * Clients call [.edit] to create or update the values of an entry. An 62 | * entry may have only one editor at one time; if a value is not available to be 63 | * edited then [.edit] will return null. 64 | * 65 | * * When an entry is being **created** it is necessary to 66 | * supply a full set of values; the empty value should be used as a 67 | * placeholder if necessary. 68 | * * When an entry is being **edited**, it is not necessary 69 | * to supply data for every value; values default to their previous 70 | * value. 71 | * 72 | * Every [.edit] call must be matched by a call to [Editor.commit] 73 | * or [Editor.abort]. Committing is atomic: a read observes the full set 74 | * of values as they were before or after the commit, but never a mix of values. 75 | * 76 | * 77 | * Clients call [.get] to read a snapshot of an entry. The read will 78 | * observe the value at the time that [.get] was called. Updates and 79 | * removals after the call do not impact ongoing reads. 80 | * 81 | * 82 | * This class is tolerant of some I/O errors. If files are missing from the 83 | * filesystem, the corresponding entries will be dropped from the cache. If 84 | * an error occurs while writing a cache value, the edit will fail silently. 85 | * Callers should handle other problems by catching `IOException` and 86 | * responding appropriately. 87 | */ 88 | class DiskLruCache private constructor( 89 | val directory: File, 90 | private val appVersion: Int, 91 | private val valueCount: Int, 92 | private var maxSize: Long 93 | ) : Closeable { 94 | 95 | /* 96 | * This cache uses a journal file named "journal". A typical journal file 97 | * looks like this: 98 | * libcore.io.DiskLruCache 99 | * 1 100 | * 100 101 | * 2 102 | * 103 | * CLEAN 3400330d1dfc7f3f7f4b8d4d803dfcf6 832 21054 104 | * DIRTY 335c4c6028171cfddfbaae1a9c313c52 105 | * CLEAN 335c4c6028171cfddfbaae1a9c313c52 3934 2342 106 | * REMOVE 335c4c6028171cfddfbaae1a9c313c52 107 | * DIRTY 1ab96a171faeeee38496d8b330771a7a 108 | * CLEAN 1ab96a171faeeee38496d8b330771a7a 1600 234 109 | * READ 335c4c6028171cfddfbaae1a9c313c52 110 | * READ 3400330d1dfc7f3f7f4b8d4d803dfcf6 111 | * 112 | * The first five lines of the journal form its header. They are the 113 | * constant string "libcore.io.DiskLruCache", the disk cache's version, 114 | * the application's version, the value count, and a blank line. 115 | * 116 | * Each of the subsequent lines in the file is a record of the state of a 117 | * cache entry. Each line contains space-separated values: a state, a key, 118 | * and optional state-specific values. 119 | * o DIRTY lines track that an entry is actively being created or updated. 120 | * Every successful DIRTY action should be followed by a CLEAN or REMOVE 121 | * action. DIRTY lines without a matching CLEAN or REMOVE indicate that 122 | * temporary files may need to be deleted. 123 | * o CLEAN lines track a cache entry that has been successfully published 124 | * and may be read. A publish line is followed by the lengths of each of 125 | * its values. 126 | * o READ lines track accesses for LRU. 127 | * o REMOVE lines track entries that have been deleted. 128 | * 129 | * The journal file is appended to as cache operations occur. The journal may 130 | * occasionally be compacted by dropping redundant lines. A temporary file named 131 | * "journal.tmp" will be used during compaction; that file should be deleted if 132 | * it exists when the cache is opened. 133 | */ 134 | private val journalFile = File(directory, JOURNAL_FILE) 135 | private val journalFileTmp = File(directory, JOURNAL_FILE_TEMP) 136 | private val journalFileBackup = File(directory, JOURNAL_FILE_BACKUP) 137 | 138 | private val lruEntries = LinkedHashMap(0, 0.75f, true) 139 | 140 | /** This cache uses a single background thread to evict entries. */ 141 | internal val executorService = ThreadPoolExecutor(0, 1, 60L, TimeUnit.SECONDS, LinkedBlockingQueue()) 142 | 143 | private var journalWriter: BufferedSink? = null 144 | private var redundantOpCount = 0 145 | private var size = 0L 146 | 147 | /** 148 | * To differentiate between old and current snapshots, each entry is given 149 | * a sequence number each time an edit is committed. A snapshot is stale if 150 | * its sequence number is not equal to its entry's sequence number. 151 | */ 152 | private var nextSequenceNumber = 0L 153 | 154 | @Throws(IOException::class) 155 | private fun readJournal() { 156 | journalFile.source().buffer().use { reader -> 157 | val magic = reader.readUtf8LineStrict() 158 | val version = reader.readUtf8LineStrict() 159 | val appVersionString = reader.readUtf8LineStrict() 160 | val valueCountString = reader.readUtf8LineStrict() 161 | val blank = reader.readUtf8LineStrict() 162 | 163 | if (MAGIC != magic 164 | || VERSION_1 != version 165 | || appVersion.toString() != appVersionString 166 | || valueCount.toString() != valueCountString 167 | || blank.isNotBlank() 168 | ) { 169 | throw IOException("Unexpected journal header: [$magic, $version, $valueCountString, $blank]") 170 | } 171 | 172 | var lineCount = 0 173 | while (true) { 174 | try { 175 | readJournalLine(reader.readUtf8LineStrict()) 176 | lineCount++ 177 | } catch (ignored: EOFException) { 178 | break 179 | } 180 | } 181 | redundantOpCount = lineCount - lruEntries.count() 182 | 183 | // If we ended on a truncated line, rebuild the journal before appending to it. 184 | if (!reader.exhausted()) { 185 | rebuildJournal() 186 | } else { 187 | journalWriter = journalFile.appendingSink().buffer() 188 | } 189 | } 190 | } 191 | 192 | @Throws(IOException::class) 193 | private fun readJournalLine(line: String) { 194 | val firstSpace = line.indexOf(' ') 195 | if (firstSpace == -1) { 196 | throw IOException("Unexpected journal line: $line") 197 | } 198 | 199 | val keyBegin = firstSpace + 1 200 | val secondSpace = line.indexOf(' ', keyBegin) 201 | val key: String 202 | if (secondSpace == -1) { 203 | key = line.substring(keyBegin) 204 | if (firstSpace == REMOVE.count() && line.startsWith(REMOVE)) { 205 | lruEntries.remove(key) 206 | return 207 | } 208 | } else { 209 | key = line.substring(keyBegin, secondSpace) 210 | } 211 | 212 | val entry = lruEntries[key] ?: Entry(key).also { lruEntries[key] = it } 213 | 214 | if (secondSpace != -1 && firstSpace == CLEAN.count() && line.startsWith(CLEAN)) { 215 | val parts = line 216 | .substring(secondSpace + 1) 217 | .split(" ") 218 | .dropLastWhile { it.isEmpty() } 219 | entry.readable = true 220 | entry.currentEditor = null 221 | entry.setLengths(parts) 222 | } else if (secondSpace == -1 && firstSpace == DIRTY.count() && line.startsWith(DIRTY)) { 223 | entry.currentEditor = Editor(entry) 224 | } else if (secondSpace == -1 && firstSpace == READ.count() && line.startsWith(READ)) { 225 | // This work was already done by calling lruEntries.get(). 226 | } else { 227 | throw IOException("Unexpected journal line: $line") 228 | } 229 | } 230 | 231 | /** 232 | * Computes the initial size and collects garbage as a part of opening the 233 | * cache. Dirty entries are assumed to be inconsistent and will be deleted. 234 | */ 235 | @Throws(IOException::class) 236 | private fun processJournal() { 237 | journalFileTmp.deleteIfExists() 238 | 239 | val iterator = lruEntries.values.iterator() 240 | while (iterator.hasNext()) { 241 | val entry = iterator.next() 242 | if (entry.currentEditor == null) { 243 | for (i in 0 until valueCount) { 244 | size += entry.lengths[i] 245 | } 246 | } else { 247 | entry.currentEditor = null 248 | for (i in 0 until valueCount) { 249 | entry.getCleanFile(i).deleteIfExists() 250 | entry.getDirtyFile(i).deleteIfExists() 251 | } 252 | iterator.remove() 253 | } 254 | } 255 | } 256 | 257 | /** 258 | * Creates a new journal that omits redundant information. This replaces the 259 | * current journal if it exists. 260 | */ 261 | @Synchronized 262 | @Throws(IOException::class) 263 | private fun rebuildJournal() { 264 | journalWriter?.close() 265 | 266 | journalFileTmp.sink().buffer().use { writer -> 267 | writer.writeUtf8(MAGIC) 268 | writer.writeUtf8("\n") 269 | writer.writeUtf8(VERSION_1) 270 | writer.writeUtf8("\n") 271 | writer.writeUtf8(appVersion.toString()) 272 | writer.writeUtf8("\n") 273 | writer.writeUtf8(valueCount.toString()) 274 | writer.writeUtf8("\n") 275 | writer.writeUtf8("\n") 276 | 277 | lruEntries.values.forEach { entry -> 278 | if (entry.currentEditor != null) { 279 | writer.writeUtf8(DIRTY) 280 | writer.writeUtf8(" ") 281 | writer.writeUtf8(entry.key) 282 | writer.writeUtf8("\n") 283 | } else { 284 | writer.writeUtf8(CLEAN) 285 | writer.writeUtf8(" ") 286 | writer.writeUtf8(entry.key) 287 | writer.writeUtf8(" ") 288 | writer.writeUtf8(entry.getLengthsString()) 289 | writer.writeUtf8("\n") 290 | } 291 | } 292 | } 293 | 294 | if (journalFile.exists()) { 295 | journalFile.renameTo(journalFileBackup, true) 296 | } 297 | journalFileTmp.renameTo(journalFile, false) 298 | journalFileBackup.delete() 299 | 300 | journalWriter = journalFile.appendingSink().buffer() 301 | } 302 | 303 | @Synchronized 304 | @Throws(IOException::class) 305 | private fun cleanupJournal() { 306 | journalWriter ?: return // Closed. 307 | trimToSize() 308 | 309 | if (journalRebuildRequired()) { 310 | rebuildJournal() 311 | redundantOpCount = 0 312 | } 313 | } 314 | 315 | /** 316 | * Returns a snapshot of the entry named `key`, or null if it doesn't 317 | * exist is not currently readable. If a value is returned, it is moved to 318 | * the head of the LRU queue. 319 | */ 320 | @Synchronized 321 | @Throws(IOException::class) 322 | operator fun get(key: String): Snapshot? { 323 | ensureNotClosed() 324 | validateKey(key) 325 | 326 | val entry = lruEntries[key]?.takeIf { it.readable } ?: return null 327 | 328 | // Open all streams eagerly to guarantee that we see a single published 329 | // snapshot. If we opened streams lazily then the streams could come 330 | // from different edits. 331 | val inputs = arrayOfNulls(valueCount) 332 | try { 333 | for (index in 0 until valueCount) { 334 | inputs[index] = entry.getCleanFile(index).source() 335 | } 336 | } catch (e: FileNotFoundException) { 337 | // A file must have been deleted manually! 338 | for (input in inputs) { 339 | input?.closeQuietly() ?: break 340 | } 341 | return null 342 | } 343 | 344 | redundantOpCount++ 345 | journalWriter?.apply { 346 | writeUtf8(READ) 347 | writeUtf8(" ") 348 | writeUtf8(key) 349 | writeUtf8("\n") 350 | } 351 | 352 | if (journalRebuildRequired()) { 353 | executorService.submit(this::cleanupJournal) 354 | } 355 | 356 | @Suppress("UNCHECKED_CAST") 357 | return Snapshot( 358 | key = key, 359 | sequenceNumber = entry.sequenceNumber, 360 | inputs = inputs as Array, 361 | lengths = entry.lengths 362 | ) 363 | } 364 | 365 | @Throws(IOException::class) 366 | fun edit(key: String): Editor? { 367 | return edit(key, ANY_SEQUENCE_NUMBER) 368 | } 369 | 370 | /** 371 | * Returns an editor for the entry named `key`, or null if another 372 | * edit is in progress. 373 | */ 374 | @Synchronized 375 | @Throws(IOException::class) 376 | private fun edit(key: String, expectedSequenceNumber: Long): Editor? { 377 | ensureNotClosed() 378 | validateKey(key) 379 | 380 | var entry: Entry? = lruEntries[key] 381 | if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null || entry.sequenceNumber != expectedSequenceNumber)) { 382 | return null // Snapshot is stale. 383 | } 384 | if (entry == null) { 385 | entry = Entry(key) 386 | lruEntries[key] = entry 387 | } else if (entry.currentEditor != null) { 388 | return null // Another edit is in progress. 389 | } 390 | 391 | val editor = Editor(entry) 392 | entry.currentEditor = editor 393 | 394 | // Flush the journal before creating files to prevent file leaks. 395 | journalWriter?.apply { 396 | writeUtf8(DIRTY) 397 | writeUtf8(" ") 398 | writeUtf8(key) 399 | writeUtf8("\n") 400 | flush() 401 | } 402 | return editor 403 | } 404 | 405 | /** 406 | * Returns the maximum number of bytes that this cache should use to store 407 | * its data. 408 | */ 409 | @Synchronized 410 | fun getMaxSize(): Long { 411 | return maxSize 412 | } 413 | 414 | /** 415 | * Changes the maximum number of bytes the cache can store and queues a job 416 | * to trim the existing store, if necessary. 417 | */ 418 | @Synchronized 419 | fun setMaxSize(maxSize: Long) { 420 | this.maxSize = maxSize 421 | executorService.submit(this::cleanupJournal) 422 | } 423 | 424 | /** 425 | * Returns the number of bytes currently being used to store the values in 426 | * this cache. This may be greater than the max size if a background 427 | * deletion is pending. 428 | */ 429 | @Synchronized 430 | fun size(): Long { 431 | return size 432 | } 433 | 434 | /** Returns true if this cache has been closed. */ 435 | @Synchronized 436 | fun isClosed(): Boolean { 437 | return journalWriter == null 438 | } 439 | 440 | @Synchronized 441 | @Throws(IOException::class) 442 | private fun completeEdit(editor: Editor, success: Boolean) { 443 | val entry = editor.entry 444 | if (entry.currentEditor != editor) { 445 | throw IllegalStateException() 446 | } 447 | 448 | // If this edit is creating the entry for the first time, every index must have a value. 449 | if (success && !entry.readable) { 450 | for (i in 0 until valueCount) { 451 | if (editor.written?.get(i) != true) { 452 | editor.abort() 453 | throw IllegalStateException("Newly created entry didn't create value for index $i.") 454 | } 455 | if (!entry.getDirtyFile(i).exists()) { 456 | editor.abort() 457 | return 458 | } 459 | } 460 | } 461 | 462 | for (i in 0 until valueCount) { 463 | val dirty = entry.getDirtyFile(i) 464 | if (success) { 465 | if (dirty.exists()) { 466 | val clean = entry.getCleanFile(i) 467 | dirty.renameTo(clean) 468 | val oldLength = entry.lengths[i] 469 | val newLength = clean.length() 470 | entry.lengths[i] = newLength 471 | size = size - oldLength + newLength 472 | } 473 | } else { 474 | dirty.deleteIfExists() 475 | } 476 | } 477 | 478 | redundantOpCount++ 479 | entry.currentEditor = null 480 | 481 | if (entry.readable or success) { 482 | entry.readable = true 483 | journalWriter?.apply { 484 | writeUtf8(CLEAN) 485 | writeUtf8(" ") 486 | writeUtf8(entry.key) 487 | writeUtf8(" ") 488 | writeUtf8(entry.getLengthsString()) 489 | writeUtf8("\n") 490 | } 491 | if (success) { 492 | entry.sequenceNumber = nextSequenceNumber++ 493 | } 494 | } else { 495 | lruEntries.remove(entry.key) 496 | journalWriter?.apply { 497 | writeUtf8(REMOVE) 498 | writeUtf8(" ") 499 | writeUtf8(entry.key) 500 | writeUtf8("\n") 501 | } 502 | } 503 | journalWriter?.flush() 504 | 505 | if (size > maxSize || journalRebuildRequired()) { 506 | executorService.submit(this::cleanupJournal) 507 | } 508 | } 509 | 510 | /** 511 | * Drops the entry for `key` if it exists and can be removed. Entries 512 | * actively being edited cannot be removed. 513 | * 514 | * @return true if an entry was removed. 515 | */ 516 | @Synchronized 517 | @Throws(IOException::class) 518 | fun remove(key: String): Boolean { 519 | ensureNotClosed() 520 | validateKey(key) 521 | 522 | val entry = lruEntries[key] 523 | if (entry == null || entry.currentEditor != null) { 524 | return false 525 | } 526 | 527 | for (i in 0 until valueCount) { 528 | val file = entry.getCleanFile(i) 529 | if (file.exists() && !file.delete()) { 530 | throw IOException("Failed to delete $file!") 531 | } 532 | size -= entry.lengths[i] 533 | entry.lengths[i] = 0 534 | } 535 | 536 | redundantOpCount++ 537 | journalWriter?.apply { 538 | writeUtf8(REMOVE) 539 | writeUtf8(" ") 540 | writeUtf8(key) 541 | writeUtf8("\n") 542 | } 543 | lruEntries.remove(key) 544 | 545 | if (journalRebuildRequired()) { 546 | executorService.submit(this::cleanupJournal) 547 | } 548 | 549 | return true 550 | } 551 | 552 | /** Force buffered operations to the filesystem. */ 553 | @Synchronized 554 | @Throws(IOException::class) 555 | fun flush() { 556 | ensureNotClosed() 557 | trimToSize() 558 | journalWriter?.flush() 559 | } 560 | 561 | /** Closes this cache. Stored values will remain on the filesystem. */ 562 | @Synchronized 563 | @Throws(IOException::class) 564 | override fun close() { 565 | journalWriter ?: return // Already closed. 566 | lruEntries.values.forEach { entry -> 567 | entry.currentEditor?.abort() 568 | } 569 | trimToSize() 570 | journalWriter?.close() 571 | journalWriter = null 572 | } 573 | 574 | /** 575 | * Closes the cache and deletes all of its stored values. This will delete 576 | * all files in the cache directory including files that weren't created by 577 | * the cache. 578 | */ 579 | @Throws(IOException::class) 580 | fun delete() { 581 | close() 582 | directory.deleteDirectory() 583 | } 584 | 585 | private fun ensureNotClosed() { 586 | journalWriter ?: throw IllegalStateException("Cache is closed!") 587 | } 588 | 589 | @Throws(IOException::class) 590 | private fun trimToSize() { 591 | while (size > maxSize) { 592 | remove(lruEntries.entries.iterator().next().key) 593 | } 594 | } 595 | 596 | /** 597 | * We only rebuild the journal when it will halve the size of the journal 598 | * and eliminate at least 2000 ops. 599 | */ 600 | private fun journalRebuildRequired(): Boolean { 601 | return redundantOpCount >= 2000 && redundantOpCount >= lruEntries.count() 602 | } 603 | 604 | private fun validateKey(key: String) { 605 | if (!LEGAL_KEY_PATTERN.matches(key)) { 606 | throw IllegalArgumentException("Keys must match regex $STRING_KEY_PATTERN: \"$key\"") 607 | } 608 | } 609 | 610 | /** A snapshot of the values for an entry. */ 611 | inner class Snapshot( 612 | private val key: String, 613 | private val sequenceNumber: Long, 614 | private val inputs: Array, 615 | private val lengths: LongArray 616 | ) : Closeable { 617 | 618 | /** 619 | * Returns an editor for this snapshot's entry, or null if either the 620 | * entry has changed since this snapshot was created or if another edit 621 | * is in progress. 622 | */ 623 | @Throws(IOException::class) 624 | fun edit(): Editor? { 625 | return this@DiskLruCache.edit(key, sequenceNumber) 626 | } 627 | 628 | /** Returns the unbuffered stream with the value for `index`. */ 629 | fun getSource(index: Int): Source { 630 | return inputs[index] 631 | } 632 | 633 | /** Returns the string value for `index`. */ 634 | @Throws(IOException::class) 635 | fun getString(index: Int): String { 636 | return getSource(index).buffer().readString(Charsets.UTF_8) 637 | } 638 | 639 | /** Returns the byte length of the value for `index`. */ 640 | fun getLength(index: Int): Long { 641 | return lengths[index] 642 | } 643 | 644 | override fun close() { 645 | inputs.forEach { it.closeQuietly() } 646 | } 647 | } 648 | 649 | /** Edits the values for an entry. */ 650 | inner class Editor internal constructor( 651 | internal val entry: Entry 652 | ) { 653 | 654 | internal var written = if (entry.readable) null else BooleanArray(valueCount) 655 | internal var hasErrors = false 656 | internal var committed = false 657 | 658 | /** 659 | * Returns the last committed value as a string, or null if no value 660 | * has been committed. 661 | */ 662 | @Throws(IOException::class) 663 | fun getString(index: Int): String? { 664 | return newSource(index)?.buffer()?.readString(Charsets.UTF_8) 665 | } 666 | 667 | /** Sets the value at `index` to `value`. */ 668 | @Throws(IOException::class) 669 | fun setString(index: Int, value: String) { 670 | newSink(index).buffer().use { it.writeUtf8(value) } 671 | } 672 | 673 | /** 674 | * Returns an unbuffered input stream to read the last committed value, 675 | * or null if no value has been committed. 676 | */ 677 | @Throws(IOException::class) 678 | fun newSource(index: Int): Source? { 679 | validateIndex(index) 680 | 681 | synchronized(this@DiskLruCache) { 682 | if (entry.currentEditor != this) { 683 | throw IllegalStateException() 684 | } 685 | if (!entry.readable) { 686 | return null 687 | } 688 | 689 | return try { 690 | entry.getCleanFile(index).source() 691 | } catch (e: FileNotFoundException) { 692 | null 693 | } 694 | } 695 | } 696 | 697 | /** 698 | * Returns a new unbuffered output stream to write the value at 699 | * `index`. If the underlying output stream encounters errors 700 | * when writing to the filesystem, this edit will be aborted when 701 | * [.commit] is called. The returned output stream does not throw 702 | * IOExceptions. 703 | */ 704 | @Throws(IOException::class) 705 | fun newSink(index: Int): Sink { 706 | validateIndex(index) 707 | 708 | synchronized(this@DiskLruCache) { 709 | if (entry.currentEditor != this) { 710 | throw IllegalStateException() 711 | } 712 | if (!entry.readable) { 713 | written?.set(index, true) 714 | } 715 | 716 | val dirtyFile = entry.getDirtyFile(index) 717 | val sink = try { 718 | dirtyFile.sink() 719 | } catch (ignored: FileNotFoundException) { 720 | // Attempt to recreate the cache directory. 721 | directory.mkdirs() 722 | try { 723 | dirtyFile.sink() 724 | } catch (ignored: FileNotFoundException) { 725 | // We are unable to recover. Silently eat the writes. 726 | return blackholeSink() 727 | } 728 | } 729 | 730 | return FaultHidingSink(sink) 731 | } 732 | } 733 | 734 | /** 735 | * Commits this edit so it is visible to readers. This releases the 736 | * edit lock so another edit may be started on the same key. 737 | */ 738 | @Throws(IOException::class) 739 | fun commit() { 740 | if (hasErrors) { 741 | completeEdit(this, false) 742 | remove(entry.key) // The previous entry is stale. 743 | } else { 744 | completeEdit(this, true) 745 | } 746 | committed = true 747 | } 748 | 749 | /** 750 | * Aborts this edit. This releases the edit lock so another edit may be 751 | * started on the same key. 752 | */ 753 | @Throws(IOException::class) 754 | fun abort() { 755 | completeEdit(this, false) 756 | } 757 | 758 | fun abortUnlessCommitted() { 759 | if (!committed) { 760 | try { 761 | abort() 762 | } catch (ignored: IOException) { 763 | } 764 | } 765 | } 766 | 767 | private fun validateIndex(index: Int) { 768 | if (index < 0 || index >= valueCount) { 769 | throw IllegalArgumentException("Expected index $index to be greater than 0 and less than the maximum value count of $valueCount.") 770 | } 771 | } 772 | 773 | private inner class FaultHidingSink(private val output: Sink) : Sink by output { 774 | 775 | override fun write(source: Buffer, byteCount: Long) { 776 | try { 777 | output.write(source, byteCount) 778 | } catch (e: IOException) { 779 | hasErrors = true 780 | } 781 | } 782 | 783 | override fun flush() { 784 | try { 785 | output.flush() 786 | } catch (e: IOException) { 787 | hasErrors = true 788 | } 789 | } 790 | 791 | override fun close() { 792 | try { 793 | output.close() 794 | } catch (e: IOException) { 795 | hasErrors = true 796 | } 797 | } 798 | } 799 | } 800 | 801 | internal inner class Entry(val key: String) { 802 | 803 | /** Lengths of this entry's files. */ 804 | val lengths = LongArray(valueCount) 805 | 806 | /** True if this entry has ever been published. */ 807 | var readable = false 808 | 809 | /** The ongoing edit or null if this entry is not being edited. */ 810 | var currentEditor: Editor? = null 811 | 812 | /** The sequence number of the most recently committed edit to this entry. */ 813 | var sequenceNumber = 0L 814 | 815 | @Throws(IOException::class) 816 | fun getLengthsString(): String { 817 | return lengths.joinToString(" ") 818 | } 819 | 820 | /** Set lengths using decimal numbers like "10123". */ 821 | @Throws(IOException::class) 822 | fun setLengths(strings: List) { 823 | if (strings.count() != valueCount) { 824 | throw invalidLengths(strings) 825 | } 826 | 827 | try { 828 | strings.forEachIndexed { index, value -> 829 | lengths[index] = value.toLong() 830 | } 831 | } catch (e: NumberFormatException) { 832 | throw invalidLengths(strings) 833 | } 834 | } 835 | 836 | fun getCleanFile(index: Int): File { 837 | return File(directory, "$key.$index") 838 | } 839 | 840 | fun getDirtyFile(index: Int): File { 841 | return File(directory, "$key.$index.tmp") 842 | } 843 | 844 | @Throws(IOException::class) 845 | private fun invalidLengths(strings: List): IOException { 846 | throw IOException("Unexpected journal line: ${strings.toTypedArray().contentToString()}") 847 | } 848 | } 849 | 850 | companion object { 851 | private const val CLEAN = "CLEAN" 852 | private const val DIRTY = "DIRTY" 853 | private const val REMOVE = "REMOVE" 854 | private const val READ = "READ" 855 | 856 | internal const val JOURNAL_FILE = "journal" 857 | internal const val JOURNAL_FILE_TEMP = "journal.tmp" 858 | internal const val JOURNAL_FILE_BACKUP = "journal.bkp" 859 | internal const val MAGIC = "libcore.io.DiskLruCache" 860 | internal const val VERSION_1 = "1" 861 | internal const val ANY_SEQUENCE_NUMBER = -1L 862 | internal const val STRING_KEY_PATTERN = "[a-z0-9_-]{1,120}" 863 | 864 | private val LEGAL_KEY_PATTERN = STRING_KEY_PATTERN.toRegex() 865 | 866 | /** 867 | * Opens the cache in `directory`, creating a cache if none exists 868 | * there. 869 | * 870 | * @param directory a writable directory 871 | * @param valueCount the number of values per cache entry. Must be positive. 872 | * @param maxSize the maximum number of bytes this cache should use to store 873 | * @throws IOException if reading or writing the cache directory fails 874 | */ 875 | @Throws(IOException::class) 876 | fun open(directory: File, appVersion: Int, valueCount: Int, maxSize: Long): DiskLruCache { 877 | if (maxSize <= 0) { 878 | throw IllegalArgumentException("maxSize <= 0") 879 | } 880 | if (valueCount <= 0) { 881 | throw IllegalArgumentException("valueCount <= 0") 882 | } 883 | 884 | // If a bkp file exists, use it instead. 885 | val backupFile = File(directory, JOURNAL_FILE_BACKUP) 886 | if (backupFile.exists()) { 887 | val journalFile = File(directory, JOURNAL_FILE) 888 | // If journal file also exists just delete backup file. 889 | if (journalFile.exists()) { 890 | backupFile.delete() 891 | } else { 892 | backupFile.renameTo(journalFile, false) 893 | } 894 | } 895 | 896 | // Prefer to pick up where we left off. 897 | var cache = DiskLruCache(directory, appVersion, valueCount, maxSize) 898 | if (cache.journalFile.exists()) { 899 | try { 900 | cache.readJournal() 901 | cache.processJournal() 902 | return cache 903 | } catch (journalIsCorrupt: IOException) { 904 | println("DiskLruCache $directory is corrupt: ${journalIsCorrupt.message}, removing.") 905 | cache.delete() 906 | } 907 | } 908 | 909 | // Create a new empty cache. 910 | directory.mkdirs() 911 | cache = DiskLruCache(directory, appVersion, valueCount, maxSize) 912 | cache.rebuildJournal() 913 | return cache 914 | } 915 | } 916 | } 917 | -------------------------------------------------------------------------------- /src/test/java/com/colinrtwhite/disklrucache/DiskLruCacheTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.colinrtwhite.disklrucache 18 | 19 | import okio.buffer 20 | import okio.sink 21 | import okio.source 22 | import org.apache.commons.io.FileUtils 23 | import org.assertj.core.api.Assertions.assertThat 24 | import org.junit.After 25 | import org.junit.Assert.fail 26 | import org.junit.Before 27 | import org.junit.Rule 28 | import org.junit.Test 29 | import org.junit.rules.TemporaryFolder 30 | import java.io.File 31 | import java.io.FileWriter 32 | 33 | class DiskLruCacheTest { 34 | 35 | private lateinit var cacheDir: File 36 | private lateinit var journalFile: File 37 | private lateinit var journalBkpFile: File 38 | private lateinit var cache: DiskLruCache 39 | 40 | @get:Rule 41 | var tempDir = TemporaryFolder() 42 | 43 | @Before 44 | fun setUp() { 45 | cacheDir = tempDir.newFolder("DiskLruCacheTest") 46 | journalFile = File(cacheDir, DiskLruCache.JOURNAL_FILE) 47 | journalBkpFile = File(cacheDir, DiskLruCache.JOURNAL_FILE_BACKUP) 48 | cacheDir.listFiles().forEach { it.delete() } 49 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 50 | } 51 | 52 | @After 53 | fun tearDown() { 54 | cache.close() 55 | } 56 | 57 | @Test 58 | fun emptyCache() { 59 | cache.close() 60 | assertJournalEquals() 61 | } 62 | 63 | @Test 64 | fun validateKey() { 65 | var key: String? = null 66 | try { 67 | key = "has_space " 68 | cache.edit(key) 69 | fail("Expecting an IllegalArgumentException as the key was invalid.") 70 | } catch (iae: IllegalArgumentException) { 71 | assertThat(iae.message).isEqualTo("Keys must match regex [a-z0-9_-]{1,120}: \"$key\"") 72 | } 73 | 74 | try { 75 | key = "has_CR\r" 76 | cache.edit(key) 77 | fail("Expecting an IllegalArgumentException as the key was invalid.") 78 | } catch (iae: IllegalArgumentException) { 79 | assertThat(iae.message).isEqualTo("Keys must match regex [a-z0-9_-]{1,120}: \"$key\"") 80 | } 81 | 82 | try { 83 | key = "has_LF\n" 84 | cache.edit(key) 85 | fail("Expecting an IllegalArgumentException as the key was invalid.") 86 | } catch (iae: IllegalArgumentException) { 87 | assertThat(iae.message).isEqualTo("Keys must match regex [a-z0-9_-]{1,120}: \"$key\"") 88 | } 89 | 90 | try { 91 | key = "has_invalid/" 92 | cache.edit(key) 93 | fail("Expecting an IllegalArgumentException as the key was invalid.") 94 | } catch (iae: IllegalArgumentException) { 95 | assertThat(iae.message).isEqualTo("Keys must match regex [a-z0-9_-]{1,120}: \"$key\"") 96 | } 97 | 98 | try { 99 | key = "has_invalid\u2603" 100 | cache.edit(key) 101 | fail("Expecting an IllegalArgumentException as the key was invalid.") 102 | } catch (iae: IllegalArgumentException) { 103 | assertThat(iae.message).isEqualTo("Keys must match regex [a-z0-9_-]{1,120}: \"$key\"") 104 | } 105 | 106 | try { 107 | key = "this_is_way_too_long_this_is_way_too_long_this_is_way_too_long_" + 108 | "this_is_way_too_long_this_is_way_too_long_this_is_way_too_long" 109 | cache.edit(key) 110 | fail("Expecting an IllegalArgumentException as the key was too long.") 111 | } catch (iae: IllegalArgumentException) { 112 | assertThat(iae.message).isEqualTo("Keys must match regex [a-z0-9_-]{1,120}: \"$key\"") 113 | } 114 | 115 | // Test valid cases. 116 | 117 | // Exactly 120. 118 | key = "0123456789012345678901234567890123456789012345678901234567890123456789" + "01234567890123456789012345678901234567890123456789" 119 | cache.edit(key)!!.abort() 120 | // Contains all valid characters. 121 | key = "abcdefghijklmnopqrstuvwxyz_0123456789" 122 | cache.edit(key)!!.abort() 123 | // Contains dash. 124 | key = "-20384573948576" 125 | cache.edit(key)!!.abort() 126 | } 127 | 128 | @Test 129 | fun writeAndReadEntry() { 130 | val creator = cache.edit("k1")!! 131 | creator.setString(0, "ABC") 132 | creator.setString(1, "DE") 133 | assertThat(creator.getString(0)).isNull() 134 | assertThat(creator.newSource(0)).isNull() 135 | assertThat(creator.getString(1)).isNull() 136 | assertThat(creator.newSource(1)).isNull() 137 | creator.commit() 138 | 139 | val snapshot = cache["k1"]!! 140 | assertThat(snapshot.getString(0)).isEqualTo("ABC") 141 | assertThat(snapshot.getLength(0)).isEqualTo(3) 142 | assertThat(snapshot.getString(1)).isEqualTo("DE") 143 | assertThat(snapshot.getLength(1)).isEqualTo(2) 144 | } 145 | 146 | @Test 147 | fun readAndWriteEntryAcrossCacheOpenAndClose() { 148 | val creator = cache.edit("k1")!! 149 | creator.setString(0, "A") 150 | creator.setString(1, "B") 151 | creator.commit() 152 | cache.close() 153 | 154 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 155 | val snapshot = cache["k1"]!! 156 | assertThat(snapshot.getString(0)).isEqualTo("A") 157 | assertThat(snapshot.getLength(0)).isEqualTo(1) 158 | assertThat(snapshot.getString(1)).isEqualTo("B") 159 | assertThat(snapshot.getLength(1)).isEqualTo(1) 160 | snapshot.close() 161 | } 162 | 163 | @Test 164 | fun readAndWriteEntryWithoutProperClose() { 165 | val creator = cache.edit("k1")!! 166 | creator.setString(0, "A") 167 | creator.setString(1, "B") 168 | creator.commit() 169 | 170 | // Simulate a dirty close of 'cache' by opening the cache directory again. 171 | val cache2 = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 172 | val snapshot = cache2["k1"]!! 173 | assertThat(snapshot.getString(0)).isEqualTo("A") 174 | assertThat(snapshot.getLength(0)).isEqualTo(1) 175 | assertThat(snapshot.getString(1)).isEqualTo("B") 176 | assertThat(snapshot.getLength(1)).isEqualTo(1) 177 | snapshot.close() 178 | cache2.close() 179 | } 180 | 181 | @Test 182 | fun journalWithEditAndPublish() { 183 | val creator = cache.edit("k1")!! 184 | assertJournalEquals("DIRTY k1") // DIRTY must always be flushed. 185 | creator.setString(0, "AB") 186 | creator.setString(1, "C") 187 | creator.commit() 188 | cache.close() 189 | assertJournalEquals("DIRTY k1", "CLEAN k1 2 1") 190 | } 191 | 192 | @Test 193 | fun revertedNewFileIsRemoveInJournal() { 194 | val creator = cache.edit("k1")!! 195 | assertJournalEquals("DIRTY k1") // DIRTY must always be flushed. 196 | creator.setString(0, "AB") 197 | creator.setString(1, "C") 198 | creator.abort() 199 | cache.close() 200 | assertJournalEquals("DIRTY k1", "REMOVE k1") 201 | } 202 | 203 | @Test 204 | fun unterminatedEditIsRevertedOnClose() { 205 | cache.edit("k1") 206 | cache.close() 207 | assertJournalEquals("DIRTY k1", "REMOVE k1") 208 | } 209 | 210 | @Test 211 | fun journalDoesNotIncludeReadOfYetUnpublishedValue() { 212 | val creator = cache.edit("k1")!! 213 | assertThat(cache["k1"]).isNull() 214 | creator.setString(0, "A") 215 | creator.setString(1, "BC") 216 | creator.commit() 217 | cache.close() 218 | assertJournalEquals("DIRTY k1", "CLEAN k1 1 2") 219 | } 220 | 221 | @Test 222 | fun journalWithEditAndPublishAndRead() { 223 | val k1Creator = cache.edit("k1")!! 224 | k1Creator.setString(0, "AB") 225 | k1Creator.setString(1, "C") 226 | k1Creator.commit() 227 | val k2Creator = cache.edit("k2")!! 228 | k2Creator.setString(0, "DEF") 229 | k2Creator.setString(1, "G") 230 | k2Creator.commit() 231 | val k1Snapshot = cache["k1"]!! 232 | k1Snapshot.close() 233 | cache.close() 234 | assertJournalEquals("DIRTY k1", "CLEAN k1 2 1", "DIRTY k2", "CLEAN k2 3 1", "READ k1") 235 | } 236 | 237 | @Test 238 | fun cannotOperateOnEditAfterPublish() { 239 | val editor = cache.edit("k1")!! 240 | editor.setString(0, "A") 241 | editor.setString(1, "B") 242 | editor.commit() 243 | assertInoperable(editor) 244 | } 245 | 246 | @Test 247 | fun cannotOperateOnEditAfterRevert() { 248 | val editor = cache.edit("k1")!! 249 | editor.setString(0, "A") 250 | editor.setString(1, "B") 251 | editor.abort() 252 | assertInoperable(editor) 253 | } 254 | 255 | @Test 256 | fun explicitRemoveAppliedToDiskImmediately() { 257 | val editor = cache.edit("k1")!! 258 | editor.setString(0, "ABC") 259 | editor.setString(1, "B") 260 | editor.commit() 261 | val k1 = getCleanFile("k1", 0) 262 | assertThat(readFile(k1)).isEqualTo("ABC") 263 | cache.remove("k1") 264 | assertThat(k1.exists()).isFalse() 265 | } 266 | 267 | /** 268 | * Each read sees a snapshot of the file at the time read was called. 269 | * This means that two reads of the same key can see different data. 270 | */ 271 | @Test 272 | fun readAndWriteOverlapsMaintainConsistency() { 273 | val v1Creator = cache.edit("k1")!! 274 | v1Creator.setString(0, "AAaa") 275 | v1Creator.setString(1, "BBbb") 276 | v1Creator.commit() 277 | 278 | val snapshot1 = cache["k1"]!! 279 | val inV1 = snapshot1.getSource(0).buffer() 280 | assertThat(inV1.readByte()).isEqualTo('A'.toByte()) 281 | assertThat(inV1.readByte()).isEqualTo('A'.toByte()) 282 | 283 | val v1Updater = cache.edit("k1")!! 284 | v1Updater.setString(0, "CCcc") 285 | v1Updater.setString(1, "DDdd") 286 | v1Updater.commit() 287 | 288 | val snapshot2 = cache["k1"]!! 289 | assertThat(snapshot2.getString(0)).isEqualTo("CCcc") 290 | assertThat(snapshot2.getLength(0)).isEqualTo(4) 291 | assertThat(snapshot2.getString(1)).isEqualTo("DDdd") 292 | assertThat(snapshot2.getLength(1)).isEqualTo(4) 293 | snapshot2.close() 294 | 295 | assertThat(inV1.readByte()).isEqualTo('a'.toByte()) 296 | assertThat(inV1.readByte()).isEqualTo('a'.toByte()) 297 | assertThat(snapshot1.getString(1)).isEqualTo("BBbb") 298 | assertThat(snapshot1.getLength(1)).isEqualTo(4) 299 | snapshot1.close() 300 | } 301 | 302 | @Test 303 | fun openWithDirtyKeyDeletesAllFilesForThatKey() { 304 | cache.close() 305 | val cleanFile0 = getCleanFile("k1", 0) 306 | val cleanFile1 = getCleanFile("k1", 1) 307 | val dirtyFile0 = getDirtyFile("k1", 0) 308 | val dirtyFile1 = getDirtyFile("k1", 1) 309 | writeFile(cleanFile0, "A") 310 | writeFile(cleanFile1, "B") 311 | writeFile(dirtyFile0, "C") 312 | writeFile(dirtyFile1, "D") 313 | createJournal("CLEAN k1 1 1", "DIRTY k1") 314 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 315 | assertThat(cleanFile0.exists()).isFalse() 316 | assertThat(cleanFile1.exists()).isFalse() 317 | assertThat(dirtyFile0.exists()).isFalse() 318 | assertThat(dirtyFile1.exists()).isFalse() 319 | assertThat(cache["k1"]).isNull() 320 | } 321 | 322 | @Test 323 | fun openWithInvalidVersionClearsDirectory() { 324 | cache.close() 325 | generateSomeGarbageFiles() 326 | createJournalWithHeader(DiskLruCache.MAGIC, "0", "100", "2", "") 327 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 328 | assertGarbageFilesAllDeleted() 329 | } 330 | 331 | @Test 332 | fun openWithInvalidAppVersionClearsDirectory() { 333 | cache.close() 334 | generateSomeGarbageFiles() 335 | createJournalWithHeader(DiskLruCache.MAGIC, "1", "101", "2", "") 336 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 337 | assertGarbageFilesAllDeleted() 338 | } 339 | 340 | @Test 341 | fun openWithInvalidValueCountClearsDirectory() { 342 | cache.close() 343 | generateSomeGarbageFiles() 344 | createJournalWithHeader(DiskLruCache.MAGIC, "1", "100", "1", "") 345 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 346 | assertGarbageFilesAllDeleted() 347 | } 348 | 349 | @Test 350 | fun openWithInvalidBlankLineClearsDirectory() { 351 | cache.close() 352 | generateSomeGarbageFiles() 353 | createJournalWithHeader(DiskLruCache.MAGIC, "1", "100", "2", "x") 354 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 355 | assertGarbageFilesAllDeleted() 356 | } 357 | 358 | @Test 359 | fun openWithInvalidJournalLineClearsDirectory() { 360 | cache.close() 361 | generateSomeGarbageFiles() 362 | createJournal("CLEAN k1 1 1", "BOGUS") 363 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 364 | assertGarbageFilesAllDeleted() 365 | assertThat(cache["k1"]).isNull() 366 | } 367 | 368 | @Test 369 | fun openWithInvalidFileSizeClearsDirectory() { 370 | cache.close() 371 | generateSomeGarbageFiles() 372 | createJournal("CLEAN k1 0000x001 1") 373 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 374 | assertGarbageFilesAllDeleted() 375 | assertThat(cache["k1"]).isNull() 376 | } 377 | 378 | @Test 379 | fun openWithTruncatedLineDiscardsThatLine() { 380 | cache.close() 381 | writeFile(getCleanFile("k1", 0), "A") 382 | writeFile(getCleanFile("k1", 1), "B") 383 | val writer = FileWriter(journalFile) 384 | writer.write(DiskLruCache.MAGIC + "\n" + DiskLruCache.VERSION_1 + "\n100\n2\n\nCLEAN k1 1 1") // no trailing newline 385 | writer.close() 386 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 387 | assertThat(cache["k1"]).isNull() 388 | 389 | // The journal is not corrupt when editing after a truncated line. 390 | set("k1", "C", "D") 391 | cache.close() 392 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 393 | assertValue("k1", "C", "D") 394 | } 395 | 396 | @Test 397 | fun openWithTooManyFileSizesClearsDirectory() { 398 | cache.close() 399 | generateSomeGarbageFiles() 400 | createJournal("CLEAN k1 1 1 1") 401 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 402 | assertGarbageFilesAllDeleted() 403 | assertThat(cache["k1"]).isNull() 404 | } 405 | 406 | @Test 407 | fun keyWithSpaceNotPermitted() { 408 | try { 409 | cache.edit("my key") 410 | fail() 411 | } catch (expected: IllegalArgumentException) { 412 | } 413 | } 414 | 415 | @Test 416 | fun keyWithNewlineNotPermitted() { 417 | try { 418 | cache.edit("my\nkey") 419 | fail() 420 | } catch (expected: IllegalArgumentException) { 421 | } 422 | } 423 | 424 | @Test 425 | fun keyWithCarriageReturnNotPermitted() { 426 | try { 427 | cache.edit("my\rkey") 428 | fail() 429 | } catch (expected: IllegalArgumentException) { 430 | } 431 | } 432 | 433 | @Test 434 | fun createNewEntryWithTooFewValuesFails() { 435 | val creator = cache.edit("k1")!! 436 | creator.setString(1, "A") 437 | try { 438 | creator.commit() 439 | fail() 440 | } catch (expected: IllegalStateException) { 441 | } 442 | 443 | assertThat(getCleanFile("k1", 0).exists()).isFalse() 444 | assertThat(getCleanFile("k1", 1).exists()).isFalse() 445 | assertThat(getDirtyFile("k1", 0).exists()).isFalse() 446 | assertThat(getDirtyFile("k1", 1).exists()).isFalse() 447 | assertThat(cache["k1"]).isNull() 448 | 449 | val creator2 = cache.edit("k1")!! 450 | creator2.setString(0, "B") 451 | creator2.setString(1, "C") 452 | creator2.commit() 453 | } 454 | 455 | @Test 456 | fun revertWithTooFewValues() { 457 | val creator = cache.edit("k1")!! 458 | creator.setString(1, "A") 459 | creator.abort() 460 | assertThat(getCleanFile("k1", 0).exists()).isFalse() 461 | assertThat(getCleanFile("k1", 1).exists()).isFalse() 462 | assertThat(getDirtyFile("k1", 0).exists()).isFalse() 463 | assertThat(getDirtyFile("k1", 1).exists()).isFalse() 464 | assertThat(cache["k1"]).isNull() 465 | } 466 | 467 | @Test 468 | fun updateExistingEntryWithTooFewValuesReusesPreviousValues() { 469 | val creator = cache.edit("k1")!! 470 | creator.setString(0, "A") 471 | creator.setString(1, "B") 472 | creator.commit() 473 | 474 | val updater = cache.edit("k1")!! 475 | updater.setString(0, "C") 476 | updater.commit() 477 | 478 | val snapshot = cache["k1"]!! 479 | assertThat(snapshot.getString(0)).isEqualTo("C") 480 | assertThat(snapshot.getLength(0)).isEqualTo(1) 481 | assertThat(snapshot.getString(1)).isEqualTo("B") 482 | assertThat(snapshot.getLength(1)).isEqualTo(1) 483 | snapshot.close() 484 | } 485 | 486 | @Test 487 | fun growMaxSize() { 488 | cache.close() 489 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 10) 490 | set("a", "a", "aaa") // size 4 491 | set("b", "bb", "bbbb") // size 6 492 | cache.setMaxSize(20) 493 | set("c", "c", "c") // size 12 494 | assertThat(cache.size()).isEqualTo(12) 495 | } 496 | 497 | @Test 498 | fun shrinkMaxSizeEvicts() { 499 | cache.close() 500 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 20) 501 | set("a", "a", "aaa") // size 4 502 | set("b", "bb", "bbbb") // size 6 503 | set("c", "c", "c") // size 12 504 | cache.setMaxSize(10) 505 | assertThat(cache.executorService.queue.size).isEqualTo(1) 506 | cache.executorService.purge() 507 | } 508 | 509 | @Test 510 | fun evictOnInsert() { 511 | cache.close() 512 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 10) 513 | 514 | set("a", "a", "aaa") // size 4 515 | set("b", "bb", "bbbb") // size 6 516 | assertThat(cache.size()).isEqualTo(10) 517 | 518 | // Cause the size to grow to 12 should evict 'A'. 519 | set("c", "c", "c") 520 | cache.flush() 521 | assertThat(cache.size()).isEqualTo(8) 522 | assertAbsent("a") 523 | assertValue("b", "bb", "bbbb") 524 | assertValue("c", "c", "c") 525 | 526 | // Causing the size to grow to 10 should evict nothing. 527 | set("d", "d", "d") 528 | cache.flush() 529 | assertThat(cache.size()).isEqualTo(10) 530 | assertAbsent("a") 531 | assertValue("b", "bb", "bbbb") 532 | assertValue("c", "c", "c") 533 | assertValue("d", "d", "d") 534 | 535 | // Causing the size to grow to 18 should evict 'B' and 'C'. 536 | set("e", "eeee", "eeee") 537 | cache.flush() 538 | assertThat(cache.size()).isEqualTo(10) 539 | assertAbsent("a") 540 | assertAbsent("b") 541 | assertAbsent("c") 542 | assertValue("d", "d", "d") 543 | assertValue("e", "eeee", "eeee") 544 | } 545 | 546 | @Test 547 | fun evictOnUpdate() { 548 | cache.close() 549 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 10) 550 | 551 | set("a", "a", "aa") // size 3 552 | set("b", "b", "bb") // size 3 553 | set("c", "c", "cc") // size 3 554 | assertThat(cache.size()).isEqualTo(9) 555 | 556 | // Causing the size to grow to 11 should evict 'A'. 557 | set("b", "b", "bbbb") 558 | cache.flush() 559 | assertThat(cache.size()).isEqualTo(8) 560 | assertAbsent("a") 561 | assertValue("b", "b", "bbbb") 562 | assertValue("c", "c", "cc") 563 | } 564 | 565 | @Test 566 | fun evictionHonorsLruFromCurrentSession() { 567 | cache.close() 568 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 10) 569 | set("a", "a", "a") 570 | set("b", "b", "b") 571 | set("c", "c", "c") 572 | set("d", "d", "d") 573 | set("e", "e", "e") 574 | cache["b"]!!.close() // 'B' is now least recently used. 575 | 576 | // Causing the size to grow to 12 should evict 'A'. 577 | set("f", "f", "f") 578 | // Causing the size to grow to 12 should evict 'C'. 579 | set("g", "g", "g") 580 | cache.flush() 581 | assertThat(cache.size()).isEqualTo(10) 582 | assertAbsent("a") 583 | assertValue("b", "b", "b") 584 | assertAbsent("c") 585 | assertValue("d", "d", "d") 586 | assertValue("e", "e", "e") 587 | assertValue("f", "f", "f") 588 | } 589 | 590 | @Test 591 | fun evictionHonorsLruFromPreviousSession() { 592 | set("a", "a", "a") 593 | set("b", "b", "b") 594 | set("c", "c", "c") 595 | set("d", "d", "d") 596 | set("e", "e", "e") 597 | set("f", "f", "f") 598 | cache["b"]!!.close() // 'B' is now least recently used. 599 | assertThat(cache.size()).isEqualTo(12) 600 | cache.close() 601 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 10) 602 | 603 | set("g", "g", "g") 604 | cache.flush() 605 | assertThat(cache.size()).isEqualTo(10) 606 | assertAbsent("a") 607 | assertValue("b", "b", "b") 608 | assertAbsent("c") 609 | assertValue("d", "d", "d") 610 | assertValue("e", "e", "e") 611 | assertValue("f", "f", "f") 612 | assertValue("g", "g", "g") 613 | } 614 | 615 | @Test 616 | fun cacheSingleEntryOfSizeGreaterThanMaxSize() { 617 | cache.close() 618 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 10) 619 | set("a", "aaaaa", "aaaaaa") // size=11 620 | cache.flush() 621 | assertAbsent("a") 622 | } 623 | 624 | @Test 625 | fun cacheSingleValueOfSizeGreaterThanMaxSize() { 626 | cache.close() 627 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 10) 628 | set("a", "aaaaaaaaaaa", "a") // size=12 629 | cache.flush() 630 | assertAbsent("a") 631 | } 632 | 633 | @Test 634 | fun constructorDoesNotAllowZeroCacheSize() { 635 | try { 636 | DiskLruCache.open(cacheDir, APP_VERSION, 2, 0) 637 | fail() 638 | } catch (expected: IllegalArgumentException) { 639 | } 640 | } 641 | 642 | @Test 643 | fun constructorDoesNotAllowZeroValuesPerEntry() { 644 | try { 645 | DiskLruCache.open(cacheDir, APP_VERSION, 0, 10) 646 | fail() 647 | } catch (expected: IllegalArgumentException) { 648 | } 649 | } 650 | 651 | @Test 652 | fun removeAbsentElement() { 653 | cache.remove("a") 654 | } 655 | 656 | @Test 657 | fun readingTheSameStreamMultipleTimes() { 658 | set("a", "a", "b") 659 | val snapshot = cache["a"]!! 660 | assertThat(snapshot.getSource(0)).isSameAs(snapshot.getSource(0)) 661 | snapshot.close() 662 | } 663 | 664 | @Test 665 | fun rebuildJournalOnRepeatedReads() { 666 | set("a", "a", "a") 667 | set("b", "b", "b") 668 | var lastJournalLength: Long = 0 669 | while (true) { 670 | val journalLength = journalFile.length() 671 | assertValue("a", "a", "a") 672 | assertValue("b", "b", "b") 673 | if (journalLength < lastJournalLength) { 674 | println("Journal compacted from $lastJournalLength bytes to $journalLength bytes") 675 | break // Test passed! 676 | } 677 | lastJournalLength = journalLength 678 | } 679 | } 680 | 681 | @Test 682 | fun rebuildJournalOnRepeatedEdits() { 683 | var lastJournalLength: Long = 0 684 | while (true) { 685 | val journalLength = journalFile.length() 686 | set("a", "a", "a") 687 | set("b", "b", "b") 688 | if (journalLength < lastJournalLength) { 689 | println("Journal compacted from $lastJournalLength bytes to $journalLength bytes") 690 | break 691 | } 692 | lastJournalLength = journalLength 693 | } 694 | 695 | // Sanity check that a rebuilt journal behaves normally. 696 | assertValue("a", "a", "a") 697 | assertValue("b", "b", "b") 698 | } 699 | 700 | /** 701 | * @see [Issue .28](https://github.com/JakeWharton/DiskLruCache/issues/28) 702 | */ 703 | @Test 704 | fun rebuildJournalOnRepeatedReadsWithOpenAndClose() { 705 | set("a", "a", "a") 706 | set("b", "b", "b") 707 | var lastJournalLength = 0L 708 | while (true) { 709 | val journalLength = journalFile.length() 710 | assertValue("a", "a", "a") 711 | assertValue("b", "b", "b") 712 | cache.close() 713 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 714 | if (journalLength < lastJournalLength) { 715 | println("Journal compacted from $lastJournalLength bytes to $journalLength bytes.") 716 | break // Test passed! 717 | } 718 | lastJournalLength = journalLength 719 | } 720 | } 721 | 722 | /** 723 | * @see [Issue .28](https://github.com/JakeWharton/DiskLruCache/issues/28) 724 | */ 725 | @Test 726 | fun rebuildJournalOnRepeatedEditsWithOpenAndClose() { 727 | var lastJournalLength: Long = 0 728 | while (true) { 729 | val journalLength = journalFile.length() 730 | set("a", "a", "a") 731 | set("b", "b", "b") 732 | cache.close() 733 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 734 | if (journalLength < lastJournalLength) { 735 | println("Journal compacted from $lastJournalLength bytes to $journalLength bytes.") 736 | break 737 | } 738 | lastJournalLength = journalLength 739 | } 740 | } 741 | 742 | @Test 743 | fun restoreBackupFile() { 744 | val creator = cache.edit("k1")!! 745 | creator.setString(0, "ABC") 746 | creator.setString(1, "DE") 747 | creator.commit() 748 | cache.close() 749 | 750 | assertThat(journalFile.renameTo(journalBkpFile)).isTrue() 751 | assertThat(journalFile.exists()).isFalse() 752 | 753 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 754 | 755 | val snapshot = cache["k1"]!! 756 | assertThat(snapshot.getString(0)).isEqualTo("ABC") 757 | assertThat(snapshot.getLength(0)).isEqualTo(3) 758 | assertThat(snapshot.getString(1)).isEqualTo("DE") 759 | assertThat(snapshot.getLength(1)).isEqualTo(2) 760 | 761 | assertThat(journalBkpFile.exists()).isFalse() 762 | assertThat(journalFile.exists()).isTrue() 763 | } 764 | 765 | @Test 766 | fun journalFileIsPreferredOverBackupFile() { 767 | var creator = cache.edit("k1")!! 768 | creator.setString(0, "ABC") 769 | creator.setString(1, "DE") 770 | creator.commit() 771 | cache.flush() 772 | 773 | FileUtils.copyFile(journalFile, journalBkpFile) 774 | 775 | creator = cache.edit("k2")!! 776 | creator.setString(0, "F") 777 | creator.setString(1, "GH") 778 | creator.commit() 779 | cache.close() 780 | 781 | assertThat(journalFile.exists()).isTrue() 782 | assertThat(journalBkpFile.exists()).isTrue() 783 | 784 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, Long.MAX_VALUE) 785 | 786 | val snapshotA = cache["k1"]!! 787 | assertThat(snapshotA.getString(0)).isEqualTo("ABC") 788 | assertThat(snapshotA.getLength(0)).isEqualTo(3) 789 | assertThat(snapshotA.getString(1)).isEqualTo("DE") 790 | assertThat(snapshotA.getLength(1)).isEqualTo(2) 791 | 792 | val snapshotB = cache["k2"]!! 793 | assertThat(snapshotB.getString(0)).isEqualTo("F") 794 | assertThat(snapshotB.getLength(0)).isEqualTo(1) 795 | assertThat(snapshotB.getString(1)).isEqualTo("GH") 796 | assertThat(snapshotB.getLength(1)).isEqualTo(2) 797 | 798 | assertThat(journalBkpFile.exists()).isFalse() 799 | assertThat(journalFile.exists()).isTrue() 800 | } 801 | 802 | @Test 803 | fun openCreatesDirectoryIfNecessary() { 804 | cache.close() 805 | val dir = tempDir.newFolder("testOpenCreatesDirectoryIfNecessary") 806 | cache = DiskLruCache.open(dir, APP_VERSION, 2, Long.MAX_VALUE) 807 | set("a", "a", "a") 808 | assertThat(File(dir, "a.0").exists()).isTrue() 809 | assertThat(File(dir, "a.1").exists()).isTrue() 810 | assertThat(File(dir, "journal").exists()).isTrue() 811 | } 812 | 813 | @Test 814 | fun fileDeletedExternally() { 815 | set("a", "a", "a") 816 | getCleanFile("a", 1).delete() 817 | assertThat(cache["a"]).isNull() 818 | } 819 | 820 | @Test 821 | fun editSameVersion() { 822 | set("a", "a", "a") 823 | val snapshot = cache["a"]!! 824 | val editor = snapshot.edit()!! 825 | editor.setString(1, "a2") 826 | editor.commit() 827 | assertValue("a", "a", "a2") 828 | } 829 | 830 | @Test 831 | fun editSnapshotAfterChangeAborted() { 832 | set("a", "a", "a") 833 | val snapshot = cache["a"]!! 834 | val toAbort = snapshot.edit()!! 835 | toAbort.setString(0, "b") 836 | toAbort.abort() 837 | val editor = snapshot.edit()!! 838 | editor.setString(1, "a2") 839 | editor.commit() 840 | assertValue("a", "a", "a2") 841 | } 842 | 843 | @Test 844 | fun editSnapshotAfterChangeCommitted() { 845 | set("a", "a", "a") 846 | val snapshot = cache["a"]!! 847 | val toAbort = snapshot.edit()!! 848 | toAbort.setString(0, "b") 849 | toAbort.commit() 850 | assertThat(snapshot.edit()).isNull() 851 | } 852 | 853 | @Test 854 | fun editSinceEvicted() { 855 | cache.close() 856 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 10) 857 | set("a", "aa", "aaa") // size 5 858 | val snapshot = cache["a"]!! 859 | set("b", "bb", "bbb") // size 5 860 | set("c", "cc", "ccc") // size 5; will evict 'A' 861 | cache.flush() 862 | assertThat(snapshot.edit()).isNull() 863 | } 864 | 865 | @Test 866 | fun editSinceEvictedAndRecreated() { 867 | cache.close() 868 | cache = DiskLruCache.open(cacheDir, APP_VERSION, 2, 10) 869 | set("a", "aa", "aaa") // size 5 870 | val snapshot = cache["a"]!! 871 | set("b", "bb", "bbb") // size 5 872 | set("c", "cc", "ccc") // size 5; will evict 'A' 873 | set("a", "a", "aaaa") // size 5; will evict 'B' 874 | cache.flush() 875 | assertThat(snapshot.edit()).isNull() 876 | } 877 | 878 | /** 879 | * @see [Issue .2](https://github.com/JakeWharton/DiskLruCache/issues/2) 880 | */ 881 | @Test 882 | fun aggressiveClearingHandlesWrite() { 883 | FileUtils.deleteDirectory(cacheDir) 884 | set("a", "a", "a") 885 | assertValue("a", "a", "a") 886 | } 887 | 888 | /** 889 | * @see [Issue .2](https://github.com/JakeWharton/DiskLruCache/issues/2) 890 | */ 891 | @Test 892 | fun aggressiveClearingHandlesEdit() { 893 | set("a", "a", "a") 894 | val a = cache["a"]!!.edit()!! 895 | FileUtils.deleteDirectory(cacheDir) 896 | a.setString(1, "a2") 897 | a.commit() 898 | } 899 | 900 | @Test 901 | fun removeHandlesMissingFile() { 902 | set("a", "a", "a") 903 | getCleanFile("a", 0).delete() 904 | cache.remove("a") 905 | } 906 | 907 | /** 908 | * @see [Issue .2](https://github.com/JakeWharton/DiskLruCache/issues/2) 909 | */ 910 | @Test 911 | fun aggressiveClearingHandlesPartialEdit() { 912 | set("a", "a", "a") 913 | set("b", "b", "b") 914 | val a = cache["a"]!!.edit()!! 915 | a.setString(0, "a1") 916 | FileUtils.deleteDirectory(cacheDir) 917 | a.setString(1, "a2") 918 | a.commit() 919 | assertThat(cache["a"]).isNull() 920 | } 921 | 922 | /** @see [Issue .2](https://github.com/JakeWharton/DiskLruCache/issues/2) 923 | */ 924 | @Test 925 | fun aggressiveClearingHandlesRead() { 926 | FileUtils.deleteDirectory(cacheDir) 927 | assertThat(cache["a"]).isNull() 928 | } 929 | 930 | private fun assertJournalEquals(vararg expectedBodyLines: String) { 931 | val expectedLines = ArrayList() 932 | expectedLines.add(DiskLruCache.MAGIC) 933 | expectedLines.add(DiskLruCache.VERSION_1) 934 | expectedLines.add("100") 935 | expectedLines.add("2") 936 | expectedLines.add("") 937 | expectedLines.addAll(expectedBodyLines.toList()) 938 | assertThat(readJournalLines()).isEqualTo(expectedLines) 939 | } 940 | 941 | private fun createJournal(vararg bodyLines: String) { 942 | createJournalWithHeader(DiskLruCache.MAGIC, DiskLruCache.VERSION_1, "100", "2", "", *bodyLines) 943 | } 944 | 945 | private fun createJournalWithHeader( 946 | magic: String, 947 | version: String, 948 | appVersion: String, 949 | valueCount: String, 950 | blank: String, 951 | vararg bodyLines: String 952 | ) { 953 | val writer = FileWriter(journalFile) 954 | writer.write(magic + "\n") 955 | writer.write(version + "\n") 956 | writer.write(appVersion + "\n") 957 | writer.write(valueCount + "\n") 958 | writer.write(blank + "\n") 959 | for (line in bodyLines) { 960 | writer.write(line) 961 | writer.write('\n'.toInt()) 962 | } 963 | writer.close() 964 | } 965 | 966 | private fun readJournalLines(): List { 967 | val lines = mutableListOf() 968 | val sink = journalFile.source().buffer() 969 | var line = sink.readUtf8Line() 970 | while (line != null) { 971 | lines += line 972 | line = sink.readUtf8Line() 973 | } 974 | return lines 975 | } 976 | 977 | private fun getCleanFile(key: String, index: Int): File { 978 | return File(cacheDir, "$key.$index") 979 | } 980 | 981 | private fun getDirtyFile(key: String, index: Int): File { 982 | return File(cacheDir, "$key.$index.tmp") 983 | } 984 | 985 | private fun generateSomeGarbageFiles() { 986 | val dir1 = File(cacheDir, "dir1") 987 | val dir2 = File(dir1, "dir2") 988 | writeFile(getCleanFile("g1", 0), "A") 989 | writeFile(getCleanFile("g1", 1), "B") 990 | writeFile(getCleanFile("g2", 0), "C") 991 | writeFile(getCleanFile("g2", 1), "D") 992 | writeFile(getCleanFile("g2", 1), "D") 993 | writeFile(File(cacheDir, "otherFile0"), "E") 994 | dir1.mkdir() 995 | dir2.mkdir() 996 | writeFile(File(dir2, "otherFile1"), "F") 997 | } 998 | 999 | private fun assertGarbageFilesAllDeleted() { 1000 | assertThat(getCleanFile("g1", 0)).doesNotExist() 1001 | assertThat(getCleanFile("g1", 1)).doesNotExist() 1002 | assertThat(getCleanFile("g2", 0)).doesNotExist() 1003 | assertThat(getCleanFile("g2", 1)).doesNotExist() 1004 | assertThat(File(cacheDir, "otherFile0")).doesNotExist() 1005 | assertThat(File(cacheDir, "dir1")).doesNotExist() 1006 | } 1007 | 1008 | private operator fun set(key: String, value0: String, value1: String) { 1009 | val editor = cache.edit(key)!! 1010 | editor.setString(0, value0) 1011 | editor.setString(1, value1) 1012 | editor.commit() 1013 | } 1014 | 1015 | private fun assertAbsent(key: String) { 1016 | val snapshot = cache[key] 1017 | if (snapshot != null) { 1018 | snapshot.close() 1019 | fail() 1020 | } 1021 | assertThat(getCleanFile(key, 0)).doesNotExist() 1022 | assertThat(getCleanFile(key, 1)).doesNotExist() 1023 | assertThat(getDirtyFile(key, 0)).doesNotExist() 1024 | assertThat(getDirtyFile(key, 1)).doesNotExist() 1025 | } 1026 | 1027 | private fun assertValue(key: String, value0: String, value1: String) { 1028 | val snapshot = cache[key]!! 1029 | assertThat(snapshot.getString(0)).isEqualTo(value0) 1030 | assertThat(snapshot.getLength(0)).isEqualTo(value0.length.toLong()) 1031 | assertThat(snapshot.getString(1)).isEqualTo(value1) 1032 | assertThat(snapshot.getLength(1)).isEqualTo(value1.length.toLong()) 1033 | assertThat(getCleanFile(key, 0)).exists() 1034 | assertThat(getCleanFile(key, 1)).exists() 1035 | snapshot.close() 1036 | } 1037 | 1038 | companion object { 1039 | 1040 | private const val APP_VERSION = 100 1041 | 1042 | private fun readFile(file: File): String { 1043 | return file.source().buffer().readString(Charsets.UTF_8) 1044 | } 1045 | 1046 | fun writeFile(file: File, content: String) { 1047 | file.sink().buffer().writeUtf8(content) 1048 | } 1049 | 1050 | private fun assertInoperable(editor: DiskLruCache.Editor) { 1051 | try { 1052 | editor.getString(0) 1053 | fail() 1054 | } catch (expected: IllegalStateException) { 1055 | } 1056 | 1057 | try { 1058 | editor.setString(0, "A") 1059 | fail() 1060 | } catch (expected: IllegalStateException) { 1061 | } 1062 | 1063 | try { 1064 | editor.newSource(0) 1065 | fail() 1066 | } catch (expected: IllegalStateException) { 1067 | } 1068 | 1069 | try { 1070 | editor.newSink(0) 1071 | fail() 1072 | } catch (expected: IllegalStateException) { 1073 | } 1074 | 1075 | try { 1076 | editor.commit() 1077 | fail() 1078 | } catch (expected: IllegalStateException) { 1079 | } 1080 | 1081 | try { 1082 | editor.abort() 1083 | fail() 1084 | } catch (expected: IllegalStateException) { 1085 | } 1086 | } 1087 | } 1088 | } 1089 | --------------------------------------------------------------------------------