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