chromium/chrome/browser/back_press/android/java/src/org/chromium/chrome/browser/back_press/BackPressManager.java

// Copyright 2022 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.back_press;

import android.annotation.SuppressLint;
import android.util.SparseIntArray;

import androidx.activity.BackEventCompat;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.base.cached_flags.BooleanCachedFieldTrialParameter;
import org.chromium.base.lifetime.Destroyable;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.components.browser_ui.widget.gesture.BackPressHandler;
import org.chromium.components.browser_ui.widget.gesture.BackPressHandler.BackPressResult;
import org.chromium.components.browser_ui.widget.gesture.BackPressHandler.Type;

import java.util.ArrayList;
import java.util.List;

/**
 * A central manager class to handle the back gesture. Every component/feature which is going to
 * intercept the back press event must implement the {@link BackPressHandler} and be registered
 * in a proper order.
 * In order to register a Handler:
 * 1. Implement {@link BackPressHandler}.
 * 2. Add a new {@link Type} which implies the order of intercepting.
 * 3. Add a new value in {@link #sMetricsMap} which stands for the histograms.
 * 4. Call {@link #addHandler(BackPressHandler, int)} to register the implementer of
 * {@link BackPressHandler} with the new defined {@link Type}.
 */
public class BackPressManager implements Destroyable {
    public static final BooleanCachedFieldTrialParameter TAB_HISTORY_RECOVER =
            ChromeFeatureList.newBooleanCachedFieldTrialParameter(
                    ChromeFeatureList.BACK_GESTURE_REFACTOR, "tab_history_recover", false);
    private static final SparseIntArray sMetricsMap;
    private static final int sMetricsMaxValue;

    static {
        // Max value is 23 - 1 obsolete value +1 for 0 indexing = 22 elements.
        SparseIntArray map = new SparseIntArray(20);
        map.put(Type.TEXT_BUBBLE, 0);
        // map.put(Type.VR_DELEGATE, 1);
        // map.put(Type.AR_DELEGATE, 2);
        map.put(Type.SCENE_OVERLAY, 3);
        map.put(Type.START_SURFACE, 4);
        map.put(Type.SELECTION_POPUP, 5);
        map.put(Type.MANUAL_FILLING, 6);
        map.put(Type.FULLSCREEN, 7);
        map.put(Type.BOTTOM_SHEET, 8);
        map.put(Type.TAB_MODAL_HANDLER, 9);
        map.put(Type.TAB_SWITCHER, 10);
        map.put(Type.CLOSE_WATCHER, 11);
        map.put(Type.TAB_HISTORY, 12);
        // map.put(Type.TAB_RETURN_TO_CHROME_START_SURFACE, 13);
        map.put(Type.SHOW_READING_LIST, 14);
        map.put(Type.MINIMIZE_APP_AND_CLOSE_TAB, 15);
        map.put(Type.FIND_TOOLBAR, 16);
        map.put(Type.LOCATION_BAR, 17);
        map.put(Type.XR_DELEGATE, 18);
        map.put(Type.BOTTOM_CONTROLS, 20);
        map.put(Type.HUB, 21);
        map.put(Type.ARCHIVED_TABS_DIALOG, 22);

        // Add new one here and update array size.
        sMetricsMaxValue = 23;
        sMetricsMap = map;
    }

