chromium/components/browser_ui/bottomsheet/android/internal/java/src/org/chromium/components/browser_ui/bottomsheet/BottomSheetControllerImpl.java

// Copyright 2018 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.components.browser_ui.bottomsheet;

import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.Supplier;
import org.chromium.components.browser_ui.widget.gesture.BackPressHandler;
import org.chromium.components.browser_ui.widget.scrim.ScrimCoordinator;
import org.chromium.components.browser_ui.widget.scrim.ScrimProperties;
import org.chromium.ui.KeyboardVisibilityDelegate;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.util.TokenHolder;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.PriorityQueue;

/**
 * This class is responsible for managing the content shown by the {@link BottomSheet}. Features
 * wishing to show content in the {@link BottomSheet} UI must implement {@link BottomSheetContent}
 * and call {@link #requestShowContent(BottomSheetContent, boolean)} which will return true if the
 * content was actually shown (see full doc on method).
 */
class BottomSheetControllerImpl implements ManagedBottomSheetController {
    /** The initial capacity for the priority queue handling pending content show requests. */
    private static final int INITIAL_QUEUE_CAPACITY = 1;

    /** A handle to the {@link BottomSheet} that this class controls. */
    private BottomSheet mBottomSheet;

    /**
     * The container that the sheet exists in. This is one layer inside of the root coordinator view
     * to support the view's shadow.
     */
    private ViewGroup mBottomSheetContainer;

    /** A queue for content that is waiting to be shown in the {@link BottomSheet}. */
    private PriorityQueue<BottomSheetContent> mContentQueue;

    /** Whether the controller is already processing a hide request for the tab. */
    private boolean mIsProcessingHideRequest;

    /** Whether the currently processing show request is suppressing existing content. */
    private boolean mIsSuppressingCurrentContent;

    /** A runnable that initializes the bottom sheet when necessary. */
    private Runnable mSheetInitializer;

    /**
     * A list of observers maintained by this controller until the bottom sheet is created, at which
     * point they will be added to the bottom sheet.
     */
    private List<BottomSheetObserver> mPendingSheetObservers;

    /** The state of the sheet so it can be returned to what it was prior to suppression. */
    @SheetState private int mSheetStateBeforeSuppress;

    /** The content being shown prior to the sheet being suppressed. */
    private BottomSheetContent mContentWhenSuppressed;

    /** A means of accessing the ScrimCoordinator. */
    private Supplier<ScrimCoordinator> mScrimCoordinatorSupplier;

    /**
     * A set of tokens for features suppressing the bottom sheet. If this holder has tokens, the
     * sheet is suppressed.
     */
    private final TokenHolder mSuppressionTokens;

    /** A supplier indicating whether back press should be handled by the bottom sheet. */
    private final ObservableSupplierImpl<Boolean> mBackPressStateChangedSupplier =
            new ObservableSupplierImpl<>();

    /**
     * A {@link BackPressHandler} to handle back press when the bottom sheet is open and/or has
     * sheet content.
     */
    private final BackPressHandler mBackPressHandler;

    /** Whether or not always use the fulll width of the container. */
    private final boolean mAlwaysFullWidth;

    /**
     * An observer that observes changes to the bottom sheet content {@code
     * BottomSheetContent#mBackPressStateChangedSupplier} and updates the {@code
     * BottomSheetControllerImpl#mBackPressStateChangedSupplier}.
     */
    private Callback<Boolean> mContentBackPressStateChangedObserver;

    private Supplier<Integer> mEdgeToEdgeBottomInsetSupplier;

    private KeyboardVisibilityDelegate mKeyboardVisibilityDelegate;

