diff options
| author | Lena <lena@omega> | 2026-01-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-06-24 22:14:50 +0300 |
| commit | eb0c8951196c637e44daf3c0617b131b997d5d2c (patch) | |
| tree | 2f83b6a41cee745467e53fc401942b82cea286ea /app/src/main/java/invalid/lena/scrcpy/Server.java | |
| download | scrcpy-android-eb0c8951196c637e44daf3c0617b131b997d5d2c.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.java | 304 |
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) {} + } + } +} |