diff options
Diffstat (limited to 'src')
29 files changed, 1530 insertions, 484 deletions
diff --git a/src/main/java/com/android/volley/Cache.java b/src/main/java/com/android/volley/Cache.java index 8482c22..fd7eea1 100644 --- a/src/main/java/com/android/volley/Cache.java +++ b/src/main/java/com/android/volley/Cache.java @@ -17,6 +17,7 @@ package com.android.volley; import java.util.Collections; +import java.util.List; import java.util.Map; /** @@ -83,9 +84,22 @@ public interface Cache { /** Soft TTL for this record. */ public long softTtl; - /** Immutable response headers as received from server; must be non-null. */ + /** + * Response headers as received from server; must be non-null. Should not be mutated + * directly. + * + * <p>Note that if the server returns two headers with the same (case-insensitive) name, + * this map will only contain the one of them. {@link #allResponseHeaders} may contain all + * headers if the {@link Cache} implementation supports it. + */ public Map<String, String> responseHeaders = Collections.emptyMap(); + /** + * All response headers. May be null depending on the {@link Cache} implementation. Should + * not be mutated directly. + */ + public List<Header> allResponseHeaders; + /** True if the entry is expired. */ public boolean isExpired() { return this.ttl < System.currentTimeMillis(); diff --git a/src/main/java/com/android/volley/CacheDispatcher.java b/src/main/java/com/android/volley/CacheDispatcher.java index 1e7dfc4..51dfd9c 100644 --- a/src/main/java/com/android/volley/CacheDispatcher.java +++ b/src/main/java/com/android/volley/CacheDispatcher.java @@ -18,6 +18,10 @@ package com.android.volley; import android.os.Process; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.BlockingQueue; /** @@ -48,6 +52,9 @@ public class CacheDispatcher extends Thread { /** Used for telling us to die. */ private volatile boolean mQuit = false; + /** Manage list of waiting requests and de-duplicate requests with same cache key. */ + private final WaitingRequestManager mWaitingRequestManager; + /** * Creates a new cache triage dispatcher thread. You must call {@link #start()} * in order to begin processing. @@ -64,6 +71,7 @@ public class CacheDispatcher extends Thread { mNetworkQueue = networkQueue; mCache = cache; mDelivery = delivery; + mWaitingRequestManager = new WaitingRequestManager(this); } /** @@ -101,7 +109,9 @@ public class CacheDispatcher extends Thread { if (entry == null) { request.addMarker("cache-miss"); // Cache miss; send off to the network dispatcher. - mNetworkQueue.put(request); + if (!mWaitingRequestManager.maybeAddToWaitingRequests(request)) { + mNetworkQueue.put(request); + } continue; } @@ -109,7 +119,9 @@ public class CacheDispatcher extends Thread { if (entry.isExpired()) { request.addMarker("cache-hit-expired"); request.setCacheEntry(entry); - mNetworkQueue.put(request); + if (!mWaitingRequestManager.maybeAddToWaitingRequests(request)) { + mNetworkQueue.put(request); + } continue; } @@ -128,22 +140,28 @@ public class CacheDispatcher extends Thread { // refreshing. request.addMarker("cache-hit-refresh-needed"); request.setCacheEntry(entry); - // Mark the response as intermediate. response.intermediate = true; - // Post the intermediate response back to the user and have - // the delivery then forward the request along to the network. - mDelivery.postResponse(request, response, new Runnable() { - @Override - public void run() { - try { - mNetworkQueue.put(request); - } catch (InterruptedException e) { - // Not much we can do about this. + if (!mWaitingRequestManager.maybeAddToWaitingRequests(request)) { + // Post the intermediate response back to the user and have + // the delivery then forward the request along to the network. + mDelivery.postResponse(request, response, new Runnable() { + @Override + public void run() { + try { + mNetworkQueue.put(request); + } catch (InterruptedException e) { + // Restore the interrupted status + Thread.currentThread().interrupt(); + } } - } - }); + }); + } else { + // request has been added to list of waiting requests + // to receive the network response from the first request once it returns. + mDelivery.postResponse(request, response); + } } } catch (InterruptedException e) { @@ -154,4 +172,109 @@ public class CacheDispatcher extends Thread { } } } + + 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> + * <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.</li> + * </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); + 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<?>>(); + } + 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/Header.java b/src/main/java/com/android/volley/Header.java new file mode 100644 index 0000000..ac8aa11 --- /dev/null +++ b/src/main/java/com/android/volley/Header.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2017 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.text.TextUtils; + +/** An HTTP header. */ +public final class Header { + private final String mName; + private final String mValue; + + public Header(String name, String value) { + mName = name; + mValue = value; + } + + public final String getName() { + return mName; + } + + public final String getValue() { + return mValue; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Header header = (Header) o; + + return TextUtils.equals(mName, header.mName) + && TextUtils.equals(mValue, header.mValue); + } + + @Override + public int hashCode() { + int result = mName.hashCode(); + result = 31 * result + mValue.hashCode(); + return result; + } + + @Override + public String toString() { + return "Header[name=" + mName + ",value=" + mValue + "]"; + } +} diff --git a/src/main/java/com/android/volley/NetworkDispatcher.java b/src/main/java/com/android/volley/NetworkDispatcher.java index beb7861..0384429 100644 --- a/src/main/java/com/android/volley/NetworkDispatcher.java +++ b/src/main/java/com/android/volley/NetworkDispatcher.java @@ -33,6 +33,7 @@ import java.util.concurrent.BlockingQueue; * errors are posted back to the caller via a {@link ResponseDelivery}. */ public class NetworkDispatcher extends Thread { + /** The queue of requests to service. */ private final BlockingQueue<Request<?>> mQueue; /** The network interface for processing requests. */ @@ -54,8 +55,7 @@ public class NetworkDispatcher extends Thread { * @param delivery Delivery interface to use for posting responses */ public NetworkDispatcher(BlockingQueue<Request<?>> queue, - Network network, Cache cache, - ResponseDelivery delivery) { + Network network, Cache cache, ResponseDelivery delivery) { mQueue = queue; mNetwork = network; mCache = cache; @@ -103,6 +103,7 @@ public class NetworkDispatcher extends Thread { // network request. if (request.isCanceled()) { request.finish("network-discard-cancelled"); + request.notifyListenerResponseNotUsable(); continue; } @@ -116,6 +117,7 @@ public class NetworkDispatcher extends Thread { // we're done -- don't deliver a second identical response. if (networkResponse.notModified && request.hasHadResponseDelivered()) { request.finish("not-modified"); + request.notifyListenerResponseNotUsable(); continue; } @@ -133,14 +135,17 @@ public class NetworkDispatcher extends Thread { // Post the response back. request.markDelivered(); mDelivery.postResponse(request, response); + request.notifyListenerResponseReceived(response); } catch (VolleyError volleyError) { volleyError.setNetworkTimeMs(SystemClock.elapsedRealtime() - startTimeMs); parseAndDeliverNetworkError(request, volleyError); + request.notifyListenerResponseNotUsable(); } catch (Exception e) { VolleyLog.e(e, "Unhandled exception %s", e.toString()); VolleyError volleyError = new VolleyError(e); volleyError.setNetworkTimeMs(SystemClock.elapsedRealtime() - startTimeMs); mDelivery.postError(request, volleyError); + request.notifyListenerResponseNotUsable(); } } } diff --git a/src/main/java/com/android/volley/NetworkResponse.java b/src/main/java/com/android/volley/NetworkResponse.java index a787fa7..f0fded3 100644 --- a/src/main/java/com/android/volley/NetworkResponse.java +++ b/src/main/java/com/android/volley/NetworkResponse.java @@ -16,15 +16,18 @@ package com.android.volley; -import org.apache.http.HttpStatus; - +import java.net.HttpURLConnection; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Map; +import java.util.TreeMap; /** * Data and headers returned from {@link Network#performRequest(Request)}. */ public class NetworkResponse { + /** * Creates a new network response. * @param statusCode the HTTP status code @@ -32,27 +35,78 @@ public class NetworkResponse { * @param headers Headers returned with this response, or null for none * @param notModified True if the server returned a 304 and the data was already in cache * @param networkTimeMs Round-trip network time to receive network response + * @deprecated see {@link #NetworkResponse(int, byte[], boolean, long, List)}. This constructor + * cannot handle server responses containing multiple headers with the same name. + * This constructor may be removed in a future release of Volley. */ + @Deprecated public NetworkResponse(int statusCode, byte[] data, Map<String, String> headers, boolean notModified, long networkTimeMs) { - this.statusCode = statusCode; - this.data = data; - this.headers = headers; - this.notModified = notModified; - this.networkTimeMs = networkTimeMs; + this(statusCode, data, headers, toAllHeaderList(headers), notModified, networkTimeMs); + } + + /** + * Creates a new network response. + * @param statusCode the HTTP status code + * @param data Response body + * @param notModified True if the server returned a 304 and the data was already in cache + * @param networkTimeMs Round-trip network time to receive network response + * @param allHeaders All headers returned with this response, or null for none + */ + public NetworkResponse(int statusCode, byte[] data, boolean notModified, long networkTimeMs, + List<Header> allHeaders) { + this(statusCode, data, toHeaderMap(allHeaders), allHeaders, notModified, networkTimeMs); } + /** + * Creates a new network response. + * @param statusCode the HTTP status code + * @param data Response body + * @param headers Headers returned with this response, or null for none + * @param notModified True if the server returned a 304 and the data was already in cache + * @deprecated see {@link #NetworkResponse(int, byte[], boolean, long, List)}. This constructor + * cannot handle server responses containing multiple headers with the same name. + * This constructor may be removed in a future release of Volley. + */ + @Deprecated public NetworkResponse(int statusCode, byte[] data, Map<String, String> headers, boolean notModified) { this(statusCode, data, headers, notModified, 0); } + /** + * Creates a new network response for an OK response with no headers. + * @param data Response body + */ public NetworkResponse(byte[] data) { - this(HttpStatus.SC_OK, data, Collections.<String, String>emptyMap(), false, 0); + this(HttpURLConnection.HTTP_OK, data, false, 0, Collections.<Header>emptyList()); } + /** + * Creates a new network response for an OK response. + * @param data Response body + * @param headers Headers returned with this response, or null for none + * @deprecated see {@link #NetworkResponse(int, byte[], boolean, long, List)}. This constructor + * cannot handle server responses containing multiple headers with the same name. + * This constructor may be removed in a future release of Volley. + */ + @Deprecated public NetworkResponse(byte[] data, Map<String, String> headers) { - this(HttpStatus.SC_OK, data, headers, false, 0); + this(HttpURLConnection.HTTP_OK, data, headers, false, 0); + } + + private NetworkResponse(int statusCode, byte[] data, Map<String, String> headers, + List<Header> allHeaders, boolean notModified, long networkTimeMs) { + this.statusCode = statusCode; + this.data = data; + this.headers = headers; + if (allHeaders == null) { + this.allHeaders = null; + } else { + this.allHeaders = Collections.unmodifiableList(allHeaders); + } + this.notModified = notModified; + this.networkTimeMs = networkTimeMs; } /** The HTTP status code. */ @@ -61,13 +115,53 @@ public class NetworkResponse { /** Raw data from this response. */ public final byte[] data; - /** Response headers. */ + /** + * Response headers. + * + * <p>This map is case-insensitive. It should not be mutated directly. + * + * <p>Note that if the server returns two headers with the same (case-insensitive) name, this + * map will only contain the last one. Use {@link #allHeaders} to inspect all headers returned + * by the server. + */ public final Map<String, String> headers; + /** All response headers. Must not be mutated directly. */ + public final List<Header> allHeaders; + /** True if the server returned a 304 (Not Modified). */ public final boolean notModified; /** Network roundtrip time in milliseconds. */ public final long networkTimeMs; + + private static Map<String, String> toHeaderMap(List<Header> allHeaders) { + if (allHeaders == null) { + return null; + } + if (allHeaders.isEmpty()) { + return Collections.emptyMap(); + } + Map<String, String> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + // Later elements in the list take precedence. + for (Header header : allHeaders) { + headers.put(header.getName(), header.getValue()); + } + return headers; + } + + private static List<Header> toAllHeaderList(Map<String, String> headers) { + if (headers == null) { + return null; + } + if (headers.isEmpty()) { + return Collections.emptyList(); + } + List<Header> allHeaders = new ArrayList<>(headers.size()); + for (Map.Entry<String, String> header : headers.entrySet()) { + allHeaders.add(new Header(header.getKey(), header.getValue())); + } + return allHeaders; + } } diff --git a/src/main/java/com/android/volley/Request.java b/src/main/java/com/android/volley/Request.java index 8200f6e..a98277e 100644 --- a/src/main/java/com/android/volley/Request.java +++ b/src/main/java/com/android/volley/Request.java @@ -56,6 +56,18 @@ public abstract class Request<T> implements Comparable<Request<T>> { int PATCH = 7; } + /** + * Callback to notify when the network request returns. + */ + /* package */ interface NetworkRequestCompleteListener { + + /** Callback when a network response has been received. */ + void onResponseReceived(Request<?> request, Response<?> response); + + /** Callback when request returns from network without valid response. */ + void onNoUsableResponseReceived(Request<?> request); + } + /** An event log tracing the lifetime of this request; for debugging. */ private final MarkerLog mEventLog = MarkerLog.ENABLED ? new MarkerLog() : null; @@ -105,6 +117,12 @@ public abstract class Request<T> implements Comparable<Request<T>> { /** An opaque token tagging this request; used for bulk cancellation. */ private Object mTag; + /** Listener that will be notified when a response has been delivered. */ + private NetworkRequestCompleteListener mRequestCompleteListener; + + /** Object to guard access to mRequestCompleteListener. */ + private final Object mLock = new Object(); + /** * Creates a new request with the given URL and error listener. Note that * the normal response listener is not provided here as delivery of responses @@ -585,6 +603,42 @@ public abstract class Request<T> implements Comparable<Request<T>> { } /** + * {@link NetworkRequestCompleteListener} that will receive callbacks when the request + * returns from the network. + */ + /* package */ void setNetworkRequestCompleteListener( + NetworkRequestCompleteListener requestCompleteListener) { + synchronized (mLock) { + mRequestCompleteListener = requestCompleteListener; + } + } + + /** + * Notify NetworkRequestCompleteListener that a valid response has been received + * which can be used for other, waiting requests. + * @param response received from the network + */ + /* package */ void notifyListenerResponseReceived(Response<?> response) { + synchronized (mLock) { + if (mRequestCompleteListener != null) { + mRequestCompleteListener.onResponseReceived(this, response); + } + } + } + + /** + * Notify NetworkRequestCompleteListener that the network request did not result in + * a response which can be used for other, waiting requests. + */ + /* package */ void notifyListenerResponseNotUsable() { + synchronized (mLock) { + if (mRequestCompleteListener != null) { + mRequestCompleteListener.onNoUsableResponseReceived(this); + } + } + } + + /** * Our comparator sorts from high to low priority, and secondarily by * sequence number to provide FIFO ordering. */ diff --git a/src/main/java/com/android/volley/RequestQueue.java b/src/main/java/com/android/volley/RequestQueue.java index 0f2e756..45679a5 100644 --- a/src/main/java/com/android/volley/RequestQueue.java +++ b/src/main/java/com/android/volley/RequestQueue.java @@ -20,12 +20,8 @@ import android.os.Handler; import android.os.Looper; import java.util.ArrayList; -import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedList; import java.util.List; -import java.util.Map; -import java.util.Queue; import java.util.Set; import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.atomic.AtomicInteger; @@ -49,19 +45,6 @@ public class RequestQueue { private final AtomicInteger mSequenceGenerator = new AtomicInteger(); /** - * 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> - * <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.</li> - * </ul> - */ - private final Map<String, Queue<Request<?>>> mWaitingRequests = - new HashMap<>(); - - /** * The set of all requests currently being processed by this RequestQueue. A Request * will be in this set if it is waiting in any queue or currently being processed by * any dispatcher. @@ -240,37 +223,13 @@ public class RequestQueue { mNetworkQueue.add(request); return request; } - - // Insert request into stage if there's already a request with the same cache key in flight. - synchronized (mWaitingRequests) { - String cacheKey = request.getCacheKey(); - if (mWaitingRequests.containsKey(cacheKey)) { - // There is already a request in flight. Queue up. - Queue<Request<?>> stagedRequests = mWaitingRequests.get(cacheKey); - if (stagedRequests == null) { - stagedRequests = new LinkedList<>(); - } - stagedRequests.add(request); - mWaitingRequests.put(cacheKey, stagedRequests); - if (VolleyLog.DEBUG) { - VolleyLog.v("Request for cacheKey=%s is in flight, putting on hold.", cacheKey); - } - } else { - // Insert 'null' queue for this cacheKey, indicating there is now a request in - // flight. - mWaitingRequests.put(cacheKey, null); - mCacheQueue.add(request); - } - return request; - } - } + mCacheQueue.add(request); + return request; + } /** * Called from {@link Request#finish(String)}, indicating that processing of the given request * has finished. - * - * <p>Releases waiting requests for <code>request.getCacheKey()</code> if - * <code>request.shouldCache()</code>.</p> */ <T> void finish(Request<T> request) { // Remove from the set of requests currently being processed. @@ -283,21 +242,6 @@ public class RequestQueue { } } - if (request.shouldCache()) { - synchronized (mWaitingRequests) { - String cacheKey = request.getCacheKey(); - Queue<Request<?>> 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. They won't be considered as in flight, but - // that's not a problem as the cache has been primed by 'request'. - mCacheQueue.addAll(waitingRequests); - } - } - } } public <T> void addRequestFinishedListener(RequestFinishedListener<T> listener) { diff --git a/src/main/java/com/android/volley/toolbox/AdaptedHttpStack.java b/src/main/java/com/android/volley/toolbox/AdaptedHttpStack.java new file mode 100644 index 0000000..e5dc62b --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/AdaptedHttpStack.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2017 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 com.android.volley.AuthFailureError; +import com.android.volley.Header; +import com.android.volley.Request; + +import org.apache.http.conn.ConnectTimeoutException; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * {@link BaseHttpStack} implementation wrapping a {@link HttpStack}. + * + * <p>{@link BasicNetwork} uses this if it is provided a {@link HttpStack} at construction time, + * allowing it to have one implementation based atop {@link BaseHttpStack}. + */ +@SuppressWarnings("deprecation") +class AdaptedHttpStack extends BaseHttpStack { + + private final HttpStack mHttpStack; + + AdaptedHttpStack(HttpStack httpStack) { + mHttpStack = httpStack; + } + + @Override + public HttpResponse executeRequest( + Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError { + org.apache.http.HttpResponse apacheResp; + try { + apacheResp = mHttpStack.performRequest(request, additionalHeaders); + } catch (ConnectTimeoutException e) { + // BasicNetwork won't know that this exception should be retried like a timeout, since + // it's an Apache-specific error, so wrap it in a standard timeout exception. + throw new SocketTimeoutException(e.getMessage()); + } + + int statusCode = apacheResp.getStatusLine().getStatusCode(); + + org.apache.http.Header[] headers = apacheResp.getAllHeaders(); + List<Header> headerList = new ArrayList<>(headers.length); + for (org.apache.http.Header header : headers) { + headerList.add(new Header(header.getName(), header.getValue())); + } + + if (apacheResp.getEntity() == null) { + return new HttpResponse(statusCode, headerList); + } + + long contentLength = apacheResp.getEntity().getContentLength(); + if ((int) contentLength != contentLength) { + throw new IOException("Response too large: " + contentLength); + } + + return new HttpResponse( + statusCode, + headerList, + (int) apacheResp.getEntity().getContentLength(), + apacheResp.getEntity().getContent()); + } +} diff --git a/src/main/java/com/android/volley/toolbox/BaseHttpStack.java b/src/main/java/com/android/volley/toolbox/BaseHttpStack.java new file mode 100644 index 0000000..257f75c --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/BaseHttpStack.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2017 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 com.android.volley.AuthFailureError; +import com.android.volley.Header; +import com.android.volley.Request; + +import org.apache.http.ProtocolVersion; +import org.apache.http.StatusLine; +import org.apache.http.entity.BasicHttpEntity; +import org.apache.http.message.BasicHeader; +import org.apache.http.message.BasicHttpResponse; +import org.apache.http.message.BasicStatusLine; + +import java.io.IOException; +import java.io.InputStream; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** An HTTP stack abstraction. */ +@SuppressWarnings("deprecation") // for HttpStack +public abstract class BaseHttpStack implements HttpStack { + + /** + * Performs an HTTP request with the given parameters. + * + * <p>A GET request is sent if request.getPostBody() == null. A POST request is sent otherwise, + * and the Content-Type header is set to request.getPostBodyContentType(). + * + * @param request the request to perform + * @param additionalHeaders additional headers to be sent together with + * {@link Request#getHeaders()} + * @return the {@link HttpResponse} + * @throws SocketTimeoutException if the request times out + * @throws IOException if another I/O error occurs during the request + * @throws AuthFailureError if an authentication failure occurs during the request + */ + public abstract HttpResponse executeRequest( + Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError; + + /** + * @deprecated use {@link #executeRequest} instead to avoid a dependency on the deprecated + * Apache HTTP library. Nothing in Volley's own source calls this method. However, since + * {@link BasicNetwork#mHttpStack} is exposed to subclasses, we provide this implementation in + * case legacy client apps are dependent on that field. This method may be removed in a future + * release of Volley. + */ + @Deprecated + @Override + public final org.apache.http.HttpResponse performRequest( + Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError { + HttpResponse response = executeRequest(request, additionalHeaders); + + ProtocolVersion protocolVersion = new ProtocolVersion("HTTP", 1, 1); + StatusLine statusLine = new BasicStatusLine( + protocolVersion, response.getStatusCode(), "" /* reasonPhrase */); + BasicHttpResponse apacheResponse = new BasicHttpResponse(statusLine); + + List<org.apache.http.Header> headers = new ArrayList<>(); + for (Header header : response.getHeaders()) { + headers.add(new BasicHeader(header.getName(), header.getValue())); + } + apacheResponse.setHeaders(headers.toArray(new org.apache.http.Header[headers.size()])); + + InputStream responseStream = response.getContent(); + if (responseStream != null) { + BasicHttpEntity entity = new BasicHttpEntity(); + entity.setContent(responseStream); + entity.setContentLength(response.getContentLength()); + apacheResponse.setEntity(entity); + } + + return apacheResponse; + } +} diff --git a/src/main/java/com/android/volley/toolbox/BasicNetwork.java b/src/main/java/com/android/volley/toolbox/BasicNetwork.java index 96fb66e..5330733 100644 --- a/src/main/java/com/android/volley/toolbox/BasicNetwork.java +++ b/src/main/java/com/android/volley/toolbox/BasicNetwork.java @@ -22,6 +22,7 @@ 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; @@ -33,23 +34,19 @@ import com.android.volley.TimeoutError; import com.android.volley.VolleyError; import com.android.volley.VolleyLog; -import org.apache.http.Header; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.StatusLine; -import org.apache.http.conn.ConnectTimeoutException; -import org.apache.http.impl.cookie.DateUtils; - 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.Date; 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}. @@ -61,13 +58,23 @@ public class BasicNetwork implements Network { private static final int DEFAULT_POOL_SIZE = 4096; + /** + * @deprecated Should never have been exposed in the API. This field may be removed in a future + * release of Volley. + */ + @Deprecated protected final HttpStack mHttpStack; + private final BaseHttpStack mBaseHttpStack; + protected final ByteArrayPool mPool; /** * @param httpStack HTTP stack to be used + * @deprecated use {@link #BasicNetwork(BaseHttpStack)} instead to avoid depending on Apache + * HTTP. This method may be removed in a future release of Volley. */ + @Deprecated public BasicNetwork(HttpStack httpStack) { // If a pool isn't passed in, then build a small default pool that will give us a lot of // benefit and not use too much memory. @@ -77,9 +84,36 @@ public class BasicNetwork implements Network { /** * @param httpStack HTTP stack to be used * @param pool a buffer pool that improves GC performance in copy operations + * @deprecated use {@link #BasicNetwork(BaseHttpStack, ByteArrayPool)} instead to avoid + * depending on Apache HTTP. This method may be removed in a future release of + * Volley. */ + @Deprecated public BasicNetwork(HttpStack httpStack, ByteArrayPool pool) { mHttpStack = httpStack; + mBaseHttpStack = new AdaptedHttpStack(httpStack); + mPool = pool; + } + + /** + * @param httpStack HTTP stack to be used + */ + public BasicNetwork(BaseHttpStack httpStack) { + // If a pool isn't passed in, then build a small default pool that will give us a lot of + // benefit and not use too much memory. + this(httpStack, new ByteArrayPool(DEFAULT_POOL_SIZE)); + } + + /** + * @param httpStack HTTP stack to be used + * @param pool a buffer pool that improves GC performance in copy operations + */ + public BasicNetwork(BaseHttpStack httpStack, ByteArrayPool pool) { + mBaseHttpStack = httpStack; + // Populate mHttpStack for backwards compatibility, since it is a protected field. However, + // we won't use it directly here, so clients which don't access it directly won't need to + // depend on Apache HTTP. + mHttpStack = httpStack; mPool = pool; } @@ -89,39 +123,33 @@ public class BasicNetwork implements Network { while (true) { HttpResponse httpResponse = null; byte[] responseContents = null; - Map<String, String> responseHeaders = Collections.emptyMap(); + List<Header> responseHeaders = Collections.emptyList(); try { // Gather headers. - Map<String, String> headers = new HashMap<String, String>(); - addCacheHeaders(headers, request.getCacheEntry()); - httpResponse = mHttpStack.performRequest(request, headers); - StatusLine statusLine = httpResponse.getStatusLine(); - int statusCode = statusLine.getStatusCode(); + Map<String, String> additionalRequestHeaders = + getCacheHeaders(request.getCacheEntry()); + httpResponse = mBaseHttpStack.executeRequest(request, additionalRequestHeaders); + int statusCode = httpResponse.getStatusCode(); - responseHeaders = convertHeaders(httpResponse.getAllHeaders()); + responseHeaders = httpResponse.getHeaders(); // Handle cache validation. - if (statusCode == HttpStatus.SC_NOT_MODIFIED) { - + if (statusCode == HttpURLConnection.HTTP_NOT_MODIFIED) { Entry entry = request.getCacheEntry(); if (entry == null) { - return new NetworkResponse(HttpStatus.SC_NOT_MODIFIED, null, - responseHeaders, true, - SystemClock.elapsedRealtime() - requestStart); + return new NetworkResponse(HttpURLConnection.HTTP_NOT_MODIFIED, null, true, + SystemClock.elapsedRealtime() - requestStart, responseHeaders); } - - // A 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. - // http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5 - entry.responseHeaders.putAll(responseHeaders); - return new NetworkResponse(HttpStatus.SC_NOT_MODIFIED, entry.data, - entry.responseHeaders, true, - SystemClock.elapsedRealtime() - requestStart); + // 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, + true, SystemClock.elapsedRealtime() - requestStart, combinedHeaders); } // Some responses such as 204s do not have content. We must check. - if (httpResponse.getEntity() != null) { - responseContents = entityToBytes(httpResponse.getEntity()); + InputStream inputStream = httpResponse.getContent(); + if (inputStream != null) { + responseContents = + inputStreamToBytes(inputStream, httpResponse.getContentLength()); } else { // Add 0 byte response as a way of honestly representing a // no-content request. @@ -130,33 +158,31 @@ public class BasicNetwork implements Network { // if the request is slow, log it. long requestLifetime = SystemClock.elapsedRealtime() - requestStart; - logSlowRequests(requestLifetime, request, responseContents, statusLine); + logSlowRequests(requestLifetime, request, responseContents, statusCode); if (statusCode < 200 || statusCode > 299) { throw new IOException(); } - return new NetworkResponse(statusCode, responseContents, responseHeaders, false, - SystemClock.elapsedRealtime() - requestStart); + return new NetworkResponse(statusCode, responseContents, false, + SystemClock.elapsedRealtime() - requestStart, responseHeaders); } catch (SocketTimeoutException e) { attemptRetryOnException("socket", request, new TimeoutError()); - } catch (ConnectTimeoutException e) { - attemptRetryOnException("connection", request, new TimeoutError()); } catch (MalformedURLException e) { throw new RuntimeException("Bad URL " + request.getUrl(), e); } catch (IOException e) { int statusCode; if (httpResponse != null) { - statusCode = httpResponse.getStatusLine().getStatusCode(); + 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, - responseHeaders, false, SystemClock.elapsedRealtime() - requestStart); - if (statusCode == HttpStatus.SC_UNAUTHORIZED || - statusCode == HttpStatus.SC_FORBIDDEN) { + networkResponse = new NetworkResponse(statusCode, responseContents, 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) { @@ -184,12 +210,12 @@ public class BasicNetwork implements Network { * Logs requests that took over SLOW_REQUEST_THRESHOLD_MS to complete. */ private void logSlowRequests(long requestLifetime, Request<?> request, - byte[] responseContents, StatusLine statusLine) { + 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", - statusLine.getStatusCode(), request.getRetryPolicy().getCurrentRetryCount()); + statusCode, request.getRetryPolicy().getCurrentRetryCount()); } } @@ -213,20 +239,24 @@ public class BasicNetwork implements Network { request.addMarker(String.format("%s-retry [timeout=%s]", logPrefix, oldTimeout)); } - private void addCacheHeaders(Map<String, String> headers, Cache.Entry entry) { + private Map<String, String> getCacheHeaders(Cache.Entry entry) { // If there's no cache entry, we're done. if (entry == null) { - return; + return Collections.emptyMap(); } + Map<String, String> headers = new HashMap<>(); + if (entry.etag != null) { headers.put("If-None-Match", entry.etag); } if (entry.lastModified > 0) { - Date refTime = new Date(entry.lastModified); - headers.put("If-Modified-Since", DateUtils.formatDate(refTime)); + headers.put("If-Modified-Since", + HttpHeaderParser.formatEpochAsRfc1123(entry.lastModified)); } + + return headers; } protected void logError(String what, String url, long start) { @@ -234,13 +264,13 @@ public class BasicNetwork implements Network { VolleyLog.v("HTTP ERROR(%s) %d ms to fetch %s", what, (now - start), url); } - /** Reads the contents of HttpEntity into a byte[]. */ - private byte[] entityToBytes(HttpEntity entity) throws IOException, ServerError { + /** Reads the contents of an InputStream into a byte[]. */ + private byte[] inputStreamToBytes(InputStream in, int contentLength) + throws IOException, ServerError { PoolingByteArrayOutputStream bytes = - new PoolingByteArrayOutputStream(mPool, (int) entity.getContentLength()); + new PoolingByteArrayOutputStream(mPool, contentLength); byte[] buffer = null; try { - InputStream in = entity.getContent(); if (in == null) { throw new ServerError(); } @@ -253,11 +283,13 @@ public class BasicNetwork implements Network { } finally { try { // Close the InputStream and release the resources by "consuming the content". - entity.consumeContent(); + if (in != null) { + in.close(); + } } catch (IOException e) { - // This can happen if there was an exception above that left the entity in + // This can happen if there was an exception above that left the stream in // an invalid state. - VolleyLog.v("Error occurred when calling consumingContent"); + VolleyLog.v("Error occurred when closing InputStream"); } mPool.returnBuf(buffer); bytes.close(); @@ -266,12 +298,62 @@ public class BasicNetwork implements Network { /** * Converts Headers[] to Map<String, String>. + * + * @deprecated Should never have been exposed in the API. This method may be removed in a future + * release of Volley. */ + @Deprecated protected static Map<String, String> convertHeaders(Header[] headers) { - Map<String, String> result = new TreeMap<String, String>(String.CASE_INSENSITIVE_ORDER); + Map<String, String> result = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); for (int i = 0; i < headers.length; i++) { result.put(headers[i].getName(), headers[i].getValue()); } 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 0e65183..a6cd960 100644 --- a/src/main/java/com/android/volley/toolbox/DiskBasedCache.java +++ b/src/main/java/com/android/volley/toolbox/DiskBasedCache.java @@ -20,6 +20,7 @@ import android.os.SystemClock; import android.text.TextUtils; import com.android.volley.Cache; +import com.android.volley.Header; import com.android.volley.VolleyLog; import java.io.BufferedInputStream; @@ -34,15 +35,18 @@ import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** * Cache implementation that caches files directly onto the hard disk in the specified * directory. The default disk usage size is 5MB, but is configurable. + * + * <p>This cache supports the {@link Entry#allResponseHeaders} headers field. */ public class DiskBasedCache implements Cache { @@ -379,30 +383,40 @@ public class DiskBasedCache implements Cache { final long softTtl; /** Headers from the response resulting in this cache entry. */ - final Map<String, String> responseHeaders; + final List<Header> allResponseHeaders; private CacheHeader(String key, String etag, long serverDate, long lastModified, long ttl, - long softTtl, Map<String, String> responseHeaders) { + long softTtl, List<Header> allResponseHeaders) { this.key = key; this.etag = ("".equals(etag)) ? null : etag; this.serverDate = serverDate; this.lastModified = lastModified; this.ttl = ttl; this.softTtl = softTtl; - this.responseHeaders = responseHeaders; + this.allResponseHeaders = allResponseHeaders; } /** - * Instantiates a new CacheHeader object + * Instantiates a new CacheHeader object. * @param key The key that identifies the cache entry * @param entry The cache entry. */ CacheHeader(String key, Entry entry) { this(key, entry.etag, entry.serverDate, entry.lastModified, entry.ttl, entry.softTtl, - entry.responseHeaders); + getAllResponseHeaders(entry)); size = entry.data.length; } + private static List<Header> getAllResponseHeaders(Entry entry) { + // If the entry contains all the response headers, use that field directly. + if (entry.allResponseHeaders != null) { + return entry.allResponseHeaders; + } + + // Legacy fallback - copy headers from the map. + return HttpHeaderParser.toAllHeaderList(entry.responseHeaders); + } + /** * Reads the header from a CountingInputStream and returns a CacheHeader object. * @param is The InputStream to read from. @@ -420,9 +434,9 @@ public class DiskBasedCache implements Cache { long lastModified = readLong(is); long ttl = readLong(is); long softTtl = readLong(is); - Map<String, String> responseHeaders = readStringStringMap(is); + List<Header> allResponseHeaders = readHeaderList(is); return new CacheHeader( - key, etag, serverDate, lastModified, ttl, softTtl, responseHeaders); + key, etag, serverDate, lastModified, ttl, softTtl, allResponseHeaders); } /** @@ -436,11 +450,11 @@ public class DiskBasedCache implements Cache { e.lastModified = lastModified; e.ttl = ttl; e.softTtl = softTtl; - e.responseHeaders = responseHeaders; + e.responseHeaders = HttpHeaderParser.toHeaderMap(allResponseHeaders); + e.allResponseHeaders = Collections.unmodifiableList(allResponseHeaders); return e; } - /** * Writes the contents of this CacheHeader to the specified OutputStream. */ @@ -453,7 +467,7 @@ public class DiskBasedCache implements Cache { writeLong(os, lastModified); writeLong(os, ttl); writeLong(os, softTtl); - writeStringStringMap(responseHeaders, os); + writeHeaderList(allResponseHeaders, os); os.flush(); return true; } catch (IOException e) { @@ -574,27 +588,27 @@ public class DiskBasedCache implements Cache { return new String(b, "UTF-8"); } - static void writeStringStringMap(Map<String, String> map, OutputStream os) throws IOException { - if (map != null) { - writeInt(os, map.size()); - for (Map.Entry<String, String> entry : map.entrySet()) { - writeString(os, entry.getKey()); - writeString(os, entry.getValue()); + static void writeHeaderList(List<Header> headers, OutputStream os) throws IOException { + if (headers != null) { + writeInt(os, headers.size()); + for (Header header : headers) { + writeString(os, header.getName()); + writeString(os, header.getValue()); } } else { writeInt(os, 0); } } - static Map<String, String> readStringStringMap(CountingInputStream cis) throws IOException { + static List<Header> readHeaderList(CountingInputStream cis) throws IOException { int size = readInt(cis); - Map<String, String> result = (size == 0) - ? Collections.<String, String>emptyMap() - : new HashMap<String, String>(size); + List<Header> result = (size == 0) + ? Collections.<Header>emptyList() + : new ArrayList<Header>(size); for (int i = 0; i < size; i++) { - String key = readString(cis).intern(); + String name = readString(cis).intern(); String value = readString(cis).intern(); - result.put(key, value); + result.add(new Header(name, value)); } return result; } diff --git a/src/main/java/com/android/volley/toolbox/HttpClientStack.java b/src/main/java/com/android/volley/toolbox/HttpClientStack.java index 377110e..023ee21 100644 --- a/src/main/java/com/android/volley/toolbox/HttpClientStack.java +++ b/src/main/java/com/android/volley/toolbox/HttpClientStack.java @@ -46,7 +46,11 @@ import java.util.Map; /** * An HttpStack that performs request over an {@link HttpClient}. + * + * @deprecated The Apache HTTP library on Android is deprecated. Use {@link HurlStack} or another + * {@link BaseHttpStack} implementation. */ +@Deprecated public class HttpClientStack implements HttpStack { protected final HttpClient mClient; diff --git a/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java b/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java index f53063c..211c329 100644 --- a/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java +++ b/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java @@ -17,19 +17,31 @@ package com.android.volley.toolbox; import com.android.volley.Cache; +import com.android.volley.Header; import com.android.volley.NetworkResponse; - -import org.apache.http.impl.cookie.DateParseException; -import org.apache.http.impl.cookie.DateUtils; -import org.apache.http.protocol.HTTP; - +import com.android.volley.VolleyLog; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.TimeZone; +import java.util.TreeMap; /** * Utility methods for parsing HTTP headers. */ public class HttpHeaderParser { + 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"; + /** * Extracts a {@link com.android.volley.Cache.Entry} from a {@link NetworkResponse}. * @@ -116,6 +128,7 @@ public class HttpHeaderParser { entry.serverDate = serverDate; entry.lastModified = lastModified; entry.responseHeaders = headers; + entry.allResponseHeaders = response.allHeaders; return entry; } @@ -126,13 +139,26 @@ public class HttpHeaderParser { public static long parseDateAsEpoch(String dateStr) { try { // Parse date in RFC1123 format if this header contains one - return DateUtils.parseDate(dateStr).getTime(); - } catch (DateParseException e) { + return newRfc1123Formatter().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); return 0; } } + /** Format an epoch date in RFC1123 format. */ + static String formatEpochAsRfc1123(long epoch) { + return newRfc1123Formatter().format(new Date(epoch)); + } + + private static SimpleDateFormat newRfc1123Formatter() { + SimpleDateFormat formatter = + new SimpleDateFormat(RFC1123_FORMAT, Locale.US); + formatter.setTimeZone(TimeZone.getTimeZone("GMT")); + return formatter; + } + /** * Retrieve a charset from headers * @@ -142,7 +168,7 @@ public class HttpHeaderParser { * or the defaultCharset if none can be found. */ public static String parseCharset(Map<String, String> headers, String defaultCharset) { - String contentType = headers.get(HTTP.CONTENT_TYPE); + String contentType = headers.get(HEADER_CONTENT_TYPE); if (contentType != null) { String[] params = contentType.split(";"); for (int i = 1; i < params.length; i++) { @@ -163,6 +189,28 @@ public class HttpHeaderParser { * or the HTTP default (ISO-8859-1) if none can be found. */ public static String parseCharset(Map<String, String> headers) { - return parseCharset(headers, HTTP.DEFAULT_CONTENT_CHARSET); + return parseCharset(headers, DEFAULT_CONTENT_CHARSET); + } + + // Note - these are copied from NetworkResponse to avoid making them public (as needed to access + // them from the .toolbox package), which would mean they'd become part of the Volley API. + // TODO: Consider obfuscating official releases so we can share utility methods between Volley + // and Toolbox without making them public APIs. + + static Map<String, String> toHeaderMap(List<Header> allHeaders) { + Map<String, String> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + // Later elements in the list take precedence. + for (Header header : allHeaders) { + headers.put(header.getName(), header.getValue()); + } + return headers; + } + + static List<Header> toAllHeaderList(Map<String, String> headers) { + List<Header> allHeaders = new ArrayList<>(headers.size()); + for (Map.Entry<String, String> header : headers.entrySet()) { + allHeaders.add(new Header(header.getKey(), header.getValue())); + } + return allHeaders; } } diff --git a/src/main/java/com/android/volley/toolbox/HttpResponse.java b/src/main/java/com/android/volley/toolbox/HttpResponse.java new file mode 100644 index 0000000..db719bc --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/HttpResponse.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2017 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 com.android.volley.Header; + +import java.io.InputStream; +import java.util.Collections; +import java.util.List; + +/** A response from an HTTP server. */ +public final class HttpResponse { + + private final int mStatusCode; + private final List<Header> mHeaders; + private final int mContentLength; + private final InputStream mContent; + + /** + * Construct a new HttpResponse for an empty response body. + * + * @param statusCode the HTTP status code of the response + * @param headers the response headers + */ + public HttpResponse(int statusCode, List<Header> headers) { + this(statusCode, headers, -1 /* contentLength */, null /* content */); + } + + /** + * Construct a new HttpResponse. + * + * @param statusCode the HTTP status code of the response + * @param headers the response headers + * @param contentLength the length of the response content. Ignored if there is no content. + * @param content an {@link InputStream} of the response content. May be null to indicate that + * the response has no content. + */ + public HttpResponse( + int statusCode, List<Header> headers, int contentLength, InputStream content) { + mStatusCode = statusCode; + mHeaders = headers; + mContentLength = contentLength; + mContent = content; + } + + /** Returns the HTTP status code of the response. */ + public final int getStatusCode() { + return mStatusCode; + } + + /** Returns the response headers. Must not be mutated directly. */ + public final List<Header> getHeaders() { + return Collections.unmodifiableList(mHeaders); + } + + /** Returns the length of the content. Only valid if {@link #getContent} is non-null. */ + public final int getContentLength() { + return mContentLength; + } + + /** + * Returns an {@link InputStream} of the response content. May be null to indicate that the + * response has no content. + */ + public final InputStream getContent() { + return mContent; + } +} diff --git a/src/main/java/com/android/volley/toolbox/HttpStack.java b/src/main/java/com/android/volley/toolbox/HttpStack.java index 06f6017..5d34b44 100644 --- a/src/main/java/com/android/volley/toolbox/HttpStack.java +++ b/src/main/java/com/android/volley/toolbox/HttpStack.java @@ -26,7 +26,12 @@ import java.util.Map; /** * An HTTP stack abstraction. + * + * @deprecated This interface should be avoided as it depends on the deprecated Apache HTTP library. + * Use {@link BaseHttpStack} to avoid this dependency. This class may be removed in a future + * release of Volley. */ +@Deprecated public interface HttpStack { /** * Performs an HTTP request with the given parameters. diff --git a/src/main/java/com/android/volley/toolbox/HurlStack.java b/src/main/java/com/android/volley/toolbox/HurlStack.java index 66f441d..a975a71 100644 --- a/src/main/java/com/android/volley/toolbox/HurlStack.java +++ b/src/main/java/com/android/volley/toolbox/HurlStack.java @@ -17,29 +17,19 @@ package com.android.volley.toolbox; import com.android.volley.AuthFailureError; +import com.android.volley.Header; import com.android.volley.Request; import com.android.volley.Request.Method; -import org.apache.http.Header; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.ProtocolVersion; -import org.apache.http.StatusLine; -import org.apache.http.entity.BasicHttpEntity; -import org.apache.http.message.BasicHeader; -import org.apache.http.message.BasicHttpResponse; -import org.apache.http.message.BasicStatusLine; - import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Map.Entry; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; @@ -47,9 +37,9 @@ import javax.net.ssl.SSLSocketFactory; /** * An {@link HttpStack} based on {@link HttpURLConnection}. */ -public class HurlStack implements HttpStack { +public class HurlStack extends BaseHttpStack { - private static final String HEADER_CONTENT_TYPE = "Content-Type"; + private static final int HTTP_CONTINUE = 100; /** * An interface for transforming URLs before use. @@ -86,10 +76,10 @@ public class HurlStack implements HttpStack { } @Override - public HttpResponse performRequest(Request<?> request, Map<String, String> additionalHeaders) + public HttpResponse executeRequest(Request<?> request, Map<String, String> additionalHeaders) throws IOException, AuthFailureError { String url = request.getUrl(); - HashMap<String, String> map = new HashMap<String, String>(); + HashMap<String, String> map = new HashMap<>(); map.putAll(request.getHeaders()); map.putAll(additionalHeaders); if (mUrlRewriter != null) { @@ -106,26 +96,34 @@ public class HurlStack implements HttpStack { } setConnectionParametersForRequest(connection, request); // Initialize HttpResponse with data from the HttpURLConnection. - ProtocolVersion protocolVersion = new ProtocolVersion("HTTP", 1, 1); int responseCode = connection.getResponseCode(); if (responseCode == -1) { // -1 is returned by getResponseCode() if the response code could not be retrieved. // Signal to the caller that something was wrong with the connection. throw new IOException("Could not retrieve response code from HttpUrlConnection."); } - StatusLine responseStatus = new BasicStatusLine(protocolVersion, - connection.getResponseCode(), connection.getResponseMessage()); - BasicHttpResponse response = new BasicHttpResponse(responseStatus); - if (hasResponseBody(request.getMethod(), responseStatus.getStatusCode())) { - response.setEntity(entityFromConnection(connection)); + + if (!hasResponseBody(request.getMethod(), responseCode)) { + return new HttpResponse(responseCode, convertHeaders(connection.getHeaderFields())); } - for (Entry<String, List<String>> header : connection.getHeaderFields().entrySet()) { - if (header.getKey() != null) { - Header h = new BasicHeader(header.getKey(), header.getValue().get(0)); - response.addHeader(h); + + return new HttpResponse(responseCode, convertHeaders(connection.getHeaderFields()), + connection.getContentLength(), inputStreamFromConnection(connection)); + } + + // VisibleForTesting + static List<Header> convertHeaders(Map<String, List<String>> responseHeaders) { + List<Header> headerList = new ArrayList<>(responseHeaders.size()); + for (Map.Entry<String, List<String>> entry : responseHeaders.entrySet()) { + // HttpUrlConnection includes the status line as a header with a null key; omit it here + // since it's not really a header and the rest of Volley assumes non-null keys. + if (entry.getKey() != null) { + for (String value : entry.getValue()) { + headerList.add(new Header(entry.getKey(), value)); + } } } - return response; + return headerList; } /** @@ -137,29 +135,24 @@ public class HurlStack implements HttpStack { */ private static boolean hasResponseBody(int requestMethod, int responseCode) { return requestMethod != Request.Method.HEAD - && !(HttpStatus.SC_CONTINUE <= responseCode && responseCode < HttpStatus.SC_OK) - && responseCode != HttpStatus.SC_NO_CONTENT - && responseCode != HttpStatus.SC_NOT_MODIFIED; + && !(HTTP_CONTINUE <= responseCode && responseCode < HttpURLConnection.HTTP_OK) + && responseCode != HttpURLConnection.HTTP_NO_CONTENT + && responseCode != HttpURLConnection.HTTP_NOT_MODIFIED; } /** - * Initializes an {@link HttpEntity} from the given {@link HttpURLConnection}. + * Initializes an {@link InputStream} from the given {@link HttpURLConnection}. * @param connection * @return an HttpEntity populated with data from <code>connection</code>. */ - private static HttpEntity entityFromConnection(HttpURLConnection connection) { - BasicHttpEntity entity = new BasicHttpEntity(); + private static InputStream inputStreamFromConnection(HttpURLConnection connection) { InputStream inputStream; try { inputStream = connection.getInputStream(); } catch (IOException ioe) { inputStream = connection.getErrorStream(); } - entity.setContent(inputStream); - entity.setContentLength(connection.getContentLength()); - entity.setContentEncoding(connection.getContentEncoding()); - entity.setContentType(connection.getContentType()); - return entity; + return inputStream; } /** @@ -261,7 +254,8 @@ public class HurlStack implements HttpStack { // since this is handled by HttpURLConnection using the size of the prepared // output stream. connection.setDoOutput(true); - connection.addRequestProperty(HEADER_CONTENT_TYPE, request.getBodyContentType()); + connection.addRequestProperty( + HttpHeaderParser.HEADER_CONTENT_TYPE, request.getBodyContentType()); DataOutputStream out = new DataOutputStream(connection.getOutputStream()); out.write(body); out.close(); diff --git a/src/main/java/com/android/volley/toolbox/Volley.java b/src/main/java/com/android/volley/toolbox/Volley.java index 0e04e87..6ec08b1 100644 --- a/src/main/java/com/android/volley/toolbox/Volley.java +++ b/src/main/java/com/android/volley/toolbox/Volley.java @@ -36,35 +36,59 @@ public class Volley { * Creates a default instance of the worker pool and calls {@link RequestQueue#start()} on it. * * @param context A {@link Context} to use for creating the cache dir. - * @param stack An {@link HttpStack} to use for the network, or null for default. + * @param stack A {@link BaseHttpStack} to use for the network, or null for default. * @return A started {@link RequestQueue} instance. */ - public static RequestQueue newRequestQueue(Context context, HttpStack stack) { - File cacheDir = new File(context.getCacheDir(), DEFAULT_CACHE_DIR); - - String userAgent = "volley/0"; - try { - String packageName = context.getPackageName(); - PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0); - userAgent = packageName + "/" + info.versionCode; - } catch (NameNotFoundException e) { - } - + public static RequestQueue newRequestQueue(Context context, BaseHttpStack stack) { + BasicNetwork network; if (stack == null) { if (Build.VERSION.SDK_INT >= 9) { - stack = new HurlStack(); + network = new BasicNetwork(new HurlStack()); } else { // Prior to Gingerbread, HttpUrlConnection was unreliable. // See: http://android-developers.blogspot.com/2011/09/androids-http-clients.html - stack = new HttpClientStack(AndroidHttpClient.newInstance(userAgent)); + // At some point in the future we'll move our minSdkVersion past Froyo and can + // delete this fallback (along with all Apache HTTP code). + String userAgent = "volley/0"; + try { + String packageName = context.getPackageName(); + PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0); + userAgent = packageName + "/" + info.versionCode; + } catch (NameNotFoundException e) { + } + + network = new BasicNetwork( + new HttpClientStack(AndroidHttpClient.newInstance(userAgent))); } + } else { + network = new BasicNetwork(stack); } - Network network = new BasicNetwork(stack); + return newRequestQueue(context, network); + } + + /** + * Creates a default instance of the worker pool and calls {@link RequestQueue#start()} on it. + * + * @param context A {@link Context} to use for creating the cache dir. + * @param stack An {@link HttpStack} to use for the network, or null for default. + * @return A started {@link RequestQueue} instance. + * @deprecated Use {@link #newRequestQueue(Context, BaseHttpStack)} instead to avoid depending + * on Apache HTTP. This method may be removed in a future release of Volley. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static RequestQueue newRequestQueue(Context context, HttpStack stack) { + if (stack == null) { + return newRequestQueue(context, (BaseHttpStack) null); + } + return newRequestQueue(context, new BasicNetwork(stack)); + } + 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); queue.start(); - return queue; } @@ -75,6 +99,6 @@ public class Volley { * @return A started {@link RequestQueue} instance. */ public static RequestQueue newRequestQueue(Context context) { - return newRequestQueue(context, null); + return newRequestQueue(context, (BaseHttpStack) null); } } diff --git a/src/test/java/com/android/volley/CacheDispatcherTest.java b/src/test/java/com/android/volley/CacheDispatcherTest.java index 42bdda0..54886f8 100644 --- a/src/test/java/com/android/volley/CacheDispatcherTest.java +++ b/src/test/java/com/android/volley/CacheDispatcherTest.java @@ -112,4 +112,60 @@ public class CacheDispatcherTest { Request request = mNetworkQueue.take(); assertSame(entry, request.getCacheEntry()); } + + @Test public void duplicateCacheMiss() throws Exception { + MockRequest secondRequest = new MockRequest(); + mRequest.setSequence(1); + secondRequest.setSequence(2); + mCacheQueue.add(mRequest); + mCacheQueue.add(secondRequest); + mCacheQueue.waitUntilEmpty(TIMEOUT_MILLIS); + assertTrue(mNetworkQueue.size() == 1); + assertFalse(mDelivery.postResponse_called); + } + + @Test public void duplicateSoftExpiredCacheHit_failedRequest() throws Exception { + Cache.Entry entry = CacheTestUtils.makeRandomCacheEntry(null, false, true); + mCache.setEntryToReturn(entry); + + MockRequest secondRequest = new MockRequest(); + mRequest.setSequence(1); + secondRequest.setSequence(2); + + mCacheQueue.add(mRequest); + mCacheQueue.add(secondRequest); + mCacheQueue.waitUntilEmpty(TIMEOUT_MILLIS); + + assertTrue(mNetworkQueue.size() == 1); + assertTrue(mDelivery.postResponse_calledNtimes == 2); + + Request request = mNetworkQueue.take(); + request.notifyListenerResponseNotUsable(); + // Second request should now be in network queue. + assertTrue(mNetworkQueue.size() == 1); + request = mNetworkQueue.take(); + assertTrue(request.equals(secondRequest)); + } + + @Test public void duplicateSoftExpiredCacheHit_successfulRequest() throws Exception { + Cache.Entry entry = CacheTestUtils.makeRandomCacheEntry(null, false, true); + mCache.setEntryToReturn(entry); + + MockRequest secondRequest = new MockRequest(); + mRequest.setSequence(1); + secondRequest.setSequence(2); + + mCacheQueue.add(mRequest); + mCacheQueue.add(secondRequest); + mCacheQueue.waitUntilEmpty(TIMEOUT_MILLIS); + + assertTrue(mNetworkQueue.size() == 1); + assertTrue(mDelivery.postResponse_calledNtimes == 2); + + Request request = mNetworkQueue.take(); + request.notifyListenerResponseReceived(Response.success(null, entry)); + // Second request should have delivered response. + assertTrue(mNetworkQueue.size() == 0); + assertTrue(mDelivery.postResponse_calledNtimes == 3); + } } diff --git a/src/test/java/com/android/volley/NetworkResponseTest.java b/src/test/java/com/android/volley/NetworkResponseTest.java new file mode 100644 index 0000000..be34143 --- /dev/null +++ b/src/test/java/com/android/volley/NetworkResponseTest.java @@ -0,0 +1,63 @@ +package com.android.volley; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; + +@RunWith(RobolectricTestRunner.class) +public class NetworkResponseTest { + + @SuppressWarnings("deprecation") + @Test + public void mapToList() { + Map<String, String> headers = new HashMap<>(); + headers.put("key1", "value1"); + headers.put("key2", "value2"); + + NetworkResponse resp = new NetworkResponse(200, null, headers, false); + + List<Header> expectedHeaders = new ArrayList<>(); + expectedHeaders.add(new Header("key1", "value1")); + expectedHeaders.add(new Header("key2", "value2")); + + assertThat(expectedHeaders, + containsInAnyOrder(resp.allHeaders.toArray(new Header[resp.allHeaders.size()]))); + } + + @Test + public void listToMap() { + List<Header> headers = new ArrayList<>(); + headers.add(new Header("key1", "value1")); + // Later values should be preferred. + headers.add(new Header("key2", "ignoredvalue")); + headers.add(new Header("key2", "value2")); + + NetworkResponse resp = new NetworkResponse(200, null, false, 0L, headers); + + Map<String, String> expectedHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + expectedHeaders.put("key1", "value1"); + expectedHeaders.put("key2", "value2"); + + assertEquals(expectedHeaders, resp.headers); + } + + @SuppressWarnings("deprecation") + @Test + public void nullValuesDontCrash() { + new NetworkResponse(null); + new NetworkResponse(null, null); + new NetworkResponse(200, null, null, false); + new NetworkResponse(200, null, null, false, 0L); + new NetworkResponse(200, null, false, 0L, null); + } +} diff --git a/src/test/java/com/android/volley/RequestQueueIntegrationTest.java b/src/test/java/com/android/volley/RequestQueueIntegrationTest.java index a73435c..304a1ab 100644 --- a/src/test/java/com/android/volley/RequestQueueIntegrationTest.java +++ b/src/test/java/com/android/volley/RequestQueueIntegrationTest.java @@ -22,29 +22,30 @@ import com.android.volley.mock.MockRequest; import com.android.volley.mock.ShadowSystemClock; import com.android.volley.toolbox.NoCache; import com.android.volley.utils.ImmediateResponseDelivery; + import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; -import java.util.ArrayList; -import java.util.List; -import java.util.Random; -import java.util.concurrent.Semaphore; -import java.util.concurrent.TimeUnit; - -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; /** - * Integration tests for {@link RequestQueue}, that verify its behavior in conjunction with real dispatcher, queues and - * Requests. Network is mocked out + * Integration tests for {@link RequestQueue} that verify its behavior in conjunction with real + * dispatcher, queues and Requests. + * + * <p>The Network is mocked out. */ @RunWith(RobolectricTestRunner.class) @Config(shadows = {ShadowSystemClock.class}) @@ -52,6 +53,8 @@ public class RequestQueueIntegrationTest { private ResponseDelivery mDelivery; @Mock private Network mMockNetwork; + @Mock private RequestFinishedListener<byte[]> mMockListener; + @Mock private RequestFinishedListener<byte[]> mMockListener2; @Before public void setUp() throws Exception { mDelivery = new ImmediateResponseDelivery(); @@ -59,9 +62,10 @@ public class RequestQueueIntegrationTest { } @Test public void add_requestProcessedInCorrectOrder() throws Exception { - // Enqueue 2 requests with different cache keys, and different priorities. The second, higher priority request - // takes 20ms. - // Assert that first request is only handled after the first one has been parsed and delivered. + // Enqueue 2 requests with different cache keys, and different priorities. The second, + // higher priority request takes 20ms. + // Assert that the first request is only handled after the first one has been parsed and + // delivered. MockRequest lowerPriorityReq = new MockRequest(); MockRequest higherPriorityReq = new MockRequest(); lowerPriorityReq.setCacheKey("1"); @@ -69,7 +73,6 @@ public class RequestQueueIntegrationTest { lowerPriorityReq.setPriority(Priority.LOW); higherPriorityReq.setPriority(Priority.HIGH); - RequestFinishedListener listener = mock(RequestFinishedListener.class); Answer<NetworkResponse> delayAnswer = new Answer<NetworkResponse>() { @Override public NetworkResponse answer(InvocationOnMock invocationOnMock) throws Throwable { @@ -77,37 +80,31 @@ public class RequestQueueIntegrationTest { return mock(NetworkResponse.class); } }; - //delay only for higher request + // delay only for higher request when(mMockNetwork.performRequest(higherPriorityReq)).thenAnswer(delayAnswer); when(mMockNetwork.performRequest(lowerPriorityReq)).thenReturn(mock(NetworkResponse.class)); RequestQueue queue = new RequestQueue(new NoCache(), mMockNetwork, 1, mDelivery); - queue.addRequestFinishedListener(listener); + queue.addRequestFinishedListener(mMockListener); queue.add(lowerPriorityReq); queue.add(higherPriorityReq); queue.start(); - // you cannot do strict order verification in combination with timeouts with mockito 1.9.5 :( - // as an alternative, first verify no requests have finished, while higherPriorityReq should be processing - verifyNoMoreInteractions(listener); + InOrder inOrder = inOrder(mMockListener); // verify higherPriorityReq goes through first - verify(listener, timeout(100)).onRequestFinished(higherPriorityReq); + inOrder.verify(mMockListener, timeout(10000)).onRequestFinished(higherPriorityReq); // verify lowerPriorityReq goes last - verify(listener, timeout(10)).onRequestFinished(lowerPriorityReq); + inOrder.verify(mMockListener, timeout(10000)).onRequestFinished(lowerPriorityReq); + queue.stop(); } - /** - * Asserts that requests with same cache key are processed in order. - * - * Needs to be an integration test because relies on complex interations between various queues - */ + /** Asserts that requests with same cache key are processed in order. */ @Test public void add_dedupeByCacheKey() throws Exception { // Enqueue 2 requests with the same cache key. The first request takes 20ms. Assert that the // second request is only handled after the first one has been parsed and delivered. - Request req1 = new MockRequest(); - Request req2 = new MockRequest(); - RequestFinishedListener listener = mock(RequestFinishedListener.class); + MockRequest req1 = new MockRequest(); + MockRequest req2 = new MockRequest(); Answer<NetworkResponse> delayAnswer = new Answer<NetworkResponse>() { @Override public NetworkResponse answer(InvocationOnMock invocationOnMock) throws Throwable { @@ -120,27 +117,23 @@ public class RequestQueueIntegrationTest { when(mMockNetwork.performRequest(req2)).thenReturn(mock(NetworkResponse.class)); RequestQueue queue = new RequestQueue(new NoCache(), mMockNetwork, 3, mDelivery); - queue.addRequestFinishedListener(listener); + queue.addRequestFinishedListener(mMockListener); queue.add(req1); queue.add(req2); queue.start(); - // you cannot do strict order verification with mockito 1.9.5 :( - // as an alternative, first verify no requests have finished, then verify req1 goes through - verifyNoMoreInteractions(listener); - verify(listener, timeout(100)).onRequestFinished(req1); - verify(listener, timeout(10)).onRequestFinished(req2); + InOrder inOrder = inOrder(mMockListener); + // verify req1 goes through first + inOrder.verify(mMockListener, timeout(10000)).onRequestFinished(req1); + // verify req2 goes last + inOrder.verify(mMockListener, timeout(10000)).onRequestFinished(req2); + queue.stop(); } - /** - * Verify RequestFinishedListeners are informed when requests are canceled - * - * Needs to be an integration test because relies on Request -> dispatcher -> RequestQueue interaction - */ + /** Verify RequestFinishedListeners are informed when requests are canceled. */ @Test public void add_requestFinishedListenerCanceled() throws Exception { - RequestFinishedListener listener = mock(RequestFinishedListener.class); - Request request = new MockRequest(); + MockRequest request = new MockRequest(); Answer<NetworkResponse> delayAnswer = new Answer<NetworkResponse>() { @Override public NetworkResponse answer(InvocationOnMock invocationOnMock) throws Throwable { @@ -152,56 +145,43 @@ public class RequestQueueIntegrationTest { when(mMockNetwork.performRequest(request)).thenAnswer(delayAnswer); - queue.addRequestFinishedListener(listener); + queue.addRequestFinishedListener(mMockListener); queue.start(); queue.add(request); request.cancel(); - verify(listener, timeout(100)).onRequestFinished(request); + verify(mMockListener, timeout(10000)).onRequestFinished(request); queue.stop(); } - /** - * Verify RequestFinishedListeners are informed when requests are successfully delivered - * - * Needs to be an integration test because relies on Request -> dispatcher -> RequestQueue interaction - */ + /** Verify RequestFinishedListeners are informed when requests are successfully delivered. */ @Test public void add_requestFinishedListenerSuccess() throws Exception { - NetworkResponse response = mock(NetworkResponse.class); - Request request = new MockRequest(); - RequestFinishedListener listener = mock(RequestFinishedListener.class); - RequestFinishedListener listener2 = mock(RequestFinishedListener.class); + MockRequest request = new MockRequest(); RequestQueue queue = new RequestQueue(new NoCache(), mMockNetwork, 1, mDelivery); - queue.addRequestFinishedListener(listener); - queue.addRequestFinishedListener(listener2); + queue.addRequestFinishedListener(mMockListener); + queue.addRequestFinishedListener(mMockListener2); queue.start(); queue.add(request); - verify(listener, timeout(100)).onRequestFinished(request); - verify(listener2, timeout(100)).onRequestFinished(request); + verify(mMockListener, timeout(10000)).onRequestFinished(request); + verify(mMockListener2, timeout(10000)).onRequestFinished(request); queue.stop(); } - /** - * Verify RequestFinishedListeners are informed when request errors - * - * Needs to be an integration test because relies on Request -> dispatcher -> RequestQueue interaction - */ + /** Verify RequestFinishedListeners are informed when request errors. */ @Test public void add_requestFinishedListenerError() throws Exception { - RequestFinishedListener listener = mock(RequestFinishedListener.class); - Request request = new MockRequest(); + MockRequest request = new MockRequest(); RequestQueue queue = new RequestQueue(new NoCache(), mMockNetwork, 1, mDelivery); when(mMockNetwork.performRequest(request)).thenThrow(new VolleyError()); - queue.addRequestFinishedListener(listener); + queue.addRequestFinishedListener(mMockListener); queue.start(); queue.add(request); - verify(listener, timeout(100)).onRequestFinished(request); + verify(mMockListener, timeout(10000)).onRequestFinished(request); queue.stop(); } - } diff --git a/src/test/java/com/android/volley/mock/MockHttpClient.java b/src/test/java/com/android/volley/mock/MockHttpClient.java deleted file mode 100644 index c2a36bc..0000000 --- a/src/test/java/com/android/volley/mock/MockHttpClient.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * 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.mock; - -import org.apache.http.HttpEntity; -import org.apache.http.HttpHost; -import org.apache.http.HttpRequest; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.ProtocolVersion; -import org.apache.http.StatusLine; -import org.apache.http.client.HttpClient; -import org.apache.http.client.ResponseHandler; -import org.apache.http.client.methods.HttpUriRequest; -import org.apache.http.conn.ClientConnectionManager; -import org.apache.http.message.BasicHttpResponse; -import org.apache.http.message.BasicStatusLine; -import org.apache.http.params.HttpParams; -import org.apache.http.protocol.HttpContext; - - -public class MockHttpClient implements HttpClient { - private int mStatusCode = HttpStatus.SC_OK; - private HttpEntity mResponseEntity = null; - - public void setResponseData(HttpEntity entity) { - mStatusCode = HttpStatus.SC_OK; - mResponseEntity = entity; - } - - public void setErrorCode(int statusCode) { - if (statusCode == HttpStatus.SC_OK) { - throw new IllegalArgumentException("statusCode cannot be 200 for an error"); - } - mStatusCode = statusCode; - } - - public HttpUriRequest requestExecuted = null; - - // This is the only one we actually use. - @Override - public HttpResponse execute(HttpUriRequest request, HttpContext context) { - requestExecuted = request; - StatusLine statusLine = new BasicStatusLine( - new ProtocolVersion("HTTP", 1, 1), mStatusCode, ""); - HttpResponse response = new BasicHttpResponse(statusLine); - response.setEntity(mResponseEntity); - - return response; - } - - - // Unimplemented methods ahoy - - @Override - public HttpResponse execute(HttpUriRequest request) { - throw new UnsupportedOperationException(); - } - - @Override - public HttpResponse execute(HttpHost target, HttpRequest request) { - throw new UnsupportedOperationException(); - } - - @Override - public <T> T execute(HttpUriRequest arg0, ResponseHandler<? extends T> arg1) { - throw new UnsupportedOperationException(); - } - - @Override - public HttpResponse execute(HttpHost target, HttpRequest request, HttpContext context) { - throw new UnsupportedOperationException(); - } - - @Override - public <T> T execute(HttpUriRequest arg0, ResponseHandler<? extends T> arg1, HttpContext arg2) { - throw new UnsupportedOperationException(); - } - - @Override - public <T> T execute(HttpHost arg0, HttpRequest arg1, ResponseHandler<? extends T> arg2) { - throw new UnsupportedOperationException(); - } - - @Override - public <T> T execute(HttpHost arg0, HttpRequest arg1, ResponseHandler<? extends T> arg2, - HttpContext arg3) { - throw new UnsupportedOperationException(); - } - - @Override - public ClientConnectionManager getConnectionManager() { - throw new UnsupportedOperationException(); - } - - @Override - public HttpParams getParams() { - throw new UnsupportedOperationException(); - } -} diff --git a/src/test/java/com/android/volley/mock/MockHttpStack.java b/src/test/java/com/android/volley/mock/MockHttpStack.java index 91872d3..56b29f1 100644 --- a/src/test/java/com/android/volley/mock/MockHttpStack.java +++ b/src/test/java/com/android/volley/mock/MockHttpStack.java @@ -18,15 +18,14 @@ package com.android.volley.mock; import com.android.volley.AuthFailureError; import com.android.volley.Request; -import com.android.volley.toolbox.HttpStack; - -import org.apache.http.HttpResponse; +import com.android.volley.toolbox.BaseHttpStack; +import com.android.volley.toolbox.HttpResponse; import java.io.IOException; import java.util.HashMap; import java.util.Map; -public class MockHttpStack implements HttpStack { +public class MockHttpStack extends BaseHttpStack { private HttpResponse mResponseToReturn; @@ -59,13 +58,13 @@ public class MockHttpStack implements HttpStack { } @Override - public HttpResponse performRequest(Request<?> request, Map<String, String> additionalHeaders) + public HttpResponse executeRequest(Request<?> request, Map<String, String> additionalHeaders) throws IOException, AuthFailureError { if (mExceptionToThrow != null) { throw mExceptionToThrow; } mLastUrl = request.getUrl(); - mLastHeaders = new HashMap<String, String>(); + mLastHeaders = new HashMap<>(); if (request.getHeaders() != null) { mLastHeaders.putAll(request.getHeaders()); } diff --git a/src/test/java/com/android/volley/mock/MockResponseDelivery.java b/src/test/java/com/android/volley/mock/MockResponseDelivery.java index 4dbfd5c..e923c1a 100644 --- a/src/test/java/com/android/volley/mock/MockResponseDelivery.java +++ b/src/test/java/com/android/volley/mock/MockResponseDelivery.java @@ -25,6 +25,7 @@ public class MockResponseDelivery implements ResponseDelivery { public boolean postResponse_called = false; public boolean postError_called = false; + public long postResponse_calledNtimes = 0; public boolean wasEitherResponseCalled() { return postResponse_called || postError_called; @@ -34,12 +35,14 @@ public class MockResponseDelivery implements ResponseDelivery { @Override public void postResponse(Request<?> request, Response<?> response) { postResponse_called = true; + postResponse_calledNtimes++; responsePosted = response; } @Override public void postResponse(Request<?> request, Response<?> response, Runnable runnable) { postResponse_called = true; + postResponse_calledNtimes++; responsePosted = response; runnable.run(); } diff --git a/src/test/java/com/android/volley/toolbox/AdaptedHttpStackTest.java b/src/test/java/com/android/volley/toolbox/AdaptedHttpStackTest.java new file mode 100644 index 0000000..615687d --- /dev/null +++ b/src/test/java/com/android/volley/toolbox/AdaptedHttpStackTest.java @@ -0,0 +1,135 @@ +package com.android.volley.toolbox; + +import android.util.Pair; + +import com.android.volley.Header; +import com.android.volley.Request; +import com.android.volley.mock.TestRequest; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.StatusLine; +import org.apache.http.conn.ConnectTimeoutException; +import org.apache.http.message.BasicHeader; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.io.IOException; +import java.io.InputStream; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +public class AdaptedHttpStackTest { + private static final Request<?> REQUEST = new TestRequest.Get(); + private static final Map<String, String> ADDITIONAL_HEADERS = Collections.emptyMap(); + + @Mock + private HttpStack mHttpStack; + @Mock + private HttpResponse mHttpResponse; + @Mock + private StatusLine mStatusLine; + @Mock + private HttpEntity mHttpEntity; + @Mock + private InputStream mContent; + + private AdaptedHttpStack mAdaptedHttpStack; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mAdaptedHttpStack = new AdaptedHttpStack(mHttpStack); + when(mHttpResponse.getStatusLine()).thenReturn(mStatusLine); + } + + @Test(expected = SocketTimeoutException.class) + public void requestTimeout() throws Exception { + when(mHttpStack.performRequest(REQUEST, ADDITIONAL_HEADERS)) + .thenThrow(new ConnectTimeoutException()); + + mAdaptedHttpStack.executeRequest(REQUEST, ADDITIONAL_HEADERS); + } + + @Test + public void emptyResponse() throws Exception { + when(mHttpStack.performRequest(REQUEST, ADDITIONAL_HEADERS)).thenReturn(mHttpResponse); + when(mStatusLine.getStatusCode()).thenReturn(12345); + when(mHttpResponse.getAllHeaders()).thenReturn(new org.apache.http.Header[0]); + + com.android.volley.toolbox.HttpResponse response = + mAdaptedHttpStack.executeRequest(REQUEST, ADDITIONAL_HEADERS); + + assertEquals(12345, response.getStatusCode()); + assertEquals(Collections.emptyList(), response.getHeaders()); + assertNull(response.getContent()); + } + + @Test + public void nonEmptyResponse() throws Exception { + when(mHttpStack.performRequest(REQUEST, ADDITIONAL_HEADERS)).thenReturn(mHttpResponse); + when(mStatusLine.getStatusCode()).thenReturn(12345); + when(mHttpResponse.getAllHeaders()).thenReturn(new org.apache.http.Header[0]); + when(mHttpResponse.getEntity()).thenReturn(mHttpEntity); + when(mHttpEntity.getContentLength()).thenReturn((long) Integer.MAX_VALUE); + when(mHttpEntity.getContent()).thenReturn(mContent); + + com.android.volley.toolbox.HttpResponse response = + mAdaptedHttpStack.executeRequest(REQUEST, ADDITIONAL_HEADERS); + + assertEquals(12345, response.getStatusCode()); + assertEquals(Collections.emptyList(), response.getHeaders()); + assertEquals(Integer.MAX_VALUE, response.getContentLength()); + assertSame(mContent, response.getContent()); + } + + @Test(expected = IOException.class) + public void responseTooBig() throws Exception { + when(mHttpStack.performRequest(REQUEST, ADDITIONAL_HEADERS)).thenReturn(mHttpResponse); + when(mStatusLine.getStatusCode()).thenReturn(12345); + when(mHttpResponse.getAllHeaders()).thenReturn(new org.apache.http.Header[0]); + when(mHttpResponse.getEntity()).thenReturn(mHttpEntity); + when(mHttpEntity.getContentLength()).thenReturn(Integer.MAX_VALUE + 1L); + when(mHttpEntity.getContent()).thenReturn(mContent); + + mAdaptedHttpStack.executeRequest(REQUEST, ADDITIONAL_HEADERS); + } + + @Test + public void responseWithHeaders() throws Exception { + when(mHttpStack.performRequest(REQUEST, ADDITIONAL_HEADERS)).thenReturn(mHttpResponse); + when(mStatusLine.getStatusCode()).thenReturn(12345); + when(mHttpResponse.getAllHeaders()).thenReturn(new org.apache.http.Header[] { + new BasicHeader("header1", "value1_B"), + new BasicHeader("header3", "value3"), + new BasicHeader("HEADER2", "value2"), + new BasicHeader("header1", "value1_A") + }); + + com.android.volley.toolbox.HttpResponse response = + mAdaptedHttpStack.executeRequest(REQUEST, ADDITIONAL_HEADERS); + + assertEquals(12345, response.getStatusCode()); + assertNull(response.getContent()); + + List<Header> expectedHeaders = new ArrayList<>(); + expectedHeaders.add(new Header("header1", "value1_B")); + expectedHeaders.add(new Header("header3", "value3")); + expectedHeaders.add(new Header("HEADER2", "value2")); + expectedHeaders.add(new Header("header1", "value1_A")); + assertEquals(expectedHeaders, response.getHeaders()); + } +} diff --git a/src/test/java/com/android/volley/toolbox/BaseHttpStackTest.java b/src/test/java/com/android/volley/toolbox/BaseHttpStackTest.java new file mode 100644 index 0000000..3ae145c --- /dev/null +++ b/src/test/java/com/android/volley/toolbox/BaseHttpStackTest.java @@ -0,0 +1,108 @@ +package com.android.volley.toolbox; + +import com.android.volley.AuthFailureError; +import com.android.volley.Header; +import com.android.volley.Request; +import com.android.volley.mock.TestRequest; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +@RunWith(RobolectricTestRunner.class) +public class BaseHttpStackTest { + private static final Request<?> REQUEST = new TestRequest.Get(); + private static final Map<String, String> ADDITIONAL_HEADERS = Collections.emptyMap(); + + @Mock + private InputStream mContent; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void legacyRequestWithoutBody() throws Exception { + BaseHttpStack stack = new BaseHttpStack() { + @Override + public HttpResponse executeRequest( + Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError { + assertSame(REQUEST, request); + assertSame(ADDITIONAL_HEADERS, additionalHeaders); + return new HttpResponse(12345, Collections.<Header>emptyList()); + } + }; + org.apache.http.HttpResponse resp = stack.performRequest(REQUEST, ADDITIONAL_HEADERS); + assertEquals(12345, resp.getStatusLine().getStatusCode()); + assertEquals(0, resp.getAllHeaders().length); + assertNull(resp.getEntity()); + } + + @Test + public void legacyResponseWithBody() throws Exception { + BaseHttpStack stack = new BaseHttpStack() { + @Override + public HttpResponse executeRequest( + Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError { + assertSame(REQUEST, request); + assertSame(ADDITIONAL_HEADERS, additionalHeaders); + return new HttpResponse( + 12345, + Collections.<Header>emptyList(), + 555, + mContent); + } + }; + org.apache.http.HttpResponse resp = stack.performRequest(REQUEST, ADDITIONAL_HEADERS); + assertEquals(12345, resp.getStatusLine().getStatusCode()); + assertEquals(0, resp.getAllHeaders().length); + assertEquals(555L, resp.getEntity().getContentLength()); + assertSame(mContent, resp.getEntity().getContent()); + } + + @Test + public void legacyResponseHeaders() throws Exception { + BaseHttpStack stack = new BaseHttpStack() { + @Override + public HttpResponse executeRequest( + Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError { + assertSame(REQUEST, request); + assertSame(ADDITIONAL_HEADERS, additionalHeaders); + List<Header> headers = new ArrayList<>(); + headers.add(new Header("HeaderA", "ValueA")); + headers.add(new Header("HeaderB", "ValueB_1")); + headers.add(new Header("HeaderB", "ValueB_2")); + return new HttpResponse(12345, headers); + } + }; + org.apache.http.HttpResponse resp = stack.performRequest(REQUEST, ADDITIONAL_HEADERS); + assertEquals(12345, resp.getStatusLine().getStatusCode()); + assertEquals(3, resp.getAllHeaders().length); + assertEquals("HeaderA", resp.getAllHeaders()[0].getName()); + assertEquals("ValueA", resp.getAllHeaders()[0].getValue()); + assertEquals("HeaderB", resp.getAllHeaders()[1].getName()); + assertEquals("ValueB_1", resp.getAllHeaders()[1].getValue()); + assertEquals("HeaderB", resp.getAllHeaders()[2].getName()); + assertEquals("ValueB_2", resp.getAllHeaders()[2].getValue()); + assertNull(resp.getEntity()); + } +} diff --git a/src/test/java/com/android/volley/toolbox/BasicNetworkTest.java b/src/test/java/com/android/volley/toolbox/BasicNetworkTest.java index c01d9b0..7f0d5e2 100644 --- a/src/test/java/com/android/volley/toolbox/BasicNetworkTest.java +++ b/src/test/java/com/android/volley/toolbox/BasicNetworkTest.java @@ -17,6 +17,8 @@ package com.android.volley.toolbox; 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.Request; import com.android.volley.Response; @@ -26,23 +28,31 @@ import com.android.volley.TimeoutError; import com.android.volley.VolleyError; import com.android.volley.mock.MockHttpStack; -import org.apache.http.ProtocolVersion; -import org.apache.http.conn.ConnectTimeoutException; -import org.apache.http.entity.StringEntity; -import org.apache.http.message.BasicHttpResponse; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.robolectric.RobolectricTestRunner; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; 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 static org.junit.Assert.*; -import static org.mockito.Mockito.*; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +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; @RunWith(RobolectricTestRunner.class) @@ -50,7 +60,6 @@ public class BasicNetworkTest { @Mock private Request<String> mMockRequest; @Mock private RetryPolicy mMockRetryPolicy; - private BasicNetwork mNetwork; @Before public void setUp() throws Exception { initMocks(this); @@ -58,36 +67,98 @@ public class BasicNetworkTest { @Test public void headersAndPostParams() throws Exception { MockHttpStack mockHttpStack = new MockHttpStack(); - BasicHttpResponse fakeResponse = new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), - 200, "OK"); - fakeResponse.setEntity(new StringEntity("foobar")); + InputStream responseStream = + new ByteArrayInputStream("foobar".getBytes()); + HttpResponse fakeResponse = + new HttpResponse(200, Collections.<Header>emptyList(), 6, responseStream); mockHttpStack.setResponseToReturn(fakeResponse); BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack); Request<String> request = buildRequest(); + Entry entry = new Entry(); + entry.etag = "foobar"; + entry.lastModified = 1503102002000L; + request.setCacheEntry(entry); httpNetwork.performRequest(request); assertEquals("foo", mockHttpStack.getLastHeaders().get("requestheader")); + assertEquals("foobar", mockHttpStack.getLastHeaders().get("If-None-Match")); + assertEquals("Sat, 19 Aug 2017 00:20:02 GMT", + mockHttpStack.getLastHeaders().get("If-Modified-Since")); assertEquals("requestpost=foo&", new String(mockHttpStack.getLastPostBody())); } - @Test public void socketTimeout() throws Exception { + @Test public void notModified() throws Exception { MockHttpStack mockHttpStack = new MockHttpStack(); - mockHttpStack.setExceptionToThrow(new SocketTimeoutException()); + 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); + mockHttpStack.setResponseToReturn(fakeResponse); BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack); Request<String> request = buildRequest(); - request.setRetryPolicy(mMockRetryPolicy); - doThrow(new VolleyError()).when(mMockRetryPolicy).retry(any(VolleyError.class)); - try { - httpNetwork.performRequest(request); - } catch (VolleyError e) { - // expected - } - // should retry socket timeouts - verify(mMockRetryPolicy).retry(any(TimeoutError.class)); + 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); + NetworkResponse response = httpNetwork.performRequest(request); + 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[response.allHeaders.size()]))); + } + + @Test public void notModified_legacyCache() throws Exception { + MockHttpStack mockHttpStack = new MockHttpStack(); + 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); + mockHttpStack.setResponseToReturn(fakeResponse); + BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack); + 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 = httpNetwork.performRequest(request); + 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[response.allHeaders.size()]))); } - @Test public void connectTimeout() throws Exception { + @Test public void socketTimeout() throws Exception { MockHttpStack mockHttpStack = new MockHttpStack(); - mockHttpStack.setExceptionToThrow(new ConnectTimeoutException()); + mockHttpStack.setExceptionToThrow(new SocketTimeoutException()); BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack); Request<String> request = buildRequest(); request.setRetryPolicy(mMockRetryPolicy); @@ -97,7 +168,7 @@ public class BasicNetworkTest { } catch (VolleyError e) { // expected } - // should retry connection timeouts + // should retry socket timeouts verify(mMockRetryPolicy).retry(any(TimeoutError.class)); } @@ -119,8 +190,7 @@ public class BasicNetworkTest { @Test public void unauthorized() throws Exception { MockHttpStack mockHttpStack = new MockHttpStack(); - BasicHttpResponse fakeResponse = new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), - 401, "Unauthorized"); + HttpResponse fakeResponse = new HttpResponse(401, Collections.<Header>emptyList()); mockHttpStack.setResponseToReturn(fakeResponse); BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack); Request<String> request = buildRequest(); @@ -137,8 +207,7 @@ public class BasicNetworkTest { @Test public void forbidden() throws Exception { MockHttpStack mockHttpStack = new MockHttpStack(); - BasicHttpResponse fakeResponse = new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), - 403, "Forbidden"); + HttpResponse fakeResponse = new HttpResponse(403, Collections.<Header>emptyList()); mockHttpStack.setResponseToReturn(fakeResponse); BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack); Request<String> request = buildRequest(); @@ -156,8 +225,7 @@ public class BasicNetworkTest { @Test public void redirect() throws Exception { for (int i = 300; i <= 399; i++) { MockHttpStack mockHttpStack = new MockHttpStack(); - BasicHttpResponse fakeResponse = - new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), i, ""); + HttpResponse fakeResponse = new HttpResponse(i, Collections.<Header>emptyList()); mockHttpStack.setResponseToReturn(fakeResponse); BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack); Request<String> request = buildRequest(); @@ -181,8 +249,7 @@ public class BasicNetworkTest { continue; } MockHttpStack mockHttpStack = new MockHttpStack(); - BasicHttpResponse fakeResponse = - new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), i, ""); + HttpResponse fakeResponse = new HttpResponse(i, Collections.<Header>emptyList()); mockHttpStack.setResponseToReturn(fakeResponse); BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack); Request<String> request = buildRequest(); @@ -202,8 +269,7 @@ public class BasicNetworkTest { @Test public void serverError_enableRetries() throws Exception { for (int i = 500; i <= 599; i++) { MockHttpStack mockHttpStack = new MockHttpStack(); - BasicHttpResponse fakeResponse = - new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), i, ""); + HttpResponse fakeResponse = new HttpResponse(i, Collections.<Header>emptyList()); mockHttpStack.setResponseToReturn(fakeResponse); BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack, new ByteArrayPool(4096)); @@ -225,8 +291,7 @@ public class BasicNetworkTest { @Test public void serverError_disableRetries() throws Exception { for (int i = 500; i <= 599; i++) { MockHttpStack mockHttpStack = new MockHttpStack(); - BasicHttpResponse fakeResponse = - new BasicHttpResponse(new ProtocolVersion("HTTP", 1, 1), i, ""); + HttpResponse fakeResponse = new HttpResponse(i, Collections.<Header>emptyList()); mockHttpStack.setResponseToReturn(fakeResponse); BasicNetwork httpNetwork = new BasicNetwork(mockHttpStack); Request<String> request = buildRequest(); diff --git a/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java b/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java index 3d8d1f1..04c071e 100644 --- a/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java +++ b/src/test/java/com/android/volley/toolbox/DiskBasedCacheTest.java @@ -17,6 +17,7 @@ package com.android.volley.toolbox; import com.android.volley.Cache; +import com.android.volley.Header; import com.android.volley.toolbox.DiskBasedCache.CacheHeader; import com.android.volley.toolbox.DiskBasedCache.CountingInputStream; @@ -38,7 +39,9 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Random; @@ -428,28 +431,33 @@ public class DiskBasedCacheTest { assertEquals(DiskBasedCache.readString(cis), "ファイカス"); } - @Test public void serializeMap() throws Exception { + @Test public void serializeHeaders() throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); - Map<String, String> empty = new HashMap<>(); - DiskBasedCache.writeStringStringMap(empty, baos); - DiskBasedCache.writeStringStringMap(null, baos); - Map<String, String> twoThings = new HashMap<>(); - twoThings.put("first", "thing"); - twoThings.put("second", "item"); - DiskBasedCache.writeStringStringMap(twoThings, baos); - Map<String, String> emptyKey = new HashMap<>(); - emptyKey.put("", "value"); - DiskBasedCache.writeStringStringMap(emptyKey, baos); - Map<String, String> emptyValue = new HashMap<>(); - emptyValue.put("key", ""); - DiskBasedCache.writeStringStringMap(emptyValue, baos); + List<Header> empty = new ArrayList<>(); + DiskBasedCache.writeHeaderList(empty, baos); + DiskBasedCache.writeHeaderList(null, baos); + List<Header> twoThings = new ArrayList<>(); + twoThings.add(new Header("first", "thing")); + twoThings.add(new Header("second", "item")); + DiskBasedCache.writeHeaderList(twoThings, baos); + List<Header> emptyKey = new ArrayList<>(); + emptyKey.add(new Header("", "value")); + DiskBasedCache.writeHeaderList(emptyKey, baos); + List<Header> emptyValue = new ArrayList<>(); + emptyValue.add(new Header("key", "")); + DiskBasedCache.writeHeaderList(emptyValue, baos); + List<Header> sameKeys = new ArrayList<>(); + sameKeys.add(new Header("key", "value")); + sameKeys.add(new Header("key", "value2")); + DiskBasedCache.writeHeaderList(sameKeys, baos); CountingInputStream cis = new CountingInputStream(new ByteArrayInputStream(baos.toByteArray()), baos.size()); - assertEquals(DiskBasedCache.readStringStringMap(cis), empty); - assertEquals(DiskBasedCache.readStringStringMap(cis), empty); // null reads back empty - assertEquals(DiskBasedCache.readStringStringMap(cis), twoThings); - assertEquals(DiskBasedCache.readStringStringMap(cis), emptyKey); - assertEquals(DiskBasedCache.readStringStringMap(cis), emptyValue); + assertEquals(DiskBasedCache.readHeaderList(cis), empty); + assertEquals(DiskBasedCache.readHeaderList(cis), empty); // null reads back empty + assertEquals(DiskBasedCache.readHeaderList(cis), twoThings); + assertEquals(DiskBasedCache.readHeaderList(cis), emptyKey); + assertEquals(DiskBasedCache.readHeaderList(cis), emptyValue); + assertEquals(DiskBasedCache.readHeaderList(cis), sameKeys); } @Test diff --git a/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java b/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java index fd8cf51..9ccac05 100644 --- a/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java +++ b/src/test/java/com/android/volley/toolbox/HttpHeaderParserTest.java @@ -17,10 +17,9 @@ package com.android.volley.toolbox; import com.android.volley.Cache; +import com.android.volley.Header; import com.android.volley.NetworkResponse; -import org.apache.http.Header; -import org.apache.http.message.BasicHeader; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -28,8 +27,10 @@ import org.robolectric.RobolectricTestRunner; import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; import java.util.HashMap; +import java.util.List; import java.util.Locale; import java.util.Map; @@ -269,24 +270,23 @@ public class HttpHeaderParserTest { } @Test public void parseCaseInsensitive() { - long now = System.currentTimeMillis(); - Header[] headersArray = new Header[5]; - headersArray[0] = new BasicHeader("eTAG", "Yow!"); - headersArray[1] = new BasicHeader("DATE", rfc1123Date(now)); - headersArray[2] = new BasicHeader("expires", rfc1123Date(now + ONE_HOUR_MILLIS)); - headersArray[3] = new BasicHeader("cache-control", "public, max-age=86400"); - headersArray[4] = new BasicHeader("content-type", "text/plain"); + List<Header> headers = new ArrayList<>(); + headers.add(new Header("eTAG", "Yow!")); + headers.add(new Header("DATE", rfc1123Date(now))); + headers.add(new Header("expires", rfc1123Date(now + ONE_HOUR_MILLIS))); + headers.add(new Header("cache-control", "public, max-age=86400")); + headers.add(new Header("content-type", "text/plain")); - Map<String, String> headers = BasicNetwork.convertHeaders(headersArray); - NetworkResponse response = new NetworkResponse(0, null, headers, false); + NetworkResponse response = new NetworkResponse(0, null, false, 0, headers); Cache.Entry entry = HttpHeaderParser.parseCacheHeaders(response); assertNotNull(entry); assertEquals("Yow!", entry.etag); assertEqualsWithin(now + ONE_DAY_MILLIS, entry.ttl, ONE_MINUTE_MILLIS); assertEquals(entry.softTtl, entry.ttl); - assertEquals("ISO-8859-1", HttpHeaderParser.parseCharset(headers)); + assertEquals("ISO-8859-1", + HttpHeaderParser.parseCharset(HttpHeaderParser.toHeaderMap(headers))); } } diff --git a/src/test/java/com/android/volley/toolbox/HurlStackTest.java b/src/test/java/com/android/volley/toolbox/HurlStackTest.java index 42aeea8..c8dd6f1 100644 --- a/src/test/java/com/android/volley/toolbox/HurlStackTest.java +++ b/src/test/java/com/android/volley/toolbox/HurlStackTest.java @@ -16,6 +16,7 @@ package com.android.volley.toolbox; +import com.android.volley.Header; import com.android.volley.Request.Method; import com.android.volley.mock.MockHttpURLConnection; import com.android.volley.mock.TestRequest; @@ -25,6 +26,12 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + import static org.junit.Assert.*; @RunWith(RobolectricTestRunner.class) @@ -152,4 +159,20 @@ public class HurlStackTest { assertEquals("PATCH", mMockConnection.getRequestMethod()); assertTrue(mMockConnection.getDoOutput()); } + + @Test public void convertHeaders() { + Map<String, List<String>> headers = new HashMap<>(); + headers.put(null, Collections.singletonList("Ignored")); + headers.put("HeaderA", Collections.singletonList("ValueA")); + List<String> values = new ArrayList<>(); + values.add("ValueB_1"); + values.add("ValueB_2"); + headers.put("HeaderB", values); + List<Header> result = HurlStack.convertHeaders(headers); + List<Header> expected = new ArrayList<>(); + expected.add(new Header("HeaderA", "ValueA")); + expected.add(new Header("HeaderB", "ValueB_1")); + expected.add(new Header("HeaderB", "ValueB_2")); + assertEquals(expected, result); + } } |