mobile-debug-mcp 0.6.0 → 0.8.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
@@ -98,6 +98,21 @@ Example WebUI MCP config using `npx --yes` and environment variables:
98
98
 
99
99
  All tools accept a JSON input payload and return a structured JSON response. **Every response includes a `device` object** (with information about the selected device/simulator used for the operation), plus the tool-specific output.
100
100
 
101
+ ### list_devices
102
+ Enumerate connected Android devices and iOS simulators.
103
+
104
+ Input (optional):
105
+ ```jsonc
106
+ { "platform": "android" | "ios" }
107
+ ```
108
+
109
+ Response:
110
+ ```json
111
+ { "devices": [ { "id": "emulator-5554", "platform": "android", "osVersion": "11", "model": "sdk_gphone64_arm64", "simulator": true, "appInstalled": false } ] }
112
+ ```
113
+
114
+ Use `list_devices` when multiple devices are attached to inspect metadata and pick a device explicitly by passing `deviceId` to subsequent tool calls.
115
+
101
116
  ### start_app
102
117
  Launch a mobile app.
103
118
 
@@ -226,6 +241,74 @@ Clear app storage (reset to fresh install state).
226
241
  }
227
242
  ```
228
243
 
244
+ ### install_app
245
+ Install an app onto a connected device or simulator (APK for Android, .app/.ipa for iOS).
246
+
247
+ **Input:**
248
+ ```jsonc
249
+ {
250
+ "platform": "android" | "ios",
251
+ "appPath": "/path/to/app.apk_or_app.app_or_ipa", // Host path to the app file (Required)
252
+ "deviceId": "emulator-5554" // Optional: target specific device/simulator
253
+ }
254
+ ```
255
+
256
+ **Response:**
257
+ ```json
258
+ {
259
+ "device": { /* device info */ },
260
+ "installed": true,
261
+ "output": "Platform-specific installer output (adb/simctl/idb)",
262
+ "error": "Optional error message if installation failed"
263
+ }
264
+ ```
265
+
266
+ Notes:
267
+ - Android: uses `adb install -r <apkPath>`. The APK must be accessible from the host running the MCP server.
268
+ - iOS: attempts `xcrun simctl install` for simulators and falls back to `idb install` if available for physical devices. Ensure `XCRUN_PATH` and `IDB` are configured if using non-standard locations.
269
+ - Installation output and errors are surfaced in the response for debugging.
270
+
271
+ ### start_log_stream / read_log_stream / stop_log_stream
272
+ Start a live log stream for an Android app and poll the accumulated entries.
273
+
274
+ start_log_stream starts a background adb logcat process filtered by the app PID. It returns immediately with success and creates a per-session NDJSON file of parsed log entries.
275
+
276
+ read_log_stream retrieves recent parsed entries and includes crash detection metadata.
277
+
278
+ Input (start_log_stream):
279
+ ```jsonc
280
+ {
281
+ "packageName": "com.example.app", // Required
282
+ "level": "error" | "warn" | "info" | "debug", // Optional, defaults to "error"
283
+ "sessionId": "optional-session-id" // Optional - used to track stream per debugging session
284
+ }
285
+ ```
286
+
287
+ Input (read_log_stream):
288
+ ```jsonc
289
+ {
290
+ "sessionId": "optional-session-id",
291
+ "limit": 100, // Optional, max number of entries to return (default 100)
292
+ "since": "2026-03-13T14:00:00Z" // Optional, ISO timestamp or epoch ms to return only newer entries
293
+ }
294
+ ```
295
+
296
+ Response (read_log_stream):
297
+ ```json
298
+ {
299
+ "entries": [
300
+ { "timestamp": "2026-03-13T14:01:04.123Z", "level": "E", "tag": "AndroidRuntime", "message": "FATAL EXCEPTION: main", "crash": true, "exception": "NullPointerException" }
301
+ ],
302
+ "crash_summary": { "crash_detected": true, "exception": "NullPointerException", "sample": "FATAL EXCEPTION: main" }
303
+ }
304
+ ```
305
+
306
+ Notes:
307
+ - The read_log_stream `since` parameter accepts ISO timestamps or epoch milliseconds. Use it to poll incrementally (pass last seen timestamp).
308
+ - Crash detection is heuristic-based (looks for 'FATAL EXCEPTION' and Exception names). It helps agents decide to capture traces or stop tests quickly.
309
+ - stop_log_stream stops the background adb process for the session.
310
+
311
+
229
312
  ### get_ui_tree
230
313
  Get the current UI hierarchy from the device. Returns a structured JSON representation of the screen content.
231
314
 
@@ -282,6 +365,150 @@ Get the currently visible activity on an Android device.
282
365
  }
283
366
  ```
