neoagent 2.3.1-beta.84 → 2.3.1-beta.86

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.
@@ -17,16 +17,20 @@ const SCREENSHOTS_DIR = path.join(DATA_DIR, 'screenshots');
17
17
  const UI_DUMPS_DIR = path.join(ARTIFACTS_DIR, 'ui-dumps');
18
18
  const LOGS_DIR = path.join(ARTIFACTS_DIR, 'logs');
19
19
  const TMP_DIR = path.join(ARTIFACTS_DIR, 'tmp');
20
+ const EMULATOR_HOME = path.join(ANDROID_ROOT, 'home');
21
+ const EMULATOR_ADVANCED_FEATURES_FILE = path.join(EMULATOR_HOME, 'advancedFeatures.ini');
20
22
  const AVD_HOME = path.join(ANDROID_ROOT, 'avd');
23
+ const EMULATOR_READY_SNAPSHOT = 'neoagent-ready';
21
24
  const STATE_DIR = path.join(ARTIFACTS_DIR, 'state');
22
25
  const STATE_FILE = path.join(ARTIFACTS_DIR, 'state.json');
23
26
  const OWNERSHIP_FILE = path.join(ARTIFACTS_DIR, 'device-ownership.json');
24
27
  const ANDROID_BOOTSTRAP_WORKER = path.join(__dirname, 'android_bootstrap_worker.js');
25
28
  const ANDROID_JAVA_TOOL_TIMEOUT_MS = 20 * 60 * 1000;
26
29
  const DEFAULT_AVD_NAME = 'neoagent-default';
