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); } // Returns true if a file was actually written (see MuxRecorder.stop). public boolean stopRecording() { MuxRecorder r = recorder; return 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(); adb.connect(target.host, target.port); Log.i("adb connect ok"); Server srv = null; ControlStream cs = null; Controller ctrl = null; AudioSink ak = null; AudioStream as = null; VideoSink vk = null; VideoStream vs = null; MuxRecorder rec = null; boolean installed = false; try { srv = new Server(ctx, adb); Server.Streams s = srv.bringUp(); cs = new ControlStream(s.controlIn, s.controlOut, this::onVideoEnded); ctrl = new Controller(ctx, cs::send); cs.setInboundSink(ctrl); ak = new AudioSink(this::onVideoEnded); as = new AudioStream(s.audioIn, ak); vk = new VideoSink(surface, this::onVideoEnded); Controller ctrlRef = ctrl; AtomicBoolean reported = new AtomicBoolean(); vs = new VideoStream(s.videoIn, vk, (w, h) -> reportConnected(ctrlRef, reported, w, h)); vs.setOnEnd(this::onVideoEnded); rec = new MuxRecorder(); vs.setRecorder(rec); // Start locals before publishing them. stop() either tears down // a previously installed generation or marks this generation for // rollback; it can never release objects that bringUp then starts. cs.start(); as.start(); vs.start(); synchronized (this) { if (stopped) 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); installed = true; } } finally { if (!installed) tearDownLocals(srv, cs, ctrl, ak, as, vk, vs, rec); } } private synchronized void reportConnected(Controller ctrl, AtomicBoolean reported, int w, int h) { if (stopped) return; ctrl.setTargetSize(w, h); if (reported.compareAndSet(false, true) && listener != null) { listener.onConnected(w, h); } } // 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(); // Closing the owning ADB streams first unblocks readers. Join them // before releasing their sinks so no callback can recreate resources // after teardown. if (srv != null) srv.close(); if (vs != null) vs.stop(); if (as != null) as.stop(); if (cs != null) cs.stop(); if (vk != null) vk.release(); if (ak != null) ak.release(); if (ctrl != null) ctrl.release(); } }