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