// Copyright 2007 The Android Open Source Project package com.google.android.gdata.client; import com.google.android.net.GoogleHttpClient; import com.google.wireless.gdata.client.GDataClient; import com.google.wireless.gdata.client.HttpException; import com.google.wireless.gdata.client.QueryParams; import com.google.wireless.gdata.data.StringUtils; import com.google.wireless.gdata.parser.ParseException; import com.google.wireless.gdata.serializer.GDataSerializer; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.StatusLine; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.entity.InputStreamEntity; import org.apache.http.entity.AbstractHttpEntity; import android.content.ContentResolver; import android.content.Context; import android.net.http.AndroidHttpClient; import android.text.TextUtils; import android.util.Config; import android.util.Log; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.io.BufferedInputStream; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; /** * Implementation of a GDataClient using GoogleHttpClient to make HTTP * requests. Always issues GETs and POSTs, using the X-HTTP-Method-Override * header when a PUT or DELETE is desired, to avoid issues with firewalls, etc., * that do not allow methods other than GET or POST. */ public class AndroidGDataClient implements GDataClient { private static final String TAG = "GDataClient"; private static final boolean DEBUG = false; private static final boolean LOCAL_LOGV = DEBUG ? Config.LOGD : Config.LOGV; private static final String X_HTTP_METHOD_OVERRIDE = "X-HTTP-Method-Override"; private static final String DEFAULT_USER_AGENT_APP_VERSION = "Android-GData/1.1"; private static final int MAX_REDIRECTS = 10; private final GoogleHttpClient mHttpClient; private ContentResolver mResolver; /** * Interface for creating HTTP requests. Used by * {@link AndroidGDataClient#createAndExecuteMethod}, since HttpUriRequest does not allow for * changing the URI after creation, e.g., when you want to follow a redirect. */ private interface HttpRequestCreator { HttpUriRequest createRequest(URI uri); } private static class GetRequestCreator implements HttpRequestCreator { public GetRequestCreator() { } public HttpUriRequest createRequest(URI uri) { HttpGet get = new HttpGet(uri); return get; } } private static class PostRequestCreator implements HttpRequestCreator { private final String mMethodOverride; private final HttpEntity mEntity; public PostRequestCreator(String methodOverride, HttpEntity entity) { mMethodOverride = methodOverride; mEntity = entity; } public HttpUriRequest createRequest(URI uri) { HttpPost post = new HttpPost(uri); if (mMethodOverride != null) { post.addHeader(X_HTTP_METHOD_OVERRIDE, mMethodOverride); } post.setEntity(mEntity); return post; } } // MAJOR TODO: make this work across redirects (if we can reset the InputStream). // OR, read the bits into a local buffer (yuck, the media could be large). private static class MediaPutRequestCreator implements HttpRequestCreator { private final InputStream mMediaInputStream; private final String mContentType; public MediaPutRequestCreator(InputStream mediaInputStream, String contentType) { mMediaInputStream = mediaInputStream; mContentType = contentType; } public HttpUriRequest createRequest(URI uri) { HttpPost post = new HttpPost(uri); post.addHeader(X_HTTP_METHOD_OVERRIDE, "PUT"); // mMediaInputStream.reset(); InputStreamEntity entity = new InputStreamEntity(mMediaInputStream, -1 /* read until EOF */); entity.setContentType(mContentType); post.setEntity(entity); return post; } } /** * @deprecated Use AndroidGDAtaClient(Context) instead. */ public AndroidGDataClient(ContentResolver resolver) { mHttpClient = new GoogleHttpClient(resolver, DEFAULT_USER_AGENT_APP_VERSION, true /* gzip capable */); mHttpClient.enableCurlLogging(TAG, Log.VERBOSE); mResolver = resolver; } /** * Creates a new AndroidGDataClient. * * @param context The ContentResolver to get URL rewriting rules from * through the Android proxy server, using null to indicate not using proxy. * The context will also be used by GoogleHttpClient for configuration of * SSL session persistence. */ public AndroidGDataClient(Context context) { this(context, DEFAULT_USER_AGENT_APP_VERSION); } /** * Creates a new AndroidGDataClient. * * @param context The ContentResolver to get URL rewriting rules from * through the Android proxy server, using null to indicate not using proxy. * The context will also be used by GoogleHttpClient for configuration of * SSL session persistence. * @param appAndVersion The application name and version to be used as the basis of the * User-Agent. e.g., Android-GData/1.5.0. */ public AndroidGDataClient(Context context, String appAndVersion) { mHttpClient = new GoogleHttpClient(context, appAndVersion, true /* gzip capable */); mHttpClient.enableCurlLogging(TAG, Log.VERBOSE); mResolver = context.getContentResolver(); } public void close() { mHttpClient.close(); } /* * (non-Javadoc) * @see GDataClient#encodeUri(java.lang.String) */ public String encodeUri(String uri) { String encodedUri; try { encodedUri = URLEncoder.encode(uri, "UTF-8"); } catch (UnsupportedEncodingException uee) { // should not happen. Log.e("JakartaGDataClient", "UTF-8 not supported -- should not happen. " + "Using default encoding.", uee); encodedUri = URLEncoder.encode(uri); } return encodedUri; } /* * (non-Javadoc) * @see com.google.wireless.gdata.client.GDataClient#createQueryParams() */ public QueryParams createQueryParams() { return new QueryParamsImpl(); } // follows redirects private InputStream createAndExecuteMethod(HttpRequestCreator creator, String uriString, String authToken) throws HttpException, IOException { HttpResponse response = null; int status = 500; int redirectsLeft = MAX_REDIRECTS; URI uri; try { uri = new URI(uriString); } catch (URISyntaxException use) { Log.w(TAG, "Unable to parse " + uriString + " as URI.", use); throw new IOException("Unable to parse " + uriString + " as URI: " + use.getMessage()); } // we follow redirects ourselves, since we want to follow redirects even on POSTs, which // the HTTP library does not do. following redirects ourselves also allows us to log // the redirects using our own logging. while (redirectsLeft > 0) { HttpUriRequest request = creator.createRequest(uri); AndroidHttpClient.modifyRequestToAcceptGzipResponse(request); // only add the auth token if not null (to allow for GData feeds that do not require // authentication.) if (!TextUtils.isEmpty(authToken)) { request.addHeader("Authorization", "GoogleLogin auth=" + authToken); } if (LOCAL_LOGV) { for (Header h : request.getAllHeaders()) { Log.v(TAG, h.getName() + ": " + h.getValue()); } } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Executing " + request.getRequestLine().toString()); } response = null; try { response = mHttpClient.execute(request); } catch (IOException ioe) { Log.w(TAG, "Unable to execute HTTP request." + ioe); throw ioe; } StatusLine statusLine = response.getStatusLine(); if (statusLine == null) { Log.w(TAG, "StatusLine is null."); throw new NullPointerException("StatusLine is null -- should not happen."); } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, response.getStatusLine().toString()); for (Header h : response.getAllHeaders()) { Log.d(TAG, h.getName() + ": " + h.getValue()); } } status = statusLine.getStatusCode(); HttpEntity entity = response.getEntity(); if ((status >= 200) && (status < 300) && entity != null) { InputStream in = AndroidHttpClient.getUngzippedContent(entity); if (Log.isLoggable(TAG, Log.DEBUG)) { in = logInputStreamContents(in); } return in; } // TODO: handle 301, 307? // TODO: let the http client handle the redirects, if we can be sure we'll never get a // redirect on POST. if (status == 302) { // consume the content, so the connection can be closed. entity.consumeContent(); Header location = response.getFirstHeader("Location"); if (location == null) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Redirect requested but no Location " + "specified."); } break; } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Following redirect to " + location.getValue()); } try { uri = new URI(location.getValue()); } catch (URISyntaxException use) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "Unable to parse " + location.getValue() + " as URI.", use); throw new IOException("Unable to parse " + location.getValue() + " as URI."); } break; } --redirectsLeft; } else { break; } } if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Received " + status + " status code."); } String errorMessage = null; HttpEntity entity = response.getEntity(); try { if (response != null && entity != null) { InputStream in = AndroidHttpClient.getUngzippedContent(entity); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buf = new byte[8192]; int bytesRead = -1; while ((bytesRead = in.read(buf)) != -1) { baos.write(buf, 0, bytesRead); } // TODO: use appropriate encoding, picked up from Content-Type. errorMessage = new String(baos.toByteArray()); if (Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, errorMessage); } } } finally { if (entity != null) { entity.consumeContent(); } } String exceptionMessage = "Received " + status + " status code"; if (errorMessage != null) { exceptionMessage += (": " + errorMessage); } throw new HttpException(exceptionMessage, status, null /* InputStream */); } /* * (non-Javadoc) * @see GDataClient#getFeedAsStream(java.lang.String, java.lang.String) */ public InputStream getFeedAsStream(String feedUrl, String authToken) throws HttpException, IOException { InputStream in = createAndExecuteMethod(new GetRequestCreator(), feedUrl, authToken); if (in != null) { return in; } throw new IOException("Unable to access feed."); } /** * Log the contents of the input stream. * The original input stream is consumed, so the caller must use the * BufferedInputStream that is returned. * @param in InputStream * @return replacement input stream for caller to use * @throws IOException */ private InputStream logInputStreamContents(InputStream in) throws IOException { if (in == null) { return in; } // bufferSize is the (arbitrary) maximum amount to log. // The original InputStream is wrapped in a // BufferedInputStream with a 16K buffer. This lets // us read up to 16K, write it to the log, and then // reset the stream so the the original client can // then read the data. The BufferedInputStream // provides the mark and reset support, even when // the original InputStream does not. final int bufferSize = 16384; BufferedInputStream bin = new BufferedInputStream(in, bufferSize); bin.mark(bufferSize); int wanted = bufferSize; int totalReceived = 0; byte buf[] = new byte[wanted]; while (wanted > 0) { int got = bin.read(buf, totalReceived, wanted); if (got <= 0) break; // EOF wanted -= got; totalReceived += got; } Log.d(TAG, new String(buf, 0, totalReceived, "UTF-8")); bin.reset(); return bin; } public InputStream getMediaEntryAsStream(String mediaEntryUrl, String authToken) throws HttpException, IOException { InputStream in = createAndExecuteMethod(new GetRequestCreator(), mediaEntryUrl, authToken); if (in != null) { return in; } throw new IOException("Unable to access media entry."); } /* (non-Javadoc) * @see GDataClient#createEntry */ public InputStream createEntry(String feedUrl, String authToken, GDataSerializer entry) throws HttpException, IOException { HttpEntity entity = createEntityForEntry(entry, GDataSerializer.FORMAT_CREATE); InputStream in = createAndExecuteMethod( new PostRequestCreator(null /* override */, entity), feedUrl, authToken); if (in != null) { return in; } throw new IOException("Unable to create entry."); } /* (non-Javadoc) * @see GDataClient#updateEntry */ public InputStream updateEntry(String editUri, String authToken, GDataSerializer entry) throws HttpException, IOException { HttpEntity entity = createEntityForEntry(entry, GDataSerializer.FORMAT_UPDATE); InputStream in = createAndExecuteMethod( new PostRequestCreator("PUT", entity), editUri, authToken); if (in != null) { return in; } throw new IOException("Unable to update entry."); } /* (non-Javadoc) * @see GDataClient#deleteEntry */ public void deleteEntry(String editUri, String authToken) throws HttpException, IOException { if (StringUtils.isEmpty(editUri)) { throw new IllegalArgumentException( "you must specify an non-empty edit url"); } InputStream in = createAndExecuteMethod( new PostRequestCreator("DELETE", null /* entity */), editUri, authToken); if (in == null) { throw new IOException("Unable to delete entry."); } try { in.close(); } catch (IOException ioe) { // ignore } } public InputStream updateMediaEntry(String editUri, String authToken, InputStream mediaEntryInputStream, String contentType) throws HttpException, IOException { InputStream in = createAndExecuteMethod( new MediaPutRequestCreator(mediaEntryInputStream, contentType), editUri, authToken); if (in != null) { return in; } throw new IOException("Unable to write media entry."); } private HttpEntity createEntityForEntry(GDataSerializer entry, int format) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { entry.serialize(baos, format); } catch (IOException ioe) { Log.e(TAG, "Unable to serialize entry.", ioe); throw ioe; } catch (ParseException pe) { Log.e(TAG, "Unable to serialize entry.", pe); throw new IOException("Unable to serialize entry: " + pe.getMessage()); } byte[] entryBytes = baos.toByteArray(); if (entryBytes != null && Log.isLoggable(TAG, Log.DEBUG)) { try { Log.d(TAG, "Serialized entry: " + new String(entryBytes, "UTF-8")); } catch (UnsupportedEncodingException uee) { // should not happen throw new IllegalStateException("UTF-8 should be supported!", uee); } } AbstractHttpEntity entity = AndroidHttpClient.getCompressedEntity(entryBytes, mResolver); entity.setContentType(entry.getContentType()); return entity; } }