chromium/components/cronet/android/java/src/org/chromium/net/impl/AndroidHttpEngineWrapper.java

// Copyright 2023 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 static org.chromium.net.impl.HttpEngineNativeProvider.EXT_API_LEVEL;
import static org.chromium.net.impl.HttpEngineNativeProvider.EXT_VERSION;

import android.net.Network;
import android.net.http.HttpEngine;
import android.os.Process;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresExtension;
import androidx.annotation.VisibleForTesting;

import org.chromium.net.BidirectionalStream;
import org.chromium.net.CronetEngine;
import org.chromium.net.RequestFinishedInfo;
import org.chromium.net.UploadDataProvider;
import org.chromium.net.UrlRequest;

import java.io.IOException;
import java.net.Proxy;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandlerFactory;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.concurrent.RejectedExecutionException;

@RequiresExtension(extension = EXT_API_LEVEL, version = EXT_VERSION)
class AndroidHttpEngineWrapper extends CronetEngineBase {
    private static final String TAG = "HttpEngineWrapper";

    private static boolean sNetlogUnsupportedLogged;
    private static boolean sGlobalMetricsUnsupportedLogged;

    private final HttpEngine mBackend;
    private final int mThreadPriority;
    // The thread that priority has been set on.
    private Thread mPriorityThread;
    private final Map<
                    RequestFinishedInfo.Listener, VersionSafeCallbacks.RequestFinishedInfoListener>
            mFinishedListenerMap = Collections.synchronizedMap(new HashMap<>());

    public AndroidHttpEngineWrapper(HttpEngine backend, int threadPriority) {
        mBackend = backend;
        mThreadPriority = threadPriority;
    }

    @Override
    public String getVersionString() {
        return HttpEngine.getVersionString();
    }

    @Override
    public void shutdown() {
        mBackend.shutdown();
    }

    @Override
    public void startNetLogToFile(String fileName, boolean logAll) {
        // TODO: Hidden API access
        if (!sNetlogUnsupportedLogged) {
            Log.i(TAG, "Netlog is unsupported when HttpEngineNativeProvider is used.");
            sNetlogUnsupportedLogged = true;
        }
    }

    @Override
    public void stopNetLog() {
        // TODO: Hidden API access
    }

    @Override
    public byte[] getGlobalMetricsDeltas() {
        // TODO: Hidden API access
        if (!sGlobalMetricsUnsupportedLogged) {
            Log.i(
                    TAG,
                    "GlobalMetricsDelta is unsupported when HttpEngineNativeProvider is used. An"
                            + " empty protobuf is returned.");
            sGlobalMetricsUnsupportedLogged = true;
        }
        return new byte[0];
    }

    @Override
    public void bindToNetwork(long networkHandle) {
        mBackend.bindToNetwork(getNetwork(networkHandle));
    }

    @Override
    public URLConnection openConnection(URL url) throws IOException {
        return CronetExceptionTranslationUtils.executeTranslatingCronetExceptions(
                () -> mBackend.openConnection(url), IOException.class);
    }

    @Override
    public URLConnection openConnection(URL url, Proxy proxy) throws IOException {
        // HttpEngine doesn't expose an openConnection(URL, Proxy) method. To maintain compatibility
        // copy-paste CronetUrlRequestContext's logic here.
        if (proxy.type() != Proxy.Type.DIRECT) {
            throw new UnsupportedOperationException();
        }
        String protocol = url.getProtocol();
        if ("http".equals(protocol) || "https".equals(protocol)) {
            return openConnection(url);
        }
        throw new UnsupportedOperationException("Unexpected protocol:" + protocol);
    }

    @Override
    public URLStreamHandlerFactory createURLStreamHandlerFactory() {
        return mBackend.createUrlStreamHandlerFactory();
    }

    @Override
    public org.chromium.net.ExperimentalBidirectionalStream.Builder newBidirectionalStreamBuilder(
            String url, org.chromium.net.BidirectionalStream.Callback callback, Executor executor) {
        return new BidirectionalStreamBuilderImpl(
                url, callback, maybeWrapWithPrioritySettingExecutor(executor), this);
    }

