neoagent 2.3.1-beta.89 → 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,
@@ -59,6 +61,9 @@ function ensureEmulatorAdvancedFeaturesFile() {
59
61
  [
60
62
  'QuickbootFileBacked=off',
61
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',
62
67
  '',
63
68
  ].join('\n'),
64
69
  'utf8',
@@ -207,6 +212,40 @@ function tailFile(filePath, maxLines = 40) {
207
212
  }
208
213
  }
209
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
+
210
249
  function isLikelyPng(buffer) {
211
250
  return Buffer.isBuffer(buffer)
212
251
  && buffer.length > 24
@@ -216,6 +255,58 @@ function isLikelyPng(buffer) {
216
255
  && buffer[3] === 0x47;
217
256
  }
218
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
+
219
310
  function commandExists(command) {
220
311
  const probe = spawnSync('bash', ['-lc', `command -v "${command}"`], { encoding: 'utf8' });
221
312
  return probe.status === 0;
@@ -284,38 +375,14 @@ function emulatorHostArch() {
284
375
  }
285
376
 
286
377
  function emulatorGpuMode() {
287
- if (process.platform === 'darwin' && process.arch === 'arm64') {
288
- return 'swiftshader_indirect';
289
- }
290
- if (process.platform === 'linux' && process.arch === 'arm64') {
291
- return 'swiftshader_indirect';
378
+ if (process.platform === 'darwin') {
379
+ return 'host';
292
380
  }
293
381
  return 'auto';
294
382
  }
295
383
 
296
- function emulatorLaunchArgs() {
297
- return [
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',
306
- ];
307
- }
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
- );
384
+ function logAndroidIssue(event, details = {}) {
385
+ console.warn(`[Android][${event}]`, details);
319
386
  }
320
387
 
321
388
  function parseCsvEnv(value) {
@@ -415,6 +482,7 @@ function sdkEnv() {
415
482
  ANDROID_USER_HOME: EMULATOR_HOME,
416
483
  ANDROID_AVD_HOME: AVD_HOME,
417
484
  AVD_HOME,
485
+ ADB_VENDOR_KEYS: EMULATOR_HOME,
418
486
  JAVA_TOOL_OPTIONS: process.env.JAVA_TOOL_OPTIONS || '-Xint',
419
487
  };
420
488
  const pathParts = [
@@ -752,7 +820,11 @@ function parseRepositorySystemImages(xml) {
752
820
  return parseSystemImageCandidates(matches);
753
821
  }
754
822
 
755
- 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/';
756
828
  const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="${packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}">([\\s\\S]*?)<\\/remotePackage>`));
757
829
  if (!packageMatch) throw new Error(`Could not locate ${packageName} in Android repository metadata`);
758
830
 
@@ -762,13 +834,52 @@ function parseLatestSystemImageUrl(xml, packageName) {
762
834
  if (urlMatch) {
763
835
  const urlPart = urlMatch[1];
764
836
  if (urlPart.startsWith('http')) return urlPart;
765
- return `https://dl.google.com/android/repository/sys-img/android/${urlPart}`;
837
+ return `${baseUrl}${urlPart}`;
766
838
  }
767
839
  }
768
840
 
769
841
  throw new Error(`Could not find a system image archive for ${packageName}`);
770
842
  }
771
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
+
772
883
  async function fetchEmulatorMetadata() {
773
884
  const urls = [
774
885
  'https://dl.google.com/android/repository/repository2-3.xml',
@@ -831,15 +942,43 @@ function shouldInstallPlatformToolsArchive() {
831
942
  }
832
943
 
833
944
  async function installSystemImageArchive(metadata, packageName) {
834
- 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);
835
958
  const zipPath = path.join(TMP_DIR, path.basename(url));
836
959
  const targetRoot = path.join(activeAndroidSdkRoot(), ...String(packageName).split(';').filter(Boolean));
837
960
  const extractDir = fs.mkdtempSync(path.join(TMP_DIR, 'system-image-'));
838
961
 
839
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
+ }
840
972
  fs.rmSync(targetRoot, { recursive: true, force: true });
841
973
  fs.mkdirSync(path.dirname(targetRoot), { recursive: true });
842
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
+ }
843
982
  extractZip(zipPath, extractDir);
844
983
 
845
984
  const extractedRoot = findDirectoryContainingFiles(extractDir, ['userdata.img']) ||
@@ -853,6 +992,13 @@ async function installSystemImageArchive(metadata, packageName) {
853
992
  try {
854
993
  fs.renameSync(extractedRoot, targetRoot);
855
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
+ }
856
1002
  fs.cpSync(extractedRoot, targetRoot, { recursive: true, force: true });
857
1003
  if (renameErr) {
858
1004
  console.warn(`[Android] Falling back to copy for ${packageName}: ${renameErr.message}`);
@@ -964,11 +1110,59 @@ function chooseStableRuntimeSystemImage(candidates, currentPackage) {
964
1110
  return recommended;
965
1111
  }
966
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
+
967
1160
  function rankSystemImagePool(pool) {
968
1161
  const preferredMatches = pool.filter((candidate) => candidate.tagScore > 0);
969
1162
  const rankedPool = preferredMatches.length > 0 ? preferredMatches : pool;
970
1163
 
971
1164
  rankedPool.sort((a, b) =>
1165
+ runtimeSystemImagePreferenceRank(a) - runtimeSystemImagePreferenceRank(b) ||
972
1166
  Number(b.stable) - Number(a.stable) ||
973
1167
  b.tagScore - a.tagScore ||
974
1168
  b.apiLevel - a.apiLevel ||
@@ -1085,12 +1279,24 @@ function isValidInstalledSystemImage(packageName) {
1085
1279
  const relativeDir = systemImagePackageToRelativeDir(packageName);
1086
1280
  if (!relativeDir) return false;
1087
1281
  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));
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);
1094
1300
  }
1095
1301
 
1096
1302
  function systemImagePackageToAbi(packageName) {
@@ -1371,7 +1577,30 @@ class AndroidController {
1371
1577
  cwd: options.cwd || ANDROID_ROOT,
1372
1578
  });
1373
1579
  if (result.exitCode !== 0) {
1374
- 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;
1375
1604
  }
1376
1605
  return result.stdout || '';
1377
1606
  }
@@ -1385,6 +1614,11 @@ class AndroidController {
1385
1614
  }
1386
1615
 
1387
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
+ });
1388
1622
  const desiredArch = systemImageArch();
