Snap for 7547121 from 5731f9f9aea90e074693bdfdeb3b58fd218e374d to mainline-permission-release

Change-Id: I7304423eb78a03897c247272d453f266aa15d6aa
diff --git a/Android.bp b/Android.bp
index e101494..803b4f2 100644
--- a/Android.bp
+++ b/Android.bp
@@ -14,12 +14,38 @@
 // limitations under the License.
 //
 
+package {
+    default_applicable_licenses: ["external_volley_license"],
+}
+
+// Added automatically by a large-scale-change
+// http://go/android-license-faq
+license {
+    name: "external_volley_license",
+    visibility: [":__subpackages__"],
+    license_kinds: [
+        "SPDX-license-identifier-Apache-2.0",
+    ],
+    license_text: [
+        "LICENSE",
+    ],
+}
+
 java_library {
 
     name: "volley",
-    sdk_version: "17",
+    sdk_version: "28",
+    min_sdk_version: "8",
     srcs: ["src/main/java/**/*.java"],
 
-    // Only needed at compile-time.
-    libs: ["androidx.annotation_annotation"],
+    // Exclude Cronet support for now. Can be enabled later if/when Cronet is made available as a
+    // compilation dependency for Volley clients.
+    exclude_srcs: ["src/main/java/com/android/volley/cronet/**/*"],
+
+    libs: [
+        // Only needed at compile-time.
+        "androidx.annotation_annotation",
+
+        "org.apache.http.legacy",
+    ],
 }
diff --git a/METADATA b/METADATA
new file mode 100644
index 0000000..d97975c
--- /dev/null
+++ b/METADATA
@@ -0,0 +1,3 @@
+third_party {
+  license_type: NOTICE
+}
diff --git a/NOTICE b/NOTICE
deleted file mode 100644
index d645695..0000000
--- a/NOTICE
+++ /dev/null
@@ -1,202 +0,0 @@
-
-                                 Apache License
-                           Version 2.0, January 2004
-                        http://www.apache.org/licenses/
-
-   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
-   1. Definitions.
-
-      "License" shall mean the terms and conditions for use, reproduction,
-      and distribution as defined by Sections 1 through 9 of this document.
-
-      "Licensor" shall mean the copyright owner or entity authorized by
-      the copyright owner that is granting the License.
-
-      "Legal Entity" shall mean the union of the acting entity and all
-      other entities that control, are controlled by, or are under common
-      control with that entity. For the purposes of this definition,
-      "control" means (i) the power, direct or indirect, to cause the
-      direction or management of such entity, whether by contract or
-      otherwise, or (ii) ownership of fifty percent (50%) or more of the
-      outstanding shares, or (iii) beneficial ownership of such entity.
-
-      "You" (or "Your") shall mean an individual or Legal Entity
-      exercising permissions granted by this License.
-
-      "Source" form shall mean the preferred form for making modifications,
-      including but not limited to software source code, documentation
-      source, and configuration files.
-
-      "Object" form shall mean any form resulting from mechanical
-      transformation or translation of a Source form, including but
-      not limited to compiled object code, generated documentation,
-      and conversions to other media types.
-
-      "Work" shall mean the work of authorship, whether in Source or
-      Object form, made available under the License, as indicated by a
-      copyright notice that is included in or attached to the work
-      (an example is provided in the Appendix below).
-
-      "Derivative Works" shall mean any work, whether in Source or Object
-      form, that is based on (or derived from) the Work and for which the
-      editorial revisions, annotations, elaborations, or other modifications
-      represent, as a whole, an original work of authorship. For the purposes
-      of this License, Derivative Works shall not include works that remain
-      separable from, or merely link (or bind by name) to the interfaces of,
-      the Work and Derivative Works thereof.
-
-      "Contribution" shall mean any work of authorship, including
-      the original version of the Work and any modifications or additions
-      to that Work or Derivative Works thereof, that is intentionally
-      submitted to Licensor for inclusion in the Work by the copyright owner
-      or by an individual or Legal Entity authorized to submit on behalf of
-      the copyright owner. For the purposes of this definition, "submitted"
-      means any form of electronic, verbal, or written communication sent
-      to the Licensor or its representatives, including but not limited to
-      communication on electronic mailing lists, source code control systems,
-      and issue tracking systems that are managed by, or on behalf of, the
-      Licensor for the purpose of discussing and improving the Work, but
-      excluding communication that is conspicuously marked or otherwise
-      designated in writing by the copyright owner as "Not a Contribution."
-
-      "Contributor" shall mean Licensor and any individual or Legal Entity
-      on behalf of whom a Contribution has been received by Licensor and
-      subsequently incorporated within the Work.
-
-   2. Grant of Copyright License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      copyright license to reproduce, prepare Derivative Works of,
-      publicly display, publicly perform, sublicense, and distribute the
-      Work and such Derivative Works in Source or Object form.
-
-   3. Grant of Patent License. Subject to the terms and conditions of
-      this License, each Contributor hereby grants to You a perpetual,
-      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
-      (except as stated in this section) patent license to make, have made,
-      use, offer to sell, sell, import, and otherwise transfer the Work,
-      where such license applies only to those patent claims licensable
-      by such Contributor that are necessarily infringed by their
-      Contribution(s) alone or by combination of their Contribution(s)
-      with the Work to which such Contribution(s) was submitted. If You
-      institute patent litigation against any entity (including a
-      cross-claim or counterclaim in a lawsuit) alleging that the Work
-      or a Contribution incorporated within the Work constitutes direct
-      or contributory patent infringement, then any patent licenses
-      granted to You under this License for that Work shall terminate
-      as of the date such litigation is filed.
-
-   4. Redistribution. You may reproduce and distribute copies of the
-      Work or Derivative Works thereof in any medium, with or without
-      modifications, and in Source or Object form, provided that You
-      meet the following conditions:
-
-      (a) You must give any other recipients of the Work or
-          Derivative Works a copy of this License; and
-
-      (b) You must cause any modified files to carry prominent notices
-          stating that You changed the files; and
-
-      (c) You must retain, in the Source form of any Derivative Works
-          that You distribute, all copyright, patent, trademark, and
-          attribution notices from the Source form of the Work,
-          excluding those notices that do not pertain to any part of
-          the Derivative Works; and
-
-      (d) If the Work includes a "NOTICE" text file as part of its
-          distribution, then any Derivative Works that You distribute must
-          include a readable copy of the attribution notices contained
-          within such NOTICE file, excluding those notices that do not
-          pertain to any part of the Derivative Works, in at least one
-          of the following places: within a NOTICE text file distributed
-          as part of the Derivative Works; within the Source form or
-          documentation, if provided along with the Derivative Works; or,
-          within a display generated by the Derivative Works, if and
-          wherever such third-party notices normally appear. The contents
-          of the NOTICE file are for informational purposes only and
-          do not modify the License. You may add Your own attribution
-          notices within Derivative Works that You distribute, alongside
-          or as an addendum to the NOTICE text from the Work, provided
-          that such additional attribution notices cannot be construed
-          as modifying the License.
-
-      You may add Your own copyright statement to Your modifications and
-      may provide additional or different license terms and conditions
-      for use, reproduction, or distribution of Your modifications, or
-      for any such Derivative Works as a whole, provided Your use,
-      reproduction, and distribution of the Work otherwise complies with
-      the conditions stated in this License.
-
-   5. Submission of Contributions. Unless You explicitly state otherwise,
-      any Contribution intentionally submitted for inclusion in the Work
-      by You to the Licensor shall be under the terms and conditions of
-      this License, without any additional terms or conditions.
-      Notwithstanding the above, nothing herein shall supersede or modify
-      the terms of any separate license agreement you may have executed
-      with Licensor regarding such Contributions.
-
-   6. Trademarks. This License does not grant permission to use the trade
-      names, trademarks, service marks, or product names of the Licensor,
-      except as required for reasonable and customary use in describing the
-      origin of the Work and reproducing the content of the NOTICE file.
-
-   7. Disclaimer of Warranty. Unless required by applicable law or
-      agreed to in writing, Licensor provides the Work (and each
-      Contributor provides its Contributions) on an "AS IS" BASIS,
-      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
-      implied, including, without limitation, any warranties or conditions
-      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
-      PARTICULAR PURPOSE. You are solely responsible for determining the
-      appropriateness of using or redistributing the Work and assume any
-      risks associated with Your exercise of permissions under this License.
-
-   8. Limitation of Liability. In no event and under no legal theory,
-      whether in tort (including negligence), contract, or otherwise,
-      unless required by applicable law (such as deliberate and grossly
-      negligent acts) or agreed to in writing, shall any Contributor be
-      liable to You for damages, including any direct, indirect, special,
-      incidental, or consequential damages of any character arising as a
-      result of this License or out of the use or inability to use the
-      Work (including but not limited to damages for loss of goodwill,
-      work stoppage, computer failure or malfunction, or any and all
-      other commercial damages or losses), even if such Contributor
-      has been advised of the possibility of such damages.
-
-   9. Accepting Warranty or Additional Liability. While redistributing
-      the Work or Derivative Works thereof, You may choose to offer,
-      and charge a fee for, acceptance of support, warranty, indemnity,
-      or other liability obligations and/or rights consistent with this
-      License. However, in accepting such obligations, You may act only
-      on Your own behalf and on Your sole responsibility, not on behalf
-      of any other Contributor, and only if You agree to indemnify,
-      defend, and hold each Contributor harmless for any liability
-      incurred by, or claims asserted against, such Contributor by reason
-      of your accepting any such warranty or additional liability.
-
-   END OF TERMS AND CONDITIONS
-
-   APPENDIX: How to apply the Apache License to your work.
-
-      To apply the Apache License to your work, attach the following
-      boilerplate notice, with the fields enclosed by brackets "[]"
-      replaced with your own identifying information. (Don't include
-      the brackets!)  The text should be enclosed in the appropriate
-      comment syntax for the file format. We also recommend that a
-      file or class name and description of purpose be included on the
-      same "printed page" as the copyright notice for easier
-      identification within third-party archives.
-
-   Copyright [yyyy] [name of copyright owner]
-
-   Licensed under the Apache License, Version 2.0 (the "License");
-   you may not use this file except in compliance with the License.
-   You may obtain a copy of the License at
-
-       http://www.apache.org/licenses/LICENSE-2.0
-
-   Unless required by applicable law or agreed to in writing, software
-   distributed under the License is distributed on an "AS IS" BASIS,
-   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-   See the License for the specific language governing permissions and
-   limitations under the License.
diff --git a/bintray.gradle b/bintray.gradle
index 9007c31..b642b41 100644
--- a/bintray.gradle
+++ b/bintray.gradle
@@ -43,6 +43,12 @@
             version project.version
             pom {
                 packaging 'aar'
+                licenses {
+                    license {
+                      name = "The Apache License, Version 2.0"
+                      url = "http://www.apache.org/licenses/LICENSE-2.0.txt"
+                    }
+                }
             }
 
             // Release AAR, Sources, and JavaDoc
diff --git a/build.gradle b/build.gradle
index 828a192..544771c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -62,7 +62,6 @@
     buildToolsVersion = '28.0.3'
 
     defaultConfig {
-        // Keep in sync with src/main/AndroidManifest.xml
         minSdkVersion 8
     }
 }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 9d8a946..104b82e 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@
 distributionPath=wrapper/dists
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip
diff --git a/rules.gradle b/rules.gradle
index fd660cd..e0aef80 100644
--- a/rules.gradle
+++ b/rules.gradle
@@ -21,14 +21,16 @@
 
 dependencies {
     implementation "androidx.annotation:annotation:1.0.1"
+    compileOnly "org.chromium.net:cronet-embedded:76.3809.111"
 }
 
 // Check if the android plugin version supports unit testing.
