chromium/chrome/android/java/src/org/chromium/chrome/browser/identity_disc/IdentityDiscController.java

// Copyright 2019 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.identity_disc;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.content.res.AppCompatResources;

import org.chromium.base.Callback;
import org.chromium.base.ObserverList;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.NativeInitObserver;
import org.chromium.chrome.browser.ntp.NewTabPage;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.settings.MainSettings;
import org.chromium.chrome.browser.settings.SettingsLauncherFactory;
import org.chromium.chrome.browser.signin.SigninAndHistorySyncActivityLauncherImpl;
import org.chromium.chrome.browser.signin.SyncConsentActivityLauncherImpl;
import org.chromium.chrome.browser.signin.services.DisplayableProfileData;
import org.chromium.chrome.browser.signin.services.IdentityServicesProvider;
import org.chromium.chrome.browser.signin.services.ProfileDataCache;
import org.chromium.chrome.browser.signin.services.SigninManager;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.toolbar.ButtonData;
import org.chromium.chrome.browser.toolbar.ButtonData.ButtonSpec;
import org.chromium.chrome.browser.toolbar.ButtonDataImpl;
import org.chromium.chrome.browser.toolbar.ButtonDataProvider;
import org.chromium.chrome.browser.toolbar.adaptive.AdaptiveToolbarButtonVariant;
import org.chromium.chrome.browser.ui.signin.SigninAndHistorySyncCoordinator;
import org.chromium.chrome.browser.ui.signin.SigninUtils;
import org.chromium.chrome.browser.ui.signin.account_picker.AccountPickerBottomSheetStrings;
import org.chromium.chrome.browser.user_education.IPHCommandBuilder;
import org.chromium.chrome.browser.util.BrowserUiUtils;
import org.chromium.components.browser_ui.settings.SettingsLauncher;
import org.chromium.components.feature_engagement.EventConstants;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.components.feature_engagement.Tracker;
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.signin.identitymanager.PrimaryAccountChangeEvent;
import org.chromium.components.signin.metrics.SigninAccessPoint;

/**
 * Handles displaying IdentityDisc on toolbar depending on several conditions (user sign-in state,
 * whether NTP is shown)
 */
