chromium/chrome/android/java/src/org/chromium/chrome/browser/sync/SyncErrorNotifier.java

// Copyright 2015 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;

import android.app.PendingIntent;
import android.content.Intent;

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

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.Promise;
import org.chromium.base.ThreadUtils;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.notifications.NotificationConstants;
import org.chromium.chrome.browser.notifications.NotificationUmaTracker;
import org.chromium.chrome.browser.notifications.NotificationWrapperBuilderFactory;
import org.chromium.chrome.browser.notifications.channels.ChromeChannelDefinitions;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileKeyedMap;
import org.chromium.chrome.browser.sync.ui.PassphraseActivity;
import org.chromium.chrome.browser.sync.ui.SyncTrustedVaultProxyActivity;
import org.chromium.components.browser_ui.notifications.BaseNotificationManagerProxy;
import org.chromium.components.browser_ui.notifications.BaseNotificationManagerProxyFactory;
import org.chromium.components.browser_ui.notifications.NotificationMetadata;
import org.chromium.components.browser_ui.notifications.NotificationWrapper;
import org.chromium.components.browser_ui.notifications.PendingIntentProvider;
import org.chromium.components.sync.SyncService;
import org.chromium.components.sync.TrustedVaultUserActionTriggerForUMA;

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

/**
 * {@link SyncErrorNotifier} displays Android notifications regarding sync errors.
 * Errors can be fixed by clicking the notification.
 */
public class SyncErrorNotifier implements SyncService.SyncStateChangedListener {
    @IntDef({
        NotificationState.REQUIRE_PASSPHRASE,
        NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_PASSWORDS,
        NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_EVERYTHING,
        NotificationState.HIDDEN
    })
    @Retention(RetentionPolicy.SOURCE)
    private @interface NotificationState {
        int REQUIRE_PASSPHRASE = 0;
        int REQUIRE_TRUSTED_VAULT_KEY_FOR_PASSWORDS = 1;
        int REQUIRE_TRUSTED_VAULT_KEY_FOR_EVERYTHING = 2;
        int HIDDEN = 3;
    }

    private static final String TAG = "SyncUI";

    private static ProfileKeyedMap<SyncErrorNotifier> sProfileMap =
            new ProfileKeyedMap<>(ProfileKeyedMap.NO_REQUIRED_CLEANUP_ACTION);

    private final BaseNotificationManagerProxy mNotificationManager;
    private final SyncService mSyncService;
    private final TrustedVaultClient mTrustedVaultClient;

    // What notification is being shown, if any. In truth, for REQUIRE_TRUSTED_VAULT_* states this
    // is set slightly earlier, when the class calls createTrustedVaultKeyRetrievalIntent().
    private @NotificationState int mNotificationState = NotificationState.HIDDEN;

    /**
     * Returns null if there's no instance of SyncService for the given {@link Profile} (Sync
     * disabled via command-line).
     */
    public static @Nullable SyncErrorNotifier getForProfile(Profile profile) {
        ThreadUtils.assertOnUiThread();
        SyncService syncService = SyncServiceFactory.getForProfile(profile);
        if (syncService == null) return null;
        return sProfileMap.getForProfile(profile, SyncErrorNotifier::buildForProfile);
    }

    private static SyncErrorNotifier buildForProfile(Profile profile) {
        return new SyncErrorNotifier(
                BaseNotificationManagerProxyFactory.create(ContextUtils.getApplicationContext()),
                SyncServiceFactory.getForProfile(profile),
                TrustedVaultClient.get());
    }

    @VisibleForTesting
    public SyncErrorNotifier(
            BaseNotificationManagerProxy notificationManager,
            SyncService syncService,
            TrustedVaultClient trustedVaultClient) {
        mNotificationManager = notificationManager;
        mSyncService = syncService;
        mTrustedVaultClient = trustedVaultClient;
        mSyncService.addSyncStateChangedListener(this);
    }