    /**
     * Build a new controller of the bottom sheet.
     *
     * @param scrim A supplier of the scrim that shows when the bottom sheet is opened.
     * @param initializedCallback A callback for the sheet being created (as the sheet is not
     *     initialized until first use.
     * @param window A means of accessing the screen size.
     * @param keyboardDelegate A means of hiding the keyboard.
     * @param root The view that should contain the sheet.
     * @param alwaysFullWidth Whether bottom sheet is full-width.
     * @param edgeToEdgeBottomInsetSupplier The supplier of bottom inset when e2e is on.
     */
    public BottomSheetControllerImpl(
            final Supplier<ScrimCoordinator> scrim,
            Callback<View> initializedCallback,
            Window window,
            KeyboardVisibilityDelegate keyboardDelegate,
            Supplier<ViewGroup> root,
            boolean alwaysFullWidth,
            @NonNull Supplier<Integer> edgeToEdgeBottomInsetSupplier) {
        mScrimCoordinatorSupplier = scrim;
        mPendingSheetObservers = new ArrayList<>();
        mSuppressionTokens = new TokenHolder(() -> onSuppressionTokensChanged());
        mAlwaysFullWidth = alwaysFullWidth;
        mEdgeToEdgeBottomInsetSupplier = edgeToEdgeBottomInsetSupplier;
        mKeyboardVisibilityDelegate = keyboardDelegate;
        mSheetInitializer =
                () -> {
                    initializeSheet(initializedCallback, window, keyboardDelegate, root);
                };

        mBackPressHandler =
                new BackPressHandler() {
                    @Override
                    public @BackPressResult int handleBackPress() {
                        assert mBottomSheet != null
                                && !mSuppressionTokens.hasTokens()
                                && mBottomSheet.getCurrentSheetContent() != null;
                        if (Boolean.TRUE.equals(
                                mBottomSheet
                                        .getCurrentSheetContent()
                                        .getBackPressStateChangedSupplier()
                                        .get())) {
                            mBottomSheet.getCurrentSheetContent().onBackPressed();
                            return BackPressResult.SUCCESS;
                        }
                        int sheetState = mBottomSheet.getMinSwipableSheetState();
                        mBottomSheet.setSheetState(sheetState, true, StateChangeReason.BACK_PRESS);
                        return BackPressResult.SUCCESS;
                    }

                    @Override
                    public ObservableSupplier<Boolean> getHandleBackPressChangedSupplier() {
                        return mBackPressStateChangedSupplier;
                    }
                };
    }

    @Override
    public BackPressHandler getBottomSheetBackPressHandler() {
        return mBackPressHandler;
    }