    @Override
    public org.chromium.net.ExperimentalUrlRequest.Builder newUrlRequestBuilder(
            String url, org.chromium.net.UrlRequest.Callback callback, Executor executor) {
        return super.newUrlRequestBuilder(
                url, callback, maybeWrapWithPrioritySettingExecutor(executor));
    }

    @Override
    public void addRequestFinishedListener(RequestFinishedInfo.Listener listener) {
        mFinishedListenerMap.put(
                listener, new VersionSafeCallbacks.RequestFinishedInfoListener(listener));
    }

    @Override
    public void removeRequestFinishedListener(RequestFinishedInfo.Listener listener) {
        mFinishedListenerMap.remove(listener);
    }

    void reportRequestFinished(
            RequestFinishedInfo requestInfo,
            VersionSafeCallbacks.RequestFinishedInfoListener extraRequestListener) {
        ArrayList<VersionSafeCallbacks.RequestFinishedInfoListener> currentListeners =
                new ArrayList<>();
        synchronized (mFinishedListenerMap) {
            currentListeners.addAll(mFinishedListenerMap.values());
        }
        if (extraRequestListener != null) {
            currentListeners.add(extraRequestListener);
        }
        for (final VersionSafeCallbacks.RequestFinishedInfoListener listener : currentListeners) {
            try {
                listener.getExecutor()
                        .execute(
                                () -> {
                                    try {
                                        listener.onRequestFinished(requestInfo);
                                    } catch (Exception e) {
                                        Log.e(TAG, "Exception thrown from observation task", e);
                                    }
                                });
            } catch (RejectedExecutionException failException) {
                Log.e(TAG, "Exception posting task to executor", failException);
            }
        }
    }

    @Override
    public org.chromium.net.ExperimentalBidirectionalStream createBidirectionalStream(
            String url,
            BidirectionalStream.Callback callback,
            Executor executor,
            String httpMethod,
            List<Entry<String, String>> requestHeaders,
            @StreamPriority int priority,
            boolean delayRequestHeadersUntilFirstFlush,
            Collection<Object> requestAnnotations,
            boolean trafficStatsTagSet,
            int trafficStatsTag,
            boolean trafficStatsUidSet,
            int trafficStatsUid,
            long networkHandle /* TODO(b/309112420): add to HttpEngine */) {
        AndroidBidirectionalStreamCallbackWrapper wrappedCallback =
                new AndroidBidirectionalStreamCallbackWrapper(callback);

        android.net.http.BidirectionalStream.Builder streamBuilder =
                mBackend.newBidirectionalStreamBuilder(url, executor, wrappedCallback);
        streamBuilder.setHttpMethod(httpMethod);
        for (Map.Entry<String, String> header : requestHeaders) {
            streamBuilder.addHeader(header.getKey(), header.getValue());
        }
        streamBuilder.setPriority(priority);
        streamBuilder.setDelayRequestHeadersUntilFirstFlushEnabled(
                delayRequestHeadersUntilFirstFlush);
        if (trafficStatsTagSet) {
            streamBuilder.setTrafficStatsTag(trafficStatsTag);
        }
        if (trafficStatsUidSet) {
            streamBuilder.setTrafficStatsUid(trafficStatsUid);
        }

        return AndroidBidirectionalStreamWrapper.createAndAddToCallback(
                streamBuilder.build(), wrappedCallback, this, url, requestAnnotations);
    }

