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.
- package/README.md +16 -7
- package/flutter_app/lib/features/location/location_service.dart +2 -4
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_app_shell.dart +11 -11
- package/flutter_app/lib/main_chat.dart +46 -42
- package/flutter_app/lib/main_controller.dart +1 -1
- package/flutter_app/lib/main_devices.dart +10 -1
- package/flutter_app/lib/main_integrations.dart +3 -3
- package/flutter_app/lib/main_spacing.dart +18 -0
- package/flutter_app/lib/main_theme.dart +9 -0
- package/flutter_app/lib/main_unified.dart +3 -3
- package/flutter_app/web/index.html +0 -1
- package/lib/manager.js +33 -0
- package/package.json +1 -1
- package/server/db/database.js +74 -16
- package/server/guest_agent.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/index.html +0 -1
- package/server/public/main.dart.js +11977 -11964
- package/server/services/android/android_bootstrap_worker.js +18 -3
- package/server/services/android/controller.js +610 -337
- package/server/services/runtime/backends/local-vm.js +39 -9
|
@@ -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 =
|
|
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
|
-
|
|
57
|
-
|
|
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'
|
|
272
|
-
return '
|
|
378
|
+
if (process.platform === 'darwin') {
|
|
379
|
+
return 'host';
|
|
273
380
|
}
|
|
274
381
|
return 'auto';
|
|
275
382
|
}
|
|
276
383
|
|
|
277
|
-
function
|
|
278
|
-
|
|
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 =
|
|
529
|
+
const SHARED_ANDROID_SDK_ROOT = null;
|
|
441
530
|
|
|
442
531
|
function activeAndroidSdkRoot() {
|
|
443
|
-
return
|
|
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(
|
|
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
|
|
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
|
-
|
|
823
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1383
|
-
const available =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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 (
|
|
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:
|
|
1413
|
-
apiLevel:
|
|
1414
|
-
systemImageArch:
|
|
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:
|
|
1427
|
-
apiLevel:
|
|
1428
|
-
systemImageArch:
|
|
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 &&
|
|
1730
|
+
if (!shouldForceSdkRefresh() && !legacyLinuxArm64Image && !selectedImageInvalid) {
|
|
1731
|
+
if (!runtimeNeedsRefresh && effectiveSelectedImage && effectiveSelectedImage === preferredInstalled) {
|
|
1456
1732
|
const stateNeedsRefresh =
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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:
|
|
1472
|
-
apiLevel:
|
|
1473
|
-
systemImageArch:
|
|
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:
|
|
1486
|
-
apiLevel:
|
|
1487
|
-
systemImageArch:
|
|
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
|
-
|
|
1497
|
-
|
|
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 (!
|
|
1793
|
+
if (!effectiveSelectedImage) {
|
|
1510
1794
|
throw new Error(formatSystemImageError(available));
|
|
1511
1795
|
}
|
|
1512
|
-
if (
|
|
1513
|
-
|
|
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:
|
|
1519
|
-
apiLevel:
|
|
1520
|
-
systemImageArch:
|
|
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
|
|
1553
|
-
const available =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1594
|
-
|
|
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
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
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
|
-
'-
|
|
1883
|
-
'
|
|
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
|
|
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) ||
|
|
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
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
2962
|
+
bootstrapWorkerPid,
|
|
2690
2963
|
systemImage: state.systemImage || null,
|
|
2691
2964
|
systemImageArch: state.systemImageArch || null,
|
|
2692
2965
|
preferredSystemImageArchs: systemImageArchCandidates(),
|