    /**
     * Do the actual initialization of the bottom sheet.
     * @param initializedCallback A callback for the creation of the sheet.
     * @param window A means of accessing the screen size.
     * @param keyboardDelegate A means of hiding the keyboard.
     * @param root The view that should contain the sheet.
     */
    private void initializeSheet(
            Callback<View> initializedCallback,
            Window window,
            KeyboardVisibilityDelegate keyboardDelegate,
            Supplier<ViewGroup> root) {
        mBottomSheetContainer = root.get();
        mBottomSheetContainer.setVisibility(View.VISIBLE);

        LayoutInflater.from(root.get().getContext())
                .inflate(R.layout.bottom_sheet, mBottomSheetContainer);
        mBottomSheet = (BottomSheet) root.get().findViewById(R.id.bottom_sheet);
        initializedCallback.onResult(mBottomSheet);

        mBottomSheet.init(
                window, keyboardDelegate, mAlwaysFullWidth, mEdgeToEdgeBottomInsetSupplier);

        // Initialize the queue with a comparator that checks content priority.
        mContentQueue =
                new PriorityQueue<>(
                        INITIAL_QUEUE_CAPACITY,
                        (content1, content2) -> content1.getPriority() - content2.getPriority());

        PropertyModel scrimProperties =
                new PropertyModel.Builder(ScrimProperties.REQUIRED_KEYS)
                        .with(ScrimProperties.TOP_MARGIN, 0)
                        .with(ScrimProperties.AFFECTS_STATUS_BAR, true)
                        .with(ScrimProperties.ANCHOR_VIEW, mBottomSheet)
                        .with(ScrimProperties.SHOW_IN_FRONT_OF_ANCHOR_VIEW, false)
                        .with(
                                ScrimProperties.CLICK_DELEGATE,
                                () -> {
                                    if (!mBottomSheet.isSheetOpen()) return;
                                    mBottomSheet.setSheetState(
                                            mBottomSheet.getMinSwipableSheetState(),
                                            true,
                                            StateChangeReason.TAP_SCRIM);
                                })
                        .build();

        mBottomSheet.addObserver(
                new EmptyBottomSheetObserver() {
                    /**
                     * Whether the scrim was shown for the last content.
                     * TODO(mdjones): We should try to make sure the content in the sheet is not nulled
                     *                prior to the close event occurring; sheets that don't have a peek
                     *                state make this difficult since the sheet needs to be hidden before it
                     *                is closed.
                     */
                    private boolean mScrimShown;

                    @Override
                    public void onSheetOpened(@StateChangeReason int reason) {
                        if (mBottomSheet.getCurrentSheetContent() != null
                                && mBottomSheet
                                        .getCurrentSheetContent()
                                        .hasCustomScrimLifecycle()) {
                            updateBackPressStateChangedSupplier();
                            return;
                        }

                        mScrimCoordinatorSupplier.get().showScrim(scrimProperties);
                        mScrimShown = true;
                        updateBackPressStateChangedSupplier();
                    }

                    @Override
                    public void onSheetClosed(@StateChangeReason int reason) {
                        // Hide the scrim if the current content doesn't have a custom scrim
                        // lifecycle.
                        if (mScrimShown) {
                            mScrimCoordinatorSupplier.get().hideScrim(true);
                            mScrimShown = false;
                        }

                        // Try to swap contents unless the sheet's content has a custom lifecycle.
                        if (mBottomSheet.getCurrentSheetContent() != null
                                && !mBottomSheet.getCurrentSheetContent().hasCustomLifecycle()) {
                            // If the sheet is closed, it is an opportunity for another content to
                            // try to take its place if it is a higher priority.
                            BottomSheetContent content = mBottomSheet.getCurrentSheetContent();
                            BottomSheetContent nextContent = mContentQueue.peek();
                            if (content != null
                                    && nextContent != null
                                    && nextContent.getPriority() < content.getPriority()) {
                                mContentQueue.add(content);
                                mBottomSheet.setSheetState(SheetState.HIDDEN, true);
                            }
                        }
                        updateBackPressStateChangedSupplier();
                    }

                    @Override
                    public void onSheetStateChanged(@SheetState int state, int reason) {
                        // If hiding request is in progress, destroy the current sheet content being
                        // hidden even when it is in suppressed state. See
                        // https://crbug.com/1057966.
                        if (state != SheetState.HIDDEN
                                || (!mIsProcessingHideRequest && mSuppressionTokens.hasTokens())) {
                            return;
                        }
                        if (mBottomSheet.getCurrentSheetContent() != null
                                && !mIsSuppressingCurrentContent) {
                            mBottomSheet.getCurrentSheetContent().destroy();
                        }
                        mIsSuppressingCurrentContent = false;
                        mIsProcessingHideRequest = false;
                        showNextContent(true);
                        updateBackPressStateChangedSupplier();
                    }

                    @Override
                    public void onSheetContentChanged(BottomSheetContent newContent) {
                        updateBackPressStateChangedSupplier();

                        if (newContent != null) return;

                        // If there are no more things to be shown, the container can avoid layouts.
                        mBottomSheetContainer.setVisibility(View.GONE);
                    }
                });

        // Add any of the pending observers that were added prior to the sheet being created.
        for (int i = 0; i < mPendingSheetObservers.size(); i++) {
            mBottomSheet.addObserver(mPendingSheetObservers.get(i));
        }
        mPendingSheetObservers.clear();

        mSheetInitializer = null;
    }

    @Override
    public void setBrowserControlsHiddenRatio(float ratio) {
        if (mBottomSheet != null) mBottomSheet.setBrowserControlsHiddenRatio(ratio);
    }

    @Override
    public ScrimCoordinator getScrimCoordinator() {
        return mScrimCoordinatorSupplier.get();
    }

    @Override
    public PropertyModel createScrimParams() {
        return new PropertyModel.Builder(ScrimProperties.REQUIRED_KEYS)
                .with(ScrimProperties.TOP_MARGIN, 0)
                .with(ScrimProperties.AFFECTS_STATUS_BAR, true)
                .with(ScrimProperties.ANCHOR_VIEW, mBottomSheet)
                .with(ScrimProperties.SHOW_IN_FRONT_OF_ANCHOR_VIEW, false)
                .with(
                        ScrimProperties.CLICK_DELEGATE,
                        () -> {
                            if (!mBottomSheet.isSheetOpen()) return;
                            mBottomSheet.setSheetState(
                                    mBottomSheet.getMinSwipableSheetState(),
                                    true,
                                    StateChangeReason.TAP_SCRIM);
                        })
                .build();
    }

