neoagent 2.3.1-beta.87 → 2.3.1-beta.89

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.
@@ -20,7 +20,6 @@ const TMP_DIR = path.join(ARTIFACTS_DIR, 'tmp');
20
20
  const EMULATOR_HOME = path.join(ANDROID_ROOT, 'home');
21
21
  const EMULATOR_ADVANCED_FEATURES_FILE = path.join(EMULATOR_HOME, 'advancedFeatures.ini');
22
22
  const AVD_HOME = path.join(ANDROID_ROOT, 'avd');
23
- const EMULATOR_READY_SNAPSHOT = 'neoagent-ready';
24
23
  const STATE_DIR = path.join(ARTIFACTS_DIR, 'state');
25
24
  const STATE_FILE = path.join(ARTIFACTS_DIR, 'state.json');
26
25
  const OWNERSHIP_FILE = path.join(ARTIFACTS_DIR, 'device-ownership.json');
@@ -53,19 +52,17 @@ for (const dir of [ANDROID_ROOT, SDK_ROOT, EMULATOR_HOME, ARTIFACTS_DIR, SCREENS
53
52
  }
54
53
 
55
54
  function ensureEmulatorAdvancedFeaturesFile() {
56
- const content = [
57
- 'Vulkan = off',
58
- 'GLDirectMem = off',
59
- '',
60
- ].join('\n');
61
55
  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);
56
+ fs.mkdirSync(path.dirname(EMULATOR_ADVANCED_FEATURES_FILE), { recursive: true });
57
+ fs.writeFileSync(
58
+ EMULATOR_ADVANCED_FEATURES_FILE,
59
+ [
60
+ 'QuickbootFileBacked=off',
61
+ 'QuickbootSupport=off',
62
+ '',
63
+ ].join('\n'),
64
+ 'utf8',
65
+ );
69
66
  } catch {}
70
67
  }
71
68
 
@@ -103,7 +100,7 @@ function readOwnershipUnlocked() {
103
100
  if (!parsed || typeof parsed !== 'object') {
104
101
  return {};
105
102
  }
106
- return parsed;
103
+ return parsed;
107
104
  } catch {
108
105
  return {};
109
106
  }
@@ -185,6 +182,19 @@ function sleep(ms) {
185
182
  return new Promise((resolve) => setTimeout(resolve, ms));
186
183
  }
187
184
 
