neoagent 2.3.1-beta.89 → 2.3.1-beta.91

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.
Files changed (31) hide show
  1. package/.env.example +4 -0
  2. package/README.md +16 -7
  3. package/flutter_app/lib/features/location/location_service.dart +2 -4
  4. package/flutter_app/lib/main.dart +1 -0
  5. package/flutter_app/lib/main_app_shell.dart +17 -15
  6. package/flutter_app/lib/main_chat.dart +46 -42
  7. package/flutter_app/lib/main_controller.dart +6 -1
  8. package/flutter_app/lib/main_devices.dart +86 -742
  9. package/flutter_app/lib/main_integrations.dart +3 -3
  10. package/flutter_app/lib/main_settings.dart +50 -0
  11. package/flutter_app/lib/main_spacing.dart +18 -0
  12. package/flutter_app/lib/main_theme.dart +9 -0
  13. package/flutter_app/lib/main_unified.dart +3 -3
  14. package/lib/manager.js +33 -0
  15. package/package.json +1 -1
  16. package/server/db/database.js +74 -16
  17. package/server/guest_agent.js +1 -0
  18. package/server/public/.last_build_id +1 -1
  19. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  20. package/server/public/flutter_bootstrap.js +1 -1
  21. package/server/public/main.dart.js +50396 -50271
  22. package/server/services/ai/capabilityHealth.js +2 -3
  23. package/server/services/android/android_bootstrap_worker.js +18 -3
  24. package/server/services/android/controller.js +460 -2753
  25. package/server/services/runtime/backends/local-vm.js +33 -145
  26. package/server/services/runtime/docker-vm-manager.js +392 -0
  27. package/server/services/runtime/manager.js +53 -38
  28. package/server/services/runtime/settings.js +12 -10
  29. package/server/services/runtime/validation.js +4 -1
  30. package/server/utils/deployment.js +8 -2
  31. package/server/services/runtime/qemu.js +0 -1118
@@ -1,2894 +1,601 @@
1
+ 'use strict';
2
+
3
+ const { spawn, spawnSync } = require('child_process');
1
4
  const fs = require('fs');
5
+ const https = require('https');
6
+ const net = require('net');
2
7
  const os = require('os');
3
8
  const path = require('path');
4
- const https = require('https');
5
- const { spawn, spawnSync } = require('child_process');
6
- const lockfile = require('proper-lockfile');
7
- const { CLIExecutor } = require('../cli/executor');
8
- const { DATA_DIR, RUNTIME_HOME } = require('../../../runtime/paths');
9
- const { findBestNode, parseUiDump, summarizeNode } = require('./uia');
10
-
11
- const ANDROID_ROOT = path.join(RUNTIME_HOME, 'android');
12
- const SDK_ROOT = path.join(ANDROID_ROOT, 'sdk');
13
- const CMDLINE_ROOT = path.join(SDK_ROOT, 'cmdline-tools');
14
- const CMDLINE_LATEST = path.join(CMDLINE_ROOT, 'latest');
15
- const ARTIFACTS_DIR = path.join(DATA_DIR, 'android');
16
- const SCREENSHOTS_DIR = path.join(DATA_DIR, 'screenshots');
17
- const UI_DUMPS_DIR = path.join(ARTIFACTS_DIR, 'ui-dumps');
18
- const LOGS_DIR = path.join(ARTIFACTS_DIR, 'logs');
19
- const TMP_DIR = path.join(ARTIFACTS_DIR, 'tmp');
20
- const EMULATOR_HOME = path.join(ANDROID_ROOT, 'home');
21
- const EMULATOR_ADVANCED_FEATURES_FILE = path.join(EMULATOR_HOME, 'advancedFeatures.ini');
22
- const AVD_HOME = path.join(ANDROID_ROOT, 'avd');
23
- const STATE_DIR = path.join(ARTIFACTS_DIR, 'state');
24
- const STATE_FILE = path.join(ARTIFACTS_DIR, 'state.json');
25
- const OWNERSHIP_FILE = path.join(ARTIFACTS_DIR, 'device-ownership.json');
26
- const ANDROID_BOOTSTRAP_WORKER = path.join(__dirname, 'android_bootstrap_worker.js');
27
- const ANDROID_JAVA_TOOL_TIMEOUT_MS = 20 * 60 * 1000;
28
- const DEFAULT_AVD_NAME = 'neoagent-default';
29
- const DEFAULT_DATA_PARTITION_BYTES = 1024 * 1024 * 1024;
30
- const DEFAULT_PARTITION_SIZE_MB = 1024;
31
- const DEFAULT_SDCARD_SIZE_BYTES = 32 * 1024 * 1024;
32
- const DEFAULT_RAM_SIZE_MB = 768;
33
- const DEFAULT_KEYEVENTS = Object.freeze({
34
- home: 3,
35
- back: 4,
36
- up: 19,
37
- down: 20,
38
- left: 21,
39
- right: 22,
40
- enter: 66,
41
- menu: 82,
42
- search: 84,
43
- app_switch: 187,
44
- delete: 67,
45
- escape: 111,
46
- space: 62,
47
- tab: 61,
48
- });
49
-
50
- for (const dir of [ANDROID_ROOT, SDK_ROOT, EMULATOR_HOME, ARTIFACTS_DIR, SCREENSHOTS_DIR, UI_DUMPS_DIR, LOGS_DIR, TMP_DIR, AVD_HOME, STATE_DIR]) {
51
- fs.mkdirSync(dir, { recursive: true });
52
- }
9
+ const { DATA_DIR } = require('../../../runtime/paths');
53
10
 
54
- function ensureEmulatorAdvancedFeaturesFile() {
55
- try {
56
- fs.mkdirSync(path.dirname(EMULATOR_ADVANCED_FEATURES_FILE), { recursive: true });
57
- fs.writeFileSync(
58
- EMULATOR_ADVANCED_FEATURES_FILE,
59
- [
60
- 'QuickbootFileBacked=off',
61
- 'QuickbootSupport=off',
62
- '',
63
- ].join('\n'),
64
- 'utf8',
65
- );
66
- } catch {}
67
- }
11
+ // ─── Constants ───────────────────────────────────────────────────────────────
68
12
 
69
- function sanitizeScopeKey(value) {
70
- const normalized = String(value || '')
71
- .trim()
72
- .toLowerCase()
73
- .replace(/[^a-z0-9_-]+/g, '-');
74
- return normalized.replace(/^-+|-+$/g, '').slice(0, 48) || 'default';
75
- }
13
+ const DEFAULT_SDK_DIR = path.join(os.homedir(), '.neoagent', 'android-sdk');
14
+ const STATE_DIR = path.join(DATA_DIR, 'android', 'state');
15
+ const LOGO_PATH = path.join(__dirname, '..', '..', '..', 'flutter_app', 'assets', 'branding', 'app_icon_512.png');
76
16
 
77
- function resolveStateFile(scopeKey) {
78
- const key = sanitizeScopeKey(scopeKey);
79
- if (key === 'default') {
80
- return STATE_FILE;
81
- }
82
- return path.join(STATE_DIR, `${key}.json`);
83
- }
17
+ // Even ports in 5554–5682 (documented ADB range). 65 slots for 65 concurrent users.
18
+ const ADB_PORT_BASE = 5554;
19
+ const ADB_PORT_SLOTS = 65;
84
20
 
85
- function readOwnership() {
86
- return withOwnershipLock(() => {
87
- return readOwnershipUnlocked();
88
- });
89
- }
21
+ const CMDLINE_TOOLS_VERSION = '14742923';
22
+ const CMDLINE_TOOLS_URLS = {
23
+ darwin: `https://edgedl.me.gvt1.com/android/studio/commandlinetools-mac-${CMDLINE_TOOLS_VERSION}_latest.zip`,
24
+ linux: `https://edgedl.me.gvt1.com/android/studio/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip`,
25
+ win32: `https://edgedl.me.gvt1.com/android/studio/commandlinetools-win-${CMDLINE_TOOLS_VERSION}_latest.zip`,
26
+ };
90
27
 
91
- function writeOwnership(nextOwners) {
92
- withOwnershipLock(() => {
93
- writeOwnershipUnlocked(nextOwners);
94
- });
95
- }
28
+ fs.mkdirSync(STATE_DIR, { recursive: true });
96
29
 
97
- function readOwnershipUnlocked() {
98
- try {
99
- const parsed = JSON.parse(fs.readFileSync(OWNERSHIP_FILE, 'utf8'));
100
- if (!parsed || typeof parsed !== 'object') {
101
- return {};
102
- }
103
- return parsed;
104
- } catch {
105
- return {};
106
- }
107
- }
30
+ // ─── State persistence ───────────────────────────────────────────────────────
108
31
 
109
- function ensureSparseFile(filePath, sizeBytes) {
110
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
111
- const fd = fs.openSync(filePath, 'a');
112
- try {
113
- const currentSize = fs.statSync(filePath).size;
114
- if (currentSize !== sizeBytes) {
115
- fs.ftruncateSync(fd, sizeBytes);
116
- }
117
- } finally {
118
- fs.closeSync(fd);
119
- }
120
- }
32
+ function stateFile(userId) { return path.join(STATE_DIR, `${userId}.json`); }
121
33
 
