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

import android.content.ComponentName;
import android.content.Context;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;

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

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.StrictModeContext;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.build.BuildConfig;

import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/** A browser-process class for querying SafeMode state and executing SafeModeActions. */
public class SafeModeController {
    public static final String SAFE_MODE_STATE_COMPONENT =
            "org.chromium.android_webview.SafeModeState";
    public static final String URI_AUTHORITY_SUFFIX = ".SafeModeContentProvider";
    public static final String SAFE_MODE_ACTIONS_URI_PATH = "/safe-mode-actions";
    public static final String ACTIONS_COLUMN = "actions";

    private static final String TAG = "WebViewSafeMode";

    private SafeModeAction[] mRegisteredActions;

    private SafeModeController() {}

    private static SafeModeController sInstanceForTests;

    private static class LazyHolder {
        static final SafeModeController INSTANCE = new SafeModeController();
    }

    // These values are persisted to logs. Entries should not be renumbered and
    // numeric values should never be reused.
    @IntDef({
        SafeModeExecutionResult.SUCCESS,
        SafeModeExecutionResult.UNKNOWN_ERROR,
        SafeModeExecutionResult.ACTION_FAILED,
        SafeModeExecutionResult.ACTION_UNKNOWN,
        SafeModeExecutionResult.COUNT
    })
    public static @interface SafeModeExecutionResult {
        int SUCCESS = 0;
        int UNKNOWN_ERROR = 1;
        int ACTION_FAILED = 2;
        int ACTION_UNKNOWN = 3;
        int COUNT = 3;
    }

    // These values are persisted to logs. Entries should not be renumbered and
    // numeric values should never be reused.
    @IntDef({
        SafeModeActionName.DELETE_VARIATIONS_SEED,
        SafeModeActionName.FAST_VARIATIONS_SEED,
        SafeModeActionName.NOOP,
        SafeModeActionName.DISABLE_ANDROID_AUTOFILL,
        SafeModeActionName.DISABLE_ORIGIN_TRIALS,
        SafeModeActionName.DISABLE_SAFE_BROWSING,
        SafeModeActionName.RESET_COMPONENT_UPDATER
    })
    private static @interface SafeModeActionName {
        int DELETE_VARIATIONS_SEED = 0;
        int FAST_VARIATIONS_SEED = 1;
        int NOOP = 2;
        int DISABLE_ANDROID_AUTOFILL = 3;
        // int DISABLE_CHROME_AUTOCOMPLETE = 4;  // Autofill replaced Autocomplete since Android O.
        int DISABLE_ORIGIN_TRIALS = 5;
        int DISABLE_SAFE_BROWSING = 6;
        int RESET_COMPONENT_UPDATER = 7;
        int COUNT = 8;
    }

    // Maps the SafeModeAction ID to its histogram enum
    @VisibleForTesting
    public static final Map<String, Integer> sSafeModeActionLoggingMap = createLoggingMap();

    private static Map<String, Integer> createLoggingMap() {
        Map<String, Integer> map = new HashMap<String, Integer>();
        map.put(
                SafeModeActionIds.DELETE_VARIATIONS_SEED,
                SafeModeActionName.DELETE_VARIATIONS_SEED);
        map.put(SafeModeActionIds.FAST_VARIATIONS_SEED, SafeModeActionName.FAST_VARIATIONS_SEED);
        map.put(SafeModeActionIds.NOOP, SafeModeActionName.NOOP);
        map.put(
                SafeModeActionIds.DISABLE_ANDROID_AUTOFILL,
                SafeModeActionName.DISABLE_ANDROID_AUTOFILL);
        map.put(SafeModeActionIds.DISABLE_ORIGIN_TRIALS, SafeModeActionName.DISABLE_ORIGIN_TRIALS);
        map.put(
                SafeModeActionIds.DISABLE_AW_SAFE_BROWSING,
                SafeModeActionName.DISABLE_SAFE_BROWSING);
        map.put(
                SafeModeActionIds.RESET_COMPONENT_UPDATER,
                SafeModeActionName.RESET_COMPONENT_UPDATER);
        return map;
    }

    /**
     * Sets the singleton instance for testing. Not thread safe, must only be called from single
     * threaded tests.
     * @param controller The SafeModeController object to return from getInstance(). Passing in a
     * null value resets this.
     */
    public static void setInstanceForTests(SafeModeController controller) {
        sInstanceForTests = controller;
        ResettersForTesting.register(() -> sInstanceForTests = null);
    }

    public static SafeModeController getInstance() {
        return sInstanceForTests == null ? LazyHolder.INSTANCE : sInstanceForTests;
    }

    /**
     * Registers a list of {@link SafeModeAction}s which can be executed. This must only be called
     * once (per-process) and each action in the list must have a unique ID.
     *
     * @throws IllegalStateException if actions have already been registered.
     * @throws IllegalArgumentException if there are any duplicates.
     */
    public void registerActions(@NonNull SafeModeAction[] actions) {
        if (mRegisteredActions != null) {
            throw new IllegalStateException("Already registered a list of actions in this process");
        }
        if (BuildConfig.ENABLE_ASSERTS) {
            // Verify we don't register any duplicate IDs. Only check this in debug builds to avoid
            // delaying startup.
            Set<String> allIds = new HashSet<>();
            for (SafeModeAction action : actions) {
                if (!allIds.add(action.getId())) {
                    throw new IllegalArgumentException("Received duplicate ID: " + action.getId());
                }
            }
        }
        mRegisteredActions = actions;
    }

