diff options
Diffstat (limited to 'cronet/src/main/java/com/android/volley/cronet/CronetHttpStack.java')
-rw-r--r-- | cronet/src/main/java/com/android/volley/cronet/CronetHttpStack.java | 634 |
1 files changed, 634 insertions, 0 deletions
diff --git a/cronet/src/main/java/com/android/volley/cronet/CronetHttpStack.java b/cronet/src/main/java/com/android/volley/cronet/CronetHttpStack.java new file mode 100644 index 0000000..874029b --- /dev/null +++ b/cronet/src/main/java/com/android/volley/cronet/CronetHttpStack.java @@ -0,0 +1,634 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.volley.cronet; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Base64; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.android.volley.AuthFailureError; +import com.android.volley.Header; +import com.android.volley.Request; +import com.android.volley.RequestTask; +import com.android.volley.VolleyLog; +import com.android.volley.toolbox.AsyncHttpStack; +import com.android.volley.toolbox.ByteArrayPool; +import com.android.volley.toolbox.HttpHeaderParser; +import com.android.volley.toolbox.HttpResponse; +import com.android.volley.toolbox.PoolingByteArrayOutputStream; +import com.android.volley.toolbox.UrlRewriter; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import org.chromium.net.CronetEngine; +import org.chromium.net.CronetException; +import org.chromium.net.UploadDataProvider; +import org.chromium.net.UploadDataProviders; +import org.chromium.net.UrlRequest; +import org.chromium.net.UrlRequest.Callback; +import org.chromium.net.UrlResponseInfo; + +/** + * A {@link AsyncHttpStack} that's based on Cronet's fully asynchronous API for network requests. + * + * <p><b>WARNING</b>: This API is experimental and subject to breaking changes. Please see + * https://github.com/google/volley/wiki/Asynchronous-Volley for more details. + */ +public class CronetHttpStack extends AsyncHttpStack { + + private final CronetEngine mCronetEngine; + private final ByteArrayPool mPool; + private final UrlRewriter mUrlRewriter; + private final RequestListener mRequestListener; + + // cURL logging support + private final boolean mCurlLoggingEnabled; + private final CurlCommandLogger mCurlCommandLogger; + private final boolean mLogAuthTokensInCurlCommands; + + private CronetHttpStack( + CronetEngine cronetEngine, + ByteArrayPool pool, + UrlRewriter urlRewriter, + RequestListener requestListener, + boolean curlLoggingEnabled, + CurlCommandLogger curlCommandLogger, + boolean logAuthTokensInCurlCommands) { + mCronetEngine = cronetEngine; + mPool = pool; + mUrlRewriter = urlRewriter; + mRequestListener = requestListener; + mCurlLoggingEnabled = curlLoggingEnabled; + mCurlCommandLogger = curlCommandLogger; + mLogAuthTokensInCurlCommands = logAuthTokensInCurlCommands; + + mRequestListener.initialize(this); + } + + @Override + public void executeRequest( + final Request<?> request, + final Map<String, String> additionalHeaders, + final OnRequestComplete callback) { + if (getBlockingExecutor() == null || getNonBlockingExecutor() == null) { + throw new IllegalStateException("Must set blocking and non-blocking executors"); + } + final Callback urlCallback = + new Callback() { + PoolingByteArrayOutputStream bytesReceived = null; + WritableByteChannel receiveChannel = null; + + @Override + public void onRedirectReceived( + UrlRequest urlRequest, + UrlResponseInfo urlResponseInfo, + String newLocationUrl) { + urlRequest.followRedirect(); + } + + @Override + public void onResponseStarted( + UrlRequest urlRequest, UrlResponseInfo urlResponseInfo) { + bytesReceived = + new PoolingByteArrayOutputStream( + mPool, getContentLength(urlResponseInfo)); + receiveChannel = Channels.newChannel(bytesReceived); + urlRequest.read(ByteBuffer.allocateDirect(1024)); + } + + @Override + public void onReadCompleted( + UrlRequest urlRequest, + UrlResponseInfo urlResponseInfo, + ByteBuffer byteBuffer) { + byteBuffer.flip(); + try { + receiveChannel.write(byteBuffer); + byteBuffer.clear(); + urlRequest.read(byteBuffer); + } catch (IOException e) { + urlRequest.cancel(); + callback.onError(e); + } + } + + @Override + public void onSucceeded( + UrlRequest urlRequest, UrlResponseInfo urlResponseInfo) { + List<Header> headers = getHeaders(urlResponseInfo.getAllHeadersAsList()); + HttpResponse response = + new HttpResponse( + urlResponseInfo.getHttpStatusCode(), + headers, + bytesReceived.toByteArray()); + callback.onSuccess(response); + } + + @Override + public void onFailed( + UrlRequest urlRequest, + UrlResponseInfo urlResponseInfo, + CronetException e) { + callback.onError(e); + } + }; + + String url = request.getUrl(); + String rewritten = mUrlRewriter.rewriteUrl(url); + if (rewritten == null) { + callback.onError(new IOException("URL blocked by rewriter: " + url)); + return; + } + url = rewritten; + + // We can call allowDirectExecutor here and run directly on the network thread, since all + // the callbacks are non-blocking. + final UrlRequest.Builder builder = + mCronetEngine + .newUrlRequestBuilder(url, urlCallback, getNonBlockingExecutor()) + .allowDirectExecutor() + .disableCache() + .setPriority(getPriority(request)); + // request.getHeaders() may be blocking, so submit it to the blocking executor. + getBlockingExecutor() + .execute( + new SetUpRequestTask<>(request, url, builder, additionalHeaders, callback)); + } + + private class SetUpRequestTask<T> extends RequestTask<T> { + UrlRequest.Builder builder; + String url; + Map<String, String> additionalHeaders; + OnRequestComplete callback; + Request<T> request; + + SetUpRequestTask( + Request<T> request, + String url, + UrlRequest.Builder builder, + Map<String, String> additionalHeaders, + OnRequestComplete callback) { + super(request); + // Note that this URL may be different from Request#getUrl() due to the UrlRewriter. + this.url = url; + this.builder = builder; + this.additionalHeaders = additionalHeaders; + this.callback = callback; + this.request = request; + } + + @Override + public void run() { + try { + mRequestListener.onRequestPrepared(request, builder); + CurlLoggedRequestParameters requestParameters = new CurlLoggedRequestParameters(); + setHttpMethod(requestParameters, request); + setRequestHeaders(requestParameters, request, additionalHeaders); + requestParameters.applyToRequest(builder, getNonBlockingExecutor()); + UrlRequest urlRequest = builder.build(); + if (mCurlLoggingEnabled) { + mCurlCommandLogger.logCurlCommand(generateCurlCommand(url, requestParameters)); + } + urlRequest.start(); + } catch (AuthFailureError authFailureError) { + callback.onAuthError(authFailureError); + } + } + } + + @VisibleForTesting + public static List<Header> getHeaders(List<Map.Entry<String, String>> headersList) { + List<Header> headers = new ArrayList<>(); + for (Map.Entry<String, String> header : headersList) { + headers.add(new Header(header.getKey(), header.getValue())); + } + return headers; + } + + /** Sets the connection parameters for the UrlRequest */ + private void setHttpMethod(CurlLoggedRequestParameters requestParameters, Request<?> request) + throws AuthFailureError { + switch (request.getMethod()) { + case Request.Method.DEPRECATED_GET_OR_POST: + // This is the deprecated way that needs to be handled for backwards compatibility. + // If the request's post body is null, then the assumption is that the request is + // GET. Otherwise, it is assumed that the request is a POST. + byte[] postBody = request.getPostBody(); + if (postBody != null) { + requestParameters.setHttpMethod("POST"); + addBodyIfExists(requestParameters, request.getPostBodyContentType(), postBody); + } else { + requestParameters.setHttpMethod("GET"); + } + break; + case Request.Method.GET: + // Not necessary to set the request method because connection defaults to GET but + // being explicit here. + requestParameters.setHttpMethod("GET"); + break; + case Request.Method.DELETE: + requestParameters.setHttpMethod("DELETE"); + break; + case Request.Method.POST: + requestParameters.setHttpMethod("POST"); + addBodyIfExists(requestParameters, request.getBodyContentType(), request.getBody()); + break; + case Request.Method.PUT: + requestParameters.setHttpMethod("PUT"); + addBodyIfExists(requestParameters, request.getBodyContentType(), request.getBody()); + break; + case Request.Method.HEAD: + requestParameters.setHttpMethod("HEAD"); + break; + case Request.Method.OPTIONS: + requestParameters.setHttpMethod("OPTIONS"); + break; + case Request.Method.TRACE: + requestParameters.setHttpMethod("TRACE"); + break; + case Request.Method.PATCH: + requestParameters.setHttpMethod("PATCH"); + addBodyIfExists(requestParameters, request.getBodyContentType(), request.getBody()); + break; + default: + throw new IllegalStateException("Unknown method type."); + } + } + + /** + * Sets the request headers for the UrlRequest. + * + * @param requestParameters parameters that we are adding the request headers to + * @param request to get the headers from + * @param additionalHeaders for the UrlRequest + * @throws AuthFailureError is thrown if Request#getHeaders throws ones + */ + private void setRequestHeaders( + CurlLoggedRequestParameters requestParameters, + Request<?> request, + Map<String, String> additionalHeaders) + throws AuthFailureError { + requestParameters.putAllHeaders(additionalHeaders); + // Request.getHeaders() takes precedence over the given additional (cache) headers). + requestParameters.putAllHeaders(request.getHeaders()); + } + + /** Sets the UploadDataProvider of the UrlRequest.Builder */ + private void addBodyIfExists( + CurlLoggedRequestParameters requestParameters, + String contentType, + @Nullable byte[] body) { + requestParameters.setBody(contentType, body); + } + + /** Helper method that maps Volley's request priority to Cronet's */ + private int getPriority(Request<?> request) { + switch (request.getPriority()) { + case LOW: + return UrlRequest.Builder.REQUEST_PRIORITY_LOW; + case HIGH: + case IMMEDIATE: + return UrlRequest.Builder.REQUEST_PRIORITY_HIGHEST; + case NORMAL: + default: + return UrlRequest.Builder.REQUEST_PRIORITY_MEDIUM; + } + } + + private int getContentLength(UrlResponseInfo urlResponseInfo) { + List<String> content = urlResponseInfo.getAllHeaders().get("Content-Length"); + if (content == null) { + return 1024; + } else { + return Integer.parseInt(content.get(0)); + } + } + + private String generateCurlCommand(String url, CurlLoggedRequestParameters requestParameters) { + StringBuilder builder = new StringBuilder("curl "); + + // HTTP method + builder.append("-X ").append(requestParameters.getHttpMethod()).append(" "); + + // Request headers + for (Map.Entry<String, String> header : requestParameters.getHeaders().entrySet()) { + builder.append("--header \"").append(header.getKey()).append(": "); + if (!mLogAuthTokensInCurlCommands + && ("Authorization".equals(header.getKey()) + || "Cookie".equals(header.getKey()))) { + builder.append("[REDACTED]"); + } else { + builder.append(header.getValue()); + } + builder.append("\" "); + } + + // URL + builder.append("\"").append(url).append("\""); + + // Request body (if any) + if (requestParameters.getBody() != null) { + if (requestParameters.getBody().length >= 1024) { + builder.append(" [REQUEST BODY TOO LARGE TO INCLUDE]"); + } else if (isBinaryContentForLogging(requestParameters)) { + String base64 = Base64.encodeToString(requestParameters.getBody(), Base64.NO_WRAP); + builder.insert(0, "echo '" + base64 + "' | base64 -d > /tmp/$$.bin; ") + .append(" --data-binary @/tmp/$$.bin"); + } else { + // Just assume the request body is UTF-8 since this is for debugging. + try { + builder.append(" --data-ascii \"") + .append(new String(requestParameters.getBody(), "UTF-8")) + .append("\""); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Could not encode to UTF-8", e); + } + } + } + + return builder.toString(); + } + + /** Rough heuristic to determine whether the request body is binary, for logging purposes. */ + private boolean isBinaryContentForLogging(CurlLoggedRequestParameters requestParameters) { + // Check to see if the content is gzip compressed - this means it should be treated as + // binary content regardless of the content type. + String contentEncoding = requestParameters.getHeaders().get("Content-Encoding"); + if (contentEncoding != null) { + String[] encodings = TextUtils.split(contentEncoding, ","); + for (String encoding : encodings) { + if ("gzip".equals(encoding.trim())) { + return true; + } + } + } + + // If the content type is a known text type, treat it as text content. + String contentType = requestParameters.getHeaders().get("Content-Type"); + if (contentType != null) { + return !contentType.startsWith("text/") + && !contentType.startsWith("application/xml") + && !contentType.startsWith("application/json"); + } + + // Otherwise, assume it is binary content. + return true; + } + + /** + * Builder is used to build an instance of {@link CronetHttpStack} from values configured by the + * setters. + */ + public static class Builder { + private static final int DEFAULT_POOL_SIZE = 4096; + private CronetEngine mCronetEngine; + private final Context context; + private ByteArrayPool mPool; + private UrlRewriter mUrlRewriter; + private RequestListener mRequestListener; + private boolean mCurlLoggingEnabled; + private CurlCommandLogger mCurlCommandLogger; + private boolean mLogAuthTokensInCurlCommands; + + public Builder(Context context) { + this.context = context; + } + + /** Sets the CronetEngine to be used. Defaults to a vanialla CronetEngine. */ + public Builder setCronetEngine(CronetEngine engine) { + mCronetEngine = engine; + return this; + } + + /** Sets the ByteArrayPool to be used. Defaults to a new pool with 4096 bytes. */ + public Builder setPool(ByteArrayPool pool) { + mPool = pool; + return this; + } + + /** Sets the UrlRewriter to be used. Default is to return the original string. */ + public Builder setUrlRewriter(UrlRewriter urlRewriter) { + mUrlRewriter = urlRewriter; + return this; + } + + /** Set the optional RequestListener to be used. */ + public Builder setRequestListener(RequestListener requestListener) { + mRequestListener = requestListener; + return this; + } + + /** + * Sets whether cURL logging should be enabled for debugging purposes. + * + * <p>When enabled, for each request dispatched to the network, a roughly-equivalent cURL + * command will be logged to logcat. + * + * <p>The command may be missing some headers that are added by Cronet automatically, and + * the full request body may not be included if it is too large. To inspect the full + * requests and responses, see {@code CronetEngine#startNetLogToFile}. + * + * <p>WARNING: This is only intended for debugging purposes and should never be enabled on + * production devices. + * + * @see #setCurlCommandLogger(CurlCommandLogger) + * @see #setLogAuthTokensInCurlCommands(boolean) + */ + public Builder setCurlLoggingEnabled(boolean curlLoggingEnabled) { + mCurlLoggingEnabled = curlLoggingEnabled; + return this; + } + + /** + * Sets the function used to log cURL commands. + * + * <p>Allows customization of the logging performed when cURL logging is enabled. + * + * <p>By default, when cURL logging is enabled, cURL commands are logged using {@link + * VolleyLog#v}, e.g. at the verbose log level with the same log tag used by the rest of + * Volley. This function may optionally be invoked to provide a custom logger. + * + * @see #setCurlLoggingEnabled(boolean) + */ + public Builder setCurlCommandLogger(CurlCommandLogger curlCommandLogger) { + mCurlCommandLogger = curlCommandLogger; + return this; + } + + /** + * Sets whether to log known auth tokens in cURL commands, or redact them. + * + * <p>By default, headers which may contain auth tokens (e.g. Authorization or Cookie) will + * have their values redacted. Passing true to this method will disable this redaction and + * log the values of these headers. + * + * <p>This heuristic is not perfect; tokens that are logged in unknown headers, or in the + * request body itself, will not be redacted as they cannot be detected generically. + * + * @see #setCurlLoggingEnabled(boolean) + */ + public Builder setLogAuthTokensInCurlCommands(boolean logAuthTokensInCurlCommands) { + mLogAuthTokensInCurlCommands = logAuthTokensInCurlCommands; + return this; + } + + public CronetHttpStack build() { + if (mCronetEngine == null) { + mCronetEngine = new CronetEngine.Builder(context).build(); + } + if (mUrlRewriter == null) { + mUrlRewriter = + new UrlRewriter() { + @Override + public String rewriteUrl(String originalUrl) { + return originalUrl; + } + }; + } + if (mRequestListener == null) { + mRequestListener = new RequestListener() {}; + } + if (mPool == null) { + mPool = new ByteArrayPool(DEFAULT_POOL_SIZE); + } + if (mCurlCommandLogger == null) { + mCurlCommandLogger = + new CurlCommandLogger() { + @Override + public void logCurlCommand(String curlCommand) { + VolleyLog.v(curlCommand); + } + }; + } + return new CronetHttpStack( + mCronetEngine, + mPool, + mUrlRewriter, + mRequestListener, + mCurlLoggingEnabled, + mCurlCommandLogger, + mLogAuthTokensInCurlCommands); + } + } + + /** Callback interface allowing clients to intercept different parts of the request flow. */ + public abstract static class RequestListener { + private CronetHttpStack mStack; + + void initialize(CronetHttpStack stack) { + mStack = stack; + } + + /** + * Called when a request is prepared and about to be sent over the network. + * + * <p>Clients may use this callback to customize UrlRequests before they are dispatched, + * e.g. to enable socket tagging or request finished listeners. + */ + public void onRequestPrepared(Request<?> request, UrlRequest.Builder requestBuilder) {} + + /** @see AsyncHttpStack#getNonBlockingExecutor() */ + protected Executor getNonBlockingExecutor() { + return mStack.getNonBlockingExecutor(); + } + + /** @see AsyncHttpStack#getBlockingExecutor() */ + protected Executor getBlockingExecutor() { + return mStack.getBlockingExecutor(); + } + } + + /** + * Interface for logging cURL commands for requests. + * + * @see Builder#setCurlCommandLogger(CurlCommandLogger) + */ + public interface CurlCommandLogger { + /** Log the given cURL command. */ + void logCurlCommand(String curlCommand); + } + + /** + * Internal container class for request parameters that impact logged cURL commands. + * + * <p>When cURL logging is enabled, an equivalent cURL command to a given request must be + * generated and logged. However, the Cronet UrlRequest object is write-only. So, we write any + * relevant parameters into this read-write container so they can be referenced when generating + * the cURL command (if needed) and then merged into the UrlRequest. + */ + private static class CurlLoggedRequestParameters { + private final TreeMap<String, String> mHeaders = + new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + private String mHttpMethod; + @Nullable private byte[] mBody; + + /** + * Return the headers to be used for the request. + * + * <p>The returned map is case-insensitive. + */ + TreeMap<String, String> getHeaders() { + return mHeaders; + } + + /** Apply all the headers in the given map to the request. */ + void putAllHeaders(Map<String, String> headers) { + mHeaders.putAll(headers); + } + + String getHttpMethod() { + return mHttpMethod; + } + + void setHttpMethod(String httpMethod) { + mHttpMethod = httpMethod; + } + + @Nullable + byte[] getBody() { + return mBody; + } + + void setBody(String contentType, @Nullable byte[] body) { + mBody = body; + if (body != null && !mHeaders.containsKey(HttpHeaderParser.HEADER_CONTENT_TYPE)) { + // Set the content-type unless it was already set (by Request#getHeaders). + mHeaders.put(HttpHeaderParser.HEADER_CONTENT_TYPE, contentType); + } + } + + void applyToRequest(UrlRequest.Builder builder, ExecutorService nonBlockingExecutor) { + for (Map.Entry<String, String> header : mHeaders.entrySet()) { + builder.addHeader(header.getKey(), header.getValue()); + } + builder.setHttpMethod(mHttpMethod); + if (mBody != null) { + UploadDataProvider dataProvider = UploadDataProviders.create(mBody); + builder.setUploadDataProvider(dataProvider, nonBlockingExecutor); + } + } + } +} |