/* * Copyright (C) 2020 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.volley.cronet; import android.content.Context; import android.text.TextUtils; import android.util.Base64; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import com.android.volley.AuthFailureError; import com.android.volley.Header; import com.android.volley.Request; import com.android.volley.RequestTask; import com.android.volley.VolleyLog; import com.android.volley.toolbox.AsyncHttpStack; import com.android.volley.toolbox.ByteArrayPool; import com.android.volley.toolbox.HttpHeaderParser; import com.android.volley.toolbox.HttpResponse; import com.android.volley.toolbox.PoolingByteArrayOutputStream; import com.android.volley.toolbox.UrlRewriter; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.WritableByteChannel; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import org.chromium.net.CronetEngine; import org.chromium.net.CronetException; import org.chromium.net.UploadDataProvider; import org.chromium.net.UploadDataProviders; import org.chromium.net.UrlRequest; import org.chromium.net.UrlRequest.Callback; import org.chromium.net.UrlResponseInfo; /** * A {@link AsyncHttpStack} that's based on Cronet's fully asynchronous API for network requests. */ public class CronetHttpStack extends AsyncHttpStack { private final CronetEngine mCronetEngine; private final ByteArrayPool mPool; private final UrlRewriter mUrlRewriter; private final RequestListener mRequestListener; // cURL logging support private final boolean mCurlLoggingEnabled; private final CurlCommandLogger mCurlCommandLogger; private final boolean mLogAuthTokensInCurlCommands; private CronetHttpStack( CronetEngine cronetEngine, ByteArrayPool pool, UrlRewriter urlRewriter, RequestListener requestListener, boolean curlLoggingEnabled, CurlCommandLogger curlCommandLogger, boolean logAuthTokensInCurlCommands) { mCronetEngine = cronetEngine; mPool = pool; mUrlRewriter = urlRewriter; mRequestListener = requestListener; mCurlLoggingEnabled = curlLoggingEnabled; mCurlCommandLogger = curlCommandLogger; mLogAuthTokensInCurlCommands = logAuthTokensInCurlCommands; mRequestListener.initialize(this); } @Override public void executeRequest( final Request request, final Map 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
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 extends RequestTask { UrlRequest.Builder builder; String url; Map additionalHeaders; OnRequestComplete callback; Request request; SetUpRequestTask( Request request, String url, UrlRequest.Builder builder, Map 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
getHeaders(List> headersList) { List
headers = new ArrayList<>(); for (Map.Entry 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 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 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 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. * *

When enabled, for each request dispatched to the network, a roughly-equivalent cURL * command will be logged to logcat. * *

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}. * *

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. * *

Allows customization of the logging performed when cURL logging is enabled. * *

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. * *

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. * *

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. * *

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. * *

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 mHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); private String mHttpMethod; @Nullable private byte[] mBody; /** * Return the headers to be used for the request. * *

The returned map is case-insensitive. */ TreeMap getHeaders() { return mHeaders; } /** Apply all the headers in the given map to the request. */ void putAllHeaders(Map 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 header : mHeaders.entrySet()) { builder.addHeader(header.getKey(), header.getValue()); } builder.setHttpMethod(mHttpMethod); if (mBody != null) { UploadDataProvider dataProvider = UploadDataProviders.create(mBody); builder.setUploadDataProvider(dataProvider, nonBlockingExecutor); } } } }