-if (configurations.findByName("testCompile")) {
+if (configurations.findByName("testImplementation")) {
   dependencies {
-      testCompile "junit:junit:4.12"
-      testCompile "org.hamcrest:hamcrest-library:1.3"
-      testCompile "org.mockito:mockito-core:2.19.0"
-      testCompile "org.robolectric:robolectric:3.4.2"
+      testImplementation "org.chromium.net:cronet-embedded:76.3809.111"
+      testImplementation "junit:junit:4.12"
+      testImplementation "org.hamcrest:hamcrest-library:1.3"
+      testImplementation "org.mockito:mockito-core:2.19.0"
+      testImplementation "org.robolectric:robolectric:3.4.2"
   }
 }
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index da8d33e..ba3a2a7 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -1,15 +1,2 @@
 <?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    package="com.android.volley"
-    android:versionCode="1"
-    android:versionName="1.0" >
-
-    <!-- Keep in sync with build.gradle -->
-    <uses-sdk
-        android:minSdkVersion="8"
-        tools:ignore="GradleOverrides" />
-
-    <application />
-
-</manifest>
+<manifest package="com.android.volley" />
diff --git a/src/main/java/com/android/volley/AsyncCache.java b/src/main/java/com/android/volley/AsyncCache.java
new file mode 100644
index 0000000..3cddb4b
--- /dev/null
+++ b/src/main/java/com/android/volley/AsyncCache.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley;
+
+import androidx.annotation.Nullable;
+
+/** Asynchronous equivalent to the {@link Cache} interface. */
+public abstract class AsyncCache {
+
+    public interface OnGetCompleteCallback {
+        /**
+         * Invoked when the read from the cache is complete.
+         *
+         * @param entry The entry read from the cache, or null if the read failed or the key did not
+         *     exist in the cache.
+         */
+        void onGetComplete(@Nullable Cache.Entry entry);
+    }
+
+    /**
+     * Retrieves an entry from the cache and sends it back through the {@link
+     * OnGetCompleteCallback#onGetComplete} function
+     *
+     * @param key Cache key
+     * @param callback Callback that will be notified when the information has been retrieved
+     */
+    public abstract void get(String key, OnGetCompleteCallback callback);
+
+    public interface OnWriteCompleteCallback {
+        /** Invoked when the cache operation is complete */
+        void onWriteComplete();
+    }
+
+    /**
+     * Writes a {@link Cache.Entry} to the cache, and calls {@link
+     * OnWriteCompleteCallback#onWriteComplete} after the operation is finished.
+     *
+     * @param key Cache key
+     * @param entry The entry to be written to the cache
+     * @param callback Callback that will be notified when the information has been written
+     */
+    public abstract void put(String key, Cache.Entry entry, OnWriteCompleteCallback callback);
+
+    /**
+     * Clears the cache. Deletes all cached files from disk. Calls {@link
+     * OnWriteCompleteCallback#onWriteComplete} after the operation is finished.
+     */
+    public abstract void clear(OnWriteCompleteCallback callback);
+
+    /**
+     * Initializes the cache and calls {@link OnWriteCompleteCallback#onWriteComplete} after the
+     * operation is finished.
+     */
+    public abstract void initialize(OnWriteCompleteCallback callback);
+
+    /**
+     * Invalidates an entry in the cache and calls {@link OnWriteCompleteCallback#onWriteComplete}
+     * after the operation is finished.
+     *
+     * @param key Cache key
+     * @param fullExpire True to fully expire the entry, false to soft expire
+     * @param callback Callback that's invoked once the entry has been invalidated
+     */
+    public abstract void invalidate(
+            String key, boolean fullExpire, OnWriteCompleteCallback callback);
+
+    /**
+     * Removes a {@link Cache.Entry} from the cache, and calls {@link
+     * OnWriteCompleteCallback#onWriteComplete} after the operation is finished.
+     *
+     * @param key Cache key
+     * @param callback Callback that's invoked once the entry has been removed
+     */
+    public abstract void remove(String key, OnWriteCompleteCallback callback);
+}
diff --git a/src/main/java/com/android/volley/AsyncNetwork.java b/src/main/java/com/android/volley/AsyncNetwork.java
new file mode 100644
index 0000000..ad19c03
--- /dev/null
+++ b/src/main/java/com/android/volley/AsyncNetwork.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley;
+
+import androidx.annotation.RestrictTo;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** An asynchronous implementation of {@link Network} to perform requests. */
+public abstract class AsyncNetwork implements Network {
+    private ExecutorService mBlockingExecutor;
+    private ExecutorService mNonBlockingExecutor;
+    private ScheduledExecutorService mNonBlockingScheduledExecutor;
+
+    protected AsyncNetwork() {}
+
+    /** Interface for callback to be called after request is processed. */
+    public interface OnRequestComplete {
+        /** Method to be called after successful network request. */
+        void onSuccess(NetworkResponse networkResponse);
+
+        /** Method to be called after unsuccessful network request. */
+        void onError(VolleyError volleyError);
+    }
+
+    /**
+     * Non-blocking method to perform the specified request.
+     *
+     * @param request Request to process
+     * @param callback to be called once NetworkResponse is received
+     */
+    public abstract void performRequest(Request<?> request, OnRequestComplete callback);
+
+    /**
+     * Blocking method to perform network request.
+     *
+     * @param request Request to process
+     * @return response retrieved from the network
+     * @throws VolleyError in the event of an error
+     */
+    @Override
+    public NetworkResponse performRequest(Request<?> request) throws VolleyError {
+        final CountDownLatch latch = new CountDownLatch(1);
+        final AtomicReference<NetworkResponse> response = new AtomicReference<>();
+        final AtomicReference<VolleyError> error = new AtomicReference<>();
+        performRequest(
+                request,
+                new OnRequestComplete() {
+                    @Override
+                    public void onSuccess(NetworkResponse networkResponse) {
+                        response.set(networkResponse);
+                        latch.countDown();
+                    }
+
+                    @Override
+                    public void onError(VolleyError volleyError) {
+                        error.set(volleyError);
+                        latch.countDown();
+                    }
+                });
+        try {
+            latch.await();
+        } catch (InterruptedException e) {
+            VolleyLog.e(e, "while waiting for CountDownLatch");
+            Thread.currentThread().interrupt();
+            throw new VolleyError(e);
+        }
+
+        if (response.get() != null) {
+            return response.get();
+        } else if (error.get() != null) {
+            throw error.get();
+        } else {
+            throw new VolleyError("Neither response entry was set");
+        }
+    }
+
+    /**
+     * This method sets the non blocking executor to be used by the network for non-blocking tasks.
+     *
+     * <p>This method must be called before performing any requests.
+     */
+    @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP})
+    public void setNonBlockingExecutor(ExecutorService executor) {
+        mNonBlockingExecutor = executor;
+    }
+
+    /**
+     * This method sets the blocking executor to be used by the network for potentially blocking
+     * tasks.
+     *
+     * <p>This method must be called before performing any requests.
+     */
+    @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP})
+    public void setBlockingExecutor(ExecutorService executor) {
+        mBlockingExecutor = executor;
+    }
+
+    /**
+     * This method sets the scheduled executor to be used by the network for non-blocking tasks to
+     * be scheduled.
+     *
+     * <p>This method must be called before performing any requests.
+     */
+    @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP})
+    public void setNonBlockingScheduledExecutor(ScheduledExecutorService executor) {
+        mNonBlockingScheduledExecutor = executor;
+    }
+
+    /** Gets blocking executor to perform any potentially blocking tasks. */
+    protected ExecutorService getBlockingExecutor() {
+        return mBlockingExecutor;
+    }
+
+    /** Gets non-blocking executor to perform any non-blocking tasks. */
+    protected ExecutorService getNonBlockingExecutor() {
+        return mNonBlockingExecutor;
+    }
+
+    /** Gets scheduled executor to perform any non-blocking tasks that need to be scheduled. */
+    protected ScheduledExecutorService getNonBlockingScheduledExecutor() {
+        return mNonBlockingScheduledExecutor;
+    }
+}
diff --git a/src/main/java/com/android/volley/AsyncRequestQueue.java b/src/main/java/com/android/volley/AsyncRequestQueue.java
new file mode 100644
index 0000000..3754866
--- /dev/null
+++ b/src/main/java/com/android/volley/AsyncRequestQueue.java
@@ -0,0 +1,626 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.android.volley.AsyncCache.OnGetCompleteCallback;
+import com.android.volley.AsyncNetwork.OnRequestComplete;
+import com.android.volley.Cache.Entry;
+import java.net.HttpURLConnection;
+import java.util.Comparator;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.PriorityBlockingQueue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * An asynchronous request dispatch queue.
+ *
+ * <p>Add requests to the queue with {@link #add(Request)}. Once completed, responses will be
+ * delivered on the main thread (unless a custom {@link ResponseDelivery} has been provided)
+ */
+public class AsyncRequestQueue extends RequestQueue {
+    /** Default number of blocking threads to start. */
+    private static final int DEFAULT_BLOCKING_THREAD_POOL_SIZE = 4;
+
+    /**
+     * AsyncCache used to retrieve and store responses.
+     *
+     * <p>{@code null} indicates use of blocking Cache.
+     */
+    @Nullable private final AsyncCache mAsyncCache;
+
+    /** AsyncNetwork used to perform nework requests. */
+    private final AsyncNetwork mNetwork;
+
+    /** Executor for non-blocking tasks. */
+    private ExecutorService mNonBlockingExecutor;
+
+    /** Executor to be used for non-blocking tasks that need to be scheduled. */
+    private ScheduledExecutorService mNonBlockingScheduledExecutor;
+
+    /**
+     * Executor for blocking tasks.
+     *
+     * <p>Some tasks in handling requests may not be easy to implement in a non-blocking way, such
+     * as reading or parsing the response data. This executor is used to run these tasks.
+     */
+    private ExecutorService mBlockingExecutor;
+
+    /**
+     * This interface may be used by advanced applications to provide custom executors according to
+     * their needs. Apps must create ExecutorServices dynamically given a blocking queue rather than
+     * providing them directly so that Volley can provide a PriorityQueue which will prioritize
+     * requests according to Request#getPriority.
+     */
+    private ExecutorFactory mExecutorFactory;
+
+    /** Manage list of waiting requests and de-duplicate requests with same cache key. */
+    private final WaitingRequestManager mWaitingRequestManager = new WaitingRequestManager(this);
+
+    /**
+     * Sets all the variables, but processing does not begin until {@link #start()} is called.
+     *
+     * @param cache to use for persisting responses to disk. If an AsyncCache was provided, then
+     *     this will be a {@link ThrowingCache}
+     * @param network to perform HTTP requests
+     * @param asyncCache to use for persisting responses to disk. May be null to indicate use of
+     *     blocking cache
+     * @param responseDelivery interface for posting responses and errors
+     * @param executorFactory Interface to be used to provide custom executors according to the
+     *     users needs.
+     */
+    private AsyncRequestQueue(
+            Cache cache,
+            AsyncNetwork network,
+            @Nullable AsyncCache asyncCache,
+            ResponseDelivery responseDelivery,
+            ExecutorFactory executorFactory) {
+        super(cache, network, /* threadPoolSize= */ 0, responseDelivery);
+        mAsyncCache = asyncCache;
+        mNetwork = network;
+        mExecutorFactory = executorFactory;
+    }
+
+    /** Sets the executors and initializes the cache. */
+    @Override
+    public void start() {
+        stop(); // Make sure any currently running threads are stopped
+
+        // Create blocking / non-blocking executors and set them in the network and stack.
+        mNonBlockingExecutor = mExecutorFactory.createNonBlockingExecutor(getBlockingQueue());
+        mBlockingExecutor = mExecutorFactory.createBlockingExecutor(getBlockingQueue());
+        mNonBlockingScheduledExecutor = mExecutorFactory.createNonBlockingScheduledExecutor();
+        mNetwork.setBlockingExecutor(mBlockingExecutor);
+        mNetwork.setNonBlockingExecutor(mNonBlockingExecutor);
+        mNetwork.setNonBlockingScheduledExecutor(mNonBlockingScheduledExecutor);
+
+        mNonBlockingExecutor.execute(
+                new Runnable() {
+                    @Override
+                    public void run() {
+                        // This is intentionally blocking, because we don't want to process any
+                        // requests until the cache is initialized.
+                        if (mAsyncCache != null) {
+                            final CountDownLatch latch = new CountDownLatch(1);
+                            mAsyncCache.initialize(
+                                    new AsyncCache.OnWriteCompleteCallback() {
+                                        @Override
+                                        public void onWriteComplete() {
+                                            latch.countDown();
+                                        }
+                                    });
+                            try {
+                                latch.await();
+                            } catch (InterruptedException e) {
+                                VolleyLog.e(
+                                        e, "Thread was interrupted while initializing the cache.");
+                                Thread.currentThread().interrupt();
+                                throw new RuntimeException(e);
+                            }
+                        } else {
+                            getCache().initialize();
+                        }
+                    }
+                });
+    }
+
+    /** Shuts down and nullifies both executors */
+    @Override
+    public void stop() {
+        if (mNonBlockingExecutor != null) {
+            mNonBlockingExecutor.shutdownNow();
+            mNonBlockingExecutor = null;
+        }
+        if (mBlockingExecutor != null) {
+            mBlockingExecutor.shutdownNow();
+            mBlockingExecutor = null;
+        }
+        if (mNonBlockingScheduledExecutor != null) {
+            mNonBlockingScheduledExecutor.shutdownNow();
+            mNonBlockingScheduledExecutor = null;
+        }
+    }
+
+    /** Begins the request by sending it to the Cache or Network. */
+    @Override
+    <T> void beginRequest(Request<T> request) {
+        // If the request is uncacheable, send it over the network.
+        if (request.shouldCache()) {
+            if (mAsyncCache != null) {
+                mNonBlockingExecutor.execute(new CacheTask<>(request));
+            } else {
+                mBlockingExecutor.execute(new CacheTask<>(request));
+            }
+        } else {
+            sendRequestOverNetwork(request);
+        }
+    }
+
+    @Override
+    <T> void sendRequestOverNetwork(Request<T> request) {
+        mNonBlockingExecutor.execute(new NetworkTask<>(request));
+    }
+
+    /** Runnable that gets an entry from the cache. */
+    private class CacheTask<T> extends RequestTask<T> {
+        CacheTask(Request<T> request) {
+            super(request);
+        }
+
+        @Override
+        public void run() {
+            // If the request has been canceled, don't bother dispatching it.
+            if (mRequest.isCanceled()) {
+                mRequest.finish("cache-discard-canceled");
+                return;
+            }
+
+            mRequest.addMarker("cache-queue-take");
+
+            // Attempt to retrieve this item from cache.
+            if (mAsyncCache != null) {
+                mAsyncCache.get(
+                        mRequest.getCacheKey(),
+                        new OnGetCompleteCallback() {
+                            @Override
+                            public void onGetComplete(Entry entry) {
+                                handleEntry(entry, mRequest);
+                            }
+                        });
+            } else {
+                Entry entry = getCache().get(mRequest.getCacheKey());
+                handleEntry(entry, mRequest);
+            }
+        }
+    }
+
+    /** Helper method that handles the cache entry after getting it from the Cache. */
+    private void handleEntry(final Entry entry, final Request<?> mRequest) {
+        if (entry == null) {
+            mRequest.addMarker("cache-miss");
+            // Cache miss; send off to the network dispatcher.
+            if (!mWaitingRequestManager.maybeAddToWaitingRequests(mRequest)) {
+                sendRequestOverNetwork(mRequest);
+            }
+            return;
+        }
+
+        // If it is completely expired, just send it to the network.
+        if (entry.isExpired()) {
+            mRequest.addMarker("cache-hit-expired");
+            mRequest.setCacheEntry(entry);
+            if (!mWaitingRequestManager.maybeAddToWaitingRequests(mRequest)) {
+                sendRequestOverNetwork(mRequest);
+            }
+            return;
+        }
+
+        // We have a cache hit; parse its data for delivery back to the request.
+        mBlockingExecutor.execute(new CacheParseTask<>(mRequest, entry));
+    }
+
+    private class CacheParseTask<T> extends RequestTask<T> {
+        Cache.Entry entry;
+
+        CacheParseTask(Request<T> request, Cache.Entry entry) {
+            super(request);
+            this.entry = entry;
+        }
+
+        @Override
+        public void run() {
+            mRequest.addMarker("cache-hit");
+            Response<?> response =
+                    mRequest.parseNetworkResponse(
+                            new NetworkResponse(
+                                    HttpURLConnection.HTTP_OK,
+                                    entry.data,
+                                    /* notModified= */ false,
+                                    /* networkTimeMs= */ 0,
+                                    entry.allResponseHeaders));
+            mRequest.addMarker("cache-hit-parsed");
+
+            if (!entry.refreshNeeded()) {
+                // Completely unexpired cache hit. Just deliver the response.
+                getResponseDelivery().postResponse(mRequest, response);
+            } else {
+                // Soft-expired cache hit. We can deliver the cached response,
+                // but we need to also send the request to the network for
+                // refreshing.
+                mRequest.addMarker("cache-hit-refresh-needed");
+                mRequest.setCacheEntry(entry);
+                // Mark the response as intermediate.
+                response.intermediate = true;
+
+                if (!mWaitingRequestManager.maybeAddToWaitingRequests(mRequest)) {
+                    // Post the intermediate response back to the user and have
+                    // the delivery then forward the request along to the network.
+                    getResponseDelivery()
+                            .postResponse(
+                                    mRequest,
+                                    response,
+                                    new Runnable() {
+                                        @Override
+                                        public void run() {
+                                            sendRequestOverNetwork(mRequest);
+                                        }
+                                    });
+                } else {
+                    // request has been added to list of waiting requests
+                    // to receive the network response from the first request once it
+                    // returns.
+                    getResponseDelivery().postResponse(mRequest, response);
+                }
+            }
+        }
+    }
+
+    private class ParseErrorTask<T> extends RequestTask<T> {
+        VolleyError volleyError;
+
+        ParseErrorTask(Request<T> request, VolleyError volleyError) {
+            super(request);
+            this.volleyError = volleyError;
+        }
+
+        @Override
+        public void run() {
+            VolleyError parsedError = mRequest.parseNetworkError(volleyError);
+            getResponseDelivery().postError(mRequest, parsedError);
+            mRequest.notifyListenerResponseNotUsable();
+        }
+    }
+
+    /** Runnable that performs the network request */
+    private class NetworkTask<T> extends RequestTask<T> {
+        NetworkTask(Request<T> request) {
+            super(request);
+        }
+
+        @Override
+        public void run() {
+            // If the request was cancelled already, do not perform the network request.
+            if (mRequest.isCanceled()) {
+                mRequest.finish("network-discard-cancelled");
+                mRequest.notifyListenerResponseNotUsable();
+                return;
+            }
+
+            final long startTimeMs = SystemClock.elapsedRealtime();
+            mRequest.addMarker("network-queue-take");
+
+            // TODO: Figure out what to do with traffic stats tags. Can this be pushed to the
+            // HTTP stack, or is it no longer feasible to support?
+
+            // Perform the network request.
+            mNetwork.performRequest(
+                    mRequest,
+                    new OnRequestComplete() {
+                        @Override
+                        public void onSuccess(final NetworkResponse networkResponse) {
+                            mRequest.addMarker("network-http-complete");
+
+                            // If the server returned 304 AND we delivered a response already,
+                            // we're done -- don't deliver a second identical response.
+                            if (networkResponse.notModified && mRequest.hasHadResponseDelivered()) {
+                                mRequest.finish("not-modified");
+                                mRequest.notifyListenerResponseNotUsable();
+                                return;
+                            }
+
+                            // Parse the response here on the worker thread.
+                            mBlockingExecutor.execute(
+                                    new NetworkParseTask<>(mRequest, networkResponse));
+                        }
+
+                        @Override
+                        public void onError(final VolleyError volleyError) {
+                            volleyError.setNetworkTimeMs(
+                                    SystemClock.elapsedRealtime() - startTimeMs);
+                            mBlockingExecutor.execute(new ParseErrorTask<>(mRequest, volleyError));
+                        }
+                    });
+        }
+    }
+
+    /** Runnable that parses a network response. */
+    private class NetworkParseTask<T> extends RequestTask<T> {
+        NetworkResponse networkResponse;
+
+        NetworkParseTask(Request<T> request, NetworkResponse networkResponse) {
+            super(request);
+            this.networkResponse = networkResponse;
+        }
+
+        @Override
+        public void run() {
+            final Response<?> response = mRequest.parseNetworkResponse(networkResponse);
+            mRequest.addMarker("network-parse-complete");
+
+            // Write to cache if applicable.
+            // TODO: Only update cache metadata instead of entire
+            // record for 304s.
+            if (mRequest.shouldCache() && response.cacheEntry != null) {
+                if (mAsyncCache != null) {
+                    mNonBlockingExecutor.execute(new CachePutTask<>(mRequest, response));
+                } else {
+                    mBlockingExecutor.execute(new CachePutTask<>(mRequest, response));
+                }
+            } else {
+                finishRequest(mRequest, response, /* cached= */ false);
+            }
+        }
+    }
+
+    private class CachePutTask<T> extends RequestTask<T> {
+        Response<?> response;
+
+        CachePutTask(Request<T> request, Response<?> response) {
+            super(request);
+            this.response = response;
+        }
+
+        @Override
+        public void run() {
+            if (mAsyncCache != null) {
+                mAsyncCache.put(
+                        mRequest.getCacheKey(),
+                        response.cacheEntry,
+                        new AsyncCache.OnWriteCompleteCallback() {
+                            @Override
+                            public void onWriteComplete() {
+                                finishRequest(mRequest, response, /* cached= */ true);
+                            }
+                        });
+            } else {
+                getCache().put(mRequest.getCacheKey(), response.cacheEntry);
+                finishRequest(mRequest, response, /* cached= */ true);
+            }
+        }
+    }
+
+    /** Posts response and notifies listener */
+    private void finishRequest(Request<?> mRequest, Response<?> response, boolean cached) {
+        if (cached) {
+            mRequest.addMarker("network-cache-written");
+        }
+        // Post the response back.
+        mRequest.markDelivered();
+        getResponseDelivery().postResponse(mRequest, response);
+        mRequest.notifyListenerResponseReceived(response);
+    }
+
+    /**
+     * This class may be used by advanced applications to provide custom executors according to
+     * their needs. Apps must create ExecutorServices dynamically given a blocking queue rather than
+     * providing them directly so that Volley can provide a PriorityQueue which will prioritize
+     * requests according to Request#getPriority.
+     */
+    public abstract static class ExecutorFactory {
+        abstract ExecutorService createNonBlockingExecutor(BlockingQueue<Runnable> taskQueue);
+
+        abstract ExecutorService createBlockingExecutor(BlockingQueue<Runnable> taskQueue);
+
+        abstract ScheduledExecutorService createNonBlockingScheduledExecutor();
+    }
+
+    /** Provides a BlockingQueue to be used to create executors. */
+    private static PriorityBlockingQueue<Runnable> getBlockingQueue() {
+        return new PriorityBlockingQueue<>(
+                /* initialCapacity= */ 11,
+                new Comparator<Runnable>() {
+                    @Override
+                    public int compare(Runnable r1, Runnable r2) {
+                        // Vanilla runnables are prioritized first, then RequestTasks are ordered
+                        // by the underlying Request.
+                        if (r1 instanceof RequestTask) {
+                            if (r2 instanceof RequestTask) {
+                                return ((RequestTask<?>) r1).compareTo(((RequestTask<?>) r2));
+                            }
+                            return 1;
+                        }
+                        return r2 instanceof RequestTask ? -1 : 0;
+                    }
+                });
+    }
+
+    /**
+     * Builder is used to build an instance of {@link AsyncRequestQueue} from values configured by
+     * the setters.
+     */
+    public static class Builder {
+        @Nullable private AsyncCache mAsyncCache = null;
+        private final AsyncNetwork mNetwork;
+        @Nullable private Cache mCache = null;
+        @Nullable private ExecutorFactory mExecutorFactory = null;
+        @Nullable private ResponseDelivery mResponseDelivery = null;
+
+        public Builder(AsyncNetwork asyncNetwork) {
+            if (asyncNetwork == null) {
+                throw new IllegalArgumentException("Network cannot be null");
+            }
+            mNetwork = asyncNetwork;
+        }
+
+        /**
+         * Sets the executor factory to be used by the AsyncRequestQueue. If this is not called,
+         * Volley will create suitable private thread pools.
+         */
+        public Builder setExecutorFactory(ExecutorFactory executorFactory) {
+            mExecutorFactory = executorFactory;
+            return this;
+        }
+
+        /**
+         * Sets the response deliver to be used by the AsyncRequestQueue. If this is not called, we
+         * will default to creating a new {@link ExecutorDelivery} with the application's main
+         * thread.
+         */
+        public Builder setResponseDelivery(ResponseDelivery responseDelivery) {
+            mResponseDelivery = responseDelivery;
+            return this;
+        }
+
+        /** Sets the AsyncCache to be used by the AsyncRequestQueue. */
+        public Builder setAsyncCache(AsyncCache asyncCache) {
+            mAsyncCache = asyncCache;
+            return this;
+        }
+
+        /** Sets the Cache to be used by the AsyncRequestQueue. */
+        public Builder setCache(Cache cache) {
+            mCache = cache;
+            return this;
+        }
+
+        /** Provides a default ExecutorFactory to use, if one is never set. */
+        private ExecutorFactory getDefaultExecutorFactory() {
+            return new ExecutorFactory() {
+                @Override
+                public ExecutorService createNonBlockingExecutor(
+                        BlockingQueue<Runnable> taskQueue) {
+                    return getNewThreadPoolExecutor(
+                            /* maximumPoolSize= */ 1,
+                            /* threadNameSuffix= */ "Non-BlockingExecutor",
+                            taskQueue);
+                }
+
+                @Override
+                public ExecutorService createBlockingExecutor(BlockingQueue<Runnable> taskQueue) {
+                    return getNewThreadPoolExecutor(
+                            /* maximumPoolSize= */ DEFAULT_BLOCKING_THREAD_POOL_SIZE,
+                            /* threadNameSuffix= */ "BlockingExecutor",
+                            taskQueue);
+                }
+
+                @Override
+                public ScheduledExecutorService createNonBlockingScheduledExecutor() {
+                    return new ScheduledThreadPoolExecutor(
+                            /* corePoolSize= */ 0, getThreadFactory("ScheduledExecutor"));
+                }
+
+                private ThreadPoolExecutor getNewThreadPoolExecutor(
+                        int maximumPoolSize,
+                        final String threadNameSuffix,
+                        BlockingQueue<Runnable> taskQueue) {
+                    return new ThreadPoolExecutor(
+                            /* corePoolSize= */ 0,
+                            /* maximumPoolSize= */ maximumPoolSize,
+                            /* keepAliveTime= */ 60,
+                            /* unit= */ TimeUnit.SECONDS,
+                            taskQueue,
+                            getThreadFactory(threadNameSuffix));
+                }
+
+                private ThreadFactory getThreadFactory(final String threadNameSuffix) {
+                    return new ThreadFactory() {
+                        @Override
+                        public Thread newThread(@NonNull Runnable runnable) {
+                            Thread t = Executors.defaultThreadFactory().newThread(runnable);
+                            t.setName("Volley-" + threadNameSuffix);
+                            return t;
+                        }
+                    };
+                }
+            };
+        }
+
+        public AsyncRequestQueue build() {
+            // If neither cache is set by the caller, throw an illegal argument exception.
+            if (mCache == null && mAsyncCache == null) {
+                throw new IllegalArgumentException("You must set one of the cache objects");
+            }
+            if (mCache == null) {
+                // if no cache is provided, we will provide one that throws
+                // UnsupportedOperationExceptions to pass into the parent class.
+                mCache = new ThrowingCache();
+            }
+            if (mResponseDelivery == null) {
+                mResponseDelivery = new ExecutorDelivery(new Handler(Looper.getMainLooper()));
+            }
+            if (mExecutorFactory == null) {
+                mExecutorFactory = getDefaultExecutorFactory();
+            }
+            return new AsyncRequestQueue(
+                    mCache, mNetwork, mAsyncCache, mResponseDelivery, mExecutorFactory);
+        }
+    }
+
+    /** A cache that throws an error if a method is called. */
+    private static class ThrowingCache implements Cache {
+        @Override
+        public Entry get(String key) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void put(String key, Entry entry) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void initialize() {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void invalidate(String key, boolean fullExpire) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void remove(String key) {
+            throw new UnsupportedOperationException();
+        }
+
+        @Override
+        public void clear() {
+            throw new UnsupportedOperationException();
+        }
+    }
+}
diff --git a/src/main/java/com/android/volley/Cache.java b/src/main/java/com/android/volley/Cache.java
index 35b2a96..b8908ac 100644
--- a/src/main/java/com/android/volley/Cache.java
+++ b/src/main/java/com/android/volley/Cache.java
@@ -16,6 +16,7 @@
 
 package com.android.volley;
 
+import androidx.annotation.Nullable;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
@@ -28,6 +29,7 @@
      * @param key Cache key
      * @return An {@link Entry} or null in the event of a cache miss
      */
+    @Nullable
     Entry get(String key);
 
     /**
diff --git a/src/main/java/com/android/volley/CacheDispatcher.java b/src/main/java/com/android/volley/CacheDispatcher.java
index be06d1f..1bfc0ea 100644
--- a/src/main/java/com/android/volley/CacheDispatcher.java
+++ b/src/main/java/com/android/volley/CacheDispatcher.java
@@ -18,10 +18,6 @@
 
 import android.os.Process;
 import androidx.annotation.VisibleForTesting;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
 import java.util.concurrent.BlockingQueue;
 
 /**
@@ -72,7 +68,7 @@
         mNetworkQueue = networkQueue;
         mCache = cache;
         mDelivery = delivery;
-        mWaitingRequestManager = new WaitingRequestManager(this);
+        mWaitingRequestManager = new WaitingRequestManager(this, networkQueue, delivery);
     }
 
     /**
@@ -159,6 +155,15 @@
                             new NetworkResponse(entry.data, entry.responseHeaders));
             request.addMarker("cache-hit-parsed");
 
+            if (!response.isSuccess()) {
+                request.addMarker("cache-parsing-failed");
+                mCache.invalidate(request.getCacheKey(), true);
+                request.setCacheEntry(null);
+                if (!mWaitingRequestManager.maybeAddToWaitingRequests(request)) {
+                    mNetworkQueue.put(request);
+                }
+                return;
+            }
             if (!entry.refreshNeeded()) {
                 // Completely unexpired cache hit. Just deliver the response.
                 mDelivery.postResponse(request, response);
@@ -198,113 +203,4 @@
             request.sendEvent(RequestQueue.RequestEvent.REQUEST_CACHE_LOOKUP_FINISHED);
         }
     }
-
-    private static class WaitingRequestManager implements Request.NetworkRequestCompleteListener {
-
-        /**
-         * Staging area for requests that already have a duplicate request in flight.
-         *
-         * <ul>
-         *   <li>containsKey(cacheKey) indicates that there is a request in flight for the given
-         *       cache key.
-         *   <li>get(cacheKey) returns waiting requests for the given cache key. The in flight
-         *       request is <em>not</em> contained in that list. Is null if no requests are staged.
-         * </ul>
-         */
-        private final Map<String, List<Request<?>>> mWaitingRequests = new HashMap<>();
-
-        private final CacheDispatcher mCacheDispatcher;
-
-        WaitingRequestManager(CacheDispatcher cacheDispatcher) {
-            mCacheDispatcher = cacheDispatcher;
-        }
-
-        /** Request received a valid response that can be used by other waiting requests. */
-        @Override
-        public void onResponseReceived(Request<?> request, Response<?> response) {
-            if (response.cacheEntry == null || response.cacheEntry.isExpired()) {
-                onNoUsableResponseReceived(request);
-                return;
-            }
-            String cacheKey = request.getCacheKey();
-            List<Request<?>> waitingRequests;
-            synchronized (this) {
-                waitingRequests = mWaitingRequests.remove(cacheKey);
-            }
-            if (waitingRequests != null) {
-                if (VolleyLog.DEBUG) {
-                    VolleyLog.v(
-                            "Releasing %d waiting requests for cacheKey=%s.",
-                            waitingRequests.size(), cacheKey);
-                }
-                // Process all queued up requests.
-                for (Request<?> waiting : waitingRequests) {
-                    mCacheDispatcher.mDelivery.postResponse(waiting, response);
-                }
-            }
-        }
-
-        /** No valid response received from network, release waiting requests. */
-        @Override
-        public synchronized void onNoUsableResponseReceived(Request<?> request) {
-            String cacheKey = request.getCacheKey();
-            List<Request<?>> waitingRequests = mWaitingRequests.remove(cacheKey);
-            if (waitingRequests != null && !waitingRequests.isEmpty()) {
-                if (VolleyLog.DEBUG) {
-                    VolleyLog.v(
-                            "%d waiting requests for cacheKey=%s; resend to network",
-                            waitingRequests.size(), cacheKey);
-                }
-                Request<?> nextInLine = waitingRequests.remove(0);
-                mWaitingRequests.put(cacheKey, waitingRequests);
-                nextInLine.setNetworkRequestCompleteListener(this);
-                try {
-                    mCacheDispatcher.mNetworkQueue.put(nextInLine);
-                } catch (InterruptedException iex) {
-                    VolleyLog.e("Couldn't add request to queue. %s", iex.toString());
-                    // Restore the interrupted status of the calling thread (i.e. NetworkDispatcher)
-                    Thread.currentThread().interrupt();
-                    // Quit the current CacheDispatcher thread.
-                    mCacheDispatcher.quit();
-                }
-            }
-        }
-
-        /**
-         * For cacheable requests, if a request for the same cache key is already in flight, add it
-         * to a queue to wait for that in-flight request to finish.
-         *
-         * @return whether the request was queued. If false, we should continue issuing the request
-         *     over the network. If true, we should put the request on hold to be processed when the
-         *     in-flight request finishes.
-         */
-        private synchronized boolean maybeAddToWaitingRequests(Request<?> request) {
-            String cacheKey = request.getCacheKey();
-            // Insert request into stage if there's already a request with the same cache key
-            // in flight.
-            if (mWaitingRequests.containsKey(cacheKey)) {
-                // There is already a request in flight. Queue up.
-                List<Request<?>> stagedRequests = mWaitingRequests.get(cacheKey);
-                if (stagedRequests == null) {
-                    stagedRequests = new ArrayList<>();
-                }
-                request.addMarker("waiting-for-response");
-                stagedRequests.add(request);
-                mWaitingRequests.put(cacheKey, stagedRequests);
-                if (VolleyLog.DEBUG) {
-                    VolleyLog.d("Request for cacheKey=%s is in flight, putting on hold.", cacheKey);
-                }
-                return true;
-            } else {
-                // Insert 'null' queue for this cacheKey, indicating there is now a request in
-                // flight.
-                mWaitingRequests.put(cacheKey, null);
-                request.setNetworkRequestCompleteListener(this);
-                if (VolleyLog.DEBUG) {
-                    VolleyLog.d("new request, sending to network %s", cacheKey);
-                }
-                return false;
-            }
-        }
-    }
 }
diff --git a/src/main/java/com/android/volley/NetworkResponse.java b/src/main/java/com/android/volley/NetworkResponse.java
index 01f48c6..cfbc371 100644
--- a/src/main/java/com/android/volley/NetworkResponse.java
+++ b/src/main/java/com/android/volley/NetworkResponse.java
@@ -16,6 +16,7 @@
 
 package com.android.volley;
 
+import androidx.annotation.Nullable;
 import java.net.HttpURLConnection;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -42,7 +43,7 @@
     public NetworkResponse(
             int statusCode,
             byte[] data,
-            Map<String, String> headers,
+            @Nullable Map<String, String> headers,
             boolean notModified,
             long networkTimeMs) {
         this(statusCode, data, headers, toAllHeaderList(headers), notModified, networkTimeMs);
@@ -62,7 +63,7 @@
             byte[] data,
             boolean notModified,
             long networkTimeMs,
-            List<Header> allHeaders) {
+            @Nullable List<Header> allHeaders) {
         this(statusCode, data, toHeaderMap(allHeaders), allHeaders, notModified, networkTimeMs);
     }
 
