chromium/chrome/android/javatests/src/org/chromium/chrome/browser/VirtualKeyboardResizeTest.java

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser;

import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.lessThanOrEqualTo;

import android.util.JsonReader;

import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.MediumTest;

import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
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.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CallbackHelper;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.CriteriaNotSatisfiedException;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.preferences.Pref;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.test.util.Coordinates;
import org.chromium.content_public.browser.test.util.DOMUtils;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;
import org.chromium.net.test.EmbeddedTestServer;
import org.chromium.ui.mojom.VirtualKeyboardMode;

import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.concurrent.TimeUnit;

/** Tests the virtual keyboard's effect on resizing web pages. */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({
    ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE,
})
@Batch(Batch.PER_CLASS)
public class VirtualKeyboardResizeTest {
    @Rule
    public ChromeTabbedActivityTestRule mActivityTestRule = new ChromeTabbedActivityTestRule();

    private static final String TEXTFIELD_DOM_ID = "inputElement";
    private static final int TEST_TIMEOUT = 10000;

    private EmbeddedTestServer mTestServer;

    private static PrefService getPrefService() {
        return UserPrefs.get(ProfileManager.getLastUsedRegularProfile());
    }

    @Before
    public void setUp() {
        mTestServer =
                EmbeddedTestServer.createAndStartServer(
                        ApplicationProvider.getApplicationContext());
    }

