Import of Volley from GitHub to AOSP.
am: 65d9fb8add

Change-Id: I5b96728796f5d26f4cb017eff6e51eaf4b1bff88
diff --git a/src/main/java/com/android/volley/CacheDispatcher.java b/src/main/java/com/android/volley/CacheDispatcher.java
index 4ea8a0b..f616285 100644
--- a/src/main/java/com/android/volley/CacheDispatcher.java
+++ b/src/main/java/com/android/volley/CacheDispatcher.java
@@ -98,8 +98,12 @@
             } catch (InterruptedException e) {
                 // We may have been interrupted because it was time to quit.
                 if (mQuit) {
+                    Thread.currentThread().interrupt();
                     return;
                 }
+                VolleyLog.e(
+                        "Ignoring spurious interrupt of CacheDispatcher thread; "
+                                + "use quit() to terminate it");
             }
         }
     }
diff --git a/src/main/java/com/android/volley/NetworkDispatcher.java b/src/main/java/com/android/volley/NetworkDispatcher.java
index 6e47465..327afed 100644
--- a/src/main/java/com/android/volley/NetworkDispatcher.java
+++ b/src/main/java/com/android/volley/NetworkDispatcher.java
@@ -91,8 +91,12 @@
             } catch (InterruptedException e) {
                 // We may have been interrupted because it was time to quit.
                 if (mQuit) {
+                    Thread.currentThread().interrupt();
                     return;
                 }
+                VolleyLog.e(
+                        "Ignoring spurious interrupt of NetworkDispatcher thread; "
+                                + "use quit() to terminate it");
             }
         }
     }
diff --git a/src/main/java/com/android/volley/Request.java b/src/main/java/com/android/volley/Request.java
index c958088..cd7290a 100644
--- a/src/main/java/com/android/volley/Request.java
+++ b/src/main/java/com/android/volley/Request.java
@@ -22,6 +22,7 @@
 import android.os.Looper;
 import android.support.annotation.CallSuper;
 import android.support.annotation.GuardedBy;
+import android.support.annotation.Nullable;
 import android.text.TextUtils;
 import com.android.volley.VolleyLog.MarkerLog;
 import java.io.UnsupportedEncodingException;
@@ -81,6 +82,7 @@
     private final Object mLock = new Object();
 
     /** Listener interface for errors. */
+    @Nullable
     @GuardedBy("mLock")
     private Response.ErrorListener mErrorListener;
 
@@ -91,6 +93,7 @@
     private RequestQueue mRequestQueue;
 
     /** Whether or not responses to this request should be cached. */
+    // TODO(#190): Turn this off by default for anything other than GET requests.
     private boolean mShouldCache = true;
 
     /** Whether or not this request has been canceled. */
@@ -139,7 +142,7 @@
      * responses is provided by subclasses, who have a better idea of how to deliver an
      * already-parsed response.
      */
