mobile-debug-mcp 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,6 +26,7 @@ This server is designed with security in mind, using strict argument handling to
26
26
  - Node.js >= 18
27
27
  - Android SDK (`adb` in PATH) for Android support
28
28
  - Xcode command-line tools (`xcrun simctl`) for iOS support
29
+ - **iOS Device Bridge (`idb`)** for iOS UI tree support
29
30
  - Booted iOS simulator for iOS testing
30
31
 
31
32
  ---
@@ -34,7 +35,18 @@ This server is designed with security in mind, using strict argument handling to
34
35
 
35
36
  You can install and use **Mobile Debug MCP** in one of two ways:
36
37
 
37
- ### 1. Clone the repository for local development
38
+ ### 1. Install Dependencies
39
+
40
+ **iOS Prerequisite (`idb`):**
41
+ To use the `get_ui_tree` tool on iOS, you must install Facebook's `idb`:
42
+
43
+ ```bash
44
+ brew tap facebook/fb
45
+ brew install idb-companion
46
+ pip3 install fb-idb
47
+ ```
48
+
49
+ ### 2. Clone the repository for local development
38
50
 
39
51
  ```bash
40
52
  git clone https://github.com/YOUR_USERNAME/mobile-debug-mcp.git
@@ -45,7 +57,7 @@ npm run build
45
57
 
46
58
  This option is suitable if you want to modify or contribute to the code.
47
59
 
48
- ### 2. Install via npm for standard use
60
+ ### 3. Install via npm for standard use
49
61
 
50
62
  ```bash
51
63
  npm install -g mobile-debug-mcp
@@ -55,27 +67,7 @@ This option installs the package globally for easy use without cloning the repo.
55
67
 
56
68
  ---
57
69
 
58
- ## Testing
59
- 33.
60
- 34. The repository includes a smoke test script to verify end-to-end functionality on real devices or simulators.
61
- 35.
62
- 36. ```bash
63
- 37. # Run smoke test for Android
64
- 38. npx tsx smoke-test.ts android com.example.package
65
- 39.
66
- 40. # Run smoke test for iOS
67
- 41. npx tsx smoke-test.ts ios com.example.bundleid
68
- 42. ```
69
- 43.
70
- 44. The smoke test performs the following sequence:
71
- 45. 1. Starts the app
72
- 46. 2. Captures a screenshot
73
- 47. 3. Fetches logs
74
- 48. 4. Terminates the app
75
- 49.
76
- 50. ---
77
- 51.
78
- 52. ## MCP Server Configuration
70
+ ## MCP Server Configuration
79
71
 
80
72
  Example WebUI MCP config using `npx --yes` and environment variables:
81
73
 
@@ -110,7 +102,7 @@ All tools accept a JSON input payload and return a structured JSON response. **E
110
102
  Launch a mobile app.
111
103
 
112
104
  **Input:**
