/* * Copyright (C) 2012 Pixmob (http://github.com/pixmob) * * 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 org.pixmob.httpclient; import static org.pixmob.httpclient.Constants.HTTP_DELETE; import static org.pixmob.httpclient.Constants.HTTP_GET; import static org.pixmob.httpclient.Constants.HTTP_HEAD; import static org.pixmob.httpclient.Constants.HTTP_POST; import static org.pixmob.httpclient.Constants.HTTP_PUT; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.lang.reflect.Field; import java.net.HttpURLConnection; import java.net.InetAddress; import java.net.Socket; import java.net.SocketTimeoutException; import java.net.URL; import java.net.URLEncoder; import java.net.UnknownHostException; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.SecureRandom; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.zip.GZIPInputStream; import java.util.zip.Inflater; import java.util.zip.InflaterInputStream; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import org.apache.http.conn.ssl.BrowserCompatHostnameVerifier; import android.content.Context; import android.os.Build; /** * This class is used to prepare and execute an Http request. * @author Pixmob */ public final class HttpRequestBuilder { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final String CONTENT_CHARSET = "UTF-8"; private static final Map<String, List<String>> NO_HEADERS = new HashMap<String, List<String>>(0); private static TrustManager[] trustManagers; private final byte[] buffer = new byte[1024]; private final HttpClient hc; private final List<HttpRequestHandler> reqHandlers = new ArrayList<HttpRequestHandler>(2); private String uri; private String method; private Set<Integer> expectedStatusCodes = new HashSet<Integer>(2); private Map<String, String> cookies; private Map<String, List<String>> headers; private Map<String, String> parameters; private byte[] content; private boolean contentSet; private String contentType; private HttpResponseHandler handler; HttpRequestBuilder(final HttpClient hc, final String uri, final String method) { this.hc = hc; this.uri = uri; this.method = method; } public HttpRequestBuilder with(HttpRequestHandler handler) { if (handler != null) { reqHandlers.add(handler); } return this; } public HttpRequestBuilder expect(int... statusCodes) { if (statusCodes != null) { for (final int statusCode : statusCodes) { if (statusCode < 1) { throw new IllegalArgumentException("Invalid status code: " + statusCode); } expectedStatusCodes.add(statusCode); } } return this; } public HttpRequestBuilder content(byte[] content, String contentType) { this.content = content; this.contentType = contentType; if (content != null) { contentSet = true; } return this; } public HttpRequestBuilder cookies(Map<String, String> cookies) { this.cookies = cookies; return this; } public HttpRequestBuilder headers(Map<String, List<String>> headers) { this.headers = headers; return this; } public HttpRequestBuilder header(String name, String value) { if (name == null) { throw new IllegalArgumentException("Header name cannot be null"); } if (value == null) { throw new IllegalArgumentException("Header value cannot be null"); } if (headers == null) { headers = new HashMap<String, List<String>>(2); } List<String> values = headers.get(name); if (values == null) { values = new ArrayList<String>(1); headers.put(name, values); } values.add(value); return this; } public HttpRequestBuilder params(Map<String, String> parameters) { this.parameters = parameters; return this; } public HttpRequestBuilder param(String name, String value) { if (name == null) { throw new IllegalArgumentException("Parameter name cannot be null"); } if (value == null) { throw new IllegalArgumentException("Parameter value cannot be null"); } if (parameters == null) { parameters = new HashMap<String, String>(4); } parameters.put(name, value); return this; } public HttpRequestBuilder cookie(String name, String value) { if (name == null) { throw new IllegalArgumentException("Cookie name cannot be null"); } if (value == null) { throw new IllegalArgumentException("Cookie value cannot be null"); } if (cookies == null) { cookies = new HashMap<String, String>(2); } cookies.put(name, value); return this; } public HttpRequestBuilder to(HttpResponseHandler handler) { this.handler = handler; return this; } public HttpRequestBuilder to(File file) throws IOException { to(new WriteToOutputStreamHandler(new FileOutputStream(file))); return this; } public HttpRequestBuilder to(OutputStream output) { to(new WriteToOutputStreamHandler(output)); return this; } public HttpResponse execute() throws HttpClientException { HttpURLConnection conn = null; UncloseableInputStream payloadStream = null; try { if (parameters != null && !parameters.isEmpty()) { final StringBuilder buf = new StringBuilder(256); if (HTTP_GET.equals(method) || HTTP_HEAD.equals(method)) { buf.append('?'); } int paramIdx = 0; for (final Map.Entry<String, String> e : parameters.entrySet()) { if (paramIdx != 0) { buf.append("&"); } final String name = e.getKey(); final String value = e.getValue(); buf.append(URLEncoder.encode(name, CONTENT_CHARSET)).append("=") .append(URLEncoder.encode(value, CONTENT_CHARSET)); ++paramIdx; } if (!contentSet && (HTTP_POST.equals(method) || HTTP_DELETE.equals(method) || HTTP_PUT.equals(method))) { try { content = buf.toString().getBytes(CONTENT_CHARSET); } catch (UnsupportedEncodingException e) { // Unlikely to happen. throw new HttpClientException("Encoding error", e); } } else { uri += buf; } } conn = (HttpURLConnection) new URL(uri).openConnection(); conn.setConnectTimeout(hc.getConnectTimeout()); conn.setReadTimeout(hc.getReadTimeout()); conn.setAllowUserInteraction(false); conn.setInstanceFollowRedirects(false); conn.setRequestMethod(method); conn.setUseCaches(false); conn.setDoInput(true); if (headers != null && !headers.isEmpty()) { for (final Map.Entry<String, List<String>> e : headers.entrySet()) { final List<String> values = e.getValue(); if (values != null) { final String name = e.getKey(); for (final String value : values) { conn.addRequestProperty(name, value); } } } } if (cookies != null && !cookies.isEmpty() || hc.getInMemoryCookies() != null && !hc.getInMemoryCookies().isEmpty()) { final StringBuilder cookieHeaderValue = new StringBuilder(256); prepareCookieHeader(cookies, cookieHeaderValue); prepareCookieHeader(hc.getInMemoryCookies(), cookieHeaderValue); conn.setRequestProperty("Cookie", cookieHeaderValue.toString()); } final String userAgent = hc.getUserAgent(); if (userAgent != null) { conn.setRequestProperty("User-Agent", userAgent); } conn.setRequestProperty("Connection", "close"); conn.setRequestProperty("Location", uri); conn.setRequestProperty("Referrer", uri); conn.setRequestProperty("Accept-Encoding", "gzip,deflate"); conn.setRequestProperty("Accept-Charset", CONTENT_CHARSET); if (conn instanceof HttpsURLConnection) { setupSecureConnection(hc.getContext(), (HttpsURLConnection) conn); } if (HTTP_POST.equals(method) || HTTP_DELETE.equals(method) || HTTP_PUT.equals(method)) { if (content != null) { conn.setDoOutput(true); if (!contentSet) { conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=" + CONTENT_CHARSET); } else if (contentType != null) { conn.setRequestProperty("Content-Type", contentType); } conn.setFixedLengthStreamingMode(content.length); final OutputStream out = conn.getOutputStream(); out.write(content); out.flush(); } else { conn.setFixedLengthStreamingMode(0); } } for (final HttpRequestHandler connHandler : reqHandlers) { try { connHandler.onRequest(conn); } catch (HttpClientException e) { throw e; } catch (Exception e) { throw new HttpClientException("Failed to prepare request to " + uri, e); } } conn.connect(); final int statusCode = conn.getResponseCode(); if (statusCode == -1) { throw new HttpClientException("Invalid response from " + uri); } if (!expectedStatusCodes.isEmpty() && !expectedStatusCodes.contains(statusCode)) { throw new HttpClientException("Expected status code " + expectedStatusCodes + ", got " + statusCode); } else if (expectedStatusCodes.isEmpty() && statusCode / 100 != 2) { throw new HttpClientException("Expected status code 2xx, got " + statusCode); } final Map<String, List<String>> headerFields = conn.getHeaderFields(); final Map<String, String> inMemoryCookies = hc.getInMemoryCookies(); if (headerFields != null) { final List<String> newCookies = headerFields.get("Set-Cookie"); if (newCookies != null) { for (final String newCookie : newCookies) { final String rawCookie = newCookie.split(";", 2)[0]; final int i = rawCookie.indexOf('='); final String name = rawCookie.substring(0, i); final String value = rawCookie.substring(i + 1); inMemoryCookies.put(name, value); } } } if (isStatusCodeError(statusCode)) { // Got an error: cannot read input. payloadStream = new UncloseableInputStream(getErrorStream(conn)); } else { payloadStream = new UncloseableInputStream(getInputStream(conn)); } final HttpResponse resp = new HttpResponse(statusCode, payloadStream, headerFields == null ? NO_HEADERS : headerFields, inMemoryCookies); if (handler != null) { try { handler.onResponse(resp); } catch (HttpClientException e) { throw e; } catch (Exception e) { throw new HttpClientException("Error in response handler", e); } } else { final File temp = File.createTempFile("httpclient-req-", ".cache", hc.getContext().getCacheDir()); resp.preload(temp); temp.delete(); } return resp; } catch (SocketTimeoutException e) { if (handler != null) { try { handler.onTimeout(); return null; } catch (HttpClientException e2) { throw e2; } catch (Exception e2) { throw new HttpClientException("Error in response handler", e2); } } else { throw new HttpClientException("Response timeout from " + uri, e); } } catch (IOException e) { throw new HttpClientException("Connection failed to " + uri, e); } finally { if (conn != null) { if (payloadStream != null) { // Fully read Http response: // http://docs.oracle.com/javase/6/docs/technotes/guides/net/http-keepalive.html try { while (payloadStream.read(buffer) != -1) { ; } } catch (IOException ignore) { } payloadStream.forceClose(); } conn.disconnect(); } } } private static boolean isStatusCodeError(int sc) { final int i = sc / 100; return i == 4 || i == 5; } private static void prepareCookieHeader(Map<String, String> cookies, StringBuilder headerValue) { if (cookies != null) { for (final Map.Entry<String, String> e : cookies.entrySet()) { if (headerValue.length() != 0) { headerValue.append("; "); } headerValue.append(e.getKey()).append("=").append(e.getValue()); } } } /** * Open the {@link InputStream} of an Http response. This method supports * GZIP and DEFLATE responses. */ private static InputStream getInputStream(HttpURLConnection conn) throws IOException { final List<String> contentEncodingValues = conn.getHeaderFields().get("Content-Encoding"); if (contentEncodingValues != null) { for (final String contentEncoding : contentEncodingValues) { if (contentEncoding != null) { if (contentEncoding.contains("gzip")) { return new GZIPInputStream(conn.getInputStream()); } if (contentEncoding.contains("deflate")) { return new InflaterInputStream(conn.getInputStream(), new Inflater(true)); } } } } return conn.getInputStream(); } /** * Open the error {@link InputStream} of an Http response. This method * supports GZIP and DEFLATE responses. */ private static InputStream getErrorStream(HttpURLConnection conn) throws IOException { final List<String> contentEncodingValues = conn.getHeaderFields().get("Content-Encoding"); if (contentEncodingValues != null) { for (final String contentEncoding : contentEncodingValues) { if (contentEncoding != null) { if (contentEncoding.contains("gzip")) { return new GZIPInputStream(conn.getErrorStream()); } if (contentEncoding.contains("deflate")) { return new InflaterInputStream(conn.getErrorStream(), new Inflater(true)); } } } } return conn.getErrorStream(); } private static KeyStore loadCertificates(Context context) throws IOException { try { final KeyStore localTrustStore = KeyStore.getInstance("BKS"); final InputStream in = context.getResources().openRawResource(R.raw.hc_keystore); try { localTrustStore.load(in, null); } finally { in.close(); } return localTrustStore; } catch (Exception e) { final IOException ioe = new IOException("Failed to load SSL certificates"); ioe.initCause(e); throw ioe; } } /** * Setup SSL connection. */ private static void setupSecureConnection(Context context, HttpsURLConnection conn) throws IOException { final SSLContext sslContext; try { // SSL certificates are provided by the Guardian Project: // https://github.com/guardianproject/cacert if (trustManagers == null) { // Load SSL certificates: // http://nelenkov.blogspot.com/2011/12/using-custom-certificate-trust-store-on.html // Earlier Android versions do not have updated root CA // certificates, resulting in connection errors. final KeyStore keyStore = loadCertificates(context); final CustomTrustManager customTrustManager = new CustomTrustManager(keyStore); trustManagers = new TrustManager[] { customTrustManager }; } // Init SSL connection with custom certificates. // The same SecureRandom instance is used for every connection to // speed up initialization. sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustManagers, SECURE_RANDOM); } catch (GeneralSecurityException e) { final IOException ioe = new IOException("Failed to initialize SSL engine"); ioe.initCause(e); throw ioe; } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { // Fix slow read: // http://code.google.com/p/android/issues/detail?id=13117 // Prior to ICS, the host name is still resolved even if we already // know its IP address, for each connection. final SSLSocketFactory delegate = sslContext.getSocketFactory(); final SSLSocketFactory socketFactory = new SSLSocketFactory() { @Override public Socket createSocket(String host, int port) throws IOException, UnknownHostException { InetAddress addr = InetAddress.getByName(host); injectHostname(addr, host); return delegate.createSocket(addr, port); } @Override public Socket createSocket(InetAddress host, int port) throws IOException { return delegate.createSocket(host, port); } @Override public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException, UnknownHostException { return delegate.createSocket(host, port, localHost, localPort); } @Override public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { return delegate.createSocket(address, port, localAddress, localPort); } private void injectHostname(InetAddress address, String host) { try { Field field = InetAddress.class.getDeclaredField("hostName"); field.setAccessible(true); field.set(address, host); } catch (Exception ignored) { } } @Override public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { injectHostname(s.getInetAddress(), host); return delegate.createSocket(s, host, port, autoClose); } @Override public String[] getDefaultCipherSuites() { return delegate.getDefaultCipherSuites(); } @Override public String[] getSupportedCipherSuites() { return delegate.getSupportedCipherSuites(); } }; conn.setSSLSocketFactory(socketFactory); } else { conn.setSSLSocketFactory(sslContext.getSocketFactory()); } conn.setHostnameVerifier(new BrowserCompatHostnameVerifier()); } }