284
367
 
368
+ ### wait_for_element
369
+ Wait until a UI element with matching text appears on screen or timeout is reached. Useful for handling loading states or transitions.
370
+
371
+ **Input:**
372
+ ```jsonc
373
+ {
374
+ "platform": "android" | "ios",
375
+ "text": "Home", // Text to wait for
376
+ "timeout": 5000, // Max wait time in ms (default 10000)
377
+ "deviceId": "emulator-5554" // Optional
378
+ }
379
+ ```
380
+
381
+ **Response:**
382
+ ```json
383
+ {
384
+ "device": { /* device info */ },
385
+ "found": true,
386
+ "element": { /* UIElement object if found */ }
387
+ }
388
+ ```
389
+
390
+ If the element is not found within the timeout, `found` will be `false`. If a system error occurs (e.g., ADB failure), an `error` field will be present.
391
+
392
+ ```json
393
+ {
394
+ "device": { /* device info */ },
395
+ "found": false,
396
+ "error": "Optional error message"
397
+ }
398
+ ```
399
+
400
+ ### tap
401
+ Simulate a finger tap on the device screen at specific coordinates.
402
+
403
+ Platform support and constraints:
404
+ - Android: Implemented via `adb shell input tap` and works when `adb` is available in PATH or configured via `ADB_PATH`.
405
+ - iOS: Requires Facebook's `idb` tooling. The iOS implementation uses `idb` to deliver UI events and is simulator-oriented (works reliably on a booted simulator). Physical device support depends on `idb` capabilities and a running `idb_companion` on the target device; it may not work in all environments.
406
+
407
+ Prerequisites for iOS (if you intend to use tap on iOS):
408
+ ```bash
409
+ brew tap facebook/fb
410
+ brew install idb-companion
411
+ pip3 install fb-idb
412
+ ```
413
+ Ensure `idb` and `idb_companion` are in your PATH. If you use non-standard tool locations, set `XCRUN_PATH` and/or `ADB_PATH` environment variables as appropriate.
414
+
415
+ Behavior notes:
416
+ - The tool is a primitive input: it only sends a tap at the provided coordinates. It does not inspect or interpret the UI.
417
+ - If `idb` is missing or the simulator/device is not available, the tool will return an error explaining the failure.
418
+
419
+ **Input:**
420
+ ```jsonc
421
+ {
422
+ "platform": "android" | "ios", // Optional, defaults to "android"
423
+ "x": 200, // X coordinate (Required)
424
+ "y": 400, // Y coordinate (Required)
425
+ "deviceId": "emulator-5554" // Optional
426
+ }
427
+ ```
428
+
429
+ **Response:**
430
+ ```json
431
+ {
432
+ "device": { /* device info */ },
433
+ "success": true,
434
+ "x": 200,
435
+ "y": 400
436
+ }
437
+ ```
438
+
439
+ If the tap fails (e.g., missing `adb`/`idb`, device not found), `success` will be `false` and an `error` field will be present.
440
+
441
+ ### swipe
442
+ Simulate a swipe gesture on an Android device.
443
+
444
+ **Input:**
445
+ ```jsonc
446
+ {
447
+ "platform": "android", // Optional, defaults to "android"
448
+ "x1": 500, // Start X (Required)
449
+ "y1": 1500, // Start Y (Required)
450
+ "x2": 500, // End X (Required)
451
+ "y2": 500, // End Y (Required)
452
+ "duration": 300, // Duration in ms (Required)
453
+ "deviceId": "emulator-5554" // Optional
454
+ }
455
+ ```
456
+
457
+ **Response:**
458
+ ```json
459
+ {
460
+ "device": { /* device info */ },
461
+ "success": true,
462
+ "start": [500, 1500],
463
+ "end": [500, 500],
464
+ "duration": 300
465
+ }
466
+ ```
467
+
468
+ If the swipe fails, `success` will be `false` and an `error` field will be present.
469
+
470
+ ### type_text
471
+ Type text into the currently focused input field on an Android device.
472
+
473
+ **Input:**
474
+ ```jsonc
475
+ {
476
+ "platform": "android", // Optional, defaults to "android"
477
+ "text": "hello world", // Text to type (Required)
478
+ "deviceId": "emulator-5554" // Optional
479
+ }
480
+ ```
481
+
482
+ **Response:**
483
+ ```json
484
+ {
485
+ "device": { /* device info */ },
486
+ "success": true,
487
+ "text": "hello world"
488
+ }
489
+ ```
490
+
491
+ If the command fails, `success` will be `false` and an `error` field will be present.
492
+
493
+ ### press_back
494
+ Simulate pressing the Android Back button.
495
+
496
+ **Input:**
497
+ ```jsonc
498
+ {
499
+ "platform": "android", // Optional
500
+ "deviceId": "emulator-5554" // Optional
501
+ }
502
+ ```
503
+
504
+ **Response:**
505
+ ```json
506
+ {
507
+ "device": { /* device info */ },
508
+ "success": true
509
+ }
510
+ ```
511
+
285
512
  ---