@@ -79,7 +80,10 @@
      */
     @Deprecated
     public NetworkResponse(
-            int statusCode, byte[] data, Map<String, String> headers, boolean notModified) {
+            int statusCode,
+            byte[] data,
+            @Nullable Map<String, String> headers,
+            boolean notModified) {
         this(statusCode, data, headers, notModified, /* networkTimeMs= */ 0);
     }
 
@@ -107,7 +111,7 @@
      *     constructor may be removed in a future release of Volley.
      */
     @Deprecated
-    public NetworkResponse(byte[] data, Map<String, String> headers) {
+    public NetworkResponse(byte[] data, @Nullable Map<String, String> headers) {
         this(
                 HttpURLConnection.HTTP_OK,
                 data,
@@ -119,8 +123,8 @@
     private NetworkResponse(
             int statusCode,
             byte[] data,
-            Map<String, String> headers,
-            List<Header> allHeaders,
+            @Nullable Map<String, String> headers,
+            @Nullable List<Header> allHeaders,
             boolean notModified,
             long networkTimeMs) {
         this.statusCode = statusCode;
@@ -150,10 +154,10 @@
      * map will only contain the last one. Use {@link #allHeaders} to inspect all headers returned
      * by the server.
      */
-    public final Map<String, String> headers;
+    @Nullable public final Map<String, String> headers;
 
     /** All response headers. Must not be mutated directly. */
-    public final List<Header> allHeaders;
+    @Nullable public final List<Header> allHeaders;
 
     /** True if the server returned a 304 (Not Modified). */
     public final boolean notModified;
@@ -161,7 +165,8 @@
     /** Network roundtrip time in milliseconds. */
     public final long networkTimeMs;
 
-    private static Map<String, String> toHeaderMap(List<Header> allHeaders) {
+    @Nullable
+    private static Map<String, String> toHeaderMap(@Nullable List<Header> allHeaders) {
         if (allHeaders == null) {
             return null;
         }
@@ -176,7 +181,8 @@
         return headers;
     }
 
-    private static List<Header> toAllHeaderList(Map<String, String> headers) {
+    @Nullable
+    private static List<Header> toAllHeaderList(@Nullable Map<String, String> headers) {
         if (headers == null) {
             return null;
         }
diff --git a/src/main/java/com/android/volley/Request.java b/src/main/java/com/android/volley/Request.java
index 104b046..b60dc74 100644
--- a/src/main/java/com/android/volley/Request.java
+++ b/src/main/java/com/android/volley/Request.java
@@ -107,6 +107,9 @@
     /** Whether the request should be retried in the event of an HTTP 5xx (server) error. */
     private boolean mShouldRetryServerErrors = false;
 
+    /** Whether the request should be retried in the event of a {@link NoConnectionError}. */
+    private boolean mShouldRetryConnectionErrors = false;
+
     /** The retry policy for this request. */
     private RetryPolicy mRetryPolicy;
 
@@ -115,7 +118,7 @@
      * entry will be stored here so that in the event of a "Not Modified" response, we can be sure
      * it hasn't been evicted from cache.
      */
-    private Cache.Entry mCacheEntry = null;
+    @Nullable private Cache.Entry mCacheEntry = null;
 
     /** An opaque token tagging this request; used for bulk cancellation. */
     private Object mTag;
@@ -319,6 +322,7 @@
     }
 
     /** Returns the annotated cache entry, or null if there isn't one. */
+    @Nullable
     public Cache.Entry getCacheEntry() {
         return mCacheEntry;
     }
@@ -374,6 +378,7 @@
      * @deprecated Use {@link #getParams()} instead.
      */
     @Deprecated
+    @Nullable
     protected Map<String, String> getPostParams() throws AuthFailureError {
         return getParams();
     }
@@ -431,6 +436,7 @@
      *
      * @throws AuthFailureError in the event of auth failure
      */
+    @Nullable
     protected Map<String, String> getParams() throws AuthFailureError {
         return null;
     }
@@ -531,6 +537,25 @@
     }
 
     /**
+     * Sets whether or not the request should be retried in the event that no connection could be
+     * established.
+     *
+     * @return This Request object to allow for chaining.
+     */
+    public final Request<?> setShouldRetryConnectionErrors(boolean shouldRetryConnectionErrors) {
+        mShouldRetryConnectionErrors = shouldRetryConnectionErrors;
+        return this;
+    }
+
+    /**
+     * Returns true if this request should be retried in the event that no connection could be
+     * established.
+     */
+    public final boolean shouldRetryConnectionErrors() {
+        return mShouldRetryConnectionErrors;
+    }
+
+    /**
      * Priority values. Requests will be processed from higher priorities to lower priorities, in
      * FIFO order.
      */
diff --git a/src/main/java/com/android/volley/RequestQueue.java b/src/main/java/com/android/volley/RequestQueue.java
index c127c7f..6db0b1c 100644
--- a/src/main/java/com/android/volley/RequestQueue.java
+++ b/src/main/java/com/android/volley/RequestQueue.java
@@ -263,13 +263,17 @@
         request.addMarker("add-to-queue");
         sendRequestEvent(request, RequestEvent.REQUEST_QUEUED);
 
+        beginRequest(request);
+        return request;
+    }
+
+    <T> void beginRequest(Request<T> request) {
         // If the request is uncacheable, skip the cache queue and go straight to the network.
         if (!request.shouldCache()) {
-            mNetworkQueue.add(request);
-            return request;
+            sendRequestOverNetwork(request);
+        } else {
+            mCacheQueue.add(request);
         }
-        mCacheQueue.add(request);
-        return request;
     }
 
     /**
@@ -327,4 +331,12 @@
             mFinishedListeners.remove(listener);
         }
     }
+
+    public ResponseDelivery getResponseDelivery() {
+        return mDelivery;
+    }
+
+    <T> void sendRequestOverNetwork(Request<T> request) {
+        mNetworkQueue.add(request);
+    }
 }
diff --git a/src/main/java/com/android/volley/RequestTask.java b/src/main/java/com/android/volley/RequestTask.java
new file mode 100644
index 0000000..8eeaf2c
--- /dev/null
+++ b/src/main/java/com/android/volley/RequestTask.java
@@ -0,0 +1,15 @@
+package com.android.volley;
+
+/** Abstract runnable that's a task to be completed by the RequestQueue. */
+public abstract class RequestTask<T> implements Runnable {
+    final Request<T> mRequest;
+
+    public RequestTask(Request<T> request) {
+        mRequest = request;
+    }
+
+    @SuppressWarnings("unchecked")
+    public int compareTo(RequestTask<?> other) {
+        return mRequest.compareTo((Request<T>) other.mRequest);
+    }
+}
diff --git a/src/main/java/com/android/volley/Response.java b/src/main/java/com/android/volley/Response.java
index 2f50e2d..622bdc4 100644
--- a/src/main/java/com/android/volley/Response.java
+++ b/src/main/java/com/android/volley/Response.java
@@ -16,6 +16,8 @@
 
 package com.android.volley;
 
+import androidx.annotation.Nullable;
+
 /**
  * Encapsulates a parsed response for delivery.
  *
@@ -39,7 +41,7 @@
     }
 
     /** Returns a successful response containing the parsed result. */
-    public static <T> Response<T> success(T result, Cache.Entry cacheEntry) {
+    public static <T> Response<T> success(@Nullable T result, @Nullable Cache.Entry cacheEntry) {
         return new Response<>(result, cacheEntry);
     }
 
@@ -51,14 +53,14 @@
         return new Response<>(error);
     }
 
-    /** Parsed response, or null in the case of error. */
-    public final T result;
+    /** Parsed response, can be null; always null in the case of error. */
+    @Nullable public final T result;
 
-    /** Cache metadata for this response, or null in the case of error. */
-    public final Cache.Entry cacheEntry;
+    /** Cache metadata for this response; null if not cached or in the case of error. */
+    @Nullable public final Cache.Entry cacheEntry;
 
     /** Detailed error information if <code>errorCode != OK</code>. */
-    public final VolleyError error;
+    @Nullable public final VolleyError error;
 
     /** True if this response was a soft-expired one and a second one MAY be coming. */
     public boolean intermediate = false;
@@ -68,7 +70,7 @@
         return error == null;
     }
 
-    private Response(T result, Cache.Entry cacheEntry) {
+    private Response(@Nullable T result, @Nullable Cache.Entry cacheEntry) {
         this.result = result;
         this.cacheEntry = cacheEntry;
         this.error = null;
diff --git a/src/main/java/com/android/volley/WaitingRequestManager.java b/src/main/java/com/android/volley/WaitingRequestManager.java
new file mode 100644
index 0000000..682e339
--- /dev/null
+++ b/src/main/java/com/android/volley/WaitingRequestManager.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+
+/**
+ * Callback to notify the caller when the network request returns. Valid responses can be used by
+ * all duplicate requests.
+ */
+class WaitingRequestManager implements Request.NetworkRequestCompleteListener {
+
+    /**
+     * Staging area for requests that already have a duplicate request in flight.
+     *
+     * <ul>
+     *   <li>containsKey(cacheKey) indicates that there is a request in flight for the given cache
+     *       key.
+     *   <li>get(cacheKey) returns waiting requests for the given cache key. The in flight request
+     *       is <em>not</em> contained in that list. Is null if no requests are staged.
+     * </ul>
+     */
+    private final Map<String, List<Request<?>>> mWaitingRequests = new HashMap<>();
+
+    private final ResponseDelivery mResponseDelivery;
+
+    /**
+     * RequestQueue that is passed in by the AsyncRequestQueue. This is null when this instance is
+     * initialized by the {@link CacheDispatcher}
+     */
+    @Nullable private final RequestQueue mRequestQueue;
+
+    /**
+     * CacheDispacter that is passed in by the CacheDispatcher. This is null when this instance is
+     * initialized by the {@link AsyncRequestQueue}
+     */
+    @Nullable private final CacheDispatcher mCacheDispatcher;
+
+    /**
+     * BlockingQueue that is passed in by the CacheDispatcher. This is null when this instance is
+     * initialized by the {@link AsyncRequestQueue}
+     */
+    @Nullable private final BlockingQueue<Request<?>> mNetworkQueue;
+
+    WaitingRequestManager(@NonNull RequestQueue requestQueue) {
+        mRequestQueue = requestQueue;
+        mResponseDelivery = mRequestQueue.getResponseDelivery();
+        mCacheDispatcher = null;
+        mNetworkQueue = null;
+    }
+
+    WaitingRequestManager(
+            @NonNull CacheDispatcher cacheDispatcher,
+            @NonNull BlockingQueue<Request<?>> networkQueue,
+            ResponseDelivery responseDelivery) {
+        mRequestQueue = null;
+        mResponseDelivery = responseDelivery;
+        mCacheDispatcher = cacheDispatcher;
+        mNetworkQueue = networkQueue;
+    }
+
+    /** Request received a valid response that can be used by other waiting requests. */
+    @Override
+    public void onResponseReceived(Request<?> request, Response<?> response) {
+        if (response.cacheEntry == null || response.cacheEntry.isExpired()) {
+            onNoUsableResponseReceived(request);
+            return;
+        }
+        String cacheKey = request.getCacheKey();
+        List<Request<?>> waitingRequests;
+        synchronized (this) {
+            waitingRequests = mWaitingRequests.remove(cacheKey);
+        }
+        if (waitingRequests != null) {
+            if (VolleyLog.DEBUG) {
+                VolleyLog.v(
+                        "Releasing %d waiting requests for cacheKey=%s.",
+                        waitingRequests.size(), cacheKey);
+            }
+            // Process all queued up requests.
+            for (Request<?> waiting : waitingRequests) {
+                mResponseDelivery.postResponse(waiting, response);
+            }
+        }
+    }
+
+    /** No valid response received from network, release waiting requests. */
+    @Override
+    public synchronized void onNoUsableResponseReceived(Request<?> request) {
+        String cacheKey = request.getCacheKey();
+        List<Request<?>> waitingRequests = mWaitingRequests.remove(cacheKey);
+        if (waitingRequests != null && !waitingRequests.isEmpty()) {
+            if (VolleyLog.DEBUG) {
+                VolleyLog.v(
+                        "%d waiting requests for cacheKey=%s; resend to network",
+                        waitingRequests.size(), cacheKey);
+            }
+            Request<?> nextInLine = waitingRequests.remove(0);
+            mWaitingRequests.put(cacheKey, waitingRequests);
+            nextInLine.setNetworkRequestCompleteListener(this);
+            // RequestQueue will be non-null if this instance was created in AsyncRequestQueue.
+            if (mRequestQueue != null) {
+                // Will send the network request from the RequestQueue.
+                mRequestQueue.sendRequestOverNetwork(nextInLine);
+            } else if (mCacheDispatcher != null && mNetworkQueue != null) {
+                // If we're not using the AsyncRequestQueue, then submit it to the network queue.
+                try {
+                    mNetworkQueue.put(nextInLine);
+                } catch (InterruptedException iex) {
+                    VolleyLog.e("Couldn't add request to queue. %s", iex.toString());
+                    // Restore the interrupted status of the calling thread (i.e. NetworkDispatcher)
+                    Thread.currentThread().interrupt();
+                    // Quit the current CacheDispatcher thread.
+                    mCacheDispatcher.quit();
+                }
+            }
+        }
+    }
+
+    /**
+     * For cacheable requests, if a request for the same cache key is already in flight, add it to a
+     * queue to wait for that in-flight request to finish.
+     *
+     * @return whether the request was queued. If false, we should continue issuing the request over
+     *     the network. If true, we should put the request on hold to be processed when the
+     *     in-flight request finishes.
+     */
+    synchronized boolean maybeAddToWaitingRequests(Request<?> request) {
+        String cacheKey = request.getCacheKey();
+        // Insert request into stage if there's already a request with the same cache key
+        // in flight.
+        if (mWaitingRequests.containsKey(cacheKey)) {
+            // There is already a request in flight. Queue up.
+            List<Request<?>> stagedRequests = mWaitingRequests.get(cacheKey);
+            if (stagedRequests == null) {
+                stagedRequests = new ArrayList<>();
+            }
+            request.addMarker("waiting-for-response");
+            stagedRequests.add(request);
+            mWaitingRequests.put(cacheKey, stagedRequests);
+            if (VolleyLog.DEBUG) {
+                VolleyLog.d("Request for cacheKey=%s is in flight, putting on hold.", cacheKey);
+            }
+            return true;
+        } else {
+            // Insert 'null' queue for this cacheKey, indicating there is now a request in
+            // flight.
+            mWaitingRequests.put(cacheKey, null);
+            request.setNetworkRequestCompleteListener(this);
+            if (VolleyLog.DEBUG) {
+                VolleyLog.d("new request, sending to network %s", cacheKey);
+            }
+            return false;
+        }
+    }
+}
diff --git a/src/main/java/com/android/volley/cronet/CronetHttpStack.java b/src/main/java/com/android/volley/cronet/CronetHttpStack.java
new file mode 100644
index 0000000..f3baace
--- /dev/null
+++ b/src/main/java/com/android/volley/cronet/CronetHttpStack.java
@@ -0,0 +1,631 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.cronet;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.Base64;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import com.android.volley.AuthFailureError;
+import com.android.volley.Header;
+import com.android.volley.Request;
+import com.android.volley.RequestTask;
+import com.android.volley.VolleyLog;
+import com.android.volley.toolbox.AsyncHttpStack;
+import com.android.volley.toolbox.ByteArrayPool;
+import com.android.volley.toolbox.HttpHeaderParser;
+import com.android.volley.toolbox.HttpResponse;
+import com.android.volley.toolbox.PoolingByteArrayOutputStream;
+import com.android.volley.toolbox.UrlRewriter;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.nio.ByteBuffer;
+import java.nio.channels.Channels;
+import java.nio.channels.WritableByteChannel;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import org.chromium.net.CronetEngine;
+import org.chromium.net.CronetException;
+import org.chromium.net.UploadDataProvider;
+import org.chromium.net.UploadDataProviders;
+import org.chromium.net.UrlRequest;
+import org.chromium.net.UrlRequest.Callback;
+import org.chromium.net.UrlResponseInfo;
+
+/**
+ * A {@link AsyncHttpStack} that's based on Cronet's fully asynchronous API for network requests.
+ */
+public class CronetHttpStack extends AsyncHttpStack {
+
+    private final CronetEngine mCronetEngine;
+    private final ByteArrayPool mPool;
+    private final UrlRewriter mUrlRewriter;
+    private final RequestListener mRequestListener;
+
+    // cURL logging support
+    private final boolean mCurlLoggingEnabled;
+    private final CurlCommandLogger mCurlCommandLogger;
+    private final boolean mLogAuthTokensInCurlCommands;
+
+    private CronetHttpStack(
+            CronetEngine cronetEngine,
+            ByteArrayPool pool,
+            UrlRewriter urlRewriter,
+            RequestListener requestListener,
+            boolean curlLoggingEnabled,
+            CurlCommandLogger curlCommandLogger,
+            boolean logAuthTokensInCurlCommands) {
+        mCronetEngine = cronetEngine;
+        mPool = pool;
+        mUrlRewriter = urlRewriter;
+        mRequestListener = requestListener;
+        mCurlLoggingEnabled = curlLoggingEnabled;
+        mCurlCommandLogger = curlCommandLogger;
+        mLogAuthTokensInCurlCommands = logAuthTokensInCurlCommands;
+
+        mRequestListener.initialize(this);
+    }
+
+    @Override
+    public void executeRequest(
+            final Request<?> request,
+            final Map<String, String> additionalHeaders,
+            final OnRequestComplete callback) {
+        if (getBlockingExecutor() == null || getNonBlockingExecutor() == null) {
+            throw new IllegalStateException("Must set blocking and non-blocking executors");
+        }
+        final Callback urlCallback =
+                new Callback() {
+                    PoolingByteArrayOutputStream bytesReceived = null;
+                    WritableByteChannel receiveChannel = null;
+
+                    @Override
+                    public void onRedirectReceived(
+                            UrlRequest urlRequest,
+                            UrlResponseInfo urlResponseInfo,
+                            String newLocationUrl) {
+                        urlRequest.followRedirect();
+                    }
+
+                    @Override
+                    public void onResponseStarted(
+                            UrlRequest urlRequest, UrlResponseInfo urlResponseInfo) {
+                        bytesReceived =
+                                new PoolingByteArrayOutputStream(
+                                        mPool, getContentLength(urlResponseInfo));
+                        receiveChannel = Channels.newChannel(bytesReceived);
+                        urlRequest.read(ByteBuffer.allocateDirect(1024));
+                    }
+
+                    @Override
+                    public void onReadCompleted(
+                            UrlRequest urlRequest,
+                            UrlResponseInfo urlResponseInfo,
+                            ByteBuffer byteBuffer) {
+                        byteBuffer.flip();
+                        try {
+                            receiveChannel.write(byteBuffer);
+                            byteBuffer.clear();
+                            urlRequest.read(byteBuffer);
+                        } catch (IOException e) {
+                            urlRequest.cancel();
+                            callback.onError(e);
+                        }
+                    }
+
+                    @Override
+                    public void onSucceeded(
+                            UrlRequest urlRequest, UrlResponseInfo urlResponseInfo) {
+                        List<Header> headers = getHeaders(urlResponseInfo.getAllHeadersAsList());
+                        HttpResponse response =
+                                new HttpResponse(
+                                        urlResponseInfo.getHttpStatusCode(),
+                                        headers,
+                                        bytesReceived.toByteArray());
+                        callback.onSuccess(response);
+                    }
+
+                    @Override
+                    public void onFailed(
+                            UrlRequest urlRequest,
+                            UrlResponseInfo urlResponseInfo,
+                            CronetException e) {
+                        callback.onError(e);
+                    }
+                };
+
+        String url = request.getUrl();
+        String rewritten = mUrlRewriter.rewriteUrl(url);
+        if (rewritten == null) {
+            callback.onError(new IOException("URL blocked by rewriter: " + url));
+            return;
+        }
+        url = rewritten;
+
+        // We can call allowDirectExecutor here and run directly on the network thread, since all
+        // the callbacks are non-blocking.
+        final UrlRequest.Builder builder =
+                mCronetEngine
+                        .newUrlRequestBuilder(url, urlCallback, getNonBlockingExecutor())
+                        .allowDirectExecutor()
+                        .disableCache()
+                        .setPriority(getPriority(request));
+        // request.getHeaders() may be blocking, so submit it to the blocking executor.
+        getBlockingExecutor()
+                .execute(
+                        new SetUpRequestTask<>(request, url, builder, additionalHeaders, callback));
+    }
+
+    private class SetUpRequestTask<T> extends RequestTask<T> {
+        UrlRequest.Builder builder;
+        String url;
+        Map<String, String> additionalHeaders;
+        OnRequestComplete callback;
+        Request<T> request;
+
+        SetUpRequestTask(
+                Request<T> request,
+                String url,
+                UrlRequest.Builder builder,
+                Map<String, String> additionalHeaders,
+                OnRequestComplete callback) {
+            super(request);
+            // Note that this URL may be different from Request#getUrl() due to the UrlRewriter.
+            this.url = url;
+            this.builder = builder;
+            this.additionalHeaders = additionalHeaders;
+            this.callback = callback;
+            this.request = request;
+        }
+
+        @Override
+        public void run() {
+            try {
+                mRequestListener.onRequestPrepared(request, builder);
+                CurlLoggedRequestParameters requestParameters = new CurlLoggedRequestParameters();
+                setHttpMethod(requestParameters, request);
+                setRequestHeaders(requestParameters, request, additionalHeaders);
+                requestParameters.applyToRequest(builder, getNonBlockingExecutor());
+                UrlRequest urlRequest = builder.build();
+                if (mCurlLoggingEnabled) {
+                    mCurlCommandLogger.logCurlCommand(generateCurlCommand(url, requestParameters));
+                }
+                urlRequest.start();
+            } catch (AuthFailureError authFailureError) {
+                callback.onAuthError(authFailureError);
+            }
+        }
+    }
+
+    @VisibleForTesting
+    public static List<Header> getHeaders(List<Map.Entry<String, String>> headersList) {
+        List<Header> headers = new ArrayList<>();
+        for (Map.Entry<String, String> header : headersList) {
+            headers.add(new Header(header.getKey(), header.getValue()));
+        }
+        return headers;
+    }
+
+    /** Sets the connection parameters for the UrlRequest */
+    private void setHttpMethod(CurlLoggedRequestParameters requestParameters, Request<?> request)
+            throws AuthFailureError {
+        switch (request.getMethod()) {
+            case Request.Method.DEPRECATED_GET_OR_POST:
+                // This is the deprecated way that needs to be handled for backwards compatibility.
+                // If the request's post body is null, then the assumption is that the request is
+                // GET.  Otherwise, it is assumed that the request is a POST.
+                byte[] postBody = request.getPostBody();
+                if (postBody != null) {
+                    requestParameters.setHttpMethod("POST");
+                    addBodyIfExists(requestParameters, request.getPostBodyContentType(), postBody);
+                } else {
+                    requestParameters.setHttpMethod("GET");
+                }
+                break;
+            case Request.Method.GET:
+                // Not necessary to set the request method because connection defaults to GET but
+                // being explicit here.
+                requestParameters.setHttpMethod("GET");
+                break;
+            case Request.Method.DELETE:
+                requestParameters.setHttpMethod("DELETE");
+                break;
+            case Request.Method.POST:
+                requestParameters.setHttpMethod("POST");
+                addBodyIfExists(requestParameters, request.getBodyContentType(), request.getBody());
+                break;
+            case Request.Method.PUT:
+                requestParameters.setHttpMethod("PUT");
+                addBodyIfExists(requestParameters, request.getBodyContentType(), request.getBody());
+                break;
+            case Request.Method.HEAD:
+                requestParameters.setHttpMethod("HEAD");
+                break;
+            case Request.Method.OPTIONS:
+                requestParameters.setHttpMethod("OPTIONS");
+                break;
+            case Request.Method.TRACE:
+                requestParameters.setHttpMethod("TRACE");
+                break;
+            case Request.Method.PATCH:
+                requestParameters.setHttpMethod("PATCH");
+                addBodyIfExists(requestParameters, request.getBodyContentType(), request.getBody());
+                break;
+            default:
+                throw new IllegalStateException("Unknown method type.");
+        }
+    }
+
+    /**
+     * Sets the request headers for the UrlRequest.
+     *
+     * @param requestParameters parameters that we are adding the request headers to
+     * @param request to get the headers from
+     * @param additionalHeaders for the UrlRequest
+     * @throws AuthFailureError is thrown if Request#getHeaders throws ones
+     */
+    private void setRequestHeaders(
+            CurlLoggedRequestParameters requestParameters,
+            Request<?> request,
+            Map<String, String> additionalHeaders)
+            throws AuthFailureError {
+        requestParameters.putAllHeaders(additionalHeaders);
+        // Request.getHeaders() takes precedence over the given additional (cache) headers).
+        requestParameters.putAllHeaders(request.getHeaders());
+    }
+
+    /** Sets the UploadDataProvider of the UrlRequest.Builder */
+    private void addBodyIfExists(
+            CurlLoggedRequestParameters requestParameters,
+            String contentType,
+            @Nullable byte[] body) {
+        requestParameters.setBody(contentType, body);
+    }
+
+    /** Helper method that maps Volley's request priority to Cronet's */
+    private int getPriority(Request<?> request) {
+        switch (request.getPriority()) {
+            case LOW:
+                return UrlRequest.Builder.REQUEST_PRIORITY_LOW;
+            case HIGH:
+            case IMMEDIATE:
+                return UrlRequest.Builder.REQUEST_PRIORITY_HIGHEST;
+            case NORMAL:
+            default:
+                return UrlRequest.Builder.REQUEST_PRIORITY_MEDIUM;
+        }
+    }
+
+    private int getContentLength(UrlResponseInfo urlResponseInfo) {
+        List<String> content = urlResponseInfo.getAllHeaders().get("Content-Length");
+        if (content == null) {
+            return 1024;
+        } else {
+            return Integer.parseInt(content.get(0));
+        }
+    }
+
+    private String generateCurlCommand(String url, CurlLoggedRequestParameters requestParameters) {
+        StringBuilder builder = new StringBuilder("curl ");
+
+        // HTTP method
+        builder.append("-X ").append(requestParameters.getHttpMethod()).append(" ");
+
+        // Request headers
+        for (Map.Entry<String, String> header : requestParameters.getHeaders().entrySet()) {
+            builder.append("--header \"").append(header.getKey()).append(": ");
+            if (!mLogAuthTokensInCurlCommands
+                    && ("Authorization".equals(header.getKey())
+                            || "Cookie".equals(header.getKey()))) {
+                builder.append("[REDACTED]");
+            } else {
+                builder.append(header.getValue());
+            }
+            builder.append("\" ");
+        }
+
+        // URL
+        builder.append("\"").append(url).append("\"");
+
+        // Request body (if any)
+        if (requestParameters.getBody() != null) {
+            if (requestParameters.getBody().length >= 1024) {
+                builder.append(" [REQUEST BODY TOO LARGE TO INCLUDE]");
+            } else if (isBinaryContentForLogging(requestParameters)) {
+                String base64 = Base64.encodeToString(requestParameters.getBody(), Base64.NO_WRAP);
+                builder.insert(0, "echo '" + base64 + "' | base64 -d > /tmp/$$.bin; ")
+                        .append(" --data-binary @/tmp/$$.bin");
+            } else {
+                // Just assume the request body is UTF-8 since this is for debugging.
+                try {
+                    builder.append(" --data-ascii \"")
+                            .append(new String(requestParameters.getBody(), "UTF-8"))
+                            .append("\"");
+                } catch (UnsupportedEncodingException e) {
+                    throw new RuntimeException("Could not encode to UTF-8", e);
+                }
+            }
+        }
+
+        return builder.toString();
+    }
+
+    /** Rough heuristic to determine whether the request body is binary, for logging purposes. */
+    private boolean isBinaryContentForLogging(CurlLoggedRequestParameters requestParameters) {
+        // Check to see if the content is gzip compressed - this means it should be treated as
+        // binary content regardless of the content type.
+        String contentEncoding = requestParameters.getHeaders().get("Content-Encoding");
+        if (contentEncoding != null) {
+            String[] encodings = TextUtils.split(contentEncoding, ",");
+            for (String encoding : encodings) {
+                if ("gzip".equals(encoding.trim())) {
+                    return true;
+                }
+            }
+        }
+
+        // If the content type is a known text type, treat it as text content.
+        String contentType = requestParameters.getHeaders().get("Content-Type");
+        if (contentType != null) {
+            return !contentType.startsWith("text/")
+                    && !contentType.startsWith("application/xml")
+                    && !contentType.startsWith("application/json");
+        }
+
+        // Otherwise, assume it is binary content.
+        return true;
+    }
+
+    /**
+     * Builder is used to build an instance of {@link CronetHttpStack} from values configured by the
+     * setters.
+     */
+    public static class Builder {
+        private static final int DEFAULT_POOL_SIZE = 4096;
+        private CronetEngine mCronetEngine;
+        private final Context context;
+        private ByteArrayPool mPool;
+        private UrlRewriter mUrlRewriter;
+        private RequestListener mRequestListener;
+        private boolean mCurlLoggingEnabled;
+        private CurlCommandLogger mCurlCommandLogger;
+        private boolean mLogAuthTokensInCurlCommands;
+
+        public Builder(Context context) {
+            this.context = context;
+        }
+
+        /** Sets the CronetEngine to be used. Defaults to a vanialla CronetEngine. */
+        public Builder setCronetEngine(CronetEngine engine) {
+            mCronetEngine = engine;
+            return this;
+        }
+
+        /** Sets the ByteArrayPool to be used. Defaults to a new pool with 4096 bytes. */
+        public Builder setPool(ByteArrayPool pool) {
+            mPool = pool;
+            return this;
+        }
+
+        /** Sets the UrlRewriter to be used. Default is to return the original string. */
+        public Builder setUrlRewriter(UrlRewriter urlRewriter) {
+            mUrlRewriter = urlRewriter;
+            return this;
+        }
+
+        /** Set the optional RequestListener to be used. */
+        public Builder setRequestListener(RequestListener requestListener) {
+            mRequestListener = requestListener;
+            return this;
+        }
+
+        /**
+         * Sets whether cURL logging should be enabled for debugging purposes.
+         *
+         * <p>When enabled, for each request dispatched to the network, a roughly-equivalent cURL
+         * command will be logged to logcat.
+         *
+         * <p>The command may be missing some headers that are added by Cronet automatically, and
+         * the full request body may not be included if it is too large. To inspect the full
+         * requests and responses, see {@code CronetEngine#startNetLogToFile}.
+         *
+         * <p>WARNING: This is only intended for debugging purposes and should never be enabled on
+         * production devices.
+         *
+         * @see #setCurlCommandLogger(CurlCommandLogger)
+         * @see #setLogAuthTokensInCurlCommands(boolean)
+         */
+        public Builder setCurlLoggingEnabled(boolean curlLoggingEnabled) {
+            mCurlLoggingEnabled = curlLoggingEnabled;
+            return this;
+        }
+
+        /**
+         * Sets the function used to log cURL commands.
+         *
+         * <p>Allows customization of the logging performed when cURL logging is enabled.
+         *
+         * <p>By default, when cURL logging is enabled, cURL commands are logged using {@link
+         * VolleyLog#v}, e.g. at the verbose log level with the same log tag used by the rest of
+         * Volley. This function may optionally be invoked to provide a custom logger.
+         *
+         * @see #setCurlLoggingEnabled(boolean)
+         */
+        public Builder setCurlCommandLogger(CurlCommandLogger curlCommandLogger) {
+            mCurlCommandLogger = curlCommandLogger;
+            return this;
+        }
+
+        /**
+         * Sets whether to log known auth tokens in cURL commands, or redact them.
+         *
+         * <p>By default, headers which may contain auth tokens (e.g. Authorization or Cookie) will
+         * have their values redacted. Passing true to this method will disable this redaction and
+         * log the values of these headers.
+         *
+         * <p>This heuristic is not perfect; tokens that are logged in unknown headers, or in the
+         * request body itself, will not be redacted as they cannot be detected generically.
+         *
+         * @see #setCurlLoggingEnabled(boolean)
+         */
+        public Builder setLogAuthTokensInCurlCommands(boolean logAuthTokensInCurlCommands) {
+            mLogAuthTokensInCurlCommands = logAuthTokensInCurlCommands;
+            return this;
+        }
+
+        public CronetHttpStack build() {
+            if (mCronetEngine == null) {
+                mCronetEngine = new CronetEngine.Builder(context).build();
+            }
+            if (mUrlRewriter == null) {
+                mUrlRewriter =
+                        new UrlRewriter() {
+                            @Override
+                            public String rewriteUrl(String originalUrl) {
+                                return originalUrl;
+                            }
+                        };
+            }
+            if (mRequestListener == null) {
+                mRequestListener = new RequestListener() {};
+            }
+            if (mPool == null) {
+                mPool = new ByteArrayPool(DEFAULT_POOL_SIZE);
+            }
+            if (mCurlCommandLogger == null) {
+                mCurlCommandLogger =
+                        new CurlCommandLogger() {
+                            @Override
+                            public void logCurlCommand(String curlCommand) {
+                                VolleyLog.v(curlCommand);
+                            }
+                        };
+            }
+            return new CronetHttpStack(
+                    mCronetEngine,
+                    mPool,
+                    mUrlRewriter,
+                    mRequestListener,
+                    mCurlLoggingEnabled,
+                    mCurlCommandLogger,
+                    mLogAuthTokensInCurlCommands);
+        }
+    }
+
+    /** Callback interface allowing clients to intercept different parts of the request flow. */
+    public abstract static class RequestListener {
+        private CronetHttpStack mStack;
+
+        void initialize(CronetHttpStack stack) {
+            mStack = stack;
+        }
+
+        /**
+         * Called when a request is prepared and about to be sent over the network.
+         *
+         * <p>Clients may use this callback to customize UrlRequests before they are dispatched,
+         * e.g. to enable socket tagging or request finished listeners.
+         */
+        public void onRequestPrepared(Request<?> request, UrlRequest.Builder requestBuilder) {}
+
+        /** @see AsyncHttpStack#getNonBlockingExecutor() */
+        protected Executor getNonBlockingExecutor() {
+            return mStack.getNonBlockingExecutor();
+        }
+
+        /** @see AsyncHttpStack#getBlockingExecutor() */
+        protected Executor getBlockingExecutor() {
+            return mStack.getBlockingExecutor();
+        }
+    }
+
+    /**
+     * Interface for logging cURL commands for requests.
+     *
+     * @see Builder#setCurlCommandLogger(CurlCommandLogger)
+     */
+    public interface CurlCommandLogger {
+        /** Log the given cURL command. */
+        void logCurlCommand(String curlCommand);
+    }
+
+    /**
+     * Internal container class for request parameters that impact logged cURL commands.
+     *
+     * <p>When cURL logging is enabled, an equivalent cURL command to a given request must be
+     * generated and logged. However, the Cronet UrlRequest object is write-only. So, we write any
+     * relevant parameters into this read-write container so they can be referenced when generating
+     * the cURL command (if needed) and then merged into the UrlRequest.
+     */
+    private static class CurlLoggedRequestParameters {
+        private final TreeMap<String, String> mHeaders =
+                new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        private String mHttpMethod;
+        @Nullable private byte[] mBody;
+
+        /**
+         * Return the headers to be used for the request.
+         *
+         * <p>The returned map is case-insensitive.
+         */
+        TreeMap<String, String> getHeaders() {
+            return mHeaders;
+        }
+
+        /** Apply all the headers in the given map to the request. */
+        void putAllHeaders(Map<String, String> headers) {
+            mHeaders.putAll(headers);
+        }
+
+        String getHttpMethod() {
+            return mHttpMethod;
+        }
+
+        void setHttpMethod(String httpMethod) {
+            mHttpMethod = httpMethod;
+        }
+
+        @Nullable
+        byte[] getBody() {
+            return mBody;
+        }
+
+        void setBody(String contentType, @Nullable byte[] body) {
+            mBody = body;
+            if (body != null && !mHeaders.containsKey(HttpHeaderParser.HEADER_CONTENT_TYPE)) {
+                // Set the content-type unless it was already set (by Request#getHeaders).
+                mHeaders.put(HttpHeaderParser.HEADER_CONTENT_TYPE, contentType);
+            }
+        }
+
+        void applyToRequest(UrlRequest.Builder builder, ExecutorService nonBlockingExecutor) {
+            for (Map.Entry<String, String> header : mHeaders.entrySet()) {
+                builder.addHeader(header.getKey(), header.getValue());
+            }
+            builder.setHttpMethod(mHttpMethod);
+            if (mBody != null) {
+                UploadDataProvider dataProvider = UploadDataProviders.create(mBody);
+                builder.setUploadDataProvider(dataProvider, nonBlockingExecutor);
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/android/volley/toolbox/AsyncHttpStack.java b/src/main/java/com/android/volley/toolbox/AsyncHttpStack.java
new file mode 100644
index 0000000..bafab8c
--- /dev/null
+++ b/src/main/java/com/android/volley/toolbox/AsyncHttpStack.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.toolbox;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import com.android.volley.AuthFailureError;
+import com.android.volley.Request;
+import com.android.volley.VolleyLog;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
+
+/** Asynchronous extension of the {@link BaseHttpStack} class. */
+public abstract class AsyncHttpStack extends BaseHttpStack {
+    private ExecutorService mBlockingExecutor;
+    private ExecutorService mNonBlockingExecutor;
+
+    public interface OnRequestComplete {
+        /** Invoked when the stack successfully completes a request. */
+        void onSuccess(HttpResponse httpResponse);
+
+        /** Invoked when the stack throws an {@link AuthFailureError} during a request. */
+        void onAuthError(AuthFailureError authFailureError);
+
+        /** Invoked when the stack throws an {@link IOException} during a request. */
+        void onError(IOException ioException);
+    }
+
+    /**
+     * Makes an HTTP request with the given parameters, and calls the {@link OnRequestComplete}
+     * callback, with either the {@link HttpResponse} or error that was thrown.
+     *
+     * @param request to perform
+     * @param additionalHeaders to be sent together with {@link Request#getHeaders()}
+     * @param callback to be called after retrieving the {@link HttpResponse} or throwing an error.
+     */
+    public abstract void executeRequest(
+            Request<?> request, Map<String, String> additionalHeaders, OnRequestComplete callback);
+
+    /**
+     * This method sets the non blocking executor to be used by the stack for non-blocking tasks.
+     * This method must be called before executing any requests.
+     */
+    @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP})
+    public void setNonBlockingExecutor(ExecutorService executor) {
+        mNonBlockingExecutor = executor;
+    }
+
+    /**
+     * This method sets the blocking executor to be used by the stack for potentially blocking
+     * tasks. This method must be called before executing any requests.
+     */
+    @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP})
+    public void setBlockingExecutor(ExecutorService executor) {
+        mBlockingExecutor = executor;
+    }
+
+    /** Gets blocking executor to perform any potentially blocking tasks. */
+    protected ExecutorService getBlockingExecutor() {
+        return mBlockingExecutor;
+    }
+
+    /** Gets non-blocking executor to perform any non-blocking tasks. */
+    protected ExecutorService getNonBlockingExecutor() {
+        return mNonBlockingExecutor;
+    }
+
+    /**
+     * Performs an HTTP request with the given parameters.
+     *
+     * @param request the request to perform
+     * @param additionalHeaders additional headers to be sent together with {@link
+     *     Request#getHeaders()}
+     * @return the {@link HttpResponse}
+     * @throws IOException if an I/O error occurs during the request
+     * @throws AuthFailureError if an authentication failure occurs during the request
+     */
+    @Override
+    public final HttpResponse executeRequest(
+            Request<?> request, Map<String, String> additionalHeaders)
+            throws IOException, AuthFailureError {
+        final CountDownLatch latch = new CountDownLatch(1);
+        final AtomicReference<Response> entry = new AtomicReference<>();
+        executeRequest(
+                request,
+                additionalHeaders,
+                new OnRequestComplete() {
+                    @Override
+                    public void onSuccess(HttpResponse httpResponse) {
+                        Response response =
+                                new Response(
+                                        httpResponse,
+                                        /* ioException= */ null,
+                                        /* authFailureError= */ null);
+                        entry.set(response);
+                        latch.countDown();
+                    }
+
+                    @Override
+                    public void onAuthError(AuthFailureError authFailureError) {
+                        Response response =
+                                new Response(
+                                        /* httpResponse= */ null,
+                                        /* ioException= */ null,
+                                        authFailureError);
+                        entry.set(response);
+                        latch.countDown();
+                    }
+
+                    @Override
+                    public void onError(IOException ioException) {
+                        Response response =
+                                new Response(
+                                        /* httpResponse= */ null,
+                                        ioException,
+                                        /* authFailureError= */ null);
+                        entry.set(response);
+                        latch.countDown();
+                    }
+                });
+        try {
+            latch.await();
+        } catch (InterruptedException e) {
+            VolleyLog.e(e, "while waiting for CountDownLatch");
+            Thread.currentThread().interrupt();
+            throw new InterruptedIOException(e.toString());
+        }
+        Response response = entry.get();
+        if (response.httpResponse != null) {
+            return response.httpResponse;
+        } else if (response.ioException != null) {
+            throw response.ioException;
+        } else {
+            throw response.authFailureError;
+        }
+    }
+
+    private static class Response {
+        HttpResponse httpResponse;
+        IOException ioException;
+        AuthFailureError authFailureError;
+
+        private Response(
+                @Nullable HttpResponse httpResponse,
+                @Nullable IOException ioException,
+                @Nullable AuthFailureError authFailureError) {
+            this.httpResponse = httpResponse;
+            this.ioException = ioException;
+            this.authFailureError = authFailureError;
+        }
+    }
+}
diff --git a/src/main/java/com/android/volley/toolbox/BasicAsyncNetwork.java b/src/main/java/com/android/volley/toolbox/BasicAsyncNetwork.java
new file mode 100644
index 0000000..55892a0
--- /dev/null
+++ b/src/main/java/com/android/volley/toolbox/BasicAsyncNetwork.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.toolbox;
+
+import static com.android.volley.toolbox.NetworkUtility.logSlowRequests;
+
+import android.os.SystemClock;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import com.android.volley.AsyncNetwork;
+import com.android.volley.AuthFailureError;
+import com.android.volley.Header;
+import com.android.volley.NetworkResponse;
+import com.android.volley.Request;
+import com.android.volley.RequestTask;
+import com.android.volley.VolleyError;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+
+/** A network performing Volley requests over an {@link HttpStack}. */
+public class BasicAsyncNetwork extends AsyncNetwork {
+
+    private final AsyncHttpStack mAsyncStack;
+    private final ByteArrayPool mPool;
+
+    /**
+     * @param httpStack HTTP stack to be used
+     * @param pool a buffer pool that improves GC performance in copy operations
+     */
+    private BasicAsyncNetwork(AsyncHttpStack httpStack, ByteArrayPool pool) {
+        mAsyncStack = httpStack;
+        mPool = pool;
+    }
+
+    @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP})
+    @Override
+    public void setBlockingExecutor(ExecutorService executor) {
+        super.setBlockingExecutor(executor);
+        mAsyncStack.setBlockingExecutor(executor);
+    }
+
+    @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP})
+    @Override
+    public void setNonBlockingExecutor(ExecutorService executor) {
+        super.setNonBlockingExecutor(executor);
+        mAsyncStack.setNonBlockingExecutor(executor);
+    }
+
+    /* Method to be called after a successful network request */
+    private void onRequestSucceeded(
+            final Request<?> request,
+            final long requestStartMs,
+            final HttpResponse httpResponse,
+            final OnRequestComplete callback) {
+        final int statusCode = httpResponse.getStatusCode();
+        final List<Header> responseHeaders = httpResponse.getHeaders();
+        // Handle cache validation.
+        if (statusCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
+            long requestDuration = SystemClock.elapsedRealtime() - requestStartMs;
+            callback.onSuccess(
+                    NetworkUtility.getNotModifiedNetworkResponse(
+                            request, requestDuration, responseHeaders));
+            return;
+        }
+
+        byte[] responseContents = httpResponse.getContentBytes();
+        if (responseContents == null && httpResponse.getContent() == null) {
+            // Add 0 byte response as a way of honestly representing a
+            // no-content request.
+            responseContents = new byte[0];
+        }
+
+        if (responseContents != null) {
+            onResponseRead(
+                    requestStartMs,
+                    statusCode,
+                    httpResponse,
+                    request,
+                    callback,
+                    responseHeaders,
+                    responseContents);
+            return;
+        }
+
+        // The underlying AsyncHttpStack does not support asynchronous reading of the response into
+        // a byte array, so we need to submit a blocking task to copy the response from the
+        // InputStream instead.
+        final InputStream inputStream = httpResponse.getContent();
+        getBlockingExecutor()
+                .execute(
+                        new ResponseParsingTask<>(
+                                inputStream,
+                                httpResponse,
+                                request,
+                                callback,
+                                requestStartMs,
+                                responseHeaders,
+                                statusCode));
+    }
+
+    /* Method to be called after a failed network request */
+    private void onRequestFailed(
+            Request<?> request,
+            OnRequestComplete callback,
+            IOException exception,
+            long requestStartMs,
+            @Nullable HttpResponse httpResponse,
+            @Nullable byte[] responseContents) {
+        try {
+            NetworkUtility.handleException(
+                    request, exception, requestStartMs, httpResponse, responseContents);
+        } catch (VolleyError volleyError) {
+            callback.onError(volleyError);
+            return;
+        }
+        performRequest(request, callback);
+    }
+
+    @Override
+    public void performRequest(final Request<?> request, final OnRequestComplete callback) {
+        if (getBlockingExecutor() == null) {
+            throw new IllegalStateException(
+                    "mBlockingExecuter must be set before making a request");
+        }
+        final long requestStartMs = SystemClock.elapsedRealtime();
+        // Gather headers.
+        final Map<String, String> additionalRequestHeaders =
+                HttpHeaderParser.getCacheHeaders(request.getCacheEntry());
+        mAsyncStack.executeRequest(
+                request,
+                additionalRequestHeaders,
+                new AsyncHttpStack.OnRequestComplete() {
+                    @Override
+                    public void onSuccess(HttpResponse httpResponse) {
+                        onRequestSucceeded(request, requestStartMs, httpResponse, callback);
+                    }
+
+                    @Override
+                    public void onAuthError(AuthFailureError authFailureError) {
+                        callback.onError(authFailureError);
+                    }
+
+                    @Override
+                    public void onError(IOException ioException) {
+                        onRequestFailed(
+                                request,
+                                callback,
+                                ioException,
+                                requestStartMs,
+                                /* httpResponse= */ null,
+                                /* responseContents= */ null);
+                    }
+                });
+    }
+
+    /* Helper method that determines what to do after byte[] is received */
+    private void onResponseRead(
+            long requestStartMs,
+            int statusCode,
+            HttpResponse httpResponse,
+            Request<?> request,
+            OnRequestComplete callback,
+            List<Header> responseHeaders,
+            byte[] responseContents) {
+        // if the request is slow, log it.
+        long requestLifetime = SystemClock.elapsedRealtime() - requestStartMs;
+        logSlowRequests(requestLifetime, request, responseContents, statusCode);
+
+        if (statusCode < 200 || statusCode > 299) {
+            onRequestFailed(
+                    request,
+                    callback,
+                    new IOException(),
+                    requestStartMs,
+                    httpResponse,
+                    responseContents);
+            return;
+        }
+
+        callback.onSuccess(
+                new NetworkResponse(
+                        statusCode,
+                        responseContents,
+                        /* notModified= */ false,
+                        SystemClock.elapsedRealtime() - requestStartMs,
+                        responseHeaders));
+    }
+
+    private class ResponseParsingTask<T> extends RequestTask<T> {
+        InputStream inputStream;
+        HttpResponse httpResponse;
+        Request<T> request;
+        OnRequestComplete callback;
+        long requestStartMs;
+        List<Header> responseHeaders;
+        int statusCode;
+
+        ResponseParsingTask(
+                InputStream inputStream,
+                HttpResponse httpResponse,
+                Request<T> request,
+                OnRequestComplete callback,
+                long requestStartMs,
+                List<Header> responseHeaders,
+                int statusCode) {
+            super(request);
+            this.inputStream = inputStream;
+            this.httpResponse = httpResponse;
+            this.request = request;
+            this.callback = callback;
+            this.requestStartMs = requestStartMs;
+            this.responseHeaders = responseHeaders;
+            this.statusCode = statusCode;
+        }
+
+        @Override
+        public void run() {
+            byte[] finalResponseContents;
+            try {
+                finalResponseContents =
+                        NetworkUtility.inputStreamToBytes(
+                                inputStream, httpResponse.getContentLength(), mPool);
+            } catch (IOException e) {
+                onRequestFailed(request, callback, e, requestStartMs, httpResponse, null);
+                return;
+            }
+            onResponseRead(
+                    requestStartMs,
+                    statusCode,
+                    httpResponse,
+                    request,
+                    callback,
+                    responseHeaders,
+                    finalResponseContents);
+        }
+    }
+
+    /**
+     * Builder is used to build an instance of {@link BasicAsyncNetwork} from values configured by
+     * the setters.
+     */
+    public static class Builder {
+        private static final int DEFAULT_POOL_SIZE = 4096;
+        @NonNull private AsyncHttpStack mAsyncStack;
+        private ByteArrayPool mPool;
+
+        public Builder(@NonNull AsyncHttpStack httpStack) {
+            mAsyncStack = httpStack;
+            mPool = null;
+        }
+
+        /**
+         * Sets the ByteArrayPool to be used. If not set, it will default to a pool with the default
+         * pool size.
+         */
+        public Builder setPool(ByteArrayPool pool) {
+            mPool = pool;
+            return this;
+        }
+
+        /** Builds the {@link com.android.volley.toolbox.BasicAsyncNetwork} */
+        public BasicAsyncNetwork build() {
+            if (mPool == null) {
+                mPool = new ByteArrayPool(DEFAULT_POOL_SIZE);
+            }
+            return new BasicAsyncNetwork(mAsyncStack, mPool);
+        }
+    }
+}
diff --git a/src/main/java/com/android/volley/toolbox/BasicNetwork.java b/src/main/java/com/android/volley/toolbox/BasicNetwork.java
index b527cb9..06427fe 100644
--- a/src/main/java/com/android/volley/toolbox/BasicNetwork.java
+++ b/src/main/java/com/android/volley/toolbox/BasicNetwork.java
@@ -17,41 +17,21 @@
 package com.android.volley.toolbox;
 
 import android.os.SystemClock;
-import com.android.volley.AuthFailureError;
-import com.android.volley.Cache;
-import com.android.volley.Cache.Entry;
-import com.android.volley.ClientError;
 import com.android.volley.Header;
 import com.android.volley.Network;
-import com.android.volley.NetworkError;
 import com.android.volley.NetworkResponse;
-import com.android.volley.NoConnectionError;
 import com.android.volley.Request;
-import com.android.volley.RetryPolicy;
-import com.android.volley.ServerError;
-import com.android.volley.TimeoutError;
 import com.android.volley.VolleyError;
-import com.android.volley.VolleyLog;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.HttpURLConnection;
-import java.net.MalformedURLException;
-import java.net.SocketTimeoutException;
-import java.util.ArrayList;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import java.util.TreeMap;
-import java.util.TreeSet;
 
 /** A network performing Volley requests over an {@link HttpStack}. */
 public class BasicNetwork implements Network {
-    protected static final boolean DEBUG = VolleyLog.DEBUG;
-
-    private static final int SLOW_REQUEST_THRESHOLD_MS = 3000;
-
     private static final int DEFAULT_POOL_SIZE = 4096;
 
     /**
@@ -119,37 +99,24 @@
             try {
                 // Gather headers.
                 Map<String, String> additionalRequestHeaders =
-                        getCacheHeaders(request.getCacheEntry());
+                        HttpHeaderParser.getCacheHeaders(request.getCacheEntry());
                 httpResponse = mBaseHttpStack.executeRequest(request, additionalRequestHeaders);
                 int statusCode = httpResponse.getStatusCode();
 
                 responseHeaders = httpResponse.getHeaders();
                 // Handle cache validation.
                 if (statusCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
-                    Entry entry = request.getCacheEntry();
-                    if (entry == null) {
-                        return new NetworkResponse(
-                                HttpURLConnection.HTTP_NOT_MODIFIED,
-                                /* data= */ null,
-                                /* notModified= */ true,
-                                SystemClock.elapsedRealtime() - requestStart,
-                                responseHeaders);
-                    }
-                    // Combine cached and response headers so the response will be complete.
-                    List<Header> combinedHeaders = combineHeaders(responseHeaders, entry);
-                    return new NetworkResponse(
-                            HttpURLConnection.HTTP_NOT_MODIFIED,
-                            entry.data,
-                            /* notModified= */ true,
-                            SystemClock.elapsedRealtime() - requestStart,
-                            combinedHeaders);
+                    long requestDuration = SystemClock.elapsedRealtime() - requestStart;
+                    return NetworkUtility.getNotModifiedNetworkResponse(
+                            request, requestDuration, responseHeaders);
                 }
 
                 // Some responses such as 204s do not have content.  We must check.
                 InputStream inputStream = httpResponse.getContent();
                 if (inputStream != null) {
                     responseContents =
-                            inputStreamToBytes(inputStream, httpResponse.getContentLength());
+                            NetworkUtility.inputStreamToBytes(
+                                    inputStream, httpResponse.getContentLength(), mPool);
                 } else {
                     // Add 0 byte response as a way of honestly representing a
                     // no-content request.
@@ -158,7 +125,8 @@
 
                 // if the request is slow, log it.
                 long requestLifetime = SystemClock.elapsedRealtime() - requestStart;
-                logSlowRequests(requestLifetime, request, responseContents, statusCode);
+                NetworkUtility.logSlowRequests(
+                        requestLifetime, request, responseContents, statusCode);
 
                 if (statusCode < 200 || statusCode > 299) {
                     throw new IOException();
@@ -169,144 +137,15 @@
                         /* notModified= */ false,
                         SystemClock.elapsedRealtime() - requestStart,
                         responseHeaders);
-            } catch (SocketTimeoutException e) {
-                attemptRetryOnException("socket", request, new TimeoutError());
-            } catch (MalformedURLException e) {
-                throw new RuntimeException("Bad URL " + request.getUrl(), e);
             } catch (IOException e) {
-                int statusCode;
-                if (httpResponse != null) {
-                    statusCode = httpResponse.getStatusCode();
-                } else {
-                    throw new NoConnectionError(e);
-                }
-                VolleyLog.e("Unexpected response code %d for %s", statusCode, request.getUrl());
-                NetworkResponse networkResponse;
-                if (responseContents != null) {
-                    networkResponse =
-                            new NetworkResponse(
-                                    statusCode,
-                                    responseContents,
-                                    /* notModified= */ false,
-                                    SystemClock.elapsedRealtime() - requestStart,
-                                    responseHeaders);
-                    if (statusCode == HttpURLConnection.HTTP_UNAUTHORIZED
-                            || statusCode == HttpURLConnection.HTTP_FORBIDDEN) {
-                        attemptRetryOnException(
-                                "auth", request, new AuthFailureError(networkResponse));
-                    } else if (statusCode >= 400 && statusCode <= 499) {
-                        // Don't retry other client errors.
-                        throw new ClientError(networkResponse);
-                    } else if (statusCode >= 500 && statusCode <= 599) {
-                        if (request.shouldRetryServerErrors()) {
-                            attemptRetryOnException(
-                                    "server", request, new ServerError(networkResponse));
-                        } else {
-                            throw new ServerError(networkResponse);
-                        }
-                    } else {
-                        // 3xx? No reason to retry.
-                        throw new ServerError(networkResponse);
-                    }
-                } else {
-                    attemptRetryOnException("network", request, new NetworkError());
-                }
+                // This will either throw an exception, breaking us from the loop, or will loop
+                // again and retry the request.
+                NetworkUtility.handleException(
+                        request, e, requestStart, httpResponse, responseContents);
             }
         }
     }
 
