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 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_ 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 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 result = new AtomicReference<>(); AtomicReference 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) {} } } }