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/Session.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/Session.java')
| -rw-r--r-- | app/src/main/java/invalid/lena/scrcpy/Session.java | 318 |
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(); + } +} |