chromium/android_webview/nonembedded/java/src/org/chromium/android_webview/nonembedded/NetworkFetcherTask.java

// Copyright 2021 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.android_webview.nonembedded;

import android.text.TextUtils;

import androidx.annotation.VisibleForTesting;

import org.jni_zero.CalledByNative;
import org.jni_zero.JNINamespace;
import org.jni_zero.NativeMethods;

import org.chromium.base.Log;
import org.chromium.url.GURL;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;

/**
 * This class  is responsible for doing network operations, and is to be used by WebView nonembedded
 * processes where Chromium's network service can't be used to do network operations.
 */
@JNINamespace("android_webview")
public class NetworkFetcherTask {
    private static final String TAG = "AWNetworkFetcherTask";
    private static final int REQUEST_TIMEOUT = 30000;
    private static final int READ_TIMEOUT_MS = 30000;
    private static final int BUFFER_SIZE_BYTES = 8192;

    private static final String CONTENTTYPE = "Content-Type";

    // The following two headers carry the ECSDA signature of the POST response,
    // if signing has been used. Two headers are used for redundancy purposes.
    // The value of the `X-Cup-Server-Proof` is preferred.
    private static final String ETAG = "ETag";
    private static final String XCUPSERVERPROOF = "X-Cup-Server-Proof";

    // The server uses the optional X-Retry-After header to indicate that the
    // current request should not be attempted again.
    //
    // The value of the header is the number of seconds to wait before trying to
    // do a subsequent update check. Only the values retrieved over HTTPS are
    // trusted.
    private static final String XRETRYAFTER = "X-Retry-After";

    @CalledByNative
    private static void download(
            long nativeDownloadFileTask, long mainTaskRunner, GURL url, String filePath) {
        downloadToFile(
                /* connection= */ null, nativeDownloadFileTask, mainTaskRunner, url, filePath);
    }

    /** Downloads from a given url to a file. */
    @VisibleForTesting
    public static void downloadToFile(
            HttpURLConnection connection,
            long nativeDownloadFileTask,
            long mainTaskRunner,
            GURL gurl,
            String filePath) {
        long bytesDownloaded = 0;
        try {
            if (connection == null) {
                URL url = new URL(gurl.getSpec());
                connection = (HttpURLConnection) url.openConnection();
            }

            File outputFile = new File(filePath);
            long contentLength = 0;
            String contentLengthString = connection.getHeaderField("Content-Length");
            if (contentLengthString != null && !contentLengthString.isEmpty()) {
                contentLength = Long.parseLong(contentLengthString);
            }

            NetworkFetcherTaskJni.get()
                    .callResponseStartedCallback(
                            nativeDownloadFileTask,
                            mainTaskRunner,
                            /* responseCode= */ connection.getResponseCode(),
                            contentLength);

            connection.setConnectTimeout(READ_TIMEOUT_MS);

            try (InputStream inputStream = connection.getInputStream();
                    OutputStream outputStream = new FileOutputStream(outputFile)) {
                byte[] buffer = new byte[BUFFER_SIZE_BYTES];
                int bytesCount;
                while ((bytesCount = inputStream.read(buffer)) > 0) {
                    outputStream.write(buffer, 0, bytesCount);
                    bytesDownloaded += bytesCount;
                    NetworkFetcherTaskJni.get()
                            .callProgressCallback(
                                    nativeDownloadFileTask, mainTaskRunner, bytesDownloaded);
                }
            }

            NetworkFetcherTaskJni.get()
                    .callDownloadToFileCompleteCallback(
                            nativeDownloadFileTask,
                            mainTaskRunner,
                            /* networkError= */ 0,
                            bytesDownloaded);
        } catch (IOException exception) {
            Log.w(TAG, "IOException during downloadToFile.", exception);

            // Notify task completion with a generic error.
            NetworkFetcherTaskJni.get()
                    .callDownloadToFileCompleteCallback(
                            nativeDownloadFileTask,
                            mainTaskRunner,
                            /* networkError= */ -2,
                            bytesDownloaded);
        } finally {
            if (connection != null) {
                connection.disconnect();
            }
        }
    }

    @CalledByNative
    private static void postRequest(
            long nativeNetworkFetcherTask,
            long mainTaskRunner,
            GURL url,
            byte[] postData,
            String contentType,
            String[] headerKeys,
            String[] headerValues) {
        postRequest(
                /* connection= */ null,
                nativeNetworkFetcherTask,
                mainTaskRunner,
                url,
                postData,
                contentType,
                headerKeys,
                headerValues);
    }