    /**
     * {@link SyncService.SyncStateChangedListener} implementation.
     * Decides which error notification to show (if any), based on the sync state.
     */
    @Override
    public void syncStateChanged() {
        ThreadUtils.assertOnUiThread();

        final @NotificationState int goalState = computeGoalNotificationState();
        if (mNotificationState == goalState) {
            // Quite common, syncStateChanged() is triggered often. Spare NotificationManager calls
            // by early returning, they are expensive.
            // This also covers the case where the class is transitioning to REQUIRE_TRUSTED_VAULT_*
            // but createTrustedVaultKeyRetrievalIntent() hasn't responded yet. In that case this
            // check spares new createTrustedVaultKeyRetrievalIntent() calls.
            return;
        }

        @NotificationState int previousState = mNotificationState;
        mNotificationState = goalState;
        switch (goalState) {
            case NotificationState.HIDDEN:
                {
                    mNotificationManager.cancel(NotificationConstants.NOTIFICATION_ID_SYNC);
                    break;
                }
            case NotificationState.REQUIRE_PASSPHRASE:
                {
                    mSyncService.markPassphrasePromptMutedForCurrentProductVersion();
                    showNotification(
                            createPassphraseIntent());
                    break;
                }
            case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_PASSWORDS:
            case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_EVERYTHING:
                {
                    createTrustedVaultKeyRetrievalIntent()
                            .then(
                                    intent -> {
                                        if (mNotificationState != goalState) {
                                            // State changed in the meantime, throw the intent away.
                                            return;
                                        }
                                        showNotification(intent);
                                    },
                                    exception -> {
                                        if (mNotificationState != goalState) {
                                            // State changed in the meantime. Lucky us, because we'd
                                            // have no intent to show the notification :).
                                            return;
                                        }
                                        // We still want to show the trusted vault notification but
                                        // couldn't produce the intent. Just reset the state.
                                        mNotificationState = previousState;
                                        Log.w(
                                                TAG,
                                                "Error creating key retrieval intent: ",
                                                exception);
                                    });
                    break;
                }
            default:
                {
                    assert false;
                    break;
                }
        }
    }

    private @NotificationState int computeGoalNotificationState() {
        if (!mSyncService.isEngineInitialized()) {
            // The notifications expose encryption errors and those can only be detected once the
            // engine is up. In the meantime, don't show anything.
            return NotificationState.HIDDEN;
        }

        if (mSyncService.isPassphraseRequiredForPreferredDataTypes()
                && !mSyncService.isPassphrasePromptMutedForCurrentProductVersion()) {
            return NotificationState.REQUIRE_PASSPHRASE;
        }

        if (mSyncService.isTrustedVaultKeyRequiredForPreferredDataTypes()) {
            return mSyncService.isEncryptEverythingEnabled()
                    ? NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_EVERYTHING
                    : NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_PASSWORDS;
        }

        return NotificationState.HIDDEN;
    }

    /** Displays the error notification with `title` and `textBody`. Replaces any existing one. */
    private void showNotification(Intent intentTriggeredOnClick) {
        // Converting |intentTriggeredOnClick| into a PendingIntent is needed because it will be
        // handed over to the Android notification manager, a foreign application.
        // FLAG_UPDATE_CURRENT ensures any cached intent extras are updated.
        PendingIntentProvider pendingIntent =
                PendingIntentProvider.getActivity(
                        ContextUtils.getApplicationContext(),
                        0,
                        intentTriggeredOnClick,
                        PendingIntent.FLAG_UPDATE_CURRENT);

        @StringRes int title = getNotificationTitle();
        @StringRes int textBody = getNotificationText();
        // There is no need to provide a group summary notification because NOTIFICATION_ID_SYNC
        // ensures there's only one sync notification at a time.
        NotificationWrapper notification =
                NotificationWrapperBuilderFactory.createNotificationWrapperBuilder(
                                ChromeChannelDefinitions.ChannelId.BROWSER,
                                new NotificationMetadata(
                                        NotificationUmaTracker.SystemNotificationType.SYNC,
                                        // TODO(crbug.com/41489615): Investigate why passing null
                                        // leads to no notifications.
                                        TAG,
                                        NotificationConstants.NOTIFICATION_ID_SYNC))
                        .setAutoCancel(true)
                        .setContentIntent(pendingIntent)
                        .setContentTitle(getString(title))
                        .setContentText(getString(textBody))
                        .setSmallIcon(R.drawable.ic_chrome)
                        .setTicker(getString(textBody))
                        .setLocalOnly(true)
                        .setGroup(NotificationConstants.GROUP_SYNC)
                        .buildWithBigTextStyle(getString(textBody));
        mNotificationManager.notify(notification);
        NotificationUmaTracker.getInstance()
                .onNotificationShown(
                        NotificationUmaTracker.SystemNotificationType.SYNC,
                        notification.getNotification());
    }

