chromium/components/stylus_handwriting/android/junit/src/org/chromium/components/stylus_handwriting/DirectWritingServiceCallbackTest.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.components.stylus_handwriting;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.robolectric.Shadows.shadowOf;

import android.content.Context;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.Rect;
import android.os.Bundle;
import android.os.Looper;
import android.text.TextUtils;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;

import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.Feature;
import org.chromium.blink.mojom.StylusWritingGestureAction;
import org.chromium.blink.mojom.StylusWritingGestureData;
import org.chromium.blink_public.web.WebTextInputFlags;
import org.chromium.blink_public.web.WebTextInputMode;
import org.chromium.content.browser.input.ImeUtils;
import org.chromium.content_public.browser.StylusWritingImeCallback;
import org.chromium.mojo_base.mojom.String16;
import org.chromium.ui.base.ime.TextInputAction;
import org.chromium.ui.base.ime.TextInputType;

import java.util.Arrays;
import java.util.List;

/** Unit tests for {@link DirectWritingServiceCallback}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class DirectWritingServiceCallbackTest {
    private static final String SAMPLE_INPUT = "sample input";
    private static final String FALLBACK_TEXT = "fallback";
    private static final float[] GESTURE_START_POINT = new float[] {20.f, 50.f};
    private static final float[] GESTURE_END_POINT = new float[] {100.f, 50.f};
    private static final List<String> TWO_POINT_GESTURES =
            Arrays.asList(
                    DirectWritingServiceCallback.GESTURE_TYPE_ZIGZAG,
                    DirectWritingServiceCallback.GESTURE_TYPE_BACKSPACE,
                    DirectWritingServiceCallback.GESTURE_TYPE_U_TYPE_REMOVE_SPACE,
                    DirectWritingServiceCallback.GESTURE_TYPE_ARCH_TYPE_REMOVE_SPACE);

    @Mock private StylusWritingImeCallback mImeCallback;
    @Mock private ViewGroup mContainerView;

    private DirectWritingServiceCallback mDwServiceCallback = new DirectWritingServiceCallback();
    private Context mContext;

    private static String mojoStringToJavaString(String16 mojoString) {
        short[] data = mojoString.data;
        char[] chars = new char[data.length];
        for (int i = 0; i < chars.length; i++) {
            chars[i] = (char) data[i];
        }
        return String.valueOf(chars);
    }

    private static Bundle getGestureBundle(String gestureType) {
        Bundle bundle = new Bundle();
        bundle.putString(DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_GESTURE_TYPE, gestureType);
        bundle.putString(
                DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_TEXT_ALTERNATIVE, FALLBACK_TEXT);
        bundle.putFloatArray(getGestureStartPointKey(gestureType), GESTURE_START_POINT);

        if (isTwoPointGesture(gestureType)) {
            bundle.putFloatArray(
                    DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_END_POINT, GESTURE_END_POINT);
        }
        return bundle;
    }

    private static boolean isTwoPointGesture(String gestureType) {
        return TWO_POINT_GESTURES.contains(gestureType);
    }

    private static String getGestureStartPointKey(String gestureType) {
        if (gestureType.equals(DirectWritingServiceCallback.GESTURE_TYPE_V_SPACE)) {
            return DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_LOWEST_POINT;
        } else if (gestureType.equals(DirectWritingServiceCallback.GESTURE_TYPE_WEDGE_SPACE)) {
            return DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_HIGHEST_POINT;
        } else if (gestureType.equals(DirectWritingServiceCallback.GESTURE_I_TYPE_FUNCTIONAL)) {
            return DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_CENTER_POINT;
        } else {
            return DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_START_POINT;
        }
    }

    private void setImeCallbackAndVerifyMojoGestureData(
            Bundle gestureBundle,
            @StylusWritingGestureAction.EnumType int expectedAction,
            String expectedTextToInsert) {
        mDwServiceCallback.updateEditableBounds(new Rect(0, 0, 400, 400), new Point(50, 50));
        mDwServiceCallback.setImeCallback(mImeCallback);
        mDwServiceCallback.onTextViewExtraCommand(
                DirectWritingServiceCallback.GESTURE_ACTION_RECOGNITION_INFO, gestureBundle);
        shadowOf(Looper.getMainLooper()).idle();
        ArgumentCaptor<StylusWritingGestureData> gestureDataCaptor =
                ArgumentCaptor.forClass(StylusWritingGestureData.class);
        verify(mImeCallback)
                .handleStylusWritingGestureAction(anyInt(), gestureDataCaptor.capture());
        StylusWritingGestureData gestureData = gestureDataCaptor.getValue();
        assertEquals(expectedAction, gestureData.action);
        assertEquals(GESTURE_START_POINT[0], gestureData.startRect.x, /* tolerance= */ 0.1);
        assertEquals(GESTURE_START_POINT[1], gestureData.startRect.y, /* tolerance= */ 0.1);

        if (isTwoPointGesture(
                gestureBundle.getString(
                        DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_GESTURE_TYPE, ""))) {
            assertEquals(GESTURE_END_POINT[0], gestureData.endRect.x, /* tolerance= */ 0.1);
            assertEquals(GESTURE_END_POINT[1], gestureData.endRect.y, /* tolerance= */ 0.1);
        } else {
            assertNull(gestureData.endRect);
        }

        assertEquals(FALLBACK_TEXT, mojoStringToJavaString(gestureData.textAlternative));
        if (expectedTextToInsert == null) {
            assertNull(gestureData.textToInsert);
        } else {
            assertEquals(expectedTextToInsert, mojoStringToJavaString(gestureData.textToInsert));
        }
    }

    private void sendGestureAndVerifyGestureNotHandled(Bundle bundle) {
        mDwServiceCallback.onTextViewExtraCommand(
                DirectWritingServiceCallback.GESTURE_ACTION_RECOGNITION_INFO, bundle);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback, never()).handleStylusWritingGestureAction(anyInt(), any());
    }

    @Before
    public void setup() {
        MockitoAnnotations.initMocks(this);
        mContext = RuntimeEnvironment.application;
        doReturn(mContainerView).when(mImeCallback).getContainerView();
        doReturn(mContext).when(mContainerView).getContext();
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testInputStateData() {
        // Input state values are default when updateInputState is not called yet.
        assertEquals("", mDwServiceCallback.getText());
        assertEquals(0, mDwServiceCallback.getSelectionStart());
        assertEquals(0, mDwServiceCallback.getSelectionEnd());

        // Set input state params and verify.
        int selectionStart = 2;
        int selectionEnd = 2;
        mDwServiceCallback.updateInputState(SAMPLE_INPUT, selectionStart, selectionEnd);
        assertEquals(SAMPLE_INPUT, mDwServiceCallback.getText());
        assertEquals(selectionStart, mDwServiceCallback.getSelectionStart());
        assertEquals(selectionEnd, mDwServiceCallback.getSelectionEnd());

        // Verify edit bounds rect and cursor location point.
        assertTrue(mDwServiceCallback.getCursorLocation(0).equals(0, 0));
        assertEquals(0, mDwServiceCallback.getLeft());
        assertEquals(0, mDwServiceCallback.getRight());
        assertEquals(0, mDwServiceCallback.getTop());
        assertEquals(0, mDwServiceCallback.getBottom());
        Rect editBounds = new Rect(10, 20, 100, 200);
        Point cursorLocation = new Point(10, 20);
        mDwServiceCallback.updateEditableBounds(editBounds, cursorLocation);
        assertEquals(new PointF(cursorLocation), mDwServiceCallback.getCursorLocation(0));
        assertEquals(editBounds.left, mDwServiceCallback.getLeft());
        assertEquals(editBounds.right, mDwServiceCallback.getRight());
        assertEquals(editBounds.top, mDwServiceCallback.getTop());
        assertEquals(editBounds.bottom, mDwServiceCallback.getBottom());
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testHideKeyboardMessage() {
        // hide keyboard is called only after Ime callback is set.
        mDwServiceCallback.semForceHideSoftInput();
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback, never()).hideKeyboard();

        mDwServiceCallback.setImeCallback(mImeCallback);
        mDwServiceCallback.semForceHideSoftInput();
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback).hideKeyboard();
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testShowKeyboardMessage() {
        String action = "show keyboard";
        // hide keyboard is called only after Ime callback is set.
        Bundle bundle = spy(new Bundle());
        bundle.putBoolean(DirectWritingServiceCallback.BUNDLE_KEY_SHOW_KEYBOARD, false);
        mDwServiceCallback.onAppPrivateCommand(action, bundle);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback, never()).getContainerView();
        verify(mImeCallback, never()).showSoftKeyboard();

        mDwServiceCallback.setImeCallback(mImeCallback);
        mDwServiceCallback.onAppPrivateCommand(action, bundle);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback).getContainerView();
        verify(bundle).getBoolean(DirectWritingServiceCallback.BUNDLE_KEY_SHOW_KEYBOARD);
        verify(mImeCallback, never()).showSoftKeyboard();

        bundle.putBoolean(DirectWritingServiceCallback.BUNDLE_KEY_SHOW_KEYBOARD, true);
        mDwServiceCallback.onAppPrivateCommand(action, bundle);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback).showSoftKeyboard();
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testOnEditorActionMessage() {
        int editorAction = EditorInfo.IME_ACTION_SEARCH;
        // Editor action can be done only after Ime callback is set.
        mDwServiceCallback.onEditorAction(editorAction);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback, never()).performEditorAction(anyInt());

        mDwServiceCallback.setImeCallback(mImeCallback);
        mDwServiceCallback.onEditorAction(editorAction);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback).performEditorAction(editorAction);
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testSetTextSelectionMessage() {
        // Text received from service is committed only after Ime callback is set.
        int index = SAMPLE_INPUT.length();
        mDwServiceCallback.setTextSelection(SAMPLE_INPUT, index);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback, never()).setEditableSelectionOffsets(anyInt(), anyInt());
        verify(mImeCallback, never()).sendCompositionToNative(any(), anyInt(), anyBoolean());
        verify(mImeCallback, never()).finishComposingText();

        // Text received from service replaces the current text in input.
        mDwServiceCallback.setImeCallback(mImeCallback);
        String currentInputText = "test";
        mDwServiceCallback.updateInputState(currentInputText, 4, 4);
        mDwServiceCallback.setTextSelection(SAMPLE_INPUT, index);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback).finishComposingText();
        verify(mImeCallback).setEditableSelectionOffsets(0, currentInputText.length());
        verify(mImeCallback).sendCompositionToNative(SAMPLE_INPUT, index, true);
        verify(mImeCallback).setEditableSelectionOffsets(index, index);
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testUpdateEditBoundsMessage() {
        mDwServiceCallback.setImeCallback(mImeCallback);
        DirectWritingServiceCallback.TriggerCallback mockTriggercallback =
                mock(DirectWritingServiceCallback.TriggerCallback.class);
        mDwServiceCallback.setTriggerCallback(mockTriggercallback);
        mDwServiceCallback.updateBoundedEditTextRect();
        shadowOf(Looper.getMainLooper()).idle();
        verify(mockTriggercallback).updateEditableBoundsToService();
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testIsHoverIconShowing() {
        DirectWritingServiceCallback.TriggerCallback mockTriggercallback =
                mock(DirectWritingServiceCallback.TriggerCallback.class);
        mDwServiceCallback.setTriggerCallback(mockTriggercallback);
        assertFalse(mDwServiceCallback.isHoverIconShowing());
        doReturn(true).when(mockTriggercallback).isHandwritingIconShowing();
        assertTrue(mDwServiceCallback.isHoverIconShowing());
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testEditorInfoAttributes() {
        // Service callback returns default values until Editor info is set.
        assertTrue(TextUtils.isEmpty(mDwServiceCallback.getPrivateImeOptions()));
        assertEquals(0, mDwServiceCallback.getImeOptions());
        assertEquals(0, mDwServiceCallback.getInputType());

        EditorInfo editorInfo = new EditorInfo();
        int index = SAMPLE_INPUT.length();
        ImeUtils.computeEditorInfo(
                TextInputType.TEXT,
                WebTextInputFlags.NONE,
                WebTextInputMode.DEFAULT,
                TextInputAction.SEARCH,
                index,
                index,
                SAMPLE_INPUT,
                editorInfo);
        mDwServiceCallback.updateEditorInfo(editorInfo);
        assertEquals(editorInfo.privateImeOptions, mDwServiceCallback.getPrivateImeOptions());
        assertEquals(editorInfo.imeOptions, mDwServiceCallback.getImeOptions());
        assertEquals(editorInfo.inputType, mDwServiceCallback.getInputType());
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_deleteByStrike() {
        // Stylus gesture Delete is handled only after Ime callback is set.
        Bundle bundle = getGestureBundle(DirectWritingServiceCallback.GESTURE_TYPE_BACKSPACE);
        sendGestureAndVerifyGestureNotHandled(bundle);

        setImeCallbackAndVerifyMojoGestureData(
                bundle, StylusWritingGestureAction.DELETE_TEXT, null);
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_deleteByScribble() {
        // Stylus gesture Delete is handled only after Ime callback is set.
        Bundle bundle = getGestureBundle(DirectWritingServiceCallback.GESTURE_TYPE_ZIGZAG);
        sendGestureAndVerifyGestureNotHandled(bundle);

        setImeCallbackAndVerifyMojoGestureData(
                bundle, StylusWritingGestureAction.DELETE_TEXT, null);
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_deletePointsClampedToEditBounds() {
        mDwServiceCallback.setImeCallback(mImeCallback);
        // Set Edit field bounds smaller than gesture x-coordinates.
        Rect editBounds = new Rect(30, 30, 80, 80);
        mDwServiceCallback.updateEditableBounds(editBounds, new Point(50, 50));

        Bundle bundle = getGestureBundle(DirectWritingServiceCallback.GESTURE_TYPE_BACKSPACE);
        mDwServiceCallback.onTextViewExtraCommand(
                DirectWritingServiceCallback.GESTURE_ACTION_RECOGNITION_INFO, bundle);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback)
                .handleStylusWritingGestureAction(
                        anyInt(),
                        argThat(
                                gestureData -> {
                                    assertEquals(
                                            StylusWritingGestureAction.DELETE_TEXT,
                                            gestureData.action);
                                    // assert that start-x and end-x are clamped to Edit bounds.
                                    assertEquals(editBounds.left, gestureData.startRect.x);
                                    assertEquals(editBounds.right, gestureData.endRect.x);
                                    return true;
                                }));
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_insertVSpace() {
        // Stylus gesture add space is handled only after Ime callback is set.
        Bundle bundle = getGestureBundle(DirectWritingServiceCallback.GESTURE_TYPE_V_SPACE);
        sendGestureAndVerifyGestureNotHandled(bundle);

        setImeCallbackAndVerifyMojoGestureData(
                bundle, StylusWritingGestureAction.ADD_SPACE_OR_TEXT, " ");
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_insertVText() {
        Bundle bundle = getGestureBundle(DirectWritingServiceCallback.GESTURE_TYPE_V_SPACE);
        // Set text to be inserted with V gesture.
        String textToInsert = "text";
        bundle.putString(
                DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_TEXT_INSERTION, textToInsert);
        setImeCallbackAndVerifyMojoGestureData(
                bundle, StylusWritingGestureAction.ADD_SPACE_OR_TEXT, textToInsert);
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_insertWedgeSpace() {
        // Stylus gesture add wedge space is handled only after Ime callback is set.
        Bundle bundle = getGestureBundle(DirectWritingServiceCallback.GESTURE_TYPE_WEDGE_SPACE);
        sendGestureAndVerifyGestureNotHandled(bundle);

        setImeCallbackAndVerifyMojoGestureData(
                bundle, StylusWritingGestureAction.ADD_SPACE_OR_TEXT, " ");
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_insertWedgeText() {
        Bundle bundle = getGestureBundle(DirectWritingServiceCallback.GESTURE_TYPE_WEDGE_SPACE);
        // Set text to be inserted with wedge gesture.
        String textToInsert = "text";
        bundle.putString(
                DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_TEXT_INSERTION, textToInsert);
        setImeCallbackAndVerifyMojoGestureData(
                bundle, StylusWritingGestureAction.ADD_SPACE_OR_TEXT, textToInsert);
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_splitOrMerge() {
        // Stylus gesture split or merge is handled only after Ime callback is set.
        Bundle bundle = getGestureBundle(DirectWritingServiceCallback.GESTURE_I_TYPE_FUNCTIONAL);
        sendGestureAndVerifyGestureNotHandled(bundle);

        setImeCallbackAndVerifyMojoGestureData(
                bundle, StylusWritingGestureAction.SPLIT_OR_MERGE, null);
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_unSupportedWithFallbackText() {
        // Invalid gesture commits fallback text.
        Bundle bundle = new Bundle();
        bundle.putString(DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_GESTURE_TYPE, "invalid");
        bundle.putString(
                DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_TEXT_ALTERNATIVE, FALLBACK_TEXT);
        sendGestureAndVerifyGestureNotHandled(bundle);

        mDwServiceCallback.updateEditableBounds(new Rect(0, 0, 400, 400), new Point(50, 50));
        mDwServiceCallback.setImeCallback(mImeCallback);
        mDwServiceCallback.onTextViewExtraCommand(
                DirectWritingServiceCallback.GESTURE_ACTION_RECOGNITION_INFO, bundle);
        shadowOf(Looper.getMainLooper()).idle();
        verify(mImeCallback).sendCompositionToNative(FALLBACK_TEXT, FALLBACK_TEXT.length(), true);
        verify(mImeCallback, never()).handleStylusWritingGestureAction(anyInt(), any());
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_UTypeRemoveSpaces() {
        // Stylus gesture remove spaces is handled only after Ime callback is set.
        Bundle bundle =
                getGestureBundle(DirectWritingServiceCallback.GESTURE_TYPE_U_TYPE_REMOVE_SPACE);
        sendGestureAndVerifyGestureNotHandled(bundle);

        setImeCallbackAndVerifyMojoGestureData(
                bundle, StylusWritingGestureAction.REMOVE_SPACES, null);
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_archTypeRemoveSpaces() {
        // Stylus gesture remove spaces is handled only after Ime callback is set.
        Bundle bundle =
                getGestureBundle(DirectWritingServiceCallback.GESTURE_TYPE_ARCH_TYPE_REMOVE_SPACE);
        sendGestureAndVerifyGestureNotHandled(bundle);

        setImeCallbackAndVerifyMojoGestureData(
                bundle, StylusWritingGestureAction.REMOVE_SPACES, null);
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testStylusGestureMessage_invalidGestureTypeWithoutFallbackText() {
        mDwServiceCallback.setImeCallback(mImeCallback);
        // Gesture type other than the expected ones are not handled.
        Bundle bundle = spy(new Bundle());
        bundle.putString(
                DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_GESTURE_TYPE, "invalid_gesture");
        // verify that gesture bundle is accessed but gesture is not handled for invalid gesture.
        sendGestureAndVerifyGestureNotHandled(bundle);
        verify(mImeCallback, never()).sendCompositionToNative(any(), anyInt(), anyBoolean());
        verify(bundle).getString(DirectWritingServiceCallback.GESTURE_BUNDLE_KEY_GESTURE_TYPE, "");
    }

    @Test
    @Feature({"Stylus Handwriting"})
    public void testOnTextViewExtraCommand_invalidAction() {
        mDwServiceCallback.setImeCallback(mImeCallback);
        // Text view extra command only handles gesture recognition. Other actions are ignored.
        Bundle bundle =
                spy(
                        getGestureBundle(
                                DirectWritingServiceCallback.GESTURE_TYPE_ARCH_TYPE_REMOVE_SPACE));
        mDwServiceCallback.onTextViewExtraCommand("invalid", bundle);
        shadowOf(Looper.getMainLooper()).idle();
        // verify that Gesture bundle is never accessed, and not handled for invalid action.
        verify(bundle, never()).getString(any(), any());
        verify(mImeCallback, never()).handleStylusWritingGestureAction(anyInt(), any());
    }
}