chromium/chrome/android/java/src/org/chromium/chrome/browser/infobar/InfoBarContainer.java

// Copyright 2013 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.infobar;

import android.app.Activity;
import android.view.View;
import android.view.ViewGroup;

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

import org.jni_zero.CalledByNative;
import org.jni_zero.NativeMethods;

import org.chromium.base.ObserverList;
import org.chromium.base.UserData;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.app.ChromeActivity;
import org.chromium.chrome.browser.fullscreen.BrowserControlsManager;
import org.chromium.chrome.browser.fullscreen.BrowserControlsManagerSupplier;
import org.chromium.chrome.browser.fullscreen.FullscreenManager;
import org.chromium.chrome.browser.fullscreen.FullscreenOptions;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.ui.edge_to_edge.EdgeToEdgeController;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetControllerProvider;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetObserver;
import org.chromium.components.browser_ui.bottomsheet.EmptyBottomSheetObserver;
import org.chromium.components.infobars.InfoBar;
import org.chromium.components.infobars.InfoBarAnimationListener;
import org.chromium.components.infobars.InfoBarUiItem;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.KeyboardVisibilityDelegate.KeyboardVisibilityListener;
import org.chromium.ui.accessibility.AccessibilityState;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.WindowAndroid;

import java.util.ArrayList;

/**
 * A container for all the infobars of a specific tab.
 * Note that infobars creation can be initiated from Java or from native code.
 * When initiated from native code, special code is needed to keep the Java and native infobar in
 * sync, see NativeInfoBar.
 */
public class InfoBarContainer implements UserData, KeyboardVisibilityListener, InfoBar.Container {
    private static final Class<InfoBarContainer> USER_DATA_KEY = InfoBarContainer.class;

    private static final AccessibilityState.Listener sAccessibilityStateListener;

    static {
        sAccessibilityStateListener =
                (oldAccessibilityState, newAccessibilityState) -> {
                    setIsAllowedToAutoHide(
                            !newAccessibilityState.isTouchExplorationEnabled
                                    && !newAccessibilityState.isPerformGesturesEnabled);
                };
        AccessibilityState.addListener(sAccessibilityStateListener);
    }

    /** An observer that is notified of changes to a {@link InfoBarContainer} object. */
    public interface InfoBarContainerObserver {
        /**
         * Called when an {@link InfoBar} is about to be added (before the animation).
         * @param container The notifying {@link InfoBarContainer}
         * @param infoBar An {@link InfoBar} being added
         * @param isFirst Whether the infobar container was empty
         */
        void onAddInfoBar(InfoBarContainer container, InfoBar infoBar, boolean isFirst);

        /**
         * Called when an {@link InfoBar} is about to be removed (before the animation).
         * @param container The notifying {@link InfoBarContainer}
         * @param infoBar An {@link InfoBar} being removed
         * @param isLast Whether the infobar container is going to be empty
         */
        void onRemoveInfoBar(InfoBarContainer container, InfoBar infoBar, boolean isLast);

        /**
         * Called when the InfobarContainer is attached to the window.
         * @param hasInfobars True if infobar container has infobars to show.
         */
        void onInfoBarContainerAttachedToWindow(boolean hasInfobars);

        /**
         * A notification that the shown ratio of the infobar container has changed.
         * @param container The notifying {@link InfoBarContainer}
         * @param shownRatio The shown ratio of the infobar container.
         */
        void onInfoBarContainerShownRatioChanged(InfoBarContainer container, float shownRatio);
    }

    /** Resets the state of the InfoBarContainer when the user navigates. */
    private final TabObserver mTabObserver =
            new EmptyTabObserver() {
                @Override
                public void onDidStartNavigationInPrimaryMainFrame(
                        Tab tab, NavigationHandle navigationHandle) {
                    // Make sure Y translation is reset on navigation.
                    if (mInfoBarContainerView != null) {
                        mInfoBarContainerView.setTranslationY(0);
                    }
                }

                @Override
                public void onDidFinishNavigationInPrimaryMainFrame(
                        Tab tab, NavigationHandle navigation) {
                    if (navigation.hasCommitted()) {
                        setHidden(false);
                    }
                }

                @Override
                public void onContentChanged(Tab tab) {
                    updateWebContents();
                }

                @Override
                public void onActivityAttachmentChanged(Tab tab, @Nullable WindowAndroid window) {
                    if (window != null) {
                        initializeContainerView(getActivity(tab));
                        updateWebContents();
                    } else {
                        destroyContainerView();
                    }
                }
            };