1389
1623
  const state = this.#readState();
1390
1624
  const installedImages = parseInstalledSystemImages();
@@ -1392,13 +1626,22 @@ class AndroidController {
1392
1626
  chooseConfiguredSystemImage(installedImages) ||
1393
1627
  chooseLatestSystemImage(installedImages, [desiredArch]) ||
1394
1628
  chooseLatestSystemImage(installedImages);
1395
- const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
1396
- const available = parseRepositorySystemImages(systemImageMetadata);
1629
+ const systemImageMetadata = await fetchSystemImageRepositories();
1630
+ const available = parseRepositorySystemImagesFromSources(systemImageMetadata);
1397
1631
  const preferredAvailable =
1398
1632
  chooseConfiguredSystemImage(available) ||
1633
+ preferredRuntimeSystemImageCandidate(available) ||
1399
1634
  chooseLatestSystemImage(available, [desiredArch]) ||
1400
1635
  chooseLatestSystemImage(available);
1401
- 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);
1402
1645
  const stateApiLevel = Number(state.apiLevel || 0) || 0;
1403
1646
  const legacyLinuxArm64Image =
1404
1647
  process.platform === 'linux'
@@ -1465,6 +1708,10 @@ class AndroidController {
1465
1708
  isExecutable(emulatorBinary()) &&
1466
1709
  installedEmulatorMatchesHost();
1467
1710
  if (!binariesReady) {
1711
+ this.#appendState({
1712
+ startupPhase: 'Installing Android tools',
1713
+ lastLogLine: 'Installing Android platform tools and emulator.',
1714
+ });
1468
1715
  if (this.bootstrapPromise) {
1469
1716
  await this.bootstrapPromise;
1470
1717
  } else {
@@ -1532,13 +1779,25 @@ class AndroidController {
1532
1779
  this.#appendState({ bootstrapped: true });
1533
1780
  const metadata = await fetchEmulatorMetadata();
1534
1781
  if (shouldInstallPlatformToolsArchive()) {
1782
+ this.#appendState({
1783
+ startupPhase: 'Installing Android platform tools',
1784
+ lastLogLine: 'Installing Android platform tools.',
1785
+ });
1535
1786
  await installPlatformToolsArchive(metadata);
1536
1787
  }
