aboutsummaryrefslogtreecommitdiff
path: root/app/src/main/java/invalid/lena/scrcpy/Session.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/Session.java
downloadscrcpy-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/Session.java')
-rw-r--r--app/src/main/java/invalid/lena/scrcpy/Session.java318
1 files changed, 318 insertions, 0 deletions
diff --git a/app/src/main/java/invalid/lena/scrcpy/Session.java b/app/src/main/java/invalid/lena/scrcpy/Session.java
new file mode 100644
index 0000000..019ce80
--- /dev/null
+++ b/app/src/main/java/invalid/lena/scrcpy/Session.java
@@ -0,0 +1,318 @@
+package invalid.lena.scrcpy;
+
+import android.content.Context;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+// One mirroring session: owns the Adb connection, the spawned scrcpy
+// server, the three streams, sinks, and the controller. start() and
+// stop() are idempotent and may be called from any thread.
+//
+// Listener contract:
+// onConnected(w,h) - wire open, frames flowing. Fires on the first
+// connect and again after each successful auto-reconnect.
+// onReconnecting() - a live link dropped; the bring-up ladder is rerun.
+// Followed by onConnected (recovered) or onError (gave up).
+// onError(t) - fatal: retries exhausted, or non-retriable failure.
+// Always followed by onStopped().
+// onStopped() - final state. Fires exactly once per session, whether
+// user stop() or retries-exhausted.
+//
+// Auto-reconnect: after a successful connect the video socket is watched;
+// if it ends mid-session (target sleep, Wi-Fi blip, server crash) the
+// session reruns the bring-up ladder instead of freezing. Only when that
+// ladder is exhausted does it give up via onError + onStopped.
+//
+// Surface-readiness race: Mirror.surfaceCreated builds the Session and
+// calls start(), but Mirror.surfaceChanged (which carries the view
+// dimensions) can fire before run() has finished constructing Controller.
+// setViewSize() therefore stashes the value and applies it as soon as
+// the Controller is available.
+public final class Session {
+
+ public interface Listener {
+ // Fires the first time the server reports a session-meta packet
+ // (wire open, frames about to flow), and again after each
+ // successful auto-reconnect. Useful for the activity's status bar.
+ default void onConnected(int w, int h) {}
+ // A previously-live link dropped and we are bringing it back up.
+ // Followed by onConnected (recovered), onError (gave up), or
+ // onStopped alone (user stop() raced the reconnect).
+ default void onReconnecting() {}
+ void onError(Throwable t);
+ void onStopped();
+ }
+
+ // Bring-up retry budget. Total worst-case wait ~ sum of these
+ // delays + per-attempt bring-up time. Keep below e2e.sh's deadline.
+ private static final long[] BACKOFF_MS = {0L, 1_500L, 5_000L};
+
+ private final Context ctx;
+ private final Adb adb;
+ private final Devices.Device target;
+ private volatile Surface surface;
+ private final Listener listener;
+
+ // Lightweight counters for the in-app status overlay; readers reach
+ // into the sinks/controller directly.
+ public long videoFrames() { VideoSink v = videoSink; return v == null ? 0 : v.frames; }
+ public long audioFrames() { AudioSink a = audioSink; return a == null ? 0 : a.frames; }
+ public String lastEvent() { Controller c = controller; return c == null ? "(idle)" : c.lastEvent; }
+
+ private Server server;
+ private VideoStream videoStream;
+ private VideoSink videoSink;
+ private AudioStream audioStream;
+ private AudioSink audioSink;
+ private ControlStream controlStream;
+ private Controller controller;
+ private MuxRecorder recorder;
+ private Thread runner;
+ private volatile boolean stopped;
+ private volatile int pendingViewW, pendingViewH;
+
+ // Counted down by the video reader when its loop exits; the session
+ // thread parks on it for the live duration of a connection. Swapped
+ // for a fresh latch on each reconnect cycle.
+ private volatile CountDownLatch endSignal = new CountDownLatch(1);
+
+ public Session(Context ctx, Adb adb, Devices.Device target, Surface surface, Listener listener) {
+ this.ctx = ctx;
+ this.adb = adb;
+ this.target = target;
+ this.surface = surface;
+ this.listener = listener;
+ }
+
+ public synchronized void start() {
+ if (runner != null) return;
+ runner = new Thread(this::run, "session");
+ runner.start();
+ }
+
+ public synchronized void stop() {
+ if (stopped) return;
+ stopped = true;
+ Log.i("session: stop");
+ tearDownInstalled();
+ if (listener != null) listener.onStopped();
+ }
+
+ // ---- input forwarding (Controller stays internal) ----
+
+ public void onTouch(MotionEvent ev) {
+ Controller c = controller;
+ if (c != null) c.onTouch(ev);
+ }
+
+ public void onKey(KeyEvent ev) {
+ Controller c = controller;
+ if (c != null) c.onKey(ev);
+ }
+
+ public void onBack() {
+ Controller c = controller;
+ if (c != null) c.onBack();
+ }
+
+ public void setViewSize(int w, int h) {
+ pendingViewW = w; pendingViewH = h;
+ Controller c = controller;
+ if (c != null) c.setViewSize(w, h);
+ }
+
+ // ---- recording (delegates to MuxRecorder) ----
+
+ public void armRecording(File out) {
+ MuxRecorder r = recorder;
+ if (r == null) {
+ Log.w("session: armRecording before bring-up - ignored");
+ return;
+ }
+ r.arm(out);
+ }
+
+ public void stopRecording() {
+ MuxRecorder r = recorder;
+ if (r != null) r.stop();
+ }
+
+ public boolean isRecording() {
+ MuxRecorder r = recorder;
+ return r != null && r.isActive();
+ }
+
+ // Swap the surface the video pipeline draws to. The audio and control
+ // sides keep streaming, so audio + clipboard still work while the
+ // activity is backgrounded. Pass null to detach; pass a new Surface
+ // (from a recreated SurfaceView) to resume rendering.
+ public void swapSurface(Surface s) {
+ surface = s;
+ VideoSink vk = videoSink;
+ if (vk != null) vk.setOutputSurface(s);
+ }
+
+ // ---- internals ----
+
+ // Supervisor loop: connect, park until the live pipeline dies, and
+ // reconnect if the death wasn't a user stop(). Runs on one thread for
+ // the whole session lifetime.
+ private void run() {
+ while (!stopped) {
+ if (!connect()) return; // gave up: onError + onStopped fired
+ try {
+ endSignal.await(); // park until the pipeline dies or stop()
+ } catch (InterruptedException ie) {
+ return;
+ }
+ // Decide teardown-and-retry under the monitor so a concurrent
+ // stop() can't interleave: without this, onReconnecting()
+ // could fire after stop()'s onStopped(), breaking the
+ // listener contract.
+ synchronized (this) {
+ if (stopped) return; // user stop()
+ Log.i("session: video stream ended - link lost, reconnecting");
+ tearDownInstalled();
+ endSignal = new CountDownLatch(1);
+ if (listener != null) listener.onReconnecting();
+ }
+ }
+ }
+
+ // Run the bring-up retry ladder once. Returns true when a session is
+ // live (read threads started); false if the budget was exhausted, in
+ // which case onError() + onStopped() have already fired.
+ private boolean connect() {
+ Throwable lastErr = null;
+ for (int attempt = 0; attempt < BACKOFF_MS.length && !stopped; attempt++) {
+ if (BACKOFF_MS[attempt] > 0) {
+ Log.i("session: retry %d/%d after %d ms",
+ attempt + 1, BACKOFF_MS.length, BACKOFF_MS[attempt]);
+ try { Thread.sleep(BACKOFF_MS[attempt]); }
+ catch (InterruptedException ie) { return false; }
+ if (stopped) return false;
+ }
+ try {
+ bringUp();
+ return true; // success; the read threads own the live session
+ } catch (InterruptedException ie) {
+ return false;
+ } catch (Throwable t) {
+ Log.w("session: bring-up attempt %d/%d failed: %s",
+ attempt + 1, BACKOFF_MS.length, t);
+ lastErr = t;
+ // Discard any partial state from this attempt before retrying.
+ tearDownInstalled();
+ }
+ }
+ if (stopped) return false;
+ Log.e(lastErr, "session: gave up after %d attempts", BACKOFF_MS.length);
+ Throwable err = lastErr;
+ synchronized (this) {
+ if (stopped) return false;
+ stopped = true;
+ tearDownInstalled();
+ }
+ if (listener != null) {
+ listener.onError(err);
+ listener.onStopped();
+ }
+ return false;
+ }
+
+ // Build everything into locals first. Then install + start under the
+ // monitor - but only if stop() hasn't already fired, in which case
+ // we tear down the locals we just built so nothing leaks.
+ private void bringUp() throws Exception {
+ Log.i("session: connect %s:%d", target.host, target.port);
+ adb.disconnect();
+ if (!adb.connect(target.host, target.port)) {
+ throw new IOException("adb connect returned false");
+ }
+ Log.i("adb connect ok");
+
+ Server srv = new Server(ctx, adb);
+ Server.Streams s = srv.bringUp();
+
+ ControlStream cs = new ControlStream(s.controlIn, s.controlOut);
+ Controller ctrl = new Controller(ctx, cs::send);
+ cs.setInboundSink(ctrl);
+
+ AudioSink ak = new AudioSink();
+ AudioStream as = new AudioStream(s.audioIn, ak);
+
+ VideoSink vk = new VideoSink(surface);
+ final Controller ctrlRef = ctrl; // capture for SizeListener
+ AtomicBoolean reported = new AtomicBoolean();
+ VideoStream vs = new VideoStream(s.videoIn, vk, (w, h) -> {
+ ctrlRef.setTargetSize(w, h);
+ if (reported.compareAndSet(false, true) && listener != null) {
+ listener.onConnected(w, h);
+ }
+ });
+ vs.setOnEnd(this::onVideoEnded);
+ MuxRecorder rec = new MuxRecorder();
+ vs.setRecorder(rec);
+
+ synchronized (this) {
+ if (stopped) {
+ tearDownLocals(srv, cs, ctrl, ak, as, vk, vs, rec);
+ throw new IOException("session: stopped during bring-up");
+ }
+ server = srv;
+ controlStream = cs;
+ controller = ctrl;
+ audioSink = ak;
+ audioStream = as;
+ videoSink = vk;
+ videoStream = vs;
+ recorder = rec;
+ if (pendingViewW > 0) ctrl.setViewSize(pendingViewW, pendingViewH);
+ }
+
+ cs.start();
+ as.start();
+ vs.start();
+ }
+
+ // Fired on the video-reader thread when its read loop exits (EOF,
+ // error, or stop()). The video socket is the authoritative stream;
+ // its end wakes the supervisor, which either unwinds (stop) or
+ // reconnects. A no-op countdown after stop() is harmless.
+ private void onVideoEnded() {
+ endSignal.countDown();
+ }
+
+ private synchronized void tearDownInstalled() {
+ tearDownLocals(server, controlStream, controller,
+ audioSink, audioStream, videoSink, videoStream, recorder);
+ server = null;
+ controlStream = null;
+ controller = null;
+ audioSink = null;
+ audioStream = null;
+ videoSink = null;
+ videoStream = null;
+ recorder = null;
+ try { adb.disconnect(); } catch (Exception ignored) {}
+ }
+
+ private static void tearDownLocals(Server srv, ControlStream cs, Controller ctrl,
+ AudioSink ak, AudioStream as,
+ VideoSink vk, VideoStream vs, MuxRecorder rec) {
+ if (rec != null) rec.close();
+ if (vs != null) vs.stop();
+ if (vk != null) vk.release();
+ if (as != null) as.stop();
+ if (ak != null) ak.release();
+ if (cs != null) cs.stop();
+ if (ctrl != null) ctrl.release();
+ if (srv != null) srv.close();
+ }
+}