    /**
     * Creates an intent that launches an activity that requests the sync passphrase.
     *
     * @return the intent for opening the passphrase activity
     */
    private static Intent createPassphraseIntent() {
        Intent intent = new Intent(ContextUtils.getApplicationContext(), PassphraseActivity.class);
        // This activity will become the start of a new task on this history stack.
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        // Clears the task stack above this activity if it already exists.
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        return intent;
    }

    /** Creates an intent that launches an activity that retrieves the trusted vault key. */
    private Promise<Intent> createTrustedVaultKeyRetrievalIntent() {
        assert mSyncService.getAccountInfo() != null;
        Promise<Intent> promise = new Promise<Intent>();
        mTrustedVaultClient
                .createKeyRetrievalIntent(mSyncService.getAccountInfo())
                // Cf. SyncTrustedVaultProxyActivity as to why use a proxy intent.
                .then(
                        realIntent ->
                                promise.fulfill(
                                        SyncTrustedVaultProxyActivity.createKeyRetrievalProxyIntent(
                                                realIntent,
                                                TrustedVaultUserActionTriggerForUMA.NOTIFICATION)),
                        exception -> promise.reject(exception));
        return promise;
    }

    private String getString(@StringRes int messageId) {
        return ContextUtils.getApplicationContext().getString(messageId);
    }

    private @StringRes int getNotificationTitle() {
        // Check if this is a sync error or an identity error.
        if (mSyncService.isSyncFeatureEnabled()) {
            // Sync error messages.
            switch (mNotificationState) {
                case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_PASSWORDS:
                    return R.string.password_sync_error_summary;
                case NotificationState.REQUIRE_PASSPHRASE:
                case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_EVERYTHING:
                    return R.string.sync_error_card_title;
                case NotificationState.HIDDEN:
                default:
                    assert false;
            }
        }

        // Identity error messages.
        switch (mNotificationState) {
            case NotificationState.REQUIRE_PASSPHRASE:
                return R.string.identity_error_message_title_passphrase_required;
            case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_PASSWORDS:
            case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_EVERYTHING:
                return R.string.identity_error_card_button_verify;
            case NotificationState.HIDDEN:
            default:
                assert false;
        }
        return R.string.sync_error_card_title;
    }

    private @StringRes int getNotificationText() {
        // Check if this is a sync error or an identity error.
        if (mSyncService.isSyncFeatureEnabled()) {
            // Sync error messages.
            switch (mNotificationState) {
                case NotificationState.REQUIRE_PASSPHRASE:
                    return R.string.hint_passphrase_required;
                case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_PASSWORDS:
                    return R.string.hint_sync_retrieve_keys_for_passwords;
                case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_EVERYTHING:
                    return R.string.hint_sync_retrieve_keys_for_everything;
                case NotificationState.HIDDEN:
                default:
                    assert false;
            }
        }

        // Identity error messages.
        switch (mNotificationState) {
            case NotificationState.REQUIRE_PASSPHRASE:
            case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_EVERYTHING:
                return R.string.identity_error_message_body;
            case NotificationState.REQUIRE_TRUSTED_VAULT_KEY_FOR_PASSWORDS:
                return R.string.identity_error_message_body_sync_retrieve_keys_for_passwords;
            case NotificationState.HIDDEN:
            default:
                assert false;
        }
        return R.string.identity_error_message_body;
    }
}