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