summaryrefslogtreecommitdiffstats
path: root/core/java/android/webkit/CacheManager.java
diff options
context:
space:
mode:
authorThe Android Open Source Project <initial-contribution@android.com>2009-03-03 19:31:44 -0800
committerThe Android Open Source Project <initial-contribution@android.com>2009-03-03 19:31:44 -0800
commit9066cfe9886ac131c34d59ed0e2d287b0e3c0087 (patch)
treed88beb88001f2482911e3d28e43833b50e4b4e97 /core/java/android/webkit/CacheManager.java
parentd83a98f4ce9cfa908f5c54bbd70f03eec07e7553 (diff)
downloadframeworks_base-9066cfe9886ac131c34d59ed0e2d287b0e3c0087.zip
frameworks_base-9066cfe9886ac131c34d59ed0e2d287b0e3c0087.tar.gz
frameworks_base-9066cfe9886ac131c34d59ed0e2d287b0e3c0087.tar.bz2
auto import from //depot/cupcake/@135843
Diffstat (limited to 'core/java/android/webkit/CacheManager.java')
-rw-r--r--core/java/android/webkit/CacheManager.java703
1 files changed, 703 insertions, 0 deletions
diff --git a/core/java/android/webkit/CacheManager.java b/core/java/android/webkit/CacheManager.java
new file mode 100644
index 0000000..d12940d
--- /dev/null
+++ b/core/java/android/webkit/CacheManager.java
@@ -0,0 +1,703 @@
+/*
+ * Copyright (C) 2006 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.webkit;
+
+import android.content.Context;
+import android.net.http.Headers;
+import android.os.FileUtils;
+import android.util.Config;
+import android.util.Log;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Map;
+
+import org.bouncycastle.crypto.Digest;
+import org.bouncycastle.crypto.digests.SHA1Digest;
+
+/**
+ * The class CacheManager provides the persistent cache of content that is
+ * received over the network. The component handles parsing of HTTP headers and
+ * utilizes the relevant cache headers to determine if the content should be
+ * stored and if so, how long it is valid for. Network requests are provided to
+ * this component and if they can not be resolved by the cache, the HTTP headers
+ * are attached, as appropriate, to the request for revalidation of content. The
+ * class also manages the cache size.
+ */
+public final class CacheManager {
+
+ private static final String LOGTAG = "cache";
+
+ static final String HEADER_KEY_IFMODIFIEDSINCE = "if-modified-since";
+ static final String HEADER_KEY_IFNONEMATCH = "if-none-match";
+
+ private static final String NO_STORE = "no-store";
+ private static final String NO_CACHE = "no-cache";
+ private static final String MAX_AGE = "max-age";
+
+ private static long CACHE_THRESHOLD = 6 * 1024 * 1024;
+ private static long CACHE_TRIM_AMOUNT = 2 * 1024 * 1024;
+
+ private static boolean mDisabled;
+
+ // Reference count the enable/disable transaction
+ private static int mRefCount;
+
+ // trimCacheIfNeeded() is called when a page is fully loaded. But JavaScript
+ // can load the content, e.g. in a slideshow, continuously, so we need to
+ // trim the cache on a timer base too. endCacheTransaction() is called on a
+ // timer base. We share the same timer with less frequent update.
+ private static int mTrimCacheCount = 0;
+ private static final int TRIM_CACHE_INTERVAL = 5;
+
+ private static WebViewDatabase mDataBase;
+ private static File mBaseDir;
+
+ // Flag to clear the cache when the CacheManager is initialized
+ private static boolean mClearCacheOnInit = false;
+
+ public static class CacheResult {
+ // these fields are saved to the database
+ int httpStatusCode;
+ long contentLength;
+ long expires;
+ String localPath;
+ String lastModified;
+ String etag;
+ String mimeType;
+ String location;
+ String encoding;
+
+ // these fields are NOT saved to the database
+ InputStream inStream;
+ OutputStream outStream;
+ File outFile;
+
+ public int getHttpStatusCode() {
+ return httpStatusCode;
+ }
+
+ public long getContentLength() {
+ return contentLength;
+ }
+
+ public String getLocalPath() {
+ return localPath;
+ }
+
+ public long getExpires() {
+ return expires;
+ }
+
+ public String getLastModified() {
+ return lastModified;
+ }
+
+ public String getETag() {
+ return etag;
+ }
+
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ public String getLocation() {
+ return location;
+ }
+
+ public String getEncoding() {
+ return encoding;
+ }
+
+ // For out-of-package access to the underlying streams.
+ public InputStream getInputStream() {
+ return inStream;
+ }
+
+ public OutputStream getOutputStream() {
+ return outStream;
+ }
+
+ // These fields can be set manually.
+ public void setInputStream(InputStream stream) {
+ this.inStream = stream;
+ }
+
+ public void setEncoding(String encoding) {
+ this.encoding = encoding;
+ }
+ }
+
+ /**
+ * initialize the CacheManager. WebView should handle this for each process.
+ *
+ * @param context The application context.
+ */
+ static void init(Context context) {
+ mDataBase = WebViewDatabase.getInstance(context);
+ mBaseDir = new File(context.getCacheDir(), "webviewCache");
+ if (createCacheDirectory() && mClearCacheOnInit) {
+ removeAllCacheFiles();
+ mClearCacheOnInit = false;
+ }
+ }
+
+ /**
+ * Create the cache directory if it does not already exist.
+ *
+ * @return true if the cache directory didn't exist and was created.
+ */
+ static private boolean createCacheDirectory() {
+ if (!mBaseDir.exists()) {
+ if(!mBaseDir.mkdirs()) {
+ Log.w(LOGTAG, "Unable to create webviewCache directory");
+ return false;
+ }
+ FileUtils.setPermissions(
+ mBaseDir.toString(),
+ FileUtils.S_IRWXU|FileUtils.S_IRWXG|FileUtils.S_IXOTH,
+ -1, -1);
+ // If we did create the directory, we need to flush
+ // the cache database. The directory could be recreated
+ // because the system flushed all the data/cache directories
+ // to free up disk space.
+ WebViewCore.endCacheTransaction();
+ mDataBase.clearCache();
+ WebViewCore.startCacheTransaction();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * get the base directory of the cache. With localPath of the CacheResult,
+ * it identifies the cache file.
+ *
+ * @return File The base directory of the cache.
+ */
+ public static File getCacheFileBaseDir() {
+ return mBaseDir;
+ }
+
+ /**
+ * set the flag to control whether cache is enabled or disabled
+ *
+ * @param disabled true to disable the cache
+ */
+ // only called from WebCore thread
+ static void setCacheDisabled(boolean disabled) {
+ if (disabled == mDisabled) {
+ return;
+ }
+ mDisabled = disabled;
+ if (mDisabled) {
+ removeAllCacheFiles();
+ }
+ }
+
+ /**
+ * get the state of the current cache, enabled or disabled
+ *
+ * @return return if it is disabled
+ */
+ public static boolean cacheDisabled() {
+ return mDisabled;
+ }
+
+ // only called from WebCore thread
+ // make sure to call enableTransaction/disableTransaction in pair
+ static boolean enableTransaction() {
+ if (++mRefCount == 1) {
+ mDataBase.startCacheTransaction();
+ return true;
+ }
+ return false;
+ }
+
+ // only called from WebCore thread
+ // make sure to call enableTransaction/disableTransaction in pair
+ static boolean disableTransaction() {
+ if (mRefCount == 0) {
+ Log.e(LOGTAG, "disableTransaction is out of sync");
+ }
+ if (--mRefCount == 0) {
+ mDataBase.endCacheTransaction();
+ return true;
+ }
+ return false;
+ }
+
+ // only called from WebCore thread
+ // make sure to call startCacheTransaction/endCacheTransaction in pair
+ public static boolean startCacheTransaction() {
+ return mDataBase.startCacheTransaction();
+ }
+
+ // only called from WebCore thread
+ // make sure to call startCacheTransaction/endCacheTransaction in pair
+ public static boolean endCacheTransaction() {
+ boolean ret = mDataBase.endCacheTransaction();
+ if (++mTrimCacheCount >= TRIM_CACHE_INTERVAL) {
+ mTrimCacheCount = 0;
+ trimCacheIfNeeded();
+ }
+ return ret;
+ }
+
+ /**
+ * Given a url, returns the CacheResult if exists. Otherwise returns null.
+ * If headers are provided and a cache needs validation,
+ * HEADER_KEY_IFNONEMATCH or HEADER_KEY_IFMODIFIEDSINCE will be set in the
+ * cached headers.
+ *
+ * @return the CacheResult for a given url
+ */
+ // only called from WebCore thread
+ public static CacheResult getCacheFile(String url,
+ Map<String, String> headers) {
+ if (mDisabled) {
+ return null;
+ }
+
+ CacheResult result = mDataBase.getCache(url);
+ if (result != null) {
+ if (result.contentLength == 0) {
+ if (result.httpStatusCode != 301
+ && result.httpStatusCode != 302
+ && result.httpStatusCode != 307) {
+ // this should not happen. If it does, remove it.
+ mDataBase.removeCache(url);
+ return null;
+ }
+ } else {
+ File src = new File(mBaseDir, result.localPath);
+ try {
+ // open here so that even the file is deleted, the content
+ // is still readable by the caller until close() is called
+ result.inStream = new FileInputStream(src);
+ } catch (FileNotFoundException e) {
+ // the files in the cache directory can be removed by the
+ // system. If it is gone, clean up the database
+ mDataBase.removeCache(url);
+ return null;
+ }
+ }
+ } else {
+ return null;
+ }
+
+ // null headers request coming from CACHE_MODE_CACHE_ONLY
+ // which implies that it needs cache even it is expired.
+ // negative expires means time in the far future.
+ if (headers != null && result.expires >= 0
+ && result.expires <= System.currentTimeMillis()) {
+ if (result.lastModified == null && result.etag == null) {
+ return null;
+ }
+ // return HEADER_KEY_IFNONEMATCH or HEADER_KEY_IFMODIFIEDSINCE
+ // for requesting validation
+ if (result.etag != null) {
+ headers.put(HEADER_KEY_IFNONEMATCH, result.etag);
+ }
+ if (result.lastModified != null) {
+ headers.put(HEADER_KEY_IFMODIFIEDSINCE, result.lastModified);
+ }
+ }
+
+ if (Config.LOGV) {
+ Log.v(LOGTAG, "getCacheFile for url " + url);
+ }
+
+ return result;
+ }
+
+ /**
+ * Given a url and its full headers, returns CacheResult if a local cache
+ * can be stored. Otherwise returns null. The mimetype is passed in so that
+ * the function can use the mimetype that will be passed to WebCore which
+ * could be different from the mimetype defined in the headers.
+ * forceCache is for out-of-package callers to force creation of a
+ * CacheResult, and is used to supply surrogate responses for URL
+ * interception.
+ * @return CacheResult for a given url
+ * @hide - hide createCacheFile since it has a parameter of type headers, which is
+ * in a hidden package.
+ */
+ // can be called from any thread
+ public static CacheResult createCacheFile(String url, int statusCode,
+ Headers headers, String mimeType, boolean forceCache) {
+ if (!forceCache && mDisabled) {
+ return null;
+ }
+
+ CacheResult ret = parseHeaders(statusCode, headers, mimeType);
+ if (ret != null) {
+ setupFiles(url, ret);
+ try {
+ ret.outStream = new FileOutputStream(ret.outFile);
+ } catch (FileNotFoundException e) {
+ // This can happen with the system did a purge and our
+ // subdirectory has gone, so lets try to create it again
+ if (createCacheDirectory()) {
+ try {
+ ret.outStream = new FileOutputStream(ret.outFile);
+ } catch (FileNotFoundException e2) {
+ // We failed to create the file again, so there
+ // is something else wrong. Return null.
+ return null;
+ }
+ } else {
+ // Failed to create cache directory
+ return null;
+ }
+ }
+ ret.mimeType = mimeType;
+ }
+
+ return ret;
+ }
+
+ /**
+ * Save the info of a cache file for a given url to the CacheMap so that it
+ * can be reused later
+ */
+ // only called from WebCore thread
+ public static void saveCacheFile(String url, CacheResult cacheRet) {
+ try {
+ cacheRet.outStream.close();
+ } catch (IOException e) {
+ return;
+ }
+
+ if (!cacheRet.outFile.exists()) {
+ // the file in the cache directory can be removed by the system
+ return;
+ }
+
+ cacheRet.contentLength = cacheRet.outFile.length();
+ if (cacheRet.httpStatusCode == 301
+ || cacheRet.httpStatusCode == 302
+ || cacheRet.httpStatusCode == 307) {
+ // location is in database, no need to keep the file
+ cacheRet.contentLength = 0;
+ cacheRet.localPath = new String();
+ cacheRet.outFile.delete();
+ } else if (cacheRet.contentLength == 0) {
+ cacheRet.outFile.delete();
+ return;
+ }
+
+ mDataBase.addCache(url, cacheRet);
+
+ if (Config.LOGV) {
+ Log.v(LOGTAG, "saveCacheFile for url " + url);
+ }
+ }
+
+ /**
+ * remove all cache files
+ *
+ * @return true if it succeeds
+ */
+ // only called from WebCore thread
+ static boolean removeAllCacheFiles() {
+ // Note, this is called before init() when the database is
+ // created or upgraded.
+ if (mBaseDir == null) {
+ // Init() has not been called yet, so just flag that
+ // we need to clear the cache when init() is called.
+ mClearCacheOnInit = true;
+ return true;
+ }
+ // delete cache in a separate thread to not block UI.
+ final Runnable clearCache = new Runnable() {
+ public void run() {
+ // delete all cache files
+ try {
+ String[] files = mBaseDir.list();
+ // if mBaseDir doesn't exist, files can be null.
+ if (files != null) {
+ for (int i = 0; i < files.length; i++) {
+ new File(mBaseDir, files[i]).delete();
+ }
+ }
+ } catch (SecurityException e) {
+ // Ignore SecurityExceptions.
+ }
+ // delete database
+ mDataBase.clearCache();
+ }
+ };
+ new Thread(clearCache).start();
+ return true;
+ }
+
+ /**
+ * Return true if the cache is empty.
+ */
+ // only called from WebCore thread
+ static boolean cacheEmpty() {
+ return mDataBase.hasCache();
+ }
+
+ // only called from WebCore thread
+ static void trimCacheIfNeeded() {
+ if (mDataBase.getCacheTotalSize() > CACHE_THRESHOLD) {
+ ArrayList<String> pathList = mDataBase.trimCache(CACHE_TRIM_AMOUNT);
+ int size = pathList.size();
+ for (int i = 0; i < size; i++) {
+ new File(mBaseDir, pathList.get(i)).delete();
+ }
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private static void setupFiles(String url, CacheResult cacheRet) {
+ if (true) {
+ // Note: SHA1 is much stronger hash. But the cost of setupFiles() is
+ // 3.2% cpu time for a fresh load of nytimes.com. While a simple
+ // String.hashCode() is only 0.6%. If adding the collision resolving
+ // to String.hashCode(), it makes the cpu time to be 1.6% for a
+ // fresh load, but 5.3% for the worst case where all the files
+ // already exist in the file system, but database is gone. So it
+ // needs to resolve collision for every file at least once.
+ int hashCode = url.hashCode();
+ StringBuffer ret = new StringBuffer(8);
+ appendAsHex(hashCode, ret);
+ String path = ret.toString();
+ File file = new File(mBaseDir, path);
+ if (true) {
+ boolean checkOldPath = true;
+ // Check hash collision. If the hash file doesn't exist, just
+ // continue. There is a chance that the old cache file is not
+ // same as the hash file. As mDataBase.getCache() is more
+ // expansive than "leak" a file until clear cache, don't bother.
+ // If the hash file exists, make sure that it is same as the
+ // cache file. If it is not, resolve the collision.
+ while (file.exists()) {
+ if (checkOldPath) {
+ // as this is called from http thread through
+ // createCacheFile, we need endCacheTransaction before
+ // database access.
+ WebViewCore.endCacheTransaction();
+ CacheResult oldResult = mDataBase.getCache(url);
+ WebViewCore.startCacheTransaction();
+ if (oldResult != null && oldResult.contentLength > 0) {
+ if (path.equals(oldResult.localPath)) {
+ path = oldResult.localPath;
+ } else {
+ path = oldResult.localPath;
+ file = new File(mBaseDir, path);
+ }
+ break;
+ }
+ checkOldPath = false;
+ }
+ ret = new StringBuffer(8);
+ appendAsHex(++hashCode, ret);
+ path = ret.toString();
+ file = new File(mBaseDir, path);
+ }
+ }
+ cacheRet.localPath = path;
+ cacheRet.outFile = file;
+ } else {
+ // get hash in byte[]
+ Digest digest = new SHA1Digest();
+ int digestLen = digest.getDigestSize();
+ byte[] hash = new byte[digestLen];
+ int urlLen = url.length();
+ byte[] data = new byte[urlLen];
+ url.getBytes(0, urlLen, data, 0);
+ digest.update(data, 0, urlLen);
+ digest.doFinal(hash, 0);
+ // convert byte[] to hex String
+ StringBuffer result = new StringBuffer(2 * digestLen);
+ for (int i = 0; i < digestLen; i = i + 4) {
+ int h = (0x00ff & hash[i]) << 24 | (0x00ff & hash[i + 1]) << 16
+ | (0x00ff & hash[i + 2]) << 8 | (0x00ff & hash[i + 3]);
+ appendAsHex(h, result);
+ }
+ cacheRet.localPath = result.toString();
+ cacheRet.outFile = new File(mBaseDir, cacheRet.localPath);
+ }
+ }
+
+ private static void appendAsHex(int i, StringBuffer ret) {
+ String hex = Integer.toHexString(i);
+ switch (hex.length()) {
+ case 1:
+ ret.append("0000000");
+ break;
+ case 2:
+ ret.append("000000");
+ break;
+ case 3:
+ ret.append("00000");
+ break;
+ case 4:
+ ret.append("0000");
+ break;
+ case 5:
+ ret.append("000");
+ break;
+ case 6:
+ ret.append("00");
+ break;
+ case 7:
+ ret.append("0");
+ break;
+ }
+ ret.append(hex);
+ }
+
+ private static CacheResult parseHeaders(int statusCode, Headers headers,
+ String mimeType) {
+ // TODO: if authenticated or secure, return null
+ CacheResult ret = new CacheResult();
+ ret.httpStatusCode = statusCode;
+
+ String location = headers.getLocation();
+ if (location != null) ret.location = location;
+
+ ret.expires = -1;
+ String expires = headers.getExpires();
+ if (expires != null) {
+ try {
+ ret.expires = HttpDateTime.parse(expires);
+ } catch (IllegalArgumentException ex) {
+ // Take care of the special "-1" and "0" cases
+ if ("-1".equals(expires) || "0".equals(expires)) {
+ // make it expired, but can be used for history navigation
+ ret.expires = 0;
+ } else {
+ Log.e(LOGTAG, "illegal expires: " + expires);
+ }
+ }
+ }
+
+ String lastModified = headers.getLastModified();
+ if (lastModified != null) ret.lastModified = lastModified;
+
+ String etag = headers.getEtag();
+ if (etag != null) ret.etag = etag;
+
+ String cacheControl = headers.getCacheControl();
+ if (cacheControl != null) {
+ String[] controls = cacheControl.toLowerCase().split("[ ,;]");
+ for (int i = 0; i < controls.length; i++) {
+ if (NO_STORE.equals(controls[i])) {
+ return null;
+ }
+ // According to the spec, 'no-cache' means that the content
+ // must be re-validated on every load. It does not mean that
+ // the content can not be cached. set to expire 0 means it
+ // can only be used in CACHE_MODE_CACHE_ONLY case
+ if (NO_CACHE.equals(controls[i])) {
+ ret.expires = 0;
+ } else if (controls[i].startsWith(MAX_AGE)) {
+ int separator = controls[i].indexOf('=');
+ if (separator < 0) {
+ separator = controls[i].indexOf(':');
+ }
+ if (separator > 0) {
+ String s = controls[i].substring(separator + 1);
+ try {
+ long sec = Long.parseLong(s);
+ if (sec >= 0) {
+ ret.expires = System.currentTimeMillis() + 1000
+ * sec;
+ }
+ } catch (NumberFormatException ex) {
+ if ("1d".equals(s)) {
+ // Take care of the special "1d" case
+ ret.expires = System.currentTimeMillis() + 86400000; // 24*60*60*1000
+ } else {
+ Log.e(LOGTAG, "exception in parseHeaders for "
+ + "max-age:"
+ + controls[i].substring(separator + 1));
+ ret.expires = 0;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // According to RFC 2616 section 14.32:
+ // HTTP/1.1 caches SHOULD treat "Pragma: no-cache" as if the
+ // client had sent "Cache-Control: no-cache"
+ if (NO_CACHE.equals(headers.getPragma())) {
+ ret.expires = 0;
+ }
+
+ // According to RFC 2616 section 13.2.4, if an expiration has not been
+ // explicitly defined a heuristic to set an expiration may be used.
+ if (ret.expires == -1) {
+ if (ret.httpStatusCode == 301) {
+ // If it is a permanent redirect, and it did not have an
+ // explicit cache directive, then it never expires
+ ret.expires = Long.MAX_VALUE;
+ } else if (ret.httpStatusCode == 302 || ret.httpStatusCode == 307) {
+ // If it is temporary redirect, expires
+ ret.expires = 0;
+ } else if (ret.lastModified == null) {
+ // When we have no last-modified, then expire the content with
+ // in 24hrs as, according to the RFC, longer time requires a
+ // warning 113 to be added to the response.
+
+ // Only add the default expiration for non-html markup. Some
+ // sites like news.google.com have no cache directives.
+ if (!mimeType.startsWith("text/html")) {
+ ret.expires = System.currentTimeMillis() + 86400000; // 24*60*60*1000
+ } else {
+ // Setting a expires as zero will cache the result for
+ // forward/back nav.
+ ret.expires = 0;
+ }
+ } else {
+ // If we have a last-modified value, we could use it to set the
+ // expiration. Suggestion from RFC is 10% of time since
+ // last-modified. As we are on mobile, loads are expensive,
+ // increasing this to 20%.
+
+ // 24 * 60 * 60 * 1000
+ long lastmod = System.currentTimeMillis() + 86400000;
+ try {
+ lastmod = HttpDateTime.parse(ret.lastModified);
+ } catch (IllegalArgumentException ex) {
+ Log.e(LOGTAG, "illegal lastModified: " + ret.lastModified);
+ }
+ long difference = System.currentTimeMillis() - lastmod;
+ if (difference > 0) {
+ ret.expires = System.currentTimeMillis() + difference / 5;
+ } else {
+ // last modified is in the future, expire the content
+ // on the last modified
+ ret.expires = lastmod;
+ }
+ }
+ }
+
+ return ret;
+ }
+}