chromium/chrome/android/java/src/org/chromium/chrome/browser/gesturenav/NavigationHandler.java

// Copyright 2019 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.gesturenav;

import static org.chromium.chrome.browser.gesturenav.GestureNavigationProperties.ACTION;
import static org.chromium.chrome.browser.gesturenav.GestureNavigationProperties.ALLOW_NAV;
import static org.chromium.chrome.browser.gesturenav.GestureNavigationProperties.BUBBLE_OFFSET;
import static org.chromium.chrome.browser.gesturenav.GestureNavigationProperties.CLOSE_INDICATOR;
import static org.chromium.chrome.browser.gesturenav.GestureNavigationProperties.DIRECTION;
import static org.chromium.chrome.browser.gesturenav.GestureNavigationProperties.EDGE;

import android.app.Activity;
import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.util.DisplayMetrics;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;

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

import org.chromium.base.supplier.Supplier;
import org.chromium.cc.input.BrowserControlsState;
import org.chromium.chrome.browser.back_press.BackPressMetrics;
import org.chromium.chrome.browser.gesturenav.BackActionDelegate.ActionType;
import org.chromium.chrome.browser.gesturenav.NavigationBubble.CloseTarget;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabBrowserControlsConstraintsHelper;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.components.browser_ui.widget.TouchEventObserver;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.ui.base.BackGestureEventSwipeEdge;
import org.chromium.ui.base.LocalizationUtils;
import org.chromium.ui.modelutil.PropertyModel;

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

/**
 * Handles history overscroll navigation controlling the underlying UI widget. Note: used only from
 * 3-button navigation mode. For gestural navigation mode, see {@link
 * ToolbarManager#OnBackPressHandler}
 */
class NavigationHandler implements TouchEventObserver {
    // Width of a rectangluar area in dp on the left/right edge used for navigation.
    // Swipe beginning from a point within these rects triggers the operation.
    @VisibleForTesting static final int EDGE_WIDTH_DP = 24;

    // Weighted value to determine when to trigger an edge swipe. Initial scroll
    // vector should form 30 deg or below to initiate swipe action.
    private static final float WEIGTHED_TRIGGER_THRESHOLD = 1.73f;

    // |EDGE_WIDTH_DP| in physical pixel.
    private final float mEdgeWidthPx;

    @IntDef({GestureState.NONE, GestureState.STARTED, GestureState.DRAGGED, GestureState.GLOW})
    @Retention(RetentionPolicy.SOURCE)
    @interface GestureState {
        int NONE = 0;
        int STARTED = 1;
        int DRAGGED = 2;
        int GLOW = 3;
    }

    @IntDef({GestureAction.SHOW_ARROW, GestureAction.RELEASE_BUBBLE, GestureAction.RESET_BUBBLE})
    @Retention(RetentionPolicy.SOURCE)
    @interface GestureAction {
        int SHOW_ARROW = 1;
        int RELEASE_BUBBLE = 2;
        int RESET_BUBBLE = 3;
    }

    private final ViewGroup mParentView;
    private final Context mContext;
    private final Handler mHandler = new Handler();

    private GestureDetector mDetector;
    private View.OnAttachStateChangeListener mAttachStateListener;
    private final BackActionDelegate mBackActionDelegate;
    @Nullable private TabOnBackGestureHandler mTabOnBackGestureHandler;
    private Tab mTab;
    private final Supplier<Boolean> mWillNavigateSupplier;

    private @GestureState int mState;

    private PropertyModel mModel;

    // Total horizontal pull offset for a swipe gesture.
    private float mPullOffsetX;
    // Total vertical pull offset for a swipe gesture.
    private float mPullOffsetY;

    private @BackGestureEventSwipeEdge int mInitiatingEdge;

    private boolean mBackGestureForTabHistoryInProgress;
    private boolean mStartNavDuringOngoingGesture;
    private TabObserver mTabObserver =
            new EmptyTabObserver() {
                @Override
                public void onDidStartNavigationInPrimaryMainFrame(
                        Tab tab, NavigationHandle navigationHandle) {
                    if (tab != mTab) return;
                    mStartNavDuringOngoingGesture |= mBackGestureForTabHistoryInProgress;
                }
            };

