chromium/content/public/test/android/javatests/src/org/chromium/content_public/browser/test/util/TouchCommon.java

// Copyright 2012 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_public.browser.test.util;

import android.app.Activity;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;

import org.chromium.base.ThreadUtils;

/**
 * Touch-related functionality reused across test cases.
 *
 * <p>Differs from {@link TestTouchUtils} as this does not rely on injecting events via
 * Instrumentation. Injecting events is more brittle (e.g. it will fail if a dialog pops up in front
 * of the test), and simulating the touch events in the manner used here is just as effective.
 */
public class TouchCommon {
    // The amount of time between a pointer up and the last move event such that the pointer is
    // considered to be stopped. This must be larger than kAssumePointerUpStoppedTime in
    // ui/events/velocity_tracker/velocity_tracker.cc.
    private static final int POINTER_UP_STOPPED_TIME_MS = 200;

    // Prevent instantiation.
    private TouchCommon() {}

    /**
     * Synchronously perform a start-to-end drag event on the specified view with deterministic
     * timing (events do not use system time).
     *
     * @param view The view to dispatch events to.
     * @param fromX X coordinate of the initial touch, in screen coordinates.
     * @param toX X coordinate of the drag destination, in screen coordinates.
     * @param fromY X coordinate of the initial touch, in screen coordinates.
     * @param toY Y coordinate of the drag destination, in screen coordinates.
     * @param stepCount How many move steps to include in the drag.
     * @param duration The amount of time that will be simulated for the event stream in ms.
     */
    public static void performDrag(
            View view,
            float fromX,
            float toX,
            float fromY,
            float toY,
            int stepCount,
            long duration) {
        int dispatchIntervalMs = (int) duration / stepCount;
        performDragInternal(
                view,
                fromX,
                toX,
                fromY,
                toY,
                stepCount,
                dispatchIntervalMs,
                /* useWallClock= */ false,
                /* preventFling= */ false);
    }

    /**
     * Synchronously perform a start-to-end drag event on the specified view with deterministic
     * timing (events do not use system time).
     *
     * <p>Differs from the plain performDrag method by pausing before dispatching the up event to
     * avoid generating a fling gesture.
     *
     * @param view The view to dispatch events to.
     * @param fromX X coordinate of the initial touch, in screen coordinates.
     * @param toX X coordinate of the drag destination, in screen coordinates.
     * @param fromY X coordinate of the initial touch, in screen coordinates.
     * @param toY Y coordinate of the drag destination, in screen coordinates.
     * @param stepCount How many move steps to include in the drag.
     * @param duration The amount of time that will be simulated for the event stream in ms.
     */
    public static void performDragNoFling(
            View view,
            float fromX,
            float toX,
            float fromY,
            float toY,
            int stepCount,
            long duration) {
        int dispatchIntervalMs = (int) duration / stepCount;
        performDragInternal(
                view,
                fromX,
                toX,
                fromY,
                toY,
                stepCount,
                dispatchIntervalMs,
                /* useWallClock= */ false,
                /* preventFling= */ true);
    }

    /**
     * Synchronously perform a start-to-end drag event on the specified view, using real wall clock
     * time when dispatching events.
     *
     * <p>This method will actually wait (i.e. sleep) between event dispatches to simulate a real
     * touch stream. This will make a test slow so use only when needed. This can be useful when
     * events are sent to the renderer - doing so with deterministic, simulated time means many
     * events may be coalesced, unpredictably, into a single event.
     *
     * @param view The view to dispatch events to.
     * @param fromX X coordinate of the initial touch, in screen coordinates.
     * @param toX X coordinate of the drag destination, in screen coordinates.
     * @param fromY X coordinate of the initial touch, in screen coordinates.
     * @param toY Y coordinate of the drag destination, in screen coordinates.
     * @param duration The amount of time over which move touches will be dispatched.
     * @param preventFling If true, pause before lifting finger to prevent a fling from being
     *     synthesized.
     */
    public static void performWallClockDrag(
            View view,
            float fromX,
            float toX,
            float fromY,
            float toY,
            long duration,
            int dispatchIntervalMs,
            boolean preventFling) {
        int stepCount = (int) duration / dispatchIntervalMs;
        performDragInternal(
                view,
                fromX,
                toX,
                fromY,
                toY,
                stepCount,
                dispatchIntervalMs,
                /* useWallClock= */ true,
                /* preventFling= */ preventFling);
    }

