/* * 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.AndroidHttpClient; import android.net.http.Headers; import android.os.FileUtils; import android.util.Log; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FilenameFilter; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.List; import java.util.Map; import com.android.org.bouncycastle.crypto.Digest; import com.android.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. * * CacheManager may only be used if your activity contains a WebView. * * @deprecated Access to the HTTP cache will be removed in a future release. */ @Deprecated 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 final String MANIFEST_MIME = "text/cache-manifest"; private static long CACHE_THRESHOLD = 6 * 1024 * 1024; private static long CACHE_TRIM_AMOUNT = 2 * 1024 * 1024; // Limit the maximum cache file size to half of the normal capacity static long CACHE_MAX_SIZE = (CACHE_THRESHOLD - CACHE_TRIM_AMOUNT) / 2; 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; /** * This class represents a resource retrieved from the HTTP cache. * Instances of this class can be obtained by invoking the * CacheManager.getCacheFile() method. * * @deprecated Access to the HTTP cache will be removed in a future release. */ @Deprecated public static class CacheResult { // these fields are saved to the database int httpStatusCode; long contentLength; long expires; String expiresString; String localPath; String lastModified; String etag; String mimeType; String location; String encoding; String contentdisposition; String crossDomain; // 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 getExpiresString() { return expiresString; } 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; } public String getContentDisposition() { return contentdisposition; } // 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; } /** * @hide */ public void setContentLength(long contentLength) { this.contentLength = contentLength; } } /** * Initialize the CacheManager. * * Note that this is called automatically when a {@link android.webkit.WebView} is created. * * @param context The application context. */ static void init(Context context) { if (JniUtil.useChromiumHttpStack()) { // This isn't actually where the real cache lives, but where we put files for the // purpose of getCacheFile(). mBaseDir = new File(context.getCacheDir(), "webviewCacheChromiumStaging"); if (!mBaseDir.exists()) { mBaseDir.mkdirs(); } return; } mDataBase = WebViewDatabase.getInstance(context.getApplicationContext()); 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() { assert !JniUtil.useChromiumHttpStack(); 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, -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. // delete rows in the cache database WebViewWorker.getHandler().sendEmptyMessage( WebViewWorker.MSG_CLEAR_CACHE); return true; } return false; } /** * Get the base directory of the cache. Together with the local path of the CacheResult, * obtained from {@link android.webkit.CacheManager.CacheResult#getLocalPath}, this * identifies the cache file. * * Cache files are not guaranteed to be in this directory before * CacheManager#getCacheFile(String, Map<String, String>) is called. * * @return File The base directory of the cache. * * @deprecated Access to the HTTP cache will be removed in a future release. */ @Deprecated public static File getCacheFileBaseDir() { return mBaseDir; } /** * Sets whether the cache is disabled. * * @param disabled Whether the cache should be disabled */ static void setCacheDisabled(boolean disabled) { assert !JniUtil.useChromiumHttpStack(); if (disabled == mDisabled) { return; } mDisabled = disabled; if (mDisabled) { removeAllCacheFiles(); } } /** * Whether the cache is disabled. * * @return return Whether the cache is disabled * * @deprecated Access to the HTTP cache will be removed in a future release. */ @Deprecated public static boolean cacheDisabled() { return mDisabled; } // only called from WebViewWorkerThread // make sure to call enableTransaction/disableTransaction in pair static boolean enableTransaction() { assert !JniUtil.useChromiumHttpStack(); if (++mRefCount == 1) { mDataBase.startCacheTransaction(); return true; } return false; } // only called from WebViewWorkerThread // make sure to call enableTransaction/disableTransaction in pair static boolean disableTransaction() { assert !JniUtil.useChromiumHttpStack(); if (--mRefCount == 0) { mDataBase.endCacheTransaction(); return true; } return false; } // only called from WebViewWorkerThread // make sure to call startTransaction/endTransaction in pair static boolean startTransaction() { assert !JniUtil.useChromiumHttpStack(); return mDataBase.startCacheTransaction(); } // only called from WebViewWorkerThread // make sure to call startTransaction/endTransaction in pair static boolean endTransaction() { assert !JniUtil.useChromiumHttpStack(); boolean ret = mDataBase.endCacheTransaction(); if (++mTrimCacheCount >= TRIM_CACHE_INTERVAL) { mTrimCacheCount = 0; trimCacheIfNeeded(); } return ret; } // only called from WebCore Thread // make sure to call startCacheTransaction/endCacheTransaction in pair /** * @deprecated Always returns false. */ @Deprecated public static boolean startCacheTransaction() { return false; } // only called from WebCore Thread // make sure to call startCacheTransaction/endCacheTransaction in pair /** * @deprecated Always returns false. */ @Deprecated public static boolean endCacheTransaction() { return false; } /** * Given a URL, returns the corresponding CacheResult if it exists, or null otherwise. * * The input stream of the CacheEntry object is initialized and opened and should be closed by * the caller when access to the underlying file is no longer required. * If a non-zero value is provided for the headers map, and the cache entry needs validation, * HEADER_KEY_IFNONEMATCH or HEADER_KEY_IFMODIFIEDSINCE will be set in headers. * * @return The CacheResult for the given URL * * @deprecated Access to the HTTP cache will be removed in a future release. */ @Deprecated public static CacheResult getCacheFile(String url, Map<String, String> headers) { return getCacheFile(url, 0, headers); } static CacheResult getCacheFile(String url, long postIdentifier, Map<String, String> headers) { if (mDisabled) { return null; } if (JniUtil.useChromiumHttpStack()) { CacheResult result = nativeGetCacheResult(url); if (result == null) { return null; } // A temporary local file will have been created native side and localPath set // appropriately. File src = new File(mBaseDir, result.localPath); try { // Open the file here so that even if it is deleted, the content // is still readable by the caller until close() is called. result.inStream = new FileInputStream(src); } catch (FileNotFoundException e) { Log.v(LOGTAG, "getCacheFile(): Failed to open file: " + e); // TODO: The files in the cache directory can be removed by the // system. If it is gone, what should we do? return null; } return result; } String databaseKey = getDatabaseKey(url, postIdentifier); CacheResult result = mDataBase.getCache(databaseKey); if (result == null) { return null; } if (result.contentLength == 0) { if (!isCachableRedirect(result.httpStatusCode)) { // This should not happen. If it does, remove it. mDataBase.removeCache(databaseKey); return null; } } else { File src = new File(mBaseDir, result.localPath); try { // Open the file here so that even if it 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(databaseKey); return null; } } // A null value for headers is used by CACHE_MODE_CACHE_ONLY to imply // that we should provide the cache result even if it is expired. // Note that a negative expires value means a 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 (DebugFlags.CACHE_MANAGER) { 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. * * @deprecated Access to the HTTP cache will be removed in a future release. */ @Deprecated public static CacheResult createCacheFile(String url, int statusCode, Headers headers, String mimeType, boolean forceCache) { if (JniUtil.useChromiumHttpStack()) { // This method is public but hidden. We break functionality. return null; } return createCacheFile(url, statusCode, headers, mimeType, 0, forceCache); } static CacheResult createCacheFile(String url, int statusCode, Headers headers, String mimeType, long postIdentifier, boolean forceCache) { assert !JniUtil.useChromiumHttpStack(); if (!forceCache && mDisabled) { return null; } String databaseKey = getDatabaseKey(url, postIdentifier); // according to the rfc 2616, the 303 response MUST NOT be cached. if (statusCode == 303) { // remove the saved cache if there is any mDataBase.removeCache(databaseKey); return null; } // like the other browsers, do not cache redirects containing a cookie // header. if (isCachableRedirect(statusCode) && !headers.getSetCookie().isEmpty()) { // remove the saved cache if there is any mDataBase.removeCache(databaseKey); return null; } CacheResult ret = parseHeaders(statusCode, headers, mimeType); if (ret == null) { // this should only happen if the headers has "no-store" in the // cache-control. remove the saved cache if there is any mDataBase.removeCache(databaseKey); } else { setupFiles(databaseKey, 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 * * @deprecated Access to the HTTP cache will be removed in a future release. */ @Deprecated public static void saveCacheFile(String url, CacheResult cacheRet) { saveCacheFile(url, 0, cacheRet); } static void saveCacheFile(String url, long postIdentifier, CacheResult cacheRet) { try { cacheRet.outStream.close(); } catch (IOException e) { return; } if (JniUtil.useChromiumHttpStack()) { // This method is exposed in the public API but the API provides no way to obtain a // new CacheResult object with a non-null output stream ... // - CacheResult objects returned by getCacheFile() have a null output stream. // - new CacheResult objects have a null output stream and no setter is provided. // Since for the Android HTTP stack this method throws a null pointer exception in this // case, this method is effectively useless from the point of view of the public API. // We should already have thrown an exception above, to maintain 'backward // compatibility' with the Android HTTP stack. assert false; } if (!cacheRet.outFile.exists()) { // the file in the cache directory can be removed by the system return; } boolean redirect = isCachableRedirect(cacheRet.httpStatusCode); if (redirect) { // location is in database, no need to keep the file cacheRet.contentLength = 0; cacheRet.localPath = ""; } if ((redirect || cacheRet.contentLength == 0) && !cacheRet.outFile.delete()) { Log.e(LOGTAG, cacheRet.outFile.getPath() + " delete failed."); } if (cacheRet.contentLength == 0) { return; } mDataBase.addCache(getDatabaseKey(url, postIdentifier), cacheRet); if (DebugFlags.CACHE_MANAGER) { Log.v(LOGTAG, "saveCacheFile for url " + url); } } static boolean cleanupCacheFile(CacheResult cacheRet) { assert !JniUtil.useChromiumHttpStack(); try { cacheRet.outStream.close(); } catch (IOException e) { return false; } return cacheRet.outFile.delete(); } /** * Remove all cache files. * * @return Whether the removal succeeded. */ static boolean removeAllCacheFiles() { // Note, this is called before init() when the database is // created or upgraded. if (mBaseDir == null) { // This method should not be called before init() when using the // chrome http stack assert !JniUtil.useChromiumHttpStack(); // 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 rows in the cache database if (!JniUtil.useChromiumHttpStack()) WebViewWorker.getHandler().sendEmptyMessage(WebViewWorker.MSG_CLEAR_CACHE); // delete cache files 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++) { File f = new File(mBaseDir, files[i]); if (!f.delete()) { Log.e(LOGTAG, f.getPath() + " delete failed."); } } } } catch (SecurityException e) { // Ignore SecurityExceptions. } } }; new Thread(clearCache).start(); return true; } static void trimCacheIfNeeded() { assert !JniUtil.useChromiumHttpStack(); if (mDataBase.getCacheTotalSize() > CACHE_THRESHOLD) { List<String> pathList = mDataBase.trimCache(CACHE_TRIM_AMOUNT); int size = pathList.size(); for (int i = 0; i < size; i++) { File f = new File(mBaseDir, pathList.get(i)); if (!f.delete()) { Log.e(LOGTAG, f.getPath() + " delete failed."); } } // remove the unreferenced files in the cache directory final List<String> fileList = mDataBase.getAllCacheFileNames(); if (fileList == null) return; String[] toDelete = mBaseDir.list(new FilenameFilter() { public boolean accept(File dir, String filename) { if (fileList.contains(filename)) { return false; } else { return true; } } }); if (toDelete == null) return; size = toDelete.length; for (int i = 0; i < size; i++) { File f = new File(mBaseDir, toDelete[i]); if (!f.delete()) { Log.e(LOGTAG, f.getPath() + " delete failed."); } } } } static void clearCache() { assert !JniUtil.useChromiumHttpStack(); // delete database mDataBase.clearCache(); } private static boolean isCachableRedirect(int statusCode) { if (statusCode == 301 || statusCode == 302 || statusCode == 307) { // as 303 can't be cached, we do not return true return true; } else { return false; } } private static String getDatabaseKey(String url, long postIdentifier) { assert !JniUtil.useChromiumHttpStack(); if (postIdentifier == 0) return url; return postIdentifier + url; } @SuppressWarnings("deprecation") private static void setupFiles(String url, CacheResult cacheRet) { assert !JniUtil.useChromiumHttpStack(); 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) { CacheResult oldResult = mDataBase.getCache(url); 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) { assert !JniUtil.useChromiumHttpStack(); 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) { assert !JniUtil.useChromiumHttpStack(); // if the contentLength is already larger than CACHE_MAX_SIZE, skip it if (headers.getContentLength() > CACHE_MAX_SIZE) return null; // The HTML 5 spec, section 6.9.4, step 7.3 of the application cache // process states that HTTP caching rules are ignored for the // purposes of the application cache download process. // At this point we can't tell that if a file is part of this process, // except for the manifest, which has its own mimeType. // TODO: work out a way to distinguish all responses that are part of // the application download process and skip them. if (MANIFEST_MIME.equals(mimeType)) return null; // TODO: if authenticated or secure, return null CacheResult ret = new CacheResult(); ret.httpStatusCode = statusCode; ret.location = headers.getLocation(); ret.expires = -1; ret.expiresString = headers.getExpires(); if (ret.expiresString != null) { try { ret.expires = AndroidHttpClient.parseDate(ret.expiresString); } catch (IllegalArgumentException ex) { // Take care of the special "-1" and "0" cases if ("-1".equals(ret.expiresString) || "0".equals(ret.expiresString)) { // make it expired, but can be used for history navigation ret.expires = 0; } else { Log.e(LOGTAG, "illegal expires: " + ret.expiresString); } } } ret.contentdisposition = headers.getContentDisposition(); ret.crossDomain = headers.getXPermittedCrossDomainPolicies(); // lastModified and etag may be set back to http header. So they can't // be empty string. String lastModified = headers.getLastModified(); if (lastModified != null && lastModified.length() > 0) { ret.lastModified = lastModified; } String etag = headers.getEtag(); if (etag != null && etag.length() > 0) { ret.etag = etag; } String cacheControl = headers.getCacheControl(); if (cacheControl != null) { String[] controls = cacheControl.toLowerCase().split("[ ,;]"); boolean noCache = false; 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; noCache = true; // if cache control = no-cache has been received, ignore max-age // header, according to http spec: // If a request includes the no-cache directive, it SHOULD NOT // include min-fresh, max-stale, or max-age. } else if (controls[i].startsWith(MAX_AGE) && !noCache) { 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 = AndroidHttpClient.parseDate(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; } private static native CacheResult nativeGetCacheResult(String url); }