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()); + } +}