chromium/chrome/android/java/src/org/chromium/chrome/browser/customtabs/features/partialcustomtab/PartialCustomTabBottomSheetStrategy.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.customtabs.features.partialcustomtab;

import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;

import static androidx.browser.customtabs.CustomTabsCallback.ACTIVITY_LAYOUT_STATE_BOTTOM_SHEET;
import static androidx.browser.customtabs.CustomTabsCallback.ACTIVITY_LAYOUT_STATE_BOTTOM_SHEET_MAXIMIZED;
import static androidx.browser.customtabs.CustomTabsCallback.ACTIVITY_LAYOUT_STATE_FULL_SCREEN;

import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.res.Configuration;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
import android.os.Handler;
import android.view.GestureDetector;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.view.animation.AccelerateInterpolator;
import android.widget.ImageView;
import android.widget.LinearLayout;

import androidx.annotation.ColorInt;
import androidx.annotation.IntDef;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import androidx.browser.customtabs.CustomTabsCallback;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.swiperefreshlayout.widget.CircularProgressDrawable;

import org.chromium.base.MathUtils;
import org.chromium.base.SysUtils;
import org.chromium.base.ThreadUtils;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
import org.chromium.chrome.browser.customtabs.features.partialcustomtab.ContentGestureListener.GestureState;
import org.chromium.chrome.browser.customtabs.features.toolbar.CustomTabToolbar;
import org.chromium.chrome.browser.fullscreen.FullscreenManager;
import org.chromium.chrome.browser.fullscreen.FullscreenOptions;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.ConfigurationChangedObserver;
import org.chromium.chrome.browser.multiwindow.MultiWindowUtils;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.components.browser_ui.widget.TouchEventObserver;
import org.chromium.components.browser_ui.widget.TouchEventProvider;
import org.chromium.ui.accessibility.AccessibilityState;
import org.chromium.ui.util.ColorUtils;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * CustomTabHeightStrategy for Partial Custom Tab. An instance of this class should be owned by the
 * CustomTabActivity. Refer to {@link
 * https://docs.google.com/document/d/1YuFXHai2JECqAPE_HgamcKid3VTR05GAvJcyb4jaL6o/edit?usp=sharing}
 * for detailed inner workings and issues addressed along the way.
 */
public class PartialCustomTabBottomSheetStrategy extends PartialCustomTabBaseStrategy
        implements ConfigurationChangedObserver,
                ValueAnimator.AnimatorUpdateListener,
                PartialCustomTabHandleStrategy.DragEventCallback,
                TouchEventObserver {
    @VisibleForTesting static final long SPINNER_TIMEOUT_MS = 500;
    @VisibleForTesting static final int BOTTOM_SHEET_MAX_WIDTH_DP_LANDSCAPE = 900;

    /** Minimal height the bottom sheet CCT should show is half of the display height. */
    private static final float MINIMAL_HEIGHT_RATIO = 0.5f;

    /**
     * The maximum height we can snap to is under experiment, we have two branches, 90% of the
     * display height and 100% of the display height. This ratio is used to calculate the 90% of the
     * display height.
     */
    private static final float EXTRA_HEIGHT_RATIO = 0.1f;

    private static final int SPINNER_FADEIN_DURATION_MS = 100;
    private static final int SPINNER_FADEOUT_DURATION_MS = 400;
    private static final int NAVBAR_BUTTON_HIDE_SHOW_DELAY_MS = 150;

    @IntDef({
        HeightStatus.TOP,
        HeightStatus.INITIAL_HEIGHT,
        HeightStatus.TRANSITION,
        HeightStatus.CLOSE
    })
    @Retention(RetentionPolicy.SOURCE)
    @interface HeightStatus {
        int TOP = 0;
        int INITIAL_HEIGHT = 1;
        int TRANSITION = 2;
        int CLOSE = 3;
    }

    private final AnimatorListener mSpinnerFadeoutAnimatorListener;
    private final @Px int mUnclampedInitialHeight;
    private final boolean mIsFixedHeight;
    private final Supplier<TouchEventProvider> mTouchEventProvider;
    private final Supplier<Tab> mTab;

    private CustomTabToolbar.HandleStrategy mHandleStrategy;
    private GestureDetector mGestureDetector;
    private ContentGestureListener mGestureHandler;

    private TabAnimator mTabAnimator;

    private @HeightStatus int mStatus = HeightStatus.INITIAL_HEIGHT;

    private ImageView mSpinnerView;
    private LinearLayout mNavbar;
    private CircularProgressDrawable mSpinner;
    private Runnable mSoftKeyboardRunnable;
    private boolean mStopShowingSpinner;
    private boolean mRestoreAfterFindPage;
    private boolean mContentScrollMayResizeTab;

    // Y offset when a dragging gesture/animation starts.
    private int mMoveStartY;
    private float mOffsetY;

    // Used to initialize the coordinator view (R.id.coordinator) to full-height at the beginning.
    // This is a workaround to an issue of the host app briefly flashing when the tab is resized.
    private boolean mInitFirstHeight;

    public PartialCustomTabBottomSheetStrategy(
            Activity activity,
            BrowserServicesIntentDataProvider intentData,
            Supplier<TouchEventProvider> touchEventProvider,
            Supplier<Tab> tab,
            OnResizedCallback onResizedCallback,
            OnActivityLayoutCallback onActivityLayoutCallback,
            ActivityLifecycleDispatcher lifecycleDispatcher,
            FullscreenManager fullscreenManager,
            boolean isTablet,
            boolean startMaximized,
            PartialCustomTabHandleStrategyFactory handleStrategyFactory) {
        super(
                activity,
                intentData,
                onResizedCallback,
                onActivityLayoutCallback,
                fullscreenManager,
                isTablet,
                handleStrategyFactory);

        mTouchEventProvider = touchEventProvider;
        mTab = tab;

        int animTime = mActivity.getResources().getInteger(android.R.integer.config_mediumAnimTime);
        mTabAnimator = new TabAnimator(this, animTime, this::onMoveEnd);
        lifecycleDispatcher.register(this);
        if (startMaximized) mStatus = HeightStatus.TOP;

        mSpinnerFadeoutAnimatorListener =
                new AnimatorListener() {
                    @Override
                    public void onAnimationStart(Animator animator) {}

                    @Override
                    public void onAnimationRepeat(Animator animator) {}

                    @Override
                    public void onAnimationEnd(Animator animator) {
                        mSpinner.stop();
                        mSpinnerView.setVisibility(View.GONE);
                    }

                    @Override
                    public void onAnimationCancel(Animator animator) {}
                };

        mPositionUpdater = mVersionCompat::updatePosition;

        mUnclampedInitialHeight = intentData.getInitialActivityHeight();
        mIsFixedHeight = intentData.isPartialCustomTabFixedHeight();
        mContentScrollMayResizeTab = intentData.contentScrollMayResizeTab();
        if (mContentScrollMayResizeTab) {
            mGestureHandler = new ContentGestureListener(mTab, this, this::isFullyExpanded);
            mGestureDetector =
                    new GestureDetector(
                            activity, mGestureHandler, ThreadUtils.getUiThreadHandler());
        }
    }

    @Override
    public @PartialCustomTabType int getStrategyType() {
        return PartialCustomTabType.BOTTOM_SHEET;
    }

    @Override
    public @StringRes int getTypeStringId() {
        return R.string.accessibility_partial_custom_tab_bottom_sheet;
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        assert mContentScrollMayResizeTab;
        mGestureDetector.onTouchEvent(e);
        return mGestureHandler.getState() == GestureState.DRAG_TAB;
    }

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        assert mContentScrollMayResizeTab;
        if (mGestureHandler.getState() == GestureState.SCROLL_CONTENT) {
            mTab.get().getContentView().onTouchEvent(e);
            // Do not return here even if motion events are targeted to the content view.
            // We keep feeding the gesture detector so it can monitor the state changes
            // and can switch the target to PCCT when necessary.
        }

        int action = e.getActionMasked();

        // The down event is interpreted above in onInterceptTouchEvent, it does not need to be
        // interpreted a second time.
        if (action != MotionEvent.ACTION_DOWN) mGestureDetector.onTouchEvent(e);

        // If the user is scrolling and the event is a cancel or up action, update scroll state and
        // return. Fling should have already cleared the gesture state. The following is for
        // the non-fling release.
        if (mGestureHandler.getState() != GestureState.NONE
                && (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL)) {
            mGestureHandler.doNonFlingRelease();
        }
        return true;
    }

    private boolean isFullyExpanded() {
        WindowManager.LayoutParams attrs = mActivity.getWindow().getAttributes();
        return attrs.y <= getFullyExpandedY();
    }

    @Override
    public void onShowSoftInput(Runnable softKeyboardRunnable) {
        // Expands to full height to avoid the tab being hidden by the soft keyboard.
        // Necessary only if we're at the initial height status.
        if (isFullHeight() || mStatus != HeightStatus.INITIAL_HEIGHT) {
            softKeyboardRunnable.run();
            return;
        }
        mSoftKeyboardRunnable = softKeyboardRunnable;
        animateTabTo(HeightStatus.TOP, /* autoResize= */ true);
    }

    /**
     * Animates the tab to the position associated with the target status.
     * @param targetStatus Target height status to animate to.
     * @param autoResize {@code true} if the animation starts not by user gesture dragging
     *        the tab but by other UI actions such as soft keyboard display/find-in-page command.
     */
    private void animateTabTo(int targetStatus, boolean autoResize) {
        // Tab cannot be animated to top/initial when running in full height.
        assert !(isFullHeight()
                && (targetStatus == HeightStatus.TOP
                        || targetStatus == HeightStatus.INITIAL_HEIGHT));

        Window window = mActivity.getWindow();
        window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
        WindowManager.LayoutParams attrs = window.getAttributes();

        int end;
        switch (targetStatus) {
            case HeightStatus.TOP:
                // Make the window full height if it is not already. Otherwise background app
                // can flash briefly while expanding.
                if (attrs.height < mDisplayHeight - mNavbarHeight) {
                    attrs.height = mDisplayHeight - mNavbarHeight;
                    window.setAttributes(attrs);
                }
                end = getFullyExpandedY();
                break;
            case HeightStatus.INITIAL_HEIGHT:
                end = initialY();
                break;
            case HeightStatus.CLOSE:
                end = mDisplayHeight;
                if (isFullHeight()) {
                    attrs.y = getFullyExpandedY();
                    window.setAttributes(attrs);
                }
                break;
            default:
                end = 0;
                assert false : "Target status should be either top or initial height";
        }

        mStatus = HeightStatus.TRANSITION;
        if (autoResize) mMoveStartY = window.getAttributes().y;
        mTabAnimator.start(attrs.y, end, targetStatus, autoResize);
    }

    @Override
    public void onPostInflationStartup() {
        super.onPostInflationStartup();

        // Bottom-sheet can start in fullscreen mode. Remove the top margin.
        if (isFullscreen()) setTopMargins(0, 0);
    }

    @Override
    protected void updatePosition() {
        if (isFullscreen() || mActivity.findViewById(android.R.id.content) == null) return;

        initializeHeight();
        positionAtWidth(mVersionCompat.getDisplayWidth());
        if (shouldDrawDividerLine()) {
            resetCoordinatorLayoutInsets();
            drawDividerLine();
        }
        updateShadowOffset();
        maybeInvokeResizeCallback();
        if (!isFixedHeight()) mRestoreAfterFindPage = false;
    }

    private int initialY() {
        return mDisplayHeight - initialHeightInPortraitMode();
    }

    private int initialHeightInPortraitMode() {
        assert !isFullHeight() : "initialHeightInPortraitMode() is used in portrait mode only";
        return MathUtils.clamp(
                mUnclampedInitialHeight,
                mDisplayHeight - mStatusbarHeight,
                (int) (mDisplayHeight * MINIMAL_HEIGHT_RATIO));
    }

    @Override
    public void onToolbarInitialized(
            View coordinatorView, CustomTabToolbar toolbar, @Px int toolbarCornerRadius) {
        super.onToolbarInitialized(coordinatorView, toolbar, toolbarCornerRadius);

        mHandleStrategy =
                new PartialCustomTabHandleStrategy(
                        mActivity, this::isFullHeight, () -> mStatus, this);
        toolbar.setHandleStrategy(mHandleStrategy);
        toolbar.setMinimizeButtonEnabled(false);
        CustomTabDragBar dragBar = mActivity.findViewById(R.id.drag_bar);
        dragBar.setHandleStrategy(mHandleStrategy);
        View dragHandle = mActivity.findViewById(R.id.drag_handle);
        dragHandle.setOnClickListener(v -> onDragBarTapped());

        if (mContentScrollMayResizeTab) {
            mTouchEventProvider.get().addTouchEventObserver(this);
        }
        updateDragBarVisibility();
    }

    private void onDragBarTapped() {
        if (mStatus == HeightStatus.TRANSITION) {
            mStatus = mTabAnimator.getTargetStatus();
            mTabAnimator.cancel();
        }
        int newStatus =
                switch (mStatus) {
                    case HeightStatus.INITIAL_HEIGHT -> HeightStatus.TOP;
                    case HeightStatus.TOP -> HeightStatus.INITIAL_HEIGHT;
                    default -> {
                        assert false : "Invalid height status: " + mStatus;
                        yield HeightStatus.INITIAL_HEIGHT;
                    }
                };
        animateTabTo(newStatus, false);
    }

    // ConfigurationChangedObserver implementation.
    @Override
    public void onConfigurationChanged(Configuration newConfig) {}

    // ValueAnimator.AnimatorUpdateListener implementation.
    @Override
    public void onAnimationUpdate(ValueAnimator valueAnimator) {
        int value = (int) valueAnimator.getAnimatedValue();
        updateWindowPos(value, false);
    }

    @Override
    protected void cleanupImeStateCallback() {
        if (mVersionCompat.setImeStateCallback(null)) {
            mStatus = HeightStatus.INITIAL_HEIGHT;
        }
    }

    @Override
    protected @CustomTabsCallback.ActivityLayoutState int getActivityLayoutState() {
        if (isFullscreen()) {
            return ACTIVITY_LAYOUT_STATE_FULL_SCREEN;
        } else if (isMaximized()) {
            return ACTIVITY_LAYOUT_STATE_BOTTOM_SHEET_MAXIMIZED;
        } else {
            return ACTIVITY_LAYOUT_STATE_BOTTOM_SHEET;
        }
    }

    @Override
    protected void adjustCornerRadius(GradientDrawable d, int radius) {
        View handleView = mActivity.findViewById(R.id.custom_tabs_handle_view);
        View dragBar = handleView.findViewById(R.id.drag_bar);
        ViewGroup.LayoutParams dragBarLayoutParams = dragBar.getLayoutParams();
        dragBarLayoutParams.height = mCachedHandleHeight;
        dragBar.setLayoutParams(dragBarLayoutParams);

        d.mutate();
        d.setCornerRadii(new float[] {radius, radius, radius, radius, 0, 0, 0, 0});
    }

    @Override
    public void setScrimFraction(float scrimFraction) {
        @ColorInt int scrimColor = mActivity.getColor(R.color.default_scrim_color);

        // Drag handle view is not part of CoordinatorLayout. As the root UI scrim changes, the
        // handle view color needs updating to match it. This is a better way than running PCCT's
        // own scrim coordinator since it can apply shape-aware scrim to the handle view that has
        // the rounded corner.
        getDragBarBackground()
                .setColor(ColorUtils.overlayColor(mToolbarColor, scrimColor, scrimFraction));

        ImageView handle = mActivity.findViewById(R.id.drag_handle);
        @ColorInt int handleColor = mActivity.getColor(R.color.drag_handlebar_color_baseline);
        if (scrimFraction > 0.f) {
            handle.setColorFilter(ColorUtils.overlayColor(handleColor, scrimColor, scrimFraction));
        } else {
            handle.clearColorFilter();
        }
    }

    @Override
    public void onFindToolbarShown() {
        if (mIsTablet) return;
        int findToolbarBackground = mActivity.getColor(R.color.find_in_page_background_color);
        getDragBarBackground().setColor(findToolbarBackground);

        if (isFullHeight()) return;

        // We get zero search results if the content view is entirely hidden by the soft keyboard,
        // which can happen if the tab is at initial height. Expand it.
        if (mStatus == HeightStatus.INITIAL_HEIGHT) {
            animateTabTo(HeightStatus.TOP, /* autoResize= */ true);
            mRestoreAfterFindPage = true;
        }
    }

    @Override
    public void onFindToolbarHidden() {
        if (mIsTablet) return;
        getDragBarBackground().setColor(mToolbarColor);

        if (isFullHeight()) return;
        if (mRestoreAfterFindPage) {
            animateTabTo(HeightStatus.INITIAL_HEIGHT, /* autoResize= */ true);
            mRestoreAfterFindPage = false;
        }
    }

    @Override
    protected void initializeHeight() {
        super.initializeHeight();

        int maxExpandedY = getFullyExpandedY();
        @Px int height = 0;

        if (!isFullHeight()) {
            if (mStatus == HeightStatus.INITIAL_HEIGHT) {
                height = initialHeightInPortraitMode();
            } else if (mStatus == HeightStatus.TOP) {
                height = mDisplayHeight - maxExpandedY;
            }
        }

        WindowManager.LayoutParams attrs = mActivity.getWindow().getAttributes();
        if (attrs.height == height) return;

        // To avoid the bottom navigation bar area flickering when starting dragging, position
        // web contents area right above the navigation bar so the two won't overlap. The
        // navigation area now just shows whatever is underneath: 1) loading view/web contents
        // while dragging 2) host app's navigation bar when at rest.
        positionAtHeight(height);
        if (!mInitFirstHeight) {
            setCoordinatorLayoutHeight(mDisplayHeight);
            mInitFirstHeight = true;
            new Handler().post(() -> setCoordinatorLayoutHeight(MATCH_PARENT));
        }
        updateDragBarVisibility();
    }

    private void positionAtWidth(int width) {
        WindowManager.LayoutParams attrs = mActivity.getWindow().getAttributes();
        if (isFullHeight() || isFullscreen()) {
            attrs.width = MATCH_PARENT;
            attrs.x = 0;
        } else {
            int x = 0;
            if (isLandscapeMaxWidth(width)) {
                float density = mActivity.getResources().getDisplayMetrics().density;
                width = (int) (BOTTOM_SHEET_MAX_WIDTH_DP_LANDSCAPE * density);
                x = (mDisplayWidth - width) / 2 + mVersionCompat.getXOffset();
            }
            attrs.width = width;
            attrs.x = x;
        }

        mActivity.getWindow().setAttributes(attrs);
    }

    private void positionAtHeight(int height) {
        WindowManager.LayoutParams attrs = mActivity.getWindow().getAttributes();
        if (isFullHeight() || isFullscreen()) {
            attrs.height = MATCH_PARENT;
            attrs.y = 0;
        } else {
            attrs.height = height - mNavbarHeight;
            attrs.y = mDisplayHeight - attrs.height - mNavbarHeight;
        }

        attrs.gravity = Gravity.TOP | Gravity.START;

        mActivity.getWindow().setAttributes(attrs);
    }

    private boolean isMaxWidthLandscapeBottomSheet() {
        int displayWidth = mVersionCompat.getDisplayWidth();
        return isLandscapeMaxWidth(displayWidth);
    }

    private void updateDragBarVisibility() {
        updateDragBarVisibility(
                /*dragHandlebarVisibility*/ isFixedHeight() ? View.GONE : View.VISIBLE);
    }

    @Override
    protected void setTopMargins(int shadowOffset, int handleOffset) {
        View handleView = mActivity.findViewById(R.id.custom_tabs_handle_view);
        boolean isMaxWidthLandscapeBottomSheet = isMaxWidthLandscapeBottomSheet();

        float maxWidthBottomSheetEv =
                mActivity.getResources().getDimensionPixelSize(R.dimen.default_elevation_2);
        float regBottomSheetEv =
                mActivity.getResources().getDimensionPixelSize(R.dimen.custom_tabs_elevation);
        float elevation = isMaxWidthLandscapeBottomSheet ? maxWidthBottomSheetEv : regBottomSheetEv;

        ViewGroup coordinatorLayout = mActivity.findViewById(R.id.coordinator);
        coordinatorLayout.setElevation(elevation);
        if (handleView != null) {
            handleView.setElevation(elevation);
        }

        int sideOffset =
                shouldDrawDividerLine() || isFullscreen()
                        ? 0
                        : mActivity
                                .getResources()
                                .getDimensionPixelSize(R.dimen.custom_tabs_shadow_offset);
        int sideMargin = isMaxWidthLandscapeBottomSheet ? sideOffset : 0;
        if (handleView != null) {
            ViewGroup.MarginLayoutParams lp =
                    (ViewGroup.MarginLayoutParams) handleView.getLayoutParams();
            lp.setMargins(sideMargin, shadowOffset, sideMargin, 0);
        }

        // Make enough room for the handle View.
        ViewGroup.MarginLayoutParams mlp =
                (ViewGroup.MarginLayoutParams) mToolbarCoordinator.getLayoutParams();
        mlp.setMargins(sideMargin, handleOffset, sideMargin, 0);
    }

    @Override
    protected int getHandleHeight() {
        return isFullHeight() ? 0 : mCachedHandleHeight;
    }

    @Override
    protected boolean shouldHaveNoShadowOffset() {
        return mStatus == HeightStatus.TOP
                || mActivity.getWindow().getAttributes().y <= getFullyExpandedY();
    }

    @Override
    protected boolean isFullHeight() {
        return MultiWindowUtils.getInstance().isInMultiWindowMode(mActivity);
    }

    private boolean isLandscape() {
        return mOrientation == Configuration.ORIENTATION_LANDSCAPE;
    }

    private boolean isFixedHeight() {
        return mIsFixedHeight;
    }

    private void updateWindowPos(@Px int y, boolean userGesture) {
        // Do not allow the Window to go above the minimum threshold capped by the status
        // bar and (optionally) the 90%-height adjustment.
        int topY = getFullyExpandedY();
        y = MathUtils.clamp(y, topY, mDisplayHeight);
        Window window = mActivity.getWindow();
        WindowManager.LayoutParams attrs = window.getAttributes();
        if (attrs.y == y) return;

        // If the tab is not resizable then dragging it higher than the initial height will not be
        // allowed. The tab can still be dragged down in order to be closed.
        if (isFixedHeight() && userGesture && y < initialY()) return;

        attrs.y = y;
        window.setAttributes(attrs);
        if (mFinishRunnable != null) return;

        // Starting dragging from INITIAL_HEIGHT state, we can hide the spinner if the tab:
        // 1) reaches full height
        // 2) is dragged below the initial height
        if (mStatus == HeightStatus.INITIAL_HEIGHT
                && (y <= topY || y > initialY())
                && isSpinnerVisible()) {
            hideSpinnerView();
            if (y <= topY) {
                // Once reaching full-height, tab can hide the spinner permanently till
                // the finger is lifted. Keep it hidden.
                mStopShowingSpinner = true;
                return;
            }
        }
        // Show the spinner lazily, only when the tab is dragged _up_, which requires showing
        // more area than initial state.
        if (!mStopShowingSpinner
                && mStatus != HeightStatus.TRANSITION
                && !isSpinnerVisible()
                && y < mMoveStartY) {
            showSpinnerView();
            // We do not have to keep the spinner till the end of dragging action, since it doesn't
            // have the flickering issue at the end. Keeping it visible up to 500ms is sufficient to
            // hide the initial glitch that can briefly expose the host app screen at the beginning.
            new Handler()
                    .postDelayed(
                            () -> {
                                hideSpinnerView();
                                mStopShowingSpinner = true;
                            },
                            SPINNER_TIMEOUT_MS);
        }
        if (isSpinnerVisible()) {
            centerSpinnerVertically((ViewGroup.LayoutParams) mSpinnerView.getLayoutParams());
        }
    }

    private boolean isSpinnerVisible() {
        return mSpinnerView != null && mSpinnerView.getVisibility() == View.VISIBLE;
    }

    private void onMoveStart() {
        Window window = mActivity.getWindow();
        window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
        WindowManager.LayoutParams attrs = window.getAttributes();
        attrs.height = mDisplayHeight;
        window.setAttributes(attrs);
        showNavbarButtons(false);
    }

    private void onMoveEnd() {
        mStatus = mTabAnimator.getTargetStatus();
        if (mFinishRunnable != null) {
            mFinishRunnable.run();
            return;
        }

        hideSpinnerView();
        showNavbarButtons(true);
        finishResizing(mStatus);
        updateShadowOffset();
        if (shouldDrawDividerLine()) drawDividerLine();
        if (mSoftKeyboardRunnable != null) {
            mSoftKeyboardRunnable.run();
            mSoftKeyboardRunnable = null;
            mVersionCompat.setImeStateCallback(this::onImeStateChanged);
        }

        if (AccessibilityState.isScreenReaderEnabled()) {
            int textId =
                    mStatus == HeightStatus.TOP
                            ? R.string.accessibility_custom_tab_expanded
                            : R.string.accessibility_custom_tab_collapsed;
            getCoordinatorLayout()
                    .announceForAccessibility(mActivity.getResources().getString(textId));
        }
    }

    @VisibleForTesting
    void onImeStateChanged(boolean imeVisible) {
        if (!imeVisible) {
            // Soft keyboard was hidden. Restore the tab to initial height state.
            mVersionCompat.setImeStateCallback(null);
            animateTabTo(HeightStatus.INITIAL_HEIGHT, /* autoResize= */ true);
        }
    }

    private void finishResizing(int targetStatus) {
        Window window = mActivity.getWindow();
        window.clearFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
        positionAtHeight(mDisplayHeight - window.getAttributes().y);
        maybeInvokeResizeCallback();
        mStatus = targetStatus;
        if (mFinishRunnable != null) {
            Runnable oldFinishRunnable = mFinishRunnable;
            mFinishRunnable = null;
            handleCloseAnimation(oldFinishRunnable);
        }
    }

    private void hideSpinnerView() {
        // TODO(crbug.com/40226472): Look into observing a view resize event to ensure the fade
        // animation can always cover the transition artifact.
        if (isSpinnerVisible()) {
            mSpinnerView
                    .animate()
                    .alpha(0f)
                    .setDuration(SPINNER_FADEOUT_DURATION_MS)
                    .setListener(mSpinnerFadeoutAnimatorListener);
        }
    }

    private void showSpinnerView() {
        if (mSpinnerView != null) {
            centerSpinnerVertically((ViewGroup.LayoutParams) mSpinnerView.getLayoutParams());
        } else {
            mSpinnerView = new ImageView(mActivity);
            mSpinnerView.setElevation(
                    mActivity.getResources().getDimensionPixelSize(R.dimen.custom_tabs_elevation));
            mSpinnerView.setBackgroundColor(mActivity.getColor(R.color.window_background_color));

            // Toolbar should not be hidden by spinner screen.
            ViewGroup.MarginLayoutParams lp = new ViewGroup.MarginLayoutParams(MATCH_PARENT, 0);
            int topMargin = mToolbarView.getHeight();
            lp.setMargins(0, topMargin, 0, 0);

            mSpinner = new CircularProgressDrawable(mActivity);
            mSpinner.setStyle(CircularProgressDrawable.LARGE);
            mSpinnerView.setImageDrawable(mSpinner);
            mSpinnerView.setScaleType(ImageView.ScaleType.CENTER);
            int[] colorList = new int[1];
            colorList[0] = mActivity.getColor(R.color.default_bg_color_blue);
            mSpinner.setColorSchemeColors(colorList);
            centerSpinnerVertically(lp);
        }
        // Spinner view is added to CoordinatorLayoutForPointer (not R.id.content) to obscure
        // the flickering at the beginning of dragging action.
        if (mSpinnerView.getParent() == null) getCoordinatorLayout().addView(mSpinnerView);
        mSpinnerView.clearAnimation();
        mSpinnerView.setAlpha(0.f);
        mSpinnerView.setVisibility(View.VISIBLE);
        mSpinnerView.animate().alpha(1.f).setDuration(SPINNER_FADEIN_DURATION_MS).setListener(null);
        mSpinner.start();
    }

    private void centerSpinnerVertically(ViewGroup.LayoutParams lp) {
        int toolbarHeight = mToolbarView.getHeight();
        int cctHeight = mDisplayHeight - mActivity.getWindow().getAttributes().y - toolbarHeight;
        lp.height = cctHeight;
        mSpinnerView.setLayoutParams(lp);
    }

    private void changeVisibilityNavbarButtons(boolean show) {
        View decorView = mActivity.getWindow().getDecorView();
        WindowInsetsControllerCompat controller =
                WindowCompat.getInsetsController(mActivity.getWindow(), decorView);
        if (show) {
            controller.show(WindowInsetsCompat.Type.navigationBars());
        } else {
            // Can we remove the slow fade-out animation?
            controller.hide(WindowInsetsCompat.Type.navigationBars());
        }
    }

    private void showNavbarButtons(boolean show) {
        // Resizing while the navbar buttons are visible, at times, flashes the host app.
        // http://crbug/1360425 fixed this for when the navbar buttons are hidden, so taking
        // advantage of that fix by hiding for a bit the navigation buttons, during the time the
        // flashing usually occurs. The navbar buttons need to be visible while resizing so that
        // the immersive mode confirmation dialog is not displayed, as fixed with
        // http://crbug/1360453
        // TODO: http://crbug/1373984 for follow-up on long term solution for fixing host app
        // flashing issues.
        if (!show) {
            changeVisibilityNavbarButtons(false);
            new Handler()
                    .postDelayed(
                            () -> {
                                changeVisibilityNavbarButtons(true);
                            },
                            NAVBAR_BUTTON_HIDE_SHOW_DELAY_MS);
        }
    }

    @VisibleForTesting
    @Px
    int getFullyExpandedY() {
        return mStatusbarHeight;
    }

    @Override
    protected boolean isMaximized() {
        return mStatus == HeightStatus.TOP;
    }

    // CustomTabHeightStrategy implementation

    @Override
    public boolean changeBackgroundColorForResizing() {
        // Need to return true to keep the transparent background we set in the init step.
        return true;
    }

    @Override
    public boolean handleCloseAnimation(Runnable finishRunnable) {
        if (!super.handleCloseAnimation(finishRunnable)) return false;

        mVersionCompat.setImeStateCallback(null);

        // Tapping the close button while in transition state should be ignored.
        // Delay it till the height settles in to either top/initial state, where the animation
        // begins when it detects the presence of |mFinishRunnable|.
        if (mStatus == HeightStatus.TRANSITION) return false;

        animateTabTo(HeightStatus.CLOSE, /* autoResize= */ true);
        return true;
    }

    // DragEventCallback implementation

    @Override
    public void onDragStart(int y) {
        onMoveStart();
        Window window = mActivity.getWindow();
        mMoveStartY = window.getAttributes().y;
        mOffsetY = mMoveStartY - y;
        mStopShowingSpinner = false;
    }

    @Override
    public void onDragMove(int y) {
        updateWindowPos((int) (y + mOffsetY), true);
    }

    @Override
    public boolean onDragEnd(int flingDistance) {
        int currentY = mActivity.getWindow().getAttributes().y;
        int finalY = currentY + flingDistance;
        int topY = getFullyExpandedY();
        int initialY = initialY();
        int bottomY = mDisplayHeight - mNavbarHeight;

        if (finalY < initialY) { // Move up
            boolean toTop = Math.abs(topY - finalY) < Math.abs(finalY - initialY);
            animateTabTo(
                    toTop && !isFixedHeight() ? HeightStatus.TOP : HeightStatus.INITIAL_HEIGHT,
                    /* autoResize= */ false);
            return true;
        } else { // Move down
            // Prevents skipping initial state when swiping from the top.
            if (mStatus == HeightStatus.TOP) finalY = Math.min(initialY, finalY);

            if (Math.abs(initialY - finalY) < Math.abs(finalY - bottomY)) {
                animateTabTo(HeightStatus.INITIAL_HEIGHT, /* autoResize= */ false);
                return true;
            }
        }
        // Tab is being closed. Animation is initiated in |handleCloseAnimation()|.
        return false;
    }

    // FullscreenManager.Observer implementation

    @Override
    public void onEnterFullscreen(Tab tab, FullscreenOptions options) {
        // Enter fullscreen i.e. (x, y, height, width) = (0, 0, MATCH_PARENT, MATCH_PARENT)
        assert isFullscreen() : "Fullscreen mode should be on";
        positionAtHeight(/* height= */ 0); // |height| is not used
        positionAtWidth(/* width= */ 0); // |width| is not used
        setTopMargins(0, 0);
        maybeInvokeResizeCallback();
    }

    @Override
    public void onExitFullscreen(Tab tab) {
        // Ignore the notification coming before toolbar/post-inflation initialization, which
        // can happen when devices gets rotated while fullscreen video is playing.
        if (mHandleStrategy == null) return;

        // System UI (navigation/status bar) dimensions still remain zero at this point.
        // For the restoring job that needs these values, we wait till they get reported
        // correctly by posting the task instead of executing them right away.
        new Handler().post(this::restoreWindow);
    }

    @Override
    protected void drawDividerLine() {
        int width =
                mActivity.getResources().getDimensionPixelSize(R.dimen.custom_tabs_outline_width);
        boolean maxWidthBottomSheet = isMaxWidthLandscapeBottomSheet();
        int dividerSideInset = maxWidthBottomSheet ? width : 0;
        int dividerTopInset = shouldHaveNoShadowOffset() ? 0 : width;

        drawDividerLineBase(dividerSideInset, dividerTopInset, dividerSideInset);
    }

    @Override
    protected boolean shouldDrawDividerLine() {
        // Elevation shadows are only rendered properly on devices >= Android Q
        return SysUtils.isLowEndDevice() || Build.VERSION.SDK_INT < Build.VERSION_CODES.Q;
    }

    // Restore the window upon exiting fullscreen.
    private void restoreWindow() {
        initializeHeight();
        positionAtWidth(mVersionCompat.getDisplayWidth());
        updateShadowOffset();
        maybeInvokeResizeCallback();

        // Status/navigation bar are not restored on T+. This makes host app visible
        // at the area. To work around this, simulate user dragging the tab by 1 pixel
        // upon exiting fullscreen.
        if (!isFullHeight() && Build.VERSION.SDK_INT > Build.VERSION_CODES.S) {
            int startY = mActivity.getWindow().getAttributes().y;
            onDragStart(startY);
            onDragMove(startY + 1);
            onDragEnd(0);
        }
    }

    private boolean isLandscapeMaxWidth(int width) {
        float density = mActivity.getResources().getDisplayMetrics().density;
        return isLandscape() && width > BOTTOM_SHEET_MAX_WIDTH_DP_LANDSCAPE * density;
    }

    @Override
    public void destroy() {
        if (mContentScrollMayResizeTab && mTouchEventProvider.get() != null) {
            mTouchEventProvider.get().removeTouchEventObserver(this);
        }
    }

    void setMockViewForTesting(
            LinearLayout navbar,
            ImageView spinnerView,
            CircularProgressDrawable spinner,
            CustomTabToolbar toolbar,
            View toolbarCoordinator,
            PartialCustomTabHandleStrategyFactory handleStrategyFactory) {
        mNavbar = navbar;
        mSpinnerView = spinnerView;
        mSpinner = spinner;
        mToolbarView = toolbar;
        mToolbarCoordinator = toolbarCoordinator;
        mHandleStrategyFactory = handleStrategyFactory;

        mPositionUpdater = this::updatePosition;
        onPostInflationStartup();
    }

    int getNavbarHeightForTesting() {
        return mNavbarHeight;
    }

    CustomTabToolbar.HandleStrategy getHandleStrategyForTesting() {
        return mHandleStrategy;
    }

    CustomTabToolbar.HandleStrategy createHandleStrategyForTesting() {
        // Pass null for context because we don't depend on the GestureDetector inside as we invoke
        // MotionEvents directly in the tests.
        mHandleStrategy =
                new PartialCustomTabHandleStrategy(null, this::isFullHeight, () -> mStatus, this);
        return mHandleStrategy;
    }

    void setToolbarColorForTesting(int toolbarColor) {
        mToolbarColor = toolbarColor;
    }

    void setGestureObjectsForTesting(GestureDetector detector, ContentGestureListener listener) {
        mGestureDetector = detector;
        mGestureHandler = listener;
    }

    // Wrapper around Animator class, also holding the information to use after the animation ends.
    private static class TabAnimator {
        private final ValueAnimator mAnimator;
        private @HeightStatus int mTargetStatus;
        private boolean mAutoResize;

        private TabAnimator(
                ValueAnimator.AnimatorUpdateListener listener,
                int animTime,
                Runnable finishRunnable) {
            mAnimator = new ValueAnimator();
            mAnimator.addListener(
                    new AnimatorListenerAdapter() {
                        @Override
                        public void onAnimationStart(Animator animation) {}

                        @Override
                        public void onAnimationEnd(Animator animation) {
                            finishRunnable.run();
                        }
                    });
            mAnimator.addUpdateListener(listener);
            mAnimator.setInterpolator(new AccelerateInterpolator());
            mAnimator.setDuration(animTime);
        }

        private void start(int startY, int endY, int targetStatus, boolean autoResize) {
            mTargetStatus = targetStatus;
            mAutoResize = autoResize;
            mAnimator.setIntValues(startY, endY);
            mAnimator.start();
        }

        private @HeightStatus int getTargetStatus() {
            return mTargetStatus;
        }

        private boolean wasAutoResized() {
            return mAutoResize;
        }

        private void cancel() {
            mAnimator.cancel();
        }
    }
}