chromium/chrome/browser/password_manager/android/account_storage_notice/java/src/org/chromium/chrome/browser/password_manager/account_storage_notice/AccountStorageNoticeCoordinator.java

// Copyright 2024 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.password_manager.account_storage_notice;

import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;

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

import org.jni_zero.CalledByNative;
import org.jni_zero.NativeMethods;

import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.password_manager.account_storage_toggle.AccountStorageToggleFragmentArgs;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.settings.SettingsLauncherFactory;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.StateChangeReason;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetControllerProvider;
import org.chromium.components.browser_ui.bottomsheet.EmptyBottomSheetObserver;
import org.chromium.components.browser_ui.settings.SettingsLauncher.SettingsFragment;
import org.chromium.components.prefs.PrefService;
import org.chromium.ui.base.WindowAndroid;

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

/** Coordinator for the UI described in account_storage_notice.h, meant to be used from native. */
class AccountStorageNoticeCoordinator extends EmptyBottomSheetObserver {
    // The reason the notice was closed.
    // These values are persisted to logs. Entries should not be renumbered and numeric values
    // should never be reused. Keep in sync with AndroidAccountStorageNoticeCloseReason in
    // tools/metrics/histograms/metadata/password/enums.xml.
    @IntDef({
        CloseReason.OTHER,
        CloseReason.USER_DISMISSED,
        CloseReason.USER_CLICKED_GOT_IT,
        CloseReason.USER_CLICKED_SETTINGS,
        CloseReason.EMBEDDER_REQUESTED,
    })
    @Retention(RetentionPolicy.SOURCE)
    @VisibleForTesting
    public @interface CloseReason {
        int OTHER = 0;
        // Dismissed by swiping or back pressing.
        int USER_DISMISSED = 1;
        int USER_CLICKED_GOT_IT = 2;
        int USER_CLICKED_SETTINGS = 3;
        // Embedder requested the notice to close via hideImmediatelyIfShowing(). In practice this
        // means the tab was closed.
        int EMBEDDER_REQUESTED = 4;
        int MAX_VALUE = EMBEDDER_REQUESTED;
    }

    @VisibleForTesting
    public static final String CLOSE_REASON_METRIC =
            "PasswordManager.AndroidAccountStorageNotice.CloseReason";

    private final WindowAndroid mWindowAndroid;
    private final AccountStorageNoticeView mView;

    private long mNativeCoordinatorObserver;
    private @CloseReason int mCloseReason = CloseReason.OTHER;
    // Whether mView is being shown. If any other sheet is being shown, this is false.
    private boolean mShowingSheet;

    // Impl note: hasChosenToSyncPasswords() and isGmsCoreUpdateRequired() can't be easily called
    // here (even if the predicates are moved to different targets to avoid cyclic dependencies,
    // that then causes issues with *UnitTest.java depending on mojo). So the booleans are plumbed
    // instead. The API is actually quite sane, all combinations of the booleans are valid.
    @CalledByNative
    public static boolean canShow(
            boolean hasSyncConsent,
            boolean hasChosenToSyncPasswords,
            boolean isGmsCoreUpdateRequired,
            PrefService prefService,
            @Nullable WindowAndroid windowAndroid) {
        return !hasSyncConsent
                && hasChosenToSyncPasswords
                && !isGmsCoreUpdateRequired
                && !prefService.getBoolean(Pref.ACCOUNT_STORAGE_NOTICE_SHOWN)
                && windowAndroid != null
                && BottomSheetControllerProvider.from(windowAndroid) != null
                && windowAndroid.getContext().get() != null
                && ChromeFeatureList.isEnabled(
                        ChromeFeatureList.ENABLE_PASSWORDS_ACCOUNT_STORAGE_FOR_NON_SYNCING_USERS);
    }

    @CalledByNative
    public static AccountStorageNoticeCoordinator createAndShow(
            WindowAndroid windowAndroid, PrefService prefService) {
        AccountStorageNoticeCoordinator coordinator =
                new AccountStorageNoticeCoordinator(windowAndroid);
        BottomSheetControllerProvider.from(windowAndroid)
                .requestShowContent(coordinator.mView, /* animate= */ true);
        prefService.setBoolean(Pref.ACCOUNT_STORAGE_NOTICE_SHOWN, true);
        return coordinator;
    }