public class IdentityDiscController
        implements NativeInitObserver,
                ProfileDataCache.Observer,
                IdentityManager.Observer,
                ButtonDataProvider {
    // Context is used for fetching resources and launching preferences page.
    private final Context mContext;
    private ActivityLifecycleDispatcher mActivityLifecycleDispatcher;
    private final ObservableSupplier<Profile> mProfileSupplier;
    private final Callback<Profile> mProfileSupplierObserver = this::setProfile;

    // We observe IdentityManager to receive primary account state change notifications.
    private IdentityManager mIdentityManager;

    // ProfileDataCache facilitates retrieving profile picture.
    private ProfileDataCache mProfileDataCache;

    private ButtonDataImpl mButtonData;
    private ObserverList<ButtonDataObserver> mObservers = new ObserverList<>();
    private boolean mNativeIsInitialized;

    private boolean mIsTabNtp;

    /**
     * @param context The Context for retrieving resources, launching preference activity, etc.
     * @param activityLifecycleDispatcher Dispatcher for activity lifecycle events, e.g. native
     *     initialization completing.
     */
    public IdentityDiscController(
            Context context,
            ActivityLifecycleDispatcher activityLifecycleDispatcher,
            ObservableSupplier<Profile> profileSupplier) {
        mContext = context;
        mActivityLifecycleDispatcher = activityLifecycleDispatcher;
        mProfileSupplier = profileSupplier;
        mActivityLifecycleDispatcher.register(this);

        mButtonData =
                new ButtonDataImpl(
                        /* canShow= */ false,
                        /* drawable= */ null,
                        /* onClickListener= */ view -> onClick(),
                        mContext.getString(R.string.accessibility_toolbar_btn_identity_disc),
                        /* supportsTinting= */ false,
                        new IPHCommandBuilder(
                                mContext.getResources(),
                                FeatureConstants.IDENTITY_DISC_FEATURE,
                                R.string.iph_identity_disc_text,
                                R.string.iph_identity_disc_accessibility_text),
                        /* isEnabled= */ true,
                        AdaptiveToolbarButtonVariant.UNKNOWN,
                        /* tooltipTextResId= */ Resources.ID_NULL,
                        /* showHoverHighlight= */ true);
    }

    /** Registers itself to observe sign-in and sync status events. */
    @Override
    public void onFinishNativeInitialization() {
        mActivityLifecycleDispatcher.unregister(this);
        mActivityLifecycleDispatcher = null;
        mNativeIsInitialized = true;

        mProfileSupplier.addObserver(mProfileSupplierObserver);
    }

    @Override
    public void addObserver(ButtonDataObserver obs) {
        mObservers.addObserver(obs);
    }

    @Override
    public void removeObserver(ButtonDataObserver obs) {
        mObservers.removeObserver(obs);
    }

    @Override
    public ButtonData get(Tab tab) {
        mIsTabNtp = tab != null && tab.getNativePage() instanceof NewTabPage;
        if (!mIsTabNtp) {
            mButtonData.setCanShow(false);
            return mButtonData;
        }

        calculateButtonData();
        return mButtonData;
    }

    private void calculateButtonData() {
        if (!mNativeIsInitialized) {
            assert !mButtonData.canShow();
            return;
        }

        String email = CoreAccountInfo.getEmailFrom(getSignedInAccountInfo());
        ensureProfileDataCache();

        mButtonData.setButtonSpec(
                buttonSpecWithDrawableAndDescription(mButtonData.getButtonSpec(), email));
        mButtonData.setCanShow(true);
    }

    private ButtonSpec buttonSpecWithDrawableAndDescription(
            ButtonSpec buttonSpec, @Nullable String email) {
        Drawable drawable = getProfileImage(email);
        if (buttonSpec.getDrawable() == drawable) {
            return buttonSpec;
        }

        // `supportsTinting` must be false when showing the user's profile image or its placeholder,
        // to not alter the images colors in those cases.
        boolean shouldSupportTinting = email == null;
        String contentDescription = getContentDescription(email);
        return new ButtonSpec(
                drawable,
                buttonSpec.getOnClickListener(),
                /* onLongClickListener= */ null,
                contentDescription,
                shouldSupportTinting,
                buttonSpec.getIPHCommandBuilder(),
                AdaptiveToolbarButtonVariant.UNKNOWN,
                buttonSpec.getActionChipLabelResId(),
                buttonSpec.getHoverTooltipTextId(),
                buttonSpec.getShouldShowHoverHighlight());
    }

    /**
     * Creates and initializes ProfileDataCache if it wasn't created previously. Subscribes
     * IdentityDiscController for profile data updates.
     */
    private void ensureProfileDataCache() {
        if (mProfileDataCache != null) return;

        mProfileDataCache =
                ProfileDataCache.createWithoutBadge(mContext, R.dimen.toolbar_identity_disc_size);
        mProfileDataCache.addObserver(this);
    }

    /**
     * Returns Profile picture Drawable. The size of the image corresponds to current visual state.
     */
    private Drawable getProfileImage(@Nullable String email) {
        return email == null
                ? AppCompatResources.getDrawable(mContext, R.drawable.account_circle)
                : mProfileDataCache.getProfileDataOrDefault(email).getImage();
    }

    /** Resets ProfileDataCache. Used for flushing cached image when sign-in state changes. */
    private void resetIdentityDiscCache() {
        if (mProfileDataCache != null) {
            mProfileDataCache.removeObserver(this);
            mProfileDataCache = null;
        }
    }

    private void notifyObservers(boolean hint) {
        for (ButtonDataObserver observer : mObservers) {
            observer.buttonDataChanged(hint);
        }
    }

    /** Called after profile image becomes available. Updates the image on toolbar button. */
    @Override
    public void onProfileDataUpdated(String accountEmail) {
        assert mProfileDataCache != null;

        if (accountEmail.equals(CoreAccountInfo.getEmailFrom(getSignedInAccountInfo()))) {
            /**
             * We need to call {@link notifyObservers(false)} before caling
             * {@link notifyObservers(true)}. This is because {@link notifyObservers(true)} has been
             * called in {@link setProfile()}, and without calling {@link notifyObservers(false)},
             * the ObservableSupplierImpl doesn't propagate the call. See https://cubug.com/1137535.
             */
            notifyObservers(false);
            notifyObservers(true);
        }
    }

    /**
     * Implements {@link IdentityManager.Observer}.
     *
     * <p>IdentityDisc should be always shown regardless of whether the user is signed out, signed
     * in or syncing.
     */
    @Override
    public void onPrimaryAccountChanged(PrimaryAccountChangeEvent eventDetails) {
        switch (eventDetails.getEventTypeFor(ConsentLevel.SIGNIN)) {
            case PrimaryAccountChangeEvent.Type.SET:
                notifyObservers(true);
                break;
            case PrimaryAccountChangeEvent.Type.CLEARED:
                resetIdentityDiscCache();
                notifyObservers(false);
                break;
            case PrimaryAccountChangeEvent.Type.NONE:
                break;
        }
    }

    /** Call to tear down dependencies. */
    @Override
    public void destroy() {
        if (mActivityLifecycleDispatcher != null) {
            mActivityLifecycleDispatcher.unregister(this);
            mActivityLifecycleDispatcher = null;
        }

        if (mProfileDataCache != null) {
            mProfileDataCache.removeObserver(this);
            mProfileDataCache = null;
        }

        if (mIdentityManager != null) {
            mIdentityManager.removeObserver(this);
            mIdentityManager = null;
        }

        if (mNativeIsInitialized) {
            mProfileSupplier.removeObserver(mProfileSupplierObserver);
        }
    }

    /**
     * Records IdentityDisc usage with feature engagement tracker. This signal can be used to decide
     * whether to show in-product help. We also record the clicking actions on the profile icon in
     * histograms.
     */
    private void recordIdentityDiscUsed() {
        BrowserUiUtils.recordIdentityDiscClicked(mIsTabNtp);

        assert isProfileInitialized();
        Tracker tracker = TrackerFactory.getTrackerForProfile(mProfileSupplier.get());
        tracker.notifyEvent(EventConstants.IDENTITY_DISC_USED);
        RecordUserAction.record("MobileToolbarIdentityDiscTap");
    }

    /**
     * Returns the account info of mIdentityManager if current profile is regular, and
     * null for off-the-record ones.
     * @return account info for the current profile. Returns null for OTR profile.
     */
    private CoreAccountInfo getSignedInAccountInfo() {
        return mIdentityManager != null
                ? mIdentityManager.getPrimaryAccountInfo(ConsentLevel.SIGNIN)
                : null;
    }

    /**
     * Triggered by mProfileSupplierObserver when profile is changed in mProfileSupplier.
     * mIdentityManager is updated with the profile, as set to null if profile is off-the-record.
     */
    private void setProfile(Profile profile) {
        if (mIdentityManager != null) {
            mIdentityManager.removeObserver(this);
        }

        if (profile.isOffTheRecord()) {
            mIdentityManager = null;
        } else {
            mIdentityManager = IdentityServicesProvider.get().getIdentityManager(profile);
            mIdentityManager.addObserver(this);
            notifyObservers(true);
        }
    }

    private String getContentDescription(@Nullable String email) {
        if (email == null) {
            if (SigninUtils.shouldShowNewSigninFlow()) {
                return mContext.getString(
                        R.string.accessibility_toolbar_btn_signed_out_identity_disc);
            } else {
                return mContext.getString(
                        R.string.accessibility_toolbar_btn_signed_out_with_sync_identity_disc);
            }
        }

        DisplayableProfileData profileData = mProfileDataCache.getProfileDataOrDefault(email);
        String userName = profileData.getFullName();
        if (profileData.hasDisplayableEmailAddress()) {
            return mContext.getString(
                    R.string.accessibility_toolbar_btn_identity_disc_with_name_and_email,
                    userName,
                    email);
        }

        return mContext.getString(
                R.string.accessibility_toolbar_btn_identity_disc_with_name, userName);
    }

    private boolean isProfileInitialized() {
        return mProfileSupplier != null && mProfileSupplier.hasValue();
    }

    @VisibleForTesting
    void onClick() {
        if (!isProfileInitialized()) {
            return;
        }
        recordIdentityDiscUsed();

        SigninManager signinManager =
                IdentityServicesProvider.get()
                        .getSigninManager(mProfileSupplier.get().getOriginalProfile());
        if (getSignedInAccountInfo() == null && !signinManager.isSigninDisabledByPolicy()) {
            if (SigninUtils.shouldShowNewSigninFlow()) {
                AccountPickerBottomSheetStrings bottomSheetStrings =
                        new AccountPickerBottomSheetStrings.Builder(
                                        R.string.signin_account_picker_bottom_sheet_title)
                                .setSubtitleStringId(
                                        R.string
                                                .signin_account_picker_bottom_sheet_benefits_subtitle)
                                .build();
                SigninAndHistorySyncActivityLauncherImpl.get()
                        .launchActivityIfAllowed(
                                mContext,
                                mProfileSupplier.get().getOriginalProfile(),
                                bottomSheetStrings,
                                SigninAndHistorySyncCoordinator.NoAccountSigninMode.BOTTOM_SHEET,
                                SigninAndHistorySyncCoordinator.WithAccountSigninMode
                                        .DEFAULT_ACCOUNT_BOTTOM_SHEET,
                                SigninAndHistorySyncCoordinator.HistoryOptInMode.OPTIONAL,
                                SigninAccessPoint.NTP_SIGNED_OUT_ICON);
            } else {
                SyncConsentActivityLauncherImpl.get()
                        .launchActivityIfAllowed(mContext, SigninAccessPoint.NTP_SIGNED_OUT_ICON);
            }
        } else {
            SettingsLauncher settingsLauncher = SettingsLauncherFactory.createSettingsLauncher();
            settingsLauncher.launchSettingsActivity(mContext, MainSettings.class);
        }
    }

    @VisibleForTesting
    boolean isProfileDataCacheEmpty() {
        return mProfileDataCache == null;
    }
}