    /**
     * Synchronously perform a start-to-end drag event on the specified view, using real wall clock
     * time when dispatching events.
     *
     * <p>This method will actually wait (i.e. sleep) between event dispatches to simulate a real
     * touch stream. This will make a test slow so use only when needed. This can be useful when
     * events are sent to the renderer - doing so with deterministic, simulated time means many
     * events may be coalesced, unpredictably, into a single event.
     *
     * @param activity The main activity to dispatch events to.
     * @param fromX X coordinate of the initial touch, in screen coordinates.
     * @param toX X coordinate of the drag destination, in screen coordinates.
     * @param fromY X coordinate of the initial touch, in screen coordinates.
     * @param toY Y coordinate of the drag destination, in screen coordinates.
     * @param duration The amount of time over which move touches will be dispatched.
     * @param preventFling If true, pause before lifting finger to prevent a fling from being
     *     synthesized.
     */
    public static void performWallClockDrag(
            Activity activity,
            float fromX,
            float toX,
            float fromY,
            float toY,
            long duration,
            int dispatchIntervalMs,
            boolean preventFling) {
        performWallClockDrag(
                getRootViewForActivity(activity),
                fromX,
                toX,
                fromY,
                toY,
                duration,
                dispatchIntervalMs,
                preventFling);
    }

    private static void performDragInternal(
            View view,
            float fromX,
            float toX,
            float fromY,
            float toY,
            int stepCount,
            int dispatchIntervalMs,
            boolean useWallClock,
            boolean preventFling) {
        // Use the current time as the base to add to.
        final long downTime = SystemClock.uptimeMillis();
        float[] windowXY = screenToWindowCoordinates(view, fromX, fromY);

        // Start by sending the down event.
        dispatchTouchEvent(
                view,
                MotionEvent.obtain(
                        downTime, downTime, MotionEvent.ACTION_DOWN, windowXY[0], windowXY[1], 0));

        float x = fromX;
        float y = fromY;
        float yStep = (toY - fromY) / stepCount;
        float xStep = (toX - fromX) / stepCount;
        long eventTime = downTime;

        // Follow with a stream of motion events to simulate the drag.
        for (int i = 0; i < stepCount; ++i) {
            y += yStep;
            x += xStep;
            if (useWallClock) {
                SystemClock.sleep(dispatchIntervalMs);
                eventTime = SystemClock.uptimeMillis();
            } else {
                eventTime += dispatchIntervalMs;
            }
            windowXY = screenToWindowCoordinates(view, x, y);
            dispatchTouchEvent(
                    view,
                    MotionEvent.obtain(
                            downTime,
                            eventTime,
                            MotionEvent.ACTION_MOVE,
                            windowXY[0],
                            windowXY[1],
                            0));
        }

        if (preventFling) {
            eventTime += POINTER_UP_STOPPED_TIME_MS;
            SystemClock.sleep(POINTER_UP_STOPPED_TIME_MS);
        }

        // Finally send the up event.
        windowXY = screenToWindowCoordinates(view, toX, toY);
        dispatchTouchEvent(
                view,
                MotionEvent.obtain(
                        downTime, eventTime, MotionEvent.ACTION_UP, windowXY[0], windowXY[1], 0));
    }

    /**
     * Synchronously perform a start-to-end drag event on the specified view with deterministic
     * timing (events do not use system time).
     *
     * @param activity The main activity to dispatch events to.
     * @param fromX X coordinate of the initial touch, in screen coordinates.
     * @param toX X coordinate of the drag destination, in screen coordinates.
     * @param fromY X coordinate of the initial touch, in screen coordinates.
     * @param toY Y coordinate of the drag destination, in screen coordinates.
     * @param stepCount How many move steps to include in the drag.
     * @param duration The amount of time that will be simulated for the event stream in ms.
     */
    public static void performDrag(
            Activity activity,
            float fromX,
            float toX,
            float fromY,
            float toY,
            int stepCount,
            long duration) {
        performDrag(getRootViewForActivity(activity), fromX, toX, fromY, toY, stepCount, duration);
    }

    /**
     * Synchronously perform a start-to-end drag event on the specified view with deterministic
     * timing (events do not use system time).
     *
     * <p>Differs from the plain performDrag method by pausing before dispatching the up event to
     * avoid generating a fling gesture.
     *
     * @param activity The main activity to dispatch events to.
     * @param fromX X coordinate of the initial touch, in screen coordinates.
     * @param toX X coordinate of the drag destination, in screen coordinates.
     * @param fromY X coordinate of the initial touch, in screen coordinates.
     * @param toY Y coordinate of the drag destination, in screen coordinates.
     * @param stepCount How many move steps to include in the drag.
     * @param duration The amount of time that will be simulated for the event stream in ms.
     */
    public static void performDragNoFling(
            Activity activity,
            float fromX,
            float toX,
            float fromY,
            float toY,
            int stepCount,
            long duration) {
        performDragNoFling(
                getRootViewForActivity(activity), fromX, toX, fromY, toY, stepCount, duration);
    }

