chromium/android_webview/java/src/org/chromium/android_webview/variations/FastVariationsSeedSafeModeAction.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.android_webview.variations;

import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.os.ParcelFileDescriptor.AutoCloseInputStream;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import org.chromium.android_webview.AwBrowserProcess;
import org.chromium.android_webview.common.Lifetime;
import org.chromium.android_webview.common.SafeModeAction;
import org.chromium.android_webview.common.SafeModeActionIds;
import org.chromium.android_webview.common.VariationsFastFetchModeUtils;
import org.chromium.android_webview.common.variations.VariationsUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.variations.LoadSeedResult;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.TimeUnit;

/**
 * A {@link SafeModeAction} to ensure the variations seed is distributed on an app's first run.
 * This is the browser-process counterpart to {@link
 * org.chromium.android_webview.services.NonEmbeddedFastVariationsSeedSafeModeAction}.
 */
@Lifetime.Singleton
public class FastVariationsSeedSafeModeAction implements SafeModeAction {
    private static final String TAG = "FastVariationsSeed";
    // This ID should not be reused.
    private static final String ID = SafeModeActionIds.FAST_VARIATIONS_SEED;
    private final String mWebViewPackageName;
    private static boolean sHasRun;
    private static File sSeedFile = VariationsUtils.getSeedFile();

    @VisibleForTesting
    public FastVariationsSeedSafeModeAction(String webViewPackageName) {
        mWebViewPackageName = webViewPackageName;
    }

    public FastVariationsSeedSafeModeAction() {
        mWebViewPackageName = AwBrowserProcess.getWebViewPackageName();
    }

    @VisibleForTesting
    public static void setAlternateSeedFilePath(File seedFile) {
        sSeedFile = seedFile;
    }

    /**
     * Determine whether a Fast Variations mitigation action is enabled.
     * Determined when the safemode action runs or does not run.
     */
    public static boolean hasRun() {
        return sHasRun;
    }

    @Override
    @NonNull
    public String getId() {
        return ID;
    }

    @Override
    public boolean execute() {
        sHasRun = true;
        long currDateTime = new Date().getTime();
        SeedParser parser = new SeedParser();
        long stampTime = sSeedFile.lastModified();
        long ageInMillis = currDateTime - stampTime;

        if (sSeedFile.exists() && ageInMillis > 0) {
            logSeedFileAge(ageInMillis);
        }
        // If we see that the local seed file has not exceeded the
        // maximum seed age of 15 minutes, parse the local seed instead
        // of requesting a new one from the ContentProvider
        if ((currDateTime - stampTime <= VariationsFastFetchModeUtils.MAX_ALLOWABLE_SEED_AGE_MS)
                && stampTime > 0) {
            return parser.parseAndSaveSeedFile();
        }
        byte[] protoAsByteArray = getProtoFromServiceBlocking();
        if (protoAsByteArray != null
                && protoAsByteArray.length > 0
                && parser.parseSeedAsByteArray(protoAsByteArray)) {
            PostTask.postTask(TaskTraits.BEST_EFFORT, new SeedWriterTask(protoAsByteArray));
            return true;
        } else {
            Log.e(TAG, "Failed to fetch seed from ContentProvider.");
            return false;
        }
    }

    private void logSeedFileAge(long ageInMillis) {
        int seconds = (int) (ageInMillis / 1000) % 60;
        int minutes = (int) (ageInMillis / TimeUnit.MINUTES.toMillis(1)) % 60;
        int hrs = (int) (ageInMillis / TimeUnit.HOURS.toMillis(1));

        String formattedAge =
                String.format(Locale.US, "%02d:%02d:%02d (hh:mm:ss)", hrs, minutes, seconds);
        Log.i(TAG, "Seed file age - " + formattedAge);
    }

    /**
     * This class queries {@link SafeModeVariationsSeedContentProvider} for the
     * latest variations seed.
     *
     * @return Byte array representation of a variations seed
     */
    private byte[] getProtoFromServiceBlocking() {
        return new ContentProviderQuery(mWebViewPackageName)
                .querySafeModeVariationsSeedContentProvider();
    }

