chromium/chrome/android/java/src/org/chromium/chrome/browser/compositor/layouts/eventfilter/OverlayPanelEventFilter.java

// Copyright 2016 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.compositor.layouts.eventfilter;

import android.content.Context;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import android.view.ViewGroup;

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

import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanel.PanelState;
import org.chromium.chrome.browser.compositor.bottombar.OverlayPanelManager;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener;
import org.chromium.content_public.browser.WebContents;

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

/**
 * The {@link MotionEventFilter} used when an overlay panel is being shown. It filters
 * events that happen in the Content View area and propagates them to the appropriate
 * WebContents.
 */
public class OverlayPanelEventFilter extends MotionEventFilter {
    /** The targets that can handle MotionEvents. */
    @IntDef({EventTarget.UNDETERMINED, EventTarget.PANEL, EventTarget.CONTENT_VIEW})
    @Retention(RetentionPolicy.SOURCE)
    private @interface EventTarget {
        int UNDETERMINED = 0;
        int PANEL = 1;
        int CONTENT_VIEW = 2;
    }

    /** The direction of the gesture. */
    @IntDef({
        GestureOrientation.UNDETERMINED,
        GestureOrientation.HORIZONTAL,
        GestureOrientation.VERTICAL
    })
    @Retention(RetentionPolicy.SOURCE)
    private @interface GestureOrientation {
        int UNDETERMINED = 0;
        int HORIZONTAL = 1;
        int VERTICAL = 2;
    }

    /**
     * The boost factor that can be applied to prioritize vertical movements over horizontal ones.
     */
    private static final float VERTICAL_DETERMINATION_BOOST = 1.25f;

    /** The OverlayPanel that this filter is for. */
    private final OverlayPanel mPanel;

    /** The {@link GestureDetector} used to distinguish tap and scroll gestures. */
    private final GestureDetector mGestureDetector;

    /** The @{link SwipeGestureListener} that recognizes directional swipe gestures. */
    private final SwipeGestureListener mSwipeGestureListener;

    /**
     * The square of ViewConfiguration.getScaledTouchSlop() in pixels used to calculate whether
     * the finger has moved beyond the established threshold.
     */
    private final float mTouchSlopSquarePx;

    /** The target to propagate events to. */
    private @EventTarget int mEventTarget;

    /** Whether the code is in the middle of the process of determining the event target. */
    private boolean mIsDeterminingEventTarget;

    /** Whether the event target has been determined. */
    private boolean mHasDeterminedEventTarget;

    /** The previous target the events were propagated to. */
    private @EventTarget int mPreviousEventTarget;

    /** Whether the event target has changed since the last touch event. */
    private boolean mHasChangedEventTarget;

    /**
     * Whether the event target might change. This will be true in cases we know the overscroll
     * and/or underscroll might happen, which means we'll have to constantly monitor the event
     * targets in order to determine the exact moment the target has changed.
     */
    private boolean mMayChangeEventTarget;

    /** Whether the gesture orientation has been determined. */
    private boolean mHasDeterminedGestureOrientation;

    /** The current gesture orientation. */
    private @GestureOrientation int mGestureOrientation;

    /** Whether the events are being recorded. */
    private boolean mIsRecordingEvents;

    /** Whether the ACTION_DOWN that initiated the MotionEvent's stream was synthetic. */
    private boolean mWasActionDownEventSynthetic;

    /** The X coordinate of the synthetic ACTION_DOWN MotionEvent. */
    private float mSyntheticActionDownX;

    /** The Y coordinate of the synthetic ACTION_DOWN MotionEvent. */
    private float mSyntheticActionDownY;

    /** The list of recorded events. */
    private final ArrayList<MotionEvent> mRecordedEvents = new ArrayList<MotionEvent>();

    /** The initial Y position of the current gesture. */
    private float mInitialEventY;

    /** Whether or not the superclass has seen a down event. */
    private boolean mFilterHadDownEvent;

    private class SwipeGestureListenerImpl extends SwipeGestureListener {
        public SwipeGestureListenerImpl(Context context) {
            super(context, mPanel);
        }

        @Override
        public boolean onSingleTapUp(MotionEvent event) {
            mPanel.handleClick(event.getX() * mPxToDp, event.getY() * mPxToDp);
            return true;
        }
    }