    private final OnBackPressedCallback mCallback =
            new OnBackPressedCallback(false) {
                private BackPressHandler mActiveHandler;
                private BackEventCompat mLastBackEvent;

                @SuppressLint("WrongConstant") // Suppress mLastCalledHandlerType assignment warning
                @Override
                public void handleOnBackPressed() {
                    if (mOnBackPressed != null) mOnBackPressed.run();
                    recordSystemBackCountIfBeforeFirstVisibleContent();
                    mLastCalledHandlerType = -1;
                    BackPressManager.this.handleBackPress();

                    // This means this back is triggered by a gesture rather than the back button.
                    if (mLastBackEvent != null
                            && mLastCalledHandlerType != -1
                            && mIsGestureNavEnabledSupplier.get()) {
                        BackPressMetrics.recordBackPressFromEdge(
                                mLastCalledHandlerType, mLastBackEvent.getSwipeEdge());

                        if (mLastCalledHandlerType == Type.TAB_HISTORY) {
                            BackPressMetrics.recordTabNavigationSwipedFromEdge(
                                    mLastBackEvent.getSwipeEdge());
                        }
                    }

                    mActiveHandler = null;
                    mLastBackEvent = null;
                }

                // Following methods are only triggered on API 34+.
                @Override
                public void handleOnBackStarted(@NonNull BackEventCompat backEvent) {
                    mActiveHandler = getEnabledBackPressHandler();
                    assert mActiveHandler != null;
                    mActiveHandler.handleOnBackStarted(backEvent);
                    mLastBackEvent = backEvent;
                }

                @Override
                public void handleOnBackCancelled() {
                    if (mActiveHandler == null) return;
                    mActiveHandler.handleOnBackCancelled();
                    mActiveHandler = null;
                    mLastBackEvent = null;
                }

                @Override
                public void handleOnBackProgressed(@NonNull BackEventCompat backEvent) {
                    if (mActiveHandler == null) return;
                    mActiveHandler.handleOnBackProgressed(backEvent);
                }
            };

    static final String HISTOGRAM = "Android.BackPress.Intercept";
    static final String HISTOGRAM_CUSTOM_TAB_SAME_TASK =
            "Android.BackPress.Intercept.CustomTab.SameTask";
    static final String HISTOGRAM_CUSTOM_TAB_SEPARATE_TASK =
            "Android.BackPress.Intercept.CustomTab.SeparateTask";
    static final String FAILURE_HISTOGRAM = "Android.BackPress.Failure";

    private final BackPressHandler[] mHandlers = new BackPressHandler[Type.NUM_TYPES];
    private final boolean mUseSystemBack;
    private boolean mHasSystemBackArm;

    private final Callback<Boolean>[] mObserverCallbacks = new Callback[Type.NUM_TYPES];
    private Runnable mFallbackOnBackPressed;
    private int mLastCalledHandlerType = -1;
    private boolean mBackBeforeFirstVisibleContentRecorded;
    private Supplier<Boolean> mIsFirstVisibleContentDrawnSupplier;
    private Runnable mOnBackPressed;
    private Supplier<Boolean> mIsGestureNavEnabledSupplier = () -> false;

    /**
     * @return True if the back gesture refactor is enabled.
     */
    public static boolean isEnabled() {
        return ChromeFeatureList.sBackGestureRefactorAndroid.isEnabled();
    }

    /**
     * @return True if ActivityTabProvider should replace ChromeTabActivity#getActivityTab
     */
    public static boolean shouldUseActivityTabProvider() {
        return isEnabled() || ChromeFeatureList.sBackGestureActivityTabProvider.isEnabled();
    }

    /**
     * @return True if the tab navigation should be corrected on fallback callback.
     */
    public static boolean correctTabNavigationOnFallback() {
        return isEnabled() && TAB_HISTORY_RECOVER.getValue();
    }

    /**
     * @return True if app should be moved to back by manually calling `moveTaskToBack` when back is
     *     pressed during start up. Otherwise, call `onBackPressed` to trigger default behavior.
     */
    public static boolean shouldMoveToBackDuringStartup() {
        return ChromeFeatureList.sBackGestureMoveToBackDuringStartup.isEnabled();
    }

    /**
     * Record when the back press is consumed by a certain feature.
     * @param type The {@link Type} which consumes the back press event.
     */
    public static void record(@Type int type) {
        RecordHistogram.recordEnumeratedHistogram(
                HISTOGRAM, sMetricsMap.get(type), sMetricsMaxValue);
    }

