chromium/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/IncognitoReauthPromoMessageService.java

// Copyright 2022 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.tasks.tab_management;

import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.INCOGNITO_REAUTH_PROMO_CARD_ENABLED;
import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.INCOGNITO_REAUTH_PROMO_SHOW_COUNT;

import android.content.Context;
import android.os.Build;

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

import org.chromium.base.ResettersForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.chrome.browser.incognito.reauth.IncognitoReauthManager;
import org.chromium.chrome.browser.incognito.reauth.IncognitoReauthSettingUtils;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.PauseResumeWithNativeObserver;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.ui.messages.snackbar.Snackbar;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.chrome.tab_ui.R;
import org.chromium.components.user_prefs.UserPrefs;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/** Message service class to show the Incognito re-auth promo inside the incognito tab switcher. */
public class IncognitoReauthPromoMessageService extends MessageService
        implements PauseResumeWithNativeObserver {
    /** TODO(crbug.com/40056462): Remove this when we support all the Android versions. */
    public static Boolean sIsPromoEnabledForTesting;

    /**
     * For instrumentation tests, we don't have the supported infrastructure to perform native
     * re-authentication. Therefore, setting this variable would skip the re-auth triggering and
     * simply call the next set of actions which would have been call, if the re-auth was indeed
     * successful.
     */
    private static Boolean sTriggerReviewActionWithoutReauthForTesting;

    @VisibleForTesting public final int mMaxPromoMessageCount = 10;

    /** The re-auth manager that is used to trigger the re-authentication. */
    private final @NonNull IncognitoReauthManager mIncognitoReauthManager;

    /** This is the data type that this MessageService is serving to its Observer. */
    class IncognitoReauthMessageData implements MessageData {
        private final MessageCardView.ReviewActionProvider mReviewActionProvider;
        private final MessageCardView.DismissActionProvider mDismissActionProvider;

        IncognitoReauthMessageData(
                @NonNull MessageCardView.ReviewActionProvider reviewActionProvider,
                @NonNull MessageCardView.DismissActionProvider dismissActionProvider) {
            mReviewActionProvider = reviewActionProvider;
            mDismissActionProvider = dismissActionProvider;
        }

        MessageCardView.ReviewActionProvider getReviewActionProvider() {
            return mReviewActionProvider;
        }

        MessageCardView.DismissActionProvider getDismissActionProvider() {
            return mDismissActionProvider;
        }
    }

    private final @NonNull Profile mProfile;
    private final @NonNull Context mContext;
    private final @NonNull SharedPreferencesManager mSharedPreferencesManager;
    private final @NonNull SnackbarManager mSnackBarManager;
    private final @NonNull ActivityLifecycleDispatcher mActivityLifecycleDispatcher;

    /**
     * A boolean to indicate when we had temporarily invalidated the promo card due to change of
     * Android level settings, which is needed to show the promo card. This is set to true in
     * such cases, where we need to re-prepare the message, in order for it to be shown again when
     * the Android level settings are on again. See #onResumeWithNative method.
     */
    private boolean mShouldTriggerPrepareMessage;

    /**
     * Represents the action type on the re-auth promo card.
     * DO NOT reorder items in this interface, because it's mirrored to UMA
     * (as IncognitoReauthPromoActionType).
     */
    @IntDef({
        IncognitoReauthPromoActionType.PROMO_ACCEPTED,
        IncognitoReauthPromoActionType.NO_THANKS,
        IncognitoReauthPromoActionType.PROMO_EXPIRED,
        IncognitoReauthPromoActionType.NUM_ENTRIES
    })
    @Retention(RetentionPolicy.SOURCE)
    @interface IncognitoReauthPromoActionType {
        int PROMO_ACCEPTED = 0;
        int NO_THANKS = 1;
        int PROMO_EXPIRED = 2;
        int NUM_ENTRIES = 3;
    }

    /**
     * @param mMessageType The type of the message.
     * @param profile {@link Profile} to use to check the re-auth status.
     * @param sharedPreferencesManager The {@link SharedPreferencesManager} to query about re-auth
     *     promo shared preference.
     * @param incognitoReauthManager The {@link IncognitoReauthManager} to trigger re-auth for the
     *     review action. This class takes ownership of the {@link IncognitoReauthManager} object
     *     and is responsible for its cleanup, see `destroy` method.
     * @param snackbarManager {@link SnackbarManager} to show a snack-bar after a successful review
     * @param activityLifecycleDispatcher The {@link ActivityLifecycleDispatcher} dispacther to
     *     register listening to onResume events.
     */
    IncognitoReauthPromoMessageService(
            int mMessageType,
            @NonNull Profile profile,
            @NonNull Context context,
            @NonNull SharedPreferencesManager sharedPreferencesManager,
            @NonNull IncognitoReauthManager incognitoReauthManager,
            @NonNull SnackbarManager snackbarManager,
            @NonNull ActivityLifecycleDispatcher activityLifecycleDispatcher) {
        super(mMessageType);
        mProfile = profile;
        mContext = context;
        mSharedPreferencesManager = sharedPreferencesManager;
        mIncognitoReauthManager = incognitoReauthManager;
        mSnackBarManager = snackbarManager;
        mActivityLifecycleDispatcher = activityLifecycleDispatcher;
        activityLifecycleDispatcher.register(this);
    }

    void destroy() {
        mIncognitoReauthManager.destroy();
        // Duplicate unregister is safe if dismiss() was invoked.
        mActivityLifecycleDispatcher.unregister(this);
    }

    @VisibleForTesting
    void dismiss() {
        sendInvalidNotification();
        disableIncognitoReauthPromoMessage();
        recordPromoImpressionsCount();

        // Once dismissed, we will never show the re-auth promo card again, so there's no need
        // to keep tracking the lifecycle events.
        mActivityLifecycleDispatcher.unregister(this);
    }

    void increasePromoShowCountAndMayDisableIfCountExceeds() {
        if (getPromoShowCount() > mMaxPromoMessageCount) {
            dismiss();

            RecordHistogram.recordEnumeratedHistogram(
                    "Android.IncognitoReauth.PromoAcceptedOrDismissed",
                    IncognitoReauthPromoActionType.PROMO_EXPIRED,
                    IncognitoReauthPromoActionType.NUM_ENTRIES);

            return;
        }

        increasePomoImpressionCount();
    }

    private void increasePomoImpressionCount() {
        mSharedPreferencesManager.writeInt(
                INCOGNITO_REAUTH_PROMO_SHOW_COUNT,
                mSharedPreferencesManager.readInt(INCOGNITO_REAUTH_PROMO_SHOW_COUNT, 0) + 1);
    }

    int getPromoShowCount() {
        return mSharedPreferencesManager.readInt(INCOGNITO_REAUTH_PROMO_SHOW_COUNT, 0);
    }

    /**
     * Prepares a re-auth promo message notifying a new message is available.
     *
     * @return A boolean indicating if the promo message was successfully prepared or not.
     */
    @VisibleForTesting
    boolean preparePromoMessage() {
        if (!isIncognitoReauthPromoMessageEnabled(mProfile)) return false;

        if (getPromoShowCount() >= mMaxPromoMessageCount) {
            dismiss();
            return false;
        }

        sendAvailabilityNotification(
                new IncognitoReauthMessageData(this::review, (int messageType) -> dismiss()));
        return true;
    }

    @Override
    public void addObserver(MessageObserver observer) {
        super.addObserver(observer);
        preparePromoMessage();
    }

    void prepareSnackBarAndShow() {
        Snackbar snackbar =
                Snackbar.make(
                        mContext.getString(R.string.incognito_reauth_snackbar_text),
                        /* controller= */ null,
                        Snackbar.TYPE_NOTIFICATION,
                        Snackbar.UMA_INCOGNITO_REAUTH_ENABLED_FROM_PROMO);
        // TODO(crbug.com/40056462):  Confirm with UX to see how the background color of the
        // snackbar needs to be revised.
        snackbar.setBackgroundColor(
                mContext.getColor(R.color.snackbar_background_color_baseline_dark));
        snackbar.setTextAppearance(R.style.TextAppearance_TextMedium_Secondary_Baseline_Light);
        snackbar.setSingleLine(false);
        mSnackBarManager.showSnackbar(snackbar);
    }

    /**
     * A method to dismiss the re-auth promo, if the #isIncognitoReauthPromoMessageEnabled returns
     * false. This ensures any state change that may occur which results in the promo not being
     * enabled are accounted for when the users resumes back to ChromeTabbedActivity.
     */
    @Override
    public void onResumeWithNative() {
        updatePromoCardDismissalStatusIfNeeded();
    }

    @Override
    public void onPauseWithNative() {}

    /** Provides the functionality to the {@link MessageCardView.ReviewActionProvider} */
    public void review() {
        // Add a safety net in-case for potential multi window flows.
        if (!isIncognitoReauthPromoMessageEnabled(mProfile)) {
            updatePromoCardDismissalStatusIfNeeded();
            return;
        }

        // Do the core review action without triggering a re-authentication for testing only.
        if (sTriggerReviewActionWithoutReauthForTesting != null
                && sTriggerReviewActionWithoutReauthForTesting) {
            onAfterReviewActionSuccessful();
            return;
        }

        mIncognitoReauthManager.startReauthenticationFlow(
                new IncognitoReauthManager.IncognitoReauthCallback() {
                    @Override
                    public void onIncognitoReauthNotPossible() {}

                    @Override
                    public void onIncognitoReauthSuccess() {
                        onAfterReviewActionSuccessful();
                    }

                    @Override
                    public void onIncognitoReauthFailure() {}
                });
    }

    /**
     * A method to indicate whether the Incognito re-auth promo is enabled or not.
     *
     * @param profile {@link Profile} to use to check the re-auth status.
     * @return True, if the incognito re-auth promo message is enabled, false otherwise.
     */
    public boolean isIncognitoReauthPromoMessageEnabled(Profile profile) {
        // To support lower android versions where we support running the render tests.
        if (sIsPromoEnabledForTesting != null) return sIsPromoEnabledForTesting;

        // The Chrome level Incognito lock setting is already enabled, so no use to show a promo for
        // that.
        if (IncognitoReauthManager.isIncognitoReauthEnabled(profile)) return false;
        // The Incognito re-auth feature is not enabled, so don't show the promo.
        if (!IncognitoReauthManager.isIncognitoReauthFeatureAvailable()) return false;
        // The promo relies on turning on the Incognito lock setting on user's behalf but after a
        // device level authentication, which must be setup beforehand.
        // TODO(crbug.com/40056462): Remove the check on the API once all Android version is
        // supported.
        if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
                || !IncognitoReauthSettingUtils.isDeviceScreenLockEnabled()) {
            return false;
        }

        return mSharedPreferencesManager.readBoolean(INCOGNITO_REAUTH_PROMO_CARD_ENABLED, true);
    }

    public static void setTriggerReviewActionWithoutReauthForTesting(boolean enabled) {
        sTriggerReviewActionWithoutReauthForTesting = enabled;
        ResettersForTesting.register(() -> sTriggerReviewActionWithoutReauthForTesting = null);
    }

    public static void setIsPromoEnabledForTesting(@Nullable Boolean enabled) {
        sIsPromoEnabledForTesting = enabled;
        ResettersForTesting.register(() -> sIsPromoEnabledForTesting = null);
    }

    private void disableIncognitoReauthPromoMessage() {
        mSharedPreferencesManager.writeBoolean(INCOGNITO_REAUTH_PROMO_CARD_ENABLED, false);
    }

    /**
     * A method that dismisses the promo card and *conditionally* disables it if the conditions
     * which were met before to show a promo card is not true any more.
     *
     * <p>For the case when it only dismisses the card but doesn't disable it, it would prepare the
     * message again once it detects the promo card can now be enabled.
     *
     * <p>TODO(crbug.com/40056462): This method can dismiss the promo card abruptly w/o stating any
     * user-visible reasoning. This needs to be revisited with UX to see how best can we provide
     * user education in such scenarios.
     */
    private void updatePromoCardDismissalStatusIfNeeded() {
        if (!isIncognitoReauthPromoMessageEnabled(mProfile)) {
            // Here, if the user has enabled the Chrome level setting directly then we should
            // dismiss the promo completely.
            if (IncognitoReauthManager.isIncognitoReauthEnabled(mProfile)) {
                // This call also unregisters this lifecycle observer.
                dismiss();
            } else {
                // For all other cases, we only send an invalidate message but don't disable the
                // promo card completely.
                sendInvalidNotification();
                mShouldTriggerPrepareMessage = true;
            }
        } else {
            // The conditions are suitable to show a promo card again but only if we had
            // invalidated the message in the past.
            if (mShouldTriggerPrepareMessage) {
                preparePromoMessage();
            }
        }
    }

    /**
     * A method which gets fired when the re-authentication was successful after the review action.
     */
    private void onAfterReviewActionSuccessful() {
        UserPrefs.get(mProfile).setBoolean(Pref.INCOGNITO_REAUTHENTICATION_FOR_ANDROID, true);
        RecordHistogram.recordEnumeratedHistogram(
                "Android.IncognitoReauth.PromoAcceptedOrDismissed",
                IncognitoReauthPromoActionType.PROMO_ACCEPTED,
                IncognitoReauthPromoActionType.NUM_ENTRIES);

        dismiss();
        prepareSnackBarAndShow();
    }

    private void recordPromoImpressionsCount() {
        RecordHistogram.recordExactLinearHistogram(
                "Android.IncognitoReauth.PromoImpressionAfterActionCount",
                getPromoShowCount(),
                mMaxPromoMessageCount);
    }
}