    /**
     * Starts (synchronously) a drag motion. Normally followed by dragTo() and dragEnd().
     *
     * @activity activity The activity where the touch action is being performed.
     * @param x X coordinate, in screen coordinates.
     * @param y Y coordinate, in screen coordinates.
     * @param downTime When the drag was started, in millis since the epoch.
     */
    @Deprecated
    public static void dragStart(Activity activity, float x, float y, long downTime) {
        View view = getRootViewForActivity(activity);
        float[] windowXY = screenToWindowCoordinates(view, x, y);
        float windowX = windowXY[0];
        float windowY = windowXY[1];
        MotionEvent event =
                MotionEvent.obtain(
                        downTime, downTime, MotionEvent.ACTION_DOWN, windowX, windowY, 0);
        dispatchTouchEvent(view, event);
    }

    /**
     * Drags / moves (synchronously) to the specified coordinates. Normally preceded by dragStart()
     * and followed by dragEnd()
     *
     * @activity activity The activity where the touch action is being performed.
     * @param fromX X coordinate of the initial touch, in screen coordinates.
     * @param toX X coordinate of the drag destination, in screen coordinates.
     * @param fromY X coordinate of the initial touch, in screen coordinates.
     * @param toY Y coordinate of the drag destination, in screen coordinates.
     * @param stepCount How many move steps to include in the drag.
     * @param downTime When the drag was started, in millis since the epoch.
     */
    @Deprecated
    public static void dragTo(
            Activity activity,
            float fromX,
            float toX,
            float fromY,
            float toY,
            int stepCount,
            long downTime) {
        View view = getRootViewForActivity(activity);
        float x = fromX;
        float y = fromY;
        float yStep = (toY - fromY) / stepCount;
        float xStep = (toX - fromX) / stepCount;
        for (int i = 0; i < stepCount; ++i) {
            y += yStep;
            x += xStep;
            long eventTime = SystemClock.uptimeMillis();
            float[] windowXY = screenToWindowCoordinates(view, x, y);
            float windowX = windowXY[0];
            float windowY = windowXY[1];
            MotionEvent event =
                    MotionEvent.obtain(
                            downTime, eventTime, MotionEvent.ACTION_MOVE, windowX, windowY, 0);
            dispatchTouchEvent(view, event);
        }
    }