    /**
     * Adds/removes the {@link InfoBarContainer} when the tab's view is attached/detached. This is
     * mostly to ensure the infobars are not shown in tab switcher overview mode.
     */
    private final View.OnAttachStateChangeListener mAttachedStateListener =
            new View.OnAttachStateChangeListener() {
                @Override
                public void onViewDetachedFromWindow(View v) {
                    if (mInfoBarContainerView == null) return;
                    mInfoBarContainerView.removeFromParentView();
                }

                @Override
                public void onViewAttachedToWindow(View v) {
                    if (mInfoBarContainerView == null) return;
                    mInfoBarContainerView.addToParentView();
                }
            };

    /** The list of all InfoBars in this container, regardless of whether they've been shown yet. */
    private final ArrayList<InfoBar> mInfoBars = new ArrayList<>();

    private final ObserverList<InfoBarContainerObserver> mObservers = new ObserverList<>();
    private final ObserverList<InfoBarAnimationListener> mAnimationListeners = new ObserverList<>();

    private final InfoBarContainerView.ContainerViewObserver mContainerViewObserver =
            new InfoBarContainerView.ContainerViewObserver() {
                @Override
                public void notifyAnimationFinished(int animationType) {
                    for (InfoBarAnimationListener listener : mAnimationListeners) {
                        listener.notifyAnimationFinished(animationType);
                    }
                }

                @Override
                public void notifyAllAnimationsFinished(InfoBarUiItem frontInfoBar) {
                    for (InfoBarAnimationListener listener : mAnimationListeners) {
                        listener.notifyAllAnimationsFinished(frontInfoBar);
                    }
                }

                @Override
                public void onShownRatioChanged(float shownFraction) {
                    for (InfoBarContainer.InfoBarContainerObserver observer : mObservers) {
                        observer.onInfoBarContainerShownRatioChanged(
                                InfoBarContainer.this, shownFraction);
                    }
                }
            };

    private final FullscreenManager.Observer mFullscreenObserver =
            new FullscreenManager.Observer() {
                @Override
                public void onEnterFullscreen(Tab tab, FullscreenOptions options) {
                    assert !isDestroyed() : "Full screen observer is not correctly removed";
                    setIsAllowedToAutoHide(false);
                    mInfoBarContainerView.setTranslationY(0);
                }

                @Override
                public void onExitFullscreen(Tab tab) {
                    setIsAllowedToAutoHide(true);
                }
            };

    /** The tab that hosts this infobar container. */
    private final Tab mTab;

    /** Native InfoBarContainer pointer which will be set by InfoBarContainerJni.get().init(). */
    private long mNativeInfoBarContainer;

    /** True when this container has been emptied and its native counterpart has been destroyed. */
    private boolean mDestroyed;

    /** Whether or not this View should be hidden. */
    private boolean mIsHidden;

    /**
     * The view that {@link Tab#getView()} returns.  It will be null when the {@link Tab} is
     * detached from a {@link Activity}.
     */
    private @Nullable View mTabView;

    /**
     * The view for this {@link InfoBarContainer}. It will be null when the {@link Tab} is detached
     * from a {@link Activity}.
     */
    private @Nullable InfoBarContainerView mInfoBarContainerView;

    /**
     * Helper class to manage showing in-product help bubbles over specific info bars. It will be
     * null when the {@link Tab} is detached from a {@link Activity}.
     */
    private @Nullable IPHInfoBarSupport mIPHSupport;

    /** A {@link BottomSheetObserver} so this view knows when to show/hide. */
    private @Nullable BottomSheetObserver mBottomSheetObserver;

    /** */
    private BottomSheetController mBottomSheetController;

    public static InfoBarContainer from(Tab tab) {
        InfoBarContainer container = get(tab);
        if (container == null) {
            container = tab.getUserDataHost().setUserData(USER_DATA_KEY, new InfoBarContainer(tab));
        }
        return container;
    }

    /**
     * Returns {@link InfoBarContainer} object for a given {@link Tab}, or {@code null}
     * if there is no object available.
     */
    public static @Nullable InfoBarContainer get(Tab tab) {
        return tab.getUserDataHost().getUserData(USER_DATA_KEY);
    }

