chromium/chrome/browser/ui/android/hats/internal/java/src/org/chromium/chrome/browser/ui/hats/SurveyClientImpl.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.chrome.browser.ui.hats;

import android.app.Activity;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import org.chromium.base.Callback;
import org.chromium.base.CommandLine;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.task.AsyncTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.LifecycleObserver;
import org.chromium.chrome.browser.lifecycle.PauseResumeWithNativeObserver;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.components.user_prefs.UserPrefs;

import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.Map;

/** Impl for SurveyClient interface. */
// TODO(crbug.com/40250401): Add metrics and refine the logging in this class.
class SurveyClientImpl implements SurveyClient {
    private static final String TAG = "SurveyClient";

    /**
     * When set, bypass the AsyncTask to read throttler in the background, and ignore whether the
     * current activity is alive. Set for unit testing / native tests.
     */
    private static Boolean sForceShowSurveyForTesting;

    private final @NonNull SurveyConfig mConfig;
    private final @NonNull SurveyUiDelegate mUiDelegate;
    private final @NonNull SurveyController mController;
    private final @NonNull SurveyThrottler mThrottler;
    private final @NonNull ObservableSupplier<Boolean> mCrashUploadPermissionSupplier;
    private final @NonNull Map<String, String> mAggregatedSurveyPsd;
    private final @NonNull Profile mProfile;

    private WeakReference<Activity> mActivityRef;
    private @Nullable ActivityLifecycleDispatcher mLifecycleDispatcher;
    private @Nullable LifecycleObserver mLifecycleObserver;
    private @Nullable Callback<Boolean> mOnCrashUploadPermissionChangeCallback;
    private boolean mIsDestroyed;

    SurveyClientImpl(
            @NonNull SurveyConfig config,
            @NonNull SurveyUiDelegate uiDelegate,
            @NonNull SurveyController controller,
            @NonNull ObservableSupplier<Boolean> crashUploadPermissionSupplier,
            @NonNull Profile profile) {
        mConfig = config;
        mUiDelegate = uiDelegate;
        mController = controller;
        mCrashUploadPermissionSupplier = crashUploadPermissionSupplier;
        mThrottler = new SurveyThrottler(mConfig);
        mAggregatedSurveyPsd = new HashMap<>();
        mProfile = profile;
    }

    @Override
    public void showSurvey(Activity activity, ActivityLifecycleDispatcher lifecycleDispatcher) {
        showSurveyImpl(activity, lifecycleDispatcher, Map.of(), Map.of());
    }

    @Override
    public void showSurvey(
            Activity activity,
            ActivityLifecycleDispatcher lifecycleDispatcher,
            Map<String, Boolean> surveyPsdBitValues,
            Map<String, String> surveyPsdStringValues) {
        showSurveyImpl(activity, lifecycleDispatcher, surveyPsdStringValues, surveyPsdBitValues);
    }

    /** Kick off the survey presentation flow. */
    private void showSurveyImpl(
            Activity activity,
            ActivityLifecycleDispatcher lifecycleDispatcher,
            Map<String, String> surveyPsdStringValues,
            Map<String, Boolean> surveyPsdBitValues) {
        if (!configurationAllowsSurveys()) return;

        mActivityRef = new WeakReference<>(activity);
        mLifecycleDispatcher = lifecycleDispatcher;
        generateSurveyPsd(surveyPsdStringValues, surveyPsdBitValues);
        showSurveyIfEligible();
    }

    /** Generate the key-value pairs of survey PSD based on the config and given value fields. */
    private void generateSurveyPsd(
            Map<String, String> surveyPsdStringValues, Map<String, Boolean> surveyPsdBitValues) {
        assert surveyPsdStringValues.size() == mConfig.mPsdStringDataFields.length
                : "StringValues have a different size with fields.";
        assert surveyPsdBitValues.size() == mConfig.mPsdBitDataFields.length
                : "BitValues have a different size with fields.";

        for (var stringField : mConfig.mPsdStringDataFields) {
            assert surveyPsdStringValues.containsKey(stringField)
                    : "Undefined string fields: " + stringField;
            mAggregatedSurveyPsd.put(stringField, surveyPsdStringValues.get(stringField));
        }
        for (var bitField : mConfig.mPsdBitDataFields) {
            assert surveyPsdBitValues.get(bitField) != null : "Undefined bit fields: " + bitField;
            mAggregatedSurveyPsd.put(bitField, surveyPsdBitValues.get(bitField) ? "true" : "false");
        }
    }

    private void showSurveyIfEligible() {
        if (forceShowSurvey()) {
            startSurveyDownload(true);
            return;
        }
        AsyncTask<Boolean> throttlerTask =
                new AsyncTask<>() {
                    @Override
                    protected Boolean doInBackground() {
                        return mThrottler.canShowSurvey();
                    }

                    @Override
                    protected void onPostExecute(Boolean canShowSurvey) {
                        startSurveyDownload(canShowSurvey);
                    }
                };
        throttlerTask.executeWithTaskTraits(TaskTraits.BEST_EFFORT_MAY_BLOCK);
    }

    private void startSurveyDownload(boolean canShowSurvey) {
        if (!canShowSurvey) {
            Log.d(TAG, "Survey can't be shown");
            return;
        }
        mController.downloadSurvey(
                mActivityRef.get(),
                mConfig.mTriggerId,
                this::onSurveyDownloadSucceeded,
                this::onSurveyDownloadFailed);
    }

