@tapflowio/android-agent 0.1.0-alpha.1

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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/bin/.gitkeep +0 -0
  4. package/bin/scrcpy-server.jar +0 -0
  5. package/dist/AdbWrapper.d.ts +28 -0
  6. package/dist/AdbWrapper.d.ts.map +1 -0
  7. package/dist/AdbWrapper.js +138 -0
  8. package/dist/AdbWrapper.js.map +1 -0
  9. package/dist/AndroidAgent.d.ts +44 -0
  10. package/dist/AndroidAgent.d.ts.map +1 -0
  11. package/dist/AndroidAgent.js +695 -0
  12. package/dist/AndroidAgent.js.map +1 -0
  13. package/dist/AndroidTouchHelper.d.ts +23 -0
  14. package/dist/AndroidTouchHelper.d.ts.map +1 -0
  15. package/dist/AndroidTouchHelper.js +73 -0
  16. package/dist/AndroidTouchHelper.js.map +1 -0
  17. package/dist/EmulatorLauncher.d.ts +6 -0
  18. package/dist/EmulatorLauncher.d.ts.map +1 -0
  19. package/dist/EmulatorLauncher.js +75 -0
  20. package/dist/EmulatorLauncher.js.map +1 -0
  21. package/dist/adb.d.ts +7 -0
  22. package/dist/adb.d.ts.map +1 -0
  23. package/dist/adb.js +41 -0
  24. package/dist/adb.js.map +1 -0
  25. package/dist/index.d.ts +9 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +8 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/scrcpy/ScrcpyControl.d.ts +17 -0
  30. package/dist/scrcpy/ScrcpyControl.d.ts.map +1 -0
  31. package/dist/scrcpy/ScrcpyControl.js +99 -0
  32. package/dist/scrcpy/ScrcpyControl.js.map +1 -0
  33. package/dist/scrcpy/ScrcpySession.d.ts +20 -0
  34. package/dist/scrcpy/ScrcpySession.d.ts.map +1 -0
  35. package/dist/scrcpy/ScrcpySession.js +127 -0
  36. package/dist/scrcpy/ScrcpySession.js.map +1 -0
  37. package/dist/scrcpy/ScrcpyVideo.d.ts +24 -0
  38. package/dist/scrcpy/ScrcpyVideo.d.ts.map +1 -0
  39. package/dist/scrcpy/ScrcpyVideo.js +145 -0
  40. package/dist/scrcpy/ScrcpyVideo.js.map +1 -0
  41. package/package.json +64 -0