1788
+ this.#appendState({
1789
+ startupPhase: 'Installing Android emulator',
1790
+ lastLogLine: 'Installing or updating the Android emulator.',
1791
+ });
1537
1792
  await installEmulatorArchive(metadata);
1538
1793
  if (!effectiveSelectedImage) {
1539
1794
  throw new Error(formatSystemImageError(available));
1540
1795
  }
1541
1796
  if (effectiveSelectedImage?.packageName) {
1797
+ this.#appendState({
1798
+ startupPhase: 'Installing Android system image',
1799
+ lastLogLine: `Installing ${effectiveSelectedImage.packageName}.`,
1800
+ });
1542
1801
  await installSystemImageArchive(systemImageMetadata, effectiveSelectedImage.packageName);
1543
1802
  }
1544
1803
  this.#appendState({
@@ -1579,8 +1838,8 @@ class AndroidController {
1579
1838
  }
1580
1839
  await installEmulatorArchive(metadata);
1581
1840
 
1582
- const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
1583
- const available = parseRepositorySystemImages(systemImageMetadata);
1841
+ const systemImageMetadata = await fetchSystemImageRepositories();
1842
+ const available = parseRepositorySystemImagesFromSources(systemImageMetadata);
1584
1843
  const systemImage = chooseConfiguredSystemImage(available) || chooseLatestSystemImage(available);
1585
1844
  if (!systemImage) throw new Error(formatSystemImageError(available));
1586
1845
 
@@ -1599,8 +1858,7 @@ class AndroidController {
1599
1858
 
1600
1859
  markBootstrapFailure(error) {
1601
1860
  const state = this.#readState();
1602
- const recentLogLines = state.logPath ? tailFile(state.logPath, 12) : [];
1603
- const detailedMessage = recentLogLines[recentLogLines.length - 1] || error?.message || String(error || 'Android bootstrap failed.');
1861
+ const detailedMessage = selectAndroidFailureMessage(state.logPath, error);
1604
1862
  this.#appendState({
1605
1863
  starting: false,
1606
1864
  startupPhase: 'Start failed',
@@ -1608,6 +1866,12 @@ class AndroidController {
1608
1866
  lastLogLine: detailedMessage,
1609
1867
  bootstrapWorkerPid: null,
1610
1868
  });
1869
+ logAndroidIssue('bootstrap_failure', {
1870
+ scopeKey: this.scopeKey,
1871
+ avdName: this.avdName,
1872
+ message: detailedMessage,
1873
+ logPath: state.logPath || null,
1874
+ });
1611
1875
  return detailedMessage;
1612
1876
  }
1613
1877
 
@@ -1617,172 +1881,40 @@ class AndroidController {
1617
1881
  const state = this.#readState();
1618
1882
  let pkg = state.systemImage;
1619
1883
  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
- }
1634
1884
  const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1635
1885
  const configPath = path.join(avdDir, 'config.ini');
1636
1886
  const avdExists = fs.existsSync(configPath);