122
- function writeOwnershipUnlocked(nextOwners) {
123
- const tempPath = `${OWNERSHIP_FILE}.tmp`;
124
- fs.writeFileSync(tempPath, JSON.stringify(nextOwners, null, 2));
125
- fs.renameSync(tempPath, OWNERSHIP_FILE);
34
+ function readState(userId) {
35
+ try { return JSON.parse(fs.readFileSync(stateFile(userId), 'utf8')); }
36
+ catch { return { userId, bootstrapped: false, starting: false, startupPhase: null, lastStartError: null, pid: null, adbSerial: null }; }
126
37
  }
127
38
 
128
- function withOwnershipLock(work) {
129
- fs.mkdirSync(path.dirname(OWNERSHIP_FILE), { recursive: true });
130
- if (!fs.existsSync(OWNERSHIP_FILE)) {
131
- fs.writeFileSync(OWNERSHIP_FILE, '{}');
132
- }
133
- let lastError = null;
134
- for (let attempt = 0; attempt < 3; attempt += 1) {
135
- try {
136
- const release = lockfile.lockSync(OWNERSHIP_FILE, {
137
- realpath: false,
138
- });
139
- try {
140
- return work();
141
- } finally {
142
- release();
143
- }
144
- } catch (err) {
145
- lastError = err;
146
- if (attempt === 2) {
147
- break;
148
- }
149
- }
150
- }
151
- throw lastError;
39
+ function writeState(userId, patch) {
40
+ const current = readState(userId);
41
+ fs.writeFileSync(stateFile(userId), JSON.stringify({ ...current, ...patch }, null, 2));
152
42
  }
153
43
 
154
- function normalizeOwnerKey(userId) {
155
- if (userId == null || String(userId).trim() === '') {
156
- return 'system';
157
- }
158
- return `user-${sanitizeScopeKey(userId)}`;
159
- }
44
+ // ─── SDK resolution ──────────────────────────────────────────────────────────
160
45
 
161
- function pruneOwnershipByDevices(owners, devices = []) {
162
- const presentSerials = new Set(
163
- devices
164
- .map((device) => String(device?.serial || '').trim())
165
- .filter(Boolean)
166
- );
167
- const next = {};
168
- let changed = false;
169
-
170
- for (const [serial, owner] of Object.entries(owners || {})) {
171
- if (!presentSerials.has(serial)) {
172
- changed = true;
173
- continue;
174
- }
175
- next[serial] = owner;
46
+ function findExistingSdk() {
47
+ const candidates = [
48
+ process.env.ANDROID_HOME,
49
+ process.env.ANDROID_SDK_ROOT,
50
+ path.join(os.homedir(), 'Library', 'Android', 'sdk'),
51
+ path.join(os.homedir(), 'Android', 'Sdk'),
52
+ path.join(os.homedir(), '.android', 'sdk'),
53
+ DEFAULT_SDK_DIR,
54
+ ].filter(Boolean);
55
+ for (const dir of candidates) {
56
+ if (fs.existsSync(path.join(dir, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator'))) return dir;
176
57
  }
177
-
178
- return { next, changed };
58
+ return null;
179
59
  }
180
60
 
181
- function sleep(ms) {
182
- return new Promise((resolve) => setTimeout(resolve, ms));
61
+ function sdkManagerBin(sdkDir) {
62
+ return path.join(sdkDir, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
183
63
  }
184
-
185
- function isProcessAlive(pid) {
186
- const numericPid = Number(pid);
187
- if (!Number.isInteger(numericPid) || numericPid <= 0) {
188
- return false;
189
- }
190
- try {
191
- process.kill(numericPid, 0);
192
- return true;
193
- } catch {
194
- return false;
195
- }
64
+ function avdManagerBin(sdkDir) {
65
+ return path.join(sdkDir, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
196
66
  }
197
-
198
- function tailFile(filePath, maxLines = 40) {
199
- try {
200
- const lines = fs.readFileSync(filePath, 'utf8')
201
- .split('\n')
202
- .map((line) => line.trim())
203
- .filter(Boolean);
204
- return lines.slice(-maxLines);
205
- } catch {
206
- return [];
207
- }
67
+ function emulatorBin(sdkDir) {
68
+ return path.join(sdkDir, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
208
69
  }
209
-
210
- function isLikelyPng(buffer) {
211
- return Buffer.isBuffer(buffer)
212
- && buffer.length > 24
213
- && buffer[0] === 0x89
214
- && buffer[1] === 0x50
215
- && buffer[2] === 0x4e
216
- && buffer[3] === 0x47;
70
+ function adbBin(sdkDir) {
71
+ return path.join(sdkDir, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
217
72
  }
218
73
 
219
- function commandExists(command) {
220
- const probe = spawnSync('bash', ['-lc', `command -v "${command}"`], { encoding: 'utf8' });
221
- return probe.status === 0;
222
- }
74
+ // ─── System image selection ──────────────────────────────────────────────────
223
75
 
224
- function parseResolvedLaunchComponent(output, packageName) {
225
- const lines = String(output || '')
226
- .split('\n')
227
- .map((line) => line.trim())
228
- .filter(Boolean);
229
- const normalizedPackage = String(packageName || '').trim();
230
- const componentPattern = /^[A-Za-z0-9._$]+\/[A-Za-z0-9._$]+$/;
231
- const relativePattern = /^[A-Za-z0-9._$]+\/\.[A-Za-z0-9._$]+$/;
232
-
233
- const exact = lines.find((line) =>
234
- normalizedPackage
235
- ? line.startsWith(`${normalizedPackage}/`)
236
- : componentPattern.test(line) || relativePattern.test(line)
237
- );
238
- if (exact) return exact;
239
-
240
- return lines.find((line) => componentPattern.test(line) || relativePattern.test(line)) || null;
241
- }
76
+ function pickSystemImage(sdkDir) {
77
+ const siRoot = path.join(sdkDir, 'system-images');
78
+ if (!fs.existsSync(siRoot)) return null;
242
79
 
243
- function appendState(patch, stateFile = STATE_FILE) {
244
- const current = readState(stateFile);
245
- const next = {
246
- ...current,
247
- ...patch,
248
- updatedAt: new Date().toISOString(),
249
- };
250
- fs.writeFileSync(stateFile, JSON.stringify(next, null, 2));
251
- return next;
252
- }
80
+ const hostArm = os.arch() === 'arm64' || os.arch() === 'arm';
81
+ const preferred = hostArm ? 'arm64-v8a' : 'x86_64';
82
+ const fallback = hostArm ? 'x86_64' : 'arm64-v8a';
253
83
 
254
- function readState(stateFile = STATE_FILE) {
255
- try {
256
- return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
257
- } catch {
258
- return {
259
- avdName: DEFAULT_AVD_NAME,
260
- serial: null,
261
- emulatorPid: null,
262
- bootstrapped: false,
263
- updatedAt: null,
264
- };
84
+ const images = [];
85
+ for (const api of fs.readdirSync(siRoot)) {
86
+ const apiPath = path.join(siRoot, api);
87
+ if (!fs.statSync(apiPath).isDirectory()) continue;
88
+ for (const tag of fs.readdirSync(apiPath)) {
89
+ const tagPath = path.join(apiPath, tag);
90
+ if (!fs.statSync(tagPath).isDirectory()) continue;
91
+ for (const abi of fs.readdirSync(tagPath)) {
92
+ images.push({ api, tag, abi, key: `system-images;${api};${tag};${abi}` });
93
+ }
94
+ }
265
95
  }
266
- }
267
96
 
268
- function platformTag() {
269
- if (process.platform === 'darwin') return 'mac';
270
- if (process.platform === 'linux') return 'linux';
271
- throw new Error(`Android runtime bootstrap is only supported on macOS and Linux, not ${process.platform}`);
97
+ images.sort((a, b) => {
98
+ const score = img => {
99
+ let s = 0;
100
+ if (img.abi === preferred) s += 100;
101
+ else if (img.abi === fallback) s += 10;
102
+ if (img.tag === 'google_apis') s += 5;
103
+ else if (img.tag === 'google_apis_playstore') s += 3;
104
+ s += parseInt(img.api.replace('android-', '') || '0', 10);
105
+ return s;
106
+ };
107
+ return score(b) - score(a);
108
+ });
109
+ return images[0]?.key || null;
272
110
  }
273
111
 
274
- function systemImageArch() {
275
- if (process.platform === 'darwin') return 'arm64-v8a';
276
- if (process.arch === 'arm64') return 'arm64-v8a';
277
- return 'x86_64';
112
+ function defaultSystemImage() {
113
+ const abi = (os.arch() === 'arm64' || os.arch() === 'arm') ? 'arm64-v8a' : 'x86_64';
114
+ return `system-images;android-33;google_apis;${abi}`;
278
115
  }
279
116
 
280
- function emulatorHostArch() {
281
- if (process.platform === 'darwin') return process.arch === 'arm64' ? 'aarch64' : 'x64';
282
- if (process.arch === 'arm64') return 'aarch64';
283
- return 'x64';
284
- }
117
+ // ─── SDK setup ───────────────────────────────────────────────────────────────
285
118
 
286
- 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';
292
- }
293
- return 'auto';
119
+ function downloadFile(url, dest) {
120
+ return new Promise((resolve, reject) => {
121
+ const file = fs.createWriteStream(dest);
122
+ const follow = u => https.get(u, res => {
123
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
124
+ file.close(); return follow(res.headers.location);
125
+ }
126
+ if (res.statusCode !== 200) { file.close(); return reject(new Error(`HTTP ${res.statusCode}`)); }
127
+ res.pipe(file);
128
+ file.on('finish', () => file.close(resolve));
129
+ }).on('error', err => { file.close(); fs.unlink(dest, () => {}); reject(err); });
130
+ follow(url);
131
+ });
294
132
  }
295
133
 
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
- }
134
+ async function ensureSdk(sdkDir, onProgress) {
135
+ if (fs.existsSync(sdkManagerBin(sdkDir))) return;
136
+ const url = CMDLINE_TOOLS_URLS[process.platform];
137
+ if (!url) throw new Error(`No cmdline-tools download for platform: ${process.platform}`);
308
138
 
309
- function isRecoverableEmulatorStartError(message) {
310
- const value = String(message || '').toLowerCase();
311
- return (
312
- value.includes('failed to restore previous context') ||
313
- value.includes('emulator exited before boot completed') ||
314
- value.includes('android framework did not become ready') ||
315
- value.includes('package manager service did not become ready') ||
316
- value.includes('failed to process .ini file') ||
317
- value.includes('error while loading state for instance')
318
- );
319
- }
139
+ fs.mkdirSync(sdkDir, { recursive: true });
140
+ onProgress('Downloading Android SDK command-line tools (~150 MB)…');
141
+ const zip = path.join(os.tmpdir(), 'cmdline-tools.zip');
142
+ await downloadFile(url, zip);
320
143
 
321
- function parseCsvEnv(value) {
322
- return String(value || '')
323
- .split(',')
324
- .map((entry) => entry.trim())
325
- .filter(Boolean);
326
- }
144
+ onProgress('Extracting…');
145
+ const toolsDir = path.join(sdkDir, 'cmdline-tools');
146
+ fs.mkdirSync(toolsDir, { recursive: true });
147
+ const unzip = spawnSync('unzip', ['-qo', zip, '-d', toolsDir]);
148
+ fs.unlinkSync(zip);
149
+ if (unzip.status !== 0) throw new Error('unzip failed');
327
150
 
328
- function configuredSystemImagePackage() {
329
- return String(process.env.ANDROID_SYSTEM_IMAGE_PACKAGE || '').trim() || null;
151
+ const extracted = path.join(toolsDir, 'cmdline-tools');
152
+ const latest = path.join(toolsDir, 'latest');
153
+ if (fs.existsSync(extracted) && !fs.existsSync(latest)) fs.renameSync(extracted, latest);
154
+ if (!fs.existsSync(sdkManagerBin(sdkDir))) throw new Error('sdkmanager not found after extraction');
330
155
  }
331
156
 
332
- function configuredSystemImagePlatform() {
333
- return String(process.env.ANDROID_SYSTEM_IMAGE_PLATFORM || '').trim() || null;
334
- }
157
+ async function ensurePackages(sdkDir, onProgress) {
158
+ const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
159
+ const sdkman = sdkManagerBin(sdkDir);
335
160
 
336
- function shouldForceSdkRefresh() {
337
- return String(process.env.ANDROID_FORCE_SDK_REFRESH || '').trim().toLowerCase() === 'true';
338
- }
161
+ onProgress('Accepting Android SDK licenses…');
162
+ spawnSync(sdkman, ['--licenses', `--sdk_root=${sdkDir}`], { input: 'y\n'.repeat(20), encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'] });
339
163
 
340
- function systemImageArchCandidates() {
341
- const configured = parseCsvEnv(process.env.ANDROID_SYSTEM_IMAGE_ARCH);
342
- if (configured.length > 0) {
343
- return configured.filter((arch, index, list) => list.indexOf(arch) === index);
344
- }
345
- const preferred = systemImageArch();
346
- const fallbacks = ['x86_64', 'arm64-v8a'];
347
- return [preferred, ...fallbacks].filter((arch, index, list) => list.indexOf(arch) === index);
164
+ const img = defaultSystemImage();
165
+ onProgress(`Installing platform-tools, emulator, ${img} (~1–2 GB, first run only)…`);
166
+ const r = spawnSync(sdkman, ['platform-tools', 'emulator', img, `--sdk_root=${sdkDir}`], {
167
+ encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'], timeout: 20 * 60 * 1000,
168
+ });
169
+ if (r.status !== 0) throw new Error(`sdkmanager failed: ${(r.stderr || r.stdout || '').slice(0, 500)}`);
348
170
  }
349
171
 
350
- function installedEmulatorMatchesHost() {
351
- const binary = emulatorBinary();
352
- if (!isExecutable(binary)) {
353
- return false;
354
- }
355
-
356
- const probe = spawnSync('file', [binary], { encoding: 'utf8' });
357
- if (probe.status !== 0) {
358
- return false;
359
- }
360
-
361
- const output = `${probe.stdout || ''}${probe.stderr || ''}`.toLowerCase();
362
- const hostArch = emulatorHostArch();
363
- if (hostArch === 'aarch64') {
364
- return output.includes('arm64') || output.includes('aarch64');
365
- }
366
- if (hostArch === 'x64') {
367
- return output.includes('x86_64');
368
- }
369
- return true;
172
+ function ensureEmulatorRegistered(sdkDir) {
173
+ const packageXml = path.join(sdkDir, 'emulator', 'package.xml');
174
+ if (fs.existsSync(packageXml) || !fs.existsSync(emulatorBin(sdkDir))) return;
175
+ const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
176
+ spawnSync(sdkManagerBin(sdkDir), ['emulator', `--sdk_root=${sdkDir}`], {
177
+ encoding: 'utf8', env, input: 'y\n'.repeat(5), stdio: ['pipe', 'pipe', 'pipe'], timeout: 5 * 60 * 1000,
178
+ });
370
179
  }
371
180
 
372
- function parseSystemImagePlatform(platformId) {
373
- const stable = String(platformId || '').match(/^android-(\d+)$/);
374
- if (stable) {
375
- return {
376
- platformId,
377
- apiLevel: Number(stable[1] || 0),
378
- stable: true,
379
- };
380
- }
181
+ function ensureAvd(sdkDir, avdName, onProgress) {
182
+ const avdDir = path.join(os.homedir(), '.android', 'avd', `${avdName}.avd`);
183
+ if (fs.existsSync(avdDir)) return;
381
184
 
382
- const released = String(platformId || '').match(/^android-(\d+(?:\.\d+)?)$/);
383
- if (released) {
384
- return {
385
- platformId,
386
- apiLevel: Math.floor(Number(released[1]) || 0),
387
- stable: false,
388
- };
389
- }
185
+ ensureEmulatorRegistered(sdkDir);
186
+ const img = pickSystemImage(sdkDir) || defaultSystemImage();
187
+ onProgress(`Creating AVD "${avdName}" using ${img}…`);
390
188
 
391
- const preview = String(platformId || '').match(/^android-([A-Za-z][A-Za-z0-9_-]*)$/);
392
- if (preview) {
393
- return {
394
- platformId,
395
- apiLevel: 0,
396
- stable: false,
397
- };
398
- }
189
+ const env = { ...process.env, ANDROID_SDK_ROOT: sdkDir, ANDROID_HOME: sdkDir };
190
+ const r = spawnSync(avdManagerBin(sdkDir), ['create', 'avd', '-n', avdName, '-k', img, '--device', 'pixel', '--force'], {
191
+ encoding: 'utf8', env, stdio: ['pipe', 'pipe', 'pipe'], input: '\n',
192
+ });
193
+ if (r.status !== 0) throw new Error(`avdmanager failed: ${(r.stderr || r.stdout || '').slice(0, 500)}`);
399
194
 
400
- return {
401
- platformId,
402
- apiLevel: 0,
403
- stable: false,
404
- };
195
+ // Patch config: sparse QCOW2 (no pre-allocation), smaller cache partition.
196
+ const cfgPath = path.join(avdDir, 'config.ini');
197
+ if (fs.existsSync(cfgPath)) {
198
+ let cfg = fs.readFileSync(cfgPath, 'utf8');
199
+ cfg = cfg.replace(/disk\.dataPartition\.size\s*=\s*\S+/, `disk.dataPartition.size = ${2 * 1024 * 1024 * 1024}`);
200
+ cfg = cfg.replace(/disk\.cachePartition\.size\s*=\s*\S+/, `disk.cachePartition.size = ${32 * 1024 * 1024}`);
201
+ cfg = cfg.replace(/userdata\.useQcow2\s*=\s*\S+/, 'userdata.useQcow2 = yes');
202
+ if (!/userdata\.useQcow2/.test(cfg)) cfg += '\nuserdata.useQcow2 = yes\n';
203
+ fs.writeFileSync(cfgPath, cfg);
204
+ }
405
205
  }
406
206
 
407
- function sdkEnv() {
408
- ensureEmulatorAdvancedFeaturesFile();
409
- const sdkRoot = activeAndroidSdkRoot();
410
- const base = {
411
- ...process.env,
412
- ANDROID_HOME: sdkRoot,
413
- ANDROID_SDK_ROOT: sdkRoot,
414
- ANDROID_EMULATOR_HOME: EMULATOR_HOME,
415
- ANDROID_USER_HOME: EMULATOR_HOME,
416
- ANDROID_AVD_HOME: AVD_HOME,
417
- AVD_HOME,
418
- JAVA_TOOL_OPTIONS: process.env.JAVA_TOOL_OPTIONS || '-Xint',
419
- };
420
- const pathParts = [
421
- path.join(sdkRoot, 'platform-tools'),
422
- path.join(sdkRoot, 'emulator'),
423
- path.join(sdkRoot, 'cmdline-tools', 'latest', 'bin'),
424
- process.env.PATH || '',
425
- ].filter(Boolean);
426
- base.PATH = pathParts.join(path.delimiter);
427
- return base;
207
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
208
+
209
+ function hashCode(str) {
210
+ let h = 0;
211
+ for (let i = 0; i < str.length; i++) h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
212
+ return h;
428
213
  }
429
214
 
430
- function systemAdbBinary() {
431
- if (process.platform === 'win32') return null;
432
- return '/usr/bin/adb';
215
+ // Escape a string for safe use inside single-quoted Android shell (`mksh`) commands.
216
+ function shellEscape(str) {
217
+ return String(str).replace(/'/g, "'\\''");
433
218
  }
434
219
 
435
- function hostAdbBinary() {
436
- const probe = spawnSync('bash', ['-lc', 'command -v adb'], { encoding: 'utf8' });
437
- if (probe.status !== 0) {
438
- return null;
439
- }
440
- const binary = String(probe.stdout || '').trim();
441
- return binary && isExecutable(binary) ? binary : null;
220
+ // Validate an Android package name or intent action (alphanumeric + dots + underscores).
221
+ function isSafeIdentifier(str) {
222
+ return /^[\w.]+$/.test(String(str || ''));
442
223
  }
443
224
 
444
- function hostAndroidSdkRoot() {
445
- const adb = hostAdbBinary();
446
- if (!adb) {
447
- return null;
448
- }
225
+ // ─── AndroidController ───────────────────────────────────────────────────────
449
226
 
450
- const root = path.dirname(path.dirname(adb));
451
- const sdkmanager = path.join(root, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
452
- const avdmanager = path.join(root, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
453
- const emulator = path.join(root, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
454
- if (isExecutable(sdkmanager) && isExecutable(avdmanager) && isExecutable(emulator)) {
455
- return root;
227
+ class AndroidController {
228
+ constructor(options = {}) {
229
+ this.userId = String(options.userId || 'default').trim();
230
+ this.avdName = `neoagent_${this.userId}`;
231
+ // Deterministic ADB console port per user, within documented range 5554–5682 (even only).
232
+ this.adbPort = ADB_PORT_BASE + ((hashCode(this.userId) >>> 0) % ADB_PORT_SLOTS) * 2;
233
+ this.adbSerial = `emulator-${this.adbPort}`;
234
+ this.sdkDir = options.sdkDir || findExistingSdk() || DEFAULT_SDK_DIR;
235
+ this.artifactStore = options.artifactStore || null;
236
+ this.startPromise = null;
456
237
  }
457
238
 
458
- return null;
459
- }
239
+ // ── Status ────────────────────────────────────────────────────────────────
460
240
 
461
- const SHARED_ANDROID_SDK_ROOT = null;
241
+ getStatusSync() { return readState(this.userId); }
462
242
 
463
- function activeAndroidSdkRoot() {
464
- return SDK_ROOT;
465
- }
243
+ async getStatus() {
244
+ const state = readState(this.userId);
245
+ const base = {
246
+ bootstrapped: state.bootstrapped || false,
247
+ starting: state.starting || false,
248
+ startupPhase: state.startupPhase || null,
249
+ lastStartError: state.lastStartError || null,
250
+ adbSerial: state.adbSerial || null,
251
+ devices: [],
252
+ };
466
253
 
467
- function sharedAndroidSdkReady() {
468
- if (!SHARED_ANDROID_SDK_ROOT) {
469
- return false;
470
- }
471
- const adb = path.join(SHARED_ANDROID_SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
472
- const sdkmanager = path.join(SHARED_ANDROID_SDK_ROOT, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
473
- const avdmanager = path.join(SHARED_ANDROID_SDK_ROOT, 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
474
- const emulator = path.join(SHARED_ANDROID_SDK_ROOT, 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
475
- return [adb, sdkmanager, avdmanager, emulator].every(isExecutable);
476
- }
254
+ if (!state.adbSerial) return base;
255
+ if (!this.#isPidAlive(state.pid)) return { ...base, bootstrapped: false };
477
256
 
478
- function adbBinary() {
479
- if (process.env.ANDROID_ADB_PATH) {
480
- return process.env.ANDROID_ADB_PATH;
257
+ try {
258
+ const r = spawnSync(adbBin(this.sdkDir), ['-s', state.adbSerial, 'shell', 'getprop', 'sys.boot_completed'],
259
+ { encoding: 'utf8', timeout: 5000 });
260
+ const booted = r.stdout?.trim() === '1';
261
+ return {
262
+ ...base,
263
+ bootstrapped: booted,
264
+ devices: booted ? [{ serial: state.adbSerial, status: 'device', emulator: true }] : [],
265
+ };
266
+ } catch {
267
+ return base;
268
+ }
481
269
  }
482
- return path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
483
- }
484
-
485
- function sdkManagerBinary() {
486
- return path.join(activeAndroidSdkRoot(), 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'sdkmanager.bat' : 'sdkmanager');
487
- }
488
270
 
489
- function avdManagerBinary() {
490
- return path.join(activeAndroidSdkRoot(), 'cmdline-tools', 'latest', 'bin', process.platform === 'win32' ? 'avdmanager.bat' : 'avdmanager');
491
- }
271
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
492
272
 
493
- function emulatorBinary() {
494
- return path.join(activeAndroidSdkRoot(), 'emulator', process.platform === 'win32' ? 'emulator.exe' : 'emulator');
495
- }
273
+ async requestStartEmulator() {
274
+ console.log(`[Android] requestStartEmulator for user ${this.userId}`);
275
+ const state = readState(this.userId);
276
+ if (state.adbSerial && this.#isPidAlive(state.pid)) {
277
+ console.log(`[Android] Emulator already running (pid=${state.pid})`);
278
+ return { success: true, pending: false, adbSerial: state.adbSerial };
279
+ }
280
+ if (!this.startPromise) {
281
+ writeState(this.userId, { starting: true, startupPhase: 'Initializing', lastStartError: null });
282
+ this.startPromise = this.#setup().finally(() => { this.startPromise = null; });
283
+ this.startPromise.catch(() => {});
284
+ }
285
+ const s = readState(this.userId);
286
+ return { success: true, pending: true, bootstrapped: false, starting: true, startupPhase: s.startupPhase };
287
+ }
496
288
 
497
- function isExecutable(filePath) {
498
- try {
499
- fs.accessSync(filePath, fs.constants.X_OK);
500
- return true;
501
- } catch {
502
- return false;
289
+ async stopEmulator() {
290
+ const state = readState(this.userId);
291
+ if (state.pid) { try { process.kill(Number(state.pid), 'SIGTERM'); } catch {} }
292
+ writeState(this.userId, { bootstrapped: false, starting: false, pid: null, adbSerial: null, startupPhase: null });
293
+ console.log('[Android] Emulator stopped');
503
294
  }
504
- }
505
295
 
506
- function fetchText(url) {
507
- return new Promise((resolve, reject) => {
508
- https.get(url, (res) => {
509
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
510
- return resolve(fetchText(res.headers.location));
511
- }
512
- if (res.statusCode !== 200) {
513
- reject(new Error(`GET ${url} failed with status ${res.statusCode}`));
514
- return;
515
- }
516
- let body = '';
517
- res.setEncoding('utf8');
518
- res.on('data', (chunk) => { body += chunk; });
519
- res.on('end', () => resolve(body));
520
- }).on('error', reject);
521
- });
522
- }
296
+ async close() { await this.stopEmulator().catch(() => {}); }
523
297
 
524
- function downloadFile(url, dest) {
525
- fs.mkdirSync(path.dirname(dest), { recursive: true });
526
- const curlBin = process.platform === 'win32' ? 'curl.exe' : 'curl';
527
- if (commandExists(curlBin)) {
528
- const curlResult = spawnSync(curlBin, [
529
- '--fail',
530
- '--location',
531
- '--silent',
532
- '--show-error',
533
- '--retry', '3',
534
- '--retry-delay', '2',
535
- '--output', dest,
536
- url,
537
- ], {
538
- encoding: 'utf8',
539
- stdio: ['ignore', 'ignore', 'inherit'],
540
- });
541
- if (curlResult.status === 0) {
542
- return waitForReadyFile(dest);
298
+ async waitForDevice(options = {}) {
299
+ const deadline = Date.now() + (options.timeoutMs || 600000);
300
+ while (Date.now() < deadline) {
301
+ const s = await this.getStatus();
302
+ if (s.bootstrapped) return this.adbSerial;
303
+ await new Promise(r => setTimeout(r, 2000));
543
304
  }
544
- const detail = String(curlResult.stderr || curlResult.stdout || curlResult.error?.message || `curl exited with code ${curlResult.status ?? 'unknown'}`).trim();
545
- console.warn(`[Android] curl download failed for ${url}; falling back to Node HTTPS (${detail || 'no detail'})`);
305
+ throw new Error('Android emulator did not become ready in time');
546
306
  }
547
307
 
548
- return downloadFileViaHttps(url, dest);
549
- }
550
-
551
- async function waitForReadyFile(filePath, timeoutMs = 600000) {
552
- const deadline = Date.now() + timeoutMs;
553
- while (Date.now() < deadline) {
554
- try {
555
- const stats = fs.statSync(filePath);
556
- if (stats.isFile() && stats.size > 0) {
557
- return;
558
- }
559
- } catch {}
560
- await sleep(250);
308
+ async listDevices() {
309
+ const s = await this.getStatus();
310
+ return s.bootstrapped ? [{ serial: this.adbSerial, status: 'device', emulator: true }] : [];
561
311
  }
562
- throw new Error(`Downloaded file was not ready at ${filePath}`);
563
- }
564
312
 
565
- function downloadFileViaHttps(url, dest) {
566
- return new Promise((resolve, reject) => {
567
- const out = fs.createWriteStream(dest);
568
- https.get(url, (res) => {
569
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
570
- out.close();
571
- fs.rmSync(dest, { force: true });
572
- return resolve(downloadFile(res.headers.location, dest));
573
- }
574
- if (res.statusCode !== 200) {
575
- out.close();
576
- fs.rmSync(dest, { force: true });
577
- reject(new Error(`Download failed for ${url} with status ${res.statusCode}`));
578
- return;
579
- }
580
- res.pipe(out);
581
- out.on('finish', () => out.close((closeErr) => {
582
- if (closeErr) {
583
- reject(closeErr);
584
- return;
585
- }
586
- resolve(waitForReadyFile(dest));
587
- }));
588
- }).on('error', (err) => {
589
- out.close();
590
- fs.rmSync(dest, { force: true });
591
- reject(new Error(`Download failed for ${url}: ${err.message}`));
313
+ async ensureBootstrapped() {
314
+ const s = readState(this.userId);
315
+ if (!s.bootstrapped) await this.requestStartEmulator();
316
+ }
317
+
318
+ // ── Shell / ADB ───────────────────────────────────────────────────────────
319
+
320
+ async shell(commandOrObj) {
321
+ const command = typeof commandOrObj === 'string' ? commandOrObj : String(commandOrObj?.command || '');
322
+ const serial = this.#requireSerial();
323
+ const adb = adbBin(this.sdkDir);
324
+ return new Promise((resolve, reject) => {
325
+ const proc = spawn(adb, ['-s', serial, 'shell', command], { encoding: 'utf8' });
326
+ let out = '', err = '';
327
+ proc.stdout?.on('data', d => { out += d; });
328
+ proc.stderr?.on('data', d => { err += d; });
329
+ proc.on('close', code => code === 0 ? resolve(out) : reject(new Error(err || out || `exit ${code}`)));
330
+ proc.on('error', reject);
592
331
  });
593
- });
594
- }
595
-
596
- function extractZip(zipPath, destDir) {
597
- if (process.platform === 'darwin' && commandExists('ditto')) {
598
- const res = spawnSync('ditto', ['-x', '-k', zipPath, destDir], { encoding: 'utf8' });
599
- if (res.status === 0) return;
600
- console.warn(`[Android] ditto failed for ${zipPath}; falling back to unzip`);
601
332
  }
602
333
 
603
- if (commandExists('unzip')) {
604
- const res = spawnSync('unzip', ['-qo', zipPath, '-d', destDir], { encoding: 'utf8' });
605
- if (res.status === 0) return;
606
- throw new Error(res.stderr || `unzip failed for ${zipPath}`);
334
+ async adb(...args) {
335
+ const state = readState(this.userId);
336
+ const adb = adbBin(this.sdkDir);
337
+ return new Promise((resolve, reject) => {
338
+ const proc = spawn(adb, ['-s', state.adbSerial || this.adbSerial, ...args], { encoding: 'utf8' });
339
+ let out = '', err = '';
340
+ proc.stdout?.on('data', d => { out += d; });
341
+ proc.stderr?.on('data', d => { err += d; });
342
+ proc.on('close', code => code === 0 ? resolve(out) : reject(new Error(err || `adb ${args[0]} exit ${code}`)));
343
+ proc.on('error', reject);
344
+ });
607
345
  }
608
346
 
609
- throw new Error('Neither unzip nor ditto is available to extract Android SDK archives');
610
- }
347
+ // ── Actions ───────────────────────────────────────────────────────────────
611
348
 
612
- function clearQuarantineAttribute(targetPath) {
613
- if (process.platform !== 'darwin' || !commandExists('xattr')) {
614
- return;
349
+ async screenshot(_opts = {}) {
350
+ const serial = this.#requireSerial();
351
+ const r = this.#adbCapture(serial, ['exec-out', 'screencap', '-p']);
352
+ if (!r?.length) throw new Error('screencap returned no data');
353
+ return { screenshotPath: this.#saveArtifact(r) };
615
354
  }
616
- spawnSync('xattr', ['-dr', 'com.apple.quarantine', targetPath], { encoding: 'utf8' });
617
- }
618
355
 
619
- function codesignAdHoc(targetPath) {
620
- if (process.platform !== 'darwin' || !commandExists('codesign')) {
621
- return;
356
+ async observe(_opts = {}) { return this.screenshot(); }
357
+
358
+ async tap({ x, y } = {}) {
359
+ await this.shell(`input tap ${Math.round(x)} ${Math.round(y)}`);
360
+ return { success: true, screenshotPath: this.#saveArtifact(this.#adbCapture(this.#requireSerial(), ['exec-out', 'screencap', '-p'])) };
622
361
  }
623
- spawnSync('codesign', ['--force', '--deep', '--sign', '-', targetPath], { encoding: 'utf8' });
624
- }
625
362
 
626
- function listFilesRecursive(rootDir, predicate, bucket = []) {
627
- for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
628
- const fullPath = path.join(rootDir, entry.name);
629
- if (entry.isDirectory()) {
630
- listFilesRecursive(fullPath, predicate, bucket);
631
- continue;
632
- }
633
- if (!predicate || predicate(fullPath, entry)) {
634
- bucket.push(fullPath);
635
- }
363
+ async longPress({ x, y, durationMs = 1000 } = {}) {
364
+ await this.shell(`input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${durationMs}`);
365
+ return { success: true };
636
366
  }
637
- return bucket;
638
- }
639
367
 
640
- function resolveBundleInstallTargets(bundleDir) {
641
- const apkFiles = listFilesRecursive(bundleDir, (filePath) => path.extname(filePath).toLowerCase() === '.apk')
642
- .sort((a, b) => a.localeCompare(b));
643
- if (apkFiles.length === 0) {
644
- throw new Error('APK bundle did not contain any installable .apk files.');
368
+ async swipe({ x1, y1, x2, y2, durationMs = 300 } = {}) {
369
+ await this.shell(`input swipe ${Math.round(x1)} ${Math.round(y1)} ${Math.round(x2)} ${Math.round(y2)} ${durationMs}`);
370
+ return { success: true, screenshotPath: this.#saveArtifact(this.#adbCapture(this.#requireSerial(), ['exec-out', 'screencap', '-p'])) };
645
371
  }
646
372
 
647
- const universalApk = apkFiles.find((filePath) => path.basename(filePath).toLowerCase() === 'universal.apk');
648
- if (universalApk) {
649
- return {
650
- mode: 'single',
651
- installPaths: [universalApk],
652
- layout: 'universal',
653
- };
373
+ async type({ text, pressEnter } = {}) {
374
+ if (!text) return { success: true };
375
+ // ADB input text encoding: %% = literal %, %s = space.
376
+ const encoded = String(text).replace(/%/g, '%%').replace(/ /g, '%s');
377
+ await this.shell(`input text '${shellEscape(encoded)}'`);
378
+ if (pressEnter) await this.shell('input keyevent KEYCODE_ENTER');
379
+ return { success: true };
654
380
  }
655
381
 
656
- if (apkFiles.length === 1) {
657
- return {
658
- mode: 'single',
659
- installPaths: apkFiles,
660
- layout: 'single-apk',
382
+ async pressKey(keyOrObj) {
383
+ const raw = typeof keyOrObj === 'string' ? keyOrObj : (keyOrObj?.key || '');
384
+ const KEY_MAP = {
385
+ back: 'KEYCODE_BACK', home: 'KEYCODE_HOME', app_switch: 'KEYCODE_APP_SWITCH',
386
+ enter: 'KEYCODE_ENTER', del: 'KEYCODE_DEL', escape: 'KEYCODE_ESCAPE',
387
+ menu: 'KEYCODE_MENU', power: 'KEYCODE_POWER',
388
+ volume_up: 'KEYCODE_VOLUME_UP', volume_down: 'KEYCODE_VOLUME_DOWN',
661
389
  };
390
+ const keycode = KEY_MAP[raw.toLowerCase()] || raw.toUpperCase();
391
+ await this.shell(`input keyevent ${keycode}`);
392
+ return { success: true };
662
393
  }
663
394
 
664
- throw new Error(
665
- 'APK bundles must include a universal APK. Export a universal .apks bundle or upload a single .apk instead.'
666
- );
667
- }
668
-
669
- function parseLatestCmdlineToolsUrl(xml) {
670
- const tag = platformTag() === 'mac' ? 'macosx' : 'linux';
671
- const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="cmdline-tools;latest">([\\s\\S]*?)<\\/remotePackage>`));
672
- if (!packageMatch) throw new Error('Could not locate cmdline-tools;latest in Android repository metadata');
673
-
674
- const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
675
- for (const block of archiveBlocks) {
676
- if (!new RegExp(`<host-os>${tag}<\\/host-os>`).test(block)) continue;
677
- const urlMatch = block.match(/<url>\s*([^<]*commandlinetools-[^<]+_latest\.zip)\s*<\/url>/);
678
- if (urlMatch) return `https://dl.google.com/android/repository/${urlMatch[1]}`;
395
+ async dumpUi(_opts = {}) {
396
+ const serial = this.#requireSerial();
397
+ await this.shell('uiautomator dump /sdcard/window_dump.xml');
398
+ const r = spawnSync(adbBin(this.sdkDir), ['-s', serial, 'shell', 'cat', '/sdcard/window_dump.xml'], { encoding: 'utf8', timeout: 10000 });
399
+ return { xml: r.stdout || '' };
679
400
  }
680
401
 
681
- throw new Error(`Could not find a command line tools archive for ${tag}`);
682
- }
683
-
684
- function parseLatestEmulatorUrl(xml) {
685
- const tag = platformTag() === 'mac' ? 'macosx' : 'linux';
686
- const hostArch = emulatorHostArch();
687
- const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="emulator">([\\s\\S]*?)<\\/remotePackage>`));
688
- if (!packageMatch) throw new Error('Could not locate emulator in Android repository metadata');
689
-
690
- const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
691
- for (const block of archiveBlocks) {
692
- if (!new RegExp(`<host-os>${tag}<\\/host-os>`).test(block)) continue;
693
- if (!new RegExp(`<host-arch>${hostArch}<\\/host-arch>`).test(block)) continue;
694
- const urlMatch = block.match(/<url>\s*([^<]*emulator-[^<]+\.zip)\s*<\/url>/);
695
- if (urlMatch) return `https://dl.google.com/android/repository/${urlMatch[1]}`;
402
+ async listApps({ includeSystem = false } = {}) {
403
+ const out = await this.shell(includeSystem ? 'pm list packages' : 'pm list packages -3');
404
+ const packages = out.trim().split('\n').filter(Boolean).map(l => l.replace('package:', '').trim());
405
+ return { packages };
696
406
  }
697
407
 
698
- throw new Error(`Could not find an Android emulator archive for ${tag}`);
699
- }
700
-
701
- function findDirectoryContainingFiles(rootDir, requiredFiles) {
702
- const stack = [rootDir];
703
- while (stack.length > 0) {
704
- const dir = stack.pop();
705
- let entries;
706
- try {
707
- entries = fs.readdirSync(dir, { withFileTypes: true });
708
- } catch {
709
- continue;
710
- }
711
- const names = new Set(entries.map((entry) => entry.name));
712
- if (requiredFiles.every((name) => names.has(name))) {
713
- return dir;
714
- }
715
- for (const entry of entries) {
716
- if (entry.isDirectory()) {
717
- stack.push(path.join(dir, entry.name));
718
- }
719
- }
408
+ async openApp({ packageName } = {}) {
409
+ if (!isSafeIdentifier(packageName)) throw new Error('Invalid package name');
410
+ await this.shell(`monkey -p '${shellEscape(packageName)}' -c android.intent.category.LAUNCHER 1`);
411
+ await new Promise(r => setTimeout(r, 1500));
412
+ return this.screenshot();
720
413
  }
721
- return null;
722
- }
723
414
 
724
- function parseLatestPlatformToolsUrl(xml) {
725
- const tag = platformTag() === 'mac' ? 'macosx' : 'linux';
726
- const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="platform-tools">([\\s\\S]*?)<\\/remotePackage>`));
727
- if (!packageMatch) throw new Error('Could not locate platform-tools in Android repository metadata');
728
-
729
- const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
730
- for (const block of archiveBlocks) {
731
- if (!new RegExp(`<host-os>${tag}<\\/host-os>`).test(block)) continue;
732
- const urlMatch = block.match(/<url>\s*([^<]*platform-tools[^<]+\.zip)\s*<\/url>/);
733
- if (urlMatch) return `https://dl.google.com/android/repository/${urlMatch[1]}`;
415
+ async openIntent({ action, dataUri, extras = {} } = {}) {
416
+ const safeAction = isSafeIdentifier(action) ? action : 'android.intent.action.VIEW';
417
+ let cmd = `am start -a '${shellEscape(safeAction)}'`;
418
+ if (dataUri) cmd += ` -d '${shellEscape(dataUri)}'`;
419
+ for (const [k, v] of Object.entries(extras || {})) {
420
+ if (isSafeIdentifier(k)) cmd += ` --es '${shellEscape(k)}' '${shellEscape(v)}'`;
421
+ }
422
+ await this.shell(cmd);
423
+ await new Promise(r => setTimeout(r, 2000));
424
+ return this.screenshot();
734
425
  }
735
426
 
736
- throw new Error(`Could not find a platform-tools archive for ${tag}`);
737
- }
738
-
739
- function parseRepositorySystemImages(xml) {
740
- const matches = [];
741
- const regex = /<remotePackage\s+path="(system-images;[^"]+)">/g;
742
- let match = regex.exec(xml);
743
- while (match) {
744
- matches.push({
745
- packageName: match[1],
746
- platformId: match[1].split(';')[1] || '',
747
- tag: match[1].split(';')[2] || '',
748
- arch: match[1].split(';')[3] || '',
427
+ async waitFor({ timeout = 10000 } = {}) {
428
+ const deadline = Date.now() + timeout;
429
+ while (Date.now() < deadline) {
430
+ const s = await this.getStatus();
431
+ if (s.bootstrapped) return { ready: true };
432
+ await new Promise(r => setTimeout(r, 1000));
433
+ }
434
+ return { ready: false };
435
+ }
436
+
437
+ async installApk({ apkPath } = {}) {
438
+ if (!apkPath) throw new Error('apkPath required');
439
+ const serial = this.#requireSerial();
440
+ const adb = adbBin(this.sdkDir);
441
+ return new Promise((resolve, reject) => {
442
+ const proc = spawn(adb, ['-s', serial, 'install', '-r', apkPath]);
443
+ let out = '', err = '';
444
+ proc.stdout?.on('data', d => { out += d; });
445
+ proc.stderr?.on('data', d => { err += d; });
446
+ proc.on('close', code => {
447
+ if (code === 0 && out.includes('Success')) resolve({ success: true, output: out });
448
+ else reject(new Error(err || out || `adb install exit ${code}`));
449
+ });
450
+ proc.on('error', reject);
749
451
  });
750
- match = regex.exec(xml);
751
- }
752
- return parseSystemImageCandidates(matches);
753
- }
754
-
755
- function parseLatestSystemImageUrl(xml, packageName) {
756
- const packageMatch = xml.match(new RegExp(`<remotePackage\\s+path="${packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}">([\\s\\S]*?)<\\/remotePackage>`));
757
- if (!packageMatch) throw new Error(`Could not locate ${packageName} in Android repository metadata`);
758
-
759
- const archiveBlocks = packageMatch[1].match(/<archive>[\s\S]*?<\/archive>/g) || [];
760
- for (const block of archiveBlocks) {
761
- const urlMatch = block.match(/<url>\s*([^<]*\.zip)\s*<\/url>/);
762
- if (urlMatch) {
763
- const urlPart = urlMatch[1];
764
- if (urlPart.startsWith('http')) return urlPart;
765
- return `https://dl.google.com/android/repository/sys-img/android/${urlPart}`;
766
- }
767
452
  }
768
453
 
769
- throw new Error(`Could not find a system image archive for ${packageName}`);
770
- }
454
+ // ── Private helpers ───────────────────────────────────────────────────────
771
455
 
772
- async function fetchEmulatorMetadata() {
773
- const urls = [
774
- 'https://dl.google.com/android/repository/repository2-3.xml',
775
- 'https://dl.google.com/android/repository/repository2-1.xml',
776
- ];
777
- let lastError = null;
778
- for (const url of urls) {
779
- try {
780
- return await fetchText(url);
781
- } catch (error) {
782
- lastError = error;
783
- }
456
+ #requireSerial() {
457
+ const state = readState(this.userId);
458
+ if (!state.adbSerial) throw new Error('No emulator running');
459
+ return state.adbSerial;
784
460
  }
785
- throw lastError || new Error('Could not fetch Android emulator repository metadata');
786
- }
787
-
788
- async function installEmulatorArchive(metadata) {
789
- const url = parseLatestEmulatorUrl(metadata);
790
- const zipPath = path.join(TMP_DIR, path.basename(url));
791
- const installDir = path.join(activeAndroidSdkRoot(), 'emulator');
792
-
793
- await downloadFile(url, zipPath);
794
- fs.rmSync(installDir, { recursive: true, force: true });
795
- extractZip(zipPath, activeAndroidSdkRoot());
796
461
 
797
- const extractedRoot = findDirectoryContainingFiles(installDir, [
798
- process.platform === 'win32' ? 'emulator.exe' : 'emulator',
799
- ]);
800
- if (!extractedRoot) {
801
- throw new Error('Downloaded Android emulator archive did not contain an emulator binary');
462
+ #isPidAlive(pid) {
463
+ if (!pid || !Number.isInteger(Number(pid))) return false;
464
+ try { process.kill(Number(pid), 0); return true; } catch { return false; }
802
465
  }
803
- clearQuarantineAttribute(installDir);
804
- codesignAdHoc(installDir);
805
466
 
806
- fs.rmSync(zipPath, { force: true });
807
- }
808
-
809
- async function installPlatformToolsArchive(metadata) {
810
- const url = parseLatestPlatformToolsUrl(metadata);
811
- const zipPath = path.join(TMP_DIR, path.basename(url));
812
- const installDir = path.join(activeAndroidSdkRoot(), 'platform-tools');
813
-
814
- await downloadFile(url, zipPath);
815
- fs.rmSync(installDir, { recursive: true, force: true });
816
- extractZip(zipPath, activeAndroidSdkRoot());
817
-
818
- const extractedRoot = findDirectoryContainingFiles(installDir, [
819
- process.platform === 'win32' ? 'adb.exe' : 'adb',
820
- ]);
821
- if (!extractedRoot) {
822
- throw new Error('Downloaded platform-tools archive did not contain adb');
467
+ #adbCapture(serial, args) {
468
+ const r = spawnSync(adbBin(this.sdkDir), ['-s', serial, ...args], { maxBuffer: 20 * 1024 * 1024, timeout: 15000 });
469
+ return (r.status === 0 && r.stdout?.length) ? r.stdout : null;
823
470
  }
824
471
 
825
- fs.rmSync(zipPath, { force: true });
826
- }
827
-
828
- function shouldInstallPlatformToolsArchive() {
829
- const localAdb = path.join(SDK_ROOT, 'platform-tools', process.platform === 'win32' ? 'adb.exe' : 'adb');
830
- return !isExecutable(localAdb);
831
- }
472
+ #saveArtifact(data) {
473
+ if (!data || !this.artifactStore) return null;
474
+ const alloc = this.artifactStore.allocateFile(this.userId, { kind: 'screenshot', extension: 'png', contentType: 'image/png' });
475
+ fs.writeFileSync(alloc.storagePath, data);
476
+ const fin = this.artifactStore.finalizeFile(alloc.artifactId, alloc.storagePath);
477
+ return fin.url;
478
+ }
832
479
 
833
- async function installSystemImageArchive(metadata, packageName) {
834
- const url = parseLatestSystemImageUrl(metadata, packageName);
835
- const zipPath = path.join(TMP_DIR, path.basename(url));
836
- const targetRoot = path.join(activeAndroidSdkRoot(), ...String(packageName).split(';').filter(Boolean));
837
- const extractDir = fs.mkdtempSync(path.join(TMP_DIR, 'system-image-'));
838
-
839
- try {
840
- fs.rmSync(targetRoot, { recursive: true, force: true });
841
- fs.mkdirSync(path.dirname(targetRoot), { recursive: true });
842
- await downloadFile(url, zipPath);
843
- extractZip(zipPath, extractDir);
844
-
845
- const extractedRoot = findDirectoryContainingFiles(extractDir, ['userdata.img']) ||
846
- findDirectoryContainingFiles(extractDir, ['system.img']) ||
847
- findDirectoryContainingFiles(extractDir, ['package.xml']);
848
- if (!extractedRoot) {
849
- throw new Error(`Downloaded Android system image archive for ${packageName} did not contain the expected files`);
850
- }
480
+ // ── Setup pipeline ────────────────────────────────────────────────────────
851
481
 
852
- fs.rmSync(zipPath, { force: true });
853
- try {
854
- fs.renameSync(extractedRoot, targetRoot);
855
- } catch (renameErr) {
856
- fs.cpSync(extractedRoot, targetRoot, { recursive: true, force: true });
857
- if (renameErr) {
858
- console.warn(`[Android] Falling back to copy for ${packageName}: ${renameErr.message}`);
482
+ async #resolveAdbPort() {
483
+ const base = (hashCode(this.userId) >>> 0) % ADB_PORT_SLOTS;
484
+ for (let i = 0; i < ADB_PORT_SLOTS; i++) {
485
+ const slot = (base + i) % ADB_PORT_SLOTS;
486
+ const port = ADB_PORT_BASE + slot * 2;
487
+ const free = await new Promise(resolve => {
488
+ const srv = net.createServer();
489
+ srv.listen(port, '127.0.0.1', () => srv.close(() => resolve(true)));
490
+ srv.on('error', () => resolve(false));
491
+ });
492
+ if (free) {
493
+ this.adbPort = port;
494
+ this.adbSerial = `emulator-${port}`;
495
+ return;
859
496
  }
860
497
  }
861
- } finally {
862
- fs.rmSync(zipPath, { force: true });
863
- fs.rmSync(extractDir, { recursive: true, force: true });
498
+ throw new Error(`No free ADB port in range ${ADB_PORT_BASE}–${ADB_PORT_BASE + ADB_PORT_SLOTS * 2}`);
864
499
  }
865
- }
866
-
867
- function systemImageTagScore(tag) {
868
- const value = String(tag || '').toLowerCase();
869
- if (value.startsWith('google_apis_playstore')) return 80;
870
- if (value.startsWith('google_apis')) return 60;
871
- if (value === 'default') return 40;
872
- if (value === 'google_atd') return 20;
873
- if (value === 'aosp_atd') return 10;
874
- return 0;
875
- }
876
500
 
877
- function parseSystemImageCandidates(entries = []) {
878
- return entries.map((entry) => {
879
- const platform = parseSystemImagePlatform(entry.platformId);
880
- return {
881
- packageName: entry.packageName,
882
- platformId: entry.platformId,
883
- tag: entry.tag,
884
- arch: entry.arch,
885
- apiLevel: platform.apiLevel,
886
- stable: platform.stable,
887
- tagScore: systemImageTagScore(entry.tag),
501
+ async #setup() {
502
+ const progress = msg => {
503
+ console.log(`[Android] ${msg}`);
504
+ writeState(this.userId, { startupPhase: msg });
888
505
  };
889
- });
890
- }
891
-
892
- function parseSystemImages(listOutput) {
893
- const matches = [];
894
- const regex = /system-images;(android-[^;\s]+);([^;\s]+);([^;\s]+)/g;
895
- let match = regex.exec(listOutput);
896
- while (match) {
897
- matches.push({
898
- packageName: match[0],
899
- platformId: match[1],
900
- tag: match[2],
901
- arch: match[3],
902
- });
903
- match = regex.exec(listOutput);
904
- }
905
-
906
- return parseSystemImageCandidates(matches);
907
- }
908
-
909
- function parseInstalledSystemImages() {
910
- const root = path.join(activeAndroidSdkRoot(), 'system-images');
911
- if (!fs.existsSync(root)) {
912
- return [];
913
- }
914
-
915
- const matches = [];
916
- const platforms = fs.readdirSync(root, { withFileTypes: true })
917
- .filter((entry) => entry.isDirectory())
918
- .map((entry) => entry.name);
919
-
920
- for (const platformId of platforms) {
921
- const platformDir = path.join(root, platformId);
922
- const tags = fs.readdirSync(platformDir, { withFileTypes: true })
923
- .filter((entry) => entry.isDirectory())
924
- .map((entry) => entry.name);
925
- for (const tag of tags) {
926
- const tagDir = path.join(platformDir, tag);
927
- const archs = fs.readdirSync(tagDir, { withFileTypes: true })
928
- .filter((entry) => entry.isDirectory())
929
- .map((entry) => entry.name);
930
- for (const arch of archs) {
931
- const packageXml = path.join(tagDir, arch, 'package.xml');
932
- if (!fs.existsSync(packageXml)) {
933
- continue;
934
- }
935
- matches.push({
936
- packageName: `system-images;${platformId};${tag};${arch}`,
937
- platformId,
938
- tag,
939
- arch,
940
- });
506
+ try {
507
+ await this.#resolveAdbPort();
508
+ const existing = findExistingSdk();
509
+ if (existing) {
510
+ this.sdkDir = existing;
511
+ progress(`Found existing Android SDK at ${existing}`);
512
+ } else {
513
+ progress('Downloading Android SDK…');
514
+ await ensureSdk(this.sdkDir, progress);
515
+ await ensurePackages(this.sdkDir, progress);
941
516
  }
517
+ ensureAvd(this.sdkDir, this.avdName, progress);
518
+ await this.#startEmulatorProcess(progress);
519
+ } catch (err) {
520
+ console.error(`[Android] Setup failed: ${err.message}`);
521
+ writeState(this.userId, { starting: false, startupPhase: 'Failed', lastStartError: err.message });
942
522
  }
943
523
  }
944
524
 
945
- return parseSystemImageCandidates(matches);
946
- }
947
-
948
- function chooseStableRuntimeSystemImage(candidates, currentPackage) {
949
- const normalizedCurrent = String(currentPackage || '').trim();
950
- const pool = Array.isArray(candidates)
951
- ? candidates.filter((item) => item && item.packageName && isValidInstalledSystemImage(item.packageName))
952
- : [];
953
- if (pool.length === 0) return null;
954
- const ranked = rankSystemImagePool(pool);
955
- const recommended = ranked.find((item) =>
956
- item.arch === 'arm64-v8a'
957
- && item.stable
958
- && item.apiLevel >= 33
959
- ) || ranked[0];
960
- if (!recommended) return null;
961
- if (normalizedCurrent && recommended.packageName === normalizedCurrent) {
962
- return null;
963
- }
964
- return recommended;
965
- }
966
-
967
- function rankSystemImagePool(pool) {
968
- const preferredMatches = pool.filter((candidate) => candidate.tagScore > 0);
969
- const rankedPool = preferredMatches.length > 0 ? preferredMatches : pool;
970
-
971
- rankedPool.sort((a, b) =>
972
- Number(b.stable) - Number(a.stable) ||
973
- b.tagScore - a.tagScore ||
974
- b.apiLevel - a.apiLevel ||
975
- a.packageName.localeCompare(b.packageName)
976
- );
977
-
978
- return rankedPool;
979
- }
980
-
981
- function chooseConfiguredSystemImage(listOutput) {
982
- const matches = Array.isArray(listOutput)
983
- ? parseSystemImageCandidates(listOutput)
984
- : parseSystemImages(listOutput);
985
- const packageName = configuredSystemImagePackage();
986
- if (packageName) {
987
- return matches.find((candidate) => candidate.packageName === packageName) || null;
988
- }
989
-
990
- const platformId = configuredSystemImagePlatform();
991
- if (!platformId) return null;
992
-
993
- const pool = matches.filter((candidate) => candidate.platformId === platformId);
994
- if (pool.length === 0) return null;
995
-
996
- let archPool = [];
997
- for (const arch of systemImageArchCandidates()) {
998
- archPool = pool.filter((candidate) => candidate.arch === arch);
999
- if (archPool.length > 0) break;
1000
- }
1001
-
1002
- return rankSystemImagePool(archPool.length > 0 ? archPool : pool)[0] || null;
1003
- }
1004
-
1005
- function chooseLatestSystemImage(listOutput, preferredArchs = systemImageArchCandidates()) {
1006
- const matches = Array.isArray(listOutput)
1007
- ? parseSystemImageCandidates(listOutput)
1008
- : parseSystemImages(listOutput);
1009
- const archPool = Array.isArray(preferredArchs) && preferredArchs.length > 0
1010
- ? preferredArchs
1011
- : systemImageArchCandidates();
1012
-
1013
- let pool = [];
1014
- for (const arch of archPool) {
1015
- pool = matches.filter((candidate) => candidate.arch === arch);
1016
- if (pool.length > 0) break;
1017
- }
1018
-
1019
- if (pool.length === 0) {
1020
- pool = matches;
1021
- }
1022
-
1023
- return rankSystemImagePool(pool)[0] || null;
1024
- }
525
+ async #startEmulatorProcess(progress) {
526
+ progress('Starting Android emulator…');
527
+ const env = { ...process.env, ANDROID_SDK_ROOT: this.sdkDir, ANDROID_HOME: this.sdkDir };
528
+ const proc = spawn(emulatorBin(this.sdkDir), [
529
+ '-avd', this.avdName,
530
+ '-no-window', '-no-audio', '-no-boot-anim',
531
+ '-port', String(this.adbPort),
532
+ '-gpu', 'swiftshader_indirect',
533
+ '-partition-size', '800',
534
+ ], { env, detached: false, stdio: ['ignore', 'pipe', 'pipe'] });
1025
535
 
1026
- function formatSystemImageError(listOutput) {
1027
- const candidates = Array.isArray(listOutput)
1028
- ? parseSystemImageCandidates(listOutput)
1029
- : parseSystemImages(listOutput);
1030
- const availableArchs = [...new Set(candidates.map((candidate) => candidate.arch))].sort();
1031
- const wantedArchs = systemImageArchCandidates().join(', ');
1032
- const packageName = configuredSystemImagePackage();
1033
- const platformId = configuredSystemImagePlatform();
1034
- const available = availableArchs.length > 0 ? availableArchs.join(', ') : 'none';
1035
- const overrideDetails = [
1036
- packageName ? `package=${packageName}` : null,
1037
- platformId ? `platform=${platformId}` : null,
1038
- ].filter(Boolean);
1039
- const overrideText = overrideDetails.length > 0 ? ` Configured override: ${overrideDetails.join(', ')}.` : '';
1040
- return `No compatible Android system image found. Preferred architectures: ${wantedArchs}. Available architectures: ${available}.${overrideText}`;
1041
- }
536
+ proc.stdout.on('data', d => console.log(`[Android/emu] ${d.toString().trimEnd()}`));
537
+ proc.stderr.on('data', d => console.log(`[Android/emu] ${d.toString().trimEnd()}`));
538
+ writeState(this.userId, { pid: proc.pid, adbSerial: this.adbSerial });
1042
539
 
1043
- function parseApiLevelFromSystemImage(packageName) {
1044
- const match = String(packageName || '').match(/system-images;android-(\d+);/);
1045
- return match ? Number(match[1] || 0) : 0;
1046
- }
540
+ proc.on('exit', code => {
541
+ console.log(`[Android] Emulator exited with code ${code}`);
542
+ writeState(this.userId, { bootstrapped: false, starting: false, pid: null });
543
+ });
1047
544
 
1048
- function androidTextEscape(text) {
1049
- return String(text || '')
1050
- .replace(/\\/g, '\\\\')
1051
- .replace(/ /g, '%s')
1052
- .replace(/"/g, '\\"')
1053
- .replace(/'/g, "\\'")
1054
- .replace(/[&()<>|;$`]/g, '');
1055
- }
545
+ progress('Waiting for Android to boot (can take 2–5 min on first run)…');
546
+ await this.#waitForBoot();
1056
547
 
1057
- function quoteShell(value) {
1058
- return `'${String(value || '').replace(/'/g, `'\\''`)}'`;
1059
- }
548
+ writeState(this.userId, { bootstrapped: true, starting: false, startupPhase: null, lastStartError: null });
549
+ console.log(`[Android] Emulator ready on ${this.adbSerial}`);
1060
550
 
1061
- function updateIniValue(content, key, value) {
1062
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1063
- const line = `${key}=${value}`;
1064
- if (new RegExp(`^${escapedKey}=.*$`, 'm').test(content)) {
1065
- return content.replace(new RegExp(`^${escapedKey}=.*$`, 'm'), line);
551
+ // Set wallpaper — best-effort, never fails the boot sequence.
552
+ this.#setWallpaper(this.adbSerial).catch(err => {
553
+ console.warn(`[Android] Wallpaper not set: ${err.message}`);
554
+ });
1066
555
  }
1067
- return `${content.replace(/\s*$/, '')}\n${line}\n`;
1068
- }
1069
-
1070
- function readIniValue(content, key) {
1071
- const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1072
- const match = content.match(new RegExp(`^${escapedKey}=(.*)$`, 'm'));
1073
- return match ? String(match[1] || '').trim() : null;
1074
- }
1075
556
 
1076
- function systemImagePackageToRelativeDir(packageName) {
1077
- const parts = String(packageName || '').split(';').filter(Boolean);
1078
- if (parts.length !== 4 || parts[0] !== 'system-images') {
1079
- return null;
557
+ async #waitForBoot(timeoutMs = 10 * 60 * 1000) {
558
+ const adb = adbBin(this.sdkDir);
559
+ const deadline = Date.now() + timeoutMs;
560
+ while (Date.now() < deadline) {
561
+ try {
562
+ const r = spawnSync(adb, ['-s', this.adbSerial, 'shell', 'getprop', 'sys.boot_completed'], { encoding: 'utf8', timeout: 5000 });
563
+ if (r.stdout?.trim() === '1') return;
564
+ } catch {}
565
+ await new Promise(r => setTimeout(r, 3000));
566
+ }
567
+ throw new Error('Emulator did not boot within timeout');
1080
568
  }
1081
- return `${parts.join('/')}/`;
1082
- }
1083
-
1084
- function isValidInstalledSystemImage(packageName) {
1085
- const relativeDir = systemImagePackageToRelativeDir(packageName);
1086
- if (!relativeDir) return false;
1087
- const root = path.join(activeAndroidSdkRoot(), relativeDir);
1088
- const required = [
1089
- path.join(root, 'package.xml'),
1090
- path.join(root, 'system.img'),
1091
- path.join(root, 'userdata.img'),
1092
- ];
1093
- return required.every((filePath) => fs.existsSync(filePath));
1094
- }
1095
569
 
1096
- function systemImagePackageToAbi(packageName) {
1097
- const parts = String(packageName || '').split(';').filter(Boolean);
1098
- if (parts.length !== 4 || parts[0] !== 'system-images') {
1099
- return null;
1100
- }
1101
- return parts[3] || null;
1102
- }
570
+ async #setWallpaper(serial) {
571
+ if (!fs.existsSync(LOGO_PATH)) return;
572
+ const adb = adbBin(this.sdkDir);
1103
573
 
1104
- function abiToCpuArch(abi) {
1105
- const value = String(abi || '').trim().toLowerCase();
1106
- if (value === 'arm64-v8a') return 'arm64';
1107
- if (value === 'armeabi-v7a' || value === 'armeabi') return 'arm';
1108
- if (value === 'x86_64') return 'x86_64';
1109
- if (value === 'x86') return 'x86';
1110
- return null;
1111
- }
574
+ // Try to gain root access (works on AOSP default images).
575
+ spawnSync(adb, ['-s', serial, 'root'], { timeout: 5000 });
576
+ await new Promise(r => setTimeout(r, 1500));
1112
577
 
1113
- function systemImagePackageToCpuArch(packageName) {
1114
- return abiToCpuArch(systemImagePackageToAbi(packageName));
1115
- }
578
+ // Push PNG to device sdcard.
579
+ const push = spawnSync(adb, ['-s', serial, 'push', LOGO_PATH, '/sdcard/neoagent-wallpaper.png'], { timeout: 15000 });
580
+ if (push.status !== 0) throw new Error('adb push logo failed');
1116
581
 
1117
- function describeAutoFixChanges(current, next, fields = []) {
1118
- return fields
1119
- .map((field) => {
1120
- const before = current?.[field] ?? null;
1121
- const after = next?.[field] ?? null;
1122
- if (before === after) return null;
1123
- return `${field}: ${before ?? 'null'} -> ${after ?? 'null'}`;
1124
- })
1125
- .filter(Boolean)
1126
- .join(', ');
1127
- }
582
+ // cmd wallpaper set-stream reads PNG from stdin (Android 7.1+).
583
+ const logoData = fs.readFileSync(LOGO_PATH);
584
+ const r = spawnSync(adb, ['-s', serial, 'shell', 'cmd', 'wallpaper', 'set-stream'], {
585
+ input: logoData, timeout: 15000,
586
+ });
587
+ if (r.status === 0) {
588
+ console.log('[Android] Wallpaper set');
589
+ return;
590
+ }
1128
591
 
1129
- function sanitizeUiXml(raw) {
1130
- const text = String(raw || '');
1131
- const start = text.indexOf('<?xml');
1132
- const end = text.lastIndexOf('</hierarchy>');
1133
- if (start >= 0 && end >= start) {
1134
- return text.slice(start, end + '</hierarchy>'.length);
592
+ // Fallback: direct file copy for rooted images (Android 11 AOSP).
593
+ spawnSync(adb, ['-s', serial, 'shell', 'cp /sdcard/neoagent-wallpaper.png /data/system/users/0/wallpaper'], { timeout: 5000 });
594
+ spawnSync(adb, ['-s', serial, 'shell', 'chmod 600 /data/system/users/0/wallpaper'], { timeout: 5000 });
595
+ spawnSync(adb, ['-s', serial, 'shell', 'chown system:system /data/system/users/0/wallpaper'], { timeout: 5000 });
596
+ spawnSync(adb, ['-s', serial, 'shell', 'am broadcast -a android.intent.action.WALLPAPER_CHANGED'], { timeout: 5000 });
597
+ console.log('[Android] Wallpaper set via direct copy');
1135
598
  }
1136
- return text.trim();
1137
599
  }
1138
600
 
1139
- class AndroidController {
1140
- constructor(options = {}) {
1141
- this.io = options?.io;
1142
- this.userId = options?.userId != null ? String(options.userId) : null;
1143
- this.artifactStore = options?.artifactStore || null;
1144
- this.runtimeBackend = options?.runtimeBackend || 'host';
1145
- this.manageProcessCleanup = options?.manageProcessCleanup !== false;
1146
- this.scopeKey = sanitizeScopeKey(this.userId ? `user-${this.userId}` : 'default');
1147
- this.ownerKey = normalizeOwnerKey(this.userId);
1148
- this.stateFile = resolveStateFile(this.scopeKey);
1149
- this.cli = new CLIExecutor();
1150
- const state = this.#readState();
1151
- const desiredArchTag = sanitizeScopeKey(systemImageArch());
1152
- const stateMatchesArch = state?.systemImageArch === systemImageArch() && String(state?.avdName || '').includes(desiredArchTag);
1153
- this.previousAvdName = String(state?.avdName || '').trim() || null;
1154
- this.avdName = stateMatchesArch
1155
- ? state.avdName
1156
- : `neoagent-${this.scopeKey}-${desiredArchTag}`;
1157
- if (this.previousAvdName !== this.avdName) {
1158
- this.#appendState({
1159
- avdName: this.avdName,
1160
- serial: null,
1161
- emulatorPid: null,
1162
- starting: false,
1163
- startupPhase: null,
1164
- lastStartError: null,
1165
- lastLogLine: null,
1166
- });
1167
- }
1168
- if (String(state?.systemImageArch || '').trim() && state.systemImageArch !== systemImageArch()) {
1169
- this.#appendState({
1170
- serial: null,
1171
- emulatorPid: null,
1172
- bootstrapped: false,
1173
- systemImage: null,
1174
- apiLevel: null,
1175
- systemImageArch: null,
1176
- avdSystemImage: null,
1177
- starting: false,
1178
- startupPhase: null,
1179
- lastStartError: null,
1180
- lastLogLine: null,
1181
- });
1182
- }
1183
- this.bootstrapPromise = null;
1184
- this.startPromise = null;
1185
- if (this.manageProcessCleanup) {
1186
- this.#registerProcessCleanup();
1187
- }
1188
- }
1189
-
1190
- static cleanupRegistered = false;
1191
- static cleanupControllers = new Set();
1192
-
1193
- #readState() {
1194
- return readState(this.stateFile);
1195
- }
1196
-
1197
- #appendState(patch) {
1198
- return appendState(patch, this.stateFile);
1199
- }
1200
-
1201
- #readOwnership() {
1202
- return readOwnership();
1203
- }
1204
-
1205
- #writeOwnership(nextOwners) {
1206
- writeOwnership(nextOwners);
1207
- }
1208
-
1209
- #releaseSerialOwnership(serial) {
1210
- const normalizedSerial = String(serial || '').trim();
1211
- if (!normalizedSerial) return;
1212
- withOwnershipLock(() => {
1213
- const owners = readOwnershipUnlocked();
1214
- const current = owners[normalizedSerial];
1215
- if (!current || current.ownerKey !== this.ownerKey) {
1216
- return;
1217
- }
1218
-
1219
- delete owners[normalizedSerial];
1220
- writeOwnershipUnlocked(owners);
1221
- });
1222
- }
1223
-
1224
- #claimSerial(serial) {
1225
- const normalizedSerial = String(serial || '').trim();
1226
- if (!normalizedSerial) {
1227
- throw new Error('Cannot claim an empty Android serial.');
1228
- }
1229
-
1230
- withOwnershipLock(() => {
1231
- const owners = readOwnershipUnlocked();
1232
- const existing = owners[normalizedSerial];
1233
- if (existing && existing.ownerKey && existing.ownerKey !== this.ownerKey) {
1234
- throw new Error(`Android device ${normalizedSerial} is currently reserved by another user.`);
1235
- }
1236
-
1237
- owners[normalizedSerial] = {
1238
- ownerKey: this.ownerKey,
1239
- ownerUserId: this.userId,
1240
- updatedAt: new Date().toISOString(),
1241
- };
1242
- writeOwnershipUnlocked(owners);
1243
- });
1244
- }
1245
-
1246
- #isSerialOwnedByAnother(serial, owners = null) {
1247
- const normalizedSerial = String(serial || '').trim();
1248
- if (!normalizedSerial) return false;
1249
- const effectiveOwners = owners || this.#readOwnership();
1250
- const existing = effectiveOwners[normalizedSerial];
1251
- return Boolean(existing?.ownerKey && existing.ownerKey !== this.ownerKey);
1252
- }
1253
-
1254
- #assertSerialAccess(serial, options = {}) {
1255
- const normalizedSerial = String(serial || '').trim();
1256
- if (!normalizedSerial) {
1257
- throw new Error('Android serial is required.');
1258
- }
1259
-
1260
- const claimIfUnowned = options.claimIfUnowned !== false;
1261
- withOwnershipLock(() => {
1262
- const owners = readOwnershipUnlocked();
1263
- const existing = owners[normalizedSerial];
1264
-
1265
- if (existing?.ownerKey && existing.ownerKey !== this.ownerKey) {
1266
- throw new Error(`Android device ${normalizedSerial} is currently reserved by another user.`);
1267
- }
1268
-
1269
- if (claimIfUnowned) {
1270
- owners[normalizedSerial] = {
1271
- ownerKey: this.ownerKey,
1272
- ownerUserId: this.userId,
1273
- updatedAt: new Date().toISOString(),
1274
- };
1275
- writeOwnershipUnlocked(owners);
1276
- }
1277
- });
1278
- }
1279
-
1280
- #pruneOwnership(devices = []) {
1281
- withOwnershipLock(() => {
1282
- const owners = readOwnershipUnlocked();
1283
- const { next, changed } = pruneOwnershipByDevices(owners, devices);
1284
- if (changed) {
1285
- writeOwnershipUnlocked(next);
1286
- }
1287
- });
1288
- }
1289
-
1290
- #registerProcessCleanup() {
1291
- AndroidController.cleanupControllers.add(this);
1292
- if (AndroidController.cleanupRegistered) {
1293
- return;
1294
- }
1295
- AndroidController.cleanupRegistered = true;
1296
-
1297
- const cleanup = () => {
1298
- for (const controller of AndroidController.cleanupControllers) {
1299
- try {
1300
- controller.#stopTrackedEmulatorSync();
1301
- } catch {}
1302
- }
1303
- };
1304
-
1305
- process.once('exit', cleanup);
1306
- process.once('uncaughtException', cleanup);
1307
- process.once('unhandledRejection', cleanup);
1308
- }
1309
-
1310
- async #terminateStaleEmulatorProcesses(names = [this.avdName]) {
1311
- const avdNames = [...new Set((Array.isArray(names) ? names : [names]).map((name) => String(name || '').trim()).filter(Boolean))];
1312
- const pids = [];
1313
- for (const avdName of avdNames) {
1314
- const probe = spawnSync('pgrep', ['-f', `@${avdName}`], { encoding: 'utf8' });
1315
- if (probe.status !== 0) {
1316
- continue;
1317
- }
1318
- const found = String(probe.stdout || '')
1319
- .split(/\s+/)
1320
- .map((pid) => Number(pid))
1321
- .filter((pid) => Number.isInteger(pid) && pid > 0);
1322
- pids.push(...found);
1323
- }
1324
- const uniquePids = [...new Set(pids)];
1325
- if (uniquePids.length === 0) {
1326
- return;
1327
- }
1328
-
1329
- for (const pid of uniquePids) {
1330
- try { process.kill(pid, 'SIGTERM'); } catch {}
1331
- }
1332
-
1333
- await sleep(1500);
1334
-
1335
- for (const pid of uniquePids) {
1336
- try {
1337
- process.kill(pid, 0);
1338
- process.kill(pid, 'SIGKILL');
1339
- } catch {}
1340
- }
1341
- }
1342
-
1343
- #stopTrackedEmulatorSync() {
1344
- const state = this.#readState();
1345
- const serial = state.serial;
1346
-
1347
- if (serial && isExecutable(adbBinary())) {
1348
- try {
1349
- spawnSync(adbBinary(), ['-s', serial, 'emu', 'kill'], {
1350
- stdio: 'ignore',
1351
- env: sdkEnv(),
1352
- });
1353
- } catch {}
1354
- }
1355
-
1356
- if (state.emulatorPid) {
1357
- try {
1358
- process.kill(state.emulatorPid, 0);
1359
- process.kill(state.emulatorPid, 'SIGTERM');
1360
- } catch {}
1361
- }
1362
-
1363
- this.#releaseSerialOwnership(serial);
1364
- this.#appendState({ serial: null, emulatorPid: null });
1365
- }
1366
-
1367
- async #run(command, options = {}) {
1368
- const result = await this.cli.execute(command, {
1369
- timeout: options.timeout || 120000,
1370
- env: sdkEnv(),
1371
- cwd: options.cwd || ANDROID_ROOT,
1372
- });
1373
- if (result.exitCode !== 0) {
1374
- throw new Error(result.stderr || result.stdout || `Command failed: ${command}`);
1375
- }
1376
- return result.stdout || '';
1377
- }
1378
-
1379
- async #runAllowFailure(command, options = {}) {
1380
- return this.cli.execute(command, {
1381
- timeout: options.timeout || 120000,
1382
- env: sdkEnv(),
1383
- cwd: options.cwd || ANDROID_ROOT,
1384
- });
1385
- }
1386
-
1387
- async ensureBootstrapped() {
1388
- const desiredArch = systemImageArch();
1389
- const state = this.#readState();
1390
- const installedImages = parseInstalledSystemImages();
1391
- const preferredInstalled =
1392
- chooseConfiguredSystemImage(installedImages) ||
1393
- chooseLatestSystemImage(installedImages, [desiredArch]) ||
1394
- 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);
1397
- const preferredAvailable =
1398
- chooseConfiguredSystemImage(available) ||
1399
- chooseLatestSystemImage(available, [desiredArch]) ||
1400
- chooseLatestSystemImage(available);
1401
- const selectedImage = rankSystemImagePool([preferredInstalled, preferredAvailable].filter(Boolean))[0] || preferredInstalled || preferredAvailable;
1402
- const stateApiLevel = Number(state.apiLevel || 0) || 0;
1403
- const legacyLinuxArm64Image =
1404
- process.platform === 'linux'
1405
- && process.arch === 'arm64'
1406
- && /system-images;android-30;default;arm64-v8a/i.test(String(state.systemImage || ''));
1407
- const migrationTargetImage =
1408
- process.platform === 'linux' && process.arch === 'arm64'
1409
- ? (
1410
- rankSystemImagePool(
1411
- [preferredAvailable, preferredInstalled]
1412
- .filter(Boolean)
1413
- .filter((image) => image.arch === 'arm64-v8a' && image.stable && image.apiLevel >= 33)
1414
- )[0] || null
1415
- )
1416
- : null;
1417
- const effectiveSelectedImage = migrationTargetImage || selectedImage;
1418
- const selectedImageInvalid = effectiveSelectedImage?.packageName && !isValidInstalledSystemImage(effectiveSelectedImage.packageName);
1419
-
1420
- if (!shouldForceSdkRefresh() && !legacyLinuxArm64Image && !selectedImageInvalid && sharedAndroidSdkReady() && effectiveSelectedImage) {
1421
- const stateAligned =
1422
- effectiveSelectedImage.packageName === state.systemImage &&
1423
- effectiveSelectedImage.apiLevel === stateApiLevel &&
1424
- effectiveSelectedImage.arch === state.systemImageArch &&
1425
- state.avdName === this.avdName;
1426
-
1427
- if (stateAligned) {
1428
- return;
1429
- }
1430
-
1431
- if (effectiveSelectedImage === preferredInstalled && effectiveSelectedImage.packageName) {
1432
- const changeSummary = describeAutoFixChanges(
1433
- {
1434
- avdName: state.avdName || null,
1435
- systemImage: state.systemImage || null,
1436
- apiLevel: stateApiLevel || null,
1437
- systemImageArch: state.systemImageArch || null,
1438
- },
1439
- {
1440
- avdName: this.avdName,
1441
- systemImage: effectiveSelectedImage.packageName,
1442
- apiLevel: effectiveSelectedImage.apiLevel,
1443
- systemImageArch: effectiveSelectedImage.arch,
1444
- },
1445
- ['avdName', 'systemImage', 'apiLevel', 'systemImageArch']
1446
- );
1447
- if (changeSummary) {
1448
- console.log(`[Android] Auto-fixed host SDK state (${changeSummary})`);
1449
- }
1450
- this.#appendState({
1451
- bootstrapped: true,
1452
- avdName: this.avdName,
1453
- serial: null,
1454
- emulatorPid: null,
1455
- systemImage: effectiveSelectedImage.packageName,
1456
- apiLevel: effectiveSelectedImage.apiLevel,
1457
- systemImageArch: effectiveSelectedImage.arch,
1458
- });
1459
- return;
1460
- }
1461
- }
1462
-
1463
- const binariesReady =
1464
- isExecutable(adbBinary()) &&
1465
- isExecutable(emulatorBinary()) &&
1466
- installedEmulatorMatchesHost();
1467
- if (!binariesReady) {
1468
- if (this.bootstrapPromise) {
1469
- await this.bootstrapPromise;
1470
- } else {
1471
- this.bootstrapPromise = this.#bootstrapRuntime();
1472
- try {
1473
- await this.bootstrapPromise;
1474
- } finally {
1475
- this.bootstrapPromise = null;
1476
- }
1477
- }
1478
- }
1479
-
1480
- const runtimeNeedsRefresh =
1481
- state.systemImageArch !== desiredArch ||
1482
- !installedEmulatorMatchesHost();
1483
- if (!shouldForceSdkRefresh() && !legacyLinuxArm64Image && !selectedImageInvalid) {
1484
- if (!runtimeNeedsRefresh && effectiveSelectedImage && effectiveSelectedImage === preferredInstalled) {
1485
- const stateNeedsRefresh =
1486
- effectiveSelectedImage.packageName !== state.systemImage ||
1487
- effectiveSelectedImage.apiLevel !== stateApiLevel ||
1488
- effectiveSelectedImage.arch !== state.systemImageArch ||
1489
- state.avdName !== this.avdName;
1490
- if (stateNeedsRefresh) {
1491
- const changeSummary = describeAutoFixChanges(
1492
- {
1493
- avdName: state.avdName || null,
1494
- systemImage: state.systemImage || null,
1495
- apiLevel: stateApiLevel || null,
1496
- systemImageArch: state.systemImageArch || null,
1497
- },
1498
- {
1499
- avdName: this.avdName,
1500
- systemImage: effectiveSelectedImage.packageName,
1501
- apiLevel: effectiveSelectedImage.apiLevel,
1502
- systemImageArch: effectiveSelectedImage.arch,
1503
- },
1504
- ['avdName', 'systemImage', 'apiLevel', 'systemImageArch']
1505
- );
1506
- if (changeSummary) {
1507
- console.log(`[Android] Auto-fixed preferred system image (${changeSummary})`);
1508
- }
1509
- this.#appendState({
1510
- bootstrapped: true,
1511
- avdName: this.avdName,
1512
- serial: null,
1513
- emulatorPid: null,
1514
- systemImage: effectiveSelectedImage.packageName,
1515
- apiLevel: effectiveSelectedImage.apiLevel,
1516
- systemImageArch: effectiveSelectedImage.arch,
1517
- });
1518
- }
1519
- return;
1520
- }
1521
- if (
1522
- !runtimeNeedsRefresh &&
1523
- state.bootstrapped === true &&
1524
- state.systemImage &&
1525
- effectiveSelectedImage &&
1526
- effectiveSelectedImage.packageName === state.systemImage
1527
- ) {
1528
- return;
1529
- }
1530
- }
1531
-
1532
- this.#appendState({ bootstrapped: true });
1533
- const metadata = await fetchEmulatorMetadata();
1534
- if (shouldInstallPlatformToolsArchive()) {
1535
- await installPlatformToolsArchive(metadata);
1536
- }
1537
- await installEmulatorArchive(metadata);
1538
- if (!effectiveSelectedImage) {
1539
- throw new Error(formatSystemImageError(available));
1540
- }
1541
- if (effectiveSelectedImage?.packageName) {
1542
- await installSystemImageArchive(systemImageMetadata, effectiveSelectedImage.packageName);
1543
- }
1544
- this.#appendState({
1545
- bootstrapped: true,
1546
- avdName: this.avdName,
1547
- systemImage: effectiveSelectedImage.packageName,
1548
- apiLevel: effectiveSelectedImage.apiLevel,
1549
- systemImageArch: effectiveSelectedImage.arch,
1550
- avdSystemImage: null,
1551
- });
1552
- }
1553
-
1554
- async #bootstrapRuntime() {
1555
- const metadata = await fetchEmulatorMetadata();
1556
- const url = parseLatestCmdlineToolsUrl(metadata);
1557
- const zipPath = path.join(TMP_DIR, path.basename(url));
1558
- const extractDir = path.join(TMP_DIR, `cmdline-tools-${Date.now()}`);
1559
-
1560
- fs.mkdirSync(extractDir, { recursive: true });
1561
- await downloadFile(url, zipPath);
1562
- extractZip(zipPath, extractDir);
1563
-
1564
- const candidates = [
1565
- path.join(extractDir, 'cmdline-tools'),
1566
- path.join(extractDir, 'tools'),
1567
- extractDir,
1568
- ];
1569
- const extractedRoot = candidates.find((candidate) => fs.existsSync(path.join(candidate, 'bin')));
1570
- if (!extractedRoot) throw new Error('Downloaded Android command line tools archive did not contain a bin directory');
1571
-
1572
- fs.rmSync(CMDLINE_LATEST, { recursive: true, force: true });
1573
- fs.mkdirSync(CMDLINE_ROOT, { recursive: true });
1574
- fs.cpSync(extractedRoot, CMDLINE_LATEST, { recursive: true });
1575
- fs.rmSync(zipPath, { force: true });
1576
- fs.rmSync(extractDir, { recursive: true, force: true });
1577
- if (shouldInstallPlatformToolsArchive()) {
1578
- await installPlatformToolsArchive(metadata);
1579
- }
1580
- await installEmulatorArchive(metadata);
1581
-
1582
- const systemImageMetadata = await fetchText('https://dl.google.com/android/repository/sys-img/android/sys-img2-1.xml');
1583
- const available = parseRepositorySystemImages(systemImageMetadata);
1584
- const systemImage = chooseConfiguredSystemImage(available) || chooseLatestSystemImage(available);
1585
- if (!systemImage) throw new Error(formatSystemImageError(available));
1586
-
1587
- await installSystemImageArchive(systemImageMetadata, systemImage.packageName);
1588
- this.#appendState({
1589
- bootstrapped: true,
1590
- systemImage: systemImage.packageName,
1591
- apiLevel: systemImage.apiLevel,
1592
- systemImageArch: systemImage.arch,
1593
- });
1594
- }
1595
-
1596
- async bootstrapEmulator(options = {}) {
1597
- return this.#startEmulatorBlocking(options);
1598
- }
1599
-
1600
- markBootstrapFailure(error) {
1601
- 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.');
1604
- this.#appendState({
1605
- starting: false,
1606
- startupPhase: 'Start failed',
1607
- lastStartError: detailedMessage,
1608
- lastLogLine: detailedMessage,
1609
- bootstrapWorkerPid: null,
1610
- });
1611
- return detailedMessage;
1612
- }
1613
-
1614
- async ensureAvd() {
1615
- await this.ensureBootstrapped();
1616
-
1617
- const state = this.#readState();
1618
- let pkg = state.systemImage;
1619
- if (!pkg) throw new Error('Android system image not installed');
1620
- if (process.platform === 'linux' && process.arch === 'arm64') {
1621
- const installedCandidates = parseInstalledSystemImages();
1622
- const migratedImage = chooseStableRuntimeSystemImage(installedCandidates, pkg);
1623
- if (migratedImage) {
1624
- pkg = migratedImage.packageName;
1625
- this.#appendState({
1626
- systemImage: pkg,
1627
- apiLevel: migratedImage.apiLevel,
1628
- systemImageArch: migratedImage.arch,
1629
- avdSystemImage: null,
1630
- lastLogLine: `Migrated Android runtime image to ${pkg} for stability.`,
1631
- });
1632
- }
1633
- }
1634
- const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1635
- const configPath = path.join(avdDir, 'config.ini');
1636
- 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 {}
1694
- }
1695
-
1696
- if (avdNeedsRecreate) {
1697
- if (avdRecreateReasons.length > 0) {
1698
- console.log(`[Android] Recreating AVD to repair config mismatch (${avdRecreateReasons.join(', ')})`);
1699
- }
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
- }
1708
-
1709
- this.#writeAvdFiles(pkg);
1710
- this.#normalizeAvdConfig();
1711
- this.#appendState({ avdSystemImage: pkg });
1712
- }
1713
-
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
- #normalizeAvdConfig() {
1787
- const configPath = path.join(AVD_HOME, `${this.avdName}.avd`, 'config.ini');
1788
- if (!fs.existsSync(configPath)) return;
1789
-
1790
- let content = fs.readFileSync(configPath, 'utf8');
1791
- content = updateIniValue(content, 'disk.dataPartition.size', DEFAULT_DATA_PARTITION_BYTES);
1792
- content = updateIniValue(content, 'sdcard.size', DEFAULT_SDCARD_SIZE_BYTES);
1793
- content = updateIniValue(content, 'hw.ramSize', DEFAULT_RAM_SIZE_MB);
1794
- content = updateIniValue(content, 'hw.gpu.mode', emulatorGpuMode());
1795
- content = updateIniValue(content, 'fastboot.forceColdBoot', 'yes');
1796
- content = updateIniValue(content, 'fastboot.forceFastBoot', 'no');
1797
- content = updateIniValue(content, 'PlayStore.enabled', String(this.#readState()?.systemImage || '').includes('playstore'));
1798
- fs.writeFileSync(configPath, content);
1799
- }
1800
-
1801
- #cleanupAvdTransientState() {
1802
- const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1803
- const transientTargets = [
1804
- 'cache.img',
1805
- 'cache.img.qcow2',
1806
- 'hardware-qemu.ini.lock',
1807
- 'multiinstance.lock',
1808
- 'snapshot.lock',
1809
- 'quickbootChoice.ini',
1810
- 'launchParams.txt',
1811
- 'emu-launch-params.txt',
1812
- 'bootcompleted.ini',
1813
- 'userdata-qemu.img.lock',
1814
- 'encryptionkey.img',
1815
- ];
1816
-
1817
- for (const target of transientTargets) {
1818
- fs.rmSync(path.join(avdDir, target), { force: true, recursive: true });
1819
- }
1820
-
1821
- for (const entry of ['snapshots', '.lock']) {
1822
- fs.rmSync(path.join(avdDir, entry), { force: true, recursive: true });
1823
- }
1824
-
1825
- try {
1826
- for (const entry of fs.readdirSync(avdDir)) {
1827
- if (/\.lock$/i.test(entry) || /\.tmp$/i.test(entry)) {
1828
- fs.rmSync(path.join(avdDir, entry), { force: true, recursive: true });
1829
- }
1830
- }
1831
- } catch {}
1832
- }
1833
-
1834
- async #forceRecreateAvdForRecovery() {
1835
- const avdDir = path.join(AVD_HOME, `${this.avdName}.avd`);
1836
- await this.stopEmulator().catch(() => {});
1837
- fs.rmSync(avdDir, { recursive: true, force: true });
1838
- fs.rmSync(path.join(AVD_HOME, `${this.avdName}.ini`), { force: true });
1839
- this.#appendState({
1840
- avdSystemImage: null,
1841
- serial: null,
1842
- emulatorPid: null,
1843
- lastLogLine: 'Recreating AVD after failed framework boot.',
1844
- });
1845
- await this.ensureAvd();
1846
- this.#cleanupAvdTransientState();
1847
- }
1848
-
1849
- async listDevices(options = {}) {
1850
- if (options.ensureBootstrapped !== false) {
1851
- await this.ensureBootstrapped();
1852
- }
1853
- if (!isExecutable(adbBinary())) {
1854
- return [];
1855
- }
1856
- const out = await this.#run(`${quoteShell(adbBinary())} devices -l`);
1857
- const lines = out.split('\n').map((line) => line.trim()).filter(Boolean);
1858
- const devices = lines
1859
- .filter((line) => !line.toLowerCase().startsWith('list of devices'))
1860
- .map((line) => {
1861
- const parts = line.split(/\s+/);
1862
- return {
1863
- serial: parts[0] || '',
1864
- status: parts[1] || 'unknown',
1865
- details: parts.slice(2).join(' '),
1866
- emulator: (parts[0] || '').startsWith('emulator-'),
1867
- };
1868
- });
1869
- this.#pruneOwnership(devices);
1870
- return devices;
1871
- }
1872
-
1873
- async getPrimarySerial(options = {}) {
1874
- const state = this.#readState();
1875
- const devices = await this.listDevices(options);
1876
- const owners = this.#readOwnership();
1877
- const canUse = (device) => device.status === 'device' && !this.#isSerialOwnedByAnother(device.serial, owners);
1878
-
1879
- const preferred = state.serial ? devices.find((device) => device.serial === state.serial && canUse(device)) : null;
1880
- if (preferred) {
1881
- this.#claimSerial(preferred.serial);
1882
- return preferred.serial;
1883
- }
1884
-
1885
- const emulator = devices.find((device) => device.emulator && canUse(device));
1886
- if (emulator) {
1887
- this.#claimSerial(emulator.serial);
1888
- this.#appendState({ serial: emulator.serial });
1889
- return emulator.serial;
1890
- }
1891
-
1892
- const online = devices.find((device) => canUse(device));
1893
- if (online) {
1894
- this.#claimSerial(online.serial);
1895
- this.#appendState({ serial: online.serial });
1896
- return online.serial;
1897
- }
1898
-
1899
- return null;
1900
- }
1901
-
1902
- async #startEmulatorBlocking(options = {}) {
1903
- this.#appendState({
1904
- starting: true,
1905
- startupPhase: 'Preparing Android runtime',
1906
- lastStartError: null,
1907
- startRequestedAt: this.#readState().startRequestedAt || new Date().toISOString(),
1908
- });
1909
- 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();
1919
- if (serial) {
1920
- this.#appendState({
1921
- starting: false,
1922
- startupPhase: null,
1923
- serial,
1924
- lastStartError: null,
1925
- lastLogLine: 'Android device already running.',
1926
- });
1927
- return {
1928
- success: true,
1929
- serial,
1930
- reused: true,
1931
- bootstrapped: this.#readState().bootstrapped === true,
1932
- };
1933
- }
1934
-
1935
- const logPath = path.join(LOGS_DIR, `emulator-${Date.now()}.log`);
1936
- const out = fs.openSync(logPath, 'a');
1937
- const args = [
1938
- `@${this.avdName}`,
1939
- '-no-boot-anim',
1940
- ...emulatorLaunchArgs(),
1941
- '-data',
1942
- path.join(AVD_HOME, `${this.avdName}.avd`, 'userdata-qemu.img'),
1943
- '-gpu',
1944
- emulatorGpuMode(),
1945
- '-accel',
1946
- 'auto',
1947
- '-partition-size',
1948
- String(DEFAULT_PARTITION_SIZE_MB),
1949
- '-netdelay',
1950
- 'none',
1951
- '-netspeed',
1952
- 'full',
1953
- ];
1954
-
1955
- const child = spawn(emulatorBinary(), args, {
1956
- detached: true,
1957
- stdio: ['ignore', out, out],
1958
- env: sdkEnv(),
1959
- });
1960
-
1961
- console.log(`[Android] Emulator process started (pid ${child.pid})`);
1962
- this.#appendState({
1963
- emulatorPid: child.pid,
1964
- avdName: this.avdName,
1965
- logPath,
1966
- starting: true,
1967
- startupPhase: 'Waiting for Android emulator to boot',
1968
- lastStartError: null,
1969
- lastLogLine: 'Android emulator process started. Waiting for boot completion...',
1970
- });
1971
- child.unref();
1972
- let onlineSerial;
1973
- try {
1974
- onlineSerial = await this.waitForDevice({ timeoutMs: options.timeoutMs || 600000 });
1975
- } 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.');
1981
- await this.stopEmulator().catch(() => {});
1982
- if (!options._recoveredOnce && isRecoverableEmulatorStartError(lastLine)) {
1983
- console.warn(`[Android] Recoverable emulator start failure detected. Cleaning transient AVD state and retrying once: ${lastLine}`);
1984
- this.#cleanupAvdTransientState();
1985
- return this.#startEmulatorBlocking({ ...options, _recoveredOnce: true });
1986
- }
1987
- if (!options._recreatedAvdOnce && isRecoverableEmulatorStartError(lastLine)) {
1988
- console.warn(`[Android] Emulator recovery escalation: recreating AVD and retrying once: ${lastLine}`);
1989
- await this.#forceRecreateAvdForRecovery();
1990
- return this.#startEmulatorBlocking({ ...options, _recoveredOnce: true, _recreatedAvdOnce: true });
1991
- }
1992
- this.markBootstrapFailure(lastLine);
1993
- throw new Error(lastLine);
1994
- }
1995
- this.#appendState({
1996
- serial: onlineSerial,
1997
- emulatorPid: child.pid,
1998
- starting: false,
1999
- startupPhase: null,
2000
- lastStartError: null,
2001
- lastLogLine: 'Android emulator boot completed.',
2002
- });
2003
- console.log(`[Android] Emulator ready on ${onlineSerial}`);
2004
-
2005
- return {
2006
- success: true,
2007
- serial: onlineSerial,
2008
- emulatorPid: child.pid,
2009
- logPath,
2010
- };
2011
- }
2012
-
2013
- async startEmulator(options = {}) {
2014
- if (this.startPromise) {
2015
- await this.startPromise;
2016
- const serial = await this.getPrimarySerial();
2017
- if (!serial) {
2018
- throw new Error(this.#readState().lastStartError || 'Android emulator did not finish starting.');
2019
- }
2020
- return {
2021
- success: true,
2022
- serial,
2023
- reused: false,
2024
- bootstrapped: this.#readState().bootstrapped === true,
2025
- };
2026
- }
2027
-
2028
- return this.#startEmulatorBlocking(options);
2029
- }
2030
-
2031
- async requestStartEmulator(options = {}) {
2032
- const serial = await this.getPrimarySerial({ ensureBootstrapped: false }).catch(() => null);
2033
- if (serial) {
2034
- this.#appendState({
2035
- starting: false,
2036
- startupPhase: null,
2037
- serial,
2038
- lastStartError: null,
2039
- lastLogLine: 'Android device already running.',
2040
- });
2041
- return {
2042
- success: true,
2043
- pending: false,
2044
- serial,
2045
- reused: true,
2046
- bootstrapped: this.#readState().bootstrapped === true,
2047
- };
2048
- }
2049
-
2050
- const currentState = this.#readState();
2051
- const existingWorkerPid = Number(currentState.bootstrapWorkerPid || 0);
2052
- if (isProcessAlive(existingWorkerPid)) {
2053
- return {
2054
- success: true,
2055
- pending: true,
2056
- bootstrapped: currentState.bootstrapped === true,
2057
- starting: true,
2058
- startupPhase: currentState.startupPhase || 'Preparing Android runtime',
2059
- startRequestedAt: currentState.startRequestedAt || null,
2060
- logPath: currentState.logPath || null,
2061
- };
2062
- }
2063
-
2064
- if (existingWorkerPid) {
2065
- this.#appendState({ bootstrapWorkerPid: null });
2066
- }
2067
-
2068
- if (!this.startPromise) {
2069
- const requestedAt = new Date().toISOString();
2070
- this.#appendState({
2071
- starting: true,
2072
- startupPhase: 'Preparing Android runtime',
2073
- lastStartError: null,
2074
- startRequestedAt: requestedAt,
2075
- lastLogLine: 'Android start requested.',
2076
- });
2077
- const workerEnv = {
2078
- ...process.env,
2079
- NEOAGENT_ANDROID_BOOTSTRAP_WORKER: '1',
2080
- NEOAGENT_ANDROID_BOOTSTRAP_USER_ID: this.userId || '',
2081
- NEOAGENT_ANDROID_BOOTSTRAP_SCOPE_KEY: this.scopeKey,
2082
- NEOAGENT_ANDROID_BOOTSTRAP_HEADLESS: String(options.headless === true),
2083
- NEOAGENT_ANDROID_BOOTSTRAP_TIMEOUT_MS: String(options.timeoutMs || 600000),
2084
- };
2085
- const child = spawn(process.execPath, [ANDROID_BOOTSTRAP_WORKER], {
2086
- detached: true,
2087
- stdio: 'ignore',
2088
- env: workerEnv,
2089
- });
2090
- child.unref();
2091
- this.#appendState({
2092
- bootstrapWorkerPid: child.pid,
2093
- });
2094
- this.startPromise = new Promise((resolve) => {
2095
- child.once('exit', (code, signal) => {
2096
- this.startPromise = null;
2097
- this.#appendState({ bootstrapWorkerPid: null });
2098
- if (code !== 0) {
2099
- console.error('[Android] Emulator bootstrap worker exited', { code, signal });
2100
- }
2101
- resolve({ code, signal });
2102
- });
2103
- child.once('error', (error) => {
2104
- this.startPromise = null;
2105
- this.#appendState({ bootstrapWorkerPid: null });
2106
- console.error('[Android] Emulator bootstrap worker failed to spawn', error);
2107
- resolve({ code: null, signal: null, error });
2108
- });
2109
- });
2110
- }
2111
-
2112
- const state = this.#readState();
2113
- return {
2114
- success: true,
2115
- pending: true,
2116
- bootstrapped: state.bootstrapped === true,
2117
- starting: true,
2118
- startupPhase: state.startupPhase || 'Preparing Android runtime',
2119
- startRequestedAt: state.startRequestedAt || null,
2120
- logPath: state.logPath || null,
2121
- };
2122
- }
2123
-
2124
- async waitForDevice(options = {}) {
2125
- const timeoutMs = Math.max(10000, Number(options.timeoutMs) || 180000);
2126
- const deadline = Date.now() + timeoutMs;
2127
- let reconnectCounter = 0;
2128
- let missingPidSince = null;
2129
- let firstOnlineAt = null;
2130
- let offlineSince = null;
2131
-
2132
- while (Date.now() < deadline) {
2133
- const serial = await this.getPrimarySerial();
2134
- if (serial) {
2135
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
2136
- if (!firstOnlineAt) {
2137
- firstOnlineAt = Date.now();
2138
- }
2139
- const bootCompleted = await this.#runAllowFailure(
2140
- `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell getprop sys.boot_completed`,
2141
- { timeout: 10000 },
2142
- );
2143
- const devBootComplete = await this.#runAllowFailure(
2144
- `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell getprop dev.bootcomplete`,
2145
- { timeout: 10000 },
2146
- );
2147
- const bootAnim = await this.#runAllowFailure(
2148
- `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell getprop init.svc.bootanim`,
2149
- { timeout: 10000 },
2150
- );
2151
- const shellProbe = await this.#runAllowFailure(
2152
- `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell echo ready`,
2153
- { timeout: 10000 },
2154
- );
2155
- const packageServiceProbe = await this.#runAllowFailure(
2156
- `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell service check package`,
2157
- { timeout: 10000 },
2158
- );
2159
- const pmProbe = await this.#runAllowFailure(
2160
- `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell pm path android`,
2161
- { timeout: 10000 },
2162
- );
2163
-
2164
- const bootValue = String(bootCompleted.stdout || '').trim();
2165
- const devBootValue = String(devBootComplete.stdout || '').trim();
2166
- const bootAnimValue = String(bootAnim.stdout || '').trim().toLowerCase();
2167
- const shellReady = String(shellProbe.stdout || '').trim() === 'ready';
2168
- const packageServiceReady = /found/i.test(String(packageServiceProbe.stdout || ''));
2169
- const packageManagerReady = /^package:/m.test(String(pmProbe.stdout || '').trim());
2170
- if (
2171
- packageServiceReady
2172
- && packageManagerReady
2173
- && (
2174
- bootValue === '1'
2175
- || devBootValue === '1'
2176
- || (shellReady && (bootAnimValue === 'stopped' || bootAnimValue === ''))
2177
- )
2178
- ) {
2179
- return serial;
2180
- }
2181
- if (
2182
- firstOnlineAt
2183
- && Date.now() - firstOnlineAt > 120000
2184
- && (
2185
- !packageServiceReady
2186
- || !packageManagerReady
2187
- )
2188
- ) {
2189
- throw new Error('Android framework did not become ready (package manager service did not become ready).');
2190
- }
2191
- missingPidSince = null;
2192
- offlineSince = null;
2193
- } else {
2194
- firstOnlineAt = null;
2195
- const state = this.#readState();
2196
- const emulatorPid = Number(state.emulatorPid || 0);
2197
- if (emulatorPid > 0 && !isProcessAlive(emulatorPid)) {
2198
- throw new Error('Android emulator exited before boot completed.');
2199
- }
2200
- if (!emulatorPid) {
2201
- if (!missingPidSince) {
2202
- missingPidSince = Date.now();
2203
- }
2204
- if (Date.now() - missingPidSince > 20000) {
2205
- throw new Error('Android emulator is not running.');
2206
- }
2207
- } else {
2208
- missingPidSince = null;
2209
- const devices = await this.listDevices({ ensureBootstrapped: false }).catch(() => []);
2210
- const hasOfflineEmulator = devices.some((device) => device.emulator && device.status === 'offline');
2211
- if (hasOfflineEmulator) {
2212
- if (!offlineSince) {
2213
- offlineSince = Date.now();
2214
- }
2215
- if (Date.now() - offlineSince > 30000) {
2216
- await this.#runAllowFailure(`${quoteShell(adbBinary())} kill-server`, { timeout: 10000 });
2217
- await sleep(600);
2218
- await this.#runAllowFailure(`${quoteShell(adbBinary())} start-server`, { timeout: 15000 });
2219
- await this.#runAllowFailure(`${quoteShell(adbBinary())} reconnect`, { timeout: 15000 });
2220
- await this.#runAllowFailure(`${quoteShell(adbBinary())} wait-for-device`, { timeout: 30000 });
2221
- offlineSince = Date.now();
2222
- }
2223
- } else {
2224
- offlineSince = null;
2225
- }
2226
- }
2227
- }
2228
- reconnectCounter += 1;
2229
- if (reconnectCounter % 5 === 0) {
2230
- await this.#runAllowFailure(`${quoteShell(adbBinary())} reconnect offline`, { timeout: 10000 });
2231
- }
2232
- await sleep(3000);
2233
- }
2234
-
2235
- throw new Error(`Android emulator did not finish booting within ${timeoutMs} ms`);
2236
- }
2237
-
2238
- async ensureDevice() {
2239
- const serial = await this.getPrimarySerial();
2240
- if (serial) {
2241
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
2242
- return serial;
2243
- }
2244
- const started = await this.startEmulator();
2245
- this.#assertSerialAccess(started.serial, { claimIfUnowned: true });
2246
- return started.serial;
2247
- }
2248
-
2249
- async stopEmulator() {
2250
- const state = this.#readState();
2251
- const serial = await this.getPrimarySerial({ ensureBootstrapped: false }).catch(() => null);
2252
- if (serial) {
2253
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
2254
- await this.#runAllowFailure(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} emu kill`, { timeout: 15000 });
2255
- }
2256
- if (state.emulatorPid) {
2257
- try { process.kill(state.emulatorPid, 'SIGTERM'); } catch {}
2258
- }
2259
- this.#releaseSerialOwnership(serial);
2260
- this.#releaseSerialOwnership(state.serial);
2261
- this.#appendState({
2262
- serial: null,
2263
- emulatorPid: null,
2264
- starting: false,
2265
- startupPhase: null,
2266
- lastStartError: null,
2267
- lastLogLine: 'Android emulator stopped.',
2268
- });
2269
-
2270
- const deadline = Date.now() + 30000;
2271
- while (Date.now() < deadline) {
2272
- const devices = await this.listDevices({ ensureBootstrapped: false }).catch(() => []);
2273
- const stillPresent = devices.some((device) => device.emulator && device.status === 'device');
2274
- let pidAlive = false;
2275
- if (state.emulatorPid) {
2276
- try {
2277
- process.kill(state.emulatorPid, 0);
2278
- pidAlive = true;
2279
- } catch {
2280
- pidAlive = false;
2281
- }
2282
- }
2283
- if (!stillPresent && !pidAlive) break;
2284
- await sleep(1000);
2285
- }
2286
-
2287
- return { success: true };
2288
- }
2289
-
2290
- async #adb(serial, command, options = {}) {
2291
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
2292
- return this.#run(`${quoteShell(adbBinary())} -s ${quoteShell(serial)} ${command}`, options);
2293
- }
2294
-
2295
- async #prepareScreenForCapture(serial) {
2296
- await this.#adb(serial, 'shell input keyevent 224', { timeout: 10000 }).catch(() => {});
2297
- await this.#adb(serial, 'shell wm dismiss-keyguard', { timeout: 10000 }).catch(() => {});
2298
- await this.#adb(serial, 'shell input keyevent 82', { timeout: 10000 }).catch(() => {});
2299
- await this.#adb(serial, 'shell input keyevent 3', { timeout: 10000 }).catch(() => {});
2300
- await sleep(350);
2301
- }
2302
-
2303
- async screenshot(options = {}) {
2304
- const serial = options.serial || await this.ensureDevice();
2305
- await this.#prepareScreenForCapture(serial).catch(() => {});
2306
- let artifactRecord = null;
2307
- let filename = `android_${Date.now()}.png`;
2308
- let fullPath = path.join(SCREENSHOTS_DIR, filename);
2309
- if (this.artifactStore && this.userId != null) {
2310
- artifactRecord = this.artifactStore.allocateFile(this.userId, {
2311
- kind: 'android-screenshot',
2312
- backend: this.runtimeBackend,
2313
- extension: 'png',
2314
- contentType: 'image/png',
2315
- filenameBase: 'android-screenshot',
2316
- metadata: {
2317
- serial,
2318
- },
2319
- });
2320
- fullPath = artifactRecord.storagePath;
2321
- filename = path.basename(fullPath);
2322
- }
2323
- let captured = false;
2324
- const localTmp = path.join(TMP_DIR, `shot-${Date.now()}-${Math.random().toString(16).slice(2)}.png`);
2325
- const remoteTmp = `/sdcard/neoagent-shot-${Date.now()}.png`;
2326
- for (let attempt = 0; attempt < 3 && !captured; attempt += 1) {
2327
- try {
2328
- if (attempt === 0) {
2329
- await this.#adb(serial, `exec-out screencap -p > ${quoteShell(fullPath)}`, { timeout: 30000 });
2330
- } else {
2331
- await this.#adb(serial, `shell screencap -p ${quoteShell(remoteTmp)}`, { timeout: 30000 });
2332
- await this.#adb(serial, `pull ${quoteShell(remoteTmp)} ${quoteShell(localTmp)}`, { timeout: 30000 });
2333
- if (fs.existsSync(localTmp)) {
2334
- fs.copyFileSync(localTmp, fullPath);
2335
- }
2336
- }
2337
- const data = fs.readFileSync(fullPath);
2338
- captured = isLikelyPng(data);
2339
- } catch {}
2340
- if (!captured) {
2341
- await sleep(500);
2342
- }
2343
- }
2344
- fs.rmSync(localTmp, { force: true });
2345
- await this.#adb(serial, `shell rm -f ${quoteShell(remoteTmp)}`, { timeout: 10000 }).catch(() => {});
2346
- if (!captured) {
2347
- throw new Error('Failed to capture a valid Android screenshot.');
2348
- }
2349
- if (artifactRecord) {
2350
- this.artifactStore.finalizeFile(artifactRecord.artifactId, fullPath);
2351
- }
2352
- return {
2353
- success: true,
2354
- serial,
2355
- screenshotPath: artifactRecord ? artifactRecord.url : `/screenshots/${filename}`,
2356
- artifactId: artifactRecord?.artifactId || null,
2357
- fullPath,
2358
- };
2359
- }
2360
-
2361
- async dumpUi(options = {}) {
2362
- const serial = options.serial || await this.ensureDevice();
2363
- let xml = await this.#adb(serial, 'shell uiautomator dump --compressed /dev/tty', { timeout: 30000 });
2364
- if (!String(xml || '').includes('<hierarchy')) {
2365
- const remote = '/sdcard/neoagent-ui.xml';
2366
- await this.#adb(serial, `shell uiautomator dump --compressed ${quoteShell(remote)}`, { timeout: 30000 });
2367
- xml = await this.#adb(serial, `shell cat ${quoteShell(remote)}`, { timeout: 30000 });
2368
- }
2369
- xml = sanitizeUiXml(xml);
2370
- let artifactRecord = null;
2371
- let filename = `android_ui_${Date.now()}.xml`;
2372
- let fullPath = path.join(UI_DUMPS_DIR, filename);
2373
- if (this.artifactStore && this.userId != null) {
2374
- artifactRecord = this.artifactStore.allocateFile(this.userId, {
2375
- kind: 'android-ui-dump',
2376
- backend: this.runtimeBackend,
2377
- extension: 'xml',
2378
- contentType: 'application/xml',
2379
- filenameBase: 'android-ui',
2380
- metadata: {
2381
- serial,
2382
- },
2383
- });
2384
- fullPath = artifactRecord.storagePath;
2385
- filename = path.basename(fullPath);
2386
- }
2387
- fs.writeFileSync(fullPath, xml);
2388
- if (artifactRecord) {
2389
- this.artifactStore.finalizeFile(artifactRecord.artifactId, fullPath);
2390
- }
2391
-
2392
- const nodes = parseUiDump(xml);
2393
- return {
2394
- success: true,
2395
- serial,
2396
- nodeCount: nodes.length,
2397
- uiDumpPath: artifactRecord ? artifactRecord.url : fullPath,
2398
- uiDumpArtifactId: artifactRecord?.artifactId || null,
2399
- preview: options.includeNodes === false ? undefined : nodes.slice(0, 25).map((node) => summarizeNode(node)),
2400
- xml,
2401
- };
2402
- }
2403
-
2404
- async #captureObservation(serial, options = {}) {
2405
- const resolvedSerial = serial || await this.ensureDevice();
2406
- const observation = {
2407
- serial: resolvedSerial,
2408
- screenshotPath: null,
2409
- fullPath: null,
2410
- uiDumpPath: null,
2411
- nodeCount: null,
2412
- preview: undefined,
2413
- observationWarnings: [],
2414
- };
2415
-
2416
- if (options.screenshot !== false) {
2417
- try {
2418
- const shot = await this.screenshot({ serial: resolvedSerial });
2419
- observation.screenshotPath = shot?.screenshotPath || null;
2420
- observation.fullPath = shot?.fullPath || null;
2421
- } catch (err) {
2422
- observation.observationWarnings.push(`screenshot: ${err.message}`);
2423
- }
2424
- }
2425
-
2426
- if (options.uiDump !== false) {
2427
- try {
2428
- const dump = await this.dumpUi({
2429
- serial: resolvedSerial,
2430
- includeNodes: options.includeNodes !== false,
2431
- });
2432
- observation.uiDumpPath = dump.uiDumpPath;
2433
- observation.nodeCount = dump.nodeCount;
2434
- observation.preview = dump.preview;
2435
- } catch (err) {
2436
- observation.observationWarnings.push(`ui_dump: ${err.message}`);
2437
- }
2438
- }
2439
-
2440
- if (observation.observationWarnings.length === 0) {
2441
- delete observation.observationWarnings;
2442
- }
2443
-
2444
- return observation;
2445
- }
2446
-
2447
- async observe(options = {}) {
2448
- const serial = options.serial || await this.ensureDevice();
2449
- const observation = await this.#captureObservation(serial, options);
2450
- if (!observation.screenshotPath && !observation.uiDumpPath) {
2451
- throw new Error(
2452
- Array.isArray(observation.observationWarnings) && observation.observationWarnings.length > 0
2453
- ? observation.observationWarnings.join(' | ')
2454
- : 'Unable to capture Android observation',
2455
- );
2456
- }
2457
- return {
2458
- success: true,
2459
- ...observation,
2460
- };
2461
- }
2462
-
2463
- async #resolveSelector(args = {}) {
2464
- const dump = await this.dumpUi({ includeNodes: false });
2465
- const selector = {
2466
- text: args.text,
2467
- resourceId: args.resourceId,
2468
- description: args.description,
2469
- className: args.className,
2470
- packageName: args.packageName,
2471
- clickable: args.clickable,
2472
- };
2473
- const node = findBestNode(dump.xml, selector);
2474
- if (!node) throw new Error('No Android UI element matched the selector');
2475
- return {
2476
- serial: dump.serial,
2477
- uiDumpPath: dump.uiDumpPath,
2478
- node,
2479
- };
2480
- }
2481
-
2482
- async tap(args = {}) {
2483
- let x = Number(args.x);
2484
- let y = Number(args.y);
2485
- let node = null;
2486
- let serial = await this.ensureDevice();
2487
- let resolvedFromUiDumpPath = null;
2488
-
2489
- if (!Number.isFinite(x) || !Number.isFinite(y)) {
2490
- const resolved = await this.#resolveSelector(args);
2491
- serial = resolved.serial;
2492
- node = resolved.node;
2493
- resolvedFromUiDumpPath = resolved.uiDumpPath;
2494
- x = node.bounds.centerX;
2495
- y = node.bounds.centerY;
2496
- }
2497
-
2498
- await this.#adb(serial, `shell input tap ${Math.round(x)} ${Math.round(y)}`, { timeout: 15000 });
2499
- const observation = await this.#captureObservation(serial, args);
2500
- return {
2501
- success: true,
2502
- serial,
2503
- x: Math.round(x),
2504
- y: Math.round(y),
2505
- target: summarizeNode(node),
2506
- resolvedFromUiDumpPath,
2507
- ...observation,
2508
- };
2509
- }
2510
-
2511
- async longPress(args = {}) {
2512
- let x = Number(args.x);
2513
- let y = Number(args.y);
2514
- let node = null;
2515
- let serial = await this.ensureDevice();
2516
- let resolvedFromUiDumpPath = null;
2517
-
2518
- if (!Number.isFinite(x) || !Number.isFinite(y)) {
2519
- const resolved = await this.#resolveSelector(args);
2520
- serial = resolved.serial;
2521
- node = resolved.node;
2522
- resolvedFromUiDumpPath = resolved.uiDumpPath;
2523
- x = node.bounds.centerX;
2524
- y = node.bounds.centerY;
2525
- }
2526
-
2527
- const durationMs = Math.max(250, Number(args.durationMs) || 650);
2528
- await this.#adb(
2529
- serial,
2530
- `shell input swipe ${Math.round(x)} ${Math.round(y)} ${Math.round(x)} ${Math.round(y)} ${Math.round(durationMs)}`,
2531
- { timeout: Math.max(15000, durationMs + 5000) },
2532
- );
2533
- const observation = await this.#captureObservation(serial, args);
2534
- return {
2535
- success: true,
2536
- serial,
2537
- x: Math.round(x),
2538
- y: Math.round(y),
2539
- durationMs,
2540
- target: summarizeNode(node),
2541
- resolvedFromUiDumpPath,
2542
- ...observation,
2543
- };
2544
- }
2545
-
2546
- async type(args = {}) {
2547
- const serial = await this.ensureDevice();
2548
- if (args.clear === true) {
2549
- await this.#adb(serial, 'shell input keyevent 123', { timeout: 10000 }).catch(() => {});
2550
- await this.#adb(serial, 'shell input keyevent 67', { timeout: 10000 }).catch(() => {});
2551
- }
2552
-
2553
- if (args.selector || args.textSelector || args.resourceId || args.description) {
2554
- await this.tap({
2555
- text: args.textSelector,
2556
- resourceId: args.resourceId,
2557
- description: args.description,
2558
- className: args.className,
2559
- clickable: true,
2560
- screenshot: false,
2561
- uiDump: false,
2562
- }).catch(() => {});
2563
- }
2564
-
2565
- await this.#adb(serial, `shell input text ${quoteShell(androidTextEscape(args.text || ''))}`, { timeout: 20000 });
2566
- if (args.pressEnter) {
2567
- await this.#adb(serial, 'shell input keyevent 66', { timeout: 10000 });
2568
- }
2569
- const observation = await this.#captureObservation(serial, args);
2570
- return {
2571
- success: true,
2572
- serial,
2573
- typed: args.text || '',
2574
- ...observation,
2575
- };
2576
- }
2577
-
2578
- async swipe(args = {}) {
2579
- const serial = await this.ensureDevice();
2580
- const x1 = Number(args.x1);
2581
- const y1 = Number(args.y1);
2582
- const x2 = Number(args.x2);
2583
- const y2 = Number(args.y2);
2584
- const duration = Math.max(50, Number(args.durationMs) || 300);
2585
- if (![x1, y1, x2, y2].every(Number.isFinite)) {
2586
- throw new Error('x1, y1, x2, and y2 are required for android_swipe');
2587
- }
2588
- await this.#adb(serial, `shell input swipe ${Math.round(x1)} ${Math.round(y1)} ${Math.round(x2)} ${Math.round(y2)} ${Math.round(duration)}`, { timeout: 15000 });
2589
- const observation = await this.#captureObservation(serial, args);
2590
- return {
2591
- success: true,
2592
- serial,
2593
- ...observation,
2594
- };
2595
- }
2596
-
2597
- async pressKey(args = {}) {
2598
- const serial = await this.ensureDevice();
2599
- const raw = String(args.key || '').trim().toLowerCase();
2600
- const keyCode = Number.isFinite(Number(raw)) ? Number(raw) : (DEFAULT_KEYEVENTS[raw] || null);
2601
- if (!keyCode) throw new Error(`Unsupported Android key: ${args.key}`);
2602
- await this.#adb(serial, `shell input keyevent ${keyCode}`, { timeout: 10000 });
2603
- const observation = await this.#captureObservation(serial, args);
2604
- return {
2605
- success: true,
2606
- serial,
2607
- key: args.key,
2608
- keyCode,
2609
- ...observation,
2610
- };
2611
- }
2612
-
2613
- async waitFor(args = {}) {
2614
- const timeoutMs = Math.max(1000, Number(args.timeoutMs) || 20000);
2615
- const intervalMs = Math.max(250, Number(args.intervalMs) || 1500);
2616
- const deadline = Date.now() + timeoutMs;
2617
-
2618
- while (Date.now() < deadline) {
2619
- const dump = await this.dumpUi({ includeNodes: false });
2620
- const node = findBestNode(dump.xml, {
2621
- text: args.text,
2622
- resourceId: args.resourceId,
2623
- description: args.description,
2624
- className: args.className,
2625
- packageName: args.packageName,
2626
- clickable: args.clickable,
2627
- });
2628
- if (node) {
2629
- const observation = await this.#captureObservation(dump.serial, {
2630
- screenshot: args.screenshot !== false,
2631
- uiDump: args.uiDump !== false,
2632
- includeNodes: args.includeNodes,
2633
- });
2634
- return {
2635
- success: true,
2636
- serial: dump.serial,
2637
- matched: summarizeNode(node),
2638
- matchedFromUiDumpPath: dump.uiDumpPath,
2639
- ...observation,
2640
- };
2641
- }
2642
- await sleep(intervalMs);
2643
- }
2644
-
2645
- throw new Error(`Timed out after ${timeoutMs} ms waiting for Android UI element`);
2646
- }
2647
-
2648
- async openApp(args = {}) {
2649
- const serial = await this.ensureDevice();
2650
- if (args.activity) {
2651
- await this.#adb(serial, `shell am start -n ${quoteShell(`${args.packageName}/${args.activity}`)}`, { timeout: 20000 });
2652
- } else if (args.packageName) {
2653
- this.#assertSerialAccess(serial, { claimIfUnowned: true });
2654
- const resolved = await this.#runAllowFailure(
2655
- `${quoteShell(adbBinary())} -s ${quoteShell(serial)} shell cmd package resolve-activity --brief -c android.intent.category.LAUNCHER ${quoteShell(args.packageName)}`,
2656
- { timeout: 15000 },
2657
- );
2658
- const component = parseResolvedLaunchComponent(
2659
- `${resolved.stdout || ''}\n${resolved.stderr || ''}`,
2660
- args.packageName,
2661
- );
2662
-
2663
- if (component) {
2664
- await this.#adb(serial, `shell am start -n ${quoteShell(component)}`, { timeout: 20000 });
2665
- } else {
2666
- await this.#adb(serial, `shell monkey -p ${quoteShell(args.packageName)} -c android.intent.category.LAUNCHER 1`, { timeout: 30000 });
2667
- }
2668
- } else {
2669
- throw new Error('packageName is required for android_open_app');
2670
- }
2671
- const observation = await this.#captureObservation(serial, args);
2672
- return {
2673
- success: true,
2674
- serial,
2675
- packageName: args.packageName,
2676
- activity: args.activity || null,
2677
- ...observation,
2678
- };
2679
- }
2680
-
2681
- async openIntent(args = {}) {
2682
- const serial = await this.ensureDevice();
2683
- const parts = ['shell am start'];
2684
- if (args.action) parts.push('-a', quoteShell(args.action));
2685
- if (args.dataUri) parts.push('-d', quoteShell(args.dataUri));
2686
- if (args.packageName) parts.push('-p', quoteShell(args.packageName));
2687
- if (args.component) parts.push('-n', quoteShell(args.component));
2688
- if (args.mimeType) parts.push('-t', quoteShell(args.mimeType));
2689
-
2690
- if (args.extras && typeof args.extras === 'object') {
2691
- for (const [key, value] of Object.entries(args.extras)) {
2692
- parts.push('--es', quoteShell(key), quoteShell(String(value)));
2693
- }
2694
- }
2695
-
2696
- await this.#adb(serial, parts.join(' '), { timeout: 20000 });
2697
- const observation = await this.#captureObservation(serial, args);
2698
- return {
2699
- success: true,
2700
- serial,
2701
- ...observation,
2702
- };
2703
- }
2704
-
2705
- async listApps(args = {}) {
2706
- let serial = null;
2707
- try {
2708
- serial = await this.ensureDevice();
2709
- } catch (error) {
2710
- return {
2711
- success: false,
2712
- serial: null,
2713
- count: 0,
2714
- packages: [],
2715
- error: String(error?.message || 'Android device is not ready.'),
2716
- };
2717
- }
2718
- const cmd = args.includeSystem === true ? 'shell pm list packages' : 'shell pm list packages -3';
2719
- let out = '';
2720
- try {
2721
- out = await this.#adb(serial, cmd, { timeout: 30000 });
2722
- } catch (error) {
2723
- await sleep(1000);
2724
- try {
2725
- out = await this.#adb(serial, cmd, { timeout: 30000 });
2726
- } catch (retryError) {
2727
- return {
2728
- success: false,
2729
- serial,
2730
- count: 0,
2731
- packages: [],
2732
- error: String(retryError?.message || error?.message || 'Failed to list Android apps.'),
2733
- };
2734
- }
2735
- }
2736
- const packages = out
2737
- .split('\n')
2738
- .map((line) => line.trim())
2739
- .filter(Boolean)
2740
- .map((line) => line.replace(/^package:/, ''))
2741
- .sort();
2742
- return {
2743
- success: true,
2744
- serial,
2745
- count: packages.length,
2746
- packages,
2747
- };
2748
- }
2749
-
2750
- async installApk(args = {}) {
2751
- const apkPath = path.resolve(String(args.apkPath || ''));
2752
- if (!apkPath || !fs.existsSync(apkPath)) throw new Error(`APK not found: ${apkPath}`);
2753
- const serial = await this.ensureDevice();
2754
- const extension = path.extname(apkPath).toLowerCase();
2755
-
2756
- if (extension === '.aab') {
2757
- throw new Error('.aab app bundles are not directly installable. Export a .apks bundle or .apk first.');
2758
- }
2759
-
2760
- if (extension === '.apks') {
2761
- const extractDir = fs.mkdtempSync(path.join(TMP_DIR, 'apk-bundle-'));
2762
- try {
2763
- extractZip(apkPath, extractDir);
2764
- const bundle = resolveBundleInstallTargets(extractDir);
2765
- await this.#adb(serial, `install -r ${quoteShell(bundle.installPaths[0])}`, { timeout: 300000 });
2766
- return {
2767
- success: true,
2768
- serial,
2769
- apkPath,
2770
- artifactType: 'apks',
2771
- installedPaths: bundle.installPaths,
2772
- bundleLayout: bundle.layout,
2773
- };
2774
- } finally {
2775
- fs.rmSync(extractDir, { recursive: true, force: true });
2776
- }
2777
- }
2778
-
2779
- await this.#adb(serial, `install -r ${quoteShell(apkPath)}`, { timeout: 300000 });
2780
- return {
2781
- success: true,
2782
- serial,
2783
- apkPath,
2784
- artifactType: 'apk',
2785
- installedPaths: [apkPath],
2786
- };
2787
- }
2788
-
2789
- async shell(args = {}) {
2790
- const serial = await this.ensureDevice();
2791
- const command = String(args.command || '').trim();
2792
- if (!command) throw new Error('command is required for android_shell');
2793
-
2794
- const timeout = Math.max(1000, Number(args.timeoutMs) || 20000);
2795
- const stdout = await this.#adb(serial, `shell ${quoteShell(command)}`, { timeout });
2796
- const observation = args.screenshot === true
2797
- ? await this.#captureObservation(serial)
2798
- : null;
2799
- return {
2800
- success: true,
2801
- serial,
2802
- command,
2803
- stdout,
2804
- screenshotPath: observation?.screenshotPath || null,
2805
- fullPath: observation?.fullPath || null,
2806
- uiDumpPath: observation?.uiDumpPath || null,
2807
- nodeCount: observation?.nodeCount,
2808
- preview: observation?.preview,
2809
- };
2810
- }
2811
-
2812
- async getStatus() {
2813
- const state = this.#readState();
2814
- const bootstrapWorkerPid = Number(state.bootstrapWorkerPid || 0) || null;
2815
- const bootstrapWorkerAlive = isProcessAlive(bootstrapWorkerPid);
2816
- const devices = isExecutable(adbBinary())
2817
- ? await this.listDevices({ ensureBootstrapped: false }).catch(() => [])
2818
- : [];
2819
- const serialInState = String(state.serial || '').trim();
2820
- const serialOwnedByCurrentUser = serialInState
2821
- ? !this.#isSerialOwnedByAnother(serialInState)
2822
- : null;
2823
- let lastLogLine =
2824
- state.lastStartError ||
2825
- state.lastLogLine ||
2826
- state.startupPhase ||
2827
- null;
2828
- if (state.logPath && fs.existsSync(state.logPath)) {
2829
- try {
2830
- const lines = fs.readFileSync(state.logPath, 'utf8')
2831
- .split('\n')
2832
- .map((line) => line.trim())
2833
- .filter(Boolean);
2834
- const emulatorLogLine = [...lines].reverse().find((line) =>
2835
- /fatal|error|warning|boot completed|disk space|running avd/i.test(line)
2836
- ) || lines[lines.length - 1] || null;
2837
- lastLogLine = emulatorLogLine || lastLogLine;
2838
- } catch {
2839
- lastLogLine =
2840
- state.lastStartError ||
2841
- state.lastLogLine ||
2842
- state.startupPhase ||
2843
- null;
2844
- }
2845
- }
2846
- return {
2847
- bootstrapped: state.bootstrapped === true,
2848
- starting: state.starting === true || this.startPromise != null || bootstrapWorkerAlive,
2849
- startupPhase: state.startupPhase || (bootstrapWorkerAlive ? 'Preparing Android runtime' : null),
2850
- startRequestedAt: state.startRequestedAt || null,
2851
- lastStartError: state.lastStartError || null,
2852
- sdkRoot: activeAndroidSdkRoot(),
2853
- avdHome: AVD_HOME,
2854
- avdName: this.avdName,
2855
- adbPath: adbBinary(),
2856
- emulatorPath: emulatorBinary(),
2857
- serial: state.serial,
2858
- serialOwnedByCurrentUser,
2859
- emulatorPid: state.emulatorPid,
2860
- bootstrapWorkerPid,
2861
- systemImage: state.systemImage || null,
2862
- systemImageArch: state.systemImageArch || null,
2863
- preferredSystemImageArchs: systemImageArchCandidates(),
2864
- configuredSystemImagePackage: configuredSystemImagePackage(),
2865
- configuredSystemImagePlatform: configuredSystemImagePlatform(),
2866
- apiLevel: Number(state.apiLevel || 0) || null,
2867
- avdSystemImage: state.avdSystemImage || null,
2868
- logPath: state.logPath || null,
2869
- lastLogLine,
2870
- devices,
2871
- canBootstrap: process.platform === 'darwin' || process.platform === 'linux',
2872
- };
2873
- }
2874
-
2875
- async close() {
2876
- AndroidController.cleanupControllers.delete(this);
2877
- return this.stopEmulator().catch(() => {});
2878
- }
2879
- }
2880
-
2881
- module.exports = {
2882
- AndroidController,
2883
- androidTextEscape,
2884
- chooseConfiguredSystemImage,
2885
- chooseLatestSystemImage,
2886
- configuredSystemImagePackage,
2887
- configuredSystemImagePlatform,
2888
- formatSystemImageError,
2889
- parseResolvedLaunchComponent,
2890
- parseLatestCmdlineToolsUrl,
2891
- parseSystemImages,
2892
- sanitizeUiXml,
2893
- systemImageArchCandidates,
2894
- };
601
+ module.exports = { AndroidController };