chromium/chrome/android/java/src/org/chromium/chrome/browser/sync/ui/SyncErrorMessage.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.chrome.browser.sync.ui;

import static org.chromium.base.ContextUtils.getApplicationContext;

import android.app.Activity;
import android.content.Context;
import android.content.res.Resources;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.IntentUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.TraceEvent;
import org.chromium.base.UnownedUserData;
import org.chromium.base.UnownedUserDataHost;
import org.chromium.base.UnownedUserDataKey;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.settings.SettingsLauncherFactory;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.chrome.browser.sync.SyncServiceFactory;
import org.chromium.chrome.browser.sync.TrustedVaultClient;
import org.chromium.chrome.browser.sync.settings.AccountManagementFragment;
import org.chromium.chrome.browser.sync.settings.ManageSyncSettings;
import org.chromium.chrome.browser.sync.settings.SyncSettingsUtils;
import org.chromium.chrome.browser.sync.settings.SyncSettingsUtils.ErrorUiAction;
import org.chromium.chrome.browser.sync.settings.SyncSettingsUtils.SyncError;
import org.chromium.components.browser_ui.settings.SettingsLauncher;
import org.chromium.components.messages.DismissReason;
import org.chromium.components.messages.MessageBannerProperties;
import org.chromium.components.messages.MessageDispatcher;
import org.chromium.components.messages.MessageDispatcherProvider;
import org.chromium.components.messages.MessageIdentifier;
import org.chromium.components.messages.PrimaryActionClickBehavior;
import org.chromium.components.signin.AccountManagerFacadeProvider;
import org.chromium.components.signin.GAIAServiceType;
import org.chromium.components.signin.base.CoreAccountInfo;
import org.chromium.components.signin.identitymanager.ConsentLevel;
import org.chromium.components.signin.identitymanager.IdentityManager;
import org.chromium.components.sync.SyncService;
import org.chromium.components.sync.TrustedVaultUserActionTriggerForUMA;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.modelutil.PropertyModel;

/**
 * A message UI that informs the current sync error and contains a button to take action to resolve
 * it. This class is tied to a window and at most one instance per window can exist at a time. In
 * practice however, because the time limit imposed between 2 displays is global, only one instance
 * in the whole application will exist at a time.
 */
public class SyncErrorMessage implements SyncService.SyncStateChangedListener, UnownedUserData {
    // Note: Not all SyncErrors have a corresponding SyncErrorMessage, see getError().
    private final @SyncError int mError;
    private final Activity mActivity;
    private final Profile mProfile;
    private final IdentityManager mIdentityManager;
    private final SyncService mSyncService;
    private final MessageDispatcher mMessageDispatcher;
    private final PropertyModel mModel;
    private static MessageDispatcher sMessageDispatcherForTesting;

    private static final UnownedUserDataKey<SyncErrorMessage> SYNC_ERROR_MESSAGE_KEY =
            new UnownedUserDataKey<>(SyncErrorMessage.class);
    private static final String TAG = "SyncErrorMessage";

    /**
     * Creates a {@link SyncErrorMessage} in the window of |dispatcher|, or results in a no-op if
     * preconditions are not satisfied. The conditions are:
     *
     * <p>a) there is an ongoing sync error and it belongs to the subset defined by {@link
     * MessageType}.
     *
     * <p>b) a minimal time interval has passed since the UI was last shown.
     *
     * <p>c) there is no other instance of the UI being shown on this window.
     *
     * <p>d) there is a valid {@link MessageDispatcher} in this window.
     *
     * @param windowAndroid The {@link WindowAndroid} to show and dismiss message UIs.
     * @param profile The {@link Profile}.
     */
    public static void maybeShowMessageUi(WindowAndroid windowAndroid, Profile profile) {
        try (TraceEvent t = TraceEvent.scoped("SyncErrorMessage.maybeShowMessageUi")) {
            if (getError(profile) == SyncError.NO_ERROR) {
                return;
            }

            if (!SyncErrorMessageImpressionTracker.canShowNow(UserPrefs.get(profile))) {
                return;
            }

            MessageDispatcher dispatcher = MessageDispatcherProvider.from(windowAndroid);
            if (dispatcher == null) {
                // Show message next time when there is a valid dispatcher attached to this
                // window.
                return;
            }

            UnownedUserDataHost host = windowAndroid.getUnownedUserDataHost();
            if (SYNC_ERROR_MESSAGE_KEY.retrieveDataFromHost(host) != null) {
                // Show message next time when the previous message has disappeared.
                return;
            }
            SYNC_ERROR_MESSAGE_KEY.attachToHost(
                    host,
                    new SyncErrorMessage(dispatcher, windowAndroid.getActivity().get(), profile));
        }
    }

