chromium/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/adaptive/AdaptiveToolbarButtonController.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.toolbar.adaptive;

import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.ADAPTIVE_TOOLBAR_CUSTOMIZATION_ENABLED;
import static org.chromium.chrome.browser.preferences.ChromePreferenceKeys.ADAPTIVE_TOOLBAR_CUSTOMIZATION_SETTINGS;

import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.view.View;

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

import org.chromium.base.Callback;
import org.chromium.base.CallbackController;
import org.chromium.base.ContextUtils;
import org.chromium.base.FeatureList;
import org.chromium.base.ObserverList;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.OneShotCallback;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.ConfigurationChangedObserver;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.settings.SettingsLauncherFactory;
import org.chromium.chrome.browser.tab.CurrentTabObserver;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
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.ButtonDataProvider.ButtonDataObserver;
import org.chromium.chrome.browser.toolbar.R;
import org.chromium.chrome.browser.toolbar.adaptive.settings.AdaptiveToolbarSettingsFragment;
import org.chromium.components.browser_ui.settings.SettingsLauncher;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.ui.permissions.AndroidPermissionDelegate;

import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Objects;

/** Meta {@link ButtonDataProvider} which chooses the optional button variant that will be shown. */
public class AdaptiveToolbarButtonController
        implements ButtonDataProvider,
                ButtonDataObserver,
                SharedPreferences.OnSharedPreferenceChangeListener,
                ConfigurationChangedObserver {

    private final Context mContext;
    private ObserverList<ButtonDataObserver> mObservers = new ObserverList<>();
    @Nullable private ButtonDataProvider mSingleProvider;

    // Maps from {@link AdaptiveToolbarButtonVariant} to {@link ButtonDataProvider}.
    private Map<Integer, ButtonDataProvider> mButtonDataProviderMap = new HashMap<>();

    /**
     * {@link ButtonData} instance returned by {@link AdaptiveToolbarButtonController#get(Tab)}
     * when wrapping {@code mOriginalButtonSpec}.
     */
    private final ButtonDataImpl mButtonData = new ButtonDataImpl();

    /** The last received {@link ButtonSpec}. */
    @Nullable private ButtonSpec mOriginalButtonSpec;

    /** {@code true} if the SessionVariant histogram value was already recorded. */
    private boolean mIsSessionVariantRecorded;

    private final ActivityLifecycleDispatcher mLifecycleDispatcher;
    private final AndroidPermissionDelegate mAndroidPermissionDelegate;
    private final SharedPreferencesManager mSharedPreferencesManager;
    private final CallbackController mCallbackController;
    private final Callback<AdaptiveToolbarStatePredictor.UiState> mUiStateCallback;

    @Nullable private AdaptiveToolbarStatePredictor mAdaptiveToolbarStatePredictor;
    @Nullable private View.OnLongClickListener mMenuHandler;
    private final Callback<Integer> mMenuClickListener;
    private final AdaptiveButtonActionMenuCoordinator mMenuCoordinator;
    private int mScreenWidthDp;

    private @AdaptiveToolbarButtonVariant int mSessionButtonVariant =
            AdaptiveToolbarButtonVariant.UNKNOWN;
    private CurrentTabObserver mPageLoadMetricsRecorder;

    /**
     * Constructs the {@link AdaptiveToolbarButtonController}.
     *
     * @param context used in {@link SettingsLauncher}
     * @param lifecycleDispatcher notifies about native initialization
     * @param profileSupplier Allows access to the {@link Profile} for the current session.
     */
    // Suppress to observe SharedPreferences, which is discouraged; use another messaging channel
    // instead.
    @SuppressWarnings("UseSharedPreferencesManagerFromChromeCheck")
    public AdaptiveToolbarButtonController(
            Context context,
            ActivityLifecycleDispatcher lifecycleDispatcher,
            ObservableSupplier<Profile> profileSupplier,
            AdaptiveButtonActionMenuCoordinator menuCoordinator,
            AndroidPermissionDelegate androidPermissionDelegate,
            SharedPreferencesManager sharedPreferencesManager) {
        mContext = context;
        mMenuClickListener =
                id -> {
                    if (id == R.id.customize_adaptive_button_menu_id) {
                        RecordUserAction.record("MobileAdaptiveMenuCustomize");
                        SettingsLauncherFactory.createSettingsLauncher()
                                .launchSettingsActivity(
                                        context, AdaptiveToolbarSettingsFragment.class);
                        return;
                    }
                    assert false : "unknown adaptive button menu id: " + id;
                };
        mLifecycleDispatcher = lifecycleDispatcher;
        mLifecycleDispatcher.register(this);
        mMenuCoordinator = menuCoordinator;
        mSharedPreferencesManager = sharedPreferencesManager;
        mScreenWidthDp = context.getResources().getConfiguration().screenWidthDp;
        mAndroidPermissionDelegate = androidPermissionDelegate;
        mCallbackController = new CallbackController();
        mUiStateCallback =
                uiState -> {
                    mSessionButtonVariant =
                            uiState.canShowUi
                                    ? uiState.toolbarButtonState
                                    : AdaptiveToolbarButtonVariant.UNKNOWN;
                    setSingleProvider(mSessionButtonVariant);
                    notifyObservers(uiState.canShowUi);
                };

        new OneShotCallback<>(
                profileSupplier, mCallbackController.makeCancelable(this::setProfile));
    }

    /**
     * Adds an instance of a button variant to the collection of buttons managed by {@code
     * AdaptiveToolbarButtonController}.
     *
     * @param variant The button variant of {@code buttonProvider}.
     * @param buttonProvider The provider implementing the button variant. {@code
     *     AdaptiveToolbarButtonController} takes ownership of the provider and will {@link
     *     #destroy()} it, once the provider is no longer needed.
     */
    public void addButtonVariant(
            @AdaptiveToolbarButtonVariant int variant, ButtonDataProvider buttonProvider) {
        assert variant >= 0 && variant <= AdaptiveToolbarButtonVariant.MAX_VALUE
                : "invalid adaptive button variant: " + variant;
        assert variant != AdaptiveToolbarButtonVariant.UNKNOWN
                : "must not provide UNKNOWN button provider";
        assert variant != AdaptiveToolbarButtonVariant.NONE
                : "must not provide NONE button provider";

        mButtonDataProviderMap.put(variant, buttonProvider);
    }

    @Override
    // Suppress to observe SharedPreferences, which is discouraged; use another messaging channel
    // instead.
    @SuppressWarnings("UseSharedPreferencesManagerFromChromeCheck")
    public void destroy() {
        setSingleProvider(AdaptiveToolbarButtonVariant.UNKNOWN);
        mObservers.clear();
        mCallbackController.destroy();
        ContextUtils.getAppSharedPreferences().unregisterOnSharedPreferenceChangeListener(this);
        mLifecycleDispatcher.unregister(this);

        Iterator<Map.Entry<Integer, ButtonDataProvider>> it =
                mButtonDataProviderMap.entrySet().iterator();
        while (it.hasNext()) {
            Map.Entry<Integer, ButtonDataProvider> entry = it.next();
            entry.getValue().destroy();
            it.remove();
        }
    }

    private void setSingleProvider(@AdaptiveToolbarButtonVariant int buttonVariant) {
        @Nullable ButtonDataProvider buttonProvider = mButtonDataProviderMap.get(buttonVariant);
        if (mSingleProvider != null) {
            mSingleProvider.removeObserver(this);
        }
        mSingleProvider = buttonProvider;
        if (mSingleProvider != null) {
            mSingleProvider.addObserver(this);
        }
    }

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

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

    @Override
    public ButtonData get(@Nullable Tab tab) {
        final ButtonData receivedButtonData =
                mSingleProvider == null ? null : mSingleProvider.get(tab);
        if (receivedButtonData == null) {
            mOriginalButtonSpec = null;
            return null;
        }

        if (!mIsSessionVariantRecorded
                && receivedButtonData.canShow()
                && receivedButtonData.isEnabled()
                && !receivedButtonData.getButtonSpec().isDynamicAction()) {
            mIsSessionVariantRecorded = true;
            RecordHistogram.recordEnumeratedHistogram(
                    "Android.AdaptiveToolbarButton.SessionVariant",
                    receivedButtonData.getButtonSpec().getButtonVariant(),
                    AdaptiveToolbarButtonVariant.MAX_VALUE + 1);
        }

        mButtonData.setCanShow(receivedButtonData.canShow() && isScreenWideEnoughForButton());
        mButtonData.setEnabled(receivedButtonData.isEnabled());
        final ButtonSpec receivedButtonSpec = receivedButtonData.getButtonSpec();
        // ButtonSpec is immutable, so we keep the previous value when noting changes.
        if (!Objects.equals(receivedButtonSpec, mOriginalButtonSpec)) {
            assert receivedButtonSpec.getOnLongClickListener() == null
                    : "adaptive button variants are expected to not set a long click listener";
            if (mMenuHandler == null) mMenuHandler = createMenuHandler();
            mOriginalButtonSpec = receivedButtonSpec;
            mButtonData.setButtonSpec(
                    new ButtonSpec(
                            receivedButtonSpec.getDrawable(),
                            wrapClickListener(
                                    receivedButtonSpec.getOnClickListener(),
                                    receivedButtonSpec.getButtonVariant()),
                            // Use menu handler only with static actions.
                            receivedButtonSpec.isDynamicAction() ? null : mMenuHandler,
                            receivedButtonSpec.getContentDescription(),
                            receivedButtonSpec.getSupportsTinting(),
                            receivedButtonSpec.getIPHCommandBuilder(),
                            receivedButtonSpec.getButtonVariant(),
                            receivedButtonSpec.getActionChipLabelResId(),
                            receivedButtonSpec.getHoverTooltipTextId(),
                            receivedButtonSpec.getShouldShowHoverHighlight()));
        }
        return mButtonData;
    }

    private static View.OnClickListener wrapClickListener(
            View.OnClickListener receivedListener,
            @AdaptiveToolbarButtonVariant int buttonVariant) {
        return view -> {
            RecordHistogram.recordEnumeratedHistogram(
                    "Android.AdaptiveToolbarButton.Clicked",
                    buttonVariant,
                    AdaptiveToolbarButtonVariant.MAX_VALUE + 1);
            receivedListener.onClick(view);
        };
    }

    @Nullable
    private View.OnLongClickListener createMenuHandler() {
        if (!FeatureList.isInitialized()) return null;
        return mMenuCoordinator.createOnLongClickListener(mMenuClickListener);
    }

    @Override
    public void buttonDataChanged(boolean canShowHint) {
        notifyObservers(canShowHint);
    }

    @VisibleForTesting
    void setProfile(Profile profile) {
        assert mAdaptiveToolbarStatePredictor == null;
        profile = profile.getOriginalProfile();
        mAdaptiveToolbarStatePredictor =
                new AdaptiveToolbarStatePredictor(mContext, profile, mAndroidPermissionDelegate);
        ContextUtils.getAppSharedPreferences().registerOnSharedPreferenceChangeListener(this);

        if (!AdaptiveToolbarFeatures.isCustomizationEnabled()) return;
        mAdaptiveToolbarStatePredictor.recomputeUiState(mUiStateCallback);
        AdaptiveToolbarStats.recordSelectedSegmentFromSegmentationPlatformAsync(
                mContext, mAdaptiveToolbarStatePredictor);
        // We need the menu handler only if the customization feature is on.
        if (mMenuHandler != null) return;
        mMenuHandler = createMenuHandler();
        if (mMenuHandler == null) return;

        // Clearing mOriginalButtonSpec forces a refresh of mButtonData on the next get()
        mOriginalButtonSpec = null;
        notifyObservers(mButtonData.canShow());
    }

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

    private boolean isScreenWideEnoughForButton() {
        return mScreenWidthDp >= AdaptiveToolbarFeatures.getDeviceMinimumWidthForShowingButton();
    }

    /** Returns the {@link ButtonDataProvider} used in a single-variant mode. */
    @Nullable
    public ButtonDataProvider getSingleProviderForTesting() {
        return mSingleProvider;
    }

    @Override
    public void onSharedPreferenceChanged(SharedPreferences sharedPrefs, @Nullable String key) {
        assert mAdaptiveToolbarStatePredictor != null;
        if (ADAPTIVE_TOOLBAR_CUSTOMIZATION_SETTINGS.equals(key)
                || ADAPTIVE_TOOLBAR_CUSTOMIZATION_ENABLED.equals(key)) {
            assert AdaptiveToolbarFeatures.isCustomizationEnabled();
            mAdaptiveToolbarStatePredictor.recomputeUiState(mUiStateCallback);
        }
    }

    /** Called to notify the controller that a dynamic action is available and should be shown. */
    public void showDynamicAction(@AdaptiveToolbarButtonVariant int action) {
        int actionToShow =
                action != AdaptiveToolbarButtonVariant.UNKNOWN ? action : mSessionButtonVariant;
        RecordHistogram.recordEnumeratedHistogram(
                "Android.AdaptiveToolbarButton.Variant.OnPageLoad",
                actionToShow,
                AdaptiveToolbarButtonVariant.MAX_VALUE + 1);
        if (mOriginalButtonSpec != null && mOriginalButtonSpec.getButtonVariant() == actionToShow) {
            return;
        }
        setSingleProvider(actionToShow);
        notifyObservers(true);
    }

    /**
     * Creates a metrics recorder that records the button variant shown for every page load. The
     * metrics is recorded at the start of a new navigation for the old page being shown.
     *
     * @param tabSupplier Supplier of current tab.
     */
    public void initializePageLoadMetricsRecorder(ObservableSupplier<Tab> tabSupplier) {
        if (mPageLoadMetricsRecorder != null) return;
        mPageLoadMetricsRecorder =
                new CurrentTabObserver(
                        tabSupplier,
                        new EmptyTabObserver() {
                            @Override
                            public void onDidStartNavigationInPrimaryMainFrame(
                                    Tab tab, NavigationHandle navigationHandle) {
                                Integer currentVariant = AdaptiveToolbarButtonVariant.UNKNOWN;
                                for (Integer variant : mButtonDataProviderMap.keySet()) {
                                    if (mSingleProvider == mButtonDataProviderMap.get(variant)) {
                                        currentVariant = variant;
                                        break;
                                    }
                                }

                                RecordHistogram.recordEnumeratedHistogram(
                                        "Android.AdaptiveToolbarButton.Variant.OnStartNavigation",
                                        currentVariant,
                                        AdaptiveToolbarButtonVariant.MAX_VALUE + 1);
                            }
                        },
                        null);
    }

    @Override
    public void onConfigurationChanged(Configuration newConfig) {
        if (!mLifecycleDispatcher.isNativeInitializationFinished()
                || mScreenWidthDp == newConfig.screenWidthDp) {
            return;
        }

        boolean wasOldScreenWideEnoughForButton = isScreenWideEnoughForButton();

        mScreenWidthDp = newConfig.screenWidthDp;

        if (wasOldScreenWideEnoughForButton != isScreenWideEnoughForButton()) {
            notifyObservers(mButtonData.canShow());
        }
    }
}