chromium/content/public/android/java/src/org/chromium/content/browser/GestureListenerManagerImpl.java

// Copyright 2017 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.content.browser;

import static org.chromium.cc.mojom.RootScrollOffsetUpdateFrequency.NONE;

import android.view.HapticFeedbackConstants;
import android.view.View;

import androidx.annotation.VisibleForTesting;

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

import org.chromium.base.ObserverList;
import org.chromium.base.ObserverList.RewindableIterator;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.TraceEvent;
import org.chromium.base.UserData;
import org.chromium.blink.mojom.EventType;
import org.chromium.cc.mojom.RootScrollOffsetUpdateFrequency;
import org.chromium.content.browser.input.ImeAdapterImpl;
import org.chromium.content.browser.selection.SelectionPopupControllerImpl;
import org.chromium.content.browser.webcontents.WebContentsImpl;
import org.chromium.content.browser.webcontents.WebContentsImpl.UserDataFactory;
import org.chromium.content_public.browser.ContentFeatureList;
import org.chromium.content_public.browser.ContentFeatureMap;
import org.chromium.content_public.browser.GestureListenerManager;
import org.chromium.content_public.browser.GestureStateListener;
import org.chromium.content_public.browser.ViewEventSink.InternalAccessDelegate;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.GestureEventType;
import org.chromium.ui.base.ViewAndroidDelegate;

import java.util.Collections;
import java.util.HashMap;

/**
 * Implementation of the interface {@link GestureListenerManager}. Manages
 * the {@link GestureStateListener} instances, and invokes them upon
 * notification of various events.
 * Instantiated object is held inside {@link UserDataHost} that is managed by {@link WebContents}.
 */