    // Destroyable implementation.
    @Override
    public void destroy() {
        if (mBottomSheet != null) mBottomSheet.destroy();
    }

    @Override
    public boolean handleBackPress() {
        // If suppressed (therefore invisible), users are likely to expect for Chrome
        // browser, not the bottom sheet, to react. Do not consume the event.
        if (mBottomSheet == null || mSuppressionTokens.hasTokens()) return false;

        // Give the sheet the opportunity to handle the back press itself before falling to the
        // default "close" behavior.
        if (getCurrentSheetContent() != null && getCurrentSheetContent().handleBackPress()) {
            return true;
        }

        if (!mBottomSheet.isSheetOpen()) return false;
        int sheetState = mBottomSheet.getMinSwipableSheetState();
        mBottomSheet.setSheetState(sheetState, true, StateChangeReason.BACK_PRESS);
        return true;
    }

    @Override
    public BottomSheetContent getCurrentSheetContent() {
        return mBottomSheet == null ? null : mBottomSheet.getCurrentSheetContent();
    }

    @Override
    @SheetState
    public int getSheetState() {
        return mBottomSheet == null ? SheetState.HIDDEN : mBottomSheet.getSheetState();
    }

    @Override
    @SheetState
    public int getTargetSheetState() {
        return mBottomSheet == null ? SheetState.NONE : mBottomSheet.getTargetSheetState();
    }

    @Override
    public boolean isSheetOpen() {
        return mBottomSheet != null && mBottomSheet.isSheetOpen();
    }

    @Override
    public boolean isSheetHiding() {
        return mBottomSheet == null ? false : mBottomSheet.isHiding();
    }

    @Override
    public int getCurrentOffset() {
        return mBottomSheet == null ? 0 : (int) mBottomSheet.getCurrentOffsetPx();
    }

    @Override
    public int getContainerHeight() {
        return mBottomSheet != null ? (int) mBottomSheet.getSheetContainerHeight() : 0;
    }

    @Override
    public void addObserver(BottomSheetObserver observer) {
        if (mBottomSheet == null) {
            mPendingSheetObservers.add(observer);
            return;
        }
        mBottomSheet.addObserver(observer);
    }

    @Override
    public void removeObserver(BottomSheetObserver observer) {
        if (mBottomSheet != null) {
            mBottomSheet.removeObserver(observer);
        } else {
            mPendingSheetObservers.remove(observer);
        }
    }

    /** Handle a change in the state of the token holder responsible for the suppression tokens. */
    private void onSuppressionTokensChanged() {
        if (!mSuppressionTokens.hasTokens()) doUnsuppression();
        updateBackPressStateChangedSupplier();
    }

    @Override
    public int suppressSheet(@StateChangeReason int reason) {
        boolean hadTokens = mSuppressionTokens.hasTokens();
        int token = mSuppressionTokens.acquireToken();
        if (!hadTokens && mBottomSheet != null) {
            // Make sure we don't save an invalid final state (particularly "scrolling").
            mSheetStateBeforeSuppress = getTargetSheetState();
            if (mSheetStateBeforeSuppress == SheetState.NONE) {
                mSheetStateBeforeSuppress = getSheetState();
            }

            mContentWhenSuppressed = getCurrentSheetContent();
            mBottomSheet.setSheetState(SheetState.HIDDEN, false, reason);
        }

        return token;
    }

    @Override
    public void unsuppressSheet(int token) {
        mSuppressionTokens.releaseToken(token);
    }

