chromium/android_webview/javatests/src/org/chromium/android_webview/test/AndroidScrollIntegrationTest.java

// Copyright 2013 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.android_webview.test;

import static org.chromium.android_webview.test.AwActivityTestRule.WAIT_TIMEOUT_MS;

import android.annotation.SuppressLint;
import android.content.Context;

import androidx.test.InstrumentationRegistry;
import androidx.test.filters.SmallTest;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.UseParametersRunnerFactory;

import org.chromium.android_webview.AwContents;
import org.chromium.android_webview.AwContentsStatics;
import org.chromium.android_webview.AwScrollOffsetManager;
import org.chromium.android_webview.test.AwActivityTestRule.PopupInfo;
import org.chromium.android_webview.test.util.AwTestTouchUtils;
import org.chromium.android_webview.test.util.CommonResources;
import org.chromium.android_webview.test.util.GraphicsTestUtils;
import org.chromium.android_webview.test.util.JavascriptEventObserver;
import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.base.test.util.Feature;
import org.chromium.content_public.browser.GestureListenerManager;
import org.chromium.content_public.browser.GestureStateListener;
import org.chromium.net.test.util.TestWebServer;

import java.util.Locale;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

/** Integration tests for synchronous scrolling. */
@RunWith(Parameterized.class)
@UseParametersRunnerFactory(AwJUnit4ClassRunnerWithParameters.Factory.class)
public class AndroidScrollIntegrationTest extends AwParameterizedTest {
    private static final double EPSILON = 1e-5;

    @Rule public AwActivityTestRule mActivityTestRule;

    public AndroidScrollIntegrationTest(AwSettingsMutation param) {
        mActivityTestRule =
                new AwActivityTestRule(param.getMutation()) {
                    @Override
                    public TestDependencyFactory createTestDependencyFactory() {
                        return new TestDependencyFactory() {
                            @Override
                            public AwScrollOffsetManager createScrollOffsetManager(
                                    AwScrollOffsetManager.Delegate delegate) {
                                return new AwScrollOffsetManager(delegate);
                            }

                            @Override
                            public AwTestContainerView createAwTestContainerView(
                                    AwTestRunnerActivity activity,
                                    boolean allowHardwareAcceleration) {
                                return new ScrollTestContainerView(
                                        activity, allowHardwareAcceleration);
                            }
                        };
                    }
                };
    }

    private TestWebServer mWebServer;

    private static class OverScrollByCallbackHelper extends CallbackHelper {
        int mDeltaX;
        int mDeltaY;
        int mScrollRangeY;

        public int getDeltaX() {
            assert getCallCount() > 0;
            return mDeltaX;
        }

        public int getDeltaY() {
            assert getCallCount() > 0;
            return mDeltaY;
        }

        public int getScrollRangeY() {
            assert getCallCount() > 0;
            return mScrollRangeY;
        }

        public void notifyCalled(int deltaX, int deltaY, int scrollRangeY) {
            mDeltaX = deltaX;
            mDeltaY = deltaY;
            mScrollRangeY = scrollRangeY;
            notifyCalled();
        }
    }

    private static class ScrollTestContainerView extends AwTestContainerView {
        private int mMaxScrollXPix = -1;
        private int mMaxScrollYPix = -1;

        private CallbackHelper mOnScrollToCallbackHelper = new CallbackHelper();
        private OverScrollByCallbackHelper mOverScrollByCallbackHelper =
                new OverScrollByCallbackHelper();

        public ScrollTestContainerView(Context context, boolean allowHardwareAcceleration) {
            super(context, allowHardwareAcceleration);
        }

        public CallbackHelper getOnScrollToCallbackHelper() {
            return mOnScrollToCallbackHelper;
        }

        public OverScrollByCallbackHelper getOverScrollByCallbackHelper() {
            return mOverScrollByCallbackHelper;
        }

        public void setMaxScrollX(int maxScrollXPix) {
            mMaxScrollXPix = maxScrollXPix;
        }

        public void setMaxScrollY(int maxScrollYPix) {
            mMaxScrollYPix = maxScrollYPix;
        }