    @After
    public void tearDown() {
        // Some tests set this pref. Clear it to ensure that state does not leak between tests.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getPrefService().clearPref(Pref.VIRTUAL_KEYBOARD_RESIZES_LAYOUT_BY_DEFAULT);
                });
    }

    private void startMainActivityWithURL(String url) throws Throwable {
        mActivityTestRule.startMainActivityWithURL(mTestServer.getURL(url));
        mActivityTestRule.waitForActivityNativeInitializationComplete();

        // Ensure a compositor commit has occurred. This ensures that browser
        // controls shown state is synced to Blink before we start querying
        // visual viewport geometry.
        waitForVisualStateCallback();
    }

    private void waitForVisualStateCallback() throws Throwable {
        final CallbackHelper ch = new CallbackHelper();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getWebContents()
                            .getMainFrame()
                            .insertVisualStateCallback(result -> ch.notifyCalled());
                });

        ch.waitForNext(TEST_TIMEOUT, TimeUnit.SECONDS);
    }

    private void navigateToURL(String url) {
        mActivityTestRule.loadUrl(mTestServer.getURL(url));
    }

    private void openInNewTab(String url) {
        mActivityTestRule.loadUrlInNewTab(mTestServer.getURL(url));
    }

    private void assertWaitForKeyboardStatus(final boolean show) {
        CriteriaHelper.pollUiThread(
                () -> {
                    boolean isKeyboardShowing =
                            mActivityTestRule
                                    .getKeyboardDelegate()
                                    .isKeyboardShowing(
                                            mActivityTestRule.getActivity(),
                                            mActivityTestRule.getActivity().getTabsView());
                    Criteria.checkThat(isKeyboardShowing, Matchers.is(show));
                },
                TEST_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    private void assertWaitForPageHeight(Matcher<java.lang.Integer> matcher) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        int curHeight = getPageInnerHeight();
                        Criteria.checkThat(curHeight, matcher);
                    } catch (Throwable e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                },
                TEST_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    private void assertWaitForVisualViewportHeight(Matcher<java.lang.Double> matcher) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        double curHeight = getVisualViewportHeight();
                        Criteria.checkThat(curHeight, matcher);
                    } catch (Throwable e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                },
                TEST_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    private void assertWaitForNthGeometryChangeEvent(final int n) {
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    try {
                        int numGeometryChangeEvents = getNumGeometryChangeEvents();
                        Criteria.checkThat(numGeometryChangeEvents, greaterThanOrEqualTo(n));
                    } catch (Throwable e) {
                        throw new CriteriaNotSatisfiedException(e);
                    }
                },
                TEST_TIMEOUT,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    private WebContents getWebContents() {
        return mActivityTestRule.getActivity().getActivityTab().getWebContents();
    }

    private int getNumGeometryChangeEvents() throws Throwable {
        return Integer.parseInt(
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        getWebContents(), "window.numGeometryChangeEvents"));
    }

    private int getPageInnerHeight() throws Throwable {
        return Integer.parseInt(
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        getWebContents(), "window.innerHeight"));
    }

    private ArrayList<Integer> getResizeEventLog() throws Throwable {
        String jsonText =
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        getWebContents(), "window.resizeEventLog");
        JsonReader jsonReader = new JsonReader(new StringReader(jsonText));
        ArrayList<Integer> pageHeights = new ArrayList<Integer>();
        try {
            jsonReader.beginArray();
            while (jsonReader.hasNext()) {
                pageHeights.add(jsonReader.nextInt());
            }
            jsonReader.endArray();

            jsonReader.close();
        } catch (IOException exception) {
            Assert.fail("Failed to evaluate JavaScript: " + jsonText + "\n" + exception);
        }

        return pageHeights;
    }

    private void clearResizeEventLog() throws Throwable {
        JavaScriptUtils.executeJavaScript(getWebContents(), "window.resizeEventLog = []");
    }

    private double getVisualViewportHeight() throws Throwable {
        return Float.parseFloat(
                JavaScriptUtils.executeJavaScriptAndWaitForResult(
                        getWebContents(), "window.visualViewport.height"));
    }

    private void hideKeyboard() {
        JavaScriptUtils.executeJavaScript(
                getWebContents(), "document.querySelector('input').blur()");
    }

    private double getKeyboardHeightDp() {
        final double dpi = Coordinates.createFor(getWebContents()).getDeviceScaleFactor();
        double keyboardHeightPx =
                mActivityTestRule
                        .getKeyboardDelegate()
                        .calculateTotalKeyboardHeight(
                                mActivityTestRule
                                        .getActivity()
                                        .getWindow()
                                        .getDecorView()
                                        .getRootView());
        return keyboardHeightPx / dpi;
    }

    /**
     * This is the same as testVirtualKeyboardDefaultResizeMode, except that the
     * OSKResizesVisualViewportByDefault flag is enabled. Normally this would cause the default
     * resize behavior to be "resize-visual", but since the
     * VIRTUAL_KEYBOARD_RESIZES_LAYOUT_BY_DEFAULT is set to "true", the default resize behavior
     * should ultimately be forced to "resize-content".
     */
    @Test
    @MediumTest
    public void testVirtualKeyboardDefaultResizeModeWithPref() throws Throwable {
        startMainActivityWithURL("/chrome/test/data/android/about.html");
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getPrefService()
                            .setBoolean(Pref.VIRTUAL_KEYBOARD_RESIZES_LAYOUT_BY_DEFAULT, true);
                });

        // Load the page after changing the pref.
        navigateToURL("/chrome/test/data/android/page_with_editable.html");
        int initialHeight = getPageInnerHeight();
        double initialVVHeight = getVisualViewportHeight();

        DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
        assertWaitForKeyboardStatus(true);

        double keyboardHeight = getKeyboardHeightDp();

        // Use less than or equal since the keyboard may actually include accessories like the
        // Autofill bar. +1px delta to account for device scale factor rounding.
        assertWaitForPageHeight(lessThanOrEqualTo((int) (initialHeight - keyboardHeight + 1.0)));
        assertWaitForVisualViewportHeight(
                lessThanOrEqualTo(initialVVHeight - keyboardHeight + 1.0));

        // Hide the OSK and ensure the state is correctly restored to the initial height.
        hideKeyboard();
        assertWaitForKeyboardStatus(false);

        assertWaitForPageHeight(Matchers.is(initialHeight));
        assertWaitForVisualViewportHeight(
                Matchers.closeTo((double) initialVVHeight, /* error= */ 1.0));
    }

    /**
     * The same as the previous test, but sets the VirtualKeyboardResizesLayoutByDefault policy
     * rather than the pref directly.
     */
    @Test
    @MediumTest
    @CommandLineFlags.Add({"policy={\"VirtualKeyboardResizesLayoutByDefault\":true}"})
    @DisabledTest(message = "crbug.com/353947757")
    public void testVirtualKeyboardDefaultResizeModeWithPolicy() throws Throwable {
        startMainActivityWithURL("/chrome/test/data/android/page_with_editable.html");

        int initialHeight = getPageInnerHeight();
        double initialVVHeight = getVisualViewportHeight();

        DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
        assertWaitForKeyboardStatus(true);

        double keyboardHeight = getKeyboardHeightDp();

        // Use less than or equal since the keyboard may actually include accessories like the
        // Autofill bar. +1px delta to account for device scale factor rounding.
        assertWaitForPageHeight(lessThanOrEqualTo((int) (initialHeight - keyboardHeight + 1.0)));
        assertWaitForVisualViewportHeight(
                lessThanOrEqualTo(initialVVHeight - keyboardHeight + 1.0));

        // Hide the OSK and ensure the state is correctly restored to the initial height.
        hideKeyboard();
        assertWaitForKeyboardStatus(false);

        assertWaitForPageHeight(Matchers.is(initialHeight));
        assertWaitForVisualViewportHeight(
                Matchers.closeTo((double) initialVVHeight, /* error= */ 1.0));
    }

    /**
     * Tests the OSKResizesVisualViewportByDefault flag changes Chrome's default behavior to the
     * virtual keyboard resizing only the visual viewport, but not the page's initial containing
     * block or layout viewport.
     */
    @Test
    @MediumTest
    @DisabledTest(message = "https://crbug.com/355432932")
    public void testVirtualKeyboardResizesVisualViewportFlag() throws Throwable {
        startMainActivityWithURL("/chrome/test/data/android/page_with_editable.html");

        int initialHeight = getPageInnerHeight();
        double initialVVHeight = getVisualViewportHeight();

        DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
        assertWaitForKeyboardStatus(true);

        double keyboardHeight = getKeyboardHeightDp();

        // Use less than or equal since the keyboard may actually include accessories like the
        // Autofill bar. +1 to account for device scale factor rounding.
        assertWaitForVisualViewportHeight(lessThanOrEqualTo(initialVVHeight - keyboardHeight + 1));
        assertWaitForPageHeight(Matchers.is(initialHeight));

        // Hide the OSK and ensure the state is correctly restored to the initial height.
        hideKeyboard();
        assertWaitForKeyboardStatus(false);

        assertWaitForPageHeight(Matchers.is(initialHeight));
        assertWaitForVisualViewportHeight(
                Matchers.closeTo((double) initialVVHeight, /* error= */ 1.0));
    }

    /**
     * Tests the <meta name="viewport" content="interactive-widget=resizes-visual"> tag causes the
     * page to resize only the visual viewport.
     */
    @Test
    @MediumTest
    public void testResizesVisualMetaTag() throws Throwable {
        startMainActivityWithURL("/chrome/test/data/android/about.html");

        // Setting the pref should have no effect on the result, since the <meta> tag explicitly
        // sets a *non-default* OSK resize behavior.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getPrefService()
                            .setBoolean(Pref.VIRTUAL_KEYBOARD_RESIZES_LAYOUT_BY_DEFAULT, true);
                });

        // Load the page after changing the pref.
        navigateToURL("/chrome/test/data/android/page_with_editable.html?resizes-visual");

        int initialHeight = getPageInnerHeight();
        double initialVVHeight = getVisualViewportHeight();

        DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
        assertWaitForKeyboardStatus(true);

        double keyboardHeight = getKeyboardHeightDp();

        // Use less than or equal since the keyboard may actually include accessories like the
        // Autofill bar. +1 to account for device scale factor rounding.
        assertWaitForVisualViewportHeight(lessThanOrEqualTo(initialVVHeight - keyboardHeight + 1));
        assertWaitForPageHeight(Matchers.is(initialHeight));

        // Hide the OSK and ensure the state is correctly restored to the initial height.
        hideKeyboard();
        assertWaitForKeyboardStatus(false);

        assertWaitForPageHeight(Matchers.is(initialHeight));
        assertWaitForVisualViewportHeight(
                Matchers.closeTo((double) initialVVHeight, /* error= */ 1.0));
    }

    /**
     * Tests the <meta name="viewport" content="interactive-widget=resizes-content"> tag opts the
     * page back into a mode where the keyboard resizes layout.
     */
    @Test
    @MediumTest
    public void testResizesLayoutMetaTag() throws Throwable {
        startMainActivityWithURL(
                "/chrome/test/data/android/page_with_editable.html?resizes-content");
        int initialHeight = getPageInnerHeight();
        double initialVVHeight = getVisualViewportHeight();

        DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
        assertWaitForKeyboardStatus(true);

        double keyboardHeight = getKeyboardHeightDp();

        // Use less than or equal since the keyboard may actually include accessories like the
        // Autofill bar. +1px to account for device scale factor rounding.
        assertWaitForPageHeight(lessThanOrEqualTo((int) (initialHeight - keyboardHeight + 1.0)));
        assertWaitForVisualViewportHeight(
                lessThanOrEqualTo(initialVVHeight - keyboardHeight + 1.0));

        // Hide the OSK and ensure the state is correctly restored to the initial height.
        hideKeyboard();
        assertWaitForKeyboardStatus(false);

        assertWaitForPageHeight(Matchers.is(initialHeight));
        assertWaitForVisualViewportHeight(
                Matchers.closeTo((double) initialVVHeight, /* error= */ 1.0));
    }

    /**
     * Tests the <meta name="viewport" content="interactive-widget=overlays-content"> tag causes the
     * page to avoid resizing any viewports.
     */
    @Test
    @MediumTest
    @DisabledTest(message = "https://crbug.com/351982700")
    public void testOverlaysContentMetaTag() throws Throwable {
        startMainActivityWithURL(
                "/chrome/test/data/android/page_with_editable.html?overlays-content");

        Assert.assertEquals(getNumGeometryChangeEvents(), 0);

        int initialHeight = getPageInnerHeight();
        double initialVVHeight = getVisualViewportHeight();

        DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
        assertWaitForKeyboardStatus(true);
        assertWaitForNthGeometryChangeEvent(1);

        // We're checking something didn't happen so we have nothing to wait on - give it some time
        // to make sure a resize should often occur by now.
        Thread.sleep(200);

        // Ensure neither the innerHeight nor visualViewport height has changed.
        Assert.assertEquals(getPageInnerHeight(), initialHeight);
        Assert.assertEquals(getVisualViewportHeight(), initialVVHeight, /* delta= */ 1.0f);
    }

    /** Test that the virtual keyboard mode is correctly set/reset on navigations. */
    @Test
    @MediumTest
    public void testModeAfterNavigation() throws Throwable {
        startMainActivityWithURL("/chrome/test/data/android/page_with_editable.html");

        Assert.assertEquals(
                mActivityTestRule
                        .getActivity()
                        .getCompositorViewHolderForTesting()
                        .getVirtualKeyboardModeForTesting(),
                VirtualKeyboardMode.RESIZES_VISUAL);

        navigateToURL("/chrome/test/data/android/page_with_editable.html?resizes-content");
        Assert.assertEquals(
                mActivityTestRule
                        .getActivity()
                        .getCompositorViewHolderForTesting()
                        .getVirtualKeyboardModeForTesting(),
                VirtualKeyboardMode.RESIZES_CONTENT);

        navigateToURL("/chrome/test/data/android/page_with_editable.html");

        Assert.assertEquals(
                mActivityTestRule
                        .getActivity()
                        .getCompositorViewHolderForTesting()
                        .getVirtualKeyboardModeForTesting(),
                VirtualKeyboardMode.RESIZES_VISUAL);

        navigateToURL("/chrome/test/data/android/page_with_editable.html?overlays-content");

        Assert.assertEquals(
                mActivityTestRule
                        .getActivity()
                        .getCompositorViewHolderForTesting()
                        .getVirtualKeyboardModeForTesting(),
                VirtualKeyboardMode.OVERLAYS_CONTENT);

        openInNewTab("/chrome/test/data/android/page_with_editable.html?resizes-content");
        Assert.assertEquals(
                mActivityTestRule
                        .getActivity()
                        .getCompositorViewHolderForTesting()
                        .getVirtualKeyboardModeForTesting(),
                VirtualKeyboardMode.RESIZES_CONTENT);

        // Ensure showing the keyboard and going through the resize flow uses the current virtual
        // keyboard mode.
        {
            int initialHeight = getPageInnerHeight();

            DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
            assertWaitForKeyboardStatus(true);

            double keyboardHeight = getKeyboardHeightDp();

            assertWaitForPageHeight(
                    lessThanOrEqualTo((int) (initialHeight - keyboardHeight + 1.0)));

            hideKeyboard();
            assertWaitForKeyboardStatus(false);

            assertWaitForPageHeight(Matchers.is(initialHeight));
        }
    }

    /**
     * Test that the virtual keyboard mode is affected by the
     * VIRTUAL_KEYBOARD_RESIZES_LAYOUT_BY_DEFAULT pref on navigations.
     */
    @Test
    @MediumTest
    public void testModeAfterNavigationWithPref() throws Throwable {
        startMainActivityWithURL("/chrome/test/data/android/page_with_editable.html");

        Assert.assertEquals(
                mActivityTestRule
                        .getActivity()
                        .getCompositorViewHolderForTesting()
                        .getVirtualKeyboardModeForTesting(),
                VirtualKeyboardMode.RESIZES_VISUAL);

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    getPrefService()
                            .setBoolean(Pref.VIRTUAL_KEYBOARD_RESIZES_LAYOUT_BY_DEFAULT, true);
                });

        navigateToURL("/chrome/test/data/android/page_with_editable.html");
        Assert.assertEquals(
                mActivityTestRule
                        .getActivity()
                        .getCompositorViewHolderForTesting()
                        .getVirtualKeyboardModeForTesting(),
                VirtualKeyboardMode.RESIZES_CONTENT);

        navigateToURL("/chrome/test/data/android/page_with_editable.html?overlays-content");
        Assert.assertEquals(
                mActivityTestRule
                        .getActivity()
                        .getCompositorViewHolderForTesting()
                        .getVirtualKeyboardModeForTesting(),
                VirtualKeyboardMode.OVERLAYS_CONTENT);

        openInNewTab("/chrome/test/data/android/page_with_editable.html");
        Assert.assertEquals(
                mActivityTestRule
                        .getActivity()
                        .getCompositorViewHolderForTesting()
                        .getVirtualKeyboardModeForTesting(),
                VirtualKeyboardMode.RESIZES_CONTENT);
    }

    /** Test that in overlays-content mode, the keyboard doesn't cause any transient resizes. */
    @Test
    @MediumTest
    public void testNoSpuriousResizeEventOverlaysContent() throws Throwable {
        startMainActivityWithURL(
                "/chrome/test/data/android/page_with_editable.html?overlays-content");
        clearResizeEventLog();

        int initialHeight = getPageInnerHeight();

        Assert.assertEquals(getNumGeometryChangeEvents(), 0);
        DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
        assertWaitForNthGeometryChangeEvent(1);

        waitForVisualStateCallback();

        hideKeyboard();
        assertWaitForNthGeometryChangeEvent(2);

        waitForVisualStateCallback();

        // TODO(crbug.com/40822136): Ideally we'd check that we didn't get *any* resize event since
        // the page height isn't changing.  However, we inconsistently receive spurious resizes
        // during page load on Android. Until that's fixed, just ensure the page height is
        // consistent at each fired resize.
        ArrayList<Integer> pageHeights = getResizeEventLog();
        for (Integer pageHeight : pageHeights) {
            Assert.assertEquals(initialHeight, pageHeight.intValue());
        }
    }

    /** Test that in resizes-visual mode, the keyboard doesn't cause any transient resizes. */
    @Test
    @MediumTest
    public void testNoSpuriousResizeEventResizesVisual() throws Throwable {
        startMainActivityWithURL(
                "/chrome/test/data/android/page_with_editable.html?resizes-visual");
        clearResizeEventLog();

        int initialHeight = getPageInnerHeight();
        double initialVVHeight = getVisualViewportHeight();

        DOMUtils.clickNode(getWebContents(), TEXTFIELD_DOM_ID);
        assertWaitForKeyboardStatus(true);

        double keyboardHeight = getKeyboardHeightDp();

        // Use less than or equal since the keyboard may actually include accessories like the
        // Autofill bar. +1 to account for device scale factor rounding.
        assertWaitForVisualViewportHeight(lessThanOrEqualTo(initialVVHeight - keyboardHeight + 1));

        waitForVisualStateCallback();

        hideKeyboard();
        assertWaitForVisualViewportHeight(
                Matchers.closeTo((double) initialVVHeight, /* error= */ 1.0));

        waitForVisualStateCallback();

        // TODO(crbug.com/40822136): Ideally we'd check that we didn't get *any* resize event since
        // the page height isn't changing.  However, we inconsistently receive spurious resizes
        // during page load on Android. Additionally, visual viewport (and browser controls) updates
        // will also induce resize events (see the TODO in WebViewImpl::ResizeWithBrowserControls).
        // Until these issues are fixed, just ensure the page height is consistent at each fired
        // resize.
        ArrayList<Integer> pageHeights = getResizeEventLog();
        for (Integer pageHeight : pageHeights) {
            Assert.assertEquals(initialHeight, pageHeight.intValue());
        }
    }
}