-    /** Logs requests that took over SLOW_REQUEST_THRESHOLD_MS to complete. */
-    private void logSlowRequests(
-            long requestLifetime, Request<?> request, byte[] responseContents, int statusCode) {
-        if (DEBUG || requestLifetime > SLOW_REQUEST_THRESHOLD_MS) {
-            VolleyLog.d(
-                    "HTTP response for request=<%s> [lifetime=%d], [size=%s], "
-                            + "[rc=%d], [retryCount=%s]",
-                    request,
-                    requestLifetime,
-                    responseContents != null ? responseContents.length : "null",
-                    statusCode,
-                    request.getRetryPolicy().getCurrentRetryCount());
-        }
-    }
-
-    /**
-     * Attempts to prepare the request for a retry. If there are no more attempts remaining in the
-     * request's retry policy, a timeout exception is thrown.
-     *
-     * @param request The request to use.
-     */
-    private static void attemptRetryOnException(
-            String logPrefix, Request<?> request, VolleyError exception) throws VolleyError {
-        RetryPolicy retryPolicy = request.getRetryPolicy();
-        int oldTimeout = request.getTimeoutMs();
-
-        try {
-            retryPolicy.retry(exception);
-        } catch (VolleyError e) {
-            request.addMarker(
-                    String.format("%s-timeout-giveup [timeout=%s]", logPrefix, oldTimeout));
-            throw e;
-        }
-        request.addMarker(String.format("%s-retry [timeout=%s]", logPrefix, oldTimeout));
-    }
-
-    private Map<String, String> getCacheHeaders(Cache.Entry entry) {
-        // If there's no cache entry, we're done.
-        if (entry == null) {
-            return Collections.emptyMap();
-        }
-
-        Map<String, String> headers = new HashMap<>();
-
-        if (entry.etag != null) {
-            headers.put("If-None-Match", entry.etag);
-        }
-
-        if (entry.lastModified > 0) {
-            headers.put(
-                    "If-Modified-Since", HttpHeaderParser.formatEpochAsRfc1123(entry.lastModified));
-        }
-
-        return headers;
-    }
-
-    protected void logError(String what, String url, long start) {
-        long now = SystemClock.elapsedRealtime();
-        VolleyLog.v("HTTP ERROR(%s) %d ms to fetch %s", what, (now - start), url);
-    }
-
-    /** Reads the contents of an InputStream into a byte[]. */
-    private byte[] inputStreamToBytes(InputStream in, int contentLength)
-            throws IOException, ServerError {
-        PoolingByteArrayOutputStream bytes = new PoolingByteArrayOutputStream(mPool, contentLength);
-        byte[] buffer = null;
-        try {
-            if (in == null) {
-                throw new ServerError();
-            }
-            buffer = mPool.getBuf(1024);
-            int count;
-            while ((count = in.read(buffer)) != -1) {
-                bytes.write(buffer, 0, count);
-            }
-            return bytes.toByteArray();
-        } finally {
-            try {
-                // Close the InputStream and release the resources by "consuming the content".
-                if (in != null) {
-                    in.close();
-                }
-            } catch (IOException e) {
-                // This can happen if there was an exception above that left the stream in
-                // an invalid state.
-                VolleyLog.v("Error occurred when closing InputStream");
-            }
-            mPool.returnBuf(buffer);
-            bytes.close();
-        }
-    }
-
     /**
      * Converts Headers[] to Map&lt;String, String&gt;.
      *
@@ -321,49 +160,4 @@
         }
         return result;
     }
-
-    /**
-     * Combine cache headers with network response headers for an HTTP 304 response.
-     *
-     * <p>An HTTP 304 response does not have all header fields. We have to use the header fields
-     * from the cache entry plus the new ones from the response. See also:
-     * http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5
-     *
-     * @param responseHeaders Headers from the network response.
-     * @param entry The cached response.
-     * @return The combined list of headers.
-     */
-    private static List<Header> combineHeaders(List<Header> responseHeaders, Entry entry) {
-        // First, create a case-insensitive set of header names from the network
-        // response.
-        Set<String> headerNamesFromNetworkResponse = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
-        if (!responseHeaders.isEmpty()) {
-            for (Header header : responseHeaders) {
-                headerNamesFromNetworkResponse.add(header.getName());
-            }
-        }
-
-        // Second, add headers from the cache entry to the network response as long as
-        // they didn't appear in the network response, which should take precedence.
-        List<Header> combinedHeaders = new ArrayList<>(responseHeaders);
-        if (entry.allResponseHeaders != null) {
-            if (!entry.allResponseHeaders.isEmpty()) {
-                for (Header header : entry.allResponseHeaders) {
-                    if (!headerNamesFromNetworkResponse.contains(header.getName())) {
-                        combinedHeaders.add(header);
-                    }
-                }
-            }
-        } else {
-            // Legacy caches only have entry.responseHeaders.
-            if (!entry.responseHeaders.isEmpty()) {
-                for (Map.Entry<String, String> header : entry.responseHeaders.entrySet()) {
-                    if (!headerNamesFromNetworkResponse.contains(header.getKey())) {
-                        combinedHeaders.add(new Header(header.getKey(), header.getValue()));
-                    }
-                }
-            }
-        }
-        return combinedHeaders;
-    }
 }
