| // Copyright 2015 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.net.impl; |
| |
| import android.content.Context; |
| import android.net.ConnectivityManager; |
| import android.net.Network; |
| import android.net.TrafficStats; |
| import android.os.Build; |
| import android.util.Log; |
| |
| import androidx.annotation.Nullable; |
| import androidx.annotation.RequiresApi; |
| import androidx.annotation.VisibleForTesting; |
| |
| import org.chromium.net.CronetException; |
| import org.chromium.net.InlineExecutionProhibitedException; |
| import org.chromium.net.NetworkException; |
| import org.chromium.net.ThreadStatsUid; |
| import org.chromium.net.UploadDataProvider; |
| import org.chromium.net.UrlResponseInfo; |
| import org.chromium.net.impl.CronetLogger.CronetTrafficInfo; |
| import org.chromium.net.impl.JavaUrlRequestUtils.CheckedRunnable; |
| import org.chromium.net.impl.JavaUrlRequestUtils.DirectPreventingExecutor; |
| import org.chromium.net.impl.JavaUrlRequestUtils.State; |
| |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.OutputStream; |
| import java.net.HttpURLConnection; |
| import java.net.URI; |
| import java.net.URL; |
| import java.nio.ByteBuffer; |
| import java.nio.channels.Channels; |
| import java.nio.channels.ReadableByteChannel; |
| import java.nio.channels.WritableByteChannel; |
| import java.time.Duration; |
| import java.util.AbstractMap.SimpleEntry; |
| import java.util.ArrayDeque; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.TreeMap; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.RejectedExecutionException; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| import java.util.concurrent.atomic.AtomicInteger; |
| |
| import javax.annotation.concurrent.GuardedBy; |
| |
| /** Pure java UrlRequest, backed by {@link HttpURLConnection}. */ |
| final class JavaUrlRequest extends UrlRequestBase { |
| private static final String X_ANDROID = "X-Android"; |
| private static final String X_ANDROID_SELECTED_TRANSPORT = "X-Android-Selected-Transport"; |
| private static final String TAG = JavaUrlRequest.class.getSimpleName(); |
| private static final int DEFAULT_CHUNK_LENGTH = |
| JavaUploadDataSinkBase.DEFAULT_UPLOAD_BUFFER_SIZE; |
| private static final String USER_AGENT = "User-Agent"; |
| private final AsyncUrlRequestCallback mCallbackAsync; |
| private final Executor mExecutor; |
| private final String mUserAgent; |
| private final Map<String, String> mRequestHeaders = |
| new TreeMap<>(String.CASE_INSENSITIVE_ORDER); |
| private final List<String> mUrlChain = new ArrayList<>(); |
| |
| /** |
| * This is the source of thread safety in this class - no other synchronization is performed. |
| * By compare-and-swapping from one state to another, we guarantee that operations aren't |
| * running concurrently. Only the winner of a CAS proceeds. |
| * |
| * <p>A caller can lose a CAS for three reasons - user error (two calls to read() without |
| * waiting for the read to succeed), runtime error (network code or user code throws an |
| * exception), or cancellation. |
| */ |
| private final AtomicInteger /* State */ mState = new AtomicInteger(State.NOT_STARTED); |
| |
| private final AtomicBoolean mUploadProviderClosed = new AtomicBoolean(false); |
| |
| private final boolean mAllowDirectExecutor; |
| |
| /* These don't change with redirects */ |
| private String mInitialMethod; |
| private VersionSafeCallbacks.UploadDataProviderWrapper mUploadDataProvider; |
| private Executor mUploadExecutor; |
| |
| /** |
| * Holds a subset of StatusValues - {@link State#STARTED} can represent |
| * {@link Status#SENDING_REQUEST} or {@link Status#WAITING_FOR_RESPONSE}. While the distinction |
| * isn't needed to implement the logic in this class, it is needed to implement |
| * {@link #getStatus(StatusListener)}. |
| * |
| * <p>Concurrency notes - this value is not atomically updated with mState, so there is some |
| * risk that we'd get an inconsistent snapshot of both - however, it also happens that this |
| * value is only used with the STARTED state, so it's inconsequential. |
| */ |
| @StatusValues private volatile int mAdditionalStatusDetails = Status.INVALID; |
| |
| /* These change with redirects. */ |
| private String mCurrentUrl; |
| @Nullable private ReadableByteChannel mResponseChannel; // Only accessed on mExecutor. |
| private UrlResponseInfoImpl mUrlResponseInfo; |
| private String mPendingRedirectUrl; |
| private HttpURLConnection mCurrentUrlConnection; // Only accessed on mExecutor. |
| private OutputStreamDataSink mOutputStreamDataSink; // Only accessed on mExecutor. |
| private final JavaCronetEngine mEngine; |
| private final int mCronetEngineId; |
| private final CronetLogger mLogger; |
| |
| private final long mNetworkHandle; |
| |
| // Executor that runs one task at a time on an underlying Executor. |
| // NOTE: Do not use to wrap user supplied Executor as lock is held while underlying execute() |
| // is called. |
| private static final class SerializingExecutor implements Executor { |
| private final Executor mUnderlyingExecutor; |
| private final Runnable mRunTasks = this::runTasks; |
| |
| // Queue of tasks to run. Tasks are added to the end and taken from the front. |
| // Synchronized on itself. |
| @GuardedBy("mTaskQueue") |
| private final ArrayDeque<Runnable> mTaskQueue = new ArrayDeque<>(); |
| |
| // Indicates if mRunTasks is actively running tasks. Synchronized on mTaskQueue. |
| @GuardedBy("mTaskQueue") |
| private boolean mRunning; |
| |
| SerializingExecutor(Executor underlyingExecutor) { |
| mUnderlyingExecutor = underlyingExecutor; |
| } |
| |
| @Override |
| public void execute(Runnable command) { |
| synchronized (mTaskQueue) { |
| mTaskQueue.addLast(command); |
| try { |
| mUnderlyingExecutor.execute(mRunTasks); |
| } catch (RejectedExecutionException e) { |
| // If shutting down, do not add new tasks to the queue. |
| mTaskQueue.removeLast(); |
| } |
| } |
| } |
| |
| private void runTasks() { |
| Runnable task; |
| synchronized (mTaskQueue) { |
| if (mRunning) { |
| return; |
| } |
| task = mTaskQueue.pollFirst(); |
| mRunning = task != null; |
| } |
| while (task != null) { |
| boolean threw = true; |
| try { |
| task.run(); |
| threw = false; |
| } finally { |
| synchronized (mTaskQueue) { |
| if (threw) { |
| // If task.run() threw, this method will abort without |
| // looping again, so repost to keep running tasks. |
| mRunning = false; |
| try { |
| mUnderlyingExecutor.execute(mRunTasks); |
| } catch (RejectedExecutionException e) { |
| // Give up if a task run at shutdown throws. |
| } |
| } else { |
| task = mTaskQueue.pollFirst(); |
| mRunning = task != null; |
| } |
| } |
| } |
| } |
| } |
| } |
| |
| /** |
| * @param executor The executor used for reading and writing from sockets |
| * @param userExecutor The executor used to dispatch to {@code callback} |
| */ |
| JavaUrlRequest( |
| JavaCronetEngine engine, |
| Callback callback, |
| final Executor executor, |
| Executor userExecutor, |
| String url, |
| String userAgent, |
| boolean allowDirectExecutor, |
| boolean trafficStatsTagSet, |
| int trafficStatsTag, |
| final boolean trafficStatsUidSet, |
| final int trafficStatsUid, |
| long networkHandle) { |
| Objects.requireNonNull(url, "URL is required"); |
| Objects.requireNonNull(callback, "Listener is required"); |
| Objects.requireNonNull(executor, "Executor is required"); |
| Objects.requireNonNull(userExecutor, "userExecutor is required"); |
| |
| mAllowDirectExecutor = allowDirectExecutor; |
| mCallbackAsync = new AsyncUrlRequestCallback(callback, userExecutor); |
| final int trafficStatsTagToUse = |
| trafficStatsTagSet ? trafficStatsTag : TrafficStats.getThreadStatsTag(); |
| mExecutor = |
| new SerializingExecutor( |
| (command) -> { |
| executor.execute( |
| () -> { |
| int oldTag = TrafficStats.getThreadStatsTag(); |
| TrafficStats.setThreadStatsTag(trafficStatsTagToUse); |
| if (trafficStatsUidSet) { |
| ThreadStatsUid.set(trafficStatsUid); |
| } |
| try { |
| command.run(); |
| } finally { |
| if (trafficStatsUidSet) { |
| ThreadStatsUid.clear(); |
| } |
| TrafficStats.setThreadStatsTag(oldTag); |
| } |
| }); |
| }); |
| mEngine = engine; |
| mCronetEngineId = engine.getCronetEngineId(); |
| mLogger = engine.getCronetLogger(); |
| mCurrentUrl = url; |
| mUserAgent = userAgent; |
| mNetworkHandle = networkHandle; |
| } |
| |
| @Override |
| public void setHttpMethod(String method) { |
| checkNotStarted(); |
| if (method == null) { |
| throw new NullPointerException("Method is required."); |
| } |
| if ("OPTIONS".equalsIgnoreCase(method) |
| || "GET".equalsIgnoreCase(method) |
| || "HEAD".equalsIgnoreCase(method) |
| || "POST".equalsIgnoreCase(method) |
| || "PUT".equalsIgnoreCase(method) |
| || "DELETE".equalsIgnoreCase(method) |
| || "TRACE".equalsIgnoreCase(method) |
| || "PATCH".equalsIgnoreCase(method)) { |
| mInitialMethod = method; |
| } else { |
| throw new IllegalArgumentException("Invalid http method " + method); |
| } |
| } |
| |
| private void checkNotStarted() { |
| @State int state = mState.get(); |
| if (state != State.NOT_STARTED) { |
| throw new IllegalStateException("Request is already started. State is: " + state); |
| } |
| } |
| |
| @Override |
| public void addHeader(String header, String value) { |
| checkNotStarted(); |
| if (!isValidHeaderName(header) || value.contains("\r\n")) { |
| throw new IllegalArgumentException("Invalid header with headername: " + header); |
| } |
| if (mRequestHeaders.containsKey(header)) { |
| mRequestHeaders.remove(header); |
| } |
| mRequestHeaders.put(header, value); |
| } |
| |
| private boolean isValidHeaderName(String header) { |
| for (int i = 0; i < header.length(); i++) { |
| char c = header.charAt(i); |
| switch (c) { |
| case '(': |
| case ')': |
| case '<': |
| case '>': |
| case '@': |
| case ',': |
| case ';': |
| case ':': |
| case '\\': |
| case '\'': |
| case '/': |
| case '[': |
| case ']': |
| case '?': |
| case '=': |
| case '{': |
| case '}': |
| return false; |
| default: |
| if (Character.isISOControl(c) || Character.isWhitespace(c)) { |
| return false; |
| } |
| } |
| } |
| return true; |
| } |
| |
| @Override |
| public void setUploadDataProvider(UploadDataProvider uploadDataProvider, Executor executor) { |
| if (uploadDataProvider == null) { |
| throw new NullPointerException("Invalid UploadDataProvider."); |
| } |
| if (!mRequestHeaders.containsKey("Content-Type")) { |
| throw new IllegalArgumentException( |
| "Requests with upload data must have a Content-Type."); |
| } |
| checkNotStarted(); |
| if (mInitialMethod == null) { |
| mInitialMethod = "POST"; |
| } |
| this.mUploadDataProvider = |
| new VersionSafeCallbacks.UploadDataProviderWrapper(uploadDataProvider); |
| if (mAllowDirectExecutor) { |
| this.mUploadExecutor = executor; |
| } else { |
| this.mUploadExecutor = new DirectPreventingExecutor(executor); |
| } |
| } |
| |
| private final class OutputStreamDataSink extends JavaUploadDataSinkBase { |
| private final HttpURLConnection mUrlConnection; |
| private final AtomicBoolean mOutputChannelClosed = new AtomicBoolean(false); |
| private WritableByteChannel mOutputChannel; |
| private OutputStream mUrlConnectionOutputStream; |
| |
| OutputStreamDataSink( |
| final Executor userExecutor, |
| Executor executor, |
| HttpURLConnection urlConnection, |
| VersionSafeCallbacks.UploadDataProviderWrapper provider) { |
| super(userExecutor, executor, provider); |
| mUrlConnection = urlConnection; |
| } |
| |
| @Override |
| protected void initializeRead() throws IOException { |
| if (mOutputChannel == null) { |
| mAdditionalStatusDetails = Status.CONNECTING; |
| mUrlConnection.setDoOutput(true); |
| mUrlConnection.connect(); |
| mAdditionalStatusDetails = Status.SENDING_REQUEST; |
| mUrlConnectionOutputStream = mUrlConnection.getOutputStream(); |
| mOutputChannel = Channels.newChannel(mUrlConnectionOutputStream); |
| } |
| } |
| |
| void closeOutputChannel() throws IOException { |
| if (mOutputChannel != null |
| && mOutputChannelClosed.compareAndSet( |
| /* expected= */ false, /* updated= */ true)) { |
| mOutputChannel.close(); |
| } |
| } |
| |
| @Override |
| protected void finish() throws IOException { |
| closeOutputChannel(); |
| fireGetHeaders(); |
| } |
| |
| @Override |
| protected void initializeStart(long totalBytes) { |
| if (totalBytes > 0) { |
| mUrlConnection.setFixedLengthStreamingMode(totalBytes); |
| } else { |
| mUrlConnection.setChunkedStreamingMode(DEFAULT_CHUNK_LENGTH); |
| } |
| } |
| |
| @Override |
| protected int processSuccessfulRead(ByteBuffer buffer) throws IOException { |
| int totalBytesProcessed = 0; |
| while (buffer.hasRemaining()) { |
| totalBytesProcessed += mOutputChannel.write(buffer); |
| } |
| // Forces a chunk to be sent, rather than buffering to the DEFAULT_CHUNK_LENGTH. |
| // This allows clients to trickle-upload bytes as they become available without |
| // introducing latency due to buffering. |
| mUrlConnectionOutputStream.flush(); |
| return totalBytesProcessed; |
| } |
| |
| @Override |
| protected Runnable getErrorSettingRunnable(CheckedRunnable runnable) { |
| return errorSetting(runnable); |
| } |
| |
| @Override |
| protected Runnable getUploadErrorSettingRunnable(CheckedRunnable runnable) { |
| return uploadErrorSetting(runnable); |
| } |
| |
| @Override |
| protected void processUploadError(Throwable exception) { |
| enterUploadErrorState(exception); |
| } |
| } |
| |
| @Override |
| public void start() { |
| mAdditionalStatusDetails = Status.CONNECTING; |
| mEngine.incrementActiveRequestCount(); |
| transitionStates( |
| State.NOT_STARTED, |
| State.STARTED, |
| () -> { |
| mUrlChain.add(mCurrentUrl); |
| fireOpenConnection(); |
| }); |
| } |
| |
| private void enterErrorState(final CronetException error) { |
| if (setTerminalState(State.ERROR)) { |
| fireDisconnect(); |
| fireCloseUploadDataProvider(); |
| mCallbackAsync.onFailed(mUrlResponseInfo, error); |
| } |
| } |
| |
| private boolean setTerminalState(@State int error) { |
| while (true) { |
| @State int oldState = mState.get(); |
| switch (oldState) { |
| case State.NOT_STARTED: |
| throw new IllegalStateException("Can't enter error state before start"); |
| case State.ERROR: // fallthrough |
| case State.COMPLETE: // fallthrough |
| case State.CANCELLED: |
| return false; // Already in a terminal state |
| default: |
| if (mState.compareAndSet(/* expected= */ oldState, /* updated= */ error)) { |
| return true; |
| } |
| } |
| } |
| } |
| |
| /** Ends the request with an error, caused by an exception thrown from user code. */ |
| private void enterUserErrorState(final Throwable error) { |
| enterErrorState( |
| new CallbackExceptionImpl("Exception received from UrlRequest.Callback", error)); |
| } |
| |
| /** Ends the request with an error, caused by an exception thrown from user code. */ |
| private void enterUploadErrorState(final Throwable error) { |
| enterErrorState( |
| new CallbackExceptionImpl("Exception received from UploadDataProvider", error)); |
| } |
| |
| private void enterCronetErrorState(final Throwable error) { |
| // TODO(clm) mapping from Java exception (UnknownHostException, for example) to net error |
| // code goes here. |
| enterErrorState(new CronetExceptionImpl("System error", error)); |
| } |
| |
| /** |
| * Atomically swaps from the expected state to a new state. If the swap fails, and it's not |
| * due to an earlier error or cancellation, throws an exception. |
| * |
| * @param afterTransition Callback to run after transition completes successfully. |
| */ |
| private void transitionStates( |
| @State int expected, @State int newState, Runnable afterTransition) { |
| if (!mState.compareAndSet(expected, newState)) { |
| @State int state = mState.get(); |
| if (!(state == State.CANCELLED || state == State.ERROR)) { |
| throw new IllegalStateException( |
| "Invalid state transition - expected " + expected + " but was " + state); |
| } |
| } else { |
| afterTransition.run(); |
| } |
| } |
| |
| @Override |
| public void followRedirect() { |
| transitionStates( |
| State.AWAITING_FOLLOW_REDIRECT, |
| State.STARTED, |
| new Runnable() { |
| @Override |
| public void run() { |
| mCurrentUrl = mPendingRedirectUrl; |
| mPendingRedirectUrl = null; |
| fireOpenConnection(); |
| } |
| }); |
| } |
| |
| private void fireGetHeaders() { |
| mAdditionalStatusDetails = Status.WAITING_FOR_RESPONSE; |
| mExecutor.execute( |
| errorSetting( |
| () -> { |
| if (mCurrentUrlConnection == null) { |
| return; // We've been cancelled |
| } |
| final List<Map.Entry<String, String>> headerList = new ArrayList<>(); |
| String selectedTransport = "http/1.1"; |
| String headerKey; |
| for (int i = 0; |
| (headerKey = mCurrentUrlConnection.getHeaderFieldKey(i)) |
| != null; |
| i++) { |
| if (X_ANDROID_SELECTED_TRANSPORT.equalsIgnoreCase(headerKey)) { |
| selectedTransport = mCurrentUrlConnection.getHeaderField(i); |
| } |
| if (!headerKey.startsWith(X_ANDROID)) { |
| headerList.add( |
| new SimpleEntry<>( |
| headerKey, |
| mCurrentUrlConnection.getHeaderField(i))); |
| } |
| } |
| |
| int responseCode = mCurrentUrlConnection.getResponseCode(); |
| // Important to copy the list here, because although we never |
| // concurrently modify the list ourselves, user code might iterate |
| // over it while we're redirecting, and that would throw |
| // ConcurrentModificationException. |
| mUrlResponseInfo = |
| new UrlResponseInfoImpl( |
| new ArrayList<>(mUrlChain), |
| responseCode, |
| mCurrentUrlConnection.getResponseMessage(), |
| Collections.unmodifiableList(headerList), |
| false, |
| selectedTransport, |
| "", |
| 0); |
| // TODO(clm) actual redirect handling? post -> get and whatnot? |
| if (responseCode >= 300 && responseCode < 400) { |
| List<String> locationFields = |
| mUrlResponseInfo.getAllHeaders().get("location"); |
| if (locationFields != null) { |
| fireRedirectReceived(locationFields.get(0)); |
| return; |
| } |
| } |
| fireCloseUploadDataProvider(); |
| if (responseCode >= 400) { |
| InputStream inputStream = mCurrentUrlConnection.getErrorStream(); |
| mResponseChannel = |
| inputStream == null |
| ? null |
| : InputStreamChannel.wrap(inputStream); |
| mCallbackAsync.onResponseStarted(mUrlResponseInfo); |
| } else { |
| mResponseChannel = |
| InputStreamChannel.wrap( |
| mCurrentUrlConnection.getInputStream()); |
| mCallbackAsync.onResponseStarted(mUrlResponseInfo); |
| } |
| })); |
| } |
| |
| private void fireCloseUploadDataProvider() { |
| if (mUploadDataProvider != null |
| && mUploadProviderClosed.compareAndSet( |
| /* expected= */ false, /* updated= */ true)) { |
| try { |
| mUploadExecutor.execute(uploadErrorSetting(mUploadDataProvider::close)); |
| } catch (RejectedExecutionException e) { |
| Log.e(TAG, "Exception when closing uploadDataProvider", e); |
| } |
| } |
| } |
| |
| private void fireRedirectReceived(final String locationField) { |
| transitionStates( |
| State.STARTED, |
| State.REDIRECT_RECEIVED, |
| () -> { |
| mPendingRedirectUrl = URI.create(mCurrentUrl).resolve(locationField).toString(); |
| mUrlChain.add(mPendingRedirectUrl); |
| transitionStates( |
| State.REDIRECT_RECEIVED, |
| State.AWAITING_FOLLOW_REDIRECT, |
| () -> { |
| mCallbackAsync.onRedirectReceived( |
| mUrlResponseInfo, mPendingRedirectUrl); |
| }); |
| }); |
| } |
| |
| private void fireOpenConnection() { |
| mExecutor.execute( |
| errorSetting( |
| () -> { |
| // If we're cancelled, then our old connection will be disconnected |
| // for us and we shouldn't open a new one. |
| if (mState.get() == State.CANCELLED) { |
| return; |
| } |
| |
| final URL url = new URL(mCurrentUrl); |
| if (mCurrentUrlConnection != null) { |
| mCurrentUrlConnection.disconnect(); |
| mCurrentUrlConnection = null; |
| } |
| |
| if (mNetworkHandle == CronetEngineBase.DEFAULT_NETWORK_HANDLE |
| || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { |
| mCurrentUrlConnection = (HttpURLConnection) url.openConnection(); |
| } else { |
| Network network = getNetworkFromHandle(mNetworkHandle); |
| if (network == null) { |
| throw new NetworkExceptionImpl( |
| "Network bound to request not found", |
| NetworkException.ERROR_ADDRESS_UNREACHABLE, |
| -4 /*Invalid argument*/); |
| } |
| mCurrentUrlConnection = |
| (HttpURLConnection) network.openConnection(url); |
| } |
| mCurrentUrlConnection.setInstanceFollowRedirects(false); |
| if (!mRequestHeaders.containsKey(USER_AGENT)) { |
| mRequestHeaders.put(USER_AGENT, mUserAgent); |
| } |
| for (Map.Entry<String, String> entry : mRequestHeaders.entrySet()) { |
| mCurrentUrlConnection.setRequestProperty( |
| entry.getKey(), entry.getValue()); |
| } |
| if (mInitialMethod == null) { |
| mInitialMethod = "GET"; |
| } |
| mCurrentUrlConnection.setRequestMethod(mInitialMethod); |
| if (mUploadDataProvider != null) { |
| mOutputStreamDataSink = |
| new OutputStreamDataSink( |
| mUploadExecutor, |
| mExecutor, |
| mCurrentUrlConnection, |
| mUploadDataProvider); |
| mOutputStreamDataSink.start(mUrlChain.size() == 1); |
| } else { |
| mAdditionalStatusDetails = Status.CONNECTING; |
| mCurrentUrlConnection.connect(); |
| fireGetHeaders(); |
| } |
| })); |
| } |
| |
| private Runnable errorSetting(final CheckedRunnable delegate) { |
| return () -> { |
| try { |
| delegate.run(); |
| } catch (Throwable t) { |
| enterCronetErrorState(t); |
| } |
| }; |
| } |
| |
| private Runnable userErrorSetting(final CheckedRunnable delegate) { |
| return () -> { |
| try { |
| delegate.run(); |
| } catch (Throwable t) { |
| enterUserErrorState(t); |
| } |
| }; |
| } |
| |
| private Runnable uploadErrorSetting(final CheckedRunnable delegate) { |
| return () -> { |
| try { |
| delegate.run(); |
| } catch (Throwable t) { |
| enterUploadErrorState(t); |
| } |
| }; |
| } |
| |
| @Override |
| public void read(final ByteBuffer buffer) { |
| Preconditions.checkDirect(buffer); |
| Preconditions.checkHasRemaining(buffer); |
| CheckedRunnable doRead = |
| () -> { |
| int read = mResponseChannel == null ? -1 : mResponseChannel.read(buffer); |
| processReadResult(read, buffer); |
| }; |
| transitionStates( |
| State.AWAITING_READ, |
| State.READING, |
| () -> { |
| mExecutor.execute(errorSetting(doRead)); |
| }); |
| } |
| |
| private void processReadResult(int read, final ByteBuffer buffer) throws IOException { |
| if (read != -1) { |
| mCallbackAsync.onReadCompleted(mUrlResponseInfo, buffer); |
| } else { |
| if (mResponseChannel != null) { |
| mResponseChannel.close(); |
| } |
| if (mState.compareAndSet( |
| /* expected= */ State.READING, /* updated= */ State.COMPLETE)) { |
| fireDisconnect(); |
| mCallbackAsync.onSucceeded(mUrlResponseInfo); |
| } |
| } |
| } |
| |
| private void fireDisconnect() { |
| mExecutor.execute( |
| () -> { |
| if (mOutputStreamDataSink != null) { |
| try { |
| mOutputStreamDataSink.closeOutputChannel(); |
| } catch (IOException e) { |
| Log.e(TAG, "Exception when closing OutputChannel", e); |
| } |
| } |
| if (mCurrentUrlConnection != null) { |
| mCurrentUrlConnection.disconnect(); |
| mCurrentUrlConnection = null; |
| } |
| }); |
| } |
| |
| @Override |
| public void cancel() { |
| @State int oldState = mState.getAndSet(State.CANCELLED); |
| switch (oldState) { |
| // We've just scheduled some user code to run. When they perform their next |
| // operation, they'll observe it and fail. However, if user code is cancelling in |
| // response to one of these callbacks, we'll never actually cancel! |
| // TODO(clm) figure out if it's possible to avoid concurrency in user callbacks. |
| case State.REDIRECT_RECEIVED: |
| case State.AWAITING_FOLLOW_REDIRECT: |
| case State.AWAITING_READ: |
| |
| // User code is waiting on us - cancel away! |
| case State.STARTED: |
| case State.READING: |
| fireDisconnect(); |
| fireCloseUploadDataProvider(); |
| mCallbackAsync.onCanceled(mUrlResponseInfo); |
| break; |
| // The rest are all termination cases - we're too late to cancel. |
| case State.ERROR: |
| case State.COMPLETE: |
| case State.CANCELLED: |
| break; |
| default: |
| break; |
| } |
| } |
| |
| @Override |
| public boolean isDone() { |
| @State int state = mState.get(); |
| return state == State.COMPLETE || state == State.ERROR || state == State.CANCELLED; |
| } |
| |
| /** |
| * Estimates the byte size of the headers in their on-wire format. |
| * We are not really interested in their specific size but something which is close enough. |
| */ |
| @VisibleForTesting |
| static long estimateHeadersSizeInBytesList(Map<String, List<String>> headers) { |
| if (headers == null) return 0; |
| |
| long responseHeaderSizeInBytes = 0; |
| for (Map.Entry<String, List<String>> entry : headers.entrySet()) { |
| String key = entry.getKey(); |
| if (key != null) responseHeaderSizeInBytes += key.length(); |
| if (entry.getValue() == null) continue; |
| |
| for (String content : entry.getValue()) { |
| if (content != null) responseHeaderSizeInBytes += content.length(); |
| } |
| } |
| return responseHeaderSizeInBytes; |
| } |
| |
| /** |
| * Estimates the byte size of the headers in their on-wire format. |
| * We are not really interested in their specific size but something which is close enough. |
| */ |
| @VisibleForTesting |
| static long estimateHeadersSizeInBytes(Map<String, String> headers) { |
| if (headers == null) return 0; |
| long responseHeaderSizeInBytes = 0; |
| for (Map.Entry<String, String> entry : headers.entrySet()) { |
| String key = entry.getKey(); |
| if (key != null) responseHeaderSizeInBytes += key.length(); |
| String value = entry.getValue(); |
| if (value != null) responseHeaderSizeInBytes += value.length(); |
| } |
| return responseHeaderSizeInBytes; |
| } |
| |
| private static long parseContentLengthString(String contentLength) { |
| try { |
| return Long.parseLong(contentLength); |
| } catch (NumberFormatException e) { |
| return 0; |
| } |
| } |
| |
| @Override |
| public void getStatus(StatusListener listener) { |
| @State int state = mState.get(); |
| int extraStatus = this.mAdditionalStatusDetails; |
| |
| @StatusValues final int status; |
| switch (state) { |
| case State.ERROR: |
| case State.COMPLETE: |
| case State.CANCELLED: |
| case State.NOT_STARTED: |
| status = Status.INVALID; |
| break; |
| case State.STARTED: |
| status = extraStatus; |
| break; |
| case State.REDIRECT_RECEIVED: |
| case State.AWAITING_FOLLOW_REDIRECT: |
| case State.AWAITING_READ: |
| status = Status.IDLE; |
| break; |
| case State.READING: |
| status = Status.READING_RESPONSE; |
| break; |
| default: |
| throw new IllegalStateException("Switch is exhaustive: " + state); |
| } |
| |
| mCallbackAsync.sendStatus( |
| new VersionSafeCallbacks.UrlRequestStatusListener(listener), status); |
| } |
| |
| /** This wrapper ensures that callbacks are always called on the correct executor */ |
| private final class AsyncUrlRequestCallback { |
| final VersionSafeCallbacks.UrlRequestCallback mCallback; |
| final Executor mUserExecutor; |
| final Executor mFallbackExecutor; |
| |
| AsyncUrlRequestCallback(Callback callback, final Executor userExecutor) { |
| this.mCallback = new VersionSafeCallbacks.UrlRequestCallback(callback); |
| if (mAllowDirectExecutor) { |
| this.mUserExecutor = userExecutor; |
| this.mFallbackExecutor = null; |
| } else { |
| mUserExecutor = new DirectPreventingExecutor(userExecutor); |
| mFallbackExecutor = userExecutor; |
| } |
| } |
| |
| void sendStatus( |
| final VersionSafeCallbacks.UrlRequestStatusListener listener, final int status) { |
| mUserExecutor.execute( |
| () -> { |
| listener.onStatus(status); |
| }); |
| } |
| |
| void execute(CheckedRunnable runnable) { |
| try { |
| mUserExecutor.execute(userErrorSetting(runnable)); |
| } catch (RejectedExecutionException e) { |
| enterErrorState(new CronetExceptionImpl("Exception posting task to executor", e)); |
| } |
| } |
| |
| void onRedirectReceived(final UrlResponseInfo info, final String newLocationUrl) { |
| execute( |
| () -> { |
| mCallback.onRedirectReceived(JavaUrlRequest.this, info, newLocationUrl); |
| }); |
| } |
| |
| void onResponseStarted(UrlResponseInfo info) { |
| execute( |
| () -> { |
| if (mState.compareAndSet( |
| /* expected= */ State.STARTED, |
| /* updated= */ State.AWAITING_READ)) { |
| mCallback.onResponseStarted(JavaUrlRequest.this, mUrlResponseInfo); |
| } |
| }); |
| } |
| |
| void onReadCompleted(final UrlResponseInfo info, final ByteBuffer byteBuffer) { |
| execute( |
| () -> { |
| if (mState.compareAndSet( |
| /* expected= */ State.READING, |
| /* updated= */ State.AWAITING_READ)) { |
| mCallback.onReadCompleted(JavaUrlRequest.this, info, byteBuffer); |
| } |
| }); |
| } |
| |
| /** |
| * Builds the {@link CronetTrafficInfo} associated to this request internal state. |
| * This helper methods makes strong assumptions about the state of the request. For this |
| * reason it should only be called within {@link JavaUrlRequest#maybeReportMetrics} where |
| * these assumptions are guaranteed to be true. |
| * @return the {@link CronetTrafficInfo} associated to this request internal state |
| */ |
| @RequiresApi(Build.VERSION_CODES.O) |
| private CronetTrafficInfo buildCronetTrafficInfo() { |
| assert mRequestHeaders != null; |
| |
| // Most of the CronetTrafficInfo fields have similar names/semantics. To avoid bugs due |
| // to typos everything is final, this means that things have to initialized through an |
| // if/else. |
| final Map<String, List<String>> responseHeaders; |
| final String negotiatedProtocol; |
| final int httpStatusCode; |
| final boolean wasCached; |
| if (mUrlResponseInfo != null) { |
| responseHeaders = mUrlResponseInfo.getAllHeaders(); |
| negotiatedProtocol = mUrlResponseInfo.getNegotiatedProtocol(); |
| httpStatusCode = mUrlResponseInfo.getHttpStatusCode(); |
| wasCached = mUrlResponseInfo.wasCached(); |
| } else { |
| responseHeaders = Collections.emptyMap(); |
| negotiatedProtocol = ""; |
| httpStatusCode = 0; |
| wasCached = false; |
| } |
| |
| final long requestHeaderSizeInBytes; |
| final long requestBodySizeInBytes; |
| if (wasCached) { |
| requestHeaderSizeInBytes = 0; |
| requestBodySizeInBytes = 0; |
| } else { |
| requestHeaderSizeInBytes = estimateHeadersSizeInBytes(mRequestHeaders); |
| // TODO(stefanoduo): Add logic to keep track of request body size. |
| requestBodySizeInBytes = -1; |
| } |
| |
| final long responseBodySizeInBytes; |
| final long responseHeaderSizeInBytes; |
| if (wasCached) { |
| responseHeaderSizeInBytes = 0; |
| responseBodySizeInBytes = 0; |
| } else { |
| responseHeaderSizeInBytes = estimateHeadersSizeInBytesList(responseHeaders); |
| // Content-Length is not mandatory, if missing report a non-valid response body size |
| // for the time being. |
| if (responseHeaders.containsKey("Content-Length")) { |
| responseBodySizeInBytes = |
| parseContentLengthString(responseHeaders.get("Content-Length").get(0)); |
| } else { |
| // TODO(stefanoduo): Add logic to keep track of response body size. |
| responseBodySizeInBytes = -1; |
| } |
| } |
| |
| final Duration headersLatency = Duration.ofSeconds(0); |
| final Duration totalLatency = Duration.ofSeconds(0); |
| |
| return new CronetTrafficInfo( |
| requestHeaderSizeInBytes, |
| requestBodySizeInBytes, |
| responseHeaderSizeInBytes, |
| responseBodySizeInBytes, |
| httpStatusCode, |
| headersLatency, |
| totalLatency, |
| negotiatedProtocol, |
| // There is no connection migration for the fallback implementation. |
| false, // wasConnectionMigrationAttempted |
| false // didConnectionMigrationSucceed |
| ); |
| } |
| |
| // Maybe report metrics. This method should only be called on Callback's executor thread and |
| // after Callback's onSucceeded, onFailed and onCanceled. |
| private void maybeReportMetrics() { |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
| try { |
| mLogger.logCronetTrafficInfo(mCronetEngineId, buildCronetTrafficInfo()); |
| } catch (RuntimeException e) { |
| // Handle any issue gracefully, we should never crash due failures while |
| // logging. |
| Log.e(TAG, "Error while trying to log CronetTrafficInfo: ", e); |
| } |
| } |
| } |
| |
| void onCanceled(final UrlResponseInfo info) { |
| closeResponseChannel(); |
| mUserExecutor.execute( |
| () -> { |
| try { |
| mCallback.onCanceled(JavaUrlRequest.this, info); |
| maybeReportMetrics(); |
| } catch (Exception exception) { |
| Log.e(TAG, "Exception in onCanceled method", exception); |
| } |
| mEngine.decrementActiveRequestCount(); |
| }); |
| } |
| |
| void onSucceeded(final UrlResponseInfo info) { |
| mUserExecutor.execute( |
| () -> { |
| try { |
| mCallback.onSucceeded(JavaUrlRequest.this, info); |
| maybeReportMetrics(); |
| } catch (Exception exception) { |
| Log.e(TAG, "Exception in onSucceeded method", exception); |
| } |
| mEngine.decrementActiveRequestCount(); |
| }); |
| } |
| |
| void onFailed(final UrlResponseInfo urlResponseInfo, final CronetException e) { |
| closeResponseChannel(); |
| Runnable runnable = |
| () -> { |
| try { |
| mCallback.onFailed(JavaUrlRequest.this, urlResponseInfo, e); |
| maybeReportMetrics(); |
| } catch (Exception exception) { |
| Log.e(TAG, "Exception in onFailed method", exception); |
| } |
| mEngine.decrementActiveRequestCount(); |
| }; |
| try { |
| mUserExecutor.execute(runnable); |
| } catch (InlineExecutionProhibitedException wasDirect) { |
| if (mFallbackExecutor != null) { |
| mFallbackExecutor.execute(runnable); |
| } |
| } |
| } |
| } |
| |
| private void closeResponseChannel() { |
| mExecutor.execute( |
| () -> { |
| if (mResponseChannel != null) { |
| try { |
| mResponseChannel.close(); |
| } catch (IOException e) { |
| e.printStackTrace(); |
| } |
| mResponseChannel = null; |
| } |
| }); |
| } |
| |
| private Network getNetworkFromHandle(long networkHandle) { |
| Network[] networks = |
| ((ConnectivityManager) |
| mEngine.getContext().getSystemService(Context.CONNECTIVITY_SERVICE)) |
| .getAllNetworks(); |
| |
| for (Network network : networks) { |
| if (network.getNetworkHandle() == networkHandle) return network; |
| } |
| |
| return null; |
| } |
| |
| @Override |
| public int getTrafficStatsUid() { |
| throw new IllegalStateException("Not Implemented"); |
| } |
| |
| @Override |
| public int getPriority() { |
| throw new IllegalStateException("Not Implemented"); |
| } |
| |
| @Override |
| public boolean hasTrafficStatsTag() { |
| throw new IllegalStateException("Not Implemented"); |
| } |
| |
| @Override |
| public boolean hasTrafficStatsUid() { |
| throw new IllegalStateException("Not Implemented"); |
| } |
| |
| @Override |
| public int getTrafficStatsTag() { |
| throw new IllegalStateException("Not Implemented"); |
| } |
| |
| @Override |
| public boolean isDirectExecutorAllowed() { |
| throw new IllegalStateException("Not Implemented"); |
| } |
| |
| @Override |
| public boolean isCacheDisabled() { |
| throw new IllegalStateException("Not Implemented"); |
| } |
| |
| @Override |
| public UrlResponseInfo.HeaderBlock getHeaders() { |
| throw new IllegalStateException("Not Implemented"); |
| } |
| |
| @Override |
| public String getHttpMethod() { |
| throw new IllegalStateException("Not Implemented"); |
| } |
| } |