diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2021-06-23 02:35:38 +0000 |
---|---|---|
committer | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2021-06-23 02:35:38 +0000 |
commit | 278cede333259621e4440f3b123e51007a16e904 (patch) | |
tree | bafe6d115be2559d39c73bfd437bb9d14e3d596a | |
parent | 5c532a6de0f207c1d42c3ef5578b82f46c8641d1 (diff) | |
parent | 5731f9f9aea90e074693bdfdeb3b58fd218e374d (diff) | |
download | volley-android12-mainline-neuralnetworks-release.tar.gz |
Snap for 7483611 from 5731f9f9aea90e074693bdfdeb3b58fd218e374d to mainline-neuralnetworks-releaseandroid-mainline-12.0.0_r92android-mainline-12.0.0_r78android-mainline-12.0.0_r50android-mainline-12.0.0_r33android-mainline-12.0.0_r3android12-mainline-neuralnetworks-release
Change-Id: I449d93fdf8ecb1e89cf338ab28860926faf3229f
43 files changed, 4166 insertions, 635 deletions
@@ -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 +} @@ -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 @@ publishing { 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 @@ android { 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 @@ distributionBase=GRADLE_USER_HOME 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 @@ tasks.withType(JavaCompile) { 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 @@ public interface Cache { * @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 @@ package com.android.volley; 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 @@ public class CacheDispatcher extends Thread { mNetworkQueue = networkQueue; mCache = cache; mDelivery = delivery; - mWaitingRequestManager = new WaitingRequestManager(this); + mWaitingRequestManager = new WaitingRequestManager(this, networkQueue, delivery); } /** @@ -159,6 +155,15 @@ public class CacheDispatcher extends Thread { 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 @@ public class CacheDispatcher extends Thread { 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 class NetworkResponse { 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 @@ public class NetworkResponse { 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 @@ public class NetworkResponse { */ @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 @@ public class NetworkResponse { * 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 @@ public class NetworkResponse { 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 @@ public class NetworkResponse { * 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 @@ public class NetworkResponse { /** 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 @@ public class NetworkResponse { 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 @@ public abstract class Request<T> implements Comparable<Request<T>> { /** 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 @@ public abstract class Request<T> implements Comparable<Request<T>> { * 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 @@ public abstract class Request<T> implements Comparable<Request<T>> { } /** Returns the annotated cache entry, or null if there isn't one. */ + @Nullable public Cache.Entry getCacheEntry() { return mCacheEntry; } @@ -374,6 +378,7 @@ public abstract class Request<T> implements Comparable<Request<T>> { * @deprecated Use {@link #getParams()} instead. */ @Deprecated + @Nullable protected Map<String, String> getPostParams() throws AuthFailureError { return getParams(); } @@ -431,6 +436,7 @@ public abstract class Request<T> implements Comparable<Request<T>> { * * @throws AuthFailureError in the event of auth failure */ + @Nullable protected Map<String, String> getParams() throws AuthFailureError { return null; } @@ -531,6 +537,25 @@ public abstract class Request<T> implements Comparable<Request<T>> { } /** + * 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 @@ public class RequestQueue { 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 @@ public class RequestQueue { 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 @@ public class Response<T> { } /** 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 @@ public class Response<T> { 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 @@ public class Response<T> { 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 @@ public class BasicNetwork implements Network { 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 @@ public class BasicNetwork implements Network { // 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,141 +137,12 @@ public class BasicNetwork implements Network { /* 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()); - } - } - } - } - - /** 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"); + // 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); } - mPool.returnBuf(buffer); - bytes.close(); } } @@ -321,49 +160,4 @@ public class BasicNetwork implements Network { } 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 @@ public class DiskBasedCache implements Cache { /** 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 @@ public class DiskBasedCache implements Cache { * 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 @@ public class DiskBasedCache implements Cache { 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 @@ public class DiskBasedCache implements Cache { */ @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 @@ public class DiskBasedCache implements Cache { 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 @@ public class DiskBasedCache implements Cache { /** 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 com.android.volley.VolleyLog; 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 @@ public class HttpHeaderParser { * @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 class HttpHeaderParser { 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 @@ public class HttpHeaderParser { * @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 @@ public class HttpHeaderParser { * 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 @@ public class HttpHeaderParser { } 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 @@ public final class HttpResponse { 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 @@ public final class HttpResponse { 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 @@ public final class HttpResponse { } /** + * 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.DataOutputStream; 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 @@ public class HurlStack extends BaseHttpStack { 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 @@ public class HurlStack extends BaseHttpStack { responseCode, convertHeaders(connection.getHeaderFields()), connection.getContentLength(), - new UrlConnectionInputStream(connection)); + createInputStream(request, connection)); } finally { if (!keepConnectionOpen) { connection.disconnect(); @@ -169,6 +164,19 @@ public class HurlStack extends BaseHttpStack { } /** + * 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 @@ public class HurlStack extends BaseHttpStack { // 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 @@ public class HurlStack extends BaseHttpStack { } } - 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 @@ public class HurlStack extends BaseHttpStack { } } - 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 @@ public class HurlStack extends BaseHttpStack { 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.os.Looper; 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 @@ public class ImageLoader { * 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 @@ public class Volley { } 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 @@ public class CacheDispatcherTest { 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.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; @@ -176,7 +177,7 @@ public class BasicNetworkTest { } @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 @@ public class BasicNetworkTest { } @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.RobolectricTestRunner; 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 class DiskBasedCacheTest { 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 @@ public class HttpHeaderParserTest { } @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 @@ public class HttpHeaderParserTest { // 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.verify; 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 @@ public class HurlStackTest { 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 class CacheTestUtils { 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(); + } } |