1637
- let avdNeedsRecreate = avdExists && (!state.avdSystemImage || state.avdSystemImage !== pkg);
1638
- const avdRecreateReasons = [];
1639
- if (avdNeedsRecreate && state.avdSystemImage !== pkg) {
1640
- avdRecreateReasons.push(`systemImage: ${state.avdSystemImage || 'null'} -> ${pkg}`);
1641
- }
1642
- if (avdExists && fs.existsSync(configPath)) {
1643
- try {
1644
- const config = fs.readFileSync(configPath, 'utf8');
1645
- const currentImageDir = readIniValue(config, 'image.sysdir.1');
1646
- const expectedImageDir = systemImagePackageToRelativeDir(pkg);
1647
- const currentAbi = readIniValue(config, 'abi.type');
1648
- const expectedAbi = systemImagePackageToAbi(pkg);
1649
- const currentCpuArch = readIniValue(config, 'hw.cpu.arch');
1650
- const expectedCpuArch = systemImagePackageToCpuArch(pkg);
1651
- const currentDataPartitionSize = readIniValue(config, 'disk.dataPartition.size');
1652
- const expectedDataPartitionSize = String(DEFAULT_DATA_PARTITION_BYTES);
1653
- const currentSdcardSize = readIniValue(config, 'sdcard.size');
1654
- const expectedSdcardSize = String(DEFAULT_SDCARD_SIZE_BYTES);
1655
- const currentRamSize = readIniValue(config, 'hw.ramSize');
1656
- const expectedRamSize = String(DEFAULT_RAM_SIZE_MB);
1657
- const currentGpuMode = readIniValue(config, 'hw.gpu.mode');
1658
- const expectedGpuMode = emulatorGpuMode();
1659
- const currentPlayStoreEnabled = readIniValue(config, 'PlayStore.enabled');
1660
- const expectedPlayStoreEnabled = String(String(pkg || '').includes('playstore'));
1661
- if (expectedImageDir && currentImageDir && currentImageDir !== expectedImageDir) {
1662
- avdNeedsRecreate = true;
1663
- avdRecreateReasons.push(`image.sysdir.1: ${currentImageDir} -> ${expectedImageDir}`);
1664
- }
1665
- if (expectedAbi && currentAbi && currentAbi !== expectedAbi) {
1666
- avdNeedsRecreate = true;
1667
- avdRecreateReasons.push(`abi.type: ${currentAbi} -> ${expectedAbi}`);
1668
- }
1669
- if (expectedCpuArch && currentCpuArch && currentCpuArch !== expectedCpuArch) {
1670
- avdNeedsRecreate = true;
1671
- avdRecreateReasons.push(`hw.cpu.arch: ${currentCpuArch} -> ${expectedCpuArch}`);
1672
- }
1673
- if (currentDataPartitionSize && currentDataPartitionSize !== expectedDataPartitionSize) {
1674
- avdNeedsRecreate = true;
1675
- avdRecreateReasons.push(`disk.dataPartition.size: ${currentDataPartitionSize} -> ${expectedDataPartitionSize}`);
1676
- }
1677
- if (currentSdcardSize && currentSdcardSize !== expectedSdcardSize) {
1678
- avdNeedsRecreate = true;
1679
- avdRecreateReasons.push(`sdcard.size: ${currentSdcardSize} -> ${expectedSdcardSize}`);
1680
- }
1681
- if (currentRamSize && currentRamSize !== expectedRamSize) {
1682
- avdNeedsRecreate = true;
1683
- avdRecreateReasons.push(`hw.ramSize: ${currentRamSize} -> ${expectedRamSize}`);
1684
- }
1685
- if (currentGpuMode && currentGpuMode !== expectedGpuMode) {
1686
- avdNeedsRecreate = true;
1687
- avdRecreateReasons.push(`hw.gpu.mode: ${currentGpuMode} -> ${expectedGpuMode}`);
1688
- }
1689
- if (currentPlayStoreEnabled && currentPlayStoreEnabled !== expectedPlayStoreEnabled) {
1690
- avdNeedsRecreate = true;
1691
- avdRecreateReasons.push(`PlayStore.enabled: ${currentPlayStoreEnabled} -> ${expectedPlayStoreEnabled}`);
1692
- }
1693
- } catch {}
1887
+ if (avdExists && state.avdSystemImage === pkg) {
1888
+ return;
1694
1889
  }
1695
1890
 
1696
- if (avdNeedsRecreate) {
1697
- if (avdRecreateReasons.length > 0) {
1698
- 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;
1699
1912
  }
1700
- await this.stopEmulator().catch(() => {});
1701
- fs.rmSync(avdDir, { recursive: true, force: true });
1702
- fs.rmSync(path.join(AVD_HOME, `${this.avdName}.ini`), { force: true });
1703
- fs.rmSync(path.join(avdDir, 'userdata-qemu.img'), { force: true });
1704
- } else if (avdExists) {
1705
- ensureSparseFile(path.join(avdDir, 'userdata-qemu.img'), DEFAULT_DATA_PARTITION_BYTES);
1706
- return;
1707
1913
  }
1708
-
1709
- this.#writeAvdFiles(pkg);
1710
1914
  this.#normalizeAvdConfig();
1711
1915
  this.#appendState({ avdSystemImage: pkg });
1712
1916
  }
1713
1917
 