    public static void removeInfoBarContainerForTesting(Tab tab) {
        InfoBarContainer container = get(tab);
        if (container != null) {
            tab.getUserDataHost().removeUserData(USER_DATA_KEY);
            container.destroy();
        }
    }

    private InfoBarContainer(Tab tab) {
        tab.addObserver(mTabObserver);
        mTabView = tab.getView();
        mTab = tab;

        Activity activity = getActivity(tab);
        if (activity != null) initializeContainerView(activity);

        // Chromium's InfoBarContainer may add an InfoBar immediately during this initialization
        // call, so make sure everything in the InfoBarContainer is completely ready beforehand.
        mNativeInfoBarContainer = InfoBarContainerJni.get().init(InfoBarContainer.this);
    }

    private static Activity getActivity(Tab tab) {
        return tab.getWindowAndroid().getActivity().get();
    }

    /**
     * Adds an {@link InfoBarContainerObserver}.
     * @param observer The {@link InfoBarContainerObserver} to add.
     */
    public void addObserver(InfoBarContainerObserver observer) {
        mObservers.addObserver(observer);
    }

    /**
     * Removes a {@link InfoBarContainerObserver}.
     * @param observer The {@link InfoBarContainerObserver} to remove.
     */
    public void removeObserver(InfoBarContainerObserver observer) {
        mObservers.removeObserver(observer);
    }

    /** Sets the parent {@link ViewGroup} that contains the {@link InfoBarContainer}. */
    public void setParentView(ViewGroup parent) {
        if (mInfoBarContainerView != null) mInfoBarContainerView.setParentView(parent);
    }

    @VisibleForTesting
    public void addAnimationListener(InfoBarAnimationListener listener) {
        mAnimationListeners.addObserver(listener);
    }

    /** Removes the passed in {@link InfoBarAnimationListener} from the {@link InfoBarContainer}. */
    public void removeAnimationListener(InfoBarAnimationListener listener) {
        mAnimationListeners.removeObserver(listener);
    }

    /**
     * Adds an InfoBar to the view hierarchy.
     * @param infoBar InfoBar to add to the View hierarchy.
     */
    @CalledByNative
    private void addInfoBar(InfoBar infoBar) {
        assert !mDestroyed;
        if (infoBar == null) {
            return;
        }
        if (mInfoBars.contains(infoBar)) {
            assert false : "Trying to add an info bar that has already been added.";
            return;
        }

        infoBar.setContext(mInfoBarContainerView.getContext());
        infoBar.setContainer(this);

        // We notify observers immediately (before the animation starts).
        for (InfoBarContainerObserver observer : mObservers) {
            observer.onAddInfoBar(this, infoBar, mInfoBars.isEmpty());
        }

        assert mInfoBarContainerView != null : "The container view is null when adding an InfoBar";

        // We add the infobar immediately to mInfoBars but we wait for the animation to end to
        // notify it's been added, as tests rely on this notification but expects the infobar view
        // to be available when they get the notification.
        mInfoBars.add(infoBar);

        mInfoBarContainerView.addInfoBar(infoBar);
    }

    /**
     * Adds an InfoBar to the view hierarchy.
     * @param infoBar InfoBar to add to the View hierarchy.
     */
    public void addInfoBarForTesting(InfoBar infoBar) {
        addInfoBar(infoBar);
    }

    @Override
    public void notifyInfoBarViewChanged() {
        assert !mDestroyed;
        if (mInfoBarContainerView != null) mInfoBarContainerView.notifyInfoBarViewChanged();
    }

    /**
     * Sets the visibility for the {@link InfoBarContainerView}.
     * @param visibility One of {@link View#GONE}, {@link View#INVISIBLE}, or {@link View#VISIBLE}.
     */
    public void setVisibility(int visibility) {
        if (mInfoBarContainerView != null) mInfoBarContainerView.setVisibility(visibility);
    }

    /**
     * @return The visibility of the {@link InfoBarContainerView}.
     */
    public int getVisibility() {
        return mInfoBarContainerView != null ? mInfoBarContainerView.getVisibility() : View.GONE;
    }

