1 // Copyright 2014 The Chromium Authors 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.net.urlconnection; 6 7 import android.net.TrafficStats; 8 import android.os.Build; 9 import android.util.Log; 10 import android.util.Pair; 11 12 import org.chromium.net.CronetEngine; 13 import org.chromium.net.CronetException; 14 import org.chromium.net.ExperimentalUrlRequest; 15 import org.chromium.net.UrlRequest; 16 import org.chromium.net.UrlResponseInfo; 17 18 import androidx.annotation.VisibleForTesting; 19 20 import java.io.FileNotFoundException; 21 import java.io.IOException; 22 import java.io.InputStream; 23 import java.io.OutputStream; 24 import java.net.HttpURLConnection; 25 import java.net.MalformedURLException; 26 import java.net.ProtocolException; 27 import java.net.URL; 28 import java.net.URLConnection; 29 import java.nio.ByteBuffer; 30 import java.util.AbstractMap; 31 import java.util.ArrayList; 32 import java.util.Collections; 33 import java.util.List; 34 import java.util.Map; 35 import java.util.TreeMap; 36 37 /** 38 * An implementation of {@link HttpURLConnection} that uses Cronet to send 39 * requests and receive responses. 40 * {@hide} 41 */ 42 public class CronetHttpURLConnection extends HttpURLConnection { 43 private static final String TAG = CronetHttpURLConnection.class.getSimpleName(); 44 private static final String CONTENT_LENGTH = "Content-Length"; 45 private final CronetEngine mCronetEngine; 46 private final MessageLoop mMessageLoop; 47 private UrlRequest mRequest; 48 private final List<Pair<String, String>> mRequestHeaders; 49 private boolean mTrafficStatsTagSet; 50 private int mTrafficStatsTag; 51 private boolean mTrafficStatsUidSet; 52 private int mTrafficStatsUid; 53 54 private CronetInputStream mInputStream; 55 private CronetOutputStream mOutputStream; 56 private UrlResponseInfo mResponseInfo; 57 private IOException mException; 58 private boolean mOnRedirectCalled; 59 // Whether response headers are received, the request is failed, or the request is canceled. 60 private boolean mHasResponseHeadersOrCompleted; 61 private List<Map.Entry<String, String>> mResponseHeadersList; 62 private Map<String, List<String>> mResponseHeadersMap; 63 CronetHttpURLConnection(URL url, CronetEngine cronetEngine)64 public CronetHttpURLConnection(URL url, CronetEngine cronetEngine) { 65 super(url); 66 mCronetEngine = cronetEngine; 67 mMessageLoop = new MessageLoop(); 68 mInputStream = new CronetInputStream(this); 69 mRequestHeaders = new ArrayList<Pair<String, String>>(); 70 } 71 72 /** 73 * Opens a connection to the resource. If the connect method is called when 74 * the connection has already been opened (indicated by the connected field 75 * having the value {@code true}), the call is ignored. 76 */ 77 @Override connect()78 public void connect() throws IOException { 79 getOutputStream(); 80 // If request is started in getOutputStream, calling startRequest() 81 // again has no effect. 82 startRequest(); 83 } 84 85 /** 86 * Releases this connection so that its resources may be either reused or 87 * closed. 88 */ 89 @Override disconnect()90 public void disconnect() { 91 // Disconnect before connection is made should have no effect. 92 if (connected) { 93 mRequest.cancel(); 94 } 95 } 96 97 /** Returns the response message returned by the remote HTTP server. */ 98 @Override getResponseMessage()99 public String getResponseMessage() throws IOException { 100 getResponse(); 101 return mResponseInfo.getHttpStatusText(); 102 } 103 104 /** Returns the response code returned by the remote HTTP server. */ 105 @Override getResponseCode()106 public int getResponseCode() throws IOException { 107 getResponse(); 108 return mResponseInfo.getHttpStatusCode(); 109 } 110 111 /** Returns an unmodifiable map of the response-header fields and values. */ 112 @Override getHeaderFields()113 public Map<String, List<String>> getHeaderFields() { 114 try { 115 getResponse(); 116 } catch (IOException e) { 117 return Collections.emptyMap(); 118 } 119 return getAllHeaders(); 120 } 121 122 /** 123 * Returns the value of the named header field. If called on a connection 124 * that sets the same header multiple times with possibly different values, 125 * only the last value is returned. 126 */ 127 @Override getHeaderField(String fieldName)128 public final String getHeaderField(String fieldName) { 129 try { 130 getResponse(); 131 } catch (IOException e) { 132 return null; 133 } 134 Map<String, List<String>> map = getAllHeaders(); 135 if (!map.containsKey(fieldName)) { 136 return null; 137 } 138 List<String> values = map.get(fieldName); 139 return values.get(values.size() - 1); 140 } 141 142 /** 143 * Returns the name of the header field at the given position {@code pos}, or {@code null} 144 * if there are fewer than {@code pos} fields. 145 */ 146 @Override getHeaderFieldKey(int pos)147 public final String getHeaderFieldKey(int pos) { 148 Map.Entry<String, String> header = getHeaderFieldEntry(pos); 149 if (header == null) { 150 return null; 151 } 152 return header.getKey(); 153 } 154 155 /** 156 * Returns the header value at the field position {@code pos} or {@code null} if the header 157 * has fewer than {@code pos} fields. 158 */ 159 @Override getHeaderField(int pos)160 public final String getHeaderField(int pos) { 161 Map.Entry<String, String> header = getHeaderFieldEntry(pos); 162 if (header == null) { 163 return null; 164 } 165 return header.getValue(); 166 } 167 168 /** 169 * Returns an InputStream for reading data from the resource pointed by this 170 * {@link java.net.URLConnection}. 171 * @throws FileNotFoundException if http response code is equal or greater 172 * than {@link HTTP_BAD_REQUEST}. 173 * @throws IOException If the request gets a network error or HTTP error 174 * status code, or if the caller tried to read the response body 175 * of a redirect when redirects are disabled. 176 */ 177 @Override getInputStream()178 public InputStream getInputStream() throws IOException { 179 getResponse(); 180 if (!instanceFollowRedirects && mOnRedirectCalled) { 181 throw new IOException("Cannot read response body of a redirect."); 182 } 183 // Emulate default implementation's behavior to throw 184 // FileNotFoundException when we get a 400 and above. 185 if (mResponseInfo.getHttpStatusCode() >= HTTP_BAD_REQUEST) { 186 throw new FileNotFoundException(url.toString()); 187 } 188 return mInputStream; 189 } 190 191 /** 192 * Returns an {@link OutputStream} for writing data to this {@link URLConnection}. 193 * @throws IOException if no {@code OutputStream} could be created. 194 */ 195 @Override getOutputStream()196 public OutputStream getOutputStream() throws IOException { 197 if (mOutputStream == null && doOutput) { 198 if (connected) { 199 throw new ProtocolException( 200 "Cannot write to OutputStream after receiving response."); 201 } 202 if (isChunkedUpload()) { 203 mOutputStream = new CronetChunkedOutputStream(this, chunkLength, mMessageLoop); 204 // Start the request now since all headers can be sent. 205 startRequest(); 206 } else { 207 long fixedStreamingModeContentLength = getStreamingModeContentLength(); 208 if (fixedStreamingModeContentLength != -1) { 209 mOutputStream = 210 new CronetFixedModeOutputStream( 211 this, fixedStreamingModeContentLength, mMessageLoop); 212 // Start the request now since all headers can be sent. 213 startRequest(); 214 } else { 215 // For the buffered case, start the request only when 216 // content-length bytes are received, or when a 217 // connect action is initiated by the consumer. 218 Log.d(TAG, "Outputstream is being buffered in memory."); 219 String length = getRequestProperty(CONTENT_LENGTH); 220 if (length == null) { 221 mOutputStream = new CronetBufferedOutputStream(this); 222 } else { 223 long lengthParsed = Long.parseLong(length); 224 mOutputStream = new CronetBufferedOutputStream(this, lengthParsed); 225 } 226 } 227 } 228 } 229 return mOutputStream; 230 } 231 232 /** 233 * Helper method to get content length passed in by 234 * {@link #setFixedLengthStreamingMode} 235 */ getStreamingModeContentLength()236 private long getStreamingModeContentLength() { 237 // Calling setFixedLengthStreamingMode(long) does not seem to set fixedContentLength (same 238 // for setFixedLengthStreamingMode(int) and fixedContentLengthLong). Check both to get this 239 // right. 240 long contentLength = fixedContentLength; 241 if (fixedContentLengthLong != -1) { 242 contentLength = fixedContentLengthLong; 243 } 244 245 return contentLength; 246 } 247 248 /** Starts the request if {@code connected} is false. */ startRequest()249 private void startRequest() throws IOException { 250 if (connected) { 251 return; 252 } 253 final ExperimentalUrlRequest.Builder requestBuilder = 254 (ExperimentalUrlRequest.Builder) 255 mCronetEngine.newUrlRequestBuilder( 256 getURL().toString(), new CronetUrlRequestCallback(), mMessageLoop); 257 if (doOutput) { 258 if (method.equals("GET")) { 259 method = "POST"; 260 } 261 if (mOutputStream != null) { 262 requestBuilder.setUploadDataProvider( 263 mOutputStream.getUploadDataProvider(), mMessageLoop); 264 if (getRequestProperty(CONTENT_LENGTH) == null && !isChunkedUpload()) { 265 addRequestProperty( 266 CONTENT_LENGTH, 267 Long.toString(mOutputStream.getUploadDataProvider().getLength())); 268 } 269 // Tells mOutputStream that startRequest() has been called, so 270 // the underlying implementation can prepare for reading if needed. 271 mOutputStream.setConnected(); 272 } else { 273 if (getRequestProperty(CONTENT_LENGTH) == null) { 274 addRequestProperty(CONTENT_LENGTH, "0"); 275 } 276 } 277 // Default Content-Type to application/x-www-form-urlencoded 278 if (getRequestProperty("Content-Type") == null) { 279 addRequestProperty("Content-Type", "application/x-www-form-urlencoded"); 280 } 281 } 282 for (Pair<String, String> requestHeader : mRequestHeaders) { 283 requestBuilder.addHeader(requestHeader.first, requestHeader.second); 284 } 285 if (!getUseCaches()) { 286 requestBuilder.disableCache(); 287 } 288 // Set HTTP method. 289 requestBuilder.setHttpMethod(method); 290 if (checkTrafficStatsTag()) { 291 requestBuilder.setTrafficStatsTag(mTrafficStatsTag); 292 } 293 if (checkTrafficStatsUid()) { 294 requestBuilder.setTrafficStatsUid(mTrafficStatsUid); 295 } 296 297 mRequest = requestBuilder.build(); 298 // Start the request. 299 mRequest.start(); 300 connected = true; 301 } 302 checkTrafficStatsTag()303 private boolean checkTrafficStatsTag() { 304 if (mTrafficStatsTagSet) { 305 return true; 306 } 307 308 int tag = TrafficStats.getThreadStatsTag(); 309 if (tag != -1) { 310 mTrafficStatsTag = tag; 311 mTrafficStatsTagSet = true; 312 } 313 314 return mTrafficStatsTagSet; 315 } 316 checkTrafficStatsUid()317 private boolean checkTrafficStatsUid() { 318 if (mTrafficStatsUidSet) { 319 return true; 320 } 321 322 // TrafficStats#getThreadStatsUid() is available on API level 28+. 323 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { 324 return false; 325 } 326 327 int uid = TrafficStats.getThreadStatsUid(); 328 if (uid != -1) { 329 mTrafficStatsUid = uid; 330 mTrafficStatsUidSet = true; 331 } 332 333 return mTrafficStatsUidSet; 334 } 335 336 /** 337 * Returns an input stream from the server in the case of an error such as 338 * the requested file has not been found on the remote server. 339 */ 340 @Override getErrorStream()341 public InputStream getErrorStream() { 342 try { 343 getResponse(); 344 } catch (IOException e) { 345 return null; 346 } 347 if (mResponseInfo.getHttpStatusCode() >= HTTP_BAD_REQUEST) { 348 return mInputStream; 349 } 350 return null; 351 } 352 353 /** Adds the given property to the request header. */ 354 @Override addRequestProperty(String key, String value)355 public final void addRequestProperty(String key, String value) { 356 setRequestPropertyInternal(key, value, false); 357 } 358 359 /** Sets the value of the specified request header field. */ 360 @Override setRequestProperty(String key, String value)361 public final void setRequestProperty(String key, String value) { 362 setRequestPropertyInternal(key, value, true); 363 } 364 setRequestPropertyInternal(String key, String value, boolean overwrite)365 private final void setRequestPropertyInternal(String key, String value, boolean overwrite) { 366 if (connected) { 367 throw new IllegalStateException( 368 "Cannot modify request property after connection is made."); 369 } 370 int index = findRequestProperty(key); 371 if (index >= 0) { 372 if (overwrite) { 373 mRequestHeaders.remove(index); 374 } else { 375 // Cronet does not support adding multiple headers 376 // of the same key, see crbug.com/432719 for more details. 377 throw new UnsupportedOperationException( 378 "Cannot add multiple headers of the same key, " 379 + key 380 + ". crbug.com/432719."); 381 } 382 } 383 // Adds the new header at the end of mRequestHeaders. 384 mRequestHeaders.add(Pair.create(key, value)); 385 } 386 387 /** 388 * Returns an unmodifiable map of general request properties used by this 389 * connection. 390 */ 391 @Override getRequestProperties()392 public Map<String, List<String>> getRequestProperties() { 393 if (connected) { 394 throw new IllegalStateException( 395 "Cannot access request headers after connection is set."); 396 } 397 Map<String, List<String>> map = 398 new TreeMap<String, List<String>>(String.CASE_INSENSITIVE_ORDER); 399 for (Pair<String, String> entry : mRequestHeaders) { 400 if (map.containsKey(entry.first)) { 401 // This should not happen due to setRequestPropertyInternal. 402 throw new IllegalStateException("Should not have multiple values."); 403 } else { 404 List<String> values = new ArrayList<String>(); 405 values.add(entry.second); 406 map.put(entry.first, Collections.unmodifiableList(values)); 407 } 408 } 409 return Collections.unmodifiableMap(map); 410 } 411 412 /** 413 * Returns the value of the request header property specified by {@code 414 * key} or {@code null} if there is no key with this name. 415 */ 416 @Override getRequestProperty(String key)417 public String getRequestProperty(String key) { 418 int index = findRequestProperty(key); 419 if (index >= 0) { 420 return mRequestHeaders.get(index).second; 421 } 422 return null; 423 } 424 425 /** Returns whether this connection uses a proxy server. */ 426 @Override usingProxy()427 public boolean usingProxy() { 428 // TODO(xunjieli): implement this. 429 return false; 430 } 431 432 @Override setConnectTimeout(int timeout)433 public void setConnectTimeout(int timeout) { 434 // Per-request connect timeout is not supported because of late binding. 435 // Sockets are assigned to requests according to request priorities 436 // when sockets are connected. This requires requests with the same host, 437 // domain and port to have same timeout. 438 Log.d(TAG, "setConnectTimeout is not supported by CronetHttpURLConnection"); 439 } 440 441 /** 442 * Used by {@link CronetInputStream} to get more data from the network 443 * stack. This should only be called after the request has started. Note 444 * that this call might block if there isn't any more data to be read. 445 * Since byteBuffer is passed to the UrlRequest, it must be a direct 446 * ByteBuffer. 447 */ 448 @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) getMoreData(ByteBuffer byteBuffer)449 public void getMoreData(ByteBuffer byteBuffer) throws IOException { 450 mRequest.read(byteBuffer); 451 mMessageLoop.loop(getReadTimeout()); 452 } 453 454 /** 455 * Sets {@link android.net.TrafficStats} tag to use when accounting socket traffic caused by 456 * this request. See {@link android.net.TrafficStats} for more information. If no tag is 457 * set (e.g. this method isn't called), then Android accounts for the socket traffic caused 458 * by this request as if the tag value were set to 0. 459 * <p> 460 * <b>NOTE:</b>Setting a tag disallows sharing of sockets with requests 461 * with other tags, which may adversely effect performance by prohibiting 462 * connection sharing. In other words use of multiplexed sockets (e.g. HTTP/2 463 * and QUIC) will only be allowed if all requests have the same socket tag. 464 * 465 * @param tag the tag value used to when accounting for socket traffic caused by this 466 * request. Tags between 0xFFFFFF00 and 0xFFFFFFFF are reserved and used 467 * internally by system services like {@link android.app.DownloadManager} when 468 * performing traffic on behalf of an application. 469 */ setTrafficStatsTag(int tag)470 public void setTrafficStatsTag(int tag) { 471 if (connected) { 472 throw new IllegalStateException( 473 "Cannot modify traffic stats tag after connection is made."); 474 } 475 mTrafficStatsTagSet = true; 476 mTrafficStatsTag = tag; 477 } 478 479 /** 480 * Sets specific UID to use when accounting socket traffic caused by this request. See 481 * {@link android.net.TrafficStats} for more information. Designed for use when performing 482 * an operation on behalf of another application. Caller must hold 483 * {@link android.Manifest.permission#MODIFY_NETWORK_ACCOUNTING} permission. By default 484 * traffic is attributed to UID of caller. 485 * <p> 486 * <b>NOTE:</b>Setting a UID disallows sharing of sockets with requests 487 * with other UIDs, which may adversely effect performance by prohibiting 488 * connection sharing. In other words use of multiplexed sockets (e.g. HTTP/2 489 * and QUIC) will only be allowed if all requests have the same UID set. 490 * 491 * @param uid the UID to attribute socket traffic caused by this request. 492 */ setTrafficStatsUid(int uid)493 public void setTrafficStatsUid(int uid) { 494 if (connected) { 495 throw new IllegalStateException( 496 "Cannot modify traffic stats UID after connection is made."); 497 } 498 mTrafficStatsUidSet = true; 499 mTrafficStatsUid = uid; 500 } 501 502 /** 503 * Returns the index of request header in {@link #mRequestHeaders} or 504 * -1 if not found. 505 */ findRequestProperty(String key)506 private int findRequestProperty(String key) { 507 for (int i = 0; i < mRequestHeaders.size(); i++) { 508 Pair<String, String> entry = mRequestHeaders.get(i); 509 if (entry.first.equalsIgnoreCase(key)) { 510 return i; 511 } 512 } 513 return -1; 514 } 515 516 private class CronetUrlRequestCallback extends UrlRequest.Callback { CronetUrlRequestCallback()517 public CronetUrlRequestCallback() {} 518 519 @Override onResponseStarted(UrlRequest request, UrlResponseInfo info)520 public void onResponseStarted(UrlRequest request, UrlResponseInfo info) { 521 mResponseInfo = info; 522 mHasResponseHeadersOrCompleted = true; 523 // Quits the message loop since we have the headers now. 524 mMessageLoop.quit(); 525 } 526 527 @Override onReadCompleted( UrlRequest request, UrlResponseInfo info, ByteBuffer byteBuffer)528 public void onReadCompleted( 529 UrlRequest request, UrlResponseInfo info, ByteBuffer byteBuffer) { 530 mResponseInfo = info; 531 mMessageLoop.quit(); 532 } 533 534 @Override onRedirectReceived( UrlRequest request, UrlResponseInfo info, String newLocationUrl)535 public void onRedirectReceived( 536 UrlRequest request, UrlResponseInfo info, String newLocationUrl) { 537 mOnRedirectCalled = true; 538 try { 539 URL newUrl = new URL(newLocationUrl); 540 boolean sameProtocol = newUrl.getProtocol().equals(url.getProtocol()); 541 if (instanceFollowRedirects) { 542 // Update the url variable even if the redirect will not be 543 // followed due to different protocols. 544 url = newUrl; 545 } 546 if (instanceFollowRedirects && sameProtocol) { 547 mRequest.followRedirect(); 548 return; 549 } 550 } catch (MalformedURLException e) { 551 // Ignored. Just cancel the request and not follow the redirect. 552 } 553 mResponseInfo = info; 554 mRequest.cancel(); 555 setResponseDataCompleted(null); 556 } 557 558 @Override onSucceeded(UrlRequest request, UrlResponseInfo info)559 public void onSucceeded(UrlRequest request, UrlResponseInfo info) { 560 mResponseInfo = info; 561 setResponseDataCompleted(null); 562 } 563 564 @Override onFailed(UrlRequest request, UrlResponseInfo info, CronetException exception)565 public void onFailed(UrlRequest request, UrlResponseInfo info, CronetException exception) { 566 if (exception == null) { 567 throw new IllegalStateException("Exception cannot be null in onFailed."); 568 } 569 mResponseInfo = info; 570 setResponseDataCompleted(exception); 571 } 572 573 @Override onCanceled(UrlRequest request, UrlResponseInfo info)574 public void onCanceled(UrlRequest request, UrlResponseInfo info) { 575 mResponseInfo = info; 576 setResponseDataCompleted(new IOException("disconnect() called")); 577 } 578 579 /** 580 * Notifies {@link #mInputStream} that transferring of response data has 581 * completed. 582 * @param exception if not {@code null}, it is the exception to report when 583 * caller tries to read more data. 584 */ setResponseDataCompleted(IOException exception)585 private void setResponseDataCompleted(IOException exception) { 586 mException = exception; 587 if (mInputStream != null) { 588 mInputStream.setResponseDataCompleted(exception); 589 } 590 if (mOutputStream != null) { 591 mOutputStream.setRequestCompleted(exception); 592 } 593 mHasResponseHeadersOrCompleted = true; 594 mMessageLoop.quit(); 595 } 596 } 597 598 /** Blocks until the response headers are received. */ getResponse()599 private void getResponse() throws IOException { 600 // Check to see if enough data has been received. 601 if (mOutputStream != null) { 602 mOutputStream.checkReceivedEnoughContent(); 603 if (isChunkedUpload()) { 604 // Write last chunk. 605 mOutputStream.close(); 606 } 607 } 608 if (!mHasResponseHeadersOrCompleted) { 609 startRequest(); 610 // Blocks until onResponseStarted or onFailed is called. 611 mMessageLoop.loop(); 612 } 613 checkHasResponseHeaders(); 614 } 615 616 /** 617 * Checks whether response headers are received, and throws an exception if 618 * an exception occurred before headers received. This method should only be 619 * called after onResponseStarted or onFailed. 620 */ checkHasResponseHeaders()621 private void checkHasResponseHeaders() throws IOException { 622 if (!mHasResponseHeadersOrCompleted) throw new IllegalStateException("No response."); 623 if (mException != null) { 624 throw mException; 625 } else if (mResponseInfo == null) { 626 throw new NullPointerException("Response info is null when there is no exception."); 627 } 628 } 629 630 /** Helper method to return the response header field at position pos. */ getHeaderFieldEntry(int pos)631 private Map.Entry<String, String> getHeaderFieldEntry(int pos) { 632 try { 633 getResponse(); 634 } catch (IOException e) { 635 return null; 636 } 637 List<Map.Entry<String, String>> headers = getAllHeadersAsList(); 638 if (pos >= headers.size()) { 639 return null; 640 } 641 return headers.get(pos); 642 } 643 644 /** 645 * Returns whether the client has used {@link #setChunkedStreamingMode} to 646 * set chunked encoding for upload. 647 */ isChunkedUpload()648 private boolean isChunkedUpload() { 649 return chunkLength > 0; 650 } 651 652 // TODO(xunjieli): Refactor to reuse code in UrlResponseInfo. getAllHeaders()653 private Map<String, List<String>> getAllHeaders() { 654 if (mResponseHeadersMap != null) { 655 return mResponseHeadersMap; 656 } 657 Map<String, List<String>> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); 658 for (Map.Entry<String, String> entry : getAllHeadersAsList()) { 659 List<String> values = new ArrayList<String>(); 660 if (map.containsKey(entry.getKey())) { 661 values.addAll(map.get(entry.getKey())); 662 } 663 values.add(entry.getValue()); 664 map.put(entry.getKey(), Collections.unmodifiableList(values)); 665 } 666 mResponseHeadersMap = Collections.unmodifiableMap(map); 667 return mResponseHeadersMap; 668 } 669 getAllHeadersAsList()670 private List<Map.Entry<String, String>> getAllHeadersAsList() { 671 if (mResponseHeadersList != null) { 672 return mResponseHeadersList; 673 } 674 mResponseHeadersList = new ArrayList<Map.Entry<String, String>>(); 675 for (Map.Entry<String, String> entry : mResponseInfo.getAllHeadersAsList()) { 676 // Strips Content-Encoding response header. See crbug.com/592700. 677 if (!entry.getKey().equalsIgnoreCase("Content-Encoding")) { 678 mResponseHeadersList.add( 679 new AbstractMap.SimpleImmutableEntry<String, String>(entry)); 680 } 681 } 682 mResponseHeadersList = Collections.unmodifiableList(mResponseHeadersList); 683 return mResponseHeadersList; 684 } 685 } 686