| /* |
| * Copyright (C) 2010 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 libcore.net.http; |
| |
| import java.io.BufferedWriter; |
| import java.io.ByteArrayInputStream; |
| import java.io.File; |
| import java.io.FilterInputStream; |
| import java.io.FilterOutputStream; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.io.OutputStreamWriter; |
| import java.io.Writer; |
| import java.net.CacheRequest; |
| import java.net.CacheResponse; |
| import java.net.ExtendedResponseCache; |
| import java.net.HttpURLConnection; |
| import java.net.ResponseCache; |
| import java.net.ResponseSource; |
| import java.net.SecureCacheResponse; |
| import java.net.URI; |
| import java.net.URLConnection; |
| import java.nio.charset.Charsets; |
| import java.security.MessageDigest; |
| import java.security.NoSuchAlgorithmException; |
| import java.security.Principal; |
| import java.security.cert.Certificate; |
| import java.security.cert.CertificateEncodingException; |
| import java.security.cert.CertificateException; |
| import java.security.cert.CertificateFactory; |
| import java.security.cert.X509Certificate; |
| import java.util.Arrays; |
| import java.util.List; |
| import java.util.Map; |
| import javax.net.ssl.HttpsURLConnection; |
| import javax.net.ssl.SSLPeerUnverifiedException; |
| import libcore.io.Base64; |
| import libcore.io.DiskLruCache; |
| import libcore.io.IoUtils; |
| import libcore.io.StrictLineReader; |
| |
| /** |
| * Cache responses in a directory on the file system. Most clients should use |
| * {@code android.net.HttpResponseCache}, the stable, documented front end for |
| * this. |
| */ |
| public final class HttpResponseCache extends ResponseCache implements ExtendedResponseCache { |
| // TODO: add APIs to iterate the cache? |
| private static final int VERSION = 201105; |
| private static final int ENTRY_METADATA = 0; |
| private static final int ENTRY_BODY = 1; |
| private static final int ENTRY_COUNT = 2; |
| |
| private final DiskLruCache cache; |
| |
| /* read and write statistics, all guarded by 'this' */ |
| private int writeSuccessCount; |
| private int writeAbortCount; |
| private int networkCount; |
| private int hitCount; |
| private int requestCount; |
| |
| public HttpResponseCache(File directory, long maxSize) throws IOException { |
| cache = DiskLruCache.open(directory, VERSION, ENTRY_COUNT, maxSize); |
| } |
| |
| private String uriToKey(URI uri) { |
| try { |
| MessageDigest messageDigest = MessageDigest.getInstance("MD5"); |
| byte[] md5bytes = messageDigest.digest(uri.toString().getBytes(Charsets.UTF_8)); |
| return IntegralToString.bytesToHexString(md5bytes, false); |
| } catch (NoSuchAlgorithmException e) { |
| throw new AssertionError(e); |
| } |
| } |
| |
| @Override public CacheResponse get(URI uri, String requestMethod, |
| Map<String, List<String>> requestHeaders) { |
| String key = uriToKey(uri); |
| DiskLruCache.Snapshot snapshot; |
| Entry entry; |
| try { |
| snapshot = cache.get(key); |
| if (snapshot == null) { |
| return null; |
| } |
| entry = new Entry(snapshot.getInputStream(ENTRY_METADATA)); |
| } catch (IOException e) { |
| // Give up because the cache cannot be read. |
| return null; |
| } |
| |
| if (!entry.matches(uri, requestMethod, requestHeaders)) { |
| snapshot.close(); |
| return null; |
| } |
| |
| return entry.isHttps() |
| ? new EntrySecureCacheResponse(entry, snapshot) |
| : new EntryCacheResponse(entry, snapshot); |
| } |
| |
| @Override public CacheRequest put(URI uri, URLConnection urlConnection) throws IOException { |
| if (!(urlConnection instanceof HttpURLConnection)) { |
| return null; |
| } |
| |
| HttpURLConnection httpConnection = (HttpURLConnection) urlConnection; |
| String requestMethod = httpConnection.getRequestMethod(); |
| String key = uriToKey(uri); |
| |
| if (requestMethod.equals(HttpEngine.POST) |
| || requestMethod.equals(HttpEngine.PUT) |
| || requestMethod.equals(HttpEngine.DELETE)) { |
| try { |
| cache.remove(key); |
| } catch (IOException ignored) { |
| // The cache cannot be written. |
| } |
| return null; |
| } else if (!requestMethod.equals(HttpEngine.GET)) { |
| /* |
| * Don't cache non-GET responses. We're technically allowed to cache |
| * HEAD requests and some POST requests, but the complexity of doing |
| * so is high and the benefit is low. |
| */ |
| return null; |
| } |
| |
| HttpEngine httpEngine = getHttpEngine(httpConnection); |
| if (httpEngine == null) { |
| // Don't cache unless the HTTP implementation is ours. |
| return null; |
| } |
| |
| ResponseHeaders response = httpEngine.getResponseHeaders(); |
| if (response.hasVaryAll()) { |
| return null; |
| } |
| |
| RawHeaders varyHeaders = httpEngine.getRequestHeaders().getHeaders().getAll( |
| response.getVaryFields()); |
| Entry entry = new Entry(uri, varyHeaders, httpConnection); |
| DiskLruCache.Editor editor = null; |
| try { |
| editor = cache.edit(key); |
| if (editor == null) { |
| return null; |
| } |
| entry.writeTo(editor); |
| return new CacheRequestImpl(editor); |
| } catch (IOException e) { |
| abortQuietly(editor); |
| return null; |
| } |
| } |
| |
| /** |
| * Handles a conditional request hit by updating the stored cache response |
| * with the headers from {@code httpConnection}. The cached response body is |
| * not updated. If the stored response has changed since {@code |
| * conditionalCacheHit} was returned, this does nothing. |
| */ |
| public void update(CacheResponse conditionalCacheHit, HttpURLConnection httpConnection) { |
| HttpEngine httpEngine = getHttpEngine(httpConnection); |
| URI uri = httpEngine.getUri(); |
| ResponseHeaders response = httpEngine.getResponseHeaders(); |
| RawHeaders varyHeaders = httpEngine.getRequestHeaders().getHeaders() |
| .getAll(response.getVaryFields()); |
| Entry entry = new Entry(uri, varyHeaders, httpConnection); |
| DiskLruCache.Snapshot snapshot = (conditionalCacheHit instanceof EntryCacheResponse) |
| ? ((EntryCacheResponse) conditionalCacheHit).snapshot |
| : ((EntrySecureCacheResponse) conditionalCacheHit).snapshot; |
| DiskLruCache.Editor editor = null; |
| try { |
| editor = snapshot.edit(); // returns null if snapshot is not current |
| if (editor != null) { |
| entry.writeTo(editor); |
| editor.commit(); |
| } |
| } catch (IOException e) { |
| abortQuietly(editor); |
| } |
| } |
| |
| private void abortQuietly(DiskLruCache.Editor editor) { |
| // Give up because the cache cannot be written. |
| try { |
| if (editor != null) { |
| editor.abort(); |
| } |
| } catch (IOException ignored) { |
| } |
| } |
| |
| private HttpEngine getHttpEngine(HttpURLConnection httpConnection) { |
| if (httpConnection instanceof HttpURLConnectionImpl) { |
| return ((HttpURLConnectionImpl) httpConnection).getHttpEngine(); |
| } else if (httpConnection instanceof HttpsURLConnectionImpl) { |
| return ((HttpsURLConnectionImpl) httpConnection).getHttpEngine(); |
| } else { |
| return null; |
| } |
| } |
| |
| public DiskLruCache getCache() { |
| return cache; |
| } |
| |
| public synchronized int getWriteAbortCount() { |
| return writeAbortCount; |
| } |
| |
| public synchronized int getWriteSuccessCount() { |
| return writeSuccessCount; |
| } |
| |
| public synchronized void trackResponse(ResponseSource source) { |
| requestCount++; |
| |
| switch (source) { |
| case CACHE: |
| hitCount++; |
| break; |
| case CONDITIONAL_CACHE: |
| case NETWORK: |
| networkCount++; |
| break; |
| } |
| } |
| |
| public synchronized void trackConditionalCacheHit() { |
| hitCount++; |
| } |
| |
| public synchronized int getNetworkCount() { |
| return networkCount; |
| } |
| |
| public synchronized int getHitCount() { |
| return hitCount; |
| } |
| |
| public synchronized int getRequestCount() { |
| return requestCount; |
| } |
| |
| private final class CacheRequestImpl extends CacheRequest { |
| private final DiskLruCache.Editor editor; |
| private OutputStream cacheOut; |
| private boolean done; |
| private OutputStream body; |
| |
| public CacheRequestImpl(final DiskLruCache.Editor editor) throws IOException { |
| this.editor = editor; |
| this.cacheOut = editor.newOutputStream(ENTRY_BODY); |
| this.body = new FilterOutputStream(cacheOut) { |
| @Override public void close() throws IOException { |
| synchronized (HttpResponseCache.this) { |
| if (done) { |
| return; |
| } |
| done = true; |
| writeSuccessCount++; |
| } |
| super.close(); |
| editor.commit(); |
| } |
| |
| @Override |
| public void write(byte[] buffer, int offset, int length) throws IOException { |
| // Since we don't override "write(int oneByte)", we can write directly to "out" |
| // and avoid the inefficient implementation from the FilterOutputStream. |
| out.write(buffer, offset, length); |
| } |
| }; |
| } |
| |
| @Override public void abort() { |
| synchronized (HttpResponseCache.this) { |
| if (done) { |
| return; |
| } |
| done = true; |
| writeAbortCount++; |
| } |
| IoUtils.closeQuietly(cacheOut); |
| try { |
| editor.abort(); |
| } catch (IOException ignored) { |
| } |
| } |
| |
| @Override public OutputStream getBody() throws IOException { |
| return body; |
| } |
| } |
| |
| private static final class Entry { |
| private final String uri; |
| private final RawHeaders varyHeaders; |
| private final String requestMethod; |
| private final RawHeaders responseHeaders; |
| private final String cipherSuite; |
| private final Certificate[] peerCertificates; |
| private final Certificate[] localCertificates; |
| |
| /* |
| * Reads an entry from an input stream. A typical entry looks like this: |
| * http://google.com/foo |
| * GET |
| * 2 |
| * Accept-Language: fr-CA |
| * Accept-Charset: UTF-8 |
| * HTTP/1.1 200 OK |
| * 3 |
| * Content-Type: image/png |
| * Content-Length: 100 |
| * Cache-Control: max-age=600 |
| * |
| * A typical HTTPS file looks like this: |
| * https://google.com/foo |
| * GET |
| * 2 |
| * Accept-Language: fr-CA |
| * Accept-Charset: UTF-8 |
| * HTTP/1.1 200 OK |
| * 3 |
| * Content-Type: image/png |
| * Content-Length: 100 |
| * Cache-Control: max-age=600 |
| * |
| * AES_256_WITH_MD5 |
| * 2 |
| * base64-encoded peerCertificate[0] |
| * base64-encoded peerCertificate[1] |
| * -1 |
| * |
| * The file is newline separated. The first two lines are the URL and |
| * the request method. Next is the number of HTTP Vary request header |
| * lines, followed by those lines. |
| * |
| * Next is the response status line, followed by the number of HTTP |
| * response header lines, followed by those lines. |
| * |
| * HTTPS responses also contain SSL session information. This begins |
| * with a blank line, and then a line containing the cipher suite. Next |
| * is the length of the peer certificate chain. These certificates are |
| * base64-encoded and appear each on their own line. The next line |
| * contains the length of the local certificate chain. These |
| * certificates are also base64-encoded and appear each on their own |
| * line. A length of -1 is used to encode a null array. |
| */ |
| public Entry(InputStream in) throws IOException { |
| try { |
| StrictLineReader reader = new StrictLineReader(in, Charsets.US_ASCII); |
| uri = reader.readLine(); |
| requestMethod = reader.readLine(); |
| varyHeaders = new RawHeaders(); |
| int varyRequestHeaderLineCount = reader.readInt(); |
| for (int i = 0; i < varyRequestHeaderLineCount; i++) { |
| varyHeaders.addLine(reader.readLine()); |
| } |
| |
| responseHeaders = new RawHeaders(); |
| responseHeaders.setStatusLine(reader.readLine()); |
| int responseHeaderLineCount = reader.readInt(); |
| for (int i = 0; i < responseHeaderLineCount; i++) { |
| responseHeaders.addLine(reader.readLine()); |
| } |
| |
| if (isHttps()) { |
| String blank = reader.readLine(); |
| if (!blank.isEmpty()) { |
| throw new IOException("expected \"\" but was \"" + blank + "\""); |
| } |
| cipherSuite = reader.readLine(); |
| peerCertificates = readCertArray(reader); |
| localCertificates = readCertArray(reader); |
| } else { |
| cipherSuite = null; |
| peerCertificates = null; |
| localCertificates = null; |
| } |
| } finally { |
| in.close(); |
| } |
| } |
| |
| public Entry(URI uri, RawHeaders varyHeaders, HttpURLConnection httpConnection) { |
| this.uri = uri.toString(); |
| this.varyHeaders = varyHeaders; |
| this.requestMethod = httpConnection.getRequestMethod(); |
| this.responseHeaders = RawHeaders.fromMultimap(httpConnection.getHeaderFields()); |
| |
| if (isHttps()) { |
| HttpsURLConnection httpsConnection = (HttpsURLConnection) httpConnection; |
| cipherSuite = httpsConnection.getCipherSuite(); |
| Certificate[] peerCertificatesNonFinal = null; |
| try { |
| peerCertificatesNonFinal = httpsConnection.getServerCertificates(); |
| } catch (SSLPeerUnverifiedException ignored) { |
| } |
| peerCertificates = peerCertificatesNonFinal; |
| localCertificates = httpsConnection.getLocalCertificates(); |
| } else { |
| cipherSuite = null; |
| peerCertificates = null; |
| localCertificates = null; |
| } |
| } |
| |
| public void writeTo(DiskLruCache.Editor editor) throws IOException { |
| OutputStream out = editor.newOutputStream(ENTRY_METADATA); |
| Writer writer = new BufferedWriter(new OutputStreamWriter(out, Charsets.UTF_8)); |
| |
| writer.write(uri + '\n'); |
| writer.write(requestMethod + '\n'); |
| writer.write(Integer.toString(varyHeaders.length()) + '\n'); |
| for (int i = 0; i < varyHeaders.length(); i++) { |
| writer.write(varyHeaders.getFieldName(i) + ": " |
| + varyHeaders.getValue(i) + '\n'); |
| } |
| |
| writer.write(responseHeaders.getStatusLine() + '\n'); |
| writer.write(Integer.toString(responseHeaders.length()) + '\n'); |
| for (int i = 0; i < responseHeaders.length(); i++) { |
| writer.write(responseHeaders.getFieldName(i) + ": " |
| + responseHeaders.getValue(i) + '\n'); |
| } |
| |
| if (isHttps()) { |
| writer.write('\n'); |
| writer.write(cipherSuite + '\n'); |
| writeCertArray(writer, peerCertificates); |
| writeCertArray(writer, localCertificates); |
| } |
| writer.close(); |
| } |
| |
| private boolean isHttps() { |
| return uri.startsWith("https://"); |
| } |
| |
| private Certificate[] readCertArray(StrictLineReader reader) throws IOException { |
| int length = reader.readInt(); |
| if (length == -1) { |
| return null; |
| } |
| try { |
| CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); |
| Certificate[] result = new Certificate[length]; |
| for (int i = 0; i < result.length; i++) { |
| String line = reader.readLine(); |
| byte[] bytes = Base64.decode(line.getBytes(Charsets.US_ASCII)); |
| result[i] = certificateFactory.generateCertificate( |
| new ByteArrayInputStream(bytes)); |
| } |
| return result; |
| } catch (CertificateException e) { |
| throw new IOException(e); |
| } |
| } |
| |
| private void writeCertArray(Writer writer, Certificate[] certificates) throws IOException { |
| if (certificates == null) { |
| writer.write("-1\n"); |
| return; |
| } |
| try { |
| writer.write(Integer.toString(certificates.length) + '\n'); |
| for (Certificate certificate : certificates) { |
| byte[] bytes = certificate.getEncoded(); |
| String line = Base64.encode(bytes); |
| writer.write(line + '\n'); |
| } |
| } catch (CertificateEncodingException e) { |
| throw new IOException(e); |
| } |
| } |
| |
| public boolean matches(URI uri, String requestMethod, |
| Map<String, List<String>> requestHeaders) { |
| return this.uri.equals(uri.toString()) |
| && this.requestMethod.equals(requestMethod) |
| && new ResponseHeaders(uri, responseHeaders) |
| .varyMatches(varyHeaders.toMultimap(), requestHeaders); |
| } |
| } |
| |
| /** |
| * Returns an input stream that reads the body of a snapshot, closing the |
| * snapshot when the stream is closed. |
| */ |
| private static InputStream newBodyInputStream(final DiskLruCache.Snapshot snapshot) { |
| return new FilterInputStream(snapshot.getInputStream(ENTRY_BODY)) { |
| @Override public void close() throws IOException { |
| snapshot.close(); |
| super.close(); |
| } |
| }; |
| } |
| |
| static class EntryCacheResponse extends CacheResponse { |
| private final Entry entry; |
| private final DiskLruCache.Snapshot snapshot; |
| private final InputStream in; |
| |
| public EntryCacheResponse(Entry entry, DiskLruCache.Snapshot snapshot) { |
| this.entry = entry; |
| this.snapshot = snapshot; |
| this.in = newBodyInputStream(snapshot); |
| } |
| |
| @Override public Map<String, List<String>> getHeaders() { |
| return entry.responseHeaders.toMultimap(); |
| } |
| |
| @Override public InputStream getBody() { |
| return in; |
| } |
| } |
| |
| static class EntrySecureCacheResponse extends SecureCacheResponse { |
| private final Entry entry; |
| private final DiskLruCache.Snapshot snapshot; |
| private final InputStream in; |
| |
| public EntrySecureCacheResponse(Entry entry, DiskLruCache.Snapshot snapshot) { |
| this.entry = entry; |
| this.snapshot = snapshot; |
| this.in = newBodyInputStream(snapshot); |
| } |
| |
| @Override public Map<String, List<String>> getHeaders() { |
| return entry.responseHeaders.toMultimap(); |
| } |
| |
| @Override public InputStream getBody() { |
| return in; |
| } |
| |
| @Override public String getCipherSuite() { |
| return entry.cipherSuite; |
| } |
| |
| @Override public List<Certificate> getServerCertificateChain() |
| throws SSLPeerUnverifiedException { |
| if (entry.peerCertificates == null || entry.peerCertificates.length == 0) { |
| throw new SSLPeerUnverifiedException(null); |
| } |
| return Arrays.asList(entry.peerCertificates.clone()); |
| } |
| |
| @Override public Principal getPeerPrincipal() throws SSLPeerUnverifiedException { |
| if (entry.peerCertificates == null || entry.peerCertificates.length == 0) { |
| throw new SSLPeerUnverifiedException(null); |
| } |
| return ((X509Certificate) entry.peerCertificates[0]).getSubjectX500Principal(); |
| } |
| |
| @Override public List<Certificate> getLocalCertificateChain() { |
| if (entry.localCertificates == null || entry.localCertificates.length == 0) { |
| return null; |
| } |
| return Arrays.asList(entry.localCertificates.clone()); |
| } |
| |
| @Override public Principal getLocalPrincipal() { |
| if (entry.localCertificates == null || entry.localCertificates.length == 0) { |
| return null; |
| } |
| return ((X509Certificate) entry.localCertificates[0]).getSubjectX500Principal(); |
| } |
| } |
| } |