1714
- #writeAvdFiles(packageName) {
1715
- const parts = String(packageName || '').split(';').filter(Boolean);
1716
- if (parts.length !== 4 || parts[0] !== 'system-images') {
1717
- throw new Error(`Invalid Android system image package: ${packageName}`);
1718
- }
1719
- const apiLevel = parts[1].replace(/^android-/, '');
1720
- const tagId = parts[2];
1721
- const tagDisplay = tagId === 'google_apis' ? 'Google APIs' : tagId.replace(/_/g, ' ');
1722
- const abi = parts[3];
1723
- const playStoreEnabled = tagId.includes('playstore');
1724
- const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1725
- const imageSysDir = systemImagePackageToRelativeDir(packageName);
1726
- if (!imageSysDir) {
1727
- throw new Error(`Invalid Android system image directory for package: ${packageName}`);
1728
- }
1729
-
1730
- fs.mkdirSync(avdDir, { recursive: true });
1731
- ensureSparseFile(path.join(avdDir, 'userdata-qemu.img'), DEFAULT_DATA_PARTITION_BYTES);
1732
- fs.writeFileSync(
1733
- path.join(AVD_HOME, `${this.avdName}.ini`),
1734
- [
1735
- 'avd.ini.encoding=UTF-8',
1736
- `path=${avdDir}`,
1737
- `path.rel=avd/${this.avdName}.avd`,
1738
- `target=android-${apiLevel}`,
1739
- '',
1740
- ].join('\n')
1741
- );
1742
-
1743
- const configLines = [
1744
- 'avd.ini.encoding=UTF-8',
1745
- `AvdId=${this.avdName}`,
1746
- `avd.ini.displayname=${this.avdName}`,
1747
- `PlayStore.enabled=${playStoreEnabled}`,
1748
- `image.sysdir.1=${imageSysDir}`,
1749
- `abi.type=${abi}`,
1750
- `hw.cpu.arch=${systemImagePackageToCpuArch(packageName) || abi}`,
1751
- 'hw.cpu.ncore=2',
1752
- 'hw.dPad=no',
1753
- 'hw.gps=yes',
1754
- 'hw.gpu.enabled=yes',
1755
- `hw.gpu.mode=${emulatorGpuMode()}`,
1756
- 'hw.initialOrientation=Portrait',
1757
- 'hw.keyboard=yes',
1758
- 'hw.lcd.density=440',
1759
- 'hw.lcd.height=1920',
1760
- 'hw.lcd.width=1080',
1761
- 'hw.mainKeys=no',
1762
- `hw.ramSize=${DEFAULT_RAM_SIZE_MB}`,
1763
- 'hw.sensors.orientation=yes',
1764
- 'hw.sensors.proximity=yes',
1765
- 'hw.trackBall=no',
1766
- `disk.dataPartition.size=${DEFAULT_DATA_PARTITION_BYTES}`,
1767
- `sdcard.size=${DEFAULT_SDCARD_SIZE_BYTES}`,
1768
- 'runtime.network.latency=none',
1769
- 'runtime.network.speed=full',
1770
- 'fastboot.forceColdBoot=yes',
1771
- 'fastboot.forceFastBoot=no',
1772
- 'vm.heapSize=256',
1773
- `tag.display=${tagDisplay}`,
1774
- `tag.id=${tagId}`,
1775
- '',
1776
- ];
1777
- fs.writeFileSync(path.join(avdDir, 'config.ini'), configLines.join('\n'));
1778
-
1779
- const systemImageRoot = path.join(activeAndroidSdkRoot(), ...String(packageName).split(';').filter(Boolean));
1780
- const userdataImage = path.join(systemImageRoot, 'userdata.img');
1781
- if (fs.existsSync(userdataImage)) {
1782
- fs.copyFileSync(userdataImage, path.join(avdDir, 'userdata.img'));
1783
- }
1784
- }
1785
-
1786
1918
  #normalizeAvdConfig() {
1787
1919
  const configPath = path.join(AVD_HOME, `${this.avdName}.avd`, 'config.ini');
1788
1920
  if (!fs.existsSync(configPath)) return;
@@ -1792,60 +1924,9 @@ class AndroidController {
1792
1924
  content = updateIniValue(content, 'sdcard.size', DEFAULT_SDCARD_SIZE_BYTES);
1793
1925
  content = updateIniValue(content, 'hw.ramSize', DEFAULT_RAM_SIZE_MB);
1794
1926
  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'));
1798
1927
  fs.writeFileSync(configPath, content);
1799
1928
  }
