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