    @Override
    public void removeInfoBar(InfoBar infoBar) {
        assert !mDestroyed;

        if (!mInfoBars.remove(infoBar)) {
            assert false : "Trying to remove an InfoBar that is not in this container.";
            return;
        }

        // Notify observers immediately, before any animations begin.
        for (InfoBarContainerObserver observer : mObservers) {
            observer.onRemoveInfoBar(this, infoBar, mInfoBars.isEmpty());
        }

        assert mInfoBarContainerView != null
                : "The container view is null when removing an InfoBar.";
        mInfoBarContainerView.removeInfoBar(infoBar);
    }

    @Override
    public boolean isDestroyed() {
        return mDestroyed;
    }

    @Override
    public void destroy() {
        destroyContainerView();
        mTab.removeObserver(mTabObserver);
        if (mNativeInfoBarContainer != 0) {
            InfoBarContainerJni.get().destroy(mNativeInfoBarContainer, InfoBarContainer.this);
            mNativeInfoBarContainer = 0;
        }
        mDestroyed = true;
    }

    /**
     * @return all of the InfoBars held in this container.
     */
    public ArrayList<InfoBar> getInfoBarsForTesting() {
        return mInfoBars;
    }

    /**
     * @return True if the container has any InfoBars.
     */
    @CalledByNative
    public boolean hasInfoBars() {
        return !mInfoBars.isEmpty();
    }

    /**
     * @return InfoBarIdentifier of the InfoBar which is currently at the top of the infobar stack,
     *         or InfoBarIdentifier.INVALID if there are no infobars.
     */
    @CalledByNative
    private @InfoBarIdentifier int getTopInfoBarIdentifier() {
        if (!hasInfoBars()) return InfoBarIdentifier.INVALID;
        return mInfoBars.get(0).getInfoBarIdentifier();
    }

    /**
     * Hides or stops hiding this View.
     *
     * @param isHidden Whether this View is should be hidden.
     */
    public void setHidden(boolean isHidden) {
        mIsHidden = isHidden;
        if (mInfoBarContainerView == null) return;
        mInfoBarContainerView.setHidden(isHidden);
    }

    /**
     * Sets whether the InfoBarContainer is allowed to auto-hide when the user scrolls the page.
     * Expected to be called when Touch Exploration is enabled.
     * @param isAllowed Whether auto-hiding is allowed.
     */
    private static void setIsAllowedToAutoHide(boolean isAllowed) {
        InfoBarContainerView.setIsAllowedToAutoHide(isAllowed);
    }

    // KeyboardVisibilityListener implementation.
    @Override
    public void keyboardVisibilityChanged(boolean isKeyboardShowing) {
        assert mInfoBarContainerView != null;
        boolean isShowing = (mInfoBarContainerView.getVisibility() == View.VISIBLE);
        if (isKeyboardShowing) {
            if (isShowing) {
                mInfoBarContainerView.setVisibility(View.INVISIBLE);
            }
        } else {
            if (!isShowing && !mIsHidden) {
                mInfoBarContainerView.setVisibility(View.VISIBLE);
            }
        }
    }

    private void updateWebContents() {
        // When the tab is detached, we don't update the InfoBarContainer web content so that it
        // stays null until the tab is attached to some Activity.
        if (mInfoBarContainerView == null) return;
        WebContents webContents = mTab.getWebContents();

        if (webContents != null && webContents != mInfoBarContainerView.getWebContents()) {
            mInfoBarContainerView.setWebContents(webContents);
            if (mNativeInfoBarContainer != 0) {
                InfoBarContainerJni.get()
                        .setWebContents(
                                mNativeInfoBarContainer, InfoBarContainer.this, webContents);
            }
        }

        if (mTabView != null) mTabView.removeOnAttachStateChangeListener(mAttachedStateListener);
        mTabView = mTab.getView();
        if (mTabView != null) mTabView.addOnAttachStateChangeListener(mAttachedStateListener);
    }