    private SyncErrorMessage(MessageDispatcher dispatcher, Activity activity, Profile profile) {
        mError = getError(profile);
        mActivity = activity;
        mProfile = profile;
        mIdentityManager = IdentityServicesProvider.get().getIdentityManager(mProfile);
        mSyncService = SyncServiceFactory.getForProfile(mProfile);
        mSyncService.addSyncStateChangedListener(this);

        String errorMessage = getMessage(activity);
        String title = getTitle(activity);
        String primaryButtonText = getPrimaryButtonText(activity);
        Resources resources = activity.getResources();
        mModel =
                new PropertyModel.Builder(MessageBannerProperties.ALL_KEYS)
                        .with(
                                MessageBannerProperties.MESSAGE_IDENTIFIER,
                                MessageIdentifier.SYNC_ERROR)
                        .with(MessageBannerProperties.TITLE, title)
                        .with(MessageBannerProperties.DESCRIPTION, errorMessage)
                        .with(MessageBannerProperties.PRIMARY_BUTTON_TEXT, primaryButtonText)
                        .with(
                                MessageBannerProperties.ICON,
                                ApiCompatibilityUtils.getDrawable(
                                        resources, R.drawable.ic_sync_error_legacy_24dp))
                        .with(
                                MessageBannerProperties.ICON_TINT_COLOR,
                                activity.getColor(R.color.default_red))
                        .with(MessageBannerProperties.ON_PRIMARY_ACTION, this::onAccepted)
                        .with(MessageBannerProperties.ON_DISMISSED, this::onDismissed)
                        .build();
        mMessageDispatcher =
                sMessageDispatcherForTesting == null ? dispatcher : sMessageDispatcherForTesting;
        mMessageDispatcher.enqueueWindowScopedMessage(mModel, false);
        SyncErrorMessageImpressionTracker.updateLastShownTime();
        recordHistogram(ErrorUiAction.SHOWN);
    }

    @Override
    public void syncStateChanged() {
        // If the error disappeared or changed type in the meantime, dismiss the UI.
        if (mError != getError(mProfile)) {
            mMessageDispatcher.dismissMessage(mModel, DismissReason.UNKNOWN);
            assert !SYNC_ERROR_MESSAGE_KEY.isAttachedToAnyHost(this)
                    : "Message UI should have been dismissed";
        }
    }

    private @PrimaryActionClickBehavior int onAccepted() {
        switch (mError) {
            case SyncError.NO_ERROR:
                assert false;
                break;
            case SyncError.AUTH_ERROR:
                startUpdateCredentialsFlow(mActivity);
                break;
            case SyncError.PASSPHRASE_REQUIRED:
            case SyncError.SYNC_SETUP_INCOMPLETE:
            case SyncError.CLIENT_OUT_OF_DATE:
                openSettings();
                break;
            case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_EVERYTHING:
            case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_PASSWORDS:
                openTrustedVaultKeyRetrievalActivity();
                break;
            case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_EVERYTHING:
            case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_PASSWORDS:
                openTrustedVaultRecoverabilityDegradedActivity();
                break;
        }

        recordHistogram(ErrorUiAction.BUTTON_CLICKED);
        return PrimaryActionClickBehavior.DISMISS_IMMEDIATELY;
    }

    private void onDismissed(@DismissReason int reason) {
        if (reason != DismissReason.TIMER
                && reason != DismissReason.GESTURE
                && reason != DismissReason.PRIMARY_ACTION) {
            // If the user didn't explicitly accept/dismiss the message, and the display timeout
            // wasn't reached either, resetLastShownTime() so the message can be shown again. This
            // includes the case where the user changes tabs while the message is showing
            // (TAB_SWITCHED).
            SyncErrorMessageImpressionTracker.resetLastShownTime();
        }
        mSyncService.removeSyncStateChangedListener(this);
        SYNC_ERROR_MESSAGE_KEY.detachFromAllHosts(this);

        // This metric should be recorded only on explicit dismissal.
        if (reason == DismissReason.GESTURE) {
            recordHistogram(ErrorUiAction.DISMISSED);
        }
    }