286
513
 
287
514
  ## Recommended Workflow
@@ -290,8 +517,9 @@ Get the currently visible activity on an Android device.
290
517
  2. Use `start_app` to launch the app.
291
518
  3. Use `get_logs` to read the latest logs.
292
519
  4. Use `capture_screenshot` to visually inspect the app if needed.
293
- 5. Use `reset_app_data` to clear state if debugging fresh install scenarios.
294
- 6. Use `restart_app` to quickly reboot the app during development cycles.
520
+ 5. Use `wait_for_element` to ensure the app is in the expected state before proceeding (e.g., after login).
521
+ 6. Use `reset_app_data` to clear state if debugging fresh install scenarios.
522
+ 7. Use `restart_app` to quickly reboot the app during development cycles.
295
523
 
296
524
  ---
297
525
 
@@ -1,5 +1,92 @@
1
1
  import { execAdb, getAndroidDeviceMetadata, getDeviceInfo } from "./utils.js";
2
+ import { AndroidObserve } from "./observe.js";
2
3
  export class AndroidInteract {
4
+ observe = new AndroidObserve();
5
+ async waitForElement(text, timeout, deviceId) {
6
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
7
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
8
+ const startTime = Date.now();
9
+ while (Date.now() - startTime < timeout) {
10
+ try {
11
+ const tree = await this.observe.getUITree(deviceId);
12
+ if (tree.error) {
13
+ return { device: deviceInfo, found: false, error: tree.error };
14
+ }
15
+ const element = tree.elements.find(e => e.text === text);
16
+ if (element) {
17
+ return { device: deviceInfo, found: true, element };
18
+ }
19
+ }
20
+ catch (e) {
21
+ // Ignore errors during polling and retry
22
+ console.error("Error polling UI tree:", e);
23
+ }
24
+ const elapsed = Date.now() - startTime;
25
+ const remaining = timeout - elapsed;
26
+ if (remaining <= 0)
27
+ break;
28
+ await new Promise(resolve => setTimeout(resolve, Math.min(500, remaining)));
29
+ }
30
+ return { device: deviceInfo, found: false };
31
+ }
32
+ async tap(x, y, deviceId) {
33
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
34
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
35
+ try {
36
+ await execAdb(['shell', 'input', 'tap', x.toString(), y.toString()], deviceId);
37
+ return { device: deviceInfo, success: true, x, y };
38
+ }
39
+ catch (e) {
40
+ return { device: deviceInfo, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
41
+ }
42
+ }
43
+ async swipe(x1, y1, x2, y2, duration, deviceId) {
44
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
45
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
46
+ try {
47
+ await execAdb(['shell', 'input', 'swipe', x1.toString(), y1.toString(), x2.toString(), y2.toString(), duration.toString()], deviceId);
48
+ return { device: deviceInfo, success: true, start: [x1, y1], end: [x2, y2], duration };
49
+ }
50
+ catch (e) {
51
+ return { device: deviceInfo, success: false, start: [x1, y1], end: [x2, y2], duration, error: e instanceof Error ? e.message : String(e) };
52
+ }
53
+ }
54
+ async typeText(text, deviceId) {
55
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
56
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
57
+ try {
58
+ // Encode spaces as %s to ensure proper input handling by adb shell input text
59
+ const encodedText = text.replace(/\s/g, '%s');
60
+ // Note: 'input text' might fail with some characters or if keyboard isn't ready, but it's the standard ADB way.
61
+ await execAdb(['shell', 'input', 'text', encodedText], deviceId);
62
+ return { device: deviceInfo, success: true, text };
63
+ }
64
+ catch (e) {
65
+ return { device: deviceInfo, success: false, text, error: e instanceof Error ? e.message : String(e) };
66
+ }
67
+ }
68
+ async pressBack(deviceId) {
69
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
70
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
71
+ try {
72
+ await execAdb(['shell', 'input', 'keyevent', '4'], deviceId);
73
+ return { device: deviceInfo, success: true };
74
+ }
75
+ catch (e) {
76
+ return { device: deviceInfo, success: false, error: e instanceof Error ? e.message : String(e) };
77
+ }
78
+ }
79
+ async installApp(apkPath, deviceId) {
80
+ const metadata = await getAndroidDeviceMetadata("", deviceId);
81
+ const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
82
+ try {
83
+ const output = await execAdb(['install', '-r', apkPath], deviceId);
84
+ return { device: deviceInfo, installed: true, output };
85
+ }
86
+ catch (e) {
87
+ return { device: deviceInfo, installed: false, error: e instanceof Error ? e.message : String(e) };
88
+ }
89
+ }
3
90
  async startApp(appId, deviceId) {
4
91
  const metadata = await getAndroidDeviceMetadata(appId, deviceId);
5
92
  const deviceInfo = getDeviceInfo(deviceId || 'default', metadata);
@@ -67,16 +67,323 @@ export function getDeviceInfo(deviceId, metadata = {}) {
67
67
  }
68
68
  export async function getAndroidDeviceMetadata(appId, deviceId) {
69
69
  try {
70
+ // If no deviceId provided, try to auto-detect a single connected device
71
+ let resolvedDeviceId = deviceId;
72
+ if (!resolvedDeviceId) {
73
+ try {
74
+ const devicesOutput = await execAdb(['devices']);
75
+ // Parse lines like: "<serial>\tdevice"
76
+ const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean);
77
+ const deviceLines = lines.slice(1) // skip header
78
+ .map(l => l.split('\t'))
79
+ .filter(parts => parts.length >= 2 && parts[1] === 'device')
80
+ .map(parts => parts[0]);
81
+ if (deviceLines.length === 1) {
82
+ resolvedDeviceId = deviceLines[0];
83
+ }
84
+ }
85
+ catch (e) {
86
+ // ignore and continue without resolvedDeviceId
87
+ }
88
+ }
70
89
  // Run these in parallel to avoid sequential timeouts
71
90
  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')
91
+ execAdb(['shell', 'getprop', 'ro.build.version.release'], resolvedDeviceId).catch(() => ''),
92
+ execAdb(['shell', 'getprop', 'ro.product.model'], resolvedDeviceId).catch(() => ''),
93
+ execAdb(['shell', 'getprop', 'ro.kernel.qemu'], resolvedDeviceId).catch(() => '0')
75
94
  ]);
76
95
  const simulator = simOutput === '1';
77
- return { platform: 'android', id: deviceId || 'default', osVersion, model, simulator };
96
+ return { platform: 'android', id: resolvedDeviceId || 'default', osVersion, model, simulator };
78
97
  }
79
98
  catch (e) {
80
99
  return { platform: 'android', id: deviceId || 'default', osVersion: '', model: '', simulator: false };
81
100
  }
82
101
  }
