chromium/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/optional_button/OptionalButtonCoordinator.java

// Copyright 2022 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.optional_button;

import android.content.res.ColorStateList;
import android.content.res.Resources;
import android.graphics.Rect;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.ColorInt;
import androidx.annotation.IntDef;

import org.chromium.base.Callback;
import org.chromium.base.FeatureList;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.toolbar.ButtonData;
import org.chromium.chrome.browser.toolbar.ButtonDataImpl;
import org.chromium.chrome.browser.toolbar.adaptive.AdaptiveToolbarFeatures;
import org.chromium.chrome.browser.user_education.IPHCommandBuilder;
import org.chromium.chrome.browser.user_education.UserEducationHelper;
import org.chromium.components.browser_ui.widget.highlight.PulseDrawable.Bounds;
import org.chromium.components.browser_ui.widget.highlight.ViewHighlighter.HighlightParams;
import org.chromium.components.browser_ui.widget.highlight.ViewHighlighter.HighlightShape;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyModelChangeProcessor;
import org.chromium.ui.widget.ViewRectProvider;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.function.BooleanSupplier;

/**
 * The coordinator for a button that may appear on the toolbar whose icon and click handler can be
 * updated with animations.
 */
public class OptionalButtonCoordinator {
    private final OptionalButtonMediator mMediator;
    private final OptionalButtonView mView;
    private final UserEducationHelper mUserEducationHelper;
    private final Supplier<Tracker> mFeatureEngagementTrackerSupplier;
    private Callback<Integer> mTransitionFinishedCallback;
    private IPHCommandBuilder mIphCommandBuilder;

