}
+ * Listens on the abstract socket "droidbridge" (reached through {@code adb forward}) for one connection.
+ */
+public final class Main {
+ public static final String VERSION = BuildInfo.VERSION;
+
+ private Main() {
+ }
+
+ public static void main(String... args) {
+ int status = 0;
+ try {
+ run(args);
+ } catch (Throwable t) {
+ Log.e("fatal", t);
+ status = 1;
+ } finally {
+ // The Android runtime may leave non-daemon threads behind.
+ System.exit(status);
+ }
+ }
+
+ private static void run(String... args) throws Exception {
+ if (args.length < 1 || !VERSION.equals(args[0])) {
+ throw new IllegalArgumentException("server is " + VERSION + ", client asked for " + (args.length > 0 ? args[0] : "nothing"));
+ }
+ if (Os.getuid() == 0) {
+ Os.setuid(2000); // the clipboard does not work as root
+ }
+ prepareMainLooper();
+ Workarounds.apply();
+
+ LocalSocket socket;
+ try (LocalServerSocket server = new LocalServerSocket("droidbridge")) {
+ Log.i("droidbridge " + VERSION + " waiting for the Mac");
+ System.out.println("READY");
+ socket = server.accept();
+ }
+ Session session = new Session(socket.getInputStream(), socket.getOutputStream());
+ Thread worker = new Thread(() -> {
+ try {
+ session.run();
+ } catch (Throwable t) {
+ Log.e("session ended", t);
+ } finally {
+ Looper.getMainLooper().quitSafely();
+ }
+ }, "session");
+ worker.start();
+ Looper.loop();
+ socket.close();
+ }
+
+ private static void prepareMainLooper() throws ReflectiveOperationException {
+ // Like Looper.prepareMainLooper(), but the loop may quit.
+ Looper.prepare();
+ synchronized (Looper.class) {
+ @SuppressLint("DiscouragedPrivateApi")
+ Field field = Looper.class.getDeclaredField("sMainLooper");
+ field.setAccessible(true);
+ field.set(null, Looper.myLooper());
+ }
+ }
+}
diff --git a/server/src/dev/droidbridge/PointerProbe.java b/server/src/dev/droidbridge/PointerProbe.java
new file mode 100644
index 0000000..39d9f66
--- /dev/null
+++ b/server/src/dev/droidbridge/PointerProbe.java
@@ -0,0 +1,124 @@
+package dev.droidbridge;
+
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+import android.view.InputDevice;
+
+import java.io.ByteArrayOutputStream;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Reads where the mouse pointer is. The shell user cannot monitor input, and the system's
+ * cursor position API is internal to system_server, but the input service's dump lists each
+ * device's hovering pointer in display coordinates, e.g.
+ * {@code DeviceId(15):[... hoveringPointers=[Pointer(id=0, MOUSE) at (0, 1835.57)] ...]}.
+ * A dump takes about 20 ms on a recent phone, so it is only taken when the pointer moves
+ * toward the edge that leads back to the Mac.
+ */
+final class PointerProbe {
+ static final class Sample {
+ final float x;
+ final float y;
+ final int width;
+ final int height;
+
+ Sample(float x, float y, int width, int height) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ }
+
+ @Override
+ public String toString() {
+ return "(" + x + ", " + y + ") in " + width + "x" + height;
+ }
+ }
+
+ private static final Pattern FRAME = Pattern.compile("logicalFrame=\\[0, 0, (\\d+), (\\d+)\\]");
+
+ private final IBinder input;
+ private int deviceId = -1;
+
+ PointerProbe() throws ReflectiveOperationException {
+ Class> serviceManager = Class.forName("android.os.ServiceManager");
+ Method getService = serviceManager.getMethod("getService", String.class);
+ input = (IBinder) getService.invoke(null, "input");
+ if (input == null) {
+ throw new IllegalStateException("input service not found");
+ }
+ }
+
+ /** Finds the input device id the system gave to our UHID mouse. */
+ boolean findDevice(String name, long timeoutMs) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeoutMs;
+ do {
+ for (int id : InputDevice.getDeviceIds()) {
+ InputDevice device = InputDevice.getDevice(id);
+ if (device != null && name.equals(device.getName())) {
+ deviceId = id;
+ Log.i("mouse is input device " + id);
+ return true;
+ }
+ }
+ Thread.sleep(50);
+ } while (System.currentTimeMillis() < deadline);
+ return false;
+ }
+
+ /** Returns the pointer position, or null if the pointer has not moved yet. */
+ Sample sample() throws IOException {
+ String dump = dump();
+ Matcher pointer = Pattern.compile("DeviceId\\(" + deviceId
+ + "\\):\\[.*?hoveringPointers=\\[Pointer\\(id=\\d+, MOUSE\\) at \\(([-\\d.]+), ([-\\d.]+)\\)").matcher(dump);
+ if (!pointer.find()) {
+ return null;
+ }
+ int width = 0;
+ int height = 0;
+ int cursor = dump.indexOf("MouseCursorController");
+ Matcher frame = FRAME.matcher(dump);
+ if (cursor >= 0 && frame.find(cursor) || frame.find(0)) {
+ width = Integer.parseInt(frame.group(1));
+ height = Integer.parseInt(frame.group(2));
+ }
+ return new Sample(Float.parseFloat(pointer.group(1)), Float.parseFloat(pointer.group(2)), width, height);
+ }
+
+ private String dump() throws IOException {
+ ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream(256 * 1024);
+ Thread reader = new Thread(() -> {
+ try (InputStream in = new FileInputStream(pipe[0].getFileDescriptor())) {
+ byte[] buf = new byte[64 * 1024];
+ int n;
+ while ((n = in.read(buf)) > 0) {
+ bytes.write(buf, 0, n);
+ }
+ } catch (IOException e) {
+ Log.w("dump read: " + e.getMessage());
+ }
+ }, "probe-reader");
+ reader.start();
+ try {
+ input.dump(pipe[1].getFileDescriptor(), new String[0]);
+ } catch (Exception e) {
+ throw new IOException("input dump failed", e);
+ } finally {
+ pipe[1].close();
+ }
+ try {
+ reader.join(1000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ pipe[0].close();
+ return new String(bytes.toByteArray(), StandardCharsets.UTF_8);
+ }
+}
diff --git a/server/src/dev/droidbridge/Protocol.java b/server/src/dev/droidbridge/Protocol.java
new file mode 100644
index 0000000..f4e3e0b
--- /dev/null
+++ b/server/src/dev/droidbridge/Protocol.java
@@ -0,0 +1,37 @@
+package dev.droidbridge;
+
+/**
+ * Wire protocol between the Mac app and this server (see protocol/README.md).
+ * Every message is: u8 type, u32 payload length (big-endian), payload.
+ */
+public final class Protocol {
+ public static final int VERSION = 1;
+
+ // Mac -> Android
+ public static final int HELLO = 0x01; // u16 protocol version
+ public static final int ENTER = 0x02; // u8 side, u16 ratio, u8 n, n x {u8 side, u16 start, u16 end}
+ public static final int LEAVE = 0x03; // empty
+ public static final int MOUSE = 0x04; // u8 buttons, i16 dx, i16 dy, i8 wheel, i8 hwheel
+ public static final int KEYS = 0x05; // 8-byte HID boot keyboard report
+ public static final int CLIPBOARD = 0x06; // utf-8 text
+ public static final int PING = 0x07; // empty
+ public static final int LAYOUT = 0x08; // utf-8 Android keyboard layout name, e.g. "english_us_intl"
+
+ // Android -> Mac
+ public static final int DEVICE = 0x81; // u16 protocol version, u16 width, u16 height, utf-8 model
+ public static final int EDGE = 0x82; // u8 side, u16 ratio along the edge
+ public static final int DEVICE_CLIPBOARD = 0x83; // utf-8 text
+ public static final int PONG = 0x84; // empty
+
+ // Sides of the Android screen
+ public static final int SIDE_NONE = -1;
+ public static final int SIDE_LEFT = 0;
+ public static final int SIDE_RIGHT = 1;
+ public static final int SIDE_TOP = 2;
+ public static final int SIDE_BOTTOM = 3;
+
+ public static final int MAX_PAYLOAD = 1 << 20;
+
+ private Protocol() {
+ }
+}
diff --git a/server/src/dev/droidbridge/Session.java b/server/src/dev/droidbridge/Session.java
new file mode 100644
index 0000000..3b63021
--- /dev/null
+++ b/server/src/dev/droidbridge/Session.java
@@ -0,0 +1,386 @@
+package dev.droidbridge;
+
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+
+import com.genymobile.scrcpy.wrappers.ClipboardManager;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/** One connection from the Mac: a mouse and a keyboard over UHID, plus the clipboard. */
+final class Session {
+ static final String MOUSE_NAME = "droidbridge mouse";
+ static final String KEYBOARD_NAME = "droidbridge keyboard";
+ private static final int VENDOR_ID = 0x18D1;
+
+ /** Counts pushed against the return edge, after it is reached, before control goes back. */
+ private static final int PUSH_TO_LEAVE = 24;
+ /** Minimum time between two pointer probes. */
+ private static final long PROBE_INTERVAL_MS = 30;
+ /** How close to the edge (px) counts as touching it. */
+ private static final float EDGE_SLOP = 1.5f;
+
+ private final DataInputStream in;
+ private final DataOutputStream out;
+ private final Uhid mouse;
+ private final Uhid keyboard;
+ private final PointerProbe probe;
+ private final ClipboardManager clipboard;
+ private final ExecutorService prober = Executors.newSingleThreadExecutor();
+
+ /** Stretches of the device edges that lead back to the Mac: {side, start, end}; null = none. */
+ private volatile float[][] returns;
+ /** The side the pointer is touching inside a return stretch, or SIDE_NONE. */
+ private volatile int edgeSide = Protocol.SIDE_NONE;
+ private volatile float edgeRatio;
+ private volatile boolean probing;
+ private volatile long lastProbe;
+ private int pushed;
+ private boolean edgeSent;
+ private int buttons;
+
+ private final Object clipLock = new Object();
+ private String lastClipFromMac;
+ private String lastClipToMac;
+
+ Session(InputStream input, OutputStream output) throws Exception {
+ in = new DataInputStream(new BufferedInputStream(input));
+ out = new DataOutputStream(new BufferedOutputStream(output));
+ mouse = new Uhid(MOUSE_NAME, VENDOR_ID, 0x4D42, Uhid.MOUSE_DESCRIPTOR);
+ keyboard = new Uhid(KEYBOARD_NAME, VENDOR_ID, 0x4B42, Uhid.KEYBOARD_DESCRIPTOR);
+ probe = new PointerProbe();
+ if (!probe.findDevice(MOUSE_NAME, 3000)) {
+ throw new IOException("the system did not add the UHID mouse");
+ }
+ clipboard = ServiceManager.getClipboardManager();
+ }
+
+ void run() throws IOException {
+ int[] size = displaySize();
+ sendDevice(size[0], size[1]);
+ if (clipboard != null) {
+ // Clipboard callbacks need a looper; the main thread runs one.
+ new Handler(Looper.getMainLooper()).post(() -> clipboard.addPrimaryClipChangedListener(this::onDeviceClipboard));
+ } else {
+ Log.w("no clipboard manager on this device");
+ }
+ try {
+ while (true) {
+ int type = in.readUnsignedByte();
+ int length = in.readInt();
+ if (length < 0 || length > Protocol.MAX_PAYLOAD) {
+ throw new IOException("bad payload length " + length);
+ }
+ byte[] payload = new byte[length];
+ in.readFully(payload);
+ handle(type, payload);
+ }
+ } catch (EOFException e) {
+ Log.i("Mac disconnected");
+ } finally {
+ prober.shutdownNow();
+ mouse.close();
+ keyboard.close();
+ }
+ }
+
+ private void handle(int type, byte[] p) throws IOException {
+ switch (type) {
+ case Protocol.HELLO:
+ int version = ((p[0] & 0xFF) << 8) | (p[1] & 0xFF);
+ Log.i("Mac protocol " + version);
+ break;
+ case Protocol.MOUSE:
+ buttons = p[0] & 0x1F;
+ int dx = (short) (((p[1] & 0xFF) << 8) | (p[2] & 0xFF));
+ int dy = (short) (((p[3] & 0xFF) << 8) | (p[4] & 0xFF));
+ moveMouse(dx, dy, p[5], p[6]);
+ onMotion(dx, dy);
+ break;
+ case Protocol.KEYS:
+ if (p.length == 8) {
+ keyboard.input(p);
+ }
+ break;
+ case Protocol.ENTER:
+ enter(p[0], u16(p, 1) / 65535f, parseReturns(p));
+ break;
+ case Protocol.LEAVE:
+ returns = null;
+ buttons = 0;
+ mouse.input(new byte[5]);
+ keyboard.input(new byte[8]);
+ break;
+ case Protocol.CLIPBOARD:
+ setDeviceClipboard(new String(p, StandardCharsets.UTF_8));
+ break;
+ case Protocol.LAYOUT:
+ KeyboardLayouts.apply(KEYBOARD_NAME, new String(p, StandardCharsets.UTF_8));
+ break;
+ case Protocol.PING:
+ send(Protocol.PONG, new byte[0]);
+ break;
+ default:
+ Log.w("unknown message type " + type);
+ }
+ }
+
+ private static int u16(byte[] p, int i) {
+ return ((p[i] & 0xFF) << 8) | (p[i + 1] & 0xFF);
+ }
+
+ /** ENTER: u8 side, u16 ratio, then (protocol 1.1) u8 count and count x {u8 side, u16 start, u16 end}. */
+ private static float[][] parseReturns(byte[] p) {
+ if (p.length < 4) {
+ return new float[][] {{p[0], 0, 1}};
+ }
+ int count = p[3] & 0xFF;
+ float[][] out = new float[count][];
+ for (int i = 0; i < count && 4 + i * 5 + 5 <= p.length; i++) {
+ int o = 4 + i * 5;
+ out[i] = new float[] {p[o], u16(p, o + 1) / 65535f, u16(p, o + 3) / 65535f};
+ }
+ return out;
+ }
+
+ /** Sends a relative move as UHID reports, each axis limited to -127..127 per report. */
+ private void moveMouse(int dx, int dy, int wheel, int hwheel) throws IOException {
+ do {
+ int sx = clamp(dx);
+ int sy = clamp(dy);
+ mouse.input(new byte[] {(byte) buttons, (byte) sx, (byte) sy, (byte) wheel, (byte) hwheel});
+ dx -= sx;
+ dy -= sy;
+ wheel = 0;
+ hwheel = 0;
+ } while (dx != 0 || dy != 0);
+ }
+
+ private static int clamp(int v) {
+ return Math.max(-127, Math.min(127, v));
+ }
+
+ /**
+ * Puts the pointer on the entry edge at the given ratio. The mouse is relative and Android
+ * accelerates it, so: push into the corner, then walk along the edge and measure.
+ */
+ private void enter(int side, float ratio, float[][] spans) throws IOException {
+ Log.i("enter side=" + side + " ratio=" + ratio + " returns=" + spans.length);
+ returns = null;
+ boolean horizontalEdge = side == Protocol.SIDE_TOP || side == Protocol.SIDE_BOTTOM;
+ int intoX = side == Protocol.SIDE_RIGHT ? 1 : -1;
+ int intoY = side == Protocol.SIDE_BOTTOM ? 1 : -1;
+ // Into the corner: the entry edge, at the start of the axis along it.
+ for (int i = 0; i < 40; i++) {
+ mouse.input(new byte[] {0, (byte) (horizontalEdge ? -127 : 127 * intoX), (byte) (horizontalEdge ? 127 * intoY : -127), 0, 0});
+ }
+ sleep(15);
+ PointerProbe.Sample s = probe.sample();
+ if (s != null && s.width > 0) {
+ float length = horizontalEdge ? s.width - 1 : s.height - 1;
+ float target = ratio * length;
+ float gain = 0;
+ for (int attempt = 0; attempt < 4; attempt++) {
+ float now = horizontalEdge ? s.x : s.y;
+ float error = target - now;
+ if (Math.abs(error) < 12) {
+ break;
+ }
+ int counts = gain > 0 ? Math.round(error / gain) : (int) Math.signum(error) * 60;
+ walk(horizontalEdge, counts);
+ sleep(15);
+ PointerProbe.Sample next = probe.sample();
+ if (next == null) {
+ break;
+ }
+ float moved = (horizontalEdge ? next.x : next.y) - now;
+ if (counts != 0 && moved * counts > 0) {
+ gain = moved / counts;
+ }
+ s = next;
+ }
+ Log.i("entered at " + s + ", target " + target);
+ }
+ edgeSide = side;
+ edgeRatio = ratio;
+ pushed = 0;
+ edgeSent = false;
+ returns = spans;
+ }
+
+ /** Moves along one axis in small steps, so acceleration stays predictable. */
+ private void walk(boolean horizontal, int counts) throws IOException {
+ int step = counts > 0 ? 10 : -10;
+ while (counts != 0) {
+ int s = Math.abs(counts) < Math.abs(step) ? counts : step;
+ mouse.input(new byte[] {0, (byte) (horizontal ? s : 0), (byte) (horizontal ? 0 : s), 0, 0});
+ counts -= s;
+ sleep(2);
+ }
+ }
+
+ private static int toward(int side, int dx, int dy) {
+ switch (side) {
+ case Protocol.SIDE_LEFT: return -dx;
+ case Protocol.SIDE_RIGHT: return dx;
+ case Protocol.SIDE_TOP: return -dy;
+ default: return dy;
+ }
+ }
+
+ /** Watches for the pointer being pushed out through a stretch of edge that leads back to the Mac. */
+ private void onMotion(int dx, int dy) throws IOException {
+ float[][] spans = returns;
+ if (spans == null || spans.length == 0 || buttons != 0) {
+ return;
+ }
+ boolean anyToward = false;
+ for (float[] span : spans) {
+ if (span != null && toward((int) span[0], dx, dy) > 0) {
+ anyToward = true;
+ }
+ }
+ if (!anyToward) {
+ if (dx != 0 || dy != 0) {
+ edgeSide = Protocol.SIDE_NONE;
+ pushed = 0;
+ }
+ return;
+ }
+ int side = edgeSide;
+ if (side != Protocol.SIDE_NONE && toward(side, dx, dy) > 0) {
+ pushed += toward(side, dx, dy);
+ if (pushed >= PUSH_TO_LEAVE && !edgeSent) {
+ edgeSent = true;
+ int r = Math.round(Math.max(0, Math.min(1, edgeRatio)) * 65535);
+ send(Protocol.EDGE, new byte[] {(byte) side, (byte) (r >> 8), (byte) r});
+ Log.i("edge reached on side " + side + ", back to the Mac (ratio " + edgeRatio + ")");
+ }
+ }
+ // Keep probing while pushing: the pointer may have slid along the edge or out of the stretch.
+ long now = System.currentTimeMillis();
+ if (!probing && now - lastProbe >= PROBE_INTERVAL_MS) {
+ probing = true;
+ lastProbe = now;
+ prober.execute(() -> {
+ try {
+ PointerProbe.Sample s = probe.sample();
+ if (s != null && s.width > 0) {
+ int found = Protocol.SIDE_NONE;
+ float foundRatio = 0;
+ for (float[] span : spans) {
+ if (span == null) {
+ continue;
+ }
+ int sd = (int) span[0];
+ boolean touching;
+ float ratio;
+ switch (sd) {
+ case Protocol.SIDE_LEFT: touching = s.x <= EDGE_SLOP; ratio = s.y / (s.height - 1); break;
+ case Protocol.SIDE_RIGHT: touching = s.x >= s.width - 1 - EDGE_SLOP; ratio = s.y / (s.height - 1); break;
+ case Protocol.SIDE_TOP: touching = s.y <= EDGE_SLOP; ratio = s.x / (s.width - 1); break;
+ default: touching = s.y >= s.height - 1 - EDGE_SLOP; ratio = s.x / (s.width - 1); break;
+ }
+ if (touching && ratio >= span[1] - 0.01f && ratio <= span[2] + 0.01f) {
+ found = sd;
+ foundRatio = ratio;
+ break;
+ }
+ }
+ if (found != edgeSide) {
+ pushed = 0;
+ }
+ edgeRatio = foundRatio;
+ edgeSide = found;
+ }
+ } catch (IOException e) {
+ Log.w("probe: " + e.getMessage());
+ } finally {
+ probing = false;
+ }
+ });
+ }
+ }
+
+ private void setDeviceClipboard(String text) {
+ if (clipboard == null) {
+ return;
+ }
+ synchronized (clipLock) {
+ lastClipFromMac = text;
+ }
+ clipboard.setText(text);
+ }
+
+ private void onDeviceClipboard() {
+ CharSequence cs = clipboard.getText();
+ if (cs == null) {
+ return;
+ }
+ String text = cs.toString();
+ synchronized (clipLock) {
+ if (text.equals(lastClipFromMac) || text.equals(lastClipToMac)) {
+ return;
+ }
+ lastClipToMac = text;
+ }
+ try {
+ send(Protocol.DEVICE_CLIPBOARD, text.getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ Log.w("clipboard send: " + e.getMessage());
+ }
+ }
+
+ private void sendDevice(int width, int height) throws IOException {
+ byte[] model = (Build.MANUFACTURER + " " + Build.MODEL).getBytes(StandardCharsets.UTF_8);
+ byte[] p = new byte[6 + model.length];
+ p[0] = (byte) (Protocol.VERSION >> 8);
+ p[1] = (byte) Protocol.VERSION;
+ p[2] = (byte) (width >> 8);
+ p[3] = (byte) width;
+ p[4] = (byte) (height >> 8);
+ p[5] = (byte) height;
+ System.arraycopy(model, 0, p, 6, model.length);
+ send(Protocol.DEVICE, p);
+ }
+
+ private synchronized void send(int type, byte[] payload) throws IOException {
+ out.writeByte(type);
+ out.writeInt(payload.length);
+ out.write(payload);
+ out.flush();
+ }
+
+ private int[] displaySize() {
+ android.util.DisplayMetrics m = new android.util.DisplayMetrics();
+ try {
+ android.view.Display d = ((android.hardware.display.DisplayManager) com.genymobile.scrcpy.FakeContext.get()
+ .getSystemService(android.content.Context.DISPLAY_SERVICE)).getDisplay(0);
+ d.getRealMetrics(m);
+ return new int[] {m.widthPixels, m.heightPixels};
+ } catch (RuntimeException e) {
+ Log.w("display size: " + e.getMessage());
+ return new int[] {0, 0};
+ }
+ }
+
+ private static void sleep(long ms) {
+ try {
+ Thread.sleep(ms);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+}
diff --git a/server/src/dev/droidbridge/Uhid.java b/server/src/dev/droidbridge/Uhid.java
new file mode 100644
index 0000000..a3a5908
--- /dev/null
+++ b/server/src/dev/droidbridge/Uhid.java
@@ -0,0 +1,94 @@
+package dev.droidbridge;
+
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+
+/** A virtual HID device backed by /dev/uhid (Linux Documentation/hid/uhid.rst). */
+final class Uhid {
+ private static final int UHID_CREATE2 = 11;
+ private static final int UHID_INPUT2 = 12;
+ private static final short BUS_VIRTUAL = 0x06;
+
+ static final byte[] MOUSE_DESCRIPTOR = {
+ 0x05, 0x01, 0x09, 0x02, (byte) 0xA1, 0x01, 0x09, 0x01, (byte) 0xA1, 0x00,
+ // 5 buttons
+ 0x05, 0x09, 0x19, 0x01, 0x29, 0x05, 0x15, 0x00, 0x25, 0x01, (byte) 0x95, 0x05, 0x75, 0x01, (byte) 0x81, 0x02,
+ (byte) 0x95, 0x01, 0x75, 0x03, (byte) 0x81, 0x01,
+ // X, Y, wheel: relative, -127..127
+ 0x05, 0x01, 0x09, 0x30, 0x09, 0x31, 0x09, 0x38, 0x15, (byte) 0x81, 0x25, 0x7F, 0x75, 0x08, (byte) 0x95, 0x03,
+ (byte) 0x81, 0x06,
+ // AC Pan (horizontal wheel)
+ 0x05, 0x0C, 0x0A, 0x38, 0x02, 0x15, (byte) 0x81, 0x25, 0x7F, 0x75, 0x08, (byte) 0x95, 0x01, (byte) 0x81, 0x06,
+ (byte) 0xC0, (byte) 0xC0,
+ };
+
+ static final byte[] KEYBOARD_DESCRIPTOR = {
+ 0x05, 0x01, 0x09, 0x06, (byte) 0xA1, 0x01,
+ // modifiers
+ 0x05, 0x07, 0x19, (byte) 0xE0, 0x29, (byte) 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, (byte) 0x95, 0x08,
+ (byte) 0x81, 0x02,
+ // reserved byte
+ 0x75, 0x08, (byte) 0x95, 0x01, (byte) 0x81, 0x01,
+ // LEDs (output, ignored)
+ 0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x75, 0x01, (byte) 0x95, 0x05, (byte) 0x91, 0x02, 0x75, 0x03, (byte) 0x95,
+ 0x01, (byte) 0x91, 0x01,
+ // 6 keys
+ 0x05, 0x07, 0x19, 0x00, 0x29, 0x65, 0x15, 0x00, 0x25, 0x65, 0x75, 0x08, (byte) 0x95, 0x06, (byte) 0x81, 0x00,
+ (byte) 0xC0,
+ };
+
+ private final FileDescriptor fd;
+
+ Uhid(String name, int vendorId, int productId, byte[] descriptor) throws IOException {
+ try {
+ fd = Os.open("/dev/uhid", OsConstants.O_RDWR, 0);
+ byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
+ ByteBuffer buf = ByteBuffer.allocate(280 + descriptor.length).order(ByteOrder.nativeOrder());
+ buf.putInt(UHID_CREATE2);
+ buf.put(nameBytes, 0, Math.min(nameBytes.length, 127));
+ buf.position(4 + 256);
+ buf.putShort((short) descriptor.length);
+ buf.putShort(BUS_VIRTUAL);
+ buf.putInt(vendorId);
+ buf.putInt(productId);
+ buf.putInt(0); // version
+ buf.putInt(0); // country
+ buf.put(descriptor);
+ write(buf.array());
+ } catch (ErrnoException e) {
+ throw new IOException("cannot open /dev/uhid", e);
+ }
+ }
+
+ void input(byte[] report) throws IOException {
+ ByteBuffer buf = ByteBuffer.allocate(6 + report.length).order(ByteOrder.nativeOrder());
+ buf.putInt(UHID_INPUT2);
+ buf.putShort((short) report.length);
+ buf.put(report);
+ write(buf.array());
+ }
+
+ private void write(byte[] data) throws IOException {
+ try {
+ Os.write(fd, data, 0, data.length);
+ } catch (ErrnoException e) {
+ throw new IOException(e);
+ }
+ }
+
+ /** Closing the fd destroys the device. */
+ void close() {
+ try {
+ Os.close(fd);
+ } catch (ErrnoException e) {
+ Log.w("uhid close: " + e.getMessage());
+ }
+ }
+}
diff --git a/server/third_party/SCRCPY-LICENSE b/server/third_party/SCRCPY-LICENSE
new file mode 100644
index 0000000..3325e9f
--- /dev/null
+++ b/server/third_party/SCRCPY-LICENSE
@@ -0,0 +1,204 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright (C) 2018 Genymobile
+ Copyright (C) 2018-2026 Romain Vimont
+
+ 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.
+
diff --git a/server/third_party/scrcpy-aidl/android/content/IOnPrimaryClipChangedListener.aidl b/server/third_party/scrcpy-aidl/android/content/IOnPrimaryClipChangedListener.aidl
new file mode 100644
index 0000000..46d7f7c
--- /dev/null
+++ b/server/third_party/scrcpy-aidl/android/content/IOnPrimaryClipChangedListener.aidl
@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 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.content;
+
+/**
+ * {@hide}
+ */
+oneway interface IOnPrimaryClipChangedListener {
+ void dispatchPrimaryClipChanged();
+}
diff --git a/server/third_party/scrcpy-aidl/android/view/IDisplayWindowListener.aidl b/server/third_party/scrcpy-aidl/android/view/IDisplayWindowListener.aidl
new file mode 100644
index 0000000..0d1f197
--- /dev/null
+++ b/server/third_party/scrcpy-aidl/android/view/IDisplayWindowListener.aidl
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2019 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;
+
+import android.graphics.Rect;
+import android.content.res.Configuration;
+
+import java.util.List;
+
+/**
+ * Interface to listen for changes to display window-containers.
+ *
+ * This differs from DisplayManager's DisplayListener in a couple ways:
+ * - onDisplayAdded is always called after the display is actually added to the WM hierarchy.
+ * This corresponds to the DisplayContent and not the raw Dislay from DisplayManager.
+ * - onDisplayConfigurationChanged is called for all configuration changes, not just changes
+ * to displayinfo (eg. windowing-mode).
+ *
+ */
+oneway interface IDisplayWindowListener {
+
+ /**
+ * Called when a new display is added to the WM hierarchy. The existing display ids are returned
+ * when this listener is registered with WM via {@link #registerDisplayWindowListener}.
+ */
+ void onDisplayAdded(int displayId);
+
+ /**
+ * Called when a display's window-container configuration has changed.
+ */
+ void onDisplayConfigurationChanged(int displayId, in Configuration newConfig);
+
+ /**
+ * Called when a display is removed from the hierarchy.
+ */
+ void onDisplayRemoved(int displayId);
+}
diff --git a/server/third_party/scrcpy-java/android/content/IContentProvider.java b/server/third_party/scrcpy-java/android/content/IContentProvider.java
new file mode 100644
index 0000000..bb907dd
--- /dev/null
+++ b/server/third_party/scrcpy-java/android/content/IContentProvider.java
@@ -0,0 +1,5 @@
+package android.content;
+
+public interface IContentProvider {
+ // android.content.IContentProvider is hidden, this is a fake one to expose the type to the project
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/AndroidVersions.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/AndroidVersions.java
new file mode 100644
index 0000000..5303924
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/AndroidVersions.java
@@ -0,0 +1,32 @@
+package com.genymobile.scrcpy;
+
+import android.os.Build;
+
+/**
+ * Android version code constants, done right.
+ *
+ * API levels
+ */
+public final class AndroidVersions {
+
+ private AndroidVersions() {
+ // not instantiable
+ }
+
+ public static final int API_21_ANDROID_5_0 = Build.VERSION_CODES.LOLLIPOP;
+ public static final int API_22_ANDROID_5_1 = Build.VERSION_CODES.LOLLIPOP_MR1;
+ public static final int API_23_ANDROID_6_0 = Build.VERSION_CODES.M;
+ public static final int API_24_ANDROID_7_0 = Build.VERSION_CODES.N;
+ public static final int API_25_ANDROID_7_1 = Build.VERSION_CODES.N_MR1;
+ public static final int API_26_ANDROID_8_0 = Build.VERSION_CODES.O;
+ public static final int API_27_ANDROID_8_1 = Build.VERSION_CODES.O_MR1;
+ public static final int API_28_ANDROID_9 = Build.VERSION_CODES.P;
+ public static final int API_29_ANDROID_10 = Build.VERSION_CODES.Q;
+ public static final int API_30_ANDROID_11 = Build.VERSION_CODES.R;
+ public static final int API_31_ANDROID_12 = Build.VERSION_CODES.S;
+ public static final int API_32_ANDROID_12L = Build.VERSION_CODES.S_V2;
+ public static final int API_33_ANDROID_13 = Build.VERSION_CODES.TIRAMISU;
+ public static final int API_34_ANDROID_14 = Build.VERSION_CODES.UPSIDE_DOWN_CAKE;
+ public static final int API_35_ANDROID_15 = Build.VERSION_CODES.VANILLA_ICE_CREAM;
+
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/AsyncProcessor.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/AsyncProcessor.java
new file mode 100644
index 0000000..d5da6a9
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/AsyncProcessor.java
@@ -0,0 +1,18 @@
+package com.genymobile.scrcpy;
+
+public interface AsyncProcessor {
+ interface TerminationListener {
+ /**
+ * Notify processor termination
+ *
+ * @param fatalError {@code true} if this must cause the termination of the whole scrcpy-server.
+ */
+ void onTerminated(boolean fatalError);
+ }
+
+ void start(TerminationListener listener);
+
+ void stop();
+
+ void join() throws InterruptedException;
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/CleanUp.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/CleanUp.java
new file mode 100644
index 0000000..a5816c3
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/CleanUp.java
@@ -0,0 +1,271 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.device.Device;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.util.Settings;
+import com.genymobile.scrcpy.util.SettingsException;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.os.BatteryManager;
+import android.os.Looper;
+import android.system.ErrnoException;
+import android.system.Os;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Handle the cleanup of scrcpy, even if the main process is killed.
+ *
+ * This is useful to restore some state when scrcpy is closed, even on device disconnection (which kills the scrcpy process).
+ */
+public final class CleanUp {
+
+ // Dynamic options
+ private static final int PENDING_CHANGE_DISPLAY_POWER = 1 << 0;
+ private int pendingChanges;
+ private boolean pendingRestoreDisplayPower;
+
+ private Thread thread;
+ private boolean interrupted;
+
+ private CleanUp(Options options) {
+ thread = new Thread(() -> runCleanUp(options), "cleanup");
+ thread.start();
+ }
+
+ public static CleanUp start(Options options) {
+ return new CleanUp(options);
+ }
+
+ public synchronized void interrupt() {
+ // Do not use thread.interrupt() because only the wait() call must be interrupted, not Command.exec()
+ interrupted = true;
+ notify();
+ }
+
+ public void join() throws InterruptedException {
+ thread.join();
+ }
+
+ private void runCleanUp(Options options) {
+ boolean disableShowTouches = false;
+ if (options.getShowTouches()) {
+ try {
+ String oldValue = Settings.getAndPutValue(Settings.TABLE_SYSTEM, "show_touches", "1");
+ // If "show touches" was disabled, it must be disabled back on clean up
+ disableShowTouches = !"1".equals(oldValue);
+ } catch (SettingsException e) {
+ Ln.e("Could not change \"show_touches\"", e);
+ }
+ }
+
+ int restoreStayOn = -1;
+ if (options.getStayAwake()) {
+ int stayOn = BatteryManager.BATTERY_PLUGGED_AC | BatteryManager.BATTERY_PLUGGED_USB | BatteryManager.BATTERY_PLUGGED_WIRELESS;
+ try {
+ String oldValue = Settings.getAndPutValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(stayOn));
+ try {
+ int currentStayOn = Integer.parseInt(oldValue);
+ // Restore only if the current value is different
+ if (currentStayOn != stayOn) {
+ restoreStayOn = currentStayOn;
+ }
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ } catch (SettingsException e) {
+ Ln.e("Could not change \"stay_on_while_plugged_in\"", e);
+ }
+ }
+
+ int restoreScreenOffTimeout = -1;
+ int screenOffTimeout = options.getScreenOffTimeout();
+ if (screenOffTimeout != -1) {
+ try {
+ String oldValue = Settings.getAndPutValue(Settings.TABLE_SYSTEM, "screen_off_timeout", String.valueOf(screenOffTimeout));
+ try {
+ int currentScreenOffTimeout = Integer.parseInt(oldValue);
+ // Restore only if the current value is different
+ if (currentScreenOffTimeout != screenOffTimeout) {
+ restoreScreenOffTimeout = currentScreenOffTimeout;
+ }
+ } catch (NumberFormatException e) {
+ // ignore
+ }
+ } catch (SettingsException e) {
+ Ln.e("Could not change \"screen_off_timeout\"", e);
+ }
+ }
+
+ int displayId = options.getDisplayId();
+
+ int restoreDisplayImePolicy = -1;
+ if (displayId > 0) {
+ int displayImePolicy = options.getDisplayImePolicy();
+ if (displayImePolicy != -1) {
+ int currentDisplayImePolicy = ServiceManager.getWindowManager().getDisplayImePolicy(displayId);
+ if (currentDisplayImePolicy != displayImePolicy) {
+ ServiceManager.getWindowManager().setDisplayImePolicy(displayId, displayImePolicy);
+ restoreDisplayImePolicy = currentDisplayImePolicy;
+ }
+ }
+ }
+
+ boolean powerOffScreen = options.getPowerOffScreenOnClose();
+
+ try {
+ run(displayId, restoreStayOn, disableShowTouches, powerOffScreen, restoreScreenOffTimeout, restoreDisplayImePolicy);
+ } catch (IOException e) {
+ Ln.e("Clean up I/O exception", e);
+ }
+ }
+
+ private void run(int displayId, int restoreStayOn, boolean disableShowTouches, boolean powerOffScreen, int restoreScreenOffTimeout,
+ int restoreDisplayImePolicy) throws IOException {
+ String[] cmd = {
+ "app_process",
+ "/",
+ CleanUp.class.getName(),
+ String.valueOf(displayId),
+ String.valueOf(restoreStayOn),
+ String.valueOf(disableShowTouches),
+ String.valueOf(powerOffScreen),
+ String.valueOf(restoreScreenOffTimeout),
+ String.valueOf(restoreDisplayImePolicy),
+ };
+
+ ProcessBuilder builder = new ProcessBuilder(cmd);
+ builder.environment().put("CLASSPATH", Server.SERVER_PATH);
+ Process process = builder.start();
+ OutputStream out = process.getOutputStream();
+
+ while (true) {
+ int localPendingChanges;
+ boolean localPendingRestoreDisplayPower;
+ synchronized (this) {
+ while (!interrupted && pendingChanges == 0) {
+ try {
+ wait();
+ } catch (InterruptedException e) {
+ throw new AssertionError("Clean up thread MUST NOT be interrupted");
+ }
+ }
+ if (interrupted) {
+ break;
+ }
+ localPendingChanges = pendingChanges;
+ localPendingRestoreDisplayPower = pendingRestoreDisplayPower;
+ pendingChanges = 0;
+ }
+ if ((localPendingChanges & PENDING_CHANGE_DISPLAY_POWER) != 0) {
+ out.write(localPendingRestoreDisplayPower ? 1 : 0);
+ out.flush();
+ }
+ }
+ }
+
+ public synchronized void setRestoreDisplayPower(boolean restoreDisplayPower) {
+ pendingRestoreDisplayPower = restoreDisplayPower;
+ pendingChanges |= PENDING_CHANGE_DISPLAY_POWER;
+ notify();
+ }
+
+ public static void unlinkSelf() {
+ try {
+ new File(Server.SERVER_PATH).delete();
+ } catch (Exception e) {
+ Ln.e("Could not unlink server", e);
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private static void prepareMainLooper() {
+ Looper.prepareMainLooper();
+ }
+
+ public static void main(String... args) {
+ try {
+ // Start a new session to avoid being terminated along with the server process on some devices
+ Os.setsid();
+ } catch (ErrnoException e) {
+ Ln.e("setsid() failed", e);
+ }
+ unlinkSelf();
+
+ // Needed for workarounds
+ prepareMainLooper();
+ Workarounds.apply();
+
+ int displayId = Integer.parseInt(args[0]);
+ int restoreStayOn = Integer.parseInt(args[1]);
+ boolean disableShowTouches = Boolean.parseBoolean(args[2]);
+ boolean powerOffScreen = Boolean.parseBoolean(args[3]);
+ int restoreScreenOffTimeout = Integer.parseInt(args[4]);
+ int restoreDisplayImePolicy = Integer.parseInt(args[5]);
+
+ // Dynamic option
+ boolean restoreDisplayPower = false;
+
+ try {
+ // Wait for the server to die
+ int msg;
+ while ((msg = System.in.read()) != -1) {
+ // Only restore display power
+ assert msg == 0 || msg == 1;
+ restoreDisplayPower = msg != 0;
+ }
+ } catch (IOException e) {
+ // Expected when the server is dead
+ }
+
+ Ln.i("Cleaning up");
+
+ if (disableShowTouches) {
+ Ln.i("Disabling \"show touches\"");
+ try {
+ Settings.putValue(Settings.TABLE_SYSTEM, "show_touches", "0");
+ } catch (SettingsException e) {
+ Ln.e("Could not restore \"show_touches\"", e);
+ }
+ }
+
+ if (restoreStayOn != -1) {
+ Ln.i("Restoring \"stay awake\"");
+ try {
+ Settings.putValue(Settings.TABLE_GLOBAL, "stay_on_while_plugged_in", String.valueOf(restoreStayOn));
+ } catch (SettingsException e) {
+ Ln.e("Could not restore \"stay_on_while_plugged_in\"", e);
+ }
+ }
+
+ if (restoreScreenOffTimeout != -1) {
+ Ln.i("Restoring \"screen off timeout\"");
+ try {
+ Settings.putValue(Settings.TABLE_SYSTEM, "screen_off_timeout", String.valueOf(restoreScreenOffTimeout));
+ } catch (SettingsException e) {
+ Ln.e("Could not restore \"screen_off_timeout\"", e);
+ }
+ }
+
+ if (restoreDisplayImePolicy != -1) {
+ Ln.i("Restoring \"display IME policy\"");
+ ServiceManager.getWindowManager().setDisplayImePolicy(displayId, restoreDisplayImePolicy);
+ }
+
+ // Change the power of the main display when mirroring a virtual display
+ int targetDisplayId = displayId != Device.DISPLAY_ID_NONE ? displayId : 0;
+ if (Device.isScreenOn(targetDisplayId)) {
+ if (powerOffScreen) {
+ Ln.i("Power off screen");
+ Device.powerOffScreen(targetDisplayId);
+ } else if (restoreDisplayPower) {
+ Ln.i("Restoring display power");
+ Device.setDisplayPower(targetDisplayId, true);
+ }
+ }
+
+ System.exit(0);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/FakeContext.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/FakeContext.java
new file mode 100644
index 0000000..5d41a8f
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/FakeContext.java
@@ -0,0 +1,127 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.AttributionSource;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.IContentProvider;
+import android.os.Binder;
+import android.os.Process;
+
+import java.lang.reflect.Field;
+
+public final class FakeContext extends ContextWrapper {
+
+ public static final String PACKAGE_NAME = "com.android.shell";
+ public static final int ROOT_UID = 0; // Like android.os.Process.ROOT_UID, but before API 29
+
+ private static final FakeContext INSTANCE = new FakeContext();
+
+ public static FakeContext get() {
+ return INSTANCE;
+ }
+
+ private final ContentResolver contentResolver = new ContentResolver(this) {
+ @SuppressWarnings({"unused", "ProtectedMemberInFinalClass"})
+ // @Override (but super-class method not visible)
+ protected IContentProvider acquireProvider(Context c, String name) {
+ return ServiceManager.getActivityManager().getContentProviderExternal(name, new Binder());
+ }
+
+ @SuppressWarnings("unused")
+ // @Override (but super-class method not visible)
+ public boolean releaseProvider(IContentProvider icp) {
+ return false;
+ }
+
+ @SuppressWarnings({"unused", "ProtectedMemberInFinalClass"})
+ // @Override (but super-class method not visible)
+ protected IContentProvider acquireUnstableProvider(Context c, String name) {
+ return null;
+ }
+
+ @SuppressWarnings("unused")
+ // @Override (but super-class method not visible)
+ public boolean releaseUnstableProvider(IContentProvider icp) {
+ return false;
+ }
+
+ @SuppressWarnings("unused")
+ // @Override (but super-class method not visible)
+ public void unstableProviderDied(IContentProvider icp) {
+ // ignore
+ }
+ };
+
+ private FakeContext() {
+ super(Workarounds.getSystemContext());
+ }
+
+ @Override
+ public String getPackageName() {
+ return PACKAGE_NAME;
+ }
+
+ @Override
+ public String getOpPackageName() {
+ return PACKAGE_NAME;
+ }
+
+ @TargetApi(AndroidVersions.API_31_ANDROID_12)
+ @Override
+ public AttributionSource getAttributionSource() {
+ AttributionSource.Builder builder = new AttributionSource.Builder(Process.SHELL_UID);
+ builder.setPackageName(PACKAGE_NAME);
+ return builder.build();
+ }
+
+ // @Override to be added on SDK upgrade for Android 14
+ @SuppressWarnings("unused")
+ public int getDeviceId() {
+ return 0;
+ }
+
+ @Override
+ public Context getApplicationContext() {
+ return this;
+ }
+
+ @Override
+ public Context createPackageContext(String packageName, int flags) {
+ return this;
+ }
+
+ @Override
+ public ContentResolver getContentResolver() {
+ return contentResolver;
+ }
+
+ @SuppressLint("SoonBlockedPrivateApi")
+ @Override
+ public Object getSystemService(String name) {
+ Object service = super.getSystemService(name);
+ if (service == null) {
+ return null;
+ }
+
+ // "semclipboard" is a Samsung-internal service
+ // See:
+ // -
+ // -
+ if (Context.CLIPBOARD_SERVICE.equals(name) || "semclipboard".equals(name) || Context.ACTIVITY_SERVICE.equals(name)) {
+ try {
+ Field field = service.getClass().getDeclaredField("mContext");
+ field.setAccessible(true);
+ field.set(service, this);
+ } catch (ReflectiveOperationException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ return service;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/Options.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Options.java
new file mode 100644
index 0000000..26a6ba9
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Options.java
@@ -0,0 +1,707 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.audio.AudioCodec;
+import com.genymobile.scrcpy.audio.AudioSource;
+import com.genymobile.scrcpy.device.Device;
+import com.genymobile.scrcpy.model.CodecOption;
+import com.genymobile.scrcpy.model.NewDisplay;
+import com.genymobile.scrcpy.model.Orientation;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.video.CameraAspectRatio;
+import com.genymobile.scrcpy.video.CameraFacing;
+import com.genymobile.scrcpy.video.VideoCodec;
+import com.genymobile.scrcpy.video.VideoSource;
+import com.genymobile.scrcpy.wrappers.WindowManager;
+
+import android.graphics.Rect;
+import android.util.Pair;
+
+import java.util.List;
+import java.util.Locale;
+
+public class Options {
+
+ private Ln.Level logLevel = Ln.Level.DEBUG;
+ private int scid = -1; // 31-bit non-negative value, or -1
+ private boolean video = true;
+ private boolean audio = true;
+ private int maxSize;
+ private int minSizeAlignment = 1;
+ private VideoCodec videoCodec = VideoCodec.H264;
+ private AudioCodec audioCodec = AudioCodec.OPUS;
+ private VideoSource videoSource = VideoSource.DISPLAY;
+ private AudioSource audioSource = AudioSource.OUTPUT;
+ private boolean audioDup;
+ private int videoBitRate = 8000000;
+ private int audioBitRate = 128000;
+ private float maxFps;
+ private float angle;
+ private boolean tunnelForward;
+ private Rect crop;
+ private boolean control = true;
+ private int displayId;
+ private String cameraId;
+ private Size cameraSize;
+ private CameraFacing cameraFacing;
+ private CameraAspectRatio cameraAspectRatio;
+ private float cameraZoom = 1;
+ private int cameraFps;
+ private boolean cameraHighSpeed;
+ private boolean cameraTorch;
+ private boolean showTouches;
+ private boolean stayAwake;
+ private int screenOffTimeout = -1;
+ private int displayImePolicy = -1;
+ private List videoCodecOptions;
+ private List audioCodecOptions;
+
+ private String videoEncoder;
+ private String audioEncoder;
+ private boolean powerOffScreenOnClose;
+ private boolean clipboardAutosync = true;
+ private boolean downsizeOnError = true;
+ private boolean cleanup = true;
+ private boolean powerOn = true;
+
+ private NewDisplay newDisplay;
+ private boolean vdDestroyContent = true;
+ private boolean vdSystemDecorations = true;
+ private boolean flexDisplay;
+
+ private boolean keepActive;
+ private boolean ignoreVideoEncoderConstraints;
+
+ private Orientation.Lock captureOrientationLock = Orientation.Lock.Unlocked;
+ private Orientation captureOrientation = Orientation.Orient0;
+
+ private boolean listEncoders;
+ private boolean listDisplays;
+ private boolean listCameras;
+ private boolean listCameraSizes;
+ private boolean listApps;
+
+ // Options not used by the scrcpy client, but useful to use scrcpy-server directly
+ private boolean sendDeviceMeta = true; // send device name and size
+ private boolean sendFrameMeta = true; // send PTS so that the client may record properly
+ private boolean sendDummyByte = true; // write a byte on start to detect connection issues
+ private boolean sendStreamMeta = true; // write the stream metadata (codec and session)
+
+ public Ln.Level getLogLevel() {
+ return logLevel;
+ }
+
+ public int getScid() {
+ return scid;
+ }
+
+ public boolean getVideo() {
+ return video;
+ }
+
+ public boolean getAudio() {
+ return audio;
+ }
+
+ public int getMaxSize() {
+ return maxSize;
+ }
+
+ public int getMinSizeAlignment() {
+ return minSizeAlignment;
+ }
+
+ public VideoCodec getVideoCodec() {
+ return videoCodec;
+ }
+
+ public AudioCodec getAudioCodec() {
+ return audioCodec;
+ }
+
+ public VideoSource getVideoSource() {
+ return videoSource;
+ }
+
+ public AudioSource getAudioSource() {
+ return audioSource;
+ }
+
+ public boolean getAudioDup() {
+ return audioDup;
+ }
+
+ public int getVideoBitRate() {
+ return videoBitRate;
+ }
+
+ public int getAudioBitRate() {
+ return audioBitRate;
+ }
+
+ public float getMaxFps() {
+ return maxFps;
+ }
+
+ public float getAngle() {
+ return angle;
+ }
+
+ public boolean isTunnelForward() {
+ return tunnelForward;
+ }
+
+ public Rect getCrop() {
+ return crop;
+ }
+
+ public boolean getControl() {
+ return control;
+ }
+
+ public int getDisplayId() {
+ return displayId;
+ }
+
+ public String getCameraId() {
+ return cameraId;
+ }
+
+ public Size getCameraSize() {
+ return cameraSize;
+ }
+
+ public CameraFacing getCameraFacing() {
+ return cameraFacing;
+ }
+
+ public CameraAspectRatio getCameraAspectRatio() {
+ return cameraAspectRatio;
+ }
+
+ public float getCameraZoom() {
+ return cameraZoom;
+ }
+
+ public int getCameraFps() {
+ return cameraFps;
+ }
+
+ public boolean getCameraHighSpeed() {
+ return cameraHighSpeed;
+ }
+
+ public boolean getCameraTorch() {
+ return cameraTorch;
+ }
+
+ public boolean getShowTouches() {
+ return showTouches;
+ }
+
+ public boolean getStayAwake() {
+ return stayAwake;
+ }
+
+ public int getScreenOffTimeout() {
+ return screenOffTimeout;
+ }
+
+ public int getDisplayImePolicy() {
+ return displayImePolicy;
+ }
+
+ public List getVideoCodecOptions() {
+ return videoCodecOptions;
+ }
+
+ public List getAudioCodecOptions() {
+ return audioCodecOptions;
+ }
+
+ public String getVideoEncoder() {
+ return videoEncoder;
+ }
+
+ public String getAudioEncoder() {
+ return audioEncoder;
+ }
+
+ public boolean getPowerOffScreenOnClose() {
+ return this.powerOffScreenOnClose;
+ }
+
+ public boolean getClipboardAutosync() {
+ return clipboardAutosync;
+ }
+
+ public boolean getDownsizeOnError() {
+ return downsizeOnError;
+ }
+
+ public boolean getCleanup() {
+ return cleanup;
+ }
+
+ public boolean getPowerOn() {
+ return powerOn;
+ }
+
+ public NewDisplay getNewDisplay() {
+ return newDisplay;
+ }
+
+ public Orientation getCaptureOrientation() {
+ return captureOrientation;
+ }
+
+ public Orientation.Lock getCaptureOrientationLock() {
+ return captureOrientationLock;
+ }
+
+ public boolean getVDDestroyContent() {
+ return vdDestroyContent;
+ }
+
+ public boolean getVDSystemDecorations() {
+ return vdSystemDecorations;
+ }
+
+ public boolean getKeepActive() {
+ return keepActive;
+ }
+
+ public boolean getFlexDisplay() {
+ return flexDisplay;
+ }
+
+ public boolean getIgnoreVideoEncoderConstraints() {
+ return ignoreVideoEncoderConstraints;
+ }
+
+ public boolean getList() {
+ return listEncoders || listDisplays || listCameras || listCameraSizes || listApps;
+ }
+
+ public boolean getListEncoders() {
+ return listEncoders;
+ }
+
+ public boolean getListDisplays() {
+ return listDisplays;
+ }
+
+ public boolean getListCameras() {
+ return listCameras;
+ }
+
+ public boolean getListCameraSizes() {
+ return listCameraSizes;
+ }
+
+ public boolean getListApps() {
+ return listApps;
+ }
+
+ public boolean getSendDeviceMeta() {
+ return sendDeviceMeta;
+ }
+
+ public boolean getSendFrameMeta() {
+ return sendFrameMeta;
+ }
+
+ public boolean getSendDummyByte() {
+ return sendDummyByte;
+ }
+
+ public boolean getSendStreamMeta() {
+ return sendStreamMeta;
+ }
+
+ @SuppressWarnings("MethodLength")
+ public static Options parse(String... args) {
+ if (args.length < 1) {
+ throw new IllegalArgumentException("Missing client version");
+ }
+
+ String clientVersion = args[0];
+ if (!clientVersion.equals(BuildConfig.VERSION_NAME)) {
+ throw new IllegalArgumentException(
+ "The server version (" + BuildConfig.VERSION_NAME + ") does not match the client " + "(" + clientVersion + ")");
+ }
+
+ Options options = new Options();
+
+ for (int i = 1; i < args.length; ++i) {
+ String arg = args[i];
+ int equalIndex = arg.indexOf('=');
+ if (equalIndex == -1) {
+ throw new IllegalArgumentException("Invalid key=value pair: \"" + arg + "\"");
+ }
+ String key = arg.substring(0, equalIndex);
+ String value = arg.substring(equalIndex + 1);
+ switch (key) {
+ case "scid":
+ int scid = Integer.parseInt(value, 0x10);
+ if (scid < -1) {
+ throw new IllegalArgumentException("scid may not be negative (except -1 for 'none'): " + scid);
+ }
+ options.scid = scid;
+ break;
+ case "log_level":
+ options.logLevel = Ln.Level.valueOf(value.toUpperCase(Locale.ENGLISH));
+ break;
+ case "video":
+ options.video = Boolean.parseBoolean(value);
+ break;
+ case "audio":
+ options.audio = Boolean.parseBoolean(value);
+ break;
+ case "video_codec":
+ VideoCodec videoCodec = VideoCodec.findByName(value);
+ if (videoCodec == null) {
+ throw new IllegalArgumentException("Video codec " + value + " not supported");
+ }
+ options.videoCodec = videoCodec;
+ break;
+ case "audio_codec":
+ AudioCodec audioCodec = AudioCodec.findByName(value);
+ if (audioCodec == null) {
+ throw new IllegalArgumentException("Audio codec " + value + " not supported");
+ }
+ options.audioCodec = audioCodec;
+ break;
+ case "video_source":
+ VideoSource videoSource = VideoSource.findByName(value);
+ if (videoSource == null) {
+ throw new IllegalArgumentException("Video source " + value + " not supported");
+ }
+ options.videoSource = videoSource;
+ break;
+ case "audio_source":
+ AudioSource audioSource = AudioSource.findByName(value);
+ if (audioSource == null) {
+ throw new IllegalArgumentException("Audio source " + value + " not supported");
+ }
+ options.audioSource = audioSource;
+ break;
+ case "audio_dup":
+ options.audioDup = Boolean.parseBoolean(value);
+ break;
+ case "max_size":
+ options.maxSize = Integer.parseInt(value);
+ break;
+ case "min_size_alignment":
+ int align = Integer.parseInt(value);
+ if (align < 1 || align > 16 || (align & (align - 1)) != 0) {
+ throw new IllegalArgumentException("min_size_alignment (" + align + ") must be 1, 2, 4, 8 or 16");
+ }
+ options.minSizeAlignment = align;
+ break;
+ case "video_bit_rate":
+ options.videoBitRate = Integer.parseInt(value);
+ break;
+ case "audio_bit_rate":
+ options.audioBitRate = Integer.parseInt(value);
+ break;
+ case "max_fps":
+ options.maxFps = parseFloat("max_fps", value);
+ break;
+ case "angle":
+ options.angle = parseFloat("angle", value);
+ break;
+ case "tunnel_forward":
+ options.tunnelForward = Boolean.parseBoolean(value);
+ break;
+ case "crop":
+ if (!value.isEmpty()) {
+ options.crop = parseCrop(value);
+ }
+ break;
+ case "control":
+ options.control = Boolean.parseBoolean(value);
+ break;
+ case "display_id":
+ options.displayId = Integer.parseInt(value);
+ break;
+ case "show_touches":
+ options.showTouches = Boolean.parseBoolean(value);
+ break;
+ case "stay_awake":
+ options.stayAwake = Boolean.parseBoolean(value);
+ break;
+ case "screen_off_timeout":
+ options.screenOffTimeout = Integer.parseInt(value);
+ if (options.screenOffTimeout < -1) {
+ throw new IllegalArgumentException("Invalid screen off timeout: " + options.screenOffTimeout);
+ }
+ break;
+ case "video_codec_options":
+ options.videoCodecOptions = CodecOption.parse(value);
+ break;
+ case "audio_codec_options":
+ options.audioCodecOptions = CodecOption.parse(value);
+ break;
+ case "video_encoder":
+ if (!value.isEmpty()) {
+ options.videoEncoder = value;
+ }
+ break;
+ case "audio_encoder":
+ if (!value.isEmpty()) {
+ options.audioEncoder = value;
+ }
+ break;
+ case "power_off_on_close":
+ options.powerOffScreenOnClose = Boolean.parseBoolean(value);
+ break;
+ case "clipboard_autosync":
+ options.clipboardAutosync = Boolean.parseBoolean(value);
+ break;
+ case "downsize_on_error":
+ options.downsizeOnError = Boolean.parseBoolean(value);
+ break;
+ case "cleanup":
+ options.cleanup = Boolean.parseBoolean(value);
+ break;
+ case "power_on":
+ options.powerOn = Boolean.parseBoolean(value);
+ break;
+ case "list_encoders":
+ options.listEncoders = Boolean.parseBoolean(value);
+ break;
+ case "list_displays":
+ options.listDisplays = Boolean.parseBoolean(value);
+ break;
+ case "list_cameras":
+ options.listCameras = Boolean.parseBoolean(value);
+ break;
+ case "list_camera_sizes":
+ options.listCameraSizes = Boolean.parseBoolean(value);
+ break;
+ case "list_apps":
+ options.listApps = Boolean.parseBoolean(value);
+ break;
+ case "camera_id":
+ if (!value.isEmpty()) {
+ options.cameraId = value;
+ }
+ break;
+ case "camera_size":
+ if (!value.isEmpty()) {
+ options.cameraSize = parseSize(value);
+ }
+ break;
+ case "camera_facing":
+ if (!value.isEmpty()) {
+ CameraFacing facing = CameraFacing.findByName(value);
+ if (facing == null) {
+ throw new IllegalArgumentException("Camera facing " + value + " not supported");
+ }
+ options.cameraFacing = facing;
+ }
+ break;
+ case "camera_ar":
+ if (!value.isEmpty()) {
+ options.cameraAspectRatio = parseCameraAspectRatio(value);
+ }
+ break;
+ case "camera_zoom":
+ if (!value.isEmpty()) {
+ options.cameraZoom = Float.parseFloat(value);
+ }
+ break;
+ case "camera_fps":
+ options.cameraFps = Integer.parseInt(value);
+ break;
+ case "camera_high_speed":
+ options.cameraHighSpeed = Boolean.parseBoolean(value);
+ break;
+ case "camera_torch":
+ options.cameraTorch = Boolean.parseBoolean(value);
+ break;
+ case "new_display":
+ options.newDisplay = parseNewDisplay(value);
+ break;
+ case "vd_destroy_content":
+ options.vdDestroyContent = Boolean.parseBoolean(value);
+ break;
+ case "vd_system_decorations":
+ options.vdSystemDecorations = Boolean.parseBoolean(value);
+ break;
+ case "flex_display":
+ options.flexDisplay = Boolean.parseBoolean(value);
+ break;
+ case "capture_orientation":
+ Pair pair = parseCaptureOrientation(value);
+ options.captureOrientationLock = pair.first;
+ options.captureOrientation = pair.second;
+ break;
+ case "display_ime_policy":
+ options.displayImePolicy = parseDisplayImePolicy(value);
+ break;
+ case "keep_active":
+ options.keepActive = Boolean.parseBoolean(value);
+ break;
+ case "ignore_video_encoder_constraints":
+ options.ignoreVideoEncoderConstraints = Boolean.parseBoolean(value);
+ break;
+ case "send_device_meta":
+ options.sendDeviceMeta = Boolean.parseBoolean(value);
+ break;
+ case "send_frame_meta":
+ options.sendFrameMeta = Boolean.parseBoolean(value);
+ break;
+ case "send_dummy_byte":
+ options.sendDummyByte = Boolean.parseBoolean(value);
+ break;
+ case "send_stream_meta":
+ options.sendStreamMeta = Boolean.parseBoolean(value);
+ break;
+ case "raw_stream":
+ boolean rawStream = Boolean.parseBoolean(value);
+ if (rawStream) {
+ options.sendDeviceMeta = false;
+ options.sendFrameMeta = false;
+ options.sendDummyByte = false;
+ options.sendStreamMeta = false;
+ }
+ break;
+ default:
+ Ln.w("Unknown server option: " + key);
+ break;
+ }
+ }
+
+ if (options.newDisplay != null) {
+ assert options.displayId == 0 : "Must not set both displayId and newDisplay";
+ options.displayId = Device.DISPLAY_ID_NONE;
+ }
+
+ return options;
+ }
+
+ private static Rect parseCrop(String crop) {
+ // 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]);
+ if (width <= 0 || height <= 0) {
+ throw new IllegalArgumentException("Invalid crop size: " + width + "x" + height);
+ }
+ int x = Integer.parseInt(tokens[2]);
+ int y = Integer.parseInt(tokens[3]);
+ if (x < 0 || y < 0) {
+ throw new IllegalArgumentException("Invalid crop offset: " + x + ":" + y);
+ }
+ return new Rect(x, y, x + width, y + height);
+ }
+
+ private static Size parseSize(String size) {
+ // input format: "x"
+ String[] tokens = size.split("x");
+ if (tokens.length != 2) {
+ throw new IllegalArgumentException("Invalid size format (expected x): \"" + size + "\"");
+ }
+ int width = Integer.parseInt(tokens[0]);
+ int height = Integer.parseInt(tokens[1]);
+ if (width <= 0 || height <= 0) {
+ throw new IllegalArgumentException("Invalid non-positive size dimension: \"" + size + "\"");
+ }
+ return new Size(width, height);
+ }
+
+ private static CameraAspectRatio parseCameraAspectRatio(String ar) {
+ if ("sensor".equals(ar)) {
+ return CameraAspectRatio.sensorAspectRatio();
+ }
+
+ String[] tokens = ar.split(":");
+ if (tokens.length == 2) {
+ int w = Integer.parseInt(tokens[0]);
+ int h = Integer.parseInt(tokens[1]);
+ return CameraAspectRatio.fromFraction(w, h);
+ }
+
+ float floatAr = Float.parseFloat(tokens[0]);
+ return CameraAspectRatio.fromFloat(floatAr);
+ }
+
+ private static float parseFloat(String key, String value) {
+ try {
+ return Float.parseFloat(value);
+ } catch (NumberFormatException e) {
+ throw new IllegalArgumentException("Invalid float value for " + key + ": \"" + value + "\"");
+ }
+ }
+
+ private static NewDisplay parseNewDisplay(String newDisplay) {
+ // Possible inputs:
+ // - "" (empty string)
+ // - "x/"
+ // - "x"
+ // - "/"
+ if (newDisplay.isEmpty()) {
+ return new NewDisplay();
+ }
+
+ String[] tokens = newDisplay.split("/");
+
+ Size size;
+ if (!tokens[0].isEmpty()) {
+ size = parseSize(tokens[0]);
+ } else {
+ size = null;
+ }
+
+ int dpi;
+ if (tokens.length >= 2) {
+ dpi = Integer.parseInt(tokens[1]);
+ if (dpi <= 0) {
+ throw new IllegalArgumentException("Invalid non-positive dpi: " + tokens[1]);
+ }
+ } else {
+ dpi = 0;
+ }
+
+ return new NewDisplay(size, dpi);
+ }
+
+ private static Pair parseCaptureOrientation(String value) {
+ if (value.isEmpty()) {
+ throw new IllegalArgumentException("Empty capture orientation string");
+ }
+
+ Orientation.Lock lock;
+ if (value.charAt(0) == '@') {
+ // Consume '@'
+ value = value.substring(1);
+ if (value.isEmpty()) {
+ // Only '@': lock to the initial orientation (orientation is unused)
+ return Pair.create(Orientation.Lock.LockedInitial, Orientation.Orient0);
+ }
+ lock = Orientation.Lock.LockedValue;
+ } else {
+ lock = Orientation.Lock.Unlocked;
+ }
+
+ return Pair.create(lock, Orientation.getByName(value));
+ }
+
+ private static int parseDisplayImePolicy(String value) {
+ switch (value) {
+ case "local":
+ return WindowManager.DISPLAY_IME_POLICY_LOCAL;
+ case "fallback":
+ return WindowManager.DISPLAY_IME_POLICY_FALLBACK_DISPLAY;
+ case "hide":
+ return WindowManager.DISPLAY_IME_POLICY_HIDE;
+ default:
+ throw new IllegalArgumentException("Invalid display IME policy: " + value);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/Server.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Server.java
new file mode 100644
index 0000000..d7d1a76
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Server.java
@@ -0,0 +1,291 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.audio.AudioCapture;
+import com.genymobile.scrcpy.audio.AudioCodec;
+import com.genymobile.scrcpy.audio.AudioDirectCapture;
+import com.genymobile.scrcpy.audio.AudioEncoder;
+import com.genymobile.scrcpy.audio.AudioPlaybackCapture;
+import com.genymobile.scrcpy.audio.AudioRawRecorder;
+import com.genymobile.scrcpy.audio.AudioSource;
+import com.genymobile.scrcpy.control.ControlChannel;
+import com.genymobile.scrcpy.control.Controller;
+import com.genymobile.scrcpy.device.DesktopConnection;
+import com.genymobile.scrcpy.device.Device;
+import com.genymobile.scrcpy.device.Streamer;
+import com.genymobile.scrcpy.model.ConfigurationException;
+import com.genymobile.scrcpy.model.NewDisplay;
+import com.genymobile.scrcpy.opengl.OpenGLRunner;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.util.LogUtils;
+import com.genymobile.scrcpy.video.CameraCapture;
+import com.genymobile.scrcpy.video.NewDisplayCapture;
+import com.genymobile.scrcpy.video.ScreenCapture;
+import com.genymobile.scrcpy.video.SurfaceCapture;
+import com.genymobile.scrcpy.video.SurfaceEncoder;
+import com.genymobile.scrcpy.video.VideoSource;
+
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.os.Looper;
+import android.system.Os;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+
+public final class Server {
+
+ public static final String SERVER_PATH;
+
+ static {
+ String[] classPaths = System.getProperty("java.class.path").split(File.pathSeparator);
+ // By convention, scrcpy is always executed with the absolute path of scrcpy-server.jar as the first item in the classpath
+ SERVER_PATH = classPaths[0];
+ }
+
+ private static class Completion {
+ private int running;
+ private boolean fatalError;
+
+ Completion(int running) {
+ this.running = running;
+ }
+
+ synchronized void addCompleted(boolean fatalError) {
+ --running;
+ if (fatalError) {
+ this.fatalError = true;
+ }
+ if (running == 0 || this.fatalError) {
+ Looper.getMainLooper().quitSafely();
+ }
+ }
+ }
+
+ private Server() {
+ // not instantiable
+ }
+
+ private static void scrcpy(Options options) throws IOException, ConfigurationException {
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_31_ANDROID_12 && options.getVideoSource() == VideoSource.CAMERA) {
+ Ln.e("Camera mirroring is not supported before Android 12");
+ throw new ConfigurationException("Camera mirroring is not supported");
+ }
+
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
+ if (options.getNewDisplay() != null) {
+ Ln.e("New virtual display is not supported before Android 10");
+ throw new ConfigurationException("New virtual display is not supported");
+ }
+ if (options.getDisplayImePolicy() != -1) {
+ Ln.e("Display IME policy is not supported before Android 10");
+ throw new ConfigurationException("Display IME policy is not supported");
+ }
+ }
+
+ CleanUp cleanUp = null;
+
+ if (options.getCleanup()) {
+ cleanUp = CleanUp.start(options);
+ }
+
+ int scid = options.getScid();
+ boolean tunnelForward = options.isTunnelForward();
+ boolean control = options.getControl();
+ boolean video = options.getVideo();
+ boolean audio = options.getAudio();
+ boolean sendDummyByte = options.getSendDummyByte();
+
+ Workarounds.apply();
+
+ List asyncProcessors = new ArrayList<>();
+
+ DesktopConnection connection = DesktopConnection.open(scid, tunnelForward, video, audio, control, sendDummyByte);
+ try {
+ if (options.getSendDeviceMeta()) {
+ connection.sendDeviceMeta(Device.getDeviceName());
+ }
+
+ Controller controller = null;
+
+ if (control) {
+ ControlChannel controlChannel = connection.getControlChannel();
+ controller = new Controller(controlChannel, cleanUp, options);
+ asyncProcessors.add(controller);
+ }
+
+ if (audio) {
+ AudioCodec audioCodec = options.getAudioCodec();
+ AudioSource audioSource = options.getAudioSource();
+ AudioCapture audioCapture;
+ if (audioSource.isDirect()) {
+ audioCapture = new AudioDirectCapture(audioSource);
+ } else {
+ audioCapture = new AudioPlaybackCapture(options.getAudioDup());
+ }
+
+ Streamer audioStreamer = new Streamer(connection.getAudioFd(), audioCodec, options.getSendStreamMeta(), options.getSendFrameMeta());
+ AsyncProcessor audioRecorder;
+ if (audioCodec == AudioCodec.RAW) {
+ audioRecorder = new AudioRawRecorder(audioCapture, audioStreamer);
+ } else {
+ audioRecorder = new AudioEncoder(audioCapture, audioStreamer, options);
+ }
+ asyncProcessors.add(audioRecorder);
+ }
+
+ if (video) {
+ Streamer videoStreamer = new Streamer(connection.getVideoFd(), options.getVideoCodec(), options.getSendStreamMeta(),
+ options.getSendFrameMeta());
+ SurfaceCapture surfaceCapture;
+ if (options.getVideoSource() == VideoSource.DISPLAY) {
+ NewDisplay newDisplay = options.getNewDisplay();
+ if (newDisplay != null) {
+ surfaceCapture = new NewDisplayCapture(controller, options);
+ } else {
+ assert options.getDisplayId() != Device.DISPLAY_ID_NONE;
+ surfaceCapture = new ScreenCapture(controller, options);
+ }
+ } else {
+ surfaceCapture = new CameraCapture(options);
+ }
+ SurfaceEncoder surfaceEncoder = new SurfaceEncoder(surfaceCapture, videoStreamer, options);
+ asyncProcessors.add(surfaceEncoder);
+
+ if (controller != null) {
+ controller.setSurfaceCapture(surfaceCapture);
+ }
+ }
+
+ Completion completion = new Completion(asyncProcessors.size());
+ for (AsyncProcessor asyncProcessor : asyncProcessors) {
+ asyncProcessor.start((fatalError) -> {
+ completion.addCompleted(fatalError);
+ });
+ }
+
+ Looper.loop(); // interrupted by the Completion implementation
+ } finally {
+ if (cleanUp != null) {
+ cleanUp.interrupt();
+ }
+ for (AsyncProcessor asyncProcessor : asyncProcessors) {
+ asyncProcessor.stop();
+ }
+
+ connection.shutdown();
+
+ try {
+ if (cleanUp != null) {
+ cleanUp.join();
+ }
+ for (AsyncProcessor asyncProcessor : asyncProcessors) {
+ asyncProcessor.join();
+ }
+
+ OpenGLRunner.shutdown();
+ } catch (InterruptedException e) {
+ // ignore
+ }
+
+ connection.close();
+ }
+ }
+
+ private static void prepareMainLooper() {
+ // Like Looper.prepareMainLooper(), but with quitAllowed set to true
+ Looper.prepare();
+ synchronized (Looper.class) {
+ try {
+ @SuppressLint("DiscouragedPrivateApi")
+ Field field = Looper.class.getDeclaredField("sMainLooper");
+ field.setAccessible(true);
+ field.set(null, Looper.myLooper());
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError(e);
+ }
+ }
+ }
+
+ public static void main(String... args) {
+ int status = 0;
+ try {
+ internalMain(args);
+ } catch (Throwable t) {
+ Ln.e(t.getMessage(), t);
+ status = 1;
+ } finally {
+ // By default, the Java process exits when all non-daemon threads are terminated.
+ // The Android SDK might start some non-daemon threads internally, preventing the scrcpy server to exit.
+ // So force the process to exit explicitly.
+ System.exit(status);
+ }
+ }
+
+ private static void internalMain(String... args) throws Exception {
+ Thread.UncaughtExceptionHandler defaultHandler = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
+ Ln.e("Exception on thread " + t, e);
+ if (defaultHandler != null) {
+ defaultHandler.uncaughtException(t, e);
+ }
+ });
+
+ dropRootPrivileges();
+
+ prepareMainLooper();
+
+ Options options = Options.parse(args);
+
+ Ln.disableSystemStreams();
+ Ln.initLogLevel(options.getLogLevel());
+
+ Ln.i("Device: [" + Build.MANUFACTURER + "] " + Build.BRAND + " " + Build.MODEL + " (Android " + Build.VERSION.RELEASE + ")");
+
+ if (options.getList()) {
+ if (options.getCleanup()) {
+ CleanUp.unlinkSelf();
+ }
+
+ if (options.getListEncoders()) {
+ Ln.i(LogUtils.buildVideoEncoderListMessage());
+ Ln.i(LogUtils.buildAudioEncoderListMessage());
+ }
+ if (options.getListDisplays()) {
+ Ln.i(LogUtils.buildDisplayListMessage());
+ }
+ if (options.getListCameras() || options.getListCameraSizes()) {
+ Workarounds.apply();
+ Ln.i(LogUtils.buildCameraListMessage(options.getListCameraSizes()));
+ }
+ if (options.getListApps()) {
+ Workarounds.apply();
+ Ln.i("Processing Android apps... (this may take some time)");
+ Ln.i(LogUtils.buildAppListMessage());
+ }
+ // Just print the requested data, do not mirror
+ return;
+ }
+
+ try {
+ scrcpy(options);
+ } catch (ConfigurationException e) {
+ // Do not print stack trace, a user-friendly error-message has already been logged
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private static void dropRootPrivileges() {
+ try {
+ if (Os.getuid() == 0) {
+ // Copy-paste does not work with root user
+ //
+ Os.setuid(2000);
+ }
+ } catch (Exception e) {
+ Ln.w("Cannot set UID", e);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/Workarounds.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Workarounds.java
new file mode 100644
index 0000000..1e012de
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/Workarounds.java
@@ -0,0 +1,296 @@
+package com.genymobile.scrcpy;
+
+import com.genymobile.scrcpy.audio.AudioCaptureException;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.Application;
+import android.app.Instrumentation;
+import android.content.AttributionSource;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.media.AudioAttributes;
+import android.media.AudioManager;
+import android.media.AudioRecord;
+import android.os.Build;
+import android.os.Looper;
+import android.os.Parcel;
+
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+@SuppressLint("PrivateApi,BlockedPrivateApi,SoonBlockedPrivateApi,DiscouragedPrivateApi")
+public final class Workarounds {
+
+ private static final Class> ACTIVITY_THREAD_CLASS;
+ private static final Object ACTIVITY_THREAD;
+
+ static {
+ try {
+ // ActivityThread activityThread = new ActivityThread();
+ ACTIVITY_THREAD_CLASS = Class.forName("android.app.ActivityThread");
+ Constructor> activityThreadConstructor = ACTIVITY_THREAD_CLASS.getDeclaredConstructor();
+ activityThreadConstructor.setAccessible(true);
+ ACTIVITY_THREAD = activityThreadConstructor.newInstance();
+
+ // ActivityThread.sCurrentActivityThread = activityThread;
+ Field sCurrentActivityThreadField = ACTIVITY_THREAD_CLASS.getDeclaredField("sCurrentActivityThread");
+ sCurrentActivityThreadField.setAccessible(true);
+ sCurrentActivityThreadField.set(null, ACTIVITY_THREAD);
+
+ // activityThread.mSystemThread = true;
+ Field mSystemThreadField = ACTIVITY_THREAD_CLASS.getDeclaredField("mSystemThread");
+ mSystemThreadField.setAccessible(true);
+ mSystemThreadField.setBoolean(ACTIVITY_THREAD, true);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private Workarounds() {
+ // not instantiable
+ }
+
+ public static void apply() {
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) {
+ // On some Samsung devices, DisplayManagerGlobal.getDisplayInfoLocked() calls ActivityThread.currentActivityThread().getConfiguration(),
+ // which requires a non-null ConfigurationController.
+ // ConfigurationController was introduced in Android 12, so do not attempt to set it on lower versions.
+ //
+ // Must be called before fillAppContext() because it is necessary to get a valid system context.
+ fillConfigurationController();
+ }
+
+ // On ONYX devices, fillAppInfo() breaks video mirroring:
+ //
+ boolean mustFillAppInfo = !Build.BRAND.equalsIgnoreCase("ONYX");
+
+ if (mustFillAppInfo) {
+ fillAppInfo();
+ }
+
+ fillAppContext();
+ }
+
+ private static void fillAppInfo() {
+ try {
+ // ActivityThread.AppBindData appBindData = new ActivityThread.AppBindData();
+ Class> appBindDataClass = Class.forName("android.app.ActivityThread$AppBindData");
+ Constructor> appBindDataConstructor = appBindDataClass.getDeclaredConstructor();
+ appBindDataConstructor.setAccessible(true);
+ Object appBindData = appBindDataConstructor.newInstance();
+
+ ApplicationInfo applicationInfo = new ApplicationInfo();
+ applicationInfo.packageName = FakeContext.PACKAGE_NAME;
+
+ // appBindData.appInfo = applicationInfo;
+ Field appInfoField = appBindDataClass.getDeclaredField("appInfo");
+ appInfoField.setAccessible(true);
+ appInfoField.set(appBindData, applicationInfo);
+
+ // activityThread.mBoundApplication = appBindData;
+ Field mBoundApplicationField = ACTIVITY_THREAD_CLASS.getDeclaredField("mBoundApplication");
+ mBoundApplicationField.setAccessible(true);
+ mBoundApplicationField.set(ACTIVITY_THREAD, appBindData);
+ } catch (Throwable throwable) {
+ // this is a workaround, so failing is not an error
+ Ln.d("Could not fill app info: " + throwable.getMessage());
+ }
+ }
+
+ private static void fillAppContext() {
+ try {
+ Application app = Instrumentation.newApplication(Application.class, FakeContext.get());
+
+ // activityThread.mInitialApplication = app;
+ Field mInitialApplicationField = ACTIVITY_THREAD_CLASS.getDeclaredField("mInitialApplication");
+ mInitialApplicationField.setAccessible(true);
+ mInitialApplicationField.set(ACTIVITY_THREAD, app);
+ } catch (Throwable throwable) {
+ // this is a workaround, so failing is not an error
+ Ln.d("Could not fill app context: " + throwable.getMessage());
+ }
+ }
+
+ private static void fillConfigurationController() {
+ try {
+ Class> configurationControllerClass = Class.forName("android.app.ConfigurationController");
+ Class> activityThreadInternalClass = Class.forName("android.app.ActivityThreadInternal");
+
+ // configurationController = new ConfigurationController(ACTIVITY_THREAD);
+ Constructor> configurationControllerConstructor = configurationControllerClass.getDeclaredConstructor(activityThreadInternalClass);
+ configurationControllerConstructor.setAccessible(true);
+ Object configurationController = configurationControllerConstructor.newInstance(ACTIVITY_THREAD);
+
+ // ACTIVITY_THREAD.mConfigurationController = configurationController;
+ Field configurationControllerField = ACTIVITY_THREAD_CLASS.getDeclaredField("mConfigurationController");
+ configurationControllerField.setAccessible(true);
+ configurationControllerField.set(ACTIVITY_THREAD, configurationController);
+ } catch (Throwable throwable) {
+ Ln.d("Could not fill configuration: " + throwable.getMessage());
+ }
+ }
+
+ static Context getSystemContext() {
+ try {
+ Method getSystemContextMethod = ACTIVITY_THREAD_CLASS.getDeclaredMethod("getSystemContext");
+ return (Context) getSystemContextMethod.invoke(ACTIVITY_THREAD);
+ } catch (Throwable throwable) {
+ // this is a workaround, so failing is not an error
+ Ln.d("Could not get system context: " + throwable.getMessage());
+ return null;
+ }
+ }
+
+ @TargetApi(AndroidVersions.API_30_ANDROID_11)
+ @SuppressLint("WrongConstant,MissingPermission")
+ public static AudioRecord createAudioRecord(int source, int sampleRate, int channelConfig, int channels, int channelMask, int encoding) throws
+ AudioCaptureException {
+ // Vivo (and maybe some other third-party ROMs) modified `AudioRecord`'s constructor, requiring `Context`s from real App environment.
+ //
+ // This method invokes the `AudioRecord(long nativeRecordInJavaObj)` constructor to create an empty `AudioRecord` instance, then uses
+ // reflections to initialize it like the normal constructor do (or the `AudioRecord.Builder.build()` method do).
+ // As a result, the modified code was not executed.
+ try {
+ // AudioRecord audioRecord = new AudioRecord(0L);
+ Constructor audioRecordConstructor = AudioRecord.class.getDeclaredConstructor(long.class);
+ audioRecordConstructor.setAccessible(true);
+ AudioRecord audioRecord = audioRecordConstructor.newInstance(0L);
+
+ // audioRecord.mRecordingState = RECORDSTATE_STOPPED;
+ Field mRecordingStateField = AudioRecord.class.getDeclaredField("mRecordingState");
+ mRecordingStateField.setAccessible(true);
+ mRecordingStateField.set(audioRecord, AudioRecord.RECORDSTATE_STOPPED);
+
+ Looper looper = Looper.myLooper();
+ if (looper == null) {
+ looper = Looper.getMainLooper();
+ }
+
+ // audioRecord.mInitializationLooper = looper;
+ Field mInitializationLooperField = AudioRecord.class.getDeclaredField("mInitializationLooper");
+ mInitializationLooperField.setAccessible(true);
+ mInitializationLooperField.set(audioRecord, looper);
+
+ // Create `AudioAttributes` with fixed capture preset
+ int capturePreset = source;
+ AudioAttributes.Builder audioAttributesBuilder = new AudioAttributes.Builder();
+ Method setInternalCapturePresetMethod = AudioAttributes.Builder.class.getMethod("setInternalCapturePreset", int.class);
+ setInternalCapturePresetMethod.invoke(audioAttributesBuilder, capturePreset);
+ AudioAttributes attributes = audioAttributesBuilder.build();
+
+ // audioRecord.mAudioAttributes = attributes;
+ Field mAudioAttributesField = AudioRecord.class.getDeclaredField("mAudioAttributes");
+ mAudioAttributesField.setAccessible(true);
+ mAudioAttributesField.set(audioRecord, attributes);
+
+ // audioRecord.audioParamCheck(capturePreset, sampleRate, encoding);
+ Method audioParamCheckMethod = AudioRecord.class.getDeclaredMethod("audioParamCheck", int.class, int.class, int.class);
+ audioParamCheckMethod.setAccessible(true);
+ audioParamCheckMethod.invoke(audioRecord, capturePreset, sampleRate, encoding);
+
+ // audioRecord.mChannelCount = channels
+ Field mChannelCountField = AudioRecord.class.getDeclaredField("mChannelCount");
+ mChannelCountField.setAccessible(true);
+ mChannelCountField.set(audioRecord, channels);
+
+ // audioRecord.mChannelMask = channelMask
+ Field mChannelMaskField = AudioRecord.class.getDeclaredField("mChannelMask");
+ mChannelMaskField.setAccessible(true);
+ mChannelMaskField.set(audioRecord, channelMask);
+
+ int minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, encoding);
+ int bufferSizeInBytes = minBufferSize * 8;
+
+ // audioRecord.audioBuffSizeCheck(bufferSizeInBytes)
+ Method audioBuffSizeCheckMethod = AudioRecord.class.getDeclaredMethod("audioBuffSizeCheck", int.class);
+ audioBuffSizeCheckMethod.setAccessible(true);
+ audioBuffSizeCheckMethod.invoke(audioRecord, bufferSizeInBytes);
+
+ final int channelIndexMask = 0;
+
+ int[] sampleRateArray = new int[]{sampleRate};
+ int[] session = new int[]{AudioManager.AUDIO_SESSION_ID_GENERATE};
+
+ int initResult;
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_31_ANDROID_12) {
+ // private native final int native_setup(Object audiorecord_this,
+ // Object /*AudioAttributes*/ attributes,
+ // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat,
+ // int buffSizeInBytes, int[] sessionId, String opPackageName,
+ // long nativeRecordInJavaObj);
+ Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class, int.class,
+ int.class, int.class, int.class, int[].class, String.class, long.class);
+ nativeSetupMethod.setAccessible(true);
+ initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes, sampleRateArray,
+ channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session, FakeContext.get().getOpPackageName(),
+ 0L);
+ } else {
+ // Assume `context` is never `null`
+ AttributionSource attributionSource = FakeContext.get().getAttributionSource();
+
+ // Assume `attributionSource.getPackageName()` is never null
+
+ // ScopedParcelState attributionSourceState = attributionSource.asScopedParcelState()
+ Method asScopedParcelStateMethod = AttributionSource.class.getDeclaredMethod("asScopedParcelState");
+ asScopedParcelStateMethod.setAccessible(true);
+
+ try (AutoCloseable attributionSourceState = (AutoCloseable) asScopedParcelStateMethod.invoke(attributionSource)) {
+ Method getParcelMethod = attributionSourceState.getClass().getDeclaredMethod("getParcel");
+ Parcel attributionSourceParcel = (Parcel) getParcelMethod.invoke(attributionSourceState);
+
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_34_ANDROID_14) {
+ // private native int native_setup(Object audiorecordThis,
+ // Object /*AudioAttributes*/ attributes,
+ // int[] sampleRate, int channelMask, int channelIndexMask, int audioFormat,
+ // int buffSizeInBytes, int[] sessionId, @NonNull Parcel attributionSource,
+ // long nativeRecordInJavaObj, int maxSharedAudioHistoryMs);
+ Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class,
+ int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class);
+ nativeSetupMethod.setAccessible(true);
+ initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes,
+ sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session,
+ attributionSourceParcel, 0L, 0);
+ } else {
+ // Android 14 added a new int parameter "halInputFlags"
+ //
+ Method nativeSetupMethod = AudioRecord.class.getDeclaredMethod("native_setup", Object.class, Object.class, int[].class,
+ int.class, int.class, int.class, int.class, int[].class, Parcel.class, long.class, int.class, int.class);
+ nativeSetupMethod.setAccessible(true);
+ initResult = (int) nativeSetupMethod.invoke(audioRecord, new WeakReference(audioRecord), attributes,
+ sampleRateArray, channelMask, channelIndexMask, audioRecord.getAudioFormat(), bufferSizeInBytes, session,
+ attributionSourceParcel, 0L, 0, 0);
+ }
+ }
+ }
+
+ if (initResult != AudioRecord.SUCCESS) {
+ Ln.e("Error code " + initResult + " when initializing native AudioRecord object.");
+ throw new RuntimeException("Cannot create AudioRecord");
+ }
+
+ // mSampleRate = sampleRate[0]
+ Field mSampleRateField = AudioRecord.class.getDeclaredField("mSampleRate");
+ mSampleRateField.setAccessible(true);
+ mSampleRateField.set(audioRecord, sampleRateArray[0]);
+
+ // audioRecord.mSessionId = session[0]
+ Field mSessionIdField = AudioRecord.class.getDeclaredField("mSessionId");
+ mSessionIdField.setAccessible(true);
+ mSessionIdField.set(audioRecord, session[0]);
+
+ // audioRecord.mState = AudioRecord.STATE_INITIALIZED
+ Field mStateField = AudioRecord.class.getDeclaredField("mState");
+ mStateField.setAccessible(true);
+ mStateField.set(audioRecord, AudioRecord.STATE_INITIALIZED);
+
+ return audioRecord;
+ } catch (Exception e) {
+ Ln.e("Cannot create AudioRecord", e);
+ throw new AudioCaptureException();
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCapture.java
new file mode 100644
index 0000000..62903f8
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCapture.java
@@ -0,0 +1,20 @@
+package com.genymobile.scrcpy.audio;
+
+import android.media.MediaCodec;
+
+import java.nio.ByteBuffer;
+
+public interface AudioCapture {
+ void checkCompatibility() throws AudioCaptureException;
+ void start() throws AudioCaptureException;
+ void stop();
+
+ /**
+ * Read a chunk of {@link AudioConfig#MAX_READ_SIZE} samples.
+ *
+ * @param outDirectBuffer The target buffer
+ * @param outBufferInfo The info to provide to MediaCodec
+ * @return the number of bytes actually read.
+ */
+ int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo);
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCaptureException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCaptureException.java
new file mode 100644
index 0000000..4b0b7e8
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCaptureException.java
@@ -0,0 +1,12 @@
+package com.genymobile.scrcpy.audio;
+
+/**
+ * Exception for any audio capture issue.
+ *
+ * This includes the case where audio capture failed on Android 11 specifically because the running App (Shell) was not in foreground.
+ *
+ * Its purpose is to disable audio without errors (that's why the exception is empty, any error message must be printed by the caller before
+ * throwing the exception).
+ */
+public class AudioCaptureException extends Exception {
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCodec.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCodec.java
new file mode 100644
index 0000000..5e20a2a
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioCodec.java
@@ -0,0 +1,51 @@
+package com.genymobile.scrcpy.audio;
+
+import com.genymobile.scrcpy.model.Codec;
+
+import android.media.MediaFormat;
+
+public enum AudioCodec implements Codec {
+ OPUS(0x6f_70_75_73, "opus", MediaFormat.MIMETYPE_AUDIO_OPUS),
+ AAC(0x00_61_61_63, "aac", MediaFormat.MIMETYPE_AUDIO_AAC),
+ FLAC(0x66_6c_61_63, "flac", MediaFormat.MIMETYPE_AUDIO_FLAC),
+ RAW(0x00_72_61_77, "raw", MediaFormat.MIMETYPE_AUDIO_RAW);
+
+ private final int id; // 4-byte ASCII representation of the name
+ private final String name;
+ private final String mimeType;
+
+ AudioCodec(int id, String name, String mimeType) {
+ this.id = id;
+ this.name = name;
+ this.mimeType = mimeType;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.AUDIO;
+ }
+
+ @Override
+ public int getId() {
+ return id;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ public static AudioCodec findByName(String name) {
+ for (AudioCodec codec : values()) {
+ if (codec.name.equals(name)) {
+ return codec;
+ }
+ }
+ return null;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioConfig.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioConfig.java
new file mode 100644
index 0000000..c77165a
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioConfig.java
@@ -0,0 +1,29 @@
+package com.genymobile.scrcpy.audio;
+
+import android.media.AudioFormat;
+
+public final class AudioConfig {
+ public static final int SAMPLE_RATE = 48000;
+ public static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_STEREO;
+ public static final int CHANNELS = 2;
+ public static final int CHANNEL_MASK = AudioFormat.CHANNEL_IN_LEFT | AudioFormat.CHANNEL_IN_RIGHT;
+ public static final int ENCODING = AudioFormat.ENCODING_PCM_16BIT;
+ public static final int BYTES_PER_SAMPLE = 2;
+
+ // Never read more than 1024 samples, even if the buffer is bigger (that would increase latency).
+ // A lower value is useless, since the system captures audio samples by blocks of 1024 (so for example if we read by blocks of 256 samples, we
+ // receive 4 successive blocks without waiting, then we wait for the 4 next ones).
+ public static final int MAX_READ_SIZE = 1024 * CHANNELS * BYTES_PER_SAMPLE;
+
+ private AudioConfig() {
+ // Not instantiable
+ }
+
+ public static AudioFormat createAudioFormat() {
+ AudioFormat.Builder builder = new AudioFormat.Builder();
+ builder.setEncoding(ENCODING);
+ builder.setSampleRate(SAMPLE_RATE);
+ builder.setChannelMask(CHANNEL_CONFIG);
+ return builder.build();
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioDirectCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioDirectCapture.java
new file mode 100644
index 0000000..bf870be
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioDirectCapture.java
@@ -0,0 +1,142 @@
+package com.genymobile.scrcpy.audio;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.FakeContext;
+import com.genymobile.scrcpy.Workarounds;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.media.AudioRecord;
+import android.media.MediaCodec;
+import android.os.Build;
+import android.os.SystemClock;
+
+import java.nio.ByteBuffer;
+
+public class AudioDirectCapture implements AudioCapture {
+
+ private static final int SAMPLE_RATE = AudioConfig.SAMPLE_RATE;
+ private static final int CHANNEL_CONFIG = AudioConfig.CHANNEL_CONFIG;
+ private static final int CHANNELS = AudioConfig.CHANNELS;
+ private static final int CHANNEL_MASK = AudioConfig.CHANNEL_MASK;
+ private static final int ENCODING = AudioConfig.ENCODING;
+
+ private final int audioSource;
+
+ private AudioRecord recorder;
+ private AudioRecordReader reader;
+
+ public AudioDirectCapture(AudioSource audioSource) {
+ this.audioSource = audioSource.getDirectAudioSource();
+ }
+
+ @TargetApi(AndroidVersions.API_23_ANDROID_6_0)
+ @SuppressLint({"WrongConstant", "MissingPermission"})
+ private static AudioRecord createAudioRecord(int audioSource) {
+ AudioRecord.Builder builder = new AudioRecord.Builder();
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) {
+ // On older APIs, Workarounds.fillAppInfo() must be called beforehand
+ builder.setContext(FakeContext.get());
+ }
+ builder.setAudioSource(audioSource);
+ builder.setAudioFormat(AudioConfig.createAudioFormat());
+ int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, ENCODING);
+ if (minBufferSize > 0) {
+ // This buffer size does not impact latency
+ builder.setBufferSizeInBytes(8 * minBufferSize);
+ }
+
+ return builder.build();
+ }
+
+ private static void startWorkaroundAndroid11() {
+ // Android 11 requires Apps to be at foreground to record audio.
+ // Normally, each App has its own user ID, so Android checks whether the requesting App has the user ID that's at the foreground.
+ // But scrcpy server is NOT an App, it's a Java application started from Android shell, so it has the same user ID (2000) with Android
+ // shell ("com.android.shell").
+ // If there is an Activity from Android shell running at foreground, then the permission system will believe scrcpy is also in the
+ // foreground.
+ Intent intent = new Intent(Intent.ACTION_MAIN);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.addCategory(Intent.CATEGORY_LAUNCHER);
+ intent.setComponent(new ComponentName(FakeContext.PACKAGE_NAME, "com.android.shell.HeapDumpActivity"));
+ ServiceManager.getActivityManager().startActivity(intent);
+ }
+
+ private static void stopWorkaroundAndroid11() {
+ ServiceManager.getActivityManager().forceStopPackage(FakeContext.PACKAGE_NAME);
+ }
+
+ private void tryStartRecording(int attempts, int delayMs) throws AudioCaptureException {
+ while (attempts-- > 0) {
+ // Wait for activity to start
+ SystemClock.sleep(delayMs);
+ try {
+ startRecording();
+ return; // it worked
+ } catch (UnsupportedOperationException e) {
+ if (attempts == 0) {
+ Ln.e("Failed to start audio capture");
+ Ln.e("On Android 11, audio capture must be started in the foreground, make sure that the device is unlocked when starting "
+ + "scrcpy.");
+ throw new AudioCaptureException();
+ } else {
+ Ln.d("Failed to start audio capture, retrying...");
+ }
+ }
+ }
+ }
+
+ private void startRecording() throws AudioCaptureException {
+ try {
+ recorder = createAudioRecord(audioSource);
+ } catch (NullPointerException e) {
+ // Creating an AudioRecord using an AudioRecord.Builder does not work on Vivo phones:
+ // -
+ // -
+ recorder = Workarounds.createAudioRecord(audioSource, SAMPLE_RATE, CHANNEL_CONFIG, CHANNELS, CHANNEL_MASK, ENCODING);
+ }
+ recorder.startRecording();
+ reader = new AudioRecordReader(recorder);
+ }
+
+ @Override
+ public void checkCompatibility() throws AudioCaptureException {
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11) {
+ Ln.w("Audio disabled: it is not supported before Android 11");
+ throw new AudioCaptureException();
+ }
+ }
+
+ @Override
+ public void start() throws AudioCaptureException {
+ if (Build.VERSION.SDK_INT == AndroidVersions.API_30_ANDROID_11) {
+ startWorkaroundAndroid11();
+ try {
+ tryStartRecording(5, 100);
+ } finally {
+ stopWorkaroundAndroid11();
+ }
+ } else {
+ startRecording();
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (recorder != null) {
+ // Will call .stop() if necessary, without throwing an IllegalStateException
+ recorder.release();
+ }
+ }
+
+ @Override
+ @TargetApi(AndroidVersions.API_24_ANDROID_7_0)
+ public int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo) {
+ return reader.read(outDirectBuffer, outBufferInfo);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioEncoder.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioEncoder.java
new file mode 100644
index 0000000..1be21eb
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioEncoder.java
@@ -0,0 +1,380 @@
+package com.genymobile.scrcpy.audio;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.AsyncProcessor;
+import com.genymobile.scrcpy.Options;
+import com.genymobile.scrcpy.device.Streamer;
+import com.genymobile.scrcpy.model.Codec;
+import com.genymobile.scrcpy.model.CodecOption;
+import com.genymobile.scrcpy.model.ConfigurationException;
+import com.genymobile.scrcpy.util.CodecUtils;
+import com.genymobile.scrcpy.util.IO;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.util.LogUtils;
+
+import android.annotation.TargetApi;
+import android.media.MediaCodec;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+
+public final class AudioEncoder implements AsyncProcessor {
+
+ private static class InputTask {
+ private final int index;
+
+ InputTask(int index) {
+ this.index = index;
+ }
+ }
+
+ private static class OutputTask {
+ private final int index;
+ private final MediaCodec.BufferInfo bufferInfo;
+
+ OutputTask(int index, MediaCodec.BufferInfo bufferInfo) {
+ this.index = index;
+ this.bufferInfo = bufferInfo;
+ }
+ }
+
+ private static final int SAMPLE_RATE = AudioConfig.SAMPLE_RATE;
+ private static final int CHANNELS = AudioConfig.CHANNELS;
+
+ private final AudioCapture capture;
+ private final Streamer streamer;
+ private final int bitRate;
+ private final List codecOptions;
+ private final String encoderName;
+
+ private boolean recreatePts;
+ private long previousPts;
+
+ // Capacity of 64 is in practice "infinite" (it is limited by the number of available MediaCodec buffers, typically 4).
+ // So many pending tasks would lead to an unacceptable delay anyway.
+ private final BlockingQueue inputTasks = new ArrayBlockingQueue<>(64);
+ private final BlockingQueue outputTasks = new ArrayBlockingQueue<>(64);
+
+ private Thread thread;
+ private HandlerThread mediaCodecThread;
+
+ private Thread inputThread;
+ private Thread outputThread;
+
+ private boolean ended;
+
+ public AudioEncoder(AudioCapture capture, Streamer streamer, Options options) {
+ this.capture = capture;
+ this.streamer = streamer;
+ this.bitRate = options.getAudioBitRate();
+ this.codecOptions = options.getAudioCodecOptions();
+ this.encoderName = options.getAudioEncoder();
+ }
+
+ private static MediaFormat createFormat(String mimeType, int bitRate, List codecOptions) {
+ MediaFormat format = new MediaFormat();
+ format.setString(MediaFormat.KEY_MIME, mimeType);
+ format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
+ format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS);
+ format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE);
+
+ if (codecOptions != null) {
+ for (CodecOption option : codecOptions) {
+ String key = option.getKey();
+ Object value = option.getValue();
+ CodecUtils.setCodecOption(format, key, value);
+ Ln.d("Audio codec option set: " + key + " (" + value.getClass().getSimpleName() + ") = " + value);
+ }
+ }
+
+ return format;
+ }
+
+ @TargetApi(AndroidVersions.API_24_ANDROID_7_0)
+ private void inputThread(MediaCodec mediaCodec, AudioCapture capture) throws IOException, InterruptedException {
+ final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+
+ while (!Thread.currentThread().isInterrupted()) {
+ InputTask task = inputTasks.take();
+ ByteBuffer buffer = mediaCodec.getInputBuffer(task.index);
+ int r = capture.read(buffer, bufferInfo);
+ if (r <= 0) {
+ throw new IOException("Could not read audio: " + r);
+ }
+
+ mediaCodec.queueInputBuffer(task.index, bufferInfo.offset, bufferInfo.size, bufferInfo.presentationTimeUs, bufferInfo.flags);
+ }
+ }
+
+ private void outputThread(MediaCodec mediaCodec) throws IOException, InterruptedException {
+ streamer.writeAudioHeader();
+
+ while (!Thread.currentThread().isInterrupted()) {
+ OutputTask task = outputTasks.take();
+ ByteBuffer buffer = mediaCodec.getOutputBuffer(task.index);
+ try {
+ if (recreatePts) {
+ fixTimestamp(task.bufferInfo);
+ }
+ streamer.writePacket(buffer, task.bufferInfo);
+ } finally {
+ mediaCodec.releaseOutputBuffer(task.index, false);
+ }
+ }
+ }
+
+ private void fixTimestamp(MediaCodec.BufferInfo bufferInfo) {
+ assert recreatePts;
+
+ if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
+ // Config packet, nothing to fix
+ return;
+ }
+
+ long pts = bufferInfo.presentationTimeUs;
+ if (previousPts != 0) {
+ long now = System.nanoTime() / 1000;
+ // This specific encoder produces PTS matching the exact number of samples
+ long duration = pts - previousPts;
+ bufferInfo.presentationTimeUs = now - duration;
+ }
+
+ previousPts = pts;
+ }
+
+ @Override
+ public void start(TerminationListener listener) {
+ thread = new Thread(() -> {
+ boolean fatalError = false;
+ try {
+ encode();
+ } catch (ConfigurationException e) {
+ // Do not print stack trace, a user-friendly error-message has already been logged
+ fatalError = true;
+ } catch (AudioCaptureException e) {
+ // Do not print stack trace, a user-friendly error-message has already been logged
+ } catch (IOException e) {
+ Ln.e("Audio encoding error", e);
+ fatalError = true;
+ } finally {
+ Ln.d("Audio encoder stopped");
+ listener.onTerminated(fatalError);
+ }
+ }, "audio-encoder");
+ thread.start();
+ }
+
+ @Override
+ public void stop() {
+ if (thread != null) {
+ // Just wake up the blocking wait from the thread, so that it properly releases all its resources and terminates
+ end();
+ }
+ }
+
+ @Override
+ public void join() throws InterruptedException {
+ if (thread != null) {
+ thread.join();
+ }
+ }
+
+ private synchronized void end() {
+ ended = true;
+ notify();
+ }
+
+ private synchronized void waitEnded() {
+ try {
+ while (!ended) {
+ wait();
+ }
+ } catch (InterruptedException e) {
+ // ignore
+ }
+ }
+
+ @TargetApi(AndroidVersions.API_23_ANDROID_6_0)
+ private void encode() throws IOException, ConfigurationException, AudioCaptureException {
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11) {
+ Ln.w("Audio disabled: it is not supported before Android 11");
+ streamer.writeDisableStream(false);
+ return;
+ }
+
+ MediaCodec mediaCodec = null;
+
+ boolean mediaCodecStarted = false;
+ try {
+ capture.checkCompatibility(); // throws an AudioCaptureException on error
+
+ Codec codec = streamer.getCodec();
+ mediaCodec = createMediaCodec(codec, encoderName);
+
+ // The default OPUS and FLAC encoders overwrite the input PTS with a value that matches the number of samples. This is not the behavior
+ // we want: it ignores any audio clock drift and hard silences (packets not produced on silence). To work around this behavior,
+ // regenerate PTS based on the current time and the packet duration.
+ String codecName = mediaCodec.getCanonicalName();
+ recreatePts = "c2.android.opus.encoder".equals(codecName) || "c2.android.flac.encoder".equals(codecName);
+
+ mediaCodecThread = new HandlerThread("media-codec");
+ mediaCodecThread.start();
+
+ MediaFormat format = createFormat(codec.getMimeType(), bitRate, codecOptions);
+ mediaCodec.setCallback(new EncoderCallback(), new Handler(mediaCodecThread.getLooper()));
+ mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+
+ capture.start();
+
+ final MediaCodec mediaCodecRef = mediaCodec;
+ inputThread = new Thread(() -> {
+ try {
+ inputThread(mediaCodecRef, capture);
+ } catch (IOException | InterruptedException e) {
+ Ln.e("Audio capture error", e);
+ } finally {
+ end();
+ }
+ }, "audio-in");
+
+ outputThread = new Thread(() -> {
+ try {
+ outputThread(mediaCodecRef);
+ } catch (InterruptedException e) {
+ // this is expected on close
+ } catch (IOException e) {
+ // Broken pipe is expected on close, because the socket is closed by the client
+ if (!IO.isBrokenPipe(e)) {
+ Ln.e("Audio encoding error", e);
+ }
+ } finally {
+ end();
+ }
+ }, "audio-out");
+
+ mediaCodec.start();
+ mediaCodecStarted = true;
+ inputThread.start();
+ outputThread.start();
+
+ waitEnded();
+ } catch (AudioCaptureException e) {
+ // Notify the client that the audio could not be captured
+ streamer.writeDisableStream(false);
+ throw e;
+ } catch (Throwable e) {
+ // Notify the error to make scrcpy exit
+ streamer.writeDisableStream(true);
+ throw e;
+ } finally {
+ // Cleanup everything (either at the end or on error at any step of the initialization)
+ if (mediaCodecThread != null) {
+ Looper looper = mediaCodecThread.getLooper();
+ if (looper != null) {
+ looper.quitSafely();
+ }
+ }
+ if (inputThread != null) {
+ inputThread.interrupt();
+ }
+ if (outputThread != null) {
+ outputThread.interrupt();
+ }
+
+ try {
+ if (mediaCodecThread != null) {
+ mediaCodecThread.join();
+ }
+ if (inputThread != null) {
+ inputThread.join();
+ }
+ if (outputThread != null) {
+ outputThread.join();
+ }
+ } catch (InterruptedException e) {
+ // Should never happen
+ throw new AssertionError(e);
+ }
+
+ if (mediaCodec != null) {
+ if (mediaCodecStarted) {
+ mediaCodec.stop();
+ }
+ mediaCodec.release();
+ }
+ if (capture != null) {
+ capture.stop();
+ }
+ }
+ }
+
+ private static MediaCodec createMediaCodec(Codec codec, String encoderName) throws IOException, ConfigurationException {
+ if (encoderName != null) {
+ Ln.d("Creating audio encoder by name: '" + encoderName + "'");
+ try {
+ MediaCodec mediaCodec = MediaCodec.createByCodecName(encoderName);
+ String mimeType = Codec.getMimeType(mediaCodec);
+ if (!codec.getMimeType().equals(mimeType)) {
+ Ln.e("Audio encoder type for \"" + encoderName + "\" (" + mimeType + ") does not match codec type (" + codec.getMimeType() + ")");
+ throw new ConfigurationException("Incorrect encoder type: " + encoderName);
+ }
+ return mediaCodec;
+ } catch (IllegalArgumentException e) {
+ Ln.e("Audio encoder '" + encoderName + "' for " + codec.getName() + " not found\n" + LogUtils.buildAudioEncoderListMessage());
+ throw new ConfigurationException("Unknown encoder: " + encoderName);
+ } catch (IOException e) {
+ Ln.e("Could not create audio encoder '" + encoderName + "' for " + codec.getName() + "\n" + LogUtils.buildAudioEncoderListMessage());
+ throw e;
+ }
+ }
+
+ try {
+ MediaCodec mediaCodec = MediaCodec.createEncoderByType(codec.getMimeType());
+ Ln.d("Using audio encoder: '" + mediaCodec.getName() + "'");
+ return mediaCodec;
+ } catch (IOException | IllegalArgumentException e) {
+ Ln.e("Could not create default audio encoder for " + codec.getName() + "\n" + LogUtils.buildAudioEncoderListMessage());
+ throw e;
+ }
+ }
+
+ private final class EncoderCallback extends MediaCodec.Callback {
+ @TargetApi(AndroidVersions.API_24_ANDROID_7_0)
+ @Override
+ public void onInputBufferAvailable(MediaCodec codec, int index) {
+ try {
+ inputTasks.put(new InputTask(index));
+ } catch (InterruptedException e) {
+ end();
+ }
+ }
+
+ @Override
+ public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo bufferInfo) {
+ try {
+ outputTasks.put(new OutputTask(index, bufferInfo));
+ } catch (InterruptedException e) {
+ end();
+ }
+ }
+
+ @Override
+ public void onError(MediaCodec codec, MediaCodec.CodecException e) {
+ Ln.e("MediaCodec error", e);
+ end();
+ }
+
+ @Override
+ public void onOutputFormatChanged(MediaCodec codec, MediaFormat format) {
+ // ignore
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioPlaybackCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioPlaybackCapture.java
new file mode 100644
index 0000000..009a239
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioPlaybackCapture.java
@@ -0,0 +1,138 @@
+package com.genymobile.scrcpy.audio;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.FakeContext;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.media.AudioAttributes;
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioRecord;
+import android.media.MediaCodec;
+import android.os.Build;
+
+import java.lang.reflect.Method;
+import java.nio.ByteBuffer;
+
+public final class AudioPlaybackCapture implements AudioCapture {
+
+ private final boolean keepPlayingOnDevice;
+
+ private AudioRecord recorder;
+ private AudioRecordReader reader;
+
+ public AudioPlaybackCapture(boolean keepPlayingOnDevice) {
+ this.keepPlayingOnDevice = keepPlayingOnDevice;
+ }
+
+ @SuppressLint("PrivateApi")
+ private AudioRecord createAudioRecord() throws AudioCaptureException {
+ // See
+ try {
+ Class> audioMixingRuleClass = Class.forName("android.media.audiopolicy.AudioMixingRule");
+ Class> audioMixingRuleBuilderClass = Class.forName("android.media.audiopolicy.AudioMixingRule$Builder");
+
+ // AudioMixingRule.Builder audioMixingRuleBuilder = new AudioMixingRule.Builder();
+ Object audioMixingRuleBuilder = audioMixingRuleBuilderClass.getConstructor().newInstance();
+
+ // audioMixingRuleBuilder.setTargetMixRole(AudioMixingRule.MIX_ROLE_PLAYERS);
+ int mixRolePlayersConstant = audioMixingRuleClass.getField("MIX_ROLE_PLAYERS").getInt(null);
+ Method setTargetMixRoleMethod = audioMixingRuleBuilderClass.getMethod("setTargetMixRole", int.class);
+ setTargetMixRoleMethod.invoke(audioMixingRuleBuilder, mixRolePlayersConstant);
+
+ AudioAttributes attributes = new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build();
+
+ // audioMixingRuleBuilder.addMixRule(AudioMixingRule.RULE_MATCH_ATTRIBUTE_USAGE, attributes);
+ int ruleMatchAttributeUsageConstant = audioMixingRuleClass.getField("RULE_MATCH_ATTRIBUTE_USAGE").getInt(null);
+ Method addMixRuleMethod = audioMixingRuleBuilderClass.getMethod("addMixRule", int.class, Object.class);
+ addMixRuleMethod.invoke(audioMixingRuleBuilder, ruleMatchAttributeUsageConstant, attributes);
+
+ // AudioMixingRule audioMixingRule = builder.build();
+ Object audioMixingRule = audioMixingRuleBuilderClass.getMethod("build").invoke(audioMixingRuleBuilder);
+
+ // audioMixingRuleBuilder.voiceCommunicationCaptureAllowed(true);
+ Method voiceCommunicationCaptureAllowedMethod = audioMixingRuleBuilderClass.getMethod("voiceCommunicationCaptureAllowed", boolean.class);
+ voiceCommunicationCaptureAllowedMethod.invoke(audioMixingRuleBuilder, true);
+
+ Class> audioMixClass = Class.forName("android.media.audiopolicy.AudioMix");
+ Class> audioMixBuilderClass = Class.forName("android.media.audiopolicy.AudioMix$Builder");
+
+ // AudioMix.Builder audioMixBuilder = new AudioMix.Builder(audioMixingRule);
+ Object audioMixBuilder = audioMixBuilderClass.getConstructor(audioMixingRuleClass).newInstance(audioMixingRule);
+
+ // audioMixBuilder.setFormat(createAudioFormat());
+ Method setFormat = audioMixBuilder.getClass().getMethod("setFormat", AudioFormat.class);
+ setFormat.invoke(audioMixBuilder, AudioConfig.createAudioFormat());
+
+ String routeFlagName = keepPlayingOnDevice ? "ROUTE_FLAG_LOOP_BACK_RENDER" : "ROUTE_FLAG_LOOP_BACK";
+ int routeFlags = audioMixClass.getField(routeFlagName).getInt(null);
+
+ // audioMixBuilder.setRouteFlags(routeFlag);
+ Method setRouteFlags = audioMixBuilder.getClass().getMethod("setRouteFlags", int.class);
+ setRouteFlags.invoke(audioMixBuilder, routeFlags);
+
+ // AudioMix audioMix = audioMixBuilder.build();
+ Object audioMix = audioMixBuilderClass.getMethod("build").invoke(audioMixBuilder);
+
+ Class> audioPolicyClass = Class.forName("android.media.audiopolicy.AudioPolicy");
+ Class> audioPolicyBuilderClass = Class.forName("android.media.audiopolicy.AudioPolicy$Builder");
+
+ // AudioPolicy.Builder audioPolicyBuilder = new AudioPolicy.Builder();
+ Object audioPolicyBuilder = audioPolicyBuilderClass.getConstructor(Context.class).newInstance(FakeContext.get());
+
+ // audioPolicyBuilder.addMix(audioMix);
+ Method addMixMethod = audioPolicyBuilderClass.getMethod("addMix", audioMixClass);
+ addMixMethod.invoke(audioPolicyBuilder, audioMix);
+
+ // AudioPolicy audioPolicy = audioPolicyBuilder.build();
+ Object audioPolicy = audioPolicyBuilderClass.getMethod("build").invoke(audioPolicyBuilder);
+
+ // AudioManager.registerAudioPolicyStatic(audioPolicy);
+ Method registerAudioPolicyStaticMethod = AudioManager.class.getDeclaredMethod("registerAudioPolicyStatic", audioPolicyClass);
+ registerAudioPolicyStaticMethod.setAccessible(true);
+ int result = (int) registerAudioPolicyStaticMethod.invoke(null, audioPolicy);
+ if (result != 0) {
+ throw new RuntimeException("registerAudioPolicy() returned " + result);
+ }
+
+ // audioPolicy.createAudioRecordSink(audioPolicy);
+ Method createAudioRecordSinkClass = audioPolicyClass.getMethod("createAudioRecordSink", audioMixClass);
+ return (AudioRecord) createAudioRecordSinkClass.invoke(audioPolicy, audioMix);
+ } catch (Exception e) {
+ Ln.e("Could not capture audio playback", e);
+ throw new AudioCaptureException();
+ }
+ }
+
+ @Override
+ public void checkCompatibility() throws AudioCaptureException {
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_33_ANDROID_13) {
+ Ln.w("Audio disabled: audio playback capture source not supported before Android 13");
+ throw new AudioCaptureException();
+ }
+ }
+
+ @Override
+ public void start() throws AudioCaptureException {
+ recorder = createAudioRecord();
+ recorder.startRecording();
+ reader = new AudioRecordReader(recorder);
+ }
+
+ @Override
+ public void stop() {
+ if (recorder != null) {
+ // Will call .stop() if necessary, without throwing an IllegalStateException
+ recorder.release();
+ }
+ }
+
+ @Override
+ @TargetApi(AndroidVersions.API_24_ANDROID_7_0)
+ public int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo) {
+ return reader.read(outDirectBuffer, outBufferInfo);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRawRecorder.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRawRecorder.java
new file mode 100644
index 0000000..9645bbb
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRawRecorder.java
@@ -0,0 +1,99 @@
+package com.genymobile.scrcpy.audio;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.AsyncProcessor;
+import com.genymobile.scrcpy.device.Streamer;
+import com.genymobile.scrcpy.util.IO;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.media.MediaCodec;
+import android.os.Build;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public final class AudioRawRecorder implements AsyncProcessor {
+
+ private final AudioCapture capture;
+ private final Streamer streamer;
+
+ private Thread thread;
+
+ public AudioRawRecorder(AudioCapture capture, Streamer streamer) {
+ this.capture = capture;
+ this.streamer = streamer;
+ }
+
+ private void record() throws IOException, AudioCaptureException {
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11) {
+ Ln.w("Audio disabled: it is not supported before Android 11");
+ streamer.writeDisableStream(false);
+ return;
+ }
+
+ final ByteBuffer buffer = ByteBuffer.allocateDirect(AudioConfig.MAX_READ_SIZE);
+ final MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+
+ try {
+ try {
+ capture.start();
+ } catch (Throwable t) {
+ // Notify the client that the audio could not be captured
+ streamer.writeDisableStream(false);
+ throw t;
+ }
+
+ streamer.writeAudioHeader();
+ while (!Thread.currentThread().isInterrupted()) {
+ buffer.position(0);
+ int r = capture.read(buffer, bufferInfo);
+ if (r < 0) {
+ throw new IOException("Could not read audio: " + r);
+ }
+ buffer.limit(r);
+
+ streamer.writePacket(buffer, bufferInfo);
+ }
+ } catch (IOException e) {
+ // Broken pipe is expected on close, because the socket is closed by the client
+ if (!IO.isBrokenPipe(e)) {
+ Ln.e("Audio capture error", e);
+ }
+ } finally {
+ capture.stop();
+ }
+ }
+
+ @Override
+ public void start(TerminationListener listener) {
+ thread = new Thread(() -> {
+ boolean fatalError = false;
+ try {
+ record();
+ } catch (AudioCaptureException e) {
+ // Do not print stack trace, a user-friendly error-message has already been logged
+ } catch (Throwable t) {
+ Ln.e("Audio recording error", t);
+ fatalError = true;
+ } finally {
+ Ln.d("Audio recorder stopped");
+ listener.onTerminated(fatalError);
+ }
+ }, "audio-raw");
+ thread.start();
+ }
+
+ @Override
+ public void stop() {
+ if (thread != null) {
+ thread.interrupt();
+ }
+ }
+
+ @Override
+ public void join() throws InterruptedException {
+ if (thread != null) {
+ thread.join();
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRecordReader.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRecordReader.java
new file mode 100644
index 0000000..32b4225
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioRecordReader.java
@@ -0,0 +1,67 @@
+package com.genymobile.scrcpy.audio;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.annotation.TargetApi;
+import android.media.AudioRecord;
+import android.media.AudioTimestamp;
+import android.media.MediaCodec;
+
+import java.nio.ByteBuffer;
+
+public class AudioRecordReader {
+
+ private static final long ONE_SAMPLE_US =
+ (1000000 + AudioConfig.SAMPLE_RATE - 1) / AudioConfig.SAMPLE_RATE; // 1 sample in microseconds (used for fixing PTS)
+
+ private final AudioRecord recorder;
+
+ private final AudioTimestamp timestamp = new AudioTimestamp();
+ private long previousRecorderTimestamp = -1;
+ private long previousPts = 0;
+ private long nextPts = 0;
+
+ public AudioRecordReader(AudioRecord recorder) {
+ this.recorder = recorder;
+ }
+
+ @TargetApi(AndroidVersions.API_24_ANDROID_7_0)
+ public int read(ByteBuffer outDirectBuffer, MediaCodec.BufferInfo outBufferInfo) {
+ int r = recorder.read(outDirectBuffer, AudioConfig.MAX_READ_SIZE);
+ if (r <= 0) {
+ return r;
+ }
+
+ long pts;
+
+ int ret = recorder.getTimestamp(timestamp, AudioTimestamp.TIMEBASE_MONOTONIC);
+ if (ret == AudioRecord.SUCCESS && timestamp.nanoTime != previousRecorderTimestamp) {
+ pts = timestamp.nanoTime / 1000;
+ previousRecorderTimestamp = timestamp.nanoTime;
+ } else {
+ if (nextPts == 0) {
+ Ln.w("Could not get initial audio timestamp");
+ nextPts = System.nanoTime() / 1000;
+ }
+ // compute from previous timestamp and packet size
+ pts = nextPts;
+ }
+
+ long durationUs = r * 1000000L / (AudioConfig.CHANNELS * AudioConfig.BYTES_PER_SAMPLE * AudioConfig.SAMPLE_RATE);
+ nextPts = pts + durationUs;
+
+ if (previousPts != 0 && pts < previousPts + ONE_SAMPLE_US) {
+ // Audio PTS may come from two sources:
+ // - recorder.getTimestamp() if the call works;
+ // - an estimation from the previous PTS and the packet size as a fallback.
+ //
+ // Therefore, the property that PTS are monotonically increasing is no guaranteed in corner cases, so enforce it.
+ pts = previousPts + ONE_SAMPLE_US;
+ }
+ previousPts = pts;
+
+ outBufferInfo.set(0, r, pts, 0);
+ return r;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioSource.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioSource.java
new file mode 100644
index 0000000..d16b5e3
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/audio/AudioSource.java
@@ -0,0 +1,45 @@
+package com.genymobile.scrcpy.audio;
+
+import android.annotation.SuppressLint;
+import android.media.MediaRecorder;
+
+@SuppressLint("InlinedApi")
+public enum AudioSource {
+ OUTPUT("output", MediaRecorder.AudioSource.REMOTE_SUBMIX),
+ MIC("mic", MediaRecorder.AudioSource.MIC),
+ PLAYBACK("playback", -1),
+ MIC_UNPROCESSED("mic-unprocessed", MediaRecorder.AudioSource.UNPROCESSED),
+ MIC_CAMCORDER("mic-camcorder", MediaRecorder.AudioSource.CAMCORDER),
+ MIC_VOICE_RECOGNITION("mic-voice-recognition", MediaRecorder.AudioSource.VOICE_RECOGNITION),
+ MIC_VOICE_COMMUNICATION("mic-voice-communication", MediaRecorder.AudioSource.VOICE_COMMUNICATION),
+ VOICE_CALL("voice-call", MediaRecorder.AudioSource.VOICE_CALL),
+ VOICE_CALL_UPLINK("voice-call-uplink", MediaRecorder.AudioSource.VOICE_UPLINK),
+ VOICE_CALL_DOWNLINK("voice-call-downlink", MediaRecorder.AudioSource.VOICE_DOWNLINK),
+ VOICE_PERFORMANCE("voice-performance", MediaRecorder.AudioSource.VOICE_PERFORMANCE);
+
+ private final String name;
+ private final int directAudioSource;
+
+ AudioSource(String name, int directAudioSource) {
+ this.name = name;
+ this.directAudioSource = directAudioSource;
+ }
+
+ public boolean isDirect() {
+ return this != PLAYBACK;
+ }
+
+ public int getDirectAudioSource() {
+ return directAudioSource;
+ }
+
+ public static AudioSource findByName(String name) {
+ for (AudioSource audioSource : AudioSource.values()) {
+ if (name.equals(audioSource.name)) {
+ return audioSource;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlChannel.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlChannel.java
new file mode 100644
index 0000000..2f12cdb
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlChannel.java
@@ -0,0 +1,24 @@
+package com.genymobile.scrcpy.control;
+
+import android.net.LocalSocket;
+
+import java.io.IOException;
+
+public final class ControlChannel {
+
+ private final ControlMessageReader reader;
+ private final DeviceMessageWriter writer;
+
+ public ControlChannel(LocalSocket controlSocket) throws IOException {
+ reader = new ControlMessageReader(controlSocket.getInputStream());
+ writer = new DeviceMessageWriter(controlSocket.getOutputStream());
+ }
+
+ public ControlMessage recv() throws IOException {
+ return reader.read();
+ }
+
+ public void send(DeviceMessage msg) throws IOException {
+ writer.write(msg);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessage.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessage.java
new file mode 100644
index 0000000..777873b
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessage.java
@@ -0,0 +1,289 @@
+package com.genymobile.scrcpy.control;
+
+import com.genymobile.scrcpy.model.Position;
+
+/**
+ * Union of all supported event types, identified by their {@code type}.
+ */
+public final class ControlMessage {
+
+ public static final int TYPE_INJECT_KEYCODE = 0;
+ public static final int TYPE_INJECT_TEXT = 1;
+ public static final int TYPE_INJECT_TOUCH_EVENT = 2;
+ public static final int TYPE_INJECT_SCROLL_EVENT = 3;
+ public static final int TYPE_BACK_OR_SCREEN_ON = 4;
+ public static final int TYPE_EXPAND_NOTIFICATION_PANEL = 5;
+ public static final int TYPE_EXPAND_SETTINGS_PANEL = 6;
+ public static final int TYPE_COLLAPSE_PANELS = 7;
+ public static final int TYPE_GET_CLIPBOARD = 8;
+ public static final int TYPE_SET_CLIPBOARD = 9;
+ public static final int TYPE_SET_DISPLAY_POWER = 10;
+ public static final int TYPE_ROTATE_DEVICE = 11;
+ public static final int TYPE_UHID_CREATE = 12;
+ public static final int TYPE_UHID_INPUT = 13;
+ public static final int TYPE_UHID_DESTROY = 14;
+ public static final int TYPE_OPEN_HARD_KEYBOARD_SETTINGS = 15;
+ public static final int TYPE_START_APP = 16;
+ public static final int TYPE_RESET_VIDEO = 17;
+ public static final int TYPE_CAMERA_SET_TORCH = 18;
+ public static final int TYPE_CAMERA_ZOOM_IN = 19;
+ public static final int TYPE_CAMERA_ZOOM_OUT = 20;
+ public static final int TYPE_RESIZE_DISPLAY = 21;
+ public static final int TYPE_SCAN_FILE = 22;
+
+ public static final long SEQUENCE_INVALID = 0;
+
+ public static final int COPY_KEY_NONE = 0;
+ public static final int COPY_KEY_COPY = 1;
+ public static final int COPY_KEY_CUT = 2;
+
+ private int type;
+ private String text;
+ private int metaState; // KeyEvent.META_*
+ private int action; // KeyEvent.ACTION_* or MotionEvent.ACTION_*
+ private int keycode; // KeyEvent.KEYCODE_*
+ private int actionButton; // MotionEvent.BUTTON_*
+ private int buttons; // MotionEvent.BUTTON_*
+ private long pointerId;
+ private float pressure;
+ private Position position;
+ private float hScroll;
+ private float vScroll;
+ private int copyKey;
+ private boolean paste;
+ private int repeat;
+ private long sequence;
+ private int id;
+ private byte[] data;
+ private boolean on;
+ private int vendorId;
+ private int productId;
+ private int width;
+ private int height;
+
+ private ControlMessage() {
+ }
+
+ public static ControlMessage createInjectKeycode(int action, int keycode, int repeat, int metaState) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_INJECT_KEYCODE;
+ msg.action = action;
+ msg.keycode = keycode;
+ msg.repeat = repeat;
+ msg.metaState = metaState;
+ return msg;
+ }
+
+ public static ControlMessage createInjectText(String text) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_INJECT_TEXT;
+ msg.text = text;
+ return msg;
+ }
+
+ public static ControlMessage createInjectTouchEvent(int action, long pointerId, Position position, float pressure, int actionButton,
+ int buttons) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_INJECT_TOUCH_EVENT;
+ msg.action = action;
+ msg.pointerId = pointerId;
+ msg.pressure = pressure;
+ msg.position = position;
+ msg.actionButton = actionButton;
+ msg.buttons = buttons;
+ return msg;
+ }
+
+ public static ControlMessage createInjectScrollEvent(Position position, float hScroll, float vScroll, int buttons) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_INJECT_SCROLL_EVENT;
+ msg.position = position;
+ msg.hScroll = hScroll;
+ msg.vScroll = vScroll;
+ msg.buttons = buttons;
+ return msg;
+ }
+
+ public static ControlMessage createBackOrScreenOn(int action) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_BACK_OR_SCREEN_ON;
+ msg.action = action;
+ return msg;
+ }
+
+ public static ControlMessage createGetClipboard(int copyKey) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_GET_CLIPBOARD;
+ msg.copyKey = copyKey;
+ return msg;
+ }
+
+ public static ControlMessage createSetClipboard(long sequence, String text, boolean paste) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_SET_CLIPBOARD;
+ msg.sequence = sequence;
+ msg.text = text;
+ msg.paste = paste;
+ return msg;
+ }
+
+ public static ControlMessage createSetDisplayPower(boolean on) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_SET_DISPLAY_POWER;
+ msg.on = on;
+ return msg;
+ }
+
+ public static ControlMessage createEmpty(int type) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = type;
+ return msg;
+ }
+
+ public static ControlMessage createUhidCreate(int id, int vendorId, int productId, String name, byte[] reportDesc) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_UHID_CREATE;
+ msg.id = id;
+ msg.vendorId = vendorId;
+ msg.productId = productId;
+ msg.text = name;
+ msg.data = reportDesc;
+ return msg;
+ }
+
+ public static ControlMessage createUhidInput(int id, byte[] data) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_UHID_INPUT;
+ msg.id = id;
+ msg.data = data;
+ return msg;
+ }
+
+ public static ControlMessage createUhidDestroy(int id) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_UHID_DESTROY;
+ msg.id = id;
+ return msg;
+ }
+
+ public static ControlMessage createStartApp(String name) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_START_APP;
+ msg.text = name;
+ return msg;
+ }
+
+ public static ControlMessage createCameraSetTorch(boolean on) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_CAMERA_SET_TORCH;
+ msg.on = on;
+ return msg;
+ }
+
+ public static ControlMessage createResizeDisplay(int width, int height) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_RESIZE_DISPLAY;
+ msg.width = width;
+ msg.height = height;
+ return msg;
+ }
+
+ public static ControlMessage createScanFile(String path) {
+ ControlMessage msg = new ControlMessage();
+ msg.type = TYPE_SCAN_FILE;
+ msg.text = path;
+ return msg;
+ }
+
+ 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 getActionButton() {
+ return actionButton;
+ }
+
+ public int getButtons() {
+ return buttons;
+ }
+
+ public long getPointerId() {
+ return pointerId;
+ }
+
+ public float getPressure() {
+ return pressure;
+ }
+
+ public Position getPosition() {
+ return position;
+ }
+
+ public float getHScroll() {
+ return hScroll;
+ }
+
+ public float getVScroll() {
+ return vScroll;
+ }
+
+ public int getCopyKey() {
+ return copyKey;
+ }
+
+ public boolean getPaste() {
+ return paste;
+ }
+
+ public int getRepeat() {
+ return repeat;
+ }
+
+ public long getSequence() {
+ return sequence;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public byte[] getData() {
+ return data;
+ }
+
+ public boolean getOn() {
+ return on;
+ }
+
+ public int getVendorId() {
+ return vendorId;
+ }
+
+ public int getProductId() {
+ return productId;
+ }
+
+ public int getWidth() {
+ return width;
+ }
+
+ public int getHeight() {
+ return height;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessageReader.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessageReader.java
new file mode 100644
index 0000000..b2591d6
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlMessageReader.java
@@ -0,0 +1,200 @@
+package com.genymobile.scrcpy.control;
+
+import com.genymobile.scrcpy.model.Position;
+import com.genymobile.scrcpy.util.Binary;
+
+import java.io.BufferedInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+public class ControlMessageReader {
+
+ private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k
+
+ public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 14; // type: 1 byte; sequence: 8 bytes; paste flag: 1 byte; length: 4 bytes
+ public static final int INJECT_TEXT_MAX_LENGTH = 300;
+
+ private final DataInputStream dis;
+
+ public ControlMessageReader(InputStream rawInputStream) {
+ dis = new DataInputStream(new BufferedInputStream(rawInputStream));
+ }
+
+ public ControlMessage read() throws IOException {
+ int type = dis.readUnsignedByte();
+ switch (type) {
+ case ControlMessage.TYPE_INJECT_KEYCODE:
+ return parseInjectKeycode();
+ case ControlMessage.TYPE_INJECT_TEXT:
+ return parseInjectText();
+ case ControlMessage.TYPE_INJECT_TOUCH_EVENT:
+ return parseInjectTouchEvent();
+ case ControlMessage.TYPE_INJECT_SCROLL_EVENT:
+ return parseInjectScrollEvent();
+ case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
+ return parseBackOrScreenOnEvent();
+ case ControlMessage.TYPE_GET_CLIPBOARD:
+ return parseGetClipboard();
+ case ControlMessage.TYPE_SET_CLIPBOARD:
+ return parseSetClipboard();
+ case ControlMessage.TYPE_SET_DISPLAY_POWER:
+ return parseSetDisplayPower();
+ case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
+ case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL:
+ case ControlMessage.TYPE_COLLAPSE_PANELS:
+ case ControlMessage.TYPE_ROTATE_DEVICE:
+ case ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS:
+ case ControlMessage.TYPE_RESET_VIDEO:
+ case ControlMessage.TYPE_CAMERA_ZOOM_IN:
+ case ControlMessage.TYPE_CAMERA_ZOOM_OUT:
+ return ControlMessage.createEmpty(type);
+ case ControlMessage.TYPE_UHID_CREATE:
+ return parseUhidCreate();
+ case ControlMessage.TYPE_UHID_INPUT:
+ return parseUhidInput();
+ case ControlMessage.TYPE_UHID_DESTROY:
+ return parseUhidDestroy();
+ case ControlMessage.TYPE_START_APP:
+ return parseStartApp();
+ case ControlMessage.TYPE_CAMERA_SET_TORCH:
+ return parseCameraSetTorch();
+ case ControlMessage.TYPE_RESIZE_DISPLAY:
+ return parseResizeDisplay();
+ case ControlMessage.TYPE_SCAN_FILE:
+ return parseScanFile();
+ default:
+ throw new ControlProtocolException("Unknown event type: " + type);
+ }
+ }
+
+ private ControlMessage parseInjectKeycode() throws IOException {
+ int action = dis.readUnsignedByte();
+ int keycode = dis.readInt();
+ int repeat = dis.readInt();
+ int metaState = dis.readInt();
+ return ControlMessage.createInjectKeycode(action, keycode, repeat, metaState);
+ }
+
+ private int parseBufferLength(int sizeBytes) throws IOException {
+ assert sizeBytes > 0 && sizeBytes <= 4;
+ int value = 0;
+ for (int i = 0; i < sizeBytes; ++i) {
+ value = (value << 8) | dis.readUnsignedByte();
+ }
+ return value;
+ }
+
+ private String parseString(int sizeBytes) throws IOException {
+ assert sizeBytes > 0 && sizeBytes <= 4;
+ byte[] data = parseByteArray(sizeBytes);
+ return new String(data, StandardCharsets.UTF_8);
+ }
+
+ private String parseString() throws IOException {
+ return parseString(4);
+ }
+
+ private byte[] parseByteArray(int sizeBytes) throws IOException {
+ int len = parseBufferLength(sizeBytes);
+ byte[] data = new byte[len];
+ dis.readFully(data);
+ return data;
+ }
+
+ private ControlMessage parseInjectText() throws IOException {
+ String text = parseString();
+ return ControlMessage.createInjectText(text);
+ }
+
+ private ControlMessage parseInjectTouchEvent() throws IOException {
+ int action = dis.readUnsignedByte();
+ long pointerId = dis.readLong();
+ Position position = parsePosition();
+ float pressure = Binary.u16FixedPointToFloat(dis.readShort());
+ int actionButton = dis.readInt();
+ int buttons = dis.readInt();
+ return ControlMessage.createInjectTouchEvent(action, pointerId, position, pressure, actionButton, buttons);
+ }
+
+ private ControlMessage parseInjectScrollEvent() throws IOException {
+ Position position = parsePosition();
+ // Binary.i16FixedPointToFloat() decodes values assuming the full range is [-1, 1], but the actual range is [-16, 16].
+ float hScroll = Binary.i16FixedPointToFloat(dis.readShort()) * 16;
+ float vScroll = Binary.i16FixedPointToFloat(dis.readShort()) * 16;
+ int buttons = dis.readInt();
+ return ControlMessage.createInjectScrollEvent(position, hScroll, vScroll, buttons);
+ }
+
+ private ControlMessage parseBackOrScreenOnEvent() throws IOException {
+ int action = dis.readUnsignedByte();
+ return ControlMessage.createBackOrScreenOn(action);
+ }
+
+ private ControlMessage parseGetClipboard() throws IOException {
+ int copyKey = dis.readUnsignedByte();
+ return ControlMessage.createGetClipboard(copyKey);
+ }
+
+ private ControlMessage parseSetClipboard() throws IOException {
+ long sequence = dis.readLong();
+ boolean paste = dis.readByte() != 0;
+ String text = parseString();
+ return ControlMessage.createSetClipboard(sequence, text, paste);
+ }
+
+ private ControlMessage parseSetDisplayPower() throws IOException {
+ boolean on = dis.readBoolean();
+ return ControlMessage.createSetDisplayPower(on);
+ }
+
+ private ControlMessage parseUhidCreate() throws IOException {
+ int id = dis.readUnsignedShort();
+ int vendorId = dis.readUnsignedShort();
+ int productId = dis.readUnsignedShort();
+ String name = parseString(1);
+ byte[] data = parseByteArray(2);
+ return ControlMessage.createUhidCreate(id, vendorId, productId, name, data);
+ }
+
+ private ControlMessage parseUhidInput() throws IOException {
+ int id = dis.readUnsignedShort();
+ byte[] data = parseByteArray(2);
+ return ControlMessage.createUhidInput(id, data);
+ }
+
+ private ControlMessage parseUhidDestroy() throws IOException {
+ int id = dis.readUnsignedShort();
+ return ControlMessage.createUhidDestroy(id);
+ }
+
+ private ControlMessage parseStartApp() throws IOException {
+ String name = parseString(1);
+ return ControlMessage.createStartApp(name);
+ }
+
+ private ControlMessage parseCameraSetTorch() throws IOException {
+ boolean on = dis.readBoolean();
+ return ControlMessage.createCameraSetTorch(on);
+ }
+
+ private ControlMessage parseResizeDisplay() throws IOException {
+ int width = dis.readUnsignedShort();
+ int height = dis.readUnsignedShort();
+ return ControlMessage.createResizeDisplay(width, height);
+ }
+
+ private ControlMessage parseScanFile() throws IOException {
+ String path = parseString();
+ return ControlMessage.createScanFile(path);
+ }
+
+ private Position parsePosition() throws IOException {
+ int x = dis.readInt();
+ int y = dis.readInt();
+ int screenWidth = dis.readUnsignedShort();
+ int screenHeight = dis.readUnsignedShort();
+ return new Position(x, y, screenWidth, screenHeight);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlProtocolException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlProtocolException.java
new file mode 100644
index 0000000..cabf63e
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/ControlProtocolException.java
@@ -0,0 +1,9 @@
+package com.genymobile.scrcpy.control;
+
+import java.io.IOException;
+
+public class ControlProtocolException extends IOException {
+ public ControlProtocolException(String message) {
+ super(message);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Controller.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Controller.java
new file mode 100644
index 0000000..55d6e11
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Controller.java
@@ -0,0 +1,893 @@
+package com.genymobile.scrcpy.control;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.AsyncProcessor;
+import com.genymobile.scrcpy.CleanUp;
+import com.genymobile.scrcpy.Options;
+import com.genymobile.scrcpy.device.Device;
+import com.genymobile.scrcpy.display.DisplayInfo;
+import com.genymobile.scrcpy.model.DeviceApp;
+import com.genymobile.scrcpy.model.Point;
+import com.genymobile.scrcpy.model.Position;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.util.LogUtils;
+import com.genymobile.scrcpy.video.CameraCapture;
+import com.genymobile.scrcpy.video.CaptureControl;
+import com.genymobile.scrcpy.video.NewDisplayCapture;
+import com.genymobile.scrcpy.video.SurfaceCapture;
+import com.genymobile.scrcpy.video.VideoSource;
+import com.genymobile.scrcpy.video.VirtualDisplayListener;
+import com.genymobile.scrcpy.wrappers.ClipboardManager;
+import com.genymobile.scrcpy.wrappers.InputManager;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.SystemClock;
+import android.util.Pair;
+import android.view.InputDevice;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class Controller implements AsyncProcessor, VirtualDisplayListener {
+
+ /*
+ * For event injection, there are two display ids:
+ * - the displayId passed to the constructor (which comes from --display-id passed by the client, 0 for the main display);
+ * - the virtualDisplayId used for mirroring, notified by the capture instance via the VirtualDisplayListener interface.
+ *
+ * (In case the ScreenCapture uses the "SurfaceControl API", then both ids are equals, but this is an implementation detail.)
+ *
+ * In order to make events work correctly in all cases:
+ * - virtualDisplayId must be used for events relative to the display (mouse and touch events with coordinates);
+ * - displayId must be used for other events (like key events).
+ *
+ * If a new separate virtual display is created (using --new-display), then displayId == Device.DISPLAY_ID_NONE. In that case, all events are
+ * sent to the virtual display id.
+ */
+
+ private static final class DisplayData {
+ private final int virtualDisplayId;
+ private final PositionMapper positionMapper;
+
+ private DisplayData(int virtualDisplayId, PositionMapper positionMapper) {
+ this.virtualDisplayId = virtualDisplayId;
+ this.positionMapper = positionMapper;
+ }
+ }
+
+ private static final int DEFAULT_DEVICE_ID = 0;
+
+ // control_msg.h values of the pointerId field in inject_touch_event message
+ private static final int POINTER_ID_MOUSE = -1;
+
+ // Interval between simulated user activity events
+ private static final long KEEP_ACTIVE_INTERVAL_MS = 4000;
+
+ private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor();
+ private ExecutorService startAppExecutor;
+
+ private Thread thread;
+ private Thread keepActiveThread;
+
+ private UhidManager uhidManager;
+
+ private final boolean camera;
+ private final int displayId;
+ private final boolean supportsInputEvents;
+ private final ControlChannel controlChannel;
+ private final CleanUp cleanUp;
+ private final DeviceMessageSender sender;
+ private final boolean clipboardAutosync;
+ private final boolean powerOn;
+ private final boolean keepActive;
+
+ private final KeyCharacterMap charMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
+
+ private final AtomicBoolean isSettingClipboard = new AtomicBoolean();
+
+ private final AtomicReference displayData = new AtomicReference<>();
+ private final Object displayDataAvailable = new Object(); // condition variable
+
+ private long lastTouchDown;
+ private final PointersState pointersState = new PointersState();
+ private final MotionEvent.PointerProperties[] pointerProperties = new MotionEvent.PointerProperties[PointersState.MAX_POINTERS];
+ private final MotionEvent.PointerCoords[] pointerCoords = new MotionEvent.PointerCoords[PointersState.MAX_POINTERS];
+
+ private boolean keepDisplayPowerOff;
+
+ // Used for resetting video encoding on RESET_VIDEO message or for sending camera controls
+ private SurfaceCapture surfaceCapture;
+
+ public Controller(ControlChannel controlChannel, CleanUp cleanUp, Options options) {
+ this.camera = options.getVideoSource() == VideoSource.CAMERA;
+ this.controlChannel = controlChannel;
+ this.cleanUp = cleanUp;
+
+ if (this.camera) {
+ // Unused for camera
+ this.displayId = Device.DISPLAY_ID_NONE;
+ this.supportsInputEvents = false;
+ this.sender = null;
+ this.clipboardAutosync = false;
+ this.powerOn = false;
+ this.keepActive = false;
+ return;
+ }
+
+ this.displayId = options.getDisplayId();
+
+ this.clipboardAutosync = options.getClipboardAutosync();
+ this.powerOn = options.getPowerOn();
+ this.keepActive = options.getKeepActive();
+ initPointers();
+ sender = new DeviceMessageSender(controlChannel);
+
+ supportsInputEvents = Device.supportsInputEvents(displayId);
+ if (!supportsInputEvents) {
+ Ln.w("Input events are not supported for secondary displays before Android 10");
+ }
+
+ // Make sure the clipboard manager is always created from the main thread (even if clipboardAutosync is disabled)
+ ClipboardManager clipboardManager = ServiceManager.getClipboardManager();
+ if (clipboardAutosync) {
+ // If control and autosync are enabled, synchronize Android clipboard to the computer automatically
+ if (clipboardManager != null) {
+ clipboardManager.addPrimaryClipChangedListener(() -> {
+ if (isSettingClipboard.get()) {
+ // This is a notification for the change we are currently applying, ignore it
+ return;
+ }
+ String text = Device.getClipboardText();
+ if (text != null) {
+ DeviceMessage msg = DeviceMessage.createClipboard(text);
+ sender.send(msg);
+ }
+ });
+ } else {
+ Ln.w("No clipboard manager, copy-paste between device and computer will not work");
+ }
+ }
+ }
+
+ @Override
+ public void onNewVirtualDisplay(int virtualDisplayId, PositionMapper positionMapper) {
+ DisplayData data = new DisplayData(virtualDisplayId, positionMapper);
+ DisplayData old = this.displayData.getAndSet(data);
+ if (old == null) {
+ // The very first time the Controller is notified of a new virtual display
+ synchronized (displayDataAvailable) {
+ displayDataAvailable.notify();
+ }
+ }
+ }
+
+ public void setSurfaceCapture(SurfaceCapture surfaceCapture) {
+ this.surfaceCapture = surfaceCapture;
+ }
+
+ private UhidManager getUhidManager() {
+ if (uhidManager == null) {
+ int uhidDisplayId = displayId;
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15) {
+ if (displayId == Device.DISPLAY_ID_NONE) {
+ // Mirroring a new virtual display id (using --new-display-id feature) on Android >= 15, where the UHID mouse pointer can be
+ // associated to the virtual display
+ try {
+ // Wait for at most 1 second until a virtual display id is known
+ DisplayData data = waitDisplayData(1000);
+ if (data != null) {
+ uhidDisplayId = data.virtualDisplayId;
+ }
+ } catch (InterruptedException e) {
+ // do nothing
+ }
+ }
+ }
+
+ String displayUniqueId = null;
+ if (uhidDisplayId > 0) {
+ // Ignore Device.DISPLAY_ID_NONE and 0 (main display)
+ DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(uhidDisplayId);
+ if (displayInfo != null) {
+ displayUniqueId = displayInfo.getUniqueId();
+ }
+ }
+ uhidManager = new UhidManager(sender, displayUniqueId);
+ }
+
+ return uhidManager;
+ }
+
+ private void initPointers() {
+ for (int i = 0; i < PointersState.MAX_POINTERS; ++i) {
+ MotionEvent.PointerProperties props = new MotionEvent.PointerProperties();
+ props.toolType = MotionEvent.TOOL_TYPE_FINGER;
+
+ MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ coords.orientation = 0;
+ coords.size = 0;
+
+ pointerProperties[i] = props;
+ pointerCoords[i] = coords;
+ }
+ }
+
+ private void control() throws IOException {
+ // on start, power on the device
+ if (!camera && powerOn && displayId == 0 && !Device.isScreenOn(displayId)) {
+ Device.pressReleaseKeycode(KeyEvent.KEYCODE_POWER, displayId, Device.INJECT_MODE_ASYNC);
+
+ // dirty hack
+ // After POWER is injected, the device is powered on asynchronously.
+ // To turn the device screen off while mirroring, the client will send a message that
+ // would be handled before the device is actually powered on, so its effect would
+ // be "canceled" once the device is turned back on.
+ // Adding this delay prevents to handle the message before the device is actually
+ // powered on.
+ SystemClock.sleep(500);
+ }
+
+ boolean alive = true;
+ while (!Thread.currentThread().isInterrupted() && alive) {
+ alive = handleEvent();
+ }
+ }
+
+ private void startKeepActiveThread() {
+ keepActiveThread = new Thread(() -> {
+ try {
+ while (true) {
+ Thread.sleep(KEEP_ACTIVE_INTERVAL_MS);
+ int actionDisplayId = getActionDisplayId();
+ if (actionDisplayId != Device.DISPLAY_ID_NONE) {
+ Device.keepActive(actionDisplayId);
+ }
+ }
+ } catch (InterruptedException e) {
+ // ignore
+ } catch (Throwable e) {
+ Ln.e("Keep active error", e);
+ } finally {
+ Ln.d("Keep active thread stopped");
+ }
+ });
+ keepActiveThread.setName("keep-active");
+ keepActiveThread.setDaemon(true);
+ keepActiveThread.start();
+ }
+
+ @Override
+ public void start(TerminationListener listener) {
+ if (keepActive) {
+ startKeepActiveThread();
+ }
+
+ thread = new Thread(() -> {
+ try {
+ control();
+ } catch (IOException e) {
+ Ln.e("Controller error", e);
+ } finally {
+ Ln.d("Controller stopped");
+ if (uhidManager != null) {
+ uhidManager.closeAll();
+ }
+ listener.onTerminated(true);
+ }
+ }, "control-recv");
+ thread.start();
+ if (sender != null) {
+ sender.start();
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (keepActiveThread != null) {
+ keepActiveThread.interrupt();
+ }
+ if (thread != null) {
+ thread.interrupt();
+ }
+ if (sender != null) {
+ sender.stop();
+ }
+ }
+
+ @Override
+ public void join() throws InterruptedException {
+ if (thread != null) {
+ thread.join();
+ }
+ if (sender != null) {
+ sender.join();
+ }
+ }
+
+ private boolean handleEvent() throws IOException {
+ ControlMessage msg;
+ try {
+ msg = controlChannel.recv();
+ } catch (ControlProtocolException e) {
+ Ln.e("Control protocol error", e);
+ return false;
+ } catch (IOException e) {
+ // this is expected on close
+ return false;
+ }
+
+ int type = msg.getType();
+
+ // Events for all sources (display or camera)
+ switch (type) {
+ case ControlMessage.TYPE_RESET_VIDEO:
+ resetVideo();
+ return true;
+ default:
+ // fall through
+ }
+
+ if (!camera) {
+ switch (type) {
+ case ControlMessage.TYPE_INJECT_KEYCODE:
+ if (supportsInputEvents) {
+ injectKeycode(msg.getAction(), msg.getKeycode(), msg.getRepeat(), msg.getMetaState());
+ }
+ return true;
+ case ControlMessage.TYPE_INJECT_TEXT:
+ if (supportsInputEvents) {
+ injectText(msg.getText());
+ }
+ return true;
+ case ControlMessage.TYPE_INJECT_TOUCH_EVENT:
+ if (supportsInputEvents) {
+ injectTouch(
+ msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getActionButton(), msg.getButtons());
+ }
+ return true;
+ case ControlMessage.TYPE_INJECT_SCROLL_EVENT:
+ if (supportsInputEvents) {
+ injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll(), msg.getButtons());
+ }
+ return true;
+ case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
+ if (supportsInputEvents) {
+ pressBackOrTurnScreenOn(msg.getAction());
+ }
+ return true;
+ case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
+ Device.expandNotificationPanel();
+ return true;
+ case ControlMessage.TYPE_EXPAND_SETTINGS_PANEL:
+ Device.expandSettingsPanel();
+ return true;
+ case ControlMessage.TYPE_COLLAPSE_PANELS:
+ Device.collapsePanels();
+ return true;
+ case ControlMessage.TYPE_GET_CLIPBOARD:
+ getClipboard(msg.getCopyKey());
+ return true;
+ case ControlMessage.TYPE_SET_CLIPBOARD:
+ setClipboard(msg.getText(), msg.getPaste(), msg.getSequence());
+ return true;
+ case ControlMessage.TYPE_SET_DISPLAY_POWER:
+ if (supportsInputEvents) {
+ setDisplayPower(msg.getOn());
+ }
+ return true;
+ case ControlMessage.TYPE_ROTATE_DEVICE:
+ int actionDisplayId = getActionDisplayId();
+ if (actionDisplayId != Device.DISPLAY_ID_NONE) {
+ Device.rotateDevice(actionDisplayId);
+ }
+ return true;
+ case ControlMessage.TYPE_UHID_CREATE:
+ getUhidManager().open(msg.getId(), msg.getVendorId(), msg.getProductId(), msg.getText(), msg.getData());
+ return true;
+ case ControlMessage.TYPE_UHID_INPUT:
+ getUhidManager().writeInput(msg.getId(), msg.getData());
+ return true;
+ case ControlMessage.TYPE_UHID_DESTROY:
+ getUhidManager().close(msg.getId());
+ return true;
+ case ControlMessage.TYPE_OPEN_HARD_KEYBOARD_SETTINGS:
+ openHardKeyboardSettings();
+ return true;
+ case ControlMessage.TYPE_START_APP:
+ startAppAsync(msg.getText());
+ return true;
+ case ControlMessage.TYPE_RESIZE_DISPLAY:
+ resizeDisplay(msg.getWidth(), msg.getHeight());
+ return true;
+ case ControlMessage.TYPE_SCAN_FILE:
+ scanFile(msg.getText());
+ return true;
+ default:
+ // fall through
+ }
+ } else {
+ assert surfaceCapture instanceof CameraCapture;
+ CameraCapture cameraCapture = (CameraCapture) surfaceCapture;
+ switch (type) {
+ case ControlMessage.TYPE_CAMERA_SET_TORCH:
+ cameraCapture.setTorchEnabled(msg.getOn());
+ return true;
+ case ControlMessage.TYPE_CAMERA_ZOOM_IN:
+ cameraCapture.zoomIn();
+ return true;
+ case ControlMessage.TYPE_CAMERA_ZOOM_OUT:
+ cameraCapture.zoomOut();
+ return true;
+ default:
+ // fall through
+ }
+ }
+
+ throw new AssertionError("Unexpected message type: " + type);
+ }
+
+ private boolean injectKeycode(int action, int keycode, int repeat, int metaState) {
+ if (keepDisplayPowerOff && action == KeyEvent.ACTION_UP && (keycode == KeyEvent.KEYCODE_POWER || keycode == KeyEvent.KEYCODE_WAKEUP)) {
+ assert displayId != Device.DISPLAY_ID_NONE;
+ scheduleDisplayPowerOff(displayId);
+ }
+ return injectKeyEvent(action, keycode, repeat, metaState, Device.INJECT_MODE_ASYNC);
+ }
+
+ 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;
+ }
+
+ int actionDisplayId = getActionDisplayId();
+ if (actionDisplayId != Device.DISPLAY_ID_NONE) {
+ for (KeyEvent event : events) {
+ if (!Device.injectEvent(event, actionDisplayId, Device.INJECT_MODE_ASYNC)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ private int injectText(String text) {
+ int successCount = 0;
+ for (char c : text.toCharArray()) {
+ if (!injectChar(c)) {
+ Ln.w("Could not inject char u+" + String.format("%04x", (int) c));
+ continue;
+ }
+ successCount++;
+ }
+ return successCount;
+ }
+
+ private Pair getEventPointAndDisplayId(Position position) {
+ // it hides the field on purpose, to read it with atomic access
+ @SuppressWarnings("checkstyle:HiddenField")
+ DisplayData displayData = this.displayData.get();
+ // In scrcpy, displayData should never be null (a touch event can only be generated from the client when a video frame is present).
+ // However, it is possible to send events without video playback when using scrcpy-server alone (except for virtual displays).
+ assert displayData != null || displayId != Device.DISPLAY_ID_NONE : "Cannot receive a positional event without a display";
+
+ Point point;
+ int targetDisplayId;
+ if (displayData != null) {
+ point = displayData.positionMapper.map(position);
+ if (point == null) {
+ if (Ln.isEnabled(Ln.Level.VERBOSE)) {
+ Size eventSize = position.getScreenSize();
+ Size currentSize = displayData.positionMapper.getVideoSize();
+ Ln.v("Ignore positional event generated for size " + eventSize + " (current size is " + currentSize + ")");
+ }
+ return null;
+ }
+ targetDisplayId = displayData.virtualDisplayId;
+ } else {
+ // No display, use the raw coordinates
+ point = position.getPoint();
+ targetDisplayId = displayId;
+ }
+
+ return Pair.create(point, targetDisplayId);
+ }
+
+ private boolean injectTouch(int action, long pointerId, Position position, float pressure, int actionButton, int buttons) {
+ long now = SystemClock.uptimeMillis();
+
+ Pair pair = getEventPointAndDisplayId(position);
+ if (pair == null) {
+ return false;
+ }
+
+ Point point = pair.first;
+ int targetDisplayId = pair.second;
+
+ int pointerIndex = pointersState.getPointerIndex(pointerId);
+ if (pointerIndex == -1) {
+ Ln.w("Too many pointers for touch event");
+ return false;
+ }
+ Pointer pointer = pointersState.get(pointerIndex);
+ pointer.setPoint(point);
+ pointer.setPressure(pressure);
+
+ int source;
+ boolean activeSecondaryButtons = ((actionButton | buttons) & ~MotionEvent.BUTTON_PRIMARY) != 0;
+ if (pointerId == POINTER_ID_MOUSE && (action == MotionEvent.ACTION_HOVER_MOVE || activeSecondaryButtons)) {
+ // real mouse event, or event incompatible with a finger
+ pointerProperties[pointerIndex].toolType = MotionEvent.TOOL_TYPE_MOUSE;
+ source = InputDevice.SOURCE_MOUSE;
+ pointer.setUp(buttons == 0);
+ } else {
+ // POINTER_ID_GENERIC_FINGER, POINTER_ID_VIRTUAL_FINGER or real touch from device
+ pointerProperties[pointerIndex].toolType = MotionEvent.TOOL_TYPE_FINGER;
+ source = InputDevice.SOURCE_TOUCHSCREEN;
+ // Buttons must not be set for touch events
+ buttons = 0;
+ pointer.setUp(action == MotionEvent.ACTION_UP);
+ }
+
+ int pointerCount = pointersState.update(pointerProperties, pointerCoords);
+ if (pointerCount == 1) {
+ if (action == MotionEvent.ACTION_DOWN) {
+ lastTouchDown = now;
+ }
+ } else {
+ // secondary pointers must use ACTION_POINTER_* ORed with the pointerIndex
+ if (action == MotionEvent.ACTION_UP) {
+ action = MotionEvent.ACTION_POINTER_UP | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
+ } else if (action == MotionEvent.ACTION_DOWN) {
+ action = MotionEvent.ACTION_POINTER_DOWN | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
+ }
+ }
+
+ /* If the input device is a mouse (on API >= 23):
+ * - the first button pressed must first generate ACTION_DOWN;
+ * - all button pressed (including the first one) must generate ACTION_BUTTON_PRESS;
+ * - all button released (including the last one) must generate ACTION_BUTTON_RELEASE;
+ * - the last button released must in addition generate ACTION_UP.
+ *
+ * Otherwise, Chrome does not work properly:
+ */
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0 && source == InputDevice.SOURCE_MOUSE) {
+ if (action == MotionEvent.ACTION_DOWN) {
+ if (actionButton == buttons) {
+ // First button pressed: ACTION_DOWN
+ MotionEvent downEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_DOWN, pointerCount, pointerProperties,
+ pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0);
+ if (!Device.injectEvent(downEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) {
+ return false;
+ }
+ }
+
+ // Any button pressed: ACTION_BUTTON_PRESS
+ MotionEvent pressEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_BUTTON_PRESS, pointerCount, pointerProperties,
+ pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0);
+ if (!InputManager.setActionButton(pressEvent, actionButton)) {
+ return false;
+ }
+ if (!Device.injectEvent(pressEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ if (action == MotionEvent.ACTION_UP) {
+ // Any button released: ACTION_BUTTON_RELEASE
+ MotionEvent releaseEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_BUTTON_RELEASE, pointerCount, pointerProperties,
+ pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0);
+ if (!InputManager.setActionButton(releaseEvent, actionButton)) {
+ return false;
+ }
+ if (!Device.injectEvent(releaseEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) {
+ return false;
+ }
+
+ if (buttons == 0) {
+ // Last button released: ACTION_UP
+ MotionEvent upEvent = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_UP, pointerCount, pointerProperties,
+ pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0);
+ if (!Device.injectEvent(upEvent, targetDisplayId, Device.INJECT_MODE_ASYNC)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+ }
+
+ MotionEvent event = MotionEvent.obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f,
+ DEFAULT_DEVICE_ID, 0, source, 0);
+ return Device.injectEvent(event, targetDisplayId, Device.INJECT_MODE_ASYNC);
+ }
+
+ private boolean injectScroll(Position position, float hScroll, float vScroll, int buttons) {
+ long now = SystemClock.uptimeMillis();
+
+ Pair pair = getEventPointAndDisplayId(position);
+ if (pair == null) {
+ return false;
+ }
+
+ Point point = pair.first;
+ int targetDisplayId = pair.second;
+
+ MotionEvent.PointerProperties props = pointerProperties[0];
+ props.id = 0;
+
+ MotionEvent.PointerCoords coords = pointerCoords[0];
+ coords.x = point.getX();
+ coords.y = point.getY();
+ coords.setAxisValue(MotionEvent.AXIS_HSCROLL, hScroll);
+ coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll);
+
+ MotionEvent event = MotionEvent.obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, buttons, 1f, 1f,
+ DEFAULT_DEVICE_ID, 0, InputDevice.SOURCE_MOUSE, 0);
+ return Device.injectEvent(event, targetDisplayId, Device.INJECT_MODE_ASYNC);
+ }
+
+ /**
+ * Schedule a call to set display power to off after a small delay.
+ */
+ private static void scheduleDisplayPowerOff(int displayId) {
+ EXECUTOR.schedule(() -> {
+ Ln.i("Forcing display off");
+ Device.setDisplayPower(displayId, false);
+ }, 200, TimeUnit.MILLISECONDS);
+ }
+
+ private boolean pressBackOrTurnScreenOn(int action) {
+ boolean injectBack;
+ // Device.isScreenOn(displayId) ignores the displayId below Android 14
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) {
+ // Inject BACK if the screen is on for the current virtual display id
+ int actionDisplayId = getActionDisplayId();
+ injectBack = actionDisplayId == Device.DISPLAY_ID_NONE || Device.isScreenOn(actionDisplayId);
+ } else {
+ // Inject BACK if the display is not the main display, or if the main display is on
+ injectBack = displayId != 0 || Device.isScreenOn(0);
+ }
+ if (injectBack) {
+ return injectKeyEvent(action, KeyEvent.KEYCODE_BACK, 0, 0, Device.INJECT_MODE_ASYNC);
+ }
+
+ // Screen is off
+ // Only press POWER on ACTION_DOWN
+ if (action != KeyEvent.ACTION_DOWN) {
+ // do nothing
+ return true;
+ }
+
+ if (keepDisplayPowerOff) {
+ assert displayId != Device.DISPLAY_ID_NONE;
+ scheduleDisplayPowerOff(displayId);
+ }
+ return pressReleaseKeycode(KeyEvent.KEYCODE_POWER, Device.INJECT_MODE_ASYNC);
+ }
+
+ private void getClipboard(int copyKey) {
+ // On Android >= 7, press the COPY or CUT key if requested
+ if (copyKey != ControlMessage.COPY_KEY_NONE && Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0 && supportsInputEvents) {
+ int key = copyKey == ControlMessage.COPY_KEY_COPY ? KeyEvent.KEYCODE_COPY : KeyEvent.KEYCODE_CUT;
+ // Wait until the event is finished, to ensure that the clipboard text we read just after is the correct one
+ pressReleaseKeycode(key, Device.INJECT_MODE_WAIT_FOR_FINISH);
+ }
+
+ // If clipboard autosync is enabled, then the device clipboard is synchronized to the computer clipboard whenever it changes, in
+ // particular when COPY or CUT are injected, so it should not be synchronized twice. On Android < 7, do not synchronize at all rather than
+ // copying an old clipboard content.
+ if (!clipboardAutosync) {
+ String clipboardText = Device.getClipboardText();
+ if (clipboardText != null) {
+ DeviceMessage msg = DeviceMessage.createClipboard(clipboardText);
+ sender.send(msg);
+ }
+ }
+ }
+
+ private boolean setClipboard(String text, boolean paste, long sequence) {
+ isSettingClipboard.set(true);
+ boolean ok = Device.setClipboardText(text);
+ isSettingClipboard.set(false);
+ if (ok) {
+ Ln.i("Device clipboard set");
+ }
+
+ // On Android >= 7, also press the PASTE key if requested
+ if (paste && Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0 && supportsInputEvents) {
+ pressReleaseKeycode(KeyEvent.KEYCODE_PASTE, Device.INJECT_MODE_ASYNC);
+ }
+
+ if (sequence != ControlMessage.SEQUENCE_INVALID) {
+ // Acknowledgement requested
+ DeviceMessage msg = DeviceMessage.createAckClipboard(sequence);
+ sender.send(msg);
+ }
+
+ return ok;
+ }
+
+ private void openHardKeyboardSettings() {
+ Intent intent = new Intent("android.settings.HARD_KEYBOARD_SETTINGS");
+ ServiceManager.getActivityManager().startActivity(intent);
+ }
+
+ private boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState, int injectMode) {
+ int actionDisplayId = getActionDisplayId();
+ if (actionDisplayId == Device.DISPLAY_ID_NONE) {
+ return false;
+ }
+ return Device.injectKeyEvent(action, keyCode, repeat, metaState, actionDisplayId, injectMode);
+ }
+
+ private boolean pressReleaseKeycode(int keyCode, int injectMode) {
+ int actionDisplayId = getActionDisplayId();
+ if (actionDisplayId == Device.DISPLAY_ID_NONE) {
+ return false;
+ }
+ return Device.pressReleaseKeycode(keyCode, actionDisplayId, injectMode);
+ }
+
+ private int getActionDisplayId() {
+ if (displayId != Device.DISPLAY_ID_NONE) {
+ // Real screen mirrored, use the source display id
+ return displayId;
+ }
+
+ // Virtual display created by --new-display, use the virtualDisplayId
+ DisplayData data = displayData.get();
+ if (data == null) {
+ return Device.DISPLAY_ID_NONE;
+ }
+
+ return data.virtualDisplayId;
+ }
+
+ private void startAppAsync(String name) {
+ if (startAppExecutor == null) {
+ startAppExecutor = Executors.newSingleThreadExecutor();
+ }
+
+ // Listing and selecting the app may take a lot of time
+ startAppExecutor.submit(() -> startApp(name));
+ }
+
+ private void startApp(String name) {
+ boolean forceStopBeforeStart = name.startsWith("+");
+ if (forceStopBeforeStart) {
+ name = name.substring(1);
+ }
+
+ DeviceApp app;
+ boolean searchByName = name.startsWith("?");
+ if (searchByName) {
+ name = name.substring(1);
+
+ Ln.i("Processing Android apps... (this may take some time)");
+ List apps = Device.findByName(name);
+ if (apps.isEmpty()) {
+ Ln.w("No app found for name \"" + name + "\"");
+ return;
+ }
+
+ if (apps.size() > 1) {
+ String title = "No unique app found for name \"" + name + "\":";
+ Ln.w(LogUtils.buildAppListMessage(title, apps));
+ return;
+ }
+
+ app = apps.get(0);
+ } else {
+ app = Device.findByPackageName(name);
+ if (app == null) {
+ Ln.w("No app found for package \"" + name + "\"");
+ return;
+ }
+ }
+
+ int startAppDisplayId = getStartAppDisplayId();
+ if (startAppDisplayId == Device.DISPLAY_ID_NONE) {
+ Ln.e("No known display id to start app \"" + name + "\"");
+ return;
+ }
+
+ Ln.i("Starting app \"" + app.getName() + "\" [" + app.getPackageName() + "] on display " + startAppDisplayId + "...");
+ Device.startApp(app.getPackageName(), startAppDisplayId, forceStopBeforeStart);
+ }
+
+ private int getStartAppDisplayId() {
+ if (displayId != Device.DISPLAY_ID_NONE) {
+ return displayId;
+ }
+
+ // Mirroring a new virtual display id (using --new-display-id feature)
+ try {
+ // Wait for at most 1 second until a virtual display id is known
+ DisplayData data = waitDisplayData(1000);
+ if (data != null) {
+ return data.virtualDisplayId;
+ }
+ } catch (InterruptedException e) {
+ // do nothing
+ }
+
+ // No display id available
+ return Device.DISPLAY_ID_NONE;
+ }
+
+ private DisplayData waitDisplayData(long timeoutMillis) throws InterruptedException {
+ long deadline = System.currentTimeMillis() + timeoutMillis;
+
+ synchronized (displayDataAvailable) {
+ DisplayData data = displayData.get();
+ while (data == null) {
+ long timeout = deadline - System.currentTimeMillis();
+ if (timeout < 0) {
+ return null;
+ }
+ if (timeout > 0) {
+ displayDataAvailable.wait(timeout);
+ }
+ data = displayData.get();
+ }
+
+ return data;
+ }
+ }
+
+ private void setDisplayPower(boolean on) {
+ // Change the power of the main display when mirroring a virtual display
+ int targetDisplayId = displayId != Device.DISPLAY_ID_NONE ? displayId : 0;
+ boolean setDisplayPowerOk = Device.setDisplayPower(targetDisplayId, on);
+ if (setDisplayPowerOk) {
+ // Do not keep display power off for virtual displays: MOD+p must wake up the physical device
+ keepDisplayPowerOff = displayId != Device.DISPLAY_ID_NONE && !on;
+ Ln.i("Device display turned " + (on ? "on" : "off"));
+ if (cleanUp != null) {
+ boolean mustRestoreOnExit = !on;
+ cleanUp.setRestoreDisplayPower(mustRestoreOnExit);
+ }
+ }
+ }
+
+ private void resetVideo() {
+ if (surfaceCapture != null) {
+ Ln.i("Video capture reset");
+ surfaceCapture.getCaptureControl().reset(CaptureControl.RESET_REASON_CLIENT_RESET);
+ }
+ }
+
+ private void resizeDisplay(int width, int height) {
+ NewDisplayCapture newDisplayCapture = (NewDisplayCapture) surfaceCapture;
+ newDisplayCapture.requestResize(width, height);
+ }
+
+ private void scanFile(String path) {
+ try {
+ @SuppressWarnings("deprecation")
+ Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(new File(path)));
+ Device.sendBroadcast(intent);
+ } catch (Throwable t) {
+ Ln.e("MediaStore scan failed for " + path, t);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessage.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessage.java
new file mode 100644
index 0000000..079a7a0
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessage.java
@@ -0,0 +1,59 @@
+package com.genymobile.scrcpy.control;
+
+public final class DeviceMessage {
+
+ public static final int TYPE_CLIPBOARD = 0;
+ public static final int TYPE_ACK_CLIPBOARD = 1;
+ public static final int TYPE_UHID_OUTPUT = 2;
+
+ private int type;
+ private String text;
+ private long sequence;
+ private int id;
+ private byte[] data;
+
+ private DeviceMessage() {
+ }
+
+ public static DeviceMessage createClipboard(String text) {
+ DeviceMessage event = new DeviceMessage();
+ event.type = TYPE_CLIPBOARD;
+ event.text = text;
+ return event;
+ }
+
+ public static DeviceMessage createAckClipboard(long sequence) {
+ DeviceMessage event = new DeviceMessage();
+ event.type = TYPE_ACK_CLIPBOARD;
+ event.sequence = sequence;
+ return event;
+ }
+
+ public static DeviceMessage createUhidOutput(int id, byte[] data) {
+ DeviceMessage event = new DeviceMessage();
+ event.type = TYPE_UHID_OUTPUT;
+ event.id = id;
+ event.data = data;
+ return event;
+ }
+
+ public int getType() {
+ return type;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public long getSequence() {
+ return sequence;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public byte[] getData() {
+ return data;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageSender.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageSender.java
new file mode 100644
index 0000000..dc5e6be
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageSender.java
@@ -0,0 +1,57 @@
+package com.genymobile.scrcpy.control;
+
+import com.genymobile.scrcpy.util.Ln;
+
+import java.io.IOException;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+
+public final class DeviceMessageSender {
+
+ private final ControlChannel controlChannel;
+
+ private Thread thread;
+ private final BlockingQueue queue = new ArrayBlockingQueue<>(16);
+
+ public DeviceMessageSender(ControlChannel controlChannel) {
+ this.controlChannel = controlChannel;
+ }
+
+ public void send(DeviceMessage msg) {
+ if (!queue.offer(msg)) {
+ Ln.w("Device message dropped: " + msg.getType());
+ }
+ }
+
+ private void loop() throws IOException, InterruptedException {
+ while (!Thread.currentThread().isInterrupted()) {
+ DeviceMessage msg = queue.take();
+ controlChannel.send(msg);
+ }
+ }
+
+ public void start() {
+ thread = new Thread(() -> {
+ try {
+ loop();
+ } catch (IOException | InterruptedException e) {
+ // this is expected on close
+ } finally {
+ Ln.d("Device message sender stopped");
+ }
+ }, "control-send");
+ thread.start();
+ }
+
+ public void stop() {
+ if (thread != null) {
+ thread.interrupt();
+ }
+ }
+
+ public void join() throws InterruptedException {
+ if (thread != null) {
+ thread.join();
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageWriter.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageWriter.java
new file mode 100644
index 0000000..a18a2e5
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/DeviceMessageWriter.java
@@ -0,0 +1,47 @@
+package com.genymobile.scrcpy.control;
+
+import com.genymobile.scrcpy.util.StringUtils;
+
+import java.io.BufferedOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+
+public class DeviceMessageWriter {
+
+ private static final int MESSAGE_MAX_SIZE = 1 << 18; // 256k
+ public static final int CLIPBOARD_TEXT_MAX_LENGTH = MESSAGE_MAX_SIZE - 5; // type: 1 byte; length: 4 bytes
+
+ private final DataOutputStream dos;
+
+ public DeviceMessageWriter(OutputStream rawOutputStream) {
+ dos = new DataOutputStream(new BufferedOutputStream(rawOutputStream));
+ }
+
+ public void write(DeviceMessage msg) throws IOException {
+ int type = msg.getType();
+ dos.writeByte(type);
+ switch (type) {
+ case DeviceMessage.TYPE_CLIPBOARD:
+ String text = msg.getText();
+ byte[] raw = text.getBytes(StandardCharsets.UTF_8);
+ int len = StringUtils.getUtf8TruncationIndex(raw, CLIPBOARD_TEXT_MAX_LENGTH);
+ dos.writeInt(len);
+ dos.write(raw, 0, len);
+ break;
+ case DeviceMessage.TYPE_ACK_CLIPBOARD:
+ dos.writeLong(msg.getSequence());
+ break;
+ case DeviceMessage.TYPE_UHID_OUTPUT:
+ dos.writeShort(msg.getId());
+ byte[] data = msg.getData();
+ dos.writeShort(data.length);
+ dos.write(data);
+ break;
+ default:
+ throw new ControlProtocolException("Unknown event type: " + type);
+ }
+ dos.flush();
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/KeyComposition.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/KeyComposition.java
new file mode 100644
index 0000000..5b988f5
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/KeyComposition.java
@@ -0,0 +1,174 @@
+package com.genymobile.scrcpy.control;
+
+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/third_party/scrcpy-java/com/genymobile/scrcpy/control/Pointer.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Pointer.java
new file mode 100644
index 0000000..4624a15
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/Pointer.java
@@ -0,0 +1,57 @@
+package com.genymobile.scrcpy.control;
+
+import com.genymobile.scrcpy.model.Point;
+
+public class Pointer {
+
+ /**
+ * Pointer id as received from the client.
+ */
+ private final long id;
+
+ /**
+ * Local pointer id, using the lowest possible values to fill the {@link android.view.MotionEvent.PointerProperties PointerProperties}.
+ */
+ private final int localId;
+
+ private Point point;
+ private float pressure;
+ private boolean up;
+
+ public Pointer(long id, int localId) {
+ this.id = id;
+ this.localId = localId;
+ }
+
+ public long getId() {
+ return id;
+ }
+
+ public int getLocalId() {
+ return localId;
+ }
+
+ public Point getPoint() {
+ return point;
+ }
+
+ public void setPoint(Point point) {
+ this.point = point;
+ }
+
+ public float getPressure() {
+ return pressure;
+ }
+
+ public void setPressure(float pressure) {
+ this.pressure = pressure;
+ }
+
+ public boolean isUp() {
+ return up;
+ }
+
+ public void setUp(boolean up) {
+ this.up = up;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PointersState.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PointersState.java
new file mode 100644
index 0000000..efe16f0
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PointersState.java
@@ -0,0 +1,105 @@
+package com.genymobile.scrcpy.control;
+
+import com.genymobile.scrcpy.model.Point;
+
+import android.view.MotionEvent;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class PointersState {
+
+ public static final int MAX_POINTERS = 10;
+
+ private final List pointers = new ArrayList<>();
+
+ private int indexOf(long id) {
+ for (int i = 0; i < pointers.size(); ++i) {
+ Pointer pointer = pointers.get(i);
+ if (pointer.getId() == id) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private boolean isLocalIdAvailable(int localId) {
+ for (int i = 0; i < pointers.size(); ++i) {
+ Pointer pointer = pointers.get(i);
+ if (pointer.getLocalId() == localId) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private int nextUnusedLocalId() {
+ for (int localId = 0; localId < MAX_POINTERS; ++localId) {
+ if (isLocalIdAvailable(localId)) {
+ return localId;
+ }
+ }
+ return -1;
+ }
+
+ public Pointer get(int index) {
+ return pointers.get(index);
+ }
+
+ public int getPointerIndex(long id) {
+ int index = indexOf(id);
+ if (index != -1) {
+ // already exists, return it
+ return index;
+ }
+ if (pointers.size() >= MAX_POINTERS) {
+ // it's full
+ return -1;
+ }
+ // id 0 is reserved for mouse events
+ int localId = nextUnusedLocalId();
+ if (localId == -1) {
+ throw new AssertionError("pointers.size() < maxFingers implies that a local id is available");
+ }
+ Pointer pointer = new Pointer(id, localId);
+ pointers.add(pointer);
+ // return the index of the pointer
+ return pointers.size() - 1;
+ }
+
+ /**
+ * Initialize the motion event parameters.
+ *
+ * @param props the pointer properties
+ * @param coords the pointer coordinates
+ * @return The number of items initialized (the number of pointers).
+ */
+ public int update(MotionEvent.PointerProperties[] props, MotionEvent.PointerCoords[] coords) {
+ int count = pointers.size();
+ for (int i = 0; i < count; ++i) {
+ Pointer pointer = pointers.get(i);
+
+ // id 0 is reserved for mouse events
+ props[i].id = pointer.getLocalId();
+
+ Point point = pointer.getPoint();
+ coords[i].x = point.getX();
+ coords[i].y = point.getY();
+ coords[i].pressure = pointer.getPressure();
+ }
+ cleanUp();
+ return count;
+ }
+
+ /**
+ * Remove all pointers which are UP.
+ */
+ private void cleanUp() {
+ for (int i = pointers.size() - 1; i >= 0; --i) {
+ Pointer pointer = pointers.get(i);
+ if (pointer.isUp()) {
+ pointers.remove(i);
+ }
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PositionMapper.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PositionMapper.java
new file mode 100644
index 0000000..e8daed9
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/PositionMapper.java
@@ -0,0 +1,48 @@
+package com.genymobile.scrcpy.control;
+
+import com.genymobile.scrcpy.model.Point;
+import com.genymobile.scrcpy.model.Position;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.util.AffineMatrix;
+
+public final class PositionMapper {
+
+ private final Size videoSize;
+ private final AffineMatrix videoToDeviceMatrix;
+
+ public PositionMapper(Size videoSize, AffineMatrix videoToDeviceMatrix) {
+ this.videoSize = videoSize;
+ this.videoToDeviceMatrix = videoToDeviceMatrix;
+ }
+
+ public static PositionMapper create(Size videoSize, AffineMatrix filterTransform, Size targetSize) {
+ boolean convertToPixels = !videoSize.equals(targetSize) || filterTransform != null;
+ AffineMatrix transform = filterTransform;
+ if (convertToPixels) {
+ AffineMatrix inputTransform = AffineMatrix.ndcFromPixels(videoSize);
+ AffineMatrix outputTransform = AffineMatrix.ndcToPixels(targetSize);
+ transform = outputTransform.multiply(transform).multiply(inputTransform);
+ }
+
+ return new PositionMapper(videoSize, transform);
+ }
+
+ public Size getVideoSize() {
+ return videoSize;
+ }
+
+ public Point map(Position position) {
+ 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;
+ }
+
+ Point point = position.getPoint();
+ if (videoToDeviceMatrix != null) {
+ point = videoToDeviceMatrix.apply(point);
+ }
+ return point;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/UhidManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/UhidManager.java
new file mode 100644
index 0000000..20532c0
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/control/UhidManager.java
@@ -0,0 +1,287 @@
+package com.genymobile.scrcpy.control;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.util.StringUtils;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.os.Build;
+import android.os.HandlerThread;
+import android.os.MessageQueue;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+import android.util.ArrayMap;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+
+public final class UhidManager {
+
+ // Linux: include/uapi/linux/uhid.h
+ private static final int UHID_OUTPUT = 6;
+ private static final int UHID_CREATE2 = 11;
+ private static final int UHID_INPUT2 = 12;
+
+ // Linux: include/uapi/linux/input.h
+ private static final short BUS_VIRTUAL = 0x06;
+
+ private static final int SIZE_OF_UHID_EVENT = 4380; // sizeof(struct uhid_event)
+
+ // Must be unique across the system
+ private static final String INPUT_PORT = "scrcpy:" + Os.getpid();
+
+ private final String displayUniqueId;
+
+ private final ArrayMap fds = new ArrayMap<>();
+ private final ByteBuffer buffer = ByteBuffer.allocate(SIZE_OF_UHID_EVENT).order(ByteOrder.nativeOrder());
+
+ private final DeviceMessageSender sender;
+ private final MessageQueue queue;
+
+ public UhidManager(DeviceMessageSender sender, String displayUniqueId) {
+ this.sender = sender;
+ this.displayUniqueId = displayUniqueId;
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) {
+ HandlerThread thread = new HandlerThread("UHidManager");
+ thread.start();
+ queue = thread.getLooper().getQueue();
+ } else {
+ queue = null;
+ }
+ }
+
+ public void open(int id, int vendorId, int productId, String name, byte[] reportDesc) throws IOException {
+ try {
+ FileDescriptor fd = Os.open("/dev/uhid", OsConstants.O_RDWR, 0);
+ try {
+ // First UHID device added
+ boolean firstDevice = fds.isEmpty();
+
+ FileDescriptor old = fds.put(id, fd);
+ if (old != null) {
+ Ln.w("Duplicate UHID id: " + id);
+ close(old);
+ }
+
+ String phys = mustUseInputPort() ? INPUT_PORT : null;
+ byte[] req = buildUhidCreate2Req(vendorId, productId, name, reportDesc, phys);
+ Os.write(fd, req, 0, req.length);
+
+ if (firstDevice) {
+ addUniqueIdAssociation();
+ }
+ registerUhidListener(id, fd);
+ } catch (Exception e) {
+ close(fd);
+ throw e;
+ }
+ } catch (ErrnoException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private void registerUhidListener(int id, FileDescriptor fd) {
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) {
+ queue.addOnFileDescriptorEventListener(fd, MessageQueue.OnFileDescriptorEventListener.EVENT_INPUT, (fd2, events) -> {
+ try {
+ buffer.clear();
+ int r = Os.read(fd2, buffer);
+ buffer.flip();
+ if (r > 0) {
+ int type = buffer.getInt();
+ if (type == UHID_OUTPUT) {
+ byte[] data = extractHidOutputData(buffer);
+ if (data != null) {
+ DeviceMessage msg = DeviceMessage.createUhidOutput(id, data);
+ sender.send(msg);
+ }
+ }
+ }
+ } catch (ErrnoException | InterruptedIOException e) {
+ Ln.e("Failed to read UHID output", e);
+ return 0;
+ }
+ return events;
+ });
+ }
+ }
+
+ private void unregisterUhidListener(FileDescriptor fd) {
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) {
+ queue.removeOnFileDescriptorEventListener(fd);
+ }
+ }
+
+ private static byte[] extractHidOutputData(ByteBuffer buffer) {
+ /*
+ * #define UHID_DATA_MAX 4096
+ * struct uhid_event {
+ * uint32_t type;
+ * union {
+ * // ...
+ * struct uhid_output_req {
+ * __u8 data[UHID_DATA_MAX];
+ * __u16 size;
+ * __u8 rtype;
+ * };
+ * };
+ * } __attribute__((__packed__));
+ */
+
+ if (buffer.remaining() < 4099) {
+ Ln.w("Incomplete HID output");
+ return null;
+ }
+ int size = buffer.getShort(buffer.position() + 4096) & 0xFFFF;
+ if (size > 4096) {
+ Ln.w("Incorrect HID output size: " + size);
+ return null;
+ }
+ byte[] data = new byte[size];
+ buffer.get(data);
+ return data;
+ }
+
+ public void writeInput(int id, byte[] data) throws IOException {
+ FileDescriptor fd = fds.get(id);
+ if (fd == null) {
+ Ln.w("Unknown UHID id: " + id);
+ return;
+ }
+
+ try {
+ byte[] req = buildUhidInput2Req(data);
+ Os.write(fd, req, 0, req.length);
+ } catch (ErrnoException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static byte[] buildUhidCreate2Req(int vendorId, int productId, String name, byte[] reportDesc, String phys) {
+ /*
+ * struct uhid_event {
+ * uint32_t type;
+ * union {
+ * // ...
+ * struct uhid_create2_req {
+ * uint8_t name[128];
+ * uint8_t phys[64];
+ * uint8_t uniq[64];
+ * uint16_t rd_size;
+ * uint16_t bus;
+ * uint32_t vendor;
+ * uint32_t product;
+ * uint32_t version;
+ * uint32_t country;
+ * uint8_t rd_data[HID_MAX_DESCRIPTOR_SIZE];
+ * };
+ * };
+ * } __attribute__((__packed__));
+ */
+
+ ByteBuffer buf = ByteBuffer.allocate(280 + reportDesc.length).order(ByteOrder.nativeOrder());
+ buf.putInt(UHID_CREATE2);
+
+ String actualName = name.isEmpty() ? "scrcpy" : name;
+ byte[] nameBytes = actualName.getBytes(StandardCharsets.UTF_8);
+ int nameLen = StringUtils.getUtf8TruncationIndex(nameBytes, 127);
+ assert nameLen <= 127;
+ buf.put(nameBytes, 0, nameLen);
+
+ if (phys != null) {
+ buf.position(4 + 128);
+ byte[] physBytes = phys.getBytes(StandardCharsets.US_ASCII);
+ assert physBytes.length <= 63;
+ buf.put(physBytes);
+ }
+
+ buf.position(4 + 256);
+ buf.putShort((short) reportDesc.length);
+ buf.putShort(BUS_VIRTUAL);
+ buf.putInt(vendorId);
+ buf.putInt(productId);
+ buf.putInt(0); // version
+ buf.putInt(0); // country;
+ buf.put(reportDesc);
+ return buf.array();
+ }
+
+ private static byte[] buildUhidInput2Req(byte[] data) {
+ /*
+ * struct uhid_event {
+ * uint32_t type;
+ * union {
+ * // ...
+ * struct uhid_input2_req {
+ * uint16_t size;
+ * uint8_t data[UHID_DATA_MAX];
+ * };
+ * };
+ * } __attribute__((__packed__));
+ */
+
+ ByteBuffer buf = ByteBuffer.allocate(6 + data.length).order(ByteOrder.nativeOrder());
+ buf.putInt(UHID_INPUT2);
+ buf.putShort((short) data.length);
+ buf.put(data);
+ return buf.array();
+ }
+
+ public void close(int id) {
+ // Linux: Documentation/hid/uhid.rst
+ // If you close() the fd, the device is automatically unregistered and destroyed internally.
+ FileDescriptor fd = fds.remove(id);
+ if (fd != null) {
+ unregisterUhidListener(fd);
+ close(fd);
+
+ if (fds.isEmpty()) {
+ // Last UHID device removed
+ removeUniqueIdAssociation();
+ }
+ } else {
+ Ln.w("Closing unknown UHID device: " + id);
+ }
+ }
+
+ public void closeAll() {
+ if (fds.isEmpty()) {
+ return;
+ }
+
+ for (FileDescriptor fd : fds.values()) {
+ close(fd);
+ }
+
+ removeUniqueIdAssociation();
+ }
+
+ private static void close(FileDescriptor fd) {
+ try {
+ Os.close(fd);
+ } catch (ErrnoException e) {
+ Ln.e("Failed to close uhid: " + e.getMessage());
+ }
+ }
+
+ private boolean mustUseInputPort() {
+ return Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15 && displayUniqueId != null;
+ }
+
+ private void addUniqueIdAssociation() {
+ if (mustUseInputPort()) {
+ ServiceManager.getInputManager().addUniqueIdAssociationByPort(INPUT_PORT, displayUniqueId);
+ }
+ }
+
+ private void removeUniqueIdAssociation() {
+ if (mustUseInputPort()) {
+ ServiceManager.getInputManager().removeUniqueIdAssociationByPort(INPUT_PORT);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/DesktopConnection.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/DesktopConnection.java
new file mode 100644
index 0000000..db75aec
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/DesktopConnection.java
@@ -0,0 +1,178 @@
+package com.genymobile.scrcpy.device;
+
+import com.genymobile.scrcpy.control.ControlChannel;
+import com.genymobile.scrcpy.util.IO;
+import com.genymobile.scrcpy.util.StringUtils;
+
+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.nio.charset.StandardCharsets;
+
+public final class DesktopConnection implements Closeable {
+
+ private static final int DEVICE_NAME_FIELD_LENGTH = 64;
+
+ private static final String SOCKET_NAME_PREFIX = "scrcpy";
+
+ private final LocalSocket videoSocket;
+ private final FileDescriptor videoFd;
+
+ private final LocalSocket audioSocket;
+ private final FileDescriptor audioFd;
+
+ private final LocalSocket controlSocket;
+ private final ControlChannel controlChannel;
+
+ private DesktopConnection(LocalSocket videoSocket, LocalSocket audioSocket, LocalSocket controlSocket) throws IOException {
+ this.videoSocket = videoSocket;
+ this.audioSocket = audioSocket;
+ this.controlSocket = controlSocket;
+
+ videoFd = videoSocket != null ? videoSocket.getFileDescriptor() : null;
+ audioFd = audioSocket != null ? audioSocket.getFileDescriptor() : null;
+ controlChannel = controlSocket != null ? new ControlChannel(controlSocket) : null;
+ }
+
+ private static LocalSocket connect(String abstractName) throws IOException {
+ LocalSocket localSocket = new LocalSocket();
+ localSocket.connect(new LocalSocketAddress(abstractName));
+ return localSocket;
+ }
+
+ private static String getSocketName(int scid) {
+ if (scid == -1) {
+ // If no SCID is set, use "scrcpy" to simplify using scrcpy-server alone
+ return SOCKET_NAME_PREFIX;
+ }
+
+ return SOCKET_NAME_PREFIX + String.format("_%08x", scid);
+ }
+
+ public static DesktopConnection open(int scid, boolean tunnelForward, boolean video, boolean audio, boolean control, boolean sendDummyByte)
+ throws IOException {
+ String socketName = getSocketName(scid);
+
+ LocalSocket videoSocket = null;
+ LocalSocket audioSocket = null;
+ LocalSocket controlSocket = null;
+ try {
+ if (tunnelForward) {
+ try (LocalServerSocket localServerSocket = new LocalServerSocket(socketName)) {
+ if (video) {
+ videoSocket = localServerSocket.accept();
+ if (sendDummyByte) {
+ // send one byte so the client may read() to detect a connection error
+ videoSocket.getOutputStream().write(0);
+ sendDummyByte = false;
+ }
+ }
+ if (audio) {
+ audioSocket = localServerSocket.accept();
+ if (sendDummyByte) {
+ // send one byte so the client may read() to detect a connection error
+ audioSocket.getOutputStream().write(0);
+ sendDummyByte = false;
+ }
+ }
+ if (control) {
+ controlSocket = localServerSocket.accept();
+ if (sendDummyByte) {
+ // send one byte so the client may read() to detect a connection error
+ controlSocket.getOutputStream().write(0);
+ sendDummyByte = false;
+ }
+ }
+ }
+ } else {
+ if (video) {
+ videoSocket = connect(socketName);
+ }
+ if (audio) {
+ audioSocket = connect(socketName);
+ }
+ if (control) {
+ controlSocket = connect(socketName);
+ }
+ }
+ } catch (IOException | RuntimeException e) {
+ if (videoSocket != null) {
+ videoSocket.close();
+ }
+ if (audioSocket != null) {
+ audioSocket.close();
+ }
+ if (controlSocket != null) {
+ controlSocket.close();
+ }
+ throw e;
+ }
+
+ return new DesktopConnection(videoSocket, audioSocket, controlSocket);
+ }
+
+ private LocalSocket getFirstSocket() {
+ if (videoSocket != null) {
+ return videoSocket;
+ }
+ if (audioSocket != null) {
+ return audioSocket;
+ }
+ return controlSocket;
+ }
+
+ public void shutdown() throws IOException {
+ if (videoSocket != null) {
+ videoSocket.shutdownInput();
+ videoSocket.shutdownOutput();
+ }
+ if (audioSocket != null) {
+ audioSocket.shutdownInput();
+ audioSocket.shutdownOutput();
+ }
+ if (controlSocket != null) {
+ controlSocket.shutdownInput();
+ controlSocket.shutdownOutput();
+ }
+ }
+
+ public void close() throws IOException {
+ if (videoSocket != null) {
+ videoSocket.close();
+ }
+ if (audioSocket != null) {
+ audioSocket.close();
+ }
+ if (controlSocket != null) {
+ controlSocket.close();
+ }
+ }
+
+ public void sendDeviceMeta(String deviceName) throws IOException {
+ byte[] buffer = new byte[DEVICE_NAME_FIELD_LENGTH];
+
+ byte[] deviceNameBytes = deviceName.getBytes(StandardCharsets.UTF_8);
+ int len = StringUtils.getUtf8TruncationIndex(deviceNameBytes, DEVICE_NAME_FIELD_LENGTH - 1);
+ System.arraycopy(deviceNameBytes, 0, buffer, 0, len);
+ // byte[] are always 0-initialized in java, no need to set '\0' explicitly
+
+ FileDescriptor fd = getFirstSocket().getFileDescriptor();
+ IO.writeFully(fd, buffer, 0, buffer.length);
+ }
+
+ public FileDescriptor getVideoFd() {
+ return videoFd;
+ }
+
+ public FileDescriptor getAudioFd() {
+ return audioFd;
+ }
+
+ public ControlChannel getControlChannel() {
+ return controlChannel;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Device.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Device.java
new file mode 100644
index 0000000..615675a
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Device.java
@@ -0,0 +1,326 @@
+package com.genymobile.scrcpy.device;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.FakeContext;
+import com.genymobile.scrcpy.display.DisplayInfo;
+import com.genymobile.scrcpy.model.DeviceApp;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.wrappers.ActivityManager;
+import com.genymobile.scrcpy.wrappers.ClipboardManager;
+import com.genymobile.scrcpy.wrappers.DisplayControl;
+import com.genymobile.scrcpy.wrappers.InputManager;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+import com.genymobile.scrcpy.wrappers.SurfaceControl;
+import com.genymobile.scrcpy.wrappers.WindowManager;
+
+import android.annotation.SuppressLint;
+import android.app.ActivityOptions;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.SystemClock;
+import android.view.InputDevice;
+import android.view.InputEvent;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+public final class Device {
+
+ public static final int DISPLAY_ID_NONE = -1;
+
+ public static final int POWER_MODE_OFF = SurfaceControl.POWER_MODE_OFF;
+ public static final int POWER_MODE_NORMAL = SurfaceControl.POWER_MODE_NORMAL;
+
+ public static final int INJECT_MODE_ASYNC = InputManager.INJECT_INPUT_EVENT_MODE_ASYNC;
+ public static final int INJECT_MODE_WAIT_FOR_RESULT = InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT;
+ public static final int INJECT_MODE_WAIT_FOR_FINISH = InputManager.INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH;
+
+ // The new display power method introduced in Android 15 does not work as expected:
+ //
+ private static final boolean USE_ANDROID_15_DISPLAY_POWER = false;
+
+ private Device() {
+ // not instantiable
+ }
+
+ public static String getDeviceName() {
+ return Build.MODEL;
+ }
+
+ public static boolean supportsInputEvents(int displayId) {
+ // main display or any display on Android >= 10
+ return displayId == 0 || Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10;
+ }
+
+ public static boolean injectEvent(InputEvent inputEvent, int displayId, int injectMode) {
+ if (!supportsInputEvents(displayId)) {
+ throw new AssertionError("Could not inject input event if !supportsInputEvents()");
+ }
+
+ if (displayId != 0 && !InputManager.setDisplayId(inputEvent, displayId)) {
+ return false;
+ }
+
+ return ServiceManager.getInputManager().injectInputEvent(inputEvent, injectMode);
+ }
+
+ public static boolean injectKeyEvent(int action, int keyCode, int repeat, int metaState, int displayId, int injectMode) {
+ 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, displayId, injectMode);
+ }
+
+ public static boolean pressReleaseKeycode(int keyCode, int displayId, int injectMode) {
+ return injectKeyEvent(KeyEvent.ACTION_DOWN, keyCode, 0, 0, displayId, injectMode)
+ && injectKeyEvent(KeyEvent.ACTION_UP, keyCode, 0, 0, displayId, injectMode);
+ }
+
+ public static boolean isScreenOn(int displayId) {
+ assert displayId != DISPLAY_ID_NONE;
+ return ServiceManager.getPowerManager().isScreenOn(displayId);
+ }
+
+ public static void keepActive(int displayId) {
+ assert displayId != DISPLAY_ID_NONE;
+ ServiceManager.getPowerManager().userActivity(displayId);
+ }
+
+ public static void expandNotificationPanel() {
+ ServiceManager.getStatusBarManager().expandNotificationsPanel();
+ }
+
+ public static void expandSettingsPanel() {
+ ServiceManager.getStatusBarManager().expandSettingsPanel();
+ }
+
+ public static void collapsePanels() {
+ ServiceManager.getStatusBarManager().collapsePanels();
+ }
+
+ public static String getClipboardText() {
+ ClipboardManager clipboardManager = ServiceManager.getClipboardManager();
+ if (clipboardManager == null) {
+ return null;
+ }
+ CharSequence s = clipboardManager.getText();
+ if (s == null) {
+ return null;
+ }
+ return s.toString();
+ }
+
+ public static boolean setClipboardText(String text) {
+ ClipboardManager clipboardManager = ServiceManager.getClipboardManager();
+ if (clipboardManager == null) {
+ return false;
+ }
+
+ String currentClipboard = getClipboardText();
+ if (currentClipboard != null && currentClipboard.equals(text)) {
+ // The clipboard already contains the requested text.
+ // Since pasting text from the computer involves setting the device clipboard, it could be set twice on a copy-paste. This would cause
+ // the clipboard listeners to be notified twice, and that would flood the Android keyboard clipboard history. To workaround this
+ // problem, do not explicitly set the clipboard text if it already contains the expected content.
+ return false;
+ }
+
+ return clipboardManager.setText(text);
+ }
+
+ public static boolean setDisplayPower(int displayId, boolean on) {
+ assert displayId != Device.DISPLAY_ID_NONE;
+
+ if (USE_ANDROID_15_DISPLAY_POWER && Build.VERSION.SDK_INT >= AndroidVersions.API_35_ANDROID_15) {
+ return ServiceManager.getDisplayManager().requestDisplayPower(displayId, on);
+ }
+
+ boolean applyToMultiPhysicalDisplays = Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10;
+
+ if (applyToMultiPhysicalDisplays
+ && Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14
+ && Build.BRAND.equalsIgnoreCase("honor")
+ && SurfaceControl.hasGetBuildInDisplayMethod()) {
+ // Workaround for Honor devices with Android 14:
+ // -
+ // -
+ applyToMultiPhysicalDisplays = false;
+ }
+
+ int mode = on ? POWER_MODE_NORMAL : POWER_MODE_OFF;
+ if (applyToMultiPhysicalDisplays) {
+ // On Android 14, these internal methods have been moved to DisplayControl
+ boolean useDisplayControl =
+ Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14 && !SurfaceControl.hasGetPhysicalDisplayIdsMethod();
+
+ // Change the power mode for all physical displays
+ long[] physicalDisplayIds = useDisplayControl ? DisplayControl.getPhysicalDisplayIds() : SurfaceControl.getPhysicalDisplayIds();
+ if (physicalDisplayIds == null) {
+ Ln.e("Could not get physical display ids");
+ return false;
+ }
+
+ boolean allOk = true;
+ for (long physicalDisplayId : physicalDisplayIds) {
+ IBinder binder = useDisplayControl ? DisplayControl.getPhysicalDisplayToken(
+ physicalDisplayId) : SurfaceControl.getPhysicalDisplayToken(physicalDisplayId);
+ allOk &= SurfaceControl.setDisplayPowerMode(binder, mode);
+ }
+ return allOk;
+ }
+
+ // Older Android versions, only 1 display
+ IBinder d = SurfaceControl.getBuiltInDisplay();
+ if (d == null) {
+ Ln.e("Could not get built-in display");
+ return false;
+ }
+ return SurfaceControl.setDisplayPowerMode(d, mode);
+ }
+
+ public static boolean powerOffScreen(int displayId) {
+ assert displayId != DISPLAY_ID_NONE;
+
+ if (!isScreenOn(displayId)) {
+ return true;
+ }
+ return pressReleaseKeycode(KeyEvent.KEYCODE_POWER, displayId, Device.INJECT_MODE_ASYNC);
+ }
+
+ /**
+ * Disable auto-rotation (if enabled), set the screen rotation and re-enable auto-rotation (if it was enabled).
+ */
+ public static void rotateDevice(int displayId) {
+ assert displayId != DISPLAY_ID_NONE;
+
+ WindowManager wm = ServiceManager.getWindowManager();
+
+ boolean accelerometerRotation = !wm.isRotationFrozen(displayId);
+
+ int currentRotation = getCurrentRotation(displayId);
+ int newRotation = (currentRotation & 1) ^ 1; // 0->1, 1->0, 2->1, 3->0
+ String newRotationString = newRotation == 0 ? "portrait" : "landscape";
+
+ Ln.i("Device rotation requested: " + newRotationString);
+ wm.freezeRotation(displayId, newRotation);
+
+ // restore auto-rotate if necessary
+ if (accelerometerRotation) {
+ wm.thawRotation(displayId);
+ }
+ }
+
+ private static int getCurrentRotation(int displayId) {
+ assert displayId != DISPLAY_ID_NONE;
+
+ if (displayId == 0) {
+ return ServiceManager.getWindowManager().getRotation();
+ }
+
+ DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId);
+ return displayInfo.getRotation();
+ }
+
+ public static List listApps() {
+ List apps = new ArrayList<>();
+ PackageManager pm = FakeContext.get().getPackageManager();
+ for (ApplicationInfo appInfo : getLaunchableApps(pm)) {
+ apps.add(toApp(pm, appInfo));
+ }
+
+ return apps;
+ }
+
+ @SuppressLint("QueryPermissionsNeeded")
+ private static List getLaunchableApps(PackageManager pm) {
+ List result = new ArrayList<>();
+ for (ApplicationInfo appInfo : pm.getInstalledApplications(PackageManager.GET_META_DATA)) {
+ if (appInfo.enabled && getLaunchIntent(pm, appInfo.packageName) != null) {
+ result.add(appInfo);
+ }
+ }
+
+ return result;
+ }
+
+ public static Intent getLaunchIntent(PackageManager pm, String packageName) {
+ Intent launchIntent = pm.getLaunchIntentForPackage(packageName);
+ if (launchIntent != null) {
+ return launchIntent;
+ }
+
+ return pm.getLeanbackLaunchIntentForPackage(packageName);
+ }
+
+ private static DeviceApp toApp(PackageManager pm, ApplicationInfo appInfo) {
+ String name = pm.getApplicationLabel(appInfo).toString();
+ boolean system = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
+ return new DeviceApp(appInfo.packageName, name, system);
+ }
+
+ @SuppressLint("QueryPermissionsNeeded")
+ public static DeviceApp findByPackageName(String packageName) {
+ PackageManager pm = FakeContext.get().getPackageManager();
+ // No need to filter by "launchable" apps, an error will be reported on start if the app is not launchable
+ for (ApplicationInfo appInfo : pm.getInstalledApplications(PackageManager.GET_META_DATA)) {
+ if (packageName.equals(appInfo.packageName)) {
+ return toApp(pm, appInfo);
+ }
+ }
+
+ return null;
+ }
+
+ @SuppressLint("QueryPermissionsNeeded")
+ public static List findByName(String searchName) {
+ List result = new ArrayList<>();
+ searchName = searchName.toLowerCase(Locale.getDefault());
+
+ PackageManager pm = FakeContext.get().getPackageManager();
+ for (ApplicationInfo appInfo : getLaunchableApps(pm)) {
+ String name = pm.getApplicationLabel(appInfo).toString();
+ if (name.toLowerCase(Locale.getDefault()).startsWith(searchName)) {
+ boolean system = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
+ result.add(new DeviceApp(appInfo.packageName, name, system));
+ }
+ }
+
+ return result;
+ }
+
+ public static void startApp(String packageName, int displayId, boolean forceStop) {
+ PackageManager pm = FakeContext.get().getPackageManager();
+
+ Intent launchIntent = getLaunchIntent(pm, packageName);
+ if (launchIntent == null) {
+ Ln.w("Cannot create launch intent for app " + packageName);
+ return;
+ }
+
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ Bundle options = null;
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_26_ANDROID_8_0) {
+ ActivityOptions launchOptions = ActivityOptions.makeBasic();
+ launchOptions.setLaunchDisplayId(displayId);
+ options = launchOptions.toBundle();
+ }
+
+ ActivityManager am = ServiceManager.getActivityManager();
+ if (forceStop) {
+ am.forceStopPackage(packageName);
+ }
+ am.startActivity(launchIntent, options);
+ }
+
+ public static void sendBroadcast(Intent intent) {
+ ServiceManager.getActivityManager().sendBroadcast(intent);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Streamer.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Streamer.java
new file mode 100644
index 0000000..e793f67
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/device/Streamer.java
@@ -0,0 +1,205 @@
+package com.genymobile.scrcpy.device;
+
+import com.genymobile.scrcpy.audio.AudioCodec;
+import com.genymobile.scrcpy.model.Codec;
+import com.genymobile.scrcpy.util.IO;
+
+import android.media.MediaCodec;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Arrays;
+
+public final class Streamer {
+
+ private static final long PACKET_FLAG_SESSION = 1L << 63;
+ private static final long PACKET_FLAG_CONFIG = 1L << 62;
+ private static final long PACKET_FLAG_KEY_FRAME = 1L << 61;
+
+ private final FileDescriptor fd;
+ private final Codec codec;
+ private final boolean sendStreamMeta;
+ private final boolean sendFrameMeta;
+
+ private final ByteBuffer headerBuffer = ByteBuffer.allocate(12);
+
+ public Streamer(FileDescriptor fd, Codec codec, boolean sendCodecMeta, boolean sendFrameMeta) {
+ this.fd = fd;
+ this.codec = codec;
+ this.sendStreamMeta = sendCodecMeta;
+ this.sendFrameMeta = sendFrameMeta;
+ }
+
+ public Codec getCodec() {
+ return codec;
+ }
+
+ public void writeAudioHeader() throws IOException {
+ if (sendStreamMeta) {
+ ByteBuffer buffer = ByteBuffer.allocate(4);
+ buffer.putInt(codec.getId());
+ buffer.flip();
+ IO.writeFully(fd, buffer);
+ }
+ }
+
+ public void writeVideoHeader() throws IOException {
+ if (sendStreamMeta) {
+ ByteBuffer buffer = ByteBuffer.allocate(4);
+ buffer.putInt(codec.getId());
+ buffer.flip();
+ IO.writeFully(fd, buffer);
+ }
+ }
+
+ public void writeDisableStream(boolean error) throws IOException {
+ // Writing a specific code as codec-id means that the device disables the stream
+ // code 0: it explicitly disables the stream (because it could not capture audio), scrcpy should continue mirroring video only
+ // code 1: a configuration error occurred, scrcpy must be stopped
+ byte[] code = new byte[4];
+ if (error) {
+ code[3] = 1;
+ }
+ IO.writeFully(fd, code, 0, code.length);
+ }
+
+ public void writePacket(ByteBuffer buffer, long pts, boolean config, boolean keyFrame) throws IOException {
+ if (config) {
+ if (codec == AudioCodec.OPUS) {
+ fixOpusConfigPacket(buffer);
+ } else if (codec == AudioCodec.FLAC) {
+ fixFlacConfigPacket(buffer);
+ }
+ }
+
+ if (sendFrameMeta) {
+ writeFrameMeta(fd, buffer.remaining(), pts, config, keyFrame);
+ }
+
+ IO.writeFully(fd, buffer);
+ }
+
+ public void writePacket(ByteBuffer codecBuffer, MediaCodec.BufferInfo bufferInfo) throws IOException {
+ long pts = bufferInfo.presentationTimeUs;
+ boolean config = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0;
+ boolean keyFrame = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0;
+ writePacket(codecBuffer, pts, config, keyFrame);
+ }
+
+ public void writeSessionMeta(int width, int height, boolean isClientResize) throws IOException {
+ if (sendStreamMeta) {
+ headerBuffer.clear();
+
+ int flags = (int) (PACKET_FLAG_SESSION >> 32); // set the first bit to 1
+ if (isClientResize) {
+ flags |= 1;
+ }
+ headerBuffer.putInt(flags);
+ headerBuffer.putInt(width);
+ headerBuffer.putInt(height);
+ headerBuffer.flip();
+ IO.writeFully(fd, headerBuffer);
+ }
+ }
+
+ private void writeFrameMeta(FileDescriptor fd, int packetSize, long pts, boolean config, boolean keyFrame) throws IOException {
+ headerBuffer.clear();
+
+ long ptsAndFlags;
+ if (config) {
+ ptsAndFlags = PACKET_FLAG_CONFIG; // non-media data packet
+ } else {
+ ptsAndFlags = pts;
+ if (keyFrame) {
+ ptsAndFlags |= PACKET_FLAG_KEY_FRAME;
+ }
+ }
+
+ headerBuffer.putLong(ptsAndFlags);
+ headerBuffer.putInt(packetSize);
+ headerBuffer.flip();
+ IO.writeFully(fd, headerBuffer);
+ }
+
+ private static void fixOpusConfigPacket(ByteBuffer buffer) throws IOException {
+ // Here is an example of the config packet received for an OPUS stream:
+ //
+ // 00000000 41 4f 50 55 53 48 44 52 13 00 00 00 00 00 00 00 |AOPUSHDR........|
+ // -------------- BELOW IS THE PART WE MUST PUT AS EXTRADATA -------------------
+ // 00000010 4f 70 75 73 48 65 61 64 01 01 38 01 80 bb 00 00 |OpusHead..8.....|
+ // 00000020 00 00 00 |... |
+ // ------------------------------------------------------------------------------
+ // 00000020 41 4f 50 55 53 44 4c 59 08 00 00 00 00 | AOPUSDLY.....|
+ // 00000030 00 00 00 a0 2e 63 00 00 00 00 00 41 4f 50 55 53 |.....c.....AOPUS|
+ // 00000040 50 52 4c 08 00 00 00 00 00 00 00 00 b4 c4 04 00 |PRL.............|
+ // 00000050 00 00 00 |...|
+ //
+ // Each "section" is prefixed by a 64-bit ID and a 64-bit length.
+ //
+ //
+
+ if (buffer.remaining() < 16) {
+ throw new IOException("Not enough data in OPUS config packet");
+ }
+
+ final byte[] opusHeaderId = {'A', 'O', 'P', 'U', 'S', 'H', 'D', 'R'};
+ byte[] idBuffer = new byte[8];
+ buffer.get(idBuffer);
+ if (!Arrays.equals(idBuffer, opusHeaderId)) {
+ throw new IOException("OPUS header not found");
+ }
+
+ // The size is in native byte-order
+ long sizeLong = buffer.getLong();
+ if (sizeLong < 0 || sizeLong >= 0x7FFFFFFF) {
+ throw new IOException("Invalid block size in OPUS header: " + sizeLong);
+ }
+
+ int size = (int) sizeLong;
+ if (buffer.remaining() < size) {
+ throw new IOException("Not enough data in OPUS header (invalid size: " + size + ")");
+ }
+
+ // Set the buffer to point to the OPUS header slice
+ buffer.limit(buffer.position() + size);
+ }
+
+ private static void fixFlacConfigPacket(ByteBuffer buffer) throws IOException {
+ // 00000000 66 4c 61 43 00 00 00 22 |fLaC..." |
+ // -------------- BELOW IS THE PART WE MUST PUT AS EXTRADATA -------------------
+ // 00000000 10 00 10 00 00 00 00 00 | ........|
+ // 00000010 00 00 0b b8 02 f0 00 00 00 00 00 00 00 00 00 00 |................|
+ // 00000020 00 00 00 00 00 00 00 00 00 00 |.......... |
+ // ------------------------------------------------------------------------------
+ // 00000020 84 00 00 28 20 00 | ...( .|
+ // 00000030 00 00 72 65 66 65 72 65 6e 63 65 20 6c 69 62 46 |..reference libF|
+ // 00000040 4c 41 43 20 31 2e 33 2e 32 20 32 30 32 32 31 30 |LAC 1.3.2 202210|
+ // 00000050 32 32 00 00 00 00 |22....|
+ //
+ //
+
+ if (buffer.remaining() < 8) {
+ throw new IOException("Not enough data in FLAC config packet");
+ }
+
+ final byte[] flacHeaderId = {'f', 'L', 'a', 'C'};
+ byte[] idBuffer = new byte[4];
+ buffer.get(idBuffer);
+ if (!Arrays.equals(idBuffer, flacHeaderId)) {
+ throw new IOException("FLAC header not found");
+ }
+
+ // The size is in big-endian
+ buffer.order(ByteOrder.BIG_ENDIAN);
+
+ int size = buffer.getInt();
+ if (buffer.remaining() < size) {
+ throw new IOException("Not enough data in FLAC header (invalid size: " + size + ")");
+ }
+
+ // Set the buffer to point to the FLAC header slice
+ buffer.limit(buffer.position() + size);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayInfo.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayInfo.java
new file mode 100644
index 0000000..ea966a3
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayInfo.java
@@ -0,0 +1,53 @@
+package com.genymobile.scrcpy.display;
+
+import com.genymobile.scrcpy.model.Size;
+
+public final class DisplayInfo {
+ private final int displayId;
+ private final Size size;
+ private final int rotation;
+ private final int layerStack;
+ private final int flags;
+ private final int dpi;
+ private final String uniqueId;
+
+ public static final int FLAG_SUPPORTS_PROTECTED_BUFFERS = 0x00000001;
+
+ public DisplayInfo(int displayId, Size size, int rotation, int layerStack, int flags, int dpi, String uniqueId) {
+ this.displayId = displayId;
+ this.size = size;
+ this.rotation = rotation;
+ this.layerStack = layerStack;
+ this.flags = flags;
+ this.dpi = dpi;
+ this.uniqueId = uniqueId;
+ }
+
+ public int getDisplayId() {
+ return displayId;
+ }
+
+ public Size getSize() {
+ return size;
+ }
+
+ public int getRotation() {
+ return rotation;
+ }
+
+ public int getLayerStack() {
+ return layerStack;
+ }
+
+ public int getFlags() {
+ return flags;
+ }
+
+ public int getDpi() {
+ return dpi;
+ }
+
+ public String getUniqueId() {
+ return uniqueId;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayMonitor.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayMonitor.java
new file mode 100644
index 0000000..f6d871c
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayMonitor.java
@@ -0,0 +1,144 @@
+package com.genymobile.scrcpy.display;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.device.Device;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.wrappers.DisplayManager;
+import com.genymobile.scrcpy.wrappers.DisplayWindowListener;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.IDisplayWindowListener;
+
+public class DisplayMonitor {
+
+ public interface Listener {
+ void onDisplayPropertiesChanged(DisplayProperties props);
+ }
+
+ // On Android 14, DisplayListener may be broken (it never sends events). This is fixed in recent Android 14 upgrades, but we can't really
+ // detect it directly, so register a DisplayWindowListener (introduced in Android 11) to listen to configuration changes instead.
+ // It has been broken again after an Android 15 upgrade:
+ // So use the default method only before Android 14.
+ private static final boolean USE_DEFAULT_METHOD = Build.VERSION.SDK_INT < AndroidVersions.API_34_ANDROID_14;
+
+ private DisplayManager.DisplayListenerHandle displayListenerHandle;
+ private HandlerThread handlerThread;
+
+ private IDisplayWindowListener displayWindowListener;
+
+ private int displayId = Device.DISPLAY_ID_NONE;
+
+ private DisplayProperties props;
+
+ private Listener listener;
+
+ public void start(int displayId, Listener listener) {
+ // Once started, the listener and the displayId must never change
+ assert listener != null;
+ this.listener = listener;
+
+ assert this.displayId == Device.DISPLAY_ID_NONE;
+ this.displayId = displayId;
+
+ if (USE_DEFAULT_METHOD) {
+ handlerThread = new HandlerThread("DisplayListener");
+ handlerThread.start();
+ Handler handler = new Handler(handlerThread.getLooper());
+ displayListenerHandle = ServiceManager.getDisplayManager().registerDisplayListener(eventDisplayId -> {
+ if (Ln.isEnabled(Ln.Level.VERBOSE)) {
+ Ln.v("DisplayMonitor: onDisplayChanged(" + eventDisplayId + ")");
+ }
+
+ if (eventDisplayId == displayId) {
+ try {
+ checkDisplayPropertiesChanged();
+ } catch (Throwable e) {
+ Ln.e("DisplayMonitor error", e);
+ throw e;
+ }
+ }
+ }, handler);
+ } else {
+ displayWindowListener = new DisplayWindowListener() {
+ @Override
+ public void onDisplayConfigurationChanged(int eventDisplayId, Configuration newConfig) {
+ if (Ln.isEnabled(Ln.Level.VERBOSE)) {
+ Ln.v("DisplayMonitor: onDisplayConfigurationChanged(" + eventDisplayId + ")");
+ }
+
+ if (eventDisplayId == displayId) {
+ try {
+ checkDisplayPropertiesChanged();
+ } catch (Throwable e) {
+ Ln.e("DisplayMonitor error", e);
+ throw e;
+ }
+ }
+ }
+ };
+ ServiceManager.getWindowManager().registerDisplayWindowListener(displayWindowListener);
+ }
+ }
+
+ /**
+ * Stop and release the monitor.
+ *
+ * It must not be used anymore.
+ * It is ok to call this method even if {@link #start(int, Listener)} was not called.
+ */
+ public void stopAndRelease() {
+ if (USE_DEFAULT_METHOD) {
+ // displayListenerHandle may be null if registration failed
+ if (displayListenerHandle != null) {
+ ServiceManager.getDisplayManager().unregisterDisplayListener(displayListenerHandle);
+ displayListenerHandle = null;
+ }
+
+ if (handlerThread != null) {
+ handlerThread.quitSafely();
+ }
+ } else if (displayWindowListener != null) {
+ ServiceManager.getWindowManager().unregisterDisplayWindowListener(displayWindowListener);
+ }
+ }
+
+ private synchronized DisplayProperties getAndSetDisplayProperties(DisplayProperties props) {
+ DisplayProperties oldProps = this.props;
+ this.props = props;
+ return oldProps;
+ }
+
+ public synchronized void setSessionDisplayProperties(DisplayProperties props) {
+ this.props = props;
+ }
+
+ private void checkDisplayPropertiesChanged() {
+ DisplayInfo di = ServiceManager.getDisplayManager().getDisplayInfo(displayId);
+ if (di == null) {
+ Ln.w("DisplayInfo for " + displayId + " cannot be retrieved");
+ // We can't compare with the current properties, so reset unconditionally
+ DisplayProperties oldProps = getAndSetDisplayProperties(null); // exchange with synchronization
+ if (Ln.isEnabled(Ln.Level.VERBOSE)) {
+ Ln.v("DisplayMonitor: " + oldProps + " -> (unknown)");
+ }
+ listener.onDisplayPropertiesChanged(null);
+ } else {
+ DisplayProperties newProps = new DisplayProperties(di.getSize(), di.getRotation());
+
+ DisplayProperties oldProps = getAndSetDisplayProperties(newProps); // exchange with synchronization
+ if (!newProps.equals(oldProps)) {
+ // Reset only if the properties are different
+ if (Ln.isEnabled(Ln.Level.VERBOSE)) {
+ Ln.v("DisplayMonitor: " + oldProps + " -> " + newProps);
+ }
+ listener.onDisplayPropertiesChanged(newProps);
+ } else if (Ln.isEnabled(Ln.Level.VERBOSE)) {
+ Ln.v("DisplayMonitor: " + newProps + " (unchanged)");
+ }
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayProperties.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayProperties.java
new file mode 100644
index 0000000..d365833
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayProperties.java
@@ -0,0 +1,52 @@
+package com.genymobile.scrcpy.display;
+
+import com.genymobile.scrcpy.model.Size;
+
+import java.util.Objects;
+
+public final class DisplayProperties {
+ private Size size;
+ private int rotation;
+
+ public DisplayProperties(Size size, int rotation) {
+ assert size != null;
+ assert rotation >= 0 && rotation < 4;
+ this.size = size;
+ this.rotation = rotation;
+ }
+
+ public Size getSize() {
+ return size;
+ }
+
+ public void setSize(Size size) {
+ this.size = size;
+ }
+
+ public int getRotation() {
+ return rotation;
+ }
+
+ public void setRotation(int rotation) {
+ this.rotation = rotation;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ DisplayProperties that = (DisplayProperties) o;
+ return rotation == that.rotation && Objects.equals(size, that.size);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(size, rotation);
+ }
+
+ @Override
+ public String toString() {
+ return size + " [rotation=" + rotation + "]";
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayPropertiesTracker.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayPropertiesTracker.java
new file mode 100644
index 0000000..adc606a
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayPropertiesTracker.java
@@ -0,0 +1,84 @@
+package com.genymobile.scrcpy.display;
+
+import android.os.SystemClock;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class DisplayPropertiesTracker {
+
+ private static final long PENDING_CACHE_DURATION = 3000; // ms
+
+ private static class PendingChange {
+ private final DisplayProperties props;
+ private final long timestamp;
+
+ PendingChange(DisplayProperties props, long timestamp) {
+ this.props = props;
+ this.timestamp = timestamp;
+ }
+ }
+
+ private final List pending = new ArrayList<>();
+
+ public synchronized void pushClientRequest(DisplayProperties props) {
+ long now = SystemClock.uptimeMillis();
+ pending.add(new PendingChange(props, now));
+ }
+
+ /**
+ * Function to be called when the display properties changed.
+ *
+ * @param props the new display properties
+ * @return {@code true} if this change is the result of a client request
+ */
+ public synchronized boolean onChanged(DisplayProperties props) {
+ cleanExpired();
+ int index = getMatchingPendingIndex(props);
+ if (index == -1) {
+ return false;
+ }
+
+ pending.subList(0, index + 1).clear();
+ return true;
+ }
+
+ private int getMatchingPendingIndex(DisplayProperties props) {
+ for (int i = 0; i < pending.size(); ++i) {
+ if (pending.get(i).props.equals(props)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private int getFirstNonExpiredIndex() {
+ long now = SystemClock.uptimeMillis();
+ for (int i = 0; i < pending.size(); ++i) {
+ if (pending.get(i).timestamp + PENDING_CACHE_DURATION >= now) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private void cleanExpired() {
+ if (pending.isEmpty()) {
+ return;
+ }
+
+ int firstNonExpiredIndex = getFirstNonExpiredIndex();
+ if (firstNonExpiredIndex == 0) {
+ // All items are fresh
+ return;
+ }
+
+ if (firstNonExpiredIndex == -1) {
+ // All items have expired
+ pending.clear();
+ } else {
+ // Remove all the items up to the first non-expired index
+ pending.subList(0, firstNonExpiredIndex).clear();
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayResizeDebouncer.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayResizeDebouncer.java
new file mode 100644
index 0000000..0b961c6
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/display/DisplayResizeDebouncer.java
@@ -0,0 +1,85 @@
+package com.genymobile.scrcpy.display;
+
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.os.SystemClock;
+
+public final class DisplayResizeDebouncer {
+
+ private static final long DEBOUNCE_DELAY_MS = 300;
+
+ public interface Callback {
+ void trigger(Size size);
+ }
+
+ private final Callback callback;
+ private Size request;
+ private long deadline;
+
+ private Thread thread;
+
+ public DisplayResizeDebouncer(Callback callback) {
+ this.callback = callback;
+ }
+
+ public void start() {
+ assert thread == null;
+ thread = new Thread(this::debounce);
+ thread.setName("debouncer");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ public void stop() {
+ if (thread != null) {
+ thread.interrupt();
+ thread = null;
+ }
+ }
+
+ private void debounce() {
+ try {
+ while (true) {
+ Size newSize;
+ synchronized (this) {
+ while (true) {
+ long now = SystemClock.uptimeMillis();
+ if (request != null && now >= deadline) {
+ break;
+ }
+ if (request == null) {
+ wait();
+ } else {
+ assert now < deadline;
+ wait(deadline - now);
+ }
+ }
+ assert request != null : "An active deadline implies request != null";
+ newSize = request;
+ request = null;
+ }
+ callback.trigger(newSize);
+ }
+ } catch (InterruptedException e) {
+ // ignore
+ } finally {
+ Ln.d("Debouncer thread stopped");
+ }
+ }
+
+ public synchronized void requestResize(Size size) {
+ assert size != null;
+ if (request == null) {
+ deadline = SystemClock.uptimeMillis() + DEBOUNCE_DELAY_MS;
+ }
+ request = size;
+ notify();
+ }
+
+ public synchronized void cancelResize() {
+ request = null;
+ deadline = 0;
+ notify();
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Codec.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Codec.java
new file mode 100644
index 0000000..b111710
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Codec.java
@@ -0,0 +1,24 @@
+package com.genymobile.scrcpy.model;
+
+import android.media.MediaCodec;
+
+public interface Codec {
+
+ enum Type {
+ VIDEO,
+ AUDIO,
+ }
+
+ Type getType();
+
+ int getId();
+
+ String getName();
+
+ String getMimeType();
+
+ static String getMimeType(MediaCodec codec) {
+ String[] types = codec.getCodecInfo().getSupportedTypes();
+ return types.length > 0 ? types[0] : null;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/CodecOption.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/CodecOption.java
new file mode 100644
index 0000000..8b356cd
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/CodecOption.java
@@ -0,0 +1,112 @@
+package com.genymobile.scrcpy.model;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CodecOption {
+ private final String key;
+ private final Object value;
+
+ public CodecOption(String key, Object value) {
+ this.key = key;
+ this.value = value;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public Object getValue() {
+ return value;
+ }
+
+ public static List parse(String codecOptions) {
+ if (codecOptions.isEmpty()) {
+ return null;
+ }
+
+ List result = new ArrayList<>();
+
+ boolean escape = false;
+ StringBuilder buf = new StringBuilder();
+
+ for (char c : codecOptions.toCharArray()) {
+ switch (c) {
+ case '\\':
+ if (escape) {
+ buf.append('\\');
+ escape = false;
+ } else {
+ escape = true;
+ }
+ break;
+ case ',':
+ if (escape) {
+ buf.append(',');
+ escape = false;
+ } else {
+ // This comma is a separator between codec options
+ String codecOption = buf.toString();
+ result.add(parseOption(codecOption));
+ // Clear buf
+ buf.setLength(0);
+ }
+ break;
+ default:
+ buf.append(c);
+ break;
+ }
+ }
+
+ if (buf.length() > 0) {
+ String codecOption = buf.toString();
+ result.add(parseOption(codecOption));
+ }
+
+ return result;
+ }
+
+ private static CodecOption parseOption(String option) {
+ int equalSignIndex = option.indexOf('=');
+ if (equalSignIndex == -1) {
+ throw new IllegalArgumentException("'=' expected");
+ }
+ String keyAndType = option.substring(0, equalSignIndex);
+ if (keyAndType.length() == 0) {
+ throw new IllegalArgumentException("Key may not be null");
+ }
+
+ String key;
+ String type;
+
+ int colonIndex = keyAndType.indexOf(':');
+ if (colonIndex != -1) {
+ key = keyAndType.substring(0, colonIndex);
+ type = keyAndType.substring(colonIndex + 1);
+ } else {
+ key = keyAndType;
+ type = "int"; // assume int by default
+ }
+
+ Object value;
+ String valueString = option.substring(equalSignIndex + 1);
+ switch (type) {
+ case "int":
+ value = Integer.parseInt(valueString);
+ break;
+ case "long":
+ value = Long.parseLong(valueString);
+ break;
+ case "float":
+ value = Float.parseFloat(valueString);
+ break;
+ case "string":
+ value = valueString;
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid codec option type (int, long, float, str): " + type);
+ }
+
+ return new CodecOption(key, value);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/ConfigurationException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/ConfigurationException.java
new file mode 100644
index 0000000..0e5deaa
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/ConfigurationException.java
@@ -0,0 +1,7 @@
+package com.genymobile.scrcpy.model;
+
+public class ConfigurationException extends Exception {
+ public ConfigurationException(String message) {
+ super(message);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/DeviceApp.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/DeviceApp.java
new file mode 100644
index 0000000..60e7e87
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/DeviceApp.java
@@ -0,0 +1,26 @@
+package com.genymobile.scrcpy.model;
+
+public final class DeviceApp {
+
+ private final String packageName;
+ private final String name;
+ private final boolean system;
+
+ public DeviceApp(String packageName, String name, boolean system) {
+ this.packageName = packageName;
+ this.name = name;
+ this.system = system;
+ }
+
+ public String getPackageName() {
+ return packageName;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean isSystem() {
+ return system;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/NewDisplay.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/NewDisplay.java
new file mode 100644
index 0000000..2544f07
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/NewDisplay.java
@@ -0,0 +1,31 @@
+package com.genymobile.scrcpy.model;
+
+public final class NewDisplay {
+ private Size size;
+ private int dpi;
+
+ public NewDisplay() {
+ // Auto size and dpi
+ }
+
+ public NewDisplay(Size size, int dpi) {
+ this.size = size;
+ this.dpi = dpi;
+ }
+
+ public Size getSize() {
+ return size;
+ }
+
+ public int getDpi() {
+ return dpi;
+ }
+
+ public boolean hasExplicitSize() {
+ return size != null;
+ }
+
+ public boolean hasExplicitDpi() {
+ return dpi != 0;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Orientation.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Orientation.java
new file mode 100644
index 0000000..8e189d7
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Orientation.java
@@ -0,0 +1,54 @@
+package com.genymobile.scrcpy.model;
+
+public enum Orientation {
+
+ // @formatter:off
+ Orient0("0"),
+ Orient90("90"),
+ Orient180("180"),
+ Orient270("270"),
+ Flip0("flip0"),
+ Flip90("flip90"),
+ Flip180("flip180"),
+ Flip270("flip270");
+
+ public enum Lock {
+ Unlocked, LockedInitial, LockedValue,
+ }
+
+ private final String name;
+
+ Orientation(String name) {
+ this.name = name;
+ }
+
+ public static Orientation getByName(String name) {
+ for (Orientation orientation : values()) {
+ if (orientation.name.equals(name)) {
+ return orientation;
+ }
+ }
+
+ throw new IllegalArgumentException("Unknown orientation: " + name);
+ }
+
+ public static Orientation fromRotation(int ccwRotation) {
+ assert ccwRotation >= 0 && ccwRotation < 4;
+ // Display rotation is expressed counter-clockwise, orientation is expressed clockwise
+ int cwRotation = (4 - ccwRotation) % 4;
+ return values()[cwRotation];
+ }
+
+ public boolean isFlipped() {
+ return (ordinal() & 4) != 0;
+ }
+
+ public int getRotation() {
+ return ordinal() & 3;
+ }
+
+ public boolean isSwap() {
+ // width and height are swapped on 90-degree rotations
+ return (ordinal() & 1) != 0;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Point.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Point.java
new file mode 100644
index 0000000..8a8458f
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Point.java
@@ -0,0 +1,43 @@
+package com.genymobile.scrcpy.model;
+
+import java.util.Objects;
+
+public class Point {
+ private final int x;
+ private final int y;
+
+ public Point(int x, int y) {
+ this.x = x;
+ this.y = y;
+ }
+
+ public int getX() {
+ return x;
+ }
+
+ public int getY() {
+ return y;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ Point point = (Point) o;
+ return x == point.x && y == point.y;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(x, y);
+ }
+
+ @Override
+ public String toString() {
+ return "Point{" + "x=" + x + ", y=" + y + '}';
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Position.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Position.java
new file mode 100644
index 0000000..61fd124
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Position.java
@@ -0,0 +1,61 @@
+package com.genymobile.scrcpy.model;
+
+import java.util.Objects;
+
+public class Position {
+ private final Point point;
+ private final 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;
+ }
+
+ public Position rotate(int rotation) {
+ switch (rotation) {
+ case 1:
+ return new Position(new Point(screenSize.getHeight() - point.getY(), point.getX()), screenSize.rotate());
+ case 2:
+ return new Position(new Point(screenSize.getWidth() - point.getX(), screenSize.getHeight() - point.getY()), screenSize);
+ case 3:
+ return new Position(new Point(point.getY(), screenSize.getWidth() - point.getX()), screenSize.rotate());
+ default:
+ return this;
+ }
+ }
+
+ @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/third_party/scrcpy-java/com/genymobile/scrcpy/model/Size.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Size.java
new file mode 100644
index 0000000..5121196
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/model/Size.java
@@ -0,0 +1,198 @@
+package com.genymobile.scrcpy.model;
+
+import com.genymobile.scrcpy.util.BinarySearch;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.video.VideoConstraints;
+
+import android.graphics.Rect;
+import android.media.MediaCodecInfo;
+import android.util.Range;
+
+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 int getMax() {
+ return Math.max(width, height);
+ }
+
+ public Size rotate() {
+ return new Size(height, width);
+ }
+
+ public Size constrain(VideoConstraints constraints) {
+ return constrain(constraints, true);
+ }
+
+ public Size constrain(VideoConstraints constraints, boolean preserveAspectRatio) {
+ int maxSize = constraints.getMaxSize();
+ int alignment = constraints.getAlignment();
+ MediaCodecInfo.VideoCapabilities caps = constraints.getEncoderCapabilities();
+
+ assert maxSize >= 0 : "Max size may not be negative";
+ assert alignment > 0 : "Alignment must be positive";
+ assert (alignment & (alignment - 1)) == 0 : "Alignment must be a power-of-two";
+
+ if (caps == null) {
+ int w, h;
+ if (maxSize > 0 && (width > maxSize || height > maxSize)) {
+ if (preserveAspectRatio) {
+ if (width > height) {
+ w = maxSize;
+ h = height * maxSize / width;
+ } else {
+ w = width * maxSize / height;
+ h = maxSize;
+ }
+ } else {
+ w = Math.min(width, maxSize);
+ h = Math.min(height, maxSize);
+ }
+ } else {
+ // No constraints
+ w = width;
+ h = height;
+ }
+
+ w = Math.max(align(w, alignment), alignment);
+ h = Math.max(align(h, alignment), alignment);
+
+ return new Size(w, h);
+ }
+
+ boolean landscape = width >= height;
+ int major = landscape ? width : height;
+ int minor = landscape ? height : width;
+
+ Range majorRange = landscape ? caps.getSupportedWidths() : caps.getSupportedHeights();
+ int minMajor = majorRange.getLower();
+ int maxMajor = majorRange.getUpper();
+ if (maxMajor > major) {
+ // Never increase the size
+ maxMajor = major;
+ }
+ if (maxSize > 0 && maxMajor > maxSize) {
+ maxMajor = maxSize;
+ }
+
+ int minBlock = (minMajor + alignment - 1) / alignment;
+ int maxBlock = maxMajor / alignment;
+
+ int bestBlock = BinarySearch.findHighestTrue(
+ minBlock, maxBlock, block -> {
+ int pixels = block * alignment;
+ int w = align(width * pixels / major, alignment);
+ int h = align(height * pixels / major, alignment);
+ return caps.isSizeSupported(w, h);
+ });
+
+ if (bestBlock < minBlock) {
+ Ln.d("No matching size found, ignore encoder size validation");
+ bestBlock = maxBlock;
+ }
+
+ int bestMajor = bestBlock * alignment;
+ int bestMinor;
+
+ if (preserveAspectRatio) {
+ bestMinor = align(minor * bestMajor / major, alignment);
+ } else {
+ // The minor dimension can potentially be extended
+ int maxMinor = landscape ? caps.getSupportedHeightsFor(bestMajor).getUpper() : caps.getSupportedWidthsFor(bestMajor).getUpper();
+ if (maxMinor > minor) {
+ maxMinor = minor;
+ }
+ if (maxSize > 0 && maxMinor > maxSize) {
+ maxMinor = maxSize;
+ }
+ bestMinor = align(maxMinor, alignment);
+
+ // The major dimension can potentially be extended
+ maxMajor = landscape ? caps.getSupportedWidthsFor(bestMinor).getUpper() : caps.getSupportedHeightsFor(bestMinor).getUpper();
+ if (maxMajor > major) {
+ maxMajor = major;
+ }
+ if (maxSize > 0 && maxMajor > maxSize) {
+ maxMajor = maxSize;
+ }
+ bestMajor = align(maxMajor, alignment);
+ }
+
+ minMajor = alignUp(minMajor, alignment);
+ if (bestMajor < minMajor) {
+ bestMajor = minMajor;
+ }
+
+ int minMinor = landscape ? caps.getSupportedHeights().getLower() : caps.getSupportedWidths().getLower();
+ minMinor = alignUp(minMinor, alignment);
+ if (bestMinor < minMinor) {
+ bestMinor = minMinor;
+ }
+
+ int w = landscape ? bestMajor : bestMinor;
+ int h = landscape ? bestMinor : bestMajor;
+
+ assert w % alignment == 0 : "The width must be a multiple of alignment";
+ assert h % alignment == 0 : "The height must be a multiple of alignment";
+
+ return new Size(w, h);
+ }
+
+ public Size align(int alignment) {
+ int w = align(width, alignment);
+ int h = align(height, alignment);
+ if (w == width && h == height) {
+ return this;
+ }
+ return new Size(w, h);
+ }
+
+ private static int align(int value, int alignment) {
+ return value / alignment * alignment;
+ }
+
+ private static int alignUp(int value, int alignment) {
+ return (value + alignment - 1) / alignment * alignment;
+ }
+
+ 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 width + "x" + height;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/AffineOpenGLFilter.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/AffineOpenGLFilter.java
new file mode 100644
index 0000000..7608a57
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/AffineOpenGLFilter.java
@@ -0,0 +1,135 @@
+package com.genymobile.scrcpy.opengl;
+
+import com.genymobile.scrcpy.util.AffineMatrix;
+
+import android.opengl.GLES11Ext;
+import android.opengl.GLES20;
+
+import java.nio.FloatBuffer;
+
+public class AffineOpenGLFilter implements OpenGLFilter {
+
+ private int program;
+ private FloatBuffer vertexBuffer;
+ private FloatBuffer texCoordsBuffer;
+ private final float[] userMatrix;
+
+ private int vertexPosLoc;
+ private int texCoordsInLoc;
+
+ private int texLoc;
+ private int texMatrixLoc;
+ private int userMatrixLoc;
+
+ public AffineOpenGLFilter(AffineMatrix transform) {
+ userMatrix = transform.to4x4();
+ }
+
+ @Override
+ public void init() throws OpenGLException {
+ // @formatter:off
+ String vertexShaderCode = "#version 100\n"
+ + "attribute vec4 vertex_pos;\n"
+ + "attribute vec4 tex_coords_in;\n"
+ + "varying vec2 tex_coords;\n"
+ + "uniform mat4 tex_matrix;\n"
+ + "uniform mat4 user_matrix;\n"
+ + "void main() {\n"
+ + " gl_Position = vertex_pos;\n"
+ + " tex_coords = (tex_matrix * user_matrix * tex_coords_in).xy;\n"
+ + "}";
+
+ // @formatter:off
+ String fragmentShaderCode = "#version 100\n"
+ + "#extension GL_OES_EGL_image_external : require\n"
+ + "precision highp float;\n"
+ + "uniform samplerExternalOES tex;\n"
+ + "varying vec2 tex_coords;\n"
+ + "void main() {\n"
+ + " if (tex_coords.x >= 0.0 && tex_coords.x <= 1.0\n"
+ + " && tex_coords.y >= 0.0 && tex_coords.y <= 1.0) {\n"
+ + " gl_FragColor = texture2D(tex, tex_coords);\n"
+ + " } else {\n"
+ + " gl_FragColor = vec4(0.0);\n"
+ + " }\n"
+ + "}";
+
+ program = GLUtils.createProgram(vertexShaderCode, fragmentShaderCode);
+ if (program == 0) {
+ throw new OpenGLException("Cannot create OpenGL program");
+ }
+
+ float[] vertices = {
+ -1, -1, // Bottom-left
+ 1, -1, // Bottom-right
+ -1, 1, // Top-left
+ 1, 1, // Top-right
+ };
+
+ float[] texCoords = {
+ 0, 0, // Bottom-left
+ 1, 0, // Bottom-right
+ 0, 1, // Top-left
+ 1, 1, // Top-right
+ };
+
+ // OpenGL will fill the 3rd and 4th coordinates of the vec4 automatically with 0.0 and 1.0 respectively
+ vertexBuffer = GLUtils.createFloatBuffer(vertices);
+ texCoordsBuffer = GLUtils.createFloatBuffer(texCoords);
+
+ vertexPosLoc = GLES20.glGetAttribLocation(program, "vertex_pos");
+ assert vertexPosLoc != -1;
+
+ texCoordsInLoc = GLES20.glGetAttribLocation(program, "tex_coords_in");
+ assert texCoordsInLoc != -1;
+
+ texLoc = GLES20.glGetUniformLocation(program, "tex");
+ assert texLoc != -1;
+
+ texMatrixLoc = GLES20.glGetUniformLocation(program, "tex_matrix");
+ assert texMatrixLoc != -1;
+
+ userMatrixLoc = GLES20.glGetUniformLocation(program, "user_matrix");
+ assert userMatrixLoc != -1;
+ }
+
+ @Override
+ public void draw(int textureId, float[] texMatrix) {
+ GLES20.glUseProgram(program);
+ GLUtils.checkGlError();
+
+ GLES20.glEnableVertexAttribArray(vertexPosLoc);
+ GLUtils.checkGlError();
+ GLES20.glEnableVertexAttribArray(texCoordsInLoc);
+ GLUtils.checkGlError();
+
+ GLES20.glVertexAttribPointer(vertexPosLoc, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer);
+ GLUtils.checkGlError();
+ GLES20.glVertexAttribPointer(texCoordsInLoc, 2, GLES20.GL_FLOAT, false, 0, texCoordsBuffer);
+ GLUtils.checkGlError();
+
+ GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
+ GLUtils.checkGlError();
+ GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId);
+ GLUtils.checkGlError();
+ GLES20.glUniform1i(texLoc, 0);
+ GLUtils.checkGlError();
+
+ GLES20.glUniformMatrix4fv(texMatrixLoc, 1, false, texMatrix, 0);
+ GLUtils.checkGlError();
+
+ GLES20.glUniformMatrix4fv(userMatrixLoc, 1, false, userMatrix, 0);
+ GLUtils.checkGlError();
+
+ GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
+ GLUtils.checkGlError();
+ GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
+ GLUtils.checkGlError();
+ }
+
+ @Override
+ public void release() {
+ GLES20.glDeleteProgram(program);
+ GLUtils.checkGlError();
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/GLUtils.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/GLUtils.java
new file mode 100644
index 0000000..72a3f40
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/GLUtils.java
@@ -0,0 +1,124 @@
+package com.genymobile.scrcpy.opengl;
+
+import com.genymobile.scrcpy.BuildConfig;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.opengl.GLES20;
+import android.opengl.GLU;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.FloatBuffer;
+
+public final class GLUtils {
+
+ private static final boolean DEBUG = BuildConfig.DEBUG;
+
+ private GLUtils() {
+ // not instantiable
+ }
+
+ public static int createProgram(String vertexSource, String fragmentSource) {
+ int vertexShader = createShader(GLES20.GL_VERTEX_SHADER, vertexSource);
+ if (vertexShader == 0) {
+ return 0;
+ }
+
+ int fragmentShader = createShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource);
+ if (fragmentShader == 0) {
+ GLES20.glDeleteShader(vertexShader);
+ return 0;
+ }
+
+ int program = GLES20.glCreateProgram();
+ if (program == 0) {
+ GLES20.glDeleteShader(fragmentShader);
+ GLES20.glDeleteShader(vertexShader);
+ return 0;
+ }
+
+ GLES20.glAttachShader(program, vertexShader);
+ checkGlError();
+ GLES20.glAttachShader(program, fragmentShader);
+ checkGlError();
+ GLES20.glLinkProgram(program);
+ checkGlError();
+
+ int[] linkStatus = new int[1];
+ GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0);
+ if (linkStatus[0] == 0) {
+ Ln.e("Could not link program: " + GLES20.glGetProgramInfoLog(program));
+ GLES20.glDeleteProgram(program);
+ GLES20.glDeleteShader(fragmentShader);
+ GLES20.glDeleteShader(vertexShader);
+ return 0;
+ }
+
+ return program;
+ }
+
+ public static int createShader(int type, String source) {
+ int shader = GLES20.glCreateShader(type);
+ if (shader == 0) {
+ Ln.e(getGlErrorMessage("Could not create shader"));
+ return 0;
+ }
+
+ GLES20.glShaderSource(shader, source);
+ GLES20.glCompileShader(shader);
+
+ int[] compileStatus = new int[1];
+ GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
+ if (compileStatus[0] == 0) {
+ Ln.e("Could not compile " + getShaderTypeString(type) + ": " + GLES20.glGetShaderInfoLog(shader));
+ GLES20.glDeleteShader(shader);
+ return 0;
+ }
+
+ return shader;
+ }
+
+ private static String getShaderTypeString(int type) {
+ switch (type) {
+ case GLES20.GL_VERTEX_SHADER:
+ return "vertex shader";
+ case GLES20.GL_FRAGMENT_SHADER:
+ return "fragment shader";
+ default:
+ return "shader";
+ }
+ }
+
+ /**
+ * Throws a runtime exception if {@link GLES20#glGetError()} returns an error (useful for debugging).
+ */
+ public static void checkGlError() {
+ if (DEBUG) {
+ int error = GLES20.glGetError();
+ if (error != GLES20.GL_NO_ERROR) {
+ throw new RuntimeException(toErrorString(error));
+ }
+ }
+ }
+
+ public static String getGlErrorMessage(String userError) {
+ int glError = GLES20.glGetError();
+ if (glError == GLES20.GL_NO_ERROR) {
+ return userError;
+ }
+
+ return userError + " (" + toErrorString(glError) + ")";
+ }
+
+ private static String toErrorString(int glError) {
+ String errorString = GLU.gluErrorString(glError);
+ return "glError 0x" + Integer.toHexString(glError) + " " + errorString;
+ }
+
+ public static FloatBuffer createFloatBuffer(float[] values) {
+ FloatBuffer fb = ByteBuffer.allocateDirect(values.length * 4).order(ByteOrder.nativeOrder()).asFloatBuffer();
+ fb.put(values);
+ fb.position(0);
+ return fb;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLException.java
new file mode 100644
index 0000000..cbc9539
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLException.java
@@ -0,0 +1,13 @@
+package com.genymobile.scrcpy.opengl;
+
+import java.io.IOException;
+
+public class OpenGLException extends IOException {
+ public OpenGLException(String message) {
+ super(message);
+ }
+
+ public OpenGLException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLFilter.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLFilter.java
new file mode 100644
index 0000000..6f27777
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLFilter.java
@@ -0,0 +1,21 @@
+package com.genymobile.scrcpy.opengl;
+
+public interface OpenGLFilter {
+
+ /**
+ * Initialize the OpenGL filter (typically compile the shaders and create the program).
+ *
+ * @throws OpenGLException if an initialization error occurs
+ */
+ void init() throws OpenGLException;
+
+ /**
+ * Render a frame (call for each frame).
+ */
+ void draw(int textureId, float[] texMatrix);
+
+ /**
+ * Release resources.
+ */
+ void release();
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLRunner.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLRunner.java
new file mode 100644
index 0000000..232f68d
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/opengl/OpenGLRunner.java
@@ -0,0 +1,232 @@
+package com.genymobile.scrcpy.opengl;
+
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.util.Threads;
+
+import android.graphics.SurfaceTexture;
+import android.opengl.EGL14;
+import android.opengl.EGLConfig;
+import android.opengl.EGLContext;
+import android.opengl.EGLDisplay;
+import android.opengl.EGLExt;
+import android.opengl.EGLSurface;
+import android.opengl.GLES11Ext;
+import android.opengl.GLES20;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.view.Surface;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Semaphore;
+
+public final class OpenGLRunner {
+
+ private static HandlerThread handlerThread;
+ private static Handler handler;
+
+ private EGLDisplay eglDisplay;
+ private EGLContext eglContext;
+ private EGLSurface eglSurface;
+
+ private final OpenGLFilter filter;
+ private final float[] overrideTransformMatrix;
+
+ private SurfaceTexture surfaceTexture;
+ private Surface inputSurface;
+ private int textureId;
+
+ private boolean stopped;
+
+ public OpenGLRunner(OpenGLFilter filter, float[] overrideTransformMatrix) {
+ this.filter = filter;
+ this.overrideTransformMatrix = overrideTransformMatrix;
+ }
+
+ public OpenGLRunner(OpenGLFilter filter) {
+ this(filter, null);
+ }
+
+ public static synchronized void initOnce() {
+ if (handlerThread == null) {
+ handlerThread = new HandlerThread("OpenGLRunner");
+ handlerThread.start();
+ handler = new Handler(handlerThread.getLooper());
+ }
+ }
+
+ public static void shutdown() throws InterruptedException {
+ HandlerThread thread;
+ synchronized (OpenGLRunner.class) {
+ thread = handlerThread;
+ }
+ if (thread != null) {
+ thread.quitSafely();
+ thread.join();
+ }
+ }
+
+ public Surface start(Size inputSize, Size outputSize, Surface outputSurface) throws OpenGLException {
+ initOnce();
+
+ // The whole OpenGL execution must be performed on a Handler, so that SurfaceTexture.setOnFrameAvailableListener() works correctly.
+ // See
+ try {
+ Threads.executeSynchronouslyOn(handler, new Callable() {
+ @Override
+ public Void call() throws Exception {
+ run(inputSize, outputSize, outputSurface);
+ return null;
+ }
+ });
+ } catch (Throwable throwable) {
+ if (throwable instanceof OpenGLException) {
+ throw (OpenGLException) throwable;
+ }
+ throw new OpenGLException("Asynchronous OpenGL runner init failed", throwable);
+ }
+
+ // Synchronization is ok: inputSurface is written before sem.release() and read after sem.acquire()
+ return inputSurface;
+ }
+
+ private void run(Size inputSize, Size outputSize, Surface outputSurface) throws OpenGLException {
+ eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
+ if (eglDisplay == EGL14.EGL_NO_DISPLAY) {
+ throw new OpenGLException("Unable to get EGL14 display");
+ }
+
+ int[] version = new int[2];
+ if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) {
+ throw new OpenGLException("Unable to initialize EGL14");
+ }
+
+ // @formatter:off
+ int[] attribList = {
+ EGL14.EGL_RED_SIZE, 8,
+ EGL14.EGL_GREEN_SIZE, 8,
+ EGL14.EGL_BLUE_SIZE, 8,
+ EGL14.EGL_ALPHA_SIZE, 8,
+ EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
+ EGL14.EGL_NONE
+ };
+
+ EGLConfig[] configs = new EGLConfig[1];
+ int[] numConfigs = new int[1];
+ EGL14.eglChooseConfig(eglDisplay, attribList, 0, configs, 0, configs.length, numConfigs, 0);
+ if (numConfigs[0] <= 0) {
+ EGL14.eglTerminate(eglDisplay);
+ throw new OpenGLException("Unable to find ES2 EGL config");
+ }
+ EGLConfig eglConfig = configs[0];
+
+ // @formatter:off
+ int[] contextAttribList = {
+ EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
+ EGL14.EGL_NONE
+ };
+ eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttribList, 0);
+ if (eglContext == null) {
+ EGL14.eglTerminate(eglDisplay);
+ throw new OpenGLException("Failed to create EGL context");
+ }
+
+ int[] surfaceAttribList = {
+ EGL14.EGL_NONE
+ };
+ eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, outputSurface, surfaceAttribList, 0);
+ if (eglSurface == null) {
+ EGL14.eglDestroyContext(eglDisplay, eglContext);
+ EGL14.eglTerminate(eglDisplay);
+ throw new OpenGLException("Failed to create EGL window surface");
+ }
+
+ if (!EGL14.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext)) {
+ EGL14.eglDestroySurface(eglDisplay, eglSurface);
+ EGL14.eglDestroyContext(eglDisplay, eglContext);
+ EGL14.eglTerminate(eglDisplay);
+ throw new OpenGLException("Failed to make EGL context current");
+ }
+
+ int[] textures = new int[1];
+ GLES20.glGenTextures(1, textures, 0);
+ GLUtils.checkGlError();
+ textureId = textures[0];
+
+ GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
+ GLUtils.checkGlError();
+ GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
+ GLUtils.checkGlError();
+ GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
+ GLUtils.checkGlError();
+ GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
+ GLUtils.checkGlError();
+
+ surfaceTexture = new SurfaceTexture(textureId);
+ surfaceTexture.setDefaultBufferSize(inputSize.getWidth(), inputSize.getHeight());
+ inputSurface = new Surface(surfaceTexture);
+
+ filter.init();
+
+ surfaceTexture.setOnFrameAvailableListener(surfaceTexture -> {
+ if (stopped) {
+ // Make sure to never render after resources have been released
+ return;
+ }
+
+ render(outputSize);
+ }, handler);
+ }
+
+ private void render(Size outputSize) {
+ GLES20.glViewport(0, 0, outputSize.getWidth(), outputSize.getHeight());
+ GLUtils.checkGlError();
+
+ surfaceTexture.updateTexImage();
+
+ float[] matrix;
+ if (overrideTransformMatrix != null) {
+ matrix = overrideTransformMatrix;
+ } else {
+ matrix = new float[16];
+ surfaceTexture.getTransformMatrix(matrix);
+ }
+
+ filter.draw(textureId, matrix);
+
+ EGLExt.eglPresentationTimeANDROID(eglDisplay, eglSurface, surfaceTexture.getTimestamp());
+ EGL14.eglSwapBuffers(eglDisplay, eglSurface);
+ }
+
+ public void stopAndRelease() {
+ final Semaphore sem = new Semaphore(0);
+
+ handler.post(() -> {
+ stopped = true;
+ surfaceTexture.setOnFrameAvailableListener(null, handler);
+
+ filter.release();
+
+ int[] textures = {textureId};
+ GLES20.glDeleteTextures(1, textures, 0);
+ GLUtils.checkGlError();
+
+ EGL14.eglDestroySurface(eglDisplay, eglSurface);
+ EGL14.eglDestroyContext(eglDisplay, eglContext);
+ EGL14.eglTerminate(eglDisplay);
+ eglDisplay = EGL14.EGL_NO_DISPLAY;
+ eglContext = EGL14.EGL_NO_CONTEXT;
+ eglSurface = EGL14.EGL_NO_SURFACE;
+ surfaceTexture.release();
+ inputSurface.release();
+
+ sem.release();
+ });
+
+ try {
+ sem.acquire();
+ } catch (InterruptedException e) {
+ // Behave as if this method call was synchronous
+ Thread.currentThread().interrupt();
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/AffineMatrix.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/AffineMatrix.java
new file mode 100644
index 0000000..fb507ba
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/AffineMatrix.java
@@ -0,0 +1,368 @@
+package com.genymobile.scrcpy.util;
+
+import com.genymobile.scrcpy.model.Point;
+import com.genymobile.scrcpy.model.Size;
+
+/**
+ * Represents a 2D affine transform (a 3x3 matrix):
+ *
+ *
+ * / a c e \
+ * | b d f |
+ * \ 0 0 1 /
+ *
+ *
+ * Or, a 4x4 matrix if we add a z axis:
+ *
+ *
+ * / a c 0 e \
+ * | b d 0 f |
+ * | 0 0 1 0 |
+ * \ 0 0 0 1 /
+ *
+ */
+public class AffineMatrix {
+
+ private final double a, b, c, d, e, f;
+
+ /**
+ * The identity matrix.
+ */
+ public static final AffineMatrix IDENTITY = new AffineMatrix(1, 0, 0, 1, 0, 0);
+
+ /**
+ * Create a new matrix:
+ *
+ *
+ * / a c e \
+ * | b d f |
+ * \ 0 0 1 /
+ *
+ */
+ public AffineMatrix(double a, double b, double c, double d, double e, double f) {
+ this.a = a;
+ this.b = b;
+ this.c = c;
+ this.d = d;
+ this.e = e;
+ this.f = f;
+ }
+
+ @Override
+ public String toString() {
+ return "[" + a + ", " + c + ", " + e + "; " + b + ", " + d + ", " + f + "]";
+ }
+
+ /**
+ * Return a matrix which converts from Normalized Device Coordinates to pixels.
+ *
+ * @param size the target size
+ * @return the transform matrix
+ */
+ public static AffineMatrix ndcFromPixels(Size size) {
+ double w = size.getWidth();
+ double h = size.getHeight();
+ return new AffineMatrix(1 / w, 0, 0, -1 / h, 0, 1);
+ }
+
+ /**
+ * Return a matrix which converts from pixels to Normalized Device Coordinates.
+ *
+ * @param size the source size
+ * @return the transform matrix
+ */
+ public static AffineMatrix ndcToPixels(Size size) {
+ double w = size.getWidth();
+ double h = size.getHeight();
+ return new AffineMatrix(w, 0, 0, -h, 0, h);
+ }
+
+ /**
+ * Apply the transform to a point ({@code this} should be a matrix converted to pixels coordinates via {@link #ndcToPixels(Size)}).
+ *
+ * @param point the source point
+ * @return the converted point
+ */
+ public Point apply(Point point) {
+ int x = point.getX();
+ int y = point.getY();
+ int xx = (int) (a * x + c * y + e);
+ int yy = (int) (b * x + d * y + f);
+ return new Point(xx, yy);
+ }
+
+ /**
+ * Compute this * rhs.
+ *
+ * @param rhs the matrix to multiply
+ * @return the product
+ */
+ public AffineMatrix multiply(AffineMatrix rhs) {
+ if (rhs == null) {
+ // For convenience
+ return this;
+ }
+
+ double aa = this.a * rhs.a + this.c * rhs.b;
+ double bb = this.b * rhs.a + this.d * rhs.b;
+ double cc = this.a * rhs.c + this.c * rhs.d;
+ double dd = this.b * rhs.c + this.d * rhs.d;
+ double ee = this.a * rhs.e + this.c * rhs.f + this.e;
+ double ff = this.b * rhs.e + this.d * rhs.f + this.f;
+ return new AffineMatrix(aa, bb, cc, dd, ee, ff);
+ }
+
+ /**
+ * Multiply all matrices from left to right, ignoring any {@code null} matrix (for convenience).
+ *
+ * @param matrices the matrices
+ * @return the product
+ */
+ public static AffineMatrix multiplyAll(AffineMatrix... matrices) {
+ AffineMatrix result = null;
+ for (AffineMatrix matrix : matrices) {
+ if (result == null) {
+ result = matrix;
+ } else {
+ result = result.multiply(matrix);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Invert the matrix.
+ *
+ * @return the inverse matrix (or {@code null} if not invertible).
+ */
+ public AffineMatrix invert() {
+ // The 3x3 matrix M can be decomposed into M = M1 * M2:
+ // M1 M2
+ // / 1 0 e \ / a c 0 \
+ // | 0 1 f | * | b d 0 |
+ // \ 0 0 1 / \ 0 0 1 /
+ //
+ // The inverse of an invertible 2x2 matrix is given by this formula:
+ //
+ // / A B \⁻¹ 1 / D -B \
+ // \ C D / = ----- \ -C A /
+ // AD-BC
+ //
+ // Let B=c and C=b (to apply the general formula with the same letters).
+ //
+ // M⁻¹ = (M1 * M2)⁻¹ = M2⁻¹ * M1⁻¹
+ //
+ // M2⁻¹ M1⁻¹
+ // /----------------\
+ // 1 / d -B 0 \ / 1 0 -e \
+ // = ----- | -C a 0 | * | 0 1 -f |
+ // ad-BC \ 0 0 1 / \ 0 0 1 /
+ //
+ // With the original letters:
+ //
+ // 1 / d -c 0 \ / 1 0 -e \
+ // M⁻¹ = ----- | -b a 0 | * | 0 1 -f |
+ // ad-cb \ 0 0 1 / \ 0 0 1 /
+ //
+ // 1 / d -c cf-de \
+ // = ----- | -b a be-af |
+ // ad-cb \ 0 0 1 /
+
+ double det = a * d - c * b;
+ if (det == 0) {
+ // Not invertible
+ return null;
+ }
+
+ double aa = d / det;
+ double bb = -b / det;
+ double cc = -c / det;
+ double dd = a / det;
+ double ee = (c * f - d * e) / det;
+ double ff = (b * e - a * f) / det;
+
+ return new AffineMatrix(aa, bb, cc, dd, ee, ff);
+ }
+
+ /**
+ * Return this transform applied from the center (0.5, 0.5).
+ *
+ * @return the resulting matrix
+ */
+ public AffineMatrix fromCenter() {
+ return translate(0.5, 0.5).multiply(this).multiply(translate(-0.5, -0.5));
+ }
+
+ /**
+ * Return this transform with the specified aspect ratio.
+ *
+ * @param ar the aspect ratio
+ * @return the resulting matrix
+ */
+ public AffineMatrix withAspectRatio(double ar) {
+ return scale(1 / ar, 1).multiply(this).multiply(scale(ar, 1));
+ }
+
+ /**
+ * Return this transform with the specified aspect ratio.
+ *
+ * @param size the size describing the aspect ratio
+ * @return the transform
+ */
+ public AffineMatrix withAspectRatio(Size size) {
+ double ar = (double) size.getWidth() / size.getHeight();
+ return withAspectRatio(ar);
+ }
+
+ /**
+ * Return a translation matrix.
+ *
+ * @param x the horizontal translation
+ * @param y the vertical translation
+ * @return the matrix
+ */
+ public static AffineMatrix translate(double x, double y) {
+ return new AffineMatrix(1, 0, 0, 1, x, y);
+ }
+
+ /**
+ * Return a scaling matrix.
+ *
+ * @param x the horizontal scaling
+ * @param y the vertical scaling
+ * @return the matrix
+ */
+ public static AffineMatrix scale(double x, double y) {
+ return new AffineMatrix(x, 0, 0, y, 0, 0);
+ }
+
+ /**
+ * Return a scaling matrix.
+ *
+ * @param from the source size
+ * @param to the destination size
+ * @return the matrix
+ */
+ public static AffineMatrix scale(Size from, Size to) {
+ double scaleX = (double) to.getWidth() / from.getWidth();
+ double scaleY = (double) to.getHeight() / from.getHeight();
+ return scale(scaleX, scaleY);
+ }
+
+ /**
+ * Return a matrix applying a "reframing" (cropping a rectangle).
+ *
+ * (x, y) is the bottom-left corner, (w, h) is the size of the rectangle.
+ *
+ * @param x horizontal coordinate (increasing to the right)
+ * @param y vertical coordinate (increasing upwards)
+ * @param w width
+ * @param h height
+ * @return the matrix
+ */
+ public static AffineMatrix reframe(double x, double y, double w, double h) {
+ if (w == 0 || h == 0) {
+ throw new IllegalArgumentException("Cannot reframe to an empty area: " + w + "x" + h);
+ }
+ return scale(1 / w, 1 / h).multiply(translate(-x, -y));
+ }
+
+ /**
+ * Return an orthogonal rotation matrix.
+ *
+ * @param ccwRotation the counter-clockwise rotation
+ * @return the matrix
+ */
+ public static AffineMatrix rotateOrtho(int ccwRotation) {
+ switch (ccwRotation) {
+ case 0:
+ return IDENTITY;
+ case 1:
+ // 90° counter-clockwise
+ return new AffineMatrix(0, 1, -1, 0, 1, 0);
+ case 2:
+ // 180°
+ return new AffineMatrix(-1, 0, 0, -1, 1, 1);
+ case 3:
+ // 90° clockwise
+ return new AffineMatrix(0, -1, 1, 0, 0, 1);
+ default:
+ throw new IllegalArgumentException("Invalid rotation: " + ccwRotation);
+ }
+ }
+
+ /**
+ * Return an horizontal flip matrix.
+ *
+ * @return the matrix
+ */
+ public static AffineMatrix hflip() {
+ return new AffineMatrix(-1, 0, 0, 1, 1, 0);
+ }
+
+ /**
+ * Return a vertical flip matrix.
+ *
+ * @return the matrix
+ */
+ public static AffineMatrix vflip() {
+ return new AffineMatrix(1, 0, 0, -1, 0, 1);
+ }
+
+ /**
+ * Return a rotation matrix.
+ *
+ * @param ccwDegrees the angle, in degrees (counter-clockwise)
+ * @return the matrix
+ */
+ public static AffineMatrix rotate(double ccwDegrees) {
+ double radians = Math.toRadians(ccwDegrees);
+ double cos = Math.cos(radians);
+ double sin = Math.sin(radians);
+ return new AffineMatrix(cos, sin, -sin, cos, 0, 0);
+ }
+
+ /**
+ * Export this affine transform to a 4x4 column-major order matrix.
+ *
+ * @param matrix output 4x4 matrix
+ */
+ public void to4x4(float[] matrix) {
+ // matrix is a 4x4 matrix in column-major order
+
+ // Column 0
+ matrix[0] = (float) a;
+ matrix[1] = (float) b;
+ matrix[2] = 0;
+ matrix[3] = 0;
+
+ // Column 1
+ matrix[4] = (float) c;
+ matrix[5] = (float) d;
+ matrix[6] = 0;
+ matrix[7] = 0;
+
+ // Column 2
+ matrix[8] = 0;
+ matrix[9] = 0;
+ matrix[10] = 1;
+ matrix[11] = 0;
+
+ // Column 3
+ matrix[12] = (float) e;
+ matrix[13] = (float) f;
+ matrix[14] = 0;
+ matrix[15] = 1;
+ }
+
+ /**
+ * Export this affine transform to a 4x4 column-major order matrix.
+ *
+ * @return 4x4 matrix
+ */
+ public float[] to4x4() {
+ float[] matrix = new float[16];
+ to4x4(matrix);
+ return matrix;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Binary.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Binary.java
new file mode 100644
index 0000000..f46ba69
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Binary.java
@@ -0,0 +1,38 @@
+package com.genymobile.scrcpy.util;
+
+public final class Binary {
+ private Binary() {
+ // not instantiable
+ }
+
+ public static int toUnsigned(short value) {
+ return value & 0xffff;
+ }
+
+ public static int toUnsigned(byte value) {
+ return value & 0xff;
+ }
+
+ /**
+ * Convert unsigned 16-bit fixed-point to a float between 0 and 1
+ *
+ * @param value encoded value
+ * @return Float value between 0 and 1
+ */
+ public static float u16FixedPointToFloat(short value) {
+ int unsignedShort = Binary.toUnsigned(value);
+ // 0x1p16f is 2^16 as float
+ return unsignedShort == 0xffff ? 1f : (unsignedShort / 0x1p16f);
+ }
+
+ /**
+ * Convert signed 16-bit fixed-point to a float between -1 and 1
+ *
+ * @param value encoded value
+ * @return Float value between -1 and 1
+ */
+ public static float i16FixedPointToFloat(short value) {
+ // 0x1p15f is 2^15 as float
+ return value == 0x7fff ? 1f : (value / 0x1p15f);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/BinarySearch.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/BinarySearch.java
new file mode 100644
index 0000000..381601f
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/BinarySearch.java
@@ -0,0 +1,53 @@
+package com.genymobile.scrcpy.util;
+
+import com.genymobile.scrcpy.AndroidVersions;
+
+import android.annotation.TargetApi;
+
+import java.util.function.Predicate;
+
+public final class BinarySearch {
+ private BinarySearch() {
+ // not instantiable
+ }
+
+ /**
+ * Find the highest value for which the predicate is true.
+ *
+ * This assumes that the predicate is monotonic:
+ * - if it returns {@code false} for a given value, it must return {@code false} for higher values, or equivalently
+ * - if it returns {@code true} for a given value, it must return {@code true} for lower values.
+ *
+ * @param low the lowest value
+ * @param high the highest value
+ * @param predicate the predicate
+ * @return the highest value for which {@code predicate.test(value)} is {@code true}, or {@code low - 1} if not found.
+ */
+ // This function is used for virtual displays, so we don't need to support older versions anyway
+ @TargetApi(AndroidVersions.API_24_ANDROID_7_0) // for Predicate
+ public static int findHighestTrue(int low, int high, Predicate predicate) {
+ if (low <= high) {
+ // Fast path
+ if (predicate.test(high)) {
+ return high;
+ }
+ --high;
+ }
+
+ int result = low - 1; // low-1 means "not found"
+ while (low <= high) {
+ int mid = low + (high - low + 1) / 2;
+ if (predicate.test(mid)) {
+ result = mid;
+
+ // predicate holds, go right
+ low = mid + 1;
+ } else {
+ // predicate false, go left
+ high = mid - 1;
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/CodecUtils.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/CodecUtils.java
new file mode 100644
index 0000000..3a01256
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/CodecUtils.java
@@ -0,0 +1,38 @@
+package com.genymobile.scrcpy.util;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaFormat;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public final class CodecUtils {
+
+ private CodecUtils() {
+ // not instantiable
+ }
+
+ public static void setCodecOption(MediaFormat format, String key, Object value) {
+ if (value instanceof Integer) {
+ format.setInteger(key, (Integer) value);
+ } else if (value instanceof Long) {
+ format.setLong(key, (Long) value);
+ } else if (value instanceof Float) {
+ format.setFloat(key, (Float) value);
+ } else if (value instanceof String) {
+ format.setString(key, (String) value);
+ }
+ }
+
+ public static MediaCodecInfo[] getEncoders(MediaCodecList codecs, String mimeType) {
+ List result = new ArrayList<>();
+ for (MediaCodecInfo codecInfo : codecs.getCodecInfos()) {
+ if (codecInfo.isEncoder() && Arrays.asList(codecInfo.getSupportedTypes()).contains(mimeType)) {
+ result.add(codecInfo);
+ }
+ }
+ return result.toArray(new MediaCodecInfo[result.size()]);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Command.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Command.java
new file mode 100644
index 0000000..b26158e
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Command.java
@@ -0,0 +1,43 @@
+package com.genymobile.scrcpy.util;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Scanner;
+
+public final class Command {
+ private Command() {
+ // not instantiable
+ }
+
+ public static void exec(String... cmd) throws IOException, InterruptedException {
+ Process process = Runtime.getRuntime().exec(cmd);
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode);
+ }
+ }
+
+ public static String execReadLine(String... cmd) throws IOException, InterruptedException {
+ String result = null;
+ Process process = Runtime.getRuntime().exec(cmd);
+ Scanner scanner = new Scanner(process.getInputStream());
+ if (scanner.hasNextLine()) {
+ result = scanner.nextLine();
+ }
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode);
+ }
+ return result;
+ }
+
+ public static String execReadOutput(String... cmd) throws IOException, InterruptedException {
+ Process process = Runtime.getRuntime().exec(cmd);
+ String output = IO.toString(process.getInputStream());
+ int exitCode = process.waitFor();
+ if (exitCode != 0) {
+ throw new IOException("Command " + Arrays.toString(cmd) + " returned with value " + exitCode);
+ }
+ return output;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/HandlerExecutor.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/HandlerExecutor.java
new file mode 100644
index 0000000..0330998
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/HandlerExecutor.java
@@ -0,0 +1,23 @@
+package com.genymobile.scrcpy.util;
+
+import android.os.Handler;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
+
+// Inspired from hidden android.os.HandlerExecutor
+
+public class HandlerExecutor implements Executor {
+ private final Handler handler;
+
+ public HandlerExecutor(Handler handler) {
+ this.handler = handler;
+ }
+
+ @Override
+ public void execute(Runnable command) {
+ if (!handler.post(command)) {
+ throw new RejectedExecutionException(handler + " is shutting down");
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/IO.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/IO.java
new file mode 100644
index 0000000..16ddaed
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/IO.java
@@ -0,0 +1,79 @@
+package com.genymobile.scrcpy.util;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.BuildConfig;
+
+import android.os.Build;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.system.OsConstants;
+
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.Scanner;
+
+public final class IO {
+ private IO() {
+ // not instantiable
+ }
+
+ private static int write(FileDescriptor fd, ByteBuffer from) throws IOException {
+ while (true) {
+ try {
+ return Os.write(fd, from);
+ } catch (ErrnoException e) {
+ if (e.errno != OsConstants.EINTR) {
+ throw new IOException(e);
+ }
+ }
+ }
+ }
+
+ public static void writeFully(FileDescriptor fd, ByteBuffer from) throws IOException {
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) {
+ while (from.hasRemaining()) {
+ write(fd, from);
+ }
+ } else {
+ // ByteBuffer position is not updated as expected by Os.write() on old Android versions, so
+ // handle the position and the remaining bytes manually.
+ // See .
+ int position = from.position();
+ int remaining = from.remaining();
+ while (remaining > 0) {
+ int w = write(fd, from);
+ if (BuildConfig.DEBUG && w < 0) {
+ // w should not be negative, since an exception is thrown on error
+ throw new AssertionError("Os.write() returned a negative value (" + w + ")");
+ }
+ remaining -= w;
+ position += w;
+ from.position(position);
+ }
+ }
+ }
+
+ public static void writeFully(FileDescriptor fd, byte[] buffer, int offset, int len) throws IOException {
+ writeFully(fd, ByteBuffer.wrap(buffer, offset, len));
+ }
+
+ public static String toString(InputStream inputStream) {
+ StringBuilder builder = new StringBuilder();
+ Scanner scanner = new Scanner(inputStream);
+ while (scanner.hasNextLine()) {
+ builder.append(scanner.nextLine()).append('\n');
+ }
+ return builder.toString();
+ }
+
+ public static boolean isBrokenPipe(IOException e) {
+ Throwable cause = e.getCause();
+ return cause instanceof ErrnoException && ((ErrnoException) cause).errno == OsConstants.EPIPE;
+ }
+
+ public static boolean isBrokenPipe(Exception e) {
+ return e instanceof IOException && isBrokenPipe((IOException) e);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Ln.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Ln.java
new file mode 100644
index 0000000..811a4f3
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Ln.java
@@ -0,0 +1,122 @@
+package com.genymobile.scrcpy.util;
+
+import android.util.Log;
+
+import java.io.FileDescriptor;
+import java.io.FileOutputStream;
+import java.io.OutputStream;
+import java.io.PrintStream;
+
+/**
+ * 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";
+ private static final String PREFIX = "[server] ";
+
+ private static final PrintStream CONSOLE_OUT = new PrintStream(new FileOutputStream(FileDescriptor.out));
+ private static final PrintStream CONSOLE_ERR = new PrintStream(new FileOutputStream(FileDescriptor.err));
+
+ public enum Level {
+ VERBOSE, DEBUG, INFO, WARN, ERROR
+ }
+
+ private static Level threshold = Level.INFO;
+
+ private Ln() {
+ // not instantiable
+ }
+
+ public static void disableSystemStreams() {
+ PrintStream nullStream = new PrintStream(new NullOutputStream());
+ System.setOut(nullStream);
+ System.setErr(nullStream);
+ }
+
+ /**
+ * Initialize the log level.
+ *
+ * Must be called before starting any new thread.
+ *
+ * @param level the log level
+ */
+ public static void initLogLevel(Level level) {
+ threshold = level;
+ }
+
+ public static boolean isEnabled(Level level) {
+ return level.ordinal() >= threshold.ordinal();
+ }
+
+ public static void v(String message) {
+ if (isEnabled(Level.VERBOSE)) {
+ Log.v(TAG, message);
+ CONSOLE_OUT.print(PREFIX + "VERBOSE: " + message + '\n');
+ }
+ }
+
+ public static void d(String message) {
+ if (isEnabled(Level.DEBUG)) {
+ Log.d(TAG, message);
+ CONSOLE_OUT.print(PREFIX + "DEBUG: " + message + '\n');
+ }
+ }
+
+ public static void i(String message) {
+ if (isEnabled(Level.INFO)) {
+ Log.i(TAG, message);
+ CONSOLE_OUT.print(PREFIX + "INFO: " + message + '\n');
+ }
+ }
+
+ public static void w(String message, Throwable throwable) {
+ if (isEnabled(Level.WARN)) {
+ Log.w(TAG, message, throwable);
+ synchronized (CONSOLE_ERR) {
+ CONSOLE_ERR.print(PREFIX + "WARN: " + message + '\n');
+ if (throwable != null) {
+ throwable.printStackTrace(CONSOLE_ERR);
+ }
+ }
+ }
+ }
+
+ public static void w(String message) {
+ w(message, null);
+ }
+
+ public static void e(String message, Throwable throwable) {
+ if (isEnabled(Level.ERROR)) {
+ Log.e(TAG, message, throwable);
+ synchronized (CONSOLE_ERR) {
+ CONSOLE_ERR.print(PREFIX + "ERROR: " + message + '\n');
+ if (throwable != null) {
+ throwable.printStackTrace(CONSOLE_ERR);
+ }
+ }
+ }
+ }
+
+ public static void e(String message) {
+ e(message, null);
+ }
+
+ static class NullOutputStream extends OutputStream {
+ @Override
+ public void write(byte[] b) {
+ // ignore
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) {
+ // ignore
+ }
+
+ @Override
+ public void write(int b) {
+ // ignore
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/LogUtils.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/LogUtils.java
new file mode 100644
index 0000000..4f4605e
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/LogUtils.java
@@ -0,0 +1,299 @@
+package com.genymobile.scrcpy.util;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.audio.AudioCodec;
+import com.genymobile.scrcpy.device.Device;
+import com.genymobile.scrcpy.display.DisplayInfo;
+import com.genymobile.scrcpy.model.Codec;
+import com.genymobile.scrcpy.model.DeviceApp;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.video.VideoCodec;
+import com.genymobile.scrcpy.wrappers.DisplayManager;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.graphics.Rect;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraManager;
+import android.hardware.camera2.params.StreamConfigurationMap;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.os.Build;
+import android.util.Range;
+
+import java.text.DecimalFormat;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.SortedSet;
+import java.util.TreeSet;
+
+public final class LogUtils {
+
+ private LogUtils() {
+ // not instantiable
+ }
+
+ private static String buildEncoderListMessage(String type, Codec[] codecs) {
+ StringBuilder builder = new StringBuilder("List of ").append(type).append(" encoders:");
+ MediaCodecList codecList = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
+ for (Codec codec : codecs) {
+ MediaCodecInfo[] encoders = CodecUtils.getEncoders(codecList, codec.getMimeType());
+ for (MediaCodecInfo info : encoders) {
+ int lineStart = builder.length();
+ builder.append("\n --").append(type).append("-codec=").append(codec.getName());
+ builder.append(" --").append(type).append("-encoder=").append(info.getName());
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_29_ANDROID_10) {
+ int lineLength = builder.length() - lineStart;
+ final int column = 70;
+ if (lineLength < column) {
+ int padding = column - lineLength;
+ builder.append(String.format("%" + padding + "s", " "));
+ }
+ builder.append(" (").append(getHwCodecType(info)).append(')');
+ if (info.isVendor()) {
+ builder.append(" [vendor]");
+ }
+ if (info.isAlias()) {
+ builder.append(" (alias for ").append(info.getCanonicalName()).append(')');
+ }
+ }
+
+ }
+ }
+
+ return builder.toString();
+ }
+
+ public static String buildVideoEncoderListMessage() {
+ return buildEncoderListMessage("video", VideoCodec.values());
+ }
+
+ public static String buildAudioEncoderListMessage() {
+ return buildEncoderListMessage("audio", AudioCodec.values());
+ }
+
+ @TargetApi(AndroidVersions.API_29_ANDROID_10)
+ private static String getHwCodecType(MediaCodecInfo info) {
+ if (info.isSoftwareOnly()) {
+ return "sw";
+ }
+ if (info.isHardwareAccelerated()) {
+ return "hw";
+ }
+ return "hybrid";
+ }
+
+ public static String buildDisplayListMessage() {
+ StringBuilder builder = new StringBuilder("List of displays:");
+ DisplayManager displayManager = ServiceManager.getDisplayManager();
+ int[] displayIds = displayManager.getDisplayIds();
+ if (displayIds == null || displayIds.length == 0) {
+ builder.append("\n (none)");
+ } else {
+ for (int id : displayIds) {
+ builder.append("\n --display-id=").append(id).append(" (");
+ DisplayInfo displayInfo = displayManager.getDisplayInfo(id);
+ if (displayInfo != null) {
+ Size size = displayInfo.getSize();
+ builder.append(size.getWidth()).append("x").append(size.getHeight());
+ } else {
+ builder.append("size unknown");
+ }
+ builder.append(")");
+ }
+ }
+ return builder.toString();
+ }
+
+ private static String getCameraFacingName(int facing) {
+ switch (facing) {
+ case CameraCharacteristics.LENS_FACING_FRONT:
+ return "front";
+ case CameraCharacteristics.LENS_FACING_BACK:
+ return "back";
+ case CameraCharacteristics.LENS_FACING_EXTERNAL:
+ return "external";
+ default:
+ return "unknown";
+ }
+ }
+
+ private static boolean isCameraBackwardCompatible(CameraCharacteristics characteristics) {
+ int[] capabilities = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES);
+ if (capabilities == null) {
+ return false;
+ }
+
+ for (int capability : capabilities) {
+ if (capability == CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static String buildCameraListMessage(boolean includeSizes) {
+ StringBuilder builder = new StringBuilder("List of cameras:");
+ CameraManager cameraManager = ServiceManager.getCameraManager();
+ try {
+ String[] cameraIds = cameraManager.getCameraIdList();
+ if (cameraIds.length == 0) {
+ builder.append("\n (none)");
+ } else {
+ for (String id : cameraIds) {
+ CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id);
+
+ if (!isCameraBackwardCompatible(characteristics)) {
+ // Ignore depth cameras as suggested by official documentation
+ //
+ continue;
+ }
+
+ builder.append("\n --camera-id=").append(id);
+
+ int facing = characteristics.get(CameraCharacteristics.LENS_FACING);
+ builder.append(" (").append(getCameraFacingName(facing)).append(", ");
+
+ Rect activeSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE);
+ builder.append(activeSize.width()).append("x").append(activeSize.height());
+
+ try {
+ // Capture frame rates for low-FPS mode are the same for every resolution
+ Range[] lowFpsRanges = characteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES);
+ if (lowFpsRanges != null) {
+ String uniqueLowFps = getFormattedUniqueSet(lowFpsRanges);
+ builder.append(", fps=").append(uniqueLowFps);
+ }
+ } catch (Exception e) {
+ // Some devices may provide invalid ranges, causing an IllegalArgumentException "lower must be less than or equal to upper"
+ Ln.w("Could not get available frame rates for camera " + id, e);
+ }
+
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_30_ANDROID_11) {
+ try {
+ Range zoomRange = characteristics.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE);
+ if (zoomRange != null) {
+ String zoom = getFormattedZoomRange(zoomRange);
+ builder.append(", zoom-range=").append(zoom);
+ }
+ } catch (Exception e) {
+ Ln.w("Could not get available zoom ranges for camera " + id, e);
+ }
+ }
+
+ builder.append(')');
+
+ if (includeSizes) {
+ StreamConfigurationMap configs = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
+
+ android.util.Size[] sizes = configs.getOutputSizes(MediaCodec.class);
+ if (sizes == null || sizes.length == 0) {
+ builder.append("\n (none)");
+ } else {
+ for (android.util.Size size : sizes) {
+ builder.append("\n - ").append(size.getWidth()).append('x').append(size.getHeight());
+ }
+ }
+
+ android.util.Size[] highSpeedSizes = configs.getHighSpeedVideoSizes();
+ if (highSpeedSizes != null && highSpeedSizes.length > 0) {
+ builder.append("\n High speed capture (--camera-high-speed):");
+ for (android.util.Size size : highSpeedSizes) {
+ Range[] highFpsRanges = configs.getHighSpeedVideoFpsRanges();
+ String uniqueHighFps = getFormattedUniqueSet(highFpsRanges);
+ builder.append("\n - ").append(size.getWidth()).append("x").append(size.getHeight());
+ builder.append(" (fps=").append(uniqueHighFps).append(')');
+ }
+ }
+ }
+ }
+ }
+ } catch (CameraAccessException e) {
+ builder.append("\n (access denied)");
+ }
+ return builder.toString();
+ }
+
+ private static String getFormattedUniqueSet(Range[] ranges) {
+ SortedSet set = new TreeSet<>();
+ for (Range range : ranges) {
+ set.add(range.getUpper());
+ }
+
+ StringBuilder builder = new StringBuilder("{");
+ boolean first = true;
+ for (Integer i : set) {
+ if (!first) {
+ builder.append(", ");
+ } else {
+ first = false;
+ }
+ builder.append(i);
+ }
+ builder.append("}");
+
+ return builder.toString();
+ }
+
+ private static String getFormattedZoomRange(Range range) {
+ DecimalFormat format = new DecimalFormat("#.##");
+ return "[" + format.format(range.getLower()) + ", " + format.format(range.getUpper()) + "]";
+ }
+
+ public static String buildAppListMessage() {
+ List apps = Device.listApps();
+ return buildAppListMessage("List of apps:", apps);
+ }
+
+ @SuppressLint("QueryPermissionsNeeded")
+ public static String buildAppListMessage(String title, List apps) {
+ StringBuilder builder = new StringBuilder(title);
+
+ // Sort by:
+ // 1. system flag (system apps are before non-system apps)
+ // 2. name
+ // 3. package name
+ // Comparator.comparing() was introduced in API 24, so it cannot be used here to simplify the code
+ Collections.sort(apps, (thisApp, otherApp) -> {
+ // System apps first
+ int cmp = -Boolean.compare(thisApp.isSystem(), otherApp.isSystem());
+ if (cmp != 0) {
+ return cmp;
+ }
+
+ cmp = Objects.compare(thisApp.getName(), otherApp.getName(), String::compareTo);
+ if (cmp != 0) {
+ return cmp;
+ }
+
+ return Objects.compare(thisApp.getPackageName(), otherApp.getPackageName(), String::compareTo);
+ });
+
+ final int column = 30;
+ for (DeviceApp app : apps) {
+ String name = app.getName();
+ int padding = column - name.length();
+ builder.append("\n ");
+ if (app.isSystem()) {
+ builder.append("* ");
+ } else {
+ builder.append("- ");
+
+ }
+ builder.append(name);
+ if (padding > 0) {
+ builder.append(String.format("%" + padding + "s", " "));
+ } else {
+ builder.append("\n ").append(String.format("%" + column + "s", " "));
+ }
+ builder.append(" ").append(app.getPackageName());
+ }
+
+ return builder.toString();
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Settings.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Settings.java
new file mode 100644
index 0000000..4eb67d1
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Settings.java
@@ -0,0 +1,38 @@
+package com.genymobile.scrcpy.util;
+
+import com.genymobile.scrcpy.wrappers.ContentProvider;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+public final class Settings {
+
+ public static final String TABLE_SYSTEM = ContentProvider.TABLE_SYSTEM;
+ public static final String TABLE_SECURE = ContentProvider.TABLE_SECURE;
+ public static final String TABLE_GLOBAL = ContentProvider.TABLE_GLOBAL;
+
+ private Settings() {
+ /* not instantiable */
+ }
+
+ public static String getValue(String table, String key) throws SettingsException {
+ try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) {
+ return provider.getValue(table, key);
+ }
+ }
+
+ public static void putValue(String table, String key, String value) throws SettingsException {
+ try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) {
+ provider.putValue(table, key, value);
+ }
+
+ }
+
+ public static String getAndPutValue(String table, String key, String value) throws SettingsException {
+ try (ContentProvider provider = ServiceManager.getActivityManager().createSettingsProvider()) {
+ String oldValue = provider.getValue(table, key);
+ if (!value.equals(oldValue)) {
+ provider.putValue(table, key, value);
+ }
+ return oldValue;
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/SettingsException.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/SettingsException.java
new file mode 100644
index 0000000..87fa388
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/SettingsException.java
@@ -0,0 +1,11 @@
+package com.genymobile.scrcpy.util;
+
+public class SettingsException extends Exception {
+ private static String createMessage(String method, String table, String key, String value) {
+ return "Could not access settings: " + method + " " + table + " " + key + (value != null ? " " + value : "");
+ }
+
+ public SettingsException(String method, String table, String key, String value, Throwable cause) {
+ super(createMessage(method, table, key, value), cause);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/StringUtils.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/StringUtils.java
new file mode 100644
index 0000000..8b19ca3
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/StringUtils.java
@@ -0,0 +1,22 @@
+package com.genymobile.scrcpy.util;
+
+public final class StringUtils {
+ private StringUtils() {
+ // not instantiable
+ }
+
+ public static int getUtf8TruncationIndex(byte[] utf8, int maxLength) {
+ int len = utf8.length;
+ if (len <= maxLength) {
+ return len;
+ }
+ len = maxLength;
+ // see UTF-8 encoding
+ while ((utf8[len] & 0x80) != 0 && (utf8[len] & 0xc0) != 0xc0) {
+ // the next byte is not the start of a new UTF-8 codepoint
+ // so if we would cut there, the character would be truncated
+ len--;
+ }
+ return len;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Threads.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Threads.java
new file mode 100644
index 0000000..c561d1e
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/util/Threads.java
@@ -0,0 +1,43 @@
+package com.genymobile.scrcpy.util;
+
+import android.os.Handler;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.Semaphore;
+
+public final class Threads {
+ private Threads() {
+ // not instantiable
+ }
+
+ public static T executeSynchronouslyOn(Handler handler, Callable callable) throws Throwable {
+ // Simulate CompletableFuture, but working for all Android versions
+ final Semaphore sem = new Semaphore(0);
+ @SuppressWarnings("unchecked")
+ T[] resultRef = (T[]) new Object[1];
+ Throwable[] throwableRef = new Throwable[1];
+
+ handler.post(() -> {
+ try {
+ resultRef[0] = callable.call();
+ } catch (Throwable throwable) {
+ throwableRef[0] = throwable;
+ } finally {
+ sem.release();
+ }
+ });
+
+ try {
+ sem.acquire();
+ } catch (InterruptedException e) {
+ // Behave as if this method call was synchronous
+ Thread.currentThread().interrupt();
+ }
+
+ if (throwableRef[0] != null) {
+ throw throwableRef[0];
+ }
+
+ return resultRef[0];
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraAspectRatio.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraAspectRatio.java
new file mode 100644
index 0000000..bf1cba5
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraAspectRatio.java
@@ -0,0 +1,37 @@
+package com.genymobile.scrcpy.video;
+
+public final class CameraAspectRatio {
+ private static final float SENSOR = -1;
+
+ private float ar;
+
+ private CameraAspectRatio(float ar) {
+ this.ar = ar;
+ }
+
+ public static CameraAspectRatio fromFloat(float ar) {
+ if (ar < 0) {
+ throw new IllegalArgumentException("Invalid aspect ratio: " + ar);
+ }
+ return new CameraAspectRatio(ar);
+ }
+
+ public static CameraAspectRatio fromFraction(int w, int h) {
+ if (w <= 0 || h <= 0) {
+ throw new IllegalArgumentException("Invalid aspect ratio: " + w + ":" + h);
+ }
+ return new CameraAspectRatio((float) w / h);
+ }
+
+ public static CameraAspectRatio sensorAspectRatio() {
+ return new CameraAspectRatio(SENSOR);
+ }
+
+ public boolean isSensor() {
+ return ar == SENSOR;
+ }
+
+ public float getAspectRatio() {
+ return ar;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraCapture.java
new file mode 100644
index 0000000..1aaeafa
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraCapture.java
@@ -0,0 +1,531 @@
+package com.genymobile.scrcpy.video;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.Options;
+import com.genymobile.scrcpy.model.ConfigurationException;
+import com.genymobile.scrcpy.model.Orientation;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.opengl.AffineOpenGLFilter;
+import com.genymobile.scrcpy.opengl.OpenGLFilter;
+import com.genymobile.scrcpy.opengl.OpenGLRunner;
+import com.genymobile.scrcpy.util.AffineMatrix;
+import com.genymobile.scrcpy.util.HandlerExecutor;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.util.LogUtils;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.graphics.Rect;
+import android.hardware.camera2.CameraAccessException;
+import android.hardware.camera2.CameraCaptureSession;
+import android.hardware.camera2.CameraCharacteristics;
+import android.hardware.camera2.CameraConstrainedHighSpeedCaptureSession;
+import android.hardware.camera2.CameraDevice;
+import android.hardware.camera2.CameraManager;
+import android.hardware.camera2.CaptureFailure;
+import android.hardware.camera2.CaptureRequest;
+import android.hardware.camera2.params.OutputConfiguration;
+import android.hardware.camera2.params.SessionConfiguration;
+import android.hardware.camera2.params.StreamConfigurationMap;
+import android.media.MediaCodec;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Range;
+import android.view.Surface;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Stream;
+
+public class CameraCapture extends SurfaceCapture {
+
+ public static final float[] VFLIP_MATRIX = {
+ 1, 0, 0, 0, // column 1
+ 0, -1, 0, 0, // column 2
+ 0, 0, 1, 0, // column 3
+ 0, 1, 0, 1, // column 4
+ };
+
+ private static final float ZOOM_FACTOR = 1 + 1 / 16f;
+
+ private final String explicitCameraId;
+ private final CameraFacing cameraFacing;
+ private final Size explicitSize;
+ private final CameraAspectRatio aspectRatio;
+ private final int fps;
+ private final boolean highSpeed;
+ private final Rect crop;
+ private final Orientation captureOrientation;
+ private final float angle;
+ private final boolean initialTorch;
+ private float zoom;
+
+ private VideoConstraints videoConstraints;
+
+ private String cameraId;
+ private Size captureSize;
+ private Size videoSize; // after OpenGL transforms
+ private Range zoomRange;
+
+ private AffineMatrix transform;
+ private OpenGLRunner glRunner;
+
+ private HandlerThread cameraThread;
+ private Handler cameraHandler;
+ private CameraDevice cameraDevice;
+ private Executor cameraExecutor;
+
+ private final AtomicBoolean disconnected = new AtomicBoolean();
+
+ // The following fields must be accessed only from the camera thread
+ private boolean started;
+ private CaptureRequest.Builder requestBuilder;
+ private CameraCaptureSession currentSession;
+
+ public CameraCapture(Options options) {
+ this.explicitCameraId = options.getCameraId();
+ this.cameraFacing = options.getCameraFacing();
+ this.explicitSize = options.getCameraSize();
+ this.aspectRatio = options.getCameraAspectRatio();
+ this.fps = options.getCameraFps();
+ this.highSpeed = options.getCameraHighSpeed();
+ this.crop = options.getCrop();
+ this.captureOrientation = options.getCaptureOrientation();
+ assert captureOrientation != null;
+ this.angle = options.getAngle();
+ this.initialTorch = options.getCameraTorch();
+ this.zoom = options.getCameraZoom();
+ }
+
+ @Override
+ protected void init(VideoConstraints videoConstraints) throws ConfigurationException, IOException {
+ this.videoConstraints = videoConstraints;
+
+ cameraThread = new HandlerThread("camera");
+ cameraThread.start();
+ cameraHandler = new Handler(cameraThread.getLooper());
+ cameraExecutor = new HandlerExecutor(cameraHandler);
+
+ try {
+ cameraId = selectCamera(explicitCameraId, cameraFacing);
+ if (cameraId == null) {
+ throw new ConfigurationException("No matching camera found");
+ }
+
+ Ln.i("Using camera '" + cameraId + "'");
+ cameraDevice = openCamera(cameraId);
+ } catch (CameraAccessException | InterruptedException e) {
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void prepare() throws IOException {
+ try {
+ int maxSize = videoConstraints.getMaxSize();
+ captureSize = selectSize(cameraId, explicitSize, maxSize, aspectRatio, highSpeed);
+ if (captureSize == null) {
+ throw new IOException("Could not select camera size");
+ }
+ } catch (CameraAccessException e) {
+ throw new IOException(e);
+ }
+
+ VideoFilter filter = new VideoFilter(captureSize);
+
+ if (crop != null) {
+ filter.addCrop(crop, false);
+ }
+
+ if (captureOrientation != Orientation.Orient0) {
+ filter.addOrientation(captureOrientation);
+ }
+
+ filter.addAngle(angle);
+
+ transform = filter.getInverseTransform();
+ videoSize = filter.getOutputSize().align(videoConstraints.getAlignment());
+
+ if (!videoSize.equals(captureSize) && transform == null) {
+ // The camera stream can only be rendered on a surface matching the input size.
+ // If the video constraints change the size, an intermediate OpenGL filter is required.
+ transform = AffineMatrix.IDENTITY;
+ }
+ }
+
+ private static String selectCamera(String explicitCameraId, CameraFacing cameraFacing) throws CameraAccessException, ConfigurationException {
+ CameraManager cameraManager = ServiceManager.getCameraManager();
+
+ String[] cameraIds = cameraManager.getCameraIdList();
+ if (explicitCameraId != null) {
+ if (!Arrays.asList(cameraIds).contains(explicitCameraId)) {
+ Ln.e("Camera with id " + explicitCameraId + " not found\n" + LogUtils.buildCameraListMessage(false));
+ throw new ConfigurationException("Camera id not found");
+ }
+ return explicitCameraId;
+ }
+
+ if (cameraFacing == null) {
+ // Use the first one
+ return cameraIds.length > 0 ? cameraIds[0] : null;
+ }
+
+ for (String cameraId : cameraIds) {
+ CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId);
+
+ int facing = characteristics.get(CameraCharacteristics.LENS_FACING);
+ if (cameraFacing.value() == facing) {
+ return cameraId;
+ }
+ }
+
+ // Not found
+ return null;
+ }
+
+ @TargetApi(AndroidVersions.API_24_ANDROID_7_0)
+ private static Size selectSize(String cameraId, Size explicitSize, int maxSize, CameraAspectRatio aspectRatio, boolean highSpeed)
+ throws CameraAccessException {
+ if (explicitSize != null) {
+ return explicitSize;
+ }
+
+ CameraManager cameraManager = ServiceManager.getCameraManager();
+ CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId);
+
+ StreamConfigurationMap configs = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
+ android.util.Size[] sizes = highSpeed ? configs.getHighSpeedVideoSizes() : configs.getOutputSizes(MediaCodec.class);
+ if (sizes == null) {
+ return null;
+ }
+
+ Stream stream = Arrays.stream(sizes);
+ if (maxSize > 0) {
+ stream = stream.filter(it -> it.getWidth() <= maxSize && it.getHeight() <= maxSize);
+ }
+
+ Float targetAspectRatio = resolveAspectRatio(aspectRatio, characteristics);
+ if (targetAspectRatio != null) {
+ stream = stream.filter(it -> {
+ float ar = ((float) it.getWidth() / it.getHeight());
+ float arRatio = ar / targetAspectRatio;
+ // Accept if the aspect ratio is the target aspect ratio + or - 10%
+ return arRatio >= 0.9f && arRatio <= 1.1f;
+ });
+ }
+
+ Optional selected = stream.max((s1, s2) -> {
+ // Greater width is better
+ int cmp = Integer.compare(s1.getWidth(), s2.getWidth());
+ if (cmp != 0) {
+ return cmp;
+ }
+
+ if (targetAspectRatio != null) {
+ // Closer to the target aspect ratio is better
+ float ar1 = ((float) s1.getWidth() / s1.getHeight());
+ float arRatio1 = ar1 / targetAspectRatio;
+ float distance1 = Math.abs(1 - arRatio1);
+
+ float ar2 = ((float) s2.getWidth() / s2.getHeight());
+ float arRatio2 = ar2 / targetAspectRatio;
+ float distance2 = Math.abs(1 - arRatio2);
+
+ // Reverse the order because lower distance is better
+ cmp = Float.compare(distance2, distance1);
+ if (cmp != 0) {
+ return cmp;
+ }
+ }
+
+ // Greater height is better
+ return Integer.compare(s1.getHeight(), s2.getHeight());
+ });
+
+ if (selected.isPresent()) {
+ android.util.Size size = selected.get();
+ return new Size(size.getWidth(), size.getHeight());
+ }
+
+ // Not found
+ return null;
+ }
+
+ private static Float resolveAspectRatio(CameraAspectRatio ratio, CameraCharacteristics characteristics) {
+ if (ratio == null) {
+ return null;
+ }
+
+ if (ratio.isSensor()) {
+ Rect activeSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_ACTIVE_ARRAY_SIZE);
+ return (float) activeSize.width() / activeSize.height();
+ }
+
+ return ratio.getAspectRatio();
+ }
+
+ @TargetApi(AndroidVersions.API_30_ANDROID_11)
+ @Override
+ public void start(Surface surface) throws IOException {
+ if (transform != null) {
+ assert glRunner == null;
+ OpenGLFilter glFilter = new AffineOpenGLFilter(transform);
+ // The transform matrix returned by SurfaceTexture is incorrect for camera capture (it often contains an additional unexpected 90°
+ // rotation). Use a vertical flip transform matrix instead.
+ glRunner = new OpenGLRunner(glFilter, VFLIP_MATRIX);
+ surface = glRunner.start(captureSize, videoSize, surface);
+ }
+
+ cameraHandler.post(() -> {
+ assertCameraThread();
+ started = true;
+ });
+
+ Surface captureSurface = surface;
+ OutputConfiguration outputConfig = new OutputConfiguration(captureSurface);
+ List outputs = Collections.singletonList(outputConfig);
+ int sessionType = highSpeed ? SessionConfiguration.SESSION_HIGH_SPEED : SessionConfiguration.SESSION_REGULAR;
+ SessionConfiguration sessionConfig = new SessionConfiguration(sessionType, outputs, cameraExecutor, new CameraCaptureSession.StateCallback() {
+ @Override
+ public void onConfigured(CameraCaptureSession session) {
+ assertCameraThread();
+ if (!started) {
+ // Stopped on the encoder thread between the call to start() and this callback
+ return;
+ }
+
+ CameraManager cameraManager = ServiceManager.getCameraManager();
+ try {
+ CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(cameraId);
+ zoomRange = characteristics.get(CameraCharacteristics.CONTROL_ZOOM_RATIO_RANGE);
+ } catch (CameraAccessException e) {
+ Ln.w("Could not get camera characteristics");
+ }
+
+ try {
+ requestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD);
+ requestBuilder.addTarget(captureSurface);
+
+ if (fps > 0) {
+ requestBuilder.set(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, new Range<>(fps, fps));
+ }
+ if (initialTorch) {
+ Ln.i("Turn camera torch on");
+ requestBuilder.set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH);
+ }
+ if (zoom != 1) {
+ zoom = clampZoom(zoom);
+ Ln.i("Set camera zoom: " + zoom);
+ requestBuilder.set(CaptureRequest.CONTROL_ZOOM_RATIO, zoom);
+ }
+
+ CaptureRequest request = requestBuilder.build();
+ setRepeatingRequest(session, request);
+ currentSession = session;
+ } catch (CameraAccessException e) {
+ Ln.e("Camera error", e);
+ disconnected.set(true);
+ getCaptureControl().reset(CaptureControl.RESET_REASON_TERMINATED);
+ }
+ }
+
+ @Override
+ public void onConfigureFailed(CameraCaptureSession session) {
+ Ln.e("Camera configuration error");
+ disconnected.set(true);
+ getCaptureControl().reset(CaptureControl.RESET_REASON_TERMINATED);
+ }
+ });
+
+ try {
+ cameraDevice.createCaptureSession(sessionConfig);
+ } catch (CameraAccessException e) {
+ stop();
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void stop() {
+ cameraHandler.post(() -> {
+ assertCameraThread();
+ currentSession = null;
+ requestBuilder = null;
+ started = false;
+ });
+
+ if (glRunner != null) {
+ glRunner.stopAndRelease();
+ glRunner = null;
+ }
+ }
+
+ @Override
+ public void release() {
+ if (cameraDevice != null) {
+ cameraDevice.close();
+ }
+ if (cameraThread != null) {
+ cameraThread.quitSafely();
+ }
+ }
+
+ @Override
+ public Size getSize() {
+ return videoSize;
+ }
+
+ @Override
+ protected boolean applyNewVideoConstraints(VideoConstraints videoConstraints) {
+ if (explicitSize != null) {
+ return false;
+ }
+
+ this.videoConstraints = videoConstraints;
+ return true;
+ }
+
+ @SuppressLint("MissingPermission")
+ @TargetApi(AndroidVersions.API_31_ANDROID_12)
+ private CameraDevice openCamera(String id) throws CameraAccessException, InterruptedException {
+ CompletableFuture future = new CompletableFuture<>();
+ ServiceManager.getCameraManager().openCamera(id, new CameraDevice.StateCallback() {
+ @Override
+ public void onOpened(CameraDevice camera) {
+ Ln.d("Camera opened successfully");
+ future.complete(camera);
+ }
+
+ @Override
+ public void onDisconnected(CameraDevice camera) {
+ Ln.w("Camera disconnected");
+ disconnected.set(true);
+ getCaptureControl().reset(CaptureControl.RESET_REASON_TERMINATED);
+ }
+
+ @Override
+ public void onError(CameraDevice camera, int error) {
+ int cameraAccessExceptionErrorCode;
+ switch (error) {
+ case CameraDevice.StateCallback.ERROR_CAMERA_IN_USE:
+ cameraAccessExceptionErrorCode = CameraAccessException.CAMERA_IN_USE;
+ break;
+ case CameraDevice.StateCallback.ERROR_MAX_CAMERAS_IN_USE:
+ cameraAccessExceptionErrorCode = CameraAccessException.MAX_CAMERAS_IN_USE;
+ break;
+ case CameraDevice.StateCallback.ERROR_CAMERA_DISABLED:
+ cameraAccessExceptionErrorCode = CameraAccessException.CAMERA_DISABLED;
+ break;
+ case CameraDevice.StateCallback.ERROR_CAMERA_DEVICE:
+ case CameraDevice.StateCallback.ERROR_CAMERA_SERVICE:
+ default:
+ cameraAccessExceptionErrorCode = CameraAccessException.CAMERA_ERROR;
+ break;
+ }
+ future.completeExceptionally(new CameraAccessException(cameraAccessExceptionErrorCode));
+ }
+ }, cameraHandler);
+
+ try {
+ return future.get();
+ } catch (ExecutionException e) {
+ throw (CameraAccessException) e.getCause();
+ }
+ }
+
+ @TargetApi(AndroidVersions.API_31_ANDROID_12)
+ private void setRepeatingRequest(CameraCaptureSession session, CaptureRequest request) throws CameraAccessException {
+ CameraCaptureSession.CaptureCallback callback = new CameraCaptureSession.CaptureCallback() {
+ @Override
+ public void onCaptureStarted(CameraCaptureSession session, CaptureRequest request, long timestamp, long frameNumber) {
+ // Called for each frame captured, do nothing
+ }
+
+ @Override
+ public void onCaptureFailed(CameraCaptureSession session, CaptureRequest request, CaptureFailure failure) {
+ Ln.w("Camera capture failed: frame " + failure.getFrameNumber());
+ }
+ };
+
+ if (highSpeed) {
+ CameraConstrainedHighSpeedCaptureSession highSpeedSession = (CameraConstrainedHighSpeedCaptureSession) session;
+ List requests = highSpeedSession.createHighSpeedRequestList(request);
+ highSpeedSession.setRepeatingBurst(requests, callback, cameraHandler);
+ } else {
+ session.setRepeatingRequest(request, callback, cameraHandler);
+ }
+ }
+
+ @Override
+ public boolean isClosed() {
+ return disconnected.get();
+ }
+
+ public void setTorchEnabled(boolean enabled) {
+ cameraHandler.post(() -> {
+ assertCameraThread();
+ if (currentSession != null && requestBuilder != null) {
+ try {
+ Ln.i("Turn camera torch " + (enabled ? "on" : "off"));
+ requestBuilder.set(CaptureRequest.FLASH_MODE, enabled ? CaptureRequest.FLASH_MODE_TORCH : CaptureRequest.FLASH_MODE_OFF);
+ CaptureRequest request = requestBuilder.build();
+ setRepeatingRequest(currentSession, request);
+ } catch (CameraAccessException e) {
+ Ln.e("Camera error", e);
+ }
+ }
+ });
+ }
+
+ @TargetApi(AndroidVersions.API_30_ANDROID_11)
+ private void zoom(boolean in) {
+ cameraHandler.post(() -> {
+ assertCameraThread();
+ if (currentSession != null && requestBuilder != null) {
+ // Always align to log values
+ double z = Math.round(Math.log(zoom) / Math.log(ZOOM_FACTOR));
+ double dir = in ? 1 : -1;
+ zoom = (float) Math.pow(ZOOM_FACTOR, z + dir);
+
+ try {
+ zoom = clampZoom(zoom);
+ Ln.i("Set camera zoom: " + zoom);
+ requestBuilder.set(CaptureRequest.CONTROL_ZOOM_RATIO, zoom);
+ CaptureRequest request = requestBuilder.build();
+ setRepeatingRequest(currentSession, request);
+ } catch (CameraAccessException e) {
+ Ln.e("Camera error", e);
+ }
+ }
+ });
+ }
+
+ public void zoomIn() {
+ zoom(true);
+ }
+
+ public void zoomOut() {
+ zoom(false);
+ }
+
+ private float clampZoom(float value) {
+ assertCameraThread();
+ if (zoomRange == null) {
+ return value;
+ }
+
+ return zoomRange.clamp(value);
+ }
+
+ private void assertCameraThread() {
+ assert Thread.currentThread() == cameraThread;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraFacing.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraFacing.java
new file mode 100644
index 0000000..f818e66
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CameraFacing.java
@@ -0,0 +1,33 @@
+package com.genymobile.scrcpy.video;
+
+import android.annotation.SuppressLint;
+import android.hardware.camera2.CameraCharacteristics;
+
+public enum CameraFacing {
+ FRONT("front", CameraCharacteristics.LENS_FACING_FRONT),
+ BACK("back", CameraCharacteristics.LENS_FACING_BACK),
+ @SuppressLint("InlinedApi") // introduced in API 23
+ EXTERNAL("external", CameraCharacteristics.LENS_FACING_EXTERNAL);
+
+ private final String name;
+ private final int value;
+
+ CameraFacing(String name, int value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ int value() {
+ return value;
+ }
+
+ public static CameraFacing findByName(String name) {
+ for (CameraFacing facing : CameraFacing.values()) {
+ if (name.equals(facing.name)) {
+ return facing;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CaptureControl.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CaptureControl.java
new file mode 100644
index 0000000..7933ea7
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/CaptureControl.java
@@ -0,0 +1,42 @@
+package com.genymobile.scrcpy.video;
+
+import android.media.MediaCodec;
+
+public class CaptureControl {
+
+ public static final int RESET_REASON_TERMINATED = 1;
+ public static final int RESET_REASON_DISPLAY_PROPERTIES_CHANGED = 1 << 1;
+ public static final int RESET_REASON_CLIENT_RESET = 1 << 2;
+ public static final int RESET_REASON_CLIENT_RESIZED = 1 << 3;
+
+ private int reset = 0;
+
+ // Current instance of MediaCodec to "interrupt" on reset
+ private MediaCodec runningMediaCodec;
+
+ public synchronized boolean isResetRequested() {
+ return reset != 0;
+ }
+
+ public synchronized int consumeReset() {
+ int value = reset;
+ reset = 0;
+ return value;
+ }
+
+ public synchronized void reset(int reason) {
+ assert reason != 0;
+ reset |= reason;
+ if (runningMediaCodec != null) {
+ try {
+ runningMediaCodec.signalEndOfInputStream();
+ } catch (IllegalStateException e) {
+ // ignore
+ }
+ }
+ }
+
+ public synchronized void setRunningMediaCodec(MediaCodec runningMediaCodec) {
+ this.runningMediaCodec = runningMediaCodec;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/NewDisplayCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/NewDisplayCapture.java
new file mode 100644
index 0000000..5ed4c73
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/NewDisplayCapture.java
@@ -0,0 +1,374 @@
+package com.genymobile.scrcpy.video;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.Options;
+import com.genymobile.scrcpy.control.PositionMapper;
+import com.genymobile.scrcpy.display.DisplayInfo;
+import com.genymobile.scrcpy.display.DisplayMonitor;
+import com.genymobile.scrcpy.display.DisplayProperties;
+import com.genymobile.scrcpy.display.DisplayPropertiesTracker;
+import com.genymobile.scrcpy.display.DisplayResizeDebouncer;
+import com.genymobile.scrcpy.model.NewDisplay;
+import com.genymobile.scrcpy.model.Orientation;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.opengl.AffineOpenGLFilter;
+import com.genymobile.scrcpy.opengl.OpenGLFilter;
+import com.genymobile.scrcpy.opengl.OpenGLRunner;
+import com.genymobile.scrcpy.util.AffineMatrix;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+
+import android.graphics.Rect;
+import android.hardware.display.VirtualDisplay;
+import android.os.Build;
+import android.view.Surface;
+
+import java.io.IOException;
+
+public class NewDisplayCapture extends SurfaceCapture {
+
+ // Internal fields copied from android.hardware.display.DisplayManager
+ private static final int VIRTUAL_DISPLAY_FLAG_PUBLIC = android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_PUBLIC;
+ private static final int VIRTUAL_DISPLAY_FLAG_PRESENTATION = android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION;
+ private static final int VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY = android.hardware.display.DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY;
+ private static final int VIRTUAL_DISPLAY_FLAG_SUPPORTS_TOUCH = 1 << 6;
+ private static final int VIRTUAL_DISPLAY_FLAG_ROTATES_WITH_CONTENT = 1 << 7;
+ private static final int VIRTUAL_DISPLAY_FLAG_DESTROY_CONTENT_ON_REMOVAL = 1 << 8;
+ private static final int VIRTUAL_DISPLAY_FLAG_SHOULD_SHOW_SYSTEM_DECORATIONS = 1 << 9;
+ private static final int VIRTUAL_DISPLAY_FLAG_TRUSTED = 1 << 10;
+ private static final int VIRTUAL_DISPLAY_FLAG_OWN_DISPLAY_GROUP = 1 << 11;
+ private static final int VIRTUAL_DISPLAY_FLAG_ALWAYS_UNLOCKED = 1 << 12;
+ private static final int VIRTUAL_DISPLAY_FLAG_TOUCH_FEEDBACK_DISABLED = 1 << 13;
+ private static final int VIRTUAL_DISPLAY_FLAG_OWN_FOCUS = 1 << 14;
+ private static final int VIRTUAL_DISPLAY_FLAG_DEVICE_DISPLAY_GROUP = 1 << 15;
+
+ private final VirtualDisplayListener vdListener;
+ private final NewDisplay newDisplay;
+
+ private final DisplayMonitor displayMonitor = new DisplayMonitor();
+
+ private AffineMatrix displayTransform;
+ private AffineMatrix eventTransform;
+ private OpenGLRunner glRunner;
+
+ private Size mainDisplaySize;
+ private int mainDisplayDpi;
+ private final int displayImePolicy;
+ private final Rect crop;
+ private final boolean captureOrientationLocked;
+ private final Orientation captureOrientation;
+ private final float angle;
+ private final boolean vdDestroyContent;
+ private final boolean vdSystemDecorations;
+ private final boolean flexDisplay;
+
+ private VideoConstraints videoConstraints;
+
+ private VirtualDisplay virtualDisplay;
+ private Size videoSize;
+ private Size displaySize; // the logical size of the display (including rotation)
+ private Size physicalSize; // the physical size of the display (without rotation)
+
+ private DisplayPropertiesTracker tracker;
+ private DisplayResizeDebouncer debouncer;
+
+ private int dpi;
+
+ public NewDisplayCapture(VirtualDisplayListener vdListener, Options options) {
+ this.vdListener = vdListener;
+ this.newDisplay = options.getNewDisplay();
+ assert newDisplay != null;
+ this.displayImePolicy = options.getDisplayImePolicy();
+ this.crop = options.getCrop();
+ assert options.getCaptureOrientationLock() != null;
+ this.captureOrientationLocked = options.getCaptureOrientationLock() != Orientation.Lock.Unlocked;
+ this.captureOrientation = options.getCaptureOrientation();
+ assert captureOrientation != null;
+ this.angle = options.getAngle();
+ this.vdDestroyContent = options.getVDDestroyContent();
+ this.vdSystemDecorations = options.getVDSystemDecorations();
+ this.flexDisplay = options.getFlexDisplay();
+ }
+
+ @Override
+ protected void init(VideoConstraints videoConstraints) {
+ setVideoConstraints(videoConstraints); // synchronized
+
+ displaySize = newDisplay.getSize();
+ dpi = newDisplay.getDpi();
+ if (flexDisplay) {
+ if (crop != null) {
+ throw new IllegalArgumentException("Flex display does not support cropping");
+ }
+
+ tracker = new DisplayPropertiesTracker();
+ debouncer = new DisplayResizeDebouncer(this::triggerResize);
+ debouncer.start();
+
+ // Hardcode default values if not defined
+ if (displaySize == null) {
+ displaySize = new Size(1280, 960);
+ }
+ if (dpi == 0) {
+ dpi = 160;
+ }
+ } else if (displaySize == null || dpi == 0) {
+ DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(0);
+ if (displayInfo != null) {
+ mainDisplaySize = displayInfo.getSize();
+ if ((displayInfo.getRotation() % 2) != 0) {
+ mainDisplaySize = mainDisplaySize.rotate(); // Use the natural device orientation (at rotation 0), not the current one
+ }
+ mainDisplayDpi = displayInfo.getDpi();
+ } else {
+ Ln.w("Main display not found, fallback to 1920x1080 240dpi");
+ mainDisplaySize = new Size(1920, 1080);
+ mainDisplayDpi = 240;
+ }
+ }
+ }
+
+ @Override
+ public void prepare() {
+ int displayRotation;
+ if (virtualDisplay == null) {
+ if (flexDisplay) {
+ assert displaySize != null;
+ displaySize = displaySize.constrain(videoConstraints, false);
+ } else {
+ if (displaySize == null) {
+ assert !flexDisplay;
+ displaySize = mainDisplaySize;
+ }
+
+ // Align the physical display size to avoid unnecessary mismatches with the output size
+ displaySize = displaySize.align(videoConstraints.getAlignment());
+ }
+
+ if (dpi == 0) {
+ assert !flexDisplay;
+ dpi = scaleDpi(mainDisplaySize, mainDisplayDpi, displaySize);
+ }
+
+ displayRotation = 0;
+ // Set the current display properties to avoid an unnecessary capture reset
+ displayMonitor.setSessionDisplayProperties(new DisplayProperties(displaySize, displayRotation));
+ } else {
+ DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(virtualDisplay.getDisplay().getDisplayId());
+ dpi = displayInfo.getDpi();
+ displayRotation = displayInfo.getRotation();
+ displaySize = displayInfo.getSize();
+ if (flexDisplay) {
+ displaySize = displaySize.constrain(videoConstraints, false);
+ } else {
+ // Align the physical display size to avoid unnecessary mismatches with the output size
+ displaySize = displaySize.align(videoConstraints.getAlignment());
+ }
+ }
+
+ VideoFilter filter = new VideoFilter(displaySize);
+
+ if (crop != null) {
+ boolean transposed = (displayRotation % 2) != 0;
+ filter.addCrop(crop, transposed);
+ }
+
+ filter.addOrientation(displayRotation, captureOrientationLocked, captureOrientation);
+ filter.addAngle(angle);
+
+ if (!flexDisplay) {
+ Size outputSize = filter.getOutputSize();
+ Size filteredSize = outputSize.constrain(videoConstraints);
+ if (!filteredSize.equals(outputSize)) {
+ filter.addResize(filteredSize);
+ }
+ }
+
+ eventTransform = filter.getInverseTransform();
+
+ // DisplayInfo gives the oriented size (so videoSize includes the display rotation)
+ videoSize = filter.getOutputSize();
+
+ // However, the virtual display video always remains in its original orientation, so it must be rotated manually.
+ // This additional display rotation must not be included in the input events transform (the expected coordinates are already in the
+ // physical display size)
+ if ((displayRotation % 2) == 0) {
+ physicalSize = displaySize;
+ } else {
+ physicalSize = displaySize.rotate();
+ }
+ VideoFilter displayFilter = new VideoFilter(physicalSize);
+ displayFilter.addRotation(displayRotation);
+ AffineMatrix displayRotationMatrix = displayFilter.getInverseTransform();
+
+ // Take care of multiplication order:
+ // displayTransform = (FILTER_MATRIX * DISPLAY_FILTER_MATRIX)⁻¹
+ // = DISPLAY_FILTER_MATRIX⁻¹ * FILTER_MATRIX⁻¹
+ // = displayRotationMatrix * eventTransform
+ displayTransform = AffineMatrix.multiplyAll(displayRotationMatrix, eventTransform);
+ }
+
+ public void startNew(Surface surface) {
+ try {
+ int flags = VIRTUAL_DISPLAY_FLAG_PUBLIC
+ | VIRTUAL_DISPLAY_FLAG_PRESENTATION
+ | VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY
+ | VIRTUAL_DISPLAY_FLAG_SUPPORTS_TOUCH
+ | VIRTUAL_DISPLAY_FLAG_ROTATES_WITH_CONTENT;
+ if (vdDestroyContent) {
+ flags |= VIRTUAL_DISPLAY_FLAG_DESTROY_CONTENT_ON_REMOVAL;
+ }
+ if (vdSystemDecorations) {
+ flags |= VIRTUAL_DISPLAY_FLAG_SHOULD_SHOW_SYSTEM_DECORATIONS;
+ }
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_33_ANDROID_13) {
+ flags |= VIRTUAL_DISPLAY_FLAG_TRUSTED
+ | VIRTUAL_DISPLAY_FLAG_OWN_DISPLAY_GROUP
+ | VIRTUAL_DISPLAY_FLAG_ALWAYS_UNLOCKED
+ | VIRTUAL_DISPLAY_FLAG_TOUCH_FEEDBACK_DISABLED;
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) {
+ flags |= VIRTUAL_DISPLAY_FLAG_OWN_FOCUS
+ | VIRTUAL_DISPLAY_FLAG_DEVICE_DISPLAY_GROUP;
+ }
+ }
+ VirtualDisplay vd = ServiceManager.getDisplayManager()
+ .createNewVirtualDisplay("scrcpy", displaySize.getWidth(), displaySize.getHeight(), dpi, surface, flags);
+ setCurrentVirtualDisplay(vd); // used for client resize
+ int virtualDisplayId = vd.getDisplay().getDisplayId();
+ Ln.i("New display: " + displaySize.getWidth() + "x" + displaySize.getHeight() + "/" + dpi + " (id=" + virtualDisplayId + ")");
+
+ if (displayImePolicy != -1) {
+ ServiceManager.getWindowManager().setDisplayImePolicy(virtualDisplayId, displayImePolicy);
+ }
+
+ displayMonitor.start(virtualDisplayId, (props) -> {
+ int reason;
+ if (flexDisplay) {
+ boolean isClientResize = tracker.onChanged(props);
+ if (isClientResize) {
+ reason = CaptureControl.RESET_REASON_CLIENT_RESIZED;
+ } else {
+ reason = CaptureControl.RESET_REASON_DISPLAY_PROPERTIES_CHANGED;
+ // Display properties have changed, cancel pending client resize requests
+ debouncer.cancelResize();
+ }
+ } else {
+ reason = CaptureControl.RESET_REASON_DISPLAY_PROPERTIES_CHANGED;
+ }
+ getCaptureControl().reset(reason);
+ });
+ } catch (Exception e) {
+ Ln.e("Could not create display", e);
+ throw new AssertionError("Could not create display");
+ }
+ }
+
+ @Override
+ public void start(Surface surface) throws IOException {
+ if (displayTransform != null) {
+ assert glRunner == null;
+ OpenGLFilter glFilter = new AffineOpenGLFilter(displayTransform);
+ glRunner = new OpenGLRunner(glFilter);
+ surface = glRunner.start(physicalSize, videoSize, surface);
+ }
+
+ if (virtualDisplay == null) {
+ startNew(surface);
+ } else {
+ virtualDisplay.setSurface(surface);
+ }
+
+ if (vdListener != null) {
+ PositionMapper positionMapper = PositionMapper.create(videoSize, eventTransform, displaySize);
+ vdListener.onNewVirtualDisplay(virtualDisplay.getDisplay().getDisplayId(), positionMapper);
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (glRunner != null) {
+ glRunner.stopAndRelease();
+ glRunner = null;
+ }
+ }
+
+ @Override
+ public void release() {
+ displayMonitor.stopAndRelease();
+
+ if (debouncer != null) {
+ debouncer.stop();
+ }
+
+ if (virtualDisplay != null) {
+ // synchronized with triggerResize()
+ synchronized (this) {
+ virtualDisplay.release();
+ setCurrentVirtualDisplay(null);
+ }
+ }
+ }
+
+ @Override
+ public Size getSize() {
+ return videoSize;
+ }
+
+ @Override
+ protected boolean applyNewVideoConstraints(VideoConstraints videoConstraints) {
+ setVideoConstraints(videoConstraints); // with synchronization
+ return true;
+ }
+
+ private static int scaleDpi(Size initialSize, int initialDpi, Size size) {
+ int den = initialSize.getMax();
+ int num = size.getMax();
+ return initialDpi * num / den;
+ }
+
+ public void requestResize(int width, int height) {
+ if (!flexDisplay) {
+ throw new IllegalStateException("Cannot resize a non-flex display");
+ }
+
+ VideoConstraints constraints = getVideoConstraints(); // synchronized
+ Size newSize = new Size(width, height).constrain(constraints, false);
+ if (Ln.isEnabled(Ln.Level.VERBOSE)) {
+ Ln.v(getClass().getSimpleName() + ": requestResize(" + width + ", " + height + ")");
+ Ln.v(getClass().getSimpleName() + ": constrained size = " + newSize);
+ }
+
+ debouncer.requestResize(newSize);
+ }
+
+ private synchronized void setCurrentVirtualDisplay(VirtualDisplay virtualDisplay) {
+ this.virtualDisplay = virtualDisplay;
+ }
+
+ private synchronized VideoConstraints getVideoConstraints() {
+ return videoConstraints;
+ }
+
+ private synchronized void setVideoConstraints(VideoConstraints videoConstraints) {
+ this.videoConstraints = videoConstraints;
+ }
+
+ private synchronized void triggerResize(Size size) {
+ if (virtualDisplay != null) {
+ size = size.constrain(videoConstraints, false); // in case the constraints have changed
+ int displayId = virtualDisplay.getDisplay().getDisplayId();
+ DisplayInfo displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId);
+ @SuppressWarnings("checkstyle:HiddenField") // hides this.dpi on purpose
+ int dpi = displayInfo.getDpi();
+ int displayRotation = displayInfo.getRotation();
+ if (captureOrientation.isSwap()) {
+ size = size.rotate();
+ }
+ tracker.pushClientRequest(new DisplayProperties(size, displayRotation));
+
+ // Although the display size (as detected by the DisplayMonitor) is rotated, the virtual display itself is not
+ Size vdSize = (displayRotation % 2) == 0 ? size : size.rotate();
+ virtualDisplay.resize(vdSize.getWidth(), vdSize.getHeight(), dpi);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/ScreenCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/ScreenCapture.java
new file mode 100644
index 0000000..0768181
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/ScreenCapture.java
@@ -0,0 +1,228 @@
+package com.genymobile.scrcpy.video;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.Options;
+import com.genymobile.scrcpy.control.PositionMapper;
+import com.genymobile.scrcpy.device.Device;
+import com.genymobile.scrcpy.display.DisplayInfo;
+import com.genymobile.scrcpy.display.DisplayMonitor;
+import com.genymobile.scrcpy.display.DisplayProperties;
+import com.genymobile.scrcpy.model.ConfigurationException;
+import com.genymobile.scrcpy.model.Orientation;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.opengl.AffineOpenGLFilter;
+import com.genymobile.scrcpy.opengl.OpenGLFilter;
+import com.genymobile.scrcpy.opengl.OpenGLRunner;
+import com.genymobile.scrcpy.util.AffineMatrix;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.util.LogUtils;
+import com.genymobile.scrcpy.wrappers.ServiceManager;
+import com.genymobile.scrcpy.wrappers.SurfaceControl;
+
+import android.graphics.Rect;
+import android.hardware.display.VirtualDisplay;
+import android.os.Build;
+import android.os.IBinder;
+import android.view.Surface;
+
+import java.io.IOException;
+import java.util.Locale;
+
+public class ScreenCapture extends SurfaceCapture {
+
+ private final VirtualDisplayListener vdListener;
+ private final int displayId;
+ private final Rect crop;
+ private Orientation.Lock captureOrientationLock;
+ private Orientation captureOrientation;
+ private final float angle;
+
+ private VideoConstraints videoConstraints;
+
+ private DisplayInfo displayInfo;
+ private Size videoSize;
+
+ private final DisplayMonitor displayMonitor = new DisplayMonitor();
+
+ private IBinder display;
+ private VirtualDisplay virtualDisplay;
+
+ private AffineMatrix transform;
+ private OpenGLRunner glRunner;
+
+ public ScreenCapture(VirtualDisplayListener vdListener, Options options) {
+ this.vdListener = vdListener;
+ this.displayId = options.getDisplayId();
+ assert displayId != Device.DISPLAY_ID_NONE;
+ this.crop = options.getCrop();
+ this.captureOrientationLock = options.getCaptureOrientationLock();
+ this.captureOrientation = options.getCaptureOrientation();
+ assert captureOrientationLock != null;
+ assert captureOrientation != null;
+ this.angle = options.getAngle();
+ }
+
+ @Override
+ public void init(VideoConstraints videoConstraints) {
+ this.videoConstraints = videoConstraints;
+ displayMonitor.start(displayId, (props) -> getCaptureControl().reset(CaptureControl.RESET_REASON_DISPLAY_PROPERTIES_CHANGED));
+ }
+
+ @Override
+ public void prepare() throws ConfigurationException {
+ displayInfo = ServiceManager.getDisplayManager().getDisplayInfo(displayId);
+ if (displayInfo == null) {
+ Ln.e("Display " + displayId + " not found\n" + LogUtils.buildDisplayListMessage());
+ throw new ConfigurationException("Unknown display id: " + displayId);
+ }
+
+ if ((displayInfo.getFlags() & DisplayInfo.FLAG_SUPPORTS_PROTECTED_BUFFERS) == 0) {
+ Ln.w("Display doesn't have FLAG_SUPPORTS_PROTECTED_BUFFERS flag, mirroring can be restricted");
+ }
+
+ Size displaySize = displayInfo.getSize();
+ int displayRotation = displayInfo.getRotation();
+ displayMonitor.setSessionDisplayProperties(new DisplayProperties(displaySize, displayRotation));
+
+ if (captureOrientationLock == Orientation.Lock.LockedInitial) {
+ // The user requested to lock the video orientation to the current orientation
+ captureOrientationLock = Orientation.Lock.LockedValue;
+ captureOrientation = Orientation.fromRotation(displayRotation);
+ }
+
+ VideoFilter filter = new VideoFilter(displaySize);
+
+ if (crop != null) {
+ boolean transposed = (displayRotation % 2) != 0;
+ filter.addCrop(crop, transposed);
+ }
+
+ boolean locked = captureOrientationLock != Orientation.Lock.Unlocked;
+ filter.addOrientation(displayRotation, locked, captureOrientation);
+ filter.addAngle(angle);
+
+ transform = filter.getInverseTransform();
+ videoSize = filter.getOutputSize().constrain(videoConstraints);
+ }
+
+ @Override
+ public void start(Surface surface) throws IOException {
+ if (display != null) {
+ SurfaceControl.destroyDisplay(display);
+ display = null;
+ }
+ if (virtualDisplay != null) {
+ virtualDisplay.release();
+ virtualDisplay = null;
+ }
+
+ Size inputSize;
+ if (transform != null) {
+ // If there is a filter, it must receive the full display content
+ inputSize = displayInfo.getSize();
+ assert glRunner == null;
+ OpenGLFilter glFilter = new AffineOpenGLFilter(transform);
+ glRunner = new OpenGLRunner(glFilter);
+ surface = glRunner.start(inputSize, videoSize, surface);
+ } else {
+ // If there is no filter, the display must be rendered at target video size directly
+ inputSize = videoSize;
+ }
+
+ try {
+ virtualDisplay = ServiceManager.getDisplayManager()
+ .createVirtualDisplay("scrcpy", inputSize.getWidth(), inputSize.getHeight(), displayId, surface);
+ Ln.d("Display: using DisplayManager API");
+ } catch (Exception displayManagerException) {
+ if (Build.BRAND.equalsIgnoreCase("oculus") && Build.MODEL.toLowerCase(Locale.ROOT).startsWith("quest")) {
+ // Workaround for buggy createVirtualDisplay on Quest
+ try {
+ virtualDisplay = (VirtualDisplay) VirtualDisplay.class.getDeclaredConstructors()[0].newInstance(null, null, null, surface);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not create VirtualDisplay", e);
+ }
+ } else {
+ try {
+ display = createDisplay();
+
+ Size deviceSize = displayInfo.getSize();
+ int layerStack = displayInfo.getLayerStack();
+ setDisplaySurface(display, surface, deviceSize.toRect(), inputSize.toRect(), layerStack);
+ Ln.d("Display: using SurfaceControl API");
+ } catch (Exception surfaceControlException) {
+ Ln.e("Could not create display using DisplayManager", displayManagerException);
+ Ln.e("Could not create display using SurfaceControl", surfaceControlException);
+ throw new AssertionError("Could not create display");
+ }
+ }
+ }
+
+ if (vdListener != null) {
+ int virtualDisplayId;
+ PositionMapper positionMapper;
+ if (virtualDisplay == null || displayId == 0) {
+ // Surface control or main display: send all events to the original display, relative to the device size
+ Size deviceSize = displayInfo.getSize();
+ positionMapper = PositionMapper.create(videoSize, transform, deviceSize);
+ virtualDisplayId = displayId;
+ } else {
+ // The positions are relative to the virtual display, not the original display (so use inputSize, not deviceSize!)
+ positionMapper = PositionMapper.create(videoSize, transform, inputSize);
+ virtualDisplayId = virtualDisplay.getDisplay().getDisplayId();
+ }
+ vdListener.onNewVirtualDisplay(virtualDisplayId, positionMapper);
+ }
+ }
+
+ @Override
+ public void stop() {
+ if (glRunner != null) {
+ glRunner.stopAndRelease();
+ glRunner = null;
+ }
+ }
+
+ @Override
+ public void release() {
+ displayMonitor.stopAndRelease();
+
+ if (display != null) {
+ SurfaceControl.destroyDisplay(display);
+ display = null;
+ }
+ if (virtualDisplay != null) {
+ virtualDisplay.release();
+ virtualDisplay = null;
+ }
+ }
+
+ @Override
+ public Size getSize() {
+ return videoSize;
+ }
+
+ @Override
+ protected boolean applyNewVideoConstraints(VideoConstraints videoConstraints) {
+ this.videoConstraints = videoConstraints;
+ return true;
+ }
+
+ private static IBinder createDisplay() throws Exception {
+ // Since Android 12 (preview), secure displays could not be created with shell permissions anymore.
+ // On Android 12 preview, SDK_INT is still R (not S), but CODENAME is "S".
+ boolean secure = Build.VERSION.SDK_INT < AndroidVersions.API_30_ANDROID_11 || (Build.VERSION.SDK_INT == AndroidVersions.API_30_ANDROID_11
+ && !"S".equals(Build.VERSION.CODENAME));
+ return SurfaceControl.createDisplay("scrcpy", secure);
+ }
+
+ private static void setDisplaySurface(IBinder display, Surface surface, Rect deviceRect, Rect displayRect, int layerStack) {
+ SurfaceControl.openTransaction();
+ try {
+ SurfaceControl.setDisplaySurface(display, surface);
+ SurfaceControl.setDisplayProjection(display, 0, deviceRect, displayRect);
+ SurfaceControl.setDisplayLayerStack(display, layerStack);
+ } finally {
+ SurfaceControl.closeTransaction();
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceCapture.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceCapture.java
new file mode 100644
index 0000000..4dd2879
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceCapture.java
@@ -0,0 +1,85 @@
+package com.genymobile.scrcpy.video;
+
+import com.genymobile.scrcpy.model.ConfigurationException;
+import com.genymobile.scrcpy.model.Size;
+
+import android.view.Surface;
+
+import java.io.IOException;
+
+/**
+ * A video source which can be rendered on a Surface for encoding.
+ */
+public abstract class SurfaceCapture {
+
+ private CaptureControl captureControl;
+
+ /**
+ * Called once before the first capture starts.
+ */
+ public final void init(CaptureControl captureControl, VideoConstraints videoConstraints) throws ConfigurationException, IOException {
+ this.captureControl = captureControl;
+ init(videoConstraints);
+ }
+
+ public CaptureControl getCaptureControl() {
+ return captureControl;
+ }
+
+ /**
+ * Called once before the first capture starts.
+ *
+ * @param videoConstraints the video constraints
+ */
+ protected abstract void init(VideoConstraints videoConstraints) throws ConfigurationException, IOException;
+
+ /**
+ * Called after the last capture ends (if and only if {@link #init(VideoConstraints)} has been called).
+ */
+ public abstract void release();
+
+ /**
+ * Called once before each capture starts, before {@link #getSize()}.
+ */
+ public void prepare() throws ConfigurationException, IOException {
+ // empty by default
+ }
+
+ /**
+ * Start the capture to the target surface.
+ *
+ * @param surface the surface which will be encoded
+ */
+ public abstract void start(Surface surface) throws IOException;
+
+ /**
+ * Stop the capture.
+ */
+ public void stop() {
+ // Do nothing by default
+ }
+
+ /**
+ * Return the video size
+ *
+ * @return the video size
+ */
+ public abstract Size getSize();
+
+ /**
+ * Indicate if the capture has been closed internally.
+ *
+ * @return {@code true} is the capture is closed, {@code false} otherwise.
+ */
+ public boolean isClosed() {
+ return false;
+ }
+
+ /**
+ * Set new video constraints (called by the encoder to retry when encoding fails)
+ *
+ * @param videoConstraints the video constraints
+ * @return {@code true} if the implementation accepts the new constraints, {@code false} otherwise.
+ */
+ protected abstract boolean applyNewVideoConstraints(VideoConstraints videoConstraints);
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceEncoder.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceEncoder.java
new file mode 100644
index 0000000..539681b
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/SurfaceEncoder.java
@@ -0,0 +1,388 @@
+package com.genymobile.scrcpy.video;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.AsyncProcessor;
+import com.genymobile.scrcpy.Options;
+import com.genymobile.scrcpy.device.Streamer;
+import com.genymobile.scrcpy.model.Codec;
+import com.genymobile.scrcpy.model.CodecOption;
+import com.genymobile.scrcpy.model.ConfigurationException;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.util.CodecUtils;
+import com.genymobile.scrcpy.util.IO;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.util.LogUtils;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.Looper;
+import android.os.SystemClock;
+import android.view.Surface;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+public class SurfaceEncoder implements AsyncProcessor {
+
+ private static final int DEFAULT_I_FRAME_INTERVAL = 10; // seconds
+ private static final int REPEAT_FRAME_DELAY_US = 100_000; // repeat after 100ms
+ private static final String KEY_MAX_FPS_TO_ENCODER = "max-fps-to-encoder";
+
+ // Keep the values in descending order
+ private static final int[] MAX_SIZE_FALLBACK = {2560, 1920, 1600, 1280, 1024, 800};
+ private static final int MAX_CONSECUTIVE_ERRORS = 3;
+
+ private final SurfaceCapture capture;
+ private final Streamer streamer;
+ private final String encoderName;
+ private final List codecOptions;
+ private final int videoBitRate;
+ private final int maxSize;
+ private final float maxFps;
+ private final boolean downsizeOnError;
+ private final int minSizeAlignment;
+ private final boolean ignoreVideoEncoderConstraints;
+
+ private boolean firstFrameSent;
+ private int consecutiveErrors;
+
+ private Thread thread;
+ private final AtomicBoolean stopped = new AtomicBoolean();
+
+ private final CaptureControl captureControl = new CaptureControl();
+
+ private VideoConstraints videoConstraints;
+
+ public SurfaceEncoder(SurfaceCapture capture, Streamer streamer, Options options) {
+ this.capture = capture;
+ this.streamer = streamer;
+ this.videoBitRate = options.getVideoBitRate();
+ this.maxSize = options.getMaxSize();
+ this.maxFps = options.getMaxFps();
+ this.codecOptions = options.getVideoCodecOptions();
+ this.encoderName = options.getVideoEncoder();
+ this.downsizeOnError = options.getDownsizeOnError();
+ this.minSizeAlignment = options.getMinSizeAlignment();
+ this.ignoreVideoEncoderConstraints = options.getIgnoreVideoEncoderConstraints();
+ }
+
+ private void streamCapture() throws IOException, ConfigurationException {
+ Codec codec = streamer.getCodec();
+ MediaCodec mediaCodec = createMediaCodec(codec, encoderName);
+ MediaFormat format = createFormat(codec.getMimeType(), videoBitRate, maxFps, codecOptions);
+
+ MediaCodecInfo.VideoCapabilities caps;
+ int alignment;
+ if (ignoreVideoEncoderConstraints) {
+ caps = null;
+ alignment = 1;
+ } else {
+ caps = mediaCodec.getCodecInfo().getCapabilitiesForType(codec.getMimeType()).getVideoCapabilities();
+ assert caps != null; // caps cannot be null for a video codec
+ alignment = Math.max(caps.getWidthAlignment(), caps.getHeightAlignment());
+ Ln.d("Video codec size alignment requirement: " + alignment + "px");
+ }
+ if (alignment < minSizeAlignment) {
+ alignment = minSizeAlignment;
+ Ln.d("Actual video size alignment: " + alignment + "px");
+ }
+
+ // Do not constrain by the declared video encoder capabilities before encoding actually fails
+ videoConstraints = new VideoConstraints(maxSize, alignment, null);
+
+ capture.init(captureControl, videoConstraints);
+
+ try {
+ boolean alive;
+
+ streamer.writeVideoHeader();
+
+ int retainedResetReasons = 0;
+
+ do {
+ int resetReasons = captureControl.consumeReset();
+ if ((resetReasons & CaptureControl.RESET_REASON_TERMINATED) != 0) {
+ break;
+ }
+ if (retainedResetReasons != 0) {
+ // The reasons for the previous failed encoding must be preserved when retrying
+ resetReasons |= retainedResetReasons;
+ retainedResetReasons = 0;
+ }
+
+ capture.prepare();
+ Size size = capture.getSize();
+
+ format.setInteger(MediaFormat.KEY_WIDTH, size.getWidth());
+ format.setInteger(MediaFormat.KEY_HEIGHT, size.getHeight());
+
+ Surface surface = null;
+ boolean mediaCodecStarted = false;
+ boolean captureStarted = false;
+ try {
+ mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
+ surface = mediaCodec.createInputSurface();
+
+ capture.start(surface);
+ captureStarted = true;
+
+ mediaCodec.start();
+ mediaCodecStarted = true;
+
+ // Set the MediaCodec instance to "interrupt" (by signaling an EOS) on reset
+ captureControl.setRunningMediaCodec(mediaCodec);
+
+ if (stopped.get()) {
+ alive = false;
+ } else {
+ if (!captureControl.isResetRequested()) {
+ // The reset is due to a resize initiated by the client
+ boolean isClientResize = (resetReasons & CaptureControl.RESET_REASON_CLIENT_RESIZED) != 0
+ && (resetReasons & CaptureControl.RESET_REASON_DISPLAY_PROPERTIES_CHANGED) == 0;
+ streamer.writeSessionMeta(size.getWidth(), size.getHeight(), isClientResize);
+
+ // If a reset is requested during encode(), it will interrupt the encoding by an EOS
+ encode(mediaCodec, streamer);
+ }
+
+ // The capture might have been closed internally (for example if the camera is disconnected)
+ alive = !stopped.get() && !capture.isClosed();
+ }
+ } catch (IllegalStateException | IllegalArgumentException | IOException e) {
+ if (IO.isBrokenPipe(e)) {
+ // Do not retry on broken pipe, which is expected on close because the socket is closed by the client
+ throw e;
+ }
+ Ln.e("Capture/encoding error: " + e.getClass().getName() + ": " + e.getMessage());
+ if (!prepareRetry(caps, size)) {
+ throw e;
+ }
+ // Keep the current resetReasons flags for the retry
+ retainedResetReasons = resetReasons;
+ alive = true;
+ } finally {
+ captureControl.setRunningMediaCodec(null);
+ if (captureStarted) {
+ capture.stop();
+ }
+ if (mediaCodecStarted) {
+ try {
+ mediaCodec.stop();
+ } catch (IllegalStateException e) {
+ // ignore (just in case)
+ }
+ }
+ mediaCodec.reset();
+ if (surface != null) {
+ surface.release();
+ }
+ }
+ } while (alive);
+ } finally {
+ mediaCodec.release();
+ capture.release();
+ }
+ }
+
+ private boolean prepareRetry(MediaCodecInfo.VideoCapabilities caps, Size currentSize) {
+ if (firstFrameSent) {
+ ++consecutiveErrors;
+ if (consecutiveErrors < MAX_CONSECUTIVE_ERRORS) {
+ // Wait a bit to increase the probability that retrying will fix the problem
+ SystemClock.sleep(50);
+ return true;
+ }
+ }
+
+ if (!downsizeOnError) {
+ // Must fail immediately
+ return false;
+ }
+
+ if (caps != null && videoConstraints.getEncoderCapabilities() == null) {
+ assert !ignoreVideoEncoderConstraints : "caps != null implies !ignoreVideoEncoderConstraints";
+ Ln.i("Applying video encoder constraints");
+ videoConstraints = videoConstraints.withCapabilities(caps);
+ boolean accepted = capture.applyNewVideoConstraints(videoConstraints);
+ if (accepted) {
+ return true;
+ }
+ }
+
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
+ // Definitively fail
+ return false;
+ }
+
+ // Downsizing on error is only enabled if an encoding failure occurs before the first frame, or if the video constraints were not applied
+ // (downsizing later could be surprising)
+
+ int newMaxSize = chooseMaxSizeFallback(currentSize);
+ if (newMaxSize == 0) {
+ // Must definitively fail
+ return false;
+ }
+
+ boolean accepted = capture.applyNewVideoConstraints(videoConstraints.withMaxSize(newMaxSize));
+ if (!accepted) {
+ return false;
+ }
+
+ // Retry with a smaller size
+ Ln.i("Retrying with -m" + newMaxSize + "...");
+ return true;
+ }
+
+ private static int chooseMaxSizeFallback(Size failedSize) {
+ int currentMaxSize = Math.max(failedSize.getWidth(), failedSize.getHeight());
+ for (int value : MAX_SIZE_FALLBACK) {
+ if (value < currentMaxSize) {
+ // We found a smaller value to reduce the video size
+ return value;
+ }
+ }
+ // No fallback, fail definitively
+ return 0;
+ }
+
+ private void encode(MediaCodec codec, Streamer streamer) throws IOException {
+ MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
+
+ boolean eos;
+ do {
+ int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);
+ try {
+ eos = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+ // On EOS, there might be data or not, depending on bufferInfo.size
+ if (outputBufferId >= 0 && bufferInfo.size > 0) {
+ boolean isConfig = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0;
+ if (!isConfig) {
+ // If this is not a config packet, then it contains a frame
+ firstFrameSent = true;
+ consecutiveErrors = 0;
+ }
+
+ ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);
+ streamer.writePacket(codecBuffer, bufferInfo);
+ }
+ } finally {
+ if (outputBufferId >= 0) {
+ codec.releaseOutputBuffer(outputBufferId, false);
+ }
+ }
+ } while (!eos);
+ }
+
+ private static MediaCodec createMediaCodec(Codec codec, String encoderName) throws IOException, ConfigurationException {
+ if (encoderName != null) {
+ Ln.d("Creating encoder by name: '" + encoderName + "'");
+ try {
+ MediaCodec mediaCodec = MediaCodec.createByCodecName(encoderName);
+ String mimeType = Codec.getMimeType(mediaCodec);
+ if (!codec.getMimeType().equals(mimeType)) {
+ Ln.e("Video encoder type for \"" + encoderName + "\" (" + mimeType + ") does not match codec type (" + codec.getMimeType() + ")");
+ throw new ConfigurationException("Incorrect encoder type: " + encoderName);
+ }
+ return mediaCodec;
+ } catch (IllegalArgumentException e) {
+ Ln.e("Video encoder '" + encoderName + "' for " + codec.getName() + " not found\n" + LogUtils.buildVideoEncoderListMessage());
+ throw new ConfigurationException("Unknown encoder: " + encoderName);
+ } catch (IOException e) {
+ Ln.e("Could not create video encoder '" + encoderName + "' for " + codec.getName() + "\n" + LogUtils.buildVideoEncoderListMessage());
+ throw e;
+ }
+ }
+
+ try {
+ MediaCodec mediaCodec = MediaCodec.createEncoderByType(codec.getMimeType());
+ Ln.d("Using video encoder: '" + mediaCodec.getName() + "'");
+ return mediaCodec;
+ } catch (IOException | IllegalArgumentException e) {
+ Ln.e("Could not create default video encoder for " + codec.getName() + "\n" + LogUtils.buildVideoEncoderListMessage());
+ throw e;
+ }
+ }
+
+ private static MediaFormat createFormat(String videoMimeType, int bitRate, float maxFps, List codecOptions) {
+ MediaFormat format = new MediaFormat();
+ format.setString(MediaFormat.KEY_MIME, videoMimeType);
+ format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate);
+ // must be present to configure the encoder, but does not impact the actual frame rate, which is variable
+ format.setInteger(MediaFormat.KEY_FRAME_RATE, 60);
+ format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_24_ANDROID_7_0) {
+ format.setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED);
+ }
+ format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, DEFAULT_I_FRAME_INTERVAL);
+ // display the very first frame, and recover from bad quality when no new frames
+ format.setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_FRAME_DELAY_US); // µs
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_23_ANDROID_6_0) {
+ // real-time priority
+ format.setInteger(MediaFormat.KEY_PRIORITY, 0);
+ }
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_26_ANDROID_8_0) {
+ // output 1 frame as soon as 1 frame is queued
+ format.setInteger(MediaFormat.KEY_LATENCY, 1);
+ }
+ if (maxFps > 0) {
+ // The key existed privately before Android 10:
+ //
+ //
+ format.setFloat(KEY_MAX_FPS_TO_ENCODER, maxFps);
+ }
+
+ if (codecOptions != null) {
+ for (CodecOption option : codecOptions) {
+ String key = option.getKey();
+ Object value = option.getValue();
+ CodecUtils.setCodecOption(format, key, value);
+ Ln.d("Video codec option set: " + key + " (" + value.getClass().getSimpleName() + ") = " + value);
+ }
+ }
+
+ return format;
+ }
+
+ @Override
+ public void start(TerminationListener listener) {
+ thread = new Thread(() -> {
+ // Some devices (Meizu) deadlock if the video encoding thread has no Looper
+ //
+ Looper.prepare();
+
+ try {
+ streamCapture();
+ } catch (ConfigurationException e) {
+ // Do not print stack trace, a user-friendly error-message has already been logged
+ } catch (IOException e) {
+ // Broken pipe is expected on close, because the socket is closed by the client
+ if (!IO.isBrokenPipe(e)) {
+ Ln.e("Video encoding error", e);
+ }
+ } finally {
+ Ln.d("Screen streaming stopped");
+ listener.onTerminated(true);
+ }
+ }, "video");
+ thread.start();
+ }
+
+ @Override
+ public void stop() {
+ if (thread != null) {
+ stopped.set(true);
+ captureControl.reset(CaptureControl.RESET_REASON_TERMINATED);
+ }
+ }
+
+ @Override
+ public void join() throws InterruptedException {
+ if (thread != null) {
+ thread.join();
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoCodec.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoCodec.java
new file mode 100644
index 0000000..8b9de0f
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoCodec.java
@@ -0,0 +1,54 @@
+package com.genymobile.scrcpy.video;
+
+import com.genymobile.scrcpy.model.Codec;
+
+import android.annotation.SuppressLint;
+import android.media.MediaFormat;
+
+public enum VideoCodec implements Codec {
+ H264(0x68_32_36_34, "h264", MediaFormat.MIMETYPE_VIDEO_AVC),
+ H265(0x68_32_36_35, "h265", MediaFormat.MIMETYPE_VIDEO_HEVC),
+ @SuppressLint("InlinedApi") // introduced in API 29
+ AV1(0x00_61_76_31, "av1", MediaFormat.MIMETYPE_VIDEO_AV1),
+ VP8(0x00_76_70_38, "vp8", MediaFormat.MIMETYPE_VIDEO_VP8),
+ VP9(0x00_76_70_39, "vp9", MediaFormat.MIMETYPE_VIDEO_VP9);
+
+ private final int id; // 4-byte ASCII representation of the name
+ private final String name;
+ private final String mimeType;
+
+ VideoCodec(int id, String name, String mimeType) {
+ this.id = id;
+ this.name = name;
+ this.mimeType = mimeType;
+ }
+
+ @Override
+ public Type getType() {
+ return Type.VIDEO;
+ }
+
+ @Override
+ public int getId() {
+ return id;
+ }
+
+ @Override
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ public static VideoCodec findByName(String name) {
+ for (VideoCodec codec : values()) {
+ if (codec.name.equals(name)) {
+ return codec;
+ }
+ }
+ return null;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoConstraints.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoConstraints.java
new file mode 100644
index 0000000..fe08631
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoConstraints.java
@@ -0,0 +1,69 @@
+package com.genymobile.scrcpy.video;
+
+import android.media.MediaCodecInfo;
+
+public class VideoConstraints {
+ private final int maxSize;
+ private final int alignment;
+ private final MediaCodecInfo.VideoCapabilities caps;
+
+ public VideoConstraints(int maxSize, int alignment, MediaCodecInfo.VideoCapabilities caps) {
+ assert maxSize >= 0 : "Max size must not be negative";
+ this.maxSize = maxSize;
+
+ assert alignment > 0 : "Alignment must be positive";
+ assert (alignment & (alignment - 1)) == 0 : "Alignment must be a power-of-two";
+ this.alignment = alignment;
+
+ this.caps = caps;
+ }
+
+ /**
+ * Return the max size (0 if not requested)
+ *
+ * @return the max requested size
+ */
+ public int getMaxSize() {
+ return maxSize;
+ }
+
+ /**
+ * Return the video alignment.
+ *
+ * This a power-of-2 value that the video width and height must be multiples of.
+ *
+ * @return the video alignment
+ */
+ public int getAlignment() {
+ return alignment;
+ }
+
+ /**
+ * Return the video encoder capabilities.
+ *
+ * @return the video encoder capabilities
+ */
+ public MediaCodecInfo.VideoCapabilities getEncoderCapabilities() {
+ return caps;
+ }
+
+ /**
+ * Return the video constraints with the provided max size.
+ *
+ * @param maxSize the max requested size
+ * @return the new video constraints
+ */
+ public VideoConstraints withMaxSize(int maxSize) {
+ return new VideoConstraints(maxSize, alignment, caps);
+ }
+
+ /**
+ * Return the video constraints with the provided video capabilities.
+ *
+ * @param caps the video encoder capabilities
+ * @return the new video constraints
+ */
+ public VideoConstraints withCapabilities(MediaCodecInfo.VideoCapabilities caps) {
+ return new VideoConstraints(maxSize, alignment, caps);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoFilter.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoFilter.java
new file mode 100644
index 0000000..9a0dec6
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoFilter.java
@@ -0,0 +1,119 @@
+package com.genymobile.scrcpy.video;
+
+import com.genymobile.scrcpy.model.Orientation;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.util.AffineMatrix;
+
+import android.graphics.Rect;
+
+public class VideoFilter {
+
+ private Size size;
+ private AffineMatrix transform;
+
+ public VideoFilter(Size inputSize) {
+ this.size = inputSize;
+ }
+
+ public Size getOutputSize() {
+ return size;
+ }
+
+ public AffineMatrix getTransform() {
+ return transform;
+ }
+
+ /**
+ * Return the inverse transform.
+ *
+ * The direct affine transform describes how the input image is transformed.
+ *
+ * It is often useful to retrieve the inverse transform instead:
+ *
+ * - The OpenGL filter expects the matrix to transform the image coordinates, which is the inverse transform;
+ * - The click positions must be transformed back to the device positions, using the inverse transform too.
+ *
+ *
+ * @return the inverse transform
+ */
+ public AffineMatrix getInverseTransform() {
+ if (transform == null) {
+ return null;
+ }
+ return transform.invert();
+ }
+
+ private static Rect transposeRect(Rect rect) {
+ return new Rect(rect.top, rect.left, rect.bottom, rect.right);
+ }
+
+ public void addCrop(Rect crop, boolean transposed) {
+ if (transposed) {
+ crop = transposeRect(crop);
+ }
+
+ double inputWidth = size.getWidth();
+ double inputHeight = size.getHeight();
+
+ if (crop.left < 0 || crop.top < 0 || crop.right > inputWidth || crop.bottom > inputHeight) {
+ throw new IllegalArgumentException("Crop " + crop + " exceeds the input area (" + size + ")");
+ }
+
+ double x = crop.left / inputWidth;
+ double y = 1 - (crop.bottom / inputHeight); // OpenGL origin is bottom-left
+ double w = crop.width() / inputWidth;
+ double h = crop.height() / inputHeight;
+
+ transform = AffineMatrix.reframe(x, y, w, h).multiply(transform);
+ size = new Size(crop.width(), crop.height());
+ }
+
+ public void addRotation(int ccwRotation) {
+ if (ccwRotation == 0) {
+ return;
+ }
+
+ transform = AffineMatrix.rotateOrtho(ccwRotation).multiply(transform);
+ if (ccwRotation % 2 != 0) {
+ size = size.rotate();
+ }
+ }
+
+ public void addOrientation(Orientation captureOrientation) {
+ if (captureOrientation.isFlipped()) {
+ transform = AffineMatrix.hflip().multiply(transform);
+ }
+ int ccwRotation = (4 - captureOrientation.getRotation()) % 4;
+ addRotation(ccwRotation);
+ }
+
+ public void addOrientation(int displayRotation, boolean locked, Orientation captureOrientation) {
+ if (locked) {
+ // flip/rotate the current display from the natural device orientation (i.e. where display rotation is 0)
+ int reverseDisplayRotation = (4 - displayRotation) % 4;
+ addRotation(reverseDisplayRotation);
+ }
+ addOrientation(captureOrientation);
+ }
+
+ public void addAngle(double cwAngle) {
+ if (cwAngle == 0) {
+ return;
+ }
+ double ccwAngle = -cwAngle;
+ transform = AffineMatrix.rotate(ccwAngle).withAspectRatio(size).fromCenter().multiply(transform);
+ }
+
+ public void addResize(Size targetSize) {
+ if (size.equals(targetSize)) {
+ return;
+ }
+
+ if (transform == null) {
+ // The requested scaling is performed by the viewport (by changing the output size), but the OpenGL filter must still run, even if
+ // resizing is not performed by the shader. So transform MUST NOT be null.
+ transform = AffineMatrix.IDENTITY;
+ }
+ size = targetSize;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoSource.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoSource.java
new file mode 100644
index 0000000..53b54a5
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VideoSource.java
@@ -0,0 +1,22 @@
+package com.genymobile.scrcpy.video;
+
+public enum VideoSource {
+ DISPLAY("display"),
+ CAMERA("camera");
+
+ private final String name;
+
+ VideoSource(String name) {
+ this.name = name;
+ }
+
+ public static VideoSource findByName(String name) {
+ for (VideoSource videoSource : VideoSource.values()) {
+ if (name.equals(videoSource.name)) {
+ return videoSource;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VirtualDisplayListener.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VirtualDisplayListener.java
new file mode 100644
index 0000000..c079265
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/video/VirtualDisplayListener.java
@@ -0,0 +1,7 @@
+package com.genymobile.scrcpy.video;
+
+import com.genymobile.scrcpy.control.PositionMapper;
+
+public interface VirtualDisplayListener {
+ void onNewVirtualDisplay(int displayId, PositionMapper positionMapper);
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ActivityManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ActivityManager.java
new file mode 100644
index 0000000..0150888
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ActivityManager.java
@@ -0,0 +1,192 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.FakeContext;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.IContentProvider;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.IInterface;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+@SuppressLint("PrivateApi,DiscouragedPrivateApi")
+public final class ActivityManager {
+
+ private final IInterface manager;
+ private Method getContentProviderExternalMethod;
+ private boolean getContentProviderExternalMethodNewVersion = true;
+ private Method removeContentProviderExternalMethod;
+ private Method startActivityAsUserMethod;
+ private Method forceStopPackageMethod;
+ private Method broadcastIntentMethod;
+
+ static ActivityManager create() {
+ try {
+ // On old Android versions, the ActivityManager is not exposed via AIDL,
+ // so use ActivityManagerNative.getDefault()
+ Class> cls = Class.forName("android.app.ActivityManagerNative");
+ Method getDefaultMethod = cls.getDeclaredMethod("getDefault");
+ IInterface am = (IInterface) getDefaultMethod.invoke(null);
+ return new ActivityManager(am);
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private ActivityManager(IInterface manager) {
+ this.manager = manager;
+ }
+
+ private Method getGetContentProviderExternalMethod() throws NoSuchMethodException {
+ if (getContentProviderExternalMethod == null) {
+ try {
+ getContentProviderExternalMethod = manager.getClass()
+ .getMethod("getContentProviderExternal", String.class, int.class, IBinder.class, String.class);
+ } catch (NoSuchMethodException e) {
+ // old version
+ getContentProviderExternalMethod = manager.getClass().getMethod("getContentProviderExternal", String.class, int.class, IBinder.class);
+ getContentProviderExternalMethodNewVersion = false;
+ }
+ }
+ return getContentProviderExternalMethod;
+ }
+
+ private Method getRemoveContentProviderExternalMethod() throws NoSuchMethodException {
+ if (removeContentProviderExternalMethod == null) {
+ removeContentProviderExternalMethod = manager.getClass().getMethod("removeContentProviderExternal", String.class, IBinder.class);
+ }
+ return removeContentProviderExternalMethod;
+ }
+
+ @TargetApi(AndroidVersions.API_29_ANDROID_10)
+ public IContentProvider getContentProviderExternal(String name, IBinder token) {
+ try {
+ Method method = getGetContentProviderExternalMethod();
+ Object[] args;
+ if (getContentProviderExternalMethodNewVersion) {
+ // new version
+ args = new Object[]{name, FakeContext.ROOT_UID, token, null};
+ } else {
+ // old version
+ args = new Object[]{name, FakeContext.ROOT_UID, token};
+ }
+ // ContentProviderHolder providerHolder = getContentProviderExternal(...);
+ Object providerHolder = method.invoke(manager, args);
+ if (providerHolder == null) {
+ return null;
+ }
+ // IContentProvider provider = providerHolder.provider;
+ Field providerField = providerHolder.getClass().getDeclaredField("provider");
+ providerField.setAccessible(true);
+ return (IContentProvider) providerField.get(providerHolder);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return null;
+ }
+ }
+
+ void removeContentProviderExternal(String name, IBinder token) {
+ try {
+ Method method = getRemoveContentProviderExternalMethod();
+ method.invoke(manager, name, token);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+
+ public ContentProvider createSettingsProvider() {
+ IBinder token = new Binder();
+ IContentProvider provider = getContentProviderExternal("settings", token);
+ if (provider == null) {
+ return null;
+ }
+ return new ContentProvider(this, provider, "settings", token);
+ }
+
+ private Method getStartActivityAsUserMethod() throws NoSuchMethodException, ClassNotFoundException {
+ if (startActivityAsUserMethod == null) {
+ Class> iApplicationThreadClass = Class.forName("android.app.IApplicationThread");
+ Class> profilerInfo = Class.forName("android.app.ProfilerInfo");
+ startActivityAsUserMethod = manager.getClass()
+ .getMethod("startActivityAsUser", iApplicationThreadClass, String.class, Intent.class, String.class, IBinder.class, String.class,
+ int.class, int.class, profilerInfo, Bundle.class, int.class);
+ }
+ return startActivityAsUserMethod;
+ }
+
+ public int startActivity(Intent intent) {
+ return startActivity(intent, null);
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ public int startActivity(Intent intent, Bundle options) {
+ try {
+ Method method = getStartActivityAsUserMethod();
+ return (int) method.invoke(
+ /* this */ manager,
+ /* caller */ null,
+ /* callingPackage */ FakeContext.PACKAGE_NAME,
+ /* intent */ intent,
+ /* resolvedType */ null,
+ /* resultTo */ null,
+ /* resultWho */ null,
+ /* requestCode */ 0,
+ /* startFlags */ 0,
+ /* profilerInfo */ null,
+ /* bOptions */ options,
+ /* userId */ /* UserHandle.USER_CURRENT */ -2);
+ } catch (Throwable e) {
+ Ln.e("Could not invoke method", e);
+ return 0;
+ }
+ }
+
+ private Method getForceStopPackageMethod() throws NoSuchMethodException {
+ if (forceStopPackageMethod == null) {
+ forceStopPackageMethod = manager.getClass().getMethod("forceStopPackage", String.class, int.class);
+ }
+ return forceStopPackageMethod;
+ }
+
+ public void forceStopPackage(String packageName) {
+ try {
+ Method method = getForceStopPackageMethod();
+ method.invoke(manager, packageName, /* userId */ /* UserHandle.USER_CURRENT */ -2);
+ } catch (Throwable e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+
+ private Method getBroadcastIntentMethod() throws NoSuchMethodException {
+ if (broadcastIntentMethod == null) {
+ try {
+ Class> iApplicationThreadClass = Class.forName("android.app.IApplicationThread");
+ Class> iIntentReceiverClass = Class.forName("android.content.IIntentReceiver");
+ broadcastIntentMethod = manager.getClass()
+ .getMethod("broadcastIntent", iApplicationThreadClass, Intent.class, String.class, iIntentReceiverClass, int.class,
+ String.class, Bundle.class, String[].class, int.class, Bundle.class, boolean.class, boolean.class, int.class);
+ } catch (ClassNotFoundException e) {
+ throw new AssertionError(e);
+ }
+ }
+ return broadcastIntentMethod;
+ }
+
+ public void sendBroadcast(Intent intent) {
+ try {
+ // Equivalent to:
+ // adb shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file://
+ Method method = getBroadcastIntentMethod();
+ method.invoke(manager, null, intent, null, null, 0, null, null, null, -1, null, true, false, /* userId */ -2);
+ } catch (Throwable e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ClipboardManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ClipboardManager.java
new file mode 100644
index 0000000..5493612
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ClipboardManager.java
@@ -0,0 +1,43 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.FakeContext;
+
+import android.content.ClipData;
+import android.content.Context;
+
+public final class ClipboardManager {
+ private final android.content.ClipboardManager manager;
+
+ static ClipboardManager create() {
+ android.content.ClipboardManager manager = (android.content.ClipboardManager) FakeContext.get().getSystemService(Context.CLIPBOARD_SERVICE);
+ if (manager == null) {
+ // Some devices have no clipboard manager
+ //
+ //
+ return null;
+ }
+ return new ClipboardManager(manager);
+ }
+
+ private ClipboardManager(android.content.ClipboardManager manager) {
+ this.manager = manager;
+ }
+
+ public CharSequence getText() {
+ ClipData clipData = manager.getPrimaryClip();
+ if (clipData == null || clipData.getItemCount() == 0) {
+ return null;
+ }
+ return clipData.getItemAt(0).getText();
+ }
+
+ public boolean setText(CharSequence text) {
+ ClipData clipData = ClipData.newPlainText(null, text);
+ manager.setPrimaryClip(clipData);
+ return true;
+ }
+
+ public void addPrimaryClipChangedListener(android.content.ClipboardManager.OnPrimaryClipChangedListener listener) {
+ manager.addPrimaryClipChangedListener(listener);
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ContentProvider.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ContentProvider.java
new file mode 100644
index 0000000..f625b39
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ContentProvider.java
@@ -0,0 +1,162 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.FakeContext;
+import com.genymobile.scrcpy.util.Ln;
+import com.genymobile.scrcpy.util.SettingsException;
+
+import android.annotation.SuppressLint;
+import android.content.AttributionSource;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.IBinder;
+
+import java.io.Closeable;
+import java.lang.reflect.Method;
+
+public final class ContentProvider implements Closeable {
+
+ public static final String TABLE_SYSTEM = "system";
+ public static final String TABLE_SECURE = "secure";
+ public static final String TABLE_GLOBAL = "global";
+
+ // See android/providerHolder/Settings.java
+ private static final String CALL_METHOD_GET_SYSTEM = "GET_system";
+ private static final String CALL_METHOD_GET_SECURE = "GET_secure";
+ private static final String CALL_METHOD_GET_GLOBAL = "GET_global";
+
+ private static final String CALL_METHOD_PUT_SYSTEM = "PUT_system";
+ private static final String CALL_METHOD_PUT_SECURE = "PUT_secure";
+ private static final String CALL_METHOD_PUT_GLOBAL = "PUT_global";
+
+ private static final String CALL_METHOD_USER_KEY = "_user";
+
+ private static final String NAME_VALUE_TABLE_VALUE = "value";
+
+ private final ActivityManager manager;
+ // android.content.IContentProvider
+ private final Object provider;
+ private final String name;
+ private final IBinder token;
+
+ private Method callMethod;
+ private int callMethodVersion;
+
+ ContentProvider(ActivityManager manager, Object provider, String name, IBinder token) {
+ this.manager = manager;
+ this.provider = provider;
+ this.name = name;
+ this.token = token;
+ }
+
+ @SuppressLint("PrivateApi")
+ private Method getCallMethod() throws NoSuchMethodException {
+ if (callMethod == null) {
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) {
+ callMethod = provider.getClass().getMethod("call", AttributionSource.class, String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 0;
+ } else {
+ // old versions
+ try {
+ callMethod = provider.getClass()
+ .getMethod("call", String.class, String.class, String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 1;
+ } catch (NoSuchMethodException e1) {
+ try {
+ callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 2;
+ } catch (NoSuchMethodException e2) {
+ callMethod = provider.getClass().getMethod("call", String.class, String.class, String.class, Bundle.class);
+ callMethodVersion = 3;
+ }
+ }
+ }
+ }
+ return callMethod;
+ }
+
+ private Bundle call(String callMethod, String arg, Bundle extras) throws ReflectiveOperationException {
+ try {
+ Method method = getCallMethod();
+ Object[] args;
+
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12 && callMethodVersion == 0) {
+ args = new Object[]{FakeContext.get().getAttributionSource(), "settings", callMethod, arg, extras};
+ } else {
+ switch (callMethodVersion) {
+ case 1:
+ args = new Object[]{FakeContext.PACKAGE_NAME, null, "settings", callMethod, arg, extras};
+ break;
+ case 2:
+ args = new Object[]{FakeContext.PACKAGE_NAME, "settings", callMethod, arg, extras};
+ break;
+ default:
+ args = new Object[]{FakeContext.PACKAGE_NAME, callMethod, arg, extras};
+ break;
+ }
+ }
+ return (Bundle) method.invoke(provider, args);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ throw e;
+ }
+ }
+
+ public void close() {
+ manager.removeContentProviderExternal(name, token);
+ }
+
+ private static String getGetMethod(String table) {
+ switch (table) {
+ case TABLE_SECURE:
+ return CALL_METHOD_GET_SECURE;
+ case TABLE_SYSTEM:
+ return CALL_METHOD_GET_SYSTEM;
+ case TABLE_GLOBAL:
+ return CALL_METHOD_GET_GLOBAL;
+ default:
+ throw new IllegalArgumentException("Invalid table: " + table);
+ }
+ }
+
+ private static String getPutMethod(String table) {
+ switch (table) {
+ case TABLE_SECURE:
+ return CALL_METHOD_PUT_SECURE;
+ case TABLE_SYSTEM:
+ return CALL_METHOD_PUT_SYSTEM;
+ case TABLE_GLOBAL:
+ return CALL_METHOD_PUT_GLOBAL;
+ default:
+ throw new IllegalArgumentException("Invalid table: " + table);
+ }
+ }
+
+ public String getValue(String table, String key) throws SettingsException {
+ String method = getGetMethod(table);
+ Bundle arg = new Bundle();
+ arg.putInt(CALL_METHOD_USER_KEY, FakeContext.ROOT_UID);
+ try {
+ Bundle bundle = call(method, key, arg);
+ if (bundle == null) {
+ return null;
+ }
+ return bundle.getString("value");
+ } catch (Exception e) {
+ throw new SettingsException(table, "get", key, null, e);
+ }
+
+ }
+
+ public void putValue(String table, String key, String value) throws SettingsException {
+ String method = getPutMethod(table);
+ Bundle arg = new Bundle();
+ arg.putInt(CALL_METHOD_USER_KEY, FakeContext.ROOT_UID);
+ arg.putString(NAME_VALUE_TABLE_VALUE, value);
+ try {
+ call(method, key, arg);
+ } catch (Exception e) {
+ throw new SettingsException(table, "put", key, value, e);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayControl.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayControl.java
new file mode 100644
index 0000000..88ca3d3
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayControl.java
@@ -0,0 +1,82 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.os.IBinder;
+import android.system.Os;
+
+import java.lang.reflect.Method;
+
+@SuppressLint({"PrivateApi", "SoonBlockedPrivateApi", "BlockedPrivateApi"})
+@TargetApi(AndroidVersions.API_34_ANDROID_14)
+public final class DisplayControl {
+
+ private static final Class> CLASS;
+
+ static {
+ Class> displayControlClass = null;
+ try {
+ Class> classLoaderFactoryClass = Class.forName("com.android.internal.os.ClassLoaderFactory");
+ Method createClassLoaderMethod = classLoaderFactoryClass.getDeclaredMethod("createClassLoader", String.class, String.class, String.class,
+ ClassLoader.class, int.class, boolean.class, String.class);
+
+ String systemServerClasspath = Os.getenv("SYSTEMSERVERCLASSPATH");
+ ClassLoader classLoader = (ClassLoader) createClassLoaderMethod.invoke(null, systemServerClasspath, null, null,
+ ClassLoader.getSystemClassLoader(), 0, true, null);
+
+ displayControlClass = classLoader.loadClass("com.android.server.display.DisplayControl");
+
+ Method loadMethod = Runtime.class.getDeclaredMethod("loadLibrary0", Class.class, String.class);
+ loadMethod.setAccessible(true);
+ loadMethod.invoke(Runtime.getRuntime(), displayControlClass, "android_servers");
+ } catch (Throwable e) {
+ Ln.e("Could not initialize DisplayControl", e);
+ // Do not throw an exception here, the methods will fail when they are called
+ }
+ CLASS = displayControlClass;
+ }
+
+ private static Method getPhysicalDisplayTokenMethod;
+ private static Method getPhysicalDisplayIdsMethod;
+
+ private DisplayControl() {
+ // only static methods
+ }
+
+ private static Method getGetPhysicalDisplayTokenMethod() throws NoSuchMethodException {
+ if (getPhysicalDisplayTokenMethod == null) {
+ getPhysicalDisplayTokenMethod = CLASS.getMethod("getPhysicalDisplayToken", long.class);
+ }
+ return getPhysicalDisplayTokenMethod;
+ }
+
+ public static IBinder getPhysicalDisplayToken(long physicalDisplayId) {
+ try {
+ Method method = getGetPhysicalDisplayTokenMethod();
+ return (IBinder) method.invoke(null, physicalDisplayId);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return null;
+ }
+ }
+
+ private static Method getGetPhysicalDisplayIdsMethod() throws NoSuchMethodException {
+ if (getPhysicalDisplayIdsMethod == null) {
+ getPhysicalDisplayIdsMethod = CLASS.getMethod("getPhysicalDisplayIds");
+ }
+ return getPhysicalDisplayIdsMethod;
+ }
+
+ public static long[] getPhysicalDisplayIds() {
+ try {
+ Method method = getGetPhysicalDisplayIdsMethod();
+ return (long[]) method.invoke(null);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return null;
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayManager.java
new file mode 100644
index 0000000..1be04c3
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayManager.java
@@ -0,0 +1,250 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.FakeContext;
+import com.genymobile.scrcpy.display.DisplayInfo;
+import com.genymobile.scrcpy.model.Size;
+import com.genymobile.scrcpy.util.Command;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.hardware.display.VirtualDisplay;
+import android.os.Handler;
+import android.view.Display;
+import android.view.Surface;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@SuppressLint("PrivateApi,DiscouragedPrivateApi")
+public final class DisplayManager {
+
+ // android.hardware.display.DisplayManager.EVENT_FLAG_DISPLAY_CHANGED
+ public static final long EVENT_FLAG_DISPLAY_CHANGED = 1L << 2;
+
+ public interface DisplayListener {
+ /**
+ * Called whenever the properties of a logical {@link android.view.Display},
+ * such as size and density, have changed.
+ *
+ * @param displayId The id of the logical display that changed.
+ */
+ void onDisplayChanged(int displayId);
+ }
+
+ public static final class DisplayListenerHandle {
+ private final Object displayListenerProxy;
+ private DisplayListenerHandle(Object displayListenerProxy) {
+ this.displayListenerProxy = displayListenerProxy;
+ }
+ }
+
+ private final Object manager; // instance of hidden class android.hardware.display.DisplayManagerGlobal
+ private Method getDisplayInfoMethod;
+ private Method createVirtualDisplayMethod;
+ private Method requestDisplayPowerMethod;
+
+ static DisplayManager create() {
+ try {
+ Class> clazz = Class.forName("android.hardware.display.DisplayManagerGlobal");
+ Method getInstanceMethod = clazz.getDeclaredMethod("getInstance");
+ Object dmg = getInstanceMethod.invoke(null);
+ return new DisplayManager(dmg);
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private DisplayManager(Object manager) {
+ this.manager = manager;
+ }
+
+ // public to call it from unit tests
+ public static DisplayInfo parseDisplayInfo(String dumpsysDisplayOutput, int displayId) {
+ Pattern regex = Pattern.compile(
+ "^ mOverrideDisplayInfo=DisplayInfo\\{\".*?, displayId " + displayId + ".*?(, FLAG_.*)?, real ([0-9]+) x ([0-9]+).*?, "
+ + "rotation ([0-9]+).*?, density ([0-9]+).*?, layerStack ([0-9]+)",
+ Pattern.MULTILINE);
+ Matcher m = regex.matcher(dumpsysDisplayOutput);
+ if (!m.find()) {
+ return null;
+ }
+ int flags = parseDisplayFlags(m.group(1));
+ int width = Integer.parseInt(m.group(2));
+ int height = Integer.parseInt(m.group(3));
+ int rotation = Integer.parseInt(m.group(4));
+ int density = Integer.parseInt(m.group(5));
+ int layerStack = Integer.parseInt(m.group(6));
+
+ return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, density, null);
+ }
+
+ private static DisplayInfo getDisplayInfoFromDumpsysDisplay(int displayId) {
+ try {
+ String dumpsysDisplayOutput = Command.execReadOutput("dumpsys", "display");
+ return parseDisplayInfo(dumpsysDisplayOutput, displayId);
+ } catch (Exception e) {
+ Ln.e("Could not get display info from \"dumpsys display\" output", e);
+ return null;
+ }
+ }
+
+ private static int parseDisplayFlags(String text) {
+ if (text == null) {
+ return 0;
+ }
+
+ int flags = 0;
+ Pattern regex = Pattern.compile("FLAG_[A-Z_]+");
+ Matcher m = regex.matcher(text);
+ while (m.find()) {
+ String flagString = m.group();
+ try {
+ Field filed = Display.class.getDeclaredField(flagString);
+ flags |= filed.getInt(null);
+ } catch (ReflectiveOperationException e) {
+ // Silently ignore, some flags reported by "dumpsys display" are @TestApi
+ }
+ }
+ return flags;
+ }
+
+ // getDisplayInfo() may be used from both the Controller thread and the video (main) thread
+ private synchronized Method getGetDisplayInfoMethod() throws NoSuchMethodException {
+ if (getDisplayInfoMethod == null) {
+ getDisplayInfoMethod = manager.getClass().getMethod("getDisplayInfo", int.class);
+ }
+ return getDisplayInfoMethod;
+ }
+
+ public DisplayInfo getDisplayInfo(int displayId) {
+ try {
+ Method method = getGetDisplayInfoMethod();
+ Object displayInfo = method.invoke(manager, displayId);
+ if (displayInfo == null) {
+ // fallback when displayInfo is null
+ return getDisplayInfoFromDumpsysDisplay(displayId);
+ }
+ 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);
+ int layerStack = cls.getDeclaredField("layerStack").getInt(displayInfo);
+ int flags = cls.getDeclaredField("flags").getInt(displayInfo);
+ int dpi = cls.getDeclaredField("logicalDensityDpi").getInt(displayInfo);
+ String uniqueId;
+ try {
+ uniqueId = (String) cls.getDeclaredField("uniqueId").get(displayInfo);
+ } catch (NoSuchFieldException e) {
+ // This field might not exist:
+ uniqueId = null;
+ }
+ return new DisplayInfo(displayId, new Size(width, height), rotation, layerStack, flags, dpi, uniqueId);
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ public int[] getDisplayIds() {
+ try {
+ return (int[]) manager.getClass().getMethod("getDisplayIds").invoke(manager);
+ } catch (ReflectiveOperationException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private Method getCreateVirtualDisplayMethod() throws NoSuchMethodException {
+ if (createVirtualDisplayMethod == null) {
+ createVirtualDisplayMethod = android.hardware.display.DisplayManager.class
+ .getMethod("createVirtualDisplay", String.class, int.class, int.class, int.class, Surface.class);
+ }
+ return createVirtualDisplayMethod;
+ }
+
+ public VirtualDisplay createVirtualDisplay(String name, int width, int height, int displayIdToMirror, Surface surface) throws Exception {
+ Method method = getCreateVirtualDisplayMethod();
+ return (VirtualDisplay) method.invoke(null, name, width, height, displayIdToMirror, surface);
+ }
+
+ public VirtualDisplay createNewVirtualDisplay(String name, int width, int height, int dpi, Surface surface, int flags) throws Exception {
+ Constructor ctor = android.hardware.display.DisplayManager.class.getDeclaredConstructor(
+ Context.class);
+ ctor.setAccessible(true);
+ android.hardware.display.DisplayManager dm = ctor.newInstance(FakeContext.get());
+ return dm.createVirtualDisplay(name, width, height, dpi, surface, flags);
+ }
+
+ private Method getRequestDisplayPowerMethod() throws NoSuchMethodException {
+ if (requestDisplayPowerMethod == null) {
+ requestDisplayPowerMethod = manager.getClass().getMethod("requestDisplayPower", int.class, boolean.class);
+ }
+ return requestDisplayPowerMethod;
+ }
+
+ @TargetApi(AndroidVersions.API_35_ANDROID_15)
+ public boolean requestDisplayPower(int displayId, boolean on) {
+ try {
+ Method method = getRequestDisplayPowerMethod();
+ return (boolean) method.invoke(manager, displayId, on);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return false;
+ }
+ }
+
+ public DisplayListenerHandle registerDisplayListener(DisplayListener listener, Handler handler) {
+ try {
+ Class> displayListenerClass = Class.forName("android.hardware.display.DisplayManager$DisplayListener");
+ Object displayListenerProxy = Proxy.newProxyInstance(
+ ClassLoader.getSystemClassLoader(),
+ new Class[] {displayListenerClass},
+ (proxy, method, args) -> {
+ if ("onDisplayChanged".equals(method.getName())) {
+ listener.onDisplayChanged((int) args[0]);
+ }
+ if ("toString".equals(method.getName())) {
+ return "DisplayListener";
+ }
+ return null;
+ });
+ try {
+ manager.getClass()
+ .getMethod("registerDisplayListener", displayListenerClass, Handler.class, long.class, String.class)
+ .invoke(manager, displayListenerProxy, handler, EVENT_FLAG_DISPLAY_CHANGED, FakeContext.PACKAGE_NAME);
+ } catch (NoSuchMethodException e) {
+ try {
+ manager.getClass()
+ .getMethod("registerDisplayListener", displayListenerClass, Handler.class, long.class)
+ .invoke(manager, displayListenerProxy, handler, EVENT_FLAG_DISPLAY_CHANGED);
+ } catch (NoSuchMethodException e2) {
+ manager.getClass()
+ .getMethod("registerDisplayListener", displayListenerClass, Handler.class)
+ .invoke(manager, displayListenerProxy, handler);
+ }
+ }
+
+ return new DisplayListenerHandle(displayListenerProxy);
+ } catch (Exception e) {
+ // Rotation and screen size won't be updated, not a fatal error
+ Ln.e("Could not register display listener", e);
+ }
+
+ return null;
+ }
+
+ public void unregisterDisplayListener(DisplayListenerHandle listener) {
+ try {
+ Class> displayListenerClass = Class.forName("android.hardware.display.DisplayManager$DisplayListener");
+ manager.getClass().getMethod("unregisterDisplayListener", displayListenerClass).invoke(manager, listener.displayListenerProxy);
+ } catch (Exception e) {
+ Ln.e("Could not unregister display listener", e);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayWindowListener.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayWindowListener.java
new file mode 100644
index 0000000..1573d81
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/DisplayWindowListener.java
@@ -0,0 +1,37 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.util.Ln;
+
+import android.content.res.Configuration;
+import android.os.Parcel;
+import android.os.RemoteException;
+import android.view.IDisplayWindowListener;
+
+public class DisplayWindowListener extends IDisplayWindowListener.Stub {
+ @Override
+ public void onDisplayAdded(int displayId) {
+ // empty default implementation
+ }
+
+ @Override
+ public void onDisplayConfigurationChanged(int displayId, Configuration newConfig) {
+ // empty default implementation
+ }
+
+ @Override
+ public void onDisplayRemoved(int displayId) {
+ // empty default implementation
+ }
+
+ @Override
+ public boolean onTransact(int code, Parcel data, Parcel reply, int flags) throws RemoteException {
+ try {
+ return super.onTransact(code, data, reply, flags);
+ } catch (AbstractMethodError e) {
+ Ln.v("Ignoring AbstractMethodError: " + e.getMessage());
+ // Ignore unknown methods, write default response to reply parcel
+ reply.writeNoException();
+ return true;
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/InputManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/InputManager.java
new file mode 100644
index 0000000..f55648d
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/InputManager.java
@@ -0,0 +1,146 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.FakeContext;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.view.InputEvent;
+import android.view.MotionEvent;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+
+@SuppressLint("PrivateApi,DiscouragedPrivateApi")
+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 android.hardware.input.InputManager manager;
+ private long lastPermissionLogDate;
+
+ private static Method injectInputEventMethod;
+ private static Method setDisplayIdMethod;
+ private static Method setActionButtonMethod;
+ private static Method addUniqueIdAssociationByPortMethod;
+ private static Method removeUniqueIdAssociationByPortMethod;
+
+ static InputManager create() {
+ android.hardware.input.InputManager manager = (android.hardware.input.InputManager) FakeContext.get()
+ .getSystemService(FakeContext.INPUT_SERVICE);
+ return new InputManager(manager);
+ }
+
+ private InputManager(android.hardware.input.InputManager manager) {
+ this.manager = manager;
+ }
+
+ private static Method getInjectInputEventMethod() throws NoSuchMethodException {
+ if (injectInputEventMethod == null) {
+ injectInputEventMethod = android.hardware.input.InputManager.class.getMethod("injectInputEvent", InputEvent.class, int.class);
+ }
+ return injectInputEventMethod;
+ }
+
+ public boolean injectInputEvent(InputEvent inputEvent, int mode) {
+ try {
+ Method method = getInjectInputEventMethod();
+ return (boolean) method.invoke(manager, inputEvent, mode);
+ } catch (ReflectiveOperationException e) {
+ if (e instanceof InvocationTargetException) {
+ Throwable cause = e.getCause();
+ if (cause instanceof SecurityException) {
+ String message = e.getCause().getMessage();
+ if (message != null && message.contains("INJECT_EVENTS permission")) {
+ // Do not flood the console, limit to one permission error log every 3 seconds
+ long now = System.currentTimeMillis();
+ if (lastPermissionLogDate <= now - 3000) {
+ Ln.e(message);
+ Ln.e("Make sure you have enabled \"USB debugging (Security Settings)\" and then rebooted your device.");
+ lastPermissionLogDate = now;
+ }
+ // Do not print the stack trace
+ return false;
+ }
+ }
+ }
+ Ln.e("Could not invoke method", e);
+ return false;
+ }
+ }
+
+ private static Method getSetDisplayIdMethod() throws NoSuchMethodException {
+ if (setDisplayIdMethod == null) {
+ setDisplayIdMethod = InputEvent.class.getMethod("setDisplayId", int.class);
+ }
+ return setDisplayIdMethod;
+ }
+
+ public static boolean setDisplayId(InputEvent inputEvent, int displayId) {
+ try {
+ Method method = getSetDisplayIdMethod();
+ method.invoke(inputEvent, displayId);
+ return true;
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Cannot associate a display id to the input event", e);
+ return false;
+ }
+ }
+
+ private static Method getSetActionButtonMethod() throws NoSuchMethodException {
+ if (setActionButtonMethod == null) {
+ setActionButtonMethod = MotionEvent.class.getMethod("setActionButton", int.class);
+ }
+ return setActionButtonMethod;
+ }
+
+ public static boolean setActionButton(MotionEvent motionEvent, int actionButton) {
+ try {
+ Method method = getSetActionButtonMethod();
+ method.invoke(motionEvent, actionButton);
+ return true;
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Cannot set action button on MotionEvent", e);
+ return false;
+ }
+ }
+
+ private static Method getAddUniqueIdAssociationByPortMethod() throws NoSuchMethodException {
+ if (addUniqueIdAssociationByPortMethod == null) {
+ addUniqueIdAssociationByPortMethod = android.hardware.input.InputManager.class.getMethod(
+ "addUniqueIdAssociationByPort", String.class, String.class);
+ }
+ return addUniqueIdAssociationByPortMethod;
+ }
+
+ @TargetApi(AndroidVersions.API_35_ANDROID_15)
+ public void addUniqueIdAssociationByPort(String inputPort, String uniqueId) {
+ try {
+ Method method = getAddUniqueIdAssociationByPortMethod();
+ method.invoke(manager, inputPort, uniqueId);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Cannot add unique id association by port", e);
+ }
+ }
+
+ private static Method getRemoveUniqueIdAssociationByPortMethod() throws NoSuchMethodException {
+ if (removeUniqueIdAssociationByPortMethod == null) {
+ removeUniqueIdAssociationByPortMethod = android.hardware.input.InputManager.class.getMethod(
+ "removeUniqueIdAssociationByPort", String.class);
+ }
+ return removeUniqueIdAssociationByPortMethod;
+ }
+
+ @TargetApi(AndroidVersions.API_35_ANDROID_15)
+ public void removeUniqueIdAssociationByPort(String inputPort) {
+ try {
+ Method method = getRemoveUniqueIdAssociationByPortMethod();
+ method.invoke(manager, inputPort);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Cannot remove unique id association by port", e);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/PowerManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/PowerManager.java
new file mode 100644
index 0000000..c7ba3f1
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/PowerManager.java
@@ -0,0 +1,80 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.os.Build;
+import android.os.IInterface;
+import android.os.SystemClock;
+
+import java.lang.reflect.Method;
+
+public final class PowerManager {
+
+ private static final int USER_ACTIVITY_EVENT_OTHER = 0;
+
+ private final IInterface manager;
+ private Method isScreenOnMethod;
+ private Method userActivityMethod;
+
+ static PowerManager create() {
+ IInterface manager = ServiceManager.getService("power", "android.os.IPowerManager");
+ return new PowerManager(manager);
+ }
+
+ private PowerManager(IInterface manager) {
+ this.manager = manager;
+ }
+
+ private Method getIsScreenOnMethod() throws NoSuchMethodException {
+ if (isScreenOnMethod == null) {
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) {
+ isScreenOnMethod = manager.getClass().getMethod("isDisplayInteractive", int.class);
+ } else {
+ isScreenOnMethod = manager.getClass().getMethod("isInteractive");
+ }
+ }
+ return isScreenOnMethod;
+ }
+
+ public boolean isScreenOn(int displayId) {
+
+ try {
+ Method method = getIsScreenOnMethod();
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_34_ANDROID_14) {
+ return (boolean) method.invoke(manager, displayId);
+ }
+ return (boolean) method.invoke(manager);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return false;
+ }
+ }
+
+ private Method getUserActivityMethod() throws NoSuchMethodException {
+ if (userActivityMethod == null) {
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) {
+ // userActivity(int displayId, long time, int event, int flags);
+ userActivityMethod = manager.getClass().getMethod("userActivity", int.class, long.class, int.class, int.class);
+ } else {
+ // userActivity(long time, int event, int flags);
+ userActivityMethod = manager.getClass().getMethod("userActivity", long.class, int.class, int.class);
+ }
+ }
+ return userActivityMethod;
+ }
+
+ public void userActivity(int displayId) {
+ try {
+ Method method = getUserActivityMethod();
+ long time = SystemClock.uptimeMillis();
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) {
+ method.invoke(manager, displayId, time, USER_ACTIVITY_EVENT_OTHER, 0);
+ return;
+ }
+ method.invoke(manager, time, USER_ACTIVITY_EVENT_OTHER, 0);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ServiceManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ServiceManager.java
new file mode 100644
index 0000000..b1123b5
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/ServiceManager.java
@@ -0,0 +1,112 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.FakeContext;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.hardware.camera2.CameraManager;
+import android.os.IBinder;
+import android.os.IInterface;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
+
+@SuppressLint("PrivateApi,DiscouragedPrivateApi")
+public final class ServiceManager {
+
+ private static final Method GET_SERVICE_METHOD;
+
+ static {
+ try {
+ GET_SERVICE_METHOD = Class.forName("android.os.ServiceManager").getDeclaredMethod("getService", String.class);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private static WindowManager windowManager;
+ private static DisplayManager displayManager;
+ private static InputManager inputManager;
+ private static PowerManager powerManager;
+ private static StatusBarManager statusBarManager;
+ private static ClipboardManager clipboardManager;
+ private static ActivityManager activityManager;
+ private static CameraManager cameraManager;
+
+ private ServiceManager() {
+ /* not instantiable */
+ }
+
+ static IInterface getService(String service, String type) {
+ try {
+ IBinder binder = (IBinder) GET_SERVICE_METHOD.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 static WindowManager getWindowManager() {
+ if (windowManager == null) {
+ windowManager = WindowManager.create();
+ }
+ return windowManager;
+ }
+
+ // The DisplayManager may be used from both the Controller thread and the video (main) thread
+ public static synchronized DisplayManager getDisplayManager() {
+ if (displayManager == null) {
+ displayManager = DisplayManager.create();
+ }
+ return displayManager;
+ }
+
+ public static InputManager getInputManager() {
+ if (inputManager == null) {
+ inputManager = InputManager.create();
+ }
+ return inputManager;
+ }
+
+ public static PowerManager getPowerManager() {
+ if (powerManager == null) {
+ powerManager = PowerManager.create();
+ }
+ return powerManager;
+ }
+
+ public static StatusBarManager getStatusBarManager() {
+ if (statusBarManager == null) {
+ statusBarManager = StatusBarManager.create();
+ }
+ return statusBarManager;
+ }
+
+ public static ClipboardManager getClipboardManager() {
+ if (clipboardManager == null) {
+ // May be null, some devices have no clipboard manager
+ clipboardManager = ClipboardManager.create();
+ }
+ return clipboardManager;
+ }
+
+ public static ActivityManager getActivityManager() {
+ if (activityManager == null) {
+ activityManager = ActivityManager.create();
+ }
+ return activityManager;
+ }
+
+ public static CameraManager getCameraManager() {
+ if (cameraManager == null) {
+ try {
+ Constructor ctor = CameraManager.class.getDeclaredConstructor(Context.class);
+ cameraManager = ctor.newInstance(FakeContext.get());
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ }
+ return cameraManager;
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/StatusBarManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/StatusBarManager.java
new file mode 100644
index 0000000..ca80dde
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/StatusBarManager.java
@@ -0,0 +1,97 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.util.Ln;
+
+import android.os.IInterface;
+
+import java.lang.reflect.Method;
+
+public final class StatusBarManager {
+
+ private final IInterface manager;
+ private Method expandNotificationsPanelMethod;
+ private boolean expandNotificationPanelMethodCustomVersion;
+ private Method expandSettingsPanelMethod;
+ private boolean expandSettingsPanelMethodNewVersion = true;
+ private Method collapsePanelsMethod;
+
+ static StatusBarManager create() {
+ IInterface manager = ServiceManager.getService("statusbar", "com.android.internal.statusbar.IStatusBarService");
+ return new StatusBarManager(manager);
+ }
+
+ private StatusBarManager(IInterface manager) {
+ this.manager = manager;
+ }
+
+ private Method getExpandNotificationsPanelMethod() throws NoSuchMethodException {
+ if (expandNotificationsPanelMethod == null) {
+ try {
+ expandNotificationsPanelMethod = manager.getClass().getMethod("expandNotificationsPanel");
+ } catch (NoSuchMethodException e) {
+ // Custom version for custom vendor ROM:
+ expandNotificationsPanelMethod = manager.getClass().getMethod("expandNotificationsPanel", int.class);
+ expandNotificationPanelMethodCustomVersion = true;
+ }
+ }
+ return expandNotificationsPanelMethod;
+ }
+
+ private Method getExpandSettingsPanel() throws NoSuchMethodException {
+ if (expandSettingsPanelMethod == null) {
+ try {
+ // Since Android 7: https://android.googlesource.com/platform/frameworks/base.git/+/a9927325eda025504d59bb6594fee8e240d95b01%5E%21/
+ expandSettingsPanelMethod = manager.getClass().getMethod("expandSettingsPanel", String.class);
+ } catch (NoSuchMethodException e) {
+ // old version
+ expandSettingsPanelMethod = manager.getClass().getMethod("expandSettingsPanel");
+ expandSettingsPanelMethodNewVersion = false;
+ }
+ }
+ return expandSettingsPanelMethod;
+ }
+
+ private Method getCollapsePanelsMethod() throws NoSuchMethodException {
+ if (collapsePanelsMethod == null) {
+ collapsePanelsMethod = manager.getClass().getMethod("collapsePanels");
+ }
+ return collapsePanelsMethod;
+ }
+
+ public void expandNotificationsPanel() {
+ try {
+ Method method = getExpandNotificationsPanelMethod();
+ if (expandNotificationPanelMethodCustomVersion) {
+ method.invoke(manager, 0);
+ } else {
+ method.invoke(manager);
+ }
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+
+ public void expandSettingsPanel() {
+ try {
+ Method method = getExpandSettingsPanel();
+ if (expandSettingsPanelMethodNewVersion) {
+ // new version
+ method.invoke(manager, (Object) null);
+ } else {
+ // old version
+ method.invoke(manager);
+ }
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+
+ public void collapsePanels() {
+ try {
+ Method method = getCollapsePanelsMethod();
+ method.invoke(manager);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+}
diff --git a/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/SurfaceControl.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/SurfaceControl.java
new file mode 100644
index 0000000..3bae4a3
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/SurfaceControl.java
@@ -0,0 +1,191 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.annotation.SuppressLint;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.IBinder;
+import android.view.Surface;
+
+import java.lang.reflect.Method;
+
+@SuppressLint("PrivateApi")
+public final class SurfaceControl {
+
+ private static final Class> CLASS;
+
+ // see
+ public static final int POWER_MODE_OFF = 0;
+ public static final int POWER_MODE_NORMAL = 2;
+
+ static {
+ try {
+ CLASS = Class.forName("android.view.SurfaceControl");
+ } catch (ClassNotFoundException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ private static Method getBuiltInDisplayMethod;
+ private static Method setDisplayPowerModeMethod;
+ private static Method getPhysicalDisplayTokenMethod;
+ private static Method getPhysicalDisplayIdsMethod;
+
+ 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) throws Exception {
+ return (IBinder) CLASS.getMethod("createDisplay", String.class, boolean.class).invoke(null, name, secure);
+ }
+
+ private static Method getGetBuiltInDisplayMethod() throws NoSuchMethodException {
+ if (getBuiltInDisplayMethod == null) {
+ // the method signature has changed in Android 10
+ //
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
+ getBuiltInDisplayMethod = CLASS.getMethod("getBuiltInDisplay", int.class);
+ } else {
+ getBuiltInDisplayMethod = CLASS.getMethod("getInternalDisplayToken");
+ }
+ }
+ return getBuiltInDisplayMethod;
+ }
+
+ public static boolean hasGetBuildInDisplayMethod() {
+ try {
+ getGetBuiltInDisplayMethod();
+ return true;
+ } catch (NoSuchMethodException e) {
+ return false;
+ }
+ }
+
+ public static IBinder getBuiltInDisplay() {
+ try {
+ Method method = getGetBuiltInDisplayMethod();
+ if (Build.VERSION.SDK_INT < AndroidVersions.API_29_ANDROID_10) {
+ // call getBuiltInDisplay(0)
+ return (IBinder) method.invoke(null, 0);
+ }
+
+ // call getInternalDisplayToken()
+ return (IBinder) method.invoke(null);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return null;
+ }
+ }
+
+ private static Method getGetPhysicalDisplayTokenMethod() throws NoSuchMethodException {
+ if (getPhysicalDisplayTokenMethod == null) {
+ getPhysicalDisplayTokenMethod = CLASS.getMethod("getPhysicalDisplayToken", long.class);
+ }
+ return getPhysicalDisplayTokenMethod;
+ }
+
+ public static IBinder getPhysicalDisplayToken(long physicalDisplayId) {
+ try {
+ Method method = getGetPhysicalDisplayTokenMethod();
+ return (IBinder) method.invoke(null, physicalDisplayId);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return null;
+ }
+ }
+
+ private static Method getGetPhysicalDisplayIdsMethod() throws NoSuchMethodException {
+ if (getPhysicalDisplayIdsMethod == null) {
+ getPhysicalDisplayIdsMethod = CLASS.getMethod("getPhysicalDisplayIds");
+ }
+ return getPhysicalDisplayIdsMethod;
+ }
+
+ public static boolean hasGetPhysicalDisplayIdsMethod() {
+ try {
+ getGetPhysicalDisplayIdsMethod();
+ return true;
+ } catch (NoSuchMethodException e) {
+ return false;
+ }
+ }
+
+ public static long[] getPhysicalDisplayIds() {
+ try {
+ Method method = getGetPhysicalDisplayIdsMethod();
+ return (long[]) method.invoke(null);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return null;
+ }
+ }
+
+ private static Method getSetDisplayPowerModeMethod() throws NoSuchMethodException {
+ if (setDisplayPowerModeMethod == null) {
+ setDisplayPowerModeMethod = CLASS.getMethod("setDisplayPowerMode", IBinder.class, int.class);
+ }
+ return setDisplayPowerModeMethod;
+ }
+
+ public static boolean setDisplayPowerMode(IBinder displayToken, int mode) {
+ try {
+ Method method = getSetDisplayPowerModeMethod();
+ method.invoke(null, displayToken, mode);
+ return true;
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return false;
+ }
+ }
+
+ 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/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/WindowManager.java b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/WindowManager.java
new file mode 100644
index 0000000..7ba5cc0
--- /dev/null
+++ b/server/third_party/scrcpy-java/com/genymobile/scrcpy/wrappers/WindowManager.java
@@ -0,0 +1,267 @@
+package com.genymobile.scrcpy.wrappers;
+
+import com.genymobile.scrcpy.AndroidVersions;
+import com.genymobile.scrcpy.util.Ln;
+
+import android.annotation.TargetApi;
+import android.os.Build;
+import android.os.IInterface;
+import android.view.IDisplayWindowListener;
+
+import java.lang.reflect.Method;
+
+public final class WindowManager {
+
+ @SuppressWarnings("checkstyle:LineLength")
+ //
+ public static final int DISPLAY_IME_POLICY_LOCAL = 0;
+ public static final int DISPLAY_IME_POLICY_FALLBACK_DISPLAY = 1;
+ public static final int DISPLAY_IME_POLICY_HIDE = 2;
+
+ private final IInterface manager;
+ private Method getRotationMethod;
+
+ private Method freezeDisplayRotationMethod;
+ private int freezeDisplayRotationMethodVersion;
+
+ private Method isDisplayRotationFrozenMethod;
+ private int isDisplayRotationFrozenMethodVersion;
+
+ private Method thawDisplayRotationMethod;
+ private int thawDisplayRotationMethodVersion;
+
+ private Method getDisplayImePolicyMethod;
+ private Method setDisplayImePolicyMethod;
+
+ static WindowManager create() {
+ IInterface manager = ServiceManager.getService("window", "android.view.IWindowManager");
+ return new WindowManager(manager);
+ }
+
+ private WindowManager(IInterface manager) {
+ this.manager = manager;
+ }
+
+ private Method getGetRotationMethod() throws NoSuchMethodException {
+ if (getRotationMethod == null) {
+ Class> cls = manager.getClass();
+ try {
+ // method changed since this commit:
+ // https://android.googlesource.com/platform/frameworks/base/+/8ee7285128c3843401d4c4d0412cd66e86ba49e3%5E%21/#F2
+ getRotationMethod = cls.getMethod("getDefaultDisplayRotation");
+ } catch (NoSuchMethodException e) {
+ // old version
+ getRotationMethod = cls.getMethod("getRotation");
+ }
+ }
+ return getRotationMethod;
+ }
+
+ private Method getFreezeDisplayRotationMethod() throws NoSuchMethodException {
+ if (freezeDisplayRotationMethod == null) {
+ try {
+ // Android 15 preview and 14 QPR3 Beta added a String caller parameter for debugging:
+ //
+ freezeDisplayRotationMethod = manager.getClass().getMethod("freezeDisplayRotation", int.class, int.class, String.class);
+ freezeDisplayRotationMethodVersion = 0;
+ } catch (NoSuchMethodException e) {
+ try {
+ // New method added by this commit:
+ //
+ freezeDisplayRotationMethod = manager.getClass().getMethod("freezeDisplayRotation", int.class, int.class);
+ freezeDisplayRotationMethodVersion = 1;
+ } catch (NoSuchMethodException e1) {
+ freezeDisplayRotationMethod = manager.getClass().getMethod("freezeRotation", int.class);
+ freezeDisplayRotationMethodVersion = 2;
+ }
+ }
+ }
+ return freezeDisplayRotationMethod;
+ }
+
+ private Method getIsDisplayRotationFrozenMethod() throws NoSuchMethodException {
+ if (isDisplayRotationFrozenMethod == null) {
+ try {
+ // New method added by this commit:
+ //
+ isDisplayRotationFrozenMethod = manager.getClass().getMethod("isDisplayRotationFrozen", int.class);
+ isDisplayRotationFrozenMethodVersion = 0;
+ } catch (NoSuchMethodException e) {
+ isDisplayRotationFrozenMethod = manager.getClass().getMethod("isRotationFrozen");
+ isDisplayRotationFrozenMethodVersion = 1;
+ }
+ }
+ return isDisplayRotationFrozenMethod;
+ }
+
+ private Method getThawDisplayRotationMethod() throws NoSuchMethodException {
+ if (thawDisplayRotationMethod == null) {
+ try {
+ // Android 15 preview and 14 QPR3 Beta added a String caller parameter for debugging:
+ //
+ thawDisplayRotationMethod = manager.getClass().getMethod("thawDisplayRotation", int.class, String.class);
+ thawDisplayRotationMethodVersion = 0;
+ } catch (NoSuchMethodException e) {
+ try {
+ // New method added by this commit:
+ //
+ thawDisplayRotationMethod = manager.getClass().getMethod("thawDisplayRotation", int.class);
+ thawDisplayRotationMethodVersion = 1;
+ } catch (NoSuchMethodException e1) {
+ thawDisplayRotationMethod = manager.getClass().getMethod("thawRotation");
+ thawDisplayRotationMethodVersion = 2;
+ }
+ }
+ }
+ return thawDisplayRotationMethod;
+ }
+
+ public int getRotation() {
+ try {
+ Method method = getGetRotationMethod();
+ return (int) method.invoke(manager);
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return 0;
+ }
+ }
+
+ public void freezeRotation(int displayId, int rotation) {
+ try {
+ Method method = getFreezeDisplayRotationMethod();
+ switch (freezeDisplayRotationMethodVersion) {
+ case 0:
+ method.invoke(manager, displayId, rotation, "scrcpy#freezeRotation");
+ break;
+ case 1:
+ method.invoke(manager, displayId, rotation);
+ break;
+ default:
+ if (displayId != 0) {
+ Ln.e("Secondary display rotation not supported on this device");
+ return;
+ }
+ method.invoke(manager, rotation);
+ break;
+ }
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+
+ public boolean isRotationFrozen(int displayId) {
+ try {
+ Method method = getIsDisplayRotationFrozenMethod();
+ switch (isDisplayRotationFrozenMethodVersion) {
+ case 0:
+ return (boolean) method.invoke(manager, displayId);
+ default:
+ if (displayId != 0) {
+ Ln.e("Secondary display rotation not supported on this device");
+ return false;
+ }
+ return (boolean) method.invoke(manager);
+ }
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return false;
+ }
+ }
+
+ public void thawRotation(int displayId) {
+ try {
+ Method method = getThawDisplayRotationMethod();
+ switch (thawDisplayRotationMethodVersion) {
+ case 0:
+ method.invoke(manager, displayId, "scrcpy#thawRotation");
+ break;
+ case 1:
+ method.invoke(manager, displayId);
+ break;
+ default:
+ if (displayId != 0) {
+ Ln.e("Secondary display rotation not supported on this device");
+ return;
+ }
+ method.invoke(manager);
+ break;
+ }
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+
+ @TargetApi(AndroidVersions.API_30_ANDROID_11)
+ public int[] registerDisplayWindowListener(IDisplayWindowListener listener) {
+ try {
+ return (int[]) manager.getClass().getMethod("registerDisplayWindowListener", IDisplayWindowListener.class).invoke(manager, listener);
+ } catch (Exception e) {
+ Ln.e("Could not register display window listener", e);
+ }
+ return null;
+ }
+
+ @TargetApi(AndroidVersions.API_30_ANDROID_11)
+ public void unregisterDisplayWindowListener(IDisplayWindowListener listener) {
+ try {
+ manager.getClass().getMethod("unregisterDisplayWindowListener", IDisplayWindowListener.class).invoke(manager, listener);
+ } catch (Exception e) {
+ Ln.e("Could not unregister display window listener", e);
+ }
+ }
+
+ @TargetApi(AndroidVersions.API_29_ANDROID_10)
+ private Method getGetDisplayImePolicyMethod() throws NoSuchMethodException {
+ if (getDisplayImePolicyMethod == null) {
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) {
+ getDisplayImePolicyMethod = manager.getClass().getMethod("getDisplayImePolicy", int.class);
+ } else {
+ getDisplayImePolicyMethod = manager.getClass().getMethod("shouldShowIme", int.class);
+ }
+ }
+ return getDisplayImePolicyMethod;
+ }
+
+ @TargetApi(AndroidVersions.API_29_ANDROID_10)
+ public int getDisplayImePolicy(int displayId) {
+ try {
+ Method method = getGetDisplayImePolicyMethod();
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) {
+ return (int) method.invoke(manager, displayId);
+ }
+ boolean shouldShowIme = (boolean) method.invoke(manager, displayId);
+ return shouldShowIme ? DISPLAY_IME_POLICY_LOCAL : DISPLAY_IME_POLICY_FALLBACK_DISPLAY;
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ return -1;
+ }
+ }
+
+ @TargetApi(AndroidVersions.API_29_ANDROID_10)
+ private Method getSetDisplayImePolicyMethod() throws NoSuchMethodException {
+ if (setDisplayImePolicyMethod == null) {
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) {
+ setDisplayImePolicyMethod = manager.getClass().getMethod("setDisplayImePolicy", int.class, int.class);
+ } else {
+ setDisplayImePolicyMethod = manager.getClass().getMethod("setShouldShowIme", int.class, boolean.class);
+ }
+ }
+ return setDisplayImePolicyMethod;
+ }
+
+ @TargetApi(AndroidVersions.API_29_ANDROID_10)
+ public void setDisplayImePolicy(int displayId, int displayImePolicy) {
+ try {
+ Method method = getSetDisplayImePolicyMethod();
+ if (Build.VERSION.SDK_INT >= AndroidVersions.API_31_ANDROID_12) {
+ method.invoke(manager, displayId, displayImePolicy);
+ } else if (displayImePolicy != DISPLAY_IME_POLICY_HIDE) {
+ method.invoke(manager, displayId, displayImePolicy == DISPLAY_IME_POLICY_LOCAL);
+ } else {
+ Ln.w("DISPLAY_IME_POLICY_HIDE is not supported before Android 12");
+ }
+ } catch (ReflectiveOperationException e) {
+ Ln.e("Could not invoke method", e);
+ }
+ }
+}
diff --git a/tools/devtest.py b/tools/devtest.py
new file mode 100644
index 0000000..afa91a1
--- /dev/null
+++ b/tools/devtest.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+"""Drives droidbridge-server on a connected device without the Mac app.
+Usage: devtest.py tests: enter, edge, clipboard"""
+import socket, struct, subprocess, sys, time
+
+ADB = "adb"
+REMOTE = "/data/local/tmp/droidbridge-server.jar"
+VERSION = "0.1.0"
+LEFT, RIGHT, TOP, BOTTOM = 0, 1, 2, 3
+
+def adb(*a):
+ return subprocess.run([ADB, *a], capture_output=True, text=True).stdout
+
+class Server:
+ def __init__(self, jar):
+ adb("push", jar, REMOTE)
+ port = int(adb("forward", "tcp:0", "localabstract:droidbridge").strip())
+ self.port = port
+ self.proc = subprocess.Popen([ADB, "shell", f"CLASSPATH={REMOTE}", "app_process", "/", "dev.droidbridge.Main", VERSION],
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ line = self.proc.stdout.readline()
+ while line and "READY" not in line:
+ print("server:", line.strip()); line = self.proc.stdout.readline()
+ self.s = socket.create_connection(("127.0.0.1", port))
+ self.send(0x01, struct.pack(">H", 1))
+ t, p = self.recv()
+ assert t == 0x81, t
+ v, w, h = struct.unpack(">HHH", p[:6])
+ self.size = (w, h)
+ print("device:", p[6:].decode(), w, h)
+
+ def send(self, t, p=b""): self.s.sendall(struct.pack(">BI", t, len(p)) + p)
+ def recv(self, timeout=None):
+ self.s.settimeout(timeout)
+ try:
+ hdr = self._n(5)
+ except socket.timeout:
+ return None, None
+ t, n = struct.unpack(">BI", hdr)
+ return t, self._n(n)
+ def _n(self, n):
+ b = b""
+ while len(b) < n:
+ c = self.s.recv(n - len(b))
+ if not c: raise EOFError
+ b += c
+ return b
+ def mouse(self, dx, dy, buttons=0, wheel=0): self.send(0x04, struct.pack(">BhhbB", buttons, dx, dy, wheel, 0))
+ def enter(self, side, ratio): self.send(0x02, struct.pack(">BH", side, int(ratio * 65535)))
+ def close(self):
+ self.s.close(); time.sleep(0.5)
+ for line in self.proc.stdout.read().splitlines()[-15:]: print("server:", line)
+ adb("forward", "--remove", f"tcp:{self.port}")
+
+def main():
+ jar, test = sys.argv[1], sys.argv[2]
+ S = Server(jar)
+ try:
+ if test in ("enter", "edge"):
+ for ratio in (0.25, 0.75):
+ t0 = time.time(); S.enter(LEFT, ratio); S.send(0x07)
+ t, _ = S.recv(5); print(f"enter left {ratio}: {time.time()-t0:.2f}s (pong={t==0x84})")
+ time.sleep(0.5)
+ if test == "edge":
+ for _ in range(40): S.mouse(8, 0); time.sleep(0.008) # into the screen
+ t, p = S.recv(0.3); print("after moving in, unexpected:", t)
+ t0 = time.time(); got = None
+ for i in range(400):
+ S.mouse(-8, 0); time.sleep(0.008)
+ t, p = S.recv(0.001)
+ if t == 0x82:
+ side, r = struct.unpack(">BH", p); got = (side, r / 65535); break
+ print("edge:", got, f"after {time.time()-t0:.2f}s")
+ if test.startswith("type"):
+ # Types dead-key sequences with the given Android layout: devtest.py type:
+ layout = test.split(":", 1)[1] if ":" in test else "english_us_intl"
+ S.send(0x08, layout.encode()); time.sleep(0.8)
+ SHIFT = 0x02
+ def key(mods, usage):
+ S.send(0x05, bytes([mods, 0, usage, 0, 0, 0, 0, 0])); time.sleep(0.03)
+ S.send(0x05, bytes(8)); time.sleep(0.03)
+ A, C, E, O, U, SPACE, ENTER = 0x04, 0x06, 0x08, 0x12, 0x18, 0x2C, 0x28
+ QUOTE, GRAVE, SIX = 0x34, 0x35, 0x23
+ seqs = [("'a", [(0, QUOTE), (0, A)]), ("~a", [(SHIFT, GRAVE), (0, A)]), ("`a", [(0, GRAVE), (0, A)]),
+ ("^e", [(SHIFT, SIX), (0, E)]), ('"u', [(SHIFT, QUOTE), (0, U)]), ("'c", [(0, QUOTE), (0, C)]),
+ ("~o", [(SHIFT, GRAVE), (0, O)]), ("' space", [(0, QUOTE), (0, SPACE)])]
+ for name, keys in seqs:
+ for k in keys: key(*k)
+ key(0, SPACE)
+ key(0, ENTER)
+ print("typed:", " | ".join(n for n, _ in seqs))
+ if test == "clipboard":
+ S.send(0x06, "droidbridge mac->android ção".encode())
+ time.sleep(0.5)
+ print("set; device echo (should be none):", S.recv(1)[0])
+ finally:
+ S.close()
+
+main()