diff --git a/src/main/java/com/android/volley/toolbox/DiskBasedCache.java b/src/main/java/com/android/volley/toolbox/DiskBasedCache.java
index a6a0c83..d4310e0 100644
--- a/src/main/java/com/android/volley/toolbox/DiskBasedCache.java
+++ b/src/main/java/com/android/volley/toolbox/DiskBasedCache.java
@@ -55,8 +55,8 @@
     /** Total amount of space currently used by the cache in bytes. */
     private long mTotalSize = 0;
 
-    /** The root directory to use for the cache. */
-    private final File mRootDirectory;
+    /** The supplier for the root directory to use for the cache. */
+    private final FileSupplier mRootDirectorySupplier;
 
     /** The maximum size of the cache in bytes. */
     private final int mMaxCacheSizeInBytes;
@@ -78,8 +78,27 @@
      *     briefly exceed this size on disk when writing a new entry that pushes it over the limit
      *     until the ensuing pruning completes.
      */
-    public DiskBasedCache(File rootDirectory, int maxCacheSizeInBytes) {
-        mRootDirectory = rootDirectory;
+    public DiskBasedCache(final File rootDirectory, int maxCacheSizeInBytes) {
+        mRootDirectorySupplier =
+                new FileSupplier() {
+                    @Override
+                    public File get() {
+                        return rootDirectory;
+                    }
+                };
+        mMaxCacheSizeInBytes = maxCacheSizeInBytes;
+    }
+
+    /**
+     * Constructs an instance of the DiskBasedCache at the specified directory.
+     *
+     * @param rootDirectorySupplier The supplier for the root directory of the cache.
+     * @param maxCacheSizeInBytes The maximum size of the cache in bytes. Note that the cache may
+     *     briefly exceed this size on disk when writing a new entry that pushes it over the limit
+     *     until the ensuing pruning completes.
+     */
+    public DiskBasedCache(FileSupplier rootDirectorySupplier, int maxCacheSizeInBytes) {
+        mRootDirectorySupplier = rootDirectorySupplier;
         mMaxCacheSizeInBytes = maxCacheSizeInBytes;
     }
 
@@ -93,10 +112,20 @@
         this(rootDirectory, DEFAULT_DISK_USAGE_BYTES);
     }
 