    public void unregisterActionsForTesting() {
        mRegisteredActions = null;
    }

    /**
     * Queries SafeModeContentProvider for the set of actions which should be applied. Returns the
     * empty set if SafeMode is disabled. This should only be called from embedded WebView contexts.
     */
    public Set<String> queryActions(String webViewPackageName) {
        Set<String> actions = new HashSet<>();

        Uri uri =
                new Uri.Builder()
                        .scheme("content")
                        .authority(webViewPackageName + URI_AUTHORITY_SUFFIX)
                        .path(SAFE_MODE_ACTIONS_URI_PATH)
                        .build();

        final Context appContext = ContextUtils.getApplicationContext();
        try (Cursor cursor =
                appContext
                        .getContentResolver()
                        .query(
                                uri,
                                /* projection= */ null,
                                /* selection= */ null,
                                /* selectionArgs= */ null,
                                /* sortOrder= */ null)) {
            if (cursor == null || cursor.getCount() == 0) {
                Log.i(TAG, "ContentProvider doesn't support querying '" + uri + "'");
                return actions;
            }
            int actionIdColumnIndex = cursor.getColumnIndexOrThrow(ACTIONS_COLUMN);
            while (cursor.moveToNext()) {
                actions.add(cursor.getString(actionIdColumnIndex));
            }
        }

        Log.i(TAG, "Received SafeModeActions: %s", actions);
        return actions;
    }

    /**
     * Executes the given set of {@link SafeModeAction}s. Execution order is determined by the order
     * of the array registered by {@link registerActions}.
     *
     * @return {@code true} if <b>all</b> actions succeeded, {@code false} otherwise.
     * @throws IllegalStateException if this is called before {@link registerActions}.
     */
    public @SafeModeExecutionResult int executeActions(Set<String> actionsToExecute)
            throws Throwable {
        // Execute SafeModeActions in a deterministic order.
        if (mRegisteredActions == null) {
            throw new IllegalStateException(
                    "Must registerActions() before calling executeActions()");
        }

        String currentSafeModeActionName = "";
        try {
            @SafeModeExecutionResult int overallStatus = SafeModeExecutionResult.SUCCESS;
            Set<String> allIds = new HashSet<>();
            for (SafeModeAction action : mRegisteredActions) {
                allIds.add(action.getId());
                if (actionsToExecute.contains(action.getId())) {
                    currentSafeModeActionName = action.getId();
                    // Allow SafeModeActions in general to perform disk reads and writes.
                    try (StrictModeContext ignored = StrictModeContext.allowDiskWrites()) {
                        Log.i(TAG, "Starting to execute %s", currentSafeModeActionName);
                        if (action.execute()) {
                            Log.i(
                                    TAG,
                                    "Finished executing %s (%s)",
                                    currentSafeModeActionName,
                                    "success");
                        } else {
                            overallStatus = SafeModeExecutionResult.ACTION_FAILED;
                            Log.e(
                                    TAG,
                                    "Finished executing %s (%s)",
                                    currentSafeModeActionName,
                                    "failure");
                        }
                    }
                    logSafeModeActionName(currentSafeModeActionName);
                }
            }

            if (overallStatus != SafeModeExecutionResult.ACTION_FAILED) {
                for (String action : actionsToExecute) {
                    if (!allIds.contains(action)) {
                        overallStatus = SafeModeExecutionResult.ACTION_UNKNOWN;
                        break;
                    }
                }
            }
            logSafeModeExecutionResult(overallStatus);
            return overallStatus;
        } catch (Throwable t) {
            if (!"".equals(currentSafeModeActionName)) {
                // Logging this with the ExecutionResult will help correlate failures with a
                // specific SafeModeAction
                logSafeModeActionName(currentSafeModeActionName);
            }
            logSafeModeExecutionResult(SafeModeController.SafeModeExecutionResult.UNKNOWN_ERROR);
            throw t;
        }
    }

    /**
     *
     * @return A copy of the list of registered {@link SafeModeAction} actions.
     */
    public SafeModeAction[] getRegisteredActions() {
        if (mRegisteredActions == null) {
            return null;
        }
        return Arrays.copyOf(mRegisteredActions, mRegisteredActions.length);
    }

    private static void logSafeModeExecutionResult(@SafeModeExecutionResult int result) {
        RecordHistogram.recordEnumeratedHistogram(
                "Android.WebView.SafeMode.ExecutionResult", result, SafeModeExecutionResult.COUNT);
    }

    private static void logSafeModeActionName(String actionName) {
        if (sSafeModeActionLoggingMap.get(actionName) != null) {
            RecordHistogram.recordEnumeratedHistogram(
                    "Android.WebView.SafeMode.ActionName",
                    sSafeModeActionLoggingMap.get(actionName),
                    SafeModeExecutionResult.COUNT);
        }
    }

    /**
     * Quickly determine whether SafeMode is enabled. SafeMode is off-by-default.
     *
     * @param webViewPackageName the package name of the WebView implementation to query about
     *     SafeMode (generally this is the current WebView provider).
     */
    public boolean isSafeModeEnabled(String webViewPackageName) {
        final Context context = ContextUtils.getApplicationContext();
        ComponentName safeModeComponent =
                new ComponentName(webViewPackageName, SAFE_MODE_STATE_COMPONENT);
        int enabledState =
                context.getPackageManager().getComponentEnabledSetting(safeModeComponent);
        return enabledState == PackageManager.COMPONENT_ENABLED_STATE_ENABLED;
    }
}