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

Commit 792e5817 authored by Harry Cutts's avatar Harry Cutts Committed by Android (Google) Code Review
Browse files

Merge "uinput: support evemu recordings" into main

parents 7fa0fded 4ec6969f
Loading
Loading
Loading
Loading
+380 −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.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;
    }
}
+23 −17
Original line number Diff line number Diff line
@@ -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 @@ public class Event {

    // 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 class Event {
        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 @@ public class Event {
    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 class Event {
    }

    public int getBus() {
        return mBus.getValue();
        return mBusId;
    }

    public int[] getInjections() {
@@ -168,7 +174,7 @@ public class Event {
            + ", name=" + mName
            + ", vid=" + mVid
            + ", pid=" + mPid
            + ", bus=" + mBus
            + ", busId=" + mBusId
            + ", events=" + Arrays.toString(mInjections)
            + ", configuration=" + mConfiguration
            + ", duration=" + mDurationMillis + "ms"
@@ -218,8 +224,8 @@ public class Event {
            mEvent.mPid = pid;
        }

        public void setBus(Bus bus) {
            mEvent.mBus = bus;
        public void setBusId(int busId) {
            mEvent.mBusId = busId;
        }

        public void setDurationMillis(int durationMillis) {
+29 −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.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;
}
+32 −6
Original line number Diff line number Diff line
@@ -22,7 +22,7 @@ import android.util.Log;
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 @@ import src.com.android.commands.uinput.InputAbsInfo;
/**
 * 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 @@ public class JsonStyleParser {
                        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 @@ public class JsonStyleParser {
        });
    }

    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()
+24 −4
Original line number Diff line number Diff line
@@ -19,12 +19,12 @@ package com.android.commands.uinput;
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 @@ import java.util.Objects;
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 @@ public class Uinput {
    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;