    private static String getHeaderFieldOrEmptyStringIfUnset(
            HttpURLConnection connection, String headerName) {
        String headerValueString = connection.getHeaderField(headerName);
        if (headerValueString != null) {
            return headerValueString;
        } else {
            return "";
        }
    }

    private static long getHeaderFieldAsLong(HttpURLConnection connection, String headerName) {
        String headerValueString = connection.getHeaderField(headerName);
        if (!TextUtils.isEmpty(headerValueString)) {
            return Long.parseLong(headerValueString);
        } else {
            return 0L;
        }
    }

    /** Posts a request to a URL. */
    @VisibleForTesting
    public static void postRequest(
            HttpURLConnection connection,
            long nativeNetworkFetcherTask,
            long mainTaskRunner,
            GURL gurl,
            byte[] postData,
            String contentType,
            String[] headerKeys,
            String[] headerValues) {
        String eTag = "";
        String xCupServerProof = "";
        byte[] responseBody = new byte[0];
        long bytesDownloaded = 0;
        long xRetryAfter = 0;

        try {
            if (connection == null) {
                URL url = new URL(gurl.getSpec());
                connection = (HttpURLConnection) url.openConnection();
            }

            connection.setConnectTimeout(REQUEST_TIMEOUT);
            connection.setDoOutput(true);
            connection.setRequestMethod("POST");
            connection.setRequestProperty(CONTENTTYPE, contentType);

            assert (headerKeys.length == headerValues.length);
            for (int i = 0; i < headerKeys.length; i++) {
                connection.setRequestProperty(headerKeys[i], headerValues[i]);
            }

            connection.setFixedLengthStreamingMode(postData.length);
            try (OutputStream outputStream = connection.getOutputStream()) {
                outputStream.write(postData);
            }

            long contentLength = getHeaderFieldAsLong(connection, "Content-Length");

            int responseCode = connection.getResponseCode();
            NetworkFetcherTaskJni.get()
                    .callResponseStartedCallback(
                            nativeNetworkFetcherTask, mainTaskRunner, responseCode, contentLength);

            eTag = getHeaderFieldOrEmptyStringIfUnset(connection, ETAG);
            xCupServerProof = getHeaderFieldOrEmptyStringIfUnset(connection, XCUPSERVERPROOF);
            xRetryAfter = getHeaderFieldAsLong(connection, XRETRYAFTER);

            if (responseCode != HttpURLConnection.HTTP_OK) {
                throw new IOException(
                        "response code is not HTTP_OK. Actual value: " + responseCode);
            }

            connection.setConnectTimeout(READ_TIMEOUT_MS);

            try (InputStream inputStream = connection.getInputStream();
                    ByteArrayOutputStream outBuffer = new ByteArrayOutputStream()) {
                byte[] buffer = new byte[BUFFER_SIZE_BYTES];
                int bytesCount;
                while ((bytesCount = inputStream.read(buffer)) > 0) {
                    bytesDownloaded += bytesCount;
                    outBuffer.write(buffer, /* offset= */ 0, bytesCount);
                    NetworkFetcherTaskJni.get()
                            .callProgressCallback(
                                    nativeNetworkFetcherTask, mainTaskRunner, bytesDownloaded);
                }
                responseBody = outBuffer.toByteArray();
            }

            NetworkFetcherTaskJni.get()
                    .callPostRequestCompleteCallback(
                            nativeNetworkFetcherTask,
                            mainTaskRunner,
                            responseBody,
                            /* networkError= */ 0,
                            eTag,
                            xCupServerProof,
                            xRetryAfter);

        } catch (IOException exception) {
            Log.w(TAG, "IOException during post request.", exception);
            NetworkFetcherTaskJni.get()
                    .callPostRequestCompleteCallback(
                            nativeNetworkFetcherTask,
                            mainTaskRunner,
                            responseBody,
                            /* networkError= */ -2,
                            eTag,
                            xCupServerProof,
                            xRetryAfter);
        } finally {
            if (connection != null) {
                connection.disconnect();
            }
        }
    }

    private NetworkFetcherTask() {
        // Private constructor to prevent the object from being created.
    }

    @NativeMethods
    @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
    public interface Natives {
        void callProgressCallback(long weakPtr, long taskRunner, long current);

        void callResponseStartedCallback(
                long weakPtr, long taskRunner, int responseCode, long contentLength);

        void callDownloadToFileCompleteCallback(
                long weakPtr, long taskRunner, int networkError, long contentSize);

        void callPostRequestCompleteCallback(
                long weakPtr,
                long taskRunner,
                byte[] responseBody,
                int networkError,
                String headerETag,
                String headerXCupServerProof,
                long xHeaderRetryAfterSec);
    } // interface Natives
}