    private void initializeContainerView(Activity activity) {
        BrowserControlsManager browserControlsManager =
                BrowserControlsManagerSupplier.getValueOrNullFrom(mTab.getWindowAndroid());

        // Note: Doing a cast and pulling off dependencies from ChromeActivity is generally a
        // pattern we try to avoid. However, InfoBar is slated for deprecation soon, so a better
        // dependency management approach won't be used.
        ObservableSupplier<EdgeToEdgeController> edgeToEdgeSupplier = null;
        if (activity instanceof ChromeActivity) {
            edgeToEdgeSupplier = ((ChromeActivity) activity).getEdgeToEdgeSupplier();
        }
        mInfoBarContainerView =
                new InfoBarContainerView(
                        activity,
                        mContainerViewObserver,
                        browserControlsManager,
                        edgeToEdgeSupplier,
                        DeviceFormFactor.isWindowOnTablet(mTab.getWindowAndroid()));
        if (browserControlsManager != null) {
            browserControlsManager.getFullscreenManager().removeObserver(mFullscreenObserver);
            browserControlsManager.getFullscreenManager().addObserver(mFullscreenObserver);
        }

        mInfoBarContainerView.addOnAttachStateChangeListener(
                new View.OnAttachStateChangeListener() {
                    @Override
                    public void onViewAttachedToWindow(View view) {
                        initBottomSheetObserver();
                        boolean infoBarsExist = !mInfoBars.isEmpty();

                        for (InfoBarContainer.InfoBarContainerObserver observer : mObservers) {
                            observer.onInfoBarContainerAttachedToWindow(infoBarsExist);
                        }
                    }

                    @Override
                    public void onViewDetachedFromWindow(View view) {}
                });

        mInfoBarContainerView.setHidden(mIsHidden);
        setParentView(activity.findViewById(R.id.bottom_container));

        mIPHSupport = new IPHInfoBarSupport(new IPHBubbleDelegateImpl(activity, mTab));
        addAnimationListener(mIPHSupport);
        addObserver(mIPHSupport);

        mTab.getWindowAndroid().getKeyboardDelegate().addKeyboardVisibilityListener(this);
    }

    private void initBottomSheetObserver() {
        if (mBottomSheetObserver != null) {
            return;
        }
        mBottomSheetObserver =
                new EmptyBottomSheetObserver() {
                    @Override
                    public void onSheetStateChanged(int sheetState, int reason) {
                        if (mTab.isHidden()) return;
                        mInfoBarContainerView.setVisibility(
                                sheetState == BottomSheetController.SheetState.FULL
                                        ? View.INVISIBLE
                                        : View.VISIBLE);
                    }
                };
        mBottomSheetController = BottomSheetControllerProvider.from(mTab.getWindowAndroid());
        mBottomSheetController.addObserver(mBottomSheetObserver);
    }

    private void destroyContainerView() {
        if (mIPHSupport != null) {
            removeAnimationListener(mIPHSupport);
            removeObserver(mIPHSupport);
            mIPHSupport = null;
        }

        BrowserControlsManager browserControlsManager =
                BrowserControlsManagerSupplier.getValueOrNullFrom(mTab.getWindowAndroid());
        if (browserControlsManager != null) {
            browserControlsManager.getFullscreenManager().removeObserver(mFullscreenObserver);
        }

        if (mInfoBarContainerView != null) {
            mInfoBarContainerView.setWebContents(null);
            if (mNativeInfoBarContainer != 0) {
                InfoBarContainerJni.get()
                        .setWebContents(mNativeInfoBarContainer, InfoBarContainer.this, null);
            }
            mInfoBarContainerView.destroy();
            mInfoBarContainerView = null;
        }

        Activity activity = getActivity(mTab);
        if (activity != null && mBottomSheetObserver != null) {
            mBottomSheetController.removeObserver(mBottomSheetObserver);
        }

        mTab.getWindowAndroid().getKeyboardDelegate().removeKeyboardVisibilityListener(this);

        if (mTabView != null) {
            mTabView.removeOnAttachStateChangeListener(mAttachedStateListener);
            mTabView = null;
        }
    }

    @Override
    public boolean isFrontInfoBar(InfoBar infoBar) {
        if (mInfoBars.isEmpty()) return false;
        return mInfoBars.get(0) == infoBar;
    }

    /** Returns true if any animations are pending or in progress. */
    @VisibleForTesting
    public boolean isAnimating() {
        assert mInfoBarContainerView != null;
        return mInfoBarContainerView.isAnimating();
    }

    /**
     * @return The {@link InfoBarContainerView} this class holds.
     */
    public InfoBarContainerView getContainerViewForTesting() {
        return mInfoBarContainerView;
    }

    @NativeMethods
    interface Natives {
        long init(InfoBarContainer caller);

        void setWebContents(
                long nativeInfoBarContainerAndroid,
                InfoBarContainer caller,
                WebContents webContents);

        void destroy(long nativeInfoBarContainerAndroid, InfoBarContainer caller);
    }
}