chromium/content/public/android/javatests/src/org/chromium/content/browser/input/StylusGestureEndToEndTest.java

// Copyright 2023 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.input;

import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;

import android.graphics.PointF;
import android.graphics.RectF;
import android.os.Build;
import android.view.inputmethod.DeleteGesture;
import android.view.inputmethod.DeleteRangeGesture;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.HandwritingGesture;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InsertGesture;
import android.view.inputmethod.JoinOrSplitGesture;
import android.view.inputmethod.RemoveSpaceGesture;
import android.view.inputmethod.SelectGesture;
import android.view.inputmethod.SelectRangeGesture;

import androidx.annotation.RequiresApi;
import androidx.test.filters.LargeTest;
import androidx.test.filters.MediumTest;

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.CommandLineFlags;
import org.chromium.base.test.util.MinAndroidSdkLevel;
import org.chromium.content.browser.RenderCoordinatesImpl;
import org.chromium.content.browser.webcontents.WebContentsImpl;
import org.chromium.content_public.browser.test.ContentJUnit4ClassRunner;
import org.chromium.content_public.browser.test.util.JavaScriptUtils;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeoutException;

/**
 * Tests the entire flow of performing a stylus gesture on a website. Uses JavaScript to get an
 * area of text, simulates a handwriting gesture object over that area and asserts that the correct
 * change has been made to the page.
 */