    /**
     * Creates a {@link MotionEventFilter} with offset touch events.
     * @param context The {@link Context} for Android.
     * @param panelManager The {@link OverlayPanelManager} responsible for showing panels.
     */
    public OverlayPanelEventFilter(Context context, OverlayPanel panel) {
        super(context, panel, false, false);

        mGestureDetector = new GestureDetector(context, new InternalGestureDetector());
        mPanel = panel;

        mSwipeGestureListener = new SwipeGestureListenerImpl(context);

        // Store the square of the platform touch slop in pixels to use in the scroll detection.
        // See {@link OverlayPanelEventFilter#isDistanceGreaterThanTouchSlop}.
        float touchSlopPx = ViewConfiguration.get(context).getScaledTouchSlop();
        mTouchSlopSquarePx = touchSlopPx * touchSlopPx;

        reset();
    }

    /**
     * Gets the Content View's vertical scroll position. If the Content View
     * is not available it returns -1.
     * @return The Content View scroll position.
     */
    @VisibleForTesting
    protected float getContentViewVerticalScroll() {
        return mPanel.getContentVerticalScroll();
    }

    @Override
    public boolean onInterceptTouchEventInternal(MotionEvent e, boolean isKeyboardShowing) {
        if (mPanel.isShowing()
                && (mPanel.isCoordinateInsideOverlayPanel(e.getX() * mPxToDp, e.getY() * mPxToDp)
                        // When the Panel is opened, all events should be forwarded to the Panel,
                        // even those who are not inside the Panel. This is to prevent any events
                        // being forward to the base page when the Panel is expanded.
                        || mPanel.isPanelOpened())) {
            return super.onInterceptTouchEventInternal(e, isKeyboardShowing);
        }

        // The event filter will have been recording events before the event target was
        // determined. Clear this cache if the panel is not showing to prevent sending
        // motion events that would start a target's stream with something other than
        // ACTION_DOWN.
        mRecordedEvents.clear();
        reset();

        return false;
    }

    @Override
    public boolean onTouchEventInternal(MotionEvent e) {
        final int action = e.getActionMasked();

        if (mPanel.getPanelState() == PanelState.PEEKED) {
            if (action == MotionEvent.ACTION_DOWN) {
                // To avoid a gray flash of empty content, show the search content
                // view immediately on tap rather than waiting for panel expansion.
                // TODO(pedrosimonetti): Once we implement "side-swipe to dismiss"
                // we'll have to revisit this because we don't want to set the
                // Content View visibility to true when the side-swipe is detected.
                mPanel.notifyBarTouched(e.getX() * mPxToDp);
            }
            mSwipeGestureListener.onTouchEvent(e);
            mGestureDetector.onTouchEvent(e);
            return true;
        }

        if (!mIsDeterminingEventTarget && action == MotionEvent.ACTION_DOWN) {
            mInitialEventY = e.getY();
            if (mPanel.isCoordinateInsideContent(e.getX() * mPxToDp, mInitialEventY * mPxToDp)) {
                // If the DOWN event happened inside the Content View, we'll need
                // to wait until the user has moved the finger beyond a certain threshold,
                // so we can determine the gesture's orientation and consequently be able
                // to tell if the Content View will accept the gesture.
                mIsDeterminingEventTarget = true;
                mMayChangeEventTarget = true;
            } else {
                // If the DOWN event happened outside the Content View, then we know
                // that the Panel will start handling the event right away.
                setEventTarget(EventTarget.PANEL);
                mMayChangeEventTarget = false;
            }
        }

        // Send the event to the GestureDetector so we can distinguish between scroll and tap.
        mGestureDetector.onTouchEvent(e);

        if (mHasDeterminedEventTarget) {
            // If the event target has been determined, resume pending events, then propagate
            // the current event to the appropriate target.
            resumeAndPropagateEvent(e);
        } else {
            // If the event target has not been determined, we need to record a copy of the event
            // until we are able to determine the event target.
            MotionEvent event = MotionEvent.obtain(e);
            mRecordedEvents.add(event);
            mIsRecordingEvents = true;
        }

        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
            reset();
        }