+    /**
+     * Constructs an instance of the DiskBasedCache at the specified directory using the default
+     * maximum cache size of 5MB.
+     *
+     * @param rootDirectorySupplier The supplier for the root directory of the cache.
+     */
+    public DiskBasedCache(FileSupplier rootDirectorySupplier) {
+        this(rootDirectorySupplier, DEFAULT_DISK_USAGE_BYTES);
+    }
+
     /** Clears the cache. Deletes all cached files from disk. */
     @Override
     public synchronized void clear() {
-        File[] files = mRootDirectory.listFiles();
+        File[] files = mRootDirectorySupplier.get().listFiles();
         if (files != null) {
             for (File file : files) {
                 file.delete();
@@ -150,13 +179,14 @@
      */
     @Override
     public synchronized void initialize() {
-        if (!mRootDirectory.exists()) {
-            if (!mRootDirectory.mkdirs()) {
-                VolleyLog.e("Unable to create cache dir %s", mRootDirectory.getAbsolutePath());
+        File rootDirectory = mRootDirectorySupplier.get();
+        if (!rootDirectory.exists()) {
+            if (!rootDirectory.mkdirs()) {
+                VolleyLog.e("Unable to create cache dir %s", rootDirectory.getAbsolutePath());
             }
             return;
         }
-        File[] files = mRootDirectory.listFiles();
+        File[] files = rootDirectory.listFiles();
         if (files == null) {
             return;
         }
@@ -226,12 +256,12 @@
             e.size = file.length();
             putEntry(key, e);
             pruneIfNeeded();
-            return;
         } catch (IOException e) {
-        }
-        boolean deleted = file.delete();
-        if (!deleted) {
-            VolleyLog.d("Could not clean up file %s", file.getAbsolutePath());
+            boolean deleted = file.delete();
+            if (!deleted) {
+                VolleyLog.d("Could not clean up file %s", file.getAbsolutePath());
+            }
+            initializeIfRootDirectoryDeleted();
         }
     }
 
@@ -262,7 +292,22 @@
 
     /** Returns a file object for the given cache key. */
     public File getFileForKey(String key) {
-        return new File(mRootDirectory, getFilenameForKey(key));
+        return new File(mRootDirectorySupplier.get(), getFilenameForKey(key));
+    }
+
+    /** Re-initialize the cache if the directory was deleted. */
+    private void initializeIfRootDirectoryDeleted() {
+        if (!mRootDirectorySupplier.get().exists()) {
+            VolleyLog.d("Re-initializing cache after external clearing.");
+            mEntries.clear();
+            mTotalSize = 0;
+            initialize();
+        }
+    }
+
+    /** Represents a supplier for {@link File}s. */
+    public interface FileSupplier {
+        File get();
     }
 
     /** Prunes the cache to fit the maximum size. */
diff --git a/src/main/java/com/android/volley/toolbox/FileSupplier.java b/src/main/java/com/android/volley/toolbox/FileSupplier.java
new file mode 100644
index 0000000..70898a6
--- /dev/null
+++ b/src/main/java/com/android/volley/toolbox/FileSupplier.java
@@ -0,0 +1,24 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.toolbox;
+
+import java.io.File;
+
+/** Represents a supplier for {@link File}s. */
+public interface FileSupplier {
+    File get();
+}
diff --git a/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java b/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java
index 27d1268..0b29e80 100644
--- a/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java
+++ b/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java
@@ -16,6 +16,9 @@
 
 package com.android.volley.toolbox;
 
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
 import com.android.volley.Cache;
 import com.android.volley.Header;
 import com.android.volley.NetworkResponse;
@@ -23,21 +26,30 @@
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.TimeZone;
 import java.util.TreeMap;
+import java.util.TreeSet;
 
 /** Utility methods for parsing HTTP headers. */
 public class HttpHeaderParser {
 
-    static final String HEADER_CONTENT_TYPE = "Content-Type";
+    @RestrictTo({Scope.LIBRARY_GROUP})
+    public static final String HEADER_CONTENT_TYPE = "Content-Type";
 
     private static final String DEFAULT_CONTENT_CHARSET = "ISO-8859-1";
 
-    private static final String RFC1123_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
+    private static final String RFC1123_PARSE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz";
+
+    // Hardcode 'GMT' rather than using 'zzz' since some platforms append an extraneous +00:00.
+    // See #287.
+    private static final String RFC1123_OUTPUT_FORMAT = "EEE, dd MMM yyyy HH:mm:ss 'GMT'";
 
     /**
      * Extracts a {@link com.android.volley.Cache.Entry} from a {@link NetworkResponse}.
@@ -45,10 +57,14 @@
      * @param response The network response to parse headers from
      * @return a cache entry for the given response, or null if the response is not cacheable.
      */
+    @Nullable
     public static Cache.Entry parseCacheHeaders(NetworkResponse response) {
         long now = System.currentTimeMillis();
 
         Map<String, String> headers = response.headers;
+        if (headers == null) {
+            return null;
+        }
 
         long serverDate = 0;
         long lastModified = 0;
@@ -132,21 +148,29 @@
     public static long parseDateAsEpoch(String dateStr) {
         try {
             // Parse date in RFC1123 format if this header contains one
-            return newRfc1123Formatter().parse(dateStr).getTime();
+            return newUsGmtFormatter(RFC1123_PARSE_FORMAT).parse(dateStr).getTime();
         } catch (ParseException e) {
             // Date in invalid format, fallback to 0
-            VolleyLog.e(e, "Unable to parse dateStr: %s, falling back to 0", dateStr);
+            // If the value is either "0" or "-1" we only log to verbose,
+            // these values are pretty common and cause log spam.
+            String message = "Unable to parse dateStr: %s, falling back to 0";
+            if ("0".equals(dateStr) || "-1".equals(dateStr)) {
+                VolleyLog.v(message, dateStr);
+            } else {
+                VolleyLog.e(e, message, dateStr);
+            }
+
             return 0;
         }
     }
 
     /** Format an epoch date in RFC1123 format. */
     static String formatEpochAsRfc1123(long epoch) {
-        return newRfc1123Formatter().format(new Date(epoch));
+        return newUsGmtFormatter(RFC1123_OUTPUT_FORMAT).format(new Date(epoch));
     }
 
-    private static SimpleDateFormat newRfc1123Formatter() {
-        SimpleDateFormat formatter = new SimpleDateFormat(RFC1123_FORMAT, Locale.US);
+    private static SimpleDateFormat newUsGmtFormatter(String format) {
+        SimpleDateFormat formatter = new SimpleDateFormat(format, Locale.US);
         formatter.setTimeZone(TimeZone.getTimeZone("GMT"));
         return formatter;
     }
@@ -159,7 +183,11 @@
      * @return Returns the charset specified in the Content-Type of this header, or the
      *     defaultCharset if none can be found.
      */
-    public static String parseCharset(Map<String, String> headers, String defaultCharset) {
+    public static String parseCharset(
+            @Nullable Map<String, String> headers, String defaultCharset) {
+        if (headers == null) {
+            return defaultCharset;
+        }
         String contentType = headers.get(HEADER_CONTENT_TYPE);
         if (contentType != null) {
             String[] params = contentType.split(";", 0);
@@ -180,7 +208,7 @@
      * Returns the charset specified in the Content-Type of this header, or the HTTP default
      * (ISO-8859-1) if none can be found.
      */
-    public static String parseCharset(Map<String, String> headers) {
+    public static String parseCharset(@Nullable Map<String, String> headers) {
         return parseCharset(headers, DEFAULT_CONTENT_CHARSET);
     }
 
@@ -205,4 +233,69 @@
         }
         return allHeaders;
     }
+
+    /**
+     * Combine cache headers with network response headers for an HTTP 304 response.
+     *
+     * <p>An HTTP 304 response does not have all header fields. We have to use the header fields
+     * from the cache entry plus the new ones from the response. See also:
+     * http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5
+     *
+     * @param responseHeaders Headers from the network response.
+     * @param entry The cached response.
+     * @return The combined list of headers.
+     */
+    static List<Header> combineHeaders(List<Header> responseHeaders, Cache.Entry entry) {
+        // First, create a case-insensitive set of header names from the network
+        // response.
+        Set<String> headerNamesFromNetworkResponse = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+        if (!responseHeaders.isEmpty()) {
+            for (Header header : responseHeaders) {
+                headerNamesFromNetworkResponse.add(header.getName());
+            }
+        }
+
+        // Second, add headers from the cache entry to the network response as long as
+        // they didn't appear in the network response, which should take precedence.
+        List<Header> combinedHeaders = new ArrayList<>(responseHeaders);
+        if (entry.allResponseHeaders != null) {
+            if (!entry.allResponseHeaders.isEmpty()) {
+                for (Header header : entry.allResponseHeaders) {
+                    if (!headerNamesFromNetworkResponse.contains(header.getName())) {
+                        combinedHeaders.add(header);
+                    }
+                }
+            }
+        } else {
+            // Legacy caches only have entry.responseHeaders.
+            if (!entry.responseHeaders.isEmpty()) {
+                for (Map.Entry<String, String> header : entry.responseHeaders.entrySet()) {
+                    if (!headerNamesFromNetworkResponse.contains(header.getKey())) {
+                        combinedHeaders.add(new Header(header.getKey(), header.getValue()));
+                    }
+                }
+            }
+        }
+        return combinedHeaders;
+    }
+
+    static Map<String, String> getCacheHeaders(Cache.Entry entry) {
+        // If there's no cache entry, we're done.
+        if (entry == null) {
+            return Collections.emptyMap();
+        }
+
+        Map<String, String> headers = new HashMap<>();
+
+        if (entry.etag != null) {
+            headers.put("If-None-Match", entry.etag);
+        }
+
+        if (entry.lastModified > 0) {
+            headers.put(
+                    "If-Modified-Since", HttpHeaderParser.formatEpochAsRfc1123(entry.lastModified));
+        }
+
+        return headers;
+    }
 }
diff --git a/src/main/java/com/android/volley/toolbox/HttpResponse.java b/src/main/java/com/android/volley/toolbox/HttpResponse.java
index 9a9294f..595f926 100644
--- a/src/main/java/com/android/volley/toolbox/HttpResponse.java
+++ b/src/main/java/com/android/volley/toolbox/HttpResponse.java
@@ -15,7 +15,9 @@
  */
 package com.android.volley.toolbox;
 
+import androidx.annotation.Nullable;
 import com.android.volley.Header;
+import java.io.ByteArrayInputStream;
 import java.io.InputStream;
 import java.util.Collections;
 import java.util.List;
@@ -26,7 +28,8 @@
     private final int mStatusCode;
     private final List<Header> mHeaders;
     private final int mContentLength;
-    private final InputStream mContent;
+    @Nullable private final InputStream mContent;
+    @Nullable private final byte[] mContentBytes;
 
     /**
      * Construct a new HttpResponse for an empty response body.
@@ -53,6 +56,23 @@
         mHeaders = headers;
         mContentLength = contentLength;
         mContent = content;
+        mContentBytes = null;
+    }
+
+    /**
+     * Construct a new HttpResponse.
+     *
+     * @param statusCode the HTTP status code of the response
+     * @param headers the response headers
+     * @param contentBytes a byte[] of the response content. This is an optimization for HTTP stacks
+     *     that natively support returning a byte[].
+     */
+    public HttpResponse(int statusCode, List<Header> headers, byte[] contentBytes) {
+        mStatusCode = statusCode;
+        mHeaders = headers;
+        mContentLength = contentBytes.length;
+        mContentBytes = contentBytes;
+        mContent = null;
     }
 
     /** Returns the HTTP status code of the response. */
@@ -71,10 +91,28 @@
     }
 
     /**
+     * If a byte[] was already provided by an HTTP stack that natively supports returning one, this
+     * method will return that byte[] as an optimization over copying the bytes from an input
+     * stream. It may return null, even if the response has content, as long as mContent is
+     * provided.
+     */
+    @Nullable
+    public final byte[] getContentBytes() {
+        return mContentBytes;
+    }
+
+    /**
      * Returns an {@link InputStream} of the response content. May be null to indicate that the
      * response has no content.
      */
+    @Nullable
     public final InputStream getContent() {
-        return mContent;
+        if (mContent != null) {
+            return mContent;
+        } else if (mContentBytes != null) {
+            return new ByteArrayInputStream(mContentBytes);
+        } else {
+            return null;
+        }
     }
 }
diff --git a/src/main/java/com/android/volley/toolbox/HurlStack.java b/src/main/java/com/android/volley/toolbox/HurlStack.java
index f85d42c..35c6a72 100644
--- a/src/main/java/com/android/volley/toolbox/HurlStack.java
+++ b/src/main/java/com/android/volley/toolbox/HurlStack.java
@@ -25,6 +25,7 @@
 import java.io.FilterInputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.net.HttpURLConnection;
 import java.net.URL;
 import java.util.ArrayList;
@@ -40,13 +41,7 @@
     private static final int HTTP_CONTINUE = 100;
 
     /** An interface for transforming URLs before use. */
-    public interface UrlRewriter {
-        /**
-         * Returns a URL to use instead of the provided one, or null to indicate this URL should not
-         * be used at all.
-         */
-        String rewriteUrl(String originalUrl);
-    }
+    public interface UrlRewriter extends com.android.volley.toolbox.UrlRewriter {}
 
     private final UrlRewriter mUrlRewriter;
     private final SSLSocketFactory mSslSocketFactory;
@@ -111,7 +106,7 @@
                     responseCode,
                     convertHeaders(connection.getHeaderFields()),
                     connection.getContentLength(),
-                    new UrlConnectionInputStream(connection));
+                    createInputStream(request, connection));
         } finally {
             if (!keepConnectionOpen) {
                 connection.disconnect();
@@ -169,6 +164,19 @@
     }
 
     /**
+     * Create and return an InputStream from which the response will be read.
+     *
+     * <p>May be overridden by subclasses to manipulate or monitor this input stream.
+     *
+     * @param request current request.
+     * @param connection current connection of request.
+     * @return an InputStream from which the response will be read.
+     */
+    protected InputStream createInputStream(Request<?> request, HttpURLConnection connection) {
+        return new UrlConnectionInputStream(connection);
+    }
+
+    /**
      * Initializes an {@link InputStream} from the given {@link HttpURLConnection}.
      *
      * @param connection
@@ -223,7 +231,7 @@
     // 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(
+    /* package */ void setConnectionParametersForRequest(
             HttpURLConnection connection, Request<?> request) throws IOException, AuthFailureError {
         switch (request.getMethod()) {
             case Method.DEPRECATED_GET_OR_POST:
@@ -270,7 +278,7 @@
         }
     }
 
-    private static void addBodyIfExists(HttpURLConnection connection, Request<?> request)
+    private void addBodyIfExists(HttpURLConnection connection, Request<?> request)
             throws IOException, AuthFailureError {
         byte[] body = request.getBody();
         if (body != null) {
@@ -278,7 +286,7 @@
         }
     }
 
-    private static void addBody(HttpURLConnection connection, Request<?> request, byte[] body)
+    private void addBody(HttpURLConnection connection, Request<?> request, byte[] body)
             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
@@ -289,8 +297,25 @@
             connection.setRequestProperty(
                     HttpHeaderParser.HEADER_CONTENT_TYPE, request.getBodyContentType());
         }
-        DataOutputStream out = new DataOutputStream(connection.getOutputStream());
+        DataOutputStream out =
+                new DataOutputStream(createOutputStream(request, connection, body.length));
         out.write(body);
         out.close();
     }
+
+    /**
+     * Create and return an OutputStream to which the request body will be written.
+     *
+     * <p>May be overridden by subclasses to manipulate or monitor this output stream.
+     *
+     * @param request current request.
+     * @param connection current connection of request.
+     * @param length size of stream to write.
+     * @return an OutputStream to which the request body will be written.
+     * @throws IOException if an I/O error occurs while creating the stream.
+     */
+    protected OutputStream createOutputStream(
+            Request<?> request, HttpURLConnection connection, int length) throws IOException {
+        return connection.getOutputStream();
+    }
 }
diff --git a/src/main/java/com/android/volley/toolbox/ImageLoader.java b/src/main/java/com/android/volley/toolbox/ImageLoader.java
index b80072b..eece2cf 100644
--- a/src/main/java/com/android/volley/toolbox/ImageLoader.java
+++ b/src/main/java/com/android/volley/toolbox/ImageLoader.java
@@ -20,6 +20,7 @@
 import android.widget.ImageView;
 import android.widget.ImageView.ScaleType;
 import androidx.annotation.MainThread;
+import androidx.annotation.Nullable;
 import com.android.volley.Request;
 import com.android.volley.RequestQueue;
 import com.android.volley.Response.ErrorListener;
@@ -70,6 +71,7 @@
      * LruCache is recommended.
      */
     public interface ImageCache {
+        @Nullable
         Bitmap getBitmap(String url);
 
         void putBitmap(String url, Bitmap bitmap);
diff --git a/src/main/java/com/android/volley/toolbox/NetworkUtility.java b/src/main/java/com/android/volley/toolbox/NetworkUtility.java
new file mode 100644
index 0000000..44d5904
--- /dev/null
+++ b/src/main/java/com/android/volley/toolbox/NetworkUtility.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.toolbox;
+
+import android.os.SystemClock;
+import androidx.annotation.Nullable;
+import com.android.volley.AuthFailureError;
+import com.android.volley.Cache;
+import com.android.volley.ClientError;
+import com.android.volley.Header;
+import com.android.volley.NetworkError;
+import com.android.volley.NetworkResponse;
+import com.android.volley.NoConnectionError;
+import com.android.volley.Request;
+import com.android.volley.RetryPolicy;
+import com.android.volley.ServerError;
+import com.android.volley.TimeoutError;
+import com.android.volley.VolleyError;
+import com.android.volley.VolleyLog;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.SocketTimeoutException;
+import java.util.List;
+
+/**
+ * Utility class for methods that are shared between {@link BasicNetwork} and {@link
+ * BasicAsyncNetwork}
+ */
+public final class NetworkUtility {
+    private static final int SLOW_REQUEST_THRESHOLD_MS = 3000;
+
+    private NetworkUtility() {}
+
+    /** Logs requests that took over SLOW_REQUEST_THRESHOLD_MS to complete. */
+    static void logSlowRequests(
+            long requestLifetime, Request<?> request, byte[] responseContents, int statusCode) {
+        if (VolleyLog.DEBUG || requestLifetime > SLOW_REQUEST_THRESHOLD_MS) {
+            VolleyLog.d(
+                    "HTTP response for request=<%s> [lifetime=%d], [size=%s], "
+                            + "[rc=%d], [retryCount=%s]",
+                    request,
+                    requestLifetime,
+                    responseContents != null ? responseContents.length : "null",
+                    statusCode,
+                    request.getRetryPolicy().getCurrentRetryCount());
+        }
+    }
+
+    static NetworkResponse getNotModifiedNetworkResponse(
+            Request<?> request, long requestDuration, List<Header> responseHeaders) {
+        Cache.Entry entry = request.getCacheEntry();
+        if (entry == null) {
+            return new NetworkResponse(
+                    HttpURLConnection.HTTP_NOT_MODIFIED,
+                    /* data= */ null,
+                    /* notModified= */ true,
+                    requestDuration,
+                    responseHeaders);
+        }
+        // Combine cached and response headers so the response will be complete.
+        List<Header> combinedHeaders = HttpHeaderParser.combineHeaders(responseHeaders, entry);
+        return new NetworkResponse(
+                HttpURLConnection.HTTP_NOT_MODIFIED,
+                entry.data,
+                /* notModified= */ true,
+                requestDuration,
+                combinedHeaders);
+    }
+
+    /** Reads the contents of an InputStream into a byte[]. */
+    static byte[] inputStreamToBytes(InputStream in, int contentLength, ByteArrayPool pool)
+            throws IOException {
+        PoolingByteArrayOutputStream bytes = new PoolingByteArrayOutputStream(pool, contentLength);
+        byte[] buffer = null;
+        try {
+            buffer = pool.getBuf(1024);
+            int count;
+            while ((count = in.read(buffer)) != -1) {
+                bytes.write(buffer, 0, count);
+            }
+            return bytes.toByteArray();
+        } finally {
+            try {
+                // Close the InputStream and release the resources by "consuming the content".
+                if (in != null) {
+                    in.close();
+                }
+            } catch (IOException e) {
+                // This can happen if there was an exception above that left the stream in
+                // an invalid state.
+                VolleyLog.v("Error occurred when closing InputStream");
+            }
+            pool.returnBuf(buffer);
+            bytes.close();
+        }
+    }
+
+    /**
+     * Attempts to prepare the request for a retry. If there are no more attempts remaining in the
+     * request's retry policy, a timeout exception is thrown.
+     *
+     * @param request The request to use.
+     */
+    private static void attemptRetryOnException(
+            final String logPrefix, final Request<?> request, final VolleyError exception)
+            throws VolleyError {
+        final RetryPolicy retryPolicy = request.getRetryPolicy();
+        final int oldTimeout = request.getTimeoutMs();
+        try {
+            retryPolicy.retry(exception);
+        } catch (VolleyError e) {
+            request.addMarker(
+                    String.format("%s-timeout-giveup [timeout=%s]", logPrefix, oldTimeout));
+            throw e;
+        }
+        request.addMarker(String.format("%s-retry [timeout=%s]", logPrefix, oldTimeout));
+    }
+
+    /**
+     * Based on the exception thrown, decides whether to attempt to retry, or to throw the error.
+     * Also handles logging.
+     */
+    static void handleException(
+            Request<?> request,
+            IOException exception,
+            long requestStartMs,
+            @Nullable HttpResponse httpResponse,
+            @Nullable byte[] responseContents)
+            throws VolleyError {
+        if (exception instanceof SocketTimeoutException) {
+            attemptRetryOnException("socket", request, new TimeoutError());
+        } else if (exception instanceof MalformedURLException) {
+            throw new RuntimeException("Bad URL " + request.getUrl(), exception);
+        } else {
+            int statusCode;
+            if (httpResponse != null) {
+                statusCode = httpResponse.getStatusCode();
+            } else {
+                if (request.shouldRetryConnectionErrors()) {
+                    attemptRetryOnException("connection", request, new NoConnectionError());
+                    return;
+                } else {
+                    throw new NoConnectionError(exception);
+                }
+            }
+            VolleyLog.e("Unexpected response code %d for %s", statusCode, request.getUrl());
+            NetworkResponse networkResponse;
+            if (responseContents != null) {
+                List<Header> responseHeaders;
+                responseHeaders = httpResponse.getHeaders();
+                networkResponse =
+                        new NetworkResponse(
+                                statusCode,
+                                responseContents,
+                                /* notModified= */ false,
+                                SystemClock.elapsedRealtime() - requestStartMs,
+                                responseHeaders);
+                if (statusCode == HttpURLConnection.HTTP_UNAUTHORIZED
+                        || statusCode == HttpURLConnection.HTTP_FORBIDDEN) {
+                    attemptRetryOnException("auth", request, new AuthFailureError(networkResponse));
+                } else if (statusCode >= 400 && statusCode <= 499) {
+                    // Don't retry other client errors.
+                    throw new ClientError(networkResponse);
+                } else if (statusCode >= 500 && statusCode <= 599) {
+                    if (request.shouldRetryServerErrors()) {
+                        attemptRetryOnException(
+                                "server", request, new ServerError(networkResponse));
+                    } else {
+                        throw new ServerError(networkResponse);
+                    }
+                } else {
+                    // 3xx? No reason to retry.
+                    throw new ServerError(networkResponse);
+                }
+            } else {
+                attemptRetryOnException("network", request, new NetworkError());
+            }
+        }
+    }
+}
diff --git a/src/main/java/com/android/volley/toolbox/NoAsyncCache.java b/src/main/java/com/android/volley/toolbox/NoAsyncCache.java
new file mode 100644
index 0000000..aa4aeea
--- /dev/null
+++ b/src/main/java/com/android/volley/toolbox/NoAsyncCache.java
@@ -0,0 +1,37 @@
+package com.android.volley.toolbox;
+
+import com.android.volley.AsyncCache;
+import com.android.volley.Cache;
+
+/** An AsyncCache that doesn't cache anything. */
+public class NoAsyncCache extends AsyncCache {
+    @Override
+    public void get(String key, OnGetCompleteCallback callback) {
+        callback.onGetComplete(null);
+    }
+
+    @Override
+    public void put(String key, Cache.Entry entry, OnWriteCompleteCallback callback) {
+        callback.onWriteComplete();
+    }
+
+    @Override
+    public void clear(OnWriteCompleteCallback callback) {
+        callback.onWriteComplete();
+    }
+
+    @Override
+    public void initialize(OnWriteCompleteCallback callback) {
+        callback.onWriteComplete();
+    }
+
+    @Override
+    public void invalidate(String key, boolean fullExpire, OnWriteCompleteCallback callback) {
+        callback.onWriteComplete();
+    }
+
+    @Override
+    public void remove(String key, OnWriteCompleteCallback callback) {
+        callback.onWriteComplete();
+    }
+}
diff --git a/src/main/java/com/android/volley/toolbox/UrlRewriter.java b/src/main/java/com/android/volley/toolbox/UrlRewriter.java
new file mode 100644
index 0000000..8bbb770
--- /dev/null
+++ b/src/main/java/com/android/volley/toolbox/UrlRewriter.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.toolbox;
+
+import androidx.annotation.Nullable;
+
+/** An interface for transforming URLs before use. */
+public interface UrlRewriter {
+    /**
+     * Returns a URL to use instead of the provided one, or null to indicate this URL should not be
+     * used at all.
+     */
+    @Nullable
+    String rewriteUrl(String originalUrl);
+}
diff --git a/src/main/java/com/android/volley/toolbox/Volley.java b/src/main/java/com/android/volley/toolbox/Volley.java
index 1982802..bc65c9c 100644
--- a/src/main/java/com/android/volley/toolbox/Volley.java
+++ b/src/main/java/com/android/volley/toolbox/Volley.java
@@ -86,8 +86,22 @@
     }
 
     private static RequestQueue newRequestQueue(Context context, Network network) {
-        File cacheDir = new File(context.getCacheDir(), DEFAULT_CACHE_DIR);
-        RequestQueue queue = new RequestQueue(new DiskBasedCache(cacheDir), network);
+        final Context appContext = context.getApplicationContext();
+        // Use a lazy supplier for the cache directory so that newRequestQueue() can be called on
+        // main thread without causing strict mode violation.
+        DiskBasedCache.FileSupplier cacheSupplier =
+                new DiskBasedCache.FileSupplier() {
+                    private File cacheDir = null;
+
+                    @Override
+                    public File get() {
+                        if (cacheDir == null) {
+                            cacheDir = new File(appContext.getCacheDir(), DEFAULT_CACHE_DIR);
+                        }
+                        return cacheDir;
+                    }
+                };
+        RequestQueue queue = new RequestQueue(new DiskBasedCache(cacheSupplier), network);
         queue.start();
         return queue;
     }
diff --git a/src/test/java/com/android/volley/AsyncRequestQueueTest.java b/src/test/java/com/android/volley/AsyncRequestQueueTest.java
new file mode 100644
index 0000000..54ff0a1
--- /dev/null
+++ b/src/test/java/com/android/volley/AsyncRequestQueueTest.java
@@ -0,0 +1,164 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import com.android.volley.mock.ShadowSystemClock;
+import com.android.volley.toolbox.NoAsyncCache;
+import com.android.volley.toolbox.StringRequest;
+import com.android.volley.utils.ImmediateResponseDelivery;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+/** Unit tests for AsyncRequestQueue, with all dependencies mocked out */
+@RunWith(RobolectricTestRunner.class)
+@Config(shadows = {ShadowSystemClock.class})
+public class AsyncRequestQueueTest {
+
+    @Mock private AsyncNetwork mMockNetwork;
+    @Mock private ScheduledExecutorService mMockScheduledExecutor;
+    private AsyncRequestQueue queue;
+
+    @Before
+    public void setUp() throws Exception {
+        ResponseDelivery mDelivery = new ImmediateResponseDelivery();
+        initMocks(this);
+        queue =
+                new AsyncRequestQueue.Builder(mMockNetwork)
+                        .setAsyncCache(new NoAsyncCache())
+                        .setResponseDelivery(mDelivery)
+                        .setExecutorFactory(
+                                new AsyncRequestQueue.ExecutorFactory() {
+                                    @Override
+                                    public ExecutorService createNonBlockingExecutor(
+                                            BlockingQueue<Runnable> taskQueue) {
+                                        return MoreExecutors.newDirectExecutorService();
+                                    }
+
+                                    @Override
+                                    public ExecutorService createBlockingExecutor(
+                                            BlockingQueue<Runnable> taskQueue) {
+                                        return MoreExecutors.newDirectExecutorService();
+                                    }
+
+                                    @Override
+                                    public ScheduledExecutorService
+                                            createNonBlockingScheduledExecutor() {
+                                        return mMockScheduledExecutor;
+                                    }
+                                })
+                        .build();
+    }
+
+    @Test
+    public void cancelAll_onlyCorrectTag() throws Exception {
+        queue.start();
+        Object tagA = new Object();
+        Object tagB = new Object();
+        StringRequest req1 = mock(StringRequest.class);
+        when(req1.getTag()).thenReturn(tagA);
+        StringRequest req2 = mock(StringRequest.class);
+        when(req2.getTag()).thenReturn(tagB);
+        StringRequest req3 = mock(StringRequest.class);
+        when(req3.getTag()).thenReturn(tagA);
+        StringRequest req4 = mock(StringRequest.class);
+        when(req4.getTag()).thenReturn(tagA);
+
+        queue.add(req1); // A
+        queue.add(req2); // B
+        queue.add(req3); // A
+        queue.cancelAll(tagA);
+        queue.add(req4); // A
+
+        verify(req1).cancel(); // A cancelled
+        verify(req3).cancel(); // A cancelled
+        verify(req2, never()).cancel(); // B not cancelled
+        verify(req4, never()).cancel(); // A added after cancel not cancelled
+        queue.stop();
+    }
+
+    @Test
+    public void add_notifiesListener() throws Exception {
+        RequestQueue.RequestEventListener listener = mock(RequestQueue.RequestEventListener.class);
+        queue.start();
+        queue.addRequestEventListener(listener);
+        StringRequest req = mock(StringRequest.class);
+
+        queue.add(req);
+
+        verify(listener).onRequestEvent(req, RequestQueue.RequestEvent.REQUEST_QUEUED);
+        verifyNoMoreInteractions(listener);
+        queue.stop();
+    }
+
+    @Test
+    public void finish_notifiesListener() throws Exception {
+        RequestQueue.RequestEventListener listener = mock(RequestQueue.RequestEventListener.class);
+        queue.start();
+        queue.addRequestEventListener(listener);
+        StringRequest req = mock(StringRequest.class);
+
+        queue.finish(req);
+
+        verify(listener).onRequestEvent(req, RequestQueue.RequestEvent.REQUEST_FINISHED);
+        verifyNoMoreInteractions(listener);
+        queue.stop();
+    }
+
+    @Test
+    public void sendRequestEvent_notifiesListener() throws Exception {
+        StringRequest req = mock(StringRequest.class);
+        RequestQueue.RequestEventListener listener = mock(RequestQueue.RequestEventListener.class);
+        queue.start();
+        queue.addRequestEventListener(listener);
+
+        queue.sendRequestEvent(req, RequestQueue.RequestEvent.REQUEST_NETWORK_DISPATCH_STARTED);
+
+        verify(listener)
+                .onRequestEvent(req, RequestQueue.RequestEvent.REQUEST_NETWORK_DISPATCH_STARTED);
+        verifyNoMoreInteractions(listener);
+        queue.stop();
+    }
+
+    @Test
+    public void removeRequestEventListener_removesListener() throws Exception {
+        StringRequest req = mock(StringRequest.class);
+        RequestQueue.RequestEventListener listener = mock(RequestQueue.RequestEventListener.class);
+        queue.start();
+        queue.addRequestEventListener(listener);
+        queue.removeRequestEventListener(listener);
+
+        queue.sendRequestEvent(req, RequestQueue.RequestEvent.REQUEST_NETWORK_DISPATCH_STARTED);
+
+        verifyNoMoreInteractions(listener);
+        queue.stop();
+    }
+}
diff --git a/src/test/java/com/android/volley/CacheDispatcherTest.java b/src/test/java/com/android/volley/CacheDispatcherTest.java
index 2592a0b..aef6785 100644
--- a/src/test/java/com/android/volley/CacheDispatcherTest.java
+++ b/src/test/java/com/android/volley/CacheDispatcherTest.java
@@ -140,6 +140,25 @@
         assertSame(entry, mRequest.getCacheEntry());
     }
 