    private void onSurveyDownloadSucceeded() {
        Log.d(TAG, "Survey Download succeed.");
        if (!configurationAllowsSurveys()) return;

        // Dismiss the survey prompt if it is expired.
        if (mLifecycleDispatcher != null) {
            mLifecycleObserver =
                    new PauseResumeWithNativeObserver() {
                        @Override
                        public void onResumeWithNative() {
                            if (mController.isSurveyExpired(mConfig.mTriggerId)) {
                                mUiDelegate.dismiss();
                            }
                        }

                        @Override
                        public void onPauseWithNative() {}
                    };
            mLifecycleDispatcher.register(mLifecycleObserver);
        }

        // Dismiss the survey as soon as the crash upload permission changed.
        mOnCrashUploadPermissionChangeCallback =
                permitted -> {
                    if (!permitted) {
                        // TODO(crbug.com/40281825): Dismiss the on going survey if possible.
                        mUiDelegate.dismiss();
                    }
                };
        mCrashUploadPermissionSupplier.addObserver(mOnCrashUploadPermissionChangeCallback);

        mUiDelegate.showSurveyInvitation(
                this::onSurveyAccepted, this::onSurveyDeclined, this::onSurveyPresentationFailed);
    }

    private void onSurveyDownloadFailed() {
        Log.d(TAG, "Survey Download failed.");
        destroy(true);
    }

    private void onSurveyAccepted() {
        Log.d(TAG, "Survey accepted.");
        assert mActivityRef != null;
        if (!forceShowSurvey()
                && (mActivityRef.get() == null
                        || mActivityRef.get().isFinishing()
                        || mActivityRef.get().isDestroyed())) {
            destroy(false);
            return;
        }
        mThrottler.recordSurveyPromptDisplayed();
        mController.showSurveyIfAvailable(
                mActivityRef.get(),
                mConfig.mTriggerId,
                R.drawable.chrome_sync_logo,
                mLifecycleDispatcher,
                mAggregatedSurveyPsd);
        if (mLifecycleDispatcher != null && mLifecycleObserver != null) {
            mLifecycleDispatcher.unregister(mLifecycleObserver);
            mLifecycleObserver = null;
        }

        // Do not destroy the survey client at the end, since the controller still stored the survey
        // data.
    }

    private void onSurveyDeclined() {
        Log.d(TAG, "Survey declined.");
        mThrottler.recordSurveyPromptDisplayed();
        destroy(false);
    }

    private void onSurveyPresentationFailed() {
        Log.d(TAG, "Survey failed to present.");
        destroy(false);
    }

    /**
     * Destroy and clear the dependencies of this survey client. Note that if this call is invoked
     * bu SurveyUiDelegate, we should not call {@link SurveyUiDelegate#dismiss()} to avoid
     * recursively invoking #onSurveyPresentationFailed / #onSurveyDecliened.
     * @param dismissUiDelegate Whether we should call SurveyUiDelegate#dismiss()
     */
    private void destroy(boolean dismissUiDelegate) {
        assert !mIsDestroyed;
        mIsDestroyed = true;

        if (mActivityRef != null) {
            mActivityRef.clear();
            mActivityRef = null;
        }
        if (mLifecycleDispatcher != null && mLifecycleObserver != null) {
            mLifecycleDispatcher.unregister(mLifecycleObserver);
            mLifecycleObserver = null;
        }
        if (mOnCrashUploadPermissionChangeCallback != null) {
            mCrashUploadPermissionSupplier.removeObserver(mOnCrashUploadPermissionChangeCallback);
            mOnCrashUploadPermissionChangeCallback = null;
        }
        mLifecycleDispatcher = null;

        if (dismissUiDelegate) {
            mUiDelegate.dismiss();
        }
        mController.destroy();
    }

    /**
     * When metrics reporting is enabled (i.e. crash upload is allowed), the enterprise policy
     * `FeedbackSurveysEnabled` which defaults to true decides whether surveys can be shown. When
     * metrics reporting is disabled, surveys can never be shown.
     *
     * @return a boolean indicating whether the user's configuration allows a survey to be shown.
     */
    private boolean configurationAllowsSurveys() {
        if (forceShowSurvey()) return true;

        // Do not include any logging to avoid reveal the fact user has crash upload disabled.
        boolean isCrashUploadAllowed =
                mCrashUploadPermissionSupplier.hasValue() && mCrashUploadPermissionSupplier.get();
        boolean isHatsEnabledByPolicy =
                UserPrefs.get(mProfile).getBoolean(Pref.FEEDBACK_SURVEYS_ENABLED);
        return isCrashUploadAllowed && isHatsEnabledByPolicy;
    }

    SurveyController getControllerForTesting() {
        return mController;
    }

    boolean isDestroyed() {
        return mIsDestroyed;
    }

    static boolean forceShowSurvey() {
        if (CommandLine.getInstance().hasSwitch(ChromeSwitches.CHROME_FORCE_ENABLE_SURVEY)) {
            return true;
        }
        return sForceShowSurveyForTesting != null && sForceShowSurveyForTesting;
    }

    static void setForceShowSurveyForTesting(Boolean forcedResult) {
        sForceShowSurveyForTesting = forcedResult;
        ResettersForTesting.register(() -> sForceShowSurveyForTesting = null);
    }
}