@tapflowio/android-agent 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
@@ -1,6 +1,7 @@
1
1
  import type { Device, DeviceAgent } from '@tapflowio/agent-core';
2
2
  import { type SleepBlocker } from '@tapflowio/agent-core/utils';
3
3
  import { AdbWrapper } from './AdbWrapper.js';
4
+ export declare function pickAndroidBackend(serial: string, env?: NodeJS.ProcessEnv): 'grpc' | 'scrcpy';
4
5
  export interface AndroidAgentOptions {
5
6
  fps?: number;
6
7
  /** AVD name or emulator serial to expose. Omit to expose all detected devices. */
@@ -32,7 +33,14 @@ export declare class AndroidAgent implements DeviceAgent {
32
33
  private reportResources;
33
34
  private cleanupDeviceState;
34
35
  private sendDeviceInfo;
36
+ private forceScrcpy;
37
+ private useGrpc;
38
+ private pointerControl;
39
+ private fire;
40
+ private grpcPort;
35
41
  private startVideoStream;
42
+ private pumpVideo;
43
+ private startGrpcVideoStream;
36
44
  private restartVideoStream;
37
45
  private openStreamWs;
38
46
  private handleDeviceBoot;
@@ -1 +1 @@
1
- {"version":3,"file":"AndroidAgent.d.ts","sourceRoot":"","sources":["../src/AndroidAgent.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAiB,MAAM,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAE/E,OAAO,EAOL,KAAK,YAAY,EAIlB,MAAM,6BAA6B,CAAA;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AA+G5C,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IAC1B,2FAA2F;IAC3F,YAAY,CAAC,EAAE,YAAY,CAAA;CAC5B;AAED,qBAAa,YAAa,YAAW,WAAW;IAC9C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAY;IAChC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkB;IAC3C,OAAO,CAAC,EAAE,CAAyB;IACnC,OAAO,CAAC,YAAY,CAAiC;IAGrD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAc;IAC3C,OAAO,CAAC,QAAQ,CAAsB;IACtC,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,eAAe,CAA6C;IACpE,OAAO,CAAC,iBAAiB,CAAI;IAE7B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAU;gBAE9B,OAAO,GAAE,mBAAwB,EAAE,GAAG,CAAC,EAAE,UAAU;IAS/D,IAAI,SAAS,IAAI,MAAM,GAAG,IAAI,CAG7B;IAEK,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmD9C,OAAO,CAAC,gBAAgB;IAsBxB,UAAU,IAAI,IAAI;IAclB,OAAO,CAAC,kBAAkB;IA0B1B,OAAO,CAAC,eAAe;IAkBvB,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,cAAc;YAYR,gBAAgB;YA0EhB,kBAAkB;YAmClB,YAAY;YAOZ,gBAAgB;YA2DhB,oBAAoB;IAsBlC,OAAO,CAAC,kBAAkB;YAyNZ,cAAc;IAqC5B,WAAW,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAE1B,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQlC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQtC,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO1C,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO7C,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IAOnC,MAAM,IAAI,cAAc,CAAC,MAAM,CAAC;IAUhC,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI;IAKtC,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAM9C,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAMnB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAM1C"}
1
+ {"version":3,"file":"AndroidAgent.d.ts","sourceRoot":"","sources":["../src/AndroidAgent.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAiB,MAAM,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAA;AAE/E,OAAO,EASL,KAAK,YAAY,EAKlB,MAAM,6BAA6B,CAAA;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAqI5C,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,GAAG,QAAQ,CAI1G;AAED,MAAM,WAAW,mBAAmB;IAClC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IAC1B,2FAA2F;IAC3F,YAAY,CAAC,EAAE,YAAY,CAAA;CAC5B;AAED,qBAAa,YAAa,YAAW,WAAW;IAC9C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAY;IAChC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkB;IAC3C,OAAO,CAAC,EAAE,CAAyB;IACnC,OAAO,CAAC,YAAY,CAAiC;IAGrD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAc;IAC3C,OAAO,CAAC,QAAQ,CAAsB;IACtC,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,eAAe,CAA6C;IACpE,OAAO,CAAC,iBAAiB,CAAI;IAE7B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAU;gBAE9B,OAAO,GAAE,mBAAwB,EAAE,GAAG,CAAC,EAAE,UAAU;IAS/D,IAAI,SAAS,IAAI,MAAM,GAAG,IAAI,CAG7B;IAEK,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAoD9C,OAAO,CAAC,gBAAgB;IA2BxB,UAAU,IAAI,IAAI;IAclB,OAAO,CAAC,kBAAkB;IA0B1B,OAAO,CAAC,eAAe;IAkBvB,OAAO,CAAC,kBAAkB;IAe1B,OAAO,CAAC,cAAc;IAYtB,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,cAAc;IAOtB,OAAO,CAAC,IAAI;IAQZ,OAAO,CAAC,QAAQ;YAIF,gBAAgB;YA2DhB,SAAS;YAsDT,oBAAoB;YA6CpB,kBAAkB;YAuClB,YAAY;YAOZ,gBAAgB;YA6DhB,oBAAoB;IAsBlC,OAAO,CAAC,kBAAkB;YAiOZ,cAAc;IAqC5B,WAAW,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;IAE1B,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQlC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQtC,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO1C,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO7C,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC;IAOnC,MAAM,IAAI,cAAc,CAAC,MAAM,CAAC;IAWhC,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,IAAI;IAKtC,SAAS,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAM9C,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAMnB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAM1C"}
@@ -1,11 +1,13 @@
1
1
  import os from 'os';
2
2
  import { WebSocket } from 'ws';
3
3
  import { createLogger, PlatformError, ValidationError } from '@tapflowio/agent-core';
4
- import { createResourceSampler, registerStreamWs, sendBinaryWithBackpressure, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, CODEC_H264, } from '@tapflowio/agent-core/utils';
4
+ import { createResourceSampler, registerStreamWs, disableNagle, createKeyframeAwareSender, pickMaxSize, createRateLimitedDropWarn, createThroughputSampler, createSleepBlocker, DEFAULT_BACKPRESSURE_BYTES, writeEnvelopeHeader, rewriteLowLatencySpsInFrame, CODEC_H264, } from '@tapflowio/agent-core/utils';
5
5
  import { AdbWrapper } from './AdbWrapper.js';
6
6
  import { EmulatorLauncher } from './EmulatorLauncher.js';
7
7
  import { AndroidTouchHelper } from './AndroidTouchHelper.js';
8
8
  import { ScrcpySession } from './scrcpy/ScrcpySession.js';
9
+ import { EmulatorGrpcClient } from './emulator/EmulatorGrpcClient.js';
10
+ import { EmulatorVideo } from './emulator/EmulatorVideo.js';
9
11
  const logger = createLogger('android-agent');
10
12
  // Parse H.264 SPS NAL unit to extract frame dimensions.
11
13
  // scrcpy sends a new SPS (inside an IDR keyframe) whenever the capture size changes —
@@ -110,6 +112,16 @@ const ANDROID_BUTTONS = [
110
112
  { name: 'volume_down', accessibilityTitle: 'Volume Down', keyCode: 25 },
111
113
  { name: 'power', accessibilityTitle: 'Power', keyCode: 26 },
112
114
  ];
115
+ // Video backend per device: emulators (serial `emulator-*`) default to the gRPC host-encode path
116
+ // (bypasses the guest SW H.264 encoder); real devices use scrcpy (their SoC has a HW encoder).
117
+ // `TAPFLOW_ANDROID_BACKEND=scrcpy|grpc` overrides either way.
118
+ export function pickAndroidBackend(serial, env = process.env) {
119
+ if (env.TAPFLOW_ANDROID_BACKEND === 'scrcpy')
120
+ return 'scrcpy';
121
+ if (env.TAPFLOW_ANDROID_BACKEND === 'grpc')
122
+ return 'grpc';
123
+ return serial.startsWith('emulator-') ? 'grpc' : 'scrcpy';
124
+ }
113
125
  export class AndroidAgent {
114
126
  adb;
115
127
  launcher;
@@ -152,6 +164,7 @@ export class AndroidAgent {
152
164
  return new Promise((resolve, reject) => {
153
165
  const ws = new WebSocket(relayUrl);
154
166
  ws.once('open', () => {
167
+ disableNagle(ws);
155
168
  ws.send(JSON.stringify({
156
169
  type: 'agent:register',
157
170
  platform: 'android',
@@ -197,6 +210,11 @@ export class AndroidAgent {
197
210
  touchHelper: null,
198
211
  streamWs: null,
199
212
  scrcpySession: null,
213
+ emulatorVideo: null,
214
+ grpcClient: null,
215
+ cornerRadius: 0,
216
+ secureContext: false,
217
+ external: false,
200
218
  displayWidth: 0,
201
219
  displayHeight: 0,
202
220
  videoWidth: 0,
@@ -258,7 +276,7 @@ export class AndroidAgent {
258
276
  reportResources() {
259
277
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
260
278
  return;
261
- const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.scrcpySession !== null).length;
279
+ const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.scrcpySession !== null || s.emulatorVideo !== null).length;
262
280
  const slotsTotal = this.deviceStates.size;
263
281
  const { memUsedMB, memTotalMB } = this.resources.getMemoryUsage();
264
282
  this.ws.send(JSON.stringify({
@@ -278,6 +296,11 @@ export class AndroidAgent {
278
296
  if (serial && state.scrcpySession)
279
297
  state.scrcpySession.stop(serial);
280
298
  state.scrcpySession = null;
299
+ state.emulatorVideo?.stop();
300
+ state.emulatorVideo = null;
301
+ state.grpcClient?.close();
302
+ state.grpcClient = null;
303
+ state.cornerRadius = 0;
281
304
  state.touchHelper?.stop();
282
305
  state.touchHelper = null;
283
306
  state.streamWs?.close();
@@ -295,10 +318,55 @@ export class AndroidAgent {
295
318
  },
296
319
  }));
297
320
  }
321
+ forceScrcpy() {
322
+ return process.env.TAPFLOW_ANDROID_BACKEND === 'scrcpy';
323
+ }
324
+ useGrpc(serial) {
325
+ return pickAndroidBackend(serial) === 'grpc';
326
+ }
327
+ // The active low-latency pointer backend (gRPC preferred), or null when only the ADB fallback
328
+ // (AndroidTouchHelper) is available. Coordinates go in state.videoWidth/Height px.
329
+ pointerControl(state) {
330
+ if (state.grpcClient)
331
+ return state.grpcClient;
332
+ if (state.scrcpySession)
333
+ return state.scrcpySession.control;
334
+ return null;
335
+ }
336
+ // Fire-and-forget a pointer call: gRPC methods are async (swallow rejection), scrcpy sync (no-op).
337
+ fire(r) {
338
+ if (r)
339
+ r.catch(() => { });
340
+ }
341
+ // Port to launch the emulator with (`-grpc <port>`, unsecured localhost). A plain `-grpc` endpoint
342
+ // is unsecured; without it the emulator opens its DEFAULT gRPC port with token auth, which our
343
+ // unauthenticated client can't use. Launches are always AVDs (emulators), so default to gRPC
344
+ // unless explicitly forced to scrcpy. undefined = don't open gRPC.
345
+ grpcPort() {
346
+ return this.forceScrcpy() ? undefined : (Number(process.env.TAPFLOW_ANDROID_GRPC_PORT) || 8554);
347
+ }
298
348
  async startVideoStream(state, streamWs) {
299
349
  const serial = this.adb.getSerial(state.deviceId);
300
350
  if (!serial)
301
351
  return;
352
+ // Emulator: capture via gRPC streamScreenshot + Mac VideoToolbox (bypasses the guest SW H.264
353
+ // encoder). On any failure (e.g. an externally-booted emulator without `-grpc`), fall back to
354
+ // scrcpy so streaming still works.
355
+ if (this.useGrpc(serial)) {
356
+ try {
357
+ await this.startGrpcVideoStream(state, streamWs, serial);
358
+ return;
359
+ }
360
+ catch (e) {
361
+ logger.warn(`gRPC backend failed (${e.message}) — falling back to scrcpy`);
362
+ state.emulatorVideo?.stop();
363
+ state.emulatorVideo = null;
364
+ state.grpcClient?.close();
365
+ state.grpcClient = null;
366
+ state.touchHelper?.stop();
367
+ state.touchHelper = null;
368
+ }
369
+ }
302
370
  const touchHelper = new AndroidTouchHelper(this.adb, serial);
303
371
  touchHelper.start();
304
372
  state.touchHelper = touchHelper;
@@ -310,14 +378,46 @@ export class AndroidAgent {
310
378
  state.displayHeight = info.height;
311
379
  state.videoWidth = info.width;
312
380
  state.videoHeight = info.height;
313
- const stream = session.video.start();
314
- const reader = stream.getReader();
381
+ const reader = session.video.start().getReader();
382
+ // Detect video size changes via H.264 SPS so ScrcpyControl.screenSize always matches what
383
+ // scrcpy is encoding (landscape-aware vs portrait-locked). The SPS leads the keyframe AU.
384
+ const onFrame = (value) => {
385
+ const parsed = parseSpsFromNal(value.payload);
386
+ if (parsed && (parsed.width !== state.videoWidth || parsed.height !== state.videoHeight)) {
387
+ state.videoWidth = parsed.width;
388
+ state.videoHeight = parsed.height;
389
+ state.scrcpySession?.control.updateScreenSize(parsed.width, parsed.height);
390
+ logger.info(`video size → ${parsed.width}×${parsed.height}`);
391
+ }
392
+ };
393
+ void this.pumpVideo(state, streamWs, reader, onFrame, () => session.control.resetVideo()).then(() => {
394
+ if (state.scrcpySession === session && !state.restarting) {
395
+ state.restarting = true;
396
+ void this.restartVideoStream(state);
397
+ }
398
+ });
399
+ }
400
+ // Shared frame pump for both video backends: reads H.264 access units, wraps each in the TFFE
401
+ // envelope (codec + per-AU keyframe flag, so the relay's keyframe-aware backpressure preserves the
402
+ // reference chain), and sends with backpressure + optional throughput metrics. `onFrame` lets a
403
+ // backend inspect each frame (scrcpy parses SPS for size). Resolves when the source stream ends.
404
+ async pumpVideo(state, streamWs, reader, onFrame, requestIdr) {
315
405
  const threshold = Number(process.env.TAPFLOW_WS_BACKPRESSURE_BYTES) || DEFAULT_BACKPRESSURE_BYTES;
316
406
  const warnDrop = createRateLimitedDropWarn(logger, state.deviceId);
317
- // Opt-in throughput baseline (TAPFLOW_STREAM_METRICS=1): logs fps/KB·s/drop every 5s,
318
- // so the Android source rate can be compared against the relay→browser drop logs and
319
- // against the iOS agent (which already samples). Distinguishes source-bound (low fpsSent)
320
- // from decode/LAN-bound (high fpsSent, high dropRate).
407
+ // Keyframe-aware backpressure: when the agent→relay socket fills, drop whole GOPs to the next
408
+ // keyframe (never forward an orphan P-frame whose reference was dropped that decodes to a
409
+ // sheared/ghosted frame until the next IDR). On a drop with no keyframe, ask the encoder for an
410
+ // IDR (throttled) so the stream resyncs fast instead of waiting for the periodic one.
411
+ const dropper = createKeyframeAwareSender();
412
+ let lastIdrReq = 0;
413
+ const onWantKeyframe = requestIdr
414
+ ? () => { const now = Date.now(); if (now - lastIdrReq >= 500) {
415
+ lastIdrReq = now;
416
+ requestIdr();
417
+ } }
418
+ : undefined;
419
+ // Opt-in throughput baseline (TAPFLOW_STREAM_METRICS=1): logs fps/KB·s/drop every 5s, so the
420
+ // Android source rate can be compared against the relay→browser drop logs and the iOS agent.
321
421
  const metrics = process.env.TAPFLOW_STREAM_METRICS === '1' ? createThroughputSampler() : null;
322
422
  const metricsTimer = metrics
323
423
  ? setInterval(() => {
@@ -327,41 +427,71 @@ export class AndroidAgent {
327
427
  : undefined;
328
428
  metricsTimer?.unref();
329
429
  const onDrop = metrics ? () => { metrics.recordDropped(); warnDrop(); } : warnDrop;
330
- const pump = async () => {
331
- try {
332
- while (true) {
333
- const { value, done } = await reader.read();
334
- if (done)
335
- break;
336
- // Detect video size changes via H.264 SPS so ScrcpyControl.screenSize always
337
- // matches what scrcpy server is actually encoding (landscape-aware vs portrait-locked).
338
- // The SPS leads the keyframe access unit, so parsing value.payload finds it.
339
- const parsed = parseSpsFromNal(value.payload);
340
- if (parsed && (parsed.width !== state.videoWidth || parsed.height !== state.videoHeight)) {
341
- state.videoWidth = parsed.width;
342
- state.videoHeight = parsed.height;
343
- state.scrcpySession?.control.updateScreenSize(parsed.width, parsed.height);
344
- logger.info(`video size → ${parsed.width}×${parsed.height}`);
345
- }
346
- // Mark codec=H.264 + per-AU keyframe so the relay's keyframe-aware backpressure
347
- // preserves the reference chain (drops to the next IDR instead of tearing).
348
- const frame = writeEnvelopeHeader(value.payload, Date.now(), { codec: CODEC_H264, keyframe: value.keyframe });
349
- const sent = sendBinaryWithBackpressure(streamWs, frame, threshold, onDrop);
350
- if (sent)
351
- metrics?.recordSent(value.payload.length);
352
- }
353
- }
354
- catch {
355
- // stream cancelled or ws closed — expected on disconnect
430
+ try {
431
+ while (true) {
432
+ const { value, done } = await reader.read();
433
+ if (done)
434
+ break;
435
+ onFrame?.(value);
436
+ // Declare reorder=0 on the keyframe SPS so the decoder (WASM/WebCodecs) emits frames
437
+ // immediately instead of buffering the level's max DPB (~hundreds of ms of latency on every
438
+ // frame). The gRPC/VideoToolbox SPS omits bitstream_restriction; scrcpy's is a no-op.
439
+ const payload = value.keyframe ? rewriteLowLatencySpsInFrame(value.payload) : value.payload;
440
+ const frame = writeEnvelopeHeader(payload, Date.now(), { codec: CODEC_H264, keyframe: value.keyframe });
441
+ const sent = dropper.send(streamWs, frame, threshold, value.keyframe, onDrop, onWantKeyframe);
442
+ if (sent)
443
+ metrics?.recordSent(value.payload.length);
356
444
  }
357
- if (metricsTimer)
358
- clearInterval(metricsTimer);
359
- if (state.scrcpySession === session && !state.restarting) {
445
+ }
446
+ catch {
447
+ // stream cancelled or ws closed — expected on disconnect
448
+ }
449
+ if (metricsTimer)
450
+ clearInterval(metricsTimer);
451
+ }
452
+ // gRPC emulator backend: capture via EmulatorVideo (gRPC streamScreenshot + Mac VT encode) through
453
+ // the shared pump. Input is routed to the gRPC client in handleRelayMessage. No auto-restart —
454
+ // the backend is torn down with the device state.
455
+ async startGrpcVideoStream(state, streamWs, serial) {
456
+ const port = this.grpcPort() ?? 8554;
457
+ // Downscale box (longest side), server-side resize. Per-session tier from the viewer context
458
+ // (secure→native / LAN-HTTP→1280 / external→1000); TAPFLOW_ANDROID_MAX_SIZE | TAPFLOW_MAX_SIZE
459
+ // is a hard override.
460
+ const maxSize = pickMaxSize({
461
+ secureContext: state.secureContext,
462
+ external: state.external,
463
+ override: process.env.TAPFLOW_ANDROID_MAX_SIZE ?? process.env.TAPFLOW_MAX_SIZE,
464
+ });
465
+ // Default 30fps (iOS parity) — caps source 60fps to halve decode/transport for LAN-HTTP.
466
+ const fps = Number(process.env.TAPFLOW_ANDROID_FPS) || 30;
467
+ const touchHelper = new AndroidTouchHelper(this.adb, serial);
468
+ touchHelper.start();
469
+ state.touchHelper = touchHelper;
470
+ const client = new EmulatorGrpcClient(`127.0.0.1:${port}`);
471
+ const video = new EmulatorVideo(client, { fps, ...(maxSize ? { maxWidth: maxSize, maxHeight: maxSize } : {}) });
472
+ // Assign before start() so the caller's fallback cleanup can tear these down on failure.
473
+ state.grpcClient = client;
474
+ state.emulatorVideo = video;
475
+ const info = await video.start();
476
+ // gRPC sendTouch coordinates are in the device's native display resolution — not the (possibly
477
+ // downscaled) video size — so query it for correct normalized→px touch mapping. The aspect
478
+ // ratio matches the downscaled video, so native dims also drive the dashboard chrome.
479
+ const native = await this.adb.getScreenSize(serial).catch(() => ({ width: info.width, height: info.height }));
480
+ state.landscape = false;
481
+ state.displayWidth = native.width;
482
+ state.displayHeight = native.height;
483
+ state.videoWidth = native.width;
484
+ state.videoHeight = native.height;
485
+ state.cornerRadius = info.cornerRadius;
486
+ const reader = video.frames().getReader();
487
+ // If the gRPC video ends unexpectedly (emulator crash / disconnect), restart the stream so the
488
+ // session recovers instead of going dead — mirrors the scrcpy pump's auto-restart.
489
+ void this.pumpVideo(state, streamWs, reader, undefined, () => video.requestIdr()).then(() => {
490
+ if (state.emulatorVideo === video && !state.restarting) {
360
491
  state.restarting = true;
361
492
  void this.restartVideoStream(state);
362
493
  }
363
- };
364
- void pump();
494
+ });
365
495
  }
366
496
  async restartVideoStream(state) {
367
497
  const serial = this.adb.getSerial(state.deviceId);
@@ -371,6 +501,10 @@ export class AndroidAgent {
371
501
  }
372
502
  state.scrcpySession?.stop(serial);
373
503
  state.scrcpySession = null;
504
+ state.emulatorVideo?.stop();
505
+ state.emulatorVideo = null;
506
+ state.grpcClient?.close();
507
+ state.grpcClient = null;
374
508
  state.touchHelper?.stop();
375
509
  state.touchHelper = null;
376
510
  const { streamWs } = state;
@@ -404,12 +538,16 @@ export class AndroidAgent {
404
538
  await registerStreamWs(streamWs, state.sessionId);
405
539
  return streamWs;
406
540
  }
407
- async handleDeviceBoot(sessionId, avdId) {
541
+ async handleDeviceBoot(sessionId, avdId, tier) {
408
542
  const state = this.deviceStates.get(sessionId);
409
543
  if (!state || !this.ws)
410
544
  return;
411
545
  const seq = ++state.bootSeq;
412
546
  this.cleanupDeviceState(state);
547
+ if (tier) {
548
+ state.secureContext = tier.secureContext;
549
+ state.external = tier.external;
550
+ }
413
551
  this.ws.send(JSON.stringify({ type: 'device:booting', sessionId }));
414
552
  try {
415
553
  const avdName = avdId.replace(/^avd:/, '');
@@ -420,7 +558,7 @@ export class AndroidAgent {
420
558
  if (!target)
421
559
  throw new PlatformError(`Device not found: ${avdId}`);
422
560
  if (target.status !== 'booted') {
423
- this.launcher.launch(avdName);
561
+ this.launcher.launch(avdName, this.grpcPort());
424
562
  const serial = await this.launcher.findSerial(avdName);
425
563
  if (seq !== state.bootSeq)
426
564
  return;
@@ -450,6 +588,7 @@ export class AndroidAgent {
450
588
  streamType: 'h264',
451
589
  screenWidth: state.displayWidth,
452
590
  screenHeight: state.displayHeight,
591
+ cornerRadius: state.cornerRadius,
453
592
  },
454
593
  }));
455
594
  this.ws?.send(JSON.stringify({ type: 'device:ready', sessionId, payload: { deviceId: avdId } }));
@@ -485,8 +624,8 @@ export class AndroidAgent {
485
624
  handleRelayMessage(msg) {
486
625
  switch (msg.type) {
487
626
  case 'device:boot': {
488
- const { deviceId } = msg.payload;
489
- this.handleDeviceBoot(msg.sessionId, deviceId)
627
+ const { deviceId, secureContext, external } = msg.payload;
628
+ this.handleDeviceBoot(msg.sessionId, deviceId, { secureContext: !!secureContext, external: !!external })
490
629
  .catch((e) => logger.error('handleDeviceBoot failed:', e));
491
630
  break;
492
631
  }
@@ -548,11 +687,12 @@ export class AndroidAgent {
548
687
  if (!state)
549
688
  break;
550
689
  const { x, y } = msg.payload;
551
- if (state.scrcpySession && state.videoWidth > 0) {
690
+ const pc = this.pointerControl(state);
691
+ if (pc && state.videoWidth > 0) {
552
692
  const px = Math.round(x * state.videoWidth);
553
693
  const py = Math.round(y * state.videoHeight);
554
694
  state.lastTouchPx = { x: px, y: py };
555
- state.scrcpySession.control.touchDown(0, px, py);
695
+ this.fire(pc.touchDown(0, px, py));
556
696
  }
557
697
  else {
558
698
  state.touchHelper?.touchStart(x, y);
@@ -564,11 +704,12 @@ export class AndroidAgent {
564
704
  if (!state)
565
705
  break;
566
706
  const { x, y } = msg.payload;
567
- if (state.scrcpySession && state.videoWidth > 0) {
707
+ const pc = this.pointerControl(state);
708
+ if (pc && state.videoWidth > 0) {
568
709
  const px = Math.round(x * state.videoWidth);
569
710
  const py = Math.round(y * state.videoHeight);
570
711
  state.lastTouchPx = { x: px, y: py };
571
- state.scrcpySession.control.touchMove(0, px, py);
712
+ this.fire(pc.touchMove(0, px, py));
572
713
  }
573
714
  else {
574
715
  state.touchHelper?.touchMove(x, y);
@@ -579,8 +720,9 @@ export class AndroidAgent {
579
720
  const state = this.deviceStates.get(msg.sessionId);
580
721
  if (!state)
581
722
  break;
582
- if (state.scrcpySession) {
583
- state.scrcpySession.control.touchUp(0, state.lastTouchPx.x, state.lastTouchPx.y);
723
+ const pc = this.pointerControl(state);
724
+ if (pc) {
725
+ this.fire(pc.touchUp(0, state.lastTouchPx.x, state.lastTouchPx.y));
584
726
  }
585
727
  else {
586
728
  state.touchHelper?.touchEnd();
@@ -592,10 +734,11 @@ export class AndroidAgent {
592
734
  if (!state)
593
735
  break;
594
736
  const { f0, f1 } = msg.payload;
595
- if (state.scrcpySession && state.videoWidth > 0) {
737
+ const pc = this.pointerControl(state);
738
+ if (pc && state.videoWidth > 0) {
596
739
  const px1 = Math.round(f0.x * state.videoWidth), py1 = Math.round(f0.y * state.videoHeight);
597
740
  const px2 = Math.round(f1.x * state.videoWidth), py2 = Math.round(f1.y * state.videoHeight);
598
- state.scrcpySession.control.pinchStart(px1, py1, px2, py2);
741
+ this.fire(pc.pinchStart(px1, py1, px2, py2));
599
742
  }
600
743
  else {
601
744
  state.touchHelper?.pinchStart(f0.x, f0.y, f1.x, f1.y);
@@ -607,10 +750,11 @@ export class AndroidAgent {
607
750
  if (!state)
608
751
  break;
609
752
  const { f0, f1 } = msg.payload;
610
- if (state.scrcpySession && state.videoWidth > 0) {
753
+ const pc = this.pointerControl(state);
754
+ if (pc && state.videoWidth > 0) {
611
755
  const px1 = Math.round(f0.x * state.videoWidth), py1 = Math.round(f0.y * state.videoHeight);
612
756
  const px2 = Math.round(f1.x * state.videoWidth), py2 = Math.round(f1.y * state.videoHeight);
613
- state.scrcpySession.control.pinchMove(px1, py1, px2, py2);
757
+ this.fire(pc.pinchMove(px1, py1, px2, py2));
614
758
  }
615
759
  else {
616
760
  state.touchHelper?.pinchMove(f0.x, f0.y, f1.x, f1.y);
@@ -621,8 +765,9 @@ export class AndroidAgent {
621
765
  const state = this.deviceStates.get(msg.sessionId);
622
766
  if (!state)
623
767
  break;
624
- if (state.scrcpySession) {
625
- state.scrcpySession.control.pinchEnd();
768
+ const pc = this.pointerControl(state);
769
+ if (pc) {
770
+ this.fire(pc.pinchEnd());
626
771
  }
627
772
  else {
628
773
  state.touchHelper?.pinchEnd();
@@ -654,9 +799,11 @@ export class AndroidAgent {
654
799
  break;
655
800
  }
656
801
  case 'stream:request-idr': {
657
- // Relay drop-to-keyframe recovery: reset the encoder so it re-emits SPS/PPS + IDR,
802
+ // Relay drop-to-keyframe / join recovery: reset the encoder so it re-emits SPS/PPS + IDR,
658
803
  // resyncing fast instead of waiting for the periodic IDR. Throttled by the relay.
659
- this.deviceStates.get(msg.sessionId)?.scrcpySession?.control.resetVideo();
804
+ const st = this.deviceStates.get(msg.sessionId);
805
+ st?.scrcpySession?.control.resetVideo();
806
+ st?.emulatorVideo?.requestIdr();
660
807
  break;
661
808
  }
662
809
  case 'open-url': {
@@ -793,11 +940,12 @@ export class AndroidAgent {
793
940
  }
794
941
  stream() {
795
942
  const state = this.deviceStates.values().next().value;
796
- if (!state?.scrcpySession)
797
- throw new ValidationError('no active scrcpy session — call connect() first');
943
+ // Works on either video backend (scrcpy for real devices, gRPC host-encode for emulators).
944
+ const frames = state?.scrcpySession?.video.start() ?? state?.emulatorVideo?.frames();
945
+ if (!frames)
946
+ throw new ValidationError('no active video stream — call connect() first');
798
947
  // DeviceAgent.stream() is the platform-neutral Buffer contract; unwrap ScrcpyFrame payloads.
799
- return state.scrcpySession.video.start()
800
- .pipeThrough(new TransformStream({
948
+ return frames.pipeThrough(new TransformStream({
801
949
  transform(frame, controller) { controller.enqueue(frame.payload); },
802
950
  }));
803
951
  }