@JNINamespace("content")
public class GestureListenerManagerImpl
        implements GestureListenerManager,
                WindowEventObserver,
                UserData,
                ViewAndroidDelegate.VerticalScrollDirectionChangeListener {
    private static final class UserDataFactoryLazyHolder {
        private static final UserDataFactory<GestureListenerManagerImpl> INSTANCE =
                GestureListenerManagerImpl::new;
    }

    private static GestureListenerManagerImpl sInstanceForTesting;

    private final WebContentsImpl mWebContents;
    private final ObserverList<GestureStateListener> mListeners;
    private final RewindableIterator<GestureStateListener> mIterator;
    private final HashMap<GestureStateListener, Integer> mListenerFrequency;
    private SelectionPopupControllerImpl mSelectionPopupController;
    private ViewAndroidDelegate mViewDelegate;
    private InternalAccessDelegate mScrollDelegate;
    private final boolean mHidePastePopupOnGSB;
    private final boolean mResetGestureDetectionOnLosingFocus;

    private long mNativeGestureListenerManager;

    /**
     * Whether a user scroll sequence is active, used to hide text selection
     * handles. Note that a scroll sequence will *always* bound a pinch
     * sequence, so this will also be true for the duration of a pinch gesture.
     * A fling is also always bounded by a gesture scroll sequence so this is
     * also true for the duration of the fling.
     */
    private boolean mIsGestureScrollInProgress;

    /** Whether a fling scroll is currently active. */
    private boolean mHasActiveFlingScroll;

    private @RootScrollOffsetUpdateFrequency.EnumType Integer mRootScrollOffsetUpdateFrequency;

    /**
     * @param webContents {@link WebContents} object.
     * @return {@link GestureListenerManager} object used for the give WebContents.
     *         Creates one if not present.
     */
    public static GestureListenerManagerImpl fromWebContents(WebContents webContents) {
        if (sInstanceForTesting != null) return sInstanceForTesting;
        return ((WebContentsImpl) webContents)
                .getOrSetUserData(
                        GestureListenerManagerImpl.class, UserDataFactoryLazyHolder.INSTANCE);
    }

    // TODO(crbug.com/40850475): Mocking |#fromWebContents()| may be a better option, when
    // available.
    public static void setInstanceForTesting(GestureListenerManagerImpl instance) {
        sInstanceForTesting = instance;
        ResettersForTesting.register(() -> sInstanceForTesting = null);
    }

    public GestureListenerManagerImpl(WebContents webContents) {
        mWebContents = (WebContentsImpl) webContents;
        mListeners = new ObserverList<GestureStateListener>();
        mIterator = mListeners.rewindableIterator();
        mListenerFrequency = new HashMap<>();
        mViewDelegate = mWebContents.getViewAndroidDelegate();
        mViewDelegate.addVerticalScrollDirectionChangeListener(this);
        WindowEventObserverManager.from(mWebContents).addObserver(this);
        mNativeGestureListenerManager =
                GestureListenerManagerImplJni.get()
                        .init(GestureListenerManagerImpl.this, mWebContents);
        mHidePastePopupOnGSB =
                ContentFeatureMap.isEnabled(ContentFeatureList.HIDE_PASTE_POPUP_ON_GSB);
        mResetGestureDetectionOnLosingFocus =
                !ContentFeatureMap.isEnabled(ContentFeatureList.CONTINUE_GESTURE_ON_LOSING_FOCUS);
    }

    public void resetGestureDetection() {
        if (mNativeGestureListenerManager != 0) {
            GestureListenerManagerImplJni.get()
                    .resetGestureDetection(
                            mNativeGestureListenerManager, GestureListenerManagerImpl.this);
        }
    }

    public void setScrollDelegate(InternalAccessDelegate scrollDelegate) {
        mScrollDelegate = scrollDelegate;
    }

    @Override
    public void addListener(GestureStateListener listener) {
        addListener(listener, NONE);
    }

    @Override
    public void addListener(
            GestureStateListener listener,
            @RootScrollOffsetUpdateFrequency.EnumType int frequency) {
        final boolean didAdd = mListeners.addObserver(listener);
        if (mNativeGestureListenerManager != 0 && didAdd) {
            mListenerFrequency.put(listener, frequency);
            boolean frequencyChanged = updateRootScrollOffsetUpdateFrequency();
            if (!frequencyChanged) {
                // If the frequency changed, this update will come from the renderer, so we don't
                // need to call this with the cached offset.
                listener.onScrollOffsetOrExtentChanged(
                        verticalScrollOffset(), verticalScrollExtent());
            }
        }
    }

    @Override
    public void removeListener(GestureStateListener listener) {
        final boolean didRemove = mListeners.removeObserver(listener);
        if (mNativeGestureListenerManager != 0 && didRemove) {
            assert mListenerFrequency.containsKey(listener);
            mListenerFrequency.remove(listener);
            updateRootScrollOffsetUpdateFrequency();
        }
    }

    @Override
    public boolean hasListener(GestureStateListener listener) {
        return mListeners.hasObserver(listener);
    }

    /**
     * Calculates and updates the root scroll offset update frequency.
     * @return Whether the root scroll offset update frequency changed.
     */
    private boolean updateRootScrollOffsetUpdateFrequency() {
        int newFrequency = calculateMaxRootScrollOffsetUpdateFrequency();
        boolean frequencyChanged =
                mRootScrollOffsetUpdateFrequency == null
                        || !mRootScrollOffsetUpdateFrequency.equals(newFrequency);
        mRootScrollOffsetUpdateFrequency = newFrequency;
        if (!frequencyChanged) return false;

        GestureListenerManagerImplJni.get()
                .setRootScrollOffsetUpdateFrequency(
                        mNativeGestureListenerManager, mRootScrollOffsetUpdateFrequency);
        return true;
    }

    private @RootScrollOffsetUpdateFrequency.EnumType int
            calculateMaxRootScrollOffsetUpdateFrequency() {
        if (mListenerFrequency.isEmpty()) return NONE;
        return Collections.max(mListenerFrequency.values());
    }

    @Override
    public void updateMultiTouchZoomSupport(boolean supportsMultiTouchZoom) {
        if (mNativeGestureListenerManager == 0) return;
        GestureListenerManagerImplJni.get()
                .setMultiTouchZoomSupportEnabled(
                        mNativeGestureListenerManager,
                        GestureListenerManagerImpl.this,
                        supportsMultiTouchZoom);
    }

    @Override
    public void updateDoubleTapSupport(boolean supportsDoubleTap) {
        if (mNativeGestureListenerManager == 0) return;
        GestureListenerManagerImplJni.get()
                .setDoubleTapSupportEnabled(
                        mNativeGestureListenerManager,
                        GestureListenerManagerImpl.this,
                        supportsDoubleTap);
    }

    /** Update all the listeners after touch down event occurred. */
    @CalledByNative
    private void updateOnTouchDown() {
        for (mIterator.rewind(); mIterator.hasNext(); ) mIterator.next().onTouchDown();
    }

    /** Returns whether there's an active, ongoing fling scroll. */
    public boolean hasActiveFlingScroll() {
        return mHasActiveFlingScroll;
    }

    @VisibleForTesting
    @RootScrollOffsetUpdateFrequency.EnumType
    public int getRootScrollOffsetUpdateFrequencyForTesting() {
        return calculateMaxRootScrollOffsetUpdateFrequency();
    }

    // WindowEventObserver

    @Override
    public void onWindowFocusChanged(boolean gainFocus) {
        if (mResetGestureDetectionOnLosingFocus) {
            if (!gainFocus) resetGestureDetection();
        }
        for (mIterator.rewind(); mIterator.hasNext(); ) {
            mIterator.next().onWindowFocusChanged(gainFocus);
        }
    }

    /**
     * Update all the listeners after vertical scroll offset/extent has changed.
     * @param offset New vertical scroll offset.
     * @param extent New vertical scroll extent, or viewport height.
     */
    public void updateOnScrollChanged(int offset, int extent) {
        for (mIterator.rewind(); mIterator.hasNext(); ) {
            GestureStateListener listener = mIterator.next();
            listener.onScrollOffsetOrExtentChanged(offset, extent);
        }
    }

    /** Update all the listeners after scrolling end event occurred. */
    public void updateOnScrollEnd() {
        setGestureScrollInProgress(false);
        for (mIterator.rewind(); mIterator.hasNext(); ) {
            mIterator.next().onScrollEnded(verticalScrollOffset(), verticalScrollExtent());
        }
    }

    /**
     * Update all the listeners after min/max scale limit has changed.
     * @param minScale New minimum scale.
     * @param maxScale New maximum scale.
     */
    public void updateOnScaleLimitsChanged(float minScale, float maxScale) {
        for (mIterator.rewind(); mIterator.hasNext(); ) {
            mIterator.next().onScaleLimitsChanged(minScale, maxScale);
        }
    }

    @Override
    public void onVerticalScrollDirectionChanged(boolean directionUp, float currentScrollRatio) {
        for (mIterator.rewind(); mIterator.hasNext(); ) {
            mIterator.next().onVerticalScrollDirectionChanged(directionUp, currentScrollRatio);
        }
    }

    /* Called when ongoing fling gesture needs to be reset. */
    private void resetFlingGesture() {
        if (mHasActiveFlingScroll) {
            onFlingEnd();
            mHasActiveFlingScroll = false;
        }
    }

    @CalledByNative
    @VisibleForTesting
    void onFlingEnd() {
        mHasActiveFlingScroll = false;
        for (mIterator.rewind(); mIterator.hasNext(); ) {
            mIterator.next().onFlingEndGesture(verticalScrollOffset(), verticalScrollExtent());
        }
    }

    @CalledByNative
    @VisibleForTesting
    void onEventAck(int event, boolean consumed) {
        switch (event) {
            case EventType.GESTURE_FLING_START:
                // If we're here, then |consumed| is false as otherwise #onFlingStart() would have
                // been called by native instead.
                assert !consumed;

                // If a scroll ends with a fling, a SCROLL_END event is never sent.
                // However, if that fling went unconsumed, we still need to let the
                // listeners know that scrolling has ended.
                updateOnScrollEnd();
                break;
            case EventType.GESTURE_SCROLL_UPDATE:
                if (!consumed) break;
                if (!mHidePastePopupOnGSB) {
                    destroyPastePopup();
                }
                for (mIterator.rewind(); mIterator.hasNext(); ) {
                    mIterator.next().onScrollUpdateGestureConsumed();
                }
                break;
            case EventType.GESTURE_SCROLL_END:
                updateOnScrollEnd();
                break;
            case EventType.GESTURE_PINCH_BEGIN:
                for (mIterator.rewind(); mIterator.hasNext(); ) mIterator.next().onPinchStarted();
                break;
            case EventType.GESTURE_PINCH_END:
                for (mIterator.rewind(); mIterator.hasNext(); ) mIterator.next().onPinchEnded();
                break;
            case EventType.GESTURE_TAP:
                destroyPastePopup();
                for (mIterator.rewind(); mIterator.hasNext(); ) {
                    mIterator.next().onSingleTap(consumed);
                }
                break;
            case EventType.GESTURE_LONG_PRESS:
                if (!consumed) break;
                mViewDelegate
                        .getContainerView()
                        .performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
                break;
            case EventType.GESTURE_BEGIN:
                for (mIterator.rewind(); mIterator.hasNext(); ) {
                    mIterator.next().onGestureBegin();
                }
                break;
            case EventType.GESTURE_END:
                for (mIterator.rewind(); mIterator.hasNext(); ) {
                    mIterator.next().onGestureEnd();
                }
                break;
            default:
                break;
        }
    }

    /** Called when a gesture event ack happens for |EventType.GESTURE_SCROLL_BEGIN|. */
    @CalledByNative
    @VisibleForTesting
    void onScrollBegin(boolean isDirectionUp) {
        setGestureScrollInProgress(true);
        if (mHidePastePopupOnGSB) {
            destroyPastePopup();
        }
        for (mIterator.rewind(); mIterator.hasNext(); ) {
            mIterator
                    .next()
                    .onScrollStarted(verticalScrollOffset(), verticalScrollExtent(), isDirectionUp);
        }
    }

    /** Called when a gesture event ack happens for |EventType.GESTURE_FLING_START|. */
    @CalledByNative
    @VisibleForTesting
    void onFlingStart(boolean isDirectionUp) {
        // The view expects the fling velocity in pixels/s.
        mHasActiveFlingScroll = true;
        for (mIterator.rewind(); mIterator.hasNext(); ) {
            mIterator
                    .next()
                    .onFlingStartGesture(
                            verticalScrollOffset(), verticalScrollExtent(), isDirectionUp);
        }
    }

    private void destroyPastePopup() {
        if (mSelectionPopupController == null) {
            mSelectionPopupController =
                    SelectionPopupControllerImpl.fromWebContentsNoCreate(mWebContents);
        }
        if (mSelectionPopupController != null
                && mSelectionPopupController.isPasteActionModeValid()) {
            mSelectionPopupController.destroyActionModeAndKeepSelection();
        }
    }

    @CalledByNative
    @VisibleForTesting
    void resetPopupsAndInput(boolean renderProcessGone) {
        PopupController.hidePopupsAndClearSelection(mWebContents);
        resetScrollInProgress();
        if (renderProcessGone) {
            ImeAdapterImpl imeAdapter = ImeAdapterImpl.fromWebContents(mWebContents);
            if (imeAdapter != null) imeAdapter.resetAndHideKeyboard();
        }
    }

    @CalledByNative
    private void onNativeDestroyed() {
        for (mIterator.rewind(); mIterator.hasNext(); ) mIterator.next().onDestroyed();
        mListeners.clear();
        mListenerFrequency.clear();
        mViewDelegate.removeVerticalScrollDirectionChangeListener(this);
        mNativeGestureListenerManager = 0;
    }

    /** Called just prior to a tap or press gesture being forwarded to the renderer. */
    @SuppressWarnings("unused")
    @CalledByNative
    private boolean filterTapOrPressEvent(int type, int x, int y) {
        if (type == GestureEventType.LONG_PRESS && offerLongPressToEmbedder()) {
            return true;
        }

        return false;
    }

    @SuppressWarnings("unused")
    @CalledByNative
    private void updateScrollInfo(
            float scrollOffsetX,
            float scrollOffsetY,
            float pageScaleFactor,
            float minPageScaleFactor,
            float maxPageScaleFactor,
            float contentWidth,
            float contentHeight,
            float viewportWidth,
            float viewportHeight,
            float topBarShownPix,
            boolean topBarChanged) {
        TraceEvent.begin("GestureListenerManagerImpl:updateScrollInfo");
        RenderCoordinatesImpl rc = mWebContents.getRenderCoordinates();

        // Adjust contentWidth/Height to be always at least as big as
        // the actual viewport (as set by onSizeChanged).
        final float deviceScale = rc.getDeviceScaleFactor();
        View containerView = mViewDelegate.getContainerView();
        contentWidth =
                Math.max(contentWidth, containerView.getWidth() / (deviceScale * pageScaleFactor));
        contentHeight =
                Math.max(
                        contentHeight, containerView.getHeight() / (deviceScale * pageScaleFactor));

        final boolean scaleLimitsChanged =
                minPageScaleFactor != rc.getMinPageScaleFactor()
                        || maxPageScaleFactor != rc.getMaxPageScaleFactor();
        final boolean pageScaleChanged = pageScaleFactor != rc.getPageScaleFactor();
        final boolean scrollChanged =
                pageScaleChanged
                        || scrollOffsetX != rc.getScrollX()
                        || scrollOffsetY != rc.getScrollY();

        if (scrollChanged) {
            onRootScrollOffsetChangedImpl(pageScaleFactor, scrollOffsetX, scrollOffsetY);
        }

        // TODO(jinsukkim): Consider updating the info directly through RenderCoordinates.
        rc.updateFrameInfo(
                contentWidth,
                contentHeight,
                viewportWidth,
                viewportHeight,
                minPageScaleFactor,
                maxPageScaleFactor,
                topBarShownPix);

        if (!scrollChanged && topBarChanged) {
            updateOnScrollChanged(verticalScrollOffset(), verticalScrollExtent());
        }
        if (scaleLimitsChanged) updateOnScaleLimitsChanged(minPageScaleFactor, maxPageScaleFactor);
        TraceEvent.end("GestureListenerManagerImpl:updateScrollInfo");
    }

    @SuppressWarnings("unused")
    @CalledByNative
    private void onRootScrollOffsetChanged(float scrollOffsetX, float scrollOffsetY) {
        onRootScrollOffsetChangedImpl(
                mWebContents.getRenderCoordinates().getPageScaleFactor(),
                scrollOffsetX,
                scrollOffsetY);
    }

    private void onRootScrollOffsetChangedImpl(
            float pageScaleFactor, float scrollOffsetX, float scrollOffsetY) {
        TraceEvent.begin("GestureListenerManagerImpl:onRootScrollOffsetChanged");

        notifyDelegateOfScrollChange(scrollOffsetX, scrollOffsetY);

        mWebContents
                .getRenderCoordinates()
                .updateScrollInfo(pageScaleFactor, scrollOffsetX, scrollOffsetY);

        updateOnScrollChanged(verticalScrollOffset(), verticalScrollExtent());
        TraceEvent.end("GestureListenerManagerImpl:onRootScrollOffsetChanged");
    }

    private void notifyDelegateOfScrollChange(float scrollOffsetX, float scrollOffsetY) {
        RenderCoordinatesImpl rc = mWebContents.getRenderCoordinates();
        mScrollDelegate.onScrollChanged(
                (int) rc.fromLocalCssToPix(scrollOffsetX),
                (int) rc.fromLocalCssToPix(scrollOffsetY),
                (int) rc.getScrollXPix(),
                (int) rc.getScrollYPix());
    }

    @Override
    @CalledByNative
    public boolean isScrollInProgress() {
        return mIsGestureScrollInProgress;
    }

    private void setGestureScrollInProgress(boolean gestureScrollInProgress) {
        mIsGestureScrollInProgress = gestureScrollInProgress;

        if (mSelectionPopupController == null) {
            mSelectionPopupController = SelectionPopupControllerImpl.fromWebContents(mWebContents);
        }
        // Use the active scroll signal for hiding. The animation movement by
        // fling will naturally hide the ActionMode by invalidating its content
        // rect.
        mSelectionPopupController.setScrollInProgress(isScrollInProgress());
    }

    /**
     * Reset scroll and fling accounting, notifying listeners as appropriate.
     * This is useful as a failsafe when the input stream may have been interruped.
     */
    private void resetScrollInProgress() {
        if (!isScrollInProgress()) return;

        final boolean gestureScrollInProgress = mIsGestureScrollInProgress;
        setGestureScrollInProgress(false);
        if (gestureScrollInProgress) {
            updateOnScrollEnd();
        }
        resetFlingGesture();
    }

    /**
     * Offer a long press gesture to the embedding View, primarily for WebView compatibility.
     *
     * @return true if the embedder handled the event.
     */
    private boolean offerLongPressToEmbedder() {
        return mViewDelegate.getContainerView().performLongClick();
    }

    private int verticalScrollOffset() {
        return mWebContents.getRenderCoordinates().getScrollYPixInt();
    }

    private int verticalScrollExtent() {
        return mWebContents.getRenderCoordinates().getLastFrameViewportHeightPixInt();
    }

    @NativeMethods
    interface Natives {
        long init(GestureListenerManagerImpl caller, WebContentsImpl webContents);

        void resetGestureDetection(
                long nativeGestureListenerManager, GestureListenerManagerImpl caller);

        void setDoubleTapSupportEnabled(
                long nativeGestureListenerManager,
                GestureListenerManagerImpl caller,
                boolean enabled);

        void setMultiTouchZoomSupportEnabled(
                long nativeGestureListenerManager,
                GestureListenerManagerImpl caller,
                boolean enabled);

        void setRootScrollOffsetUpdateFrequency(
                long nativeGestureListenerManager,
                @RootScrollOffsetUpdateFrequency.EnumType int frequency);
    }
}