    @IntDef({
        TransitionType.SWAPPING,
        TransitionType.SHOWING,
        TransitionType.HIDING,
        TransitionType.EXPANDING_ACTION_CHIP,
        TransitionType.COLLAPSING_ACTION_CHIP
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface TransitionType {
        int SWAPPING = 0;
        int SHOWING = 1;
        int HIDING = 2;
        int EXPANDING_ACTION_CHIP = 3;
        int COLLAPSING_ACTION_CHIP = 4;
    }

    /**
     * Creates a new instance of OptionalButtonCoordinator
     *
     * @param view An instance of OptionalButtonView to bind to.
     * @param userEducationHelper Used to display highlight the button with IPH if needed.
     * @param transitionRoot ViewGroup that contains all the views that will be affected by our
     *     transitions.
     * @param isAnimationAllowedPredicate A BooleanProvider that is called before all transitions to
     *     determine if said transition should be animated or not.
     * @param featureEngagementTrackerSupplier Provides a {@Tracker} when available.
     */
    public OptionalButtonCoordinator(
            View view,
            UserEducationHelper userEducationHelper,
            ViewGroup transitionRoot,
            BooleanSupplier isAnimationAllowedPredicate,
            Supplier<Tracker> featureEngagementTrackerSupplier) {
        mUserEducationHelper = userEducationHelper;
        PropertyModel model =
                new PropertyModel.Builder(OptionalButtonProperties.ALL_KEYS)
                        .with(
                                OptionalButtonProperties.TRANSITION_FINISHED_CALLBACK,
                                this::onTransitionFinishedCallback)
                        .with(OptionalButtonProperties.TRANSITION_ROOT, transitionRoot)
                        .with(
                                OptionalButtonProperties.IS_ANIMATION_ALLOWED_PREDICATE,
                                isAnimationAllowedPredicate)
                        .build();

        assert view instanceof OptionalButtonView;

        mView = (OptionalButtonView) view;

        PropertyModelChangeProcessor.create(model, mView, OptionalButtonViewBinder::bind);

        mMediator = new OptionalButtonMediator(model);
        mFeatureEngagementTrackerSupplier = featureEngagementTrackerSupplier;
    }

    public void setPaddingStart(int paddingStart) {
        mMediator.setPaddingStart(paddingStart);
    }

    public void setOnBeforeHideTransitionCallback(Runnable onBeforeHideTransitionCallback) {
        mMediator.setOnBeforeHideTransitionCallback(onBeforeHideTransitionCallback);
    }

    /**
     * Sets a callback that's invoked when any transition starts.
     * @param transitionStartedCallback A callback with an integer argument, this argument a value
     *         from {@link TransitionType}.
     */
    public void setTransitionStartedCallback(Callback<Integer> transitionStartedCallback) {
        mMediator.setTransitionStartedCallback(transitionStartedCallback);
    }

    /**
     * Sets a callback that's invoked when any transition is finished.
     * @param transitionFinishedCallback A callback with an integer argument, this argument a value
     *         from {@link TransitionType}.
     */
    public void setTransitionFinishedCallback(Callback<Integer> transitionFinishedCallback) {
        mTransitionFinishedCallback = transitionFinishedCallback;
    }

    /**
     * Updates the button to replace the current action with a new one. If animations are allowed
     * (according to the BooleanSupplier set with setIsAnimationAllowedPredicate) then this update
     * will be animated. Otherwise it'll instantly switch to the new icon.
     * @param buttonData
     */
    public void updateButton(ButtonData buttonData) {
        if (buttonData != null
                && buttonData.getButtonSpec() != null
                && buttonData.getButtonSpec().getIPHCommandBuilder() != null) {
            mIphCommandBuilder = buttonData.getButtonSpec().getIPHCommandBuilder();
            setViewSpecificIphProperties(mIphCommandBuilder);
        } else {
            mIphCommandBuilder = null;
        }

        boolean hasActionChipResourceId =
                buttonData != null
                        && buttonData.getButtonSpec().getActionChipLabelResId()
                                != Resources.ID_NULL;

        // Dynamic buttons include an action chip resource ID by default regardless of variant.
        if (hasActionChipResourceId) {
            // We should only show the action chip if the action chip variant is enabled.
            boolean isActionChipVariant =
                    FeatureList.isInitialized()
                            && AdaptiveToolbarFeatures.shouldShowActionChip(
                                    buttonData.getButtonSpec().getButtonVariant());
            // And if feature engagement allows it.
            Tracker featureEngagementTracker = mFeatureEngagementTrackerSupplier.get();
            boolean shouldShowActionChip =
                    isActionChipVariant
                            && featureEngagementTracker != null
                            && featureEngagementTracker.isInitialized()
                            && featureEngagementTracker.shouldTriggerHelpUI(
                                    FeatureConstants.CONTEXTUAL_PAGE_ACTIONS_ACTION_CHIP);

            if (!shouldShowActionChip) {
                ((ButtonDataImpl) buttonData).updateActionChipResourceId(Resources.ID_NULL);
            }
        }

        // Reset background alpha, in case the IPH onDismiss callback doesn't fire.
        mMediator.setBackgroundAlpha(255);
        mMediator.updateButton(buttonData);
    }

    /**
     * Updates the button to hide it. If animations are allowed (according to the BooleanSupplier
     * set with setIsAnimationAllowedPredicate) then this update will be animated. Otherwise it'll
     * hide instantly.
     */
    public void hideButton() {
        mIphCommandBuilder = null;

        mMediator.updateButton(null);
    }

    /**
     * If there's any transition animation it gets canceled and we fast forward to the next visual
     * state. The TransitionFinished callback is invoked.
     */
    public void cancelTransition() {
        mMediator.cancelTransition();
    }

    /**
     * Updates the foreground color on the icons and label to match the current theme/website color.
     * @param colorStateList
     */
    public void setIconForegroundColor(ColorStateList colorStateList) {
        mMediator.setIconForegroundColor(colorStateList);
    }

    /**
     * Updates the color filter of the background to match the current address bar background color.
     * This color is only used when showing a contextual action button (when {@link
     * #updateButton(ButtonData)} is called with a {@link
     * org.chromium.chrome.browser.toolbar.ButtonData.ButtonSpec} where {@code isDynamicAction()} is
     * true).
     *
     * @param backgroundColor
     */
    public void setBackgroundColorFilter(@ColorInt int backgroundColor) {
        mMediator.setBackgroundColorFilter(backgroundColor);
    }

    public int getViewVisibility() {
        return mView.getVisibility();
    }

    /**
     * Gets the current width of the container view, used by ToolbarPhone for laying out other
     * views.
     */
    public int getViewWidth() {
        return mView.getWidth();
    }

    /**
     * Gets the container for the button, meant to be used by ToolbarPhone for drawing this view
     * into a texture.
     */
    public View getViewForDrawing() {
        return mView;
    }

    /** Gets the underlying ButtonView. */
    public View getButtonViewForTesting() {
        return mView.getButtonView();
    }

    private void onTransitionFinishedCallback(@TransitionType int transitionType) {
        if (mTransitionFinishedCallback != null) {
            mTransitionFinishedCallback.onResult(transitionType);
        }

        if (transitionType == TransitionType.EXPANDING_ACTION_CHIP
                && mFeatureEngagementTrackerSupplier.hasValue()) {
            // Record an event in feature engagement to limit the amount of times we show the action
            // chip.
            Tracker featureEngagementTracker = mFeatureEngagementTrackerSupplier.get();
            featureEngagementTracker.addOnInitializedCallback(
                    isReady -> {
                        if (!isReady) return;
                        featureEngagementTracker.dismissed(
                                FeatureConstants.CONTEXTUAL_PAGE_ACTIONS_ACTION_CHIP);
                    });
        }

        if (mIphCommandBuilder != null) {
            mUserEducationHelper.requestShowIPH(mIphCommandBuilder.build());
            mIphCommandBuilder = null;
        }
    }

    private void setViewSpecificIphProperties(IPHCommandBuilder iphCommandBuilder) {
        HighlightParams highlightParams = new HighlightParams(HighlightShape.CIRCLE);
        highlightParams.setCircleRadius(
                new Bounds() {
                    @Override
                    public float getMaxRadiusPx(Rect bounds) {
                        return mView.getResources().getDisplayMetrics().density * 20;
                    }

                    @Override
                    public float getMinRadiusPx(Rect bounds) {
                        return mView.getResources().getDisplayMetrics().density * 20;
                    }
                });

        // We want this IPH highlight to be on the same position as the button's background which is
        // an ImageView separate from the button's ListMenuButton. IPH highlights are implemented as
        // a drawable set to the view's background (something like:
        // backgroundImageView.setBackground(drawable)). If we try to highlight the background's
        // ImageView nothing will be shown, because the highlight is obstructed by the image. Set
        // callbacks to make the background image transparent so the highlight is visible. This gets
        // reset once the IPH is dismissed.
        iphCommandBuilder.setOnShowCallback(() -> mMediator.setBackgroundAlpha(0));
        iphCommandBuilder.setOnDismissCallback(() -> mMediator.setBackgroundAlpha(255));

        View anchorView = mView;
        View backgroundView = mView.getBackgroundView();
        if (backgroundView != null && backgroundView.getVisibility() != View.GONE) {
            anchorView = backgroundView;
        }
        ViewRectProvider viewRectProvider = new ViewRectProvider(anchorView);
        viewRectProvider.setIncludePadding(false);

        highlightParams.setBoundsRespectPadding(true);
        iphCommandBuilder.setAnchorView(anchorView);
        iphCommandBuilder.setViewRectProvider(viewRectProvider);
        iphCommandBuilder.setHighlightParams(highlightParams);
    }
}