    private class SideNavGestureListener extends GestureDetector.SimpleOnGestureListener {
        @Override
        public boolean onDown(MotionEvent event) {
            return NavigationHandler.this.onDown();
        }

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            // |onScroll| needs handling only after the state moved away from |NONE|.
            if (isStopped()) return true;
            return NavigationHandler.this.onScroll(
                    e1.getX(), distanceX, distanceY, e2.getX(), e2.getY());
        }
    }

    public NavigationHandler(
            PropertyModel model,
            ViewGroup parentView,
            BackActionDelegate backActionDelegate,
            Supplier<Boolean> supplier) {
        mModel = model;
        mParentView = parentView;
        mContext = parentView.getContext();
        mBackActionDelegate = backActionDelegate;
        mWillNavigateSupplier = supplier;
        mState = GestureState.NONE;

        mEdgeWidthPx = EDGE_WIDTH_DP * parentView.getResources().getDisplayMetrics().density;
        mDetector = new GestureDetector(mContext, new SideNavGestureListener());
        mAttachStateListener =
                new View.OnAttachStateChangeListener() {
                    @Override
                    public void onViewAttachedToWindow(View v) {}

                    @Override
                    public void onViewDetachedFromWindow(View v) {
                        reset();
                    }
                };
        parentView.addOnAttachStateChangeListener(mAttachStateListener);
    }

    void setTab(Tab tab) {
        if (mTab != null) mTab.removeObserver(mTabObserver);
        mBackGestureForTabHistoryInProgress = false;
        mTab = tab;
        if (tab != null) tab.addObserver(mTabObserver);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        // Forward gesture events only for native pages. Rendered pages receive events
        // from SwipeRefreshHandler.
        if (!shouldProcessTouchEvents()) return false;
        return isActive();
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent e) {
        assert e != null : "The motion event in NavigationHandler shouldn't be null!";
        if (e == null || !shouldProcessTouchEvents()) return false;
        mDetector.onTouchEvent(e);
        if (e.getAction() == MotionEvent.ACTION_UP) release(true);
        return false;
    }

    private boolean shouldProcessTouchEvents() {
        return mTab != null && mTab.isNativePage();
    }

    /**
     * @see GestureDetector#SimpleOnGestureListener#onDown(MotionEvent)
     */
    public boolean onDown() {
        mState = GestureState.STARTED;
        return true;
    }

    /**
     * Processes scroll event from {@link SimpleOnGestureListener#onScroll()}.
     * @param startX X coordinate of the position where gesture swipes from.
     * @param distanceX X delta between previous and current motion event.
     * @param distanceX Y delta between previous and current motion event.
     * @param endX X coordinate of the current motion event.
     * @param endY Y coordinate of the current motion event.
     */
    @VisibleForTesting
    boolean onScroll(float startX, float distanceX, float distanceY, float endX, float endY) {
        // onScroll needs handling only after the state moves away from |NONE|.
        if (mState == GestureState.NONE || !isValidState()) return true;

        if (mState == GestureState.STARTED) {
            if (shouldTriggerUi(startX, distanceX, distanceY)) {
                triggerUi(
                        distanceX > 0
                                ? BackGestureEventSwipeEdge.RIGHT
                                : BackGestureEventSwipeEdge.LEFT);
            }
            if (!isActive()) mState = GestureState.NONE;
        }
        pull(-distanceX, -distanceY);
        return true;
    }

    private boolean isValidState() {
        // We are in a valid state for UI process if the underlying tab is alive.
        return mTab != null && !mTab.isDestroyed();
    }

    private boolean shouldTriggerUi(float sX, float dX, float dY) {
        return Math.abs(dX) > Math.abs(dY) * WEIGTHED_TRIGGER_THRESHOLD
                && (sX < mEdgeWidthPx || (mParentView.getWidth() - mEdgeWidthPx) < sX);
    }

    /**
     * @see {@link HistoryNavigationCoordinator#triggerUi(int)}
     */
    boolean triggerUi(@BackGestureEventSwipeEdge int initiatingEdge) {
        if (!isValidState()) return false;

        mInitiatingEdge = initiatingEdge;

        boolean forward = mInitiatingEdge == BackGestureEventSwipeEdge.RIGHT;

        // If the UI uses an RTL layout, it may be necessary to flip the meaning of each edge so
        // that the left edge goes forward and the right goes back.
        if (LocalizationUtils.shouldMirrorBackForwardGestures()) {
            forward = !forward;
        }

        mModel.set(DIRECTION, forward);
        mModel.set(EDGE, mInitiatingEdge);
        if (canNavigate(forward)) {
            if (mState != GestureState.STARTED) mModel.set(ACTION, GestureAction.RESET_BUBBLE);
            mModel.set(CLOSE_INDICATOR, getCloseIndicator(forward));
            mModel.set(ACTION, GestureAction.SHOW_ARROW);
            mState = GestureState.DRAGGED;

            if (willUpdateTabHistory(forward)) {
                if (GestureNavigationUtils.allowTransition(mTab, forward)) {
                    if (TabOnBackGestureHandler.shouldAnimateNavigationTransition(
                            forward, initiatingEdge)) {
                        // Always force to show the top control at the start of the gesture.
                        TabBrowserControlsConstraintsHelper.update(
                                mTab, BrowserControlsState.SHOWN, /* animate= */ true);
                    }
                    mTabOnBackGestureHandler = TabOnBackGestureHandler.from(mTab);
                    mTabOnBackGestureHandler.onBackStarted(getProgress(), mInitiatingEdge, forward);
                }
                BackPressMetrics.recordNavStatusOnGestureStart(
                        mTab.getWebContents().hasUncommittedNavigationInPrimaryMainFrame(),
                        mTab.getWindowAndroid().getActivity().get().getWindow());
                mStartNavDuringOngoingGesture = false;
                mBackGestureForTabHistoryInProgress = true;
            }
            mBackActionDelegate.onGestureHandled();
        } else {
            mBackActionDelegate.onGestureUnhandled();
        }

        return true;
    }

    /**
     * @param forward {@code true} for forward navigation, or {@code false} for back.
     * @return True if the gesture is going to navigate page rather than closing tab or exiting app.
     */
    boolean willUpdateTabHistory(boolean forward) {
        if (mTab == null) return false;
        return forward ? mTab.canGoForward() : mTab.canGoBack();
    }

    private boolean canNavigate(boolean forward) {
        // Navigating back is considered always possible (actual navigation, closing
        // tab, or exiting app).
        return !forward || mTab != null && mTab.canGoForward();
    }

    /**
     * Perform navigation back or forward.
     * @param forward {@code true} for forward navigation, or {@code false} for back.
     */
    void navigate(boolean forward) {
        if (!isValidState()) return;
        if (mTabOnBackGestureHandler != null) {
            // Delegate navigation to native side: supposed to be triggered after animation.
            return;
        }
        if (forward) {
            // Session history may have changed since the beginning of the gesture such that it's no
            // longer possible to go forward.
            if (mTab.canGoForward()) {
                mTab.goForward();
            }
        } else {
            // Perform back action at the next UI thread execution. The back action can
            // potentially close the tab we're running on, which causes use-after-destroy
            // exception if the closing operation is performed synchronously.
            mHandler.post(mBackActionDelegate::onBackGesture);
        }
    }

    /**
     * @return The type of target to close when left swipe is performed. Could be
     *         the current tab, Chrome app, or none as defined in {@link CloseTarget}.
     * @param forward {@code true} for forward navigation, or {@code false} for back.
     */
    private @CloseTarget int getCloseIndicator(boolean forward) {
        if (forward) return CloseTarget.NONE;

        @ActionType int type = mBackActionDelegate.getBackActionType(mTab);
        if (type == ActionType.CLOSE_TAB) {
            return CloseTarget.TAB;
        } else if (type == ActionType.EXIT_APP) {
            return CloseTarget.APP;
        } else {
            return CloseTarget.NONE;
        }
    }

    /**
     * @see {@link HistoryNavigationCoordinator#release(boolean)}
     */
    void release(boolean allowNav) {
        // If the back gesture will update history, record the metrics.
        if (mBackGestureForTabHistoryInProgress) {
            BackPressMetrics.recordNavStatusDuringGesture(
                    mStartNavDuringOngoingGesture,
                    mTab.getWindowAndroid().getActivity().get().getWindow());
        }
        mBackGestureForTabHistoryInProgress = false;
        mStartNavDuringOngoingGesture = false;
        mModel.set(ALLOW_NAV, allowNav);
        if (mState == GestureState.DRAGGED) {
            mModel.set(ACTION, GestureAction.RELEASE_BUBBLE);
        }
        mPullOffsetX = 0.f;
        mPullOffsetY = 0.f;
        if (mTabOnBackGestureHandler != null) {
            if (allowNav && mWillNavigateSupplier.get()) {
                mTabOnBackGestureHandler.onBackInvoked();
            } else {
                mTabOnBackGestureHandler.onBackCancelled();
            }
            mTabOnBackGestureHandler = null;
        }
    }

    /**
     * @see {@link HistoryNavigationCoordinator#reset()}
     */
    void reset() {
        if (mState == GestureState.DRAGGED) {
            mModel.set(ACTION, GestureAction.RESET_BUBBLE);
        }
        mState = GestureState.NONE;
        mPullOffsetX = 0.f;
        mPullOffsetY = 0.f;
    }

    /**
     * @see {@link HistoryNavigationCoordinator#pull(float, float)}
     */
    void pull(float xDelta, float yDelta) {
        mPullOffsetX += xDelta;
        mPullOffsetY += yDelta;
        if (mState == GestureState.DRAGGED) {
            mModel.set(BUBBLE_OFFSET, mPullOffsetX);
        }
        if (mTabOnBackGestureHandler != null) {
            mTabOnBackGestureHandler.onBackProgressed(getProgress(), mInitiatingEdge);
        }
    }

    /**
     * @return {@code true} if navigation was triggered and its UI is in action, or edge glow effect
     *     is visible.
     */
    boolean isActive() {
        return mState == GestureState.DRAGGED || mState == GestureState.GLOW;
    }

    /**
     * @return Which edge the current gesture was initiated from.
     */
    @BackGestureEventSwipeEdge
    int getInitiatingEdge() {
        return mInitiatingEdge;
    }

    /**
     * @return {@code true} if navigation is not in operation.
     */
    private boolean isStopped() {
        return mState == GestureState.NONE;
    }

    /**
     * Get progress of back gesture. This is a mock of
     * {@link android.window.BackEvent#getProgress()}.
     */
    private float getProgress() {
        assert mTab != null;
        Activity activity = mTab.getWindowAndroid().getActivity().get();
        assert activity != null;
        int width;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            width = activity.getWindowManager().getCurrentWindowMetrics().getBounds().width();
        } else {
            DisplayMetrics displayMetrics = new DisplayMetrics();
            activity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics);
            width = displayMetrics.heightPixels;
        }

        // Progress runs from 0 to 1 even when pulling from the right edge.
        float offset =
                mInitiatingEdge == BackGestureEventSwipeEdge.LEFT ? mPullOffsetX : -mPullOffsetX;

        return Math.min(Math.max(0, offset / width), 1);
    }

    /** Performs cleanup upon destruction. */
    void destroy() {
        if (mTab != null) {
            assert mTabObserver != null : "Always has a tab observer";
            mTab.removeObserver(mTabObserver);
        }
        mParentView.removeOnAttachStateChangeListener(mAttachStateListener);
        mDetector = null;
    }

    TabOnBackGestureHandler getTabOnBackGestureHandlerForTesting() {
        return mTabOnBackGestureHandler;
    }
}