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.android.volley.toolbox; 18 19 import android.os.SystemClock; 20 import android.text.TextUtils; 21 import androidx.annotation.VisibleForTesting; 22 import com.android.volley.Cache; 23 import com.android.volley.Header; 24 import com.android.volley.VolleyLog; 25 import java.io.BufferedInputStream; 26 import java.io.BufferedOutputStream; 27 import java.io.DataInputStream; 28 import java.io.EOFException; 29 import java.io.File; 30 import java.io.FileInputStream; 31 import java.io.FileNotFoundException; 32 import java.io.FileOutputStream; 33 import java.io.FilterInputStream; 34 import java.io.IOException; 35 import java.io.InputStream; 36 import java.io.OutputStream; 37 import java.util.ArrayList; 38 import java.util.Collections; 39 import java.util.Iterator; 40 import java.util.LinkedHashMap; 41 import java.util.List; 42 import java.util.Map; 43 44 /** 45 * Cache implementation that caches files directly onto the hard disk in the specified directory. 46 * The default disk usage size is 5MB, but is configurable. 47 * 48 * <p>This cache supports the {@link Entry#allResponseHeaders} headers field. 49 */ 50 public class DiskBasedCache implements Cache { 51 52 /** Map of the Key, CacheHeader pairs */ 53 private final Map<String, CacheHeader> mEntries = new LinkedHashMap<>(16, .75f, true); 54 55 /** Total amount of space currently used by the cache in bytes. */ 56 private long mTotalSize = 0; 57 58 /** The supplier for the root directory to use for the cache. */ 59 private final FileSupplier mRootDirectorySupplier; 60 61 /** The maximum size of the cache in bytes. */ 62 private final int mMaxCacheSizeInBytes; 63 64 /** Default maximum disk usage in bytes. */ 65 private static final int DEFAULT_DISK_USAGE_BYTES = 5 * 1024 * 1024; 66 67 /** High water mark percentage for the cache */ 68 @VisibleForTesting static final float HYSTERESIS_FACTOR = 0.9f; 69 70 /** Magic number for current version of cache file format. */ 71 private static final int CACHE_MAGIC = 0x20150306; 72 73 /** 74 * Constructs an instance of the DiskBasedCache at the specified directory. 75 * 76 * @param rootDirectory The root directory of the cache. 77 * @param maxCacheSizeInBytes The maximum size of the cache in bytes. Note that the cache may 78 * briefly exceed this size on disk when writing a new entry that pushes it over the limit 79 * until the ensuing pruning completes. 80 */ DiskBasedCache(final File rootDirectory, int maxCacheSizeInBytes)81 public DiskBasedCache(final File rootDirectory, int maxCacheSizeInBytes) { 82 mRootDirectorySupplier = 83 new FileSupplier() { 84 @Override 85 public File get() { 86 return rootDirectory; 87 } 88 }; 89 mMaxCacheSizeInBytes = maxCacheSizeInBytes; 90 } 91 92 /** 93 * Constructs an instance of the DiskBasedCache at the specified directory. 94 * 95 * @param rootDirectorySupplier The supplier for the root directory of the cache. 96 * @param maxCacheSizeInBytes The maximum size of the cache in bytes. Note that the cache may 97 * briefly exceed this size on disk when writing a new entry that pushes it over the limit 98 * until the ensuing pruning completes. 99 */ DiskBasedCache(FileSupplier rootDirectorySupplier, int maxCacheSizeInBytes)100 public DiskBasedCache(FileSupplier rootDirectorySupplier, int maxCacheSizeInBytes) { 101 mRootDirectorySupplier = rootDirectorySupplier; 102 mMaxCacheSizeInBytes = maxCacheSizeInBytes; 103 } 104 105 /** 106 * Constructs an instance of the DiskBasedCache at the specified directory using the default 107 * maximum cache size of 5MB. 108 * 109 * @param rootDirectory The root directory of the cache. 110 */ DiskBasedCache(File rootDirectory)111 public DiskBasedCache(File rootDirectory) { 112 this(rootDirectory, DEFAULT_DISK_USAGE_BYTES); 113 } 114 115 /** 116 * Constructs an instance of the DiskBasedCache at the specified directory using the default 117 * maximum cache size of 5MB. 118 * 119 * @param rootDirectorySupplier The supplier for the root directory of the cache. 120 */ DiskBasedCache(FileSupplier rootDirectorySupplier)121 public DiskBasedCache(FileSupplier rootDirectorySupplier) { 122 this(rootDirectorySupplier, DEFAULT_DISK_USAGE_BYTES); 123 } 124 125 /** Clears the cache. Deletes all cached files from disk. */ 126 @Override clear()127 public synchronized void clear() { 128 File[] files = mRootDirectorySupplier.get().listFiles(); 129 if (files != null) { 130 for (File file : files) { 131 file.delete(); 132 } 133 } 134 mEntries.clear(); 135 mTotalSize = 0; 136 VolleyLog.d("Cache cleared."); 137 } 138 139 /** Returns the cache entry with the specified key if it exists, null otherwise. */ 140 @Override get(String key)141 public synchronized Entry get(String key) { 142 CacheHeader entry = mEntries.get(key); 143 // if the entry does not exist, return. 144 if (entry == null) { 145 return null; 146 } 147 File file = getFileForKey(key); 148 try { 149 CountingInputStream cis = 150 new CountingInputStream( 151 new BufferedInputStream(createInputStream(file)), file.length()); 152 try { 153 CacheHeader entryOnDisk = CacheHeader.readHeader(cis); 154 if (!TextUtils.equals(key, entryOnDisk.key)) { 155 // File was shared by two keys and now holds data for a different entry! 156 VolleyLog.d( 157 "%s: key=%s, found=%s", file.getAbsolutePath(), key, entryOnDisk.key); 158 // Remove key whose contents on disk have been replaced. 159 removeEntry(key); 160 return null; 161 } 162 byte[] data = streamToBytes(cis, cis.bytesRemaining()); 163 return entry.toCacheEntry(data); 164 } finally { 165 // Any IOException thrown here is handled by the below catch block by design. 166 //noinspection ThrowFromFinallyBlock 167 cis.close(); 168 } 169 } catch (IOException e) { 170 VolleyLog.d("%s: %s", file.getAbsolutePath(), e.toString()); 171 remove(key); 172 return null; 173 } 174 } 175 176 /** 177 * Initializes the DiskBasedCache by scanning for all files currently in the specified root 178 * directory. Creates the root directory if necessary. 179 */ 180 @Override initialize()181 public synchronized void initialize() { 182 File rootDirectory = mRootDirectorySupplier.get(); 183 if (!rootDirectory.exists()) { 184 if (!rootDirectory.mkdirs()) { 185 VolleyLog.e("Unable to create cache dir %s", rootDirectory.getAbsolutePath()); 186 } 187 return; 188 } 189 File[] files = rootDirectory.listFiles(); 190 if (files == null) { 191 return; 192 } 193 for (File file : files) { 194 try { 195 long entrySize = file.length(); 196 CountingInputStream cis = 197 new CountingInputStream( 198 new BufferedInputStream(createInputStream(file)), entrySize); 199 try { 200 CacheHeader entry = CacheHeader.readHeader(cis); 201 entry.size = entrySize; 202 putEntry(entry.key, entry); 203 } finally { 204 // Any IOException thrown here is handled by the below catch block by design. 205 //noinspection ThrowFromFinallyBlock 206 cis.close(); 207 } 208 } catch (IOException e) { 209 //noinspection ResultOfMethodCallIgnored 210 file.delete(); 211 } 212 } 213 } 214 215 /** 216 * Invalidates an entry in the cache. 217 * 218 * @param key Cache key 219 * @param fullExpire True to fully expire the entry, false to soft expire 220 */ 221 @Override invalidate(String key, boolean fullExpire)222 public synchronized void invalidate(String key, boolean fullExpire) { 223 Entry entry = get(key); 224 if (entry != null) { 225 entry.softTtl = 0; 226 if (fullExpire) { 227 entry.ttl = 0; 228 } 229 put(key, entry); 230 } 231 } 232 233 /** Puts the entry with the specified key into the cache. */ 234 @Override put(String key, Entry entry)235 public synchronized void put(String key, Entry entry) { 236 // If adding this entry would trigger a prune, but pruning would cause the new entry to be 237 // deleted, then skip writing the entry in the first place, as this is just churn. 238 // Note that we don't include the cache header overhead in this calculation for simplicity, 239 // so putting entries which are just below the threshold may still cause this churn. 240 if (mTotalSize + entry.data.length > mMaxCacheSizeInBytes 241 && entry.data.length > mMaxCacheSizeInBytes * HYSTERESIS_FACTOR) { 242 return; 243 } 244 File file = getFileForKey(key); 245 try { 246 BufferedOutputStream fos = new BufferedOutputStream(createOutputStream(file)); 247 CacheHeader e = new CacheHeader(key, entry); 248 boolean success = e.writeHeader(fos); 249 if (!success) { 250 fos.close(); 251 VolleyLog.d("Failed to write header for %s", file.getAbsolutePath()); 252 throw new IOException(); 253 } 254 fos.write(entry.data); 255 fos.close(); 256 e.size = file.length(); 257 putEntry(key, e); 258 pruneIfNeeded(); 259 } catch (IOException e) { 260 boolean deleted = file.delete(); 261 if (!deleted) { 262 VolleyLog.d("Could not clean up file %s", file.getAbsolutePath()); 263 } 264 initializeIfRootDirectoryDeleted(); 265 } 266 } 267 268 /** Removes the specified key from the cache if it exists. */ 269 @Override remove(String key)270 public synchronized void remove(String key) { 271 boolean deleted = getFileForKey(key).delete(); 272 removeEntry(key); 273 if (!deleted) { 274 VolleyLog.d( 275 "Could not delete cache entry for key=%s, filename=%s", 276 key, getFilenameForKey(key)); 277 } 278 } 279 280 /** 281 * Creates a pseudo-unique filename for the specified cache key. 282 * 283 * @param key The key to generate a file name for. 284 * @return A pseudo-unique filename. 285 */ getFilenameForKey(String key)286 private String getFilenameForKey(String key) { 287 int firstHalfLength = key.length() / 2; 288 String localFilename = String.valueOf(key.substring(0, firstHalfLength).hashCode()); 289 localFilename += String.valueOf(key.substring(firstHalfLength).hashCode()); 290 return localFilename; 291 } 292 293 /** Returns a file object for the given cache key. */ getFileForKey(String key)294 public File getFileForKey(String key) { 295 return new File(mRootDirectorySupplier.get(), getFilenameForKey(key)); 296 } 297 298 /** Re-initialize the cache if the directory was deleted. */ initializeIfRootDirectoryDeleted()299 private void initializeIfRootDirectoryDeleted() { 300 if (!mRootDirectorySupplier.get().exists()) { 301 VolleyLog.d("Re-initializing cache after external clearing."); 302 mEntries.clear(); 303 mTotalSize = 0; 304 initialize(); 305 } 306 } 307 308 /** Represents a supplier for {@link File}s. */ 309 public interface FileSupplier { get()310 File get(); 311 } 312 313 /** Prunes the cache to fit the maximum size. */ pruneIfNeeded()314 private void pruneIfNeeded() { 315 if (mTotalSize < mMaxCacheSizeInBytes) { 316 return; 317 } 318 if (VolleyLog.DEBUG) { 319 VolleyLog.v("Pruning old cache entries."); 320 } 321 322 long before = mTotalSize; 323 int prunedFiles = 0; 324 long startTime = SystemClock.elapsedRealtime(); 325 326 Iterator<Map.Entry<String, CacheHeader>> iterator = mEntries.entrySet().iterator(); 327 while (iterator.hasNext()) { 328 Map.Entry<String, CacheHeader> entry = iterator.next(); 329 CacheHeader e = entry.getValue(); 330 boolean deleted = getFileForKey(e.key).delete(); 331 if (deleted) { 332 mTotalSize -= e.size; 333 } else { 334 VolleyLog.d( 335 "Could not delete cache entry for key=%s, filename=%s", 336 e.key, getFilenameForKey(e.key)); 337 } 338 iterator.remove(); 339 prunedFiles++; 340 341 if (mTotalSize < mMaxCacheSizeInBytes * HYSTERESIS_FACTOR) { 342 break; 343 } 344 } 345 346 if (VolleyLog.DEBUG) { 347 VolleyLog.v( 348 "pruned %d files, %d bytes, %d ms", 349 prunedFiles, (mTotalSize - before), SystemClock.elapsedRealtime() - startTime); 350 } 351 } 352 353 /** 354 * Puts the entry with the specified key into the cache. 355 * 356 * @param key The key to identify the entry by. 357 * @param entry The entry to cache. 358 */ putEntry(String key, CacheHeader entry)359 private void putEntry(String key, CacheHeader entry) { 360 if (!mEntries.containsKey(key)) { 361 mTotalSize += entry.size; 362 } else { 363 CacheHeader oldEntry = mEntries.get(key); 364 mTotalSize += (entry.size - oldEntry.size); 365 } 366 mEntries.put(key, entry); 367 } 368 369 /** Removes the entry identified by 'key' from the cache. */ removeEntry(String key)370 private void removeEntry(String key) { 371 CacheHeader removed = mEntries.remove(key); 372 if (removed != null) { 373 mTotalSize -= removed.size; 374 } 375 } 376 377 /** 378 * Reads length bytes from CountingInputStream into byte array. 379 * 380 * @param cis input stream 381 * @param length number of bytes to read 382 * @throws IOException if fails to read all bytes 383 */ 384 @VisibleForTesting streamToBytes(CountingInputStream cis, long length)385 static byte[] streamToBytes(CountingInputStream cis, long length) throws IOException { 386 long maxLength = cis.bytesRemaining(); 387 // Length cannot be negative or greater than bytes remaining, and must not overflow int. 388 if (length < 0 || length > maxLength || (int) length != length) { 389 throw new IOException("streamToBytes length=" + length + ", maxLength=" + maxLength); 390 } 391 byte[] bytes = new byte[(int) length]; 392 new DataInputStream(cis).readFully(bytes); 393 return bytes; 394 } 395 396 @VisibleForTesting createInputStream(File file)397 InputStream createInputStream(File file) throws FileNotFoundException { 398 return new FileInputStream(file); 399 } 400 401 @VisibleForTesting createOutputStream(File file)402 OutputStream createOutputStream(File file) throws FileNotFoundException { 403 return new FileOutputStream(file); 404 } 405 406 /** Handles holding onto the cache headers for an entry. */ 407 @VisibleForTesting 408 static class CacheHeader { 409 /** 410 * The size of the data identified by this CacheHeader on disk (both header and data). 411 * 412 * <p>Must be set by the caller after it has been calculated. 413 * 414 * <p>This is not serialized to disk. 415 */ 416 long size; 417 418 /** The key that identifies the cache entry. */ 419 final String key; 420 421 /** ETag for cache coherence. */ 422 final String etag; 423 424 /** Date of this response as reported by the server. */ 425 final long serverDate; 426 427 /** The last modified date for the requested object. */ 428 final long lastModified; 429 430 /** TTL for this record. */ 431 final long ttl; 432 433 /** Soft TTL for this record. */ 434 final long softTtl; 435 436 /** Headers from the response resulting in this cache entry. */ 437 final List<Header> allResponseHeaders; 438 CacheHeader( String key, String etag, long serverDate, long lastModified, long ttl, long softTtl, List<Header> allResponseHeaders)439 private CacheHeader( 440 String key, 441 String etag, 442 long serverDate, 443 long lastModified, 444 long ttl, 445 long softTtl, 446 List<Header> allResponseHeaders) { 447 this.key = key; 448 this.etag = "".equals(etag) ? null : etag; 449 this.serverDate = serverDate; 450 this.lastModified = lastModified; 451 this.ttl = ttl; 452 this.softTtl = softTtl; 453 this.allResponseHeaders = allResponseHeaders; 454 } 455 456 /** 457 * Instantiates a new CacheHeader object. 458 * 459 * @param key The key that identifies the cache entry 460 * @param entry The cache entry. 461 */ CacheHeader(String key, Entry entry)462 CacheHeader(String key, Entry entry) { 463 this( 464 key, 465 entry.etag, 466 entry.serverDate, 467 entry.lastModified, 468 entry.ttl, 469 entry.softTtl, 470 getAllResponseHeaders(entry)); 471 } 472 getAllResponseHeaders(Entry entry)473 private static List<Header> getAllResponseHeaders(Entry entry) { 474 // If the entry contains all the response headers, use that field directly. 475 if (entry.allResponseHeaders != null) { 476 return entry.allResponseHeaders; 477 } 478 479 // Legacy fallback - copy headers from the map. 480 return HttpHeaderParser.toAllHeaderList(entry.responseHeaders); 481 } 482 483 /** 484 * Reads the header from a CountingInputStream and returns a CacheHeader object. 485 * 486 * @param is The InputStream to read from. 487 * @throws IOException if fails to read header 488 */ readHeader(CountingInputStream is)489 static CacheHeader readHeader(CountingInputStream is) throws IOException { 490 int magic = readInt(is); 491 if (magic != CACHE_MAGIC) { 492 // don't bother deleting, it'll get pruned eventually 493 throw new IOException(); 494 } 495 String key = readString(is); 496 String etag = readString(is); 497 long serverDate = readLong(is); 498 long lastModified = readLong(is); 499 long ttl = readLong(is); 500 long softTtl = readLong(is); 501 List<Header> allResponseHeaders = readHeaderList(is); 502 return new CacheHeader( 503 key, etag, serverDate, lastModified, ttl, softTtl, allResponseHeaders); 504 } 505 506 /** Creates a cache entry for the specified data. */ toCacheEntry(byte[] data)507 Entry toCacheEntry(byte[] data) { 508 Entry e = new Entry(); 509 e.data = data; 510 e.etag = etag; 511 e.serverDate = serverDate; 512 e.lastModified = lastModified; 513 e.ttl = ttl; 514 e.softTtl = softTtl; 515 e.responseHeaders = HttpHeaderParser.toHeaderMap(allResponseHeaders); 516 e.allResponseHeaders = Collections.unmodifiableList(allResponseHeaders); 517 return e; 518 } 519 520 /** Writes the contents of this CacheHeader to the specified OutputStream. */ writeHeader(OutputStream os)521 boolean writeHeader(OutputStream os) { 522 try { 523 writeInt(os, CACHE_MAGIC); 524 writeString(os, key); 525 writeString(os, etag == null ? "" : etag); 526 writeLong(os, serverDate); 527 writeLong(os, lastModified); 528 writeLong(os, ttl); 529 writeLong(os, softTtl); 530 writeHeaderList(allResponseHeaders, os); 531 os.flush(); 532 return true; 533 } catch (IOException e) { 534 VolleyLog.d("%s", e.toString()); 535 return false; 536 } 537 } 538 } 539 540 @VisibleForTesting 541 static class CountingInputStream extends FilterInputStream { 542 private final long length; 543 private long bytesRead; 544 CountingInputStream(InputStream in, long length)545 CountingInputStream(InputStream in, long length) { 546 super(in); 547 this.length = length; 548 } 549 550 @Override read()551 public int read() throws IOException { 552 int result = super.read(); 553 if (result != -1) { 554 bytesRead++; 555 } 556 return result; 557 } 558 559 @Override read(byte[] buffer, int offset, int count)560 public int read(byte[] buffer, int offset, int count) throws IOException { 561 int result = super.read(buffer, offset, count); 562 if (result != -1) { 563 bytesRead += result; 564 } 565 return result; 566 } 567 568 @VisibleForTesting bytesRead()569 long bytesRead() { 570 return bytesRead; 571 } 572 bytesRemaining()573 long bytesRemaining() { 574 return length - bytesRead; 575 } 576 } 577 578 /* 579 * Homebrewed simple serialization system used for reading and writing cache 580 * headers on disk. Once upon a time, this used the standard Java 581 * Object{Input,Output}Stream, but the default implementation relies heavily 582 * on reflection (even for standard types) and generates a ton of garbage. 583 * 584 * TODO: Replace by standard DataInput and DataOutput in next cache version. 585 */ 586 587 /** 588 * Simple wrapper around {@link InputStream#read()} that throws EOFException instead of 589 * returning -1. 590 */ read(InputStream is)591 private static int read(InputStream is) throws IOException { 592 int b = is.read(); 593 if (b == -1) { 594 throw new EOFException(); 595 } 596 return b; 597 } 598 writeInt(OutputStream os, int n)599 static void writeInt(OutputStream os, int n) throws IOException { 600 os.write((n >> 0) & 0xff); 601 os.write((n >> 8) & 0xff); 602 os.write((n >> 16) & 0xff); 603 os.write((n >> 24) & 0xff); 604 } 605 readInt(InputStream is)606 static int readInt(InputStream is) throws IOException { 607 int n = 0; 608 n |= (read(is) << 0); 609 n |= (read(is) << 8); 610 n |= (read(is) << 16); 611 n |= (read(is) << 24); 612 return n; 613 } 614 writeLong(OutputStream os, long n)615 static void writeLong(OutputStream os, long n) throws IOException { 616 os.write((byte) (n >>> 0)); 617 os.write((byte) (n >>> 8)); 618 os.write((byte) (n >>> 16)); 619 os.write((byte) (n >>> 24)); 620 os.write((byte) (n >>> 32)); 621 os.write((byte) (n >>> 40)); 622 os.write((byte) (n >>> 48)); 623 os.write((byte) (n >>> 56)); 624 } 625 readLong(InputStream is)626 static long readLong(InputStream is) throws IOException { 627 long n = 0; 628 n |= ((read(is) & 0xFFL) << 0); 629 n |= ((read(is) & 0xFFL) << 8); 630 n |= ((read(is) & 0xFFL) << 16); 631 n |= ((read(is) & 0xFFL) << 24); 632 n |= ((read(is) & 0xFFL) << 32); 633 n |= ((read(is) & 0xFFL) << 40); 634 n |= ((read(is) & 0xFFL) << 48); 635 n |= ((read(is) & 0xFFL) << 56); 636 return n; 637 } 638 writeString(OutputStream os, String s)639 static void writeString(OutputStream os, String s) throws IOException { 640 byte[] b = s.getBytes("UTF-8"); 641 writeLong(os, b.length); 642 os.write(b, 0, b.length); 643 } 644 readString(CountingInputStream cis)645 static String readString(CountingInputStream cis) throws IOException { 646 long n = readLong(cis); 647 byte[] b = streamToBytes(cis, n); 648 return new String(b, "UTF-8"); 649 } 650 writeHeaderList(List<Header> headers, OutputStream os)651 static void writeHeaderList(List<Header> headers, OutputStream os) throws IOException { 652 if (headers != null) { 653 writeInt(os, headers.size()); 654 for (Header header : headers) { 655 writeString(os, header.getName()); 656 writeString(os, header.getValue()); 657 } 658 } else { 659 writeInt(os, 0); 660 } 661 } 662 readHeaderList(CountingInputStream cis)663 static List<Header> readHeaderList(CountingInputStream cis) throws IOException { 664 int size = readInt(cis); 665 if (size < 0) { 666 throw new IOException("readHeaderList size=" + size); 667 } 668 List<Header> result = 669 (size == 0) ? Collections.<Header>emptyList() : new ArrayList<Header>(); 670 for (int i = 0; i < size; i++) { 671 String name = readString(cis).intern(); 672 String value = readString(cis).intern(); 673 result.add(new Header(name, value)); 674 } 675 return result; 676 } 677 } 678