1800
1929
 
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
-
1849
1930
  async listDevices(options = {}) {
1850
1931
  if (options.ensureBootstrapped !== false) {
1851
1932
  await this.ensureBootstrapped();
@@ -1875,6 +1956,14 @@ class AndroidController {
1875
1956
  const devices = await this.listDevices(options);
1876
1957
  const owners = this.#readOwnership();
1877
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
+ }
1878
1967
 
1879
1968
  const preferred = state.serial ? devices.find((device) => device.serial === state.serial && canUse(device)) : null;
1880
1969
  if (preferred) {
@@ -1907,15 +1996,22 @@ class AndroidController {
1907
1996
  startRequestedAt: this.#readState().startRequestedAt || new Date().toISOString(),
1908
1997
  });
1909
1998
  console.log('[Android] Preparing emulator start');
1910
- await this.#terminateStaleEmulatorProcesses([this.avdName, this.previousAvdName]).catch(() => {});
1911
- await this.ensureAvd();
1912
- this.#appendState({
1913
- starting: true,
1914
- startupPhase: 'Checking for an existing Android device',
1915
- lastStartError: null,
1916
- });
1917
- this.#normalizeAvdConfig();
1918
- 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
+ }
1919
2015
  if (serial) {
1920
2016
  this.#appendState({
1921
2017
  starting: false,
@@ -1937,21 +2033,14 @@ class AndroidController {
1937
2033
  const args = [
1938
2034
  `@${this.avdName}`,
1939
2035
  '-no-boot-anim',
1940
- ...emulatorLaunchArgs(),
1941
- '-data',
1942
- path.join(AVD_HOME, `${this.avdName}.avd`, 'userdata-qemu.img'),
1943
2036
  '-gpu',
1944
2037
  emulatorGpuMode(),
1945
- '-accel',
1946
- 'auto',
1947
- '-partition-size',
1948
- String(DEFAULT_PARTITION_SIZE_MB),
1949
- '-netdelay',
1950
- 'none',
1951
- '-netspeed',
1952
- 'full',
2038
+ '-no-window',
2039
+ '-no-audio',
1953
2040
  ];
1954
2041
 
2042
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} kill-server`, { timeout: 15000 });
2043
+ await this.#runAllowFailure(`${quoteShell(adbBinary())} start-server`, { timeout: 15000 });
1955
2044
  const child = spawn(emulatorBinary(), args, {
1956
2045
  detached: true,
1957
2046
  stdio: ['ignore', out, out],
@@ -1973,23 +2062,12 @@ class AndroidController {
1973
2062
  try {
1974
2063
  onlineSerial = await this.waitForDevice({ timeoutMs: options.timeoutMs || 600000 });
1975
2064
  } catch (error) {
1976
- const recentLogLines = tailFile(logPath, 12);
1977
- const lastLine =
1978
- recentLogLines[recentLogLines.length - 1] ||
1979
- error?.message ||
1980
- String(error || 'Android emulator did not finish booting.');
2065
+ const lastLine = selectAndroidFailureMessage(logPath, error);
1981
2066
  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
- }
1992
2067
  this.markBootstrapFailure(lastLine);
2068
+ if (error?.details?.logTail) {
2069
+ throw error;
2070
+ }
1993
2071
  throw new Error(lastLine);
1994
2072
  }
1995
2073
  this.#appendState({
@@ -2065,6 +2143,8 @@ class AndroidController {
2065
2143
  this.#appendState({ bootstrapWorkerPid: null });
2066
2144
  }
2067
2145
 
2146
+ await this.ensureBootstrapped();
2147
+
2068
2148
  if (!this.startPromise) {
2069
2149
  const requestedAt = new Date().toISOString();
2070
2150
  this.#appendState({
@@ -2122,15 +2202,14 @@ class AndroidController {
2122
2202
  }
2123
2203
 
2124
2204
  async waitForDevice(options = {}) {
2125
- const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 180000);
2205
+ const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 600000);
2126
2206
  const deadline = Date.now() + timeoutMs;
2127
- let reconnectCounter = 0;
2128
2207
  let missingPidSince = null;
2129
2208
  let firstOnlineAt = null;
2130
- let offlineSince = null;
2209
+ let lastDiagnosticAt = 0;
2131
2210
 
2132
2211
  while (Date.now() < deadline) {
2133
- const serial = await this.getPrimarySerial();
2212
+ const serial = await this.getPrimarySerial({ ensureBootstrapped: false });
2134
2213
  if (serial) {
2135
2214
  this.#assertSerialAccess(serial, { claimIfUnowned: true });
2136
2215
  if (!firstOnlineAt) {
@@ -2167,6 +2246,13 @@ class AndroidController {
2167
2246
  const shellReady = String(shellProbe.stdout || '').trim() === 'ready';
2168
2247
  const packageServiceReady = /found/i.test(String(packageServiceProbe.stdout || ''));
2169
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
+ }
2170
2256
  if (
2171
2257
  packageServiceReady
2172
2258
  && packageManagerReady
@@ -2178,18 +2264,7 @@ class AndroidController {
2178
2264
  ) {
2179
2265
  return serial;
2180
2266
  }
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
2267
  missingPidSince = null;
2192
- offlineSince = null;
2193
2268
  } else {
2194
2269
  firstOnlineAt = null;
2195
2270
  const state = this.#readState();
@@ -2207,32 +2282,37 @@ class AndroidController {
2207
2282
  } else {
2208
2283
  missingPidSince = null;
2209
2284
  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;
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
+ });
2225
2293
  }
2226
2294
  }
2227
2295
  }
2228
- reconnectCounter += 1;
2229
- if (reconnectCounter % 5 === 0) {
2230
- await this.#runAllowFailure(`${quoteShell(adbBinary())} reconnect offline`, { timeout: 10000 });
2231
- }
2232
2296
  await sleep(3000);
2233
2297
  }
2234
2298
 
2235
- 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
+ );
2236
2316
  }
2237
2317
 
2238
2318
  async ensureDevice() {
@@ -2338,12 +2418,22 @@ class AndroidController {
2338
2418
  captured = isLikelyPng(data);
2339
2419
  } catch {}
2340
2420
  if (!captured) {
2421
+ logAndroidIssue('screenshot_attempt_failed', {
2422
+ scopeKey: this.scopeKey,
2423
+ serial,
2424
+ attempt: attempt + 1,
2425
+ });
2341
2426
  await sleep(500);
2342
2427
  }
2343
2428
  }
2344
2429
  fs.rmSync(localTmp, { force: true });
2345
2430
  await this.#adb(serial, `shell rm -f ${quoteShell(remoteTmp)}`, { timeout: 10000 }).catch(() => {});
2346
2431
  if (!captured) {
2432
+ logAndroidIssue('screenshot_capture_failed', {
2433
+ scopeKey: this.scopeKey,
2434
+ serial,
2435
+ fullPath,
2436
+ });
2347
2437
  throw new Error('Failed to capture a valid Android screenshot.');
2348
2438
  }
2349
2439
  if (artifactRecord) {
@@ -2707,6 +2797,10 @@ class AndroidController {
2707
2797
  try {
2708
2798
  serial = await this.ensureDevice();
2709
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
+ });
2710
2804
  return {
2711
2805
  success: false,
2712
2806
  serial: null,
@@ -2724,6 +2818,12 @@ class AndroidController {
2724
2818
  try {
2725
2819
  out = await this.#adb(serial, cmd, { timeout: 30000 });
2726
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
+ });
2727
2827
  return {
2728
2828
  success: false,
2729
2829
  serial,
@@ -2846,7 +2946,9 @@ class AndroidController {
2846
2946
  return {
2847
2947
  bootstrapped: state.bootstrapped === true,
2848
2948
  starting: state.starting === true || this.startPromise != null || bootstrapWorkerAlive,
2849
- startupPhase: state.startupPhase || (bootstrapWorkerAlive ? 'Preparing Android runtime' : null),
2949
+ startupPhase: (state.starting === true || this.startPromise != null || bootstrapWorkerAlive)
2950
+ ? (state.startupPhase || (bootstrapWorkerAlive ? 'Preparing Android runtime' : null))
2951
+ : null,
2850
2952
  startRequestedAt: state.startRequestedAt || null,
2851
2953
  lastStartError: state.lastStartError || null,
2852
2954
  sdkRoot: activeAndroidSdkRoot(),