Merge "uinput: support evemu recordings" into main
diff --git a/cmds/uinput/src/com/android/commands/uinput/EvemuParser.java b/cmds/uinput/src/com/android/commands/uinput/EvemuParser.java
new file mode 100644
index 0000000..b89e2cd
--- /dev/null
+++ b/cmds/uinput/src/com/android/commands/uinput/EvemuParser.java
@@ -0,0 +1,380 @@
+/*
+ * 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.commands.uinput;
+
+import android.annotation.Nullable;
+import android.util.SparseArray;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+
+import src.com.android.commands.uinput.InputAbsInfo;
+
+/**
+ * Parser for the <a href="https://gitlab.freedesktop.org/libevdev/evemu">FreeDesktop evemu</a>
+ * event recording format.
+ */
+public class EvemuParser implements EventParser {
+ private static final String TAG = "UinputEvemuParser";
+
+ /**
+ * The device ID to use for all events. Since evemu files only support single-device
+ * recordings, this will always be the same.
+ */
+ private static final int DEVICE_ID = 1;
+ private static final int REGISTRATION_DELAY_MILLIS = 500;
+
+ private static class CommentAwareReader {
+ private final BufferedReader mReader;
+ private String mNextLine;
+
+ CommentAwareReader(BufferedReader in) throws IOException {
+ mReader = in;
+ mNextLine = findNextLine();
+ }
+
+ private @Nullable String findNextLine() throws IOException {
+ String line = "";
+ while (line != null && line.length() == 0) {
+ String unstrippedLine = mReader.readLine();
+ if (unstrippedLine == null) {
+ // End of file.
+ return null;
+ }
+ line = stripComments(unstrippedLine);
+ }
+ return line;
+ }
+
+ private static String stripComments(String line) {
+ int index = line.indexOf('#');
+ // 'N:' lines (which contain the name of the input device) do not support trailing
+ // comments, to support recording device names that contain #s.
+ if (index < 0 || line.startsWith("N: ")) {
+ return line;
+ } else {
+ return line.substring(0, index).strip();
+ }
+ }
+
+ /**
+ * Returns the next line of the file that isn't blank when stripped of comments, or
+ * {@code null} if the end of the file is reached. However, it does not advance to the
+ * next line of the file.
+ */
+ public @Nullable String peekLine() {
+ return mNextLine;
+ }
+
+ /** Moves to the next line of the file. */
+ public void advance() throws IOException {
+ mNextLine = findNextLine();
+ }
+
+ public boolean isAtEndOfFile() {
+ return mNextLine == null;
+ }
+ }
+
+ private final CommentAwareReader mReader;
+ /**
+ * The timestamp of the last event returned, of the head of {@link #mQueuedEvents} if there is
+ * one, or -1 if no events have been returned yet.
+ */
+ private long mLastEventTimeMicros = -1;
+ private final Queue<Event> mQueuedEvents = new ArrayDeque<>(2);
+
+ public EvemuParser(Reader in) throws IOException {
+ mReader = new CommentAwareReader(new BufferedReader(in));
+ mQueuedEvents.add(parseRegistrationEvent());
+
+ // The kernel takes a little time to set up an evdev device after the initial
+ // registration. Any events that we try to inject during this period would be silently
+ // dropped, so we delay for a short period after registration and before injecting any
+ // events.
+ final Event.Builder delayEb = new Event.Builder();
+ delayEb.setId(DEVICE_ID);
+ delayEb.setCommand(Event.Command.DELAY);
+ delayEb.setDurationMillis(REGISTRATION_DELAY_MILLIS);
+ mQueuedEvents.add(delayEb.build());
+ }
+
+ /**
+ * Returns the next event in the evemu recording.
+ */
+ public Event getNextEvent() throws IOException {
+ if (!mQueuedEvents.isEmpty()) {
+ return mQueuedEvents.remove();
+ }
+
+ if (mReader.isAtEndOfFile()) {
+ return null;
+ }
+
+ final String[] parts = expectLineWithParts("E", 4);
+ final String[] timeParts = parts[0].split("\\.");
+ if (timeParts.length != 2) {
+ throw new RuntimeException("Invalid timestamp (does not contain a '.')");
+ }
+ // TODO(b/310958309): use timeMicros to set the timestamp on the event being sent.
+ final long timeMicros =
+ Long.parseLong(timeParts[0]) * 1_000_000 + Integer.parseInt(timeParts[1]);
+ final Event.Builder eb = new Event.Builder();
+ eb.setId(DEVICE_ID);
+ eb.setCommand(Event.Command.INJECT);
+ final int eventType = Integer.parseInt(parts[1], 16);
+ final int eventCode = Integer.parseInt(parts[2], 16);
+ final int value = Integer.parseInt(parts[3]);
+ eb.setInjections(new int[] {eventType, eventCode, value});
+
+ if (mLastEventTimeMicros == -1) {
+ // This is the first event being injected, so send it straight away.
+ mLastEventTimeMicros = timeMicros;
+ return eb.build();
+ } else {
+ final long delayMicros = timeMicros - mLastEventTimeMicros;
+ // The shortest delay supported by Handler.sendMessageAtTime (used for timings by the
+ // Device class) is 1ms, so ignore time differences smaller than that.
+ if (delayMicros < 1000) {
+ mLastEventTimeMicros = timeMicros;
+ return eb.build();
+ } else {
+ // Send a delay now, and queue the actual event for the next call.
+ mQueuedEvents.add(eb.build());
+ mLastEventTimeMicros = timeMicros;
+ final Event.Builder delayEb = new Event.Builder();
+ delayEb.setId(DEVICE_ID);
+ delayEb.setCommand(Event.Command.DELAY);
+ delayEb.setDurationMillis((int) (delayMicros / 1000));
+ return delayEb.build();
+ }
+ }
+ }
+
+ private Event parseRegistrationEvent() throws IOException {
+ // The registration details at the start of a recording are specified by a set of lines
+ // that have to be in this order: N, I, P, B, A, L, S. Recordings must have exactly one N
+ // (name) and I (IDs) line. The remaining lines are optional, and there may be multiple
+ // of those lines.
+
+ final Event.Builder eb = new Event.Builder();
+ eb.setId(DEVICE_ID);
+ eb.setCommand(Event.Command.REGISTER);
+ eb.setName(expectLine("N"));
+
+ final String[] idStrings = expectLineWithParts("I", 4);
+ eb.setBusId(Integer.parseInt(idStrings[0], 16));
+ eb.setVid(Integer.parseInt(idStrings[1], 16));
+ eb.setPid(Integer.parseInt(idStrings[2], 16));
+ // TODO(b/302297266): support setting the version ID, and set it to idStrings[3].
+
+ final SparseArray<int[]> config = new SparseArray<>();
+ config.append(Event.UinputControlCode.UI_SET_PROPBIT.getValue(), parseProperties());
+
+ parseAxisBitmaps(config);
+
+ eb.setConfiguration(config);
+ if (config.contains(Event.UinputControlCode.UI_SET_FFBIT.getValue())) {
+ // If the device specifies any force feedback effects, the kernel will require the
+ // ff_effects_max value to be set.
+ eb.setFfEffectsMax(config.get(Event.UinputControlCode.UI_SET_FFBIT.getValue()).length);
+ }
+
+ eb.setAbsInfo(parseAbsInfos());
+
+ // L: and S: lines allow the initial states of the device's LEDs and switches to be
+ // recorded. However, the FreeDesktop implementation doesn't support actually setting these
+ // states at the start of playback (apparently due to concerns over race conditions), and we
+ // have no need for this feature either, so for now just skip over them.
+ skipUnsupportedLines("L");
+ skipUnsupportedLines("S");
+
+ return eb.build();
+ }
+
+ private int[] parseProperties() throws IOException {
+ final List<String> propBitmapParts = new ArrayList<>();
+ String line = acceptLine("P");
+ while (line != null) {
+ propBitmapParts.addAll(List.of(line.strip().split(" ")));
+ line = acceptLine("P");
+ }
+ return hexStringBitmapToEventCodes(propBitmapParts);
+ }
+
+ private void parseAxisBitmaps(SparseArray<int[]> config) throws IOException {
+ final Map<Integer, List<String>> axisBitmapParts = new HashMap<>();
+ String line = acceptLine("B");
+ while (line != null) {
+ final String[] parts = line.strip().split(" ");
+ if (parts.length < 2) {
+ throw new RuntimeException(
+ "Expected event type and at least one bitmap byte on 'B:' line; only found "
+ + parts.length + " elements");
+ }
+ final int eventType = Integer.parseInt(parts[0], 16);
+ // EV_SYN cannot be configured through uinput, so skip it.
+ if (eventType != Event.EV_SYN) {
+ if (!axisBitmapParts.containsKey(eventType)) {
+ axisBitmapParts.put(eventType, new ArrayList<>());
+ }
+ for (int i = 1; i < parts.length; i++) {
+ axisBitmapParts.get(eventType).add(parts[i]);
+ }
+ }
+ line = acceptLine("B");
+ }
+ final List<Integer> eventTypesToSet = new ArrayList<>();
+ for (var entry : axisBitmapParts.entrySet()) {
+ if (entry.getValue().size() == 0) {
+ continue;
+ }
+ final Event.UinputControlCode controlCode =
+ Event.UinputControlCode.forEventType(entry.getKey());
+ final int[] eventCodes = hexStringBitmapToEventCodes(entry.getValue());
+ if (controlCode != null && eventCodes.length > 0) {
+ config.append(controlCode.getValue(), eventCodes);
+ eventTypesToSet.add(entry.getKey());
+ }
+ }
+ config.append(
+ Event.UinputControlCode.UI_SET_EVBIT.getValue(), unboxIntList(eventTypesToSet));
+ }
+
+ private SparseArray<InputAbsInfo> parseAbsInfos() throws IOException {
+ final SparseArray<InputAbsInfo> absInfos = new SparseArray<>();
+ String line = acceptLine("A");
+ while (line != null) {
+ final String[] parts = line.strip().split(" ");
+ if (parts.length < 5 || parts.length > 6) {
+ throw new RuntimeException(
+ "'A:' lines should have the format 'A: <index (hex)> <min> <max> <fuzz> "
+ + "<flat> [<resolution>]'; expected 5 or 6 numbers but found "
+ + parts.length);
+ }
+ final int axisCode = Integer.parseInt(parts[0], 16);
+ final InputAbsInfo info = new InputAbsInfo();
+ info.minimum = Integer.parseInt(parts[1]);
+ info.maximum = Integer.parseInt(parts[2]);
+ info.fuzz = Integer.parseInt(parts[3]);
+ info.flat = Integer.parseInt(parts[4]);
+ info.resolution = parts.length > 5 ? Integer.parseInt(parts[5]) : 0;
+ absInfos.append(axisCode, info);
+ line = acceptLine("A");
+ }
+ return absInfos;
+ }
+
+ private void skipUnsupportedLines(String type) throws IOException {
+ if (acceptLine(type) != null) {
+ while (acceptLine(type) != null) {
+ // Skip the line.
+ }
+ }
+ }
+
+ /**
+ * Returns the contents of the next line in the file if it has the given type, or raises an
+ * error if it does not.
+ *
+ * @param type the type of the line to expect, represented by the letter before the ':'.
+ * @return the part of the line after the ": ".
+ */
+ private String expectLine(String type) throws IOException {
+ final String line = acceptLine(type);
+ if (line == null) {
+ throw new RuntimeException("Expected line of type '" + type + "'");
+ } else {
+ return line;
+ }
+ }
+
+ /**
+ * Peeks at the next line in the file to see if it has the given type, and if so, returns its
+ * contents and advances the reader.
+ *
+ * @param type the type of the line to accept, represented by the letter before the ':'.
+ * @return the part of the line after the ": ", if the type matches; otherwise {@code null}.
+ */
+ private @Nullable String acceptLine(String type) throws IOException {
+ final String line = mReader.peekLine();
+ if (line == null) {
+ return null;
+ }
+ final String[] lineParts = line.split(": ", 2);
+ if (lineParts.length < 2) {
+ // TODO(b/302297266): make a proper exception class for syntax errors, including line
+ // numbers, etc.. (We can use LineNumberReader to track them.)
+ throw new RuntimeException("Line without ': '");
+ }
+ if (lineParts[0].equals(type)) {
+ mReader.advance();
+ return lineParts[1];
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Like {@link #expectLine(String)}, but also checks that the contents of the line is formed of
+ * {@code numParts} space-separated parts.
+ *
+ * @param type the type of the line to expect, represented by the letter before the ':'.
+ * @param numParts the number of parts to expect.
+ * @return the part of the line after the ": ", split into {@code numParts} sections.
+ */
+ private String[] expectLineWithParts(String type, int numParts) throws IOException {
+ final String[] parts = expectLine(type).strip().split(" ");
+ if (parts.length != numParts) {
+ throw new RuntimeException("Expected a '" + type + "' line with " + numParts
+ + " parts, found one with " + parts.length);
+ }
+ return parts;
+ }
+
+ private static int[] hexStringBitmapToEventCodes(List<String> strs) {
+ final List<Integer> codes = new ArrayList<>();
+ for (int iByte = 0; iByte < strs.size(); iByte++) {
+ int b = Integer.parseInt(strs.get(iByte), 16);
+ if (b < 0x0 || b > 0xff) {
+ throw new RuntimeException("Bitmap part '" + strs.get(iByte)
+ + "' invalid; parts must be between 00 and ff.");
+ }
+ for (int iBit = 0; iBit < 8; iBit++) {
+ if ((b & 1) != 0) {
+ codes.add(iByte * 8 + iBit);
+ }
+ b >>= 1;
+ }
+ }
+ return unboxIntList(codes);
+ }
+
+ private static int[] unboxIntList(List<Integer> list) {
+ final int[] array = new int[list.size()];
+ Arrays.setAll(array, list::get);
+ return array;
+ }
+}
diff --git a/cmds/uinput/src/com/android/commands/uinput/Event.java b/cmds/uinput/src/com/android/commands/uinput/Event.java
index 4498bc2..5ec40e5 100644
--- a/cmds/uinput/src/com/android/commands/uinput/Event.java
+++ b/cmds/uinput/src/com/android/commands/uinput/Event.java
@@ -16,6 +16,7 @@
package com.android.commands.uinput;
+import android.annotation.Nullable;
import android.util.SparseArray;
import java.util.Arrays;
@@ -39,6 +40,7 @@
// Constants representing evdev event types, from include/uapi/linux/input-event-codes.h in the
// kernel.
+ public static final int EV_SYN = 0x00;
public static final int EV_KEY = 0x01;
public static final int EV_REL = 0x02;
public static final int EV_ABS = 0x03;
@@ -69,19 +71,23 @@
public int getValue() {
return mValue;
}
- }
- // These constants come from "include/uapi/linux/input.h" in the kernel
- enum Bus {
- USB(0x03), BLUETOOTH(0x05);
- private final int mValue;
-
- Bus(int value) {
- mValue = value;
- }
-
- int getValue() {
- return mValue;
+ /**
+ * Returns the control code for the given evdev event type, or {@code null} if there is no
+ * control code for that type.
+ */
+ public static @Nullable UinputControlCode forEventType(int eventType) {
+ return switch (eventType) {
+ case EV_KEY -> UI_SET_KEYBIT;
+ case EV_REL -> UI_SET_RELBIT;
+ case EV_ABS -> UI_SET_ABSBIT;
+ case EV_MSC -> UI_SET_MSCBIT;
+ case EV_SW -> UI_SET_SWBIT;
+ case EV_LED -> UI_SET_LEDBIT;
+ case EV_SND -> UI_SET_SNDBIT;
+ case EV_FF -> UI_SET_FFBIT;
+ default -> null;
+ };
}
}
@@ -90,7 +96,7 @@
private String mName;
private int mVid;
private int mPid;
- private Bus mBus;
+ private int mBusId;
private int[] mInjections;
private SparseArray<int[]> mConfiguration;
private int mDurationMillis;
@@ -120,7 +126,7 @@
}
public int getBus() {
- return mBus.getValue();
+ return mBusId;
}
public int[] getInjections() {
@@ -168,7 +174,7 @@
+ ", name=" + mName
+ ", vid=" + mVid
+ ", pid=" + mPid
- + ", bus=" + mBus
+ + ", busId=" + mBusId
+ ", events=" + Arrays.toString(mInjections)
+ ", configuration=" + mConfiguration
+ ", duration=" + mDurationMillis + "ms"
@@ -218,8 +224,8 @@
mEvent.mPid = pid;
}
- public void setBus(Bus bus) {
- mEvent.mBus = bus;
+ public void setBusId(int busId) {
+ mEvent.mBusId = busId;
}
public void setDurationMillis(int durationMillis) {
diff --git a/cmds/uinput/src/com/android/commands/uinput/EventParser.java b/cmds/uinput/src/com/android/commands/uinput/EventParser.java
new file mode 100644
index 0000000..a4df03d
--- /dev/null
+++ b/cmds/uinput/src/com/android/commands/uinput/EventParser.java
@@ -0,0 +1,29 @@
+/*
+ * 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.commands.uinput;
+
+import java.io.IOException;
+
+/**
+ * Interface for a class that reads a stream of {@link Event}s.
+ */
+public interface EventParser {
+ /**
+ * Returns the next event in the file that the parser is reading from.
+ */
+ Event getNextEvent() throws IOException;
+}
diff --git a/cmds/uinput/src/com/android/commands/uinput/JsonStyleParser.java b/cmds/uinput/src/com/android/commands/uinput/JsonStyleParser.java
index a2195c7..888ec5a 100644
--- a/cmds/uinput/src/com/android/commands/uinput/JsonStyleParser.java
+++ b/cmds/uinput/src/com/android/commands/uinput/JsonStyleParser.java
@@ -22,7 +22,7 @@
import android.util.SparseArray;
import java.io.IOException;
-import java.io.InputStreamReader;
+import java.io.Reader;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -34,12 +34,12 @@
/**
* A class that parses the JSON-like event format described in the README to build {@link Event}s.
*/
-public class JsonStyleParser {
+public class JsonStyleParser implements EventParser {
private static final String TAG = "UinputJsonStyleParser";
private JsonReader mReader;
- public JsonStyleParser(InputStreamReader in) {
+ public JsonStyleParser(Reader in) {
mReader = new JsonReader(in);
mReader.setLenient(true);
}
@@ -62,7 +62,7 @@
case "name" -> eb.setName(mReader.nextString());
case "vid" -> eb.setVid(readInt());
case "pid" -> eb.setPid(readInt());
- case "bus" -> eb.setBus(readBus());
+ case "bus" -> eb.setBusId(readBus());
case "events" -> {
int[] injections = readInjectedEvents().stream()
.mapToInt(Integer::intValue).toArray();
@@ -139,9 +139,35 @@
});
}
- private Event.Bus readBus() throws IOException {
+ private int readBus() throws IOException {
String val = mReader.nextString();
- return Event.Bus.valueOf(val.toUpperCase());
+ // See include/uapi/linux/input.h in the kernel for the source of these constants.
+ return switch (val.toUpperCase()) {
+ case "PCI" -> 0x01;
+ case "ISAPNP" -> 0x02;
+ case "USB" -> 0x03;
+ case "HIL" -> 0x04;
+ case "BLUETOOTH" -> 0x05;
+ case "VIRTUAL" -> 0x06;
+ case "ISA" -> 0x10;
+ case "I8042" -> 0x11;
+ case "XTKBD" -> 0x12;
+ case "RS232" -> 0x13;
+ case "GAMEPORT" -> 0x14;
+ case "PARPORT" -> 0x15;
+ case "AMIGA" -> 0x16;
+ case "ADB" -> 0x17;
+ case "I2C" -> 0x18;
+ case "HOST" -> 0x19;
+ case "GSC" -> 0x1A;
+ case "ATARI" -> 0x1B;
+ case "SPI" -> 0x1C;
+ case "RMI" -> 0x1D;
+ case "CEC" -> 0x1E;
+ case "INTEL_ISHTP" -> 0x1F;
+ case "AMD_SFH" -> 0x20;
+ default -> throw new IllegalArgumentException("Invalid bus ID " + val);
+ };
}
private SparseArray<int[]> readConfiguration()
diff --git a/cmds/uinput/src/com/android/commands/uinput/Uinput.java b/cmds/uinput/src/com/android/commands/uinput/Uinput.java
index fe76abb..684a12f 100644
--- a/cmds/uinput/src/com/android/commands/uinput/Uinput.java
+++ b/cmds/uinput/src/com/android/commands/uinput/Uinput.java
@@ -19,12 +19,12 @@
import android.util.Log;
import android.util.SparseArray;
+import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
-import java.io.UnsupportedEncodingException;
import java.util.Objects;
/**
@@ -35,7 +35,7 @@
public class Uinput {
private static final String TAG = "UINPUT";
- private final JsonStyleParser mParser;
+ private final EventParser mParser;
private final SparseArray<Device> mDevices;
private static void usage() {
@@ -74,12 +74,32 @@
private Uinput(InputStream in) {
mDevices = new SparseArray<Device>();
try {
- mParser = new JsonStyleParser(new InputStreamReader(in, "UTF-8"));
- } catch (UnsupportedEncodingException e) {
+ BufferedReader reader = new BufferedReader(new InputStreamReader(in, "UTF-8"));
+ mParser = isEvemuFile(reader) ? new EvemuParser(reader) : new JsonStyleParser(reader);
+ } catch (IOException e) {
throw new RuntimeException(e);
}
}
+ private boolean isEvemuFile(BufferedReader in) throws IOException {
+ // After zero or more empty lines (not even containing horizontal whitespace), evemu
+ // recordings must either start with '#' (indicating the EVEMU version header or a comment)
+ // or 'N' (for the name line). If we encounter anything else, assume it's a JSON-style input
+ // file.
+
+ String lineSep = System.lineSeparator();
+ char[] buf = new char[1];
+
+ in.mark(1 /* readAheadLimit */);
+ int charsRead = in.read(buf);
+ while (charsRead > 0 && lineSep.contains(String.valueOf(buf[0]))) {
+ in.mark(1 /* readAheadLimit */);
+ charsRead = in.read(buf);
+ }
+ in.reset();
+ return buf[0] == '#' || buf[0] == 'N';
+ }
+
private void run() {
try {
Event e = null;