    /**
     * Record when the back press is consumed by a custom tab.
     *
     * @param type The {@link Type} which consumes the back press event.
     * @param separateTask Whether the custom tab runs in a separate task.
     */
    public static void recordForCustomTab(@Type int type, boolean separateTask) {
        RecordHistogram.recordEnumeratedHistogram(
                separateTask ? HISTOGRAM_CUSTOM_TAB_SEPARATE_TASK : HISTOGRAM_CUSTOM_TAB_SAME_TASK,
                sMetricsMap.get(type),
                sMetricsMaxValue);
    }

    /**
     * @param type The {@link Type} of the back press handler.
     * @return The corresponding histogram value.
     */
    public static int getHistogramValue(@Type int type) {
        return sMetricsMap.get(type);
    }

    private static void recordFailure(@Type int type) {
        RecordHistogram.recordEnumeratedHistogram(
                FAILURE_HISTOGRAM, sMetricsMap.get(type), sMetricsMaxValue);
    }

    public BackPressManager() {
        mFallbackOnBackPressed = () -> {};
        mUseSystemBack = MinimizeAppAndCloseTabBackPressHandler.shouldUseSystemBack();
        backPressStateChanged();
    }

    /**
     * Register the handler to intercept the back gesture.
     * @param handler Implementer of {@link BackPressHandler}.
     * @param type The {@link Type} of the handler.
     */
    public void addHandler(BackPressHandler handler, @Type int type) {
        assert mHandlers[type] == null : "Each type can have at most one handler";
        mHandlers[type] = handler;
        mObserverCallbacks[type] = (t) -> backPressStateChanged();
        handler.getHandleBackPressChangedSupplier().addObserver(mObserverCallbacks[type]);
        backPressStateChanged();
    }

    /**
     * Remove a registered handler. The methods of handler will not be called any more.
     * @param handler {@link BackPressHandler} to be removed.
     */
    public void removeHandler(@NonNull BackPressHandler handler) {
        for (int i = 0; i < mHandlers.length; i++) {
            if (mHandlers[i] == handler) {
                removeHandler(i);
                return;
            }
        }
    }

    /**
     * Remove a registered handler. The methods of handler will not be called any more.
     * @param type {@link Type} to be removed.
     */
    public void removeHandler(@Type int type) {
        BackPressHandler handler = mHandlers[type];
        handler.getHandleBackPressChangedSupplier().removeObserver(mObserverCallbacks[type]);
        mObserverCallbacks[type] = null;
        mHandlers[type] = null;
        backPressStateChanged();
    }

    /**
     * @param type The {@link Type} which needs to check.
     * @return True if a handler of this type has been registered.
     */
    public boolean has(@Type int type) {
        return mHandlers[type] != null;
    }

    /**
     * @return A {@link OnBackPressedCallback} which should be added to
     * {@link androidx.activity.OnBackPressedDispatcher}.
     */
    public OnBackPressedCallback getCallback() {
        return mCallback;
    }

    /*
     * @param fallbackOnBackPressed Callback executed when a handler claims to intercept back press
     *         but no handler succeeds.
     */
    public void setFallbackOnBackPressed(Runnable runnable) {
        mFallbackOnBackPressed = runnable;
    }

    /**
     * Set a callback fired when a back press is triggered. This is introduced to investigate data
     * inconsistency between experimental groups. and is not intended to be re-used.
     * TODO(crbug.com/40944523): remove after sufficient data is collected.
     */
    public void setOnBackPressedListener(Runnable callback) {
        mOnBackPressed = callback;
    }

    /**
     * Turn on more checks if a system back arm is available, such as when running on tabbed
     * activity.
     * @param hasSystemBackArm True if system back arm is feasible.
     */
    public void setHasSystemBackArm(boolean hasSystemBackArm) {
        mHasSystemBackArm = hasSystemBackArm;
    }

    /** Set a supplier to provide whether first visible content has been drawn. */
    public void setIsFirstVisibleContentDrawnSupplier(Supplier<Boolean> supplier) {
        mIsFirstVisibleContentDrawnSupplier = supplier;
    }

    /** Set a supplier to provide whether gesture nav mode is on when called. */
    public void setIsGestureNavEnabledSupplier(Supplier<Boolean> supplier) {
        mIsGestureNavEnabledSupplier = supplier;
    }