102
+ export async function listAndroidDevices(appId) {
103
+ try {
104
+ const devicesOutput = await execAdb(['devices', '-l']);
105
+ const lines = devicesOutput.split('\n').map(l => l.trim()).filter(Boolean);
106
+ // Skip header if present (some adb versions include 'List of devices attached')
107
+ const deviceLines = lines.filter(l => !l.startsWith('List of devices')).map(l => l);
108
+ const serials = deviceLines.map(line => line.split(/\s+/)[0]).filter(Boolean);
109
+ const infos = await Promise.all(serials.map(async (serial) => {
110
+ try {
111
+ const [osVersion, model, simOutput] = await Promise.all([
112
+ execAdb(['shell', 'getprop', 'ro.build.version.release'], serial).catch(() => ''),
113
+ execAdb(['shell', 'getprop', 'ro.product.model'], serial).catch(() => ''),
114
+ execAdb(['shell', 'getprop', 'ro.kernel.qemu'], serial).catch(() => '0')
115
+ ]);
116
+ const simulator = simOutput === '1';
117
+ let appInstalled = false;
118
+ if (appId) {
119
+ try {
120
+ const pm = await execAdb(['shell', 'pm', 'path', appId], serial);
121
+ appInstalled = !!(pm && pm.includes('package:'));
122
+ }
123
+ catch {
124
+ appInstalled = false;
125
+ }
126
+ }
127
+ return { platform: 'android', id: serial, osVersion, model, simulator, appInstalled };
128
+ }
129
+ catch {
130
+ return { platform: 'android', id: serial, osVersion: '', model: '', simulator: false, appInstalled: false };
131
+ }
132
+ }));
133
+ return infos;
134
+ }
135
+ catch (e) {
136
+ return [];
137
+ }
138
+ }
139
+ // Log stream management (one stream per session)
140
+ import { createWriteStream, promises as fsPromises } from 'fs';
141
+ import path from 'path';
142
+ const activeLogStreams = new Map();
143
+ // Test helper to register a pre-existing NDJSON file as the active stream for a session (used by unit tests)
144
+ export function _setActiveLogStream(sessionId, file) {
145
+ activeLogStreams.set(sessionId, { proc: { kill: () => { } }, file });
146
+ }
147
+ export function _clearActiveLogStream(sessionId) {
148
+ activeLogStreams.delete(sessionId);
149
+ }
150
+ // Robust log line parser supporting multiple logcat formats
151
+ export function parseLogLine(line) {
152
+ // Collapse internal newlines so multiline stack traces are parseable as a single entry
153
+ const rawLine = line;
154
+ const normalizedLine = rawLine.replace(/\r?\n/g, ' ');
155
+ const entry = { timestamp: '', level: '', tag: '', message: rawLine, _iso: null, crash: false };
156
+ const nowYear = new Date().getFullYear();
157
+ const tryIso = (ts) => {
158
+ if (!ts)
159
+ return null;
160
+ // If it's already ISO
161
+ if (/^\d{4}-\d{2}-\d{2}T/.test(ts))
162
+ return ts;
163
+ // If format MM-DD HH:MM:SS(.sss)
164
+ const m = ts.match(/^(\d{2})-(\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/);
165
+ if (m) {
166
+ const month = m[1];
167
+ const day = m[2];
168
+ const time = m[3];
169
+ const candidate = `${nowYear}-${month}-${day}T${time}`;
170
+ const d = new Date(candidate);
171
+ if (!isNaN(d.getTime()))
172
+ return d.toISOString();
173
+ }
174
+ // If format YYYY-MM-DD HH:MM:SS(.sss)
175
+ const m2 = ts.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)$/);
176
+ if (m2) {
177
+ const candidate = `${m2[1]}T${m2[2]}`;
178
+ const d = new Date(candidate);
179
+ if (!isNaN(d.getTime()))
180
+ return d.toISOString();
181
+ }
182
+ return null;
183
+ };
184
+ // Patterns to try (ordered)
185
+ const patterns = [
186
+ // MM-DD HH:MM:SS.mmm PID TID LEVEL/Tag: msg
187
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
188
+ // MM-DD HH:MM:SS.mmm PID TID LEVEL Tag: msg (space between level and tag)
189
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
190
+ // YYYY-MM-DD full date with PID TID LEVEL/Tag
191
+ { re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
192
+ // YYYY-MM-DD with space separation
193
+ { re: /^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'tid', 'level', 'tag', 'msg'] },
194
+ // MM-DD PID LEVEL/Tag: msg
195
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\/([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'level', 'tag', 'msg'] },
196
+ // MM-DD PID LEVEL Tag: msg (space)
197
+ { re: /^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s+(\d+)\s+([VDIWE])\s+([^:]+):\s*(.*)$/, groups: ['ts', 'pid', 'level', 'tag', 'msg'] },
198
+ // Short form LEVEL/Tag: msg
199
+ { re: /^([VDIWE])\/([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level', 'tag', 'msg'] },
200
+ // Short form LEVEL Tag: msg
201
+ { re: /^([VDIWE])\s+([^\(\:]+)(?:\([0-9]+\))?:\s*(.*)$/, groups: ['level', 'tag', 'msg'] },
202
+ ];
203
+ for (const p of patterns) {
204
+ const m = normalizedLine.match(p.re);
205
+ if (m) {
206
+ const g = p.groups;
207
+ const vals = {};
208
+ for (let i = 0; i < g.length; i++)
209
+ vals[g[i]] = m[i + 1];
210
+ const ts = vals.ts;
211
+ if (ts) {
212
+ const iso = tryIso(ts);
213
+ if (iso) {
214
+ entry.timestamp = ts;
215
+ entry._iso = iso;
216
+ }
217
+ else {
218
+ entry.timestamp = ts;
219
+ }
220
+ }
221
+ if (vals.level)
222
+ entry.level = vals.level;
223
+ if (vals.tag)
224
+ entry.tag = vals.tag.trim();
225
+ entry.message = vals.msg || entry.message;
226
+ // Crash heuristics
227
+ const msg = (entry.message || '').toString();
228
+ const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg);
229
+ if (crash) {
230
+ entry.crash = true;
231
+ const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
232
+ if (exMatch)
233
+ entry.exception = exMatch[1];
234
+ }
235
+ return entry;
236
+ }
237
+ }
238
+ // No pattern matched: attempt to extract level/tag like '... E/Tag: msg'
239
+ const alt = normalizedLine.match(/([VDIWE])\/([^:]+):\s*(.*)$/);
240
+ if (alt) {
241
+ entry.level = alt[1];
242
+ entry.tag = alt[2].trim();
243
+ entry.message = alt[3];
244
+ const msg = entry.message;
245
+ const crash = /FATAL EXCEPTION/i.test(msg) || /\b([A-Za-z0-9_$.]+Exception)\b/.test(msg);
246
+ if (crash) {
247
+ entry.crash = true;
248
+ const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
249
+ if (exMatch)
250
+ entry.exception = exMatch[1];
251
+ }
252
+ }
253
+ return entry;
254
+ }
255
+ export async function startAndroidLogStream(packageName, level = 'error', deviceId, sessionId = 'default') {
256
+ try {
257
+ // Determine PID
258
+ const pidOutput = await execAdb(['shell', 'pidof', packageName], deviceId).catch(() => '');
259
+ const pid = (pidOutput || '').trim();
260
+ if (!pid) {
261
+ return { success: false, error: 'app_not_running' };
262
+ }
263
+ // Map level to logcat filter
264
+ const levelMap = { error: '*:E', warn: '*:W', info: '*:I', debug: '*:D' };
265
+ const filter = levelMap[level] || levelMap['error'];
266
+ // Prevent multiple streams per session
267
+ if (activeLogStreams.has(sessionId)) {
268
+ // stop existing
269
+ try {
270
+ activeLogStreams.get(sessionId).proc.kill();
271
+ }
272
+ catch (e) { }
273
+ activeLogStreams.delete(sessionId);
274
+ }
275
+ // Start logcat process
276
+ const args = ['logcat', `--pid=${pid}`, filter];
277
+ const proc = spawn(ADB, args);
278
+ // Prepare output file
279
+ const tmpDir = process.env.TMPDIR || '/tmp';
280
+ const file = path.join(tmpDir, `mobile-debug-log-${sessionId}.ndjson`);
281
+ const stream = createWriteStream(file, { flags: 'a' });
282
+ proc.stdout.on('data', (chunk) => {
283
+ const text = chunk.toString();
284
+ const lines = text.split(/\r?\n/).filter(Boolean);
285
+ for (const l of lines) {
286
+ const entry = parseLogLine(l);
287
+ stream.write(JSON.stringify(entry) + '\n');
288
+ }
289
+ });
290
+ proc.stderr.on('data', (chunk) => {
291
+ // write stderr lines as message with level 'E'
292
+ const text = chunk.toString();
293
+ const lines = text.split(/\r?\n/).filter(Boolean);
294
+ for (const l of lines) {
295
+ const entry = { timestamp: '', level: 'E', tag: 'adb', message: l };
296
+ stream.write(JSON.stringify(entry) + '\n');
297
+ }
298
+ });
299
+ proc.on('close', (code) => {
300
+ stream.end();
301
+ activeLogStreams.delete(sessionId);
302
+ });
303
+ activeLogStreams.set(sessionId, { proc, file });
304
+ return { success: true, stream_started: true };
305
+ }
306
+ catch (err) {
307
+ return { success: false, error: 'log_stream_start_failed' };
308
+ }
309
+ }
310
+ export async function stopAndroidLogStream(sessionId = 'default') {
311
+ const entry = activeLogStreams.get(sessionId);
312
+ if (!entry)
313
+ return { success: true };
314
+ try {
315
+ entry.proc.kill();
316
+ }
317
+ catch (e) { }
318
+ activeLogStreams.delete(sessionId);
319
+ return { success: true };
320
+ }
321
+ export async function readLogStreamLines(sessionId = 'default', limit = 100, since) {
322
+ const entry = activeLogStreams.get(sessionId);
323
+ if (!entry)
324
+ return { entries: [] };
325
+ try {
326
+ const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '');
327
+ if (!data)
328
+ return { entries: [], crash_summary: { crash_detected: false } };
329
+ const lines = data.split(/\r?\n/).filter(Boolean);
330
+ // Parse NDJSON lines into objects. Prefer fields written by parseLogLine. For backward compatibility, if _iso or crash are missing, enrich minimally here (avoid duplicating full parse logic).
331
+ const parsed = lines.map(l => {
332
+ try {
333
+ const obj = JSON.parse(l);
334
+ // Ensure _iso: if missing, try to derive using Date()
335
+ if (typeof obj._iso === 'undefined') {
336
+ let iso = null;
337
+ if (obj.timestamp) {
338
+ const d = new Date(obj.timestamp);
339
+ if (!isNaN(d.getTime()))
340
+ iso = d.toISOString();
341
+ }
342
+ obj._iso = iso;
343
+ }
344
+ // Ensure crash flag: if missing, run minimal heuristic
345
+ if (typeof obj.crash === 'undefined') {
346
+ const msg = (obj.message || '').toString();
347
+ const exMatch = msg.match(/\b([A-Za-z0-9_$.]+Exception)\b/);
348
+ if (/FATAL EXCEPTION/i.test(msg) || exMatch) {
349
+ obj.crash = true;
350
+ if (exMatch)
351
+ obj.exception = exMatch[1];
352
+ }
353
+ else {
354
+ obj.crash = false;
355
+ }
356
+ }
357
+ return obj;
358
+ }
359
+ catch {
360
+ return { message: l, _iso: null, crash: false };
361
+ }
362
+ });
363
+ // Filter by since if provided (accept ISO or epoch ms)
364
+ let filtered = parsed;
365
+ if (since) {
366
+ let sinceMs = null;
367
+ // If numeric string
368
+ if (/^\d+$/.test(since))
369
+ sinceMs = Number(since);
370
+ else {
371
+ const sDate = new Date(since);
372
+ if (!isNaN(sDate.getTime()))
373
+ sinceMs = sDate.getTime();
374
+ }
375
+ if (sinceMs !== null) {
376
+ filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs));
377
+ }
378
+ }
379
+ // Return the last `limit` entries (most recent)
380
+ const entries = filtered.slice(-Math.max(0, limit));
381
+ // Crash summary
382
+ const crashEntry = entries.find(e => e.crash);
383
+ const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false };
384
+ return { entries, crash_summary };
385
+ }
386
+ catch (e) {
387
+ return { entries: [], crash_summary: { crash_detected: false } };
388
+ }
389
+ }