aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena/scrcpy/Server.java
diff options
context:
space:
mode:
authorLena <lena@omega>2026-01-01 00:00:00 +0000
committerLena <lena@omega>2026-06-24 22:14:50 +0300
commiteb0c8951196c637e44daf3c0617b131b997d5d2c (patch)
tree2f83b6a41cee745467e53fc401942b82cea286ea /app/src/main/java/invalid/lena/scrcpy/Server.java
downloadscrcpy-android-0.1.tar.gz
scrcpy-android: mirror an Android device over wireless ADB0.1
Native Java app for Android 12+ that mirrors another Android device over wireless ADB, forwarding video, audio, touch input, and clipboard. Bundles a pinned scrcpy-server.jar and the vendored libadb-android stack. Supports h264/h265/av1 video and raw/opus audio with in-app codec selection. No NDK, no Kotlin. Includes JVM unit tests and a Docker-based emulator e2e rig.
Diffstat (limited to 'app/src/main/java/invalid/lena/scrcpy/Server.java')
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Server.java304
1 files changed, 304 insertions, 0 deletions
diff --git a/app/src/main/java/invalid/lena/scrcpy/Server.java b/app/src/main/java/invalid/lena/scrcpy/Server.java
new file mode 100644
index 0000000..cc16b44
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Server.java
@@ -0,0 +1,304 @@
+package invalid.lena.scrcpy;
+
+import android.content.Context;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import io.github.muntashirakon.adb.AdbStream;
+
+// Bring the scrcpy server up on the target:
+// 1. Push assets/scrcpy-server.jar to /data/local/tmp/scrcpy-server.jar
+// via the adb sync protocol.
+// 2. Spawn `app_process / com.genymobile.scrcpy.Server <ver> key=value...`
+// via an adb shell stream and hold it open. cleanup=true means the
+// server exits when we close that stream.
+// 3. Open three localabstract:scrcpy_<scid> streams in order
+// (video, audio, control). With tunnel_forward=true the server is
+// the listener, so we just dial.
+// 4. Drain 1 probe byte + 64-byte device name from the FIRST stream
+// that the server accepts (video, if requested).
+//
+// Returns a Streams record with everything Session needs: typed
+// InputStream/OutputStream wrappers (opened ONCE per AdbStream so the
+// reader's offset is unambiguous), plus the underlying AdbStream
+// handles so close() can release them.
+public final class Server {
+
+ private static final String REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar";
+ private static final String ASSET_JAR = "scrcpy-server.jar";
+ private static final String ASSET_VERSION = "scrcpy-server.version";
+ private static final int FILE_MODE = 0100644; // regular file, 0644
+ // Server forks a CleanUp helper before opening its abstract sockets;
+ // on slow emulators that takes several seconds, so the budget needs
+ // to be generous. Wall-clock bounded: even if every attempt wedges
+ // for the full OPEN_ATTEMPT_TIMEOUT_MS, the total stays near
+ // OPEN_DEADLINE_MS - strictly below the e2e test deadline
+ // (test-rig/e2e.sh's E2E_DEADLINE, default 60 s), or the test
+ // would fail the wrong way.
+ private static final long OPEN_DEADLINE_MS = 20_000;
+ private static final int OPEN_BACKOFF_MS = 100;
+ // Per-attempt timeout for adb.openAbstract. libadb-android's
+ // AdbConnection.open() blocks on a naked stream.wait() with no
+ // loop or timeout - a missed notification (response arrives before
+ // we park) wedges the call forever. Wrap each attempt with a
+ // timeout + interrupt so the loop can move on.
+ private static final long OPEN_ATTEMPT_TIMEOUT_MS = 800;
+
+ public static final class Streams {
+ public final AdbStream videoAds, audioAds, controlAds;
+ public final InputStream videoIn, audioIn, controlIn;
+ public final OutputStream controlOut;
+ public final String deviceName;
+ public final String scid;
+ public final String version;
+
+ Streams(AdbStream va, AdbStream aa, AdbStream ca,
+ InputStream vi, InputStream ai, InputStream ci, OutputStream co,
+ String name, String scid, String version) {
+ this.videoAds = va; this.audioAds = aa; this.controlAds = ca;
+ this.videoIn = vi; this.audioIn = ai; this.controlIn = ci;
+ this.controlOut = co;
+ this.deviceName = name; this.scid = scid; this.version = version;
+ }
+ }
+
+ private final Context ctx;
+ private final Adb adb;
+ private AdbStream shell;
+ private Thread shellPump;
+ private Streams streams;
+ private volatile boolean serverEof;
+
+ public Server(Context ctx, Adb adb) {
+ this.ctx = ctx;
+ this.adb = adb;
+ }
+
+ public Streams bringUp() throws Exception {
+ String version = readVersion();
+ long pushed = push();
+ Log.i("push %s bytes=%d", REMOTE_PATH, pushed);
+
+ String scid = newScid();
+ String cmd = buildCmdline(version, scid);
+ Log.i("spawn server ver=%s scid=%s", version, scid);
+ Log.i("cmdline: %s", cmd);
+
+ shell = adb.openShell(cmd);
+ shellPump = new Thread(() -> pump(shell.openInputStream()), "server-stdout");
+ shellPump.setDaemon(true);
+ shellPump.start();
+
+ AdbStream va = openAbstract(scid);
+ AdbStream aa = openAbstract(scid);
+ AdbStream ca = openAbstract(scid);
+
+ InputStream vi = va.openInputStream();
+ InputStream ai = aa.openInputStream();
+ InputStream ci = ca.openInputStream();
+ OutputStream co = ca.openOutputStream();
+
+ String name = readDeviceMeta(vi);
+ Log.i("device name=%s", name);
+ streams = new Streams(va, aa, ca, vi, ai, ci, co, name, scid, version);
+ return streams;
+ }
+
+ // Idempotent. Closes the three media/control streams first (lets
+ // the wire drain), then the shell (which makes the scrcpy server
+ // exit via cleanup=true), then gives the cleanup helper up to
+ // CLOSE_GRACE_MS to restore device state - display power, screen
+ // timeout, brightness, etc. - before we kill the adb connection
+ // out from under it. Best-effort: cleanup is server-side and we
+ // can't synchronously confirm it.
+ public void close() {
+ if (streams != null) {
+ closeQuietly(streams.videoAds);
+ closeQuietly(streams.audioAds);
+ closeQuietly(streams.controlAds);
+ streams = null;
+ }
+ Thread t = shellPump;
+ AdbStream s = shell;
+ shell = null;
+ shellPump = null;
+ closeQuietly(s);
+ if (t != null) {
+ try { t.join(CLOSE_GRACE_MS); }
+ catch (InterruptedException ignored) {}
+ if (t.isAlive()) t.interrupt();
+ }
+ }
+
+ private static final long CLOSE_GRACE_MS = 500;
+
+ private static void closeQuietly(AdbStream s) {
+ if (s == null) return;
+ try { s.close(); } catch (IOException ignored) {}
+ }
+
+ // ---- helpers ----
+
+ private String readVersion() throws IOException {
+ try (InputStream in = ctx.getAssets().open(ASSET_VERSION)) {
+ byte[] buf = new byte[64];
+ int n = 0, r;
+ while ((r = in.read(buf, n, buf.length - n)) > 0) n += r;
+ String v = new String(buf, 0, n, StandardCharsets.UTF_8).trim();
+ if (v.isEmpty()) {
+ throw new IOException("scrcpy-server.version is empty");
+ }
+ return v;
+ }
+ }
+
+ private static String newScid() {
+ // 31-bit random, 8 lowercase hex chars - matches scrcpy upstream client.
+ int v = new SecureRandom().nextInt() & 0x7fffffff;
+ return String.format("%08x", v);
+ }
+
+ private String buildCmdline(String version, String scid) {
+ String videoCodec = Settings.videoCodec(ctx);
+ String audioCodec = Settings.audioCodec(ctx);
+ int maxSize = Settings.maxSize(ctx);
+ int videoBitR = Settings.videoBitRate(ctx);
+ int maxFps = Settings.maxFps(ctx);
+ List<String> args = new ArrayList<>();
+ args.add("CLASSPATH=" + REMOTE_PATH);
+ args.add("app_process");
+ args.add("/");
+ args.add("com.genymobile.scrcpy.Server");
+ args.add(version);
+ args.add("scid=" + scid);
+ args.add("log_level=info");
+ args.add("video=true");
+ args.add("audio=true");
+ args.add("control=true");
+ args.add("video_codec=" + videoCodec);
+ args.add("audio_codec=" + audioCodec);
+ args.add("max_size=" + maxSize);
+ args.add("video_bit_rate=" + videoBitR);
+ if (maxFps > 0) args.add("max_fps=" + maxFps);
+ args.add("clipboard_autosync=true");
+ args.add("tunnel_forward=true");
+ args.add("cleanup=true");
+ args.add("power_on=true");
+ return String.join(" ", args);
+ }
+
+ private AdbStream openAbstract(String scid) throws Exception {
+ String name = "scrcpy_" + scid;
+ long deadline = System.currentTimeMillis() + OPEN_DEADLINE_MS;
+ Throwable last = null;
+ for (int attempt = 1; System.currentTimeMillis() < deadline; attempt++) {
+ // If the shell stream closed (server died early - usually with
+ // an error printed to stderr that our pump captured), bail
+ // immediately. Otherwise we'd spin out the deadline on a
+ // dead target.
+ if (serverEof) {
+ throw new IOException("server exited before opening "
+ + name + " (see 'server:' logs)",
+ last instanceof Exception ? (Exception) last : null);
+ }
+ try {
+ AdbStream s = openAbstractOnce(name, OPEN_ATTEMPT_TIMEOUT_MS);
+ Log.i("openAbstract %s ok (attempt %d)", name, attempt);
+ return s;
+ } catch (Throwable t) {
+ last = t;
+ Thread.sleep(OPEN_BACKOFF_MS);
+ }
+ }
+ throw new IOException("openAbstract " + name + " failed after "
+ + (OPEN_DEADLINE_MS / 1000) + " s",
+ last instanceof Exception ? (Exception) last : null);
+ }
+
+ // Wraps a single adb.openAbstract call with a hard timeout. The
+ // upstream call can wedge forever on a missed notify in its naked
+ // stream.wait(); we run it on a daemon thread and join with timeout,
+ // interrupt on overrun, and let the caller retry. If the orphaned
+ // call succeeds after we gave up, the stream must be closed:
+ // keeping it would silently consume one of the scrcpy server's
+ // three accepts and shift every later dial off by one.
+ private AdbStream openAbstractOnce(String name, long timeoutMs) throws Exception {
+ AtomicReference<AdbStream> result = new AtomicReference<>();
+ AtomicReference<Throwable> err = new AtomicReference<>();
+ AtomicBoolean abandoned = new AtomicBoolean();
+ Thread t = new Thread(() -> {
+ try {
+ AdbStream s = adb.openAbstract(name);
+ result.set(s);
+ if (abandoned.get()) closeQuietly(s);
+ } catch (Throwable ex) {
+ err.set(ex);
+ }
+ }, "openAbstract-" + name);
+ t.setDaemon(true);
+ t.start();
+ t.join(timeoutMs);
+ if (t.isAlive()) {
+ abandoned.set(true);
+ t.interrupt();
+ // One side of the publish/abandon race closes the stream;
+ // closeQuietly tolerates both doing it.
+ closeQuietly(result.get());
+ throw new IOException("openAbstract " + name + " timed out");
+ }
+ Throwable ex = err.get();
+ if (ex instanceof Exception) throw (Exception) ex;
+ if (ex != null) throw new RuntimeException(ex);
+ return result.get();
+ }
+
+ private static String readDeviceMeta(InputStream in) throws IOException {
+ byte[] probe = new byte[1];
+ Wire.readFully(in, probe);
+ byte[] name = new byte[64];
+ Wire.readFully(in, name);
+ int n = 0;
+ while (n < name.length && name[n] != 0) n++;
+ return new String(name, 0, n, StandardCharsets.UTF_8);
+ }
+
+ private void pump(InputStream in) {
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ Log.i("server: %s", line);
+ }
+ } catch (IOException e) {
+ if (!Thread.currentThread().isInterrupted()) Log.w("server-stdout closed: %s", e);
+ } finally {
+ serverEof = true;
+ Log.i("server: stream end");
+ }
+ }
+
+ // ---- adb sync push ----
+
+ private long push() throws Exception {
+ AdbStream sync = adb.openSync();
+ // sync.openInput/OutputStream() return wrapper streams whose close()
+ // is a no-op; the AdbStream itself owns the channel. Close it once
+ // in finally.
+ try (InputStream src = ctx.getAssets().open(ASSET_JAR)) {
+ int mtime = (int)(System.currentTimeMillis() / 1000L);
+ return Sync.push(src, sync.openOutputStream(), sync.openInputStream(),
+ REMOTE_PATH, FILE_MODE, mtime);
+ } finally {
+ try { sync.close(); } catch (IOException ignored) {}
+ }
+ }
+}