+    // An fresh cache hit with parse error, does not post a response and queues to the network.
+    @Test
+    public void freshCacheHit_parseError() throws Exception {
+        Request request = mock(Request.class);
+        when(request.parseNetworkResponse(any(NetworkResponse.class)))
+                .thenReturn(Response.error(new ParseError()));
+        when(request.getCacheKey()).thenReturn("cache/key");
+        Cache.Entry entry = CacheTestUtils.makeRandomCacheEntry(null, false, false);
+        when(mCache.get(anyString())).thenReturn(entry);
+
+        mDispatcher.processRequest(request);
+
+        verifyNoResponse(mDelivery);
+        verify(mNetworkQueue).put(request);
+        assertNull(request.getCacheEntry());
+        verify(mCache).invalidate("cache/key", true);
+        verify(request).addMarker("cache-parsing-failed");
+    }
+
     @Test
     public void duplicateCacheMiss() throws Exception {
         StringRequest secondRequest =
diff --git a/src/test/java/com/android/volley/cronet/CronetHttpStackTest.java b/src/test/java/com/android/volley/cronet/CronetHttpStackTest.java
new file mode 100644
index 0000000..cedb6ff
--- /dev/null
+++ b/src/test/java/com/android/volley/cronet/CronetHttpStackTest.java
@@ -0,0 +1,381 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.cronet;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.android.volley.Header;
+import com.android.volley.cronet.CronetHttpStack.CurlCommandLogger;
+import com.android.volley.mock.TestRequest;
+import com.android.volley.toolbox.AsyncHttpStack.OnRequestComplete;
+import com.android.volley.toolbox.UrlRewriter;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.io.UnsupportedEncodingException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import org.chromium.net.CronetEngine;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Answers;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+@RunWith(RobolectricTestRunner.class)
+public class CronetHttpStackTest {
+    @Mock private CurlCommandLogger mMockCurlCommandLogger;
+    @Mock private OnRequestComplete mMockOnRequestComplete;
+    @Mock private UrlRewriter mMockUrlRewriter;
+
+    // A fake would be ideal here, but Cronet doesn't (yet) provide one, and at the moment we aren't
+    // exercising the full response flow.
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private CronetEngine mMockCronetEngine;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+    }
+
+    @Test
+    public void curlLogging_disabled() {
+        CronetHttpStack stack =
+                createStack(
+                        new Consumer<CronetHttpStack.Builder>() {
+                            @Override
+                            public void accept(CronetHttpStack.Builder builder) {
+                                // Default parameters should not enable cURL logging.
+                            }
+                        });
+
+        stack.executeRequest(
+                new TestRequest.Get(), ImmutableMap.<String, String>of(), mMockOnRequestComplete);
+
+        verify(mMockCurlCommandLogger, never()).logCurlCommand(anyString());
+    }
+
+    @Test
+    public void curlLogging_simpleTextRequest() {
+        CronetHttpStack stack =
+                createStack(
+                        new Consumer<CronetHttpStack.Builder>() {
+                            @Override
+                            public void accept(CronetHttpStack.Builder builder) {
+                                builder.setCurlLoggingEnabled(true);
+                            }
+                        });
+
+        stack.executeRequest(
+                new TestRequest.Get(), ImmutableMap.<String, String>of(), mMockOnRequestComplete);
+
+        ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class);
+        verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture());
+        assertEquals("curl -X GET \"http://foo.com\"", curlCommandCaptor.getValue());
+    }
+
+    @Test
+    public void curlLogging_rewrittenUrl() {
+        CronetHttpStack stack =
+                createStack(
+                        new Consumer<CronetHttpStack.Builder>() {
+                            @Override
+                            public void accept(CronetHttpStack.Builder builder) {
+                                builder.setCurlLoggingEnabled(true)
+                                        .setUrlRewriter(mMockUrlRewriter);
+                            }
+                        });
+        when(mMockUrlRewriter.rewriteUrl("http://foo.com")).thenReturn("http://bar.com");
+
+        stack.executeRequest(
+                new TestRequest.Get(), ImmutableMap.<String, String>of(), mMockOnRequestComplete);
+
+        ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class);
+        verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture());
+        assertEquals("curl -X GET \"http://bar.com\"", curlCommandCaptor.getValue());
+    }
+
+    @Test
+    public void curlLogging_headers_withoutTokens() {
+        CronetHttpStack stack =
+                createStack(
+                        new Consumer<CronetHttpStack.Builder>() {
+                            @Override
+                            public void accept(CronetHttpStack.Builder builder) {
+                                builder.setCurlLoggingEnabled(true);
+                            }
+                        });
+
+        stack.executeRequest(
+                new TestRequest.Delete() {
+                    @Override
+                    public Map<String, String> getHeaders() {
+                        return ImmutableMap.of(
+                                "SomeHeader", "SomeValue",
+                                "Authorization", "SecretToken");
+                    }
+                },
+                ImmutableMap.of("SomeOtherHeader", "SomeValue"),
+                mMockOnRequestComplete);
+
+        ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class);
+        verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture());
+        // NOTE: Header order is stable because the implementation uses a TreeMap.
+        assertEquals(
+                "curl -X DELETE --header \"Authorization: [REDACTED]\" "
+                        + "--header \"SomeHeader: SomeValue\" "
+                        + "--header \"SomeOtherHeader: SomeValue\" \"http://foo.com\"",
+                curlCommandCaptor.getValue());
+    }
+
+    @Test
+    public void curlLogging_headers_withTokens() {
+        CronetHttpStack stack =
+                createStack(
+                        new Consumer<CronetHttpStack.Builder>() {
+                            @Override
+                            public void accept(CronetHttpStack.Builder builder) {
+                                builder.setCurlLoggingEnabled(true)
+                                        .setLogAuthTokensInCurlCommands(true);
+                            }
+                        });
+
+        stack.executeRequest(
+                new TestRequest.Delete() {
+                    @Override
+                    public Map<String, String> getHeaders() {
+                        return ImmutableMap.of(
+                                "SomeHeader", "SomeValue",
+                                "Authorization", "SecretToken");
+                    }
+                },
+                ImmutableMap.of("SomeOtherHeader", "SomeValue"),
+                mMockOnRequestComplete);
+
+        ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class);
+        verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture());
+        // NOTE: Header order is stable because the implementation uses a TreeMap.
+        assertEquals(
+                "curl -X DELETE --header \"Authorization: SecretToken\" "
+                        + "--header \"SomeHeader: SomeValue\" "
+                        + "--header \"SomeOtherHeader: SomeValue\" \"http://foo.com\"",
+                curlCommandCaptor.getValue());
+    }
+
+    @Test
+    public void curlLogging_textRequest() {
+        CronetHttpStack stack =
+                createStack(
+                        new Consumer<CronetHttpStack.Builder>() {
+                            @Override
+                            public void accept(CronetHttpStack.Builder builder) {
+                                builder.setCurlLoggingEnabled(true);
+                            }
+                        });
+
+        stack.executeRequest(
+                new TestRequest.PostWithBody() {
+                    @Override
+                    public byte[] getBody() {
+                        try {
+                            return "hello".getBytes("UTF-8");
+                        } catch (UnsupportedEncodingException e) {
+                            throw new RuntimeException(e);
+                        }
+                    }
+
+                    @Override
+                    public String getBodyContentType() {
+                        return "text/plain; charset=UTF-8";
+                    }
+                },
+                ImmutableMap.<String, String>of(),
+                mMockOnRequestComplete);
+
+        ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class);
+        verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture());
+        assertEquals(
+                "curl -X POST "
+                        + "--header \"Content-Type: text/plain; charset=UTF-8\" \"http://foo.com\" "
+                        + "--data-ascii \"hello\"",
+                curlCommandCaptor.getValue());
+    }
+
+    @Test
+    public void curlLogging_gzipTextRequest() {
+        CronetHttpStack stack =
+                createStack(
+                        new Consumer<CronetHttpStack.Builder>() {
+                            @Override
+                            public void accept(CronetHttpStack.Builder builder) {
+                                builder.setCurlLoggingEnabled(true);
+                            }
+                        });
+
+        stack.executeRequest(
+                new TestRequest.PostWithBody() {
+                    @Override
+                    public byte[] getBody() {
+                        return new byte[] {1, 2, 3, 4, 5};
+                    }
+
+                    @Override
+                    public String getBodyContentType() {
+                        return "text/plain";
+                    }
+
+                    @Override
+                    public Map<String, String> getHeaders() {
+                        return ImmutableMap.of("Content-Encoding", "gzip, identity");
+                    }
+                },
+                ImmutableMap.<String, String>of(),
+                mMockOnRequestComplete);
+
+        ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class);
+        verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture());
+        assertEquals(
+                "echo 'AQIDBAU=' | base64 -d > /tmp/$$.bin; curl -X POST "
+                        + "--header \"Content-Encoding: gzip, identity\" "
+                        + "--header \"Content-Type: text/plain\" \"http://foo.com\" "
+                        + "--data-binary @/tmp/$$.bin",
+                curlCommandCaptor.getValue());
+    }
+
+    @Test
+    public void curlLogging_binaryRequest() {
+        CronetHttpStack stack =
+                createStack(
+                        new Consumer<CronetHttpStack.Builder>() {
+                            @Override
+                            public void accept(CronetHttpStack.Builder builder) {
+                                builder.setCurlLoggingEnabled(true);
+                            }
+                        });
+
+        stack.executeRequest(
+                new TestRequest.PostWithBody() {
+                    @Override
+                    public byte[] getBody() {
+                        return new byte[] {1, 2, 3, 4, 5};
+                    }
+
+                    @Override
+                    public String getBodyContentType() {
+                        return "application/octet-stream";
+                    }
+                },
+                ImmutableMap.<String, String>of(),
+                mMockOnRequestComplete);
+
+        ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class);
+        verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture());
+        assertEquals(
+                "echo 'AQIDBAU=' | base64 -d > /tmp/$$.bin; curl -X POST "
+                        + "--header \"Content-Type: application/octet-stream\" \"http://foo.com\" "
+                        + "--data-binary @/tmp/$$.bin",
+                curlCommandCaptor.getValue());
+    }
+
+    @Test
+    public void curlLogging_largeRequest() {
+        CronetHttpStack stack =
+                createStack(
+                        new Consumer<CronetHttpStack.Builder>() {
+                            @Override
+                            public void accept(CronetHttpStack.Builder builder) {
+                                builder.setCurlLoggingEnabled(true);
+                            }
+                        });
+
+        stack.executeRequest(
+                new TestRequest.PostWithBody() {
+                    @Override
+                    public byte[] getBody() {
+                        return new byte[2048];
+                    }
+
+                    @Override
+                    public String getBodyContentType() {
+                        return "application/octet-stream";
+                    }
+                },
+                ImmutableMap.<String, String>of(),
+                mMockOnRequestComplete);
+
+        ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class);
+        verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture());
+        assertEquals(
+                "curl -X POST "
+                        + "--header \"Content-Type: application/octet-stream\" \"http://foo.com\" "
+                        + "[REQUEST BODY TOO LARGE TO INCLUDE]",
+                curlCommandCaptor.getValue());
+    }
+
+    @Test
+    public void getHeadersEmptyTest() {
+        List<Map.Entry<String, String>> list = new ArrayList<>();
+        List<Header> actual = CronetHttpStack.getHeaders(list);
+        List<Header> expected = new ArrayList<>();
+        assertEquals(expected, actual);
+    }
+
+    @Test
+    public void getHeadersNonEmptyTest() {
+        Map<String, String> headers = new HashMap<>();
+        for (int i = 1; i < 5; i++) {
+            headers.put("key" + i, "value" + i);
+        }
+        List<Map.Entry<String, String>> list = new ArrayList<>(headers.entrySet());
+        List<Header> actual = CronetHttpStack.getHeaders(list);
+        List<Header> expected = new ArrayList<>();
+        for (int i = 1; i < 5; i++) {
+            expected.add(new Header("key" + i, "value" + i));
+        }
+        assertHeaderListsEqual(expected, actual);
+    }
+
+    private void assertHeaderListsEqual(List<Header> expected, List<Header> actual) {
+        assertEquals(expected.size(), actual.size());
+        for (int i = 0; i < expected.size(); i++) {
+            assertEquals(expected.get(i).getName(), actual.get(i).getName());
+            assertEquals(expected.get(i).getValue(), actual.get(i).getValue());
+        }
+    }
+
+    private CronetHttpStack createStack(Consumer<CronetHttpStack.Builder> stackEditor) {
+        CronetHttpStack.Builder builder =
+                new CronetHttpStack.Builder(RuntimeEnvironment.application)
+                        .setCronetEngine(mMockCronetEngine)
+                        .setCurlCommandLogger(mMockCurlCommandLogger);
+        stackEditor.accept(builder);
+        CronetHttpStack stack = builder.build();
+        stack.setBlockingExecutor(MoreExecutors.newDirectExecutorService());
+        stack.setNonBlockingExecutor(MoreExecutors.newDirectExecutorService());
+        return stack;
+    }
+}
diff --git a/src/test/java/com/android/volley/mock/MockAsyncStack.java b/src/test/java/com/android/volley/mock/MockAsyncStack.java
new file mode 100644
index 0000000..5ea8343
--- /dev/null
+++ b/src/test/java/com/android/volley/mock/MockAsyncStack.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.mock;
+
+import com.android.volley.AuthFailureError;
+import com.android.volley.Request;
+import com.android.volley.toolbox.AsyncHttpStack;
+import com.android.volley.toolbox.HttpResponse;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class MockAsyncStack extends AsyncHttpStack {
+
+    private HttpResponse mResponseToReturn;
+
+    private IOException mExceptionToThrow;
+
+    private String mLastUrl;
+
+    private Map<String, String> mLastHeaders;
+
+    private byte[] mLastPostBody;
+
+    public String getLastUrl() {
+        return mLastUrl;
+    }
+
+    public Map<String, String> getLastHeaders() {
+        return mLastHeaders;
+    }
+
+    public byte[] getLastPostBody() {
+        return mLastPostBody;
+    }
+
+    public void setResponseToReturn(HttpResponse response) {
+        mResponseToReturn = response;
+    }
+
+    public void setExceptionToThrow(IOException exception) {
+        mExceptionToThrow = exception;
+    }
+
+    @Override
+    public void executeRequest(
+            Request<?> request, Map<String, String> additionalHeaders, OnRequestComplete callback) {
+        if (mExceptionToThrow != null) {
+            callback.onError(mExceptionToThrow);
+            return;
+        }
+        mLastUrl = request.getUrl();
+        mLastHeaders = new HashMap<>();
+        try {
+            if (request.getHeaders() != null) {
+                mLastHeaders.putAll(request.getHeaders());
+            }
+        } catch (AuthFailureError authFailureError) {
+            callback.onAuthError(authFailureError);
+            return;
+        }
+        if (additionalHeaders != null) {
+            mLastHeaders.putAll(additionalHeaders);
+        }
+        try {
+            mLastPostBody = request.getBody();
+        } catch (AuthFailureError e) {
+            mLastPostBody = null;
+        }
+        callback.onSuccess(mResponseToReturn);
+    }
+}
diff --git a/src/test/java/com/android/volley/toolbox/BasicAsyncNetworkTest.java b/src/test/java/com/android/volley/toolbox/BasicAsyncNetworkTest.java
new file mode 100644
index 0000000..91d4062
--- /dev/null
+++ b/src/test/java/com/android/volley/toolbox/BasicAsyncNetworkTest.java
@@ -0,0 +1,508 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.volley.toolbox;
+
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThat;
+import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.MockitoAnnotations.initMocks;
+
+import com.android.volley.AsyncNetwork;
+import com.android.volley.AuthFailureError;
+import com.android.volley.Cache.Entry;
+import com.android.volley.Header;
+import com.android.volley.NetworkResponse;
+import com.android.volley.NoConnectionError;
+import com.android.volley.Request;
+import com.android.volley.Response;
+import com.android.volley.RetryPolicy;
+import com.android.volley.ServerError;
+import com.android.volley.TimeoutError;
+import com.android.volley.VolleyError;
+import com.android.volley.mock.MockAsyncStack;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class BasicAsyncNetworkTest {
+
+    @Mock private RetryPolicy mMockRetryPolicy;
+    @Mock private AsyncNetwork.OnRequestComplete mockCallback;
+    private ExecutorService executor = MoreExecutors.newDirectExecutorService();
+
+    @Before
+    public void setUp() throws Exception {
+        initMocks(this);
+    }
+
+    @Test
+    public void headersAndPostParams() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        HttpResponse fakeResponse =
+                new HttpResponse(
+                        200,
+                        Collections.<Header>emptyList(),
+                        "foobar".getBytes(StandardCharsets.UTF_8));
+        mockAsyncStack.setResponseToReturn(fakeResponse);
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        Entry entry = new Entry();
+        entry.etag = "foobar";
+        entry.lastModified = 1503102002000L;
+        request.setCacheEntry(entry);
+        perform(request, httpNetwork).get();
+        assertEquals("foo", mockAsyncStack.getLastHeaders().get("requestheader"));
+        assertEquals("foobar", mockAsyncStack.getLastHeaders().get("If-None-Match"));
+        assertEquals(
+                "Sat, 19 Aug 2017 00:20:02 GMT",
+                mockAsyncStack.getLastHeaders().get("If-Modified-Since"));
+        assertEquals(
+                "requestpost=foo&",
+                new String(mockAsyncStack.getLastPostBody(), StandardCharsets.UTF_8));
+    }
+
+    @Test
+    public void headersAndPostParamsStream() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        ByteArrayInputStream stream = new ByteArrayInputStream("foobar".getBytes("UTF-8"));
+        HttpResponse fakeResponse =
+                new HttpResponse(200, Collections.<Header>emptyList(), 6, stream);
+        mockAsyncStack.setResponseToReturn(fakeResponse);
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        Entry entry = new Entry();
+        entry.etag = "foobar";
+        entry.lastModified = 1503102002000L;
+        request.setCacheEntry(entry);
+        perform(request, httpNetwork).get();
+        assertEquals("foo", mockAsyncStack.getLastHeaders().get("requestheader"));
+        assertEquals("foobar", mockAsyncStack.getLastHeaders().get("If-None-Match"));
+        assertEquals(
+                "Sat, 19 Aug 2017 00:20:02 GMT",
+                mockAsyncStack.getLastHeaders().get("If-Modified-Since"));
+        assertEquals(
+                "requestpost=foo&",
+                new String(mockAsyncStack.getLastPostBody(), StandardCharsets.UTF_8));
+    }
+
+    @Test
+    public void notModified() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        List<Header> headers = new ArrayList<>();
+        headers.add(new Header("ServerKeyA", "ServerValueA"));
+        headers.add(new Header("ServerKeyB", "ServerValueB"));
+        headers.add(new Header("SharedKey", "ServerValueShared"));
+        headers.add(new Header("sharedcaseinsensitivekey", "ServerValueShared1"));
+        headers.add(new Header("SharedCaseInsensitiveKey", "ServerValueShared2"));
+        HttpResponse fakeResponse = new HttpResponse(HttpURLConnection.HTTP_NOT_MODIFIED, headers);
+        mockAsyncStack.setResponseToReturn(fakeResponse);
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        Entry entry = new Entry();
+        entry.allResponseHeaders = new ArrayList<>();
+        entry.allResponseHeaders.add(new Header("CachedKeyA", "CachedValueA"));
+        entry.allResponseHeaders.add(new Header("CachedKeyB", "CachedValueB"));
+        entry.allResponseHeaders.add(new Header("SharedKey", "CachedValueShared"));
+        entry.allResponseHeaders.add(new Header("SHAREDCASEINSENSITIVEKEY", "CachedValueShared1"));
+        entry.allResponseHeaders.add(new Header("shAREDcaSEinSENSITIVEkeY", "CachedValueShared2"));
+        request.setCacheEntry(entry);
+        httpNetwork.performRequest(request, mockCallback);
+        NetworkResponse response = perform(request, httpNetwork).get();
+        List<Header> expectedHeaders = new ArrayList<>();
+        // Should have all server headers + cache headers that didn't show up in server response.
+        expectedHeaders.add(new Header("ServerKeyA", "ServerValueA"));
+        expectedHeaders.add(new Header("ServerKeyB", "ServerValueB"));
+        expectedHeaders.add(new Header("SharedKey", "ServerValueShared"));
+        expectedHeaders.add(new Header("sharedcaseinsensitivekey", "ServerValueShared1"));
+        expectedHeaders.add(new Header("SharedCaseInsensitiveKey", "ServerValueShared2"));
+        expectedHeaders.add(new Header("CachedKeyA", "CachedValueA"));
+        expectedHeaders.add(new Header("CachedKeyB", "CachedValueB"));
+        assertThat(expectedHeaders, containsInAnyOrder(response.allHeaders.toArray(new Header[0])));
+    }
+
+    @Test
+    public void notModified_legacyCache() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        List<Header> headers = new ArrayList<>();
+        headers.add(new Header("ServerKeyA", "ServerValueA"));
+        headers.add(new Header("ServerKeyB", "ServerValueB"));
+        headers.add(new Header("SharedKey", "ServerValueShared"));
+        headers.add(new Header("sharedcaseinsensitivekey", "ServerValueShared1"));
+        headers.add(new Header("SharedCaseInsensitiveKey", "ServerValueShared2"));
+        HttpResponse fakeResponse = new HttpResponse(HttpURLConnection.HTTP_NOT_MODIFIED, headers);
+        mockAsyncStack.setResponseToReturn(fakeResponse);
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        Entry entry = new Entry();
+        entry.responseHeaders = new HashMap<>();
+        entry.responseHeaders.put("CachedKeyA", "CachedValueA");
+        entry.responseHeaders.put("CachedKeyB", "CachedValueB");
+        entry.responseHeaders.put("SharedKey", "CachedValueShared");
+        entry.responseHeaders.put("SHAREDCASEINSENSITIVEKEY", "CachedValueShared1");
+        entry.responseHeaders.put("shAREDcaSEinSENSITIVEkeY", "CachedValueShared2");
+        request.setCacheEntry(entry);
+        NetworkResponse response = perform(request, httpNetwork).get();
+        List<Header> expectedHeaders = new ArrayList<>();
+        // Should have all server headers + cache headers that didn't show up in server response.
+        expectedHeaders.add(new Header("ServerKeyA", "ServerValueA"));
+        expectedHeaders.add(new Header("ServerKeyB", "ServerValueB"));
+        expectedHeaders.add(new Header("SharedKey", "ServerValueShared"));
+        expectedHeaders.add(new Header("sharedcaseinsensitivekey", "ServerValueShared1"));
+        expectedHeaders.add(new Header("SharedCaseInsensitiveKey", "ServerValueShared2"));
+        expectedHeaders.add(new Header("CachedKeyA", "CachedValueA"));
+        expectedHeaders.add(new Header("CachedKeyB", "CachedValueB"));
+        assertThat(expectedHeaders, containsInAnyOrder(response.allHeaders.toArray(new Header[0])));
+    }
+
+    @Test
+    public void socketTimeout() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        mockAsyncStack.setExceptionToThrow(new SocketTimeoutException());
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        request.setRetryPolicy(mMockRetryPolicy);
+        doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+        httpNetwork.performRequest(request, mockCallback);
+        verify(mockCallback, times(1)).onError(any(VolleyError.class));
+        verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+        // should retry socket timeouts
+        verify(mMockRetryPolicy).retry(any(TimeoutError.class));
+        reset(mMockRetryPolicy, mockCallback);
+    }
+
+    @Test
+    public void noConnectionDefault() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        mockAsyncStack.setExceptionToThrow(new IOException());
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        request.setRetryPolicy(mMockRetryPolicy);
+        doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+        httpNetwork.performRequest(request, mockCallback);
+        verify(mockCallback, times(1)).onError(any(VolleyError.class));
+        verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+        // should not retry when there is no connection
+        verify(mMockRetryPolicy, never()).retry(any(VolleyError.class));
+        reset(mMockRetryPolicy, mockCallback);
+    }
+
+    @Test
+    public void noConnectionRetry() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        mockAsyncStack.setExceptionToThrow(new IOException());
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        request.setRetryPolicy(mMockRetryPolicy);
+        request.setShouldRetryConnectionErrors(true);
+        doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+        httpNetwork.performRequest(request, mockCallback);
+        verify(mockCallback, times(1)).onError(any(VolleyError.class));
+        verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+        // should retry when there is no connection
+        verify(mMockRetryPolicy).retry(any(NoConnectionError.class));
+        reset(mMockRetryPolicy, mockCallback);
+    }
+
+    @Test
+    public void noConnectionNoRetry() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        mockAsyncStack.setExceptionToThrow(new IOException());
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        request.setRetryPolicy(mMockRetryPolicy);
+        request.setShouldRetryConnectionErrors(false);
+        doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+        httpNetwork.performRequest(request, mockCallback);
+        verify(mockCallback, times(1)).onError(any(VolleyError.class));
+        verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+        // should not retry when there is no connection
+        verify(mMockRetryPolicy, never()).retry(any(VolleyError.class));
+        reset(mMockRetryPolicy, mockCallback);
+    }
+
+    @Test
+    public void unauthorized() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        HttpResponse fakeResponse = new HttpResponse(401, Collections.<Header>emptyList());
+        mockAsyncStack.setResponseToReturn(fakeResponse);
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        request.setRetryPolicy(mMockRetryPolicy);
+        doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+        httpNetwork.performRequest(request, mockCallback);
+        verify(mockCallback, times(1)).onError(any(VolleyError.class));
+        verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+        // should retry in case it's an auth failure.
+        verify(mMockRetryPolicy).retry(any(AuthFailureError.class));
+        reset(mMockRetryPolicy, mockCallback);
+    }
+
+    @Test(expected = RuntimeException.class)
+    public void malformedUrlRequest() throws VolleyError, ExecutionException, InterruptedException {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        mockAsyncStack.setExceptionToThrow(new MalformedURLException());
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        request.setRetryPolicy(mMockRetryPolicy);
+        doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+        perform(request, httpNetwork).get();
+    }
+
+    @Test
+    public void forbidden() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        HttpResponse fakeResponse = new HttpResponse(403, Collections.<Header>emptyList());
+        mockAsyncStack.setResponseToReturn(fakeResponse);
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        request.setRetryPolicy(mMockRetryPolicy);
+        doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+        httpNetwork.performRequest(request, mockCallback);
+        verify(mockCallback, times(1)).onError(any(VolleyError.class));
+        verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+        // should retry in case it's an auth failure.
+        verify(mMockRetryPolicy).retry(any(AuthFailureError.class));
+        reset(mMockRetryPolicy, mockCallback);
+    }
+
+    @Test
+    public void redirect() throws Exception {
+        for (int i = 300; i <= 399; i++) {
+            MockAsyncStack mockAsyncStack = new MockAsyncStack();
+            HttpResponse fakeResponse = new HttpResponse(i, Collections.<Header>emptyList());
+            mockAsyncStack.setResponseToReturn(fakeResponse);
+            BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+            httpNetwork.setBlockingExecutor(executor);
+            Request<String> request = buildRequest();
+            request.setRetryPolicy(mMockRetryPolicy);
+            doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+            httpNetwork.performRequest(request, mockCallback);
+            if (i != 304) {
+                verify(mockCallback, times(1)).onError(any(VolleyError.class));
+                verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+            } else {
+                verify(mockCallback, never()).onError(any(VolleyError.class));
+                verify(mockCallback, times(1)).onSuccess(any(NetworkResponse.class));
+            }
+            // should not retry 300 responses.
+            verify(mMockRetryPolicy, never()).retry(any(VolleyError.class));
+            reset(mMockRetryPolicy, mockCallback);
+        }
+    }
+
+    @Test
+    public void otherClientError() throws Exception {
+        for (int i = 400; i <= 499; i++) {
+            if (i == 401 || i == 403) {
+                // covered above.
+                continue;
+            }
+            MockAsyncStack mockAsyncStack = new MockAsyncStack();
+            HttpResponse fakeResponse = new HttpResponse(i, Collections.<Header>emptyList());
+            mockAsyncStack.setResponseToReturn(fakeResponse);
+            BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+            httpNetwork.setBlockingExecutor(executor);
+            Request<String> request = buildRequest();
+            request.setRetryPolicy(mMockRetryPolicy);
+            doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+            httpNetwork.performRequest(request, mockCallback);
+            verify(mockCallback, times(1)).onError(any(VolleyError.class));
+            verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+            // should not retry other 400 errors.
+            verify(mMockRetryPolicy, never()).retry(any(VolleyError.class));
+            reset(mMockRetryPolicy, mockCallback);
+        }
+    }
+
+    @Test
+    public void serverError_enableRetries() throws Exception {
+        for (int i = 500; i <= 599; i++) {
+            MockAsyncStack mockAsyncStack = new MockAsyncStack();
+            HttpResponse fakeResponse = new HttpResponse(i, Collections.<Header>emptyList());
+            mockAsyncStack.setResponseToReturn(fakeResponse);
+            BasicAsyncNetwork httpNetwork =
+                    new BasicAsyncNetwork.Builder(mockAsyncStack)
+                            .setPool(new ByteArrayPool(4096))
+                            .build();
+            httpNetwork.setBlockingExecutor(executor);
+            Request<String> request = buildRequest();
+            request.setRetryPolicy(mMockRetryPolicy);
+            request.setShouldRetryServerErrors(true);
+            doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+            httpNetwork.performRequest(request, mockCallback);
+            verify(mockCallback, times(1)).onError(any(VolleyError.class));
+            verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+            // should retry all 500 errors
+            verify(mMockRetryPolicy).retry(any(ServerError.class));
+            reset(mMockRetryPolicy, mockCallback);
+        }
+    }
+
+    @Test
+    public void serverError_disableRetries() throws Exception {
+        for (int i = 500; i <= 599; i++) {
+            MockAsyncStack mockAsyncStack = new MockAsyncStack();
+            HttpResponse fakeResponse = new HttpResponse(i, Collections.<Header>emptyList());
+            mockAsyncStack.setResponseToReturn(fakeResponse);
+            BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+            httpNetwork.setBlockingExecutor(executor);
+            Request<String> request = buildRequest();
+            request.setRetryPolicy(mMockRetryPolicy);
+            doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+            httpNetwork.performRequest(request, mockCallback);
+            verify(mockCallback, times(1)).onError(any(VolleyError.class));
+            verify(mockCallback, never()).onSuccess(any(NetworkResponse.class));
+            // should not retry any 500 error w/ HTTP 500 retries turned off (the default).
+            verify(mMockRetryPolicy, never()).retry(any(VolleyError.class));
+            reset(mMockRetryPolicy, mockCallback);
+        }
+    }
+
+    @Test
+    public void notModifiedShortCircuit() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        List<Header> headers = new ArrayList<>();
+        headers.add(new Header("ServerKeyA", "ServerValueA"));
+        headers.add(new Header("ServerKeyB", "ServerValueB"));
+        headers.add(new Header("SharedKey", "ServerValueShared"));
+        headers.add(new Header("sharedcaseinsensitivekey", "ServerValueShared1"));
+        headers.add(new Header("SharedCaseInsensitiveKey", "ServerValueShared2"));
+        HttpResponse fakeResponse = new HttpResponse(HttpURLConnection.HTTP_NOT_MODIFIED, headers);
+        mockAsyncStack.setResponseToReturn(fakeResponse);
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        httpNetwork.performRequest(request, mockCallback);
+        verify(mockCallback, times(1)).onSuccess(any(NetworkResponse.class));
+        verify(mockCallback, never()).onError(any(VolleyError.class));
+        reset(mMockRetryPolicy, mockCallback);
+    }
+
+    @Test
+    public void performRequestSuccess() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        HttpResponse fakeResponse =
+                new HttpResponse(
+                        200,
+                        Collections.<Header>emptyList(),
+                        "foobar".getBytes(StandardCharsets.UTF_8));
+        mockAsyncStack.setResponseToReturn(fakeResponse);
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        httpNetwork.setBlockingExecutor(executor);
+        Request<String> request = buildRequest();
+        Entry entry = new Entry();
+        entry.etag = "foobar";
+        entry.lastModified = 1503102002000L;
+        request.setCacheEntry(entry);
+        httpNetwork.performRequest(request, mockCallback);
+        verify(mockCallback, times(1)).onSuccess(any(NetworkResponse.class));
+        verify(mockCallback, never()).onError(any(VolleyError.class));
+        reset(mMockRetryPolicy, mockCallback);
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void performRequestNeverSetExecutorTest() throws Exception {
+        MockAsyncStack mockAsyncStack = new MockAsyncStack();
+        HttpResponse fakeResponse = new HttpResponse(200, Collections.<Header>emptyList());
+        mockAsyncStack.setResponseToReturn(fakeResponse);
+        BasicAsyncNetwork httpNetwork = new BasicAsyncNetwork.Builder(mockAsyncStack).build();
+        Request<String> request = buildRequest();
+        perform(request, httpNetwork).get();
+    }
+
+    /** Helper functions */
+    private CompletableFuture<NetworkResponse> perform(Request<?> request, AsyncNetwork network)
+            throws VolleyError {
+        final CompletableFuture<NetworkResponse> future = new CompletableFuture<>();
+        network.performRequest(
+                request,
+                new AsyncNetwork.OnRequestComplete() {
+                    @Override
+                    public void onSuccess(NetworkResponse networkResponse) {
+                        future.complete(networkResponse);
+                    }
+
+                    @Override
+                    public void onError(VolleyError volleyError) {
+                        future.complete(null);
+                    }
+                });
+        return future;
+    }
+
+    private static Request<String> buildRequest() {
+        return new Request<String>(Request.Method.GET, "http://foo", null) {
+
+            @Override
+            protected Response<String> parseNetworkResponse(NetworkResponse response) {
+                return null;
+            }
+
+            @Override
+            protected void deliverResponse(String response) {}
+
+            @Override
+            public Map<String, String> getHeaders() {
+                Map<String, String> result = new HashMap<String, String>();
+                result.put("requestheader", "foo");
+                return result;
+            }
+
+            @Override
+            public Map<String, String> getParams() {
+                Map<String, String> result = new HashMap<String, String>();
+                result.put("requestpost", "foo");
+                return result;
+            }
+        };
+    }
+}
diff --git a/src/test/java/com/android/volley/toolbox/BasicNetworkTest.java b/src/test/java/com/android/volley/toolbox/BasicNetworkTest.java
index fec0694..3630379 100644
--- a/src/test/java/com/android/volley/toolbox/BasicNetworkTest.java
+++ b/src/test/java/com/android/volley/toolbox/BasicNetworkTest.java
@@ -30,6 +30,7 @@
 import com.android.volley.Cache.Entry;
 import com.android.volley.Header;
 import com.android.volley.NetworkResponse;
