mobile-debug-mcp 0.32.1 → 0.32.2
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/dist/observe/android-tree-bridge.js +10 -0
- package/dist/server-core.js +1 -1
- package/docs/CHANGELOG.md +3 -0
- package/docs/benchmarks/ui-tree-performance/README.md +7 -0
- package/docs/benchmarks/ui-tree-performance/bridge-probe.json +9 -8
- package/docs/tools/observe.md +2 -1
- package/package.json +1 -1
- package/src/observe/android-tree-bridge.ts +8 -0
- package/src/server-core.ts +1 -1
- package/test/device/automated/ui-tree-performance/bridge-probe.ts +14 -0
- package/test/fixtures/latency-journey-app/src/MainActivity.java +32 -1
- package/test/fixtures/ui-tree-bridge/src/TreeInstrumentation.java +13 -6
- package/test/unit/observe/android_tree_bridge_cleanup.test.ts +20 -1
|
@@ -10,6 +10,7 @@ const devicePort = 39019;
|
|
|
10
10
|
const active = new Map();
|
|
11
11
|
const starting = new Map();
|
|
12
12
|
const stopping = new Map();
|
|
13
|
+
const bridgeSupport = new Map();
|
|
13
14
|
export function androidTreeBridgeEnabled() {
|
|
14
15
|
return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK);
|
|
15
16
|
}
|
|
@@ -168,6 +169,15 @@ export async function readAndroidTreeFromBridge(deviceId) {
|
|
|
168
169
|
return null;
|
|
169
170
|
let bridge;
|
|
170
171
|
try {
|
|
172
|
+
let supported = bridgeSupport.get(deviceId);
|
|
173
|
+
if (supported === undefined) {
|
|
174
|
+
const sdkLevel = Number(await execAdb(['shell', 'getprop', 'ro.build.version.sdk'], deviceId));
|
|
175
|
+
supported = Number.isInteger(sdkLevel) && sdkLevel >= 34;
|
|
176
|
+
if (supported)
|
|
177
|
+
bridgeSupport.set(deviceId, true);
|
|
178
|
+
}
|
|
179
|
+
if (!supported)
|
|
180
|
+
return null;
|
|
171
181
|
bridge = await ensure(deviceId, apk);
|
|
172
182
|
const output = await request(bridge, 'TREE', remainingBudget(10000));
|
|
173
183
|
if (!output.startsWith('OK\n') || !output.includes('<hierarchy'))
|
package/dist/server-core.js
CHANGED
|
@@ -6,7 +6,7 @@ import { handleToolCall } from './server/tool-handlers.js';
|
|
|
6
6
|
export { wrapResponse, toolDefinitions, handleToolCall };
|
|
7
7
|
export const serverInfo = {
|
|
8
8
|
name: 'mobile-debug-mcp',
|
|
9
|
-
version: '0.32.
|
|
9
|
+
version: '0.32.2'
|
|
10
10
|
};
|
|
11
11
|
export function createServer() {
|
|
12
12
|
const server = new Server(serverInfo, {
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -49,3 +49,10 @@ network failures; see `comparison.md` for the outcome and limitation.
|
|
|
49
49
|
The report commit SHAs are available locally as `codex/spec-019-baseline` and
|
|
50
50
|
`codex/spec-019-candidate`; create clean, built checkouts of those refs to
|
|
51
51
|
reproduce the paired measurement.
|
|
52
|
+
|
|
53
|
+
For the opt-in Android bridge, run
|
|
54
|
+
`LATENCY_DEVICE_ID=emulator-5554 node --import tsx test/device/automated/ui-tree-performance/bridge-probe.ts`.
|
|
55
|
+
Its `bridge-probe.json` compares ten bridge and legacy reads, checks a network
|
|
56
|
+
update, and checks a timer that changes visually without emitting an
|
|
57
|
+
accessibility event. The timer check catches a stale accessibility cache even
|
|
58
|
+
when every bridge request calls `getRootInActiveWindow()`.
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
"device_id": "[redacted]",
|
|
4
4
|
"fixture": "latency-journey-app",
|
|
5
5
|
"network_freshness_passed": true,
|
|
6
|
+
"silent_timer_freshness_passed": true,
|
|
6
7
|
"bridge": {
|
|
7
8
|
"samples": {
|
|
8
9
|
"count": 10,
|
|
9
|
-
"p50":
|
|
10
|
-
"p95":
|
|
11
|
-
"min":
|
|
12
|
-
"max":
|
|
10
|
+
"p50": 3.122458,
|
|
11
|
+
"p95": 4.390417,
|
|
12
|
+
"min": 2.858792,
|
|
13
|
+
"max": 4.390417
|
|
13
14
|
},
|
|
14
15
|
"node_count": 7,
|
|
15
16
|
"identified_nodes": 4
|
|
@@ -17,10 +18,10 @@
|
|
|
17
18
|
"legacy": {
|
|
18
19
|
"samples": {
|
|
19
20
|
"count": 10,
|
|
20
|
-
"p50":
|
|
21
|
-
"p95":
|
|
22
|
-
"min":
|
|
23
|
-
"max":
|
|
21
|
+
"p50": 2101.953208,
|
|
22
|
+
"p95": 2257.736083,
|
|
23
|
+
"min": 2021.97375,
|
|
24
|
+
"max": 2257.736083
|
|
24
25
|
},
|
|
25
26
|
"node_count": 7,
|
|
26
27
|
"identified_nodes": 4
|
package/docs/tools/observe.md
CHANGED
|
@@ -92,7 +92,8 @@ Notes:
|
|
|
92
92
|
- Elements may also include platform-native identity hints such as `stable_id`, `role`, `test_tag`, `selector`, and `semantic`.
|
|
93
93
|
- The tree response may include `snapshot_revision`, `captured_at_ms`, and `loading_state` when a reliable signal is available.
|
|
94
94
|
- Prefer `wait_for_ui` for deterministic element resolution in interactive flows.
|
|
95
|
-
- Android can use the experimental persistent hierarchy reader
|
|
95
|
+
- Android 14+ can use the experimental persistent hierarchy reader. Build its APK with `npx tsx test/fixtures/ui-tree-bridge/build.ts`, then set `MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK` to its absolute path before starting the server. The reader clears Android's accessibility cache before every tree read, including when a screen changes without an accessibility event. Older Android versions use `uiautomator dump`.
|
|
96
|
+
- If the bridge fails, the server stops it before falling back to `uiautomator dump`. While running, the bridge holds Android's UiAutomation connection, so other `uiautomator dump` clients may fail. Leave the variable unset for the default reader. To reproduce the device probe, set `LATENCY_DEVICE_ID` and run `npx tsx test/device/automated/ui-tree-performance/bridge-probe.ts`.
|
|
96
97
|
|
|
97
98
|
---
|
|
98
99
|
|
package/package.json
CHANGED
|
@@ -20,6 +20,7 @@ interface Bridge {
|
|
|
20
20
|
const active = new Map<string, Bridge>()
|
|
21
21
|
const starting = new Map<string, Promise<Bridge>>()
|
|
22
22
|
const stopping = new Map<string, Promise<void>>()
|
|
23
|
+
const bridgeSupport = new Map<string, boolean>()
|
|
23
24
|
|
|
24
25
|
export function androidTreeBridgeEnabled(): boolean {
|
|
25
26
|
return Boolean(process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK)
|
|
@@ -150,6 +151,13 @@ export async function readAndroidTreeFromBridge(deviceId: string): Promise<strin
|
|
|
150
151
|
if (!apk) return null
|
|
151
152
|
let bridge: Bridge | undefined
|
|
152
153
|
try {
|
|
154
|
+
let supported = bridgeSupport.get(deviceId)
|
|
155
|
+
if (supported === undefined) {
|
|
156
|
+
const sdkLevel = Number(await execAdb(['shell', 'getprop', 'ro.build.version.sdk'], deviceId))
|
|
157
|
+
supported = Number.isInteger(sdkLevel) && sdkLevel >= 34
|
|
158
|
+
if (supported) bridgeSupport.set(deviceId, true)
|
|
159
|
+
}
|
|
160
|
+
if (!supported) return null
|
|
153
161
|
bridge = await ensure(deviceId, apk)
|
|
154
162
|
const output = await request(bridge, 'TREE', remainingBudget(10000))
|
|
155
163
|
if (!output.startsWith('OK\n') || !output.includes('<hierarchy')) throw new Error('Android UI tree bridge returned invalid hierarchy output')
|
package/src/server-core.ts
CHANGED
|
@@ -111,6 +111,19 @@ async function main() {
|
|
|
111
111
|
await readyBridge()
|
|
112
112
|
bridgeStarted = true
|
|
113
113
|
adb('shell', 'am', 'force-stop', fixturePackage)
|
|
114
|
+
adb('shell', 'am', 'start', '-n', `${fixturePackage}/.MainActivity`, '--ez', 'silentTimer', 'true')
|
|
115
|
+
const initialDeadline = Date.now() + 5000
|
|
116
|
+
let initialTimer = false
|
|
117
|
+
while (Date.now() < initialDeadline) {
|
|
118
|
+
initialTimer = nodes((await bridgeRead()).xml).some((node) => node['@_content-desc'] === 'silent timer 0:00')
|
|
119
|
+
if (initialTimer) break
|
|
120
|
+
await sleep(50)
|
|
121
|
+
}
|
|
122
|
+
if (!initialTimer) throw new Error('Bridge did not observe the initial silent timer')
|
|
123
|
+
await sleep(2300)
|
|
124
|
+
const updatedTimer = nodes((await bridgeRead()).xml).some((node) => node['@_content-desc'] === 'silent timer 0:01')
|
|
125
|
+
if (!updatedTimer) throw new Error('Bridge reused a cached timer node after a visible update without an accessibility event')
|
|
126
|
+
adb('shell', 'am', 'force-stop', fixturePackage)
|
|
114
127
|
adb('shell', 'pm', 'clear', fixturePackage)
|
|
115
128
|
adb('shell', 'am', 'start', '-n', `${fixturePackage}/.MainActivity`, '--es', 'networkBaseUrl', `http://10.0.2.2:${address.port}`, '--es', 'networkRunId', runId)
|
|
116
129
|
await Promise.race([requestPromise, sleep(10000).then(() => { throw new Error('Fixture request timed out') })])
|
|
@@ -164,6 +177,7 @@ async function main() {
|
|
|
164
177
|
const report = {
|
|
165
178
|
platform: 'android', device_id: '[redacted]', fixture: 'latency-journey-app',
|
|
166
179
|
network_freshness_passed: true,
|
|
180
|
+
silent_timer_freshness_passed: true,
|
|
167
181
|
bridge: { samples: summarize(bridgeSamples.map((sample) => sample.elapsed_ms)), node_count: bridgeNodes.length, identified_nodes: countIdentifiers(bridgeNodes) },
|
|
168
182
|
legacy: { samples: summarize(legacySamples.map((sample) => sample.elapsed_ms)), node_count: legacyNodes.length, identified_nodes: countIdentifiers(legacyNodes) }
|
|
169
183
|
}
|
|
@@ -2,7 +2,12 @@ package dev.mobiledebugmcp.latencyfixture;
|
|
|
2
2
|
|
|
3
3
|
import android.app.Activity;
|
|
4
4
|
import android.content.Intent;
|
|
5
|
+
import android.graphics.Canvas;
|
|
6
|
+
import android.graphics.Color;
|
|
7
|
+
import android.graphics.Paint;
|
|
5
8
|
import android.os.Bundle;
|
|
9
|
+
import android.view.View;
|
|
10
|
+
import android.view.accessibility.AccessibilityNodeInfo;
|
|
6
11
|
import android.widget.Button;
|
|
7
12
|
import android.widget.LinearLayout;
|
|
8
13
|
import android.widget.TextView;
|
|
@@ -30,9 +35,35 @@ public final class MainActivity extends Activity {
|
|
|
30
35
|
super.onCreate(state);
|
|
31
36
|
String networkBaseUrl = getIntent().getStringExtra("networkBaseUrl");
|
|
32
37
|
String networkRunId = getIntent().getStringExtra("networkRunId");
|
|
33
|
-
if (
|
|
38
|
+
if (getIntent().getBooleanExtra("silentTimer", false)) silentTimerScreen();
|
|
39
|
+
else if (networkBaseUrl != null && networkRunId != null) networkScreen(networkBaseUrl, networkRunId);
|
|
34
40
|
else home();
|
|
35
41
|
}
|
|
42
|
+
private void silentTimerScreen() {
|
|
43
|
+
layout = new LinearLayout(this);
|
|
44
|
+
layout.setPadding(32, 100, 32, 32);
|
|
45
|
+
setContentView(layout);
|
|
46
|
+
SilentTimerView timer = new SilentTimerView();
|
|
47
|
+
layout.addView(timer, new LinearLayout.LayoutParams(-1, 200));
|
|
48
|
+
timer.postDelayed(() -> timer.setValue("0:01"), 2000);
|
|
49
|
+
}
|
|
50
|
+
private final class SilentTimerView extends View {
|
|
51
|
+
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
|
52
|
+
private String value = "0:00";
|
|
53
|
+
SilentTimerView() {
|
|
54
|
+
super(MainActivity.this);
|
|
55
|
+
setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
|
|
56
|
+
paint.setColor(Color.BLACK);
|
|
57
|
+
paint.setTextSize(72);
|
|
58
|
+
}
|
|
59
|
+
// Deliberately redraw without an accessibility event to expose stale node caches.
|
|
60
|
+
void setValue(String next) { value = next; invalidate(); }
|
|
61
|
+
@Override protected void onDraw(Canvas canvas) { canvas.drawText(value, 20, 110, paint); }
|
|
62
|
+
@Override public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
|
|
63
|
+
super.onInitializeAccessibilityNodeInfo(info);
|
|
64
|
+
info.setContentDescription("silent timer " + value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
36
67
|
private void networkScreen(String baseUrl, String runId) {
|
|
37
68
|
layout = new LinearLayout(this);
|
|
38
69
|
layout.setOrientation(LinearLayout.VERTICAL);
|
|
@@ -4,6 +4,7 @@ import android.app.Instrumentation;
|
|
|
4
4
|
import android.app.UiAutomation;
|
|
5
5
|
import android.graphics.Rect;
|
|
6
6
|
import android.os.Bundle;
|
|
7
|
+
import android.os.Build;
|
|
7
8
|
import android.util.Xml;
|
|
8
9
|
import android.util.Log;
|
|
9
10
|
import android.view.accessibility.AccessibilityNodeInfo;
|
|
@@ -47,13 +48,19 @@ public final class TreeInstrumentation extends Instrumentation {
|
|
|
47
48
|
if (!"TREE".equals(command)) {
|
|
48
49
|
output.write("ERROR invalid_command\n");
|
|
49
50
|
} else {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
if (Build.VERSION.SDK_INT < 34) {
|
|
52
|
+
output.write("ERROR cache_clear_unavailable\n");
|
|
53
|
+
} else if (!automation.clearCache()) {
|
|
54
|
+
output.write("ERROR cache_clear_failed\n");
|
|
53
55
|
} else {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
AccessibilityNodeInfo root = automation.getRootInActiveWindow();
|
|
57
|
+
if (root == null) {
|
|
58
|
+
output.write("ERROR null_root\n");
|
|
59
|
+
} else {
|
|
60
|
+
output.write("OK\n");
|
|
61
|
+
output.write(serialize(root));
|
|
62
|
+
root.recycle();
|
|
63
|
+
}
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
output.flush();
|
|
@@ -11,7 +11,8 @@ async function run() {
|
|
|
11
11
|
const adbPath = path.join(directory, 'adb')
|
|
12
12
|
const apkPath = path.join(directory, 'bridge.apk')
|
|
13
13
|
const launchesPath = path.join(directory, 'launches')
|
|
14
|
-
const
|
|
14
|
+
const getpropsPath = path.join(directory, 'getprops')
|
|
15
|
+
const previous = new Map(['ADB_PATH', 'MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK', 'MCP_FAKE_BRIDGE_PORT', 'MCP_FAKE_BRIDGE_LAUNCHES', 'MCP_FAKE_BRIDGE_GETPROPS', 'MCP_FAKE_FORCE_STOP_FAIL', 'MCP_FAKE_SDK'].map((key) => [key, process.env[key]]))
|
|
15
16
|
let invalidTree = false
|
|
16
17
|
const sockets = new Set<Socket>()
|
|
17
18
|
const server = createServer((socket) => {
|
|
@@ -26,6 +27,7 @@ async function run() {
|
|
|
26
27
|
})
|
|
27
28
|
writeFileSync(apkPath, '')
|
|
28
29
|
writeFileSync(launchesPath, '')
|
|
30
|
+
writeFileSync(getpropsPath, '')
|
|
29
31
|
writeFileSync(adbPath, `#!/usr/bin/env node
|
|
30
32
|
const fs = require('node:fs')
|
|
31
33
|
const args = process.argv.slice(2).join(' ')
|
|
@@ -36,6 +38,9 @@ if (args.includes('am instrument')) {
|
|
|
36
38
|
process.exit(1)
|
|
37
39
|
} else if (args.includes('forward tcp:0')) {
|
|
38
40
|
console.log(process.env.MCP_FAKE_BRIDGE_PORT)
|
|
41
|
+
} else if (args.includes('getprop ro.build.version.sdk')) {
|
|
42
|
+
fs.appendFileSync(process.env.MCP_FAKE_BRIDGE_GETPROPS, 'getprop\\n')
|
|
43
|
+
console.log(process.env.MCP_FAKE_SDK || '36')
|
|
39
44
|
}
|
|
40
45
|
`)
|
|
41
46
|
chmodSync(adbPath, 0o755)
|
|
@@ -46,6 +51,7 @@ if (args.includes('am instrument')) {
|
|
|
46
51
|
process.env.MOBILE_DEBUG_MCP_ANDROID_TREE_BRIDGE_APK = apkPath
|
|
47
52
|
process.env.MCP_FAKE_BRIDGE_PORT = String(address.port)
|
|
48
53
|
process.env.MCP_FAKE_BRIDGE_LAUNCHES = launchesPath
|
|
54
|
+
process.env.MCP_FAKE_BRIDGE_GETPROPS = getpropsPath
|
|
49
55
|
try {
|
|
50
56
|
assert.match(await readAndroidTreeFromBridge('test-device') ?? '', /<hierarchy>/)
|
|
51
57
|
invalidTree = true
|
|
@@ -62,6 +68,19 @@ if (args.includes('am instrument')) {
|
|
|
62
68
|
assert.equal(readFileSync(launchesPath, 'utf8').trim().split('\n').length, 2)
|
|
63
69
|
invalidTree = true
|
|
64
70
|
assert.equal(await readAndroidTreeFromBridge('test-device'), null, 'clean shutdown permits legacy fallback')
|
|
71
|
+
process.env.MCP_FAKE_SDK = '33'
|
|
72
|
+
assert.equal(await readAndroidTreeFromBridge('old-device'), null, 'older Android versions use the legacy reader')
|
|
73
|
+
assert.equal(await readAndroidTreeFromBridge('old-device'), null)
|
|
74
|
+
assert.equal(readFileSync(getpropsPath, 'utf8').trim().split('\n').length, 3, 'unsupported devices recheck SDK support')
|
|
75
|
+
process.env.MCP_FAKE_SDK = '36'
|
|
76
|
+
invalidTree = false
|
|
77
|
+
assert.match(await readAndroidTreeFromBridge('old-device') ?? '', /<hierarchy>/, 'a newly supported device can start the bridge')
|
|
78
|
+
for (let attempt = 0; attempt < 20 && readFileSync(launchesPath, 'utf8').trim().split('\n').length < 3; attempt++) {
|
|
79
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
80
|
+
}
|
|
81
|
+
assert.equal(readFileSync(launchesPath, 'utf8').trim().split('\n').length, 3)
|
|
82
|
+
invalidTree = true
|
|
83
|
+
assert.equal(await readAndroidTreeFromBridge('old-device'), null, 'the new bridge shuts down before fallback')
|
|
65
84
|
} finally {
|
|
66
85
|
for (const [key, value] of previous) {
|
|
67
86
|
if (value === undefined) delete process.env[key]
|