-    public Request(int method, String url, Response.ErrorListener listener) {
+    public Request(int method, String url, @Nullable Response.ErrorListener listener) {
         mMethod = method;
         mUrl = url;
         mErrorListener = listener;
@@ -174,6 +177,7 @@
     }
 
     /** @return this request's {@link com.android.volley.Response.ErrorListener}. */
+    @Nullable
     public Response.ErrorListener getErrorListener() {
         synchronized (mLock) {
             return mErrorListener;
@@ -283,7 +287,18 @@
 
     /** Returns the cache key for this request. By default, this is the URL. */
     public String getCacheKey() {
-        return getUrl();
+        String url = getUrl();
+        // If this is a GET request, just use the URL as the key.
+        // For callers using DEPRECATED_GET_OR_POST, we assume the method is GET, which matches
+        // legacy behavior where all methods had the same cache key. We can't determine which method
+        // will be used because doing so requires calling getPostBody() which is expensive and may
+        // throw AuthFailureError.
+        // TODO(#190): Remove support for non-GET methods.
+        int method = getMethod();
+        if (method == Method.GET || method == Method.DEPRECATED_GET_OR_POST) {
+            return url;
+        }
+        return Integer.toString(method) + '-' + url;
     }
 
     /**
@@ -458,6 +473,14 @@
         StringBuilder encodedParams = new StringBuilder();
         try {
             for (Map.Entry<String, String> entry : params.entrySet()) {
+                if (entry.getKey() == null || entry.getValue() == null) {
+                    throw new IllegalArgumentException(
+                            String.format(
+                                    "Request#getParams() or Request#getPostParams() returned a map "
+                                            + "containing a null key or value: (%s, %s). All keys "
+                                            + "and values must be non-null.",
+                                    entry.getKey(), entry.getValue()));
+                }
                 encodedParams.append(URLEncoder.encode(entry.getKey(), paramsEncoding));
                 encodedParams.append('=');
                 encodedParams.append(URLEncoder.encode(entry.getValue(), paramsEncoding));
@@ -523,7 +546,7 @@
      * remaining, this will cause delivery of a {@link TimeoutError} error.
      */
     public final int getTimeoutMs() {
-        return mRetryPolicy.getCurrentTimeout();
+        return getRetryPolicy().getCurrentTimeout();
     }
 
     /** Returns the retry policy that should be used for this request. */
diff --git a/src/main/java/com/android/volley/RetryPolicy.java b/src/main/java/com/android/volley/RetryPolicy.java
index aa6af43..3ef26de 100644
--- a/src/main/java/com/android/volley/RetryPolicy.java
+++ b/src/main/java/com/android/volley/RetryPolicy.java
@@ -16,7 +16,27 @@
 
 package com.android.volley;
 
-/** Retry policy for a request. */
+/**
+ * Retry policy for a request.
+ *
+ * <p>A retry policy can control two parameters:
+ *
+ * <ul>
+ *   <li>The number of tries. This can be a simple counter or more complex logic based on the type
+ *       of error passed to {@link #retry(VolleyError)}, although {@link #getCurrentRetryCount()}
+ *       should always return the current retry count for logging purposes.
+ *   <li>The request timeout for each try, via {@link #getCurrentTimeout()}. In the common case that
+ *       a request times out before the response has been received from the server, retrying again
+ *       with a longer timeout can increase the likelihood of success (at the expense of causing the
+ *       user to wait longer, especially if the request still fails).
+ * </ul>
+ *
+ * <p>Note that currently, retries triggered by a retry policy are attempted immediately in sequence
+ * with no delay between them (although the time between tries may increase if the requests are
+ * timing out and {@link #getCurrentTimeout()} is returning increasing values).
+ *
+ * <p>By default, Volley uses {@link DefaultRetryPolicy}.
+ */
 public interface RetryPolicy {
 
     /** Returns the current timeout (used for logging). */
diff --git a/src/main/java/com/android/volley/toolbox/HttpClientStack.java b/src/main/java/com/android/volley/toolbox/HttpClientStack.java
index be0918a..1e9e4b0 100644
--- a/src/main/java/com/android/volley/toolbox/HttpClientStack.java
+++ b/src/main/java/com/android/volley/toolbox/HttpClientStack.java
@@ -58,7 +58,7 @@
         mClient = client;
     }
 
-    private static void addHeaders(HttpUriRequest httpRequest, Map<String, String> headers) {
+    private static void setHeaders(HttpUriRequest httpRequest, Map<String, String> headers) {
         for (String key : headers.keySet()) {
             httpRequest.setHeader(key, headers.get(key));
         }
@@ -77,8 +77,10 @@
     public HttpResponse performRequest(Request<?> request, Map<String, String> additionalHeaders)
             throws IOException, AuthFailureError {
         HttpUriRequest httpRequest = createHttpRequest(request, additionalHeaders);
-        addHeaders(httpRequest, additionalHeaders);
-        addHeaders(httpRequest, request.getHeaders());
+        setHeaders(httpRequest, additionalHeaders);
+        // Request.getHeaders() takes precedence over the given additional (cache) headers) and any
+        // headers set by createHttpRequest (like the Content-Type header).
+        setHeaders(httpRequest, request.getHeaders());
         onPrepareRequest(httpRequest);
         HttpParams httpParams = httpRequest.getParams();
         int timeoutMs = request.getTimeoutMs();
diff --git a/src/main/java/com/android/volley/toolbox/HurlStack.java b/src/main/java/com/android/volley/toolbox/HurlStack.java
index dd73759..5af18ef 100644
--- a/src/main/java/com/android/volley/toolbox/HurlStack.java
+++ b/src/main/java/com/android/volley/toolbox/HurlStack.java
@@ -74,8 +74,9 @@
             throws IOException, AuthFailureError {
         String url = request.getUrl();
         HashMap<String, String> map = new HashMap<>();
-        map.putAll(request.getHeaders());
         map.putAll(additionalHeaders);
+        // Request.getHeaders() takes precedence over the given additional (cache) headers).
+        map.putAll(request.getHeaders());
         if (mUrlRewriter != null) {
             String rewritten = mUrlRewriter.rewriteUrl(url);
             if (rewritten == null) {
@@ -88,7 +89,7 @@
         boolean keepConnectionOpen = false;
         try {
             for (String headerName : map.keySet()) {
-                connection.addRequestProperty(headerName, map.get(headerName));
+                connection.setRequestProperty(headerName, map.get(headerName));
             }
             setConnectionParametersForRequest(connection, request);
             // Initialize HttpResponse with data from the HttpURLConnection.
@@ -219,6 +220,8 @@
         return connection;
     }
 
+    // NOTE: Any request headers added here (via setRequestProperty or addRequestProperty) should be
+    // checked against the existing properties in the connection and not overridden if already set.
     @SuppressWarnings("deprecation")
     /* package */ static void setConnectionParametersForRequest(
             HttpURLConnection connection, Request<?> request) throws IOException, AuthFailureError {
@@ -276,13 +279,16 @@
     }
 
     private static void addBody(HttpURLConnection connection, Request<?> request, byte[] body)
-            throws IOException, AuthFailureError {
+            throws IOException {
         // Prepare output. There is no need to set Content-Length explicitly,
         // since this is handled by HttpURLConnection using the size of the prepared
         // output stream.
         connection.setDoOutput(true);
-        connection.addRequestProperty(
-                HttpHeaderParser.HEADER_CONTENT_TYPE, request.getBodyContentType());
+        // Set the content-type unless it was already set (by Request#getHeaders).
+        if (!connection.getRequestProperties().containsKey(HttpHeaderParser.HEADER_CONTENT_TYPE)) {
+            connection.setRequestProperty(
+                    HttpHeaderParser.HEADER_CONTENT_TYPE, request.getBodyContentType());
+        }
         DataOutputStream out = new DataOutputStream(connection.getOutputStream());
         out.write(body);
         out.close();
diff --git a/src/main/java/com/android/volley/toolbox/ImageRequest.java b/src/main/java/com/android/volley/toolbox/ImageRequest.java
index c804267..59e468f 100644
--- a/src/main/java/com/android/volley/toolbox/ImageRequest.java
+++ b/src/main/java/com/android/volley/toolbox/ImageRequest.java
@@ -20,6 +20,7 @@
 import android.graphics.Bitmap.Config;
 import android.graphics.BitmapFactory;
 import android.support.annotation.GuardedBy;
+import android.support.annotation.Nullable;
 import android.support.annotation.VisibleForTesting;
 import android.widget.ImageView.ScaleType;
 import com.android.volley.DefaultRetryPolicy;
@@ -44,6 +45,7 @@
     private final Object mLock = new Object();
 
     @GuardedBy("mLock")
+    @Nullable
     private Response.Listener<Bitmap> mListener;
 
     private final Config mDecodeConfig;
@@ -76,7 +78,7 @@
             int maxHeight,
             ScaleType scaleType,
             Config decodeConfig,
-            Response.ErrorListener errorListener) {
+            @Nullable Response.ErrorListener errorListener) {
         super(Method.GET, url, errorListener);
         setRetryPolicy(
                 new DefaultRetryPolicy(
diff --git a/src/main/java/com/android/volley/toolbox/JsonArrayRequest.java b/src/main/java/com/android/volley/toolbox/JsonArrayRequest.java
index 757c7f9..1abaec7 100644
--- a/src/main/java/com/android/volley/toolbox/JsonArrayRequest.java
+++ b/src/main/java/com/android/volley/toolbox/JsonArrayRequest.java
@@ -16,6 +16,7 @@
 
 package com.android.volley.toolbox;
 
+import android.support.annotation.Nullable;
 import com.android.volley.NetworkResponse;
 import com.android.volley.ParseError;
 import com.android.volley.Response;
@@ -35,7 +36,8 @@
      * @param listener Listener to receive the JSON response
      * @param errorListener Error listener, or null to ignore errors.
      */
-    public JsonArrayRequest(String url, Listener<JSONArray> listener, ErrorListener errorListener) {
+    public JsonArrayRequest(
+            String url, Listener<JSONArray> listener, @Nullable ErrorListener errorListener) {
         super(Method.GET, url, null, listener, errorListener);
     }
 
@@ -44,17 +46,17 @@
      *
      * @param method the HTTP method to use
      * @param url URL to fetch the JSON from
-     * @param jsonRequest A {@link JSONArray} to post with the request. Null is allowed and
-     *     indicates no parameters will be posted along with request.
+     * @param jsonRequest A {@link JSONArray} to post with the request. Null indicates no parameters
+     *     will be posted along with request.
      * @param listener Listener to receive the JSON response
      * @param errorListener Error listener, or null to ignore errors.
      */
     public JsonArrayRequest(
             int method,
             String url,
-            JSONArray jsonRequest,
+            @Nullable JSONArray jsonRequest,
             Listener<JSONArray> listener,
-            ErrorListener errorListener) {
+            @Nullable ErrorListener errorListener) {
         super(
                 method,
                 url,
diff --git a/src/main/java/com/android/volley/toolbox/JsonObjectRequest.java b/src/main/java/com/android/volley/toolbox/JsonObjectRequest.java
index e9dc3d7..cee5efe 100644
--- a/src/main/java/com/android/volley/toolbox/JsonObjectRequest.java
+++ b/src/main/java/com/android/volley/toolbox/JsonObjectRequest.java
@@ -16,6 +16,7 @@
 
 package com.android.volley.toolbox;
 
+import android.support.annotation.Nullable;
 import com.android.volley.NetworkResponse;
 import com.android.volley.ParseError;
 import com.android.volley.Response;
@@ -36,17 +37,17 @@
      *
      * @param method the HTTP method to use
      * @param url URL to fetch the JSON from
-     * @param jsonRequest A {@link JSONObject} to post with the request. Null is allowed and
-     *     indicates no parameters will be posted along with request.
+     * @param jsonRequest A {@link JSONObject} to post with the request. Null indicates no
+     *     parameters will be posted along with request.
      * @param listener Listener to receive the JSON response
      * @param errorListener Error listener, or null to ignore errors.
      */
     public JsonObjectRequest(
             int method,
             String url,
-            JSONObject jsonRequest,
+            @Nullable JSONObject jsonRequest,
             Listener<JSONObject> listener,
-            ErrorListener errorListener) {
+            @Nullable ErrorListener errorListener) {
         super(
                 method,
                 url,
@@ -63,9 +64,9 @@
      */
     public JsonObjectRequest(
             String url,
-            JSONObject jsonRequest,
+            @Nullable JSONObject jsonRequest,
             Listener<JSONObject> listener,
-            ErrorListener errorListener) {
+            @Nullable ErrorListener errorListener) {
         this(
                 jsonRequest == null ? Method.GET : Method.POST,
                 url,
diff --git a/src/main/java/com/android/volley/toolbox/JsonRequest.java b/src/main/java/com/android/volley/toolbox/JsonRequest.java
index fd395dd..c00d3db 100644
--- a/src/main/java/com/android/volley/toolbox/JsonRequest.java
+++ b/src/main/java/com/android/volley/toolbox/JsonRequest.java
@@ -17,6 +17,7 @@
 package com.android.volley.toolbox;
 
 import android.support.annotation.GuardedBy;
+import android.support.annotation.Nullable;
 import com.android.volley.NetworkResponse;
 import com.android.volley.Request;
 import com.android.volley.Response;
@@ -42,10 +43,11 @@
     /** Lock to guard mListener as it is cleared on cancel() and read on delivery. */
     private final Object mLock = new Object();
 
+    @Nullable
     @GuardedBy("mLock")
     private Listener<T> mListener;
 
-    private final String mRequestBody;
+    @Nullable private final String mRequestBody;
 
     /**
      * Deprecated constructor for a JsonRequest which defaults to GET unless {@link #getPostBody()}
@@ -62,9 +64,9 @@
     public JsonRequest(
             int method,
             String url,
-            String requestBody,
+            @Nullable String requestBody,
             Listener<T> listener,
-            ErrorListener errorListener) {
+            @Nullable ErrorListener errorListener) {
         super(method, url, errorListener);
         mListener = listener;
         mRequestBody = requestBody;
diff --git a/src/main/java/com/android/volley/toolbox/StringRequest.java b/src/main/java/com/android/volley/toolbox/StringRequest.java
index 0fbab14..c4c89b5 100644
--- a/src/main/java/com/android/volley/toolbox/StringRequest.java
+++ b/src/main/java/com/android/volley/toolbox/StringRequest.java
@@ -17,6 +17,7 @@
 package com.android.volley.toolbox;
 
 import android.support.annotation.GuardedBy;
+import android.support.annotation.Nullable;
 import com.android.volley.NetworkResponse;
 import com.android.volley.Request;
 import com.android.volley.Response;
@@ -30,6 +31,7 @@
     /** Lock to guard mListener as it is cleared on cancel() and read on delivery. */
     private final Object mLock = new Object();
 
+    @Nullable
     @GuardedBy("mLock")
     private Listener<String> mListener;
 
@@ -42,7 +44,10 @@
      * @param errorListener Error listener, or null to ignore errors
      */
     public StringRequest(
-            int method, String url, Listener<String> listener, ErrorListener errorListener) {
+            int method,
+            String url,
+            Listener<String> listener,
+            @Nullable ErrorListener errorListener) {
         super(method, url, errorListener);
         mListener = listener;
     }
@@ -54,7 +59,8 @@
      * @param listener Listener to receive the String response
      * @param errorListener Error listener, or null to ignore errors
      */
-    public StringRequest(String url, Listener<String> listener, ErrorListener errorListener) {
+    public StringRequest(
+            String url, Listener<String> listener, @Nullable ErrorListener errorListener) {
         this(Method.GET, url, listener, errorListener);
     }
 
diff --git a/src/test/java/com/android/volley/RequestTest.java b/src/test/java/com/android/volley/RequestTest.java
index e2dd655..382d9da 100644
--- a/src/test/java/com/android/volley/RequestTest.java
+++ b/src/test/java/com/android/volley/RequestTest.java
@@ -20,7 +20,10 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
+import com.android.volley.Request.Method;
 import com.android.volley.Request.Priority;
+import java.util.Collections;
+import java.util.Map;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricTestRunner;
@@ -84,9 +87,30 @@
         assertFalse(0 == goodProtocol.getTrafficStatsTag());
     }
 
+    @Test
+    public void getCacheKey() {
+        assertEquals(
+                "http://example.com",
+                new UrlParseRequest(Method.GET, "http://example.com").getCacheKey());
+        assertEquals(
+                "http://example.com",
+                new UrlParseRequest(Method.DEPRECATED_GET_OR_POST, "http://example.com")
+                        .getCacheKey());
+        assertEquals(
+                "1-http://example.com",
+                new UrlParseRequest(Method.POST, "http://example.com").getCacheKey());
+        assertEquals(
+                "2-http://example.com",
+                new UrlParseRequest(Method.PUT, "http://example.com").getCacheKey());
+    }
+
     private static class UrlParseRequest extends Request<Object> {
-        public UrlParseRequest(String url) {
-            super(Request.Method.GET, url, null);
+        UrlParseRequest(String url) {
+            this(Method.GET, url);
+        }
+
+        UrlParseRequest(int method, String url) {
+            super(method, url, null);
         }
 
         @Override
@@ -97,4 +121,72 @@
             return null;
         }
     }
+
+    @Test
+    public void nullKeyInPostParams() throws Exception {
+        Request<Object> request =
+                new Request<Object>(Method.POST, "url", null) {
+                    @Override
+                    protected void deliverResponse(Object response) {}
+
+                    @Override
+                    protected Response<Object> parseNetworkResponse(NetworkResponse response) {
+                        return null;
+                    }
+
+                    @Override
+                    protected Map<String, String> getParams() {
+                        return Collections.singletonMap(null, "value");
+                    }
+
+                    @Override
+                    protected Map<String, String> getPostParams() {
+                        return Collections.singletonMap(null, "value");
+                    }
+                };
+        try {
+            request.getBody();
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+        try {
+            request.getPostBody();
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+    }
+
+    @Test
+    public void nullValueInPostParams() throws Exception {
+        Request<Object> request =
+                new Request<Object>(Method.POST, "url", null) {
+                    @Override
+                    protected void deliverResponse(Object response) {}
+
+                    @Override
+                    protected Response<Object> parseNetworkResponse(NetworkResponse response) {
+                        return null;
+                    }
+
+                    @Override
+                    protected Map<String, String> getParams() {
+                        return Collections.singletonMap("key", null);
+                    }
+
+                    @Override
+                    protected Map<String, String> getPostParams() {
+                        return Collections.singletonMap("key", null);
+                    }
+                };
+        try {
+            request.getBody();
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+        try {
+            request.getPostBody();
+        } catch (IllegalArgumentException e) {
+            // expected
+        }
+    }
 }
diff --git a/src/test/java/com/android/volley/toolbox/HttpStackConformanceTest.java b/src/test/java/com/android/volley/toolbox/HttpStackConformanceTest.java
new file mode 100644
index 0000000..6794af8
--- /dev/null
+++ b/src/test/java/com/android/volley/toolbox/HttpStackConformanceTest.java
@@ -0,0 +1,192 @@
+package com.android.volley.toolbox;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+
+import com.android.volley.Request;
+import com.android.volley.RetryPolicy;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.http.Header;
+import org.apache.http.HttpRequest;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.Spy;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests to validate that HttpStack implementations conform with expected behavior. */
+@RunWith(RobolectricTestRunner.class)
+public class HttpStackConformanceTest {
+    @Mock private RetryPolicy mMockRetryPolicy;
+    @Mock private Request mMockRequest;
+
+    @Mock private HttpURLConnection mMockConnection;
+    @Mock private OutputStream mMockOutputStream;
+    @Spy private HurlStack mHurlStack = new HurlStack();
+
+    @Mock private HttpClient mMockHttpClient;
+    private HttpClientStack mHttpClientStack;
+
+    private final TestCase[] mTestCases =
+            new TestCase[] {
+                // TestCase for HurlStack.
+                new TestCase() {
+                    @Override
+                    public HttpStack getStack() {
+                        return mHurlStack;
+                    }
+
+                    @Override
+                    public void setOutputHeaderMap(final Map<String, String> outputHeaderMap) {
+                        doAnswer(
+                                        new Answer<Void>() {
+                                            @Override
+                                            public Void answer(InvocationOnMock invocation) {
+                                                outputHeaderMap.put(
+                                                        invocation.<String>getArgument(0),
+                                                        invocation.<String>getArgument(1));
+                                                return null;
+                                            }
+                                        })
+                                .when(mMockConnection)
+                                .setRequestProperty(anyString(), anyString());
+                        doAnswer(
+                                        new Answer<Map<String, List<String>>>() {
+                                            @Override
+                                            public Map<String, List<String>> answer(
+                                                    InvocationOnMock invocation) {
+                                                Map<String, List<String>> result = new HashMap<>();
+                                                for (Map.Entry<String, String> entry :
+                                                        outputHeaderMap.entrySet()) {
+                                                    result.put(
+                                                            entry.getKey(),
+                                                            Collections.singletonList(
+                                                                    entry.getValue()));
+                                                }
+                                                return result;
+                                            }
+                                        })
+                                .when(mMockConnection)
+                                .getRequestProperties();
+                    }
+                },
+
+                // TestCase for HttpClientStack.
+                new TestCase() {
+                    @Override
+                    public HttpStack getStack() {
+                        return mHttpClientStack;
+                    }
+
+                    @Override
+                    public void setOutputHeaderMap(final Map<String, String> outputHeaderMap) {
+                        try {
+                            doAnswer(
+                                            new Answer<Void>() {
+                                                @Override
+                                                public Void answer(InvocationOnMock invocation)
+                                                        throws Throwable {
+                                                    HttpRequest request = invocation.getArgument(0);
+                                                    for (Header header : request.getAllHeaders()) {
+                                                        if (outputHeaderMap.containsKey(
+                                                                header.getName())) {
+                                                            fail(
+                                                                    "Multiple values for header "
+                                                                            + header.getName());
+                                                        }
+                                                        outputHeaderMap.put(
+                                                                header.getName(),
+                                                                header.getValue());
+                                                    }
+                                                    return null;
+                                                }
+                                            })
+                                    .when(mMockHttpClient)
+                                    .execute(any(HttpUriRequest.class));
+                        } catch (IOException e) {
+                            throw new RuntimeException(e);
+                        }
+                    }
+                }
+            };
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mHttpClientStack = spy(new HttpClientStack(mMockHttpClient));
+
+        doReturn(mMockConnection).when(mHurlStack).createConnection(any(URL.class));
+        doReturn(mMockOutputStream).when(mMockConnection).getOutputStream();
+        when(mMockRequest.getUrl()).thenReturn("http://127.0.0.1");
+        when(mMockRequest.getRetryPolicy()).thenReturn(mMockRetryPolicy);
+    }
+
+    @Test
+    public void headerPrecedence() throws Exception {
+        Map<String, String> additionalHeaders = new HashMap<>();
+        additionalHeaders.put("A", "AddlA");
+        additionalHeaders.put("B", "AddlB");
+
+        Map<String, String> requestHeaders = new HashMap<>();
+        requestHeaders.put("A", "RequestA");
+        requestHeaders.put("C", "RequestC");
+        when(mMockRequest.getHeaders()).thenReturn(requestHeaders);
+
+        when(mMockRequest.getMethod()).thenReturn(Request.Method.POST);
+        when(mMockRequest.getBody()).thenReturn(new byte[0]);
+        when(mMockRequest.getBodyContentType()).thenReturn("BodyContentType");
+
+        for (TestCase testCase : mTestCases) {
+            // Test once without a Content-Type header in getHeaders().
+            Map<String, String> combinedHeaders = new HashMap<>();
+            testCase.setOutputHeaderMap(combinedHeaders);
+
+            testCase.getStack().performRequest(mMockRequest, additionalHeaders);
+
+            Map<String, String> expectedHeaders = new HashMap<>();
+            expectedHeaders.put("A", "RequestA");
+            expectedHeaders.put("B", "AddlB");
+            expectedHeaders.put("C", "RequestC");
+            expectedHeaders.put(HttpHeaderParser.HEADER_CONTENT_TYPE, "BodyContentType");
+
+            assertEquals(expectedHeaders, combinedHeaders);
+
+            // Reset and test again with a Content-Type header in getHeaders().
+            combinedHeaders.clear();
+
+            requestHeaders.put(HttpHeaderParser.HEADER_CONTENT_TYPE, "RequestContentType");
+            expectedHeaders.put(HttpHeaderParser.HEADER_CONTENT_TYPE, "RequestContentType");
+
+            testCase.getStack().performRequest(mMockRequest, additionalHeaders);
+            assertEquals(expectedHeaders, combinedHeaders);
+
+            // Clear the Content-Type header for the next TestCase.
+            requestHeaders.remove(HttpHeaderParser.HEADER_CONTENT_TYPE);
+        }
+    }
+
+    private interface TestCase {
+        HttpStack getStack();
+
+        void setOutputHeaderMap(Map<String, String> outputHeaderMap);
+    }
+}