Implement keyboard/mouse control

To control the device from the computer:
 - retrieve mouse and keyboard SDL events;
 - convert them to Android events;
 - serialize them;
 - send them on the same socket used by the video stream (but in the
 opposite direction);
 - deserialize the events on the Android side;
 - inject them using the InputManager.
This commit is contained in:
Romain Vimont
2017-12-14 11:38:44 +01:00
parent 6605ab8e23
commit cabb102a04
23 changed files with 2999 additions and 101 deletions

View File

@@ -1,9 +1,12 @@
.PHONY: jar push run clean
.PHONY: jar push run clean compile compiletests test
SRC_DIR := src
GEN_DIR := gen
CLS_DIR := classes
CLS_DEX := classes.dex
TEST_SRC_DIR := tests
TEST_CLS_DIR := test_classes
TEST_LIBS := /usr/share/java/junit4.jar:/usr/share/java/hamcrest-core.jar
BUILD_TOOLS := $(ANDROID_HOME)/build-tools/26.0.2
AIDL := $(BUILD_TOOLS)/aidl
@@ -17,15 +20,24 @@ ANDROID_JAR := $(ANDROID_HOME)/platforms/android-26/android.jar
AIDL_SRC := android/view/IRotationWatcher.aidl
SRC := com/genymobile/scrcpy/ScrCpyServer.java \
com/genymobile/scrcpy/ControlEvent.java \
com/genymobile/scrcpy/ControlEventReader.java \
com/genymobile/scrcpy/DesktopConnection.java \
com/genymobile/scrcpy/DeviceUtil.java \
com/genymobile/scrcpy/EventController.java \
com/genymobile/scrcpy/ScreenInfo.java \
com/genymobile/scrcpy/ScreenStreamer.java \
com/genymobile/scrcpy/ScreenStreamerSession.java \
com/genymobile/scrcpy/wrappers/DisplayManager.java \
com/genymobile/scrcpy/wrappers/InputManager.java \
com/genymobile/scrcpy/wrappers/ServiceManager.java \
com/genymobile/scrcpy/wrappers/WindowManager.java \
TEST_SRC := com/genymobile/scrcpy/ControlEventReaderTest.java \
# generate classnames from filepath
TEST_CLS := $(subst /,.,$(basename $(TEST_SRC)))
JAR := scrcpy-server.jar
MAIN := com.genymobile.scrcpy.ScrCpyServer
@@ -35,6 +47,7 @@ SRC_CLS := $(SRC:%.java=$(CLS_DIR)/%.class)
CLS := $(AIDL_CLS) $(SRC_CLS)
ALL_JAVA := $(AIDL_GEN) $(addprefix $(SRC_DIR)/,$(SRC))
ALL_TESTS := $(addprefix $(TEST_SRC_DIR)/,$(TEST_SRC))
jar: $(JAR)
@@ -42,13 +55,16 @@ $(AIDL_GEN): $(GEN_DIR)/%.java : $(SRC_DIR)/%.aidl
mkdir -p $(GEN_DIR)
"$(AIDL)" -o$(GEN_DIR) $(SRC_DIR)/$(AIDL_SRC)
$(JAR): $(ALL_JAVA)
@mkdir -p $(CLS_DIR)
compile: $(ALL_JAVA)
@mkdir -p "$(CLS_DIR)"
javac -source 1.7 -target 1.7 \
-cp "$(ANDROID_JAR)" \
-d "$(CLS_DIR)" -sourcepath $(SRC_DIR):$(GEN_DIR) \
$(ALL_JAVA)
$(JAR): $(ALL_JAVA)
# we cannot track easily class dependencies, so execute compile only when jar is outdated
+$(MAKE) compile
"$(DX)" --dex --output=$(CLS_DEX) $(CLS_DIR)
jar cvf $(JAR) classes.dex
@@ -59,4 +75,12 @@ run: push
adb shell "CLASSPATH=/data/local/tmp/$(JAR) app_process /system/bin $(MAIN)"
clean:
rm -rf $(CLS_DEX) $(CLS_DIR) $(GEN_DIR) $(JAR)
rm -rf $(CLS_DEX) $(CLS_DIR) $(GEN_DIR) $(JAR) $(TEST_CLS_DIR)
compiletests: compile $(ALL_TESTS)
@mkdir -p "$(TEST_CLS_DIR)"
javac -cp "$(TEST_LIBS):$(ANDROID_JAR):$(CLS_DIR)" -d "$(TEST_CLS_DIR)" -sourcepath "$(TEST_SRC_DIR)" $(ALL_TESTS)
test:
+$(MAKE) compiletests
java -cp "$(TEST_LIBS):$(ANDROID_JAR):$(CLS_DIR):$(TEST_CLS_DIR)" org.junit.runner.JUnitCore $(TEST_CLS)

