| /* |
| * 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); |
| } |
| } |
| } |
| } |