@@ -0,0 +1,695 @@
1
+ import os from 'os';
2
+ import { WebSocket } from 'ws';
3
+ import { createLogger } from '@tapflowio/agent-core';
4
+ import { createResourceSampler, registerStreamWs } from '@tapflowio/agent-core/utils';
5
+ import { AdbWrapper } from './AdbWrapper.js';
6
+ import { EmulatorLauncher } from './EmulatorLauncher.js';
7
+ import { AndroidTouchHelper } from './AndroidTouchHelper.js';
8
+ import { ScrcpySession } from './scrcpy/ScrcpySession.js';
9
+ const logger = createLogger('android-agent');
10
+ // Parse H.264 SPS NAL unit to extract frame dimensions.
11
+ // scrcpy sends a new SPS (inside an IDR keyframe) whenever the capture size changes —
12
+ // e.g. portrait→landscape for landscape-aware apps. This lets the agent track the
13
+ // actual video dimensions and keep ScrcpyControl.screenSize in sync without guessing.
14
+ function parseSpsFromNal(nal) {
15
+ // Locate NAL header byte after Annex B start code
16
+ let offset = 0;
17
+ if (nal.length >= 4 && nal[0] === 0 && nal[1] === 0 && nal[2] === 0 && nal[3] === 1)
18
+ offset = 4;
19
+ else if (nal.length >= 3 && nal[0] === 0 && nal[1] === 0 && nal[2] === 1)
20
+ offset = 3;
21
+ else
22
+ return null;
23
+ if (offset >= nal.length || (nal[offset] & 0x1f) !== 7)
24
+ return null; // not SPS
25
+ // Collect RBSP bytes (remove emulation-prevention 0x03 bytes)
26
+ const bytes = [];
27
+ for (let i = offset + 1; i < nal.length; i++) {
28
+ const b = nal[i];
29
+ const len = bytes.length;
30
+ if (len >= 2 && b === 3 && bytes[len - 1] === 0 && bytes[len - 2] === 0)
31
+ continue;
32
+ bytes.push(b);
33
+ }
34
+ let bit = 0;
35
+ const readU = (n) => {
36
+ let v = 0;
37
+ for (let i = 0; i < n; i++) {
38
+ if ((bit >> 3) >= bytes.length)
39
+ throw new Error('truncated');
40
+ v = (v << 1) | ((bytes[bit >> 3] >> (7 - (bit & 7))) & 1);
41
+ bit++;
42
+ }
43
+ return v;
44
+ };
45
+ const readUE = () => {
46
+ let lz = 0;
47
+ while (readU(1) === 0) {
48
+ if (++lz > 31)
49
+ throw new Error('overflow');
50
+ }
51
+ return lz === 0 ? 0 : (1 << lz) - 1 + readU(lz);
52
+ };
53
+ const readSE = () => { const v = readUE(); return v % 2 === 0 ? -(v >> 1) : (v + 1) >> 1; };
54
+ try {
55
+ const profile = readU(8);
56
+ readU(8);
57
+ readU(8); // constraint_flags, level_idc
58
+ readUE(); // seq_parameter_set_id
59
+ let subWC = 2, subHC = 2; // 4:2:0 defaults
60
+ if ([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135].includes(profile)) {
61
+ const cfmt = readUE();
62
+ subWC = cfmt === 0 ? 1 : cfmt === 2 ? 2 : cfmt === 3 ? 1 : 2;
63
+ subHC = cfmt === 0 ? 1 : cfmt === 1 ? 2 : 1;
64
+ if (cfmt === 3)
65
+ readU(1); // separate_colour_plane_flag
66
+ readUE();
67
+ readUE(); // bit_depth_luma/chroma_minus8
68
+ readU(1); // qpprime_y_zero_transform_bypass_flag
69
+ if (readU(1))
70
+ return null; // seq_scaling_matrix_present_flag — skip
71
+ }
72
+ readUE(); // log2_max_frame_num_minus4
73
+ const pocType = readUE();
74
+ if (pocType === 0)
75
+ readUE();
76
+ else if (pocType === 1) {
77
+ readU(1);
78
+ readSE();
79
+ readSE();
80
+ const n = readUE();
81
+ for (let i = 0; i < n; i++)
82
+ readSE();
83
+ }
84
+ readUE();
85
+ readU(1); // max_num_ref_frames, gaps_in_frame_num_value_allowed_flag
86
+ const codedW = (readUE() + 1) * 16;
87
+ const mapH = readUE();
88
+ const frameMbsOnly = readU(1);
89
+ const codedH = (mapH + 1) * 16 * (frameMbsOnly ? 1 : 2);
90
+ if (!frameMbsOnly)
91
+ readU(1); // mb_adaptive_frame_field_flag
92
+ readU(1); // direct_8x8_inference_flag
93
+ let w = codedW, h = codedH;
94
+ if (readU(1)) { // frame_cropping_flag
95
+ const cl = readUE(), cr = readUE(), ct = readUE(), cb = readUE();
96
+ w = codedW - (cl + cr) * subWC;
97
+ h = codedH - (ct + cb) * subHC * (frameMbsOnly ? 1 : 2);
98
+ }
99
+ return { width: w, height: h };
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ const ANDROID_BUTTONS = [
106
+ { name: 'home', accessibilityTitle: 'Home', keyCode: 3 },
107
+ { name: 'back', accessibilityTitle: 'Back', keyCode: 4 },
108
+ { name: 'recent_apps', accessibilityTitle: 'Recent Apps', keyCode: 187 },
109
+ { name: 'volume_up', accessibilityTitle: 'Volume Up', keyCode: 24 },
110
+ { name: 'volume_down', accessibilityTitle: 'Volume Down', keyCode: 25 },
111
+ { name: 'power', accessibilityTitle: 'Power', keyCode: 26 },
112
+ ];
113
+ export class AndroidAgent {
114
+ adb;
115
+ launcher;
116
+ ws = null;
117
+ deviceStates = new Map();
118
+ relayUrl = null;
119
+ resourcesTimer = null;
120
+ resources = createResourceSampler();
121
+ deviceFilter;
122
+ constructor(options = {}, adb) {
123
+ this.adb = adb ?? new AdbWrapper();
124
+ this.launcher = new EmulatorLauncher();
125
+ this.deviceFilter = options.deviceFilter;
126
+ }
127
+ get sessionId() {
128
+ const first = this.deviceStates.values().next().value;
129
+ return first?.sessionId ?? null;
130
+ }
131
+ async connect(relayUrl) {
132
+ this.relayUrl = relayUrl;
133
+ const allDevices = await this.adb.listDevices();
134
+ const devices = this.deviceFilter
135
+ ? allDevices.filter((d) => d.name === this.deviceFilter || d.id === this.deviceFilter)
136
+ : allDevices;
137
+ return new Promise((resolve, reject) => {
138
+ const ws = new WebSocket(relayUrl);
139
+ ws.once('open', () => {
140
+ ws.send(JSON.stringify({
141
+ type: 'agent:register',
142
+ platform: 'android',
143
+ agentName: os.hostname(),
144
+ devices: devices.map((d) => ({
145
+ id: d.id,
146
+ name: d.name,
147
+ platform: d.platform,
148
+ status: d.status,
149
+ osVersion: d.osVersion,
150
+ })),
151
+ }));
152
+ });
153
+ ws.once('message', (data) => {
154
+ const msg = JSON.parse(data.toString());
155
+ if (msg.type === 'agent:registered') {
156
+ this.ws = ws;
157
+ this.initDeviceStates(msg.registeredSessions);
158
+ ws.on('message', (d) => {
159
+ try {
160
+ this.handleRelayMessage(JSON.parse(d.toString()));
161
+ }
162
+ catch { /* ignore malformed */ }
163
+ });
164
+ this.reportResources();
165
+ this.resourcesTimer = setInterval(() => this.reportResources(), 5000);
166
+ resolve();
167
+ }
168
+ else {
169
+ reject(new Error(`Unexpected message during handshake: ${msg.type}`));
170
+ }
171
+ });
172
+ ws.once('error', reject);
173
+ });
174
+ }
175
+ initDeviceStates(registeredSessions) {
176
+ registeredSessions.forEach(({ deviceId, sessionId }) => {
177
+ this.deviceStates.set(sessionId, {
178
+ sessionId,
179
+ deviceId,
180
+ touchHelper: null,
181
+ streamWs: null,
182
+ scrcpySession: null,
183
+ displayWidth: 0,
184
+ displayHeight: 0,
185
+ videoWidth: 0,
186
+ videoHeight: 0,
187
+ deviceRotation: 0,
188
+ lastTouchPx: { x: 0, y: 0 },
189
+ bootSeq: 0,
190
+ restarting: false,
191
+ });
192
+ });
193
+ }
194
+ disconnect() {
195
+ if (this.resourcesTimer) {
196
+ clearInterval(this.resourcesTimer);
197
+ this.resourcesTimer = null;
198
+ }
199
+ for (const state of this.deviceStates.values()) {
200
+ this.cleanupDeviceState(state);
201
+ }
202
+ this.deviceStates.clear();
203
+ this.ws?.close();
204
+ this.ws = null;
205
+ this.relayUrl = null;
206
+ }
207
+ reportResources() {
208
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
209
+ return;
210
+ const bootedCount = Array.from(this.deviceStates.values()).filter((s) => s.scrcpySession !== null).length;
211
+ const slotsTotal = this.deviceStates.size;
212
+ const { memUsedMB, memTotalMB } = this.resources.getMemoryUsage();
213
+ this.ws.send(JSON.stringify({
214
+ type: 'agent:resources',
215
+ resources: {
216
+ cpuPercent: this.resources.getCpuPercent(),
217
+ memUsedMB,
218
+ memTotalMB,
219
+ slotsAvailable: Math.max(0, slotsTotal - bootedCount),
220
+ slotsTotal,
221
+ reportedAt: Date.now(),
222
+ },
223
+ }));
224
+ }
225
+ cleanupDeviceState(state) {
226
+ const serial = this.adb.getSerial(state.deviceId);
227
+ if (serial && state.scrcpySession)
228
+ state.scrcpySession.stop(serial);
229
+ state.scrcpySession = null;
230
+ state.touchHelper?.stop();
231
+ state.touchHelper = null;
232
+ state.streamWs?.close();
233
+ state.streamWs = null;
234
+ }
235
+ sendDeviceInfo(state, device) {
236
+ if (!this.ws)
237
+ return;
238
+ this.ws.send(JSON.stringify({
239
+ type: 'session:deviceInfo',
240
+ sessionId: state.sessionId,
241
+ payload: {
242
+ deviceName: device.name,
243
+ osVersion: device.osVersion ?? '',
244
+ },
245
+ }));
246
+ }
247
+ async startVideoStream(state, streamWs) {
248
+ const serial = this.adb.getSerial(state.deviceId);
249
+ if (!serial)
250
+ return;
251
+ const touchHelper = new AndroidTouchHelper(this.adb, serial);
252
+ touchHelper.start();
253
+ state.touchHelper = touchHelper;
254
+ const session = new ScrcpySession();
255
+ const info = await session.start(serial, (rotation) => this.handleRotationNotification(state, rotation));
256
+ state.scrcpySession = session;
257
+ state.deviceRotation = 0;
258
+ state.displayWidth = info.width;
259
+ state.displayHeight = info.height;
260
+ state.videoWidth = info.width;
261
+ state.videoHeight = info.height;
262
+ const stream = session.video.start();
263
+ const reader = stream.getReader();
264
+ const pump = async () => {
265
+ try {
266
+ while (true) {
267
+ const { value, done } = await reader.read();
268
+ if (done)
269
+ break;
270
+ // Detect video size changes via H.264 SPS so ScrcpyControl.screenSize always
271
+ // matches what scrcpy server is actually encoding (landscape-aware vs portrait-locked).
272
+ const parsed = parseSpsFromNal(value);
273
+ if (parsed && (parsed.width !== state.videoWidth || parsed.height !== state.videoHeight)) {
274
+ state.videoWidth = parsed.width;
275
+ state.videoHeight = parsed.height;
276
+ state.scrcpySession?.control.updateScreenSize(parsed.width, parsed.height);
277
+ logger.info(`video size → ${parsed.width}×${parsed.height}`);
278
+ }
279
+ if (streamWs.readyState === WebSocket.OPEN)
280
+ streamWs.send(value);
281
+ }
282
+ }
283
+ catch {
284
+ // stream cancelled or ws closed — expected on disconnect
285
+ }
286
+ if (state.scrcpySession === session && !state.restarting) {
287
+ state.restarting = true;
288
+ void this.restartVideoStream(state);
289
+ }
290
+ };
291
+ void pump();
292
+ }
293
+ async restartVideoStream(state) {
294
+ const serial = this.adb.getSerial(state.deviceId);
295
+ if (!serial) {
296
+ state.restarting = false;
297
+ return;
298
+ }
299
+ state.scrcpySession?.stop(serial);
300
+ state.scrcpySession = null;
301
+ state.touchHelper?.stop();
302
+ state.touchHelper = null;
303
+ const { streamWs } = state;
304
+ if (!streamWs || streamWs.readyState !== WebSocket.OPEN) {
305
+ state.restarting = false;
306
+ return;
307
+ }
308
+ // kill any lingering scrcpy server process on the device before restarting
309
+ await this.adb.pkill(serial, 'scrcpy-server').catch(() => { });
310
+ await new Promise((r) => setTimeout(r, 1500));
311
+ if (!this.deviceStates.has(state.sessionId))
312
+ return;
313
+ try {
314
+ await this.startVideoStream(state, streamWs);
315
+ }
316
+ catch (err) {
317
+ logger.error(`scrcpy restart failed: ${err}`);
318
+ this.ws?.send(JSON.stringify({
319
+ type: 'device:boot-error',
320
+ sessionId: state.sessionId,
321
+ message: 'scrcpy failed to restart',
322
+ }));
323
+ }
324
+ finally {
325
+ state.restarting = false;
326
+ }
327
+ }
328
+ handleRotationNotification(state, rotation) {
329
+ if (rotation === state.deviceRotation)
330
+ return;
331
+ const prevRotation = state.deviceRotation;
332
+ state.deviceRotation = rotation;
333
+ // Swap displayW/H when rotation changes by an odd number of 90° steps.
334
+ // Do NOT call control.updateScreenSize here — scrcpy screenWidth/Height must match
335
+ // the actual video frame dimensions set in the ScrcpyControl constructor, not the
336
+ // display orientation. Portrait-locked apps keep portrait video even on a landscape
337
+ // device; mismatching screenSize causes scrcpy to silently drop all touch events.
338
+ const quarters = ((rotation - prevRotation) + 4) % 4;
339
+ if (quarters === 1 || quarters === 3) {
340
+ ;
341
+ [state.displayWidth, state.displayHeight] = [state.displayHeight, state.displayWidth];
342
+ }
343
+ logger.info(`rotation ${prevRotation}→${rotation} displaySize=${state.displayWidth}×${state.displayHeight}`);
344
+ this.ws?.send(JSON.stringify({
345
+ type: 'device:rotate',
346
+ sessionId: state.sessionId,
347
+ payload: { rotation, displayWidth: state.displayWidth, displayHeight: state.displayHeight },
348
+ }));
349
+ }
350
+ async openStreamWs(state) {
351
+ const streamWs = new WebSocket(this.relayUrl);
352
+ state.streamWs = streamWs;
353
+ await registerStreamWs(streamWs, state.sessionId);
354
+ return streamWs;
355
+ }
356
+ async handleDeviceBoot(sessionId, avdId) {
357
+ const state = this.deviceStates.get(sessionId);
358
+ if (!state || !this.ws)
359
+ return;
360
+ const seq = ++state.bootSeq;
361
+ this.cleanupDeviceState(state);
362
+ this.ws.send(JSON.stringify({ type: 'device:booting', sessionId }));
363
+ try {
364
+ const avdName = avdId.replace(/^avd:/, '');
365
+ const devices = await this.adb.listDevices();
366
+ if (seq !== state.bootSeq)
367
+ return;
368
+ const target = devices.find((d) => d.id === avdId);
369
+ if (!target)
370
+ throw new Error(`Device not found: ${avdId}`);
371
+ if (target.status !== 'booted') {
372
+ this.launcher.launch(avdName);
373
+ const serial = await this.launcher.findSerial(avdName);
374
+ if (seq !== state.bootSeq)
375
+ return;
376
+ await this.launcher.waitForBoot(serial);
377
+ if (seq !== state.bootSeq)
378
+ return;
379
+ this.adb.setSerial(avdId, serial);
380
+ }
381
+ const refreshed = await this.adb.listDevices();
382
+ if (seq !== state.bootSeq)
383
+ return;
384
+ const refreshedDevice = refreshed.find((d) => d.id === avdId) ?? target;
385
+ this.sendDeviceInfo(state, { ...refreshedDevice, status: 'booted' });
386
+ const streamWs = await this.openStreamWs(state);
387
+ if (seq !== state.bootSeq) {
388
+ streamWs.close();
389
+ return;
390
+ }
391
+ await this.startVideoStream(state, streamWs);
392
+ if (seq !== state.bootSeq)
393
+ return;
394
+ this.ws?.send(JSON.stringify({
395
+ type: 'session:chrome',
396
+ sessionId: state.sessionId,
397
+ payload: {
398
+ buttons: ANDROID_BUTTONS,
399
+ streamType: 'h264',
400
+ screenWidth: state.displayWidth,
401
+ screenHeight: state.displayHeight,
402
+ },
403
+ }));
404
+ this.ws?.send(JSON.stringify({ type: 'device:ready', sessionId, payload: { deviceId: avdId } }));
405
+ }
406
+ catch (e) {
407
+ if (seq !== state.bootSeq)
408
+ return;
409
+ const message = e instanceof Error ? e.message : String(e);
410
+ logger.error('boot failed:', message);
411
+ this.ws?.send(JSON.stringify({ type: 'device:boot-error', sessionId, message }));
412
+ }
413
+ }
414
+ async handleDeviceShutdown(sessionId, avdId) {
415
+ const state = this.deviceStates.get(sessionId);
416
+ if (!state)
417
+ return;
418
+ state.bootSeq++;
419
+ this.cleanupDeviceState(state);
420
+ const serial = this.adb.getSerial(avdId);
421
+ if (serial) {
422
+ // best-effort — emulator may already be gone
423
+ await this.adb.shutdown(serial).catch((e) => {
424
+ logger.warn('emu kill failed (already gone?):', e.message);
425
+ });
426
+ this.adb.clearSerial(avdId);
427
+ }
428
+ this.ws?.send(JSON.stringify({
429
+ type: 'device:shutdown-done',
430
+ sessionId,
431
+ payload: { deviceId: avdId },
432
+ }));
433
+ }
434
+ handleRelayMessage(msg) {
435
+ switch (msg.type) {
436
+ case 'device:boot': {
437
+ const { deviceId } = msg.payload;
438
+ this.handleDeviceBoot(msg.sessionId, deviceId)
439
+ .catch((e) => logger.error('handleDeviceBoot failed:', e));
440
+ break;
441
+ }
442
+ case 'device:shutdown': {
443
+ const { deviceId } = msg.payload;
444
+ this.handleDeviceShutdown(msg.sessionId, deviceId)
445
+ .catch((e) => logger.error('handleDeviceShutdown failed:', e));
446
+ break;
447
+ }
448
+ case 'app:install': {
449
+ const { filePath, bundleId } = msg.payload;
450
+ const sessionId = msg.sessionId;
451
+ const state = this.deviceStates.get(sessionId);
452
+ const serial = state ? this.adb.getSerial(state.deviceId) : undefined;
453
+ if (!serial) {
454
+ this.ws?.send(JSON.stringify({ type: 'app:install-error', sessionId, message: 'No booted device' }));
455
+ break;
456
+ }
457
+ if (filePath.endsWith('.app.zip') || filePath.endsWith('.app')) {
458
+ this.ws?.send(JSON.stringify({
459
+ type: 'app:install-error',
460
+ sessionId,
461
+ message: '.app.zip is an iOS simulator build — upload a .apk file for Android.',
462
+ }));
463
+ break;
464
+ }
465
+ const doInstall = async () => {
466
+ if (bundleId)
467
+ await this.adb.clearAppData(serial, bundleId).catch(() => { });
468
+ await this.adb.installApp(serial, filePath);
469
+ };
470
+ doInstall()
471
+ .then(() => this.ws?.send(JSON.stringify({ type: 'app:install-done', sessionId })))
472
+ .catch((e) => {
473
+ const message = e instanceof Error ? e.message : String(e);
474
+ this.ws?.send(JSON.stringify({ type: 'app:install-error', sessionId, message }));
475
+ });
476
+ break;
477
+ }
478
+ case 'app:launch': {
479
+ const { bundleId } = msg.payload;
480
+ const sessionId = msg.sessionId;
481
+ const state = this.deviceStates.get(sessionId);
482
+ const serial = state ? this.adb.getSerial(state.deviceId) : undefined;
483
+ if (!serial) {
484
+ this.ws?.send(JSON.stringify({ type: 'app:launch-error', sessionId, message: 'No booted device' }));
485
+ break;
486
+ }
487
+ this.adb.launchApp(serial, bundleId)
488
+ .then(() => this.ws?.send(JSON.stringify({ type: 'app:launch-done', sessionId })))
489
+ .catch((e) => {
490
+ const message = e instanceof Error ? e.message : String(e);
491
+ this.ws?.send(JSON.stringify({ type: 'app:launch-error', sessionId, message }));
492
+ });
493
+ break;
494
+ }
495
+ case 'input:touch:start': {
496
+ const state = this.deviceStates.get(msg.sessionId);
497
+ if (!state)
498
+ break;
499
+ const { x, y } = msg.payload;
500
+ if (state.scrcpySession && state.videoWidth > 0) {
501
+ const px = Math.round(x * state.videoWidth);
502
+ const py = Math.round(y * state.videoHeight);
503
+ state.lastTouchPx = { x: px, y: py };
504
+ state.scrcpySession.control.touchDown(0, px, py);
505
+ }
506
+ else {
507
+ state.touchHelper?.touchStart(x, y);
508
+ }
509
+ break;
510
+ }
511
+ case 'input:touch:move': {
512
+ const state = this.deviceStates.get(msg.sessionId);
513
+ if (!state)
514
+ break;
515
+ const { x, y } = msg.payload;
516
+ if (state.scrcpySession && state.videoWidth > 0) {
517
+ const px = Math.round(x * state.videoWidth);
518
+ const py = Math.round(y * state.videoHeight);
519
+ state.lastTouchPx = { x: px, y: py };
520
+ state.scrcpySession.control.touchMove(0, px, py);
521
+ }
522
+ else {
523
+ state.touchHelper?.touchMove(x, y);
524
+ }
525
+ break;
526
+ }
527
+ case 'input:touch:end': {
528
+ const state = this.deviceStates.get(msg.sessionId);
529
+ if (!state)
530
+ break;
531
+ if (state.scrcpySession) {
532
+ state.scrcpySession.control.touchUp(0, state.lastTouchPx.x, state.lastTouchPx.y);
533
+ }
534
+ else {
535
+ state.touchHelper?.touchEnd();
536
+ }
537
+ break;
538
+ }
539
+ case 'input:pinch:start': {
540
+ const state = this.deviceStates.get(msg.sessionId);
541
+ if (!state?.touchHelper)
542
+ break;
543
+ const { x1, y1, x2, y2 } = msg.payload;
544
+ state.touchHelper.pinchStart(x1, y1, x2, y2);
545
+ break;
546
+ }
547
+ case 'input:pinch:move': {
548
+ const state = this.deviceStates.get(msg.sessionId);
549
+ if (!state?.touchHelper)
550
+ break;
551
+ const { x1, y1, x2, y2 } = msg.payload;
552
+ state.touchHelper.pinchMove(x1, y1, x2, y2);
553
+ break;
554
+ }
555
+ case 'input:pinch:end': {
556
+ const state = this.deviceStates.get(msg.sessionId);
557
+ state?.touchHelper?.pinchEnd();
558
+ break;
559
+ }
560
+ case 'input:rotate': {
561
+ const state = this.deviceStates.get(msg.sessionId);
562
+ if (!state)
563
+ break;
564
+ const serial = this.adb.getSerial(state.deviceId);
565
+ if (!serial)
566
+ break;
567
+ // Optimistically advance rotation by 90° so the view updates immediately.
568
+ // ROTATION_NOTIFICATION will reconcile the actual value; dedup logic in handleRotationNotification
569
+ // prevents a double-swap if prediction matches.
570
+ this.handleRotationNotification(state, (state.deviceRotation + 1) % 4);
571
+ this.adb.enableAutoRotate(serial).catch(() => { });
572
+ this.adb.emuRotate(serial).catch(() => { });
573
+ break;
574
+ }
575
+ case 'input:button': {
576
+ const state = this.deviceStates.get(msg.sessionId);
577
+ if (!state?.touchHelper)
578
+ break;
579
+ const { name } = msg.payload;
580
+ state.touchHelper.pressButton(name);
581
+ break;
582
+ }
583
+ case 'input:keyboard:toggle': {
584
+ // client-side key forwarding toggle only — no ADB side effect needed
585
+ break;
586
+ }
587
+ case 'input:key': {
588
+ const state = this.deviceStates.get(msg.sessionId);
589
+ const serial = state ? this.adb.getSerial(state.deviceId) : undefined;
590
+ if (!serial)
591
+ break;
592
+ const { code, modifiers } = msg.payload;
593
+ this.handleKeyInput(serial, code, modifiers).catch(() => { });
594
+ break;
595
+ }
596
+ }
597
+ }
598
+ async handleKeyInput(serial, code, modifiers) {
599
+ const SPECIAL = {
600
+ Backspace: '67', Enter: '66', Tab: '61', Space: '62', Escape: '111',
601
+ ArrowLeft: '21', ArrowRight: '22', ArrowUp: '19', ArrowDown: '20',
602
+ Delete: '112', Home: '122', End: '123', PageUp: '92', PageDown: '93',
603
+ F1: '131', F2: '132', F3: '133', F4: '134', F5: '135',
604
+ F6: '136', F7: '137', F8: '138', F9: '139', F10: '140', F11: '141', F12: '142',
605
+ };
606
+ if (SPECIAL[code]) {
607
+ await this.adb.sendKeyEvent(serial, SPECIAL[code]);
608
+ return;
609
+ }
610
+ const shift = Boolean(modifiers & 0x02);
611
+ let char = null;
612
+ if (code.startsWith('Key')) {
613
+ const letter = code.slice(3);
614
+ char = shift ? letter.toUpperCase() : letter.toLowerCase();
615
+ }
616
+ else if (code.startsWith('Digit')) {
617
+ const digit = code.slice(5);
618
+ const shiftDigits = {
619
+ '1': '!', '2': '@', '3': '#', '4': '$', '5': '%',
620
+ '6': '^', '7': '&', '8': '*', '9': '(', '0': ')',
621
+ };
622
+ char = shift ? (shiftDigits[digit] ?? digit) : digit;
623
+ }
624
+ else {
625
+ const PUNCT = {
626
+ Minus: ['-', '_'], Equal: ['=', '+'],
627
+ BracketLeft: ['[', '{'], BracketRight: [']', '}'],
628
+ Backslash: ['\\', '|'], Semicolon: [';', ':'],
629
+ Quote: ["'", '"'], Comma: [',', '<'],
630
+ Period: ['.', '>'], Slash: ['/', '?'], Backquote: ['`', '~'],
631
+ };
632
+ if (PUNCT[code])
633
+ char = shift ? PUNCT[code][1] : PUNCT[code][0];
634
+ }
635
+ if (char)
636
+ await this.adb.sendInput(serial, 'text', char);
637
+ }
638
+ listDevices() { return this.adb.listDevices(); }
639
+ async boot(avdId) {
640
+ const avdName = avdId.replace(/^avd:/, '');
641
+ this.launcher.launch(avdName);
642
+ const serial = await this.launcher.findSerial(avdName);
643
+ await this.launcher.waitForBoot(serial);
644
+ this.adb.setSerial(avdId, serial);
645
+ }
646
+ async shutdown(avdId) {
647
+ const serial = this.adb.getSerial(avdId);
648
+ if (serial) {
649
+ await this.adb.shutdown(serial);
650
+ this.adb.clearSerial(avdId);
651
+ }
652
+ }
653
+ async installApp(apkPath) {
654
+ const first = this.deviceStates.values().next().value;
655
+ const serial = first ? this.adb.getSerial(first.deviceId) : undefined;
656
+ if (!serial)
657
+ throw new Error('no booted device — call connect() first');
658
+ await this.adb.installApp(serial, apkPath);
659
+ }
660
+ async launchApp(packageName) {
661
+ const first = this.deviceStates.values().next().value;
662
+ const serial = first ? this.adb.getSerial(first.deviceId) : undefined;
663
+ if (!serial)
664
+ throw new Error('no booted device — call connect() first');
665
+ await this.adb.launchApp(serial, packageName);
666
+ }
667
+ async screenshot() {
668
+ const first = this.deviceStates.values().next().value;
669
+ const serial = first ? this.adb.getSerial(first.deviceId) : undefined;
670
+ if (!serial)
671
+ throw new Error('no booted device — call connect() first');
672
+ return this.adb.screenshot(serial);
673
+ }
674
+ stream() {
675
+ const state = this.deviceStates.values().next().value;
676
+ if (!state?.scrcpySession)
677
+ throw new Error('no active scrcpy session — call connect() first');
678
+ return state.scrcpySession.video.start();
679
+ }
680
+ touchStart(x, y) {
681
+ const first = this.deviceStates.values().next().value;
682
+ first?.touchHelper?.touchStart(x, y);
683
+ }
684
+ touchMove(x, y) {
685
+ const first = this.deviceStates.values().next().value;
686
+ first?.touchHelper?.touchMove(x, y);
687
+ return Promise.resolve();
688
+ }
689
+ touchEnd() {
690
+ const first = this.deviceStates.values().next().value;
691
+ first?.touchHelper?.touchEnd();
692
+ return Promise.resolve();
693
+ }
694
+ }
695
+ //# sourceMappingURL=AndroidAgent.js.map