@RunWith(ContentJUnit4ClassRunner.class)
@Batch(Batch.PER_CLASS)
@CommandLineFlags.Add({"enable-features=StylusRichGestures"})
@MinAndroidSdkLevel(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
public class StylusGestureEndToEndTest {
    @Rule public ImeActivityTestRule mRule = new ImeActivityTestRule();

    private InputConnection mWrappedInputConnection;
    private HandwritingGesture mHandwritingGesture;
    private static final String FALLBACK_TEXT = "this gesture failed";

    @Before
    public void setUp() throws Exception {
        mRule.setUpForUrl(ImeActivityTestRule.INPUT_FORM_HTML);
        EditorInfo editorInfo = new EditorInfo();
        ImeAdapterImpl imeAdapter = spy(mRule.getImeAdapter());
        doReturn(mRule.getConnection())
                .when(imeAdapter)
                .onCreateInputConnection(any(EditorInfo.class), any(boolean.class));
        doCallRealMethod().when(imeAdapter).onCreateInputConnection(any(EditorInfo.class));
        mWrappedInputConnection = imeAdapter.onCreateInputConnection(editorInfo);
    }

    @Test
    @LargeTest
    public void testSelectGesture() throws TimeoutException {
        List<RectF> bounds =
                initialiseElementAndGetCharacterBounds("contenteditable1", "hello world");
        RectF gestureRect = bounds.get(0);
        gestureRect.union(bounds.get(bounds.size() - 1));

        mHandwritingGesture =
                new SelectGesture.Builder()
                        .setGranularity(HandwritingGesture.GRANULARITY_WORD)
                        .setSelectionArea(gestureRect)
                        .setFallbackText(FALLBACK_TEXT)
                        .build();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWrappedInputConnection.performHandwritingGesture(
                            mHandwritingGesture, null, null);
                });

        // Get the text inside contenteditable1 and assert that it is the same.
        assertEquals(
                "\"hello world\"",
                runJavaScript("document.getElementById(\"contenteditable1\").innerText;"));
        // Get the currently selected text and assert that it is the contents of contenteditable1.
        assertEquals("\"hello world\"", runJavaScript("window.getSelection().toString();"));
    }

    @Test
    @MediumTest
    public void testInsertGesture() throws TimeoutException {
        List<RectF> bounds =
                initialiseElementAndGetCharacterBounds("contenteditable1", "hello world");
        PointF insertionPoint = new PointF(bounds.get(7).right, bounds.get(7).centerY());

        mHandwritingGesture =
                new InsertGesture.Builder()
                        .setInsertionPoint(insertionPoint)
                        .setTextToInsert("inserted")
                        .setFallbackText(FALLBACK_TEXT)
                        .build();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWrappedInputConnection.performHandwritingGesture(
                            mHandwritingGesture, null, null);
                });

        // Get the text inside contenteditable1 and assert that it contains the inserted text.
        assertEquals(
                "\"hello woinsertedrld\"",
                runJavaScript("document.getElementById(\"contenteditable1\").innerText;"));
    }

    @Test
    @LargeTest
    public void testDeleteGesture() throws TimeoutException {
        List<RectF> bounds =
                initialiseElementAndGetCharacterBounds("contenteditable1", "hello world");
        RectF gestureRect = bounds.get(0);
        gestureRect.union(bounds.get(6));

        mHandwritingGesture =
                new DeleteGesture.Builder()
                        .setGranularity(HandwritingGesture.GRANULARITY_WORD)
                        .setDeletionArea(gestureRect)
                        .setFallbackText(FALLBACK_TEXT)
                        .build();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWrappedInputConnection.performHandwritingGesture(
                            mHandwritingGesture, null, null);
                });

        // Get the text inside contenteditable1 and assert that it has been deleted.
        assertEquals(
                "\"world\"",
                runJavaScript("document.getElementById(\"contenteditable1\").innerText"));
    }

    @Test
    @MediumTest
    public void testRemoveSpaceGesture() throws TimeoutException {
        List<RectF> bounds =
                initialiseElementAndGetCharacterBounds("contenteditable1", "hello world");
        RectF removalRect = bounds.get(2);
        removalRect.union(bounds.get(9));

        // The following reflection creates a new RemoveSpaceGesture for the space between "hello"
        // and "world" in contenteditable1.
        mHandwritingGesture =
                new RemoveSpaceGesture.Builder()
                        .setPoints(
                                new PointF(removalRect.left, removalRect.bottom),
                                new PointF(removalRect.right, removalRect.top))
                        .setFallbackText(FALLBACK_TEXT)
                        .build();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWrappedInputConnection.performHandwritingGesture(
                            mHandwritingGesture, null, null);
                });

        // Get the text inside contenteditable1 and assert that it contains the inserted text.
        assertEquals(
                "\"helloworld\"",
                runJavaScript("document.getElementById(\"contenteditable1\").innerText;"));
    }

    @Test
    @MediumTest
    public void testJoinOrSplitGesture() throws TimeoutException {
        List<RectF> bounds =
                initialiseElementAndGetCharacterBounds("contenteditable1", "hello world");
        PointF joinOrSplitPoint = new PointF(bounds.get(5).centerX(), bounds.get(5).centerY());

        mHandwritingGesture =
                new JoinOrSplitGesture.Builder()
                        .setJoinOrSplitPoint(joinOrSplitPoint)
                        .setFallbackText(FALLBACK_TEXT)
                        .build();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWrappedInputConnection.performHandwritingGesture(
                            mHandwritingGesture, null, null);
                });

        // Get the text inside contenteditable1 and assert that it contains the inserted text.
        assertEquals(
                "\"helloworld\"",
                runJavaScript("document.getElementById(\"contenteditable1\").innerText;"));

        joinOrSplitPoint = new PointF(bounds.get(1).right, bounds.get(2).centerY());
        mHandwritingGesture =
                new JoinOrSplitGesture.Builder()
                        .setJoinOrSplitPoint(joinOrSplitPoint)
                        .setFallbackText(FALLBACK_TEXT)
                        .build();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWrappedInputConnection.performHandwritingGesture(
                            mHandwritingGesture, null, null);
                });

        // Get the text inside contenteditable1 and assert that it contains the inserted text.
        assertEquals(
                "\"he lloworld\"",
                runJavaScript("document.getElementById(\"contenteditable1\").innerText;"));
    }

    @Test
    @MediumTest
    public void testSelectRangeGesture() throws TimeoutException {
        List<RectF> bounds =
                initialiseElementAndGetCharacterBounds(
                        "contenteditable1", "hello world\\ngoodbye world");
        // "orld"
        RectF startRect = bounds.get(7);
        startRect.union(bounds.get(10));
        // "goodb"
        RectF endRect = bounds.get(11);
        endRect.union(bounds.get(15));

        mHandwritingGesture =
                new SelectRangeGesture.Builder()
                        .setGranularity(HandwritingGesture.GRANULARITY_WORD)
                        .setSelectionStartArea(startRect)
                        .setSelectionEndArea(endRect)
                        .setFallbackText(FALLBACK_TEXT)
                        .build();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWrappedInputConnection.performHandwritingGesture(
                            mHandwritingGesture, null, null);
                });

        // Get the text inside contenteditable1 and assert that it is the same.
        assertEquals(
                "\"hello world\\ngoodbye world\"",
                runJavaScript("document.getElementById(\"contenteditable1\").innerText;"));

        assertEquals("\"world\\ngoodbye\"", runJavaScript("window.getSelection().toString();"));
    }

    @Test
    @MediumTest
    public void testDeleteRangeGesture() throws TimeoutException {
        List<RectF> bounds =
                initialiseElementAndGetCharacterBounds(
                        "contenteditable1", "hello world\\ngoodbye world");
        // "llo world"
        RectF startRect = bounds.get(2);
        startRect.union(bounds.get(10));
        // "good"
        RectF endRect = bounds.get(11);
        endRect.union(bounds.get(14));

        mHandwritingGesture =
                new DeleteRangeGesture.Builder()
                        .setGranularity(HandwritingGesture.GRANULARITY_WORD)
                        .setDeletionStartArea(startRect)
                        .setDeletionEndArea(endRect)
                        .setFallbackText(FALLBACK_TEXT)
                        .build();

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mWrappedInputConnection.performHandwritingGesture(
                            mHandwritingGesture, null, null);
                });

        // We have to remove the 0 width line break character that JavaScript leaves in the string.
        assertEquals(
                "\"world\"",
                runJavaScript("document.getElementById(\"contenteditable1\").innerText;")
                        .replaceAll("\u00A0", ""));
    }

    /**
     * Sets the text of the specified element to the string value then uses javascript to get the
     * bounds of each individual character in the specified element.
     * @param elementId The ID of the element in the DOM containing the text (must not be <input>
     *                  or <textarea> as these do not report bounds for their selections.
     * @param value The text to set inside the element represented by elementId.
     * @return A list of rectangle bounds (one per character).
     */
    private List<RectF> initialiseElementAndGetCharacterBounds(String elementId, String value)
            throws TimeoutException {
        runJavaScript(
                String.format(
                        "document.getElementById(\"%s\").focus() = \"%s\"", elementId, value));
        runJavaScript(
                String.format(
                        "document.getElementById(\"%s\").innerText = \"%s\"", elementId, value));
        int numChildNodes =
                Integer.parseInt(
                        runJavaScript(
                                String.format(
                                        "document.getElementById(\"%s\").childNodes.length",
                                        elementId)));
        // Iterate through all child text nodes of elementId, populating bounds for each character.
        List<RectF> bounds = new ArrayList<>();
        for (int child = 0; child < numChildNodes; child++) {
            String snippet =
                    String.format(
                            "document.getElementById(\"%s\").childNodes[%d].nodeName",
                            elementId, child);
            if ("\"#text\"".equals(runJavaScript(snippet))) {
                snippet =
                        String.format(
                                "document.getElementById(\"%s\").childNodes[%d].textContent.length",
                                elementId, child);
                int length = Integer.parseInt(runJavaScript(snippet));
                for (int i = 0; i < length; i++) {
                    String code =
                            String.format(
                                    "(function() {let el = document.getElementById(\"%s\"); "
                                            + "let range = new Range(); "
                                            + "range.setStart(el.childNodes[%d], %d); "
                                            + "range.setEnd(el.childNodes[%d], %d); "
                                            + "window.getSelection().removeAllRanges(); "
                                            + "window.getSelection().addRange(range); "
                                            + "return window.getSelection().getRangeAt(0)"
                                            + ".getBoundingClientRect().%s;"
                                            + "})();",
                                    elementId, child, i, child, i + 1, "%s");
                    float left = Float.parseFloat(runJavaScript(String.format(code, "left")));
                    float top = Float.parseFloat(runJavaScript(String.format(code, "top")));
                    float right = Float.parseFloat(runJavaScript(String.format(code, "right")));
                    float bottom = Float.parseFloat(runJavaScript(String.format(code, "bottom")));
                    bounds.add(
                            toScreenRectF(
                                    left,
                                    top,
                                    right,
                                    bottom,
                                    (WebContentsImpl) mRule.getWebContents()));
                }
            }
        }
        runJavaScript("window.getSelection().removeAllRange();");
        return bounds;
    }

    /**
     * Helper method to make the tests slightly more readable. Runs a code snippet of JavaScript.
     * @param code The JavaScript code to execute.
     * @return The result of the JavaScript code.
     */
    private String runJavaScript(String code) throws TimeoutException {
        return JavaScriptUtils.executeJavaScriptAndWaitForResult(mRule.getWebContents(), code);
    }

    private static RectF toScreenRectF(
        float left, float top, float right, float bottom, WebContentsImpl webContents) {
        // Convert from local CSS coordinates to absolute screen coordinates.
        RenderCoordinatesImpl rc = webContents.getRenderCoordinates();
        int[] screenLocation = new int[2];
        webContents.getViewAndroidDelegate().getContainerView().getLocationOnScreen(screenLocation);
        left = rc.fromLocalCssToPix(left) + screenLocation[0];
        top = rc.fromLocalCssToPix(top) + rc.getContentOffsetYPix() + screenLocation[1];
        right = rc.fromLocalCssToPix(right) + screenLocation[0];
        bottom = rc.fromLocalCssToPix(bottom) + rc.getContentOffsetYPixInt() + screenLocation[1];

        return new RectF(left, top, right, bottom);
    }
}