        @Override
        protected boolean overScrollBy(
                int deltaX,
                int deltaY,
                int scrollX,
                int scrollY,
                int scrollRangeX,
                int scrollRangeY,
                int maxOverScrollX,
                int maxOverScrollY,
                boolean isTouchEvent) {
            mOverScrollByCallbackHelper.notifyCalled(deltaX, deltaY, scrollRangeY);
            return super.overScrollBy(
                    deltaX,
                    deltaY,
                    scrollX,
                    scrollY,
                    scrollRangeX,
                    scrollRangeY,
                    maxOverScrollX,
                    maxOverScrollY,
                    isTouchEvent);
        }

        @Override
        public void scrollTo(int x, int y) {
            if (mMaxScrollXPix != -1) x = Math.min(mMaxScrollXPix, x);
            if (mMaxScrollYPix != -1) y = Math.min(mMaxScrollYPix, y);
            super.scrollTo(x, y);
            mOnScrollToCallbackHelper.notifyCalled();
        }
    }

    @Before
    public void setUp() throws Exception {
        mWebServer = TestWebServer.start();
    }

    @After
    public void tearDown() {
        if (mWebServer != null) {
            mWebServer.shutdown();
        }
    }

    private static final String TEST_PAGE_COMMON_HEADERS =
            """
            <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
            <style type="text/css">
                body {
                    margin: 0px;
                }
                div {
                    width: 10000px;
                    height: 10000px;
                    background-color: blue;
                }
            </style>""";
    private static final String TEST_PAGE_COMMON_CONTENT = "<div>test div</div> ";

    private String makeTestPage(
            String onscrollObserver, String firstFrameObserver, String extraContent) {
        String content = TEST_PAGE_COMMON_CONTENT + extraContent;
        if (onscrollObserver != null) {
            content +=
                    String.format(
                            """
                            <script>
                            window.onscroll = function(oEvent) {
                                %s.notifyJava();
                            }
                            </script>""",
                            onscrollObserver);
        }
        if (firstFrameObserver != null) {
            content +=
                    String.format(
                            """
                            <script>
                            window.framesToIgnore = 20;
                            window.onAnimationFrame = function(timestamp) {
                                if (window.framesToIgnore == 0) {
                                    %s.notifyJava();
                                } else {
                                    window.framesToIgnore -= 1;
                                    window.requestAnimationFrame(window.onAnimationFrame);
                                }
                            };
                            window.requestAnimationFrame(window.onAnimationFrame);
                            </script>""",
                            firstFrameObserver);
        }
        return CommonResources.makeHtmlPageFrom(TEST_PAGE_COMMON_HEADERS, content);
    }