185
+ function isProcessAlive(pid) {
186
+ const numericPid = Number(pid);
187
+ if (!Number.isInteger(numericPid) || numericPid <= 0) {
188
+ return false;
189
+ }
190
+ try {
191
+ process.kill(numericPid, 0);
192
+ return true;
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
188
198
  function tailFile(filePath, maxLines = 40) {
189
199
  try {
190
200
  const lines = fs.readFileSync(filePath, 'utf8')
@@ -197,6 +207,15 @@ function tailFile(filePath, maxLines = 40) {
197
207
  }
198
208
  }
199
209
 
210
+ function isLikelyPng(buffer) {
211
+ return Buffer.isBuffer(buffer)
212
+ && buffer.length > 24
213
+ && buffer[0] === 0x89
214
+ && buffer[1] === 0x50
215
+ && buffer[2] === 0x4e
216
+ && buffer[3] === 0x47;
217
+ }
218
+
200
219
  function commandExists(command) {
201
220
  const probe = spawnSync('bash', ['-lc', `command -v "${command}"`], { encoding: 'utf8' });
202
221
  return probe.status === 0;
@@ -265,25 +284,40 @@ function emulatorHostArch() {
265
284
  }
266
285
 
267
286
  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
287
  if (process.platform === 'darwin' && process.arch === 'arm64') {
277
- return ['-no-snapshot'];
288
+ return 'swiftshader_indirect';
278
289
  }
290
+ if (process.platform === 'linux' && process.arch === 'arm64') {
291
+ return 'swiftshader_indirect';
292
+ }
293
+ return 'auto';
294
+ }
295
+
296
+ function emulatorLaunchArgs() {
279
297
  return [
280
- '-snapshot',
281
- EMULATOR_READY_SNAPSHOT,
282
- '-snapstorage',
283
- emulatorSnapshotStoragePath(avdName),
298
+ '-no-snapshot',
299
+ '-no-snapshot-save',
300
+ '-no-window',
301
+ '-no-audio',
302
+ '-no-metrics',
303
+ '-skip-adb-auth',
304
+ '-crash-report-mode',
305
+ 'disabled',
284
306
  ];
285
307
  }
286
308
 
309
+ function isRecoverableEmulatorStartError(message) {
310
+ const value = String(message || '').toLowerCase();
311
+ return (
312
+ value.includes('failed to restore previous context') ||
313
+ value.includes('emulator exited before boot completed') ||
314
+ value.includes('android framework did not become ready') ||
315
+ value.includes('package manager service did not become ready') ||
316
+ value.includes('failed to process .ini file') ||
317
+ value.includes('error while loading state for instance')
318
+ );
319
+ }
320
+
287
321
  function parseCsvEnv(value) {
288
322
  return String(value || '')
289
323
  .split(',')
@@ -345,6 +379,15 @@ function parseSystemImagePlatform(platformId) {
345
379
  };
346
380
  }
347
381
 
382
+ const released = String(platformId || '').match(/^android-(\d+(?:\.\d+)?)$/);
383
+ if (released) {
384
+ return {
385
+ platformId,
386
+ apiLevel: Math.floor(Number(released[1]) || 0),
387
+ stable: false,
388
+ };
389
+ }
390
+
348
391
  const preview = String(platformId || '').match(/^android-([A-Za-z][A-Za-z0-9_-]*)$/);
349
392
  if (preview) {
350
393
  return {
@@ -415,10 +458,10 @@ function hostAndroidSdkRoot() {
415
458
  return null;
416
459
  }
417
460
 
418
- const SHARED_ANDROID_SDK_ROOT = hostAndroidSdkRoot();
461
+ const SHARED_ANDROID_SDK_ROOT = null;
419
462
 
420
463
  function activeAndroidSdkRoot() {
421
- return SHARED_ANDROID_SDK_ROOT || SDK_ROOT;
464
+ return SDK_ROOT;
422
465
  }
423
466
 
424
467
  function sharedAndroidSdkReady() {
@@ -436,20 +479,6 @@ function adbBinary() {
436
479
  if (process.env.ANDROID_ADB_PATH) {
437
480
  return process.env.ANDROID_ADB_PATH;
438
481
  }
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
482
  return path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
454
483
  }
455
484
 
@@ -797,17 +826,8 @@ async function installPlatformToolsArchive(metadata) {
797
826
  }
798
827
 
799
828
  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;
829
+ const localAdb = path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
830
+ return !isExecutable(localAdb);
811
831
  }
812
832
 
813
833
  async function installSystemImageArchive(metadata, packageName) {
@@ -817,6 +837,8 @@ async function installSystemImageArchive(metadata, packageName) {
817
837
  const extractDir = fs.mkdtempSync(path.join(TMP_DIR, 'system-image-'));
818
838
 
819
839
  try {
840
+ fs.rmSync(targetRoot, { recursive: true, force: true });
841
+ fs.mkdirSync(path.dirname(targetRoot), { recursive: true });
820
842
  await downloadFile(url, zipPath);
821
843
  extractZip(zipPath, extractDir);
822
844
 
@@ -827,9 +849,15 @@ async function installSystemImageArchive(metadata, packageName) {
827
849
  throw new Error(`Downloaded Android system image archive for ${packageName} did not contain the expected files`);
828
850
  }
829
851
 
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 });
852
+ fs.rmSync(zipPath, { force: true });
853
+ try {
854
+ fs.renameSync(extractedRoot, targetRoot);
855
+ } catch (renameErr) {
856
+ fs.cpSync(extractedRoot, targetRoot, { recursive: true, force: true });
857
+ if (renameErr) {
858
+ console.warn(`[Android] Falling back to copy for ${packageName}: ${renameErr.message}`);
859
+ }
860
+ }
833
861
  } finally {
834
862
  fs.rmSync(zipPath, { force: true });
835
863
  fs.rmSync(extractDir, { recursive: true, force: true });
@@ -838,8 +866,8 @@ async function installSystemImageArchive(metadata, packageName) {
838
866
 
839
867
  function systemImageTagScore(tag) {
840
868
  const value = String(tag || '').toLowerCase();
841
- if (value.startsWith('google_apis_playstore')) return 60;
842
- if (value.startsWith('google_apis')) return 50;
869
+ if (value.startsWith('google_apis_playstore')) return 80;
870
+ if (value.startsWith('google_apis')) return 60;
843
871
  if (value === 'default') return 40;
844
872
  if (value === 'google_atd') return 20;
845
873
  if (value === 'aosp_atd') return 10;
@@ -917,6 +945,25 @@ function parseInstalledSystemImages() {
917
945
  return parseSystemImageCandidates(matches);
918
946
  }
919
947
 
948
+ function chooseStableRuntimeSystemImage(candidates, currentPackage) {
949
+ const normalizedCurrent = String(currentPackage || '').trim();
950
+ const pool = Array.isArray(candidates)
951
+ ? candidates.filter((item) => item && item.packageName && isValidInstalledSystemImage(item.packageName))
952
+ : [];
953
+ if (pool.length === 0) return null;
954
+ const ranked = rankSystemImagePool(pool);
955
+ const recommended = ranked.find((item) =>
956
+ item.arch === 'arm64-v8a'
957
+ && item.stable
958
+ && item.apiLevel >= 33
959
+ ) || ranked[0];
960
+ if (!recommended) return null;
961
+ if (normalizedCurrent && recommended.packageName === normalizedCurrent) {
962
+ return null;
963
+ }
964
+ return recommended;
965
+ }
966
+
920
967
  function rankSystemImagePool(pool) {
921
968
  const preferredMatches = pool.filter((candidate) => candidate.tagScore > 0);
922
969
  const rankedPool = preferredMatches.length > 0 ? preferredMatches : pool;
@@ -1034,6 +1081,18 @@ function systemImagePackageToRelativeDir(packageName) {
1034
1081
  return `${parts.join('/')}/`;
1035
1082
  }
1036
1083
 
1084
+ function isValidInstalledSystemImage(packageName) {
1085
+ const relativeDir = systemImagePackageToRelativeDir(packageName);
1086
+ if (!relativeDir) return false;
1087
+ const root = path.join(activeAndroidSdkRoot(), relativeDir);
1088
+ const required = [
1089
+ path.join(root, 'package.xml'),
1090
+ path.join(root, 'system.img'),
1091
+ path.join(root, 'userdata.img'),
1092
+ ];
1093
+ return required.every((filePath) => fs.existsSync(filePath));
1094
+ }
1095
+
1037
1096
  function systemImagePackageToAbi(packageName) {
1038
1097
  const parts = String(packageName || '').split(';').filter(Boolean);
1039
1098
  if (parts.length !== 4 || parts[0] !== 'system-images') {
@@ -1083,6 +1142,7 @@ class AndroidController {
1083
1142
  this.userId = options?.userId != null ? String(options.userId) : null;
1084
1143
  this.artifactStore = options?.artifactStore || null;
1085
1144
  this.runtimeBackend = options?.runtimeBackend || 'host';
1145
+ this.manageProcessCleanup = options?.manageProcessCleanup !== false;
1086
1146
  this.scopeKey = sanitizeScopeKey(this.userId ? `user-${this.userId}` : 'default');
1087
1147
  this.ownerKey = normalizeOwnerKey(this.userId);
1088
1148
  this.stateFile = resolveStateFile(this.scopeKey);
@@ -1122,7 +1182,9 @@ class AndroidController {
1122
1182
  }
1123
1183
  this.bootstrapPromise = null;
1124
1184
  this.startPromise = null;
1125
- this.#registerProcessCleanup();
1185
+ if (this.manageProcessCleanup) {
1186
+ this.#registerProcessCleanup();
1187
+ }
1126
1188
  }
1127
1189
 
1128
1190
  static cleanupRegistered = false;
@@ -1336,20 +1398,37 @@ class AndroidController {
1336
1398
  chooseConfiguredSystemImage(available) ||
1337
1399
  chooseLatestSystemImage(available, [desiredArch]) ||
1338
1400
  chooseLatestSystemImage(available);
1401
+ const selectedImage = rankSystemImagePool([preferredInstalled, preferredAvailable].filter(Boolean))[0] || preferredInstalled || preferredAvailable;
1339
1402
  const stateApiLevel = Number(state.apiLevel || 0) || 0;
1340
-
1341
- if (!shouldForceSdkRefresh() && sharedAndroidSdkReady() && preferredInstalled && preferredAvailable) {
1403
+ const legacyLinuxArm64Image =
1404
+ process.platform === 'linux'
1405
+ && process.arch === 'arm64'
1406
+ && /system-images;android-30;default;arm64-v8a/i.test(String(state.systemImage || ''));
1407
+ const migrationTargetImage =
1408
+ process.platform === 'linux' && process.arch === 'arm64'
1409
+ ? (
1410
+ rankSystemImagePool(
1411
+ [preferredAvailable, preferredInstalled]
1412
+ .filter(Boolean)
1413
+ .filter((image) => image.arch === 'arm64-v8a' && image.stable && image.apiLevel >= 33)
1414
+ )[0] || null
1415
+ )
1416
+ : null;
1417
+ const effectiveSelectedImage = migrationTargetImage || selectedImage;
1418
+ const selectedImageInvalid = effectiveSelectedImage?.packageName && !isValidInstalledSystemImage(effectiveSelectedImage.packageName);
1419
+
1420
+ if (!shouldForceSdkRefresh() && !legacyLinuxArm64Image && !selectedImageInvalid && sharedAndroidSdkReady() && effectiveSelectedImage) {
1342
1421
  const stateAligned =
1343
- preferredInstalled.packageName === state.systemImage &&
1344
- preferredInstalled.apiLevel === stateApiLevel &&
1345
- preferredInstalled.arch === state.systemImageArch &&
1422
+ effectiveSelectedImage.packageName === state.systemImage &&
1423
+ effectiveSelectedImage.apiLevel === stateApiLevel &&
1424
+ effectiveSelectedImage.arch === state.systemImageArch &&
1346
1425
  state.avdName === this.avdName;
1347
1426
 
1348
- if (stateAligned && preferredInstalled.packageName === preferredAvailable.packageName) {
1427
+ if (stateAligned) {
1349
1428
  return;
1350
1429
  }
1351
1430
 
1352
- if (preferredInstalled.packageName !== preferredAvailable.packageName) {
1431
+ if (effectiveSelectedImage === preferredInstalled && effectiveSelectedImage.packageName) {
1353
1432
  const changeSummary = describeAutoFixChanges(
1354
1433
  {
1355
1434
  avdName: state.avdName || null,
@@ -1359,38 +1438,26 @@ class AndroidController {
1359
1438
  },
1360
1439
  {
1361
1440
  avdName: this.avdName,
1362
- systemImage: preferredAvailable.packageName,
1363
- apiLevel: preferredAvailable.apiLevel,
1364
- systemImageArch: preferredAvailable.arch,
1441
+ systemImage: effectiveSelectedImage.packageName,
1442
+ apiLevel: effectiveSelectedImage.apiLevel,
1443
+ systemImageArch: effectiveSelectedImage.arch,
1365
1444
  },
1366
1445
  ['avdName', 'systemImage', 'apiLevel', 'systemImageArch']
1367
1446
  );
1368
1447
  if (changeSummary) {
1369
1448
  console.log(`[Android] Auto-fixed host SDK state (${changeSummary})`);
1370
1449
  }
1371
- await installSystemImageArchive(systemImageMetadata, preferredAvailable.packageName);
1372
1450
  this.#appendState({
1373
1451
  bootstrapped: true,
1374
1452
  avdName: this.avdName,
1375
1453
  serial: null,
1376
1454
  emulatorPid: null,
1377
- systemImage: preferredAvailable.packageName,
1378
- apiLevel: preferredAvailable.apiLevel,
1379
- systemImageArch: preferredAvailable.arch,
1455
+ systemImage: effectiveSelectedImage.packageName,
1456
+ apiLevel: effectiveSelectedImage.apiLevel,
1457
+ systemImageArch: effectiveSelectedImage.arch,
1380
1458
  });
1381
1459
  return;
1382
1460
  }
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
1461
  }
1395
1462
 
1396
1463
  const binariesReady =
@@ -1413,12 +1480,12 @@ class AndroidController {
1413
1480
  const runtimeNeedsRefresh =
1414
1481
  state.systemImageArch !== desiredArch ||
1415
1482
  !installedEmulatorMatchesHost();
1416
- if (!shouldForceSdkRefresh()) {
1417
- if (!runtimeNeedsRefresh && preferredInstalled) {
1483
+ if (!shouldForceSdkRefresh() && !legacyLinuxArm64Image && !selectedImageInvalid) {
1484
+ if (!runtimeNeedsRefresh && effectiveSelectedImage && effectiveSelectedImage === preferredInstalled) {
1418
1485
  const stateNeedsRefresh =
1419
- preferredInstalled.packageName !== state.systemImage ||
1420
- preferredInstalled.apiLevel !== stateApiLevel ||
1421
- preferredInstalled.arch !== state.systemImageArch ||
1486
+ effectiveSelectedImage.packageName !== state.systemImage ||
1487
+ effectiveSelectedImage.apiLevel !== stateApiLevel ||
1488
+ effectiveSelectedImage.arch !== state.systemImageArch ||
1422
1489
  state.avdName !== this.avdName;
1423
1490
  if (stateNeedsRefresh) {
1424
1491
  const changeSummary = describeAutoFixChanges(
@@ -1430,9 +1497,9 @@ class AndroidController {
1430
1497
  },
1431
1498
  {
1432
1499
  avdName: this.avdName,
1433
- systemImage: preferredInstalled.packageName,
1434
- apiLevel: preferredInstalled.apiLevel,
1435
- systemImageArch: preferredInstalled.arch,
1500
+ systemImage: effectiveSelectedImage.packageName,
1501
+ apiLevel: effectiveSelectedImage.apiLevel,
1502
+ systemImageArch: effectiveSelectedImage.arch,
1436
1503
  },
1437
1504
  ['avdName', 'systemImage', 'apiLevel', 'systemImageArch']
1438
1505
  );
@@ -1444,14 +1511,20 @@ class AndroidController {
1444
1511
  avdName: this.avdName,
1445
1512
  serial: null,
1446
1513
  emulatorPid: null,
1447
- systemImage: preferredInstalled.packageName,
1448
- apiLevel: preferredInstalled.apiLevel,
1449
- systemImageArch: preferredInstalled.arch,
1514
+ systemImage: effectiveSelectedImage.packageName,
1515
+ apiLevel: effectiveSelectedImage.apiLevel,
1516
+ systemImageArch: effectiveSelectedImage.arch,
1450
1517
  });
1451
1518
  }
1452
1519
  return;
1453
1520
  }
1454
- if (!runtimeNeedsRefresh && state.bootstrapped === true && state.systemImage) {
1521
+ if (
1522
+ !runtimeNeedsRefresh &&
1523
+ state.bootstrapped === true &&
1524
+ state.systemImage &&
1525
+ effectiveSelectedImage &&
1526
+ effectiveSelectedImage.packageName === state.systemImage
1527
+ ) {
1455
1528
  return;
1456
1529
  }
1457
1530
  }
@@ -1462,17 +1535,19 @@ class AndroidController {
1462
1535
  await installPlatformToolsArchive(metadata);
1463
1536
  }
1464
1537
  await installEmulatorArchive(metadata);
1465
- await installSystemImageArchive(systemImageMetadata, preferredAvailable?.packageName || preferredInstalled?.packageName);
1466
- const selectedImage = preferredAvailable || preferredInstalled;
1467
- if (!selectedImage) {
1538
+ if (!effectiveSelectedImage) {
1468
1539
  throw new Error(formatSystemImageError(available));
1469
1540
  }
1541
+ if (effectiveSelectedImage?.packageName) {
1542
+ await installSystemImageArchive(systemImageMetadata, effectiveSelectedImage.packageName);
1543
+ }
1470
1544
  this.#appendState({
1471
1545
  bootstrapped: true,
1472
1546
  avdName: this.avdName,
1473
- systemImage: selectedImage.packageName,
1474
- apiLevel: selectedImage.apiLevel,
1475
- systemImageArch: selectedImage.arch,
1547
+ systemImage: effectiveSelectedImage.packageName,
1548
+ apiLevel: effectiveSelectedImage.apiLevel,
1549
+ systemImageArch: effectiveSelectedImage.arch,
1550
+ avdSystemImage: null,
1476
1551
  });
1477
1552
  }
1478
1553
 
@@ -1531,6 +1606,7 @@ class AndroidController {
1531
1606
  startupPhase: 'Start failed',
1532
1607
  lastStartError: detailedMessage,
1533
1608
  lastLogLine: detailedMessage,
1609
+ bootstrapWorkerPid: null,
1534
1610
  });
1535
1611
  return detailedMessage;
1536
1612
  }
@@ -1539,8 +1615,22 @@ class AndroidController {
1539
1615
  await this.ensureBootstrapped();
1540
1616
 
1541
1617
  const state = this.#readState();
1542
- const pkg = state.systemImage;
1618
+ let pkg = state.systemImage;
1543
1619
  if (!pkg) throw new Error('Android system image not installed');
1620
+ if (process.platform === 'linux' && process.arch === 'arm64') {
1621
+ const installedCandidates = parseInstalledSystemImages();
1622
+ const migratedImage = chooseStableRuntimeSystemImage(installedCandidates, pkg);
1623
+ if (migratedImage) {
1624
+ pkg = migratedImage.packageName;
1625
+ this.#appendState({
1626
+ systemImage: pkg,
1627
+ apiLevel: migratedImage.apiLevel,
1628
+ systemImageArch: migratedImage.arch,
1629
+ avdSystemImage: null,
1630
+ lastLogLine: `Migrated Android runtime image to ${pkg} for stability.`,
1631
+ });
1632
+ }
1633
+ }
1544
1634
  const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1545
1635
  const configPath = path.join(avdDir, 'config.ini');
1546
1636
  const avdExists = fs.existsSync(configPath);
@@ -1566,6 +1656,8 @@ class AndroidController {
1566
1656
  const expectedRamSize = String(DEFAULT_RAM_SIZE_MB);
1567
1657
  const currentGpuMode = readIniValue(config, 'hw.gpu.mode');
1568
1658
  const expectedGpuMode = emulatorGpuMode();
1659
+ const currentPlayStoreEnabled = readIniValue(config, 'PlayStore.enabled');
1660
+ const expectedPlayStoreEnabled = String(String(pkg || '').includes('playstore'));
1569
1661
  if (expectedImageDir && currentImageDir && currentImageDir !== expectedImageDir) {
1570
1662
  avdNeedsRecreate = true;
1571
1663
  avdRecreateReasons.push(`image.sysdir.1: ${currentImageDir} -> ${expectedImageDir}`);
@@ -1594,6 +1686,10 @@ class AndroidController {
1594
1686
  avdNeedsRecreate = true;
1595
1687
  avdRecreateReasons.push(`hw.gpu.mode: ${currentGpuMode} -> ${expectedGpuMode}`);
1596
1688
  }
1689
+ if (currentPlayStoreEnabled && currentPlayStoreEnabled !== expectedPlayStoreEnabled) {
1690
+ avdNeedsRecreate = true;
1691
+ avdRecreateReasons.push(`PlayStore.enabled: ${currentPlayStoreEnabled} -> ${expectedPlayStoreEnabled}`);
1692
+ }
1597
1693
  } catch {}
1598
1694
  }
1599
1695
 
@@ -1605,7 +1701,6 @@ class AndroidController {
1605
1701
  fs.rmSync(avdDir, { recursive: true, force: true });
1606
1702
  fs.rmSync(path.join(AVD_HOME, `${this.avdName}.ini`), { force: true });
1607
1703
  fs.rmSync(path.join(avdDir, 'userdata-qemu.img'), { force: true });
1608
- fs.rmSync(emulatorSnapshotStoragePath(this.avdName), { force: true });
1609
1704
  } else if (avdExists) {
1610
1705
  ensureSparseFile(path.join(avdDir, 'userdata-qemu.img'), DEFAULT_DATA_PARTITION_BYTES);
1611
1706
  return;
@@ -1625,6 +1720,7 @@ class AndroidController {
1625
1720
  const tagId = parts[2];
1626
1721
  const tagDisplay = tagId === 'google_apis' ? 'Google APIs' : tagId.replace(/_/g, ' ');
1627
1722
  const abi = parts[3];
1723
+ const playStoreEnabled = tagId.includes('playstore');
1628
1724
  const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1629
1725
  const imageSysDir = systemImagePackageToRelativeDir(packageName);
1630
1726
  if (!imageSysDir) {
@@ -1648,7 +1744,7 @@ class AndroidController {
1648
1744
  'avd.ini.encoding=UTF-8',
1649
1745
  `AvdId=${this.avdName}`,
1650
1746
  `avd.ini.displayname=${this.avdName}`,
1651
- 'PlayStore.enabled=false',
1747
+ `PlayStore.enabled=${playStoreEnabled}`,
1652
1748
  `image.sysdir.1=${imageSysDir}`,
1653
1749
  `abi.type=${abi}`,
1654
1750
  `hw.cpu.arch=${systemImagePackageToCpuArch(packageName) || abi}`,
@@ -1671,6 +1767,8 @@ class AndroidController {
1671
1767
  `sdcard.size=${DEFAULT_SDCARD_SIZE_BYTES}`,
1672
1768
  'runtime.network.latency=none',
1673
1769
  'runtime.network.speed=full',
1770
+ 'fastboot.forceColdBoot=yes',
1771
+ 'fastboot.forceFastBoot=no',
1674
1772
  'vm.heapSize=256',
1675
1773
  `tag.display=${tagDisplay}`,
1676
1774
  `tag.id=${tagId}`,
@@ -1683,7 +1781,6 @@ class AndroidController {
1683
1781
  if (fs.existsSync(userdataImage)) {
1684
1782
  fs.copyFileSync(userdataImage, path.join(avdDir, 'userdata.img'));
1685
1783
  }
1686
- fs.rmSync(emulatorSnapshotStoragePath(this.avdName), { force: true });
1687
1784
  }
1688
1785
 
1689
1786
  #normalizeAvdConfig() {
@@ -1695,9 +1792,60 @@ class AndroidController {
1695
1792
  content = updateIniValue(content, 'sdcard.size', DEFAULT_SDCARD_SIZE_BYTES);
1696
1793
  content = updateIniValue(content, 'hw.ramSize', DEFAULT_RAM_SIZE_MB);
1697
1794
  content = updateIniValue(content, 'hw.gpu.mode', emulatorGpuMode());
1795
+ content = updateIniValue(content, 'fastboot.forceColdBoot', 'yes');
1796
+ content = updateIniValue(content, 'fastboot.forceFastBoot', 'no');
1797
+ content = updateIniValue(content, 'PlayStore.enabled', String(this.#readState()?.systemImage || '').includes('playstore'));
1698
1798
  fs.writeFileSync(configPath, content);
1699
1799
  }
1700
1800
 
1801
+ #cleanupAvdTransientState() {
1802
+ const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1803
+ const transientTargets = [
1804
+ 'cache.img',
1805
+ 'cache.img.qcow2',
1806
+ 'hardware-qemu.ini.lock',
1807
+ 'multiinstance.lock',
1808
+ 'snapshot.lock',
1809
+ 'quickbootChoice.ini',
1810
+ 'launchParams.txt',
1811
+ 'emu-launch-params.txt',
1812
+ 'bootcompleted.ini',
1813
+ 'userdata-qemu.img.lock',
1814
+ 'encryptionkey.img',
1815
+ ];
1816
+
1817
+ for (const target of transientTargets) {
1818
+ fs.rmSync(path.join(avdDir, target), { force: true, recursive: true });
1819
+ }
1820
+
1821
+ for (const entry of ['snapshots', '.lock']) {
1822
+ fs.rmSync(path.join(avdDir, entry), { force: true, recursive: true });
1823
+ }
1824
+
1825
+ try {
1826
+ for (const entry of fs.readdirSync(avdDir)) {
1827
+ if (/\.lock$/i.test(entry) || /\.tmp$/i.test(entry)) {
1828
+ fs.rmSync(path.join(avdDir, entry), { force: true, recursive: true });
1829
+ }
1830
+ }
1831
+ } catch {}
1832
+ }
1833
+
1834
+ async #forceRecreateAvdForRecovery() {
1835
+ const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1836
+ await this.stopEmulator().catch(() => {});
1837
+ fs.rmSync(avdDir, { recursive: true, force: true });
1838
+ fs.rmSync(path.join(AVD_HOME, `${this.avdName}.ini`), { force: true });
1839
+ this.#appendState({
1840
+ avdSystemImage: null,
1841
+ serial: null,
1842
+ emulatorPid: null,
1843
+ lastLogLine: 'Recreating AVD after failed framework boot.',
1844
+ });
1845
+ await this.ensureAvd();
1846
+ this.#cleanupAvdTransientState();
1847
+ }
1848
+
1701
1849
  async listDevices(options = {}) {
1702
1850
  if (options.ensureBootstrapped !== false) {
1703
1851
  await this.ensureBootstrapped();
@@ -1789,7 +1937,7 @@ class AndroidController {
1789
1937
  const args = [
1790
1938
  `@${this.avdName}`,
1791
1939
  '-no-boot-anim',
1792
- ...emulatorLaunchArgs(this.avdName),
1940
+ ...emulatorLaunchArgs(),
1793
1941
  '-data',
1794
1942
  path.join(AVD_HOME, `${this.avdName}.avd`, 'userdata-qemu.img'),
1795
1943
  '-gpu',
@@ -1831,6 +1979,16 @@ class AndroidController {
1831
1979
  error?.message ||
1832
1980
  String(error || 'Android emulator did not finish booting.');
1833
1981
  await this.stopEmulator().catch(() => {});
1982
+ if (!options._recoveredOnce && isRecoverableEmulatorStartError(lastLine)) {
1983
+ console.warn(`[Android] Recoverable emulator start failure detected. Cleaning transient AVD state and retrying once: ${lastLine}`);
1984
+ this.#cleanupAvdTransientState();
1985
+ return this.#startEmulatorBlocking({ ...options, _recoveredOnce: true });
1986
+ }
1987
+ if (!options._recreatedAvdOnce && isRecoverableEmulatorStartError(lastLine)) {
1988
+ console.warn(`[Android] Emulator recovery escalation: recreating AVD and retrying once: ${lastLine}`);
1989
+ await this.#forceRecreateAvdForRecovery();
1990
+ return this.#startEmulatorBlocking({ ...options, _recoveredOnce: true, _recreatedAvdOnce: true });
1991
+ }
1834
1992
  this.markBootstrapFailure(lastLine);
1835
1993
  throw new Error(lastLine);
1836
1994
  }
@@ -1889,6 +2047,24 @@ class AndroidController {
1889
2047
  };
1890
2048
  }
1891
2049
 
2050
+ const currentState = this.#readState();
2051
+ const existingWorkerPid = Number(currentState.bootstrapWorkerPid || 0);
2052
+ if (isProcessAlive(existingWorkerPid)) {
2053
+ return {
2054
+ success: true,
2055
+ pending: true,
2056
+ bootstrapped: currentState.bootstrapped === true,
2057
+ starting: true,
2058
+ startupPhase: currentState.startupPhase || 'Preparing Android runtime',
2059
+ startRequestedAt: currentState.startRequestedAt || null,
2060
+ logPath: currentState.logPath || null,
2061
+ };
2062
+ }
2063
+
2064
+ if (existingWorkerPid) {
2065
+ this.#appendState({ bootstrapWorkerPid: null });
2066
+ }
2067
+
1892
2068
  if (!this.startPromise) {
1893
2069
  const requestedAt = new Date().toISOString();
1894
2070
  this.#appendState({
@@ -1912,9 +2088,13 @@ class AndroidController {
1912
2088
  env: workerEnv,
1913
2089
  });
1914
2090
  child.unref();
2091
+ this.#appendState({
2092
+ bootstrapWorkerPid: child.pid,
2093
+ });
1915
2094
  this.startPromise = new Promise((resolve) => {
1916
2095
  child.once('exit', (code, signal) => {
1917
2096
  this.startPromise = null;
2097
+ this.#appendState({ bootstrapWorkerPid: null });
1918
2098
  if (code !== 0) {
1919
2099
  console.error('[Android] Emulator bootstrap worker exited', { code, signal });
1920
2100
  }
@@ -1922,6 +2102,7 @@ class AndroidController {
1922
2102
  });
1923
2103
  child.once('error', (error) => {
1924
2104
  this.startPromise = null;
2105
+ this.#appendState({ bootstrapWorkerPid: null });
1925
2106
  console.error('[Android] Emulator bootstrap worker failed to spawn', error);
1926
2107
  resolve({ code: null, signal: null, error });
1927
2108
  });
@@ -1943,15 +2124,110 @@ class AndroidController {
1943
2124
  async waitForDevice(options = {}) {
1944
2125
  const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 180000);
1945
2126
  const deadline = Date.now() + timeoutMs;
2127
+ let reconnectCounter = 0;
2128
+ let missingPidSince = null;
2129
+ let firstOnlineAt = null;
2130
+ let offlineSince = null;
1946
2131
 
1947
2132
  while (Date.now() < deadline) {
1948
2133
  const serial = await this.getPrimarySerial();
1949
2134
  if (serial) {
1950
2135
  this.#assertSerialAccess(serial, { claimIfUnowned: true });
1951
- const boot = await this.#runAllowFailure(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell getprop sys.boot_completed`, { timeout: 10000 });
1952
- if ((boot.stdout || '').trim() === '1') {
2136
+ if (!firstOnlineAt) {
2137
+ firstOnlineAt = Date.now();
2138
+ }
2139
+ const bootCompleted = await this.#runAllowFailure(
2140
+ `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell getprop sys.boot_completed`,
2141
+ { timeout: 10000 },
2142
+ );
2143
+ const devBootComplete = await this.#runAllowFailure(
2144
+ `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell getprop dev.bootcomplete`,
2145
+ { timeout: 10000 },
2146
+ );
2147
+ const bootAnim = await this.#runAllowFailure(
2148
+ `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell getprop init.svc.bootanim`,
2149
+ { timeout: 10000 },
2150
+ );
2151
+ const shellProbe = await this.#runAllowFailure(
2152
+ `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell echo ready`,
2153
+ { timeout: 10000 },
2154
+ );
2155
+ const packageServiceProbe = await this.#runAllowFailure(
2156
+ `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell service check package`,
2157
+ { timeout: 10000 },
2158
+ );
2159
+ const pmProbe = await this.#runAllowFailure(
2160
+ `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell pm path android`,
2161
+ { timeout: 10000 },
2162
+ );
2163
+
2164
+ const bootValue = String(bootCompleted.stdout || '').trim();
2165
+ const devBootValue = String(devBootComplete.stdout || '').trim();
2166
+ const bootAnimValue = String(bootAnim.stdout || '').trim().toLowerCase();
2167
+ const shellReady = String(shellProbe.stdout || '').trim() === 'ready';
2168
+ const packageServiceReady = /found/i.test(String(packageServiceProbe.stdout || ''));
2169
+ const packageManagerReady = /^package:/m.test(String(pmProbe.stdout || '').trim());
2170
+ if (
2171
+ packageServiceReady
2172
+ && packageManagerReady
2173
+ && (
2174
+ bootValue === '1'
2175
+ || devBootValue === '1'
2176
+ || (shellReady && (bootAnimValue === 'stopped' || bootAnimValue === ''))
2177
+ )
2178
+ ) {
1953
2179
  return serial;
1954
2180
  }
2181
+ if (
2182
+ firstOnlineAt
2183
+ && Date.now() - firstOnlineAt > 120000
2184
+ && (
2185
+ !packageServiceReady
2186
+ || !packageManagerReady
2187
+ )
2188
+ ) {
2189
+ throw new Error('Android framework did not become ready (package manager service did not become ready).');
2190
+ }
2191
+ missingPidSince = null;
2192
+ offlineSince = null;
2193
+ } else {
2194
+ firstOnlineAt = null;
2195
+ const state = this.#readState();
2196
+ const emulatorPid = Number(state.emulatorPid || 0);
2197
+ if (emulatorPid > 0 && !isProcessAlive(emulatorPid)) {
2198
+ throw new Error('Android emulator exited before boot completed.');
2199
+ }
2200
+ if (!emulatorPid) {
2201
+ if (!missingPidSince) {
2202
+ missingPidSince = Date.now();
2203
+ }
2204
+ if (Date.now() - missingPidSince > 20000) {
2205
+ throw new Error('Android emulator is not running.');
2206
+ }
2207
+ } else {
2208
+ missingPidSince = null;
2209
+ const devices = await this.listDevices({ ensureBootstrapped: false }).catch(() => []);
2210
+ const hasOfflineEmulator = devices.some((device) => device.emulator && device.status === 'offline');
2211
+ if (hasOfflineEmulator) {
2212
+ if (!offlineSince) {
2213
+ offlineSince = Date.now();
2214
+ }
2215
+ if (Date.now() - offlineSince > 30000) {
2216
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} kill-server`, { timeout: 10000 });
2217
+ await sleep(600);
2218
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} start-server`, { timeout: 15000 });
2219
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} reconnect`, { timeout: 15000 });
2220
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} wait-for-device`, { timeout: 30000 });
2221
+ offlineSince = Date.now();
2222
+ }
2223
+ } else {
2224
+ offlineSince = null;
2225
+ }
2226
+ }
2227
+ }
2228
+ reconnectCounter += 1;
2229
+ if (reconnectCounter % 5 === 0) {
2230
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} reconnect offline`, { timeout: 10000 });
1955
2231
  }
1956
2232
  await sleep(3000);
1957
2233
  }
@@ -2016,8 +2292,17 @@ class AndroidController {
2016
2292
  return this.#run(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} ${command}`, options);
2017
2293
  }
2018
2294
 
2295
+ async #prepareScreenForCapture(serial) {
2296
+ await this.#adb(serial, 'shell input keyevent 224', { timeout: 10000 }).catch(() => {});
2297
+ await this.#adb(serial, 'shell wm dismiss-keyguard', { timeout: 10000 }).catch(() => {});
2298
+ await this.#adb(serial, 'shell input keyevent 82', { timeout: 10000 }).catch(() => {});
2299
+ await this.#adb(serial, 'shell input keyevent 3', { timeout: 10000 }).catch(() => {});
2300
+ await sleep(350);
2301
+ }
2302
+
2019
2303
  async screenshot(options = {}) {
2020
2304
  const serial = options.serial || await this.ensureDevice();
2305
+ await this.#prepareScreenForCapture(serial).catch(() => {});
2021
2306
  let artifactRecord = null;
2022
2307
  let filename = `android_${Date.now()}.png`;
2023
2308
  let fullPath = path.join(SCREENSHOTS_DIR, filename);
@@ -2035,7 +2320,32 @@ class AndroidController {
2035
2320
  fullPath = artifactRecord.storagePath;
2036
2321
  filename = path.basename(fullPath);
2037
2322
  }
2038
- await this.#adb(serial, `exec-out screencap -p > ${quoteShell(fullPath)}`, { timeout: 30000 });
2323
+ let captured = false;
2324
+ const localTmp = path.join(TMP_DIR, `shot-${Date.now()}-${Math.random().toString(16).slice(2)}.png`);
2325
+ const remoteTmp = `/sdcard/neoagent-shot-${Date.now()}.png`;
2326
+ for (let attempt = 0; attempt < 3 && !captured; attempt += 1) {
2327
+ try {
2328
+ if (attempt === 0) {
2329
+ await this.#adb(serial, `exec-out screencap -p > ${quoteShell(fullPath)}`, { timeout: 30000 });
2330
+ } else {
2331
+ await this.#adb(serial, `shell screencap -p ${quoteShell(remoteTmp)}`, { timeout: 30000 });
2332
+ await this.#adb(serial, `pull ${quoteShell(remoteTmp)} ${quoteShell(localTmp)}`, { timeout: 30000 });
2333
+ if (fs.existsSync(localTmp)) {
2334
+ fs.copyFileSync(localTmp, fullPath);
2335
+ }
2336
+ }
2337
+ const data = fs.readFileSync(fullPath);
2338
+ captured = isLikelyPng(data);
2339
+ } catch {}
2340
+ if (!captured) {
2341
+ await sleep(500);
2342
+ }
2343
+ }
2344
+ fs.rmSync(localTmp, { force: true });
2345
+ await this.#adb(serial, `shell rm -f ${quoteShell(remoteTmp)}`, { timeout: 10000 }).catch(() => {});
2346
+ if (!captured) {
2347
+ throw new Error('Failed to capture a valid Android screenshot.');
2348
+ }
2039
2349
  if (artifactRecord) {
2040
2350
  this.artifactStore.finalizeFile(artifactRecord.artifactId, fullPath);
2041
2351
  }
@@ -2393,9 +2703,36 @@ class AndroidController {
2393
2703
  }
2394
2704
 
2395
2705
  async listApps(args = {}) {
2396
- const serial = await this.ensureDevice();
2706
+ let serial = null;
2707
+ try {
2708
+ serial = await this.ensureDevice();
2709
+ } catch (error) {
2710
+ return {
2711
+ success: false,
2712
+ serial: null,
2713
+ count: 0,
2714
+ packages: [],
2715
+ error: String(error?.message || 'Android device is not ready.'),
2716
+ };
2717
+ }
2397
2718
  const cmd = args.includeSystem === true ? 'shell pm list packages' : 'shell pm list packages -3';
2398
- const out = await this.#adb(serial, cmd, { timeout: 30000 });
2719
+ let out = '';
2720
+ try {
2721
+ out = await this.#adb(serial, cmd, { timeout: 30000 });
2722
+ } catch (error) {
2723
+ await sleep(1000);
2724
+ try {
2725
+ out = await this.#adb(serial, cmd, { timeout: 30000 });
2726
+ } catch (retryError) {
2727
+ return {
2728
+ success: false,
2729
+ serial,
2730
+ count: 0,
2731
+ packages: [],
2732
+ error: String(retryError?.message || error?.message || 'Failed to list Android apps.'),
2733
+ };
2734
+ }
2735
+ }
2399
2736
  const packages = out
2400
2737
  .split('\n')
2401
2738
  .map((line) => line.trim())
@@ -2474,8 +2811,9 @@ class AndroidController {
2474
2811
 
2475
2812
  async getStatus() {
2476
2813
  const state = this.#readState();
2477
- const shouldSkipDeviceProbe = state.starting === true || this.startPromise != null;
2478
- const devices = !shouldSkipDeviceProbe && isExecutable(adbBinary())
2814
+ const bootstrapWorkerPid = Number(state.bootstrapWorkerPid || 0) || null;
2815
+ const bootstrapWorkerAlive = isProcessAlive(bootstrapWorkerPid);
2816
+ const devices = isExecutable(adbBinary())
2479
2817
  ? await this.listDevices({ ensureBootstrapped: false }).catch(() => [])
2480
2818
  : [];
2481
2819
  const serialInState = String(state.serial || '').trim();
@@ -2507,8 +2845,8 @@ class AndroidController {
2507
2845
  }
2508
2846
  return {
2509
2847
  bootstrapped: state.bootstrapped === true,
2510
- starting: state.starting === true || this.startPromise != null,
2511
- startupPhase: state.startupPhase || null,
2848
+ starting: state.starting === true || this.startPromise != null || bootstrapWorkerAlive,
2849
+ startupPhase: state.startupPhase || (bootstrapWorkerAlive ? 'Preparing Android runtime' : null),
2512
2850
  startRequestedAt: state.startRequestedAt || null,
2513
2851
  lastStartError: state.lastStartError || null,
2514
2852
  sdkRoot: activeAndroidSdkRoot(),
@@ -2519,6 +2857,7 @@ class AndroidController {
2519
2857
  serial: state.serial,
2520
2858
  serialOwnedByCurrentUser,
2521
2859
  emulatorPid: state.emulatorPid,
2860
+ bootstrapWorkerPid,
2522
2861
  systemImage: state.systemImage || null,
2523
2862
  systemImageArch: state.systemImageArch || null,
2524
2863
  preferredSystemImageArchs: systemImageArchCandidates(),