        return true;
    }

    /**
     * Resets the current and previous {@link EventTarget} as well the {@link GestureOrientation}
     * to the UNDETERMINED state.
     */
    private void reset() {
        mEventTarget = EventTarget.UNDETERMINED;
        mIsDeterminingEventTarget = false;
        mHasDeterminedEventTarget = false;

        mPreviousEventTarget = EventTarget.UNDETERMINED;
        mHasChangedEventTarget = false;
        mMayChangeEventTarget = false;

        mWasActionDownEventSynthetic = false;

        mGestureOrientation = GestureOrientation.UNDETERMINED;
        mHasDeterminedGestureOrientation = false;
    }

    /**
     * Resumes pending events then propagates the given event to the current {@link EventTarget}.
     *
     * Resuming events might consist in simply propagating previously recorded events if the
     * EventTarget was UNDETERMINED when the gesture started.
     *
     * For the case where the EventTarget has changed during the course of the gesture, we'll
     * need to simulate a gesture end in the previous target (by simulating an ACTION_CANCEL
     * event) and a gesture start in the new target (by simulating an ACTION_DOWN event).
     *
     * @param e The {@link MotionEvent} to be propagated after resuming the pending events.
     */
    private void resumeAndPropagateEvent(MotionEvent e) {
        if (mIsRecordingEvents) {
            resumeRecordedEvents();
        }

        if (mHasChangedEventTarget) {
            // If the event target has changed since the beginning of the gesture, then we need
            // to send a ACTION_CANCEL to the previous event target to make sure it no longer
            // expects events.
            propagateAndRecycleEvent(copyEvent(e, MotionEvent.ACTION_CANCEL), mPreviousEventTarget);

            // Similarly we need to send an ACTION_DOWN to the new event target so subsequent
            // events can be analyzed properly by the Gesture Detector.
            MotionEvent syntheticActionDownEvent = copyEvent(e, MotionEvent.ACTION_DOWN);

            // Store the synthetic ACTION_DOWN coordinates to prevent unwanted taps from
            // happening. See {@link OverlayPanelEventFilter#propagateEventToContent}.
            mWasActionDownEventSynthetic = true;
            mSyntheticActionDownX = syntheticActionDownEvent.getX();
            mSyntheticActionDownY =
                    syntheticActionDownEvent.getY() - mPanel.getContentY() / mPxToDp;

            propagateAndRecycleEvent(syntheticActionDownEvent, mEventTarget);

            mHasChangedEventTarget = false;
        }

        propagateEvent(e, mEventTarget);
    }

    /** Resumes recorded events by propagating all of them to the current {@link EventTarget}. */
    private void resumeRecordedEvents() {
        for (int i = 0, size = mRecordedEvents.size(); i < size; i++) {
            propagateAndRecycleEvent(mRecordedEvents.get(i), mEventTarget);
        }

        mRecordedEvents.clear();
        mIsRecordingEvents = false;
    }

    /**
     * Propagates the given {@link MotionEvent} to the given {@link EventTarget}, recycling it
     * afterwards. This is intended for synthetic events only, those create by
     * {@link MotionEvent#obtain} or the helper methods
     * {@link OverlayPanelEventFilter#lockEventHorizontallty} and
     * {@link OverlayPanelEventFilter#copyEvent}.
     *
     * @param e The {@link MotionEvent} to be propagated.
     * @param target The {@link EventTarget} to propagate events to.
     */
    private void propagateAndRecycleEvent(MotionEvent e, @EventTarget int target) {
        propagateEvent(e, target);
        e.recycle();
    }

    /**
     * Propagates the given {@link MotionEvent} to the given {@link EventTarget}.
     * @param e The {@link MotionEvent} to be propagated.
     * @param target The {@link EventTarget} to propagate events to.
     */
    private void propagateEvent(MotionEvent e, @EventTarget int target) {
        if (target == EventTarget.PANEL) {
            // Make sure the internal gesture detector has seen at least on down event.
            if (e.getActionMasked() == MotionEvent.ACTION_DOWN) mFilterHadDownEvent = true;
            if (!mFilterHadDownEvent) {
                MotionEvent down = MotionEvent.obtain(e);
                down.setAction(MotionEvent.ACTION_DOWN);
                super.onTouchEventInternal(down);
                mFilterHadDownEvent = true;
            }
            super.onTouchEventInternal(e);
        } else if (target == EventTarget.CONTENT_VIEW) {
            propagateEventToContent(e);
        }
    }

    /**
     * Propagates the given {@link MotionEvent} to the {@link WebContents}.
     * @param e The {@link MotionEvent} to be propagated.
     */
    protected void propagateEventToContent(MotionEvent e) {
        MotionEvent event = e;
        int action = event.getActionMasked();
        boolean isSyntheticEvent = false;
        if (mGestureOrientation == GestureOrientation.HORIZONTAL && !mPanel.isMaximized()) {
            // Ignores multitouch events to prevent the Content View from scrolling.
            if (action == MotionEvent.ACTION_POINTER_UP
                    || action == MotionEvent.ACTION_POINTER_DOWN) {
                return;
            }

            // NOTE(pedrosimonetti): Lock horizontal motion, ignoring all vertical changes,
            // when the Panel is not maximized. This is to prevent the Content View
            // from scrolling when side swiping on the expanded Panel. Also, note that the
            // method {@link OverlayPanelEventFilter#lockEventHorizontallty} will always
            // return an event with a single pointer, which is necessary to prevent
            // the app from crashing when the motion involves multiple pointers.
            // See: crbug.com/486901
            event =
                    MotionEvent.obtain(
                            e.getDownTime(),
                            e.getEventTime(),
                            // NOTE(pedrosimonetti): Use getActionMasked() to make sure we're not
                            // send any pointer information to the event, given that getAction()
                            // may have the pointer Id associated to it.
                            e.getActionMasked(),
                            e.getX(),
                            mInitialEventY,
                            e.getMetaState());

            isSyntheticEvent = true;
        }

        final float contentViewOffsetXPx = mPanel.getContentX() / mPxToDp;
        final float contentViewOffsetYPx = mPanel.getContentY() / mPxToDp;

        // Adjust the offset to be relative to the Content View.
        event.offsetLocation(-contentViewOffsetXPx, -contentViewOffsetYPx);

        // Get the container view to propagate the event to.
        ViewGroup containerView = mPanel.getContainerView();

        boolean wasEventCanceled = false;
        if (mWasActionDownEventSynthetic && action == MotionEvent.ACTION_UP) {
            float deltaX = event.getX() - mSyntheticActionDownX;
            float deltaY = event.getY() - mSyntheticActionDownY;
            // NOTE(pedrosimonetti): If the ACTION_DOWN event was synthetic and the distance
            // between it and the ACTION_UP event was short, then we should synthesize an
            // ACTION_CANCEL event to prevent a Tap gesture from being triggered on the
            // Content View. See crbug.com/408654
            if (!isDistanceGreaterThanTouchSlop(deltaX, deltaY)) {
                event.setAction(MotionEvent.ACTION_CANCEL);
                if (containerView != null) containerView.dispatchTouchEvent(event);
                wasEventCanceled = true;
            }
        } else if (action == MotionEvent.ACTION_DOWN) {
            mPanel.onTouchSearchContentViewAck();
        }

        if (!wasEventCanceled && containerView != null) containerView.dispatchTouchEvent(event);

        // Synthetic events should be recycled.
        if (isSyntheticEvent) event.recycle();
    }

    /**
     * Creates a {@link MotionEvent} inheriting from a given |e| event.
     * @param e The {@link MotionEvent} to inherit properties from.
     * @param action The MotionEvent's Action to be used.
     * @return A new {@link MotionEvent}.
     */
    private MotionEvent copyEvent(MotionEvent e, int action) {
        MotionEvent event = MotionEvent.obtain(e);
        event.setAction(action);
        return event;
    }

    /**
     * Handles the tap event, determining the event target.
     * @param e The tap {@link MotionEvent}.
     * @return Whether the event has been consumed.
     */
    protected boolean handleSingleTapUp(MotionEvent e) {
        // If the panel is peeking then the panel was already notified in #onTouchEventInternal().
        if (mPanel.getPanelState() == PanelState.PEEKED) return false;

        setEventTarget(
                mPanel.isCoordinateInsideContent(e.getX() * mPxToDp, e.getY() * mPxToDp)
                        ? EventTarget.CONTENT_VIEW
                        : EventTarget.PANEL);
        return false;
    }

    /**
     * Handles the scroll event, determining the gesture orientation and event target,
     * when appropriate.
     * @param e1 The first down {@link MotionEvent} that started the scrolling.
     * @param e2 The move {@link MotionEvent} that triggered the current scroll.
     * @param distanceY The distance along the Y axis that has been scrolled since the last call
     *                  to handleScroll.
     * @return Whether the event has been consumed.
     */
    protected boolean handleScroll(MotionEvent e1, MotionEvent e2, float distanceY) {
        // If the panel is peeking then the swipe recognizer will handle the scroll event.
        if (mPanel.getPanelState() == PanelState.PEEKED) return false;

        // Only determines the gesture orientation if it hasn't been determined yet,
        // affectively "locking" the orientation once the gesture has started.
        if (!mHasDeterminedGestureOrientation && isDistanceGreaterThanTouchSlop(e1, e2)) {
            determineGestureOrientation(e1, e2);
        }

        // Only determines the event target after determining the gesture orientation and
        // if it hasn't been determined yet or if changing the event target during the
        // middle of the gesture is supported. This will allow a smooth transition from
        // swiping the Panel and scrolling the Content View.
        final boolean mayChangeEventTarget = mMayChangeEventTarget && e2.getPointerCount() == 1;
        if (mHasDeterminedGestureOrientation
                && (!mHasDeterminedEventTarget || mayChangeEventTarget)) {
            determineEventTarget(distanceY);
        }

        return false;
    }

    /**
     * Determines the gesture orientation.
     * @param e1 The first down {@link MotionEvent} that started the scrolling.
     * @param e2 The move {@link MotionEvent} that triggered the current scroll.
     */
    private void determineGestureOrientation(MotionEvent e1, MotionEvent e2) {
        float deltaX = Math.abs(e2.getX() - e1.getX());
        float deltaY = Math.abs(e2.getY() - e1.getY());
        mGestureOrientation =
                deltaY * VERTICAL_DETERMINATION_BOOST > deltaX
                        ? GestureOrientation.VERTICAL
                        : GestureOrientation.HORIZONTAL;
        mHasDeterminedGestureOrientation = true;
    }

    /**
     * Determines the target to propagate events to. This will not only update the
     * {@code mEventTarget} but also save the previous target and determine whether the
     * target has changed.
     * @param distanceY The distance along the Y axis that has been scrolled since the last call
     *                  to handleScroll.
     */
    private void determineEventTarget(float distanceY) {
        boolean isVertical = mGestureOrientation == GestureOrientation.VERTICAL;

        boolean shouldPropagateEventsToPanel;
        if (mPanel.isMaximized()) {
            // Allow overscroll in the Content View to move the Panel.
            boolean isMovingDown = distanceY < 0;
            shouldPropagateEventsToPanel =
                    isVertical && isMovingDown && getContentViewVerticalScroll() == 0;
        } else {
            // Only allow horizontal movements to be propagated to the Content View
            // when the Panel is expanded (that is, not maximized).
            shouldPropagateEventsToPanel = isVertical;

            // If the gesture is horizontal, then we know that the event target won't change.
            if (!isVertical) mMayChangeEventTarget = false;
        }

        @EventTarget
        int target = shouldPropagateEventsToPanel ? EventTarget.PANEL : EventTarget.CONTENT_VIEW;

        if (target != mEventTarget) {
            mPreviousEventTarget = mEventTarget;
            setEventTarget(target);

            mHasChangedEventTarget =
                    mEventTarget != mPreviousEventTarget
                            && mPreviousEventTarget != EventTarget.UNDETERMINED;
        }
    }

    /**
     * Sets the {@link EventTarget}.
     * @param target The {@link EventTarget} to be set.
     */
    private void setEventTarget(@EventTarget int target) {
        mEventTarget = target;

        mIsDeterminingEventTarget = false;
        mHasDeterminedEventTarget = true;
    }

    /**
     * @param e1 The first down {@link MotionEvent} that started the scrolling.
     * @param e2 The move {@link MotionEvent} that triggered the current scroll.
     * @return Whether the distance is greater than the touch slop threshold.
     */
    private boolean isDistanceGreaterThanTouchSlop(MotionEvent e1, MotionEvent e2) {
        float deltaX = e2.getX() - e1.getX();
        float deltaY = e2.getY() - e1.getY();
        // Check if the distance between the events |e1| and |e2| is greater than the touch slop.
        return isDistanceGreaterThanTouchSlop(deltaX, deltaY);
    }

    /**
     * @param deltaX The delta X in pixels.
     * @param deltaY The delta Y in pixels.
     * @return Whether the distance is greater than the touch slop threshold.
     */
    private boolean isDistanceGreaterThanTouchSlop(float deltaX, float deltaY) {
        return deltaX * deltaX + deltaY * deltaY > mTouchSlopSquarePx;
    }

    /** Internal GestureDetector class that is responsible for determining the event target. */
    private class InternalGestureDetector extends GestureDetector.SimpleOnGestureListener {
        @Override
        public void onShowPress(MotionEvent e) {
            mPanel.onShowPress(e.getX() * mPxToDp, e.getY() * mPxToDp);
        }

        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            return handleSingleTapUp(e);
        }

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            return handleScroll(e1, e2, distanceY);
        }
    }
}