113
- ```json
105
+ ```jsonc
114
106
  {
115
107
  "platform": "android" | "ios",
116
108
  "appId": "com.example.app", // Android package or iOS bundle ID (Required)
@@ -131,7 +123,7 @@ Launch a mobile app.
131
123
  Fetch recent logs from the app or device.
132
124
 
133
125
  **Input:**
134
- ```json
126
+ ```jsonc
135
127
  {
136
128
  "platform": "android" | "ios",
137
129
  "appId": "com.example.app", // Optional: filter logs by app
@@ -155,7 +147,7 @@ Returns two content blocks:
155
147
  Capture a screenshot of the current device screen.
156
148
 
157
149
  **Input:**
158
- ```json
150
+ ```jsonc
159
151
  {
160
152
  "platform": "android" | "ios",
161
153
  "deviceId": "emulator-5554" // Optional: target specific device
@@ -177,7 +169,7 @@ Returns two content blocks:
177
169
  Terminate a running app.
178
170
 
179
171
  **Input:**
180
- ```json
172
+ ```jsonc
181
173
  {
182
174
  "platform": "android" | "ios",
183
175
  "appId": "com.example.app", // Android package or iOS bundle ID (Required)
@@ -197,7 +189,7 @@ Terminate a running app.
197
189
  Restart an app (terminate then launch).
198
190
 
199
191
  **Input:**
200
- ```json
192
+ ```jsonc
201
193
  {
202
194
  "platform": "android" | "ios",
203
195
  "appId": "com.example.app", // Android package or iOS bundle ID (Required)
@@ -218,7 +210,7 @@ Restart an app (terminate then launch).
218
210
  Clear app storage (reset to fresh install state).
219
211
 
220
212
  **Input:**
221
- ```json
213
+ ```jsonc
222
214
  {
223
215
  "platform": "android" | "ios",
224
216
  "appId": "com.example.app", // Android package or iOS bundle ID (Required)
@@ -234,6 +226,62 @@ Clear app storage (reset to fresh install state).
234
226
  }
235
227
  ```
236
228
 
229
+ ### get_ui_tree
230
+ Get the current UI hierarchy from the device. Returns a structured JSON representation of the screen content.
231
+
232
+ **Input:**
233
+ ```jsonc
234
+ {
235
+ "platform": "android" | "ios",
236
+ "deviceId": "emulator-5554" // Optional
237
+ }
238
+ ```
239
+
240
+ **Response:**
241
+ ```json
242
+ {
243
+ "device": { /* device info */ },
244
+ "screen": "",
245
+ "resolution": { "width": 1080, "height": 1920 },
246
+ "elements": [
247
+ {
248
+ "text": "Login",
249
+ "contentDescription": null,
250
+ "type": "android.widget.Button",
251
+ "resourceId": "com.example:id/login_button",
252
+ "clickable": true,
253
+ "enabled": true,
254
+ "visible": true,
255
+ "bounds": [120,400,280,450],
256
+ "center": [200, 425],
257
+ "depth": 1,
258
+ "parentId": 0,
259
+ "children": []
260
+ }
261
+ ]
262
+ }
263
+ ```
264
+
265
+ ### get_current_screen
266
+ Get the currently visible activity on an Android device.
267
+
268
+ **Input:**
269
+ ```jsonc
270
+ {
271
+ "deviceId": "emulator-5554" // Optional: target specific device
272
+ }
273
+ ```
274
+
275
+ **Response:**
276
+ ```json
277
+ {
278
+ "device": { /* device info */ },
279
+ "package": "com.example.app",
280
+ "activity": "com.example.app.LoginActivity",
281
+ "shortActivity": "LoginActivity"
282
+ }
283
+ ```
284
+
237
285
  ---
238
286
 
239
287
  ## Recommended Workflow
@@ -255,6 +303,26 @@ Clear app storage (reset to fresh install state).
255
303
 
256
304
  ---
257
305
 
306
+ ## Testing
307
+
308
+ The repository includes a smoke test script to verify end-to-end functionality on real devices or simulators.
309
+
310
+ ```bash
311
+ # Run smoke test for Android
312
+ npx tsx smoke-test.ts android com.example.package
313
+
314
+ # Run smoke test for iOS
315
+ npx tsx smoke-test.ts ios com.example.bundleid
316
+ ```
317
+
318
+ The smoke test performs the following sequence:
319
+ 1. Starts the app
320
+ 2. Captures a screenshot
321
+ 3. Fetches logs
322
+ 4. Terminates the app
323
+
324
+ ---
325
+
258
326
  ## License
259
327
 
260
328
  MIT License
@@ -0,0 +1,30 @@
1
+ import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
2
+ export class AndroidInteract {
3
+ async startApp(appId, deviceId) {
4
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
5
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
6
+ await execAdb(['shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1'], deviceId);
7
+ return { device: deviceInfo, appStarted: true, launchTimeMs: 1000 };
8
+ }
9
+ async terminateApp(appId, deviceId) {
10
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
11
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
12
+ await execAdb(['shell', 'am', 'force-stop', appId], deviceId);
13
+ return { device: deviceInfo, appTerminated: true };
14
+ }
15
+ async restartApp(appId, deviceId) {
16
+ await this.terminateApp(appId, deviceId);
17
+ const startResult = await this.startApp(appId, deviceId);
18
+ return {
19
+ device: startResult.device,
20
+ appRestarted: startResult.appStarted,
21
+ launchTimeMs: startResult.launchTimeMs
22
+ };
23
+ }
24
+ async resetAppData(appId, deviceId) {
25
+ const metadata = await getAndroidDeviceMetadata(appId, deviceId);
26
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
27
+ const output = await execAdb(['shell', 'pm', 'clear', appId], deviceId);
28
+ return { device: deviceInfo, dataCleared: output === 'Success' };
29
+ }
30
+ }
@@ -0,0 +1,313 @@
1
+ import { spawn } from "child_process";
2
+ import { XMLParser } from "fast-xml-parser";
3
+ import { ADB, execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
4
+ // --- Helper Functions Specific to Observe ---
5
+ const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
6
+ function parseBounds(bounds) {
7
+ const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
8
+ if (match) {
9
+ return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3]), parseInt(match[4])];
10
+ }
11
+ return [0, 0, 0, 0];
12
+ }
13
+ function getCenter(bounds) {
14
+ const [x1, y1, x2, y2] = bounds;
15
+ return [Math.floor((x1 + x2) / 2), Math.floor((y1 + y2) / 2)];
16
+ }
17
+ async function getScreenResolution(deviceId) {
18
+ try {
19
+ const output = await execAdb(['shell', 'wm', 'size'], deviceId);
20
+ const match = output.match(/Physical size: (\d+)x(\d+)/);
21
+ if (match) {
22
+ return { width: parseInt(match[1]), height: parseInt(match[2]) };
23
+ }
24
+ }
25
+ catch (e) {
26
+ // ignore
27
+ }
28
+ return { width: 0, height: 0 };
29
+ }
30
+ function traverseNode(node, elements, parentIndex = -1, depth = 0) {
31
+ if (!node)
32
+ return -1;
33
+ let currentIndex = -1;
34
+ // Check if it's a valid node with attributes we care about
35
+ if (node['@_class']) {
36
+ const text = node['@_text'] || null;
37
+ const contentDescription = node['@_content-desc'] || null;
38
+ const clickable = node['@_clickable'] === 'true';
39
+ const bounds = parseBounds(node['@_bounds'] || '[0,0][0,0]');
40
+ // Filtering Logic:
41
+ // Keep if clickable OR has visible text OR has content description
42
+ const isUseful = clickable || (text && text.length > 0) || (contentDescription && contentDescription.length > 0);
43
+ if (isUseful) {
44
+ const element = {
45
+ text,
46
+ contentDescription,
47
+ type: node['@_class'] || 'unknown',
48
+ resourceId: node['@_resource-id'] || null,
49
+ clickable,
50
+ enabled: node['@_enabled'] === 'true',
51
+ visible: true,
52
+ bounds,
53
+ center: getCenter(bounds),
54
+ depth
55
+ };
56
+ if (parentIndex !== -1) {
57
+ element.parentId = parentIndex;
58
+ }
59
+ elements.push(element);
60
+ currentIndex = elements.length - 1;
61
+ }
62
+ }
63
+ // If current node was skipped (not useful or no class), children inherit parentIndex
64
+ // If current node was added, children use currentIndex
65
+ const nextParentIndex = currentIndex !== -1 ? currentIndex : parentIndex;
66
+ const nextDepth = currentIndex !== -1 ? depth + 1 : depth;
67
+ const childrenIndices = [];
68
+ // Traverse children
69
+ if (node.node) {
70
+ if (Array.isArray(node.node)) {
71
+ node.node.forEach((child) => {
72
+ const childIndex = traverseNode(child, elements, nextParentIndex, nextDepth);
73
+ if (childIndex !== -1)
74
+ childrenIndices.push(childIndex);
75
+ });
76
+ }
77
+ else {
78
+ const childIndex = traverseNode(node.node, elements, nextParentIndex, nextDepth);
79
+ if (childIndex !== -1)
80
+ childrenIndices.push(childIndex);
81
+ }
82
+ }
83
+ // Update current element with children if it was added
84
+ if (currentIndex !== -1 && childrenIndices.length > 0) {
85
+ elements[currentIndex].children = childrenIndices;
86
+ }
87
+ return currentIndex;
88
+ }
89
+ export class AndroidObserve {
90
+ async getDeviceMetadata(appId, deviceId) {
91
+ return getAndroidDeviceMetadata(appId, deviceId);
92
+ }
93
+ async getUITree(deviceId) {
94
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
95
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
96
+ try {
97
+ // Get screen resolution first
98
+ const resolution = await getScreenResolution(deviceId);
99
+ if (resolution.width === 0 && resolution.height === 0) {
100
+ throw new Error("Failed to get screen resolution. Is the device connected and authorized?");
101
+ }
102
+ // Retry Logic
103
+ let xmlContent = '';
104
+ let attempts = 0;
105
+ const maxAttempts = 3;
106
+ while (attempts < maxAttempts) {
107
+ attempts++;
108
+ try {
109
+ // Stabilization delay
110
+ await delay(300 + (attempts * 100)); // 300ms, 400ms, 500ms...
111
+ // Dump UI hierarchy
112
+ await execAdb(['shell', 'uiautomator', 'dump', '/sdcard/ui.xml'], deviceId);
113
+ // Read the file
114
+ xmlContent = await execAdb(['shell', 'cat', '/sdcard/ui.xml'], deviceId);
115
+ // Check validity
116
+ if (xmlContent && xmlContent.trim().length > 0 && !xmlContent.includes("ERROR:")) {
117
+ break; // Success
118
+ }
119
+ }
120
+ catch (err) {
121
+ console.error(`Attempt ${attempts} failed: ${err}`);
122
+ }
123
+ if (attempts === maxAttempts) {
124
+ throw new Error(`Failed to retrieve valid UI dump after ${maxAttempts} attempts.`);
125
+ }
126
+ }
127
+ const parser = new XMLParser({
128
+ ignoreAttributes: false,
129
+ attributeNamePrefix: "@_"
130
+ });
131
+ const result = parser.parse(xmlContent);
132
+ const elements = [];
133
+ // The root is usually hierarchy -> node
134
+ if (result.hierarchy && result.hierarchy.node) {
135
+ // If the root is an array (unlikely for root, but good to be safe) or single object
136
+ if (Array.isArray(result.hierarchy.node)) {
137
+ result.hierarchy.node.forEach((n) => traverseNode(n, elements));
138
+ }
139
+ else {
140
+ traverseNode(result.hierarchy.node, elements);
141
+ }
142
+ }
143
+ return {
144
+ device: deviceInfo,
145
+ screen: "",
146
+ resolution,
147
+ elements
148
+ };
149
+ }
150
+ catch (e) {
151
+ const errorMessage = `Failed to get UI tree. ADB Path: '${ADB}'. Error: ${e instanceof Error ? e.message : String(e)}`;
152
+ console.error(errorMessage);
153
+ return {
154
+ device: deviceInfo,
155
+ screen: "",
156
+ resolution: { width: 0, height: 0 },
157
+ elements: [],
158
+ error: errorMessage
159
+ };
160
+ }
161
+ }
162
+ async getLogs(appId, lines = 200, deviceId) {
163
+ const metadata = await getAndroidDeviceMetadata(appId || "", deviceId);
164
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
165
+ try {
166
+ // We'll skip PID lookup for now to avoid potential hangs with 'pidof' on some emulators
167
+ // and rely on robust string matching against the log line.
168
+ // Get logs
169
+ const stdout = await execAdb(['logcat', '-d', '-t', lines.toString(), '-v', 'threadtime'], deviceId);
170
+ const allLogs = stdout.split('\n');
171
+ let filteredLogs = allLogs;
172
+ if (appId) {
173
+ // Filter by checking if the line contains the appId string.
174
+ const matchingLogs = allLogs.filter(line => line.includes(appId));
175
+ if (matchingLogs.length > 0) {
176
+ filteredLogs = matchingLogs;
177
+ }
178
+ else {
179
+ // Fallback: if no logs match the appId, return the raw logs (last N lines)
180
+ // This matches the behavior of the "working" version provided by the user,
181
+ // ensuring they at least see system activity if the app is silent or crashing early.
182
+ filteredLogs = allLogs;
183
+ }
184
+ }
185
+ return { device: deviceInfo, logs: filteredLogs, logCount: filteredLogs.length };
186
+ }
187
+ catch (e) {
188
+ console.error("Error fetching logs:", e);
189
+ return { device: deviceInfo, logs: [], logCount: 0 };
190
+ }
191
+ }
192
+ async captureScreen(deviceId) {
193
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
194
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
195
+ return new Promise((resolve, reject) => {
196
+ // Need to construct ADB args manually since spawn handles it
197
+ const args = deviceId ? ['-s', deviceId, 'exec-out', 'screencap', '-p'] : ['exec-out', 'screencap', '-p'];
198
+ // Using spawn for screencap as well to ensure consistent process handling
199
+ const child = spawn(ADB, args);
200
+ const chunks = [];
201
+ let stderr = '';
202
+ child.stdout.on('data', (chunk) => {
203
+ chunks.push(Buffer.from(chunk));
204
+ });
205
+ child.stderr.on('data', (data) => {
206
+ stderr += data.toString();
207
+ });
208
+ const timeout = setTimeout(() => {
209
+ child.kill();
210
+ reject(new Error(`ADB screencap timed out after 10s`));
211
+ }, 10000);
212
+ child.on('close', (code) => {
213
+ clearTimeout(timeout);
214
+ if (code !== 0) {
215
+ reject(new Error(stderr.trim() || `Screencap failed with code ${code}`));
216
+ return;
217
+ }
218
+ const screenshotBuffer = Buffer.concat(chunks);
219
+ const screenshotBase64 = screenshotBuffer.toString('base64');
220
+ // Get resolution
221
+ execAdb(['shell', 'wm', 'size'], deviceId)
222
+ .then(sizeStdout => {
223
+ let width = 0;
224
+ let height = 0;
225
+ const match = sizeStdout.match(/Physical size: (\d+)x(\d+)/);
226
+ if (match) {
227
+ width = parseInt(match[1], 10);
228
+ height = parseInt(match[2], 10);
229
+ }
230
+ resolve({
231
+ device: deviceInfo,
232
+ screenshot: screenshotBase64,
233
+ resolution: { width, height }
234
+ });
235
+ })
236
+ .catch(() => {
237
+ resolve({
238
+ device: deviceInfo,
239
+ screenshot: screenshotBase64,
240
+ resolution: { width: 0, height: 0 }
241
+ });
242
+ });
243
+ });
244
+ child.on('error', (err) => {
245
+ clearTimeout(timeout);
246
+ reject(err);
247
+ });
248
+ });
249
+ }
250
+ async getCurrentScreen(deviceId) {
251
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
252
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
253
+ try {
254
+ // Dumpsys activity can be slow on some devices, so we increase timeout to 10s
255
+ const output = await execAdb(['shell', 'dumpsys', 'activity', 'activities'], deviceId, { timeout: 10000 });
256
+ // Find the line with mResumedActivity or ResumedActivity (some versions might differ)
257
+ const lines = output.split('\n');
258
+ // Prioritize mResumedActivity, then ResumedActivity.
259
+ // Use strict regex match to ensure it starts with the key, avoiding false positives like 'mLastResumedActivity'.
260
+ let resumedLine = lines.find(line => /^\s*mResumedActivity:/.test(line));
261
+ if (!resumedLine) {
262
+ resumedLine = lines.find(line => /^\s*ResumedActivity:/.test(line));
263
+ }
264
+ if (!resumedLine) {
265
+ return {
266
+ device: deviceInfo,
267
+ package: "",
268
+ activity: "",
269
+ shortActivity: "",
270
+ error: "Could not find 'mResumedActivity' in dumpsys output"
271
+ };
272
+ }
273
+ // Regex to parse the line: ActivityRecord{... package/activity ...}
274
+ // Matches: ActivityRecord{<hex> <user> <package>/<activity> ...}
275
+ // We want to capture the component "package/activity" which is separated by space from other tokens.
276
+ // We use greedy match ([^ \{}]+) for activity to ensure we get the full name until a space or closing brace.
277
+ const match = resumedLine.match(/ActivityRecord\{[^ ]*(?:\s+[^ ]+)*\s+([^\/ ]+)\/([^ \{}]+)[^}]*\}/);
278
+ if (match) {
279
+ const packageName = match[1];
280
+ let activityName = match[2];
281
+ // Handle relative activity names (e.g. .LoginActivity)
282
+ if (activityName.startsWith('.')) {
283
+ activityName = packageName + activityName;
284
+ }
285
+ const shortActivity = activityName.split('.').pop() || activityName;
286
+ return {
287
+ device: deviceInfo,
288
+ package: packageName,
289
+ activity: activityName,
290
+ shortActivity: shortActivity
291
+ };
292
+ }
293
+ else {
294
+ return {
295
+ device: deviceInfo,
296
+ package: "",
297
+ activity: "",
298
+ shortActivity: "",
299
+ error: `Found resumed activity line but failed to parse: '${resumedLine.trim()}'`
300
+ };
301
+ }
302
+ }
303
+ catch (e) {
304
+ return {
305
+ device: deviceInfo,
306
+ package: "",
307
+ activity: "",
308
+ shortActivity: "",
309
+ error: e instanceof Error ? e.message : String(e)
310
+ };
311
+ }
312
+ }
313
+ }
@@ -0,0 +1,82 @@
1
+ import { spawn } from "child_process";
2
+ export const ADB = process.env.ADB_PATH || "adb";
3
+ // Helper to construct ADB args with optional device ID
4
+ function getAdbArgs(args, deviceId) {
5
+ if (deviceId) {
6
+ return ['-s', deviceId, ...args];
7
+ }
8
+ return args;
9
+ }
10
+ export function execAdb(args, deviceId, options = {}) {
11
+ const adbArgs = getAdbArgs(args, deviceId);
12
+ return new Promise((resolve, reject) => {
13
+ // Extract timeout from options if present, otherwise pass options to spawn
14
+ const { timeout: customTimeout, ...spawnOptions } = options;
15
+ // Use spawn instead of execFile for better stream control and to avoid potential buffering hangs
16
+ const child = spawn(ADB, adbArgs, spawnOptions);
17
+ let stdout = '';
18
+ let stderr = '';
19
+ if (child.stdout) {
20
+ child.stdout.on('data', (data) => {
21
+ stdout += data.toString();
22
+ });
23
+ }
24
+ if (child.stderr) {
25
+ child.stderr.on('data', (data) => {
26
+ stderr += data.toString();
27
+ });
28
+ }
29
+ let timeoutMs = customTimeout || 2000;
30
+ if (!customTimeout) {
31
+ if (args.includes('logcat')) {
32
+ timeoutMs = 10000;
33
+ }
34
+ else if (args.includes('uiautomator') && args.includes('dump')) {
35
+ timeoutMs = 20000; // UI dump can be slow
36
+ }
37
+ }
38
+ const timeout = setTimeout(() => {
39
+ child.kill();
40
+ reject(new Error(`ADB command timed out after ${timeoutMs}ms: ${args.join(' ')}`));
41
+ }, timeoutMs);
42
+ child.on('close', (code) => {
43
+ clearTimeout(timeout);
44
+ if (code !== 0) {
45
+ // If there's an actual error (non-zero exit code), reject
46
+ reject(new Error(stderr.trim() || `Command failed with code ${code}`));
47
+ }
48
+ else {
49
+ // If exit code is 0, resolve with stdout
50
+ resolve(stdout.trim());
51
+ }
52
+ });
53
+ child.on('error', (err) => {
54
+ clearTimeout(timeout);
55
+ reject(err);
56
+ });
57
+ });
58
+ }
59
+ export function getDeviceInfo(deviceId, metadata = {}) {
60
+ return {
61
+ platform: 'android',
62
+ id: deviceId || 'default',
63
+ osVersion: metadata.osVersion || '',
64
+ model: metadata.model || '',
65
+ simulator: metadata.simulator || false
66
+ };
67
+ }
68
+ export async function getAndroidDeviceMetadata(appId, deviceId) {
69
+ try {
70
+ // Run these in parallel to avoid sequential timeouts
71
+ const [osVersion, model, simOutput] = await Promise.all([
72
+ execAdb(['shell', 'getprop', 'ro.build.version.release'], deviceId).catch(() => ''),
73
+ execAdb(['shell', 'getprop', 'ro.product.model'], deviceId).catch(() => ''),
74
+ execAdb(['shell', 'getprop', 'ro.kernel.qemu'], deviceId).catch(() => '0')
75
+ ]);
76
+ const simulator = simOutput === '1';
77
+ return { platform: 'android', id: deviceId || 'default', osVersion, model, simulator };
78
+ }
79
+ catch (e) {
80
+ return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
81
+ }
82
+ }