chromium/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabBottomBarDelegate.java

// Copyright 2016 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.customtabs;

import android.app.Activity;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.app.PendingIntent.CanceledException;
import android.content.Intent;
import android.net.Uri;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnLayoutChangeListener;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.RemoteViews;

import androidx.annotation.Nullable;
import androidx.browser.customtabs.CustomTabsIntent;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.browser_controls.BrowserControlsSizer;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider;
import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
import org.chromium.chrome.browser.browserservices.intents.CustomButtonParams;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanelManager.OverlayPanelManagerObserver;
import org.chromium.chrome.browser.compositor.layouts.LayoutManagerImpl;
import org.chromium.chrome.browser.customtabs.content.CustomTabActivityTabProvider;
import org.chromium.chrome.browser.dependency_injection.ActivityScope;
import org.chromium.chrome.browser.night_mode.RemoteViewsWithNightModeInflater;
import org.chromium.chrome.browser.night_mode.SystemNightModeMonitor;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection;
import org.chromium.ui.base.ViewportInsets;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.interpolators.Interpolators;

import java.util.List;

import javax.inject.Inject;

/** Delegate that manages bottom bar area inside of {@link CustomTabActivity}. */
@ActivityScope
public class CustomTabBottomBarDelegate
        implements BrowserControlsStateProvider.Observer, SwipeGestureListener.SwipeHandler {
    private static final String TAG = "CustomTab";
    private static final int SLIDE_ANIMATION_DURATION_MS = 400;

    /**
     * Provides an interface for updating custom button states based on provided parameters.
     *
     * <p>Implementations of this interface should define the logic for determining how a custom
     * button's appearance or behavior should change in response to the given parameters.
     */
    public interface CustomButtonsUpdater {

        /**
         * Updates the state of a bottom bar button based on the provided parameters.
         *
         * @param params The parameters containing information relevant to the button update.
         * @return {@code true} if the button was successfully updated, {@code false} otherwise.
         */
        boolean updateBottomBarButton(CustomButtonParams params);
    }

    private final Activity mActivity;
    private final WindowAndroid mWindowAndroid;
    private final BrowserControlsSizer mBrowserControlsSizer;
    private final BrowserServicesIntentDataProvider mDataProvider;
    private final Supplier<Tab> mTabProvider;
    private final CustomTabNightModeStateController mNightModeStateController;
    private final SystemNightModeMonitor mSystemNightModeMonitor;

    private CustomTabBottomBarView mBottomBarView;
    @Nullable private View mBottomBarContentView;
    @Nullable private CustomButtonsUpdater mCustomButtonsUpdater;
    private PendingIntent mClickPendingIntent;
    private int[] mClickableIDs;
    private boolean mShowShadow = true;
    private @Nullable PendingIntent mSwipeUpPendingIntent;
    private boolean mKeepContentView;

    /**
     * The override height in pixels. A value of -1 is interpreted as "not set" and means it should
     * not be used.
     */
    private int mBottomBarHeightOverride = -1;

    private OnClickListener mBottomBarClickListener =
            new OnClickListener() {
                @Override
                public void onClick(View v) {
                    if (mClickPendingIntent == null) return;
                    Intent extraIntent = new Intent();
                    int originalId = (Integer) v.getTag(R.id.view_id_tag_key);
                    extraIntent.putExtra(CustomTabsIntent.EXTRA_REMOTEVIEWS_CLICKED_ID, originalId);
                    sendPendingIntentWithUrl(
                            mClickPendingIntent, extraIntent, mActivity, mTabProvider);
                }
            };

    @Inject
    public CustomTabBottomBarDelegate(
            Activity activity,
            WindowAndroid windowAndroid,
            BrowserServicesIntentDataProvider dataProvider,
            BrowserControlsSizer browserControlsSizer,
            CustomTabNightModeStateController nightModeStateController,
            SystemNightModeMonitor systemNightModeMonitor,
            CustomTabActivityTabProvider tabProvider,
            CustomTabCompositorContentInitializer compositorContentInitializer) {
        mActivity = activity;
        mWindowAndroid = windowAndroid;
        mDataProvider = dataProvider;
        mBrowserControlsSizer = browserControlsSizer;
        mNightModeStateController = nightModeStateController;
        mSystemNightModeMonitor = systemNightModeMonitor;
        mTabProvider = () -> tabProvider.getTab();
        browserControlsSizer.addObserver(this);
        mKeepContentView = false;
        compositorContentInitializer.addCallback(this::addOverlayPanelManagerObserver);

        Callback<ViewportInsets> insetObserver = this::onViewportInsetChange;
        // TODO(REVIEW): Is it ok this doesn't remove itself?
        mWindowAndroid.getApplicationBottomInsetSupplier().addObserver(insetObserver);
    }

    /** Makes the bottom bar area to show, if any. */
    public void showBottomBarIfNecessary() {
        if (!shouldShowBottomBar()) return;

        getBottomBarView()
                .findViewById(R.id.bottombar_shadow)
                .setVisibility(mShowShadow ? View.VISIBLE : View.GONE);

        if (mDataProvider.getSecondaryToolbarSwipeUpPendingIntent() != null) {
            startListeningForSwipeUpGestures(
                    mDataProvider.getSecondaryToolbarSwipeUpPendingIntent());
        }

        if (mBottomBarContentView != null) {
            getBottomBarView().addView(mBottomBarContentView);
            mBottomBarContentView.addOnLayoutChangeListener(
                    new OnLayoutChangeListener() {
                        @Override
                        public void onLayoutChange(
                                View v,
                                int left,
                                int top,
                                int right,
                                int bottom,
                                int oldLeft,
                                int oldTop,
                                int oldRight,
                                int oldBottom) {
                            mBottomBarContentView.removeOnLayoutChangeListener(this);
                            setBottomControlsHeight(getBottomBarHeight());
                        }
                    });
            return;
        }

        RemoteViews remoteViews = mDataProvider.getBottomBarRemoteViews();
        if (remoteViews != null) {
            RecordUserAction.record("CustomTabsRemoteViewsShown");
            mClickableIDs = mDataProvider.getClickableViewIDs();
            mClickPendingIntent = mDataProvider.getRemoteViewsPendingIntent();
            showRemoteViews(remoteViews);
            return;
        }

        List<CustomButtonParams> items = mDataProvider.getCustomButtonsOnBottombar();
        if (items.isEmpty()) return;
        LinearLayout layout = new LinearLayout(mActivity);
        layout.setId(R.id.custom_tab_bottom_bar_wrapper);
        layout.setBackgroundColor(mDataProvider.getColorProvider().getBottomBarColor());
        for (CustomButtonParams params : items) {
            if (params.showOnToolbar()) continue;
            final PendingIntent pendingIntent = params.getPendingIntent();
            OnClickListener clickListener = null;
            if (pendingIntent != null) {
                clickListener =
                        v -> sendPendingIntentWithUrl(pendingIntent, null, mActivity, mTabProvider);
            }
            layout.addView(
                    params.buildBottomBarButton(mActivity, getBottomBarView(), clickListener));
        }
        getBottomBarView().addView(layout);
    }

    /**
     * Updates the custom buttons on bottom bar area.
     *
     * @param params The {@link CustomButtonParams} that describes the button to update.
     */
    public void updateBottomBarButtons(CustomButtonParams params) {
        if (mCustomButtonsUpdater != null && mCustomButtonsUpdater.updateBottomBarButton(params)) {
            return;
        }
        ImageButton button = (ImageButton) getBottomBarView().findViewById(params.getId());
        button.setContentDescription(params.getDescription());
        button.setImageDrawable(params.getIcon(mActivity));
    }

    /**
     * Sets the updater responsible for managing the state of custom buttons.
     *
     * <p>If the bottom bar view is set with {@link #setBottomBarContentView} you should always
     * provide customButtonsUpdater.
     *
     * @param customButtonsUpdater The {@link CustomButtonsUpdater} implementation that will handle
     *     the logic for updating custom button states, overriding the default logic.
     */
    public void setCustomButtonsUpdater(CustomButtonsUpdater customButtonsUpdater) {
        mCustomButtonsUpdater = customButtonsUpdater;
    }

    /**
     * Updates the RemoteViews on the bottom bar. If the given remote view is null, animates the
     * bottom bar out.
     *
     * @param remoteViews The new remote view hierarchy sent from the client.
     * @param clickableIDs Array of view ids, the onclick event of which is intercepcted by chrome.
     * @param pendingIntent The {@link PendingIntent} that will be sent on clicking event.
     * @return Whether the update is successful.
     */
    public boolean updateRemoteViews(
            RemoteViews remoteViews, int[] clickableIDs, PendingIntent pendingIntent) {
        // If the contentView is already set, it should have priority to keep being displayed over
        // any remote views that are trying to be updated.
        if (mBottomBarContentView != null && mKeepContentView) {
            return false;
        }
        RecordUserAction.record("CustomTabsRemoteViewsUpdated");
        if (remoteViews == null) {
            if (mBottomBarView == null) return false;
            hideBottomBar();
            mClickableIDs = null;
            mClickPendingIntent = null;
            return true;
        } else {
            // TODO: investigate updating the RemoteViews without replacing the entire hierarchy.
            mClickableIDs = clickableIDs;
            mClickPendingIntent = pendingIntent;
            if (getBottomBarView().getChildCount() > 1) getBottomBarView().removeViewAt(1);
            return showRemoteViews(remoteViews);
        }
    }

    /**
     * Updates the {@link PendingIntent} to be sent when the user swipes up from the toolbar.
     * @param pendingIntent The {@link PendingIntent}.
     * @return Whether the update is successful.
     */
    public boolean updateSwipeUpPendingIntent(PendingIntent pendingIntent) {
        if (pendingIntent == null) {
            if (mBottomBarView == null) return false;
            stopListeningForSwipeUpGestures();
        } else {
            startListeningForSwipeUpGestures(pendingIntent);
        }
        return true;
    }

    /** Sets the content of the bottom bar. */
    public void setBottomBarContentView(View view) {
        mBottomBarContentView = view;
    }

    /** Sets the visibility of the bottom bar shadow. */
    public void setShowShadow(boolean show) {
        mShowShadow = show;
    }

    /**
     * Determines the behavior of the bottom bar content view when using RemoteViews.
     *
     * <p>By default, RemoteViews may replace the bottom bar content view. If the bottom bar view
     * set with {@link #setBottomBarContentView} should always displayed, set this value to {@code
     * true}.
     *
     * <p>**Important Note:** Enabling this feature will prevent RemoteViews from being used via
     * {@link #updateRemoteViews}.
     */
    public void setKeepContentView(boolean keep) {
        mKeepContentView = keep;
    }

    /**
     * @return The height of the bottom bar, excluding its top shadow.
     */
    public int getBottomBarHeight() {
        if (!shouldShowBottomBar()
                || mBottomBarView == null
                || mBottomBarView.getChildCount() < 2) {
            return 0;
        }
        if (mBottomBarHeightOverride != -1) return mBottomBarHeightOverride;
        return mBottomBarView.getHeight();
    }

    /**
     * Sets a height override for the bottom bar. If this value is not set, the height of the
     * content is used instead.
     *
     * @param height The override height in pixels. A value of -1 is interpreted as "not set" and
     *     means it will not be used.
     */
    public void setBottomBarHeight(int height) {
        mBottomBarHeightOverride = height;
    }

    /**
     * Gets the {@link ViewGroup} of the bottom bar. If it has not been inflated, inflate it first.
     */
    private ViewGroup getBottomBarView() {
        if (mBottomBarView == null) {
            assert isViewReady() : "The required view stub couldn't be found! (Called too early?)";
            ViewStub bottomBarStub = mActivity.findViewById(R.id.bottombar_stub);
            mBottomBarView = (CustomTabBottomBarView) bottomBarStub.inflate();
        }
        return mBottomBarView;
    }

    public void addOverlayPanelManagerObserver(LayoutManagerImpl layoutDriver) {
        layoutDriver
                .getOverlayPanelManager()
                .addObserver(
                        new OverlayPanelManagerObserver() {
                            @Override
                            public void onOverlayPanelShown() {
                                if (mBottomBarView == null) return;
                                mBottomBarView
                                        .animate()
                                        .alpha(0)
                                        .setInterpolator(
                                                Interpolators.FAST_OUT_SLOW_IN_INTERPOLATOR)
                                        .setDuration(SLIDE_ANIMATION_DURATION_MS)
                                        .withEndAction(
                                                () -> mBottomBarView.setVisibility(View.GONE))
                                        .start();
                            }

                            @Override
                            public void onOverlayPanelHidden() {
                                if (mBottomBarView == null) return;
                                mBottomBarView.setVisibility(View.VISIBLE);
                                mBottomBarView
                                        .animate()
                                        .alpha(1)
                                        .setInterpolator(
                                                Interpolators.FAST_OUT_SLOW_IN_INTERPOLATOR)
                                        .setDuration(SLIDE_ANIMATION_DURATION_MS)
                                        .start();
                            }
                        });
    }

    /**
     * This method remove bottomBarView completely.
     * If you need to hide it temporarily use {@link #hideBottomBar(boolean)}.
     */
    private void hideBottomBar() {
        if (mBottomBarView == null) return;
        stopListeningForSwipeUpGestures();
        mBottomBarView
                .animate()
                .alpha(0f)
                .translationY(mBottomBarView.getHeight())
                .setInterpolator(Interpolators.FAST_OUT_SLOW_IN_INTERPOLATOR)
                .setDuration(SLIDE_ANIMATION_DURATION_MS)
                .withEndAction(
                        new Runnable() {
                            @Override
                            public void run() {
                                ((ViewGroup) mBottomBarView.getParent()).removeView(mBottomBarView);
                                mBottomBarView = null;
                            }
                        })
                .start();
        setBottomControlsHeight(0);
    }

    private void transformViewIds(View view) {
        // Store the old id in a tag. The tag key here does not matter as long
        // as it is unique across all tags.
        view.setTag(R.id.view_id_tag_key, view.getId());
        view.setId(View.NO_ID);
        if (view instanceof ViewGroup group) {
            final int childCount = group.getChildCount();
            for (int i = 0; i < childCount; i++) {
                final View child = group.getChildAt(i);
                transformViewIds(child);
            }
        }
    }

    private boolean showRemoteViews(RemoteViews remoteViews) {
        final View inflatedView =
                RemoteViewsWithNightModeInflater.inflate(
                        remoteViews,
                        getBottomBarView(),
                        mNightModeStateController.isInNightMode(),
                        mSystemNightModeMonitor.isSystemNightModeOn());

        if (inflatedView == null) return false;

        if (mClickableIDs != null && mClickPendingIntent != null) {
            for (int id : mClickableIDs) {
                if (id < 0) return false;
                View view = inflatedView.findViewById(id);
                if (view != null) view.setOnClickListener(mBottomBarClickListener);
            }
        }

        // Set all views' ids to be View.NO_ID to prevent them clashing with
        // chrome's resource ids. See http://crbug.com/1061872
        transformViewIds(inflatedView);

        getBottomBarView().addView(inflatedView, 1);
        inflatedView.addOnLayoutChangeListener(
                new OnLayoutChangeListener() {
                    @Override
                    public void onLayoutChange(
                            View v,
                            int left,
                            int top,
                            int right,
                            int bottom,
                            int oldLeft,
                            int oldTop,
                            int oldRight,
                            int oldBottom) {
                        inflatedView.removeOnLayoutChangeListener(this);
                        setBottomControlsHeight(getBottomBarHeight());
                    }
                });
        return true;
    }

    private static void sendPendingIntentWithUrl(
            PendingIntent pendingIntent,
            Intent extraIntent,
            Activity activity,
            Supplier<Tab> tabProvider) {
        Intent addedIntent = extraIntent == null ? new Intent() : new Intent(extraIntent);
        Tab tab = tabProvider.get();
        if (tab != null) addedIntent.setData(Uri.parse(tab.getUrl().getSpec()));
        try {
            ActivityOptions options = ActivityOptions.makeBasic();
            ApiCompatibilityUtils.setActivityOptionsBackgroundActivityStartMode(options);
            pendingIntent.send(activity, 0, addedIntent, null, null, null, options.toBundle());
        } catch (CanceledException e) {
            Log.e(TAG, "CanceledException when sending pending intent.");
        }
    }

    private boolean shouldShowBottomBar() {
        return mBottomBarContentView != null || mDataProvider.shouldShowBottomBar();
    }

    /**
     * Returns whether the view was or can be inflated.
     * @return True if the ViewStub is present or was inflated. False otherwise.
     */
    private boolean isViewReady() {
        return mBottomBarView != null || mActivity.findViewById(R.id.bottombar_stub) != null;
    }

    // BrowserControlsStateProvider.Observer methods

    @Override
    public void onControlsOffsetChanged(
            int topOffset,
            int topControlsMinHeightOffset,
            int bottomOffset,
            int bottomControlsMinHeightOffset,
            boolean needsAnimate,
            boolean isVisibilityForced) {
        if (mBottomBarView != null) {
            int minHeight = mBrowserControlsSizer.getBottomControlsMinHeight();
            mBottomBarView.setTranslationY(bottomOffset - minHeight);
        }
        // If the bottom bar is not visible use the top controls as a guide to set state.
        int offset = getBottomBarHeight() == 0 ? topOffset : bottomOffset;
        int height =
                getBottomBarHeight() == 0
                        ? mBrowserControlsSizer.getTopControlsHeight()
                        : mBrowserControlsSizer.getBottomControlsHeight();
        // Avoid spamming this callback across process boundaries, by only sending messages at
        // absolute transitions.
        if (Math.abs(offset) == height || offset == 0) {
            CustomTabsConnection.getInstance()
                    .onBottomBarScrollStateChanged(mDataProvider.getSession(), offset != 0);
        }
    }

    @Override
    public void onBottomControlsHeightChanged(
            int bottomControlsHeight, int bottomControlsMinHeight) {
        if (!isViewReady()) return;
        // Bottom offset might not have been received by BrowserControlsManager at this point, so
        // using getBrowserControlHiddenRatio(), http://crbug.com/928903.
        getBottomBarView()
                .setTranslationY(
                        mBrowserControlsSizer.getBrowserControlHiddenRatio() * bottomControlsHeight
                                - mBrowserControlsSizer.getBottomControlsMinHeightOffset());
    }

    /**
     * This method temporarily hides bottomBarView.
     *
     * <p>If you need to remove bottom bar completely use {@link #hideBottomBar()}.
     *
     * @param hidesBottomBar whether bottom bar needs to be hidden.
     */
    public void hideBottomBar(boolean hidesBottomBar) {
        if (hidesBottomBar) {
            // No-op if it is already in hidden state. This keeps bottom controls height from
            // changing inadvertently while it is being updated by other insets.
            if (getBottomBarView().getVisibility() == View.GONE) return;
            getBottomBarView().setVisibility(View.GONE);
            setBottomControlsHeight(0);
        } else {
            getBottomBarView().setVisibility(View.VISIBLE);
            setBottomControlsHeight(getBottomBarHeight());
        }
    }

    private void onViewportInsetChange(ViewportInsets insets) {
        if (mBottomBarView == null) return;
        boolean isKeyboardShowing =
                mWindowAndroid
                        .getKeyboardDelegate()
                        .isKeyboardShowing(mBottomBarView.getContext(), mBottomBarView);

        hideBottomBar(insets.viewVisibleHeightInset > 0 || isKeyboardShowing);
    }

    /**
     * Starts listening for swipe up gesture to send the {@link PendingIntent}.
     * @param pendingIntent The {@link PendingIntent} to be sent.
     */
    private void startListeningForSwipeUpGestures(PendingIntent pendingIntent) {
        if (mBottomBarView == null) return;
        mSwipeUpPendingIntent = pendingIntent;
        mBottomBarView.setSwipeHandler(this);
    }

    private void stopListeningForSwipeUpGestures() {
        if (mBottomBarView == null) return;
        mBottomBarView.setSwipeHandler(null);
        mSwipeUpPendingIntent = null;
    }

    private void setBottomControlsHeight(int height) {
        int minHeight = mBrowserControlsSizer.getBottomControlsMinHeight();
        mBrowserControlsSizer.setBottomControlsHeight(minHeight + height, minHeight);
    }

    // SwipeGestureListener.SwipeHandler methods

    @Override
    public void onSwipeStarted(@ScrollDirection int direction, MotionEvent ev) {
        if (mSwipeUpPendingIntent == null) return;
        // Do not send URL for swipe action.
        sendPendingIntentWithUrl(mSwipeUpPendingIntent, null, mActivity, () -> null);
    }

    @Override
    public boolean isSwipeEnabled(@ScrollDirection int direction) {
        return direction == ScrollDirection.UP
                && getBottomBarView().getVisibility() == View.VISIBLE;
    }

    void setBottomBarViewForTesting(CustomTabBottomBarView bottomBarView) {
        mBottomBarView = bottomBarView;
    }
}