    private void doUnsuppression() {
        if (mBottomSheet == null) return;

        if (mBottomSheet.getCurrentSheetContent() != null) {
            @SheetState
            int openState =
                    mContentWhenSuppressed == getCurrentSheetContent()
                            ? mSheetStateBeforeSuppress
                            : mBottomSheet.getOpeningState();
            mBottomSheet.setSheetState(openState, true);
        } else {
            // In the event the previous content was hidden, try to show the next one.
            showNextContent(true);
        }
        mContentWhenSuppressed = null;
        mSheetStateBeforeSuppress = SheetState.NONE;
    }

    void setSheetStateForTesting(@SheetState int state, boolean animate) {
        mBottomSheet.setSheetState(state, animate);
    }

    View getBottomSheetViewForTesting() {
        return mBottomSheet;
    }

    public void endAnimationsForTesting() {
        mBottomSheet.endAnimations();
    }

    @VisibleForTesting
    public void forceDismissAllContent() {
        clearRequestsAndHide();

        // Handle content that has a custom lifecycle.
        hideContent(mBottomSheet.getCurrentSheetContent(), /* animate= */ true);
    }

    @Override
    public boolean requestShowContent(BottomSheetContent content, boolean animate) {
        if (content == null) {
            throw new RuntimeException("Attempting to show null content in the sheet!");
        }

        if (mBottomSheet == null) mSheetInitializer.run();

        // If already showing (or queued to show) the requested content, do nothing.
        if (content == mBottomSheet.getCurrentSheetContent() || mContentQueue.contains(content)) {
            return content == mBottomSheet.getCurrentSheetContent();
        }

        boolean shouldSwapForPriorityContent =
                mBottomSheet.getCurrentSheetContent() != null
                        && content.getPriority()
                                < mBottomSheet.getCurrentSheetContent().getPriority()
                        && canBottomSheetSwitchContent();

        // Always add the content to the queue, it will be handled after the sheet closes if
        // necessary. If already hidden, |showNextContent| will handle the request.
        mContentQueue.add(content);

        if (mBottomSheet.getCurrentSheetContent() == null && !mSuppressionTokens.hasTokens()) {
            showNextContent(animate);
            return true;
        } else if (shouldSwapForPriorityContent) {
            mIsSuppressingCurrentContent = true;
            mContentQueue.add(mBottomSheet.getCurrentSheetContent());
            if (!mSuppressionTokens.hasTokens()) {
                mBottomSheet.setSheetState(SheetState.HIDDEN, animate);
                return true;
            } else {
                // Since the sheet is already suppressed and hidden, clear the sheet's content if
                // the requested content is higher priority. The unsuppression logic will figure out
                // which content to show next.
                mBottomSheet.showContent(null);
            }
        }
        return false;
    }

    @Override
    public void hideContent(
            BottomSheetContent content, boolean animate, @StateChangeReason int hideReason) {
        if (mBottomSheet == null) return;

        if (content != mBottomSheet.getCurrentSheetContent()) {
            mContentQueue.remove(content);
            return;
        }

        if (mIsProcessingHideRequest) return;

        // Handle showing the next content if it exists.
        if (mBottomSheet.getSheetState() == SheetState.HIDDEN) {
            // If the sheet is already hidden, destroy it and simply show the next content.
            // TODO(mdjones): Add tests to make sure the content is being destroyed as expected.
            if (mBottomSheet.getCurrentSheetContent() != null) {
                mBottomSheet.getCurrentSheetContent().destroy();
            }
            showNextContent(animate);
        } else {
            mIsProcessingHideRequest = true;
            mBottomSheet.setSheetState(SheetState.HIDDEN, animate, hideReason);
        }
    }

    @Override
    public void hideContent(BottomSheetContent content, boolean animate) {
        hideContent(content, animate, StateChangeReason.NONE);
    }

    @Override
    public void expandSheet() {
        if (mBottomSheet == null || mSuppressionTokens.hasTokens() || mBottomSheet.isHiding()) {
            return;
        }

        if (mBottomSheet.getCurrentSheetContent() == null) return;
        mBottomSheet.setSheetState(SheetState.HALF, true);
    }

    @Override
    public boolean collapseSheet(boolean animate) {
        if (mBottomSheet == null || mSuppressionTokens.hasTokens() || mBottomSheet.isHiding()) {
            return false;
        }
        if (mBottomSheet.isSheetOpen() && mBottomSheet.isPeekStateEnabled()) {
            mBottomSheet.setSheetState(SheetState.PEEK, animate);
            return true;
        }
        return false;
    }