    /**
     * Record if back press occurs before first visible content is drawn. TODO(crbug.com/40944523):
     * remove after it is fixed.
     */
    public void recordSystemBackCountIfBeforeFirstVisibleContent() {
        if (mBackBeforeFirstVisibleContentRecorded) return;
        if (mIsFirstVisibleContentDrawnSupplier != null
                && mIsFirstVisibleContentDrawnSupplier.get()) return;
        mBackBeforeFirstVisibleContentRecorded = true;
        RecordUserAction.record("SystemBackBeforeFirstVisibleContent");
    }

    private void backPressStateChanged() {
        boolean intercept = shouldInterceptBackPress();
        if (mHasSystemBackArm) {
            // If not using system back and MINIMIZE_APP_AND_CLOSE_TAB has registered, this must be
            // true, since MINIMIZE_APP_AND_CLOSE_TAB unconditionally consumes last back press.
            if (has(Type.MINIMIZE_APP_AND_CLOSE_TAB) && !mUseSystemBack) {
                assert intercept : "Should always intercept back press on non-systemback group";
            }
            mCallback.setEnabled(intercept || !mUseSystemBack);
        } else {
            mCallback.setEnabled(intercept);
        }
    }

    @VisibleForTesting
    BackPressHandler getEnabledBackPressHandler() {
        for (int i = 0; i < mHandlers.length; i++) {
            BackPressHandler handler = mHandlers[i];
            if (handler == null) continue;
            Boolean enabled = handler.getHandleBackPressChangedSupplier().get();
            if (enabled != null && enabled) {
                return handler;
            }
        }
        assert false;
        return null;
    }

    private void handleBackPress() {
        var failed = new ArrayList<String>();
        for (int i = 0; i < mHandlers.length; i++) {
            BackPressHandler handler = mHandlers[i];
            if (handler == null) continue;
            Boolean enabled = handler.getHandleBackPressChangedSupplier().get();
            if (enabled != null && enabled) {
                int res = handler.handleBackPress();
                mLastCalledHandlerType = i;
                if (res == BackPressResult.FAILURE) {
                    failed.add(i + "");
                    recordFailure(i);
                } else {
                    record(i);
                    assertListOfFailedHandlers(failed, i);
                    return;
                }
            }
        }
        if (mFallbackOnBackPressed != null) mFallbackOnBackPressed.run();
        assertListOfFailedHandlers(failed, -1);
        assert !failed.isEmpty() : "Callback is enabled but no handler consumed back gesture.";
    }

    @Override
    public void destroy() {
        for (int i = 0; i < mHandlers.length; i++) {
            if (has(i)) {
                removeHandler(i);
            }
        }
    }

    @VisibleForTesting
    boolean shouldInterceptBackPress() {
        for (BackPressHandler handler : mHandlers) {
            if (handler == null) continue;
            if (!Boolean.TRUE.equals(handler.getHandleBackPressChangedSupplier().get())) continue;
            return true;
        }
        return false;
    }

    private void assertListOfFailedHandlers(List<String> failed, int succeed) {
        if (failed.isEmpty()) return;
        var msg = String.join(", ", failed);
        assert false
                : String.format(
                        "%s didn't correctly handle back press; handled by %s.", msg, succeed);
    }

    public BackPressHandler[] getHandlersForTesting() {
        return mHandlers;
    }

    public int getLastCalledHandlerForTesting() {
        return mLastCalledHandlerType;
    }

    public void resetLastCalledHandlerForTesting() {
        mLastCalledHandlerType = -1;
    }

    public static String getHistogramForTesting() {
        return HISTOGRAM;
    }

    public static String getCustomTabSameTaskHistogramForTesting() {
        return HISTOGRAM_CUSTOM_TAB_SAME_TASK;
    }

    public static String getCustomTabSeparateTaskHistogramForTesting() {
        return HISTOGRAM_CUSTOM_TAB_SEPARATE_TASK;
    }
}