CacheManager.java revision 686cf75d5cf447d34961f6217f2ea3ce3e484ac2
1/* 2 * Copyright (C) 2006 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 17package android.webkit; 18 19import android.content.Context; 20import android.net.http.Headers; 21import android.os.FileUtils; 22import android.util.Log; 23import java.io.File; 24import java.io.FileInputStream; 25import java.io.FileNotFoundException; 26import java.io.FileOutputStream; 27import java.io.IOException; 28import java.io.InputStream; 29import java.io.OutputStream; 30import java.util.ArrayList; 31import java.util.Map; 32 33import org.bouncycastle.crypto.Digest; 34import org.bouncycastle.crypto.digests.SHA1Digest; 35 36/** 37 * The class CacheManager provides the persistent cache of content that is 38 * received over the network. The component handles parsing of HTTP headers and 39 * utilizes the relevant cache headers to determine if the content should be 40 * stored and if so, how long it is valid for. Network requests are provided to 41 * this component and if they can not be resolved by the cache, the HTTP headers 42 * are attached, as appropriate, to the request for revalidation of content. The 43 * class also manages the cache size. 44 */ 45public final class CacheManager { 46 47 private static final String LOGTAG = "cache"; 48 49 static final String HEADER_KEY_IFMODIFIEDSINCE = "if-modified-since"; 50 static final String HEADER_KEY_IFNONEMATCH = "if-none-match"; 51 52 private static final String NO_STORE = "no-store"; 53 private static final String NO_CACHE = "no-cache"; 54 private static final String MAX_AGE = "max-age"; 55 56 private static long CACHE_THRESHOLD = 6 * 1024 * 1024; 57 private static long CACHE_TRIM_AMOUNT = 2 * 1024 * 1024; 58 59 private static boolean mDisabled; 60 61 // Reference count the enable/disable transaction 62 private static int mRefCount; 63 64 // trimCacheIfNeeded() is called when a page is fully loaded. But JavaScript 65 // can load the content, e.g. in a slideshow, continuously, so we need to 66 // trim the cache on a timer base too. endCacheTransaction() is called on a 67 // timer base. We share the same timer with less frequent update. 68 private static int mTrimCacheCount = 0; 69 private static final int TRIM_CACHE_INTERVAL = 5; 70 71 private static WebViewDatabase mDataBase; 72 private static File mBaseDir; 73 74 // Flag to clear the cache when the CacheManager is initialized 75 private static boolean mClearCacheOnInit = false; 76 77 public static class CacheResult { 78 // these fields are saved to the database 79 int httpStatusCode; 80 long contentLength; 81 long expires; 82 String expiresString; 83 String localPath; 84 String lastModified; 85 String etag; 86 String mimeType; 87 String location; 88 String encoding; 89 String contentdisposition; 90 91 // these fields are NOT saved to the database 92 InputStream inStream; 93 OutputStream outStream; 94 File outFile; 95 96 public int getHttpStatusCode() { 97 return httpStatusCode; 98 } 99 100 public long getContentLength() { 101 return contentLength; 102 } 103 104 public String getLocalPath() { 105 return localPath; 106 } 107 108 public long getExpires() { 109 return expires; 110 } 111 112 /** 113 * @hide Pending API council approval 114 */ 115 public String getExpiresString() { 116 return expiresString; 117 } 118 119 public String getLastModified() { 120 return lastModified; 121 } 122 123 public String getETag() { 124 return etag; 125 } 126 127 public String getMimeType() { 128 return mimeType; 129 } 130 131 public String getLocation() { 132 return location; 133 } 134 135 public String getEncoding() { 136 return encoding; 137 } 138 139 /** 140 * @hide Pending API council approval 141 */ 142 public String getContentDisposition() { 143 return contentdisposition; 144 } 145 146 // For out-of-package access to the underlying streams. 147 public InputStream getInputStream() { 148 return inStream; 149 } 150 151 public OutputStream getOutputStream() { 152 return outStream; 153 } 154 155 // These fields can be set manually. 156 public void setInputStream(InputStream stream) { 157 this.inStream = stream; 158 } 159 160 public void setEncoding(String encoding) { 161 this.encoding = encoding; 162 } 163 } 164 165 /** 166 * initialize the CacheManager. WebView should handle this for each process. 167 * 168 * @param context The application context. 169 */ 170 static void init(Context context) { 171 mDataBase = WebViewDatabase.getInstance(context); 172 mBaseDir = new File(context.getCacheDir(), "webviewCache"); 173 if (createCacheDirectory() && mClearCacheOnInit) { 174 removeAllCacheFiles(); 175 mClearCacheOnInit = false; 176 } 177 } 178 179 /** 180 * Create the cache directory if it does not already exist. 181 * 182 * @return true if the cache directory didn't exist and was created. 183 */ 184 static private boolean createCacheDirectory() { 185 if (!mBaseDir.exists()) { 186 if(!mBaseDir.mkdirs()) { 187 Log.w(LOGTAG, "Unable to create webviewCache directory"); 188 return false; 189 } 190 FileUtils.setPermissions( 191 mBaseDir.toString(), 192 FileUtils.S_IRWXU|FileUtils.S_IRWXG|FileUtils.S_IXOTH, 193 -1, -1); 194 // If we did create the directory, we need to flush 195 // the cache database. The directory could be recreated 196 // because the system flushed all the data/cache directories 197 // to free up disk space. 198 WebViewCore.endCacheTransaction(); 199 mDataBase.clearCache(); 200 WebViewCore.startCacheTransaction(); 201 return true; 202 } 203 return false; 204 } 205 206 /** 207 * get the base directory of the cache. With localPath of the CacheResult, 208 * it identifies the cache file. 209 * 210 * @return File The base directory of the cache. 211 */ 212 public static File getCacheFileBaseDir() { 213 return mBaseDir; 214 } 215 216 /** 217 * set the flag to control whether cache is enabled or disabled 218 * 219 * @param disabled true to disable the cache 220 */ 221 // only called from WebCore thread 222 static void setCacheDisabled(boolean disabled) { 223 if (disabled == mDisabled) { 224 return; 225 } 226 mDisabled = disabled; 227 if (mDisabled) { 228 removeAllCacheFiles(); 229 } 230 } 231 232 /** 233 * get the state of the current cache, enabled or disabled 234 * 235 * @return return if it is disabled 236 */ 237 public static boolean cacheDisabled() { 238 return mDisabled; 239 } 240 241 // only called from WebCore thread 242 // make sure to call enableTransaction/disableTransaction in pair 243 static boolean enableTransaction() { 244 if (++mRefCount == 1) { 245 mDataBase.startCacheTransaction(); 246 return true; 247 } 248 return false; 249 } 250 251 // only called from WebCore thread 252 // make sure to call enableTransaction/disableTransaction in pair 253 static boolean disableTransaction() { 254 if (mRefCount == 0) { 255 Log.e(LOGTAG, "disableTransaction is out of sync"); 256 } 257 if (--mRefCount == 0) { 258 mDataBase.endCacheTransaction(); 259 return true; 260 } 261 return false; 262 } 263 264 // only called from WebCore thread 265 // make sure to call startCacheTransaction/endCacheTransaction in pair 266 public static boolean startCacheTransaction() { 267 return mDataBase.startCacheTransaction(); 268 } 269 270 // only called from WebCore thread 271 // make sure to call startCacheTransaction/endCacheTransaction in pair 272 public static boolean endCacheTransaction() { 273 boolean ret = mDataBase.endCacheTransaction(); 274 if (++mTrimCacheCount >= TRIM_CACHE_INTERVAL) { 275 mTrimCacheCount = 0; 276 trimCacheIfNeeded(); 277 } 278 return ret; 279 } 280 281 /** 282 * Given a url, returns the CacheResult if exists. Otherwise returns null. 283 * If headers are provided and a cache needs validation, 284 * HEADER_KEY_IFNONEMATCH or HEADER_KEY_IFMODIFIEDSINCE will be set in the 285 * cached headers. 286 * 287 * @return the CacheResult for a given url 288 */ 289 // only called from WebCore thread 290 public static CacheResult getCacheFile(String url, 291 Map<String, String> headers) { 292 if (mDisabled) { 293 return null; 294 } 295 296 CacheResult result = mDataBase.getCache(url); 297 if (result != null) { 298 if (result.contentLength == 0) { 299 if (!checkCacheRedirect(result.httpStatusCode)) { 300 // this should not happen. If it does, remove it. 301 mDataBase.removeCache(url); 302 return null; 303 } 304 } else { 305 File src = new File(mBaseDir, result.localPath); 306 try { 307 // open here so that even the file is deleted, the content 308 // is still readable by the caller until close() is called 309 result.inStream = new FileInputStream(src); 310 } catch (FileNotFoundException e) { 311 // the files in the cache directory can be removed by the 312 // system. If it is gone, clean up the database 313 mDataBase.removeCache(url); 314 return null; 315 } 316 } 317 } else { 318 return null; 319 } 320 321 // null headers request coming from CACHE_MODE_CACHE_ONLY 322 // which implies that it needs cache even it is expired. 323 // negative expires means time in the far future. 324 if (headers != null && result.expires >= 0 325 && result.expires <= System.currentTimeMillis()) { 326 if (result.lastModified == null && result.etag == null) { 327 return null; 328 } 329 // return HEADER_KEY_IFNONEMATCH or HEADER_KEY_IFMODIFIEDSINCE 330 // for requesting validation 331 if (result.etag != null) { 332 headers.put(HEADER_KEY_IFNONEMATCH, result.etag); 333 } 334 if (result.lastModified != null) { 335 headers.put(HEADER_KEY_IFMODIFIEDSINCE, result.lastModified); 336 } 337 } 338 339 if (DebugFlags.CACHE_MANAGER) { 340 Log.v(LOGTAG, "getCacheFile for url " + url); 341 } 342 343 return result; 344 } 345 346 /** 347 * Given a url and its full headers, returns CacheResult if a local cache 348 * can be stored. Otherwise returns null. The mimetype is passed in so that 349 * the function can use the mimetype that will be passed to WebCore which 350 * could be different from the mimetype defined in the headers. 351 * forceCache is for out-of-package callers to force creation of a 352 * CacheResult, and is used to supply surrogate responses for URL 353 * interception. 354 * @return CacheResult for a given url 355 * @hide - hide createCacheFile since it has a parameter of type headers, which is 356 * in a hidden package. 357 */ 358 // only called from WebCore thread 359 public static CacheResult createCacheFile(String url, int statusCode, 360 Headers headers, String mimeType, boolean forceCache) { 361 if (!forceCache && mDisabled) { 362 return null; 363 } 364 365 // according to the rfc 2616, the 303 response MUST NOT be cached. 366 if (statusCode == 303) { 367 // remove the saved cache if there is any 368 mDataBase.removeCache(url); 369 return null; 370 } 371 372 // like the other browsers, do not cache redirects containing a cookie 373 // header. 374 if (checkCacheRedirect(statusCode) && !headers.getSetCookie().isEmpty()) { 375 // remove the saved cache if there is any 376 mDataBase.removeCache(url); 377 return null; 378 } 379 380 CacheResult ret = parseHeaders(statusCode, headers, mimeType); 381 if (ret == null) { 382 // this should only happen if the headers has "no-store" in the 383 // cache-control. remove the saved cache if there is any 384 mDataBase.removeCache(url); 385 } else { 386 setupFiles(url, ret); 387 try { 388 ret.outStream = new FileOutputStream(ret.outFile); 389 } catch (FileNotFoundException e) { 390 // This can happen with the system did a purge and our 391 // subdirectory has gone, so lets try to create it again 392 if (createCacheDirectory()) { 393 try { 394 ret.outStream = new FileOutputStream(ret.outFile); 395 } catch (FileNotFoundException e2) { 396 // We failed to create the file again, so there 397 // is something else wrong. Return null. 398 return null; 399 } 400 } else { 401 // Failed to create cache directory 402 return null; 403 } 404 } 405 ret.mimeType = mimeType; 406 } 407 408 return ret; 409 } 410 411 /** 412 * Save the info of a cache file for a given url to the CacheMap so that it 413 * can be reused later 414 */ 415 // only called from WebCore thread 416 public static void saveCacheFile(String url, CacheResult cacheRet) { 417 try { 418 cacheRet.outStream.close(); 419 } catch (IOException e) { 420 return; 421 } 422 423 if (!cacheRet.outFile.exists()) { 424 // the file in the cache directory can be removed by the system 425 return; 426 } 427 428 cacheRet.contentLength = cacheRet.outFile.length(); 429 if (checkCacheRedirect(cacheRet.httpStatusCode)) { 430 // location is in database, no need to keep the file 431 cacheRet.contentLength = 0; 432 cacheRet.localPath = ""; 433 cacheRet.outFile.delete(); 434 } else if (cacheRet.contentLength == 0) { 435 cacheRet.outFile.delete(); 436 return; 437 } 438 439 mDataBase.addCache(url, cacheRet); 440 441 if (DebugFlags.CACHE_MANAGER) { 442 Log.v(LOGTAG, "saveCacheFile for url " + url); 443 } 444 } 445 446 /** 447 * remove all cache files 448 * 449 * @return true if it succeeds 450 */ 451 // only called from WebCore thread 452 static boolean removeAllCacheFiles() { 453 // Note, this is called before init() when the database is 454 // created or upgraded. 455 if (mBaseDir == null) { 456 // Init() has not been called yet, so just flag that 457 // we need to clear the cache when init() is called. 458 mClearCacheOnInit = true; 459 return true; 460 } 461 // delete cache in a separate thread to not block UI. 462 final Runnable clearCache = new Runnable() { 463 public void run() { 464 // delete all cache files 465 try { 466 String[] files = mBaseDir.list(); 467 // if mBaseDir doesn't exist, files can be null. 468 if (files != null) { 469 for (int i = 0; i < files.length; i++) { 470 new File(mBaseDir, files[i]).delete(); 471 } 472 } 473 } catch (SecurityException e) { 474 // Ignore SecurityExceptions. 475 } 476 // delete database 477 mDataBase.clearCache(); 478 } 479 }; 480 new Thread(clearCache).start(); 481 return true; 482 } 483 484 /** 485 * Return true if the cache is empty. 486 */ 487 // only called from WebCore thread 488 static boolean cacheEmpty() { 489 return mDataBase.hasCache(); 490 } 491 492 // only called from WebCore thread 493 static void trimCacheIfNeeded() { 494 if (mDataBase.getCacheTotalSize() > CACHE_THRESHOLD) { 495 ArrayList<String> pathList = mDataBase.trimCache(CACHE_TRIM_AMOUNT); 496 int size = pathList.size(); 497 for (int i = 0; i < size; i++) { 498 new File(mBaseDir, pathList.get(i)).delete(); 499 } 500 } 501 } 502 503 private static boolean checkCacheRedirect(int statusCode) { 504 if (statusCode == 301 || statusCode == 302 || statusCode == 307) { 505 // as 303 can't be cached, we do not return true 506 return true; 507 } else { 508 return false; 509 } 510 } 511 512 @SuppressWarnings("deprecation") 513 private static void setupFiles(String url, CacheResult cacheRet) { 514 if (true) { 515 // Note: SHA1 is much stronger hash. But the cost of setupFiles() is 516 // 3.2% cpu time for a fresh load of nytimes.com. While a simple 517 // String.hashCode() is only 0.6%. If adding the collision resolving 518 // to String.hashCode(), it makes the cpu time to be 1.6% for a 519 // fresh load, but 5.3% for the worst case where all the files 520 // already exist in the file system, but database is gone. So it 521 // needs to resolve collision for every file at least once. 522 int hashCode = url.hashCode(); 523 StringBuffer ret = new StringBuffer(8); 524 appendAsHex(hashCode, ret); 525 String path = ret.toString(); 526 File file = new File(mBaseDir, path); 527 if (true) { 528 boolean checkOldPath = true; 529 // Check hash collision. If the hash file doesn't exist, just 530 // continue. There is a chance that the old cache file is not 531 // same as the hash file. As mDataBase.getCache() is more 532 // expansive than "leak" a file until clear cache, don't bother. 533 // If the hash file exists, make sure that it is same as the 534 // cache file. If it is not, resolve the collision. 535 while (file.exists()) { 536 if (checkOldPath) { 537 CacheResult oldResult = mDataBase.getCache(url); 538 if (oldResult != null && oldResult.contentLength > 0) { 539 if (path.equals(oldResult.localPath)) { 540 path = oldResult.localPath; 541 } else { 542 path = oldResult.localPath; 543 file = new File(mBaseDir, path); 544 } 545 break; 546 } 547 checkOldPath = false; 548 } 549 ret = new StringBuffer(8); 550 appendAsHex(++hashCode, ret); 551 path = ret.toString(); 552 file = new File(mBaseDir, path); 553 } 554 } 555 cacheRet.localPath = path; 556 cacheRet.outFile = file; 557 } else { 558 // get hash in byte[] 559 Digest digest = new SHA1Digest(); 560 int digestLen = digest.getDigestSize(); 561 byte[] hash = new byte[digestLen]; 562 int urlLen = url.length(); 563 byte[] data = new byte[urlLen]; 564 url.getBytes(0, urlLen, data, 0); 565 digest.update(data, 0, urlLen); 566 digest.doFinal(hash, 0); 567 // convert byte[] to hex String 568 StringBuffer result = new StringBuffer(2 * digestLen); 569 for (int i = 0; i < digestLen; i = i + 4) { 570 int h = (0x00ff & hash[i]) << 24 | (0x00ff & hash[i + 1]) << 16 571 | (0x00ff & hash[i + 2]) << 8 | (0x00ff & hash[i + 3]); 572 appendAsHex(h, result); 573 } 574 cacheRet.localPath = result.toString(); 575 cacheRet.outFile = new File(mBaseDir, cacheRet.localPath); 576 } 577 } 578 579 private static void appendAsHex(int i, StringBuffer ret) { 580 String hex = Integer.toHexString(i); 581 switch (hex.length()) { 582 case 1: 583 ret.append("0000000"); 584 break; 585 case 2: 586 ret.append("000000"); 587 break; 588 case 3: 589 ret.append("00000"); 590 break; 591 case 4: 592 ret.append("0000"); 593 break; 594 case 5: 595 ret.append("000"); 596 break; 597 case 6: 598 ret.append("00"); 599 break; 600 case 7: 601 ret.append("0"); 602 break; 603 } 604 ret.append(hex); 605 } 606 607 private static CacheResult parseHeaders(int statusCode, Headers headers, 608 String mimeType) { 609 // TODO: if authenticated or secure, return null 610 CacheResult ret = new CacheResult(); 611 ret.httpStatusCode = statusCode; 612 613 String location = headers.getLocation(); 614 if (location != null) ret.location = location; 615 616 ret.expires = -1; 617 ret.expiresString = headers.getExpires(); 618 if (ret.expiresString != null) { 619 try { 620 ret.expires = HttpDateTime.parse(ret.expiresString); 621 } catch (IllegalArgumentException ex) { 622 // Take care of the special "-1" and "0" cases 623 if ("-1".equals(ret.expiresString) 624 || "0".equals(ret.expiresString)) { 625 // make it expired, but can be used for history navigation 626 ret.expires = 0; 627 } else { 628 Log.e(LOGTAG, "illegal expires: " + ret.expiresString); 629 } 630 } 631 } 632 633 String contentDisposition = headers.getContentDisposition(); 634 if (contentDisposition != null) { 635 ret.contentdisposition = contentDisposition; 636 } 637 638 String lastModified = headers.getLastModified(); 639 if (lastModified != null) ret.lastModified = lastModified; 640 641 String etag = headers.getEtag(); 642 if (etag != null) ret.etag = etag; 643 644 String cacheControl = headers.getCacheControl(); 645 if (cacheControl != null) { 646 String[] controls = cacheControl.toLowerCase().split("[ ,;]"); 647 for (int i = 0; i < controls.length; i++) { 648 if (NO_STORE.equals(controls[i])) { 649 return null; 650 } 651 // According to the spec, 'no-cache' means that the content 652 // must be re-validated on every load. It does not mean that 653 // the content can not be cached. set to expire 0 means it 654 // can only be used in CACHE_MODE_CACHE_ONLY case 655 if (NO_CACHE.equals(controls[i])) { 656 ret.expires = 0; 657 } else if (controls[i].startsWith(MAX_AGE)) { 658 int separator = controls[i].indexOf('='); 659 if (separator < 0) { 660 separator = controls[i].indexOf(':'); 661 } 662 if (separator > 0) { 663 String s = controls[i].substring(separator + 1); 664 try { 665 long sec = Long.parseLong(s); 666 if (sec >= 0) { 667 ret.expires = System.currentTimeMillis() + 1000 668 * sec; 669 } 670 } catch (NumberFormatException ex) { 671 if ("1d".equals(s)) { 672 // Take care of the special "1d" case 673 ret.expires = System.currentTimeMillis() + 86400000; // 24*60*60*1000 674 } else { 675 Log.e(LOGTAG, "exception in parseHeaders for " 676 + "max-age:" 677 + controls[i].substring(separator + 1)); 678 ret.expires = 0; 679 } 680 } 681 } 682 } 683 } 684 } 685 686 // According to RFC 2616 section 14.32: 687 // HTTP/1.1 caches SHOULD treat "Pragma: no-cache" as if the 688 // client had sent "Cache-Control: no-cache" 689 if (NO_CACHE.equals(headers.getPragma())) { 690 ret.expires = 0; 691 } 692 693 // According to RFC 2616 section 13.2.4, if an expiration has not been 694 // explicitly defined a heuristic to set an expiration may be used. 695 if (ret.expires == -1) { 696 if (ret.httpStatusCode == 301) { 697 // If it is a permanent redirect, and it did not have an 698 // explicit cache directive, then it never expires 699 ret.expires = Long.MAX_VALUE; 700 } else if (ret.httpStatusCode == 302 || ret.httpStatusCode == 307) { 701 // If it is temporary redirect, expires 702 ret.expires = 0; 703 } else if (ret.lastModified == null) { 704 // When we have no last-modified, then expire the content with 705 // in 24hrs as, according to the RFC, longer time requires a 706 // warning 113 to be added to the response. 707 708 // Only add the default expiration for non-html markup. Some 709 // sites like news.google.com have no cache directives. 710 if (!mimeType.startsWith("text/html")) { 711 ret.expires = System.currentTimeMillis() + 86400000; // 24*60*60*1000 712 } else { 713 // Setting a expires as zero will cache the result for 714 // forward/back nav. 715 ret.expires = 0; 716 } 717 } else { 718 // If we have a last-modified value, we could use it to set the 719 // expiration. Suggestion from RFC is 10% of time since 720 // last-modified. As we are on mobile, loads are expensive, 721 // increasing this to 20%. 722 723 // 24 * 60 * 60 * 1000 724 long lastmod = System.currentTimeMillis() + 86400000; 725 try { 726 lastmod = HttpDateTime.parse(ret.lastModified); 727 } catch (IllegalArgumentException ex) { 728 Log.e(LOGTAG, "illegal lastModified: " + ret.lastModified); 729 } 730 long difference = System.currentTimeMillis() - lastmod; 731 if (difference > 0) { 732 ret.expires = System.currentTimeMillis() + difference / 5; 733 } else { 734 // last modified is in the future, expire the content 735 // on the last modified 736 ret.expires = lastmod; 737 } 738 } 739 } 740 741 return ret; 742 } 743} 744