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