Add a custom rightClick Espresso ViewAction

Espresso's default click() doesn't emulate a right click well enough for
us. In DocsUI code (e.g. roots list) we listen for Generic Motion events
which only trigger on ACTION_BUTTON_PRESS and since Espresso doesn't
send these events, our tests cannot perform a right click with out a
custom ViewAction. See b/436682861 for more details.

Bug: 436118913
Flag: EXEMPT updating tests
Test: updating tests
Change-Id: Id8effa26851769bba8a591fb188f61fa346c8b39
diff --git a/tests/common/com/android/documentsui/actions/EspressoViewActionsFork.java b/tests/common/com/android/documentsui/actions/EspressoViewActionsFork.java
new file mode 100644
index 0000000..71f5b21
--- /dev/null
+++ b/tests/common/com/android/documentsui/actions/EspressoViewActionsFork.java
@@ -0,0 +1,428 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.actions;
+
+import static androidx.test.internal.util.Checks.checkNotNull;
+
+import android.os.SystemClock;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.ViewConfiguration;
+
+import androidx.test.espresso.InjectEventSecurityException;
+import androidx.test.espresso.PerformException;
+import androidx.test.espresso.UiController;
+import androidx.test.espresso.action.MotionEvents;
+import androidx.test.espresso.action.Tapper;
+
+import java.util.Locale;
+
+/**
+ * A fork of some parts of Espresso which are necessary to send a better emulation of right click.
+ *
+ * <p>Espresso click() only sends ACTION_DOWN and ACTION_UP events. The absence of
+ * ACTION_BUTTON_PRESS and ACTION_BUTTON_RELEASE events causes right click implemented using
+ * GenericMotion listener to fail (e.g. right click on root list).
+ *
+ * <p>See b/436682861 for more information.
+ */
+public class EspressoViewActionsFork {
+    private static final String TAG = "EspressoViewActionsFork";
+    private static final int MAX_CLICK_ATTEMPTS = 3;
+    // android.view.MotionEvent uses 0 to represent no buttons in actionButton and buttonState.
+    private static final int NO_BUTTONS = 0;
+
+    /** Copied from espresso/core/java/androidx/test/espresso/action/Tap.java */
+    public static class SingleClick implements Tapper {
+        @Override
+        public Tapper.Status sendTap(
+                UiController uiController, float[] coordinates, float[] precision) {
+            return sendTap(uiController, coordinates, precision, 0, NO_BUTTONS);
+        }
+
+        @Override
+        public Tapper.Status sendTap(
+                UiController uiController,
+                float[] coordinates,
+                float[] precision,
+                int inputDevice,
+                int buttonState) {
+            Tapper.Status status =
+                    sendSingleClick(uiController, coordinates, precision, inputDevice, buttonState);
+            if (status == Tapper.Status.SUCCESS) {
+                // Wait until the touch event was processed by the main thread.
+                long singlePressTimeout = (long) (ViewConfiguration.getTapTimeout() * 1.5f);
+                uiController.loopMainThreadForAtLeast(singlePressTimeout);
+            }
+            return status;
+        }
+    }
+
+    /**
+     * Send a single click by emulating the event sequence that a real mouse click emits:
+     * ACTION_DOWN -> ACTION_BUTTON_PRESS -> ACTION_BUTTON_RELEASE -> ACTION_UP.
+     *
+     * <p>See b/436682861 for more information.
+     *
+     * <p>Copied from espresso/core/java/androidx/test/espresso/action/Tap.java
+     */
+    private static Tapper.Status sendSingleClick(
+            UiController uiController,
+            float[] coordinates,
+            float[] precision,
+            int inputDevice,
+            int buttonState) {
+        checkNotNull(uiController);
+        checkNotNull(coordinates);
+        checkNotNull(precision);
+        /*
+         * There isn't an official doc for the sequence of MotionEvents but b/436682861#comment2
+         * gives us a starting point, e.g. for a right click it should look like:
+         *
+         * | Action           | Action button | Button state |
+         * |------------------|---------------|--------------|
+         * | `DOWN`           | -             | `SECONDARY`  |
+         * | `BUTTON_PRESS`   | `SECONDARY`   | `SECONDARY`  |
+         * | `BUTTON_RELEASE` | `SECONDARY`   | -            |
+         * | `UP`             | -             | -            |
+         *
+         * I wanted include this in the javadoc but the formatter kept mangling the table :/
+         */
+        DownResultHolder touchResult =
+                sendDown(
+                        uiController,
+                        coordinates,
+                        precision,
+                        MotionEvent.ACTION_DOWN,
+                        inputDevice,
+                        buttonState,
+                        NO_BUTTONS); // ACTION_DOWN shouldn't have actionButton
+        DownResultHolder buttonResult =
+                sendDown(
+                        uiController,
+                        coordinates,
+                        precision,
+                        MotionEvent.ACTION_BUTTON_PRESS,
+                        inputDevice,
+                        buttonState,
+                        buttonState); // actionButton same as buttonState
+        try {
+            // The up events use NO_BUTTONS for buttonState and copy actionButton from downEvent.
+            boolean touchFailed =
+                    !sendUp(
+                            uiController,
+                            buttonResult.mDown,
+                            MotionEvent.ACTION_BUTTON_RELEASE);
+            boolean buttonFailed =
+                    !sendUp(
+                            uiController,
+                            touchResult.mDown,
+                            MotionEvent.ACTION_UP);
+            if (touchFailed || buttonFailed) {
+                Log.d(
+                        TAG,
+                        "Injection of up event as part of the click failed. Send cancel "
+                                + "event.");
+                // ACTION_BUTTON_* doesn't have a cancel action type, so we just send a cancel event
+                // for the touch event.
+                MotionEvents.sendCancel(uiController, touchResult.mDown);
+                return Tapper.Status.FAILURE;
+            }
+        } finally {
+            touchResult.mDown.recycle();
+            buttonResult.mDown.recycle();
+        }
+        return touchResult.mLongPress ? Tapper.Status.WARNING : Tapper.Status.SUCCESS;
+    }
+
+    /**
+     * Copied from espresso/core/java/androidx/test/espresso/action/MotionEvents.java with
+     * additional arguments for action and actionButton so we can send ACTION_DOWN and
+     * ACTION_BUTTON_PRESS events.
+     */
+    private static DownResultHolder sendDown(
+            UiController uiController,
+            float[] coordinates,
+            float[] precision,
+            int action,
+            int inputDevice,
+            int buttonState,
+            int actionButton) {
+        checkNotNull(uiController);
+        checkNotNull(coordinates);
+        checkNotNull(precision);
+
+        for (int retry = 0; retry < MAX_CLICK_ATTEMPTS; retry++) {
+            MotionEvent motionEvent;
+            try {
+                motionEvent =
+                        obtainDownEvent(
+                                coordinates,
+                                precision,
+                                action,
+                                inputDevice,
+                                buttonState,
+                                actionButton);
+                // The down event should be considered a tap if it is long enough to be detected
+                // but short enough not to be a long-press. Assume that TapTimeout is set at
+                // least twice the detection time for a tap (no need to sleep for the whole
+                // TapTimeout since we aren't concerned about scrolling here).
+                long downTime = motionEvent.getDownTime();
+                long isTapAt = downTime + (ViewConfiguration.getTapTimeout() / 2);
+
+                boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent);
+
+                while (true) {
+                    long delayToBeTap = isTapAt - SystemClock.uptimeMillis();
+                    if (delayToBeTap <= 10) {
+                        break;
+                    }
+                    // Sleep only a fraction of the time, since there may be other events in the
+                    // UI queue that could cause us to start sleeping late, and then oversleep.
+                    uiController.loopMainThreadForAtLeast(delayToBeTap / 4);
+                }
+
+                boolean longPress = false;
+                if (SystemClock.uptimeMillis()
+                        > (downTime + ViewConfiguration.getLongPressTimeout())) {
+                    longPress = true;
+                    Log.w(TAG, "Overslept and turned a tap into a long press");
+                }
+
+                if (!injectEventSucceeded) {
+                    motionEvent.recycle();
+                    continue;
+                }
+
+                return new DownResultHolder(motionEvent, longPress);
+            } catch (InjectEventSecurityException e) {
+                throw new PerformException.Builder()
+                        .withActionDescription("Send down motion event")
+                        .withViewDescription("unknown") // likely to be replaced by FailureHandler
+                        .withCause(e)
+                        .build();
+            }
+        }
+        throw new PerformException.Builder()
+                .withActionDescription(
+                        String.format(Locale.ROOT, "click (after %s attempts)", MAX_CLICK_ATTEMPTS))
+                .withViewDescription("unknown") // likely to be replaced by FailureHandler
+                .build();
+    }
+
+    /**
+     * Copied from espresso/core/java/androidx/test/espresso/action/MotionEvents.java with
+     * additional arguments for action and actionButton so we can send ACTION_DOWN and
+     * ACTION_BUTTON_PRESS events.
+     */
+    private static MotionEvent obtainDownEvent(
+            float[] coordinates,
+            float[] precision,
+            int action,
+            int inputDevice,
+            int buttonState,
+            int actionButton) {
+        checkNotNull(coordinates);
+        checkNotNull(precision);
+
+        long downTime = SystemClock.uptimeMillis();
+        return obtain(
+                downTime,
+                downTime,
+                action,
+                coordinates,
+                precision[0],
+                precision[1],
+                inputDevice,
+                buttonState,
+                actionButton);
+    }
+
+    /**
+     * Copied from espresso/core/java/androidx/test/espresso/action/MotionEvents.java with an new
+     * argument for action to support ACTION_BUTTON_RELEASE and ACTION_UP events.
+     */
+    private static boolean sendUp(UiController uiController, MotionEvent downEvent, int action) {
+        checkNotNull(uiController);
+        checkNotNull(downEvent);
+
+        MotionEvent motionEvent = null;
+        try {
+            motionEvent = obtainUpEvent(downEvent, action);
+            boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent);
+
+            if (!injectEventSucceeded) {
+                Log.e(
+                        TAG,
+                        String.format(
+                                Locale.ROOT,
+                                "Injection of up event failed (corresponding down event: %s)",
+                                downEvent));
+                return false;
+            }
+        } catch (InjectEventSecurityException e) {
+            throw new PerformException.Builder()
+                    .withActionDescription(
+                            String.format(
+                                    Locale.ROOT,
+                                    "inject up event (corresponding down event: %s)",
+                                    downEvent))
+                    .withViewDescription("unknown") // likely to be replaced by FailureHandler
+                    .withCause(e)
+                    .build();
+        } finally {
+            if (null != motionEvent) {
+                motionEvent.recycle();
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Copied from espresso/core/java/androidx/test/espresso/action/MotionEvents.java with a new
+     * argument for action to support ACTION_BUTTON_RELEASE and ACTION_UP events.
+     */
+    public static MotionEvent obtainUpEvent(MotionEvent downEvent, int action) {
+        checkNotNull(downEvent);
+        // The up event is mostly the same as the down event except for the different action and
+        // buttonState, see the table in sendSingleClick.
+        return obtain(
+                downEvent.getDownTime(),
+                SystemClock.uptimeMillis(),
+                action,
+                new float[] {downEvent.getX(), downEvent.getY()},
+                downEvent.getXPrecision(),
+                downEvent.getYPrecision(),
+                downEvent.getSource(),
+                downEvent.getToolType(0),
+                NO_BUTTONS, // buttonState is always empty for ACTION_UP and ACTION_BUTTON_RELEASE
+                downEvent.getActionButton());
+    }
+
+    /**
+     * Copied from espresso/core/java/androidx/test/espresso/action/MotionEvents.java with a new
+     * argument for actionButton to properly support ACTION_BUTTON_{PRESS,RELEASE}.
+     */
+    private static MotionEvent obtain(
+            long downTime,
+            long eventTime,
+            int action,
+            float[] coordinates,
+            float xPrecision,
+            float yPrecision,
+            int source,
+            int buttonState,
+            int actionButton) {
+        return obtain(
+                downTime,
+                eventTime,
+                action,
+                coordinates,
+                xPrecision,
+                yPrecision,
+                source,
+                mapInputDeviceToToolType(source),
+                buttonState,
+                actionButton);
+    }
+
+    /**
+     * Copied from espresso/core/java/androidx/test/espresso/action/MotionEvents.java with a new
+     * argument for actionButton to properly support ACTION_BUTTON_{PRESS,RELEASE}.
+     */
+    private static MotionEvent obtain(
+            long downTime,
+            long eventTime,
+            int action,
+            float[] coordinates,
+            float xPrecision,
+            float yPrecision,
+            int source,
+            int toolType,
+            int buttonState,
+            int actionButton) {
+        final MotionEvent.PointerCoords[] pointerCoords = {new MotionEvent.PointerCoords()};
+        final MotionEvent.PointerProperties[] pointerProperties = getPointerProperties(toolType);
+        pointerCoords[0].clear();
+        pointerCoords[0].x = coordinates[0];
+        pointerCoords[0].y = coordinates[1];
+        pointerCoords[0].pressure = 0;
+        pointerCoords[0].size = 1;
+
+        MotionEvent e =
+                MotionEvent.obtain(
+                        downTime,
+                        eventTime,
+                        action,
+                        1, // pointerCount
+                        pointerProperties,
+                        pointerCoords,
+                        0, // metaState
+                        buttonState,
+                        xPrecision,
+                        yPrecision,
+                        0, // deviceId
+                        0, // edgeFlags
+                        source,
+                        0); // flags
+        if (actionButton != NO_BUTTONS) {
+            e.setActionButton(actionButton);
+        }
+        return e;
+    }
+
+    /** Copied from espresso/core/java/androidx/test/espresso/action/MotionEvents.java. */
+    private static MotionEvent.PointerProperties[] getPointerProperties(int toolType) {
+        MotionEvent.PointerProperties[] pointerProperties = {new MotionEvent.PointerProperties()};
+        pointerProperties[0].clear();
+        pointerProperties[0].id = 0;
+        pointerProperties[0].toolType = toolType;
+        return pointerProperties;
+    }
+
+    /** Copied from espresso/core/java/androidx/test/espresso/action/MotionEvents.java. */
+    private static int mapInputDeviceToToolType(int inputDevice) {
+        int toolType;
+        switch (inputDevice) {
+            case InputDevice.SOURCE_MOUSE:
+                toolType = MotionEvent.TOOL_TYPE_MOUSE;
+                break;
+            case InputDevice.SOURCE_STYLUS:
+                toolType = MotionEvent.TOOL_TYPE_STYLUS;
+                break;
+            case InputDevice.SOURCE_TOUCHSCREEN:
+                toolType = MotionEvent.TOOL_TYPE_FINGER;
+                break;
+            default:
+                toolType = MotionEvent.TOOL_TYPE_UNKNOWN;
+                break;
+        }
+        return toolType;
+    }
+
+    /** Copied from espresso/core/java/androidx/test/espresso/action/MotionEvents.java. */
+    private static class DownResultHolder {
+        final MotionEvent mDown;
+        final boolean mLongPress;
+
+        DownResultHolder(MotionEvent down, boolean longPress) {
+            this.mDown = down;
+            this.mLongPress = longPress;
+        }
+    }
+}
diff --git a/tests/common/com/android/documentsui/actions/RightClickAction.kt b/tests/common/com/android/documentsui/actions/RightClickAction.kt
new file mode 100644
index 0000000..5a1deaa
--- /dev/null
+++ b/tests/common/com/android/documentsui/actions/RightClickAction.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.actions
+
+import android.view.InputDevice
+import android.view.MotionEvent
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.action.GeneralClickAction
+import androidx.test.espresso.action.GeneralLocation
+import androidx.test.espresso.action.Press
+import androidx.test.espresso.action.ViewActions
+import com.android.documentsui.actions.EspressoViewActionsFork.SingleClick
+
+/**
+ * A ViewAction using a custom tap action to perform a right click with button press events.
+ */
+fun rightClick(): ViewAction {
+    return ViewActions.actionWithAssertions(
+        GeneralClickAction(
+            SingleClick(),
+            GeneralLocation.VISIBLE_CENTER,
+            Press.PINPOINT,
+            InputDevice.SOURCE_MOUSE,
+            MotionEvent.BUTTON_SECONDARY
+        )
+    )
+}