blob: 56042b1a611a9b850732597cfc9704a0f090d456 [file] [log] [blame]
/*
* 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.BufferedInputStream;
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.HttpURLConnection;
import java.net.ResponseCache;
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.Streams;
/**
* 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 {
// 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(new BufferedInputStream(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;
}
InputStream body = newBodyInputStream(snapshot);
return entry.isHttps()
? entry.newSecureCacheResponse(body)
: entry.newCacheResponse(body);
}
/**
* Returns an input stream that reads the body of a snapshot, closing the
* snapshot when the stream is closed.
*/
private InputStream newBodyInputStream(final DiskLruCache.Snapshot snapshot) {
return new FilterInputStream(snapshot.getInputStream(ENTRY_BODY)) {
@Override public void close() throws IOException {
snapshot.close();
super.close();
}
};
}
@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) {
// Give up because the cache cannot be written.
try {
if (editor != null) {
editor.abort();
}
} catch (IOException ignored) {
}
return null;
}
}
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;
}
synchronized void trackResponse(ResponseSource source) {
requestCount++;
switch (source) {
case CACHE:
hitCount++;
break;
case CONDITIONAL_CACHE:
case NETWORK:
networkCount++;
break;
}
}
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 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 {
uri = Streams.readAsciiLine(in);
requestMethod = Streams.readAsciiLine(in);
varyHeaders = new RawHeaders();
int varyRequestHeaderLineCount = readInt(in);
for (int i = 0; i < varyRequestHeaderLineCount; i++) {
varyHeaders.addLine(Streams.readAsciiLine(in));
}
responseHeaders = new RawHeaders();
responseHeaders.setStatusLine(Streams.readAsciiLine(in));
int responseHeaderLineCount = readInt(in);
for (int i = 0; i < responseHeaderLineCount; i++) {
responseHeaders.addLine(Streams.readAsciiLine(in));
}
if (isHttps()) {
String blank = Streams.readAsciiLine(in);
if (!blank.isEmpty()) {
throw new IOException("expected \"\" but was \"" + blank + "\"");
}
cipherSuite = Streams.readAsciiLine(in);
peerCertificates = readCertArray(in);
localCertificates = readCertArray(in);
} 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(0);
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 int readInt(InputStream in) throws IOException {
String intString = Streams.readAsciiLine(in);
try {
return Integer.parseInt(intString);
} catch (NumberFormatException e) {
throw new IOException("expected an int but was \"" + intString + "\"");
}
}
private Certificate[] readCertArray(InputStream in) throws IOException {
int length = readInt(in);
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 = Streams.readAsciiLine(in);
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);
}
public CacheResponse newCacheResponse(final InputStream in) {
return new CacheResponse() {
@Override public Map<String, List<String>> getHeaders() {
return responseHeaders.toMultimap();
}
@Override public InputStream getBody() {
return in;
}
};
}
public SecureCacheResponse newSecureCacheResponse(final InputStream in) {
return new SecureCacheResponse() {
@Override public Map<String, List<String>> getHeaders() {
return responseHeaders.toMultimap();
}
@Override public InputStream getBody() {
return in;
}
@Override public String getCipherSuite() {
return cipherSuite;
}
@Override public List<Certificate> getServerCertificateChain()
throws SSLPeerUnverifiedException {
if (peerCertificates == null || peerCertificates.length == 0) {
throw new SSLPeerUnverifiedException(null);
}
return Arrays.asList(peerCertificates.clone());
}
@Override public Principal getPeerPrincipal() throws SSLPeerUnverifiedException {
if (peerCertificates == null || peerCertificates.length == 0) {
throw new SSLPeerUnverifiedException(null);
}
return ((X509Certificate) peerCertificates[0]).getSubjectX500Principal();
}
@Override public List<Certificate> getLocalCertificateChain() {
if (localCertificates == null || localCertificates.length == 0) {
return null;
}
return Arrays.asList(localCertificates.clone());
}
@Override public Principal getLocalPrincipal() {
if (localCertificates == null || localCertificates.length == 0) {
return null;
}
return ((X509Certificate) localCertificates[0]).getSubjectX500Principal();
}
};
}
}
}