chromium/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarManager.java

// Copyright 2015 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.ui.messages.snackbar;

import android.app.Activity;
import android.os.Handler;
import android.util.Pair;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;

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

import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.ApplicationStatus.ActivityStateListener;
import org.chromium.base.ObserverList;
import org.chromium.base.UnownedUserData;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.chrome.browser.ui.edge_to_edge.EdgeToEdgeSupplier;
import org.chromium.ui.InsetObserver;
import org.chromium.ui.accessibility.AccessibilityState;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.util.TokenHolder;

import java.util.Stack;

/**
 * Manager for the snackbar showing at the bottom of activity. There should be only one
 * SnackbarManager and one snackbar in the activity.
 *
 * <p>When action button is clicked, this manager will call {@link
 * SnackbarController#onAction(Object)} in corresponding listener, and show the next entry.
 * Otherwise if no action is taken by user during {@link #DEFAULT_SNACKBAR_DURATION_MS}
 * milliseconds, it will call {@link SnackbarController#onDismissNoAction(Object)}. Note, snackbars
 * of {@link Snackbar#TYPE_PERSISTENT} do not get automatically dismissed after a timeout.
 */
public class SnackbarManager
        implements OnClickListener,
                ActivityStateListener,
                UnownedUserData,
                InsetObserver.WindowInsetObserver,
                SnackbarStateProvider {
    /** Interface that shows the ability to provide a snackbar manager. */
    public interface SnackbarManageable {
        /**
         * @return The snackbar manager that has a proper anchor view.
         */
        SnackbarManager getSnackbarManager();
    }

    /**
     * Controller that post entries to snackbar manager and interact with snackbar manager during
     * dismissal and action click event.
     */
    public interface SnackbarController {
        /**
         * Called when the user clicks the action button on the snackbar.
         * @param actionData Data object passed when showing this specific snackbar.
         */
        default void onAction(Object actionData) {}

        /**
         * Called when the snackbar is dismissed by timeout or UI environment change.
         * @param actionData Data object associated with the dismissed snackbar entry.
         */
        default void onDismissNoAction(Object actionData) {}
    }

    public static final int DEFAULT_SNACKBAR_DURATION_MS = 3000;
    // For snackbars with long strings where a longer duration is favorable.
    public static final int DEFAULT_SNACKBAR_DURATION_LONG_MS = 8000;
    public static final int DEFAULT_TYPE_ACTION_SNACKBAR_DURATION_MS = 10000;
    private static final int ACCESSIBILITY_MODE_SNACKBAR_DURATION_MS = 30000;

    // Used instead of the constant so tests can override the value.
    private static int sSnackbarDurationMs = DEFAULT_SNACKBAR_DURATION_MS;
    private static int sAccessibilitySnackbarDurationMs = ACCESSIBILITY_MODE_SNACKBAR_DURATION_MS;
    private static int sTypeActionSnackbarDurationsMs = DEFAULT_TYPE_ACTION_SNACKBAR_DURATION_MS;

    private final Activity mActivity;
    private final @NonNull WindowAndroid mWindowAndroid;
    private final @NonNull Handler mUIThreadHandler;
    private final Runnable mHideRunnable =
            new Runnable() {
                @Override
                public void run() {
                    mSnackbars.removeCurrentDueToTimeout();
                    updateView();
                }
            };
    private final ObservableSupplierImpl<Boolean> mIsShowingSupplier =
            new ObservableSupplierImpl<>();
    private final @NonNull ViewGroup mOriginalParentView;
    private final Stack<Pair<Integer, ViewGroup>> mParentViewOverrideStack = new Stack<>();
    protected final ObserverList<SnackbarStateProvider.Observer> mObservers = new ObserverList<>();
    private final TokenHolder mTokenHolder = new TokenHolder(this::onTokenHolderChanged);

    private SnackbarView mView;
    private SnackbarCollection mSnackbars = new SnackbarCollection();
    private boolean mActivityInForeground;
    private boolean mIsDisabledForTesting;
    private @Nullable EdgeToEdgeSupplier mEdgeToEdgeSupplier;

    /**
     * Constructs a SnackbarManager to show snackbars in the given window.
     *
     * @param activity The embedding activity.
     * @param snackbarParentView The ViewGroup used to display this snackbar.
     * @param windowAndroid The WindowAndroid used for starting animation. If it is null,
     *     Animator#start is called instead.
     */
    public SnackbarManager(
            @NonNull Activity activity,
            @NonNull ViewGroup snackbarParentView,
            @Nullable WindowAndroid windowAndroid) {
        mActivity = activity;
        mUIThreadHandler = new Handler();
        mOriginalParentView = snackbarParentView;
        mWindowAndroid = windowAndroid;

        ApplicationStatus.registerStateListenerForActivity(this, mActivity);
        if (ApplicationStatus.getStateForActivity(mActivity) == ActivityState.STARTED
                || ApplicationStatus.getStateForActivity(mActivity) == ActivityState.RESUMED) {
            onStart();
        }

        mIsShowingSupplier.set(isShowing());
    }

    @Override
    public void onActivityStateChange(Activity activity, @ActivityState int newState) {
        assert activity == mActivity;
        if (newState == ActivityState.STARTED) {
            onStart();
        } else if (newState == ActivityState.STOPPED) {
            onStop();
        }
    }

    /** Notifies the snackbar manager that the activity is running in foreground now. */
    private void onStart() {
        mActivityInForeground = true;
    }

    /** Notifies the snackbar manager that the activity has been pushed to background. */
    private void onStop() {
        mSnackbars.clear();
        updateView();
        mActivityInForeground = false;
    }

    /**
     * @return True if a Snackbar can currently be shown by this SnackbarManager.
     */
    public boolean canShowSnackbar() {
        return mActivityInForeground && !mIsDisabledForTesting;
    }

    /** Shows a snackbar at the bottom of the screen. */
    public void showSnackbar(Snackbar snackbar) {
        if (!mActivityInForeground || mIsDisabledForTesting) return;
        RecordHistogram.recordSparseHistogram("Snackbar.Shown", snackbar.getIdentifier());

        mSnackbars.add(snackbar);
        updateView();
        mView.announceforAccessibility();
    }

    /** Dismisses all snackbars. */
    public void dismissAllSnackbars() {
        if (mSnackbars.isEmpty()) return;

        mSnackbars.clear();
        updateView();
    }

    /**
     * Dismisses snackbars that are associated with the given {@link SnackbarController}.
     *
     * @param controller Only snackbars with this controller will be removed. A snackbar associated
     *         with a null controller cannot be dismissed via this method.
     */
    public void dismissSnackbars(SnackbarController controller) {
        if (mSnackbars.removeMatchingSnackbars(controller)) {
            updateView();
        }
    }

    /**
     * Dismisses snackbars that have a certain controller and action data.
     *
     * @param controller Only snackbars with this controller will be removed. A snackbar associated
     *         with a null controller cannot be dismissed via this method.
     * @param actionData Only snackbars whose action data is equal to actionData will be removed.
     */
    public void dismissSnackbars(SnackbarController controller, Object actionData) {
        if (mSnackbars.removeMatchingSnackbars(controller, actionData)) {
            updateView();
        }
    }

    /** Handles click event for action button at end of snackbar. */
    @Override
    public void onClick(View v) {
        mView.announceActionForAccessibility();
        mSnackbars.removeCurrentDueToAction();
        updateView();
    }

    /**
     * After an infobar is added, brings snackbar view above it. TODO(crbug.com/40109125): Currently
     * SnackbarManager doesn't observe InfobarContainer events. Restore this functionality, only
     * without references to Infobar classes.
     */
    public void onAddInfoBar() {
        // Bring Snackbars to the foreground so that it's not blocked by infobars.
        if (isShowing()) {
            mView.bringToFront();
        }
    }

    /**
     * Pushes the given {@link ViewGroup} onto the override stack, this given parent will be used
     * for all {@link SnackbarView}s until #popParentViewFromOverrideStack is called.
     *
     * @param parentView The new parent for snackbars, must be non-null.
     * @return A token to be used when calling a corresponding pop.
     */
    public int pushParentViewToOverrideStack(@NonNull ViewGroup parentView) {
        assert parentView != null;
        int overrideToken = mTokenHolder.acquireToken();
        mParentViewOverrideStack.push(new Pair<Integer, ViewGroup>(overrideToken, parentView));
        overrideParent(parentView);
        return overrideToken;
    }

    /**
     * Pops the the last {@link ViewGroup} that was pushed onto the stack by the
     * #pushParentViewToOverrideStack method. The last used parent override will be used, and in if
     * the stack is empty then the original parent will be used. This function is a no-op if the
     * stack is already empty.
     *
     * @param token The token passed from #pushParentViewToOverrideStack. This is used to ensure
     *     that the push/pop methods are matching.
     */
    public void popParentViewFromOverrideStack(@NonNull int token) {
        assert token != TokenHolder.INVALID_TOKEN;
        Pair<Integer, ViewGroup> parentViewPair = mParentViewOverrideStack.pop();
        assert parentViewPair.first.equals(token);
        mTokenHolder.releaseToken(token);
        overrideParent(
                mParentViewOverrideStack.empty()
                        ? mOriginalParentView
                        : mParentViewOverrideStack.peek().second);
    }

    /**
     * @return Supplier of whether the snackbar is showing
     */
    public ObservableSupplier<Boolean> isShowingSupplier() {
        return mIsShowingSupplier;
    }

    /**
     * @return Whether there is a snackbar on screen.
     */
    public boolean isShowing() {
        return mView != null && mView.isShowing();
    }

    /**
     * @param supplier The supplier publishes the changes of the edge-to-edge state and the expected
     *     bottom paddings when edge-to-edge is on.
     */
    public void setEdgeToEdgeSupplier(@Nullable EdgeToEdgeSupplier supplier) {
        mEdgeToEdgeSupplier = supplier;
    }

    /**
     * Overrides the parent {@link ViewGroup} of the currently-showing snackbar. This method removes
     * the snackbar from its original parent, and attaches it to the given parent. If <code>null
     * </code> is given, the snackbar will be reattached to its original parent.
     *
     * @param overridingParent The overriding parent for the current snackbar. If null, previous
     *     calls of this method will be reverted.
     */
    // TODO(crbug.com/355062900): Fix upstream tests which reference this method.
    @VisibleForTesting
    public void overrideParent(ViewGroup overridingParent) {
        if (mView != null) mView.overrideParent(overridingParent);
    }

    /**
     * Updates the {@link SnackbarView} to reflect the value of mSnackbars.currentSnackbar(), which
     * may be null. This might show, change, or hide the view.
     */
    private void updateView() {
        if (!mActivityInForeground) return;
        Snackbar currentSnackbar = mSnackbars.getCurrent();
        if (currentSnackbar == null) {
            mUIThreadHandler.removeCallbacks(mHideRunnable);
            if (mView != null) {
                mView.dismiss();
                mView = null;
            }
        } else {
            boolean viewChanged = true;
            if (mView == null) {
                mView =
                        new SnackbarView(
                                mActivity,
                                this,
                                currentSnackbar,
                                mOriginalParentView,
                                mWindowAndroid,
                                mEdgeToEdgeSupplier,
                                isTablet());
                mView.show();

                // If there is a temporary parent set, reparent accordingly. We override here
                // instead of instantiating the new SnackbarView with the temporary parent, so
                // that overriding with <code>null</code> will reparent to mSnackbarParentView.
                if (!mParentViewOverrideStack.empty()) {
                    mView.overrideParent(mParentViewOverrideStack.peek().second);
                }
            } else {
                viewChanged = mView.update(currentSnackbar);
            }

            if (viewChanged) {
                mUIThreadHandler.removeCallbacks(mHideRunnable);
                if (!currentSnackbar.isTypePersistent()) {
                    int durationMs = getDuration(currentSnackbar);
                    mUIThreadHandler.postDelayed(mHideRunnable, durationMs);
                }
                mView.announceforAccessibility();
            }
        }

        for (Observer observer : mObservers) {
            if (isShowing()) {
                observer.onSnackbarStateChanged(true, mView.getBackgroundColor());
            } else {
                observer.onSnackbarStateChanged(false, null);
            }
        }
        mIsShowingSupplier.set(isShowing());
    }

    private boolean isTablet() {
        return DeviceFormFactor.isNonMultiDisplayContextOnTablet(mActivity);
    }

    private void onTokenHolderChanged() {
        // Intentional no-op.
    }

    // ============================================================================================
    // SnackbarStateProvider
    // ============================================================================================

    @Override
    public void addObserver(Observer observer) {
        mObservers.addObserver(observer);
    }

    @Override
    public void removeObserver(Observer observer) {
        mObservers.removeObserver(observer);
    }

    @Override
    public boolean isFullWidth() {
        return !isTablet();
    }

    // ============================================================================================
    // Testing
    // ============================================================================================

    @VisibleForTesting
    int getDuration(Snackbar snackbar) {
        int durationMs = Math.max(snackbar.getDuration(), sSnackbarDurationMs);
        if (snackbar.isTypeAction()) {
            durationMs = Math.max(sTypeActionSnackbarDurationsMs, durationMs);
        }

        // If a11y is on, set a longer minimum duration; otherwise, use the recommended timeout
        // duration.
        int minDuration =
                AccessibilityState.isPerformGesturesEnabled()
                        ? sAccessibilitySnackbarDurationMs
                        : durationMs;

        return AccessibilityState.getRecommendedTimeoutMillis(minDuration, durationMs);
    }

    /** Disables the snackbar manager. This is only intended for testing purposes. */
    public void disableForTesting() {
        mIsDisabledForTesting = true;
    }

    /**
     * Overrides the default snackbar duration with a custom value for testing.
     * @param durationMs The duration to use in ms.
     */
    public static void setDurationForTesting(int durationMs) {
        sSnackbarDurationMs = durationMs;
        sAccessibilitySnackbarDurationMs = durationMs;
        sTypeActionSnackbarDurationsMs = durationMs;
    }

    /** Clears any overrides set for testing. */
    public static void resetDurationForTesting() {
        sSnackbarDurationMs = DEFAULT_SNACKBAR_DURATION_MS;
        sAccessibilitySnackbarDurationMs = ACCESSIBILITY_MODE_SNACKBAR_DURATION_MS;
        sTypeActionSnackbarDurationsMs = DEFAULT_TYPE_ACTION_SNACKBAR_DURATION_MS;
    }

    static int getDefaultDurationForTesting() {
        return sSnackbarDurationMs;
    }

    static int getDefaultA11yDurationForTesting() {
        return sAccessibilitySnackbarDurationMs;
    }

    static int getDefaultTypeActionSnackbarDuration() {
        return sTypeActionSnackbarDurationsMs;
    }

    /**
     * @return The currently showing snackbar. For testing only.
     */
    public Snackbar getCurrentSnackbarForTesting() {
        return mSnackbars.getCurrent();
    }

    /**
     * @return The currently showing snackbar view. For testing only.
     */
    public SnackbarView getCurrentSnackbarViewForTesting() {
        return mView;
    }
}