AndroidHttpClient.java revision 7762d93621f508f4410fd37b5bbdfec0071b0420
1/*
2 * Copyright (C) 2007 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 com.android.common;
18
19import org.apache.http.Header;
20import org.apache.http.HttpEntity;
21import org.apache.http.HttpEntityEnclosingRequest;
22import org.apache.http.HttpException;
23import org.apache.http.HttpHost;
24import org.apache.http.HttpRequest;
25import org.apache.http.HttpRequestInterceptor;
26import org.apache.http.HttpResponse;
27import org.apache.http.entity.AbstractHttpEntity;
28import org.apache.http.entity.ByteArrayEntity;
29import org.apache.http.client.HttpClient;
30import org.apache.http.client.ResponseHandler;
31import org.apache.http.client.ClientProtocolException;
32import org.apache.http.client.protocol.ClientContext;
33import org.apache.http.client.methods.HttpUriRequest;
34import org.apache.http.client.params.HttpClientParams;
35import org.apache.http.conn.ClientConnectionManager;
36import org.apache.http.conn.scheme.PlainSocketFactory;
37import org.apache.http.conn.scheme.Scheme;
38import org.apache.http.conn.scheme.SchemeRegistry;
39import org.apache.http.conn.ssl.SSLSocketFactory;
40import org.apache.http.impl.client.DefaultHttpClient;
41import org.apache.http.impl.client.RequestWrapper;
42import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
43import org.apache.http.params.BasicHttpParams;
44import org.apache.http.params.HttpConnectionParams;
45import org.apache.http.params.HttpParams;
46import org.apache.http.params.HttpProtocolParams;
47import org.apache.http.protocol.BasicHttpProcessor;
48import org.apache.http.protocol.HttpContext;
49import org.apache.http.protocol.BasicHttpContext;
50import org.apache.harmony.xnet.provider.jsse.SSLClientSessionCache;
51import org.apache.harmony.xnet.provider.jsse.SSLContextImpl;
52
53import java.io.IOException;
54import java.io.InputStream;
55import java.io.ByteArrayOutputStream;
56import java.io.OutputStream;
57import java.util.zip.GZIPInputStream;
58import java.util.zip.GZIPOutputStream;
59import java.net.URI;
60import java.security.KeyManagementException;
61
62import android.content.ContentResolver;
63import android.os.Looper;
64import android.os.SystemProperties;
65import android.provider.Settings;
66import android.text.TextUtils;
67import android.util.Log;
68
69/**
70 * Subclass of the Apache {@link DefaultHttpClient} that is configured with
71 * reasonable default settings and registered schemes for Android, and
72 * also lets the user add {@link HttpRequestInterceptor} classes.
73 * Don't create this directly, use the {@link #newInstance} factory method.
74 *
75 * <p>This client processes cookies but does not retain them by default.
76 * To retain cookies, simply add a cookie store to the HttpContext:</p>
77 *
78 * <pre>context.setAttribute(ClientContext.COOKIE_STORE, cookieStore);</pre>
79 *
80 * {@hide}
81 */
82public final class AndroidHttpClient implements HttpClient {
83
84    // Gzip of data shorter than this probably won't be worthwhile
85    public static long DEFAULT_SYNC_MIN_GZIP_BYTES = 256;
86
87    private static final String TAG = "AndroidHttpClient";
88
89
90    /** Interceptor throws an exception if the executing thread is blocked */
91    private static final HttpRequestInterceptor sThreadCheckInterceptor =
92            new HttpRequestInterceptor() {
93        public void process(HttpRequest request, HttpContext context) {
94            // Prevent the HttpRequest from being sent on the main thread
95            if (Looper.myLooper() != null && Looper.myLooper() == Looper.getMainLooper() ) {
96                throw new RuntimeException("This thread forbids HTTP requests");
97            }
98        }
99    };
100
101    /**
102     * Create a new HttpClient with reasonable defaults (which you can update).
103     *
104     * @param userAgent to report in your HTTP requests.
105     * @param sessionCache persistent session cache
106     * @return AndroidHttpClient for you to use for all your requests.
107     */
108    public static AndroidHttpClient newInstance(String userAgent,
109            SSLClientSessionCache sessionCache) {
110        HttpParams params = new BasicHttpParams();
111
112        // Turn off stale checking.  Our connections break all the time anyway,
113        // and it's not worth it to pay the penalty of checking every time.
114        HttpConnectionParams.setStaleCheckingEnabled(params, false);
115
116        // Default connection and socket timeout of 20 seconds.  Tweak to taste.
117        HttpConnectionParams.setConnectionTimeout(params, 20 * 1000);
118        HttpConnectionParams.setSoTimeout(params, 20 * 1000);
119        HttpConnectionParams.setSocketBufferSize(params, 8192);
120
121        // Don't handle redirects -- return them to the caller.  Our code
122        // often wants to re-POST after a redirect, which we must do ourselves.
123        HttpClientParams.setRedirecting(params, false);
124
125        // Set the specified user agent and register standard protocols.
126        HttpProtocolParams.setUserAgent(params, userAgent);
127        SchemeRegistry schemeRegistry = new SchemeRegistry();
128        schemeRegistry.register(new Scheme("http",
129                PlainSocketFactory.getSocketFactory(), 80));
130        schemeRegistry.register(new Scheme("https",
131                socketFactoryWithCache(sessionCache), 443));
132
133        ClientConnectionManager manager =
134                new ThreadSafeClientConnManager(params, schemeRegistry);
135
136        // We use a factory method to modify superclass initialization
137        // parameters without the funny call-a-static-method dance.
138        return new AndroidHttpClient(manager, params);
139    }
140
141    /**
142     * Returns a socket factory backed by the given persistent session cache.
143     *
144     * @param sessionCache to retrieve sessions from, null for no cache
145     */
146    private static SSLSocketFactory socketFactoryWithCache(
147            SSLClientSessionCache sessionCache) {
148        if (sessionCache == null) {
149            // Use the default factory which doesn't support persistent
150            // caching.
151            return SSLSocketFactory.getSocketFactory();
152        }
153
154        // Create a new SSL context backed by the cache.
155        // TODO: Keep a weak *identity* hash map of caches to engines. In the
156        // mean time, if we have two engines for the same cache, they'll still
157        // share sessions but will have to do so through the persistent cache.
158        SSLContextImpl sslContext = new SSLContextImpl();
159        try {
160            sslContext.engineInit(null, null, null, sessionCache, null);
161        } catch (KeyManagementException e) {
162            throw new AssertionError(e);
163        }
164        return new SSLSocketFactory(sslContext.engineGetSocketFactory());
165    }
166
167    /**
168     * Create a new HttpClient with reasonable defaults (which you can update).
169     * @param userAgent to report in your HTTP requests.
170     * @return AndroidHttpClient for you to use for all your requests.
171     */
172    public static AndroidHttpClient newInstance(String userAgent) {
173        return newInstance(userAgent, null /* session cache */);
174    }
175
176    private final HttpClient delegate;
177
178    private RuntimeException mLeakedException = new IllegalStateException(
179            "AndroidHttpClient created and never closed");
180
181    private AndroidHttpClient(ClientConnectionManager ccm, HttpParams params) {
182        this.delegate = new DefaultHttpClient(ccm, params) {
183            @Override
184            protected BasicHttpProcessor createHttpProcessor() {
185                // Add interceptor to prevent making requests from main thread.
186                BasicHttpProcessor processor = super.createHttpProcessor();
187                processor.addRequestInterceptor(sThreadCheckInterceptor);
188                processor.addRequestInterceptor(new CurlLogger());
189
190                return processor;
191            }
192
193            @Override
194            protected HttpContext createHttpContext() {
195                // Same as DefaultHttpClient.createHttpContext() minus the
196                // cookie store.
197                HttpContext context = new BasicHttpContext();
198                context.setAttribute(
199                        ClientContext.AUTHSCHEME_REGISTRY,
200                        getAuthSchemes());
201                context.setAttribute(
202                        ClientContext.COOKIESPEC_REGISTRY,
203                        getCookieSpecs());
204                context.setAttribute(
205                        ClientContext.CREDS_PROVIDER,
206                        getCredentialsProvider());
207                return context;
208            }
209        };
210    }
211
212    @Override
213    protected void finalize() throws Throwable {
214        super.finalize();
215        if (mLeakedException != null) {
216            Log.e(TAG, "Leak found", mLeakedException);
217            mLeakedException = null;
218        }
219    }
220
221    /**
222     * Modifies a request to indicate to the server that we would like a
223     * gzipped response.  (Uses the "Accept-Encoding" HTTP header.)
224     * @param request the request to modify
225     * @see #getUngzippedContent
226     */
227    public static void modifyRequestToAcceptGzipResponse(HttpRequest request) {
228        request.addHeader("Accept-Encoding", "gzip");
229    }
230
231    /**
232     * Gets the input stream from a response entity.  If the entity is gzipped
233     * then this will get a stream over the uncompressed data.
234     *
235     * @param entity the entity whose content should be read
236     * @return the input stream to read from
237     * @throws IOException
238     */
239    public static InputStream getUngzippedContent(HttpEntity entity)
240            throws IOException {
241        InputStream responseStream = entity.getContent();
242        if (responseStream == null) return responseStream;
243        Header header = entity.getContentEncoding();
244        if (header == null) return responseStream;
245        String contentEncoding = header.getValue();
246        if (contentEncoding == null) return responseStream;
247        if (contentEncoding.contains("gzip")) responseStream
248                = new GZIPInputStream(responseStream);
249        return responseStream;
250    }
251
252    /**
253     * Release resources associated with this client.  You must call this,
254     * or significant resources (sockets and memory) may be leaked.
255     */
256    public void close() {
257        if (mLeakedException != null) {
258            getConnectionManager().shutdown();
259            mLeakedException = null;
260        }
261    }
262
263    public HttpParams getParams() {
264        return delegate.getParams();
265    }
266
267    public ClientConnectionManager getConnectionManager() {
268        return delegate.getConnectionManager();
269    }
270
271    public HttpResponse execute(HttpUriRequest request) throws IOException {
272        return delegate.execute(request);
273    }
274
275    public HttpResponse execute(HttpUriRequest request, HttpContext context)
276            throws IOException {
277        return delegate.execute(request, context);
278    }
279
280    public HttpResponse execute(HttpHost target, HttpRequest request)
281            throws IOException {
282        return delegate.execute(target, request);
283    }
284
285    public HttpResponse execute(HttpHost target, HttpRequest request,
286            HttpContext context) throws IOException {
287        return delegate.execute(target, request, context);
288    }
289
290    public <T> T execute(HttpUriRequest request,
291            ResponseHandler<? extends T> responseHandler)
292            throws IOException, ClientProtocolException {
293        return delegate.execute(request, responseHandler);
294    }
295
296    public <T> T execute(HttpUriRequest request,
297            ResponseHandler<? extends T> responseHandler, HttpContext context)
298            throws IOException, ClientProtocolException {
299        return delegate.execute(request, responseHandler, context);
300    }
301
302    public <T> T execute(HttpHost target, HttpRequest request,
303            ResponseHandler<? extends T> responseHandler) throws IOException,
304            ClientProtocolException {
305        return delegate.execute(target, request, responseHandler);
306    }
307
308    public <T> T execute(HttpHost target, HttpRequest request,
309            ResponseHandler<? extends T> responseHandler, HttpContext context)
310            throws IOException, ClientProtocolException {
311        return delegate.execute(target, request, responseHandler, context);
312    }
313
314    /**
315     * Compress data to send to server.
316     * Creates a Http Entity holding the gzipped data.
317     * The data will not be compressed if it is too short.
318     * @param data The bytes to compress
319     * @return Entity holding the data
320     */
321    public static AbstractHttpEntity getCompressedEntity(byte data[], ContentResolver resolver)
322            throws IOException {
323        AbstractHttpEntity entity;
324        if (data.length < getMinGzipSize(resolver)) {
325            entity = new ByteArrayEntity(data);
326        } else {
327            ByteArrayOutputStream arr = new ByteArrayOutputStream();
328            OutputStream zipper = new GZIPOutputStream(arr);
329            zipper.write(data);
330            zipper.close();
331            entity = new ByteArrayEntity(arr.toByteArray());
332            entity.setContentEncoding("gzip");
333        }
334        return entity;
335    }
336
337    /**
338     * Retrieves the minimum size for compressing data.
339     * Shorter data will not be compressed.
340     */
341    public static long getMinGzipSize(ContentResolver resolver) {
342        String sMinGzipBytes = Settings.Gservices.getString(resolver,
343                Settings.Gservices.SYNC_MIN_GZIP_BYTES);
344
345        if (!TextUtils.isEmpty(sMinGzipBytes)) {
346            try {
347                return Long.parseLong(sMinGzipBytes);
348            } catch (NumberFormatException nfe) {
349                Log.w(TAG, "Unable to parse " +
350                        Settings.Gservices.SYNC_MIN_GZIP_BYTES + " " +
351                        sMinGzipBytes, nfe);
352            }
353        }
354        return DEFAULT_SYNC_MIN_GZIP_BYTES;
355    }
356
357    /* cURL logging support. */
358
359    /**
360     * Logging tag and level.
361     */
362    private static class LoggingConfiguration {
363
364        private final String tag;
365        private final int level;
366
367        private LoggingConfiguration(String tag, int level) {
368            this.tag = tag;
369            this.level = level;
370        }
371
372        /**
373         * Returns true if logging is turned on for this configuration.
374         */
375        private boolean isLoggable() {
376            return Log.isLoggable(tag, level);
377        }
378
379        /**
380         * Returns true if auth logging is turned on for this configuration.  Can only be set on
381         * insecure devices.
382         */
383        private boolean isAuthLoggable() {
384            String secure = SystemProperties.get("ro.secure");
385            return "0".equals(secure) && Log.isLoggable(tag + "-auth", level);
386        }
387
388        /**
389         * Prints a message using this configuration.
390         */
391        private void println(String message) {
392            Log.println(level, tag, message);
393        }
394    }
395
396    /** cURL logging configuration. */
397    private volatile LoggingConfiguration curlConfiguration;
398
399    /**
400     * Enables cURL request logging for this client.
401     *
402     * @param name to log messages with
403     * @param level at which to log messages (see {@link android.util.Log})
404     */
405    public void enableCurlLogging(String name, int level) {
406        if (name == null) {
407            throw new NullPointerException("name");
408        }
409        if (level < Log.VERBOSE || level > Log.ASSERT) {
410            throw new IllegalArgumentException("Level is out of range ["
411                + Log.VERBOSE + ".." + Log.ASSERT + "]");
412        }
413
414        curlConfiguration = new LoggingConfiguration(name, level);
415    }
416
417    /**
418     * Disables cURL logging for this client.
419     */
420    public void disableCurlLogging() {
421        curlConfiguration = null;
422    }
423
424    /**
425     * Logs cURL commands equivalent to requests.
426     */
427    private class CurlLogger implements HttpRequestInterceptor {
428        public void process(HttpRequest request, HttpContext context)
429                throws HttpException, IOException {
430            LoggingConfiguration configuration = curlConfiguration;
431            if (configuration != null
432                    && configuration.isLoggable()
433                    && request instanceof HttpUriRequest) {
434                configuration.println(toCurl((HttpUriRequest) request,
435                        configuration.isAuthLoggable()));
436            }
437        }
438    }
439
440    /**
441     * Generates a cURL command equivalent to the given request.
442     */
443    private static String toCurl(HttpUriRequest request, boolean logAuthToken) throws IOException {
444        StringBuilder builder = new StringBuilder();
445
446        builder.append("curl ");
447
448        for (Header header: request.getAllHeaders()) {
449            if (!logAuthToken
450                    && (header.getName().equals("Authorization") ||
451                        header.getName().equals("Cookie"))) {
452                continue;
453            }
454            builder.append("--header \"");
455            builder.append(header.toString().trim());
456            builder.append("\" ");
457        }
458
459        URI uri = request.getURI();
460
461        // If this is a wrapped request, use the URI from the original
462        // request instead. getURI() on the wrapper seems to return a
463        // relative URI. We want an absolute URI.
464        if (request instanceof RequestWrapper) {
465            HttpRequest original = ((RequestWrapper) request).getOriginal();
466            if (original instanceof HttpUriRequest) {
467                uri = ((HttpUriRequest) original).getURI();
468            }
469        }
470
471        builder.append("\"");
472        builder.append(uri);
473        builder.append("\"");
474
475        if (request instanceof HttpEntityEnclosingRequest) {
476            HttpEntityEnclosingRequest entityRequest =
477                    (HttpEntityEnclosingRequest) request;
478            HttpEntity entity = entityRequest.getEntity();
479            if (entity != null && entity.isRepeatable()) {
480                if (entity.getContentLength() < 1024) {
481                    ByteArrayOutputStream stream = new ByteArrayOutputStream();
482                    entity.writeTo(stream);
483                    String entityString = stream.toString();
484
485                    // TODO: Check the content type, too.
486                    builder.append(" --data-ascii \"")
487                            .append(entityString)
488                            .append("\"");
489                } else {
490                    builder.append(" [TOO MUCH DATA TO INCLUDE]");
491                }
492            }
493        }
494
495        return builder.toString();
496    }
497}
498