chromium/chrome/android/java/src/org/chromium/chrome/browser/firstrun/FirstRunAppRestrictionInfo.java

// Copyright 2020 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.chrome.browser.firstrun;

import android.content.Context;
import android.os.Bundle;
import android.os.SystemClock;
import android.os.UserManager;

import org.chromium.base.Callback;
import org.chromium.base.CommandLine;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.ThreadUtils;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.policy.AbstractAppRestrictionsProvider;
import org.chromium.components.policy.AppRestrictionsProvider;
import org.chromium.components.policy.PolicySwitches;

import java.util.LinkedList;
import java.util.Locale;
import java.util.Queue;
import java.util.concurrent.RejectedExecutionException;

/**
 * A helper class used to check if app restrictions are present during first run. Internally it
 * uses an asynchronous background task to fetch restrictions, then notifies registered callbacks
 * once complete.
 *
 * In order to get a result as soon as possible, this class provides a mechanism to kick off the
 * async via {@link #startInitializationHint()}. This instance will be stored in a private static
 * field, and will be returned by {@link #takeMaybeInitialized}, as well as nulling out the private
 * static. This mechanism doesn't strictly need to be used, and {@link #startInitializationHint()}
 * can be ignored at the cost of latency.
 *
 * This class is only used during first run flow, so its lifecycle ends when first run completes. At
 * which point {@link #destroy()} should be called.
 */
class FirstRunAppRestrictionInfo {
    private static final String TAG = "FRAppRestrictionInfo";

    private static FirstRunAppRestrictionInfo sInitializedInstance;

    private boolean mInitialized;
    private boolean mHasAppRestriction;
    private long mCompletionElapsedRealtimeMs;
    private Queue<Callback<Boolean>> mCallbacks = new LinkedList<>();
    private Queue<Callback<Long>> mCompletionTimeCallbacks = new LinkedList<>();

    private AsyncTask<Boolean> mFetchAppRestrictionAsyncTask;

    private FirstRunAppRestrictionInfo() {
        initialize();
    }

    /**
     * Starts initialization and stores an instance of {@link FirstRunAppRestrictionInfo} in a
     * static field. This will be waiting for the first caller of
     * {@link FirstRunAppRestrictionInfo#takeMaybeInitialized()}.
     */
    public static void startInitializationHint() {
        if (sInitializedInstance == null) {
            sInitializedInstance = new FirstRunAppRestrictionInfo();
        }
    }

    /**
     * Tries to transfer ownership of the previously instantiated static instance if possible. When
     * there is no such instance, this will simply return a new {@link FirstRunAppRestrictionInfo}.
     * Either way, an async check for app restrictions will have been started before this method
     * returns. Call {@link #getHasAppRestriction(Callback)} to be notified when the check
     * completes.
     */
    public static FirstRunAppRestrictionInfo takeMaybeInitialized() {
        ThreadUtils.assertOnUiThread();
        FirstRunAppRestrictionInfo info;
        if (sInitializedInstance == null) {
            info = new FirstRunAppRestrictionInfo();
        } else {
            info = sInitializedInstance;
            sInitializedInstance = null;
        }
        return info;
    }

    /** Stops the async initialization if it is in progress, and remove all the callbacks. */
    public void destroy() {
        if (mFetchAppRestrictionAsyncTask != null) {
            mFetchAppRestrictionAsyncTask.cancel(true);
        }
        mCallbacks.clear();
        mCompletionTimeCallbacks.clear();
    }

    /**
     * Register a callback whether app restriction is found on device. If app restrictions have
     * already been fetched, the callback will be invoked immediately.
     *
     * @param callback Callback to run with whether app restriction is found on device.
     */
    public void getHasAppRestriction(Callback<Boolean> callback) {
        ThreadUtils.assertOnUiThread();

        if (mInitialized) {
            callback.onResult(mHasAppRestriction);
        } else {
            mCallbacks.add(callback);
        }
    }

    /**
     * Registers a callback for the timestamp from {@link SystemClock#elapsedRealtime} when the app
     * restrictions call finished. If the restrictions have already been fetched, the callback will
     * be invoked immediately.
     *
     * @param callback Callback to run with the timestamp of completing the fetch.
     */
    public void getCompletionElapsedRealtimeMs(Callback<Long> callback) {
        ThreadUtils.assertOnUiThread();
        if (mInitialized) {
            callback.onResult(mCompletionElapsedRealtimeMs);
        } else {
            mCompletionTimeCallbacks.add(callback);
        }
    }

    /** Start fetching app restriction on an async thread. */
    private void initialize() {
        ThreadUtils.assertOnUiThread();
        long startTime = SystemClock.elapsedRealtime();

        // This is an imperfect system, and can sometimes return true when there will not actually
        // be any app restrictions. But we do not have parsing logic in Java to understand if the
        // switch sets valid policies.
        if (CommandLine.getInstance().hasSwitch(PolicySwitches.CHROME_POLICY)) {
            onRestrictionDetected(true, startTime);
            return;
        }

        if (AbstractAppRestrictionsProvider.hasTestRestrictions()) {
            onRestrictionDetected(true, startTime);
            return;
        }

        Context appContext = ContextUtils.getApplicationContext();
        try {
            mFetchAppRestrictionAsyncTask =
                    new AsyncTask<Boolean>() {
                        @Override
                        protected Boolean doInBackground() {
                            UserManager userManager =
                                    (UserManager) appContext.getSystemService(Context.USER_SERVICE);
                            Bundle bundle =
                                    AppRestrictionsProvider
                                            .getApplicationRestrictionsFromUserManager(
                                                    userManager, appContext.getPackageName());
                            return bundle != null && !bundle.isEmpty();
                        }

                        @Override
                        protected void onPostExecute(Boolean isAppRestricted) {
                            onRestrictionDetected(isAppRestricted, startTime);
                        }
                    };
            mFetchAppRestrictionAsyncTask.executeWithTaskTraits(TaskTraits.USER_BLOCKING_MAY_BLOCK);
        } catch (RejectedExecutionException e) {
            // Though unlikely, if the task is rejected, we assume no restriction exists.
            onRestrictionDetected(false, startTime);
        }
    }

    private void onRestrictionDetected(boolean isAppRestricted, long startTime) {
        mHasAppRestriction = isAppRestricted;
        mInitialized = true;

        // Only record histogram when startTime is valid.
        if (startTime > 0) {
            mCompletionElapsedRealtimeMs = SystemClock.elapsedRealtime();
            long runTime = mCompletionElapsedRealtimeMs - startTime;
            Log.i(
                    TAG,
                    String.format(
                            Locale.US,
                            "Policy received. Runtime: [%d], result: [%s]",
                            runTime,
                            isAppRestricted));
        }

        while (!mCallbacks.isEmpty()) {
            mCallbacks.remove().onResult(mHasAppRestriction);
        }
        while (!mCompletionTimeCallbacks.isEmpty()) {
            mCompletionTimeCallbacks.remove().onResult(mCompletionElapsedRealtimeMs);
        }
    }

    static void setInitializedInstanceForTest(
            FirstRunAppRestrictionInfo firstRunAppRestrictionInfo) {
        var oldValue = sInitializedInstance;
        sInitializedInstance = firstRunAppRestrictionInfo;
        ResettersForTesting.register(() -> sInitializedInstance = oldValue);
    }
}