neoagent 2.3.1-beta.88 → 2.3.1-beta.90

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.
@@ -27,9 +27,11 @@ const ANDROID_BOOTSTRAP_WORKER = path.join(__dirname, 'android_bootstrap_worker.
27
27
  const ANDROID_JAVA_TOOL_TIMEOUT_MS = 20 * 60 * 1000;
28
28
  const DEFAULT_AVD_NAME = 'neoagent-default';
29
29
  const DEFAULT_DATA_PARTITION_BYTES = 1024 * 1024 * 1024;
30
- const DEFAULT_PARTITION_SIZE_MB = 1024;
31
30
  const DEFAULT_SDCARD_SIZE_BYTES = 32 * 1024 * 1024;
32
- const DEFAULT_RAM_SIZE_MB = 768;
31
+ const DEFAULT_RAM_SIZE_MB = 1536;
32
+ const MIN_SYSTEM_IMAGE_INSTALL_FREE_BYTES = 5 * 1024 * 1024 * 1024;
33
+ const EMULATOR_CONSOLE_PORT_MIN = 5554;
34
+ const EMULATOR_CONSOLE_PORT_MAX = 5584;
33
35
  const DEFAULT_KEYEVENTS = Object.freeze({
34
36
  home: 3,
35
37
  back: 4,
@@ -53,9 +55,19 @@ for (const dir of [ANDROID_ROOT, SDK_ROOT, EMULATOR_HOME, ARTIFACTS_DIR, SCREENS
53
55
 
54
56
  function ensureEmulatorAdvancedFeaturesFile() {
55
57
  try {
56
- if (fs.existsSync(EMULATOR_ADVANCED_FEATURES_FILE)) {
57
- fs.rmSync(EMULATOR_ADVANCED_FEATURES_FILE, { force: true });
58
- }
58
+ fs.mkdirSync(path.dirname(EMULATOR_ADVANCED_FEATURES_FILE), { recursive: true });
59
+ fs.writeFileSync(
60
+ EMULATOR_ADVANCED_FEATURES_FILE,
61
+ [
62
+ 'QuickbootFileBacked=off',
63
+ 'QuickbootSupport=off',
64
+ // Disable FBE so Android doesn't boot into Before-First-Unlock state,
65
+ // which blocks ADB from transitioning offline → device.
66
+ 'EncryptUserData=off',
67
+ '',
68
+ ].join('\n'),
69
+ 'utf8',
70
+ );
59
71
  } catch {}
60
72
  }
61
73
 
@@ -200,6 +212,101 @@ function tailFile(filePath, maxLines = 40) {
200
212
  }
201
213
  }
202
214
 
215
+ function buildAndroidBootstrapError(message, details = {}) {
216
+ const error = new Error(message);
217
+ error.code = details.code || 'ANDROID_BOOTSTRAP_FAILED';
218
+ error.details = details;
219
+ return error;
220
+ }
221
+
222
+ function selectAndroidFailureMessage(logPath, error) {
223
+ const structuredTail = Array.isArray(error?.details?.logTail) ? error.details.logTail : null;
224
+ if (structuredTail && structuredTail.length > 0) {
225
+ return structuredTail.slice(-100).join(' | ');
226
+ }
227
+
228
+ const errorMessage = error?.message || (error ? String(error) : '');
229
+ const sanitizedErrorMessage = errorMessage
230
+ .split('\n')
231
+ .map((line) => line.trim())
232
+ .filter((line) => line && line !== 'null' && !/^Picked up JAVA_TOOL_OPTIONS:/i.test(line))
233
+ .join('\n');
234
+ if (sanitizedErrorMessage && !/^INFO\s+\|/i.test(sanitizedErrorMessage) && !/^WARNING\s+\|/i.test(sanitizedErrorMessage)) {
235
+ return sanitizedErrorMessage;
236
+ }
237
+
238
+ const seriousLine = tailFile(logPath, 100)
239
+ .reverse()
240
+ .find((line) => /FATAL|PANIC|ERROR|disk full|broken avd|failed|exited/i.test(line));
241
+ if (seriousLine) {
242
+ return seriousLine;
243
+ }
244
+
245
+ return sanitizedErrorMessage || tailFile(logPath, 100).at(-1) || 'Android bootstrap failed.';
246
+ }
247
+
248
+
249
+ function isLikelyPng(buffer) {
250
+ return Buffer.isBuffer(buffer)
251
+ && buffer.length > 24
252
+ && buffer[0] === 0x89
253
+ && buffer[1] === 0x50
254
+ && buffer[2] === 0x4e
255
+ && buffer[3] === 0x47;
256
+ }
257
+
258
+ function freeBytesForPath(targetPath) {
259
+ try {
260
+ const stats = fs.statfsSync(targetPath);
261
+ return Number(stats.bavail || 0) * Number(stats.bsize || 0);
262
+ } catch {
263
+ return 0;
264
+ }
265
+ }
266
+
267
+ function pruneAndroidRuntimeCache(keepPackageName = null) {
268
+ fs.rmSync(TMP_DIR, { recursive: true, force: true });
269
+ fs.mkdirSync(TMP_DIR, { recursive: true });
270
+
271
+ for (const orphan of ['arm64-v8a', 'x86_64', 'x86']) {
272
+ fs.rmSync(path.join(activeAndroidSdkRoot(), orphan), { recursive: true, force: true });
273
+ }
274
+
275
+ const keep = String(keepPackageName || '').trim();
276
+ const root = path.join(activeAndroidSdkRoot(), 'system-images');
277
+ if (!fs.existsSync(root)) return;
278
+
279
+ for (const candidate of parseInstalledSystemImages()) {
280
+ if (keep && candidate.packageName === keep) continue;
281
+ const candidateRoot = path.join(activeAndroidSdkRoot(), ...String(candidate.packageName).split(';').filter(Boolean));
282
+ const notSelectedImage = Boolean(keep && candidate.packageName !== keep);
283
+ const wrongArch = candidate.arch !== systemImageArch();
284
+ const oldProblemImage = /system-images;android-30;default;arm64-v8a/i.test(candidate.packageName);
285
+ if (notSelectedImage || wrongArch || oldProblemImage || !isValidInstalledSystemImage(candidate.packageName)) {
286
+ console.warn('[Android][runtime_cache_prune]', {
287
+ packageName: candidate.packageName,
288
+ reason: notSelectedImage ? 'not_selected_image' : wrongArch ? 'wrong_arch' : oldProblemImage ? 'known_unstable_image' : 'invalid_image',
289
+ });
290
+ fs.rmSync(candidateRoot, { recursive: true, force: true });
291
+ }
292
+ }
293
+ }
294
+
295
+ function isBlankSparseFile(filePath) {
296
+ try {
297
+ const stats = fs.statSync(filePath);
298
+ if (!stats.isFile()) return false;
299
+ if (stats.size === 0) return true;
300
+ if (stats.blocks !== 0) return false;
301
+ // Sparse backing file is normal when a qcow2 overlay exists alongside it.
302
+ const qcow2Path = `${filePath}.qcow2`;
303
+ const qcow2Stats = fs.statSync(qcow2Path);
304
+ return !qcow2Stats.isFile() || qcow2Stats.size < 8192;
305
+ } catch {
306
+ return false;
307
+ }
308
+ }
309
+
203
310
  function commandExists(command) {
204
311
  const probe = spawnSync('bash', ['-lc', `command -v "${command}"`], { encoding: 'utf8' });
205
312
  return probe.status === 0;
@@ -268,33 +375,14 @@ function emulatorHostArch() {
268
375
  }
269
376
 
270
377
  function emulatorGpuMode() {
271
- if (process.platform === 'darwin' && process.arch === 'arm64') {
272
- return 'swiftshader_indirect';
378
+ if (process.platform === 'darwin') {
379
+ return 'host';
273
380
  }
274
381
  return 'auto';
275
382
  }
276
383
 
277
- function emulatorLaunchArgs() {
278
- return [
279
- '-no-snapshot',
280
- '-no-snapshot-save',
281
- '-no-window',
282
- '-no-audio',
283
- '-no-metrics',
284
- '-skip-adb-auth',
285
- '-crash-report-mode',
286
- 'disabled',
287
- ];
288
- }
289
-
290
- function isRecoverableEmulatorStartError(message) {
291
- const value = String(message || '').toLowerCase();
292
- return (
293
- value.includes('failed to restore previous context') ||
294
- value.includes('emulator exited before boot completed') ||
295
- value.includes('failed to process .ini file') ||
296
- value.includes('error while loading state for instance')
297
- );
384
+ function logAndroidIssue(event, details = {}) {
385
+ console.warn(`[Android][${event}]`, details);
298
386
  }
299
387
 
300
388
  function parseCsvEnv(value) {
@@ -394,6 +482,7 @@ function sdkEnv() {
394
482
  ANDROID_USER_HOME: EMULATOR_HOME,
395
483
  ANDROID_AVD_HOME: AVD_HOME,
396
484
  AVD_HOME,
485
+ ADB_VENDOR_KEYS: EMULATOR_HOME,
397
486
  JAVA_TOOL_OPTIONS: process.env.JAVA_TOOL_OPTIONS || '-Xint',
398
487
  };
399
488
  const pathParts = [
@@ -437,10 +526,10 @@ function hostAndroidSdkRoot() {
437
526
  return null;
438
527
  }
439
528
 
440
- const SHARED_ANDROID_SDK_ROOT = hostAndroidSdkRoot();
529
+ const SHARED_ANDROID_SDK_ROOT = null;
441
530
 
442
531
  function activeAndroidSdkRoot() {
443
- return SHARED_ANDROID_SDK_ROOT || SDK_ROOT;
532
+ return SDK_ROOT;
444
533
  }
445
534
 
446
535
  function sharedAndroidSdkReady() {
@@ -458,20 +547,6 @@ function adbBinary() {
458
547
  if (process.env.ANDROID_ADB_PATH) {
459
548
  return process.env.ANDROID_ADB_PATH;
460
549
  }
461
- if (SHARED_ANDROID_SDK_ROOT) {
462
- const sharedAdb = path.join(SHARED_ANDROID_SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
463
- if (isExecutable(sharedAdb)) {
464
- return sharedAdb;
465
- }
466
- }
467
- const hostAdb = hostAdbBinary();
468
- if (hostAdb) {
469
- return hostAdb;
470
- }
471
- const distroAdb = systemAdbBinary();
472
- if (process.platform === 'linux' && process.arch === 'arm64' && distroAdb && isExecutable(distroAdb)) {
473
- return distroAdb;
474
- }
475
550
  return path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
476
551
  }
477
552
 
@@ -745,7 +820,11 @@ function parseRepositorySystemImages(xml) {
745
820
  return parseSystemImageCandidates(matches);
746
821
  }
747
822
 
748
- function parseLatestSystemImageUrl(xml, packageName) {
823
+ function parseLatestSystemImageUrl(source, packageName) {
824
+ const xml = typeof source === 'string' ? source : source?.xml;
825
+ const baseUrl = typeof source === 'string'
826
+ ? 'https://dl.google.com/android/repository/sys-img/android/'
827
+ : source?.baseUrl || 'https://dl.google.com/android/repository/sys-img/android/';
749
828
  const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="${packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}">([\\s\\S]*?)<\\/remotePackage>`));
750
829
  if (!packageMatch) throw new Error(`Could not locate ${packageName} in Android repository metadata`);
751
830
 
@@ -755,13 +834,52 @@ function parseLatestSystemImageUrl(xml, packageName) {
755
834
  if (urlMatch) {
756
835
  const urlPart = urlMatch[1];
757
836
  if (urlPart.startsWith('http')) return urlPart;
758
- return `https://dl.google.com/android/repository/sys-img/android/${urlPart}`;
837
+ return `${baseUrl}${urlPart}`;
759
838
  }
760
839
  }
761
840
 
762
841
  throw new Error(`Could not find a system image archive for ${packageName}`);
763
842
  }
764
843
 
844
+ async function fetchSystemImageRepositories() {
845
+ const sources = [
846
+ {
847
+ url: 'https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml',
848
+ baseUrl: 'https://dl.google.com/android/repository/sys-img/android/',
849
+ },
850
+ {
851
+ url: 'https://dl.google.com/android/repository/sys-img/google_apis/sys-img2-1.xml',
852
+ baseUrl: 'https://dl.google.com/android/repository/sys-img/google_apis/',
853
+ },
854
+ {
855
+ url: 'https://dl.google.com/android/repository/sys-img/google_apis_playstore/sys-img2-1.xml',
856
+ baseUrl: 'https://dl.google.com/android/repository/sys-img/google_apis_playstore/',
857
+ },
858
+ ];
859
+ const results = [];
860
+ for (const source of sources) {
861
+ try {
862
+ results.push({
863
+ ...source,
864
+ xml: await fetchText(source.url),
865
+ });
866
+ } catch (error) {
867
+ logAndroidIssue('system_image_repository_fetch_failed', {
868
+ url: source.url,
869
+ message: String(error?.message || error),
870
+ });
871
+ }
872
+ }
873
+ if (results.length === 0) {
874
+ throw new Error('Could not fetch Android system image repository metadata.');
875
+ }
876
+ return results;
877
+ }
878
+
879
+ function parseRepositorySystemImagesFromSources(sources) {
880
+ return sources.flatMap((source) => parseRepositorySystemImages(source.xml));
881
+ }
882
+
765
883
  async function fetchEmulatorMetadata() {
766
884
  const urls = [
767
885
  'https://dl.google.com/android/repository/repository2-3.xml',
@@ -819,29 +937,48 @@ async function installPlatformToolsArchive(metadata) {
819
937
  }
820
938
 
821
939
  function shouldInstallPlatformToolsArchive() {
822
- if (SHARED_ANDROID_SDK_ROOT) {
823
- return false;
824
- }
825
- if (hostAdbBinary()) {
826
- return false;
827
- }
828
- const distroAdb = systemAdbBinary();
829
- if (process.platform === 'linux' && process.arch === 'arm64' && distroAdb && isExecutable(distroAdb)) {
830
- return false;
831
- }
832
- return true;
940
+ const localAdb = path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
941
+ return !isExecutable(localAdb);
833
942
  }
834
943
 
835
944
  async function installSystemImageArchive(metadata, packageName) {
836
- const url = parseLatestSystemImageUrl(metadata, packageName);
945
+ const sources = Array.isArray(metadata) ? metadata : [{ xml: metadata }];
946
+ const source = sources.find((entry) => {
947
+ try {
948
+ parseLatestSystemImageUrl(entry, packageName);
949
+ return true;
950
+ } catch {
951
+ return false;
952
+ }
953
+ });
954
+ if (!source) {
955
+ throw new Error(`Could not locate ${packageName} in Android repository metadata`);
956
+ }
957
+ const url = parseLatestSystemImageUrl(source, packageName);
837
958
  const zipPath = path.join(TMP_DIR, path.basename(url));
838
959
  const targetRoot = path.join(activeAndroidSdkRoot(), ...String(packageName).split(';').filter(Boolean));
839
960
  const extractDir = fs.mkdtempSync(path.join(TMP_DIR, 'system-image-'));
840
961
 
841
962
  try {
963
+ pruneAndroidRuntimeCache(packageName);
964
+ fs.mkdirSync(extractDir, { recursive: true });
965
+ let freeBytes = freeBytesForPath(ANDROID_ROOT);
966
+ if (freeBytes < MIN_SYSTEM_IMAGE_INSTALL_FREE_BYTES) {
967
+ throw new Error(
968
+ `Not enough disk space to install Android system image ${packageName}. ` +
969
+ `Free ${Math.round(freeBytes / 1024 / 1024)} MB, need at least ${Math.round(MIN_SYSTEM_IMAGE_INSTALL_FREE_BYTES / 1024 / 1024)} MB.`
970
+ );
971
+ }
842
972
  fs.rmSync(targetRoot, { recursive: true, force: true });
843
973
  fs.mkdirSync(path.dirname(targetRoot), { recursive: true });
844
974
  await downloadFile(url, zipPath);
975
+ freeBytes = freeBytesForPath(ANDROID_ROOT);
976
+ if (freeBytes < MIN_SYSTEM_IMAGE_INSTALL_FREE_BYTES / 2) {
977
+ throw new Error(
978
+ `Not enough disk space to extract Android system image ${packageName}. ` +
979
+ `Free ${Math.round(freeBytes / 1024 / 1024)} MB after download.`
980
+ );
981
+ }
845
982
  extractZip(zipPath, extractDir);
846
983
 
847
984
  const extractedRoot = findDirectoryContainingFiles(extractDir, ['userdata.img']) ||
@@ -855,6 +992,13 @@ async function installSystemImageArchive(metadata, packageName) {
855
992
  try {
856
993
  fs.renameSync(extractedRoot, targetRoot);
857
994
  } catch (renameErr) {
995
+ freeBytes = freeBytesForPath(ANDROID_ROOT);
996
+ if (freeBytes < MIN_SYSTEM_IMAGE_INSTALL_FREE_BYTES / 2) {
997
+ throw new Error(
998
+ `Not enough disk space to move Android system image ${packageName}. ` +
999
+ `Free ${Math.round(freeBytes / 1024 / 1024)} MB.`
1000
+ );
1001
+ }
858
1002
  fs.cpSync(extractedRoot, targetRoot, { recursive: true, force: true });
859
1003
  if (renameErr) {
860
1004
  console.warn(`[Android] Falling back to copy for ${packageName}: ${renameErr.message}`);
@@ -947,27 +1091,78 @@ function parseInstalledSystemImages() {
947
1091
  return parseSystemImageCandidates(matches);
948
1092
  }
949
1093
 
1094
+ function chooseStableRuntimeSystemImage(candidates, currentPackage) {
1095
+ const normalizedCurrent = String(currentPackage || '').trim();
1096
+ const pool = Array.isArray(candidates)
1097
+ ? candidates.filter((item) => item && item.packageName && isValidInstalledSystemImage(item.packageName))
1098
+ : [];
1099
+ if (pool.length === 0) return null;
1100
+ const ranked = rankSystemImagePool(pool);
1101
+ const recommended = ranked.find((item) =>
1102
+ item.arch === 'arm64-v8a'
1103
+ && item.stable
1104
+ && item.apiLevel >= 33
1105
+ ) || ranked[0];
1106
+ if (!recommended) return null;
1107
+ if (normalizedCurrent && recommended.packageName === normalizedCurrent) {
1108
+ return null;
1109
+ }
1110
+ return recommended;
1111
+ }
1112
+
1113
+ function runtimeSystemImagePreferenceRank(candidate) {
1114
+ if (!candidate) return Number.MAX_SAFE_INTEGER;
1115
+ if (process.platform === 'darwin' && process.arch === 'arm64') {
1116
+ const preferredApis = [31, 34, 35, 33, 32];
1117
+ const index = preferredApis.indexOf(Number(candidate.apiLevel || 0));
1118
+ return index === -1 ? 1000 + Math.abs(Number(candidate.apiLevel || 0) - 31) : index;
1119
+ }
1120
+ if (process.platform === 'linux' && process.arch === 'arm64') {
1121
+ const preferredApis = [34, 35, 33, 31, 36];
1122
+ const index = preferredApis.indexOf(Number(candidate.apiLevel || 0));
1123
+ return index === -1 ? 1000 + Math.abs(Number(candidate.apiLevel || 0) - 34) : index;
1124
+ }
1125
+ return 0;
1126
+ }
1127
+
1128
+ function preferredRuntimeSystemImageCandidate(candidates = []) {
1129
+ if (!(process.platform === 'darwin' && process.arch === 'arm64')) {
1130
+ return null;
1131
+ }
1132
+
1133
+ const desired = [
1134
+ ['android-31', 'google_apis', 'arm64-v8a'],
1135
+ ['android-34', 'google_apis', 'arm64-v8a'],
1136
+ ['android-33', 'google_apis', 'arm64-v8a'],
1137
+ ['android-31', 'google_apis_playstore', 'arm64-v8a'],
1138
+ ['android-34', 'google_apis_playstore', 'arm64-v8a'],
1139
+ ['android-33', 'google_apis_playstore', 'arm64-v8a'],
1140
+ ];
1141
+ const parsed = Array.isArray(candidates) ? candidates : [];
1142
+ for (const [platformId, tag, arch] of desired) {
1143
+ const found = parsed.find((candidate) =>
1144
+ candidate.platformId === platformId &&
1145
+ candidate.tag === tag &&
1146
+ candidate.arch === arch
1147
+ );
1148
+ if (found) return found;
1149
+ }
1150
+ const [platformId, tag, arch] = desired[0];
1151
+ return parseSystemImageCandidates([{
1152
+ packageName: `system-images;${platformId};${tag};${arch}`,
1153
+ platformId,
1154
+ tag,
1155
+ arch,
1156
+ }])[0];
1157
+ }
1158
+
1159
+
950
1160
  function rankSystemImagePool(pool) {
951
1161
  const preferredMatches = pool.filter((candidate) => candidate.tagScore > 0);
952
1162
  const rankedPool = preferredMatches.length > 0 ? preferredMatches : pool;
953
- const appleSiliconPreferredApis = process.platform === 'darwin' && process.arch === 'arm64'
954
- ? [30, 31, 35, 36]
955
- : [];
956
- const preferredApiRank = (apiLevel) => appleSiliconPreferredApis.indexOf(Number(apiLevel || 0));
957
1163
 
958
1164
  rankedPool.sort((a, b) =>
959
- (
960
- (() => {
961
- const aRank = preferredApiRank(a.apiLevel);
962
- const bRank = preferredApiRank(b.apiLevel);
963
- if (aRank !== -1 || bRank !== -1) {
964
- if (aRank === -1) return 1;
965
- if (bRank === -1) return -1;
966
- return aRank - bRank;
967
- }
968
- return 0;
969
- })()
970
- ) ||
1165
+ runtimeSystemImagePreferenceRank(a) - runtimeSystemImagePreferenceRank(b) ||
971
1166
  Number(b.stable) - Number(a.stable) ||
972
1167
  b.tagScore - a.tagScore ||
973
1168
  b.apiLevel - a.apiLevel ||
@@ -1080,6 +1275,30 @@ function systemImagePackageToRelativeDir(packageName) {
1080
1275
  return `${parts.join('/')}/`;
1081
1276
  }
1082
1277
 
1278
+ function isValidInstalledSystemImage(packageName) {
1279
+ const relativeDir = systemImagePackageToRelativeDir(packageName);
1280
+ if (!relativeDir) return false;
1281
+ const root = path.join(activeAndroidSdkRoot(), relativeDir);
1282
+ const hasMetadata = fs.existsSync(path.join(root, 'package.xml')) || fs.existsSync(path.join(root, 'source.properties'));
1283
+ return hasMetadata
1284
+ && fs.existsSync(path.join(root, 'system.img'))
1285
+ && fs.existsSync(path.join(root, 'userdata.img'));
1286
+ }
1287
+
1288
+ function systemImagePackageRoot(packageName) {
1289
+ const relativeDir = systemImagePackageToRelativeDir(packageName);
1290
+ if (!relativeDir) return null;
1291
+ return path.join(activeAndroidSdkRoot(), relativeDir);
1292
+ }
1293
+
1294
+ function ensureSystemImageUserdataImage(packageName) {
1295
+ const root = systemImagePackageRoot(packageName);
1296
+ if (!root) return;
1297
+ const userdataImage = path.join(root, 'userdata.img');
1298
+ if (fs.existsSync(userdataImage)) return;
1299
+ ensureSparseFile(userdataImage, DEFAULT_DATA_PARTITION_BYTES);
1300
+ }
1301
+
1083
1302
  function systemImagePackageToAbi(packageName) {
1084
1303
  const parts = String(packageName || '').split(';').filter(Boolean);
1085
1304
  if (parts.length !== 4 || parts[0] !== 'system-images') {
@@ -1358,7 +1577,30 @@ class AndroidController {
1358
1577
  cwd: options.cwd || ANDROID_ROOT,
1359
1578
  });
1360
1579
  if (result.exitCode !== 0) {
1361
- throw new Error(result.stderr || result.stdout || `Command failed: ${command}`);
1580
+ const stderr = String(result.stderr || '').trim();
1581
+ const stdout = String(result.stdout || '').trim();
1582
+ const fragments = [];
1583
+ if (result.timedOut) {
1584
+ fragments.push(`Command timed out after ${result.durationMs || options.timeout || 120000}ms: ${command}`);
1585
+ } else {
1586
+ fragments.push(`Command failed (exit ${result.exitCode ?? 'unknown'}): ${command}`);
1587
+ }
1588
+ if (stderr) {
1589
+ fragments.push(`stderr: ${stderr}`);
1590
+ }
1591
+ if (stdout) {
1592
+ fragments.push(`stdout: ${stdout}`);
1593
+ }
1594
+ const error = new Error(fragments.join('\n'));
1595
+ error.details = {
1596
+ command,
1597
+ exitCode: result.exitCode,
1598
+ timedOut: result.timedOut === true,
1599
+ durationMs: result.durationMs,
1600
+ stderr,
1601
+ stdout,
1602
+ };
1603
+ throw error;
1362
1604
  }
1363
1605
  return result.stdout || '';
1364
1606
  }
@@ -1372,6 +1614,11 @@ class AndroidController {
1372
1614
  }
1373
1615
 
1374
1616
  async ensureBootstrapped() {
1617
+ this.#appendState({
1618
+ starting: this.#readState().starting === true,
1619
+ startupPhase: 'Checking Android runtime',
1620
+ lastLogLine: 'Checking Android SDK, emulator, and system image.',
1621
+ });
1375
1622
  const desiredArch = systemImageArch();
1376
1623
  const state = this.#readState();
1377
1624
  const installedImages = parseInstalledSystemImages();
@@ -1379,27 +1626,52 @@ class AndroidController {
1379
1626
  chooseConfiguredSystemImage(installedImages) ||
1380
1627
  chooseLatestSystemImage(installedImages, [desiredArch]) ||
1381
1628
  chooseLatestSystemImage(installedImages);
1382
- const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
1383
- const available = parseRepositorySystemImages(systemImageMetadata);
1629
+ const systemImageMetadata = await fetchSystemImageRepositories();
1630
+ const available = parseRepositorySystemImagesFromSources(systemImageMetadata);
1384
1631
  const preferredAvailable =
1385
1632
  chooseConfiguredSystemImage(available) ||
1633
+ preferredRuntimeSystemImageCandidate(available) ||
1386
1634
  chooseLatestSystemImage(available, [desiredArch]) ||
1387
1635
  chooseLatestSystemImage(available);
1388
- const selectedImage = rankSystemImagePool([preferredInstalled, preferredAvailable].filter(Boolean))[0] || preferredInstalled || preferredAvailable;
1636
+ const shouldPreferLighterAvailableImage =
1637
+ process.platform === 'darwin' &&
1638
+ process.arch === 'arm64' &&
1639
+ preferredAvailable?.packageName &&
1640
+ /;google_apis;/.test(preferredAvailable.packageName) &&
1641
+ /;google_apis_playstore;/.test(String(preferredInstalled?.packageName || ''));
1642
+ const selectedImage = shouldPreferLighterAvailableImage
1643
+ ? preferredAvailable
1644
+ : (rankSystemImagePool([preferredInstalled, preferredAvailable].filter(Boolean))[0] || preferredInstalled || preferredAvailable);
1389
1645
  const stateApiLevel = Number(state.apiLevel || 0) || 0;
1390
-
1391
- if (!shouldForceSdkRefresh() && sharedAndroidSdkReady() && selectedImage) {
1646
+ const legacyLinuxArm64Image =
1647
+ process.platform === 'linux'
1648
+ && process.arch === 'arm64'
1649
+ && /system-images;android-30;default;arm64-v8a/i.test(String(state.systemImage || ''));
1650
+ const migrationTargetImage =
1651
+ process.platform === 'linux' && process.arch === 'arm64'
1652
+ ? (
1653
+ rankSystemImagePool(
1654
+ [preferredAvailable, preferredInstalled]
1655
+ .filter(Boolean)
1656
+ .filter((image) => image.arch === 'arm64-v8a' && image.stable && image.apiLevel >= 33)
1657
+ )[0] || null
1658
+ )
1659
+ : null;
1660
+ const effectiveSelectedImage = migrationTargetImage || selectedImage;
1661
+ const selectedImageInvalid = effectiveSelectedImage?.packageName && !isValidInstalledSystemImage(effectiveSelectedImage.packageName);
1662
+
1663
+ if (!shouldForceSdkRefresh() && !legacyLinuxArm64Image && !selectedImageInvalid && sharedAndroidSdkReady() && effectiveSelectedImage) {
1392
1664
  const stateAligned =
1393
- selectedImage.packageName === state.systemImage &&
1394
- selectedImage.apiLevel === stateApiLevel &&
1395
- selectedImage.arch === state.systemImageArch &&
1665
+ effectiveSelectedImage.packageName === state.systemImage &&
1666
+ effectiveSelectedImage.apiLevel === stateApiLevel &&
1667
+ effectiveSelectedImage.arch === state.systemImageArch &&
1396
1668
  state.avdName === this.avdName;
1397
1669
 
1398
1670
  if (stateAligned) {
1399
1671
  return;
1400
1672
  }
1401
1673
 
1402
- if (selectedImage === preferredInstalled && selectedImage.packageName) {
1674
+ if (effectiveSelectedImage === preferredInstalled && effectiveSelectedImage.packageName) {
1403
1675
  const changeSummary = describeAutoFixChanges(
1404
1676
  {
1405
1677
  avdName: state.avdName || null,
@@ -1409,9 +1681,9 @@ class AndroidController {
1409
1681
  },
1410
1682
  {
1411
1683
  avdName: this.avdName,
1412
- systemImage: selectedImage.packageName,
1413
- apiLevel: selectedImage.apiLevel,
1414
- systemImageArch: selectedImage.arch,
1684
+ systemImage: effectiveSelectedImage.packageName,
1685
+ apiLevel: effectiveSelectedImage.apiLevel,
1686
+ systemImageArch: effectiveSelectedImage.arch,
1415
1687
  },
1416
1688
  ['avdName', 'systemImage', 'apiLevel', 'systemImageArch']
1417
1689
  );
@@ -1423,9 +1695,9 @@ class AndroidController {
1423
1695
  avdName: this.avdName,
1424
1696
  serial: null,
1425
1697
  emulatorPid: null,
1426
- systemImage: selectedImage.packageName,
1427
- apiLevel: selectedImage.apiLevel,
1428
- systemImageArch: selectedImage.arch,
1698
+ systemImage: effectiveSelectedImage.packageName,
1699
+ apiLevel: effectiveSelectedImage.apiLevel,
1700
+ systemImageArch: effectiveSelectedImage.arch,
1429
1701
  });
1430
1702
  return;
1431
1703
  }
@@ -1436,6 +1708,10 @@ class AndroidController {
1436
1708
  isExecutable(emulatorBinary()) &&
1437
1709
  installedEmulatorMatchesHost();
1438
1710
  if (!binariesReady) {
1711
+ this.#appendState({
1712
+ startupPhase: 'Installing Android tools',
1713
+ lastLogLine: 'Installing Android platform tools and emulator.',
1714
+ });
1439
1715
  if (this.bootstrapPromise) {
1440
1716
  await this.bootstrapPromise;
1441
1717
  } else {
@@ -1451,12 +1727,12 @@ class AndroidController {
1451
1727
  const runtimeNeedsRefresh =
1452
1728
  state.systemImageArch !== desiredArch ||
1453
1729
  !installedEmulatorMatchesHost();
1454
- if (!shouldForceSdkRefresh()) {
1455
- if (!runtimeNeedsRefresh && selectedImage && selectedImage === preferredInstalled) {
1730
+ if (!shouldForceSdkRefresh() && !legacyLinuxArm64Image && !selectedImageInvalid) {
1731
+ if (!runtimeNeedsRefresh && effectiveSelectedImage && effectiveSelectedImage === preferredInstalled) {
1456
1732
  const stateNeedsRefresh =
1457
- selectedImage.packageName !== state.systemImage ||
1458
- selectedImage.apiLevel !== stateApiLevel ||
1459
- selectedImage.arch !== state.systemImageArch ||
1733
+ effectiveSelectedImage.packageName !== state.systemImage ||
1734
+ effectiveSelectedImage.apiLevel !== stateApiLevel ||
1735
+ effectiveSelectedImage.arch !== state.systemImageArch ||
1460
1736
  state.avdName !== this.avdName;
1461
1737
  if (stateNeedsRefresh) {
1462
1738
  const changeSummary = describeAutoFixChanges(
@@ -1468,9 +1744,9 @@ class AndroidController {
1468
1744
  },
1469
1745
  {
1470
1746
  avdName: this.avdName,
1471
- systemImage: selectedImage.packageName,
1472
- apiLevel: selectedImage.apiLevel,
1473
- systemImageArch: selectedImage.arch,
1747
+ systemImage: effectiveSelectedImage.packageName,
1748
+ apiLevel: effectiveSelectedImage.apiLevel,
1749
+ systemImageArch: effectiveSelectedImage.arch,
1474
1750
  },
1475
1751
  ['avdName', 'systemImage', 'apiLevel', 'systemImageArch']
1476
1752
  );
@@ -1482,9 +1758,9 @@ class AndroidController {
1482
1758
  avdName: this.avdName,
1483
1759
  serial: null,
1484
1760
  emulatorPid: null,
1485
- systemImage: selectedImage.packageName,
1486
- apiLevel: selectedImage.apiLevel,
1487
- systemImageArch: selectedImage.arch,
1761
+ systemImage: effectiveSelectedImage.packageName,
1762
+ apiLevel: effectiveSelectedImage.apiLevel,
1763
+ systemImageArch: effectiveSelectedImage.arch,
1488
1764
  });
1489
1765
  }
1490
1766
  return;
@@ -1493,8 +1769,8 @@ class AndroidController {
1493
1769
  !runtimeNeedsRefresh &&
1494
1770
  state.bootstrapped === true &&
1495
1771
  state.systemImage &&
1496
- selectedImage &&
1497
- selectedImage.packageName === state.systemImage
1772
+ effectiveSelectedImage &&
1773
+ effectiveSelectedImage.packageName === state.systemImage
1498
1774
  ) {
1499
1775
  return;
1500
1776
  }
@@ -1503,21 +1779,34 @@ class AndroidController {
1503
1779
  this.#appendState({ bootstrapped: true });
1504
1780
  const metadata = await fetchEmulatorMetadata();
1505
1781
  if (shouldInstallPlatformToolsArchive()) {
1782
+ this.#appendState({
1783
+ startupPhase: 'Installing Android platform tools',
1784
+ lastLogLine: 'Installing Android platform tools.',
1785
+ });
1506
1786
  await installPlatformToolsArchive(metadata);
1507
1787
  }
1788
+ this.#appendState({
1789
+ startupPhase: 'Installing Android emulator',
1790
+ lastLogLine: 'Installing or updating the Android emulator.',
1791
+ });
1508
1792
  await installEmulatorArchive(metadata);
1509
- if (!selectedImage) {
1793
+ if (!effectiveSelectedImage) {
1510
1794
  throw new Error(formatSystemImageError(available));
1511
1795
  }
1512
- if (selectedImage?.packageName) {
1513
- await installSystemImageArchive(systemImageMetadata, selectedImage.packageName);
1796
+ if (effectiveSelectedImage?.packageName) {
1797
+ this.#appendState({
1798
+ startupPhase: 'Installing Android system image',
1799
+ lastLogLine: `Installing ${effectiveSelectedImage.packageName}.`,
1800
+ });
1801
+ await installSystemImageArchive(systemImageMetadata, effectiveSelectedImage.packageName);
1514
1802
  }
1515
1803
  this.#appendState({
1516
1804
  bootstrapped: true,
1517
1805
  avdName: this.avdName,
1518
- systemImage: selectedImage.packageName,
1519
- apiLevel: selectedImage.apiLevel,
1520
- systemImageArch: selectedImage.arch,
1806
+ systemImage: effectiveSelectedImage.packageName,
1807
+ apiLevel: effectiveSelectedImage.apiLevel,
1808
+ systemImageArch: effectiveSelectedImage.arch,
1809
+ avdSystemImage: null,
1521
1810
  });
1522
1811
  }
1523
1812
 
@@ -1549,8 +1838,8 @@ class AndroidController {
1549
1838
  }
1550
1839
  await installEmulatorArchive(metadata);
1551
1840
 
1552
- const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
1553
- const available = parseRepositorySystemImages(systemImageMetadata);
1841
+ const systemImageMetadata = await fetchSystemImageRepositories();
1842
+ const available = parseRepositorySystemImagesFromSources(systemImageMetadata);
1554
1843
  const systemImage = chooseConfiguredSystemImage(available) || chooseLatestSystemImage(available);
1555
1844
  if (!systemImage) throw new Error(formatSystemImageError(available));
1556
1845
 
@@ -1569,8 +1858,7 @@ class AndroidController {
1569
1858
 
1570
1859
  markBootstrapFailure(error) {
1571
1860
  const state = this.#readState();
1572
- const recentLogLines = state.logPath ? tailFile(state.logPath, 12) : [];
1573
- const detailedMessage = recentLogLines[recentLogLines.length - 1] || error?.message || String(error || 'Android bootstrap failed.');
1861
+ const detailedMessage = selectAndroidFailureMessage(state.logPath, error);
1574
1862
  this.#appendState({
1575
1863
  starting: false,
1576
1864
  startupPhase: 'Start failed',
@@ -1578,6 +1866,12 @@ class AndroidController {
1578
1866
  lastLogLine: detailedMessage,
1579
1867
  bootstrapWorkerPid: null,
1580
1868
  });
1869
+ logAndroidIssue('bootstrap_failure', {
1870
+ scopeKey: this.scopeKey,
1871
+ avdName: this.avdName,
1872
+ message: detailedMessage,
1873
+ logPath: state.logPath || null,
1874
+ });
1581
1875
  return detailedMessage;
1582
1876
  }
1583
1877
 
@@ -1585,158 +1879,42 @@ class AndroidController {
1585
1879
  await this.ensureBootstrapped();
1586
1880
 
1587
1881
  const state = this.#readState();
1588
- const pkg = state.systemImage;
1882
+ let pkg = state.systemImage;
1589
1883
  if (!pkg) throw new Error('Android system image not installed');
1590
1884
  const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1591
1885
  const configPath = path.join(avdDir, 'config.ini');
1592
1886
  const avdExists = fs.existsSync(configPath);
1593
- let avdNeedsRecreate = avdExists && (!state.avdSystemImage || state.avdSystemImage !== pkg);
1594
- const avdRecreateReasons = [];
1595
- if (avdNeedsRecreate && state.avdSystemImage !== pkg) {
1596
- avdRecreateReasons.push(`systemImage: ${state.avdSystemImage || 'null'} -> ${pkg}`);
1597
- }
1598
- if (avdExists && fs.existsSync(configPath)) {
1599
- try {
1600
- const config = fs.readFileSync(configPath, 'utf8');
1601
- const currentImageDir = readIniValue(config, 'image.sysdir.1');
1602
- const expectedImageDir = systemImagePackageToRelativeDir(pkg);
1603
- const currentAbi = readIniValue(config, 'abi.type');
1604
- const expectedAbi = systemImagePackageToAbi(pkg);
1605
- const currentCpuArch = readIniValue(config, 'hw.cpu.arch');
1606
- const expectedCpuArch = systemImagePackageToCpuArch(pkg);
1607
- const currentDataPartitionSize = readIniValue(config, 'disk.dataPartition.size');
1608
- const expectedDataPartitionSize = String(DEFAULT_DATA_PARTITION_BYTES);
1609
- const currentSdcardSize = readIniValue(config, 'sdcard.size');
1610
- const expectedSdcardSize = String(DEFAULT_SDCARD_SIZE_BYTES);
1611
- const currentRamSize = readIniValue(config, 'hw.ramSize');
1612
- const expectedRamSize = String(DEFAULT_RAM_SIZE_MB);
1613
- const currentGpuMode = readIniValue(config, 'hw.gpu.mode');
1614
- const expectedGpuMode = emulatorGpuMode();
1615
- const currentPlayStoreEnabled = readIniValue(config, 'PlayStore.enabled');
1616
- const expectedPlayStoreEnabled = String(String(pkg || '').includes('playstore'));
1617
- if (expectedImageDir && currentImageDir && currentImageDir !== expectedImageDir) {
1618
- avdNeedsRecreate = true;
1619
- avdRecreateReasons.push(`image.sysdir.1: ${currentImageDir} -> ${expectedImageDir}`);
1620
- }
1621
- if (expectedAbi && currentAbi && currentAbi !== expectedAbi) {
1622
- avdNeedsRecreate = true;
1623
- avdRecreateReasons.push(`abi.type: ${currentAbi} -> ${expectedAbi}`);
1624
- }
1625
- if (expectedCpuArch && currentCpuArch && currentCpuArch !== expectedCpuArch) {
1626
- avdNeedsRecreate = true;
1627
- avdRecreateReasons.push(`hw.cpu.arch: ${currentCpuArch} -> ${expectedCpuArch}`);
1628
- }
1629
- if (currentDataPartitionSize && currentDataPartitionSize !== expectedDataPartitionSize) {
1630
- avdNeedsRecreate = true;
1631
- avdRecreateReasons.push(`disk.dataPartition.size: ${currentDataPartitionSize} -> ${expectedDataPartitionSize}`);
1632
- }
1633
- if (currentSdcardSize && currentSdcardSize !== expectedSdcardSize) {
1634
- avdNeedsRecreate = true;
1635
- avdRecreateReasons.push(`sdcard.size: ${currentSdcardSize} -> ${expectedSdcardSize}`);
1636
- }
1637
- if (currentRamSize && currentRamSize !== expectedRamSize) {
1638
- avdNeedsRecreate = true;
1639
- avdRecreateReasons.push(`hw.ramSize: ${currentRamSize} -> ${expectedRamSize}`);
1640
- }
1641
- if (currentGpuMode && currentGpuMode !== expectedGpuMode) {
1642
- avdNeedsRecreate = true;
1643
- avdRecreateReasons.push(`hw.gpu.mode: ${currentGpuMode} -> ${expectedGpuMode}`);
1644
- }
1645
- if (currentPlayStoreEnabled && currentPlayStoreEnabled !== expectedPlayStoreEnabled) {
1646
- avdNeedsRecreate = true;
1647
- avdRecreateReasons.push(`PlayStore.enabled: ${currentPlayStoreEnabled} -> ${expectedPlayStoreEnabled}`);
1648
- }
1649
- } catch {}
1887
+ if (avdExists && state.avdSystemImage === pkg) {
1888
+ return;
1650
1889
  }
1651
1890
 
1652
- if (avdNeedsRecreate) {
1653
- if (avdRecreateReasons.length > 0) {
1654
- console.log(`[Android] Recreating AVD to repair config mismatch (${avdRecreateReasons.join(', ')})`);
1891
+ ensureSystemImageUserdataImage(pkg);
1892
+
1893
+ const createAvdCommand =
1894
+ `printf 'no\\n' | ${quoteShell(avdManagerBinary())} create avd -n ${quoteShell(this.avdName)} -k "${pkg}" --force`;
1895
+ try {
1896
+ await this.#run(createAvdCommand, { timeout: 300000 });
1897
+ } catch (error) {
1898
+ const message = String(error?.message || error || '');
1899
+ if (/Unable to find a 'userdata\.img' file/i.test(message)) {
1900
+ ensureSystemImageUserdataImage(pkg);
1901
+ await this.#run(createAvdCommand, { timeout: 300000 });
1902
+ } else if (/"emulator" package must be installed/i.test(message)) {
1903
+ this.#appendState({
1904
+ startupPhase: 'Repairing Android emulator package',
1905
+ lastLogLine: 'Reinstalling Android emulator package.',
1906
+ });
1907
+ const metadata = await fetchEmulatorMetadata();
1908
+ await installEmulatorArchive(metadata);
1909
+ await this.#run(createAvdCommand, { timeout: 300000 });
1910
+ } else {
1911
+ throw error;
1655
1912
  }
1656
- await this.stopEmulator().catch(() => {});
1657
- fs.rmSync(avdDir, { recursive: true, force: true });
1658
- fs.rmSync(path.join(AVD_HOME, `${this.avdName}.ini`), { force: true });
1659
- fs.rmSync(path.join(avdDir, 'userdata-qemu.img'), { force: true });
1660
- } else if (avdExists) {
1661
- ensureSparseFile(path.join(avdDir, 'userdata-qemu.img'), DEFAULT_DATA_PARTITION_BYTES);
1662
- return;
1663
1913
  }
1664
-
1665
- this.#writeAvdFiles(pkg);
1666
1914
  this.#normalizeAvdConfig();
1667
1915
  this.#appendState({ avdSystemImage: pkg });
1668
1916
  }
1669
1917
 
1670
- #writeAvdFiles(packageName) {
1671
- const parts = String(packageName || '').split(';').filter(Boolean);
1672
- if (parts.length !== 4 || parts[0] !== 'system-images') {
1673
- throw new Error(`Invalid Android system image package: ${packageName}`);
1674
- }
1675
- const apiLevel = parts[1].replace(/^android-/, '');
1676
- const tagId = parts[2];
1677
- const tagDisplay = tagId === 'google_apis' ? 'Google APIs' : tagId.replace(/_/g, ' ');
1678
- const abi = parts[3];
1679
- const playStoreEnabled = tagId.includes('playstore');
1680
- const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1681
- const imageSysDir = systemImagePackageToRelativeDir(packageName);
1682
- if (!imageSysDir) {
1683
- throw new Error(`Invalid Android system image directory for package: ${packageName}`);
1684
- }
1685
-
1686
- fs.mkdirSync(avdDir, { recursive: true });
1687
- ensureSparseFile(path.join(avdDir, 'userdata-qemu.img'), DEFAULT_DATA_PARTITION_BYTES);
1688
- fs.writeFileSync(
1689
- path.join(AVD_HOME, `${this.avdName}.ini`),
1690
- [
1691
- 'avd.ini.encoding=UTF-8',
1692
- `path=${avdDir}`,
1693
- `path.rel=avd/${this.avdName}.avd`,
1694
- `target=android-${apiLevel}`,
1695
- '',
1696
- ].join('\n')
1697
- );
1698
-
1699
- const configLines = [
1700
- 'avd.ini.encoding=UTF-8',
1701
- `AvdId=${this.avdName}`,
1702
- `avd.ini.displayname=${this.avdName}`,
1703
- `PlayStore.enabled=${playStoreEnabled}`,
1704
- `image.sysdir.1=${imageSysDir}`,
1705
- `abi.type=${abi}`,
1706
- `hw.cpu.arch=${systemImagePackageToCpuArch(packageName) || abi}`,
1707
- 'hw.cpu.ncore=2',
1708
- 'hw.dPad=no',
1709
- 'hw.gps=yes',
1710
- 'hw.gpu.enabled=yes',
1711
- `hw.gpu.mode=${emulatorGpuMode()}`,
1712
- 'hw.initialOrientation=Portrait',
1713
- 'hw.keyboard=yes',
1714
- 'hw.lcd.density=440',
1715
- 'hw.lcd.height=1920',
1716
- 'hw.lcd.width=1080',
1717
- 'hw.mainKeys=no',
1718
- `hw.ramSize=${DEFAULT_RAM_SIZE_MB}`,
1719
- 'hw.sensors.orientation=yes',
1720
- 'hw.sensors.proximity=yes',
1721
- 'hw.trackBall=no',
1722
- `disk.dataPartition.size=${DEFAULT_DATA_PARTITION_BYTES}`,
1723
- `sdcard.size=${DEFAULT_SDCARD_SIZE_BYTES}`,
1724
- 'runtime.network.latency=none',
1725
- 'runtime.network.speed=full',
1726
- 'vm.heapSize=256',
1727
- `tag.display=${tagDisplay}`,
1728
- `tag.id=${tagId}`,
1729
- '',
1730
- ];
1731
- fs.writeFileSync(path.join(avdDir, 'config.ini'), configLines.join('\n'));
1732
-
1733
- const systemImageRoot = path.join(activeAndroidSdkRoot(), ...String(packageName).split(';').filter(Boolean));
1734
- const userdataImage = path.join(systemImageRoot, 'userdata.img');
1735
- if (fs.existsSync(userdataImage)) {
1736
- fs.copyFileSync(userdataImage, path.join(avdDir, 'userdata.img'));
1737
- }
1738
- }
1739
-
1740
1918
  #normalizeAvdConfig() {
1741
1919
  const configPath = path.join(AVD_HOME, `${this.avdName}.avd`, 'config.ini');
1742
1920
  if (!fs.existsSync(configPath)) return;
@@ -1746,43 +1924,9 @@ class AndroidController {
1746
1924
  content = updateIniValue(content, 'sdcard.size', DEFAULT_SDCARD_SIZE_BYTES);
1747
1925
  content = updateIniValue(content, 'hw.ramSize', DEFAULT_RAM_SIZE_MB);
1748
1926
  content = updateIniValue(content, 'hw.gpu.mode', emulatorGpuMode());
1749
- content = updateIniValue(content, 'PlayStore.enabled', String(this.#readState()?.systemImage || '').includes('playstore'));
1750
1927
  fs.writeFileSync(configPath, content);
1751
1928
  }
1752
1929
 
1753
- #cleanupAvdTransientState() {
1754
- const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1755
- const transientTargets = [
1756
- 'cache.img',
1757
- 'cache.img.qcow2',
1758
- 'hardware-qemu.ini.lock',
1759
- 'multiinstance.lock',
1760
- 'snapshot.lock',
1761
- 'quickbootChoice.ini',
1762
- 'launchParams.txt',
1763
- 'emu-launch-params.txt',
1764
- 'bootcompleted.ini',
1765
- 'userdata-qemu.img.lock',
1766
- 'encryptionkey.img',
1767
- ];
1768
-
1769
- for (const target of transientTargets) {
1770
- fs.rmSync(path.join(avdDir, target), { force: true, recursive: true });
1771
- }
1772
-
1773
- for (const entry of ['snapshots', '.lock']) {
1774
- fs.rmSync(path.join(avdDir, entry), { force: true, recursive: true });
1775
- }
1776
-
1777
- try {
1778
- for (const entry of fs.readdirSync(avdDir)) {
1779
- if (/\.lock$/i.test(entry) || /\.tmp$/i.test(entry)) {
1780
- fs.rmSync(path.join(avdDir, entry), { force: true, recursive: true });
1781
- }
1782
- }
1783
- } catch {}
1784
- }
1785
-
1786
1930
  async listDevices(options = {}) {
1787
1931
  if (options.ensureBootstrapped !== false) {
1788
1932
  await this.ensureBootstrapped();
@@ -1812,6 +1956,14 @@ class AndroidController {
1812
1956
  const devices = await this.listDevices(options);
1813
1957
  const owners = this.#readOwnership();
1814
1958
  const canUse = (device) => device.status === 'device' && !this.#isSerialOwnedByAnother(device.serial, owners);
1959
+ const expected = state.expectedSerial
1960
+ ? devices.find((device) => device.serial === state.expectedSerial && canUse(device))
1961
+ : null;
1962
+ if (expected) {
1963
+ this.#claimSerial(expected.serial);
1964
+ this.#appendState({ serial: expected.serial });
1965
+ return expected.serial;
1966
+ }
1815
1967
 
1816
1968
  const preferred = state.serial ? devices.find((device) => device.serial === state.serial && canUse(device)) : null;
1817
1969
  if (preferred) {
@@ -1844,15 +1996,22 @@ class AndroidController {
1844
1996
  startRequestedAt: this.#readState().startRequestedAt || new Date().toISOString(),
1845
1997
  });
1846
1998
  console.log('[Android] Preparing emulator start');
1847
- await this.#terminateStaleEmulatorProcesses([this.avdName, this.previousAvdName]).catch(() => {});
1848
- await this.ensureAvd();
1849
- this.#appendState({
1850
- starting: true,
1851
- startupPhase: 'Checking for an existing Android device',
1852
- lastStartError: null,
1853
- });
1854
- this.#normalizeAvdConfig();
1855
- const serial = await this.getPrimarySerial();
1999
+ let serial = null;
2000
+ try {
2001
+ await this.ensureBootstrapped();
2002
+ await this.#terminateStaleEmulatorProcesses([this.avdName, this.previousAvdName]).catch(() => {});
2003
+ await this.ensureAvd();
2004
+ this.#appendState({
2005
+ starting: true,
2006
+ startupPhase: 'Checking for an existing Android device',
2007
+ lastStartError: null,
2008
+ });
2009
+ this.#normalizeAvdConfig();
2010
+ serial = await this.getPrimarySerial();
2011
+ } catch (error) {
2012
+ this.markBootstrapFailure(error);
2013
+ throw error;
2014
+ }
1856
2015
  if (serial) {
1857
2016
  this.#appendState({
1858
2017
  starting: false,
@@ -1874,21 +2033,14 @@ class AndroidController {
1874
2033
  const args = [
1875
2034
  `@${this.avdName}`,
1876
2035
  '-no-boot-anim',
1877
- ...emulatorLaunchArgs(),
1878
- '-data',
1879
- path.join(AVD_HOME, `${this.avdName}.avd`, 'userdata-qemu.img'),
1880
2036
  '-gpu',
1881
2037
  emulatorGpuMode(),
1882
- '-accel',
1883
- 'auto',
1884
- '-partition-size',
1885
- String(DEFAULT_PARTITION_SIZE_MB),
1886
- '-netdelay',
1887
- 'none',
1888
- '-netspeed',
1889
- 'full',
2038
+ '-no-window',
2039
+ '-no-audio',
1890
2040
  ];
1891
2041
 
2042
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} kill-server`, { timeout: 15000 });
2043
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} start-server`, { timeout: 15000 });
1892
2044
  const child = spawn(emulatorBinary(), args, {
1893
2045
  detached: true,
1894
2046
  stdio: ['ignore', out, out],
@@ -1910,18 +2062,12 @@ class AndroidController {
1910
2062
  try {
1911
2063
  onlineSerial = await this.waitForDevice({ timeoutMs: options.timeoutMs || 600000 });
1912
2064
  } catch (error) {
1913
- const recentLogLines = tailFile(logPath, 12);
1914
- const lastLine =
1915
- recentLogLines[recentLogLines.length - 1] ||
1916
- error?.message ||
1917
- String(error || 'Android emulator did not finish booting.');
2065
+ const lastLine = selectAndroidFailureMessage(logPath, error);
1918
2066
  await this.stopEmulator().catch(() => {});
1919
- if (!options._recoveredOnce && isRecoverableEmulatorStartError(lastLine)) {
1920
- console.warn(`[Android] Recoverable emulator start failure detected. Cleaning transient AVD state and retrying once: ${lastLine}`);
1921
- this.#cleanupAvdTransientState();
1922
- return this.#startEmulatorBlocking({ ...options, _recoveredOnce: true });
1923
- }
1924
2067
  this.markBootstrapFailure(lastLine);
2068
+ if (error?.details?.logTail) {
2069
+ throw error;
2070
+ }
1925
2071
  throw new Error(lastLine);
1926
2072
  }
1927
2073
  this.#appendState({
@@ -1997,6 +2143,8 @@ class AndroidController {
1997
2143
  this.#appendState({ bootstrapWorkerPid: null });
1998
2144
  }
1999
2145
 
2146
+ await this.ensureBootstrapped();
2147
+
2000
2148
  if (!this.startPromise) {
2001
2149
  const requestedAt = new Date().toISOString();
2002
2150
  this.#appendState({
@@ -2054,14 +2202,14 @@ class AndroidController {
2054
2202
  }
2055
2203
 
2056
2204
  async waitForDevice(options = {}) {
2057
- const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 180000);
2205
+ const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 600000);
2058
2206
  const deadline = Date.now() + timeoutMs;
2059
- let reconnectCounter = 0;
2060
2207
  let missingPidSince = null;
2061
2208
  let firstOnlineAt = null;
2209
+ let lastDiagnosticAt = 0;
2062
2210
 
2063
2211
  while (Date.now() < deadline) {
2064
- const serial = await this.getPrimarySerial();
2212
+ const serial = await this.getPrimarySerial({ ensureBootstrapped: false });
2065
2213
  if (serial) {
2066
2214
  this.#assertSerialAccess(serial, { claimIfUnowned: true });
2067
2215
  if (!firstOnlineAt) {
@@ -2083,21 +2231,39 @@ class AndroidController {
2083
2231
  `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell echo ready`,
2084
2232
  { timeout: 10000 },
2085
2233
  );
2234
+ const packageServiceProbe = await this.#runAllowFailure(
2235
+ `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell service check package`,
2236
+ { timeout: 10000 },
2237
+ );
2238
+ const pmProbe = await this.#runAllowFailure(
2239
+ `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell pm path android`,
2240
+ { timeout: 10000 },
2241
+ );
2086
2242
 
2087
2243
  const bootValue = String(bootCompleted.stdout || '').trim();
2088
2244
  const devBootValue = String(devBootComplete.stdout || '').trim();
2089
2245
  const bootAnimValue = String(bootAnim.stdout || '').trim().toLowerCase();
2090
2246
  const shellReady = String(shellProbe.stdout || '').trim() === 'ready';
2247
+ const packageServiceReady = /found/i.test(String(packageServiceProbe.stdout || ''));
2248
+ const packageManagerReady = /^package:/m.test(String(pmProbe.stdout || '').trim());
2249
+ if (Date.now() - lastDiagnosticAt > 15000) {
2250
+ lastDiagnosticAt = Date.now();
2251
+ this.#appendState({
2252
+ startupPhase: 'Waiting for Android emulator to boot',
2253
+ lastLogLine: `ADB connected to ${serial}; boot=${bootValue || 'unknown'}, devBoot=${devBootValue || 'unknown'}, packageService=${packageServiceReady}, packageManager=${packageManagerReady}.`,
2254
+ });
2255
+ }
2091
2256
  if (
2092
- bootValue === '1' ||
2093
- devBootValue === '1' ||
2094
- (shellReady && (bootAnimValue === 'stopped' || bootAnimValue === ''))
2257
+ packageServiceReady
2258
+ && packageManagerReady
2259
+ && (
2260
+ bootValue === '1'
2261
+ || devBootValue === '1'
2262
+ || (shellReady && (bootAnimValue === 'stopped' || bootAnimValue === ''))
2263
+ )
2095
2264
  ) {
2096
2265
  return serial;
2097
2266
  }
2098
- if (shellReady && firstOnlineAt && Date.now() - firstOnlineAt >= 45000) {
2099
- return serial;
2100
- }
2101
2267
  missingPidSince = null;
2102
2268
  } else {
2103
2269
  firstOnlineAt = null;
@@ -2115,16 +2281,38 @@ class AndroidController {
2115
2281
  }
2116
2282
  } else {
2117
2283
  missingPidSince = null;
2284
+ const devices = await this.listDevices({ ensureBootstrapped: false }).catch(() => []);
2285
+ if (Date.now() - lastDiagnosticAt > 15000) {
2286
+ lastDiagnosticAt = Date.now();
2287
+ this.#appendState({
2288
+ startupPhase: 'Waiting for Android emulator to connect',
2289
+ lastLogLine: devices.length > 0
2290
+ ? `Emulator process running; ADB: ${devices.map((d) => `${d.serial}:${d.status}`).join(', ')}.`
2291
+ : 'Emulator running, no ADB transport yet.',
2292
+ });
2293
+ }
2118
2294
  }
2119
2295
  }
2120
- reconnectCounter += 1;
2121
- if (reconnectCounter % 5 === 0) {
2122
- await this.#runAllowFailure(`${quoteShell(adbBinary())} reconnect offline`, { timeout: 10000 });
2123
- }
2124
2296
  await sleep(3000);
2125
2297
  }
2126
2298
 
2127
- throw new Error(`Android emulator did not finish booting within ${timeoutMs} ms`);
2299
+ const state = this.#readState();
2300
+ const logTail = tailFile(state.logPath || null, 100);
2301
+ throw buildAndroidBootstrapError(
2302
+ `Android emulator did not finish booting within ${timeoutMs} ms`,
2303
+ {
2304
+ code: 'ANDROID_BOOTSTRAP_TIMEOUT',
2305
+ timeoutMs,
2306
+ logPath: state.logPath || null,
2307
+ logTail,
2308
+ state: {
2309
+ emulatorPid: state.emulatorPid || null,
2310
+ serial: state.serial || null,
2311
+ startupPhase: state.startupPhase || null,
2312
+ lastStartError: state.lastStartError || null,
2313
+ },
2314
+ }
2315
+ );
2128
2316
  }
2129
2317
 
2130
2318
  async ensureDevice() {
@@ -2184,8 +2372,17 @@ class AndroidController {
2184
2372
  return this.#run(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} ${command}`, options);
2185
2373
  }
2186
2374
 
2375
+ async #prepareScreenForCapture(serial) {
2376
+ await this.#adb(serial, 'shell input keyevent 224', { timeout: 10000 }).catch(() => {});
2377
+ await this.#adb(serial, 'shell wm dismiss-keyguard', { timeout: 10000 }).catch(() => {});
2378
+ await this.#adb(serial, 'shell input keyevent 82', { timeout: 10000 }).catch(() => {});
2379
+ await this.#adb(serial, 'shell input keyevent 3', { timeout: 10000 }).catch(() => {});
2380
+ await sleep(350);
2381
+ }
2382
+
2187
2383
  async screenshot(options = {}) {
2188
2384
  const serial = options.serial || await this.ensureDevice();
2385
+ await this.#prepareScreenForCapture(serial).catch(() => {});
2189
2386
  let artifactRecord = null;
2190
2387
  let filename = `android_${Date.now()}.png`;
2191
2388
  let fullPath = path.join(SCREENSHOTS_DIR, filename);
@@ -2203,7 +2400,42 @@ class AndroidController {
2203
2400
  fullPath = artifactRecord.storagePath;
2204
2401
  filename = path.basename(fullPath);
2205
2402
  }
2206
- await this.#adb(serial, `exec-out screencap -p > ${quoteShell(fullPath)}`, { timeout: 30000 });
2403
+ let captured = false;
2404
+ const localTmp = path.join(TMP_DIR, `shot-${Date.now()}-${Math.random().toString(16).slice(2)}.png`);
2405
+ const remoteTmp = `/sdcard/neoagent-shot-${Date.now()}.png`;
2406
+ for (let attempt = 0; attempt < 3 && !captured; attempt += 1) {
2407
+ try {
2408
+ if (attempt === 0) {
2409
+ await this.#adb(serial, `exec-out screencap -p > ${quoteShell(fullPath)}`, { timeout: 30000 });
2410
+ } else {
2411
+ await this.#adb(serial, `shell screencap -p ${quoteShell(remoteTmp)}`, { timeout: 30000 });
2412
+ await this.#adb(serial, `pull ${quoteShell(remoteTmp)} ${quoteShell(localTmp)}`, { timeout: 30000 });
2413
+ if (fs.existsSync(localTmp)) {
2414
+ fs.copyFileSync(localTmp, fullPath);
2415
+ }
2416
+ }
2417
+ const data = fs.readFileSync(fullPath);
2418
+ captured = isLikelyPng(data);
2419
+ } catch {}
2420
+ if (!captured) {
2421
+ logAndroidIssue('screenshot_attempt_failed', {
2422
+ scopeKey: this.scopeKey,
2423
+ serial,
2424
+ attempt: attempt + 1,
2425
+ });
2426
+ await sleep(500);
2427
+ }
2428
+ }
2429
+ fs.rmSync(localTmp, { force: true });
2430
+ await this.#adb(serial, `shell rm -f ${quoteShell(remoteTmp)}`, { timeout: 10000 }).catch(() => {});
2431
+ if (!captured) {
2432
+ logAndroidIssue('screenshot_capture_failed', {
2433
+ scopeKey: this.scopeKey,
2434
+ serial,
2435
+ fullPath,
2436
+ });
2437
+ throw new Error('Failed to capture a valid Android screenshot.');
2438
+ }
2207
2439
  if (artifactRecord) {
2208
2440
  this.artifactStore.finalizeFile(artifactRecord.artifactId, fullPath);
2209
2441
  }
@@ -2561,9 +2793,46 @@ class AndroidController {
2561
2793
  }
2562
2794
 
2563
2795
  async listApps(args = {}) {
2564
- const serial = await this.ensureDevice();
2796
+ let serial = null;
2797
+ try {
2798
+ serial = await this.ensureDevice();
2799
+ } catch (error) {
2800
+ logAndroidIssue('list_apps_device_not_ready', {
2801
+ scopeKey: this.scopeKey,
2802
+ message: String(error?.message || error || 'Android device is not ready.'),
2803
+ });
2804
+ return {
2805
+ success: false,
2806
+ serial: null,
2807
+ count: 0,
2808
+ packages: [],
2809
+ error: String(error?.message || 'Android device is not ready.'),
2810
+ };
2811
+ }
2565
2812
  const cmd = args.includeSystem === true ? 'shell pm list packages' : 'shell pm list packages -3';
2566
- const out = await this.#adb(serial, cmd, { timeout: 30000 });
2813
+ let out = '';
2814
+ try {
2815
+ out = await this.#adb(serial, cmd, { timeout: 30000 });
2816
+ } catch (error) {
2817
+ await sleep(1000);
2818
+ try {
2819
+ out = await this.#adb(serial, cmd, { timeout: 30000 });
2820
+ } catch (retryError) {
2821
+ logAndroidIssue('list_apps_command_failed', {
2822
+ scopeKey: this.scopeKey,
2823
+ serial,
2824
+ command: cmd,
2825
+ message: String(retryError?.message || error?.message || 'Failed to list Android apps.'),
2826
+ });
2827
+ return {
2828
+ success: false,
2829
+ serial,
2830
+ count: 0,
2831
+ packages: [],
2832
+ error: String(retryError?.message || error?.message || 'Failed to list Android apps.'),
2833
+ };
2834
+ }
2835
+ }
2567
2836
  const packages = out
2568
2837
  .split('\n')
2569
2838
  .map((line) => line.trim())
@@ -2642,6 +2911,8 @@ class AndroidController {
2642
2911
 
2643
2912
  async getStatus() {
2644
2913
  const state = this.#readState();
2914
+ const bootstrapWorkerPid = Number(state.bootstrapWorkerPid || 0) || null;
2915
+ const bootstrapWorkerAlive = isProcessAlive(bootstrapWorkerPid);
2645
2916
  const devices = isExecutable(adbBinary())
2646
2917
  ? await this.listDevices({ ensureBootstrapped: false }).catch(() => [])
2647
2918
  : [];
@@ -2674,8 +2945,10 @@ class AndroidController {
2674
2945
  }
2675
2946
  return {
2676
2947
  bootstrapped: state.bootstrapped === true,
2677
- starting: state.starting === true || this.startPromise != null,
2678
- startupPhase: state.startupPhase || null,
2948
+ starting: state.starting === true || this.startPromise != null || bootstrapWorkerAlive,
2949
+ startupPhase: (state.starting === true || this.startPromise != null || bootstrapWorkerAlive)
2950
+ ? (state.startupPhase || (bootstrapWorkerAlive ? 'Preparing Android runtime' : null))
2951
+ : null,
2679
2952
  startRequestedAt: state.startRequestedAt || null,
2680
2953
  lastStartError: state.lastStartError || null,
2681
2954
  sdkRoot: activeAndroidSdkRoot(),
@@ -2686,7 +2959,7 @@ class AndroidController {
2686
2959
  serial: state.serial,
2687
2960
  serialOwnedByCurrentUser,
2688
2961
  emulatorPid: state.emulatorPid,
2689
- bootstrapWorkerPid: Number(state.bootstrapWorkerPid || 0) || null,
2962
+ bootstrapWorkerPid,
2690
2963
  systemImage: state.systemImage || null,
2691
2964
  systemImageArch: state.systemImageArch || null,
2692
2965
  preferredSystemImageArchs: systemImageArchCandidates(),