    /**
     * Show the next {@link BottomSheetContent} if it is available and peek the sheet. If no content
     * is available the sheet's content is set to null.
     * @param animate Whether the sheet should animate opened.
     */
    private void showNextContent(boolean animate) {
        if (mBottomSheet.getSheetState() != SheetState.HIDDEN) {
            throw new RuntimeException("Showing next content before sheet is hidden!");
        }

        // Make sure the container is visible as it is set to "gone" when there is no content.
        mBottomSheetContainer.setVisibility(View.VISIBLE);

        if (mContentQueue.isEmpty()) {
            mBottomSheet.showContent(null);
            return;
        }

        BottomSheetContent nextContent = mContentQueue.poll();
        if (mBottomSheet.getCurrentSheetContent() != null) {
            mBottomSheet
                    .getCurrentSheetContent()
                    .getBackPressStateChangedSupplier()
                    .removeObserver(mContentBackPressStateChangedObserver);
        }
        if (nextContent != null) {
            mKeyboardVisibilityDelegate.hideKeyboard(mBottomSheetContainer);
            mContentBackPressStateChangedObserver =
                    (contentWillHandleBackPress) -> updateBackPressStateChangedSupplier();
            nextContent
                    .getBackPressStateChangedSupplier()
                    .addObserver(mContentBackPressStateChangedObserver);
        }
        mBottomSheet.showContent(nextContent);
        mBottomSheet.setSheetState(mBottomSheet.getOpeningState(), animate);
    }

    @Override
    public void clearRequestsAndHide() {
        if (mBottomSheet == null) return;

        clearRequests(mContentQueue.iterator());

        BottomSheetContent currentContent = mBottomSheet.getCurrentSheetContent();
        if (currentContent == null || !currentContent.hasCustomLifecycle()) {
            hideContent(currentContent, /* animate= */ true);
        }
        mContentWhenSuppressed = null;
        mSheetStateBeforeSuppress = SheetState.NONE;
    }

    @Override
    public boolean isFullWidth() {
        return mBottomSheet.isFullWidth();
    }

    @Override
    @VisibleForTesting
    public boolean isSmallScreen() {
        return mBottomSheet.isSmallScreen();
    }

    /**
     * Remove all contents from {@code iterator} that don't have a custom lifecycle.
     * @param iterator The iterator whose items must be removed.
     */
    private void clearRequests(Iterator<BottomSheetContent> iterator) {
        while (iterator.hasNext()) {
            if (!iterator.next().hasCustomLifecycle()) {
                iterator.remove();
            }
        }
    }

    /**
     * The bottom sheet cannot change content while it is open, unless the current content returns
     * true from canSuppressInAnyState(). If the user has the bottom sheet open with content that is
     * not suppressable, they are currently engaged in a task and shouldn't be interrupted.
     *
     * @return Whether the sheet currently supports switching its content.
     */
    private boolean canBottomSheetSwitchContent() {
        BottomSheetContent currentContent = mBottomSheet.getCurrentSheetContent();
        if (!mBottomSheet.isSheetOpen()) {
            return true;
        }

        if (currentContent != null && currentContent.canSuppressInAnyState()) {
            assert currentContent.getPriority() == BottomSheetContent.ContentPriority.LOW;
            return true;
        }

        return false;
    }

    boolean hasSuppressionTokensForTesting() {
        return mSuppressionTokens.hasTokens();
    }

    /**
     * Update the supplier to hold true when the sheet is in a valid state and holds sheet content,
     * and when there are no suppression tokens, false otherwise.
     */
    private void updateBackPressStateChangedSupplier() {
        mBackPressStateChangedSupplier.set(
                mBottomSheet != null
                        && !mSuppressionTokens.hasTokens()
                        && mBottomSheet.getCurrentSheetContent() != null
                        && (Boolean.TRUE.equals(
                                        mBottomSheet
                                                .getCurrentSheetContent()
                                                .getBackPressStateChangedSupplier()
                                                .get())
                                || mBottomSheet.isSheetOpen()));
    }
}