chromium/ui/android/junit/src/org/chromium/ui/base/EventForwarderTest.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.ui.base;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyFloat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import android.content.ClipData;
import android.content.ClipDescription;
import android.net.Uri;
import android.view.DragEvent;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.MotionEvent.PointerCoords;
import android.view.View;

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

import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.Features.EnableFeatures;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.base.test.util.JniMocker;
import org.chromium.ui.MotionEventUtils;

/** Tests logic in the {@link EventForwarder} class. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class EventForwarderTest {
    @Rule public JniMocker mocker = new JniMocker();

    @Mock EventForwarder.Natives mNativeMock;

    private static final long NATIVE_EVENT_FORWARDER_ID = 1;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        mocker.mock(EventForwarderJni.TEST_HOOKS, mNativeMock);
    }

    @Test
    public void testSendTrackpadClicksAsMouseEventToNative() {
        EventForwarder eventForwarder = new EventForwarder(NATIVE_EVENT_FORWARDER_ID, true, true);

        // Left click
        MotionEvent leftClickEvent = getTrackpadLeftClickEvent();
        eventForwarder.onTouchEvent(leftClickEvent);
        verifyNativeMouseEventSent(NATIVE_EVENT_FORWARDER_ID, leftClickEvent, eventForwarder, 1);

        // Right click
        MotionEvent rightClickEvent = getTrackRightClickEvent();
        eventForwarder.onTouchEvent(rightClickEvent);
        verifyNativeMouseEventSent(NATIVE_EVENT_FORWARDER_ID, rightClickEvent, eventForwarder, 1);
    }

    @Test
    public void testSendTrackpadClickReleaseAsMouseEventToNative() {
        EventForwarder eventForwarder = new EventForwarder(NATIVE_EVENT_FORWARDER_ID, true, true);

        // Left click
        MotionEvent leftClickReleaseEvent =
                getTrackpadEvent(MotionEvent.ACTION_BUTTON_RELEASE, MotionEvent.BUTTON_PRIMARY);
        eventForwarder.onTouchEvent(leftClickReleaseEvent);
        verifyNativeMouseEventSent(
                NATIVE_EVENT_FORWARDER_ID, leftClickReleaseEvent, eventForwarder, 1);

        // Right click
        MotionEvent rightClickReleaseEvent =
                getTrackpadEvent(MotionEvent.ACTION_BUTTON_RELEASE, MotionEvent.BUTTON_SECONDARY);
        eventForwarder.onTouchEvent(rightClickReleaseEvent);
        verifyNativeMouseEventSent(
                NATIVE_EVENT_FORWARDER_ID, rightClickReleaseEvent, eventForwarder, 1);
    }

    @Test
    public void testSendTrackpadClickAndDragAsMouseEventToNative() {
        EventForwarder eventForwarder = new EventForwarder(NATIVE_EVENT_FORWARDER_ID, true, true);
        MotionEvent clickAndDragEvent =
                getTrackpadEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_PRIMARY);
        eventForwarder.onTouchEvent(clickAndDragEvent);
        verifyNativeMouseEventSent(NATIVE_EVENT_FORWARDER_ID, clickAndDragEvent, eventForwarder, 1);
    }

    @Test
    public void testMotionEventWithHistory() {
        EventForwarder eventForwarder = new EventForwarder(NATIVE_EVENT_FORWARDER_ID, true, false);
        final long eventTime = 200;
        final long latestEventTime = 400;
        MotionEvent dragEvent =
                MotionEvent.obtain(
                        /* downTime= */ 100,
                        eventTime,
                        MotionEvent.ACTION_MOVE,
                        /* x= */ 14,
                        /* y= */ 21,
                        /* metaState= */ 0);
        var pointerCoords = new PointerCoords();
        pointerCoords.x = 16;
        pointerCoords.y = 23;
        PointerCoords[] newMovements = {pointerCoords};
        dragEvent.addBatch(latestEventTime, newMovements, /* metaState= */ 0);
        eventForwarder.onTouchEvent(dragEvent);

        // Check that the timestamp is forwarded from the first event in the batch (dragEvent) while
        // coordinates are forwarded from the last (pointerCoords).
        verify(mNativeMock, times(1))
                .onTouchEvent(
                        EventForwarderTest.NATIVE_EVENT_FORWARDER_ID,
                        eventForwarder,
                        dragEvent,
                        eventTime * 1000_000,
                        latestEventTime * 1000_000,
                        dragEvent.getActionMasked(),
                        1,
                        /* historySize= */ 1,
                        0,
                        pointerCoords.x,
                        pointerCoords.y,
                        0,
                        0,
                        dragEvent.getPointerId(0),
                        -1,
                        0.0f,
                        0.0f,
                        0.0f,
                        0.0f,
                        dragEvent.getOrientation(0),
                        0,
                        dragEvent.getAxisValue(MotionEvent.AXIS_TILT),
                        0,
                        pointerCoords.x,
                        pointerCoords.y,
                        dragEvent.getToolType(0),
                        MotionEvent.TOOL_TYPE_UNKNOWN,
                        0,
                        dragEvent.getButtonState(),
                        dragEvent.getMetaState(),
                        false);
    }

    @Test
    public void testSendTrackEventAsTouchEventWhenButtonIsNotClicked() {
        EventForwarder eventForwarder = new EventForwarder(NATIVE_EVENT_FORWARDER_ID, true, true);
        MotionEvent trackpadTouchDownEventNoClick = getTrackpadTouchDownEventNoClick();
        eventForwarder.onTouchEvent(trackpadTouchDownEventNoClick);
        verify(mNativeMock, times(1))
                .onTouchEvent(
                        anyLong(),
                        any(EventForwarder.class),
                        any(MotionEvent.class),
                        anyLong(),
                        anyLong(),
                        anyInt(),
                        anyInt(),
                        anyInt(),
                        anyInt(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyInt(),
                        anyInt(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyInt(),
                        anyInt(),
                        anyInt(),
                        anyInt(),
                        anyInt(),
                        anyBoolean());
        verify(mNativeMock, never())
                .onMouseEvent(
                        anyLong(),
                        any(EventForwarder.class),
                        anyLong(),
                        anyInt(),
                        anyFloat(),
                        anyFloat(),
                        anyInt(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyInt(),
                        anyInt(),
                        anyInt(),
                        anyInt());
    }

    @Test
    public void testNotSendTrackpadClickAsMouseEventWhenFeatureDisabled() {
        EventForwarder eventForwarder = new EventForwarder(NATIVE_EVENT_FORWARDER_ID, true, false);
        MotionEvent trackpadClickDownEvent = getTrackpadLeftClickEvent();
        eventForwarder.onTouchEvent(trackpadClickDownEvent);
        verify(mNativeMock, never())
                .onMouseEvent(
                        anyLong(),
                        any(EventForwarder.class),
                        anyLong(),
                        anyInt(),
                        anyFloat(),
                        anyFloat(),
                        anyInt(),
                        anyFloat(),
                        anyFloat(),
                        anyFloat(),
                        anyInt(),
                        anyInt(),
                        anyInt(),
                        anyInt());
    }

    @EnableFeatures({UiAndroidFeatureList.DRAG_DROP_FILES})
    @Test
    public void testDragDropEvent() {
        // Text.
        validateDragDropEvent(
                new String[] {"text/plain"},
                new ClipData.Item[] {new ClipData.Item("text content")},
                new String[][] {}, // expectedFilenames
                "text content", // expectedText
                null, // expectedHtml
                null); // expectedUrl

        // Html.
        validateDragDropEvent(
                new String[] {"text/html"},
                new ClipData.Item[] {new ClipData.Item("text content", "html content")},
                new String[][] {}, // expectedFilenames
                "text content", // expectedText
                "html content", // expectedHtml
                null); // expectedUrl

        // Url.
        validateDragDropEvent(
                new String[] {"text/x-moz-url"},
                new ClipData.Item[] {new ClipData.Item("url content")},
                new String[][] {}, // expectedFilenames
                "url content", // expectedText
                null, // expectedHtml
                "url content"); // expectedUrl

        // Files.
        validateDragDropEvent(
                new String[] {"image/jpeg", "text/plain"},
                new ClipData.Item[] {
                    new ClipData.Item(Uri.parse("image.jpg")),
                    new ClipData.Item(Uri.parse("hello.txt"))
                },
                new String[][] {{"image.jpg", ""}, {"hello.txt", ""}}, // expectedFilenames
                null, // expectedText
                null, // expectedHtml
                null); // expectedUrl
    }

    private void verifyNativeMouseEventSent(
            long nativeEventForwarder,
            MotionEvent event,
            EventForwarder eventForwarder,
            int times) {
        verify(mNativeMock, times(times))
                .onMouseEvent(
                        nativeEventForwarder,
                        eventForwarder,
                        MotionEventUtils.getEventTimeNanos(event),
                        event.getActionMasked(),
                        event.getX(),
                        event.getY(),
                        event.getPointerId(0),
                        event.getPressure(0),
                        event.getOrientation(0),
                        event.getAxisValue(MotionEvent.AXIS_TILT, 0),
                        EventForwarder.getMouseEventActionButton(event),
                        event.getButtonState(),
                        event.getMetaState(),
                        MotionEvent.TOOL_TYPE_MOUSE);
    }

    private static MotionEvent getTrackpadTouchDownEventNoClick() {
        return getTrackpadEvent(MotionEvent.ACTION_DOWN, 0);
    }

    private static MotionEvent getTrackpadLeftClickEvent() {
        return getTrackpadEvent(MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.BUTTON_PRIMARY);
    }

    private static MotionEvent getTrackRightClickEvent() {
        return getTrackpadEvent(MotionEvent.ACTION_BUTTON_PRESS, MotionEvent.BUTTON_SECONDARY);
    }

    private static MotionEvent getTrackpadEvent(int action, int buttonState) {
        return MotionEvent.obtain(
                0,
                0,
                action,
                1,
                getToolTypeFingerProperties(),
                getPointerCoords(),
                0,
                buttonState,
                0,
                0,
                0,
                0,
                getTrackpadSource(),
                0);
    }

    private static MotionEvent.PointerProperties[] getToolTypeFingerProperties() {
        MotionEvent.PointerProperties[] pointerPropertiesArray =
                new MotionEvent.PointerProperties[1];
        MotionEvent.PointerProperties trackpadProperties = new MotionEvent.PointerProperties();
        trackpadProperties.id = 7;
        trackpadProperties.toolType = MotionEvent.TOOL_TYPE_FINGER;
        pointerPropertiesArray[0] = trackpadProperties;
        return pointerPropertiesArray;
    }

    private static MotionEvent.PointerCoords[] getPointerCoords() {
        MotionEvent.PointerCoords[] pointerCoordsArray = new MotionEvent.PointerCoords[1];
        MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
        coords.x = 14;
        coords.y = 21;
        pointerCoordsArray[0] = coords;
        return pointerCoordsArray;
    }

    private static int getTrackpadSource() {
        return InputDevice.SOURCE_MOUSE;
    }

    private void validateDragDropEvent(
            String[] mimeTypes,
            ClipData.Item[] items,
            String[][] expectedFilenames,
            String expectedText,
            String expectedHtml,
            String expectedUrl) {
        ClipData clipData = new ClipData("label", mimeTypes, items[0]);
        for (int i = 1; i < items.length; i++) {
            clipData.addItem(items[i]);
        }
        ClipDescription clipDescription = new ClipDescription("label", mimeTypes);
        EventForwarder eventForwarder = new EventForwarder(NATIVE_EVENT_FORWARDER_ID, true, false);
        DragEvent event = mock(DragEvent.class);
        doReturn(DragEvent.ACTION_DROP).when(event).getAction();
        doReturn(14f).when(event).getX();
        doReturn(21f).when(event).getY();
        doReturn(clipData).when(event).getClipData();
        doReturn(clipDescription).when(event).getClipDescription();
        HistogramWatcher histograms =
                HistogramWatcher.newBuilder()
                        .expectIntRecord("Android.DragDrop.Files.Count", expectedFilenames.length)
                        .build();
        eventForwarder.onDragEvent(event, mock(View.class));
        verify(mNativeMock, times(1))
                .onDragEvent(
                        eq(EventForwarderTest.NATIVE_EVENT_FORWARDER_ID),
                        eq(eventForwarder),
                        eq(DragEvent.ACTION_DROP),
                        eq(14.0f), // x
                        eq(21.0f), // y
                        eq(14.0f), // screenX
                        eq(21.0f), // screenY
                        eq(mimeTypes),
                        eq(""), // content
                        argThat(
                                filenames -> {
                                    if (filenames.length != expectedFilenames.length) {
                                        return false;
                                    }
                                    for (int i = 0; i < filenames.length; i++) {
                                        if (filenames[i].length != 2
                                                || !expectedFilenames[i][0].equals(filenames[i][0])
                                                || !expectedFilenames[i][1].equals(
                                                        filenames[i][1])) {
                                            return false;
                                        }
                                    }
                                    return true;
                                }),
                        eq(expectedText),
                        eq(expectedHtml),
                        eq(expectedUrl));
        histograms.assertExpected();
    }
}