    private void recordHistogram(@ErrorUiAction int action) {
        assert mError != SyncError.NO_ERROR;
        String name =
                (mSyncService.hasSyncConsent()
                                ? "Signin.SyncErrorMessage"
                                : "Sync.IdentityErrorMessage")
                        + SyncSettingsUtils.getHistogramSuffixForError(mError);
        RecordHistogram.recordEnumeratedHistogram(name, action, ErrorUiAction.NUM_ENTRIES);
    }

    private String getPrimaryButtonText(Context context) {
        assert mError != SyncError.NO_ERROR;
        // Check if this is for a sync error.
        if (mSyncService.hasSyncConsent()) {
            switch (mError) {
                case SyncError.AUTH_ERROR:
                    return context.getString(R.string.password_error_sign_in_button_title);
                case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_EVERYTHING:
                case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_PASSWORDS:
                case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_EVERYTHING:
                case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_PASSWORDS:
                    return context.getString(R.string.trusted_vault_error_card_button);
                default:
                    return context.getString(R.string.open_settings_button);
            }
        }

        // Strings for identity error.
        switch (mError) {
            case SyncError.PASSPHRASE_REQUIRED:
                return context.getString(
                        R.string.identity_error_message_button_passphrase_required);
            case SyncError.CLIENT_OUT_OF_DATE:
                // Reuse the same string as that for the identity error card button.
                return context.getString(R.string.identity_error_card_button_client_out_of_date);
            case SyncError.AUTH_ERROR:
            case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_EVERYTHING:
            case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_PASSWORDS:
            case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_EVERYTHING:
            case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_PASSWORDS:
                return context.getString(R.string.identity_error_message_button_verify);
            case SyncError.SYNC_SETUP_INCOMPLETE:
            case SyncError.OTHER_ERRORS:
            default:
                assert false;
                return "";
        }
    }

    private String getTitle(Context context) {
        assert mError != SyncError.NO_ERROR;
        // Check if this is for a sync error.
        if (mSyncService.hasSyncConsent()) {
            // Use the same title with sync error card of sync settings.
            return SyncSettingsUtils.getSyncErrorCardTitle(context, mError);
        }

        // Strings for identity error.
        switch (mError) {
            case SyncError.PASSPHRASE_REQUIRED:
                return context.getString(R.string.identity_error_message_title_passphrase_required);
            case SyncError.CLIENT_OUT_OF_DATE:
                return context.getString(R.string.identity_error_message_title_client_out_of_date);
            case SyncError.AUTH_ERROR:
            case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_EVERYTHING:
            case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_PASSWORDS:
            case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_EVERYTHING:
            case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_PASSWORDS:
                // Reuse the same string as that for the identity error card button.
                return context.getString(R.string.identity_error_card_button_verify);
            case SyncError.SYNC_SETUP_INCOMPLETE:
            case SyncError.OTHER_ERRORS:
            default:
                assert false;
                return "";
        }
    }

    private String getMessage(Context context) {
        assert mError != SyncError.NO_ERROR;
        // Check if this is for a sync error.
        if (mSyncService.hasSyncConsent()) {
            return mError == SyncError.SYNC_SETUP_INCOMPLETE
                    ? context.getString(R.string.sync_settings_not_confirmed_title)
                    : SyncSettingsUtils.getSyncErrorHint(context, mError);
        }

        // Strings for identity error.
        switch (mError) {
            case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_PASSWORDS:
                return context.getString(
                        R.string.identity_error_message_body_sync_retrieve_keys_for_passwords);
            case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_EVERYTHING:
                return context.getString(
                        R.string
                                .identity_error_message_body_sync_recoverability_degraded_for_everything);
            case SyncError.TRUSTED_VAULT_RECOVERABILITY_DEGRADED_FOR_PASSWORDS:
                return context.getString(
                        R.string
                                .identity_error_message_body_sync_recoverability_degraded_for_passwords);
            case SyncError.PASSPHRASE_REQUIRED:
            case SyncError.CLIENT_OUT_OF_DATE:
            case SyncError.AUTH_ERROR:
            case SyncError.TRUSTED_VAULT_KEY_REQUIRED_FOR_EVERYTHING:
                return context.getString(R.string.identity_error_message_body);
            case SyncError.SYNC_SETUP_INCOMPLETE:
            case SyncError.OTHER_ERRORS:
            default:
                assert false;
                return "";
        }
    }