View File

@@ -0,0 +1,102 @@
package com.genymobile.scrcpy;
/**
* Union of all supported event types, identified by their {@code type}.
*/
public class ControlEvent {
public static final int TYPE_KEYCODE = 0;
public static final int TYPE_TEXT = 1;
public static final int TYPE_MOUSE = 2;
public static final int TYPE_SCROLL = 3;
private int type;
private String text;
private int metaState; // KeyEvent.META_*
private int action; // KeyEvent.ACTION_* or MotionEvent.ACTION_*
private int keycode; // KeyEvent.KEYCODE_*
private int buttons; // MotionEvent.BUTTON_*
private int x;
private int y;
private int hScroll;
private int vScroll;
private ControlEvent() {
}
public static ControlEvent createKeycodeControlEvent(int action, int keycode, int metaState) {
ControlEvent event = new ControlEvent();
event.type = TYPE_KEYCODE;
event.action = action;
event.keycode = keycode;
event.metaState = metaState;
return event;
}
public static ControlEvent createTextControlEvent(String text) {
ControlEvent event = new ControlEvent();
event.type = TYPE_TEXT;
event.text = text;
return event;
}
public static ControlEvent createMotionControlEvent(int action, int buttons, int x, int y) {
ControlEvent event = new ControlEvent();
event.type = TYPE_MOUSE;
event.action = action;
event.buttons = buttons;
event.x = x;
event.y = y;
return event;
}
public static ControlEvent createScrollControlEvent(int x, int y, int hScroll, int vScroll) {
ControlEvent event = new ControlEvent();
event.type = TYPE_SCROLL;
event.x = x;
event.y = y;
event.hScroll = hScroll;
event.vScroll = vScroll;
return event;
}
public int getType() {
return type;
}
public String getText() {
return text;
}
public int getMetaState() {
return metaState;
}
public int getAction() {
return action;
}
public int getKeycode() {
return keycode;
}
public int getButtons() {
return buttons;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
public int getHScroll() {
return hScroll;
}
public int getVScroll() {
return vScroll;
}
}

View File

@@ -0,0 +1,99 @@
package com.genymobile.scrcpy;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class ControlEventReader {
private static final int KEYCODE_PAYLOAD_LENGTH = 9;
private static final int MOUSE_PAYLOAD_LENGTH = 13;
private static final int SCROLL_PAYLOAD_LENGTH = 16;
private final byte[] rawBuffer = new byte[128];
private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
private final byte[] textBuffer = new byte[32];
public ControlEventReader() {
// invariant: the buffer is always in "get" mode
buffer.limit(0);
}
public boolean isFull() {
return buffer.remaining() == rawBuffer.length;
}
public boolean readFrom(InputStream input) throws IOException {
if (isFull()) {
throw new IllegalStateException("Buffer full, call next() to consume");
}
buffer.compact();
int head = buffer.position();
int r = input.read(rawBuffer, head, rawBuffer.length - head);
if (r == -1) {
return false;
}
buffer.position(head + r);
buffer.flip();
return true;
}
public ControlEvent next() {
if (!buffer.hasRemaining()) {
return null;
}
int savedPosition = buffer.position();
int type = buffer.get();
switch (type) {
case ControlEvent.TYPE_KEYCODE: {
if (buffer.remaining() < KEYCODE_PAYLOAD_LENGTH) {
break;
}
int action = buffer.get() & 0xff; // unsigned
int keycode = buffer.getInt();
int metaState = buffer.getInt();
return ControlEvent.createKeycodeControlEvent(action, keycode, metaState);
}
case ControlEvent.TYPE_TEXT: {
if (buffer.remaining() < 1) {
break;
}
int len = buffer.get() & 0xff; // unsigned
if (buffer.remaining() < len) {
break;
}
buffer.get(textBuffer, 0, len);
String text = new String(textBuffer, 0, len, StandardCharsets.UTF_8);
return ControlEvent.createTextControlEvent(text);
}
case ControlEvent.TYPE_MOUSE: {
if (buffer.remaining() < MOUSE_PAYLOAD_LENGTH) {
break;
}
int action = buffer.get() & 0xff; // unsigned
int buttons = buffer.getInt();
int x = buffer.getInt();
int y = buffer.getInt();
return ControlEvent.createMotionControlEvent(action, buttons, x, y);
}
case ControlEvent.TYPE_SCROLL: {
if (buffer.remaining() < SCROLL_PAYLOAD_LENGTH) {
break;
}
int x = buffer.getInt();
int y = buffer.getInt();
int hscroll = buffer.getInt();
int vscroll = buffer.getInt();
return ControlEvent.createScrollControlEvent(x, y, hscroll, vscroll);
}
default:
Ln.w("Unknown event type: " + type);
}
// failure, reset savedPosition
buffer.position(savedPosition);
return null;
}
}

View File

@@ -5,6 +5,8 @@ import android.net.LocalSocketAddress;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
public class DesktopConnection implements Closeable {
@@ -14,9 +16,15 @@ public class DesktopConnection implements Closeable {
private static final String SOCKET_NAME = "scrcpy";
private final LocalSocket socket;
private final InputStream inputStream;
private final OutputStream outputStream;
private final ControlEventReader reader = new ControlEventReader();
private DesktopConnection(LocalSocket socket) throws IOException {
this.socket = socket;
inputStream = socket.getInputStream();
outputStream = socket.getOutputStream();
}
private static LocalSocket connect(String abstractName) throws IOException {
@@ -27,8 +35,9 @@ public class DesktopConnection implements Closeable {
public static DesktopConnection open(String deviceName, int width, int height) throws IOException {
LocalSocket socket = connect(SOCKET_NAME);
send(socket, deviceName, width, height);
return new DesktopConnection(socket);
DesktopConnection connection = new DesktopConnection(socket);
connection.send(deviceName, width, height);
return connection;
}
public void close() throws IOException {
@@ -37,7 +46,7 @@ public class DesktopConnection implements Closeable {
socket.close();
}
private static void send(LocalSocket socket, String deviceName, int width, int height) throws IOException {
private void send(String deviceName, int width, int height) throws IOException {
assert width < 0x10000 : "width may not be stored on 16 bits";
assert height < 0x10000 : "height may not be stored on 16 bits";
byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH + 4];
@@ -51,11 +60,20 @@ public class DesktopConnection implements Closeable {
buffer[DEVICE_NAME_FIELD_LENGTH + 1] = (byte) width;
buffer[DEVICE_NAME_FIELD_LENGTH + 2] = (byte) (height >> 8);
buffer[DEVICE_NAME_FIELD_LENGTH + 3] = (byte) height;
socket.getOutputStream().write(buffer, 0, buffer.length);
outputStream.write(buffer, 0, buffer.length);
}
public void sendVideoStream(byte[] videoStreamBuffer, int len) throws IOException {
socket.getOutputStream().write(videoStreamBuffer, 0, len);
outputStream.write(videoStreamBuffer, 0, len);
}
public ControlEvent receiveControlEvent() throws IOException {
ControlEvent event = reader.next();
while (event == null) {
reader.readFrom(inputStream);
event = reader.next();
}
return event;
}
}

View File

@@ -3,6 +3,7 @@ package com.genymobile.scrcpy;
import android.os.Build;
import android.view.IRotationWatcher;
import com.genymobile.scrcpy.wrappers.InputManager;
import com.genymobile.scrcpy.wrappers.ServiceManager;
public class DeviceUtil {
@@ -20,4 +21,8 @@ public class DeviceUtil {
public static String getDeviceName() {
return Build.MODEL;
}
public static InputManager getInputManager() {
return serviceManager.getInputManager();
}
}

View File

@@ -0,0 +1,127 @@
package com.genymobile.scrcpy;
import android.os.SystemClock;
import android.view.InputDevice;
import android.view.InputEvent;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.MotionEvent;
import com.genymobile.scrcpy.wrappers.InputManager;
import java.io.IOException;
public class EventController {
private final InputManager inputManager;
private final DesktopConnection connection;
private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
private long lastMouseDown;
private final MotionEvent.PointerProperties[] pointerProperties = { new MotionEvent.PointerProperties() };
private final MotionEvent.PointerCoords[] pointerCoords = { new MotionEvent.PointerCoords() };
public EventController(DesktopConnection connection) {
this.connection = connection;
inputManager = DeviceUtil.getInputManager();
initPointer();
}
private void initPointer() {
MotionEvent.PointerProperties props = pointerProperties[0];
props.id = 0;
props.toolType = MotionEvent.TOOL_TYPE_MOUSE;
MotionEvent.PointerCoords coords = pointerCoords[0];
coords.orientation = 0;
coords.pressure = 1;
coords.size = 1;
coords.toolMajor = 1;
coords.toolMinor = 1;
coords.touchMajor = 1;
coords.touchMinor = 1;
}
private void setPointerCoords(int x, int y) {
MotionEvent.PointerCoords coords = pointerCoords[0];
coords.x = x;
coords.y = y;
}
private void setScroll(int hScroll, int vScroll) {
MotionEvent.PointerCoords coords = pointerCoords[0];
coords.setAxisValue(MotionEvent.AXIS_SCROLL, hScroll);
coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll);
}
public void control() throws IOException {
while (handleEvent());
}
private boolean handleEvent() throws IOException {
ControlEvent controlEvent = connection.receiveControlEvent();
if (controlEvent == null) {
return false;
}
switch (controlEvent.getType()) {
case ControlEvent.TYPE_KEYCODE:
injectKeycode(controlEvent.getAction(), controlEvent.getKeycode(), controlEvent.getMetaState());
break;
case ControlEvent.TYPE_TEXT:
injectText(controlEvent.getText());
break;
case ControlEvent.TYPE_MOUSE:
injectMouse(controlEvent.getAction(), controlEvent.getButtons(), controlEvent.getX(), controlEvent.getY());
break;
case ControlEvent.TYPE_SCROLL:
injectScroll(controlEvent.getButtons(), controlEvent.getX(), controlEvent.getY(), controlEvent.getHScroll(), controlEvent.getVScroll());
}
return true;
}
private boolean injectKeycode(int action, int keycode, int metaState) {
return injectKeyEvent(action, keycode, 0, metaState);
}
private boolean injectText(String text) {
KeyEvent[] events = charMap.getEvents(text.toCharArray());
if (events == null) {
return false;
}
for (KeyEvent event : events) {
if (!injectEvent(event)) {
return false;
}
}
return true;
}
private boolean injectMouse(int action, int buttons, int x, int y) {
long now = SystemClock.uptimeMillis();
if (action == MotionEvent.ACTION_DOWN) {
lastMouseDown = now;
}
setPointerCoords(x, y);
MotionEvent event = MotionEvent.obtain(lastMouseDown, now, action, 1, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0);
return injectEvent(event);
}
private boolean injectScroll(int buttons, int x, int y, int hScroll, int vScroll) {
long now = SystemClock.uptimeMillis();
setPointerCoords(x, y);
setScroll(hScroll, vScroll);
MotionEvent event = MotionEvent.obtain(lastMouseDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0);
return injectEvent(event);
}
private boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState) {
long now = SystemClock.uptimeMillis();
KeyEvent event = new KeyEvent(now, now, action, keyCode, repeat, metaState, KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, InputDevice.SOURCE_KEYBOARD);
return injectEvent(event);
}
private boolean injectEvent(InputEvent event) {
return inputManager.injectInputEvent(event, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
}
}

View File

@@ -6,13 +6,16 @@ public class ScrCpyServer {
private static final String TAG = "scrcpy";
public static void scrcpy() throws IOException {
private static void scrcpy() throws IOException {
String deviceName = DeviceUtil.getDeviceName();
ScreenInfo initialScreenInfo = DeviceUtil.getScreenInfo();
int width = initialScreenInfo.getLogicalWidth();
int height = initialScreenInfo.getLogicalHeight();
try (DesktopConnection connection = DesktopConnection.open(deviceName, width, height)) {
try {
// asynchronous
startEventController(connection);
// synchronous
new ScreenStreamer(connection).streamScreen();
} catch (IOException e) {
Ln.e("Screen streaming interrupted", e);
@@ -20,6 +23,19 @@ public class ScrCpyServer {
}
}
private static void startEventController(final DesktopConnection connection) {
new Thread(new Runnable() {
@Override
public void run() {
try {
new EventController(connection).control();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
public static void main(String... args) throws Exception {
try {
scrcpy();

View File

@@ -0,0 +1,34 @@
package com.genymobile.scrcpy.wrappers;
import android.os.IInterface;
import android.view.InputEvent;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class InputManager {
public static final int INJECT_INPUT_EVENT_MODE_ASYNC = 0;
public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT = 1;
public static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2;
private final IInterface manager;
private final Method injectInputEventMethod;
public InputManager(IInterface manager) {
this.manager = manager;
try {
injectInputEventMethod = manager.getClass().getMethod("injectInputEvent", InputEvent.class, int.class);
} catch (NoSuchMethodException e) {
throw new AssertionError(e);
}
}
public boolean injectInputEvent(InputEvent inputEvent, int mode) {
try {
return (Boolean) injectInputEventMethod.invoke(manager, inputEvent, mode);
} catch (InvocationTargetException | IllegalAccessException e) {
throw new AssertionError(e);
}
}
}

View File

@@ -33,4 +33,8 @@ public class ServiceManager {
public DisplayManager getDisplayManager() {
return new DisplayManager(getService("display", "android.hardware.display.IDisplayManager"));
}
public InputManager getInputManager() {
return new InputManager(getService("input", "android.hardware.input.IInputManager"));
}
}

View File

@@ -0,0 +1,151 @@
package com.genymobile.scrcpy;
import android.view.KeyEvent;
import android.view.MotionEvent;
import org.junit.Assert;
import org.junit.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class ControlEventReaderTest {
@Test
public void testParseKeycodeEvent() throws IOException {
ControlEventReader reader = new ControlEventReader();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(ControlEvent.TYPE_KEYCODE);
dos.writeByte(KeyEvent.ACTION_UP);
dos.writeInt(KeyEvent.KEYCODE_ENTER);
dos.writeInt(KeyEvent.META_CTRL_ON);
byte[] packet = bos.toByteArray();
reader.readFrom(new ByteArrayInputStream(packet));
ControlEvent event = reader.next();
Assert.assertEquals(ControlEvent.TYPE_KEYCODE, event.getType());
Assert.assertEquals(KeyEvent.ACTION_UP, event.getAction());
Assert.assertEquals(KeyEvent.KEYCODE_ENTER, event.getKeycode());
Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState());
}
@Test
public void testParseTextEvent() throws IOException {
ControlEventReader reader = new ControlEventReader();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(ControlEvent.TYPE_TEXT);
byte[] text = "testé".getBytes(StandardCharsets.UTF_8);
dos.writeByte(text.length);
dos.write("testé".getBytes(StandardCharsets.UTF_8));
byte[] packet = bos.toByteArray();
reader.readFrom(new ByteArrayInputStream(packet));
ControlEvent event = reader.next();
Assert.assertEquals(ControlEvent.TYPE_TEXT, event.getType());
Assert.assertEquals("testé", event.getText());
}
@Test
public void testParseMouseEvent() throws IOException {
ControlEventReader reader = new ControlEventReader();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(ControlEvent.TYPE_KEYCODE);
dos.writeByte(MotionEvent.ACTION_DOWN);
dos.writeInt(MotionEvent.BUTTON_PRIMARY);
dos.writeInt(KeyEvent.META_CTRL_ON);
byte[] packet = bos.toByteArray();
reader.readFrom(new ByteArrayInputStream(packet));
ControlEvent event = reader.next();
Assert.assertEquals(ControlEvent.TYPE_KEYCODE, event.getType());
Assert.assertEquals(MotionEvent.ACTION_DOWN, event.getAction());
Assert.assertEquals(MotionEvent.BUTTON_PRIMARY, event.getKeycode());
Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState());
}
@Test
public void testMultiEvents() throws IOException {
ControlEventReader reader = new ControlEventReader();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(ControlEvent.TYPE_KEYCODE);
dos.writeByte(KeyEvent.ACTION_UP);
dos.writeInt(KeyEvent.KEYCODE_ENTER);
dos.writeInt(KeyEvent.META_CTRL_ON);
dos.writeByte(ControlEvent.TYPE_KEYCODE);
dos.writeByte(MotionEvent.ACTION_DOWN);
dos.writeInt(MotionEvent.BUTTON_PRIMARY);
dos.writeInt(KeyEvent.META_CTRL_ON);
byte[] packet = bos.toByteArray();
reader.readFrom(new ByteArrayInputStream(packet));
ControlEvent event = reader.next();
Assert.assertEquals(ControlEvent.TYPE_KEYCODE, event.getType());
Assert.assertEquals(KeyEvent.ACTION_UP, event.getAction());
Assert.assertEquals(KeyEvent.KEYCODE_ENTER, event.getKeycode());
Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState());
event = reader.next();
Assert.assertEquals(ControlEvent.TYPE_KEYCODE, event.getType());
Assert.assertEquals(MotionEvent.ACTION_DOWN, event.getAction());
Assert.assertEquals(MotionEvent.BUTTON_PRIMARY, event.getKeycode());
Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState());
}
@Test
public void testPartialEvents() throws IOException {
ControlEventReader reader = new ControlEventReader();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(bos);
dos.writeByte(ControlEvent.TYPE_KEYCODE);
dos.writeByte(KeyEvent.ACTION_UP);
dos.writeInt(KeyEvent.KEYCODE_ENTER);
dos.writeInt(KeyEvent.META_CTRL_ON);
dos.writeByte(ControlEvent.TYPE_KEYCODE);
dos.writeByte(MotionEvent.ACTION_DOWN);
byte[] packet = bos.toByteArray();
reader.readFrom(new ByteArrayInputStream(packet));
ControlEvent event = reader.next();
Assert.assertEquals(ControlEvent.TYPE_KEYCODE, event.getType());
Assert.assertEquals(KeyEvent.ACTION_UP, event.getAction());
Assert.assertEquals(KeyEvent.KEYCODE_ENTER, event.getKeycode());
Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState());
event = reader.next();
Assert.assertNull(event); // the event is not complete
bos.reset();
dos.writeInt(MotionEvent.BUTTON_PRIMARY);
dos.writeInt(KeyEvent.META_CTRL_ON);
packet = bos.toByteArray();
reader.readFrom(new ByteArrayInputStream(packet));
// the event is now complete
event = reader.next();
Assert.assertEquals(ControlEvent.TYPE_KEYCODE, event.getType());
Assert.assertEquals(MotionEvent.ACTION_DOWN, event.getAction());
Assert.assertEquals(MotionEvent.BUTTON_PRIMARY, event.getKeycode());
Assert.assertEquals(KeyEvent.META_CTRL_ON, event.getMetaState());
}
}