diff --git a/server/build.gradle b/server/build.gradle
new file mode 100644
index 0000000..450067d
--- /dev/null
+++ b/server/build.gradle
@@ -0,0 +1,47 @@
+apply plugin: 'com.android.application'
+
+buildscript {
+
+ repositories {
+ google()
+ jcenter()
+ }
+ dependencies {
+ classpath 'com.android.tools.build:gradle:3.1.1'
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ }
+}
+
+android {
+ compileSdkVersion 27
+ defaultConfig {
+ applicationId "com.genymobile.scrcpy"
+ minSdkVersion 21
+ targetSdkVersion 27
+ versionCode 5
+ versionName "1.4"
+ testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+}
+
+dependencies {
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
+ testImplementation 'junit:junit:4.12'
+}
+
+apply from: "$project.rootDir/config/android-checkstyle.gradle"
diff --git a/server/config/android-checkstyle.gradle b/server/config/android-checkstyle.gradle
new file mode 100644
index 0000000..f998530
--- /dev/null
+++ b/server/config/android-checkstyle.gradle
@@ -0,0 +1,28 @@
+apply plugin: 'checkstyle'
+check.dependsOn 'checkstyle'
+
+checkstyle {
+ toolVersion = '6.19'
+}
+
+task checkstyle(type: Checkstyle) {
+ description = "Check Java style with Checkstyle"
+ configFile = rootProject.file("config/checkstyle/checkstyle.xml")
+ source = javaSources()
+ classpath = files()
+ ignoreFailures = true
+}
+
+def javaSources() {
+ def files = []
+ android.sourceSets.each { sourceSet ->
+ sourceSet.java.each { javaSource ->
+ javaSource.getSrcDirs().each {
+ if (it.exists()) {
+ files.add(it)
+ }
+ }
+ }
+ }
+ return files
+}
diff --git a/server/proguard-rules.pro b/server/proguard-rules.pro
new file mode 100644
index 0000000..f1b4245
--- /dev/null
+++ b/server/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/server/src/main/AndroidManifest.xml b/server/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ccd69d2
--- /dev/null
+++ b/server/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/server/src/main/aidl/android/view/IRotationWatcher.aidl b/server/src/main/aidl/android/view/IRotationWatcher.aidl
new file mode 100644
index 0000000..2cc5e44
--- /dev/null
+++ b/server/src/main/aidl/android/view/IRotationWatcher.aidl
@@ -0,0 +1,25 @@
+/* //device/java/android/android/hardware/ISensorListener.aidl
+**
+** Copyright 2008, 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 android.view;
+
+/**
+ * {@hide}
+ */
+interface IRotationWatcher {
+ oneway void onRotationChanged(int rotation);
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlEvent.java b/server/src/main/java/com/genymobile/scrcpy/ControlEvent.java
new file mode 100644
index 0000000..3c9cbda
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/ControlEvent.java
@@ -0,0 +1,105 @@
+package com.genymobile.scrcpy;
+
+/**
+ * Union of all supported event types, identified by their {@code type}.
+ */
+public final 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;
+ public static final int TYPE_COMMAND = 4;
+
+ public static final int COMMAND_BACK_OR_SCREEN_ON = 0;
+
+ private int type;
+ private String text;
+ private int metaState; // KeyEvent.META_*
+ private int action; // KeyEvent.ACTION_* or MotionEvent.ACTION_* or COMMAND_*
+ private int keycode; // KeyEvent.KEYCODE_*
+ private int buttons; // MotionEvent.BUTTON_*
+ private Position position;
+ 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, Position position) {
+ ControlEvent event = new ControlEvent();
+ event.type = TYPE_MOUSE;
+ event.action = action;
+ event.buttons = buttons;
+ event.position = position;
+ return event;
+ }
+
+ public static ControlEvent createScrollControlEvent(Position position, int hScroll, int vScroll) {
+ ControlEvent event = new ControlEvent();
+ event.type = TYPE_SCROLL;
+ event.position = position;
+ event.hScroll = hScroll;
+ event.vScroll = vScroll;
+ return event;
+ }
+
+ public static ControlEvent createCommandControlEvent(int action) {
+ ControlEvent event = new ControlEvent();
+ event.type = TYPE_COMMAND;
+ event.action = action;
+ 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 Position getPosition() {
+ return position;
+ }
+
+ public int getHScroll() {
+ return hScroll;
+ }
+
+ public int getVScroll() {
+ return vScroll;
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ControlEventReader.java b/server/src/main/java/com/genymobile/scrcpy/ControlEventReader.java
new file mode 100644
index 0000000..83088b1
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/ControlEventReader.java
@@ -0,0 +1,151 @@
+package com.genymobile.scrcpy;
+
+import java.io.EOFException;
+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 static final int COMMAND_PAYLOAD_LENGTH = 1;
+
+ public static final int TEXT_MAX_LENGTH = 300;
+ private static final int RAW_BUFFER_SIZE = 1024;
+
+ private final byte[] rawBuffer = new byte[RAW_BUFFER_SIZE];
+ private final ByteBuffer buffer = ByteBuffer.wrap(rawBuffer);
+ private final byte[] textBuffer = new byte[TEXT_MAX_LENGTH];
+
+ public ControlEventReader() {
+ // invariant: the buffer is always in "get" mode
+ buffer.limit(0);
+ }
+
+ public boolean isFull() {
+ return buffer.remaining() == rawBuffer.length;
+ }
+
+ public void 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) {
+ throw new EOFException("Event controller socket closed");
+ }
+ buffer.position(head + r);
+ buffer.flip();
+ }
+
+ public ControlEvent next() {
+ if (!buffer.hasRemaining()) {
+ return null;
+ }
+ int savedPosition = buffer.position();
+
+ int type = buffer.get();
+ ControlEvent controlEvent;
+ switch (type) {
+ case ControlEvent.TYPE_KEYCODE:
+ controlEvent = parseKeycodeControlEvent();
+ break;
+ case ControlEvent.TYPE_TEXT:
+ controlEvent = parseTextControlEvent();
+ break;
+ case ControlEvent.TYPE_MOUSE:
+ controlEvent = parseMouseControlEvent();
+ break;
+ case ControlEvent.TYPE_SCROLL:
+ controlEvent = parseScrollControlEvent();
+ break;
+ case ControlEvent.TYPE_COMMAND:
+ controlEvent = parseCommandControlEvent();
+ break;
+ default:
+ Ln.w("Unknown event type: " + type);
+ controlEvent = null;
+ break;
+ }
+
+ if (controlEvent == null) {
+ // failure, reset savedPosition
+ buffer.position(savedPosition);
+ }
+ return controlEvent;
+ }
+
+ private ControlEvent parseKeycodeControlEvent() {
+ if (buffer.remaining() < KEYCODE_PAYLOAD_LENGTH) {
+ return null;
+ }
+ int action = toUnsigned(buffer.get());
+ int keycode = buffer.getInt();
+ int metaState = buffer.getInt();
+ return ControlEvent.createKeycodeControlEvent(action, keycode, metaState);
+ }
+
+ private ControlEvent parseTextControlEvent() {
+ if (buffer.remaining() < 1) {
+ return null;
+ }
+ int len = toUnsigned(buffer.getShort());
+ if (buffer.remaining() < len) {
+ return null;
+ }
+ buffer.get(textBuffer, 0, len);
+ String text = new String(textBuffer, 0, len, StandardCharsets.UTF_8);
+ return ControlEvent.createTextControlEvent(text);
+ }
+
+ private ControlEvent parseMouseControlEvent() {
+ if (buffer.remaining() < MOUSE_PAYLOAD_LENGTH) {
+ return null;
+ }
+ int action = toUnsigned(buffer.get());
+ int buttons = buffer.getInt();
+ Position position = readPosition(buffer);
+ return ControlEvent.createMotionControlEvent(action, buttons, position);
+ }
+
+ private ControlEvent parseScrollControlEvent() {
+ if (buffer.remaining() < SCROLL_PAYLOAD_LENGTH) {
+ return null;
+ }
+ Position position = readPosition(buffer);
+ int hScroll = buffer.getInt();
+ int vScroll = buffer.getInt();
+ return ControlEvent.createScrollControlEvent(position, hScroll, vScroll);
+ }
+
+ private ControlEvent parseCommandControlEvent() {
+ if (buffer.remaining() < COMMAND_PAYLOAD_LENGTH) {
+ return null;
+ }
+ int action = toUnsigned(buffer.get());
+ return ControlEvent.createCommandControlEvent(action);
+ }
+
+ private static Position readPosition(ByteBuffer buffer) {
+ int x = toUnsigned(buffer.getShort());
+ int y = toUnsigned(buffer.getShort());
+ int screenWidth = toUnsigned(buffer.getShort());
+ int screenHeight = toUnsigned(buffer.getShort());
+ return new Position(x, y, screenWidth, screenHeight);
+ }
+
+ @SuppressWarnings("checkstyle:MagicNumber")
+ private static int toUnsigned(short value) {
+ return value & 0xffff;
+ }
+
+ @SuppressWarnings("checkstyle:MagicNumber")
+ private static int toUnsigned(byte value) {
+ return value & 0xff;
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java b/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java
new file mode 100644
index 0000000..d87a7fd
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/DesktopConnection.java
@@ -0,0 +1,96 @@
+package com.genymobile.scrcpy;
+
+import android.net.LocalServerSocket;
+import android.net.LocalSocket;
+import android.net.LocalSocketAddress;
+
+import java.io.Closeable;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+public final class DesktopConnection implements Closeable {
+
+ private static final int DEVICE_NAME_FIELD_LENGTH = 64;
+
+ private static final String SOCKET_NAME = "scrcpy";
+
+ private final LocalSocket socket;
+ private final InputStream inputStream;
+ private final FileDescriptor fd;
+
+ private final ControlEventReader reader = new ControlEventReader();
+
+ private DesktopConnection(LocalSocket socket) throws IOException {
+ this.socket = socket;
+ inputStream = socket.getInputStream();
+ fd = socket.getFileDescriptor();
+ }
+
+ private static LocalSocket connect(String abstractName) throws IOException {
+ LocalSocket localSocket = new LocalSocket();
+ localSocket.connect(new LocalSocketAddress(abstractName));
+ return localSocket;
+ }
+
+ private static LocalSocket listenAndAccept(String abstractName) throws IOException {
+ LocalServerSocket localServerSocket = new LocalServerSocket(abstractName);
+ try {
+ return localServerSocket.accept();
+ } finally {
+ localServerSocket.close();
+ }
+ }
+
+ public static DesktopConnection open(Device device, boolean tunnelForward) throws IOException {
+ LocalSocket socket;
+ if (tunnelForward) {
+ socket = listenAndAccept(SOCKET_NAME);
+ // send one byte so the client may read() to detect a connection error
+ socket.getOutputStream().write(0);
+ } else {
+ socket = connect(SOCKET_NAME);
+ }
+
+ DesktopConnection connection = new DesktopConnection(socket);
+ Size videoSize = device.getScreenInfo().getVideoSize();
+ connection.send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight());
+ return connection;
+ }
+
+ public void close() throws IOException {
+ socket.shutdownInput();
+ socket.shutdownOutput();
+ socket.close();
+ }
+
+ @SuppressWarnings("checkstyle:MagicNumber")
+ private void send(String deviceName, int width, int height) throws IOException {
+ byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH + 4];
+
+ byte[] deviceNameBytes = deviceName.getBytes(StandardCharsets.UTF_8);
+ int len = Math.min(DEVICE_NAME_FIELD_LENGTH - 1, deviceNameBytes.length);
+ System.arraycopy(deviceNameBytes, 0, buffer, 0, len);
+ // byte[] are always 0-initialized in java, no need to set '\0' explicitly
+
+ buffer[DEVICE_NAME_FIELD_LENGTH] = (byte) (width >> 8);
+ 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;
+ IO.writeFully(fd, buffer, 0, buffer.length);
+ }
+
+ public FileDescriptor getFd() {
+ return fd;
+ }
+
+ public ControlEvent receiveControlEvent() throws IOException {
+ ControlEvent event = reader.next();
+ while (event == null) {
+ reader.readFrom(inputStream);
+ event = reader.next();
+ }
+ return event;
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Device.java b/server/src/main/java/com/genymobile/scrcpy/Device.java
new file mode 100644
index 0000000..d2862ac
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/Device.java
@@ -0,0 +1,138 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.RemoteException;
+import android.view.IRotationWatcher;
+import android.view.InputEvent;
+
+public final class Device {
+
+ public interface RotationListener {
+ void onRotationChanged(int rotation);
+ }
+
+ private final ServiceManager serviceManager = new ServiceManager();
+
+ private ScreenInfo screenInfo;
+ private RotationListener rotationListener;
+
+ public Device(Options options) {
+ screenInfo = computeScreenInfo(options.getCrop(), options.getMaxSize());
+ registerRotationWatcher(new IRotationWatcher.Stub() {
+ @Override
+ public void onRotationChanged(int rotation) throws RemoteException {
+ synchronized (Device.this) {
+ screenInfo = screenInfo.withRotation(rotation);
+
+ // notify
+ if (rotationListener != null) {
+ rotationListener.onRotationChanged(rotation);
+ }
+ }
+ }
+ });
+ }
+
+ public synchronized ScreenInfo getScreenInfo() {
+ return screenInfo;
+ }
+
+ private ScreenInfo computeScreenInfo(Rect crop, int maxSize) {
+ DisplayInfo displayInfo = serviceManager.getDisplayManager().getDisplayInfo();
+ boolean rotated = (displayInfo.getRotation() & 1) != 0;
+ Size deviceSize = displayInfo.getSize();
+ Rect contentRect = new Rect(0, 0, deviceSize.getWidth(), deviceSize.getHeight());
+ if (crop != null) {
+ if (rotated) {
+ // the crop (provided by the user) is expressed in the natural orientation
+ crop = flipRect(crop);
+ }
+ if (!contentRect.intersect(crop)) {
+ // intersect() changes contentRect so that it is intersected with crop
+ Ln.w("Crop rectangle (" + formatCrop(crop) + ") does not intersect device screen (" + formatCrop(deviceSize.toRect()) + ")");
+ contentRect = new Rect(); // empty
+ }
+ }
+
+ Size videoSize = computeVideoSize(contentRect.width(), contentRect.height(), maxSize);
+ return new ScreenInfo(contentRect, videoSize, rotated);
+ }
+
+ private static String formatCrop(Rect rect) {
+ return rect.width() + ":" + rect.height() + ":" + rect.left + ":" + rect.top;
+ }
+
+ @SuppressWarnings("checkstyle:MagicNumber")
+ private static Size computeVideoSize(int w, int h, int maxSize) {
+ // Compute the video size and the padding of the content inside this video.
+ // Principle:
+ // - scale down the great side of the screen to maxSize (if necessary);
+ // - scale down the other side so that the aspect ratio is preserved;
+ // - round this value to the nearest multiple of 8 (H.264 only accepts multiples of 8)
+ w &= ~7; // in case it's not a multiple of 8
+ h &= ~7;
+ if (maxSize > 0) {
+ if (BuildConfig.DEBUG && maxSize % 8 != 0) {
+ throw new AssertionError("Max size must be a multiple of 8");
+ }
+ boolean portrait = h > w;
+ int major = portrait ? h : w;
+ int minor = portrait ? w : h;
+ if (major > maxSize) {
+ int minorExact = minor * maxSize / major;
+ // +4 to round the value to the nearest multiple of 8
+ minor = (minorExact + 4) & ~7;
+ major = maxSize;
+ }
+ w = portrait ? minor : major;
+ h = portrait ? major : minor;
+ }
+ return new Size(w, h);
+ }
+
+ public Point getPhysicalPoint(Position position) {
+ // it hides the field on purpose, to read it with a lock
+ @SuppressWarnings("checkstyle:HiddenField")
+ ScreenInfo screenInfo = getScreenInfo(); // read with synchronization
+ Size videoSize = screenInfo.getVideoSize();
+ Size clientVideoSize = position.getScreenSize();
+ if (!videoSize.equals(clientVideoSize)) {
+ // The client sends a click relative to a video with wrong dimensions,
+ // the device may have been rotated since the event was generated, so ignore the event
+ return null;
+ }
+ Rect contentRect = screenInfo.getContentRect();
+ Point point = position.getPoint();
+ int scaledX = contentRect.left + point.x * contentRect.width() / videoSize.getWidth();
+ int scaledY = contentRect.top + point.y * contentRect.height() / videoSize.getHeight();
+ return new Point(scaledX, scaledY);
+ }
+
+ public static String getDeviceName() {
+ return Build.MODEL;
+ }
+
+ public boolean injectInputEvent(InputEvent inputEvent, int mode) {
+ return serviceManager.getInputManager().injectInputEvent(inputEvent, mode);
+ }
+
+ public boolean isScreenOn() {
+ return serviceManager.getPowerManager().isScreenOn();
+ }
+
+ public void registerRotationWatcher(IRotationWatcher rotationWatcher) {
+ serviceManager.getWindowManager().registerRotationWatcher(rotationWatcher);
+ }
+
+ public synchronized void setRotationListener(RotationListener rotationListener) {
+ this.rotationListener = rotationListener;
+ }
+
+ static Rect flipRect(Rect crop) {
+ return new Rect(crop.top, crop.left, crop.bottom, crop.right);
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java b/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java
new file mode 100644
index 0000000..639869b
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/DisplayInfo.java
@@ -0,0 +1,20 @@
+package com.genymobile.scrcpy;
+
+public final class DisplayInfo {
+ private final Size size;
+ private final int rotation;
+
+ public DisplayInfo(Size size, int rotation) {
+ this.size = size;
+ this.rotation = rotation;
+ }
+
+ public Size getSize() {
+ return size;
+ }
+
+ public int getRotation() {
+ return rotation;
+ }
+}
+
diff --git a/server/src/main/java/com/genymobile/scrcpy/EventController.java b/server/src/main/java/com/genymobile/scrcpy/EventController.java
new file mode 100644
index 0000000..547e20c
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/EventController.java
@@ -0,0 +1,180 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.wrappers.InputManager;
+
+import android.graphics.Point;
+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 java.io.IOException;
+
+
+public class EventController {
+
+ private final Device device;
+ 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(Device device, DesktopConnection connection) {
+ this.device = device;
+ this.connection = connection;
+ initPointer();
+ }
+
+ private void initPointer() {
+ MotionEvent.PointerProperties props = pointerProperties[0];
+ props.id = 0;
+ props.toolType = MotionEvent.TOOL_TYPE_FINGER;
+
+ MotionEvent.PointerCoords coords = pointerCoords[0];
+ coords.orientation = 0;
+ coords.pressure = 1;
+ coords.size = 1;
+ }
+
+ private void setPointerCoords(Point point) {
+ MotionEvent.PointerCoords coords = pointerCoords[0];
+ coords.x = point.x;
+ coords.y = point.y;
+ }
+
+ private void setScroll(int hScroll, int vScroll) {
+ MotionEvent.PointerCoords coords = pointerCoords[0];
+ coords.setAxisValue(MotionEvent.AXIS_HSCROLL, hScroll);
+ coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll);
+ }
+
+ public void control() throws IOException {
+ // on start, turn screen on
+ turnScreenOn();
+
+ while (true) {
+ handleEvent();
+ }
+ }
+
+ private void handleEvent() throws IOException {
+ ControlEvent controlEvent = connection.receiveControlEvent();
+ 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.getPosition());
+ break;
+ case ControlEvent.TYPE_SCROLL:
+ injectScroll(controlEvent.getPosition(), controlEvent.getHScroll(), controlEvent.getVScroll());
+ break;
+ case ControlEvent.TYPE_COMMAND:
+ executeCommand(controlEvent.getAction());
+ break;
+ default:
+ // do nothing
+ }
+ }
+
+ private boolean injectKeycode(int action, int keycode, int metaState) {
+ return injectKeyEvent(action, keycode, 0, metaState);
+ }
+
+ private boolean injectChar(char c) {
+ String decomposed = KeyComposition.decompose(c);
+ char[] chars = decomposed != null ? decomposed.toCharArray() : new char[] {c};
+ KeyEvent[] events = charMap.getEvents(chars);
+ if (events == null) {
+ return false;
+ }
+ for (KeyEvent event : events) {
+ if (!injectEvent(event)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean injectText(String text) {
+ for (char c : text.toCharArray()) {
+ if (!injectChar(c)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean injectMouse(int action, int buttons, Position position) {
+ long now = SystemClock.uptimeMillis();
+ if (action == MotionEvent.ACTION_DOWN) {
+ lastMouseDown = now;
+ }
+ Point point = device.getPhysicalPoint(position);
+ if (point == null) {
+ // ignore event
+ return false;
+ }
+ setPointerCoords(point);
+ MotionEvent event = MotionEvent.obtain(lastMouseDown, now, action, 1, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, 0, 0,
+ InputDevice.SOURCE_TOUCHSCREEN, 0);
+ return injectEvent(event);
+ }
+
+ private boolean injectScroll(Position position, int hScroll, int vScroll) {
+ long now = SystemClock.uptimeMillis();
+ Point point = device.getPhysicalPoint(position);
+ if (point == null) {
+ // ignore event
+ return false;
+ }
+ setPointerCoords(point);
+ 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 injectKeycode(int keyCode) {
+ return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0)
+ && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0);
+ }
+
+ private boolean injectEvent(InputEvent event) {
+ return device.injectInputEvent(event, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
+ }
+
+ private boolean turnScreenOn() {
+ return device.isScreenOn() || injectKeycode(KeyEvent.KEYCODE_POWER);
+ }
+
+ private boolean pressBackOrTurnScreenOn() {
+ int keycode = device.isScreenOn() ? KeyEvent.KEYCODE_BACK : KeyEvent.KEYCODE_POWER;
+ return injectKeycode(keycode);
+ }
+
+ private boolean executeCommand(int action) {
+ switch (action) {
+ case ControlEvent.COMMAND_BACK_OR_SCREEN_ON:
+ return pressBackOrTurnScreenOn();
+ default:
+ Ln.w("Unsupported command: " + action);
+ }
+ return false;
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/IO.java b/server/src/main/java/com/genymobile/scrcpy/IO.java
new file mode 100644
index 0000000..bfd48be
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/IO.java
@@ -0,0 +1,31 @@
+package com.genymobile.scrcpy;
+
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public class IO {
+ private IO() {
+ // not instantiable
+ }
+
+ public static void writeFully(FileDescriptor fd, ByteBuffer from) throws IOException {
+ while (from.hasRemaining()) {
+ try {
+ Os.write(fd, from);
+ } catch (ErrnoException e) {
+ if (e.errno != OsConstants.EINTR) {
+ throw new IOException(e);
+ }
+ }
+ }
+ }
+
+ public static void writeFully(FileDescriptor fd, byte[] buffer, int offset, int len) throws IOException {
+ writeFully(fd, ByteBuffer.wrap(buffer, offset, len));
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/KeyComposition.java b/server/src/main/java/com/genymobile/scrcpy/KeyComposition.java
new file mode 100644
index 0000000..2f2835c
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/KeyComposition.java
@@ -0,0 +1,174 @@
+package com.genymobile.scrcpy;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Decompose accented characters.
+ *
+ * For example, {@link #decompose(char) decompose('é')} returns {@code "\u0301e"}.
+ *
+ * This is useful for injecting key events to generate the expected character ({@link android.view.KeyCharacterMap#getEvents(char[])}
+ * KeyCharacterMap.getEvents()} returns {@code null} with input {@code "é"} but works with input {@code "\u0301e"}).
+ *
+ * See diacritical dead key characters.
+ */
+public final class KeyComposition {
+
+ private static final String KEY_DEAD_GRAVE = "\u0300";
+ private static final String KEY_DEAD_ACUTE = "\u0301";
+ private static final String KEY_DEAD_CIRCUMFLEX = "\u0302";
+ private static final String KEY_DEAD_TILDE = "\u0303";
+ private static final String KEY_DEAD_UMLAUT = "\u0308";
+
+ private static final Map COMPOSITION_MAP = createDecompositionMap();
+
+ private KeyComposition() {
+ // not instantiable
+ }
+
+ public static String decompose(char c) {
+ return COMPOSITION_MAP.get(c);
+ }
+
+ private static String grave(char c) {
+ return KEY_DEAD_GRAVE + c;
+ }
+
+ private static String acute(char c) {
+ return KEY_DEAD_ACUTE + c;
+ }
+
+ private static String circumflex(char c) {
+ return KEY_DEAD_CIRCUMFLEX + c;
+ }
+
+ private static String tilde(char c) {
+ return KEY_DEAD_TILDE + c;
+ }
+
+ private static String umlaut(char c) {
+ return KEY_DEAD_UMLAUT + c;
+ }
+
+ private static Map createDecompositionMap() {
+ Map map = new HashMap<>();
+ map.put('À', grave('A'));
+ map.put('È', grave('E'));
+ map.put('Ì', grave('I'));
+ map.put('Ò', grave('O'));
+ map.put('Ù', grave('U'));
+ map.put('à', grave('a'));
+ map.put('è', grave('e'));
+ map.put('ì', grave('i'));
+ map.put('ò', grave('o'));
+ map.put('ù', grave('u'));
+ map.put('Ǹ', grave('N'));
+ map.put('ǹ', grave('n'));
+ map.put('Ẁ', grave('W'));
+ map.put('ẁ', grave('w'));
+ map.put('Ỳ', grave('Y'));
+ map.put('ỳ', grave('y'));
+
+ map.put('Á', acute('A'));
+ map.put('É', acute('E'));
+ map.put('Í', acute('I'));
+ map.put('Ó', acute('O'));
+ map.put('Ú', acute('U'));
+ map.put('Ý', acute('Y'));
+ map.put('á', acute('a'));
+ map.put('é', acute('e'));
+ map.put('í', acute('i'));
+ map.put('ó', acute('o'));
+ map.put('ú', acute('u'));
+ map.put('ý', acute('y'));
+ map.put('Ć', acute('C'));
+ map.put('ć', acute('c'));
+ map.put('Ĺ', acute('L'));
+ map.put('ĺ', acute('l'));
+ map.put('Ń', acute('N'));
+ map.put('ń', acute('n'));
+ map.put('Ŕ', acute('R'));
+ map.put('ŕ', acute('r'));
+ map.put('Ś', acute('S'));
+ map.put('ś', acute('s'));
+ map.put('Ź', acute('Z'));
+ map.put('ź', acute('z'));
+ map.put('Ǵ', acute('G'));
+ map.put('ǵ', acute('g'));
+ map.put('Ḉ', acute('Ç'));
+ map.put('ḉ', acute('ç'));
+ map.put('Ḱ', acute('K'));
+ map.put('ḱ', acute('k'));
+ map.put('Ḿ', acute('M'));
+ map.put('ḿ', acute('m'));
+ map.put('Ṕ', acute('P'));
+ map.put('ṕ', acute('p'));
+ map.put('Ẃ', acute('W'));
+ map.put('ẃ', acute('w'));
+
+ map.put('Â', circumflex('A'));
+ map.put('Ê', circumflex('E'));
+ map.put('Î', circumflex('I'));
+ map.put('Ô', circumflex('O'));
+ map.put('Û', circumflex('U'));
+ map.put('â', circumflex('a'));
+ map.put('ê', circumflex('e'));
+ map.put('î', circumflex('i'));
+ map.put('ô', circumflex('o'));
+ map.put('û', circumflex('u'));
+ map.put('Ĉ', circumflex('C'));
+ map.put('ĉ', circumflex('c'));
+ map.put('Ĝ', circumflex('G'));
+ map.put('ĝ', circumflex('g'));
+ map.put('Ĥ', circumflex('H'));
+ map.put('ĥ', circumflex('h'));
+ map.put('Ĵ', circumflex('J'));
+ map.put('ĵ', circumflex('j'));
+ map.put('Ŝ', circumflex('S'));
+ map.put('ŝ', circumflex('s'));
+ map.put('Ŵ', circumflex('W'));
+ map.put('ŵ', circumflex('w'));
+ map.put('Ŷ', circumflex('Y'));
+ map.put('ŷ', circumflex('y'));
+ map.put('Ẑ', circumflex('Z'));
+ map.put('ẑ', circumflex('z'));
+
+ map.put('Ã', tilde('A'));
+ map.put('Ñ', tilde('N'));
+ map.put('Õ', tilde('O'));
+ map.put('ã', tilde('a'));
+ map.put('ñ', tilde('n'));
+ map.put('õ', tilde('o'));
+ map.put('Ĩ', tilde('I'));
+ map.put('ĩ', tilde('i'));
+ map.put('Ũ', tilde('U'));
+ map.put('ũ', tilde('u'));
+ map.put('Ẽ', tilde('E'));
+ map.put('ẽ', tilde('e'));
+ map.put('Ỹ', tilde('Y'));
+ map.put('ỹ', tilde('y'));
+
+ map.put('Ä', umlaut('A'));
+ map.put('Ë', umlaut('E'));
+ map.put('Ï', umlaut('I'));
+ map.put('Ö', umlaut('O'));
+ map.put('Ü', umlaut('U'));
+ map.put('ä', umlaut('a'));
+ map.put('ë', umlaut('e'));
+ map.put('ï', umlaut('i'));
+ map.put('ö', umlaut('o'));
+ map.put('ü', umlaut('u'));
+ map.put('ÿ', umlaut('y'));
+ map.put('Ÿ', umlaut('Y'));
+ map.put('Ḧ', umlaut('H'));
+ map.put('ḧ', umlaut('h'));
+ map.put('Ẅ', umlaut('W'));
+ map.put('ẅ', umlaut('w'));
+ map.put('Ẍ', umlaut('X'));
+ map.put('ẍ', umlaut('x'));
+ map.put('ẗ', umlaut('t'));
+
+ return map;
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Ln.java b/server/src/main/java/com/genymobile/scrcpy/Ln.java
new file mode 100644
index 0000000..9364519
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/Ln.java
@@ -0,0 +1,58 @@
+package com.genymobile.scrcpy;
+
+import android.util.Log;
+
+/**
+ * Log both to Android logger (so that logs are visible in "adb logcat") and standard output/error (so that they are visible in the terminal
+ * directly).
+ */
+public final class Ln {
+
+ private static final String TAG = "scrcpy";
+
+ enum Level {
+ DEBUG,
+ INFO,
+ WARN,
+ ERROR;
+ }
+
+ private static final Level THRESHOLD = BuildConfig.DEBUG ? Level.DEBUG : Level.INFO;
+
+ private Ln() {
+ // not instantiable
+ }
+
+ public static boolean isEnabled(Level level) {
+ return level.ordinal() >= THRESHOLD.ordinal();
+ }
+
+ public static void d(String message) {
+ if (isEnabled(Level.DEBUG)) {
+ Log.d(TAG, message);
+ System.out.println("DEBUG: " + message);
+ }
+ }
+
+ public static void i(String message) {
+ if (isEnabled(Level.INFO)) {
+ Log.i(TAG, message);
+ System.out.println("INFO: " + message);
+ }
+ }
+
+ public static void w(String message) {
+ if (isEnabled(Level.WARN)) {
+ Log.w(TAG, message);
+ System.out.println("WARN: " + message);
+ }
+ }
+
+ public static void e(String message, Throwable throwable) {
+ if (isEnabled(Level.ERROR)) {
+ Log.e(TAG, message, throwable);
+ System.out.println("ERROR: " + message);
+ throwable.printStackTrace();
+ }
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Options.java b/server/src/main/java/com/genymobile/scrcpy/Options.java
new file mode 100644
index 0000000..93df896
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/Options.java
@@ -0,0 +1,42 @@
+package com.genymobile.scrcpy;
+
+import android.graphics.Rect;
+
+public class Options {
+ private int maxSize;
+ private int bitRate;
+ private boolean tunnelForward;
+ private Rect crop;
+
+ public int getMaxSize() {
+ return maxSize;
+ }
+
+ public void setMaxSize(int maxSize) {
+ this.maxSize = maxSize;
+ }
+
+ public int getBitRate() {
+ return bitRate;
+ }
+
+ public void setBitRate(int bitRate) {
+ this.bitRate = bitRate;
+ }
+
+ public boolean isTunnelForward() {
+ return tunnelForward;
+ }
+
+ public void setTunnelForward(boolean tunnelForward) {
+ this.tunnelForward = tunnelForward;
+ }
+
+ public Rect getCrop() {
+ return crop;
+ }
+
+ public void setCrop(Rect crop) {
+ this.crop = crop;
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Position.java b/server/src/main/java/com/genymobile/scrcpy/Position.java
new file mode 100644
index 0000000..e00a635
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/Position.java
@@ -0,0 +1,54 @@
+package com.genymobile.scrcpy;
+
+import android.graphics.Point;
+
+import java.util.Objects;
+
+public class Position {
+ private Point point;
+ private Size screenSize;
+
+ public Position(Point point, Size screenSize) {
+ this.point = point;
+ this.screenSize = screenSize;
+ }
+
+ public Position(int x, int y, int screenWidth, int screenHeight) {
+ this(new Point(x, y), new Size(screenWidth, screenHeight));
+ }
+
+ public Point getPoint() {
+ return point;
+ }
+
+ public Size getScreenSize() {
+ return screenSize;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Position position = (Position) o;
+ return Objects.equals(point, position.point)
+ && Objects.equals(screenSize, position.screenSize);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(point, screenSize);
+ }
+
+ @Override
+ public String toString() {
+ return "Position{"
+ + "point=" + point
+ + ", screenSize=" + screenSize
+ + '}';
+ }
+
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java b/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java
new file mode 100644
index 0000000..636bbb0
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/ScreenEncoder.java
@@ -0,0 +1,148 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.wrappers.SurfaceControl;
+
+import android.graphics.Rect;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaFormat;
+import android.os.IBinder;
+import android.view.Surface;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class ScreenEncoder implements Device.RotationListener {
+
+ private static final int DEFAULT_FRAME_RATE = 60; // fps
+ private static final int DEFAULT_I_FRAME_INTERVAL = 10; // seconds
+
+ private static final int REPEAT_FRAME_DELAY = 6; // repeat after 6 frames
+
+ private static final int MICROSECONDS_IN_ONE_SECOND = 1_000_000;
+
+ private final AtomicBoolean rotationChanged = new AtomicBoolean();
+
+ private int bitRate;
+ private int frameRate;
+ private int iFrameInterval;
+
+ public ScreenEncoder(int bitRate, int frameRate, int iFrameInterval) {
+ this.bitRate = bitRate;
+ this.frameRate = frameRate;
+ this.iFrameInterval = iFrameInterval;
+ }
+
+ public ScreenEncoder(int bitRate) {
+ this(bitRate, DEFAULT_FRAME_RATE, DEFAULT_I_FRAME_INTERVAL);
+ }
+
+ @Override
+ public void onRotationChanged(int rotation) {
+ rotationChanged.set(true);
+ }
+
+ public boolean consumeRotationChange() {
+ return rotationChanged.getAndSet(false);
+ }
+
+ public void streamScreen(Device device, FileDescriptor fd) throws IOException {
+ MediaFormat format = createFormat(bitRate, frameRate, iFrameInterval);
+ device.setRotationListener(this);
+ boolean alive;
+ try {
+ do {
+ MediaCodec codec = createCodec();
+ IBinder display = createDisplay();
+ Rect contentRect = device.getScreenInfo().getContentRect();
+ Rect videoRect = device.getScreenInfo().getVideoSize().toRect();
+ setSize(format, videoRect.width(), videoRect.height());
+ configure(codec, format);
+ Surface surface = codec.createInputSurface();
+ setDisplaySurface(display, surface, contentRect, videoRect);
+ codec.start();
+ try {
+ alive = encode(codec, fd);
+ } finally {
+ codec.stop();
+ destroyDisplay(display);
+ codec.release();
+ surface.release();
+ }
+ } while (alive);
+ } finally {
+ device.setRotationListener(null);
+ }
+ }
+
+ private boolean encode(MediaCodec codec, FileDescriptor fd) throws IOException {
+ boolean eof = false;
+ MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+ while (!consumeRotationChange() && !eof) {
+ int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);
+ eof = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+ try {
+ if (consumeRotationChange()) {
+ // must restart encoding with new size
+ break;
+ }
+ if (outputBufferId >= 0) {
+ ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);
+ IO.writeFully(fd, codecBuffer);
+ }
+ } finally {
+ if (outputBufferId >= 0) {
+ codec.releaseOutputBuffer(outputBufferId, false);
+ }
+ }
+ }
+
+ return !eof;
+ }
+
+ private static MediaCodec createCodec() throws IOException {
+ return MediaCodec.createEncoderByType("video/avc");
+ }
+
+ private static MediaFormat createFormat(int bitRate, int frameRate, int iFrameInterval) throws IOException {
+ MediaFormat format = new MediaFormat();
+ format.setString(MediaFormat.KEY_MIME, "video/avc");
+ format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
+ format.setInteger(MediaFormat.KEY_FRAME_RATE, frameRate);
+ format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
+ format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameInterval);
+ // display the very first frame, and recover from bad quality when no new frames
+ format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, MICROSECONDS_IN_ONE_SECOND * REPEAT_FRAME_DELAY / frameRate); // µs
+ return format;
+ }
+
+ private static IBinder createDisplay() {
+ return SurfaceControl.createDisplay("scrcpy", false);
+ }
+
+ private static void configure(MediaCodec codec, MediaFormat format) {
+ codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+ }
+
+ private static void setSize(MediaFormat format, int width, int height) {
+ format.setInteger(MediaFormat.KEY_WIDTH, width);
+ format.setInteger(MediaFormat.KEY_HEIGHT, height);
+ }
+
+ private static void setDisplaySurface(IBinder display, Surface surface, Rect deviceRect, Rect displayRect) {
+ SurfaceControl.openTransaction();
+ try {
+ SurfaceControl.setDisplaySurface(display, surface);
+ SurfaceControl.setDisplayProjection(display, 0, deviceRect, displayRect);
+ SurfaceControl.setDisplayLayerStack(display, 0);
+ } finally {
+ SurfaceControl.closeTransaction();
+ }
+ }
+
+ private static void destroyDisplay(IBinder display) {
+ SurfaceControl.destroyDisplay(display);
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java b/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java
new file mode 100644
index 0000000..f2fce1d
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/ScreenInfo.java
@@ -0,0 +1,31 @@
+package com.genymobile.scrcpy;
+
+import android.graphics.Rect;
+
+public final class ScreenInfo {
+ private final Rect contentRect; // device size, possibly cropped
+ private final Size videoSize;
+ private final boolean rotated;
+
+ public ScreenInfo(Rect contentRect, Size videoSize, boolean rotated) {
+ this.contentRect = contentRect;
+ this.videoSize = videoSize;
+ this.rotated = rotated;
+ }
+
+ public Rect getContentRect() {
+ return contentRect;
+ }
+
+ public Size getVideoSize() {
+ return videoSize;
+ }
+
+ public ScreenInfo withRotation(int rotation) {
+ boolean newRotated = (rotation & 1) != 0;
+ if (rotated == newRotated) {
+ return this;
+ }
+ return new ScreenInfo(Device.flipRect(contentRect), videoSize.rotate(), newRotated);
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Server.java b/server/src/main/java/com/genymobile/scrcpy/Server.java
new file mode 100644
index 0000000..b218e83
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/Server.java
@@ -0,0 +1,104 @@
+package com.genymobile.scrcpy;
+
+import android.graphics.Rect;
+
+import java.io.IOException;
+
+public final class Server {
+
+ private Server() {
+ // not instantiable
+ }
+
+ private static void scrcpy(Options options) throws IOException {
+ final Device device = new Device(options);
+ boolean tunnelForward = options.isTunnelForward();
+ try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {
+ ScreenEncoder screenEncoder = new ScreenEncoder(options.getBitRate());
+
+ // asynchronous
+ startEventController(device, connection);
+
+ try {
+ // synchronous
+ screenEncoder.streamScreen(device, connection.getFd());
+ } catch (IOException e) {
+ // this is expected on close
+ Ln.d("Screen streaming stopped");
+ }
+ }
+ }
+
+ private static void startEventController(final Device device, final DesktopConnection connection) {
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+ try {
+ new EventController(device, connection).control();
+ } catch (IOException e) {
+ // this is expected on close
+ Ln.d("Event controller stopped");
+ }
+ }
+ }).start();
+ }
+
+ @SuppressWarnings("checkstyle:MagicNumber")
+ private static Options createOptions(String... args) {
+ Options options = new Options();
+ if (args.length < 1) {
+ return options;
+ }
+ int maxSize = Integer.parseInt(args[0]) & ~7; // multiple of 8
+ options.setMaxSize(maxSize);
+
+ if (args.length < 2) {
+ return options;
+ }
+ int bitRate = Integer.parseInt(args[1]);
+ options.setBitRate(bitRate);
+
+ if (args.length < 3) {
+ return options;
+ }
+ // use "adb forward" instead of "adb tunnel"? (so the server must listen)
+ boolean tunnelForward = Boolean.parseBoolean(args[2]);
+ options.setTunnelForward(tunnelForward);
+
+ if (args.length < 4) {
+ return options;
+ }
+ Rect crop = parseCrop(args[3]);
+ options.setCrop(crop);
+
+ return options;
+ }
+
+ private static Rect parseCrop(String crop) {
+ if (crop.isEmpty()) {
+ return null;
+ }
+ // input format: "width:height:x:y"
+ String[] tokens = crop.split(":");
+ if (tokens.length != 4) {
+ throw new IllegalArgumentException("Crop must contains 4 values separated by colons: \"" + crop + "\"");
+ }
+ int width = Integer.parseInt(tokens[0]);
+ int height = Integer.parseInt(tokens[1]);
+ int x = Integer.parseInt(tokens[2]);
+ int y = Integer.parseInt(tokens[3]);
+ return new Rect(x, y, x + width, y + height);
+ }
+
+ public static void main(String... args) throws Exception {
+ Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
+ @Override
+ public void uncaughtException(Thread t, Throwable e) {
+ Ln.e("Exception on thread " + t, e);
+ }
+ });
+
+ Options options = createOptions(args);
+ scrcpy(options);
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/Size.java b/server/src/main/java/com/genymobile/scrcpy/Size.java
new file mode 100644
index 0000000..0d546bb
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/Size.java
@@ -0,0 +1,57 @@
+package com.genymobile.scrcpy;
+
+import android.graphics.Rect;
+
+import java.util.Objects;
+
+public final class Size {
+ private final int width;
+ private final int height;
+
+ public Size(int width, int height) {
+ this.width = width;
+ this.height = height;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+
+ public Size rotate() {
+ return new Size(height, width);
+ }
+
+ public Rect toRect() {
+ return new Rect(0, 0, width, height);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Size size = (Size) o;
+ return width == size.width
+ && height == size.height;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(width, height);
+ }
+
+ @Override
+ public String toString() {
+ return "Size{"
+ + "width=" + width
+ + ", height=" + height
+ + '}';
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java
new file mode 100644
index 0000000..568afac
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/DisplayManager.java
@@ -0,0 +1,28 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.DisplayInfo;
+import com.genymobile.scrcpy.Size;
+
+import android.os.IInterface;
+
+public final class DisplayManager {
+ private final IInterface manager;
+
+ public DisplayManager(IInterface manager) {
+ this.manager = manager;
+ }
+
+ public DisplayInfo getDisplayInfo() {
+ try {
+ Object displayInfo = manager.getClass().getMethod("getDisplayInfo", int.class).invoke(manager, 0);
+ Class> cls = displayInfo.getClass();
+ // width and height already take the rotation into account
+ int width = cls.getDeclaredField("logicalWidth").getInt(displayInfo);
+ int height = cls.getDeclaredField("logicalHeight").getInt(displayInfo);
+ int rotation = cls.getDeclaredField("rotation").getInt(displayInfo);
+ return new DisplayInfo(new Size(width, height), rotation);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/InputManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/InputManager.java
new file mode 100644
index 0000000..1fc78c2
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/InputManager.java
@@ -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 final 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);
+ }
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/PowerManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/PowerManager.java
new file mode 100644
index 0000000..a730d1b
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/PowerManager.java
@@ -0,0 +1,32 @@
+package com.genymobile.scrcpy.wrappers;
+
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.os.IInterface;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+public final class PowerManager {
+ private final IInterface manager;
+ private final Method isScreenOnMethod;
+
+ public PowerManager(IInterface manager) {
+ this.manager = manager;
+ try {
+ @SuppressLint("ObsoleteSdkInt") // we may lower minSdkVersion in the future
+ String methodName = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH ? "isInteractive" : "isScreenOn";
+ isScreenOnMethod = manager.getClass().getMethod(methodName);
+ } catch (NoSuchMethodException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public boolean isScreenOn() {
+ try {
+ return (Boolean) isScreenOnMethod.invoke(manager);
+ } catch (InvocationTargetException | IllegalAccessException e) {
+ throw new AssertionError(e);
+ }
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/ServiceManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/ServiceManager.java
new file mode 100644
index 0000000..2d98d0a
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/ServiceManager.java
@@ -0,0 +1,63 @@
+package com.genymobile.scrcpy.wrappers;
+
+import android.annotation.SuppressLint;
+import android.os.IBinder;
+import android.os.IInterface;
+
+import java.lang.reflect.Method;
+
+@SuppressLint("PrivateApi")
+public final class ServiceManager {
+ private final Method getServiceMethod;
+
+ private WindowManager windowManager;
+ private DisplayManager displayManager;
+ private InputManager inputManager;
+ private PowerManager powerManager;
+
+ public ServiceManager() {
+ try {
+ getServiceMethod = Class.forName("android.os.ServiceManager").getDeclaredMethod("getService", String.class);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private IInterface getService(String service, String type) {
+ try {
+ IBinder binder = (IBinder) getServiceMethod.invoke(null, service);
+ Method asInterfaceMethod = Class.forName(type + "$Stub").getMethod("asInterface", IBinder.class);
+ return (IInterface) asInterfaceMethod.invoke(null, binder);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public WindowManager getWindowManager() {
+ if (windowManager == null) {
+ windowManager = new WindowManager(getService("window", "android.view.IWindowManager"));
+ }
+ return windowManager;
+ }
+
+ public DisplayManager getDisplayManager() {
+ if (displayManager == null) {
+ displayManager = new DisplayManager(getService("display", "android.hardware.display.IDisplayManager"));
+ }
+ return displayManager;
+ }
+
+ public InputManager getInputManager() {
+ if (inputManager == null) {
+ inputManager = new InputManager(getService("input", "android.hardware.input.IInputManager"));
+ }
+ return inputManager;
+ }
+
+ public PowerManager getPowerManager() {
+ if (powerManager == null) {
+ powerManager = new PowerManager(getService("power", "android.os.IPowerManager"));
+ }
+ return powerManager;
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java
new file mode 100644
index 0000000..8573386
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/SurfaceControl.java
@@ -0,0 +1,81 @@
+package com.genymobile.scrcpy.wrappers;
+
+import android.annotation.SuppressLint;
+import android.graphics.Rect;
+import android.os.IBinder;
+import android.view.Surface;
+
+@SuppressLint("PrivateApi")
+public final class SurfaceControl {
+
+ private static final Class> CLASS;
+
+ static {
+ try {
+ CLASS = Class.forName("android.view.SurfaceControl");
+ } catch (ClassNotFoundException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private SurfaceControl() {
+ // only static methods
+ }
+
+ public static void openTransaction() {
+ try {
+ CLASS.getMethod("openTransaction").invoke(null);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public static void closeTransaction() {
+ try {
+ CLASS.getMethod("closeTransaction").invoke(null);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public static void setDisplayProjection(IBinder displayToken, int orientation, Rect layerStackRect, Rect displayRect) {
+ try {
+ CLASS.getMethod("setDisplayProjection", IBinder.class, int.class, Rect.class, Rect.class)
+ .invoke(null, displayToken, orientation, layerStackRect, displayRect);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public static void setDisplayLayerStack(IBinder displayToken, int layerStack) {
+ try {
+ CLASS.getMethod("setDisplayLayerStack", IBinder.class, int.class).invoke(null, displayToken, layerStack);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public static void setDisplaySurface(IBinder displayToken, Surface surface) {
+ try {
+ CLASS.getMethod("setDisplaySurface", IBinder.class, Surface.class).invoke(null, displayToken, surface);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public static IBinder createDisplay(String name, boolean secure) {
+ try {
+ return (IBinder) CLASS.getMethod("createDisplay", String.class, boolean.class).invoke(null, name, secure);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public static void destroyDisplay(IBinder displayToken) {
+ try {
+ CLASS.getMethod("destroyDisplay", IBinder.class).invoke(null, displayToken);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+}
diff --git a/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java b/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java
new file mode 100644
index 0000000..56330f9
--- /dev/null
+++ b/server/src/main/java/com/genymobile/scrcpy/wrappers/WindowManager.java
@@ -0,0 +1,42 @@
+package com.genymobile.scrcpy.wrappers;
+
+import android.os.IInterface;
+import android.view.IRotationWatcher;
+
+public final class WindowManager {
+ private final IInterface manager;
+
+ public WindowManager(IInterface manager) {
+ this.manager = manager;
+ }
+
+ public int getRotation() {
+ try {
+ Class> cls = manager.getClass();
+ try {
+ return (Integer) manager.getClass().getMethod("getRotation").invoke(manager);
+ } catch (NoSuchMethodException e) {
+ // method changed since this commit:
+ // https://android.googlesource.com/platform/frameworks/base/+/8ee7285128c3843401d4c4d0412cd66e86ba49e3%5E%21/#F2
+ return (Integer) cls.getMethod("getDefaultDisplayRotation").invoke(manager);
+ }
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public void registerRotationWatcher(IRotationWatcher rotationWatcher) {
+ try {
+ Class> cls = manager.getClass();
+ try {
+ cls.getMethod("watchRotation", IRotationWatcher.class).invoke(manager, rotationWatcher);
+ } catch (NoSuchMethodException e) {
+ // display parameter added since this commit:
+ // https://android.googlesource.com/platform/frameworks/base/+/35fa3c26adcb5f6577849fd0df5228b1f67cf2c6%5E%21/#F1
+ cls.getMethod("watchRotation", IRotationWatcher.class, int.class).invoke(manager, rotationWatcher, 0);
+ }
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+}
diff --git a/server/src/test/java/com/genymobile/scrcpy/ControlEventReaderTest.java b/server/src/test/java/com/genymobile/scrcpy/ControlEventReaderTest.java
new file mode 100644
index 0000000..3e97096
--- /dev/null
+++ b/server/src/test/java/com/genymobile/scrcpy/ControlEventReaderTest.java
@@ -0,0 +1,173 @@
+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;
+import java.util.Arrays;
+
+
+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.writeShort(text.length);
+ dos.write(text);
+ 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 testParseLongTextEvent() throws IOException {
+ ControlEventReader reader = new ControlEventReader();
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ DataOutputStream dos = new DataOutputStream(bos);
+ dos.writeByte(ControlEvent.TYPE_TEXT);
+ byte[] text = new byte[ControlEventReader.TEXT_MAX_LENGTH];
+ Arrays.fill(text, (byte) 'a');
+ dos.writeShort(text.length);
+ dos.write(text);
+ byte[] packet = bos.toByteArray();
+
+ reader.readFrom(new ByteArrayInputStream(packet));
+ ControlEvent event = reader.next();
+
+ Assert.assertEquals(ControlEvent.TYPE_TEXT, event.getType());
+ Assert.assertEquals(new String(text, StandardCharsets.US_ASCII), 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());
+ }
+}