    private void openTrustedVaultKeyRetrievalActivity() {
        CoreAccountInfo primaryAccountInfo =
                mIdentityManager.getPrimaryAccountInfo(ConsentLevel.SIGNIN);
        if (primaryAccountInfo == null) {
            return;
        }
        TrustedVaultClient.get()
                .createKeyRetrievalIntent(primaryAccountInfo)
                .then(
                        (intent) -> {
                            IntentUtils.safeStartActivity(
                                    getApplicationContext(),
                                    SyncTrustedVaultProxyActivity.createKeyRetrievalProxyIntent(
                                            intent,
                                            TrustedVaultUserActionTriggerForUMA
                                                    .NEW_TAB_PAGE_INFOBAR));
                        },
                        (exception) ->
                                Log.w(
                                        TAG,
                                        "Error creating trusted vault key retrieval intent: ",
                                        exception));
    }

    private void openTrustedVaultRecoverabilityDegradedActivity() {
        CoreAccountInfo primaryAccountInfo =
                mIdentityManager.getPrimaryAccountInfo(ConsentLevel.SIGNIN);
        if (primaryAccountInfo == null) {
            return;
        }
        TrustedVaultClient.get()
                .createRecoverabilityDegradedIntent(primaryAccountInfo)
                .then(
                        (intent) -> {
                            var action = TrustedVaultUserActionTriggerForUMA.NEW_TAB_PAGE_INFOBAR;
                            var proxyIntent =
                                    SyncTrustedVaultProxyActivity
                                            .createRecoverabilityDegradedProxyIntent(
                                                    intent, action);
                            IntentUtils.safeStartActivity(getApplicationContext(), proxyIntent);
                        },
                        (exception) ->
                                Log.w(
                                        TAG,
                                        "Error creating trusted vault recoverability intent: ",
                                        exception));
    }

    private void openSettings() {
        if (mSyncService.hasSyncConsent()
                || ChromeFeatureList.isEnabled(
                        ChromeFeatureList.REPLACE_SYNC_PROMOS_WITH_SIGN_IN_PROMOS)) {
            SettingsLauncher settingsLauncher = SettingsLauncherFactory.createSettingsLauncher();
            settingsLauncher.launchSettingsActivity(
                    getApplicationContext(),
                    ManageSyncSettings.class,
                    ManageSyncSettings.createArguments(false));
        } else {
            AccountManagementFragment.openAccountManagementScreen(
                    getApplicationContext(), GAIAServiceType.GAIA_SERVICE_TYPE_NONE);
        }
    }

    private void startUpdateCredentialsFlow(Activity activity) {
        final CoreAccountInfo primaryAccountInfo =
                mIdentityManager.getPrimaryAccountInfo(ConsentLevel.SIGNIN);
        assert primaryAccountInfo != null;
        AccountManagerFacadeProvider.getInstance()
                .updateCredentials(
                        CoreAccountInfo.getAndroidAccountFrom(primaryAccountInfo), activity, null);
    }

    // TODO(crbug.com/330290259): Turn this into `bool shouldShowMessage(error)`.
    private static @SyncError int getError(Profile profile) {
        @SyncError
        int error =
                IdentityServicesProvider.get()
                                .getIdentityManager(profile)
                                .hasPrimaryAccount(ConsentLevel.SYNC)
                        ? SyncSettingsUtils.getSyncError(profile)
                        : SyncSettingsUtils.getIdentityError(profile);
        // Do not show sync error message for UPM_BACKEND_OUTDATED or OTHER_ERRORS.
        if (error == SyncError.UPM_BACKEND_OUTDATED || error == SyncError.OTHER_ERRORS) {
            return SyncError.NO_ERROR;
        }
        return error;
    }

    public static void setMessageDispatcherForTesting(MessageDispatcher dispatcherForTesting) {
        sMessageDispatcherForTesting = dispatcherForTesting;
        ResettersForTesting.register(() -> sMessageDispatcherForTesting = null);
    }

    public static UnownedUserDataKey<SyncErrorMessage> getKeyForTesting() {
        return SYNC_ERROR_MESSAGE_KEY;
    }
}