+import com.android.volley.NoConnectionError;
 import com.android.volley.Request;
 import com.android.volley.Response;
 import com.android.volley.RetryPolicy;
@@ -176,7 +177,7 @@
     }
 
     @Test
-    public void noConnection() throws Exception {
+    public void noConnectionDefault() throws Exception {
         MockHttpStack mockHttpStack = new MockHttpStack();
         mockHttpStack.setExceptionToThrow(new IOException());
         BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack);
@@ -193,6 +194,43 @@
     }
 
     @Test
+    public void noConnectionRetry() throws Exception {
+        MockHttpStack mockHttpStack = new MockHttpStack();
+        mockHttpStack.setExceptionToThrow(new IOException());
+        BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack);
+        Request<String> request = buildRequest();
+        request.setRetryPolicy(mMockRetryPolicy);
+        request.setShouldRetryConnectionErrors(true);
+        doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+        try {
+            httpNetwork.performRequest(request);
+        } catch (VolleyError e) {
+            // expected
+        }
+        // should retry when there is no connection
+        verify(mMockRetryPolicy).retry(any(NoConnectionError.class));
+        reset(mMockRetryPolicy);
+    }
+
+    @Test
+    public void noConnectionNoRetry() throws Exception {
+        MockHttpStack mockHttpStack = new MockHttpStack();
+        mockHttpStack.setExceptionToThrow(new IOException());
+        BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack);
+        Request<String> request = buildRequest();
+        request.setRetryPolicy(mMockRetryPolicy);
+        request.setShouldRetryConnectionErrors(false);
+        doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class));
+        try {
+            httpNetwork.performRequest(request);
+        } catch (VolleyError e) {
+            // expected
+        }
+        // should not retry when there is no connection
+        verify(mMockRetryPolicy, never()).retry(any(VolleyError.class));
+    }
+
+    @Test
     public void unauthorized() throws Exception {
         MockHttpStack mockHttpStack = new MockHttpStack();
         HttpResponse fakeResponse = new HttpResponse(401, Collections.<Header>emptyList());
diff --git a/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java b/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java
index e499a37..db6e491 100644
--- a/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java
+++ b/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java
@@ -59,7 +59,7 @@
 import org.robolectric.annotation.Config;
 
 @RunWith(RobolectricTestRunner.class)
-@Config(manifest = "src/main/AndroidManifest.xml", sdk = 16)
+@Config(sdk = 16)
 public class DiskBasedCacheTest {
 
     private static final int MAX_SIZE = 1024 * 1024;
@@ -587,11 +587,28 @@
     public void publicMethods() throws Exception {
         // Catch-all test to find API-breaking changes.
         assertNotNull(DiskBasedCache.class.getConstructor(File.class, int.class));
+        assertNotNull(
+                DiskBasedCache.class.getConstructor(DiskBasedCache.FileSupplier.class, int.class));
         assertNotNull(DiskBasedCache.class.getConstructor(File.class));
+        assertNotNull(DiskBasedCache.class.getConstructor(DiskBasedCache.FileSupplier.class));
 
         assertNotNull(DiskBasedCache.class.getMethod("getFileForKey", String.class));
     }
 
+    @Test
+    public void initializeIfRootDirectoryDeleted() {
+        temporaryFolder.delete();
+
+        Cache.Entry entry = randomData(101);
+        cache.put("key1", entry);
+
+        assertThat(cache.get("key1"), is(nullValue()));
+
+        // confirm that we can now store entries
+        cache.put("key2", entry);
+        assertThatEntriesAreEqual(cache.get("key2"), entry);
+    }
+
     /* Test helpers */
 
     private void assertThatEntriesAreEqual(Cache.Entry actual, Cache.Entry expected) {
diff --git a/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java b/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java
index 9b670f9..7780c3e 100644
--- a/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java
+++ b/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java
@@ -67,6 +67,12 @@
     }
 
     @Test
+    public void parseCacheHeaders_nullHeaders() {
+        response = new NetworkResponse(0, null, null, false);
+        assertNull(HttpHeaderParser.parseCacheHeaders(response));
+    }
+
+    @Test
     public void parseCacheHeaders_headersSet() {
         headers.put("MyCustomHeader", "42");
 
@@ -282,6 +288,9 @@
         // None specified, extra semicolon
         headers.put("Content-Type", "text/plain;");
         assertEquals("ISO-8859-1", HttpHeaderParser.parseCharset(headers));
+
+        // No headers, use default charset
+        assertEquals("utf-8", HttpHeaderParser.parseCharset(null, "utf-8"));
     }
 
     @Test
diff --git a/src/test/java/com/android/volley/toolbox/HurlStackTest.java b/src/test/java/com/android/volley/toolbox/HurlStackTest.java
index c1fc92d..7508244 100644
--- a/src/test/java/com/android/volley/toolbox/HurlStackTest.java
+++ b/src/test/java/com/android/volley/toolbox/HurlStackTest.java
@@ -17,6 +17,7 @@
 package com.android.volley.toolbox;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.never;
@@ -24,11 +25,16 @@
 import static org.mockito.Mockito.when;
 
 import com.android.volley.Header;
+import com.android.volley.Request;
 import com.android.volley.Request.Method;
 import com.android.volley.mock.TestRequest;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
+import java.io.FilterInputStream;
+import java.io.FilterOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.net.HttpURLConnection;
 import java.net.SocketTimeoutException;
 import java.net.URL;
@@ -62,6 +68,26 @@
                     protected HttpURLConnection createConnection(URL url) {
                         return mMockConnection;
                     }
+
+                    @Override
+                    protected InputStream createInputStream(
+                            Request<?> request, HttpURLConnection connection) {
+                        return new MonitoringInputStream(
+                                super.createInputStream(request, connection));
+                    }
+
+                    @Override
+                    protected OutputStream createOutputStream(
+                            Request<?> request, HttpURLConnection connection, int length)
+                            throws IOException {
+                        if (request instanceof MonitoredRequest) {
+                            return new MonitoringOutputStream(
+                                    super.createOutputStream(request, connection, length),
+                                    (MonitoredRequest) request,
+                                    length);
+                        }
+                        return super.createOutputStream(request, connection, length);
+                    }
                 };
     }
 
@@ -70,7 +96,7 @@
         TestRequest.DeprecatedGet request = new TestRequest.DeprecatedGet();
         assertEquals(request.getMethod(), Method.DEPRECATED_GET_OR_POST);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection, never()).setRequestMethod(anyString());
         verify(mMockConnection, never()).setDoOutput(true);
     }
@@ -80,7 +106,7 @@
         TestRequest.DeprecatedPost request = new TestRequest.DeprecatedPost();
         assertEquals(request.getMethod(), Method.DEPRECATED_GET_OR_POST);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("POST");
         verify(mMockConnection).setDoOutput(true);
     }
@@ -90,7 +116,7 @@
         TestRequest.Get request = new TestRequest.Get();
         assertEquals(request.getMethod(), Method.GET);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("GET");
         verify(mMockConnection, never()).setDoOutput(true);
     }
@@ -100,7 +126,7 @@
         TestRequest.Post request = new TestRequest.Post();
         assertEquals(request.getMethod(), Method.POST);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("POST");
         verify(mMockConnection, never()).setDoOutput(true);
     }
@@ -110,7 +136,7 @@
         TestRequest.PostWithBody request = new TestRequest.PostWithBody();
         assertEquals(request.getMethod(), Method.POST);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("POST");
         verify(mMockConnection).setDoOutput(true);
     }
@@ -120,7 +146,7 @@
         TestRequest.Put request = new TestRequest.Put();
         assertEquals(request.getMethod(), Method.PUT);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("PUT");
         verify(mMockConnection, never()).setDoOutput(true);
     }
@@ -130,7 +156,7 @@
         TestRequest.PutWithBody request = new TestRequest.PutWithBody();
         assertEquals(request.getMethod(), Method.PUT);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("PUT");
         verify(mMockConnection).setDoOutput(true);
     }
@@ -140,7 +166,7 @@
         TestRequest.Delete request = new TestRequest.Delete();
         assertEquals(request.getMethod(), Method.DELETE);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("DELETE");
         verify(mMockConnection, never()).setDoOutput(true);
     }
@@ -150,7 +176,7 @@
         TestRequest.Head request = new TestRequest.Head();
         assertEquals(request.getMethod(), Method.HEAD);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("HEAD");
         verify(mMockConnection, never()).setDoOutput(true);
     }
@@ -160,7 +186,7 @@
         TestRequest.Options request = new TestRequest.Options();
         assertEquals(request.getMethod(), Method.OPTIONS);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("OPTIONS");
         verify(mMockConnection, never()).setDoOutput(true);
     }
@@ -170,7 +196,7 @@
         TestRequest.Trace request = new TestRequest.Trace();
         assertEquals(request.getMethod(), Method.TRACE);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("TRACE");
         verify(mMockConnection, never()).setDoOutput(true);
     }
@@ -180,7 +206,7 @@
         TestRequest.Patch request = new TestRequest.Patch();
         assertEquals(request.getMethod(), Method.PATCH);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("PATCH");
         verify(mMockConnection, never()).setDoOutput(true);
     }
@@ -190,7 +216,7 @@
         TestRequest.PatchWithBody request = new TestRequest.PatchWithBody();
         assertEquals(request.getMethod(), Method.PATCH);
 
-        HurlStack.setConnectionParametersForRequest(mMockConnection, request);
+        mHurlStack.setConnectionParametersForRequest(mMockConnection, request);
         verify(mMockConnection).setRequestMethod("PATCH");
         verify(mMockConnection).setDoOutput(true);
     }
@@ -256,4 +282,56 @@
         expected.add(new Header("HeaderB", "ValueB_2"));
         assertEquals(expected, result);
     }
+
+    @Test
+    public void interceptResponseStream() throws Exception {
+        when(mMockConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK);
+        when(mMockConnection.getInputStream())
+                .thenReturn(new ByteArrayInputStream("hello".getBytes(StandardCharsets.UTF_8)));
+        HttpResponse response =
+                mHurlStack.executeRequest(
+                        new TestRequest.Get(), Collections.<String, String>emptyMap());
+        assertTrue(response.getContent() instanceof MonitoringInputStream);
+    }
+
+    @Test
+    public void interceptRequestStream() throws Exception {
+        MonitoredRequest request = new MonitoredRequest();
+        mHurlStack.executeRequest(request, Collections.<String, String>emptyMap());
+        assertTrue(request.totalRequestBytes > 0);
+        assertEquals(request.totalRequestBytes, request.requestBytesRead);
+    }
+
+    private static class MonitoringInputStream extends FilterInputStream {
+        private MonitoringInputStream(InputStream in) {
+            super(in);
+        }
+    }
+
+    private static class MonitoringOutputStream extends FilterOutputStream {
+        private MonitoredRequest request;
+
+        private MonitoringOutputStream(OutputStream out, MonitoredRequest request, int length) {
+            super(out);
+            this.request = request;
+            this.request.totalRequestBytes = length;
+        }
+
+        @Override
+        public void write(int b) throws IOException {
+            this.request.requestBytesRead++;
+            out.write(b);
+        }
+
+        @Override
+        public void write(byte[] b, int off, int len) throws IOException {
+            this.request.requestBytesRead += len;
+            out.write(b, off, len);
+        }
+    }
+
+    private static class MonitoredRequest extends TestRequest.PostWithBody {
+        int requestBytesRead = 0;
+        int totalRequestBytes = 0;
+    }
 }
diff --git a/src/test/java/com/android/volley/utils/CacheTestUtils.java b/src/test/java/com/android/volley/utils/CacheTestUtils.java
index 49ab996..5980712 100644
--- a/src/test/java/com/android/volley/utils/CacheTestUtils.java
+++ b/src/test/java/com/android/volley/utils/CacheTestUtils.java
@@ -16,6 +16,11 @@
 
 package com.android.volley.utils;
 
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThat;
+
 import com.android.volley.Cache;
 import java.util.Random;
 
@@ -51,4 +56,34 @@
     public static Cache.Entry makeRandomCacheEntry(byte[] data) {
         return makeRandomCacheEntry(data, false, false);
     }
+
+    public static void assertThatEntriesAreEqual(Cache.Entry actual, Cache.Entry expected) {
+        assertNotNull(actual);
+        assertThat(actual.data, is(equalTo(expected.data)));
+        assertThat(actual.etag, is(equalTo(expected.etag)));
+        assertThat(actual.lastModified, is(equalTo(expected.lastModified)));
+        assertThat(actual.responseHeaders, is(equalTo(expected.responseHeaders)));
+        assertThat(actual.serverDate, is(equalTo(expected.serverDate)));
+        assertThat(actual.softTtl, is(equalTo(expected.softTtl)));
+        assertThat(actual.ttl, is(equalTo(expected.ttl)));
+    }
+
+    public static Cache.Entry randomData(int length) {
+        Cache.Entry entry = new Cache.Entry();
+        byte[] data = new byte[length];
+        new Random(42).nextBytes(data); // explicit seed for reproducible results
+        entry.data = data;
+        return entry;
+    }
+
+    public static int getEntrySizeOnDisk(String key) {
+        // Header size is:
+        // 4 bytes for magic int
+        // 8 + len(key) bytes for key (long length)
+        // 8 bytes for etag (long length + 0 characters)
+        // 32 bytes for serverDate, lastModified, ttl, and softTtl longs
+        // 4 bytes for length of header list int
+        // == 56 + len(key) bytes total.
+        return 56 + key.length();
+    }
 }