    private AccountStorageNoticeCoordinator(WindowAndroid windowAndroid) {
        mWindowAndroid = windowAndroid;
        // Was checked in canShow() before.
        assert mWindowAndroid != null;
        @Nullable Context context = windowAndroid.getContext().get();
        // Was checked in canShow() before.
        assert context != null;
        mView =
                new AccountStorageNoticeView(
                        context, this::onButtonClicked, this::onSettingsLinkClicked);
        @Nullable
        BottomSheetController controller = BottomSheetControllerProvider.from(mWindowAndroid);
        // Was checked in canShow() before.
        assert controller != null;
        controller.addObserver(this);
    }

    @CalledByNative
    public void setObserver(long nativeCoordinatorObserver) {
        mNativeCoordinatorObserver = nativeCoordinatorObserver;
    }

    /** If the notice is still showing, hides it promptly without animation. Otherwise, no-op. */
    @CalledByNative
    public void hideImmediatelyIfShowing() {
        hideWithReason(CloseReason.EMBEDDER_REQUESTED, /* animate= */ false);
    }

    // EmptyBottomSheetObserver overrides.
    @Override
    public void onSheetClosed(@StateChangeReason int reason) {
        // This waits for the sheet to close, then stops the observation and notifies native.
        if (!mShowingSheet) {
            return;
        }
        mShowingSheet = false;

        // The observer was notified, so the controller should be alive.
        BottomSheetControllerProvider.from(mWindowAndroid).removeObserver(this);

        if (mCloseReason == CloseReason.OTHER
                && (reason == StateChangeReason.SWIPE || reason == StateChangeReason.BACK_PRESS)) {
            mCloseReason = CloseReason.USER_DISMISSED;
        }
        RecordHistogram.recordEnumeratedHistogram(
                CLOSE_REASON_METRIC, mCloseReason, CloseReason.MAX_VALUE + 1);

        if (mNativeCoordinatorObserver != 0) {
            AccountStorageNoticeCoordinatorJni.get().onClosed(mNativeCoordinatorObserver);
        }
    }

    @Override
    public void onSheetOpened(@StateChangeReason int reason) {
        // The observer was notified, so the controller should be alive.
        if (BottomSheetControllerProvider.from(mWindowAndroid).getCurrentSheetContent() == mView) {
            mShowingSheet = true;
        }
    }

    private void onButtonClicked() {
        hideWithReason(CloseReason.USER_CLICKED_GOT_IT, /* animate= */ true);
    }

    private void onSettingsLinkClicked() {
        @Nullable Context context = mWindowAndroid.getContext().get();
        if (context == null) {
            // The activity was closed, nothing else to do.
            return;
        }

        Bundle fragmentArgs = new Bundle();
        fragmentArgs.putBoolean(AccountStorageToggleFragmentArgs.HIGHLIGHT, true);
        // The toggle to disable account storage lives on different fragments depending on the flag.
        @SettingsFragment
        int fragment =
                ChromeFeatureList.isEnabled(
                                ChromeFeatureList.REPLACE_SYNC_PROMOS_WITH_SIGN_IN_PROMOS)
                        ? SettingsFragment.MANAGE_SYNC
                        : SettingsFragment.GOOGLE_SERVICES;
        Intent intent =
                SettingsLauncherFactory.createSettingsLauncher()
                        .createSettingsActivityIntent(context, fragment, fragmentArgs);
        mWindowAndroid.showIntent(intent, this::onSettingsClosed, /* errorId= */ null);
    }

    private void onSettingsClosed(int resultCode, Intent unused) {
        // Note: closing settings via user interaction should map to Activity.RESULT_CANCELED here,
        // but we want to hide the sheet no matter what `resultCode` is.
        hideWithReason(CloseReason.USER_CLICKED_SETTINGS, /* animate= */ true);
    }

    private void hideWithReason(@CloseReason int closeReason, boolean animate) {
        @Nullable
        BottomSheetController controller = BottomSheetControllerProvider.from(mWindowAndroid);
        if (controller == null) {
            // There isn't even a sheet controller anymore, nothing else to do.
            return;
        }

        if (controller.getCurrentSheetContent() != mView) {
            // Don't hide a different sheet.
            return;
        }

        mCloseReason = closeReason;
        controller.hideContent(mView, animate);
        // onSheetClosed() will take care of notifying native and recording the metric.
    }

    @NativeMethods
    interface Natives {
        // See docs in account_storage_notice.h as to when this should be called.
        void onClosed(long nativeCoordinatorObserver);
    }

    public View getBottomSheetViewForTesting() {
        return mView.getContentView();
    }
}