    // TODO(crbug.com/40259816): Update this to include timeout capability.
    private static class ContentProviderQuery {
        private static final String URI_SUFFIX = ".SafeModeVariationsSeedContentProvider";
        private static final String URI_PATH = VariationsFastFetchModeUtils.URI_PATH;
        private final String mWebViewPackageName;

        ContentProviderQuery(String webViewPackageName) {
            mWebViewPackageName = webViewPackageName;
        }

        public byte[] querySafeModeVariationsSeedContentProvider() {
            try {
                Uri uri =
                        new Uri.Builder()
                                .scheme(ContentResolver.SCHEME_CONTENT)
                                .authority(this.mWebViewPackageName + URI_SUFFIX)
                                .path(URI_PATH)
                                .build();
                final Context appContext = ContextUtils.getApplicationContext();
                try (ParcelFileDescriptor pfd =
                        appContext
                                .getContentResolver()
                                .openFileDescriptor(uri, /* mode= */ "r", null)) {
                    if (pfd == null) {
                        Log.e(TAG, "Failed to query SafeMode seed from: " + "'" + uri + "'");
                        return null;
                    }
                    return readProtoFromFile(pfd);
                }
            } catch (IOException e) {
                // Should not crash safe mode.
                // Simply log the message and return null here
                Log.w(TAG, e.toString());
                return null;
            }
        }

        private byte[] readProtoFromFile(ParcelFileDescriptor pfd) throws IOException {
            try (AutoCloseInputStream is = new AutoCloseInputStream(pfd)) {
                byte[] buffer = new byte[2048];
                ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
                int bytesRead;

                while ((bytesRead = is.read(buffer)) != -1) {
                    byteArrayOutputStream.write(buffer, 0, bytesRead);
                }

                return byteArrayOutputStream.toByteArray();
            }
        }
    }

    private class SeedParser {
        public boolean parseSeedAsByteArray(byte[] protoAsByteArray) {
            if (protoAsByteArray == null) {
                Log.w(TAG, "Seed String is empty");
                return false;
            }
            boolean success =
                    VariationsSeedLoader.parseAndSaveSeedProtoFromByteArray(protoAsByteArray);
            if (success) {
                Log.i(TAG, "Successfully parsed and loaded new seed!");
                recordLoadSeedResult(LoadSeedResult.SUCCESS);
                VariationsSeedLoader.maybeRecordSeedFileTime(sSeedFile.lastModified());
            } else {
                Log.i(TAG, "Failure parsing and loading seed!");
                recordLoadSeedResult(LoadSeedResult.LOAD_OTHER_FAILURE);
            }
            return success;
        }

        public boolean parseAndSaveSeedFile() {
            boolean success = VariationsSeedLoader.parseAndSaveSeedFile(sSeedFile);
            if (success) {
                Log.i(TAG, "Successfully parsed and loaded new seed!");
                recordLoadSeedResult(LoadSeedResult.SUCCESS);
                VariationsSeedLoader.maybeRecordSeedFileTime(sSeedFile.lastModified());
            } else {
                Log.i(TAG, "Seed fetch not successful.");
                recordLoadSeedResult(LoadSeedResult.LOAD_OTHER_FAILURE);
            }
            return success;
        }

        private void recordLoadSeedResult(@LoadSeedResult int result) {
            RecordHistogram.recordEnumeratedHistogram(
                    "Variations.SafeMode.LoadSafeSeed.Result",
                    result,
                    LoadSeedResult.MAX_VALUE + 1);
        }
    }

    private class SeedWriterTask implements Runnable {
        private byte[] mProtoAsByteArray;

        public SeedWriterTask(byte[] protoAsByteArray) {
            mProtoAsByteArray = protoAsByteArray;
        }

        @Override
        public void run() {
            if (writeToSeedFile()) {
                VariationsUtils.updateStampTime();
            }
        }

        private boolean writeToSeedFile() {
            String filePath = sSeedFile.getPath();
            try (FileOutputStream out = new FileOutputStream(filePath, false)) {
                out.write(mProtoAsByteArray);
                out.flush();
                return true;
            } catch (IOException e) {
                Log.e(TAG, "Failed writing seed file: " + e.getMessage());
                return false;
            }
        }
    }
}