    @Override
    public org.chromium.net.ExperimentalUrlRequest createRequest(
            String url,
            UrlRequest.Callback callback,
            Executor executor,
            @RequestPriority int priority,
            Collection<Object> requestAnnotations,
            boolean disableCache,
            boolean disableConnectionMigration /* not in HttpEngine */,
            boolean allowDirectExecutor,
            boolean trafficStatsTagSet,
            int trafficStatsTag,
            boolean trafficStatsUidSet,
            int trafficStatsUid,
            @Nullable RequestFinishedInfo.Listener requestFinishedListener,
            @Idempotency int idempotency /* not in HttpEngine */,
            long networkHandle,
            String method,
            ArrayList<Map.Entry<String, String>> requestHeaders,
            UploadDataProvider uploadDataProvider,
            Executor uploadDataProviderExecutor,
            byte[] dictionarySha256Hash,
            ByteBuffer sharedDictionary,
            @NonNull String sharedDictionaryId) {
        AndroidUrlRequestCallbackWrapper wrappedCallback =
                new AndroidUrlRequestCallbackWrapper(callback);
        android.net.http.UrlRequest.Builder requestBuilder =
                mBackend.newUrlRequestBuilder(url, executor, wrappedCallback);

        requestBuilder.setPriority(priority);
        requestBuilder.setCacheDisabled(disableCache);
        requestBuilder.setDirectExecutorAllowed(allowDirectExecutor);
        if (trafficStatsTagSet) {
            requestBuilder.setTrafficStatsTag(trafficStatsTag);
        }
        if (trafficStatsUidSet) {
            requestBuilder.setTrafficStatsTag(trafficStatsUid);
        }
        requestBuilder.bindToNetwork(getNetwork(networkHandle));
        requestBuilder.setHttpMethod(method);
        for (Map.Entry<String, String> header : requestHeaders) {
            requestBuilder.addHeader(header.getKey(), header.getValue());
        }
        if (uploadDataProvider != null) {
            requestBuilder.setUploadDataProvider(
                    new AndroidUploadDataProviderWrapper(uploadDataProvider),
                    uploadDataProviderExecutor);
        }

        return AndroidUrlRequestWrapper.createAndAddToCallback(
                requestBuilder.build(),
                wrappedCallback,
                this,
                url,
                requestAnnotations,
                requestFinishedListener);
    }

    private Network getNetwork(long networkHandle) {
        // Network#fromNetworkHandle throws IAE if networkHandle does not translate to a valid
        // Network. Though, this can only happen if we're given a fake networkHandle (in which case
        // we will throw, which is fine).
        return networkHandle == CronetEngine.UNBIND_NETWORK_HANDLE
                ? null
                : Network.fromNetworkHandle(networkHandle);
    }

    /**
     * Wrap executor if user set the thread priority via {@link
     * CronetEngine.Builder#setThreadPriority(int)}. All requests/streams in an engine are either
     * wrapped or not wrapped depending on if thread priority is set.
     */
    private Executor maybeWrapWithPrioritySettingExecutor(Executor executor) {
        return mThreadPriority == Integer.MIN_VALUE
                ? executor
                : new PrioritySettingExecutor(executor);
    }

    /**
     * Set the thread priority if it has not been set before.
     *
     * @return True iff the thread priority was set.
     */
    @VisibleForTesting
    boolean setThreadPriority() {
        // Double-check that we always get called from the same thread. If this assertion fails,
        // it means we were called from a thread that is not the Cronet internal thread, which
        // is a problem because it means we could end up changing the priority of some random
        // thread we don't own.
        assert mPriorityThread == null || mPriorityThread == Thread.currentThread();
        if (mPriorityThread != null) {
            return false;
        }
        Process.setThreadPriority(mThreadPriority);
        mPriorityThread = Thread.currentThread();
        return true;
    }

    /**
     * HttpEngine does not support {@link CronetEngine.Builder#setThreadPriority). To preserve
     * compatibility with Cronet users who use this method, we reimplement the functionality using a
     * workaround where we set the priority of the first thread to call execute() which is the
     * network thread for Cronet.
     */
    private class PrioritySettingExecutor implements Executor {
        private final Executor mExecutor;

        public PrioritySettingExecutor(Executor executor) {
            mExecutor = Objects.requireNonNull(executor, "Executor is required.");
        }

        @Override
        public void execute(Runnable command) {
            setThreadPriority();
            mExecutor.execute(command);
        }
    }
}