neoagent 2.0.7 → 2.1.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.
@@ -0,0 +1,890 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const https = require('https');
5
+ const { spawn, spawnSync } = require('child_process');
6
+ const { CLIExecutor } = require('../cli/executor');
7
+ const { DATA_DIR, RUNTIME_HOME } = require('../../../runtime/paths');
8
+ const { findBestNode, parseUiDump, summarizeNode } = require('./uia');
9
+
10
+ const ANDROID_ROOT = path.join(RUNTIME_HOME, 'android');
11
+ const SDK_ROOT = path.join(ANDROID_ROOT, 'sdk');
12
+ const CMDLINE_ROOT = path.join(SDK_ROOT, 'cmdline-tools');
13
+ const CMDLINE_LATEST = path.join(CMDLINE_ROOT, 'latest');
14
+ const ARTIFACTS_DIR = path.join(DATA_DIR, 'android');
15
+ const SCREENSHOTS_DIR = path.join(DATA_DIR, 'screenshots');
16
+ const UI_DUMPS_DIR = path.join(ARTIFACTS_DIR, 'ui-dumps');
17
+ const LOGS_DIR = path.join(ARTIFACTS_DIR, 'logs');
18
+ const TMP_DIR = path.join(ARTIFACTS_DIR, 'tmp');
19
+ const AVD_HOME = path.join(ANDROID_ROOT, 'avd');
20
+ const STATE_FILE = path.join(ARTIFACTS_DIR, 'state.json');
21
+ const DEFAULT_AVD_NAME = 'neoagent-default';
22
+ const DEFAULT_DATA_PARTITION = '1024M';
23
+ const DEFAULT_SDCARD_SIZE = '128M';
24
+ const DEFAULT_RAM_SIZE = '1024';
25
+ const DEFAULT_KEYEVENTS = Object.freeze({
26
+ home: 3,
27
+ back: 4,
28
+ up: 19,
29
+ down: 20,
30
+ left: 21,
31
+ right: 22,
32
+ enter: 66,
33
+ menu: 82,
34
+ search: 84,
35
+ app_switch: 187,
36
+ delete: 67,
37
+ escape: 111,
38
+ space: 62,
39
+ tab: 61,
40
+ });
41
+
42
+ for (const dir of [ANDROID_ROOT, SDK_ROOT, ARTIFACTS_DIR, SCREENSHOTS_DIR, UI_DUMPS_DIR, LOGS_DIR, TMP_DIR, AVD_HOME]) {
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ }
45
+
46
+ function sleep(ms) {
47
+ return new Promise((resolve) => setTimeout(resolve, ms));
48
+ }
49
+
50
+ function commandExists(command) {
51
+ const probe = spawnSync('bash', ['-lc', `command -v "${command}"`], { encoding: 'utf8' });
52
+ return probe.status === 0;
53
+ }
54
+
55
+ function appendState(patch) {
56
+ const current = readState();
57
+ const next = {
58
+ ...current,
59
+ ...patch,
60
+ updatedAt: new Date().toISOString(),
61
+ };
62
+ fs.writeFileSync(STATE_FILE, JSON.stringify(next, null, 2));
63
+ return next;
64
+ }
65
+
66
+ function readState() {
67
+ try {
68
+ return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
69
+ } catch {
70
+ return {
71
+ avdName: DEFAULT_AVD_NAME,
72
+ serial: null,
73
+ emulatorPid: null,
74
+ bootstrapped: false,
75
+ updatedAt: null,
76
+ };
77
+ }
78
+ }
79
+
80
+ function platformTag() {
81
+ if (process.platform === 'darwin') return 'mac';
82
+ if (process.platform === 'linux') return 'linux';
83
+ throw new Error(`Android runtime bootstrap is only supported on macOS and Linux, not ${process.platform}`);
84
+ }
85
+
86
+ function systemImageArch() {
87
+ if (process.arch === 'arm64') return 'arm64-v8a';
88
+ return 'x86_64';
89
+ }
90
+
91
+ function sdkEnv() {
92
+ const base = {
93
+ ...process.env,
94
+ ANDROID_HOME: SDK_ROOT,
95
+ ANDROID_SDK_ROOT: SDK_ROOT,
96
+ ANDROID_AVD_HOME: AVD_HOME,
97
+ AVD_HOME,
98
+ };
99
+ const pathParts = [
100
+ path.join(SDK_ROOT, 'platform-tools'),
101
+ path.join(SDK_ROOT, 'emulator'),
102
+ path.join(CMDLINE_LATEST, 'bin'),
103
+ process.env.PATH || '',
104
+ ].filter(Boolean);
105
+ base.PATH = pathParts.join(path.delimiter);
106
+ return base;
107
+ }
108
+
109
+ function adbBinary() {
110
+ return process.env.ANDROID_ADB_PATH || path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
111
+ }
112
+
113
+ function sdkManagerBinary() {
114
+ return path.join(CMDLINE_LATEST, 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
115
+ }
116
+
117
+ function avdManagerBinary() {
118
+ return path.join(CMDLINE_LATEST, 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
119
+ }
120
+
121
+ function emulatorBinary() {
122
+ return path.join(SDK_ROOT, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
123
+ }
124
+
125
+ function isExecutable(filePath) {
126
+ try {
127
+ fs.accessSync(filePath, fs.constants.X_OK);
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ function fetchText(url) {
135
+ return new Promise((resolve, reject) => {
136
+ https.get(url, (res) => {
137
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
138
+ return resolve(fetchText(res.headers.location));
139
+ }
140
+ if (res.statusCode !== 200) {
141
+ reject(new Error(`GET ${url} failed with status ${res.statusCode}`));
142
+ return;
143
+ }
144
+ let body = '';
145
+ res.setEncoding('utf8');
146
+ res.on('data', (chunk) => { body += chunk; });
147
+ res.on('end', () => resolve(body));
148
+ }).on('error', reject);
149
+ });
150
+ }
151
+
152
+ function downloadFile(url, dest) {
153
+ return new Promise((resolve, reject) => {
154
+ const out = fs.createWriteStream(dest);
155
+ https.get(url, (res) => {
156
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
157
+ out.close();
158
+ fs.rmSync(dest, { force: true });
159
+ return resolve(downloadFile(res.headers.location, dest));
160
+ }
161
+ if (res.statusCode !== 200) {
162
+ out.close();
163
+ fs.rmSync(dest, { force: true });
164
+ reject(new Error(`Download failed with status ${res.statusCode}`));
165
+ return;
166
+ }
167
+ res.pipe(out);
168
+ out.on('finish', () => out.close(resolve));
169
+ }).on('error', (err) => {
170
+ out.close();
171
+ fs.rmSync(dest, { force: true });
172
+ reject(err);
173
+ });
174
+ });
175
+ }
176
+
177
+ function extractZip(zipPath, destDir) {
178
+ if (commandExists('unzip')) {
179
+ const res = spawnSync('unzip', ['-qo', zipPath, '-d', destDir], { encoding: 'utf8' });
180
+ if (res.status === 0) return;
181
+ throw new Error(res.stderr || `unzip failed for ${zipPath}`);
182
+ }
183
+
184
+ if (process.platform === 'darwin' && commandExists('ditto')) {
185
+ const res = spawnSync('ditto', ['-x', '-k', zipPath, destDir], { encoding: 'utf8' });
186
+ if (res.status === 0) return;
187
+ throw new Error(res.stderr || `ditto failed for ${zipPath}`);
188
+ }
189
+
190
+ throw new Error('Neither unzip nor ditto is available to extract Android SDK archives');
191
+ }
192
+
193
+ function parseLatestCmdlineToolsUrl(xml) {
194
+ const tag = platformTag() === 'mac' ? 'macosx' : 'linux';
195
+ const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="cmdline-tools;latest">([\\s\\S]*?)<\\/remotePackage>`));
196
+ if (!packageMatch) throw new Error('Could not locate cmdline-tools;latest in Android repository metadata');
197
+
198
+ const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
199
+ for (const block of archiveBlocks) {
200
+ if (!new RegExp(`<host-os>${tag}<\\/host-os>`).test(block)) continue;
201
+ const urlMatch = block.match(/<url>\s*([^<]*commandlinetools-[^<]+_latest\.zip)\s*<\/url>/);
202
+ if (urlMatch) return `https://dl.google.com/android/repository/${urlMatch[1]}`;
203
+ }
204
+
205
+ throw new Error(`Could not find a command line tools archive for ${tag}`);
206
+ }
207
+
208
+ function chooseLatestSystemImage(listOutput) {
209
+ const arch = systemImageArch();
210
+ const matches = [];
211
+ const regex = new RegExp(`system-images;android-(\\d+);google_apis;${arch}`, 'g');
212
+ let match = regex.exec(listOutput);
213
+ while (match) {
214
+ matches.push({
215
+ apiLevel: Number(match[1] || 0),
216
+ packageName: match[0],
217
+ });
218
+ match = regex.exec(listOutput);
219
+ }
220
+
221
+ matches.sort((a, b) => b.apiLevel - a.apiLevel);
222
+ return matches[0] || null;
223
+ }
224
+
225
+ function parseApiLevelFromSystemImage(packageName) {
226
+ const match = String(packageName || '').match(/system-images;android-(\d+);/);
227
+ return match ? Number(match[1] || 0) : 0;
228
+ }
229
+
230
+ function androidTextEscape(text) {
231
+ return String(text || '')
232
+ .replace(/\\/g, '\\\\')
233
+ .replace(/ /g, '%s')
234
+ .replace(/"/g, '\\"')
235
+ .replace(/'/g, "\\'")
236
+ .replace(/[&()<>|;$`]/g, '');
237
+ }
238
+
239
+ function quoteShell(value) {
240
+ return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
241
+ }
242
+
243
+ function updateIniValue(content, key, value) {
244
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
245
+ const line = `${key}=${value}`;
246
+ if (new RegExp(`^${escapedKey}=.*$`, 'm').test(content)) {
247
+ return content.replace(new RegExp(`^${escapedKey}=.*$`, 'm'), line);
248
+ }
249
+ return `${content.replace(/\s*$/, '')}\n${line}\n`;
250
+ }
251
+
252
+ function sanitizeUiXml(raw) {
253
+ const text = String(raw || '');
254
+ const start = text.indexOf('<?xml');
255
+ const end = text.lastIndexOf('</hierarchy>');
256
+ if (start >= 0 && end >= start) {
257
+ return text.slice(start, end + '</hierarchy>'.length);
258
+ }
259
+ return text.trim();
260
+ }
261
+
262
+ class AndroidController {
263
+ constructor(io) {
264
+ this.io = io;
265
+ this.cli = new CLIExecutor();
266
+ this.avdName = readState().avdName || DEFAULT_AVD_NAME;
267
+ this.bootstrapPromise = null;
268
+ this.#registerProcessCleanup();
269
+ }
270
+
271
+ static cleanupRegistered = false;
272
+
273
+ #registerProcessCleanup() {
274
+ if (AndroidController.cleanupRegistered) {
275
+ return;
276
+ }
277
+ AndroidController.cleanupRegistered = true;
278
+
279
+ const cleanup = () => {
280
+ try {
281
+ this.#stopTrackedEmulatorSync();
282
+ } catch {}
283
+ };
284
+
285
+ process.once('exit', cleanup);
286
+ process.once('uncaughtException', cleanup);
287
+ process.once('unhandledRejection', cleanup);
288
+ }
289
+
290
+ #stopTrackedEmulatorSync() {
291
+ const state = readState();
292
+ const serial = state.serial;
293
+
294
+ if (serial && isExecutable(adbBinary())) {
295
+ try {
296
+ spawnSync(adbBinary(), ['-s', serial, 'emu', 'kill'], {
297
+ stdio: 'ignore',
298
+ env: sdkEnv(),
299
+ });
300
+ } catch {}
301
+ }
302
+
303
+ if (state.emulatorPid) {
304
+ try {
305
+ process.kill(state.emulatorPid, 0);
306
+ process.kill(state.emulatorPid, 'SIGTERM');
307
+ } catch {}
308
+ }
309
+
310
+ appendState({ serial: null, emulatorPid: null });
311
+ }
312
+
313
+ async #run(command, options = {}) {
314
+ const result = await this.cli.execute(command, {
315
+ timeout: options.timeout || 120000,
316
+ env: sdkEnv(),
317
+ cwd: options.cwd || ANDROID_ROOT,
318
+ });
319
+ if (result.exitCode !== 0) {
320
+ throw new Error(result.stderr || result.stdout || `Command failed: ${command}`);
321
+ }
322
+ return result.stdout || '';
323
+ }
324
+
325
+ async #runAllowFailure(command, options = {}) {
326
+ return this.cli.execute(command, {
327
+ timeout: options.timeout || 120000,
328
+ env: sdkEnv(),
329
+ cwd: options.cwd || ANDROID_ROOT,
330
+ });
331
+ }
332
+
333
+ async ensureBootstrapped() {
334
+ if (!(isExecutable(adbBinary()) && isExecutable(sdkManagerBinary()) && isExecutable(emulatorBinary()))) {
335
+ if (this.bootstrapPromise) {
336
+ await this.bootstrapPromise;
337
+ } else {
338
+ this.bootstrapPromise = this.#bootstrapRuntime();
339
+ try {
340
+ await this.bootstrapPromise;
341
+ } finally {
342
+ this.bootstrapPromise = null;
343
+ }
344
+ }
345
+ }
346
+
347
+ appendState({ bootstrapped: true });
348
+ const sdkmanager = sdkManagerBinary();
349
+ const available = await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --list`, { timeout: 300000 });
350
+ const latestSystemImage = chooseLatestSystemImage(available);
351
+ if (!latestSystemImage) {
352
+ throw new Error(`No stable Google APIs system image found for ${systemImageArch()}`);
353
+ }
354
+
355
+ const state = readState();
356
+ const currentApiLevel = parseApiLevelFromSystemImage(state.systemImage);
357
+ const shouldUpgrade =
358
+ state.systemImage !== latestSystemImage.packageName ||
359
+ currentApiLevel < latestSystemImage.apiLevel;
360
+
361
+ if (shouldUpgrade) {
362
+ await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "${latestSystemImage.packageName}"`, {
363
+ timeout: 300000,
364
+ });
365
+ appendState({
366
+ bootstrapped: true,
367
+ systemImage: latestSystemImage.packageName,
368
+ apiLevel: latestSystemImage.apiLevel,
369
+ });
370
+ }
371
+ }
372
+
373
+ async #bootstrapRuntime() {
374
+ const metadata = await fetchText('https://dl.google.com/android/repository/repository2-1.xml');
375
+ const url = parseLatestCmdlineToolsUrl(metadata);
376
+ const zipPath = path.join(TMP_DIR, path.basename(url));
377
+ const extractDir = path.join(TMP_DIR, `cmdline-tools-${Date.now()}`);
378
+
379
+ fs.mkdirSync(extractDir, { recursive: true });
380
+ await downloadFile(url, zipPath);
381
+ extractZip(zipPath, extractDir);
382
+
383
+ const candidates = [
384
+ path.join(extractDir, 'cmdline-tools'),
385
+ path.join(extractDir, 'tools'),
386
+ extractDir,
387
+ ];
388
+ const extractedRoot = candidates.find((candidate) => fs.existsSync(path.join(candidate, 'bin')));
389
+ if (!extractedRoot) throw new Error('Downloaded Android command line tools archive did not contain a bin directory');
390
+
391
+ fs.rmSync(CMDLINE_LATEST, { recursive: true, force: true });
392
+ fs.mkdirSync(CMDLINE_ROOT, { recursive: true });
393
+ fs.cpSync(extractedRoot, CMDLINE_LATEST, { recursive: true });
394
+ fs.rmSync(zipPath, { force: true });
395
+ fs.rmSync(extractDir, { recursive: true, force: true });
396
+
397
+ const sdkmanager = sdkManagerBinary();
398
+ await this.#run(`yes | ${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --licenses`, { timeout: 300000 });
399
+ await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "platform-tools" "emulator"`, { timeout: 300000 });
400
+
401
+ const available = await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} --list`, { timeout: 300000 });
402
+ const systemImage = chooseLatestSystemImage(available);
403
+ if (!systemImage) {
404
+ throw new Error(`No stable Google APIs system image found for ${systemImageArch()}`);
405
+ }
406
+
407
+ await this.#run(`${quoteShell(sdkmanager)} --sdk_root=${quoteShell(SDK_ROOT)} "${systemImage.packageName}"`, { timeout: 300000 });
408
+ appendState({ bootstrapped: true, systemImage: systemImage.packageName, apiLevel: systemImage.apiLevel });
409
+ }
410
+
411
+ async ensureAvd() {
412
+ await this.ensureBootstrapped();
413
+
414
+ const state = readState();
415
+ const list = await this.#run(`${quoteShell(avdManagerBinary())} list avd`, { timeout: 120000 }).catch(() => '');
416
+ const pkg = state.systemImage;
417
+ if (!pkg) throw new Error('Android system image not installed');
418
+ const avdExists = list.includes(`Name: ${this.avdName}`);
419
+ const avdNeedsRecreate = avdExists && (!state.avdSystemImage || state.avdSystemImage !== pkg);
420
+
421
+ if (avdNeedsRecreate) {
422
+ await this.stopEmulator().catch(() => {});
423
+ await this.#run(`${quoteShell(avdManagerBinary())} delete avd -n ${quoteShell(this.avdName)}`, {
424
+ timeout: 120000,
425
+ }).catch(() => {});
426
+ fs.rmSync(path.join(AVD_HOME, `${this.avdName}.avd`), { recursive: true, force: true });
427
+ fs.rmSync(path.join(AVD_HOME, `${this.avdName}.ini`), { force: true });
428
+ } else if (avdExists) {
429
+ return;
430
+ }
431
+
432
+ await this.#run(`printf 'no\\n' | ${quoteShell(avdManagerBinary())} create avd -n ${quoteShell(this.avdName)} -k "${pkg}" --force`, {
433
+ timeout: 120000,
434
+ });
435
+ this.#normalizeAvdConfig();
436
+ appendState({ avdSystemImage: pkg });
437
+ }
438
+
439
+ #normalizeAvdConfig() {
440
+ const configPath = path.join(AVD_HOME, `${this.avdName}.avd`, 'config.ini');
441
+ if (!fs.existsSync(configPath)) return;
442
+
443
+ let content = fs.readFileSync(configPath, 'utf8');
444
+ content = updateIniValue(content, 'disk.dataPartition.size', DEFAULT_DATA_PARTITION);
445
+ content = updateIniValue(content, 'sdcard.size', DEFAULT_SDCARD_SIZE);
446
+ content = updateIniValue(content, 'hw.ramSize', DEFAULT_RAM_SIZE);
447
+ fs.writeFileSync(configPath, content);
448
+ }
449
+
450
+ async listDevices() {
451
+ await this.ensureBootstrapped();
452
+ const out = await this.#run(`${quoteShell(adbBinary())} devices -l`);
453
+ const lines = out.split('\n').map((line) => line.trim()).filter(Boolean);
454
+ return lines
455
+ .filter((line) => !line.toLowerCase().startsWith('list of devices'))
456
+ .map((line) => {
457
+ const parts = line.split(/\s+/);
458
+ return {
459
+ serial: parts[0] || '',
460
+ status: parts[1] || 'unknown',
461
+ details: parts.slice(2).join(' '),
462
+ emulator: (parts[0] || '').startsWith('emulator-'),
463
+ };
464
+ });
465
+ }
466
+
467
+ async getPrimarySerial() {
468
+ const state = readState();
469
+ const devices = await this.listDevices();
470
+ const preferred = state.serial ? devices.find((device) => device.serial === state.serial && device.status === 'device') : null;
471
+ if (preferred) return preferred.serial;
472
+ const emulator = devices.find((device) => device.emulator && device.status === 'device');
473
+ if (emulator) {
474
+ appendState({ serial: emulator.serial });
475
+ return emulator.serial;
476
+ }
477
+ const online = devices.find((device) => device.status === 'device');
478
+ if (online) return online.serial;
479
+ return null;
480
+ }
481
+
482
+ async startEmulator(options = {}) {
483
+ await this.ensureAvd();
484
+ this.#normalizeAvdConfig();
485
+ const serial = await this.getPrimarySerial();
486
+ if (serial) {
487
+ return {
488
+ success: true,
489
+ serial,
490
+ reused: true,
491
+ bootstrapped: readState().bootstrapped === true,
492
+ };
493
+ }
494
+
495
+ const logPath = path.join(LOGS_DIR, `emulator-${Date.now()}.log`);
496
+ const out = fs.openSync(logPath, 'a');
497
+ const args = [
498
+ `@${this.avdName}`,
499
+ '-no-boot-anim',
500
+ '-gpu',
501
+ process.platform === 'darwin' ? 'host' : 'swiftshader_indirect',
502
+ '-netdelay',
503
+ 'none',
504
+ '-netspeed',
505
+ 'full',
506
+ ];
507
+
508
+ if (options.headless !== false) {
509
+ args.push('-no-window', '-no-audio');
510
+ }
511
+
512
+ const child = spawn(emulatorBinary(), args, {
513
+ detached: true,
514
+ stdio: ['ignore', out, out],
515
+ env: sdkEnv(),
516
+ });
517
+ child.unref();
518
+
519
+ appendState({ emulatorPid: child.pid, avdName: this.avdName, logPath });
520
+
521
+ const onlineSerial = await this.waitForDevice({ timeoutMs: options.timeoutMs || 240000 });
522
+ appendState({ serial: onlineSerial, emulatorPid: child.pid });
523
+
524
+ return {
525
+ success: true,
526
+ serial: onlineSerial,
527
+ emulatorPid: child.pid,
528
+ logPath,
529
+ };
530
+ }
531
+
532
+ async waitForDevice(options = {}) {
533
+ const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 180000);
534
+ const deadline = Date.now() + timeoutMs;
535
+
536
+ while (Date.now() < deadline) {
537
+ const serial = await this.getPrimarySerial();
538
+ if (serial) {
539
+ const boot = await this.#runAllowFailure(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell getprop sys.boot_completed`, { timeout: 10000 });
540
+ if ((boot.stdout || '').trim() === '1') {
541
+ return serial;
542
+ }
543
+ }
544
+ await sleep(3000);
545
+ }
546
+
547
+ throw new Error(`Android emulator did not finish booting within ${timeoutMs} ms`);
548
+ }
549
+
550
+ async ensureDevice() {
551
+ const serial = await this.getPrimarySerial();
552
+ if (serial) return serial;
553
+ const started = await this.startEmulator();
554
+ return started.serial;
555
+ }
556
+
557
+ async stopEmulator() {
558
+ const state = readState();
559
+ const serial = await this.getPrimarySerial();
560
+ if (serial) {
561
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} emu kill`, { timeout: 15000 });
562
+ }
563
+ if (state.emulatorPid) {
564
+ try { process.kill(state.emulatorPid, 'SIGTERM'); } catch {}
565
+ }
566
+ appendState({ serial: null, emulatorPid: null });
567
+
568
+ const deadline = Date.now() + 30000;
569
+ while (Date.now() < deadline) {
570
+ const devices = await this.listDevices().catch(() => []);
571
+ const stillPresent = devices.some((device) => device.emulator && device.status === 'device');
572
+ let pidAlive = false;
573
+ if (state.emulatorPid) {
574
+ try {
575
+ process.kill(state.emulatorPid, 0);
576
+ pidAlive = true;
577
+ } catch {
578
+ pidAlive = false;
579
+ }
580
+ }
581
+ if (!stillPresent && !pidAlive) break;
582
+ await sleep(1000);
583
+ }
584
+
585
+ return { success: true };
586
+ }
587
+
588
+ async #adb(serial, command, options = {}) {
589
+ return this.#run(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} ${command}`, options);
590
+ }
591
+
592
+ async screenshot(options = {}) {
593
+ const serial = await this.ensureDevice();
594
+ const filename = `android_${Date.now()}.png`;
595
+ const fullPath = path.join(SCREENSHOTS_DIR, filename);
596
+ await this.#run(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} exec-out screencap -p > ${quoteShell(fullPath)}`, { timeout: 30000 });
597
+ return {
598
+ success: true,
599
+ serial,
600
+ screenshotPath: `/screenshots/${filename}`,
601
+ fullPath,
602
+ };
603
+ }
604
+
605
+ async dumpUi(options = {}) {
606
+ const serial = await this.ensureDevice();
607
+ let xml = await this.#adb(serial, 'shell uiautomator dump --compressed /dev/tty', { timeout: 30000 });
608
+ if (!String(xml || '').includes('<hierarchy')) {
609
+ const remote = '/sdcard/neoagent-ui.xml';
610
+ await this.#adb(serial, `shell uiautomator dump --compressed ${quoteShell(remote)}`, { timeout: 30000 });
611
+ xml = await this.#adb(serial, `shell cat ${quoteShell(remote)}`, { timeout: 30000 });
612
+ }
613
+ xml = sanitizeUiXml(xml);
614
+ const filename = `android_ui_${Date.now()}.xml`;
615
+ const fullPath = path.join(UI_DUMPS_DIR, filename);
616
+ fs.writeFileSync(fullPath, xml);
617
+
618
+ const nodes = parseUiDump(xml);
619
+ return {
620
+ success: true,
621
+ serial,
622
+ nodeCount: nodes.length,
623
+ uiDumpPath: fullPath,
624
+ preview: options.includeNodes === false ? undefined : nodes.slice(0, 25).map((node) => summarizeNode(node)),
625
+ xml,
626
+ };
627
+ }
628
+
629
+ async #resolveSelector(args = {}) {
630
+ const dump = await this.dumpUi({ includeNodes: false });
631
+ const selector = {
632
+ text: args.text,
633
+ resourceId: args.resourceId,
634
+ description: args.description,
635
+ className: args.className,
636
+ packageName: args.packageName,
637
+ clickable: args.clickable,
638
+ };
639
+ const node = findBestNode(dump.xml, selector);
640
+ if (!node) throw new Error('No Android UI element matched the selector');
641
+ return {
642
+ serial: dump.serial,
643
+ uiDumpPath: dump.uiDumpPath,
644
+ node,
645
+ };
646
+ }
647
+
648
+ async tap(args = {}) {
649
+ let x = Number(args.x);
650
+ let y = Number(args.y);
651
+ let node = null;
652
+ let serial = await this.ensureDevice();
653
+ let uiDumpPath = null;
654
+
655
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
656
+ const resolved = await this.#resolveSelector(args);
657
+ serial = resolved.serial;
658
+ node = resolved.node;
659
+ uiDumpPath = resolved.uiDumpPath;
660
+ x = node.bounds.centerX;
661
+ y = node.bounds.centerY;
662
+ }
663
+
664
+ await this.#adb(serial, `shell input tap ${Math.round(x)} ${Math.round(y)}`, { timeout: 15000 });
665
+ const shot = await this.screenshot();
666
+ return {
667
+ success: true,
668
+ serial,
669
+ x: Math.round(x),
670
+ y: Math.round(y),
671
+ target: summarizeNode(node),
672
+ uiDumpPath,
673
+ screenshotPath: shot.screenshotPath,
674
+ };
675
+ }
676
+
677
+ async type(args = {}) {
678
+ const serial = await this.ensureDevice();
679
+ if (args.clear === true) {
680
+ await this.#adb(serial, 'shell input keyevent 123', { timeout: 10000 }).catch(() => {});
681
+ await this.#adb(serial, 'shell input keyevent 67', { timeout: 10000 }).catch(() => {});
682
+ }
683
+
684
+ if (args.selector || args.textSelector || args.resourceId || args.description) {
685
+ await this.tap({
686
+ text: args.textSelector,
687
+ resourceId: args.resourceId,
688
+ description: args.description,
689
+ className: args.className,
690
+ clickable: true,
691
+ }).catch(() => {});
692
+ }
693
+
694
+ await this.#adb(serial, `shell input text ${quoteShell(androidTextEscape(args.text || ''))}`, { timeout: 20000 });
695
+ if (args.pressEnter) {
696
+ await this.#adb(serial, 'shell input keyevent 66', { timeout: 10000 });
697
+ }
698
+ const shot = await this.screenshot();
699
+ return {
700
+ success: true,
701
+ serial,
702
+ typed: args.text || '',
703
+ screenshotPath: shot.screenshotPath,
704
+ };
705
+ }
706
+
707
+ async swipe(args = {}) {
708
+ const serial = await this.ensureDevice();
709
+ const x1 = Number(args.x1);
710
+ const y1 = Number(args.y1);
711
+ const x2 = Number(args.x2);
712
+ const y2 = Number(args.y2);
713
+ const duration = Math.max(50, Number(args.durationMs) || 300);
714
+ if (![x1, y1, x2, y2].every(Number.isFinite)) {
715
+ throw new Error('x1, y1, x2, and y2 are required for android_swipe');
716
+ }
717
+ await this.#adb(serial, `shell input swipe ${Math.round(x1)} ${Math.round(y1)} ${Math.round(x2)} ${Math.round(y2)} ${Math.round(duration)}`, { timeout: 15000 });
718
+ const shot = await this.screenshot();
719
+ return {
720
+ success: true,
721
+ serial,
722
+ screenshotPath: shot.screenshotPath,
723
+ };
724
+ }
725
+
726
+ async pressKey(args = {}) {
727
+ const serial = await this.ensureDevice();
728
+ const raw = String(args.key || '').trim().toLowerCase();
729
+ const keyCode = Number.isFinite(Number(raw)) ? Number(raw) : (DEFAULT_KEYEVENTS[raw] || null);
730
+ if (!keyCode) throw new Error(`Unsupported Android key: ${args.key}`);
731
+ await this.#adb(serial, `shell input keyevent ${keyCode}`, { timeout: 10000 });
732
+ const shot = await this.screenshot();
733
+ return {
734
+ success: true,
735
+ serial,
736
+ key: args.key,
737
+ keyCode,
738
+ screenshotPath: shot.screenshotPath,
739
+ };
740
+ }
741
+
742
+ async waitFor(args = {}) {
743
+ const timeoutMs = Math.max(1000, Number(args.timeoutMs) || 20000);
744
+ const intervalMs = Math.max(250, Number(args.intervalMs) || 1500);
745
+ const deadline = Date.now() + timeoutMs;
746
+
747
+ while (Date.now() < deadline) {
748
+ const dump = await this.dumpUi({ includeNodes: false });
749
+ const node = findBestNode(dump.xml, {
750
+ text: args.text,
751
+ resourceId: args.resourceId,
752
+ description: args.description,
753
+ className: args.className,
754
+ packageName: args.packageName,
755
+ clickable: args.clickable,
756
+ });
757
+ if (node) {
758
+ const shot = args.screenshot === false ? null : await this.screenshot();
759
+ return {
760
+ success: true,
761
+ serial: dump.serial,
762
+ matched: summarizeNode(node),
763
+ uiDumpPath: dump.uiDumpPath,
764
+ screenshotPath: shot?.screenshotPath || null,
765
+ };
766
+ }
767
+ await sleep(intervalMs);
768
+ }
769
+
770
+ throw new Error(`Timed out after ${timeoutMs} ms waiting for Android UI element`);
771
+ }
772
+
773
+ async openApp(args = {}) {
774
+ const serial = await this.ensureDevice();
775
+ if (args.activity) {
776
+ await this.#adb(serial, `shell am start -n ${quoteShell(`${args.packageName}/${args.activity}`)}`, { timeout: 20000 });
777
+ } else if (args.packageName) {
778
+ await this.#adb(serial, `shell monkey -p ${quoteShell(args.packageName)} -c android.intent.category.LAUNCHER 1`, { timeout: 30000 });
779
+ } else {
780
+ throw new Error('packageName is required for android_open_app');
781
+ }
782
+ const shot = await this.screenshot();
783
+ return {
784
+ success: true,
785
+ serial,
786
+ packageName: args.packageName,
787
+ activity: args.activity || null,
788
+ screenshotPath: shot.screenshotPath,
789
+ };
790
+ }
791
+
792
+ async openIntent(args = {}) {
793
+ const serial = await this.ensureDevice();
794
+ const parts = ['shell am start'];
795
+ if (args.action) parts.push('-a', quoteShell(args.action));
796
+ if (args.dataUri) parts.push('-d', quoteShell(args.dataUri));
797
+ if (args.packageName) parts.push('-p', quoteShell(args.packageName));
798
+ if (args.component) parts.push('-n', quoteShell(args.component));
799
+ if (args.mimeType) parts.push('-t', quoteShell(args.mimeType));
800
+
801
+ if (args.extras && typeof args.extras === 'object') {
802
+ for (const [key, value] of Object.entries(args.extras)) {
803
+ parts.push('--es', quoteShell(key), quoteShell(String(value)));
804
+ }
805
+ }
806
+
807
+ await this.#adb(serial, parts.join(' '), { timeout: 20000 });
808
+ const shot = await this.screenshot();
809
+ return {
810
+ success: true,
811
+ serial,
812
+ screenshotPath: shot.screenshotPath,
813
+ };
814
+ }
815
+
816
+ async listApps(args = {}) {
817
+ const serial = await this.ensureDevice();
818
+ const cmd = args.includeSystem === true ? 'shell pm list packages' : 'shell pm list packages -3';
819
+ const out = await this.#adb(serial, cmd, { timeout: 30000 });
820
+ const packages = out
821
+ .split('\n')
822
+ .map((line) => line.trim())
823
+ .filter(Boolean)
824
+ .map((line) => line.replace(/^package:/, ''))
825
+ .sort();
826
+ return {
827
+ success: true,
828
+ serial,
829
+ count: packages.length,
830
+ packages,
831
+ };
832
+ }
833
+
834
+ async installApk(args = {}) {
835
+ const apkPath = path.resolve(String(args.apkPath || ''));
836
+ if (!apkPath || !fs.existsSync(apkPath)) throw new Error(`APK not found: ${apkPath}`);
837
+ const serial = await this.ensureDevice();
838
+ await this.#adb(serial, `install -r ${quoteShell(apkPath)}`, { timeout: 300000 });
839
+ return {
840
+ success: true,
841
+ serial,
842
+ apkPath,
843
+ };
844
+ }
845
+
846
+ async getStatus() {
847
+ const devices = isExecutable(adbBinary()) ? await this.listDevices().catch(() => []) : [];
848
+ const state = readState();
849
+ let lastLogLine = null;
850
+ if (state.logPath && fs.existsSync(state.logPath)) {
851
+ try {
852
+ const lines = fs.readFileSync(state.logPath, 'utf8')
853
+ .split('\n')
854
+ .map((line) => line.trim())
855
+ .filter(Boolean);
856
+ lastLogLine = [...lines].reverse().find((line) =>
857
+ /fatal|error|warning|boot completed|disk space|running avd/i.test(line)
858
+ ) || lines[lines.length - 1] || null;
859
+ } catch {
860
+ lastLogLine = null;
861
+ }
862
+ }
863
+ return {
864
+ bootstrapped: state.bootstrapped === true,
865
+ sdkRoot: SDK_ROOT,
866
+ avdHome: AVD_HOME,
867
+ avdName: this.avdName,
868
+ adbPath: adbBinary(),
869
+ emulatorPath: emulatorBinary(),
870
+ serial: state.serial,
871
+ emulatorPid: state.emulatorPid,
872
+ logPath: state.logPath || null,
873
+ lastLogLine,
874
+ devices,
875
+ canBootstrap: process.platform === 'darwin' || process.platform === 'linux',
876
+ };
877
+ }
878
+
879
+ async close() {
880
+ return this.stopEmulator().catch(() => {});
881
+ }
882
+ }
883
+
884
+ module.exports = {
885
+ AndroidController,
886
+ androidTextEscape,
887
+ chooseLatestSystemImage,
888
+ parseLatestCmdlineToolsUrl,
889
+ sanitizeUiXml,
890
+ };