1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
|
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();
}
}
|