Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 724215e2 authored by Yeabkal Wubshit's avatar Yeabkal Wubshit
Browse files

Add support for adb scroll event injection

This enables simulating ACTION_SCROLL motion events. It supports sources
both of SOURCE_CLASS_POINTER and those that are not pointer-based.

Test: adb shell input scroll --axis SCROLL,-2 HSCROLL,3.2
Test: adb shell input scroll --axis BAD_AXIS,3 (fails, as expected)
Test: atest InputShellCommandTest
Bug: 298107774
Change-Id: If59cda07749344657155f6553fe0f15ef151f2e9
parent 513207b1
Loading
Loading
Loading
Loading
+129 −8
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ package com.android.server.input;

import static android.view.Display.DEFAULT_DISPLAY;
import static android.view.Display.INVALID_DISPLAY;
import static android.view.InputDevice.SOURCE_CLASS_POINTER;
import static android.view.KeyEvent.KEYCODE_ALT_LEFT;
import static android.view.KeyEvent.KEYCODE_ALT_RIGHT;
import static android.view.KeyEvent.KEYCODE_CTRL_LEFT;
@@ -38,6 +39,11 @@ import static android.view.KeyEvent.META_META_RIGHT_ON;
import static android.view.KeyEvent.META_SHIFT_LEFT_ON;
import static android.view.KeyEvent.META_SHIFT_ON;
import static android.view.KeyEvent.META_SHIFT_RIGHT_ON;
import static android.view.MotionEvent.AXIS_HSCROLL;
import static android.view.MotionEvent.AXIS_SCROLL;
import static android.view.MotionEvent.AXIS_VSCROLL;
import static android.view.MotionEvent.AXIS_X;
import static android.view.MotionEvent.AXIS_Y;

import static java.util.Collections.unmodifiableMap;

@@ -47,14 +53,21 @@ import android.os.ShellCommand;
import android.os.SystemClock;
import android.util.ArrayMap;
import android.util.IntArray;
import android.util.Pair;
import android.view.InputDevice;
import android.view.InputEvent;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.ViewConfiguration;

import com.android.internal.annotations.VisibleForTesting;

import java.io.PrintWriter;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;