    /**
     * Finishes (synchronously) a drag / move at the specified coordinate.
     * Normally preceded by dragStart() and dragTo().
     *
     * @activity activity The activity where the touch action is being performed.
     * @param x X coordinate, in screen coordinates.
     * @param y Y coordinate, in screen coordinates.
     * @param downTime When the drag was started, in millis since the epoch.
     */
    @Deprecated
    public static void dragEnd(Activity activity, float x, float y, long downTime) {
        View view = getRootViewForActivity(activity);
        float[] windowXY = screenToWindowCoordinates(view, x, y);
        float windowX = windowXY[0];
        float windowY = windowXY[1];
        long eventTime = SystemClock.uptimeMillis();
        MotionEvent event =
                MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, windowX, windowY, 0);
        dispatchTouchEvent(view, event);
    }

    /**
     * Sends (synchronously) a single click to the View at the specified view-relative coordinates.
     *
     * @param v The view to be clicked.
     * @param x X coordinate, relative to v.
     * @param y Y coordinate, relative to v.
     */
    public static boolean singleClickView(View v, int x, int y) {
        return singleClickViewThroughTarget(v, v.getRootView(), x, y);
    }

    /**
     * Sends a click event to the specified view, not going through the root view.
     *
     * This is mostly useful for tests in VR, where inputs to the root view are (in a sense)
     * consumed by the platform, but the java test still wants to interact with, say, WebContents.
     *
     * @param view The view to be clicked.
     * @param target The view to inject the input into.
     * @param x X coordinate, relative to view.
     * @param y Y coordinate, relative to view.
     */
    /* package */ static boolean singleClickViewThroughTarget(
            View view, View target, int x, int y) {
        int[] windowXY = viewToWindowCoordinates(view, x, y);
        int windowX = windowXY[0];
        int windowY = windowXY[1];
        return singleClickInternal(target, windowX, windowY);
    }

    /** Sends (synchronously) a single click to the center of the View. */
    public static void singleClickView(View v) {
        int width = v.getWidth();
        int height = v.getHeight();
        if (width <= 0 || height <= 0) {
            throw new IllegalStateException(
                    String.format(
                            "Cannot click view with dimensions w%d x h%d, view=%s",
                            width, height, v));
        }
        singleClickView(v, width / 2, height / 2);
    }

    private static boolean singleClickInternal(View view, float windowX, float windowY) {
        long downTime = SystemClock.uptimeMillis();
        long eventTime = SystemClock.uptimeMillis();

        MotionEvent event =
                MotionEvent.obtain(
                        downTime, eventTime, MotionEvent.ACTION_DOWN, windowX, windowY, 0);
        if (!dispatchTouchEvent(view, event)) return false;

        eventTime = SystemClock.uptimeMillis();
        event = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, windowX, windowY, 0);
        return dispatchTouchEvent(view, event);
    }

    /**
     * Sends (synchronously) a long press to the View at the specified coordinates.
     *
     * @param v The view to receive the long press.
     * @param x X coordinate, relative to v.
     * @param y Y coordinate, relative to v.
     */
    public static void longPressView(View v, int x, int y) {
        int[] windowXY = viewToWindowCoordinates(v, x, y);
        int windowX = windowXY[0];
        int windowY = windowXY[1];
        longPressInternal(v.getRootView(), windowX, windowY);
    }

    /**
     * Sends (synchronously) a long press to the center of the View.
     * <p>Note that view should be located in the current position for a foreseeable
     * amount because this involves sleep to simulate touch to long press transition.
     *
     * @param v The view to receive the long press.
     */
    public static void longPressView(View v) {
        longPressView(v, v.getWidth() / 2, v.getHeight() / 2);
    }

    /**
     * Sends (synchronously) a long press to the View at the specified coordinates, without release.
     *
     * @param v The view to receive the long press.
     * @param x X coordinate, relative to v.
     * @param y Y coordinate, relative to v.
     * @param downTime When the drag was started, in millis since the epoch.
     */
    public static void longPressViewWithoutUp(View v, int x, int y, long downTime) {
        int[] windowXY = viewToWindowCoordinates(v, x, y);
        int windowX = windowXY[0];
        int windowY = windowXY[1];
        longPressWithoutUpInternal(v.getRootView(), windowX, windowY, downTime);
    }

    private static void longPressWithoutUpInternal(
            View view, float windowX, float windowY, long downTime) {
        long eventTime = SystemClock.uptimeMillis();

        MotionEvent event =
                MotionEvent.obtain(
                        downTime, eventTime, MotionEvent.ACTION_DOWN, windowX, windowY, 0);
        dispatchTouchEvent(view, event);

        int longPressTimeout = ViewConfiguration.getLongPressTimeout();

        // Long press is flaky with just longPressTimeout. Doubling the time to be safe.
        SystemClock.sleep(longPressTimeout * 2);
    }

    private static void longPressInternal(View view, float windowX, float windowY) {
        long downTime = SystemClock.uptimeMillis();
        longPressWithoutUpInternal(view, windowX, windowY, downTime);

        long eventTime = SystemClock.uptimeMillis();
        MotionEvent event =
                MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, windowX, windowY, 0);
        dispatchTouchEvent(view, event);
    }

    private static View getRootViewForActivity(final Activity activity) {
        View view =
                ThreadUtils.runOnUiThreadBlocking(
                        () -> activity.findViewById(android.R.id.content).getRootView());
        assert view != null : "Failed to find root view for activity";
        return view;
    }

    /**
     * Sends a MotionEvent to the specified view.
     *
     * @param view The view that should receive the event.
     * @param event The view to be dispatched.
     */
    public static boolean dispatchTouchEvent(final View view, final MotionEvent event) {
        return ThreadUtils.runOnUiThreadBlocking(() -> view.dispatchTouchEvent(event));
    }

    /**
     * Converts view-relative coordinates into window coordinates.
     * @param v The view the coordinates are relative to.
     * @param x X coordinate, relative to the view.
     * @param y Y coordinate, relative to the view.
     * @return The coordinates relative to the window as a 2-element array.
     */
    private static int[] viewToWindowCoordinates(View v, int x, int y) {
        int[] windowXY = new int[2];
        v.getLocationInWindow(windowXY);
        windowXY[0] += x;
        windowXY[1] += y;
        return windowXY;
    }

    /**
     * Converts screen coordinates into window coordinates.
     * @param view Any view in the window.
     * @param screenX X coordinate relative to the screen.
     * @param screenY Y coordinate relative to the screen.
     * @return The coordinates relative to the window as a 2-element array.
     */
    private static float[] screenToWindowCoordinates(View view, float screenX, float screenY) {
        View root = view.getRootView();
        int[] rootScreenXY = new int[2];
        int[] rootWindowXY = new int[2];
        root.getLocationOnScreen(rootScreenXY);
        root.getLocationInWindow(rootWindowXY);
        float windowX = screenX - rootScreenXY[0] + rootWindowXY[0];
        float windowY = screenY - rootScreenXY[1] + rootWindowXY[1];
        return new float[] {windowX, windowY};
    }
}