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