27
- const DEFAULT_DATA_PARTITION = '1024M';
28
- const DEFAULT_SDCARD_SIZE = '128M';
29
- const DEFAULT_RAM_SIZE = '1024';
30
+ const DEFAULT_DATA_PARTITION_BYTES = 1024 * 1024 * 1024;
31
+ const DEFAULT_PARTITION_SIZE_MB = 1024;
32
+ const DEFAULT_SDCARD_SIZE_BYTES = 32 * 1024 * 1024;
33
+ const DEFAULT_RAM_SIZE_MB = 768;
30
34
  const DEFAULT_KEYEVENTS = Object.freeze({
31
35
  home: 3,
32
36
  back: 4,
@@ -44,10 +48,27 @@ const DEFAULT_KEYEVENTS = Object.freeze({
44
48
  tab: 61,
45
49
  });
46
50
 
47
- for (const dir of [ANDROID_ROOT, SDK_ROOT, ARTIFACTS_DIR, SCREENSHOTS_DIR, UI_DUMPS_DIR, LOGS_DIR, TMP_DIR, AVD_HOME, STATE_DIR]) {
51
+ for (const dir of [ANDROID_ROOT, SDK_ROOT, EMULATOR_HOME, ARTIFACTS_DIR, SCREENSHOTS_DIR, UI_DUMPS_DIR, LOGS_DIR, TMP_DIR, AVD_HOME, STATE_DIR]) {
48
52
  fs.mkdirSync(dir, { recursive: true });
49
53
  }
50
54
 
55
+ function ensureEmulatorAdvancedFeaturesFile() {
56
+ const content = [
57
+ 'Vulkan = off',
58
+ 'GLDirectMem = off',
59
+ '',
60
+ ].join('\n');
61
+ try {
62
+ if (fs.existsSync(EMULATOR_ADVANCED_FEATURES_FILE)) {
63
+ const current = fs.readFileSync(EMULATOR_ADVANCED_FEATURES_FILE, 'utf8');
64
+ if (current.trim() === content.trim()) {
65
+ return;
66
+ }
67
+ }
68
+ fs.writeFileSync(EMULATOR_ADVANCED_FEATURES_FILE, content);
69
+ } catch {}
70
+ }
71
+
51
72
  function sanitizeScopeKey(value) {
52
73
  const normalized = String(value || '')
53
74
  .trim()
@@ -82,12 +103,25 @@ function readOwnershipUnlocked() {
82
103
  if (!parsed || typeof parsed !== 'object') {
83
104
  return {};
84
105
  }
85
- return parsed;
106
+ return parsed;
86
107
  } catch {
87
108
  return {};
88
109
  }
89
110
  }
90
111
 
112
+ function ensureSparseFile(filePath, sizeBytes) {
113
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
114
+ const fd = fs.openSync(filePath, 'a');
115
+ try {
116
+ const currentSize = fs.statSync(filePath).size;
117
+ if (currentSize !== sizeBytes) {
118
+ fs.ftruncateSync(fd, sizeBytes);
119
+ }
120
+ } finally {
121
+ fs.closeSync(fd);
122
+ }
123
+ }
124
+
91
125
  function writeOwnershipUnlocked(nextOwners) {
92
126
  const tempPath = `${OWNERSHIP_FILE}.tmp`;
93
127
  fs.writeFileSync(tempPath, JSON.stringify(nextOwners, null, 2));
@@ -219,10 +253,37 @@ function platformTag() {
219
253
  }
220
254
 
221
255
  function systemImageArch() {
256
+ if (process.platform === 'darwin') return 'arm64-v8a';
222
257
  if (process.arch === 'arm64') return 'arm64-v8a';
223
258
  return 'x86_64';
224
259
  }
225
260
 
261
+ function emulatorHostArch() {
262
+ if (process.platform === 'darwin') return process.arch === 'arm64' ? 'aarch64' : 'x64';
263
+ if (process.arch === 'arm64') return 'aarch64';
264
+ return 'x64';
265
+ }
266
+
267
+ function emulatorGpuMode() {
268
+ return 'swiftshader_indirect';
269
+ }
270
+
271
+ function emulatorSnapshotStoragePath(avdName) {
272
+ return path.join(AVD_HOME, `${avdName}.avd`, 'snapshots.img');
273
+ }
274
+
275
+ function emulatorLaunchArgs(avdName) {
276
+ if (process.platform === 'darwin' && process.arch === 'arm64') {
277
+ return ['-no-snapshot'];
278
+ }
279
+ return [
280
+ '-snapshot',
281
+ EMULATOR_READY_SNAPSHOT,
282
+ '-snapstorage',
283
+ emulatorSnapshotStoragePath(avdName),
284
+ ];
285
+ }
286
+
226
287
  function parseCsvEnv(value) {
227
288
  return String(value || '')
228
289
  .split(',')
@@ -252,6 +313,28 @@ function systemImageArchCandidates() {
252
313
  return [preferred, ...fallbacks].filter((arch, index, list) => list.indexOf(arch) === index);
253
314
  }
254
315
 
316
+ function installedEmulatorMatchesHost() {
317
+ const binary = emulatorBinary();
318
+ if (!isExecutable(binary)) {
319
+ return false;
320
+ }
321
+
322
+ const probe = spawnSync('file', [binary], { encoding: 'utf8' });
323
+ if (probe.status !== 0) {
324
+ return false;
325
+ }
326
+
327
+ const output = `${probe.stdout || ''}${probe.stderr || ''}`.toLowerCase();
328
+ const hostArch = emulatorHostArch();
329
+ if (hostArch === 'aarch64') {
330
+ return output.includes('arm64') || output.includes('aarch64');
331
+ }
332
+ if (hostArch === 'x64') {
333
+ return output.includes('x86_64');
334
+ }
335
+ return true;
336
+ }
337
+
255
338
  function parseSystemImagePlatform(platformId) {
256
339
  const stable = String(platformId || '').match(/^android-(\d+)$/);
257
340
  if (stable) {
@@ -279,38 +362,107 @@ function parseSystemImagePlatform(platformId) {
279
362
  }
280
363
 
281
364
  function sdkEnv() {
365
+ ensureEmulatorAdvancedFeaturesFile();
366
+ const sdkRoot = activeAndroidSdkRoot();
282
367
  const base = {
283
368
  ...process.env,
284
- ANDROID_HOME: SDK_ROOT,
285
- ANDROID_SDK_ROOT: SDK_ROOT,
369
+ ANDROID_HOME: sdkRoot,
370
+ ANDROID_SDK_ROOT: sdkRoot,
371
+ ANDROID_EMULATOR_HOME: EMULATOR_HOME,
372
+ ANDROID_USER_HOME: EMULATOR_HOME,
286
373
  ANDROID_AVD_HOME: AVD_HOME,
287
374
  AVD_HOME,
288
375
  JAVA_TOOL_OPTIONS: process.env.JAVA_TOOL_OPTIONS || '-Xint',
289
376
  };
290
377
  const pathParts = [
291
- path.join(SDK_ROOT, 'platform-tools'),
292
- path.join(SDK_ROOT, 'emulator'),
293
- path.join(CMDLINE_LATEST, 'bin'),
378
+ path.join(sdkRoot, 'platform-tools'),
379
+ path.join(sdkRoot, 'emulator'),
380
+ path.join(sdkRoot, 'cmdline-tools', 'latest', 'bin'),
294
381
  process.env.PATH || '',
295
382
  ].filter(Boolean);
296
383
  base.PATH = pathParts.join(path.delimiter);
297
384
  return base;
298
385
  }
299
386
 
387
+ function systemAdbBinary() {
388
+ if (process.platform === 'win32') return null;
389
+ return '/usr/bin/adb';
390
+ }
391
+
392
+ function hostAdbBinary() {
393
+ const probe = spawnSync('bash', ['-lc', 'command -v adb'], { encoding: 'utf8' });
394
+ if (probe.status !== 0) {
395
+ return null;
396
+ }
397
+ const binary = String(probe.stdout || '').trim();
398
+ return binary && isExecutable(binary) ? binary : null;
399
+ }
400
+
401
+ function hostAndroidSdkRoot() {
402
+ const adb = hostAdbBinary();
403
+ if (!adb) {
404
+ return null;
405
+ }
406
+
407
+ const root = path.dirname(path.dirname(adb));
408
+ const sdkmanager = path.join(root, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
409
+ const avdmanager = path.join(root, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
410
+ const emulator = path.join(root, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
411
+ if (isExecutable(sdkmanager) && isExecutable(avdmanager) && isExecutable(emulator)) {
412
+ return root;
413
+ }
414
+
415
+ return null;
416
+ }
417
+
418
+ const SHARED_ANDROID_SDK_ROOT = hostAndroidSdkRoot();
419
+
420
+ function activeAndroidSdkRoot() {
421
+ return SHARED_ANDROID_SDK_ROOT || SDK_ROOT;
422
+ }
423
+
424
+ function sharedAndroidSdkReady() {
425
+ if (!SHARED_ANDROID_SDK_ROOT) {
426
+ return false;
427
+ }
428
+ const adb = path.join(SHARED_ANDROID_SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
429
+ const sdkmanager = path.join(SHARED_ANDROID_SDK_ROOT, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
430
+ const avdmanager = path.join(SHARED_ANDROID_SDK_ROOT, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
431
+ const emulator = path.join(SHARED_ANDROID_SDK_ROOT, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
432
+ return [adb, sdkmanager, avdmanager, emulator].every(isExecutable);
433
+ }
434
+
300
435
  function adbBinary() {
301
- return process.env.ANDROID_ADB_PATH || path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
436
+ if (process.env.ANDROID_ADB_PATH) {
437
+ return process.env.ANDROID_ADB_PATH;
438
+ }
439
+ if (SHARED_ANDROID_SDK_ROOT) {
440
+ const sharedAdb = path.join(SHARED_ANDROID_SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
441
+ if (isExecutable(sharedAdb)) {
442
+ return sharedAdb;
443
+ }
444
+ }
445
+ const hostAdb = hostAdbBinary();
446
+ if (hostAdb) {
447
+ return hostAdb;
448
+ }
449
+ const distroAdb = systemAdbBinary();
450
+ if (process.platform === 'linux' && process.arch === 'arm64' && distroAdb && isExecutable(distroAdb)) {
451
+ return distroAdb;
452
+ }
453
+ return path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
302
454
  }
303
455
 
304
456
  function sdkManagerBinary() {
305
- return path.join(CMDLINE_LATEST, 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
457
+ return path.join(activeAndroidSdkRoot(), 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
306
458
  }
307
459
 
308
460
  function avdManagerBinary() {
309
- return path.join(CMDLINE_LATEST, 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
461
+ return path.join(activeAndroidSdkRoot(), 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
310
462
  }
311
463
 
312
464
  function emulatorBinary() {
313
- return path.join(SDK_ROOT, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
465
+ return path.join(activeAndroidSdkRoot(), 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
314
466
  }
315
467
 
316
468
  function isExecutable(filePath) {
@@ -341,6 +493,47 @@ function fetchText(url) {
341
493
  }
342
494
 
343
495
  function downloadFile(url, dest) {
496
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
497
+ const curlBin = process.platform === 'win32' ? 'curl.exe' : 'curl';
498
+ if (commandExists(curlBin)) {
499
+ const curlResult = spawnSync(curlBin, [
500
+ '--fail',
501
+ '--location',
502
+ '--silent',
503
+ '--show-error',
504
+ '--retry', '3',
505
+ '--retry-delay', '2',
506
+ '--output', dest,
507
+ url,
508
+ ], {
509
+ encoding: 'utf8',
510
+ stdio: ['ignore', 'ignore', 'inherit'],
511
+ });
512
+ if (curlResult.status === 0) {
513
+ return waitForReadyFile(dest);
514
+ }
515
+ const detail = String(curlResult.stderr || curlResult.stdout || curlResult.error?.message || `curl exited with code ${curlResult.status ?? 'unknown'}`).trim();
516
+ console.warn(`[Android] curl download failed for ${url}; falling back to Node HTTPS (${detail || 'no detail'})`);
517
+ }
518
+
519
+ return downloadFileViaHttps(url, dest);
520
+ }
521
+
522
+ async function waitForReadyFile(filePath, timeoutMs = 600000) {
523
+ const deadline = Date.now() + timeoutMs;
524
+ while (Date.now() < deadline) {
525
+ try {
526
+ const stats = fs.statSync(filePath);
527
+ if (stats.isFile() && stats.size > 0) {
528
+ return;
529
+ }
530
+ } catch {}
531
+ await sleep(250);
532
+ }
533
+ throw new Error(`Downloaded file was not ready at ${filePath}`);
534
+ }
535
+
536
+ function downloadFileViaHttps(url, dest) {
344
537
  return new Promise((resolve, reject) => {
345
538
  const out = fs.createWriteStream(dest);
346
539
  https.get(url, (res) => {
@@ -356,7 +549,13 @@ function downloadFile(url, dest) {
356
549
  return;
357
550
  }
358
551
  res.pipe(out);
359
- out.on('finish', () => out.close(resolve));
552
+ out.on('finish', () => out.close((closeErr) => {
553
+ if (closeErr) {
554
+ reject(closeErr);
555
+ return;
556
+ }
557
+ resolve(waitForReadyFile(dest));
558
+ }));
360
559
  }).on('error', (err) => {
361
560
  out.close();
362
561
  fs.rmSync(dest, { force: true });
@@ -366,19 +565,33 @@ function downloadFile(url, dest) {
366
565
  }
367
566
 
368
567
  function extractZip(zipPath, destDir) {
568
+ if (process.platform === 'darwin' && commandExists('ditto')) {
569
+ const res = spawnSync('ditto', ['-x', '-k', zipPath, destDir], { encoding: 'utf8' });
570
+ if (res.status === 0) return;
571
+ console.warn(`[Android] ditto failed for ${zipPath}; falling back to unzip`);
572
+ }
573
+
369
574
  if (commandExists('unzip')) {
370
575
  const res = spawnSync('unzip', ['-qo', zipPath, '-d', destDir], { encoding: 'utf8' });
371
576
  if (res.status === 0) return;
372
577
  throw new Error(res.stderr || `unzip failed for ${zipPath}`);
373
578
  }
374
579
 
375
- if (process.platform === 'darwin' && commandExists('ditto')) {
376
- const res = spawnSync('ditto', ['-x', '-k', zipPath, destDir], { encoding: 'utf8' });
377
- if (res.status === 0) return;
378
- throw new Error(res.stderr || `ditto failed for ${zipPath}`);
580
+ throw new Error('Neither unzip nor ditto is available to extract Android SDK archives');
581
+ }
582
+
583
+ function clearQuarantineAttribute(targetPath) {
584
+ if (process.platform !== 'darwin' || !commandExists('xattr')) {
585
+ return;
379
586
  }
587
+ spawnSync('xattr', ['-dr', 'com.apple.quarantine', targetPath], { encoding: 'utf8' });
588
+ }
380
589
 
381
- throw new Error('Neither unzip nor ditto is available to extract Android SDK archives');
590
+ function codesignAdHoc(targetPath) {
591
+ if (process.platform !== 'darwin' || !commandExists('codesign')) {
592
+ return;
593
+ }
594
+ spawnSync('codesign', ['--force', '--deep', '--sign', '-', targetPath], { encoding: 'utf8' });
382
595
  }
383
596
 
384
597
  function listFilesRecursive(rootDir, predicate, bucket = []) {
@@ -441,13 +654,14 @@ function parseLatestCmdlineToolsUrl(xml) {
441
654
 
442
655
  function parseLatestEmulatorUrl(xml) {
443
656
  const tag = platformTag() === 'mac' ? 'macosx' : 'linux';
657
+ const hostArch = emulatorHostArch();
444
658
  const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="emulator">([\\s\\S]*?)<\\/remotePackage>`));
445
659
  if (!packageMatch) throw new Error('Could not locate emulator in Android repository metadata');
446
660
 
447
661
  const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
448
662
  for (const block of archiveBlocks) {
449
663
  if (!new RegExp(`<host-os>${tag}<\\/host-os>`).test(block)) continue;
450
- if (!/<host-bits>64<\/host-bits>/.test(block)) continue;
664
+ if (!new RegExp(`<host-arch>${hostArch}<\\/host-arch>`).test(block)) continue;
451
665
  const urlMatch = block.match(/<url>\s*([^<]*emulator-[^<]+\.zip)\s*<\/url>/);
452
666
  if (urlMatch) return `https://dl.google.com/android/repository/${urlMatch[1]}`;
453
667
  }
@@ -479,7 +693,7 @@ function findDirectoryContainingFiles(rootDir, requiredFiles) {
479
693
  }
480
694
 
481
695
  function parseLatestPlatformToolsUrl(xml) {
482
- const tag = platformTag() === 'mac' ? 'darwin' : 'linux';
696
+ const tag = platformTag() === 'mac' ? 'macosx' : 'linux';
483
697
  const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="platform-tools">([\\s\\S]*?)<\\/remotePackage>`));
484
698
  if (!packageMatch) throw new Error('Could not locate platform-tools in Android repository metadata');
485
699
 
@@ -526,83 +740,109 @@ function parseLatestSystemImageUrl(xml, packageName) {
526
740
  throw new Error(`Could not find a system image archive for ${packageName}`);
527
741
  }
528
742
 
743
+ async function fetchEmulatorMetadata() {
744
+ const urls = [
745
+ 'https://dl.google.com/android/repository/repository2-3.xml',
746
+ 'https://dl.google.com/android/repository/repository2-1.xml',
747
+ ];
748
+ let lastError = null;
749
+ for (const url of urls) {
750
+ try {
751
+ return await fetchText(url);
752
+ } catch (error) {
753
+ lastError = error;
754
+ }
755
+ }
756
+ throw lastError || new Error('Could not fetch Android emulator repository metadata');
757
+ }
758
+
529
759
  async function installEmulatorArchive(metadata) {
530
760
  const url = parseLatestEmulatorUrl(metadata);
531
761
  const zipPath = path.join(TMP_DIR, path.basename(url));
532
- const extractDir = path.join(TMP_DIR, `emulator-${Date.now()}`);
762
+ const installDir = path.join(activeAndroidSdkRoot(), 'emulator');
533
763
 
534
- fs.mkdirSync(extractDir, { recursive: true });
535
764
  await downloadFile(url, zipPath);
536
- extractZip(zipPath, extractDir);
765
+ fs.rmSync(installDir, { recursive: true, force: true });
766
+ extractZip(zipPath, activeAndroidSdkRoot());
537
767
 
538
- const extractedRoot = findDirectoryContainingFiles(extractDir, [
768
+ const extractedRoot = findDirectoryContainingFiles(installDir, [
539
769
  process.platform === 'win32' ? 'emulator.exe' : 'emulator',
540
770
  ]);
541
771
  if (!extractedRoot) {
542
772
  throw new Error('Downloaded Android emulator archive did not contain an emulator binary');
543
773
  }
774
+ clearQuarantineAttribute(installDir);
775
+ codesignAdHoc(installDir);
544
776
 
545
- fs.rmSync(path.join(SDK_ROOT, 'emulator'), { recursive: true, force: true });
546
- fs.mkdirSync(path.join(SDK_ROOT, 'emulator'), { recursive: true });
547
- fs.cpSync(extractedRoot, path.join(SDK_ROOT, 'emulator'), { recursive: true });
548
777
  fs.rmSync(zipPath, { force: true });
549
- fs.rmSync(extractDir, { recursive: true, force: true });
550
778
  }
551
779
 
552
780
  async function installPlatformToolsArchive(metadata) {
553
781
  const url = parseLatestPlatformToolsUrl(metadata);
554
782
  const zipPath = path.join(TMP_DIR, path.basename(url));
555
- const extractDir = path.join(TMP_DIR, `platform-tools-${Date.now()}`);
783
+ const installDir = path.join(activeAndroidSdkRoot(), 'platform-tools');
556
784
 
557
- fs.mkdirSync(extractDir, { recursive: true });
558
785
  await downloadFile(url, zipPath);
559
- extractZip(zipPath, extractDir);
786
+ fs.rmSync(installDir, { recursive: true, force: true });
787
+ extractZip(zipPath, activeAndroidSdkRoot());
560
788
 
561
- const extractedRoot = findDirectoryContainingFiles(extractDir, [
789
+ const extractedRoot = findDirectoryContainingFiles(installDir, [
562
790
  process.platform === 'win32' ? 'adb.exe' : 'adb',
563
791
  ]);
564
792
  if (!extractedRoot) {
565
793
  throw new Error('Downloaded platform-tools archive did not contain adb');
566
794
  }
567
795
 
568
- fs.rmSync(path.join(SDK_ROOT, 'platform-tools'), { recursive: true, force: true });
569
- fs.mkdirSync(path.join(SDK_ROOT, 'platform-tools'), { recursive: true });
570
- fs.cpSync(extractedRoot, path.join(SDK_ROOT, 'platform-tools'), { recursive: true });
571
796
  fs.rmSync(zipPath, { force: true });
572
- fs.rmSync(extractDir, { recursive: true, force: true });
797
+ }
798
+
799
+ function shouldInstallPlatformToolsArchive() {
800
+ if (SHARED_ANDROID_SDK_ROOT) {
801
+ return false;
802
+ }
803
+ if (hostAdbBinary()) {
804
+ return false;
805
+ }
806
+ const distroAdb = systemAdbBinary();
807
+ if (process.platform === 'linux' && process.arch === 'arm64' && distroAdb && isExecutable(distroAdb)) {
808
+ return false;
809
+ }
810
+ return true;
573
811
  }
574
812
 
575
813
  async function installSystemImageArchive(metadata, packageName) {
576
814
  const url = parseLatestSystemImageUrl(metadata, packageName);
577
815
  const zipPath = path.join(TMP_DIR, path.basename(url));
578
- const extractDir = path.join(TMP_DIR, `system-image-${Date.now()}`);
579
- const targetRoot = path.join(SDK_ROOT, ...String(packageName).split(';').filter(Boolean));
816
+ const targetRoot = path.join(activeAndroidSdkRoot(), ...String(packageName).split(';').filter(Boolean));
817
+ const extractDir = fs.mkdtempSync(path.join(TMP_DIR, 'system-image-'));
580
818
 
581
- fs.mkdirSync(extractDir, { recursive: true });
582
- await downloadFile(url, zipPath);
583
- extractZip(zipPath, extractDir);
819
+ try {
820
+ await downloadFile(url, zipPath);
821
+ extractZip(zipPath, extractDir);
584
822
 
585
- const extractedRoot = findDirectoryContainingFiles(extractDir, ['userdata.img']) ||
586
- findDirectoryContainingFiles(extractDir, ['system.img']) ||
587
- findDirectoryContainingFiles(extractDir, ['package.xml']);
588
- if (!extractedRoot) {
589
- throw new Error(`Downloaded Android system image archive for ${packageName} did not contain the expected files`);
590
- }
823
+ const extractedRoot = findDirectoryContainingFiles(extractDir, ['userdata.img']) ||
824
+ findDirectoryContainingFiles(extractDir, ['system.img']) ||
825
+ findDirectoryContainingFiles(extractDir, ['package.xml']);
826
+ if (!extractedRoot) {
827
+ throw new Error(`Downloaded Android system image archive for ${packageName} did not contain the expected files`);
828
+ }
591
829
 
592
- fs.rmSync(targetRoot, { recursive: true, force: true });
593
- fs.mkdirSync(targetRoot, { recursive: true });
594
- fs.cpSync(extractedRoot, targetRoot, { recursive: true });
595
- fs.rmSync(zipPath, { force: true });
596
- fs.rmSync(extractDir, { recursive: true, force: true });
830
+ fs.rmSync(targetRoot, { recursive: true, force: true });
831
+ fs.mkdirSync(path.dirname(targetRoot), { recursive: true });
832
+ fs.cpSync(extractedRoot, targetRoot, { recursive: true, force: true });
833
+ } finally {
834
+ fs.rmSync(zipPath, { force: true });
835
+ fs.rmSync(extractDir, { recursive: true, force: true });
836
+ }
597
837
  }
598
838
 
599
839
  function systemImageTagScore(tag) {
600
840
  const value = String(tag || '').toLowerCase();
601
- if (value.startsWith('google_apis_playstore')) return 50;
602
- if (value.startsWith('google_apis')) return 40;
603
- if (value === 'google_atd') return 30;
604
- if (value === 'aosp_atd') return 20;
605
- if (value === 'default') return 10;
841
+ if (value.startsWith('google_apis_playstore')) return 60;
842
+ if (value.startsWith('google_apis')) return 50;
843
+ if (value === 'default') return 40;
844
+ if (value === 'google_atd') return 20;
845
+ if (value === 'aosp_atd') return 10;
606
846
  return 0;
607
847
  }
608
848
 
@@ -639,7 +879,7 @@ function parseSystemImages(listOutput) {
639
879
  }
640
880
 
641
881
  function parseInstalledSystemImages() {
642
- const root = path.join(SDK_ROOT, 'system-images');
882
+ const root = path.join(activeAndroidSdkRoot(), 'system-images');
643
883
  if (!fs.existsSync(root)) {
644
884
  return [];
645
885
  }
@@ -847,7 +1087,39 @@ class AndroidController {
847
1087
  this.ownerKey = normalizeOwnerKey(this.userId);
848
1088
  this.stateFile = resolveStateFile(this.scopeKey);
849
1089
  this.cli = new CLIExecutor();
850
- this.avdName = this.#readState().avdName || (this.userId ? `neoagent-${this.scopeKey}` : DEFAULT_AVD_NAME);
1090
+ const state = this.#readState();
1091
+ const desiredArchTag = sanitizeScopeKey(systemImageArch());
1092
+ const stateMatchesArch = state?.systemImageArch === systemImageArch() && String(state?.avdName || '').includes(desiredArchTag);
1093
+ this.previousAvdName = String(state?.avdName || '').trim() || null;
1094
+ this.avdName = stateMatchesArch
1095
+ ? state.avdName
1096
+ : `neoagent-${this.scopeKey}-${desiredArchTag}`;
1097
+ if (this.previousAvdName !== this.avdName) {
1098
+ this.#appendState({
1099
+ avdName: this.avdName,
1100
+ serial: null,
1101
+ emulatorPid: null,
1102
+ starting: false,
1103
+ startupPhase: null,
1104
+ lastStartError: null,
1105
+ lastLogLine: null,
1106
+ });
1107
+ }
1108
+ if (String(state?.systemImageArch || '').trim() && state.systemImageArch !== systemImageArch()) {
1109
+ this.#appendState({
1110
+ serial: null,
1111
+ emulatorPid: null,
1112
+ bootstrapped: false,
1113
+ systemImage: null,
1114
+ apiLevel: null,
1115
+ systemImageArch: null,
1116
+ avdSystemImage: null,
1117
+ starting: false,
1118
+ startupPhase: null,
1119
+ lastStartError: null,
1120
+ lastLogLine: null,
1121
+ });
1122
+ }
851
1123
  this.bootstrapPromise = null;
852
1124
  this.startPromise = null;
853
1125
  this.#registerProcessCleanup();
@@ -973,6 +1245,39 @@ class AndroidController {
973
1245
  process.once('unhandledRejection', cleanup);
974
1246
  }
975
1247
 
1248
+ async #terminateStaleEmulatorProcesses(names = [this.avdName]) {
1249
+ const avdNames = [...new Set((Array.isArray(names) ? names : [names]).map((name) => String(name || '').trim()).filter(Boolean))];
1250
+ const pids = [];
1251
+ for (const avdName of avdNames) {
1252
+ const probe = spawnSync('pgrep', ['-f', `@${avdName}`], { encoding: 'utf8' });
1253
+ if (probe.status !== 0) {
1254
+ continue;
1255
+ }
1256
+ const found = String(probe.stdout || '')
1257
+ .split(/\s+/)
1258
+ .map((pid) => Number(pid))
1259
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
1260
+ pids.push(...found);
1261
+ }
1262
+ const uniquePids = [...new Set(pids)];
1263
+ if (uniquePids.length === 0) {
1264
+ return;
1265
+ }
1266
+
1267
+ for (const pid of uniquePids) {
1268
+ try { process.kill(pid, 'SIGTERM'); } catch {}
1269
+ }
1270
+
1271
+ await sleep(1500);
1272
+
1273
+ for (const pid of uniquePids) {
1274
+ try {
1275
+ process.kill(pid, 0);
1276
+ process.kill(pid, 'SIGKILL');
1277
+ } catch {}
1278
+ }
1279
+ }
1280
+
976
1281
  #stopTrackedEmulatorSync() {
977
1282
  const state = this.#readState();
978
1283
  const serial = state.serial;
@@ -1018,10 +1323,80 @@ class AndroidController {
1018
1323
  }
1019
1324
 
1020
1325
  async ensureBootstrapped() {
1326
+ const desiredArch = systemImageArch();
1327
+ const state = this.#readState();
1328
+ const installedImages = parseInstalledSystemImages();
1329
+ const preferredInstalled =
1330
+ chooseConfiguredSystemImage(installedImages) ||
1331
+ chooseLatestSystemImage(installedImages, [desiredArch]) ||
1332
+ chooseLatestSystemImage(installedImages);
1333
+ const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
1334
+ const available = parseRepositorySystemImages(systemImageMetadata);
1335
+ const preferredAvailable =
1336
+ chooseConfiguredSystemImage(available) ||
1337
+ chooseLatestSystemImage(available, [desiredArch]) ||
1338
+ chooseLatestSystemImage(available);
1339
+ const stateApiLevel = Number(state.apiLevel || 0) || 0;
1340
+
1341
+ if (!shouldForceSdkRefresh() && sharedAndroidSdkReady() && preferredInstalled && preferredAvailable) {
1342
+ const stateAligned =
1343
+ preferredInstalled.packageName === state.systemImage &&
1344
+ preferredInstalled.apiLevel === stateApiLevel &&
1345
+ preferredInstalled.arch === state.systemImageArch &&
1346
+ state.avdName === this.avdName;
1347
+
1348
+ if (stateAligned && preferredInstalled.packageName === preferredAvailable.packageName) {
1349
+ return;
1350
+ }
1351
+
1352
+ if (preferredInstalled.packageName !== preferredAvailable.packageName) {
1353
+ const changeSummary = describeAutoFixChanges(
1354
+ {
1355
+ avdName: state.avdName || null,
1356
+ systemImage: state.systemImage || null,
1357
+ apiLevel: stateApiLevel || null,
1358
+ systemImageArch: state.systemImageArch || null,
1359
+ },
1360
+ {
1361
+ avdName: this.avdName,
1362
+ systemImage: preferredAvailable.packageName,
1363
+ apiLevel: preferredAvailable.apiLevel,
1364
+ systemImageArch: preferredAvailable.arch,
1365
+ },
1366
+ ['avdName', 'systemImage', 'apiLevel', 'systemImageArch']
1367
+ );
1368
+ if (changeSummary) {
1369
+ console.log(`[Android] Auto-fixed host SDK state (${changeSummary})`);
1370
+ }
1371
+ await installSystemImageArchive(systemImageMetadata, preferredAvailable.packageName);
1372
+ this.#appendState({
1373
+ bootstrapped: true,
1374
+ avdName: this.avdName,
1375
+ serial: null,
1376
+ emulatorPid: null,
1377
+ systemImage: preferredAvailable.packageName,
1378
+ apiLevel: preferredAvailable.apiLevel,
1379
+ systemImageArch: preferredAvailable.arch,
1380
+ });
1381
+ return;
1382
+ }
1383
+
1384
+ this.#appendState({
1385
+ bootstrapped: true,
1386
+ avdName: this.avdName,
1387
+ serial: null,
1388
+ emulatorPid: null,
1389
+ systemImage: preferredInstalled.packageName,
1390
+ apiLevel: preferredInstalled.apiLevel,
1391
+ systemImageArch: preferredInstalled.arch,
1392
+ });
1393
+ return;
1394
+ }
1395
+
1021
1396
  const binariesReady =
1022
1397
  isExecutable(adbBinary()) &&
1023
- isExecutable(emulatorBinary());
1024
-
1398
+ isExecutable(emulatorBinary()) &&
1399
+ installedEmulatorMatchesHost();
1025
1400
  if (!binariesReady) {
1026
1401
  if (this.bootstrapPromise) {
1027
1402
  await this.bootstrapPromise;
@@ -1035,70 +1410,74 @@ class AndroidController {
1035
1410
  }
1036
1411
  }
1037
1412
 
1038
- const state = this.#readState();
1413
+ const runtimeNeedsRefresh =
1414
+ state.systemImageArch !== desiredArch ||
1415
+ !installedEmulatorMatchesHost();
1039
1416
  if (!shouldForceSdkRefresh()) {
1040
- const installedImages = parseInstalledSystemImages();
1041
- if (installedImages.length > 0) {
1042
- const preferredInstalled =
1043
- chooseConfiguredSystemImage(installedImages) ||
1044
- chooseLatestSystemImage(installedImages);
1045
- if (!preferredInstalled) {
1046
- throw new Error(formatSystemImageError(installedImages));
1047
- }
1048
- const stateApiLevel = Number(state.apiLevel || 0) || 0;
1417
+ if (!runtimeNeedsRefresh && preferredInstalled) {
1049
1418
  const stateNeedsRefresh =
1050
1419
  preferredInstalled.packageName !== state.systemImage ||
1051
1420
  preferredInstalled.apiLevel !== stateApiLevel ||
1052
- preferredInstalled.arch !== state.systemImageArch;
1421
+ preferredInstalled.arch !== state.systemImageArch ||
1422
+ state.avdName !== this.avdName;
1053
1423
  if (stateNeedsRefresh) {
1054
1424
  const changeSummary = describeAutoFixChanges(
1055
1425
  {
1426
+ avdName: state.avdName || null,
1056
1427
  systemImage: state.systemImage || null,
1057
1428
  apiLevel: stateApiLevel || null,
1058
1429
  systemImageArch: state.systemImageArch || null,
1059
1430
  },
1060
1431
  {
1432
+ avdName: this.avdName,
1061
1433
  systemImage: preferredInstalled.packageName,
1062
1434
  apiLevel: preferredInstalled.apiLevel,
1063
1435
  systemImageArch: preferredInstalled.arch,
1064
1436
  },
1065
- ['systemImage', 'apiLevel', 'systemImageArch']
1437
+ ['avdName', 'systemImage', 'apiLevel', 'systemImageArch']
1066
1438
  );
1067
1439
  if (changeSummary) {
1068
1440
  console.log(`[Android] Auto-fixed preferred system image (${changeSummary})`);
1069
1441
  }
1070
1442
  this.#appendState({
1071
1443
  bootstrapped: true,
1444
+ avdName: this.avdName,
1445
+ serial: null,
1446
+ emulatorPid: null,
1072
1447
  systemImage: preferredInstalled.packageName,
1073
1448
  apiLevel: preferredInstalled.apiLevel,
1074
1449
  systemImageArch: preferredInstalled.arch,
1075
1450
  });
1076
1451
  }
1077
1452
  return;
1078
- } else if (state.bootstrapped === true && state.systemImage) {
1453
+ }
1454
+ if (!runtimeNeedsRefresh && state.bootstrapped === true && state.systemImage) {
1079
1455
  return;
1080
1456
  }
1081
1457
  }
1082
1458
 
1083
1459
  this.#appendState({ bootstrapped: true });
1084
- const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
1085
- const available = parseRepositorySystemImages(systemImageMetadata);
1086
- const latestSystemImage = chooseConfiguredSystemImage(available) || chooseLatestSystemImage(available);
1087
- if (!latestSystemImage) throw new Error(formatSystemImageError(available));
1088
- const metadata = await fetchText('https://dl.google.com/android/repository/repository2-1.xml');
1089
- await installPlatformToolsArchive(metadata);
1460
+ const metadata = await fetchEmulatorMetadata();
1461
+ if (shouldInstallPlatformToolsArchive()) {
1462
+ await installPlatformToolsArchive(metadata);
1463
+ }
1090
1464
  await installEmulatorArchive(metadata);
1091
- await installSystemImageArchive(systemImageMetadata, latestSystemImage.packageName);
1465
+ await installSystemImageArchive(systemImageMetadata, preferredAvailable?.packageName || preferredInstalled?.packageName);
1466
+ const selectedImage = preferredAvailable || preferredInstalled;
1467
+ if (!selectedImage) {
1468
+ throw new Error(formatSystemImageError(available));
1469
+ }
1092
1470
  this.#appendState({
1093
1471
  bootstrapped: true,
1094
- systemImage: latestSystemImage.packageName,
1095
- apiLevel: latestSystemImage.apiLevel,
1096
- systemImageArch: latestSystemImage.arch,
1472
+ avdName: this.avdName,
1473
+ systemImage: selectedImage.packageName,
1474
+ apiLevel: selectedImage.apiLevel,
1475
+ systemImageArch: selectedImage.arch,
1097
1476
  });
1098
1477
  }
1099
1478
 
1100
1479
  async #bootstrapRuntime() {
1101
- const metadata = await fetchText('https://dl.google.com/android/repository/repository2-1.xml');
1480
+ const metadata = await fetchEmulatorMetadata();
1102
1481
  const url = parseLatestCmdlineToolsUrl(metadata);
1103
1482
  const zipPath = path.join(TMP_DIR, path.basename(url));
1104
1483
  const extractDir = path.join(TMP_DIR, `cmdline-tools-${Date.now()}`);
@@ -1120,7 +1499,9 @@ class AndroidController {
1120
1499
  fs.cpSync(extractedRoot, CMDLINE_LATEST, { recursive: true });
1121
1500
  fs.rmSync(zipPath, { force: true });
1122
1501
  fs.rmSync(extractDir, { recursive: true, force: true });
1123
- await installPlatformToolsArchive(metadata);
1502
+ if (shouldInstallPlatformToolsArchive()) {
1503
+ await installPlatformToolsArchive(metadata);
1504
+ }
1124
1505
  await installEmulatorArchive(metadata);
1125
1506
 
1126
1507
  const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
@@ -1177,6 +1558,14 @@ class AndroidController {
1177
1558
  const expectedAbi = systemImagePackageToAbi(pkg);
1178
1559
  const currentCpuArch = readIniValue(config, 'hw.cpu.arch');
1179
1560
  const expectedCpuArch = systemImagePackageToCpuArch(pkg);
1561
+ const currentDataPartitionSize = readIniValue(config, 'disk.dataPartition.size');
1562
+ const expectedDataPartitionSize = String(DEFAULT_DATA_PARTITION_BYTES);
1563
+ const currentSdcardSize = readIniValue(config, 'sdcard.size');
1564
+ const expectedSdcardSize = String(DEFAULT_SDCARD_SIZE_BYTES);
1565
+ const currentRamSize = readIniValue(config, 'hw.ramSize');
1566
+ const expectedRamSize = String(DEFAULT_RAM_SIZE_MB);
1567
+ const currentGpuMode = readIniValue(config, 'hw.gpu.mode');
1568
+ const expectedGpuMode = emulatorGpuMode();
1180
1569
  if (expectedImageDir && currentImageDir && currentImageDir !== expectedImageDir) {
1181
1570
  avdNeedsRecreate = true;
1182
1571
  avdRecreateReasons.push(`image.sysdir.1: ${currentImageDir} -> ${expectedImageDir}`);
@@ -1189,6 +1578,22 @@ class AndroidController {
1189
1578
  avdNeedsRecreate = true;
1190
1579
  avdRecreateReasons.push(`hw.cpu.arch: ${currentCpuArch} -> ${expectedCpuArch}`);
1191
1580
  }
1581
+ if (currentDataPartitionSize && currentDataPartitionSize !== expectedDataPartitionSize) {
1582
+ avdNeedsRecreate = true;
1583
+ avdRecreateReasons.push(`disk.dataPartition.size: ${currentDataPartitionSize} -> ${expectedDataPartitionSize}`);
1584
+ }
1585
+ if (currentSdcardSize && currentSdcardSize !== expectedSdcardSize) {
1586
+ avdNeedsRecreate = true;
1587
+ avdRecreateReasons.push(`sdcard.size: ${currentSdcardSize} -> ${expectedSdcardSize}`);
1588
+ }
1589
+ if (currentRamSize && currentRamSize !== expectedRamSize) {
1590
+ avdNeedsRecreate = true;
1591
+ avdRecreateReasons.push(`hw.ramSize: ${currentRamSize} -> ${expectedRamSize}`);
1592
+ }
1593
+ if (currentGpuMode && currentGpuMode !== expectedGpuMode) {
1594
+ avdNeedsRecreate = true;
1595
+ avdRecreateReasons.push(`hw.gpu.mode: ${currentGpuMode} -> ${expectedGpuMode}`);
1596
+ }
1192
1597
  } catch {}
1193
1598
  }
1194
1599
 
@@ -1199,7 +1604,10 @@ class AndroidController {
1199
1604
  await this.stopEmulator().catch(() => {});
1200
1605
  fs.rmSync(avdDir, { recursive: true, force: true });
1201
1606
  fs.rmSync(path.join(AVD_HOME, `${this.avdName}.ini`), { force: true });
1607
+ fs.rmSync(path.join(avdDir, 'userdata-qemu.img'), { force: true });
1608
+ fs.rmSync(emulatorSnapshotStoragePath(this.avdName), { force: true });
1202
1609
  } else if (avdExists) {
1610
+ ensureSparseFile(path.join(avdDir, 'userdata-qemu.img'), DEFAULT_DATA_PARTITION_BYTES);
1203
1611
  return;
1204
1612
  }
1205
1613
 
@@ -1224,6 +1632,7 @@ class AndroidController {
1224
1632
  }
1225
1633
 
1226
1634
  fs.mkdirSync(avdDir, { recursive: true });
1635
+ ensureSparseFile(path.join(avdDir, 'userdata-qemu.img'), DEFAULT_DATA_PARTITION_BYTES);
1227
1636
  fs.writeFileSync(
1228
1637
  path.join(AVD_HOME, `${this.avdName}.ini`),
1229
1638
  [
@@ -1247,19 +1656,19 @@ class AndroidController {
1247
1656
  'hw.dPad=no',
1248
1657
  'hw.gps=yes',
1249
1658
  'hw.gpu.enabled=yes',
1250
- 'hw.gpu.mode=auto',
1659
+ `hw.gpu.mode=${emulatorGpuMode()}`,
1251
1660
  'hw.initialOrientation=Portrait',
1252
1661
  'hw.keyboard=yes',
1253
1662
  'hw.lcd.density=440',
1254
1663
  'hw.lcd.height=1920',
1255
1664
  'hw.lcd.width=1080',
1256
1665
  'hw.mainKeys=no',
1257
- `hw.ramSize=${DEFAULT_RAM_SIZE}`,
1666
+ `hw.ramSize=${DEFAULT_RAM_SIZE_MB}`,
1258
1667
  'hw.sensors.orientation=yes',
1259
1668
  'hw.sensors.proximity=yes',
1260
1669
  'hw.trackBall=no',
1261
- `disk.dataPartition.size=${DEFAULT_DATA_PARTITION}`,
1262
- `sdcard.size=${DEFAULT_SDCARD_SIZE}`,
1670
+ `disk.dataPartition.size=${DEFAULT_DATA_PARTITION_BYTES}`,
1671
+ `sdcard.size=${DEFAULT_SDCARD_SIZE_BYTES}`,
1263
1672
  'runtime.network.latency=none',
1264
1673
  'runtime.network.speed=full',
1265
1674
  'vm.heapSize=256',
@@ -1269,11 +1678,12 @@ class AndroidController {
1269
1678
  ];
1270
1679
  fs.writeFileSync(path.join(avdDir, 'config.ini'), configLines.join('\n'));
1271
1680
 
1272
- const systemImageRoot = path.join(SDK_ROOT, ...String(packageName).split(';').filter(Boolean));
1681
+ const systemImageRoot = path.join(activeAndroidSdkRoot(), ...String(packageName).split(';').filter(Boolean));
1273
1682
  const userdataImage = path.join(systemImageRoot, 'userdata.img');
1274
1683
  if (fs.existsSync(userdataImage)) {
1275
1684
  fs.copyFileSync(userdataImage, path.join(avdDir, 'userdata.img'));
1276
1685
  }
1686
+ fs.rmSync(emulatorSnapshotStoragePath(this.avdName), { force: true });
1277
1687
  }
1278
1688
 
1279
1689
  #normalizeAvdConfig() {
@@ -1281,9 +1691,10 @@ class AndroidController {
1281
1691
  if (!fs.existsSync(configPath)) return;
1282
1692
 
1283
1693
  let content = fs.readFileSync(configPath, 'utf8');
1284
- content = updateIniValue(content, 'disk.dataPartition.size', DEFAULT_DATA_PARTITION);
1285
- content = updateIniValue(content, 'sdcard.size', DEFAULT_SDCARD_SIZE);
1286
- content = updateIniValue(content, 'hw.ramSize', DEFAULT_RAM_SIZE);
1694
+ content = updateIniValue(content, 'disk.dataPartition.size', DEFAULT_DATA_PARTITION_BYTES);
1695
+ content = updateIniValue(content, 'sdcard.size', DEFAULT_SDCARD_SIZE_BYTES);
1696
+ content = updateIniValue(content, 'hw.ramSize', DEFAULT_RAM_SIZE_MB);
1697
+ content = updateIniValue(content, 'hw.gpu.mode', emulatorGpuMode());
1287
1698
  fs.writeFileSync(configPath, content);
1288
1699
  }
1289
1700
 
@@ -1348,6 +1759,7 @@ class AndroidController {
1348
1759
  startRequestedAt: this.#readState().startRequestedAt || new Date().toISOString(),
1349
1760
  });
1350
1761
  console.log('[Android] Preparing emulator start');
1762
+ await this.#terminateStaleEmulatorProcesses([this.avdName, this.previousAvdName]).catch(() => {});
1351
1763
  await this.ensureAvd();
1352
1764
  this.#appendState({
1353
1765
  starting: true,
@@ -1377,18 +1789,21 @@ class AndroidController {
1377
1789
  const args = [
1378
1790
  `@${this.avdName}`,
1379
1791
  '-no-boot-anim',
1792
+ ...emulatorLaunchArgs(this.avdName),
1793
+ '-data',
1794
+ path.join(AVD_HOME, `${this.avdName}.avd`, 'userdata-qemu.img'),
1380
1795
  '-gpu',
1381
- process.platform === 'darwin' ? 'host' : 'swiftshader_indirect',
1796
+ emulatorGpuMode(),
1797
+ '-accel',
1798
+ 'auto',
1799
+ '-partition-size',
1800
+ String(DEFAULT_PARTITION_SIZE_MB),
1382
1801
  '-netdelay',
1383
1802
  'none',
1384
1803
  '-netspeed',
1385
1804
  'full',
1386
1805
  ];
1387
1806
 
1388
- if (options.headless !== false) {
1389
- args.push('-no-window', '-no-audio');
1390
- }
1391
-
1392
1807
  const child = spawn(emulatorBinary(), args, {
1393
1808
  detached: true,
1394
1809
  stdio: ['ignore', out, out],
@@ -1405,39 +1820,20 @@ class AndroidController {
1405
1820
  lastStartError: null,
1406
1821
  lastLogLine: 'Android emulator process started. Waiting for boot completion...',
1407
1822
  });
1408
-
1409
- const processExit = new Promise((resolve) => {
1410
- child.once('exit', (code, signal) => {
1411
- resolve({ code, signal });
1412
- });
1413
- child.once('error', (error) => {
1414
- resolve({ code: null, signal: null, error });
1415
- });
1416
- });
1417
-
1418
1823
  child.unref();
1419
-
1420
- const bootResult = await Promise.race([
1421
- this.waitForDevice({ timeoutMs: options.timeoutMs || 240000 }).then((serial) => ({
1422
- serial,
1423
- exited: false,
1424
- })),
1425
- processExit.then((result) => ({
1426
- exited: true,
1427
- ...result,
1428
- })),
1429
- ]);
1430
-
1431
- if (bootResult.exited) {
1824
+ let onlineSerial;
1825
+ try {
1826
+ onlineSerial = await this.waitForDevice({ timeoutMs: options.timeoutMs || 600000 });
1827
+ } catch (error) {
1432
1828
  const recentLogLines = tailFile(logPath, 12);
1433
1829
  const lastLine =
1434
- bootResult.error?.message ||
1435
1830
  recentLogLines[recentLogLines.length - 1] ||
1436
- `Emulator process exited before boot completed (code ${bootResult.code ?? 'unknown'}, signal ${bootResult.signal ?? 'none'}).`;
1831
+ error?.message ||
1832
+ String(error || 'Android emulator did not finish booting.');
1833
+ await this.stopEmulator().catch(() => {});
1834
+ this.markBootstrapFailure(lastLine);
1437
1835
  throw new Error(lastLine);
1438
1836
  }
1439
-
1440
- const onlineSerial = bootResult.serial;
1441
1837
  this.#appendState({
1442
1838
  serial: onlineSerial,
1443
1839
  emulatorPid: child.pid,
@@ -1507,8 +1903,8 @@ class AndroidController {
1507
1903
  NEOAGENT_ANDROID_BOOTSTRAP_WORKER: '1',
1508
1904
  NEOAGENT_ANDROID_BOOTSTRAP_USER_ID: this.userId || '',
1509
1905
  NEOAGENT_ANDROID_BOOTSTRAP_SCOPE_KEY: this.scopeKey,
1510
- NEOAGENT_ANDROID_BOOTSTRAP_HEADLESS: String(options.headless !== false),
1511
- NEOAGENT_ANDROID_BOOTSTRAP_TIMEOUT_MS: String(options.timeoutMs || 240000),
1906
+ NEOAGENT_ANDROID_BOOTSTRAP_HEADLESS: String(options.headless === true),
1907
+ NEOAGENT_ANDROID_BOOTSTRAP_TIMEOUT_MS: String(options.timeoutMs || 600000),
1512
1908
  };
1513
1909
  const child = spawn(process.execPath, [ANDROID_BOOTSTRAP_WORKER], {
1514
1910
  detached: true,
@@ -1576,7 +1972,7 @@ class AndroidController {
1576
1972
 
1577
1973
  async stopEmulator() {
1578
1974
  const state = this.#readState();
1579
- const serial = await this.getPrimarySerial();
1975
+ const serial = await this.getPrimarySerial({ ensureBootstrapped: false }).catch(() => null);
1580
1976
  if (serial) {
1581
1977
  this.#assertSerialAccess(serial, { claimIfUnowned: true });
1582
1978
  await this.#runAllowFailure(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} emu kill`, { timeout: 15000 });
@@ -1597,7 +1993,7 @@ class AndroidController {
1597
1993
 
1598
1994
  const deadline = Date.now() + 30000;
1599
1995
  while (Date.now() < deadline) {
1600
- const devices = await this.listDevices().catch(() => []);
1996
+ const devices = await this.listDevices({ ensureBootstrapped: false }).catch(() => []);
1601
1997
  const stillPresent = devices.some((device) => device.emulator && device.status === 'device');
1602
1998
  let pidAlive = false;
1603
1999
  if (state.emulatorPid) {
@@ -2115,7 +2511,7 @@ class AndroidController {
2115
2511
  startupPhase: state.startupPhase || null,
2116
2512
  startRequestedAt: state.startRequestedAt || null,
2117
2513
  lastStartError: state.lastStartError || null,
2118
- sdkRoot: SDK_ROOT,
2514
+ sdkRoot: activeAndroidSdkRoot(),
2119
2515
  avdHome: AVD_HOME,
2120
2516
  avdName: this.avdName,
2121
2517
  adbPath: adbBinary(),