    private void scrollToOnMainSync(final AwTestContainerView view, final int xPix, final int yPix)
            throws Throwable {
        InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> view.scrollTo(xPix, yPix));
        mActivityTestRule.waitForVisualStateCallback(view.getAwContents());
    }

    private void setMaxScrollOnMainSync(
            final ScrollTestContainerView testContainerView,
            final int maxScrollXPix,
            final int maxScrollYPix) {
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            testContainerView.setMaxScrollX(maxScrollXPix);
                            testContainerView.setMaxScrollY(maxScrollYPix);
                        });
    }

    private boolean checkScrollOnMainSync(
            final ScrollTestContainerView testContainerView,
            final int scrollXPix,
            final int scrollYPix) {
        return ThreadUtils.runOnUiThreadBlocking(
                () ->
                        scrollXPix == testContainerView.getScrollX()
                                && scrollYPix == testContainerView.getScrollY());
    }

    private int[] getScrollOnMainSync(final ScrollTestContainerView testContainerView) {
        final int scroll[] = new int[2];
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            scroll[0] = testContainerView.getScrollX();
                            scroll[1] = testContainerView.getScrollY();
                        });
        return scroll;
    }

    private void assertScrollOnMainSync(
            final ScrollTestContainerView testContainerView,
            final int scrollXPix,
            final int scrollYPix) {
        int scrolled[] = getScrollOnMainSync(testContainerView);
        int scrolledXPix = scrolled[0];
        int scrolledYPix = scrolled[1];

        // Actual scrolling is done using this formula:
        // floor (scroll_offset_dip * max_offset) / max_scroll_offset_dip
        // where max_offset is calculated using a ceil operation.
        // This combination of ceiling and flooring can lead to a deviation from the test
        // calculation, which simply uses the more direct:
        // floor (scroll_offset_dip * dip_scale)
        //
        // While the math used in the functional code is correct (See crbug.com/261239), it can't
        // be verified down to the pixel in this test which doesn't have all internal values.
        // In non-rational cases, this can lead to a deviation of up to one pixel when using
        // the floor directly. To accomodate this scenario, the test allows a -1 px deviation.
        //
        // For example, imagine the following valid values:
        // scroll_offset_dip = 132
        // max_offset = 532
        // max_scroll_offset_dip = 399
        // dip_scale = 1.33125
        //
        // The functional code will return
        // floor (132 * 532 / 399) = 176
        // The test code will return
        // floor (132 * 1.33125) = 175
        //
        // For more information, see crbug.com/537343
        Assert.assertTrue(
                "Actual and expected x-scroll offsets do not match. Expected "
                        + scrollXPix
                        + ", actual "
                        + scrolledXPix,
                scrollXPix == scrolledXPix || scrollXPix == scrolledXPix - 1);
        Assert.assertTrue(
                "Actual and expected y-scroll offsets do not match. Expected "
                        + scrollYPix
                        + ", actual "
                        + scrolledYPix,
                scrollYPix == scrolledYPix || scrollYPix == scrolledYPix - 1);
    }

    private void assertScrollInJs(
            final AwContents awContents,
            final TestAwContentsClient contentsClient,
            final double xCss,
            final double yCss) {
        AwActivityTestRule.pollInstrumentationThread(
                () -> {
                    String x =
                            mActivityTestRule.executeJavaScriptAndWaitForResult(
                                    awContents, contentsClient, "window.scrollX");
                    String y =
                            mActivityTestRule.executeJavaScriptAndWaitForResult(
                                    awContents, contentsClient, "window.scrollY");

                    double scrollX = Double.parseDouble(x);
                    double scrollY = Double.parseDouble(y);

                    return Math.abs(xCss - scrollX) < EPSILON && Math.abs(yCss - scrollY) < EPSILON;
                });
    }

    private void assertScrolledToBottomInJs(
            final AwContents awContents, final TestAwContentsClient contentsClient) {
        final String isBottomScript =
                "window.scrollY == "
                        + "(window.document.documentElement.scrollHeight - window.innerHeight)";
        AwActivityTestRule.pollInstrumentationThread(
                () -> {
                    String r =
                            mActivityTestRule.executeJavaScriptAndWaitForResult(
                                    awContents, contentsClient, isBottomScript);
                    return r.equals("true");
                });
    }

    private void loadTestPageAndWaitForFirstFrame(
            final ScrollTestContainerView testContainerView,
            final TestAwContentsClient contentsClient,
            final String onscrollObserverName,
            final String extraContent)
            throws Exception {
        final JavascriptEventObserver firstFrameObserver = new JavascriptEventObserver();
        final String firstFrameObserverName = "firstFrameObserver";
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () ->
                                firstFrameObserver.register(
                                        testContainerView.getWebContents(),
                                        firstFrameObserverName));
        mActivityTestRule.loadDataSync(
                testContainerView.getAwContents(),
                contentsClient.getOnPageFinishedHelper(),
                makeTestPage(onscrollObserverName, firstFrameObserverName, extraContent),
                "text/html",
                false);

        // We wait for "a couple" of frames for the active tree in CC to stabilize and for pending
        // tree activations to stop clobbering the root scroll layer's scroll offset. This wait
        // doesn't strictly guarantee that but there isn't a good alternative and this seems to
        // work fine.
        firstFrameObserver.waitForEvent(WAIT_TIMEOUT_MS);
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testUiScrollReflectedInJs() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final double deviceDIPScale =
                GraphicsTestUtils.dipScaleForContext(testContainerView.getContext());

        final int targetScrollXCss = 233;
        final int targetScrollYCss = 322;
        final int targetScrollXPix = (int) Math.ceil(targetScrollXCss * deviceDIPScale);
        final int targetScrollYPix = (int) Math.ceil(targetScrollYCss * deviceDIPScale);
        final JavascriptEventObserver onscrollObserver = new JavascriptEventObserver();

        double expectedScrollXCss = targetScrollXCss;
        double expectedScrollYCss = targetScrollYCss;
        expectedScrollXCss = (double) targetScrollXPix / deviceDIPScale;
        expectedScrollYCss = (double) targetScrollYPix / deviceDIPScale;

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () ->
                                onscrollObserver.register(
                                        testContainerView.getWebContents(), "onscrollObserver"));

        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, "onscrollObserver", "");

        scrollToOnMainSync(testContainerView, targetScrollXPix, targetScrollYPix);

        onscrollObserver.waitForEvent(WAIT_TIMEOUT_MS);
        assertScrollInJs(
                testContainerView.getAwContents(),
                contentsClient,
                expectedScrollXCss,
                expectedScrollYCss);
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    @SuppressLint("DefaultLocale")
    public void testJsScrollReflectedInUi() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final double deviceDIPScale =
                GraphicsTestUtils.dipScaleForContext(testContainerView.getContext());
        final int targetScrollXCss = 132;
        final int targetScrollYCss = 243;
        final int targetScrollXPix = (int) Math.floor(targetScrollXCss * deviceDIPScale);
        final int targetScrollYPix = (int) Math.floor(targetScrollYCss * deviceDIPScale);

        mActivityTestRule.loadDataSync(
                testContainerView.getAwContents(),
                contentsClient.getOnPageFinishedHelper(),
                makeTestPage(null, null, ""),
                "text/html",
                false);

        final CallbackHelper onScrollToCallbackHelper =
                testContainerView.getOnScrollToCallbackHelper();
        final int scrollToCallCount = onScrollToCallbackHelper.getCallCount();
        mActivityTestRule.executeJavaScriptAndWaitForResult(
                testContainerView.getAwContents(),
                contentsClient,
                String.format("window.scrollTo(%d, %d);", targetScrollXCss, targetScrollYCss));
        onScrollToCallbackHelper.waitForCallback(scrollToCallCount);

        assertScrollOnMainSync(testContainerView, targetScrollXPix, targetScrollYPix);
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testJsScrollFromBody() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final double deviceDIPScale =
                GraphicsTestUtils.dipScaleForContext(testContainerView.getContext());
        final int targetScrollXCss = 132;
        final int targetScrollYCss = 243;
        final int targetScrollXPix = (int) Math.floor(targetScrollXCss * deviceDIPScale);
        final int targetScrollYPix = (int) Math.floor(targetScrollYCss * deviceDIPScale);

        final String scrollFromBodyScript =
                "<script> "
                        + "  window.scrollTo("
                        + targetScrollXCss
                        + ", "
                        + targetScrollYCss
                        + "); "
                        + "</script> ";

        final CallbackHelper onScrollToCallbackHelper =
                testContainerView.getOnScrollToCallbackHelper();
        final int scrollToCallCount = onScrollToCallbackHelper.getCallCount();
        mActivityTestRule.loadDataAsync(
                testContainerView.getAwContents(),
                makeTestPage(null, null, scrollFromBodyScript),
                "text/html",
                false);
        onScrollToCallbackHelper.waitForCallback(scrollToCallCount);

        assertScrollOnMainSync(testContainerView, targetScrollXPix, targetScrollYPix);
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testJsScrollCanBeAlteredByUi() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final double deviceDIPScale =
                GraphicsTestUtils.dipScaleForContext(testContainerView.getContext());
        final int targetScrollXCss = 132;
        final int targetScrollYCss = 243;
        final int targetScrollXPix = (int) Math.floor(targetScrollXCss * deviceDIPScale);
        final int targetScrollYPix = (int) Math.floor(targetScrollYCss * deviceDIPScale);

        final int maxScrollXCss = 101;
        final int maxScrollYCss = 201;
        final int maxScrollXPix = (int) Math.floor(maxScrollXCss * deviceDIPScale);
        final int maxScrollYPix = (int) Math.floor(maxScrollYCss * deviceDIPScale);

        mActivityTestRule.loadDataSync(
                testContainerView.getAwContents(),
                contentsClient.getOnPageFinishedHelper(),
                makeTestPage(null, null, ""),
                "text/html",
                false);

        setMaxScrollOnMainSync(testContainerView, maxScrollXPix, maxScrollYPix);

        final CallbackHelper onScrollToCallbackHelper =
                testContainerView.getOnScrollToCallbackHelper();
        final int scrollToCallCount = onScrollToCallbackHelper.getCallCount();
        mActivityTestRule.executeJavaScriptAndWaitForResult(
                testContainerView.getAwContents(),
                contentsClient,
                "window.scrollTo(" + targetScrollXCss + "," + targetScrollYCss + ")");
        onScrollToCallbackHelper.waitForCallback(scrollToCallCount);

        assertScrollOnMainSync(testContainerView, maxScrollXPix, maxScrollYPix);
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testTouchScrollCanBeAlteredByUi() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final int dragSteps = 10;
        final int dragStepSize = 24;
        // Watch out when modifying - if the y or x delta aren't big enough vertical or horizontal
        // scroll snapping will kick in.
        final int targetScrollXPix = dragStepSize * dragSteps;
        final int targetScrollYPix = dragStepSize * dragSteps;

        final double deviceDIPScale =
                GraphicsTestUtils.dipScaleForContext(testContainerView.getContext());
        final int maxScrollXPix = 101;
        final int maxScrollYPix = 211;
        // Make sure we can't hit these values simply as a result of scrolling.
        Assert.assertNotEquals(0, maxScrollXPix % dragStepSize);
        Assert.assertNotEquals(0, maxScrollYPix % dragStepSize);
        double maxScrollXCss = maxScrollXPix / deviceDIPScale;
        double maxScrollYCss = maxScrollYPix / deviceDIPScale;

        setMaxScrollOnMainSync(testContainerView, maxScrollXPix, maxScrollYPix);

        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, null, "");

        final CallbackHelper onScrollToCallbackHelper =
                testContainerView.getOnScrollToCallbackHelper();
        final int scrollToCallCount = onScrollToCallbackHelper.getCallCount();
        AwTestTouchUtils.dragCompleteView(
                testContainerView,
                0,
                -targetScrollXPix, // these need to be negative as we're scrolling down.
                0,
                -targetScrollYPix,
                dragSteps);

        for (int i = 1; i <= dragSteps; ++i) {
            onScrollToCallbackHelper.waitForCallback(scrollToCallCount, i);
            if (checkScrollOnMainSync(testContainerView, maxScrollXPix, maxScrollYPix)) break;
        }

        assertScrollOnMainSync(testContainerView, maxScrollXPix, maxScrollYPix);
        assertScrollInJs(
                testContainerView.getAwContents(), contentsClient, maxScrollXCss, maxScrollYCss);
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testOverScrollX() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        final OverScrollByCallbackHelper overScrollByCallbackHelper =
                testContainerView.getOverScrollByCallbackHelper();
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final int overScrollDeltaX = 30;
        final int oneStep = 1;

        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, null, "");

        // Scroll separately in different dimensions because of vertical/horizontal scroll
        // snap.
        final int overScrollCallCount = overScrollByCallbackHelper.getCallCount();
        AwTestTouchUtils.dragCompleteView(testContainerView, 0, overScrollDeltaX, 0, 0, oneStep);
        overScrollByCallbackHelper.waitForCallback(overScrollCallCount);
        // Unfortunately the gesture detector seems to 'eat' some number of pixels. For now
        // checking that the value is < 0 (overscroll is reported as negative values) will have to
        // do.
        Assert.assertTrue(0 > overScrollByCallbackHelper.getDeltaX());
        Assert.assertEquals(0, overScrollByCallbackHelper.getDeltaY());

        assertScrollOnMainSync(testContainerView, 0, 0);
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testOverScrollY() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        final OverScrollByCallbackHelper overScrollByCallbackHelper =
                testContainerView.getOverScrollByCallbackHelper();
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final int overScrollDeltaY = 30;
        final int oneStep = 1;

        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, null, "");

        int overScrollCallCount = overScrollByCallbackHelper.getCallCount();
        AwTestTouchUtils.dragCompleteView(testContainerView, 0, 0, 0, overScrollDeltaY, oneStep);
        overScrollByCallbackHelper.waitForCallback(overScrollCallCount);
        Assert.assertEquals(0, overScrollByCallbackHelper.getDeltaX());
        Assert.assertTrue(0 > overScrollByCallbackHelper.getDeltaY());

        assertScrollOnMainSync(testContainerView, 0, 0);
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    @DisabledTest(message = "https://crbug.com/1147838")
    public void testFlingScroll() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, null, "");

        assertScrollOnMainSync(testContainerView, 0, 0);

        final CallbackHelper onScrollToCallbackHelper =
                testContainerView.getOnScrollToCallbackHelper();
        final int scrollToCallCount = onScrollToCallbackHelper.getCallCount();

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(() -> testContainerView.getAwContents().flingScroll(1000, 1000));

        onScrollToCallbackHelper.waitForCallback(scrollToCallCount);

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            Assert.assertTrue(testContainerView.getScrollX() > 0);
                            Assert.assertTrue(testContainerView.getScrollY() > 0);
                        });
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    @DisabledTest(message = "https://crbug.com/813837")
    public void testFlingScrollOnPopup() throws Throwable {
        final TestAwContentsClient parentContentsClient = new TestAwContentsClient();
        final ScrollTestContainerView parentContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(parentContentsClient);
        final AwContents parentContents = parentContainerView.getAwContents();
        AwActivityTestRule.enableJavaScriptOnUiThread(parentContents);

        final String popupPath = "/popup.html";
        final String parentPageHtml =
                CommonResources.makeHtmlPageFrom(
                        "",
                        "<script>"
                                + "function tryOpenWindow() {"
                                + "  var newWindow = window.open('"
                                + popupPath
                                + "');"
                                + "}</script> <h1>Parent</h1>");

        final String popupPageHtml =
                CommonResources.makeHtmlPageFrom(
                        "<title>" + "Popup Window" + "</title>", "This is a popup window");

        mActivityTestRule.triggerPopup(
                parentContents,
                parentContentsClient,
                mWebServer,
                parentPageHtml,
                popupPageHtml,
                popupPath,
                "tryOpenWindow()");
        final PopupInfo popupInfo = mActivityTestRule.connectPendingPopup(parentContents);
        Assert.assertEquals(
                "Popup Window", mActivityTestRule.getTitleOnUiThread(popupInfo.popupContents));

        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView) popupInfo.popupContainerView;
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());
        loadTestPageAndWaitForFirstFrame(
                testContainerView, popupInfo.popupContentsClient, null, "");

        assertScrollOnMainSync(testContainerView, 0, 0);

        final CallbackHelper onScrollToCallbackHelper =
                testContainerView.getOnScrollToCallbackHelper();
        final int scrollToCallCount = onScrollToCallbackHelper.getCallCount();

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(() -> testContainerView.getAwContents().flingScroll(1000, 1000));

        onScrollToCallbackHelper.waitForCallback(scrollToCallCount);

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            Assert.assertTrue(testContainerView.getScrollX() > 0);
                            Assert.assertTrue(testContainerView.getScrollY() > 0);
                        });
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testPageDown() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, null, "");

        assertScrollOnMainSync(testContainerView, 0, 0);

        final int maxScrollYPix =
                ThreadUtils.runOnUiThreadBlocking(
                        () ->
                                (testContainerView.getAwContents().computeVerticalScrollRange()
                                        - testContainerView.getHeight()));

        final CallbackHelper onScrollToCallbackHelper =
                testContainerView.getOnScrollToCallbackHelper();
        final int scrollToCallCount = onScrollToCallbackHelper.getCallCount();

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(() -> testContainerView.getAwContents().pageDown(true));

        // Wait for the animation to hit the bottom of the page.
        for (int i = 1; ; ++i) {
            onScrollToCallbackHelper.waitForCallback(scrollToCallCount, i);
            if (checkScrollOnMainSync(testContainerView, 0, maxScrollYPix)) break;
        }
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testPageUp() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final double deviceDIPScale =
                GraphicsTestUtils.dipScaleForContext(testContainerView.getContext());
        final int targetScrollYCss = 243;
        final int targetScrollYPix = (int) Math.ceil(targetScrollYCss * deviceDIPScale);

        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, null, "");

        assertScrollOnMainSync(testContainerView, 0, 0);

        scrollToOnMainSync(testContainerView, 0, targetScrollYPix);

        final CallbackHelper onScrollToCallbackHelper =
                testContainerView.getOnScrollToCallbackHelper();
        final int scrollToCallCount = onScrollToCallbackHelper.getCallCount();

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(() -> testContainerView.getAwContents().pageUp(true));

        // Wait for the animation to hit the bottom of the page.
        for (int i = 1; ; ++i) {
            onScrollToCallbackHelper.waitForCallback(scrollToCallCount, i);
            if (checkScrollOnMainSync(testContainerView, 0, 0)) break;
        }
    }

    private static class TestGestureStateListener extends GestureStateListener {
        private CallbackHelper mOnScrollUpdateGestureConsumedHelper = new CallbackHelper();

        public CallbackHelper getOnScrollUpdateGestureConsumedHelper() {
            return mOnScrollUpdateGestureConsumedHelper;
        }

        @Override
        public void onPinchStarted() {}

        @Override
        public void onPinchEnded() {}

        @Override
        public void onFlingStartGesture(
                int scrollOffsetY, int scrollExtentY, boolean isDirectionUp) {}

        @Override
        public void onScrollUpdateGestureConsumed() {
            mOnScrollUpdateGestureConsumedHelper.notifyCalled();
        }
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testTouchScrollingConsumesScrollByGesture() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        final TestGestureStateListener testGestureStateListener = new TestGestureStateListener();
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final int dragSteps = 10;
        final int dragStepSize = 24;
        // Watch out when modifying - if the y or x delta aren't big enough vertical or horizontal
        // scroll snapping will kick in.
        final int targetScrollXPix = dragStepSize * dragSteps;
        final int targetScrollYPix = dragStepSize * dragSteps;

        loadTestPageAndWaitForFirstFrame(
                testContainerView,
                contentsClient,
                null,
                """
                <div>
                    <div style="width:10000px; height: 10000px;"> force scrolling </div>
                </div>""");

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () ->
                                GestureListenerManager.fromWebContents(
                                                testContainerView.getWebContents())
                                        .addListener(testGestureStateListener));
        final CallbackHelper onScrollUpdateGestureConsumedHelper =
                testGestureStateListener.getOnScrollUpdateGestureConsumedHelper();

        final int callCount = onScrollUpdateGestureConsumedHelper.getCallCount();
        AwTestTouchUtils.dragCompleteView(
                testContainerView,
                0,
                -targetScrollXPix, // these need to be negative as we're scrolling down.
                0,
                -targetScrollYPix,
                dragSteps);
        onScrollUpdateGestureConsumedHelper.waitForCallback(callCount);
    }

    @Test
    @SmallTest
    @Feature({"AndroidWebView"})
    public void testPinchZoomUpdatesScrollRangeSynchronously() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        final OverScrollByCallbackHelper overScrollByCallbackHelper =
                testContainerView.getOverScrollByCallbackHelper();
        final AwContents awContents = testContainerView.getAwContents();
        AwActivityTestRule.enableJavaScriptOnUiThread(awContents);

        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, null, "");

        // Containers to execute asserts on the test thread
        final AtomicBoolean canZoomIn = new AtomicBoolean(false);
        final AtomicReference<Float> atomicOldScale = new AtomicReference<>();
        final AtomicReference<Float> atomicNewScale = new AtomicReference<>();
        final AtomicInteger atomicOldScrollRange = new AtomicInteger();
        final AtomicInteger atomicNewScrollRange = new AtomicInteger();
        final AtomicInteger atomicContentHeight = new AtomicInteger();
        final AtomicInteger atomicOldContentHeightApproximation = new AtomicInteger();
        final AtomicInteger atomicNewContentHeightApproximation = new AtomicInteger();
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            canZoomIn.set(awContents.canZoomIn());

                            int oldScrollRange =
                                    awContents.computeVerticalScrollRange()
                                            - testContainerView.getHeight();
                            float oldScale = awContents.getScale();
                            double oldHeight =
                                    Math.ceil(awContents.computeVerticalScrollRange() / oldScale);
                            atomicOldContentHeightApproximation.set((int) oldHeight);

                            awContents.zoomIn();

                            int newScrollRange =
                                    awContents.computeVerticalScrollRange()
                                            - testContainerView.getHeight();
                            float newScale = awContents.getScale();

                            double newHeight =
                                    Math.ceil(awContents.computeVerticalScrollRange() / newScale);
                            atomicNewContentHeightApproximation.set((int) newHeight);

                            atomicOldScale.set(oldScale);
                            atomicNewScale.set(newScale);
                            atomicOldScrollRange.set(oldScrollRange);
                            atomicNewScrollRange.set(newScrollRange);
                            atomicContentHeight.set(awContents.getContentHeightCss());
                        });
        Assert.assertTrue(canZoomIn.get());
        Assert.assertTrue(
                String.format(
                        Locale.ENGLISH,
                        "Scale range should increase after zoom (%f) > (%f)",
                        atomicNewScale.get(),
                        atomicOldScale.get()),
                atomicNewScale.get() > atomicOldScale.get());
        Assert.assertTrue(
                String.format(
                        Locale.ENGLISH,
                        "Scroll range should increase after zoom (%d) > (%d)",
                        atomicNewScrollRange.get(),
                        atomicOldScrollRange.get()),
                atomicNewScrollRange.get() > atomicOldScrollRange.get());
        Assert.assertTrue(
                String.format(
                        Locale.ENGLISH,
                        "Old content height should be close (%d) ~= (%d)",
                        atomicContentHeight.get(),
                        atomicOldContentHeightApproximation.get()),
                Math.abs(atomicContentHeight.get() - atomicOldContentHeightApproximation.get())
                        <= 1);
        Assert.assertTrue(
                String.format(
                        Locale.ENGLISH,
                        "New content height should be close (%d) ~= (%d)",
                        atomicContentHeight.get(),
                        atomicNewContentHeightApproximation.get()),
                Math.abs(atomicContentHeight.get() - atomicNewContentHeightApproximation.get())
                        <= 1);
    }

    @Test
    @SmallTest
    @Feature("AndroidWebView")
    public void testScrollOffsetAfterCapturePicture() throws Throwable {
        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        final int targetScrollYPix = 322;

        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, null, "");

        assertScrollOnMainSync(testContainerView, 0, 0);

        scrollToOnMainSync(testContainerView, 0, targetScrollYPix);

        final int scrolledYPix =
                ThreadUtils.runOnUiThreadBlocking(() -> testContainerView.getScrollY());

        Assert.assertTrue(scrolledYPix > 0);

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(() -> testContainerView.getAwContents().capturePicture());

        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> Assert.assertEquals(testContainerView.getScrollY(), scrolledYPix));
    }

    // Regression test for crbug.com/1299753.
    @Test
    @SmallTest
    @Feature("AndroidWebView")
    public void testCanTouchScrollYWithRecordFullDocument() throws Throwable {
        InstrumentationRegistry.getInstrumentation()
                .runOnMainSync(
                        () -> {
                            AwContentsStatics.setRecordFullDocument(true);
                        });

        final TestAwContentsClient contentsClient = new TestAwContentsClient();
        final ScrollTestContainerView testContainerView =
                (ScrollTestContainerView)
                        mActivityTestRule.createAwTestContainerViewOnMainSync(contentsClient);
        AwActivityTestRule.enableJavaScriptOnUiThread(testContainerView.getAwContents());

        // Load page.
        loadTestPageAndWaitForFirstFrame(testContainerView, contentsClient, null, "");

        // Check page loaded at scroll offset 0.
        {
            int scroll[] = getScrollOnMainSync(testContainerView);
            Assert.assertEquals(0, scroll[1]);
        }

        // Drag scroll.
        final CallbackHelper onScrollToCallbackHelper =
                testContainerView.getOnScrollToCallbackHelper();
        final int scrollToCallCount = onScrollToCallbackHelper.getCallCount();
        final int dragSteps = 10;
        final int dragStepSize = 24;
        final int targetScrollYPix = dragStepSize * dragSteps;
        AwTestTouchUtils.dragCompleteView(testContainerView, 0, 0, 0, -targetScrollYPix, dragSteps);

        // Poll until scroll on UI is bigger than 0.
        AwActivityTestRule.pollInstrumentationThread(
                () -> {
                    int scroll[] = getScrollOnMainSync(testContainerView);
                    return scroll[1] > 0;
                });
    }
}