1// Copyright 2007 The Android Open Source Project
2
3package com.google.android.gdata2.client;
4
5import com.google.android.net.GoogleHttpClient;
6import com.google.wireless.gdata2.client.GDataClient;
7import com.google.wireless.gdata2.client.HttpException;
8import com.google.wireless.gdata2.client.QueryParams;
9import com.google.wireless.gdata2.data.StringUtils;
10import com.google.wireless.gdata2.parser.ParseException;
11import com.google.wireless.gdata2.serializer.GDataSerializer;
12import com.google.android.gdata2.client.QueryParamsImpl;
13
14import org.apache.http.Header;
15import org.apache.http.HttpEntity;
16import org.apache.http.HttpResponse;
17import org.apache.http.StatusLine;
18import org.apache.http.params.HttpParams;
19import org.apache.http.params.BasicHttpParams;
20import org.apache.http.client.methods.HttpGet;
21import org.apache.http.client.methods.HttpPost;
22import org.apache.http.client.methods.HttpUriRequest;
23import org.apache.http.entity.InputStreamEntity;
24import org.apache.http.entity.AbstractHttpEntity;
25import org.apache.http.entity.ByteArrayEntity;
26
27import android.content.ContentResolver;
28import android.content.Context;
29import android.net.http.AndroidHttpClient;
30import android.text.TextUtils;
31import android.util.Config;
32import android.util.Log;
33import android.os.SystemProperties;
34
35import java.io.ByteArrayOutputStream;
36import java.io.IOException;
37import java.io.InputStream;
38import java.io.UnsupportedEncodingException;
39import java.io.BufferedInputStream;
40import java.net.URI;
41import java.net.URISyntaxException;
42import java.net.URLEncoder;
43
44/**
45 * Implementation of a GDataClient using GoogleHttpClient to make HTTP
46 * requests.  Always issues GETs and POSTs, using the X-HTTP-Method-Override
47 * header when a PUT or DELETE is desired, to avoid issues with firewalls, etc.,
48 * that do not allow methods other than GET or POST.
49 */
50public class AndroidGDataClient implements GDataClient {
51
52    private static final String TAG = "GDataClient";
53    private static final boolean DEBUG = false;
54    private static final boolean LOCAL_LOGV = DEBUG ? Config.LOGD : Config.LOGV;
55
56    private static final String X_HTTP_METHOD_OVERRIDE =
57        "X-HTTP-Method-Override";
58
59    private static final String DEFAULT_USER_AGENT_APP_VERSION = "Android-GData/1.2";
60
61    private static final int MAX_REDIRECTS = 10;
62    private static String DEFAULT_GDATA_VERSION = "2.0";
63
64    // boolean system property that can be used to control whether or not
65    // requests/responses are gzip'd.
66    private static final String NO_GZIP_SYSTEM_PROPERTY = "sync.nogzip";
67
68    private String mGDataVersion;
69    private final GoogleHttpClient mHttpClient;
70    private ContentResolver mResolver;
71
72    /**
73     * Interface for creating HTTP requests.  Used by
74     * {@link AndroidGDataClient#createAndExecuteMethod}, since HttpUriRequest does not allow for
75     * changing the URI after creation, e.g., when you want to follow a redirect.
76     */
77    private interface HttpRequestCreator {
78        HttpUriRequest createRequest(URI uri);
79    }
80
81    private static class GetRequestCreator implements HttpRequestCreator {
82        public GetRequestCreator() {
83        }
84
85        public HttpUriRequest createRequest(URI uri) {
86            HttpGet get = new HttpGet(uri);
87            return get;
88        }
89    }
90
91    private static class PostRequestCreator implements HttpRequestCreator {
92        private final String mMethodOverride;
93        private final HttpEntity mEntity;
94        public PostRequestCreator(String methodOverride, HttpEntity entity) {
95            mMethodOverride = methodOverride;
96            mEntity = entity;
97        }
98
99        public HttpUriRequest createRequest(URI uri) {
100            HttpPost post = new HttpPost(uri);
101            if (mMethodOverride != null) {
102                post.addHeader(X_HTTP_METHOD_OVERRIDE, mMethodOverride);
103            }
104            post.setEntity(mEntity);
105            return post;
106        }
107    }
108
109    // MAJOR TODO: make this work across redirects (if we can reset the InputStream).
110    // OR, read the bits into a local buffer (yuck, the media could be large).
111    private static class MediaPutRequestCreator implements HttpRequestCreator {
112        private final InputStream mMediaInputStream;
113        private final String mContentType;
114        public MediaPutRequestCreator(InputStream mediaInputStream, String contentType) {
115            mMediaInputStream = mediaInputStream;
116            mContentType = contentType;
117        }
118
119        public HttpUriRequest createRequest(URI uri) {
120            HttpPost post = new HttpPost(uri);
121            post.addHeader(X_HTTP_METHOD_OVERRIDE, "PUT");
122            // mMediaInputStream.reset();
123            InputStreamEntity entity = new InputStreamEntity(mMediaInputStream,
124                    -1 /* read until EOF */);
125            entity.setContentType(mContentType);
126            post.setEntity(entity);
127            return post;
128        }
129    }
130
131
132    /**
133     * Creates a new AndroidGDataClient.
134     *
135     * @param context The ContentResolver to get URL rewriting rules from
136     * through the Android proxy server, using null to indicate not using proxy.
137     * The context will also be used by GoogleHttpClient for configuration of
138     * SSL session persistence.
139     */
140    public AndroidGDataClient(Context context) {
141       this(context, DEFAULT_USER_AGENT_APP_VERSION);
142    }
143
144    /**
145     * Creates a new AndroidGDataClient.
146     *
147     * @param context The ContentResolver to get URL rewriting rules from
148     * through the Android proxy server, using null to indicate not using proxy.
149     * The context will also be used by GoogleHttpClient for configuration of
150     * SSL session persistence.
151     * @param appAndVersion The application name and version to be used as the basis of the
152     * User-Agent.  e.g., Android-GData/1.5.0.
153     */
154    public AndroidGDataClient(Context context, String appAndVersion) {
155        this(context, appAndVersion, DEFAULT_GDATA_VERSION);
156    }
157
158    /**
159     * Creates a new AndroidGDataClient.
160     *
161     * @param context The ContentResolver to get URL rewriting rules from
162     * through the Android proxy server, using null to indicate not using proxy.
163     * The context will also be used by GoogleHttpClient for configuration of
164     * SSL session persistence.
165     * @param appAndVersion The application name and version to be used as the basis of the
166     * User-Agent.  e.g., Android-GData/1.5.0.
167     * @param gdataVersion The gdata service version that should be
168     * used, e.g. "2.0"
169     *
170     */
171    public AndroidGDataClient(Context context, String appAndVersion, String gdataVersion) {
172        mHttpClient = new GoogleHttpClient(context, appAndVersion,
173                true /* gzip capable */);
174        mHttpClient.enableCurlLogging(TAG, Log.VERBOSE);
175        mResolver = context.getContentResolver();
176        mGDataVersion = gdataVersion;
177    }
178
179
180    public void close() {
181        mHttpClient.close();
182    }
183
184    /*
185     * (non-Javadoc)
186     * @see GDataClient#encodeUri(java.lang.String)
187     */
188    public String encodeUri(String uri) {
189        String encodedUri;
190        try {
191            encodedUri = URLEncoder.encode(uri, "UTF-8");
192        } catch (UnsupportedEncodingException uee) {
193            // should not happen.
194            Log.e("JakartaGDataClient",
195                  "UTF-8 not supported -- should not happen.  "
196                  + "Using default encoding.", uee);
197            encodedUri = URLEncoder.encode(uri);
198        }
199        return encodedUri;
200    }
201
202    /*
203     * (non-Javadoc)
204     * @see com.google.wireless.gdata.client.GDataClient#createQueryParams()
205     */
206    public QueryParams createQueryParams() {
207        return new QueryParamsImpl();
208    }
209
210    // follows redirects
211    private InputStream createAndExecuteMethod(HttpRequestCreator creator,
212                                               String uriString,
213                                               String authToken,
214                                               String eTag,
215                                               String protocolVersion)
216        throws HttpException, IOException {
217
218        HttpResponse response = null;
219        int status = 500;
220        int redirectsLeft = MAX_REDIRECTS;
221
222        URI uri;
223        try {
224            uri = new URI(uriString);
225        } catch (URISyntaxException use) {
226            Log.w(TAG, "Unable to parse " + uriString + " as URI.", use);
227            throw new IOException("Unable to parse " + uriString + " as URI: "
228                    + use.getMessage());
229        }
230
231        // we follow redirects ourselves, since we want to follow redirects even on POSTs, which
232        // the HTTP library does not do.  following redirects ourselves also allows us to log
233        // the redirects using our own logging.
234        while (redirectsLeft > 0) {
235
236            HttpUriRequest request = creator.createRequest(uri);
237
238            if (!SystemProperties.getBoolean(NO_GZIP_SYSTEM_PROPERTY, false)) {
239                AndroidHttpClient.modifyRequestToAcceptGzipResponse(request);
240            }
241
242            // only add the auth token if not null (to allow for GData feeds that do not require
243            // authentication.)
244            if (!TextUtils.isEmpty(authToken)) {
245                request.addHeader("Authorization", "GoogleLogin auth=" + authToken);
246            }
247
248            // while by default we have a 2.0 in this variable, it is possible to construct
249            // a client that has an empty version field, to work with 1.0 services.
250            if (!TextUtils.isEmpty(mGDataVersion)) {
251                request.addHeader("GDataVersion", mGDataVersion);
252            }
253
254            // if we have a passed down eTag value, we need to add several headers
255            if (!TextUtils.isEmpty(eTag)) {
256                String method = request.getMethod();
257                Header overrideMethodHeader = request.getFirstHeader(X_HTTP_METHOD_OVERRIDE);
258                if (overrideMethodHeader != null) {
259                    method = overrideMethodHeader.getValue();
260                }
261                if ("GET".equals(method)) {
262                    // add the none match header, if the resource is not changed
263                    // this request will result in a 304 now.
264                    request.addHeader("If-None-Match", eTag);
265                } else if ("DELETE".equals(method)
266                           || "PUT".equals(method)) {
267                    // now we send an if-match, but only if the passed in eTag is a strong eTag
268                    // as this only makes sense for a strong eTag
269                    if (!eTag.startsWith("W/")) {
270                        request.addHeader("If-Match", eTag);
271                    }
272                }
273            }
274
275            if (LOCAL_LOGV) {
276                for (Header h : request.getAllHeaders()) {
277                    Log.v(TAG, h.getName() + ": " + h.getValue());
278                }
279            }
280
281            if (Log.isLoggable(TAG, Log.DEBUG)) {
282                    Log.d(TAG, "Executing " + request.getRequestLine().toString());
283            }
284
285            response = null;
286
287            try {
288                response = mHttpClient.execute(request);
289            } catch (IOException ioe) {
290                Log.w(TAG, "Unable to execute HTTP request." + ioe);
291                throw ioe;
292            }
293
294            StatusLine statusLine = response.getStatusLine();
295            if (statusLine == null) {
296                Log.w(TAG, "StatusLine is null.");
297                throw new NullPointerException("StatusLine is null -- should not happen.");
298            }
299
300            if (Log.isLoggable(TAG, Log.DEBUG)) {
301                Log.d(TAG, response.getStatusLine().toString());
302                for (Header h : response.getAllHeaders()) {
303                    Log.d(TAG, h.getName() + ": " + h.getValue());
304                }
305            }
306            status = statusLine.getStatusCode();
307
308            HttpEntity entity = response.getEntity();
309
310            if ((status >= 200) && (status < 300) && entity != null) {
311                InputStream in = AndroidHttpClient.getUngzippedContent(entity);
312                if (Log.isLoggable(TAG, Log.DEBUG)) {
313                    in = logInputStreamContents(in);
314                }
315                return in;
316            }
317
318            // TODO: handle 301, 307?
319            // TODO: let the http client handle the redirects, if we can be sure we'll never get a
320            // redirect on POST.
321            if (status == 302) {
322                // consume the content, so the connection can be closed.
323                entity.consumeContent();
324                Header location = response.getFirstHeader("Location");
325                if (location == null) {
326                    if (Log.isLoggable(TAG, Log.DEBUG)) {
327                        Log.d(TAG, "Redirect requested but no Location "
328                                + "specified.");
329                    }
330                    break;
331                }
332                if (Log.isLoggable(TAG, Log.DEBUG)) {
333                    Log.d(TAG, "Following redirect to " + location.getValue());
334                }
335                try {
336                    uri = new URI(location.getValue());
337                } catch (URISyntaxException use) {
338                    if (Log.isLoggable(TAG, Log.DEBUG)) {
339                        Log.d(TAG, "Unable to parse " + location.getValue() + " as URI.", use);
340                        throw new IOException("Unable to parse " + location.getValue()
341                                + " as URI.");
342                    }
343                    break;
344                }
345                --redirectsLeft;
346            } else {
347                break;
348            }
349        }
350
351        if (Log.isLoggable(TAG, Log.VERBOSE)) {
352            Log.v(TAG, "Received " + status + " status code.");
353        }
354        String errorMessage = null;
355        HttpEntity entity = response.getEntity();
356        try {
357            if (response != null && entity != null) {
358                InputStream in = AndroidHttpClient.getUngzippedContent(entity);
359                ByteArrayOutputStream baos = new ByteArrayOutputStream();
360                byte[] buf = new byte[8192];
361                int bytesRead = -1;
362                while ((bytesRead = in.read(buf)) != -1) {
363                    baos.write(buf, 0, bytesRead);
364                }
365                // TODO: use appropriate encoding, picked up from Content-Type.
366                errorMessage = new String(baos.toByteArray());
367                if (Log.isLoggable(TAG, Log.VERBOSE)) {
368                    Log.v(TAG, errorMessage);
369                }
370            }
371        } finally {
372            if (entity != null) {
373                entity.consumeContent();
374            }
375        }
376        String exceptionMessage = "Received " + status + " status code";
377        if (errorMessage != null) {
378            exceptionMessage += (": " + errorMessage);
379        }
380        throw new HttpException(exceptionMessage, status, null /* InputStream */);
381    }
382
383    /*
384     * (non-Javadoc)
385     * @see GDataClient#getFeedAsStream(java.lang.String, java.lang.String)
386     */
387    public InputStream getFeedAsStream(String feedUrl,
388                                       String authToken,
389                                       String eTag,
390                                       String protocolVersion)
391        throws HttpException, IOException {
392
393        InputStream in = createAndExecuteMethod(new GetRequestCreator(), feedUrl, authToken, eTag, protocolVersion);
394        if (in != null) {
395            return in;
396        }
397        throw new IOException("Unable to access feed.");
398    }
399
400    /**
401     * Log the contents of the input stream.
402     * The original input stream is consumed, so the caller must use the
403     * BufferedInputStream that is returned.
404     * @param in InputStream
405     * @return replacement input stream for caller to use
406     * @throws IOException
407     */
408    private InputStream logInputStreamContents(InputStream in) throws IOException {
409        if (in == null) {
410            return in;
411        }
412        // bufferSize is the (arbitrary) maximum amount to log.
413        // The original InputStream is wrapped in a
414        // BufferedInputStream with a 16K buffer.  This lets
415        // us read up to 16K, write it to the log, and then
416        // reset the stream so the the original client can
417        // then read the data.  The BufferedInputStream
418        // provides the mark and reset support, even when
419        // the original InputStream does not.
420        final int bufferSize = 16384;
421        BufferedInputStream bin = new BufferedInputStream(in, bufferSize);
422        bin.mark(bufferSize);
423        int wanted = bufferSize;
424        int totalReceived = 0;
425        byte buf[] = new byte[wanted];
426        while (wanted > 0) {
427            int got = bin.read(buf, totalReceived, wanted);
428            if (got <= 0) break; // EOF
429            wanted -= got;
430            totalReceived += got;
431        }
432        Log.d(TAG, new String(buf, 0, totalReceived, "UTF-8"));
433        bin.reset();
434        return bin;
435    }
436
437    public InputStream getMediaEntryAsStream(String mediaEntryUrl, String authToken, String eTag, String protocolVersion)
438            throws HttpException, IOException {
439
440        InputStream in = createAndExecuteMethod(new GetRequestCreator(), mediaEntryUrl, authToken, eTag, protocolVersion);
441
442        if (in != null) {
443            return in;
444        }
445        throw new IOException("Unable to access media entry.");
446    }
447
448    /* (non-Javadoc)
449    * @see GDataClient#createEntry
450    */
451    public InputStream createEntry(String feedUrl,
452                                   String authToken,
453                                   String protocolVersion,
454                                   GDataSerializer entry)
455        throws HttpException, IOException {
456
457        HttpEntity entity = createEntityForEntry(entry, GDataSerializer.FORMAT_CREATE);
458        InputStream in = createAndExecuteMethod(
459                new PostRequestCreator(null /* override */, entity),
460                feedUrl,
461                authToken,
462                null,
463                protocolVersion);
464        if (in != null) {
465            return in;
466        }
467        throw new IOException("Unable to create entry.");
468    }
469
470    /* (non-Javadoc)
471     * @see GDataClient#updateEntry
472     */
473    public InputStream updateEntry(String editUri,
474                                   String authToken,
475                                   String eTag,
476                                   String protocolVersion,
477                                   GDataSerializer entry)
478        throws HttpException, IOException {
479        HttpEntity entity = createEntityForEntry(entry, GDataSerializer.FORMAT_UPDATE);
480        final String method = entry.getSupportsPartial() ? "PATCH" : "PUT";
481        InputStream in = createAndExecuteMethod(
482                new PostRequestCreator(method, entity),
483                editUri,
484                authToken,
485                eTag,
486                protocolVersion);
487        if (in != null) {
488            return in;
489        }
490        throw new IOException("Unable to update entry.");
491    }
492
493    /* (non-Javadoc)
494     * @see GDataClient#deleteEntry
495     */
496    public void deleteEntry(String editUri, String authToken, String eTag)
497        throws HttpException, IOException {
498        if (StringUtils.isEmpty(editUri)) {
499            throw new IllegalArgumentException(
500                    "you must specify an non-empty edit url");
501        }
502        InputStream in =
503            createAndExecuteMethod(
504                    new PostRequestCreator("DELETE", null /* entity */),
505                    editUri,
506                    authToken,
507                    eTag,
508                    null /* protocolVersion, not required for a delete */);
509        if (in == null) {
510            throw new IOException("Unable to delete entry.");
511        }
512        try {
513            in.close();
514        } catch (IOException ioe) {
515            // ignore
516        }
517    }
518
519    public InputStream updateMediaEntry(String editUri, String authToken, String eTag,
520            String protocolVersion, InputStream mediaEntryInputStream, String contentType)
521        throws HttpException, IOException {
522        InputStream in = createAndExecuteMethod(
523                new MediaPutRequestCreator(mediaEntryInputStream, contentType),
524                editUri,
525                authToken,
526                eTag,
527                protocolVersion);
528        if (in != null) {
529            return in;
530        }
531        throw new IOException("Unable to write media entry.");
532    }
533
534    private HttpEntity createEntityForEntry(GDataSerializer entry, int format) throws IOException {
535        ByteArrayOutputStream baos = new ByteArrayOutputStream();
536        try {
537            entry.serialize(baos, format);
538        } catch (IOException ioe) {
539            Log.e(TAG, "Unable to serialize entry.", ioe);
540            throw ioe;
541        } catch (ParseException pe) {
542            Log.e(TAG, "Unable to serialize entry.", pe);
543            throw new IOException("Unable to serialize entry: " + pe.getMessage());
544        }
545
546        byte[] entryBytes = baos.toByteArray();
547
548        if (entryBytes != null && Log.isLoggable(TAG, Log.DEBUG)) {
549            try {
550                Log.d(TAG, "Serialized entry: " + new String(entryBytes, "UTF-8"));
551            } catch (UnsupportedEncodingException uee) {
552                // should not happen
553                throw new IllegalStateException("UTF-8 should be supported!",
554                        uee);
555            }
556        }
557
558        AbstractHttpEntity entity;
559        if (SystemProperties.getBoolean(NO_GZIP_SYSTEM_PROPERTY, false)) {
560            entity = new ByteArrayEntity(entryBytes);
561        } else {
562            entity = AndroidHttpClient.getCompressedEntity(entryBytes, mResolver);
563        }
564
565        entity.setContentType(entry.getContentType());
566        return entity;
567    }
568
569    /**
570     * Connects to a GData server (specified by the batchUrl) and submits a
571     * batch for processing.  The response from the server is returned as an
572     * {@link InputStream}.  The caller is responsible for calling
573     * {@link InputStream#close()} on the returned {@link InputStream}.
574     *
575     * @param batchUrl The batch url to which the batch is submitted.
576     * @param authToken the authentication token that should be used when
577     * submitting the batch.
578     * @param protocolVersion The version of the protocol that
579     *                 should be used for this request.
580     * @param batch The batch of entries to submit.
581     * @throws IOException Thrown if an io error occurs while communicating with
582     * the service.
583     * @throws HttpException if the service returns an error response.
584     */
585    public InputStream submitBatch(String batchUrl,
586       String authToken,
587       String protocolVersion,
588       GDataSerializer batch)
589       throws HttpException, IOException
590    {
591        HttpEntity entity = createEntityForEntry(batch, GDataSerializer.FORMAT_BATCH);
592        InputStream in = createAndExecuteMethod(
593                new PostRequestCreator("POST", entity),
594                batchUrl,
595                authToken,
596                null,
597                protocolVersion);
598        if (in != null) {
599            return in;
600        }
601        throw new IOException("Unable to process batch request.");
602    }
603}
604