diff options
Diffstat (limited to 'src/main/java/com/android/volley/toolbox')
31 files changed, 5076 insertions, 0 deletions
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..c75c25f --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/AdaptedHttpStack.java @@ -0,0 +1,78 @@ +/* + * 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 java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.http.conn.ConnectTimeoutException; + +/** + * {@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/AndroidAuthenticator.java b/src/main/java/com/android/volley/toolbox/AndroidAuthenticator.java new file mode 100644 index 0000000..f3381ae --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/AndroidAuthenticator.java @@ -0,0 +1,123 @@ +/* + * 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.toolbox; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.accounts.AccountManagerFuture; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import androidx.annotation.VisibleForTesting; +import com.android.volley.AuthFailureError; + +/** + * An Authenticator that uses {@link AccountManager} to get auth tokens of a specified type for a + * specified account. + */ +// TODO: Update this to account for runtime permissions +@SuppressLint("MissingPermission") +public class AndroidAuthenticator implements Authenticator { + private final AccountManager mAccountManager; + private final Account mAccount; + private final String mAuthTokenType; + private final boolean mNotifyAuthFailure; + + /** + * Creates a new authenticator. + * + * @param context Context for accessing AccountManager + * @param account Account to authenticate as + * @param authTokenType Auth token type passed to AccountManager + */ + public AndroidAuthenticator(Context context, Account account, String authTokenType) { + this(context, account, authTokenType, /* notifyAuthFailure= */ false); + } + + /** + * Creates a new authenticator. + * + * @param context Context for accessing AccountManager + * @param account Account to authenticate as + * @param authTokenType Auth token type passed to AccountManager + * @param notifyAuthFailure Whether to raise a notification upon auth failure + */ + public AndroidAuthenticator( + Context context, Account account, String authTokenType, boolean notifyAuthFailure) { + this(AccountManager.get(context), account, authTokenType, notifyAuthFailure); + } + + @VisibleForTesting + AndroidAuthenticator( + AccountManager accountManager, + Account account, + String authTokenType, + boolean notifyAuthFailure) { + mAccountManager = accountManager; + mAccount = account; + mAuthTokenType = authTokenType; + mNotifyAuthFailure = notifyAuthFailure; + } + + /** Returns the Account being used by this authenticator. */ + public Account getAccount() { + return mAccount; + } + + /** Returns the Auth Token Type used by this authenticator. */ + public String getAuthTokenType() { + return mAuthTokenType; + } + + // TODO: Figure out what to do about notifyAuthFailure + @SuppressWarnings("deprecation") + @Override + public String getAuthToken() throws AuthFailureError { + AccountManagerFuture<Bundle> future = + mAccountManager.getAuthToken( + mAccount, + mAuthTokenType, + mNotifyAuthFailure, + /* callback= */ null, + /* handler= */ null); + Bundle result; + try { + result = future.getResult(); + } catch (Exception e) { + throw new AuthFailureError("Error while retrieving auth token", e); + } + String authToken = null; + if (future.isDone() && !future.isCancelled()) { + if (result.containsKey(AccountManager.KEY_INTENT)) { + Intent intent = result.getParcelable(AccountManager.KEY_INTENT); + throw new AuthFailureError(intent); + } + authToken = result.getString(AccountManager.KEY_AUTHTOKEN); + } + if (authToken == null) { + throw new AuthFailureError("Got null auth token for type: " + mAuthTokenType); + } + + return authToken; + } + + @Override + public void invalidateAuthToken(String authToken) { + mAccountManager.invalidateAuthToken(mAccount.type, authToken); + } +} diff --git a/src/main/java/com/android/volley/toolbox/AsyncHttpStack.java b/src/main/java/com/android/volley/toolbox/AsyncHttpStack.java new file mode 100644 index 0000000..bafab8c --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/AsyncHttpStack.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.volley.toolbox; + +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import com.android.volley.AuthFailureError; +import com.android.volley.Request; +import com.android.volley.VolleyLog; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicReference; + +/** Asynchronous extension of the {@link BaseHttpStack} class. */ +public abstract class AsyncHttpStack extends BaseHttpStack { + private ExecutorService mBlockingExecutor; + private ExecutorService mNonBlockingExecutor; + + public interface OnRequestComplete { + /** Invoked when the stack successfully completes a request. */ + void onSuccess(HttpResponse httpResponse); + + /** Invoked when the stack throws an {@link AuthFailureError} during a request. */ + void onAuthError(AuthFailureError authFailureError); + + /** Invoked when the stack throws an {@link IOException} during a request. */ + void onError(IOException ioException); + } + + /** + * Makes an HTTP request with the given parameters, and calls the {@link OnRequestComplete} + * callback, with either the {@link HttpResponse} or error that was thrown. + * + * @param request to perform + * @param additionalHeaders to be sent together with {@link Request#getHeaders()} + * @param callback to be called after retrieving the {@link HttpResponse} or throwing an error. + */ + public abstract void executeRequest( + Request<?> request, Map<String, String> additionalHeaders, OnRequestComplete callback); + + /** + * This method sets the non blocking executor to be used by the stack for non-blocking tasks. + * This method must be called before executing any requests. + */ + @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP}) + public void setNonBlockingExecutor(ExecutorService executor) { + mNonBlockingExecutor = executor; + } + + /** + * This method sets the blocking executor to be used by the stack for potentially blocking + * tasks. This method must be called before executing any requests. + */ + @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP}) + public void setBlockingExecutor(ExecutorService executor) { + mBlockingExecutor = executor; + } + + /** Gets blocking executor to perform any potentially blocking tasks. */ + protected ExecutorService getBlockingExecutor() { + return mBlockingExecutor; + } + + /** Gets non-blocking executor to perform any non-blocking tasks. */ + protected ExecutorService getNonBlockingExecutor() { + return mNonBlockingExecutor; + } + + /** + * Performs an HTTP request with the given parameters. + * + * @param request the request to perform + * @param additionalHeaders additional headers to be sent together with {@link + * Request#getHeaders()} + * @return the {@link HttpResponse} + * @throws IOException if an I/O error occurs during the request + * @throws AuthFailureError if an authentication failure occurs during the request + */ + @Override + public final HttpResponse executeRequest( + Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError { + final CountDownLatch latch = new CountDownLatch(1); + final AtomicReference<Response> entry = new AtomicReference<>(); + executeRequest( + request, + additionalHeaders, + new OnRequestComplete() { + @Override + public void onSuccess(HttpResponse httpResponse) { + Response response = + new Response( + httpResponse, + /* ioException= */ null, + /* authFailureError= */ null); + entry.set(response); + latch.countDown(); + } + + @Override + public void onAuthError(AuthFailureError authFailureError) { + Response response = + new Response( + /* httpResponse= */ null, + /* ioException= */ null, + authFailureError); + entry.set(response); + latch.countDown(); + } + + @Override + public void onError(IOException ioException) { + Response response = + new Response( + /* httpResponse= */ null, + ioException, + /* authFailureError= */ null); + entry.set(response); + latch.countDown(); + } + }); + try { + latch.await(); + } catch (InterruptedException e) { + VolleyLog.e(e, "while waiting for CountDownLatch"); + Thread.currentThread().interrupt(); + throw new InterruptedIOException(e.toString()); + } + Response response = entry.get(); + if (response.httpResponse != null) { + return response.httpResponse; + } else if (response.ioException != null) { + throw response.ioException; + } else { + throw response.authFailureError; + } + } + + private static class Response { + HttpResponse httpResponse; + IOException ioException; + AuthFailureError authFailureError; + + private Response( + @Nullable HttpResponse httpResponse, + @Nullable IOException ioException, + @Nullable AuthFailureError authFailureError) { + this.httpResponse = httpResponse; + this.ioException = ioException; + this.authFailureError = authFailureError; + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/Authenticator.java b/src/main/java/com/android/volley/toolbox/Authenticator.java new file mode 100644 index 0000000..2ba43db --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/Authenticator.java @@ -0,0 +1,32 @@ +/* + * 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.toolbox; + +import com.android.volley.AuthFailureError; + +/** An interface for interacting with auth tokens. */ +public interface Authenticator { + /** + * Synchronously retrieves an auth token. + * + * @throws AuthFailureError If authentication did not succeed + */ + String getAuthToken() throws AuthFailureError; + + /** Invalidates the provided auth token. */ + void invalidateAuthToken(String authToken); +} 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..99a9899 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/BaseHttpStack.java @@ -0,0 +1,92 @@ +/* + * 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 java.io.IOException; +import java.io.InputStream; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +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; + +/** 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[0])); + + 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/BasicAsyncNetwork.java b/src/main/java/com/android/volley/toolbox/BasicAsyncNetwork.java new file mode 100644 index 0000000..55892a0 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/BasicAsyncNetwork.java @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.volley.toolbox; + +import static com.android.volley.toolbox.NetworkUtility.logSlowRequests; + +import android.os.SystemClock; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import com.android.volley.AsyncNetwork; +import com.android.volley.AuthFailureError; +import com.android.volley.Header; +import com.android.volley.NetworkResponse; +import com.android.volley.Request; +import com.android.volley.RequestTask; +import com.android.volley.VolleyError; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +/** A network performing Volley requests over an {@link HttpStack}. */ +public class BasicAsyncNetwork extends AsyncNetwork { + + private final AsyncHttpStack mAsyncStack; + private final ByteArrayPool mPool; + + /** + * @param httpStack HTTP stack to be used + * @param pool a buffer pool that improves GC performance in copy operations + */ + private BasicAsyncNetwork(AsyncHttpStack httpStack, ByteArrayPool pool) { + mAsyncStack = httpStack; + mPool = pool; + } + + @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP}) + @Override + public void setBlockingExecutor(ExecutorService executor) { + super.setBlockingExecutor(executor); + mAsyncStack.setBlockingExecutor(executor); + } + + @RestrictTo({RestrictTo.Scope.LIBRARY_GROUP}) + @Override + public void setNonBlockingExecutor(ExecutorService executor) { + super.setNonBlockingExecutor(executor); + mAsyncStack.setNonBlockingExecutor(executor); + } + + /* Method to be called after a successful network request */ + private void onRequestSucceeded( + final Request<?> request, + final long requestStartMs, + final HttpResponse httpResponse, + final OnRequestComplete callback) { + final int statusCode = httpResponse.getStatusCode(); + final List<Header> responseHeaders = httpResponse.getHeaders(); + // Handle cache validation. + if (statusCode == HttpURLConnection.HTTP_NOT_MODIFIED) { + long requestDuration = SystemClock.elapsedRealtime() - requestStartMs; + callback.onSuccess( + NetworkUtility.getNotModifiedNetworkResponse( + request, requestDuration, responseHeaders)); + return; + } + + byte[] responseContents = httpResponse.getContentBytes(); + if (responseContents == null && httpResponse.getContent() == null) { + // Add 0 byte response as a way of honestly representing a + // no-content request. + responseContents = new byte[0]; + } + + if (responseContents != null) { + onResponseRead( + requestStartMs, + statusCode, + httpResponse, + request, + callback, + responseHeaders, + responseContents); + return; + } + + // The underlying AsyncHttpStack does not support asynchronous reading of the response into + // a byte array, so we need to submit a blocking task to copy the response from the + // InputStream instead. + final InputStream inputStream = httpResponse.getContent(); + getBlockingExecutor() + .execute( + new ResponseParsingTask<>( + inputStream, + httpResponse, + request, + callback, + requestStartMs, + responseHeaders, + statusCode)); + } + + /* Method to be called after a failed network request */ + private void onRequestFailed( + Request<?> request, + OnRequestComplete callback, + IOException exception, + long requestStartMs, + @Nullable HttpResponse httpResponse, + @Nullable byte[] responseContents) { + try { + NetworkUtility.handleException( + request, exception, requestStartMs, httpResponse, responseContents); + } catch (VolleyError volleyError) { + callback.onError(volleyError); + return; + } + performRequest(request, callback); + } + + @Override + public void performRequest(final Request<?> request, final OnRequestComplete callback) { + if (getBlockingExecutor() == null) { + throw new IllegalStateException( + "mBlockingExecuter must be set before making a request"); + } + final long requestStartMs = SystemClock.elapsedRealtime(); + // Gather headers. + final Map<String, String> additionalRequestHeaders = + HttpHeaderParser.getCacheHeaders(request.getCacheEntry()); + mAsyncStack.executeRequest( + request, + additionalRequestHeaders, + new AsyncHttpStack.OnRequestComplete() { + @Override + public void onSuccess(HttpResponse httpResponse) { + onRequestSucceeded(request, requestStartMs, httpResponse, callback); + } + + @Override + public void onAuthError(AuthFailureError authFailureError) { + callback.onError(authFailureError); + } + + @Override + public void onError(IOException ioException) { + onRequestFailed( + request, + callback, + ioException, + requestStartMs, + /* httpResponse= */ null, + /* responseContents= */ null); + } + }); + } + + /* Helper method that determines what to do after byte[] is received */ + private void onResponseRead( + long requestStartMs, + int statusCode, + HttpResponse httpResponse, + Request<?> request, + OnRequestComplete callback, + List<Header> responseHeaders, + byte[] responseContents) { + // if the request is slow, log it. + long requestLifetime = SystemClock.elapsedRealtime() - requestStartMs; + logSlowRequests(requestLifetime, request, responseContents, statusCode); + + if (statusCode < 200 || statusCode > 299) { + onRequestFailed( + request, + callback, + new IOException(), + requestStartMs, + httpResponse, + responseContents); + return; + } + + callback.onSuccess( + new NetworkResponse( + statusCode, + responseContents, + /* notModified= */ false, + SystemClock.elapsedRealtime() - requestStartMs, + responseHeaders)); + } + + private class ResponseParsingTask<T> extends RequestTask<T> { + InputStream inputStream; + HttpResponse httpResponse; + Request<T> request; + OnRequestComplete callback; + long requestStartMs; + List<Header> responseHeaders; + int statusCode; + + ResponseParsingTask( + InputStream inputStream, + HttpResponse httpResponse, + Request<T> request, + OnRequestComplete callback, + long requestStartMs, + List<Header> responseHeaders, + int statusCode) { + super(request); + this.inputStream = inputStream; + this.httpResponse = httpResponse; + this.request = request; + this.callback = callback; + this.requestStartMs = requestStartMs; + this.responseHeaders = responseHeaders; + this.statusCode = statusCode; + } + + @Override + public void run() { + byte[] finalResponseContents; + try { + finalResponseContents = + NetworkUtility.inputStreamToBytes( + inputStream, httpResponse.getContentLength(), mPool); + } catch (IOException e) { + onRequestFailed(request, callback, e, requestStartMs, httpResponse, null); + return; + } + onResponseRead( + requestStartMs, + statusCode, + httpResponse, + request, + callback, + responseHeaders, + finalResponseContents); + } + } + + /** + * Builder is used to build an instance of {@link BasicAsyncNetwork} from values configured by + * the setters. + */ + public static class Builder { + private static final int DEFAULT_POOL_SIZE = 4096; + @NonNull private AsyncHttpStack mAsyncStack; + private ByteArrayPool mPool; + + public Builder(@NonNull AsyncHttpStack httpStack) { + mAsyncStack = httpStack; + mPool = null; + } + + /** + * Sets the ByteArrayPool to be used. If not set, it will default to a pool with the default + * pool size. + */ + public Builder setPool(ByteArrayPool pool) { + mPool = pool; + return this; + } + + /** Builds the {@link com.android.volley.toolbox.BasicAsyncNetwork} */ + public BasicAsyncNetwork build() { + if (mPool == null) { + mPool = new ByteArrayPool(DEFAULT_POOL_SIZE); + } + return new BasicAsyncNetwork(mAsyncStack, mPool); + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/BasicNetwork.java b/src/main/java/com/android/volley/toolbox/BasicNetwork.java new file mode 100644 index 0000000..06427fe --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/BasicNetwork.java @@ -0,0 +1,163 @@ +/* + * 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.toolbox; + +import android.os.SystemClock; +import com.android.volley.Header; +import com.android.volley.Network; +import com.android.volley.NetworkResponse; +import com.android.volley.Request; +import com.android.volley.VolleyError; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** A network performing Volley requests over an {@link HttpStack}. */ +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. + 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 + * @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; + } + + @Override + public NetworkResponse performRequest(Request<?> request) throws VolleyError { + long requestStart = SystemClock.elapsedRealtime(); + while (true) { + HttpResponse httpResponse = null; + byte[] responseContents = null; + List<Header> responseHeaders = Collections.emptyList(); + try { + // Gather headers. + Map<String, String> additionalRequestHeaders = + HttpHeaderParser.getCacheHeaders(request.getCacheEntry()); + httpResponse = mBaseHttpStack.executeRequest(request, additionalRequestHeaders); + int statusCode = httpResponse.getStatusCode(); + + responseHeaders = httpResponse.getHeaders(); + // Handle cache validation. + if (statusCode == HttpURLConnection.HTTP_NOT_MODIFIED) { + long requestDuration = SystemClock.elapsedRealtime() - requestStart; + return NetworkUtility.getNotModifiedNetworkResponse( + request, requestDuration, responseHeaders); + } + + // Some responses such as 204s do not have content. We must check. + InputStream inputStream = httpResponse.getContent(); + if (inputStream != null) { + responseContents = + NetworkUtility.inputStreamToBytes( + inputStream, httpResponse.getContentLength(), mPool); + } else { + // Add 0 byte response as a way of honestly representing a + // no-content request. + responseContents = new byte[0]; + } + + // if the request is slow, log it. + long requestLifetime = SystemClock.elapsedRealtime() - requestStart; + NetworkUtility.logSlowRequests( + requestLifetime, request, responseContents, statusCode); + + if (statusCode < 200 || statusCode > 299) { + throw new IOException(); + } + return new NetworkResponse( + statusCode, + responseContents, + /* notModified= */ false, + SystemClock.elapsedRealtime() - requestStart, + responseHeaders); + } catch (IOException e) { + // This will either throw an exception, breaking us from the loop, or will loop + // again and retry the request. + NetworkUtility.handleException( + request, e, requestStart, httpResponse, responseContents); + } + } + } + + /** + * 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.CASE_INSENSITIVE_ORDER); + for (int i = 0; i < headers.length; i++) { + result.put(headers[i].getName(), headers[i].getValue()); + } + return result; + } +} diff --git a/src/main/java/com/android/volley/toolbox/ByteArrayPool.java b/src/main/java/com/android/volley/toolbox/ByteArrayPool.java new file mode 100644 index 0000000..0134fa2 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/ByteArrayPool.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.volley.toolbox; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * ByteArrayPool is a source and repository of <code>byte[]</code> objects. Its purpose is to supply + * those buffers to consumers who need to use them for a short period of time and then dispose of + * them. Simply creating and disposing such buffers in the conventional manner can considerable heap + * churn and garbage collection delays on Android, which lacks good management of short-lived heap + * objects. It may be advantageous to trade off some memory in the form of a permanently allocated + * pool of buffers in order to gain heap performance improvements; that is what this class does. + * + * <p>A good candidate user for this class is something like an I/O system that uses large temporary + * <code>byte[]</code> buffers to copy data around. In these use cases, often the consumer wants the + * buffer to be a certain minimum size to ensure good performance (e.g. when copying data chunks off + * of a stream), but doesn't mind if the buffer is larger than the minimum. Taking this into account + * and also to maximize the odds of being able to reuse a recycled buffer, this class is free to + * return buffers larger than the requested size. The caller needs to be able to gracefully deal + * with getting buffers any size over the minimum. + * + * <p>If there is not a suitably-sized buffer in its recycling pool when a buffer is requested, this + * class will allocate a new buffer and return it. + * + * <p>This class has no special ownership of buffers it creates; the caller is free to take a buffer + * it receives from this pool, use it permanently, and never return it to the pool; additionally, it + * is not harmful to return to this pool a buffer that was allocated elsewhere, provided there are + * no other lingering references to it. + * + * <p>This class ensures that the total size of the buffers in its recycling pool never exceeds a + * certain byte limit. When a buffer is returned that would cause the pool to exceed the limit, + * least-recently-used buffers are disposed. + */ +public class ByteArrayPool { + /** The buffer pool, arranged both by last use and by buffer size */ + private final List<byte[]> mBuffersByLastUse = new ArrayList<>(); + + private final List<byte[]> mBuffersBySize = new ArrayList<>(64); + + /** The total size of the buffers in the pool */ + private int mCurrentSize = 0; + + /** + * The maximum aggregate size of the buffers in the pool. Old buffers are discarded to stay + * under this limit. + */ + private final int mSizeLimit; + + /** Compares buffers by size */ + protected static final Comparator<byte[]> BUF_COMPARATOR = + new Comparator<byte[]>() { + @Override + public int compare(byte[] lhs, byte[] rhs) { + return lhs.length - rhs.length; + } + }; + + /** @param sizeLimit the maximum size of the pool, in bytes */ + public ByteArrayPool(int sizeLimit) { + mSizeLimit = sizeLimit; + } + + /** + * Returns a buffer from the pool if one is available in the requested size, or allocates a new + * one if a pooled one is not available. + * + * @param len the minimum size, in bytes, of the requested buffer. The returned buffer may be + * larger. + * @return a byte[] buffer is always returned. + */ + public synchronized byte[] getBuf(int len) { + for (int i = 0; i < mBuffersBySize.size(); i++) { + byte[] buf = mBuffersBySize.get(i); + if (buf.length >= len) { + mCurrentSize -= buf.length; + mBuffersBySize.remove(i); + mBuffersByLastUse.remove(buf); + return buf; + } + } + return new byte[len]; + } + + /** + * Returns a buffer to the pool, throwing away old buffers if the pool would exceed its allotted + * size. + * + * @param buf the buffer to return to the pool. + */ + public synchronized void returnBuf(byte[] buf) { + if (buf == null || buf.length > mSizeLimit) { + return; + } + mBuffersByLastUse.add(buf); + int pos = Collections.binarySearch(mBuffersBySize, buf, BUF_COMPARATOR); + if (pos < 0) { + pos = -pos - 1; + } + mBuffersBySize.add(pos, buf); + mCurrentSize += buf.length; + trim(); + } + + /** Removes buffers from the pool until it is under its size limit. */ + private synchronized void trim() { + while (mCurrentSize > mSizeLimit) { + byte[] buf = mBuffersByLastUse.remove(0); + mBuffersBySize.remove(buf); + mCurrentSize -= buf.length; + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/ClearCacheRequest.java b/src/main/java/com/android/volley/toolbox/ClearCacheRequest.java new file mode 100644 index 0000000..856ef80 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/ClearCacheRequest.java @@ -0,0 +1,66 @@ +/* + * 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.toolbox; + +import android.os.Handler; +import android.os.Looper; +import com.android.volley.Cache; +import com.android.volley.NetworkResponse; +import com.android.volley.Request; +import com.android.volley.Response; + +/** A synthetic request used for clearing the cache. */ +public class ClearCacheRequest extends Request<Object> { + private final Cache mCache; + private final Runnable mCallback; + + /** + * Creates a synthetic request for clearing the cache. + * + * @param cache Cache to clear + * @param callback Callback to make on the main thread once the cache is clear, or null for none + */ + public ClearCacheRequest(Cache cache, Runnable callback) { + super(Method.GET, null, null); + mCache = cache; + mCallback = callback; + } + + @Override + public boolean isCanceled() { + // This is a little bit of a hack, but hey, why not. + mCache.clear(); + if (mCallback != null) { + Handler handler = new Handler(Looper.getMainLooper()); + handler.postAtFrontOfQueue(mCallback); + } + return true; + } + + @Override + public Priority getPriority() { + return Priority.IMMEDIATE; + } + + @Override + protected Response<Object> parseNetworkResponse(NetworkResponse response) { + return null; + } + + @Override + protected void deliverResponse(Object response) {} +} diff --git a/src/main/java/com/android/volley/toolbox/DiskBasedCache.java b/src/main/java/com/android/volley/toolbox/DiskBasedCache.java new file mode 100644 index 0000000..d4310e0 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/DiskBasedCache.java @@ -0,0 +1,677 @@ +/* + * 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.toolbox; + +import android.os.SystemClock; +import android.text.TextUtils; +import androidx.annotation.VisibleForTesting; +import com.android.volley.Cache; +import com.android.volley.Header; +import com.android.volley.VolleyLog; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +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.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 { + + /** Map of the Key, CacheHeader pairs */ + private final Map<String, CacheHeader> mEntries = new LinkedHashMap<>(16, .75f, true); + + /** Total amount of space currently used by the cache in bytes. */ + private long mTotalSize = 0; + + /** The supplier for the root directory to use for the cache. */ + private final FileSupplier mRootDirectorySupplier; + + /** The maximum size of the cache in bytes. */ + private final int mMaxCacheSizeInBytes; + + /** Default maximum disk usage in bytes. */ + private static final int DEFAULT_DISK_USAGE_BYTES = 5 * 1024 * 1024; + + /** High water mark percentage for the cache */ + @VisibleForTesting static final float HYSTERESIS_FACTOR = 0.9f; + + /** Magic number for current version of cache file format. */ + private static final int CACHE_MAGIC = 0x20150306; + + /** + * Constructs an instance of the DiskBasedCache at the specified directory. + * + * @param rootDirectory The root directory of the cache. + * @param maxCacheSizeInBytes The maximum size of the cache in bytes. Note that the cache may + * briefly exceed this size on disk when writing a new entry that pushes it over the limit + * until the ensuing pruning completes. + */ + public DiskBasedCache(final File rootDirectory, int maxCacheSizeInBytes) { + mRootDirectorySupplier = + new FileSupplier() { + @Override + public File get() { + return rootDirectory; + } + }; + mMaxCacheSizeInBytes = maxCacheSizeInBytes; + } + + /** + * Constructs an instance of the DiskBasedCache at the specified directory. + * + * @param rootDirectorySupplier The supplier for the root directory of the cache. + * @param maxCacheSizeInBytes The maximum size of the cache in bytes. Note that the cache may + * briefly exceed this size on disk when writing a new entry that pushes it over the limit + * until the ensuing pruning completes. + */ + public DiskBasedCache(FileSupplier rootDirectorySupplier, int maxCacheSizeInBytes) { + mRootDirectorySupplier = rootDirectorySupplier; + mMaxCacheSizeInBytes = maxCacheSizeInBytes; + } + + /** + * Constructs an instance of the DiskBasedCache at the specified directory using the default + * maximum cache size of 5MB. + * + * @param rootDirectory The root directory of the cache. + */ + public DiskBasedCache(File rootDirectory) { + this(rootDirectory, DEFAULT_DISK_USAGE_BYTES); + } + + /** + * Constructs an instance of the DiskBasedCache at the specified directory using the default + * maximum cache size of 5MB. + * + * @param rootDirectorySupplier The supplier for the root directory of the cache. + */ + public DiskBasedCache(FileSupplier rootDirectorySupplier) { + this(rootDirectorySupplier, DEFAULT_DISK_USAGE_BYTES); + } + + /** Clears the cache. Deletes all cached files from disk. */ + @Override + public synchronized void clear() { + File[] files = mRootDirectorySupplier.get().listFiles(); + if (files != null) { + for (File file : files) { + file.delete(); + } + } + mEntries.clear(); + mTotalSize = 0; + VolleyLog.d("Cache cleared."); + } + + /** Returns the cache entry with the specified key if it exists, null otherwise. */ + @Override + public synchronized Entry get(String key) { + CacheHeader entry = mEntries.get(key); + // if the entry does not exist, return. + if (entry == null) { + return null; + } + File file = getFileForKey(key); + try { + CountingInputStream cis = + new CountingInputStream( + new BufferedInputStream(createInputStream(file)), file.length()); + try { + CacheHeader entryOnDisk = CacheHeader.readHeader(cis); + if (!TextUtils.equals(key, entryOnDisk.key)) { + // File was shared by two keys and now holds data for a different entry! + VolleyLog.d( + "%s: key=%s, found=%s", file.getAbsolutePath(), key, entryOnDisk.key); + // Remove key whose contents on disk have been replaced. + removeEntry(key); + return null; + } + byte[] data = streamToBytes(cis, cis.bytesRemaining()); + return entry.toCacheEntry(data); + } finally { + // Any IOException thrown here is handled by the below catch block by design. + //noinspection ThrowFromFinallyBlock + cis.close(); + } + } catch (IOException e) { + VolleyLog.d("%s: %s", file.getAbsolutePath(), e.toString()); + remove(key); + return null; + } + } + + /** + * Initializes the DiskBasedCache by scanning for all files currently in the specified root + * directory. Creates the root directory if necessary. + */ + @Override + public synchronized void initialize() { + File rootDirectory = mRootDirectorySupplier.get(); + if (!rootDirectory.exists()) { + if (!rootDirectory.mkdirs()) { + VolleyLog.e("Unable to create cache dir %s", rootDirectory.getAbsolutePath()); + } + return; + } + File[] files = rootDirectory.listFiles(); + if (files == null) { + return; + } + for (File file : files) { + try { + long entrySize = file.length(); + CountingInputStream cis = + new CountingInputStream( + new BufferedInputStream(createInputStream(file)), entrySize); + try { + CacheHeader entry = CacheHeader.readHeader(cis); + entry.size = entrySize; + putEntry(entry.key, entry); + } finally { + // Any IOException thrown here is handled by the below catch block by design. + //noinspection ThrowFromFinallyBlock + cis.close(); + } + } catch (IOException e) { + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + } + } + + /** + * Invalidates an entry in the cache. + * + * @param key Cache key + * @param fullExpire True to fully expire the entry, false to soft expire + */ + @Override + public synchronized void invalidate(String key, boolean fullExpire) { + Entry entry = get(key); + if (entry != null) { + entry.softTtl = 0; + if (fullExpire) { + entry.ttl = 0; + } + put(key, entry); + } + } + + /** Puts the entry with the specified key into the cache. */ + @Override + public synchronized void put(String key, Entry entry) { + // If adding this entry would trigger a prune, but pruning would cause the new entry to be + // deleted, then skip writing the entry in the first place, as this is just churn. + // Note that we don't include the cache header overhead in this calculation for simplicity, + // so putting entries which are just below the threshold may still cause this churn. + if (mTotalSize + entry.data.length > mMaxCacheSizeInBytes + && entry.data.length > mMaxCacheSizeInBytes * HYSTERESIS_FACTOR) { + return; + } + File file = getFileForKey(key); + try { + BufferedOutputStream fos = new BufferedOutputStream(createOutputStream(file)); + CacheHeader e = new CacheHeader(key, entry); + boolean success = e.writeHeader(fos); + if (!success) { + fos.close(); + VolleyLog.d("Failed to write header for %s", file.getAbsolutePath()); + throw new IOException(); + } + fos.write(entry.data); + fos.close(); + e.size = file.length(); + putEntry(key, e); + pruneIfNeeded(); + } catch (IOException e) { + boolean deleted = file.delete(); + if (!deleted) { + VolleyLog.d("Could not clean up file %s", file.getAbsolutePath()); + } + initializeIfRootDirectoryDeleted(); + } + } + + /** Removes the specified key from the cache if it exists. */ + @Override + public synchronized void remove(String key) { + boolean deleted = getFileForKey(key).delete(); + removeEntry(key); + if (!deleted) { + VolleyLog.d( + "Could not delete cache entry for key=%s, filename=%s", + key, getFilenameForKey(key)); + } + } + + /** + * Creates a pseudo-unique filename for the specified cache key. + * + * @param key The key to generate a file name for. + * @return A pseudo-unique filename. + */ + private String getFilenameForKey(String key) { + int firstHalfLength = key.length() / 2; + String localFilename = String.valueOf(key.substring(0, firstHalfLength).hashCode()); + localFilename += String.valueOf(key.substring(firstHalfLength).hashCode()); + return localFilename; + } + + /** Returns a file object for the given cache key. */ + public File getFileForKey(String key) { + return new File(mRootDirectorySupplier.get(), getFilenameForKey(key)); + } + + /** Re-initialize the cache if the directory was deleted. */ + private void initializeIfRootDirectoryDeleted() { + if (!mRootDirectorySupplier.get().exists()) { + VolleyLog.d("Re-initializing cache after external clearing."); + mEntries.clear(); + mTotalSize = 0; + initialize(); + } + } + + /** Represents a supplier for {@link File}s. */ + public interface FileSupplier { + File get(); + } + + /** Prunes the cache to fit the maximum size. */ + private void pruneIfNeeded() { + if (mTotalSize < mMaxCacheSizeInBytes) { + return; + } + if (VolleyLog.DEBUG) { + VolleyLog.v("Pruning old cache entries."); + } + + long before = mTotalSize; + int prunedFiles = 0; + long startTime = SystemClock.elapsedRealtime(); + + Iterator<Map.Entry<String, CacheHeader>> iterator = mEntries.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry<String, CacheHeader> entry = iterator.next(); + CacheHeader e = entry.getValue(); + boolean deleted = getFileForKey(e.key).delete(); + if (deleted) { + mTotalSize -= e.size; + } else { + VolleyLog.d( + "Could not delete cache entry for key=%s, filename=%s", + e.key, getFilenameForKey(e.key)); + } + iterator.remove(); + prunedFiles++; + + if (mTotalSize < mMaxCacheSizeInBytes * HYSTERESIS_FACTOR) { + break; + } + } + + if (VolleyLog.DEBUG) { + VolleyLog.v( + "pruned %d files, %d bytes, %d ms", + prunedFiles, (mTotalSize - before), SystemClock.elapsedRealtime() - startTime); + } + } + + /** + * Puts the entry with the specified key into the cache. + * + * @param key The key to identify the entry by. + * @param entry The entry to cache. + */ + private void putEntry(String key, CacheHeader entry) { + if (!mEntries.containsKey(key)) { + mTotalSize += entry.size; + } else { + CacheHeader oldEntry = mEntries.get(key); + mTotalSize += (entry.size - oldEntry.size); + } + mEntries.put(key, entry); + } + + /** Removes the entry identified by 'key' from the cache. */ + private void removeEntry(String key) { + CacheHeader removed = mEntries.remove(key); + if (removed != null) { + mTotalSize -= removed.size; + } + } + + /** + * Reads length bytes from CountingInputStream into byte array. + * + * @param cis input stream + * @param length number of bytes to read + * @throws IOException if fails to read all bytes + */ + @VisibleForTesting + static byte[] streamToBytes(CountingInputStream cis, long length) throws IOException { + long maxLength = cis.bytesRemaining(); + // Length cannot be negative or greater than bytes remaining, and must not overflow int. + if (length < 0 || length > maxLength || (int) length != length) { + throw new IOException("streamToBytes length=" + length + ", maxLength=" + maxLength); + } + byte[] bytes = new byte[(int) length]; + new DataInputStream(cis).readFully(bytes); + return bytes; + } + + @VisibleForTesting + InputStream createInputStream(File file) throws FileNotFoundException { + return new FileInputStream(file); + } + + @VisibleForTesting + OutputStream createOutputStream(File file) throws FileNotFoundException { + return new FileOutputStream(file); + } + + /** Handles holding onto the cache headers for an entry. */ + @VisibleForTesting + static class CacheHeader { + /** + * The size of the data identified by this CacheHeader on disk (both header and data). + * + * <p>Must be set by the caller after it has been calculated. + * + * <p>This is not serialized to disk. + */ + long size; + + /** The key that identifies the cache entry. */ + final String key; + + /** ETag for cache coherence. */ + final String etag; + + /** Date of this response as reported by the server. */ + final long serverDate; + + /** The last modified date for the requested object. */ + final long lastModified; + + /** TTL for this record. */ + final long ttl; + + /** Soft TTL for this record. */ + final long softTtl; + + /** Headers from the response resulting in this cache entry. */ + final List<Header> allResponseHeaders; + + private CacheHeader( + String key, + String etag, + long serverDate, + long lastModified, + long ttl, + 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.allResponseHeaders = allResponseHeaders; + } + + /** + * 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, + getAllResponseHeaders(entry)); + } + + 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. + * @throws IOException if fails to read header + */ + static CacheHeader readHeader(CountingInputStream is) throws IOException { + int magic = readInt(is); + if (magic != CACHE_MAGIC) { + // don't bother deleting, it'll get pruned eventually + throw new IOException(); + } + String key = readString(is); + String etag = readString(is); + long serverDate = readLong(is); + long lastModified = readLong(is); + long ttl = readLong(is); + long softTtl = readLong(is); + List<Header> allResponseHeaders = readHeaderList(is); + return new CacheHeader( + key, etag, serverDate, lastModified, ttl, softTtl, allResponseHeaders); + } + + /** Creates a cache entry for the specified data. */ + Entry toCacheEntry(byte[] data) { + Entry e = new Entry(); + e.data = data; + e.etag = etag; + e.serverDate = serverDate; + e.lastModified = lastModified; + e.ttl = ttl; + e.softTtl = softTtl; + e.responseHeaders = HttpHeaderParser.toHeaderMap(allResponseHeaders); + e.allResponseHeaders = Collections.unmodifiableList(allResponseHeaders); + return e; + } + + /** Writes the contents of this CacheHeader to the specified OutputStream. */ + boolean writeHeader(OutputStream os) { + try { + writeInt(os, CACHE_MAGIC); + writeString(os, key); + writeString(os, etag == null ? "" : etag); + writeLong(os, serverDate); + writeLong(os, lastModified); + writeLong(os, ttl); + writeLong(os, softTtl); + writeHeaderList(allResponseHeaders, os); + os.flush(); + return true; + } catch (IOException e) { + VolleyLog.d("%s", e.toString()); + return false; + } + } + } + + @VisibleForTesting + static class CountingInputStream extends FilterInputStream { + private final long length; + private long bytesRead; + + CountingInputStream(InputStream in, long length) { + super(in); + this.length = length; + } + + @Override + public int read() throws IOException { + int result = super.read(); + if (result != -1) { + bytesRead++; + } + return result; + } + + @Override + public int read(byte[] buffer, int offset, int count) throws IOException { + int result = super.read(buffer, offset, count); + if (result != -1) { + bytesRead += result; + } + return result; + } + + @VisibleForTesting + long bytesRead() { + return bytesRead; + } + + long bytesRemaining() { + return length - bytesRead; + } + } + + /* + * Homebrewed simple serialization system used for reading and writing cache + * headers on disk. Once upon a time, this used the standard Java + * Object{Input,Output}Stream, but the default implementation relies heavily + * on reflection (even for standard types) and generates a ton of garbage. + * + * TODO: Replace by standard DataInput and DataOutput in next cache version. + */ + + /** + * Simple wrapper around {@link InputStream#read()} that throws EOFException instead of + * returning -1. + */ + private static int read(InputStream is) throws IOException { + int b = is.read(); + if (b == -1) { + throw new EOFException(); + } + return b; + } + + static void writeInt(OutputStream os, int n) throws IOException { + os.write((n >> 0) & 0xff); + os.write((n >> 8) & 0xff); + os.write((n >> 16) & 0xff); + os.write((n >> 24) & 0xff); + } + + static int readInt(InputStream is) throws IOException { + int n = 0; + n |= (read(is) << 0); + n |= (read(is) << 8); + n |= (read(is) << 16); + n |= (read(is) << 24); + return n; + } + + static void writeLong(OutputStream os, long n) throws IOException { + os.write((byte) (n >>> 0)); + os.write((byte) (n >>> 8)); + os.write((byte) (n >>> 16)); + os.write((byte) (n >>> 24)); + os.write((byte) (n >>> 32)); + os.write((byte) (n >>> 40)); + os.write((byte) (n >>> 48)); + os.write((byte) (n >>> 56)); + } + + static long readLong(InputStream is) throws IOException { + long n = 0; + n |= ((read(is) & 0xFFL) << 0); + n |= ((read(is) & 0xFFL) << 8); + n |= ((read(is) & 0xFFL) << 16); + n |= ((read(is) & 0xFFL) << 24); + n |= ((read(is) & 0xFFL) << 32); + n |= ((read(is) & 0xFFL) << 40); + n |= ((read(is) & 0xFFL) << 48); + n |= ((read(is) & 0xFFL) << 56); + return n; + } + + static void writeString(OutputStream os, String s) throws IOException { + byte[] b = s.getBytes("UTF-8"); + writeLong(os, b.length); + os.write(b, 0, b.length); + } + + static String readString(CountingInputStream cis) throws IOException { + long n = readLong(cis); + byte[] b = streamToBytes(cis, n); + return new String(b, "UTF-8"); + } + + 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 List<Header> readHeaderList(CountingInputStream cis) throws IOException { + int size = readInt(cis); + if (size < 0) { + throw new IOException("readHeaderList size=" + size); + } + List<Header> result = + (size == 0) ? Collections.<Header>emptyList() : new ArrayList<Header>(); + for (int i = 0; i < size; i++) { + String name = readString(cis).intern(); + String value = readString(cis).intern(); + result.add(new Header(name, value)); + } + return result; + } +} diff --git a/src/main/java/com/android/volley/toolbox/FileSupplier.java b/src/main/java/com/android/volley/toolbox/FileSupplier.java new file mode 100644 index 0000000..70898a6 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/FileSupplier.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.volley.toolbox; + +import java.io.File; + +/** Represents a supplier for {@link File}s. */ +public interface FileSupplier { + File get(); +} diff --git a/src/main/java/com/android/volley/toolbox/HttpClientStack.java b/src/main/java/com/android/volley/toolbox/HttpClientStack.java new file mode 100644 index 0000000..1e9e4b0 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/HttpClientStack.java @@ -0,0 +1,201 @@ +/* + * 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.toolbox; + +import com.android.volley.AuthFailureError; +import com.android.volley.Request; +import com.android.volley.Request.Method; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.NameValuePair; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpHead; +import org.apache.http.client.methods.HttpOptions; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpTrace; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.params.HttpConnectionParams; +import org.apache.http.params.HttpParams; + +/** + * 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; + + private static final String HEADER_CONTENT_TYPE = "Content-Type"; + + public HttpClientStack(HttpClient client) { + mClient = client; + } + + private static void setHeaders(HttpUriRequest httpRequest, Map<String, String> headers) { + for (String key : headers.keySet()) { + httpRequest.setHeader(key, headers.get(key)); + } + } + + @SuppressWarnings("unused") + private static List<NameValuePair> getPostParameterPairs(Map<String, String> postParams) { + List<NameValuePair> result = new ArrayList<>(postParams.size()); + for (String key : postParams.keySet()) { + result.add(new BasicNameValuePair(key, postParams.get(key))); + } + return result; + } + + @Override + public HttpResponse performRequest(Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError { + HttpUriRequest httpRequest = createHttpRequest(request, additionalHeaders); + setHeaders(httpRequest, additionalHeaders); + // Request.getHeaders() takes precedence over the given additional (cache) headers) and any + // headers set by createHttpRequest (like the Content-Type header). + setHeaders(httpRequest, request.getHeaders()); + onPrepareRequest(httpRequest); + HttpParams httpParams = httpRequest.getParams(); + int timeoutMs = request.getTimeoutMs(); + // TODO: Reevaluate this connection timeout based on more wide-scale + // data collection and possibly different for wifi vs. 3G. + HttpConnectionParams.setConnectionTimeout(httpParams, 5000); + HttpConnectionParams.setSoTimeout(httpParams, timeoutMs); + return mClient.execute(httpRequest); + } + + /** Creates the appropriate subclass of HttpUriRequest for passed in request. */ + @SuppressWarnings("deprecation") + /* protected */ static HttpUriRequest createHttpRequest( + Request<?> request, Map<String, String> additionalHeaders) throws AuthFailureError { + switch (request.getMethod()) { + case Method.DEPRECATED_GET_OR_POST: + { + // This is the deprecated way that needs to be handled for backwards + // compatibility. + // If the request's post body is null, then the assumption is that the request + // is + // GET. Otherwise, it is assumed that the request is a POST. + byte[] postBody = request.getPostBody(); + if (postBody != null) { + HttpPost postRequest = new HttpPost(request.getUrl()); + postRequest.addHeader( + HEADER_CONTENT_TYPE, request.getPostBodyContentType()); + HttpEntity entity; + entity = new ByteArrayEntity(postBody); + postRequest.setEntity(entity); + return postRequest; + } else { + return new HttpGet(request.getUrl()); + } + } + case Method.GET: + return new HttpGet(request.getUrl()); + case Method.DELETE: + return new HttpDelete(request.getUrl()); + case Method.POST: + { + HttpPost postRequest = new HttpPost(request.getUrl()); + postRequest.addHeader(HEADER_CONTENT_TYPE, request.getBodyContentType()); + setEntityIfNonEmptyBody(postRequest, request); + return postRequest; + } + case Method.PUT: + { + HttpPut putRequest = new HttpPut(request.getUrl()); + putRequest.addHeader(HEADER_CONTENT_TYPE, request.getBodyContentType()); + setEntityIfNonEmptyBody(putRequest, request); + return putRequest; + } + case Method.HEAD: + return new HttpHead(request.getUrl()); + case Method.OPTIONS: + return new HttpOptions(request.getUrl()); + case Method.TRACE: + return new HttpTrace(request.getUrl()); + case Method.PATCH: + { + HttpPatch patchRequest = new HttpPatch(request.getUrl()); + patchRequest.addHeader(HEADER_CONTENT_TYPE, request.getBodyContentType()); + setEntityIfNonEmptyBody(patchRequest, request); + return patchRequest; + } + default: + throw new IllegalStateException("Unknown request method."); + } + } + + private static void setEntityIfNonEmptyBody( + HttpEntityEnclosingRequestBase httpRequest, Request<?> request) + throws AuthFailureError { + byte[] body = request.getBody(); + if (body != null) { + HttpEntity entity = new ByteArrayEntity(body); + httpRequest.setEntity(entity); + } + } + + /** + * Called before the request is executed using the underlying HttpClient. + * + * <p>Overwrite in subclasses to augment the request. + */ + protected void onPrepareRequest(HttpUriRequest request) throws IOException { + // Nothing. + } + + /** + * The HttpPatch class does not exist in the Android framework, so this has been defined here. + */ + public static final class HttpPatch extends HttpEntityEnclosingRequestBase { + + public static final String METHOD_NAME = "PATCH"; + + public HttpPatch() { + super(); + } + + public HttpPatch(final URI uri) { + super(); + setURI(uri); + } + + /** @throws IllegalArgumentException if the uri is invalid. */ + public HttpPatch(final String uri) { + super(); + setURI(URI.create(uri)); + } + + @Override + public String getMethod() { + return METHOD_NAME; + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java b/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java new file mode 100644 index 0000000..0b29e80 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/HttpHeaderParser.java @@ -0,0 +1,301 @@ +/* + * 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.toolbox; + +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; +import androidx.annotation.RestrictTo.Scope; +import com.android.volley.Cache; +import com.android.volley.Header; +import com.android.volley.NetworkResponse; +import com.android.volley.VolleyLog; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.TreeMap; +import java.util.TreeSet; + +/** Utility methods for parsing HTTP headers. */ +public class HttpHeaderParser { + + @RestrictTo({Scope.LIBRARY_GROUP}) + public static final String HEADER_CONTENT_TYPE = "Content-Type"; + + private static final String DEFAULT_CONTENT_CHARSET = "ISO-8859-1"; + + private static final String RFC1123_PARSE_FORMAT = "EEE, dd MMM yyyy HH:mm:ss zzz"; + + // Hardcode 'GMT' rather than using 'zzz' since some platforms append an extraneous +00:00. + // See #287. + private static final String RFC1123_OUTPUT_FORMAT = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"; + + /** + * Extracts a {@link com.android.volley.Cache.Entry} from a {@link NetworkResponse}. + * + * @param response The network response to parse headers from + * @return a cache entry for the given response, or null if the response is not cacheable. + */ + @Nullable + public static Cache.Entry parseCacheHeaders(NetworkResponse response) { + long now = System.currentTimeMillis(); + + Map<String, String> headers = response.headers; + if (headers == null) { + return null; + } + + long serverDate = 0; + long lastModified = 0; + long serverExpires = 0; + long softExpire = 0; + long finalExpire = 0; + long maxAge = 0; + long staleWhileRevalidate = 0; + boolean hasCacheControl = false; + boolean mustRevalidate = false; + + String serverEtag = null; + String headerValue; + + headerValue = headers.get("Date"); + if (headerValue != null) { + serverDate = parseDateAsEpoch(headerValue); + } + + headerValue = headers.get("Cache-Control"); + if (headerValue != null) { + hasCacheControl = true; + String[] tokens = headerValue.split(",", 0); + for (int i = 0; i < tokens.length; i++) { + String token = tokens[i].trim(); + if (token.equals("no-cache") || token.equals("no-store")) { + return null; + } else if (token.startsWith("max-age=")) { + try { + maxAge = Long.parseLong(token.substring(8)); + } catch (Exception e) { + } + } else if (token.startsWith("stale-while-revalidate=")) { + try { + staleWhileRevalidate = Long.parseLong(token.substring(23)); + } catch (Exception e) { + } + } else if (token.equals("must-revalidate") || token.equals("proxy-revalidate")) { + mustRevalidate = true; + } + } + } + + headerValue = headers.get("Expires"); + if (headerValue != null) { + serverExpires = parseDateAsEpoch(headerValue); + } + + headerValue = headers.get("Last-Modified"); + if (headerValue != null) { + lastModified = parseDateAsEpoch(headerValue); + } + + serverEtag = headers.get("ETag"); + + // Cache-Control takes precedence over an Expires header, even if both exist and Expires + // is more restrictive. + if (hasCacheControl) { + softExpire = now + maxAge * 1000; + finalExpire = mustRevalidate ? softExpire : softExpire + staleWhileRevalidate * 1000; + } else if (serverDate > 0 && serverExpires >= serverDate) { + // Default semantic for Expire header in HTTP specification is softExpire. + softExpire = now + (serverExpires - serverDate); + finalExpire = softExpire; + } + + Cache.Entry entry = new Cache.Entry(); + entry.data = response.data; + entry.etag = serverEtag; + entry.softTtl = softExpire; + entry.ttl = finalExpire; + entry.serverDate = serverDate; + entry.lastModified = lastModified; + entry.responseHeaders = headers; + entry.allResponseHeaders = response.allHeaders; + + return entry; + } + + /** Parse date in RFC1123 format, and return its value as epoch */ + public static long parseDateAsEpoch(String dateStr) { + try { + // Parse date in RFC1123 format if this header contains one + return newUsGmtFormatter(RFC1123_PARSE_FORMAT).parse(dateStr).getTime(); + } catch (ParseException e) { + // Date in invalid format, fallback to 0 + // If the value is either "0" or "-1" we only log to verbose, + // these values are pretty common and cause log spam. + String message = "Unable to parse dateStr: %s, falling back to 0"; + if ("0".equals(dateStr) || "-1".equals(dateStr)) { + VolleyLog.v(message, dateStr); + } else { + VolleyLog.e(e, message, dateStr); + } + + return 0; + } + } + + /** Format an epoch date in RFC1123 format. */ + static String formatEpochAsRfc1123(long epoch) { + return newUsGmtFormatter(RFC1123_OUTPUT_FORMAT).format(new Date(epoch)); + } + + private static SimpleDateFormat newUsGmtFormatter(String format) { + SimpleDateFormat formatter = new SimpleDateFormat(format, Locale.US); + formatter.setTimeZone(TimeZone.getTimeZone("GMT")); + return formatter; + } + + /** + * Retrieve a charset from headers + * + * @param headers An {@link java.util.Map} of headers + * @param defaultCharset Charset to return if none can be found + * @return Returns the charset specified in the Content-Type of this header, or the + * defaultCharset if none can be found. + */ + public static String parseCharset( + @Nullable Map<String, String> headers, String defaultCharset) { + if (headers == null) { + return defaultCharset; + } + String contentType = headers.get(HEADER_CONTENT_TYPE); + if (contentType != null) { + String[] params = contentType.split(";", 0); + for (int i = 1; i < params.length; i++) { + String[] pair = params[i].trim().split("=", 0); + if (pair.length == 2) { + if (pair[0].equals("charset")) { + return pair[1]; + } + } + } + } + + return defaultCharset; + } + + /** + * Returns the charset specified in the Content-Type of this header, or the HTTP default + * (ISO-8859-1) if none can be found. + */ + public static String parseCharset(@Nullable Map<String, String> headers) { + 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; + } + + /** + * Combine cache headers with network response headers for an HTTP 304 response. + * + * <p>An HTTP 304 response does not have all header fields. We have to use the header fields + * from the cache entry plus the new ones from the response. See also: + * http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5 + * + * @param responseHeaders Headers from the network response. + * @param entry The cached response. + * @return The combined list of headers. + */ + static List<Header> combineHeaders(List<Header> responseHeaders, Cache.Entry entry) { + // First, create a case-insensitive set of header names from the network + // response. + Set<String> headerNamesFromNetworkResponse = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + if (!responseHeaders.isEmpty()) { + for (Header header : responseHeaders) { + headerNamesFromNetworkResponse.add(header.getName()); + } + } + + // Second, add headers from the cache entry to the network response as long as + // they didn't appear in the network response, which should take precedence. + List<Header> combinedHeaders = new ArrayList<>(responseHeaders); + if (entry.allResponseHeaders != null) { + if (!entry.allResponseHeaders.isEmpty()) { + for (Header header : entry.allResponseHeaders) { + if (!headerNamesFromNetworkResponse.contains(header.getName())) { + combinedHeaders.add(header); + } + } + } + } else { + // Legacy caches only have entry.responseHeaders. + if (!entry.responseHeaders.isEmpty()) { + for (Map.Entry<String, String> header : entry.responseHeaders.entrySet()) { + if (!headerNamesFromNetworkResponse.contains(header.getKey())) { + combinedHeaders.add(new Header(header.getKey(), header.getValue())); + } + } + } + } + return combinedHeaders; + } + + static Map<String, String> getCacheHeaders(Cache.Entry entry) { + // If there's no cache entry, we're done. + if (entry == null) { + return Collections.emptyMap(); + } + + Map<String, String> headers = new HashMap<>(); + + if (entry.etag != null) { + headers.put("If-None-Match", entry.etag); + } + + if (entry.lastModified > 0) { + headers.put( + "If-Modified-Since", HttpHeaderParser.formatEpochAsRfc1123(entry.lastModified)); + } + + return headers; + } +} diff --git a/src/main/java/com/android/volley/toolbox/HttpResponse.java b/src/main/java/com/android/volley/toolbox/HttpResponse.java new file mode 100644 index 0000000..595f926 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/HttpResponse.java @@ -0,0 +1,118 @@ +/* + * 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 androidx.annotation.Nullable; +import com.android.volley.Header; +import java.io.ByteArrayInputStream; +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; + @Nullable private final InputStream mContent; + @Nullable private final byte[] mContentBytes; + + /** + * 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, /* contentLength= */ -1, /* content= */ null); + } + + /** + * 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; + mContentBytes = null; + } + + /** + * Construct a new HttpResponse. + * + * @param statusCode the HTTP status code of the response + * @param headers the response headers + * @param contentBytes a byte[] of the response content. This is an optimization for HTTP stacks + * that natively support returning a byte[]. + */ + public HttpResponse(int statusCode, List<Header> headers, byte[] contentBytes) { + mStatusCode = statusCode; + mHeaders = headers; + mContentLength = contentBytes.length; + mContentBytes = contentBytes; + mContent = null; + } + + /** Returns the HTTP status code of the response. */ + 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; + } + + /** + * If a byte[] was already provided by an HTTP stack that natively supports returning one, this + * method will return that byte[] as an optimization over copying the bytes from an input + * stream. It may return null, even if the response has content, as long as mContent is + * provided. + */ + @Nullable + public final byte[] getContentBytes() { + return mContentBytes; + } + + /** + * Returns an {@link InputStream} of the response content. May be null to indicate that the + * response has no content. + */ + @Nullable + public final InputStream getContent() { + if (mContent != null) { + return mContent; + } else if (mContentBytes != null) { + return new ByteArrayInputStream(mContentBytes); + } else { + return null; + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/HttpStack.java b/src/main/java/com/android/volley/toolbox/HttpStack.java new file mode 100644 index 0000000..85179a7 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/HttpStack.java @@ -0,0 +1,47 @@ +/* + * 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.toolbox; + +import com.android.volley.AuthFailureError; +import com.android.volley.Request; +import java.io.IOException; +import java.util.Map; +import org.apache.http.HttpResponse; + +/** + * 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. + * + * <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 HTTP response + */ + HttpResponse performRequest(Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError; +} diff --git a/src/main/java/com/android/volley/toolbox/HurlStack.java b/src/main/java/com/android/volley/toolbox/HurlStack.java new file mode 100644 index 0000000..35c6a72 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/HurlStack.java @@ -0,0 +1,321 @@ +/* + * 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.toolbox; + +import androidx.annotation.VisibleForTesting; +import com.android.volley.AuthFailureError; +import com.android.volley.Header; +import com.android.volley.Request; +import com.android.volley.Request.Method; +import java.io.DataOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; + +/** A {@link BaseHttpStack} based on {@link HttpURLConnection}. */ +public class HurlStack extends BaseHttpStack { + + private static final int HTTP_CONTINUE = 100; + + /** An interface for transforming URLs before use. */ + public interface UrlRewriter extends com.android.volley.toolbox.UrlRewriter {} + + private final UrlRewriter mUrlRewriter; + private final SSLSocketFactory mSslSocketFactory; + + public HurlStack() { + this(/* urlRewriter = */ null); + } + + /** @param urlRewriter Rewriter to use for request URLs */ + public HurlStack(UrlRewriter urlRewriter) { + this(urlRewriter, /* sslSocketFactory = */ null); + } + + /** + * @param urlRewriter Rewriter to use for request URLs + * @param sslSocketFactory SSL factory to use for HTTPS connections + */ + public HurlStack(UrlRewriter urlRewriter, SSLSocketFactory sslSocketFactory) { + mUrlRewriter = urlRewriter; + mSslSocketFactory = sslSocketFactory; + } + + @Override + public HttpResponse executeRequest(Request<?> request, Map<String, String> additionalHeaders) + throws IOException, AuthFailureError { + String url = request.getUrl(); + HashMap<String, String> map = new HashMap<>(); + map.putAll(additionalHeaders); + // Request.getHeaders() takes precedence over the given additional (cache) headers). + map.putAll(request.getHeaders()); + if (mUrlRewriter != null) { + String rewritten = mUrlRewriter.rewriteUrl(url); + if (rewritten == null) { + throw new IOException("URL blocked by rewriter: " + url); + } + url = rewritten; + } + URL parsedUrl = new URL(url); + HttpURLConnection connection = openConnection(parsedUrl, request); + boolean keepConnectionOpen = false; + try { + for (String headerName : map.keySet()) { + connection.setRequestProperty(headerName, map.get(headerName)); + } + setConnectionParametersForRequest(connection, request); + // Initialize HttpResponse with data from the HttpURLConnection. + 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."); + } + + if (!hasResponseBody(request.getMethod(), responseCode)) { + return new HttpResponse(responseCode, convertHeaders(connection.getHeaderFields())); + } + + // Need to keep the connection open until the stream is consumed by the caller. Wrap the + // stream such that close() will disconnect the connection. + keepConnectionOpen = true; + return new HttpResponse( + responseCode, + convertHeaders(connection.getHeaderFields()), + connection.getContentLength(), + createInputStream(request, connection)); + } finally { + if (!keepConnectionOpen) { + connection.disconnect(); + } + } + } + + @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 headerList; + } + + /** + * Checks if a response message contains a body. + * + * @see <a href="https://tools.ietf.org/html/rfc7230#section-3.3">RFC 7230 section 3.3</a> + * @param requestMethod request method + * @param responseCode response status code + * @return whether the response has a body + */ + private static boolean hasResponseBody(int requestMethod, int responseCode) { + return requestMethod != Request.Method.HEAD + && !(HTTP_CONTINUE <= responseCode && responseCode < HttpURLConnection.HTTP_OK) + && responseCode != HttpURLConnection.HTTP_NO_CONTENT + && responseCode != HttpURLConnection.HTTP_NOT_MODIFIED; + } + + /** + * Wrapper for a {@link HttpURLConnection}'s InputStream which disconnects the connection on + * stream close. + */ + static class UrlConnectionInputStream extends FilterInputStream { + private final HttpURLConnection mConnection; + + UrlConnectionInputStream(HttpURLConnection connection) { + super(inputStreamFromConnection(connection)); + mConnection = connection; + } + + @Override + public void close() throws IOException { + super.close(); + mConnection.disconnect(); + } + } + + /** + * Create and return an InputStream from which the response will be read. + * + * <p>May be overridden by subclasses to manipulate or monitor this input stream. + * + * @param request current request. + * @param connection current connection of request. + * @return an InputStream from which the response will be read. + */ + protected InputStream createInputStream(Request<?> request, HttpURLConnection connection) { + return new UrlConnectionInputStream(connection); + } + + /** + * Initializes an {@link InputStream} from the given {@link HttpURLConnection}. + * + * @param connection + * @return an HttpEntity populated with data from <code>connection</code>. + */ + private static InputStream inputStreamFromConnection(HttpURLConnection connection) { + InputStream inputStream; + try { + inputStream = connection.getInputStream(); + } catch (IOException ioe) { + inputStream = connection.getErrorStream(); + } + return inputStream; + } + + /** Create an {@link HttpURLConnection} for the specified {@code url}. */ + protected HttpURLConnection createConnection(URL url) throws IOException { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + // Workaround for the M release HttpURLConnection not observing the + // HttpURLConnection.setFollowRedirects() property. + // https://code.google.com/p/android/issues/detail?id=194495 + connection.setInstanceFollowRedirects(HttpURLConnection.getFollowRedirects()); + + return connection; + } + + /** + * Opens an {@link HttpURLConnection} with parameters. + * + * @param url + * @return an open connection + * @throws IOException + */ + private HttpURLConnection openConnection(URL url, Request<?> request) throws IOException { + HttpURLConnection connection = createConnection(url); + + int timeoutMs = request.getTimeoutMs(); + connection.setConnectTimeout(timeoutMs); + connection.setReadTimeout(timeoutMs); + connection.setUseCaches(false); + connection.setDoInput(true); + + // use caller-provided custom SslSocketFactory, if any, for HTTPS + if ("https".equals(url.getProtocol()) && mSslSocketFactory != null) { + ((HttpsURLConnection) connection).setSSLSocketFactory(mSslSocketFactory); + } + + return connection; + } + + // NOTE: Any request headers added here (via setRequestProperty or addRequestProperty) should be + // checked against the existing properties in the connection and not overridden if already set. + @SuppressWarnings("deprecation") + /* package */ void setConnectionParametersForRequest( + HttpURLConnection connection, Request<?> request) throws IOException, AuthFailureError { + switch (request.getMethod()) { + case Method.DEPRECATED_GET_OR_POST: + // This is the deprecated way that needs to be handled for backwards compatibility. + // If the request's post body is null, then the assumption is that the request is + // GET. Otherwise, it is assumed that the request is a POST. + byte[] postBody = request.getPostBody(); + if (postBody != null) { + connection.setRequestMethod("POST"); + addBody(connection, request, postBody); + } + break; + case Method.GET: + // Not necessary to set the request method because connection defaults to GET but + // being explicit here. + connection.setRequestMethod("GET"); + break; + case Method.DELETE: + connection.setRequestMethod("DELETE"); + break; + case Method.POST: + connection.setRequestMethod("POST"); + addBodyIfExists(connection, request); + break; + case Method.PUT: + connection.setRequestMethod("PUT"); + addBodyIfExists(connection, request); + break; + case Method.HEAD: + connection.setRequestMethod("HEAD"); + break; + case Method.OPTIONS: + connection.setRequestMethod("OPTIONS"); + break; + case Method.TRACE: + connection.setRequestMethod("TRACE"); + break; + case Method.PATCH: + connection.setRequestMethod("PATCH"); + addBodyIfExists(connection, request); + break; + default: + throw new IllegalStateException("Unknown method type."); + } + } + + private void addBodyIfExists(HttpURLConnection connection, Request<?> request) + throws IOException, AuthFailureError { + byte[] body = request.getBody(); + if (body != null) { + addBody(connection, request, body); + } + } + + private void addBody(HttpURLConnection connection, Request<?> request, byte[] body) + throws IOException { + // Prepare output. There is no need to set Content-Length explicitly, + // since this is handled by HttpURLConnection using the size of the prepared + // output stream. + connection.setDoOutput(true); + // Set the content-type unless it was already set (by Request#getHeaders). + if (!connection.getRequestProperties().containsKey(HttpHeaderParser.HEADER_CONTENT_TYPE)) { + connection.setRequestProperty( + HttpHeaderParser.HEADER_CONTENT_TYPE, request.getBodyContentType()); + } + DataOutputStream out = + new DataOutputStream(createOutputStream(request, connection, body.length)); + out.write(body); + out.close(); + } + + /** + * Create and return an OutputStream to which the request body will be written. + * + * <p>May be overridden by subclasses to manipulate or monitor this output stream. + * + * @param request current request. + * @param connection current connection of request. + * @param length size of stream to write. + * @return an OutputStream to which the request body will be written. + * @throws IOException if an I/O error occurs while creating the stream. + */ + protected OutputStream createOutputStream( + Request<?> request, HttpURLConnection connection, int length) throws IOException { + return connection.getOutputStream(); + } +} diff --git a/src/main/java/com/android/volley/toolbox/ImageLoader.java b/src/main/java/com/android/volley/toolbox/ImageLoader.java new file mode 100644 index 0000000..eece2cf --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/ImageLoader.java @@ -0,0 +1,541 @@ +/* + * Copyright (C) 2013 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file + * except in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.volley.toolbox; + +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.os.Handler; +import android.os.Looper; +import android.widget.ImageView; +import android.widget.ImageView.ScaleType; +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import com.android.volley.Request; +import com.android.volley.RequestQueue; +import com.android.volley.Response.ErrorListener; +import com.android.volley.Response.Listener; +import com.android.volley.ResponseDelivery; +import com.android.volley.VolleyError; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * Helper that handles loading and caching images from remote URLs. + * + * <p>The simple way to use this class is to call {@link ImageLoader#get(String, ImageListener)} and + * to pass in the default image listener provided by {@link ImageLoader#getImageListener(ImageView, + * int, int)}. Note that all function calls to this class must be made from the main thread, and all + * responses will be delivered to the main thread as well. Custom {@link ResponseDelivery}s which + * don't use the main thread are not supported. + */ +public class ImageLoader { + /** RequestQueue for dispatching ImageRequests onto. */ + private final RequestQueue mRequestQueue; + + /** Amount of time to wait after first response arrives before delivering all responses. */ + private int mBatchResponseDelayMs = 100; + + /** The cache implementation to be used as an L1 cache before calling into volley. */ + private final ImageCache mCache; + + /** + * HashMap of Cache keys -> BatchedImageRequest used to track in-flight requests so that we can + * coalesce multiple requests to the same URL into a single network request. + */ + private final HashMap<String, BatchedImageRequest> mInFlightRequests = new HashMap<>(); + + /** HashMap of the currently pending responses (waiting to be delivered). */ + private final HashMap<String, BatchedImageRequest> mBatchedResponses = new HashMap<>(); + + /** Handler to the main thread. */ + private final Handler mHandler = new Handler(Looper.getMainLooper()); + + /** Runnable for in-flight response delivery. */ + private Runnable mRunnable; + + /** + * Simple cache adapter interface. If provided to the ImageLoader, it will be used as an L1 + * cache before dispatch to Volley. Implementations must not block. Implementation with an + * LruCache is recommended. + */ + public interface ImageCache { + @Nullable + Bitmap getBitmap(String url); + + void putBitmap(String url, Bitmap bitmap); + } + + /** + * Constructs a new ImageLoader. + * + * @param queue The RequestQueue to use for making image requests. + * @param imageCache The cache to use as an L1 cache. + */ + public ImageLoader(RequestQueue queue, ImageCache imageCache) { + mRequestQueue = queue; + mCache = imageCache; + } + + /** + * The default implementation of ImageListener which handles basic functionality of showing a + * default image until the network response is received, at which point it will switch to either + * the actual image or the error image. + * + * @param view The imageView that the listener is associated with. + * @param defaultImageResId Default image resource ID to use, or 0 if it doesn't exist. + * @param errorImageResId Error image resource ID to use, or 0 if it doesn't exist. + */ + public static ImageListener getImageListener( + final ImageView view, final int defaultImageResId, final int errorImageResId) { + return new ImageListener() { + @Override + public void onErrorResponse(VolleyError error) { + if (errorImageResId != 0) { + view.setImageResource(errorImageResId); + } + } + + @Override + public void onResponse(ImageContainer response, boolean isImmediate) { + if (response.getBitmap() != null) { + view.setImageBitmap(response.getBitmap()); + } else if (defaultImageResId != 0) { + view.setImageResource(defaultImageResId); + } + } + }; + } + + /** + * Interface for the response handlers on image requests. + * + * <p>The call flow is this: 1. Upon being attached to a request, onResponse(response, true) + * will be invoked to reflect any cached data that was already available. If the data was + * available, response.getBitmap() will be non-null. + * + * <p>2. After a network response returns, only one of the following cases will happen: - + * onResponse(response, false) will be called if the image was loaded. or - onErrorResponse will + * be called if there was an error loading the image. + */ + public interface ImageListener extends ErrorListener { + /** + * Listens for non-error changes to the loading of the image request. + * + * @param response Holds all information pertaining to the request, as well as the bitmap + * (if it is loaded). + * @param isImmediate True if this was called during ImageLoader.get() variants. This can be + * used to differentiate between a cached image loading and a network image loading in + * order to, for example, run an animation to fade in network loaded images. + */ + void onResponse(ImageContainer response, boolean isImmediate); + } + + /** + * Checks if the item is available in the cache. + * + * @param requestUrl The url of the remote image + * @param maxWidth The maximum width of the returned image. + * @param maxHeight The maximum height of the returned image. + * @return True if the item exists in cache, false otherwise. + */ + public boolean isCached(String requestUrl, int maxWidth, int maxHeight) { + return isCached(requestUrl, maxWidth, maxHeight, ScaleType.CENTER_INSIDE); + } + + /** + * Checks if the item is available in the cache. + * + * <p>Must be called from the main thread. + * + * @param requestUrl The url of the remote image + * @param maxWidth The maximum width of the returned image. + * @param maxHeight The maximum height of the returned image. + * @param scaleType The scaleType of the imageView. + * @return True if the item exists in cache, false otherwise. + */ + @MainThread + public boolean isCached(String requestUrl, int maxWidth, int maxHeight, ScaleType scaleType) { + Threads.throwIfNotOnMainThread(); + + String cacheKey = getCacheKey(requestUrl, maxWidth, maxHeight, scaleType); + return mCache.getBitmap(cacheKey) != null; + } + + /** + * Returns an ImageContainer for the requested URL. + * + * <p>The ImageContainer will contain either the specified default bitmap or the loaded bitmap. + * If the default was returned, the {@link ImageLoader} will be invoked when the request is + * fulfilled. + * + * @param requestUrl The URL of the image to be loaded. + */ + public ImageContainer get(String requestUrl, final ImageListener listener) { + return get(requestUrl, listener, /* maxWidth= */ 0, /* maxHeight= */ 0); + } + + /** + * Equivalent to calling {@link #get(String, ImageListener, int, int, ScaleType)} with {@code + * Scaletype == ScaleType.CENTER_INSIDE}. + */ + public ImageContainer get( + String requestUrl, ImageListener imageListener, int maxWidth, int maxHeight) { + return get(requestUrl, imageListener, maxWidth, maxHeight, ScaleType.CENTER_INSIDE); + } + + /** + * Issues a bitmap request with the given URL if that image is not available in the cache, and + * returns a bitmap container that contains all of the data relating to the request (as well as + * the default image if the requested image is not available). + * + * <p>Must be called from the main thread. + * + * @param requestUrl The url of the remote image + * @param imageListener The listener to call when the remote image is loaded + * @param maxWidth The maximum width of the returned image. + * @param maxHeight The maximum height of the returned image. + * @param scaleType The ImageViews ScaleType used to calculate the needed image size. + * @return A container object that contains all of the properties of the request, as well as the + * currently available image (default if remote is not loaded). + */ + @MainThread + public ImageContainer get( + String requestUrl, + ImageListener imageListener, + int maxWidth, + int maxHeight, + ScaleType scaleType) { + + // only fulfill requests that were initiated from the main thread. + Threads.throwIfNotOnMainThread(); + + final String cacheKey = getCacheKey(requestUrl, maxWidth, maxHeight, scaleType); + + // Try to look up the request in the cache of remote images. + Bitmap cachedBitmap = mCache.getBitmap(cacheKey); + if (cachedBitmap != null) { + // Return the cached bitmap. + ImageContainer container = + new ImageContainer( + cachedBitmap, requestUrl, /* cacheKey= */ null, /* listener= */ null); + imageListener.onResponse(container, true); + return container; + } + + // The bitmap did not exist in the cache, fetch it! + ImageContainer imageContainer = + new ImageContainer(null, requestUrl, cacheKey, imageListener); + + // Update the caller to let them know that they should use the default bitmap. + imageListener.onResponse(imageContainer, true); + + // Check to see if a request is already in-flight or completed but pending batch delivery. + BatchedImageRequest request = mInFlightRequests.get(cacheKey); + if (request == null) { + request = mBatchedResponses.get(cacheKey); + } + if (request != null) { + // If it is, add this request to the list of listeners. + request.addContainer(imageContainer); + return imageContainer; + } + + // The request is not already in flight. Send the new request to the network and + // track it. + Request<Bitmap> newRequest = + makeImageRequest(requestUrl, maxWidth, maxHeight, scaleType, cacheKey); + + mRequestQueue.add(newRequest); + mInFlightRequests.put(cacheKey, new BatchedImageRequest(newRequest, imageContainer)); + return imageContainer; + } + + protected Request<Bitmap> makeImageRequest( + String requestUrl, + int maxWidth, + int maxHeight, + ScaleType scaleType, + final String cacheKey) { + return new ImageRequest( + requestUrl, + new Listener<Bitmap>() { + @Override + public void onResponse(Bitmap response) { + onGetImageSuccess(cacheKey, response); + } + }, + maxWidth, + maxHeight, + scaleType, + Config.RGB_565, + new ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + onGetImageError(cacheKey, error); + } + }); + } + + /** + * Sets the amount of time to wait after the first response arrives before delivering all + * responses. Batching can be disabled entirely by passing in 0. + * + * @param newBatchedResponseDelayMs The time in milliseconds to wait. + */ + public void setBatchedResponseDelay(int newBatchedResponseDelayMs) { + mBatchResponseDelayMs = newBatchedResponseDelayMs; + } + + /** + * Handler for when an image was successfully loaded. + * + * @param cacheKey The cache key that is associated with the image request. + * @param response The bitmap that was returned from the network. + */ + protected void onGetImageSuccess(String cacheKey, Bitmap response) { + // cache the image that was fetched. + mCache.putBitmap(cacheKey, response); + + // remove the request from the list of in-flight requests. + BatchedImageRequest request = mInFlightRequests.remove(cacheKey); + + if (request != null) { + // Update the response bitmap. + request.mResponseBitmap = response; + + // Send the batched response + batchResponse(cacheKey, request); + } + } + + /** + * Handler for when an image failed to load. + * + * @param cacheKey The cache key that is associated with the image request. + */ + protected void onGetImageError(String cacheKey, VolleyError error) { + // Notify the requesters that something failed via a null result. + // Remove this request from the list of in-flight requests. + BatchedImageRequest request = mInFlightRequests.remove(cacheKey); + + if (request != null) { + // Set the error for this request + request.setError(error); + + // Send the batched response + batchResponse(cacheKey, request); + } + } + + /** Container object for all of the data surrounding an image request. */ + public class ImageContainer { + /** + * The most relevant bitmap for the container. If the image was in cache, the Holder to use + * for the final bitmap (the one that pairs to the requested URL). + */ + private Bitmap mBitmap; + + private final ImageListener mListener; + + /** The cache key that was associated with the request */ + private final String mCacheKey; + + /** The request URL that was specified */ + private final String mRequestUrl; + + /** + * Constructs a BitmapContainer object. + * + * @param bitmap The final bitmap (if it exists). + * @param requestUrl The requested URL for this container. + * @param cacheKey The cache key that identifies the requested URL for this container. + */ + public ImageContainer( + Bitmap bitmap, String requestUrl, String cacheKey, ImageListener listener) { + mBitmap = bitmap; + mRequestUrl = requestUrl; + mCacheKey = cacheKey; + mListener = listener; + } + + /** + * Releases interest in the in-flight request (and cancels it if no one else is listening). + * + * <p>Must be called from the main thread. + */ + @MainThread + public void cancelRequest() { + Threads.throwIfNotOnMainThread(); + + if (mListener == null) { + return; + } + + BatchedImageRequest request = mInFlightRequests.get(mCacheKey); + if (request != null) { + boolean canceled = request.removeContainerAndCancelIfNecessary(this); + if (canceled) { + mInFlightRequests.remove(mCacheKey); + } + } else { + // check to see if it is already batched for delivery. + request = mBatchedResponses.get(mCacheKey); + if (request != null) { + request.removeContainerAndCancelIfNecessary(this); + if (request.mContainers.size() == 0) { + mBatchedResponses.remove(mCacheKey); + } + } + } + } + + /** + * Returns the bitmap associated with the request URL if it has been loaded, null otherwise. + */ + public Bitmap getBitmap() { + return mBitmap; + } + + /** Returns the requested URL for this container. */ + public String getRequestUrl() { + return mRequestUrl; + } + } + + /** + * Wrapper class used to map a Request to the set of active ImageContainer objects that are + * interested in its results. + */ + private static class BatchedImageRequest { + /** The request being tracked */ + private final Request<?> mRequest; + + /** The result of the request being tracked by this item */ + private Bitmap mResponseBitmap; + + /** Error if one occurred for this response */ + private VolleyError mError; + + /** List of all of the active ImageContainers that are interested in the request */ + private final List<ImageContainer> mContainers = new ArrayList<>(); + + /** + * Constructs a new BatchedImageRequest object + * + * @param request The request being tracked + * @param container The ImageContainer of the person who initiated the request. + */ + public BatchedImageRequest(Request<?> request, ImageContainer container) { + mRequest = request; + mContainers.add(container); + } + + /** Set the error for this response */ + public void setError(VolleyError error) { + mError = error; + } + + /** Get the error for this response */ + public VolleyError getError() { + return mError; + } + + /** + * Adds another ImageContainer to the list of those interested in the results of the + * request. + */ + public void addContainer(ImageContainer container) { + mContainers.add(container); + } + + /** + * Detaches the bitmap container from the request and cancels the request if no one is left + * listening. + * + * @param container The container to remove from the list + * @return True if the request was canceled, false otherwise. + */ + public boolean removeContainerAndCancelIfNecessary(ImageContainer container) { + mContainers.remove(container); + if (mContainers.size() == 0) { + mRequest.cancel(); + return true; + } + return false; + } + } + + /** + * Starts the runnable for batched delivery of responses if it is not already started. + * + * @param cacheKey The cacheKey of the response being delivered. + * @param request The BatchedImageRequest to be delivered. + */ + private void batchResponse(String cacheKey, BatchedImageRequest request) { + mBatchedResponses.put(cacheKey, request); + // If we don't already have a batch delivery runnable in flight, make a new one. + // Note that this will be used to deliver responses to all callers in mBatchedResponses. + if (mRunnable == null) { + mRunnable = + new Runnable() { + @Override + public void run() { + for (BatchedImageRequest bir : mBatchedResponses.values()) { + for (ImageContainer container : bir.mContainers) { + // If one of the callers in the batched request canceled the + // request + // after the response was received but before it was delivered, + // skip them. + if (container.mListener == null) { + continue; + } + if (bir.getError() == null) { + container.mBitmap = bir.mResponseBitmap; + container.mListener.onResponse(container, false); + } else { + container.mListener.onErrorResponse(bir.getError()); + } + } + } + mBatchedResponses.clear(); + mRunnable = null; + } + }; + // Post the runnable. + mHandler.postDelayed(mRunnable, mBatchResponseDelayMs); + } + } + + /** + * Creates a cache key for use with the L1 cache. + * + * @param url The URL of the request. + * @param maxWidth The max-width of the output. + * @param maxHeight The max-height of the output. + * @param scaleType The scaleType of the imageView. + */ + private static String getCacheKey( + String url, int maxWidth, int maxHeight, ScaleType scaleType) { + return new StringBuilder(url.length() + 12) + .append("#W") + .append(maxWidth) + .append("#H") + .append(maxHeight) + .append("#S") + .append(scaleType.ordinal()) + .append(url) + .toString(); + } +} diff --git a/src/main/java/com/android/volley/toolbox/ImageRequest.java b/src/main/java/com/android/volley/toolbox/ImageRequest.java new file mode 100644 index 0000000..32b5aa3 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/ImageRequest.java @@ -0,0 +1,283 @@ +/* + * 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.toolbox; + +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.BitmapFactory; +import android.widget.ImageView.ScaleType; +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.android.volley.DefaultRetryPolicy; +import com.android.volley.NetworkResponse; +import com.android.volley.ParseError; +import com.android.volley.Request; +import com.android.volley.Response; +import com.android.volley.VolleyLog; + +/** A canned request for getting an image at a given URL and calling back with a decoded Bitmap. */ +public class ImageRequest extends Request<Bitmap> { + /** Socket timeout in milliseconds for image requests */ + public static final int DEFAULT_IMAGE_TIMEOUT_MS = 1000; + + /** Default number of retries for image requests */ + public static final int DEFAULT_IMAGE_MAX_RETRIES = 2; + + /** Default backoff multiplier for image requests */ + public static final float DEFAULT_IMAGE_BACKOFF_MULT = 2f; + + /** Lock to guard mListener as it is cleared on cancel() and read on delivery. */ + private final Object mLock = new Object(); + + @GuardedBy("mLock") + @Nullable + private Response.Listener<Bitmap> mListener; + + private final Config mDecodeConfig; + private final int mMaxWidth; + private final int mMaxHeight; + private final ScaleType mScaleType; + + /** Decoding lock so that we don't decode more than one image at a time (to avoid OOM's) */ + private static final Object sDecodeLock = new Object(); + + /** + * Creates a new image request, decoding to a maximum specified width and height. If both width + * and height are zero, the image will be decoded to its natural size. If one of the two is + * nonzero, that dimension will be clamped and the other one will be set to preserve the image's + * aspect ratio. If both width and height are nonzero, the image will be decoded to be fit in + * the rectangle of dimensions width x height while keeping its aspect ratio. + * + * @param url URL of the image + * @param listener Listener to receive the decoded bitmap + * @param maxWidth Maximum width to decode this bitmap to, or zero for none + * @param maxHeight Maximum height to decode this bitmap to, or zero for none + * @param scaleType The ImageViews ScaleType used to calculate the needed image size. + * @param decodeConfig Format to decode the bitmap to + * @param errorListener Error listener, or null to ignore errors + */ + public ImageRequest( + String url, + Response.Listener<Bitmap> listener, + int maxWidth, + int maxHeight, + ScaleType scaleType, + Config decodeConfig, + @Nullable Response.ErrorListener errorListener) { + super(Method.GET, url, errorListener); + setRetryPolicy( + new DefaultRetryPolicy( + DEFAULT_IMAGE_TIMEOUT_MS, + DEFAULT_IMAGE_MAX_RETRIES, + DEFAULT_IMAGE_BACKOFF_MULT)); + mListener = listener; + mDecodeConfig = decodeConfig; + mMaxWidth = maxWidth; + mMaxHeight = maxHeight; + mScaleType = scaleType; + } + + /** + * For API compatibility with the pre-ScaleType variant of the constructor. Equivalent to the + * normal constructor with {@code ScaleType.CENTER_INSIDE}. + */ + @Deprecated + public ImageRequest( + String url, + Response.Listener<Bitmap> listener, + int maxWidth, + int maxHeight, + Config decodeConfig, + Response.ErrorListener errorListener) { + this( + url, + listener, + maxWidth, + maxHeight, + ScaleType.CENTER_INSIDE, + decodeConfig, + errorListener); + } + + @Override + public Priority getPriority() { + return Priority.LOW; + } + + /** + * Scales one side of a rectangle to fit aspect ratio. + * + * @param maxPrimary Maximum size of the primary dimension (i.e. width for max width), or zero + * to maintain aspect ratio with secondary dimension + * @param maxSecondary Maximum size of the secondary dimension, or zero to maintain aspect ratio + * with primary dimension + * @param actualPrimary Actual size of the primary dimension + * @param actualSecondary Actual size of the secondary dimension + * @param scaleType The ScaleType used to calculate the needed image size. + */ + private static int getResizedDimension( + int maxPrimary, + int maxSecondary, + int actualPrimary, + int actualSecondary, + ScaleType scaleType) { + + // If no dominant value at all, just return the actual. + if ((maxPrimary == 0) && (maxSecondary == 0)) { + return actualPrimary; + } + + // If ScaleType.FIT_XY fill the whole rectangle, ignore ratio. + if (scaleType == ScaleType.FIT_XY) { + if (maxPrimary == 0) { + return actualPrimary; + } + return maxPrimary; + } + + // If primary is unspecified, scale primary to match secondary's scaling ratio. + if (maxPrimary == 0) { + double ratio = (double) maxSecondary / (double) actualSecondary; + return (int) (actualPrimary * ratio); + } + + if (maxSecondary == 0) { + return maxPrimary; + } + + double ratio = (double) actualSecondary / (double) actualPrimary; + int resized = maxPrimary; + + // If ScaleType.CENTER_CROP fill the whole rectangle, preserve aspect ratio. + if (scaleType == ScaleType.CENTER_CROP) { + if ((resized * ratio) < maxSecondary) { + resized = (int) (maxSecondary / ratio); + } + return resized; + } + + if ((resized * ratio) > maxSecondary) { + resized = (int) (maxSecondary / ratio); + } + return resized; + } + + @Override + protected Response<Bitmap> parseNetworkResponse(NetworkResponse response) { + // Serialize all decode on a global lock to reduce concurrent heap usage. + synchronized (sDecodeLock) { + try { + return doParse(response); + } catch (OutOfMemoryError e) { + VolleyLog.e("Caught OOM for %d byte image, url=%s", response.data.length, getUrl()); + return Response.error(new ParseError(e)); + } + } + } + + /** The real guts of parseNetworkResponse. Broken out for readability. */ + private Response<Bitmap> doParse(NetworkResponse response) { + byte[] data = response.data; + BitmapFactory.Options decodeOptions = new BitmapFactory.Options(); + Bitmap bitmap = null; + if (mMaxWidth == 0 && mMaxHeight == 0) { + decodeOptions.inPreferredConfig = mDecodeConfig; + bitmap = BitmapFactory.decodeByteArray(data, 0, data.length, decodeOptions); + } else { + // If we have to resize this image, first get the natural bounds. + decodeOptions.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(data, 0, data.length, decodeOptions); + int actualWidth = decodeOptions.outWidth; + int actualHeight = decodeOptions.outHeight; + + // Then compute the dimensions we would ideally like to decode to. + int desiredWidth = + getResizedDimension( + mMaxWidth, mMaxHeight, actualWidth, actualHeight, mScaleType); + int desiredHeight = + getResizedDimension( + mMaxHeight, mMaxWidth, actualHeight, actualWidth, mScaleType); + + // Decode to the nearest power of two scaling factor. + decodeOptions.inJustDecodeBounds = false; + // TODO(ficus): Do we need this or is it okay since API 8 doesn't support it? + // decodeOptions.inPreferQualityOverSpeed = PREFER_QUALITY_OVER_SPEED; + decodeOptions.inSampleSize = + findBestSampleSize(actualWidth, actualHeight, desiredWidth, desiredHeight); + Bitmap tempBitmap = BitmapFactory.decodeByteArray(data, 0, data.length, decodeOptions); + + // If necessary, scale down to the maximal acceptable size. + if (tempBitmap != null + && (tempBitmap.getWidth() > desiredWidth + || tempBitmap.getHeight() > desiredHeight)) { + bitmap = Bitmap.createScaledBitmap(tempBitmap, desiredWidth, desiredHeight, true); + tempBitmap.recycle(); + } else { + bitmap = tempBitmap; + } + } + + if (bitmap == null) { + return Response.error(new ParseError(response)); + } else { + return Response.success(bitmap, HttpHeaderParser.parseCacheHeaders(response)); + } + } + + @Override + public void cancel() { + super.cancel(); + synchronized (mLock) { + mListener = null; + } + } + + @Override + protected void deliverResponse(Bitmap response) { + Response.Listener<Bitmap> listener; + synchronized (mLock) { + listener = mListener; + } + if (listener != null) { + listener.onResponse(response); + } + } + + /** + * Returns the largest power-of-two divisor for use in downscaling a bitmap that will not result + * in the scaling past the desired dimensions. + * + * @param actualWidth Actual width of the bitmap + * @param actualHeight Actual height of the bitmap + * @param desiredWidth Desired width of the bitmap + * @param desiredHeight Desired height of the bitmap + */ + @VisibleForTesting + static int findBestSampleSize( + int actualWidth, int actualHeight, int desiredWidth, int desiredHeight) { + double wr = (double) actualWidth / desiredWidth; + double hr = (double) actualHeight / desiredHeight; + double ratio = Math.min(wr, hr); + float n = 1.0f; + while ((n * 2) <= ratio) { + n *= 2; + } + + return (int) n; + } +} diff --git a/src/main/java/com/android/volley/toolbox/JsonArrayRequest.java b/src/main/java/com/android/volley/toolbox/JsonArrayRequest.java new file mode 100644 index 0000000..86ed9e9 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/JsonArrayRequest.java @@ -0,0 +1,83 @@ +/* + * 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.toolbox; + +import androidx.annotation.Nullable; +import com.android.volley.NetworkResponse; +import com.android.volley.ParseError; +import com.android.volley.Response; +import com.android.volley.Response.ErrorListener; +import com.android.volley.Response.Listener; +import java.io.UnsupportedEncodingException; +import org.json.JSONArray; +import org.json.JSONException; + +/** A request for retrieving a {@link JSONArray} response body at a given URL. */ +public class JsonArrayRequest extends JsonRequest<JSONArray> { + + /** + * Creates a new request. + * + * @param url URL to fetch the JSON from + * @param listener Listener to receive the JSON response + * @param errorListener Error listener, or null to ignore errors. + */ + public JsonArrayRequest( + String url, Listener<JSONArray> listener, @Nullable ErrorListener errorListener) { + super(Method.GET, url, null, listener, errorListener); + } + + /** + * Creates a new request. + * + * @param method the HTTP method to use + * @param url URL to fetch the JSON from + * @param jsonRequest A {@link JSONArray} to post with the request. Null indicates no parameters + * will be posted along with request. + * @param listener Listener to receive the JSON response + * @param errorListener Error listener, or null to ignore errors. + */ + public JsonArrayRequest( + int method, + String url, + @Nullable JSONArray jsonRequest, + Listener<JSONArray> listener, + @Nullable ErrorListener errorListener) { + super( + method, + url, + (jsonRequest == null) ? null : jsonRequest.toString(), + listener, + errorListener); + } + + @Override + protected Response<JSONArray> parseNetworkResponse(NetworkResponse response) { + try { + String jsonString = + new String( + response.data, + HttpHeaderParser.parseCharset(response.headers, PROTOCOL_CHARSET)); + return Response.success( + new JSONArray(jsonString), HttpHeaderParser.parseCacheHeaders(response)); + } catch (UnsupportedEncodingException e) { + return Response.error(new ParseError(e)); + } catch (JSONException je) { + return Response.error(new ParseError(je)); + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/JsonObjectRequest.java b/src/main/java/com/android/volley/toolbox/JsonObjectRequest.java new file mode 100644 index 0000000..8dca0ec --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/JsonObjectRequest.java @@ -0,0 +1,93 @@ +/* + * 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.toolbox; + +import androidx.annotation.Nullable; +import com.android.volley.NetworkResponse; +import com.android.volley.ParseError; +import com.android.volley.Response; +import com.android.volley.Response.ErrorListener; +import com.android.volley.Response.Listener; +import java.io.UnsupportedEncodingException; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * A request for retrieving a {@link JSONObject} response body at a given URL, allowing for an + * optional {@link JSONObject} to be passed in as part of the request body. + */ +public class JsonObjectRequest extends JsonRequest<JSONObject> { + + /** + * Creates a new request. + * + * @param method the HTTP method to use + * @param url URL to fetch the JSON from + * @param jsonRequest A {@link JSONObject} to post with the request. Null indicates no + * parameters will be posted along with request. + * @param listener Listener to receive the JSON response + * @param errorListener Error listener, or null to ignore errors. + */ + public JsonObjectRequest( + int method, + String url, + @Nullable JSONObject jsonRequest, + Listener<JSONObject> listener, + @Nullable ErrorListener errorListener) { + super( + method, + url, + (jsonRequest == null) ? null : jsonRequest.toString(), + listener, + errorListener); + } + + /** + * Constructor which defaults to <code>GET</code> if <code>jsonRequest</code> is <code>null + * </code> , <code>POST</code> otherwise. + * + * @see #JsonObjectRequest(int, String, JSONObject, Listener, ErrorListener) + */ + public JsonObjectRequest( + String url, + @Nullable JSONObject jsonRequest, + Listener<JSONObject> listener, + @Nullable ErrorListener errorListener) { + this( + jsonRequest == null ? Method.GET : Method.POST, + url, + jsonRequest, + listener, + errorListener); + } + + @Override + protected Response<JSONObject> parseNetworkResponse(NetworkResponse response) { + try { + String jsonString = + new String( + response.data, + HttpHeaderParser.parseCharset(response.headers, PROTOCOL_CHARSET)); + return Response.success( + new JSONObject(jsonString), HttpHeaderParser.parseCacheHeaders(response)); + } catch (UnsupportedEncodingException e) { + return Response.error(new ParseError(e)); + } catch (JSONException je) { + return Response.error(new ParseError(je)); + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/JsonRequest.java b/src/main/java/com/android/volley/toolbox/JsonRequest.java new file mode 100644 index 0000000..bc035ae --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/JsonRequest.java @@ -0,0 +1,127 @@ +/* + * 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.toolbox; + +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; +import com.android.volley.NetworkResponse; +import com.android.volley.Request; +import com.android.volley.Response; +import com.android.volley.Response.ErrorListener; +import com.android.volley.Response.Listener; +import com.android.volley.VolleyLog; +import java.io.UnsupportedEncodingException; + +/** + * A request for retrieving a T type response body at a given URL that also optionally sends along a + * JSON body in the request specified. + * + * @param <T> JSON type of response expected + */ +public abstract class JsonRequest<T> extends Request<T> { + /** Default charset for JSON request. */ + protected static final String PROTOCOL_CHARSET = "utf-8"; + + /** Content type for request. */ + private static final String PROTOCOL_CONTENT_TYPE = + String.format("application/json; charset=%s", PROTOCOL_CHARSET); + + /** Lock to guard mListener as it is cleared on cancel() and read on delivery. */ + private final Object mLock = new Object(); + + @Nullable + @GuardedBy("mLock") + private Listener<T> mListener; + + @Nullable private final String mRequestBody; + + /** + * Deprecated constructor for a JsonRequest which defaults to GET unless {@link #getPostBody()} + * or {@link #getPostParams()} is overridden (which defaults to POST). + * + * @deprecated Use {@link #JsonRequest(int, String, String, Listener, ErrorListener)}. + */ + @Deprecated + public JsonRequest( + String url, String requestBody, Listener<T> listener, ErrorListener errorListener) { + this(Method.DEPRECATED_GET_OR_POST, url, requestBody, listener, errorListener); + } + + public JsonRequest( + int method, + String url, + @Nullable String requestBody, + Listener<T> listener, + @Nullable ErrorListener errorListener) { + super(method, url, errorListener); + mListener = listener; + mRequestBody = requestBody; + } + + @Override + public void cancel() { + super.cancel(); + synchronized (mLock) { + mListener = null; + } + } + + @Override + protected void deliverResponse(T response) { + Response.Listener<T> listener; + synchronized (mLock) { + listener = mListener; + } + if (listener != null) { + listener.onResponse(response); + } + } + + @Override + protected abstract Response<T> parseNetworkResponse(NetworkResponse response); + + /** @deprecated Use {@link #getBodyContentType()}. */ + @Deprecated + @Override + public String getPostBodyContentType() { + return getBodyContentType(); + } + + /** @deprecated Use {@link #getBody()}. */ + @Deprecated + @Override + public byte[] getPostBody() { + return getBody(); + } + + @Override + public String getBodyContentType() { + return PROTOCOL_CONTENT_TYPE; + } + + @Override + public byte[] getBody() { + try { + return mRequestBody == null ? null : mRequestBody.getBytes(PROTOCOL_CHARSET); + } catch (UnsupportedEncodingException uee) { + VolleyLog.wtf( + "Unsupported Encoding while trying to get the bytes of %s using %s", + mRequestBody, PROTOCOL_CHARSET); + return null; + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/NetworkImageView.java b/src/main/java/com/android/volley/toolbox/NetworkImageView.java new file mode 100644 index 0000000..a24b3e2 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/NetworkImageView.java @@ -0,0 +1,332 @@ +/** + * Copyright (C) 2013 The Android Open Source Project + * + * <p>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 + * + * <p>http://www.apache.org/licenses/LICENSE-2.0 + * + * <p>Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.volley.toolbox; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.ViewGroup.LayoutParams; +import android.widget.ImageView; +import androidx.annotation.MainThread; +import androidx.annotation.Nullable; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.ImageLoader.ImageContainer; +import com.android.volley.toolbox.ImageLoader.ImageListener; + +/** Handles fetching an image from a URL as well as the life-cycle of the associated request. */ +public class NetworkImageView extends ImageView { + /** The URL of the network image to load */ + private String mUrl; + + /** + * Resource ID of the image to be used as a placeholder until the network image is loaded. Won't + * be set at the same time as mDefaultImageDrawable or mDefaultImageBitmap. + */ + private int mDefaultImageId; + + /** + * Drawable of the image to be used as a placeholder until the network image is loaded. Won't be + * set at the same time as mDefaultImageId or mDefaultImageBitmap. + */ + @Nullable private Drawable mDefaultImageDrawable; + + /** + * Bitmap of the image to be used as a placeholder until the network image is loaded. Won't be + * set at the same time as mDefaultImageId or mDefaultImageDrawable. + */ + @Nullable private Bitmap mDefaultImageBitmap; + + /** + * Resource ID of the image to be used if the network response fails. Won't be set at the same + * time as mErrorImageDrawable or mErrorImageBitmap. + */ + private int mErrorImageId; + + /** + * Bitmap of the image to be used if the network response fails. Won't be set at the same time + * as mErrorImageId or mErrorImageBitmap. + */ + @Nullable private Drawable mErrorImageDrawable; + + /** + * Bitmap of the image to be used if the network response fails. Won't be set at the same time + * as mErrorImageId or mErrorImageDrawable. + */ + @Nullable private Bitmap mErrorImageBitmap; + + /** Local copy of the ImageLoader. */ + private ImageLoader mImageLoader; + + /** Current ImageContainer. (either in-flight or finished) */ + private ImageContainer mImageContainer; + + public NetworkImageView(Context context) { + this(context, null); + } + + public NetworkImageView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public NetworkImageView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + /** + * Sets URL of the image that should be loaded into this view. Note that calling this will + * immediately either set the cached image (if available) or the default image specified by + * {@link NetworkImageView#setDefaultImageResId(int)} on the view. + * + * <p>NOTE: If applicable, {@link NetworkImageView#setDefaultImageResId(int)} or {@link + * NetworkImageView#setDefaultImageBitmap} and {@link NetworkImageView#setErrorImageResId(int)} + * or {@link NetworkImageView#setErrorImageBitmap(Bitmap)} should be called prior to calling + * this function. + * + * <p>Must be called from the main thread. + * + * @param url The URL that should be loaded into this ImageView. + * @param imageLoader ImageLoader that will be used to make the request. + */ + @MainThread + public void setImageUrl(String url, ImageLoader imageLoader) { + Threads.throwIfNotOnMainThread(); + mUrl = url; + mImageLoader = imageLoader; + // The URL has potentially changed. See if we need to load it. + loadImageIfNecessary(/* isInLayoutPass= */ false); + } + + /** + * Sets the default image resource ID to be used for this view until the attempt to load it + * completes. + * + * <p>This will clear anything set by {@link NetworkImageView#setDefaultImageBitmap} or {@link + * NetworkImageView#setDefaultImageDrawable}. + */ + public void setDefaultImageResId(int defaultImage) { + mDefaultImageBitmap = null; + mDefaultImageDrawable = null; + mDefaultImageId = defaultImage; + } + + /** + * Sets the default image drawable to be used for this view until the attempt to load it + * completes. + * + * <p>This will clear anything set by {@link NetworkImageView#setDefaultImageResId} or {@link + * NetworkImageView#setDefaultImageBitmap}. + */ + public void setDefaultImageDrawable(@Nullable Drawable defaultImageDrawable) { + mDefaultImageId = 0; + mDefaultImageBitmap = null; + mDefaultImageDrawable = defaultImageDrawable; + } + + /** + * Sets the default image bitmap to be used for this view until the attempt to load it + * completes. + * + * <p>This will clear anything set by {@link NetworkImageView#setDefaultImageResId} or {@link + * NetworkImageView#setDefaultImageDrawable}. + */ + public void setDefaultImageBitmap(Bitmap defaultImage) { + mDefaultImageId = 0; + mDefaultImageDrawable = null; + mDefaultImageBitmap = defaultImage; + } + + /** + * Sets the error image resource ID to be used for this view in the event that the image + * requested fails to load. + * + * <p>This will clear anything set by {@link NetworkImageView#setErrorImageBitmap} or {@link + * NetworkImageView#setErrorImageDrawable}. + */ + public void setErrorImageResId(int errorImage) { + mErrorImageBitmap = null; + mErrorImageDrawable = null; + mErrorImageId = errorImage; + } + + /** + * Sets the error image drawable to be used for this view in the event that the image requested + * fails to load. + * + * <p>This will clear anything set by {@link NetworkImageView#setErrorImageResId} or {@link + * NetworkImageView#setDefaultImageBitmap}. + */ + public void setErrorImageDrawable(@Nullable Drawable errorImageDrawable) { + mErrorImageId = 0; + mErrorImageBitmap = null; + mErrorImageDrawable = errorImageDrawable; + } + + /** + * Sets the error image bitmap to be used for this view in the event that the image requested + * fails to load. + * + * <p>This will clear anything set by {@link NetworkImageView#setErrorImageResId} or {@link + * NetworkImageView#setDefaultImageDrawable}. + */ + public void setErrorImageBitmap(Bitmap errorImage) { + mErrorImageId = 0; + mErrorImageDrawable = null; + mErrorImageBitmap = errorImage; + } + + /** + * Loads the image for the view if it isn't already loaded. + * + * @param isInLayoutPass True if this was invoked from a layout pass, false otherwise. + */ + void loadImageIfNecessary(final boolean isInLayoutPass) { + int width = getWidth(); + int height = getHeight(); + ScaleType scaleType = getScaleType(); + + boolean wrapWidth = false, wrapHeight = false; + if (getLayoutParams() != null) { + wrapWidth = getLayoutParams().width == LayoutParams.WRAP_CONTENT; + wrapHeight = getLayoutParams().height == LayoutParams.WRAP_CONTENT; + } + + // if the view's bounds aren't known yet, and this is not a wrap-content/wrap-content + // view, hold off on loading the image. + boolean isFullyWrapContent = wrapWidth && wrapHeight; + if (width == 0 && height == 0 && !isFullyWrapContent) { + return; + } + + // if the URL to be loaded in this view is empty, cancel any old requests and clear the + // currently loaded image. + if (TextUtils.isEmpty(mUrl)) { + if (mImageContainer != null) { + mImageContainer.cancelRequest(); + mImageContainer = null; + } + setDefaultImageOrNull(); + return; + } + + // if there was an old request in this view, check if it needs to be canceled. + if (mImageContainer != null && mImageContainer.getRequestUrl() != null) { + if (mImageContainer.getRequestUrl().equals(mUrl)) { + // if the request is from the same URL, return. + return; + } else { + // if there is a pre-existing request, cancel it if it's fetching a different URL. + mImageContainer.cancelRequest(); + setDefaultImageOrNull(); + } + } + + // Calculate the max image width / height to use while ignoring WRAP_CONTENT dimens. + int maxWidth = wrapWidth ? 0 : width; + int maxHeight = wrapHeight ? 0 : height; + + // The pre-existing content of this view didn't match the current URL. Load the new image + // from the network. + + // update the ImageContainer to be the new bitmap container. + mImageContainer = + mImageLoader.get( + mUrl, + new ImageListener() { + @Override + public void onErrorResponse(VolleyError error) { + if (mErrorImageId != 0) { + setImageResource(mErrorImageId); + } else if (mErrorImageDrawable != null) { + setImageDrawable(mErrorImageDrawable); + } else if (mErrorImageBitmap != null) { + setImageBitmap(mErrorImageBitmap); + } + } + + @Override + public void onResponse( + final ImageContainer response, boolean isImmediate) { + // If this was an immediate response that was delivered inside of a + // layout + // pass do not set the image immediately as it will trigger a + // requestLayout + // inside of a layout. Instead, defer setting the image by posting + // back to + // the main thread. + if (isImmediate && isInLayoutPass) { + post( + new Runnable() { + @Override + public void run() { + onResponse(response, /* isImmediate= */ false); + } + }); + return; + } + + if (response.getBitmap() != null) { + setImageBitmap(response.getBitmap()); + } else if (mDefaultImageId != 0) { + setImageResource(mDefaultImageId); + } else if (mDefaultImageDrawable != null) { + setImageDrawable(mDefaultImageDrawable); + } else if (mDefaultImageBitmap != null) { + setImageBitmap(mDefaultImageBitmap); + } + } + }, + maxWidth, + maxHeight, + scaleType); + } + + private void setDefaultImageOrNull() { + if (mDefaultImageId != 0) { + setImageResource(mDefaultImageId); + } else if (mDefaultImageDrawable != null) { + setImageDrawable(mDefaultImageDrawable); + } else if (mDefaultImageBitmap != null) { + setImageBitmap(mDefaultImageBitmap); + } else { + setImageBitmap(null); + } + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + loadImageIfNecessary(/* isInLayoutPass= */ true); + } + + @Override + protected void onDetachedFromWindow() { + if (mImageContainer != null) { + // If the view was bound to an image request, cancel it and clear + // out the image from the view. + mImageContainer.cancelRequest(); + setImageBitmap(null); + // also clear out the container so we can reload the image if necessary. + mImageContainer = null; + } + super.onDetachedFromWindow(); + } + + @Override + protected void drawableStateChanged() { + super.drawableStateChanged(); + invalidate(); + } +} diff --git a/src/main/java/com/android/volley/toolbox/NetworkUtility.java b/src/main/java/com/android/volley/toolbox/NetworkUtility.java new file mode 100644 index 0000000..44d5904 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/NetworkUtility.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.volley.toolbox; + +import android.os.SystemClock; +import androidx.annotation.Nullable; +import com.android.volley.AuthFailureError; +import com.android.volley.Cache; +import com.android.volley.ClientError; +import com.android.volley.Header; +import com.android.volley.NetworkError; +import com.android.volley.NetworkResponse; +import com.android.volley.NoConnectionError; +import com.android.volley.Request; +import com.android.volley.RetryPolicy; +import com.android.volley.ServerError; +import com.android.volley.TimeoutError; +import com.android.volley.VolleyError; +import com.android.volley.VolleyLog; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.SocketTimeoutException; +import java.util.List; + +/** + * Utility class for methods that are shared between {@link BasicNetwork} and {@link + * BasicAsyncNetwork} + */ +public final class NetworkUtility { + private static final int SLOW_REQUEST_THRESHOLD_MS = 3000; + + private NetworkUtility() {} + + /** Logs requests that took over SLOW_REQUEST_THRESHOLD_MS to complete. */ + static void logSlowRequests( + long requestLifetime, Request<?> request, byte[] responseContents, int statusCode) { + if (VolleyLog.DEBUG || requestLifetime > SLOW_REQUEST_THRESHOLD_MS) { + VolleyLog.d( + "HTTP response for request=<%s> [lifetime=%d], [size=%s], " + + "[rc=%d], [retryCount=%s]", + request, + requestLifetime, + responseContents != null ? responseContents.length : "null", + statusCode, + request.getRetryPolicy().getCurrentRetryCount()); + } + } + + static NetworkResponse getNotModifiedNetworkResponse( + Request<?> request, long requestDuration, List<Header> responseHeaders) { + Cache.Entry entry = request.getCacheEntry(); + if (entry == null) { + return new NetworkResponse( + HttpURLConnection.HTTP_NOT_MODIFIED, + /* data= */ null, + /* notModified= */ true, + requestDuration, + responseHeaders); + } + // Combine cached and response headers so the response will be complete. + List<Header> combinedHeaders = HttpHeaderParser.combineHeaders(responseHeaders, entry); + return new NetworkResponse( + HttpURLConnection.HTTP_NOT_MODIFIED, + entry.data, + /* notModified= */ true, + requestDuration, + combinedHeaders); + } + + /** Reads the contents of an InputStream into a byte[]. */ + static byte[] inputStreamToBytes(InputStream in, int contentLength, ByteArrayPool pool) + throws IOException { + PoolingByteArrayOutputStream bytes = new PoolingByteArrayOutputStream(pool, contentLength); + byte[] buffer = null; + try { + buffer = pool.getBuf(1024); + int count; + while ((count = in.read(buffer)) != -1) { + bytes.write(buffer, 0, count); + } + return bytes.toByteArray(); + } finally { + try { + // Close the InputStream and release the resources by "consuming the content". + if (in != null) { + in.close(); + } + } catch (IOException e) { + // This can happen if there was an exception above that left the stream in + // an invalid state. + VolleyLog.v("Error occurred when closing InputStream"); + } + pool.returnBuf(buffer); + bytes.close(); + } + } + + /** + * Attempts to prepare the request for a retry. If there are no more attempts remaining in the + * request's retry policy, a timeout exception is thrown. + * + * @param request The request to use. + */ + private static void attemptRetryOnException( + final String logPrefix, final Request<?> request, final VolleyError exception) + throws VolleyError { + final RetryPolicy retryPolicy = request.getRetryPolicy(); + final int oldTimeout = request.getTimeoutMs(); + try { + retryPolicy.retry(exception); + } catch (VolleyError e) { + request.addMarker( + String.format("%s-timeout-giveup [timeout=%s]", logPrefix, oldTimeout)); + throw e; + } + request.addMarker(String.format("%s-retry [timeout=%s]", logPrefix, oldTimeout)); + } + + /** + * Based on the exception thrown, decides whether to attempt to retry, or to throw the error. + * Also handles logging. + */ + static void handleException( + Request<?> request, + IOException exception, + long requestStartMs, + @Nullable HttpResponse httpResponse, + @Nullable byte[] responseContents) + throws VolleyError { + if (exception instanceof SocketTimeoutException) { + attemptRetryOnException("socket", request, new TimeoutError()); + } else if (exception instanceof MalformedURLException) { + throw new RuntimeException("Bad URL " + request.getUrl(), exception); + } else { + int statusCode; + if (httpResponse != null) { + statusCode = httpResponse.getStatusCode(); + } else { + if (request.shouldRetryConnectionErrors()) { + attemptRetryOnException("connection", request, new NoConnectionError()); + return; + } else { + throw new NoConnectionError(exception); + } + } + VolleyLog.e("Unexpected response code %d for %s", statusCode, request.getUrl()); + NetworkResponse networkResponse; + if (responseContents != null) { + List<Header> responseHeaders; + responseHeaders = httpResponse.getHeaders(); + networkResponse = + new NetworkResponse( + statusCode, + responseContents, + /* notModified= */ false, + SystemClock.elapsedRealtime() - requestStartMs, + responseHeaders); + if (statusCode == HttpURLConnection.HTTP_UNAUTHORIZED + || statusCode == HttpURLConnection.HTTP_FORBIDDEN) { + attemptRetryOnException("auth", request, new AuthFailureError(networkResponse)); + } else if (statusCode >= 400 && statusCode <= 499) { + // Don't retry other client errors. + throw new ClientError(networkResponse); + } else if (statusCode >= 500 && statusCode <= 599) { + if (request.shouldRetryServerErrors()) { + attemptRetryOnException( + "server", request, new ServerError(networkResponse)); + } else { + throw new ServerError(networkResponse); + } + } else { + // 3xx? No reason to retry. + throw new ServerError(networkResponse); + } + } else { + attemptRetryOnException("network", request, new NetworkError()); + } + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/NoAsyncCache.java b/src/main/java/com/android/volley/toolbox/NoAsyncCache.java new file mode 100644 index 0000000..aa4aeea --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/NoAsyncCache.java @@ -0,0 +1,37 @@ +package com.android.volley.toolbox; + +import com.android.volley.AsyncCache; +import com.android.volley.Cache; + +/** An AsyncCache that doesn't cache anything. */ +public class NoAsyncCache extends AsyncCache { + @Override + public void get(String key, OnGetCompleteCallback callback) { + callback.onGetComplete(null); + } + + @Override + public void put(String key, Cache.Entry entry, OnWriteCompleteCallback callback) { + callback.onWriteComplete(); + } + + @Override + public void clear(OnWriteCompleteCallback callback) { + callback.onWriteComplete(); + } + + @Override + public void initialize(OnWriteCompleteCallback callback) { + callback.onWriteComplete(); + } + + @Override + public void invalidate(String key, boolean fullExpire, OnWriteCompleteCallback callback) { + callback.onWriteComplete(); + } + + @Override + public void remove(String key, OnWriteCompleteCallback callback) { + callback.onWriteComplete(); + } +} diff --git a/src/main/java/com/android/volley/toolbox/NoCache.java b/src/main/java/com/android/volley/toolbox/NoCache.java new file mode 100644 index 0000000..51f9945 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/NoCache.java @@ -0,0 +1,42 @@ +/* + * 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.toolbox; + +import com.android.volley.Cache; + +/** A cache that doesn't. */ +public class NoCache implements Cache { + @Override + public void clear() {} + + @Override + public Entry get(String key) { + return null; + } + + @Override + public void put(String key, Entry entry) {} + + @Override + public void invalidate(String key, boolean fullExpire) {} + + @Override + public void remove(String key) {} + + @Override + public void initialize() {} +} diff --git a/src/main/java/com/android/volley/toolbox/PoolingByteArrayOutputStream.java b/src/main/java/com/android/volley/toolbox/PoolingByteArrayOutputStream.java new file mode 100644 index 0000000..bdcc45e --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/PoolingByteArrayOutputStream.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.volley.toolbox; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/** + * A variation of {@link java.io.ByteArrayOutputStream} that uses a pool of byte[] buffers instead + * of always allocating them fresh, saving on heap churn. + */ +public class PoolingByteArrayOutputStream extends ByteArrayOutputStream { + /** + * If the {@link #PoolingByteArrayOutputStream(ByteArrayPool)} constructor is called, this is + * the default size to which the underlying byte array is initialized. + */ + private static final int DEFAULT_SIZE = 256; + + private final ByteArrayPool mPool; + + /** + * Constructs a new PoolingByteArrayOutputStream with a default size. If more bytes are written + * to this instance, the underlying byte array will expand. + */ + public PoolingByteArrayOutputStream(ByteArrayPool pool) { + this(pool, DEFAULT_SIZE); + } + + /** + * Constructs a new {@code ByteArrayOutputStream} with a default size of {@code size} bytes. If + * more than {@code size} bytes are written to this instance, the underlying byte array will + * expand. + * + * @param size initial size for the underlying byte array. The value will be pinned to a default + * minimum size. + */ + public PoolingByteArrayOutputStream(ByteArrayPool pool, int size) { + mPool = pool; + buf = mPool.getBuf(Math.max(size, DEFAULT_SIZE)); + } + + @Override + public void close() throws IOException { + mPool.returnBuf(buf); + buf = null; + super.close(); + } + + @Override + public void finalize() { + mPool.returnBuf(buf); + } + + /** Ensures there is enough space in the buffer for the given number of additional bytes. */ + @SuppressWarnings("UnsafeFinalization") + private void expand(int i) { + /* Can the buffer handle @i more bytes, if not expand it */ + if (count + i <= buf.length) { + return; + } + byte[] newbuf = mPool.getBuf((count + i) * 2); + System.arraycopy(buf, 0, newbuf, 0, count); + mPool.returnBuf(buf); + buf = newbuf; + } + + @Override + public synchronized void write(byte[] buffer, int offset, int len) { + expand(len); + super.write(buffer, offset, len); + } + + @Override + public synchronized void write(int oneByte) { + expand(1); + super.write(oneByte); + } +} diff --git a/src/main/java/com/android/volley/toolbox/RequestFuture.java b/src/main/java/com/android/volley/toolbox/RequestFuture.java new file mode 100644 index 0000000..f9cbce2 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/RequestFuture.java @@ -0,0 +1,159 @@ +/* + * 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.toolbox; + +import android.os.SystemClock; +import com.android.volley.Request; +import com.android.volley.Response; +import com.android.volley.VolleyError; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * A Future that represents a Volley request. + * + * <p>Used by providing as your response and error listeners. For example: + * + * <pre> + * RequestFuture<JSONObject> future = RequestFuture.newFuture(); + * MyRequest request = new MyRequest(URL, future, future); + * + * // If you want to be able to cancel the request: + * future.setRequest(requestQueue.add(request)); + * + * // Otherwise: + * requestQueue.add(request); + * + * try { + * JSONObject response = future.get(); + * // do something with response + * } catch (InterruptedException e) { + * // handle the error + * } catch (ExecutionException e) { + * // handle the error + * } + * </pre> + * + * @param <T> The type of parsed response this future expects. + */ +public class RequestFuture<T> implements Future<T>, Response.Listener<T>, Response.ErrorListener { + private Request<?> mRequest; + private boolean mResultReceived = false; + private T mResult; + private VolleyError mException; + + public static <E> RequestFuture<E> newFuture() { + return new RequestFuture<>(); + } + + private RequestFuture() {} + + public void setRequest(Request<?> request) { + mRequest = request; + } + + @Override + public synchronized boolean cancel(boolean mayInterruptIfRunning) { + if (mRequest == null) { + return false; + } + + if (!isDone()) { + mRequest.cancel(); + return true; + } else { + return false; + } + } + + @Override + public T get() throws InterruptedException, ExecutionException { + try { + return doGet(/* timeoutMs= */ null); + } catch (TimeoutException e) { + throw new AssertionError(e); + } + } + + @Override + public T get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + return doGet(TimeUnit.MILLISECONDS.convert(timeout, unit)); + } + + private synchronized T doGet(Long timeoutMs) + throws InterruptedException, ExecutionException, TimeoutException { + if (mException != null) { + throw new ExecutionException(mException); + } + + if (mResultReceived) { + return mResult; + } + + if (timeoutMs == null) { + while (!isDone()) { + wait(0); + } + } else if (timeoutMs > 0) { + long nowMs = SystemClock.uptimeMillis(); + long deadlineMs = nowMs + timeoutMs; + while (!isDone() && nowMs < deadlineMs) { + wait(deadlineMs - nowMs); + nowMs = SystemClock.uptimeMillis(); + } + } + + if (mException != null) { + throw new ExecutionException(mException); + } + + if (!mResultReceived) { + throw new TimeoutException(); + } + + return mResult; + } + + @Override + public boolean isCancelled() { + if (mRequest == null) { + return false; + } + return mRequest.isCanceled(); + } + + @Override + public synchronized boolean isDone() { + return mResultReceived || mException != null || isCancelled(); + } + + @Override + public synchronized void onResponse(T response) { + mResultReceived = true; + mResult = response; + notifyAll(); + } + + @Override + public synchronized void onErrorResponse(VolleyError error) { + mException = error; + notifyAll(); + } +} diff --git a/src/main/java/com/android/volley/toolbox/StringRequest.java b/src/main/java/com/android/volley/toolbox/StringRequest.java new file mode 100644 index 0000000..df7b386 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/StringRequest.java @@ -0,0 +1,100 @@ +/* + * 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.toolbox; + +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; +import com.android.volley.NetworkResponse; +import com.android.volley.Request; +import com.android.volley.Response; +import com.android.volley.Response.ErrorListener; +import com.android.volley.Response.Listener; +import java.io.UnsupportedEncodingException; + +/** A canned request for retrieving the response body at a given URL as a String. */ +public class StringRequest extends Request<String> { + + /** Lock to guard mListener as it is cleared on cancel() and read on delivery. */ + private final Object mLock = new Object(); + + @Nullable + @GuardedBy("mLock") + private Listener<String> mListener; + + /** + * Creates a new request with the given method. + * + * @param method the request {@link Method} to use + * @param url URL to fetch the string at + * @param listener Listener to receive the String response + * @param errorListener Error listener, or null to ignore errors + */ + public StringRequest( + int method, + String url, + Listener<String> listener, + @Nullable ErrorListener errorListener) { + super(method, url, errorListener); + mListener = listener; + } + + /** + * Creates a new GET request. + * + * @param url URL to fetch the string at + * @param listener Listener to receive the String response + * @param errorListener Error listener, or null to ignore errors + */ + public StringRequest( + String url, Listener<String> listener, @Nullable ErrorListener errorListener) { + this(Method.GET, url, listener, errorListener); + } + + @Override + public void cancel() { + super.cancel(); + synchronized (mLock) { + mListener = null; + } + } + + @Override + protected void deliverResponse(String response) { + Response.Listener<String> listener; + synchronized (mLock) { + listener = mListener; + } + if (listener != null) { + listener.onResponse(response); + } + } + + @Override + @SuppressWarnings("DefaultCharset") + protected Response<String> parseNetworkResponse(NetworkResponse response) { + String parsed; + try { + parsed = new String(response.data, HttpHeaderParser.parseCharset(response.headers)); + } catch (UnsupportedEncodingException e) { + // Since minSdkVersion = 8, we can't call + // new String(response.data, Charset.defaultCharset()) + // So suppress the warning instead. + parsed = new String(response.data); + } + return Response.success(parsed, HttpHeaderParser.parseCacheHeaders(response)); + } +} diff --git a/src/main/java/com/android/volley/toolbox/Threads.java b/src/main/java/com/android/volley/toolbox/Threads.java new file mode 100644 index 0000000..66c3e41 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/Threads.java @@ -0,0 +1,13 @@ +package com.android.volley.toolbox; + +import android.os.Looper; + +final class Threads { + private Threads() {} + + static void throwIfNotOnMainThread() { + if (Looper.myLooper() != Looper.getMainLooper()) { + throw new IllegalStateException("Must be invoked from the main thread."); + } + } +} diff --git a/src/main/java/com/android/volley/toolbox/UrlRewriter.java b/src/main/java/com/android/volley/toolbox/UrlRewriter.java new file mode 100644 index 0000000..8bbb770 --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/UrlRewriter.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.volley.toolbox; + +import androidx.annotation.Nullable; + +/** An interface for transforming URLs before use. */ +public interface UrlRewriter { + /** + * Returns a URL to use instead of the provided one, or null to indicate this URL should not be + * used at all. + */ + @Nullable + String rewriteUrl(String originalUrl); +} diff --git a/src/main/java/com/android/volley/toolbox/Volley.java b/src/main/java/com/android/volley/toolbox/Volley.java new file mode 100644 index 0000000..bc65c9c --- /dev/null +++ b/src/main/java/com/android/volley/toolbox/Volley.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2012 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.volley.toolbox; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager.NameNotFoundException; +import android.net.http.AndroidHttpClient; +import android.os.Build; +import com.android.volley.Network; +import com.android.volley.RequestQueue; +import java.io.File; + +public class Volley { + + /** Default on-disk cache directory. */ + private static final String DEFAULT_CACHE_DIR = "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 A {@link BaseHttpStack} to use for the network, or null for default. + * @return A started {@link RequestQueue} instance. + */ + public static RequestQueue newRequestQueue(Context context, BaseHttpStack stack) { + BasicNetwork network; + if (stack == null) { + if (Build.VERSION.SDK_INT >= 9) { + network = new BasicNetwork(new HurlStack()); + } else { + // Prior to Gingerbread, HttpUrlConnection was unreliable. + // See: http://android-developers.blogspot.com/2011/09/androids-http-clients.html + // 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, /* flags= */ 0); + userAgent = packageName + "/" + info.versionCode; + } catch (NameNotFoundException e) { + } + + network = + new BasicNetwork( + new HttpClientStack(AndroidHttpClient.newInstance(userAgent))); + } + } else { + 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) { + final Context appContext = context.getApplicationContext(); + // Use a lazy supplier for the cache directory so that newRequestQueue() can be called on + // main thread without causing strict mode violation. + DiskBasedCache.FileSupplier cacheSupplier = + new DiskBasedCache.FileSupplier() { + private File cacheDir = null; + + @Override + public File get() { + if (cacheDir == null) { + cacheDir = new File(appContext.getCacheDir(), DEFAULT_CACHE_DIR); + } + return cacheDir; + } + }; + RequestQueue queue = new RequestQueue(new DiskBasedCache(cacheSupplier), network); + queue.start(); + return queue; + } + + /** + * 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. + * @return A started {@link RequestQueue} instance. + */ + public static RequestQueue newRequestQueue(Context context) { + return newRequestQueue(context, (BaseHttpStack) null); + } +} |