/**
 * Command that sends input events to the device.
@@ -107,15 +120,31 @@ public class InputShellCommand extends ShellCommand {
        map.put("touchpad", InputDevice.SOURCE_TOUCHPAD);
        map.put("touchnavigation", InputDevice.SOURCE_TOUCH_NAVIGATION);
        map.put("joystick", InputDevice.SOURCE_JOYSTICK);
        map.put("rotaryencoder", InputDevice.SOURCE_ROTARY_ENCODER);

        SOURCES = unmodifiableMap(map);
    }

    public InputShellCommand() {
        this(InputShellCommand::injectInputEvent);
    }

    @VisibleForTesting
    InputShellCommand(BiConsumer<InputEvent, Integer> inputEventInjector) {
        mInputEventInjector = inputEventInjector;;
    }

    private static void injectInputEvent(InputEvent event, Integer injectMode) {
        InputManagerGlobal.getInstance().injectInputEvent(event, injectMode);
    }

    private final BiConsumer<InputEvent, Integer> mInputEventInjector;

    private void injectKeyEvent(KeyEvent event, boolean async) {
        int injectMode = async
                ? InputManager.INJECT_INPUT_EVENT_MODE_ASYNC
                : InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH;
        InputManagerGlobal.getInstance().injectInputEvent(event, injectMode);
        mInputEventInjector.accept(event, injectMode);
    }

    private int getInputDeviceId(int inputSource) {
@@ -161,19 +190,41 @@ public class InputShellCommand extends ShellCommand {
     */
    private void injectMotionEvent(int inputSource, int action, long downTime, long when,
            float x, float y, float pressure, int displayId) {
        final Map<Integer, Float> axisValues =
                Map.of(
                        MotionEvent.AXIS_X, x,
                        MotionEvent.AXIS_Y, y,
                        MotionEvent.AXIS_PRESSURE, pressure);
        injectMotionEvent(inputSource, action, downTime, when, axisValues, displayId);
    }

    /**
     * Builds a MotionEvent and injects it into the event stream.
     *
     * @param inputSource the InputDevice.SOURCE_* sending the input event
     * @param action the MotionEvent.ACTION_* for the event
     * @param downTime the value of the ACTION_DOWN event happened
     * @param when the value of SystemClock.uptimeMillis() at which the event happened
     * @param axisValues a map of an axis to the respective axis value
     * @param displayId the ID of the display associated to the event
     */
    private void injectMotionEvent(int inputSource, int action, long downTime, long when,
            Map<Integer, Float> axisValues, int displayId) {
        final int pointerCount = 1;
        MotionEvent.PointerProperties[] pointerProperties =
                new MotionEvent.PointerProperties[pointerCount];
        MotionEvent.PointerCoords[] pointerCoords = new MotionEvent.PointerCoords[pointerCount];
        for (int i = 0; i < pointerCount; i++) {
            pointerProperties[i] = new MotionEvent.PointerProperties();
            pointerProperties[i].id = i;
            pointerProperties[i].toolType = getToolType(inputSource);
        }
        MotionEvent.PointerCoords[] pointerCoords = new MotionEvent.PointerCoords[pointerCount];
        for (int i = 0; i < pointerCount; i++) {
            pointerCoords[i] = new MotionEvent.PointerCoords();
            pointerCoords[i].x = x;
            pointerCoords[i].y = y;
            pointerCoords[i].pressure = pressure;
            pointerCoords[i].size = DEFAULT_SIZE;
            for (var entry : axisValues.entrySet()) {
                pointerCoords[i].setAxisValue(entry.getKey(), entry.getValue());
            }
        }
        if (displayId == INVALID_DISPLAY
                && (inputSource & InputDevice.SOURCE_CLASS_POINTER) != 0) {
@@ -183,7 +234,7 @@ public class InputShellCommand extends ShellCommand {
                pointerProperties, pointerCoords, DEFAULT_META_STATE, DEFAULT_BUTTON_STATE,
                DEFAULT_PRECISION_X, DEFAULT_PRECISION_Y, getInputDeviceId(inputSource),
                DEFAULT_EDGE_FLAGS, inputSource, displayId, DEFAULT_FLAGS);
        InputManagerGlobal.getInstance().injectInputEvent(event,
        mInputEventInjector.accept(event,
                InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH);
    }

@@ -246,6 +297,8 @@ public class InputShellCommand extends ShellCommand {
                runPress(inputSource, displayId);
            } else if ("roll".equals(arg)) {
                runRoll(inputSource, displayId);
            }  else if ("scroll".equals(arg)) {
                runScroll(inputSource, displayId);
            } else if ("motionevent".equals(arg)) {
                runMotionEvent(inputSource, displayId);
            } else if ("keycombination".equals(arg)) {
@@ -268,13 +321,18 @@ public class InputShellCommand extends ShellCommand {
            for (String src : SOURCES.keySet()) {
                out.println("      " + src);
            }
            out.println("[axis_value] represents an option specifying the value of a given axis ");
            out.println("      The syntax is as follows: --axis <axis_name>,<axis_value>");
            out.println("            where <axis_name> is the name of the axis as defined in ");
            out.println("            MotionEvent without the AXIS_ prefix (e.g. SCROLL, X)");
            out.println("      Sample [axis_values] entry: `--axis Y,3`, `--axis SCROLL,-2`");
            out.println();
            out.printf("-d: specify the display ID.\n      (Default: %d for key event, "
                    + "%d for motion event if not specified.)",
                    INVALID_DISPLAY, DEFAULT_DISPLAY);
            out.println();
            out.println("The commands and default sources are:");
            out.println("      text <string> (Default: touchscreen)");
            out.println("      text <string> (Default: keyboard)");
            out.println("      keyevent [--longpress|--doubletap|--async"
                    + "|--delay <duration between keycodes in ms>]"
                    + " <key code number or name> ..."
@@ -287,6 +345,13 @@ public class InputShellCommand extends ShellCommand {
            out.println("      press (Default: trackball)");
            out.println("      roll <dx> <dy> (Default: trackball)");
            out.println("      motionevent <DOWN|UP|MOVE|CANCEL> <x> <y> (Default: touchscreen)");
            out.println("      scroll (Default: rotaryencoder). Has the following syntax:");
            out.println("            scroll <x> <y> [axis_value] (for pointer-based sources)");
            out.println("            scroll [axis_value] (for non-pointer-based sources)");
            out.println("            Axis options: SCROLL, HSCROLL, VSCROLL");
            out.println("            None or one or multiple axis value options can be specified.");
            out.println("            To specify multiple axes, use one axis option for per axis.");
            out.println("            Example: `scroll --axis VSCROLL,2 --axis SCROLL,-2.4`");
            out.println("      keycombination [-t duration(ms)] <key code 1> <key code 2> ..."
                    + " (Default: keyboard, the key order is important here.)");
        }
@@ -452,6 +517,62 @@ public class InputShellCommand extends ShellCommand {
                Float.parseFloat(getNextArgRequired()), displayId);
    }

    private void runScroll(int inputSource, int displayId) {
        inputSource = getSource(inputSource, InputDevice.SOURCE_ROTARY_ENCODER);
        final boolean isPointerEvent = (inputSource & SOURCE_CLASS_POINTER) == SOURCE_CLASS_POINTER;
        final Map<Integer, Float> axisValues = new HashMap<>();
        if (isPointerEvent) {
            axisValues.put(AXIS_X, Float.parseFloat(getNextArgRequired()));
            axisValues.put(AXIS_Y, Float.parseFloat(getNextArgRequired()));
        }
        final Set<Integer> supportedAxes = Set.of(AXIS_HSCROLL, AXIS_VSCROLL, AXIS_SCROLL);
        String nextOption;
        while ((nextOption = getNextOption()) != null) {
            switch (nextOption) {
                case "--axis":
                    final Pair<Integer, Float> axisAndValue = readAxisOptionValues(supportedAxes);
                    axisValues.put(axisAndValue.first, axisAndValue.second);
                    break;
                default:
                    throw new IllegalArgumentException("Unsupported option: " + nextOption);
            }
        }
        final long now = SystemClock.uptimeMillis();
        injectMotionEvent(inputSource, MotionEvent.ACTION_SCROLL, now /* downTime */,
                now /* when */, axisValues, displayId);
    }

    /**
     * Reads an axis value for the `--axis` command option.
     *
     * <p>The value for an `--axis` should be a single string containing the axis name without the
     * `AXIS_` prefix, and comma, and a float value representing the value for the respective axis.
     *
     * <p>Example: `--axis SCROLL,2.4` represents "a value of 2.4 for AXIS_SCROLL"
     *
     * <p>This method should be called after the `--axis` option has already been read.
     *
     * @param supportedAxes the set of allowed axes to be read. If an axis option is read where the
     *      axis is not present in this set, this method throws an {@link IllegalArgumentException}.
     * @return a Pair of the axis and its respective value.
     */
    private Pair<Integer, Float> readAxisOptionValues(Set<Integer> supportedAxes) {
        final String optionValue = getNextArgRequired();
        final String[] axisAndValue = optionValue.split(",");
        if (axisAndValue.length != 2) {
            throw new IllegalArgumentException("Invalid --axis option value: " + optionValue);
        }
        final String axisName = "AXIS_" + axisAndValue[0];
        final int axis = MotionEvent.axisFromString(axisName);
        if (axis == -1) {
            throw new IllegalArgumentException("Invalid axis name: " + axisName);
        }
        if (!supportedAxes.contains(axis)) {
            throw new IllegalArgumentException("Unsupported axis: " + axisName);
        }
        return Pair.create(axis, Float.parseFloat(axisAndValue[1]));
    }

    /**
     * Sends a simple zero-pressure move event.
     *
+167 −0
Original line number Diff line number Diff line
/*
 * Copyright 2023 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.server.input;

import static android.view.InputDevice.SOURCE_MOUSE;
import static android.view.InputDevice.SOURCE_ROTARY_ENCODER;
import static android.view.MotionEvent.ACTION_SCROLL;
import static android.view.MotionEvent.AXIS_HSCROLL;
import static android.view.MotionEvent.AXIS_SCROLL;
import static android.view.MotionEvent.AXIS_VSCROLL;
import static android.view.MotionEvent.AXIS_X;
import static android.view.MotionEvent.AXIS_Y;

import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;

import android.os.Binder;
import android.view.InputEvent;
import android.view.MotionEvent;

import androidx.test.runner.AndroidJUnit4;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.io.FileDescriptor;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;

/**
 * Build/Install/Run:
 * atest InputShellCommandTest
 */
@RunWith(AndroidJUnit4.class)
public class InputShellCommandTest {
    private TestInputEventInjector mInputEventInjector = new TestInputEventInjector();

    private InputShellCommand mCommand;

    @Before
    public void setUp() throws Exception {
        mCommand = new InputShellCommand(mInputEventInjector);
    }

    @Test
    public void testScroll_withPointerSource_noAxisOption() {
        runCommand("mouse scroll 2 -3");

        MotionEvent event = (MotionEvent) getSingleInjectedInputEvent();

        assertSourceAndAction(event, SOURCE_MOUSE, ACTION_SCROLL);
        assertAxisValues(event, Map.of(AXIS_X, 2f, AXIS_Y, -3f));
    }

    @Test
    public void testScroll_withPointerSource_withScrollAxisOptions() {
        runCommand("mouse scroll 1 -2 --axis HSCROLL,3 --axis VSCROLL,1.7 --axis SCROLL,-4");

        MotionEvent event = (MotionEvent) getSingleInjectedInputEvent();

        assertSourceAndAction(event, SOURCE_MOUSE, ACTION_SCROLL);
        assertAxisValues(
                event,
                Map.of(
                        AXIS_X, 1f,
                        AXIS_Y, -2f,
                        AXIS_HSCROLL, 3f,
                        AXIS_VSCROLL, 1.7f,
                        AXIS_SCROLL, -4f));
    }

    @Test
    public void testScroll_withNonPointerSource_noAxisOption() {
        runCommand("rotaryencoder scroll");

        MotionEvent event = (MotionEvent) getSingleInjectedInputEvent();

        assertSourceAndAction(event, SOURCE_ROTARY_ENCODER, ACTION_SCROLL);
    }

    @Test
    public void testScroll_withNonPointerSource_withScrollAxisOptions() {
        runCommand("rotaryencoder scroll --axis HSCROLL,3 --axis VSCROLL,1.7 --axis SCROLL,-4");

        MotionEvent event = (MotionEvent) getSingleInjectedInputEvent();

        assertSourceAndAction(event, SOURCE_ROTARY_ENCODER, ACTION_SCROLL);
        assertAxisValues(event, Map.of(AXIS_HSCROLL, 3f, AXIS_VSCROLL, 1.7f, AXIS_SCROLL, -4f));
    }

    @Test
    public void testDefaultScrollSource() {
        runCommand("scroll --axis SCROLL,-4");

        MotionEvent event = (MotionEvent) getSingleInjectedInputEvent();

        assertSourceAndAction(event, SOURCE_ROTARY_ENCODER, ACTION_SCROLL);
        assertAxisValues(event, Map.of(AXIS_SCROLL, -4f));
    }

    @Test
    public void testInvalidScrollCommands() {
        runCommand("scroll --sdaxis SCROLL,-4"); // invalid option
        runCommand("scroll --axis MYAXIS,-4"); // invalid axis
        runCommand("scroll --AXIS SCROLL,-4"); // invalid axis option key
        runCommand("scroll --axis SCROLL,-4abc"); // invalid axis value

        assertThat(mInputEventInjector.mInjectedEvents).isEmpty();
    }

    private InputEvent getSingleInjectedInputEvent() {
        assertThat(mInputEventInjector.mInjectedEvents).hasSize(1);
        return mInputEventInjector.mInjectedEvents.get(0);
    }

    private void assertSourceAndAction(MotionEvent event, int source, int action) {
        assertThat(event.getSource()).isEqualTo(source);
        assertThat(event.getAction()).isEqualTo(action);
    }

    private void assertAxisValues(MotionEvent event, Map<Integer, Float> expectedValues) {
        for (var entry : expectedValues.entrySet()) {
            final int axis = entry.getKey();
            final float expectedValue = entry.getValue();
            final float axisValue = event.getAxisValue(axis);
            assertWithMessage(
                    String.format(
                            "Expected [%f], found [%f] for axis %s",
                            expectedValue,
                            axisValue,
                            MotionEvent.axisToString(axis)))
                    .that(axisValue).isEqualTo(expectedValue);
        }
    }

    private void runCommand(String cmd) {
        mCommand.exec(
                new Binder(), new FileDescriptor(), new FileDescriptor(), new FileDescriptor(),
                cmd.split(" ") /* args */);
    }

    private static class TestInputEventInjector implements BiConsumer<InputEvent, Integer> {
        List<InputEvent> mInjectedEvents = new ArrayList<>();

        @Override
        public void accept(InputEvent event, Integer injectMode) {
            mInjectedEvents.add(event);
        }
    }
}