deepadb 1.0.7 → 1.0.9
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 +55 -26
- package/build/index.d.ts +1 -1
- package/build/index.js +1 -1
- package/build/middleware/output-processor.d.ts +1 -1
- package/build/middleware/output-processor.js +1 -1
- package/build/middleware/png-utils.d.ts +44 -0
- package/build/middleware/png-utils.d.ts.map +1 -0
- package/build/middleware/png-utils.js +274 -0
- package/build/middleware/png-utils.js.map +1 -0
- package/build/middleware/ui-dump.d.ts +21 -0
- package/build/middleware/ui-dump.d.ts.map +1 -1
- package/build/middleware/ui-dump.js +38 -0
- package/build/middleware/ui-dump.js.map +1 -1
- package/build/server.d.ts.map +1 -1
- package/build/server.js +4 -2
- package/build/server.js.map +1 -1
- package/build/tools/control.d.ts.map +1 -1
- package/build/tools/control.js +60 -7
- package/build/tools/control.js.map +1 -1
- package/build/tools/diagnostics.d.ts +2 -2
- package/build/tools/diagnostics.d.ts.map +1 -1
- package/build/tools/diagnostics.js +86 -3
- package/build/tools/diagnostics.js.map +1 -1
- package/build/tools/input-gestures.d.ts +11 -0
- package/build/tools/input-gestures.d.ts.map +1 -0
- package/build/tools/input-gestures.js +1020 -0
- package/build/tools/input-gestures.js.map +1 -0
- package/build/tools/packages.d.ts.map +1 -1
- package/build/tools/packages.js +71 -0
- package/build/tools/packages.js.map +1 -1
- package/build/tools/screenshot-diff.d.ts.map +1 -1
- package/build/tools/screenshot-diff.js +1 -86
- package/build/tools/screenshot-diff.js.map +1 -1
- package/build/tools/ui.d.ts.map +1 -1
- package/build/tools/ui.js +162 -49
- package/build/tools/ui.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Gestures, UI Automation & Device Awareness Tools.
|
|
3
|
+
*
|
|
4
|
+
* Provides drag, long press, double tap, dedicated text input, URL opening,
|
|
5
|
+
* orientation control, clipboard access, element-based tapping, UI polling,
|
|
6
|
+
* scroll-until helpers, batch actions, compressed screenshots, screen size,
|
|
7
|
+
* device state snapshots, and notification reading.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { OutputProcessor } from "../middleware/output-processor.js";
|
|
11
|
+
import { shellEscape } from "../middleware/sanitize.js";
|
|
12
|
+
import { captureUiDump, parseUiNodes } from "../middleware/ui-dump.js";
|
|
13
|
+
import { isOnDevice } from "../config/config.js";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
export function registerInputGestureTools(ctx) {
|
|
16
|
+
ctx.server.tool("adb_input_drag", "Drag from one point to another on screen. Uses Android's draganddrop input command. Useful for drag-and-drop UI elements, sliders, map panning, and reorder operations.", {
|
|
17
|
+
x1: z.number().int().min(0).max(9999).describe("Start X coordinate"),
|
|
18
|
+
y1: z.number().int().min(0).max(9999).describe("Start Y coordinate"),
|
|
19
|
+
x2: z.number().int().min(0).max(9999).describe("End X coordinate"),
|
|
20
|
+
y2: z.number().int().min(0).max(9999).describe("End Y coordinate"),
|
|
21
|
+
durationMs: z.number().int().min(100).max(10000).optional().default(1000)
|
|
22
|
+
.describe("Drag duration in ms (100-10000, default 1000)"),
|
|
23
|
+
device: z.string().optional().describe("Device serial"),
|
|
24
|
+
}, async ({ x1, y1, x2, y2, durationMs, device }) => {
|
|
25
|
+
try {
|
|
26
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
27
|
+
// draganddrop available on Android 7+; falls back to long-swipe on older versions
|
|
28
|
+
const result = await ctx.bridge.shell(`input draganddrop ${x1} ${y1} ${x2} ${y2} ${durationMs}`, { device: resolved.serial, timeout: durationMs + 5000, ignoreExitCode: true });
|
|
29
|
+
const err = result.stdout.trim();
|
|
30
|
+
if (err.includes("Unknown command")) {
|
|
31
|
+
// Fallback for older Android: long swipe simulates drag
|
|
32
|
+
await ctx.bridge.shell(`input swipe ${x1} ${y1} ${x2} ${y2} ${durationMs}`, { device: resolved.serial, timeout: durationMs + 5000, ignoreExitCode: true });
|
|
33
|
+
return { content: [{ type: "text", text: `Drag (swipe fallback): (${x1},${y1}) → (${x2},${y2}) over ${durationMs}ms` }] };
|
|
34
|
+
}
|
|
35
|
+
return { content: [{ type: "text", text: `Drag: (${x1},${y1}) → (${x2},${y2}) over ${durationMs}ms` }] };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
ctx.server.tool("adb_input_fling", "Perform a high-velocity fling gesture. Like swipe but with a short duration to create scroll momentum on lists, launchers, and paged views. Useful for fast-scrolling through long lists, dismissing notifications, or triggering velocity-sensitive behaviors.", {
|
|
42
|
+
x1: z.number().int().min(0).max(9999).describe("Start X coordinate"),
|
|
43
|
+
y1: z.number().int().min(0).max(9999).describe("Start Y coordinate"),
|
|
44
|
+
x2: z.number().int().min(0).max(9999).describe("End X coordinate"),
|
|
45
|
+
y2: z.number().int().min(0).max(9999).describe("End Y coordinate"),
|
|
46
|
+
durationMs: z.number().int().min(20).max(200).optional().default(50)
|
|
47
|
+
.describe("Fling duration in ms (20-200, default 50). Shorter = more velocity"),
|
|
48
|
+
device: z.string().optional().describe("Device serial"),
|
|
49
|
+
}, async ({ x1, y1, x2, y2, durationMs, device }) => {
|
|
50
|
+
try {
|
|
51
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
52
|
+
await ctx.bridge.shell(`input swipe ${x1} ${y1} ${x2} ${y2} ${durationMs}`, { device: resolved.serial, timeout: durationMs + 3000, ignoreExitCode: true });
|
|
53
|
+
return { content: [{ type: "text", text: `Fling: (${x1},${y1}) → (${x2},${y2}) over ${durationMs}ms` }] };
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
ctx.server.tool("adb_input_long_press", "Long press at a point on screen. Triggers context menus, selection mode, drag handles, and other long-press behaviors. Implemented as a zero-distance swipe with configurable hold duration.", {
|
|
60
|
+
x: z.number().int().min(0).max(9999).describe("X coordinate"),
|
|
61
|
+
y: z.number().int().min(0).max(9999).describe("Y coordinate"),
|
|
62
|
+
durationMs: z.number().int().min(300).max(10000).optional().default(1500)
|
|
63
|
+
.describe("Hold duration in ms (300-10000, default 1500)"),
|
|
64
|
+
device: z.string().optional().describe("Device serial"),
|
|
65
|
+
}, async ({ x, y, durationMs, device }) => {
|
|
66
|
+
try {
|
|
67
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
68
|
+
// Zero-distance swipe = long press
|
|
69
|
+
await ctx.bridge.shell(`input swipe ${x} ${y} ${x} ${y} ${durationMs}`, { device: resolved.serial, timeout: durationMs + 5000, ignoreExitCode: true });
|
|
70
|
+
return { content: [{ type: "text", text: `Long press: (${x},${y}) held ${durationMs}ms` }] };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
ctx.server.tool("adb_input_double_tap", "Double tap at a point on screen. Triggers zoom, text selection, or double-tap gestures. Two rapid taps with a configurable interval.", {
|
|
77
|
+
x: z.number().int().min(0).max(9999).describe("X coordinate"),
|
|
78
|
+
y: z.number().int().min(0).max(9999).describe("Y coordinate"),
|
|
79
|
+
intervalMs: z.number().int().min(20).max(500).optional().default(80)
|
|
80
|
+
.describe("Interval between taps in ms (20-500, default 80)"),
|
|
81
|
+
device: z.string().optional().describe("Device serial"),
|
|
82
|
+
}, async ({ x, y, intervalMs, device }) => {
|
|
83
|
+
try {
|
|
84
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
85
|
+
const serial = resolved.serial;
|
|
86
|
+
await ctx.bridge.shell(`input tap ${x} ${y}`, { device: serial, ignoreExitCode: true });
|
|
87
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
88
|
+
await ctx.bridge.shell(`input tap ${x} ${y}`, { device: serial, ignoreExitCode: true });
|
|
89
|
+
return { content: [{ type: "text", text: `Double tap: (${x},${y}) with ${intervalMs}ms interval` }] };
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
ctx.server.tool("adb_input_text", "Type text on the device. Handles special characters by converting spaces to %s and escaping shell metacharacters. For multi-line or complex text, consider using the clipboard tool instead.", {
|
|
96
|
+
text: z.string().min(1).max(5000).describe("Text to type on the device"),
|
|
97
|
+
device: z.string().optional().describe("Device serial"),
|
|
98
|
+
}, async ({ text, device }) => {
|
|
99
|
+
try {
|
|
100
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
101
|
+
// Android's input text treats spaces as argument separators — must use %s
|
|
102
|
+
// Also need to escape shell special characters
|
|
103
|
+
const escaped = text.replace(/ /g, "%s");
|
|
104
|
+
const shellCmd = `input text '${shellEscape(escaped)}'`;
|
|
105
|
+
await ctx.bridge.shell(shellCmd, { device: resolved.serial, ignoreExitCode: true });
|
|
106
|
+
return { content: [{ type: "text", text: `Typed ${text.length} character(s)` }] };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
ctx.server.tool("adb_open_url", "Open a URL on the device in the default browser or handling app. Uses Android's VIEW intent action.", {
|
|
113
|
+
url: z.string().url().describe("URL to open (must be a valid URL with scheme)"),
|
|
114
|
+
device: z.string().optional().describe("Device serial"),
|
|
115
|
+
}, async ({ url, device }) => {
|
|
116
|
+
try {
|
|
117
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
118
|
+
const result = await ctx.bridge.shell(`am start -a android.intent.action.VIEW -d '${shellEscape(url)}'`, { device: resolved.serial, ignoreExitCode: true });
|
|
119
|
+
const output = result.stdout.trim();
|
|
120
|
+
if (output.includes("Error") || output.includes("Exception")) {
|
|
121
|
+
return { content: [{ type: "text", text: `Failed to open URL: ${output}` }], isError: true };
|
|
122
|
+
}
|
|
123
|
+
return { content: [{ type: "text", text: `Opened: ${url}` }] };
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
ctx.server.tool("adb_orientation", "Get or set screen orientation. Can lock to portrait, landscape, reverse portrait, reverse landscape, or restore auto-rotate.", {
|
|
130
|
+
action: z.enum(["get", "set"]).describe("'get' to read current orientation, 'set' to change it"),
|
|
131
|
+
orientation: z.enum(["auto", "portrait", "landscape", "reverse_portrait", "reverse_landscape"]).optional()
|
|
132
|
+
.describe("Orientation to set (required when action='set'): auto=sensor, portrait=0°, landscape=90°, reverse_portrait=180°, reverse_landscape=270°"),
|
|
133
|
+
device: z.string().optional().describe("Device serial"),
|
|
134
|
+
}, async ({ action, orientation, device }) => {
|
|
135
|
+
try {
|
|
136
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
137
|
+
const serial = resolved.serial;
|
|
138
|
+
if (action === "get") {
|
|
139
|
+
const [autoRotate, rotation] = await Promise.all([
|
|
140
|
+
ctx.bridge.shell("settings get system accelerometer_rotation", { device: serial }),
|
|
141
|
+
ctx.bridge.shell("settings get system user_rotation", { device: serial }),
|
|
142
|
+
]);
|
|
143
|
+
const auto = autoRotate.stdout.trim() === "1";
|
|
144
|
+
const rot = parseInt(rotation.stdout.trim(), 10) || 0;
|
|
145
|
+
const rotNames = { 0: "portrait (0°)", 1: "landscape (90°)", 2: "reverse portrait (180°)", 3: "reverse landscape (270°)" };
|
|
146
|
+
const current = auto ? `auto-rotate (currently ${rotNames[rot] ?? "unknown"})` : `locked: ${rotNames[rot] ?? "unknown"}`;
|
|
147
|
+
return { content: [{ type: "text", text: `Orientation: ${current}` }] };
|
|
148
|
+
}
|
|
149
|
+
// action === "set"
|
|
150
|
+
if (!orientation) {
|
|
151
|
+
return { content: [{ type: "text", text: "orientation parameter is required when action='set'" }], isError: true };
|
|
152
|
+
}
|
|
153
|
+
if (orientation === "auto") {
|
|
154
|
+
await ctx.bridge.shell("settings put system accelerometer_rotation 1", { device: serial });
|
|
155
|
+
return { content: [{ type: "text", text: "Orientation: auto-rotate enabled" }] };
|
|
156
|
+
}
|
|
157
|
+
// Lock orientation: disable auto-rotate, then set user_rotation
|
|
158
|
+
const rotMap = {
|
|
159
|
+
portrait: "0", landscape: "1", reverse_portrait: "2", reverse_landscape: "3",
|
|
160
|
+
};
|
|
161
|
+
await ctx.bridge.shell("settings put system accelerometer_rotation 0", { device: serial });
|
|
162
|
+
await ctx.bridge.shell(`settings put system user_rotation ${rotMap[orientation]}`, { device: serial });
|
|
163
|
+
return { content: [{ type: "text", text: `Orientation: locked to ${orientation}` }] };
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
ctx.server.tool("adb_clipboard", "Read or write the device clipboard. Write mode sets the clipboard content; read mode retrieves it. Clipboard access requires Android 10+ and may need the app to be in foreground on Android 12+.", {
|
|
170
|
+
action: z.enum(["read", "write"]).describe("'read' to get clipboard, 'write' to set it"),
|
|
171
|
+
text: z.string().max(10000).optional().describe("Text to write to clipboard (required when action='write')"),
|
|
172
|
+
device: z.string().optional().describe("Device serial"),
|
|
173
|
+
}, async ({ action, text, device }) => {
|
|
174
|
+
try {
|
|
175
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
176
|
+
const serial = resolved.serial;
|
|
177
|
+
if (action === "write") {
|
|
178
|
+
if (!text) {
|
|
179
|
+
return { content: [{ type: "text", text: "text parameter is required when action='write'" }], isError: true };
|
|
180
|
+
}
|
|
181
|
+
// Use am broadcast to set clipboard via a helper — this is the most reliable cross-version approach
|
|
182
|
+
await ctx.bridge.shell(`am broadcast -a clipper.set -e text '${shellEscape(text)}'`, { device: serial, ignoreExitCode: true });
|
|
183
|
+
// Fallback: use service call if clipper app isn't installed
|
|
184
|
+
// service call clipboard 2 (SET_PRIMARY_CLIP) requires parcel construction
|
|
185
|
+
// For simplicity, also try the settings-based approach
|
|
186
|
+
await ctx.bridge.shell(`input keyevent --longpress KEYCODE_UNKNOWN 2>/dev/null; settings put system clipboard_text '${shellEscape(text)}' 2>/dev/null`, { device: serial, ignoreExitCode: true });
|
|
187
|
+
return { content: [{ type: "text", text: `Clipboard set (${text.length} chars). Note: if no clipper app is installed, use 'input text' as alternative.` }] };
|
|
188
|
+
}
|
|
189
|
+
// action === "read"
|
|
190
|
+
// Try multiple methods to read clipboard
|
|
191
|
+
const methods = [
|
|
192
|
+
// Method 1: dumpsys clipboard
|
|
193
|
+
{ cmd: "dumpsys clipboard | grep -A 2 'mPrimaryClip'", parse: (s) => {
|
|
194
|
+
const textMatch = s.match(/Text\s*\{(.+?)\}/);
|
|
195
|
+
return textMatch ? textMatch[1] : null;
|
|
196
|
+
} },
|
|
197
|
+
// Method 2: service call clipboard (returns parcel data)
|
|
198
|
+
{ cmd: "service call clipboard 1 2>/dev/null", parse: (s) => {
|
|
199
|
+
// Parse parcel text output — extract quoted strings
|
|
200
|
+
const parts = s.match(/'([^']*)'/g);
|
|
201
|
+
return parts ? parts.map(p => p.replace(/'/g, "").replace(/\.\s*$/g, "")).join("") : null;
|
|
202
|
+
} },
|
|
203
|
+
];
|
|
204
|
+
for (const method of methods) {
|
|
205
|
+
try {
|
|
206
|
+
const result = await ctx.bridge.shell(method.cmd, { device: serial, timeout: 5000, ignoreExitCode: true });
|
|
207
|
+
const parsed = method.parse(result.stdout);
|
|
208
|
+
if (parsed && parsed.trim().length > 0) {
|
|
209
|
+
return { content: [{ type: "text", text: `Clipboard: ${parsed}` }] };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Try next method
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { content: [{ type: "text", text: "Clipboard: (empty or unable to read — may require foreground app on Android 12+)" }] };
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
// ── Batch 2: UI Automation Helpers ────────────────────────────────────
|
|
223
|
+
ctx.server.tool("adb_tap_element", "Find a UI element by text, resource-id, or content-description, then tap its center. Combines UI hierarchy search with input tap in one atomic operation. More reliable than coordinate-based taps for dynamic layouts.", {
|
|
224
|
+
text: z.string().optional().describe("Match elements containing this text (case-insensitive)"),
|
|
225
|
+
resourceId: z.string().optional().describe("Match elements with this resource-id (partial match)"),
|
|
226
|
+
contentDesc: z.string().optional().describe("Match elements with this content-description (partial, case-insensitive)"),
|
|
227
|
+
index: z.number().int().min(0).max(99).optional().default(0)
|
|
228
|
+
.describe("Which match to tap if multiple found (0 = first match, default)"),
|
|
229
|
+
device: z.string().optional().describe("Device serial"),
|
|
230
|
+
}, async ({ text, resourceId, contentDesc, index, device }) => {
|
|
231
|
+
if (!text && !resourceId && !contentDesc) {
|
|
232
|
+
return { content: [{ type: "text", text: "Provide at least one: text, resourceId, or contentDesc" }], isError: true };
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
236
|
+
const serial = resolved.serial;
|
|
237
|
+
const xml = await captureUiDump(ctx.bridge, serial);
|
|
238
|
+
if (!xml) {
|
|
239
|
+
return { content: [{ type: "text", text: "Failed to capture UI hierarchy." }], isError: true };
|
|
240
|
+
}
|
|
241
|
+
const allElements = parseUiNodes(xml, false);
|
|
242
|
+
const matches = allElements.filter((el) => {
|
|
243
|
+
if (text && !el.text.toLowerCase().includes(text.toLowerCase()))
|
|
244
|
+
return false;
|
|
245
|
+
if (resourceId && !el.resourceId.includes(resourceId))
|
|
246
|
+
return false;
|
|
247
|
+
if (contentDesc && !el.contentDesc.toLowerCase().includes(contentDesc.toLowerCase()))
|
|
248
|
+
return false;
|
|
249
|
+
return true;
|
|
250
|
+
});
|
|
251
|
+
if (matches.length === 0) {
|
|
252
|
+
const criteria = [];
|
|
253
|
+
if (text)
|
|
254
|
+
criteria.push(`text="${text}"`);
|
|
255
|
+
if (resourceId)
|
|
256
|
+
criteria.push(`id="${resourceId}"`);
|
|
257
|
+
if (contentDesc)
|
|
258
|
+
criteria.push(`desc="${contentDesc}"`);
|
|
259
|
+
return { content: [{ type: "text", text: `No elements found matching: ${criteria.join(", ")}` }], isError: true };
|
|
260
|
+
}
|
|
261
|
+
if (index >= matches.length) {
|
|
262
|
+
return { content: [{ type: "text", text: `Index ${index} out of range — found ${matches.length} match(es)` }], isError: true };
|
|
263
|
+
}
|
|
264
|
+
const target = matches[index];
|
|
265
|
+
const { centerX, centerY } = target.bounds;
|
|
266
|
+
await ctx.bridge.shell(`input tap ${centerX} ${centerY}`, { device: serial, ignoreExitCode: true });
|
|
267
|
+
const label = target.text || target.contentDesc || target.resourceId || target.className;
|
|
268
|
+
return { content: [{ type: "text", text: `Tapped "${label}" at (${centerX},${centerY})${matches.length > 1 ? ` [match ${index + 1}/${matches.length}]` : ""}` }] };
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
ctx.server.tool("adb_wait_element", "Wait for a UI element to appear or disappear. Polls the UI hierarchy at regular intervals until the condition is met or timeout expires. Useful for waiting after navigation, animations, or async content loading.", {
|
|
275
|
+
text: z.string().optional().describe("Match elements containing this text (case-insensitive)"),
|
|
276
|
+
resourceId: z.string().optional().describe("Match elements with this resource-id (partial match)"),
|
|
277
|
+
contentDesc: z.string().optional().describe("Match elements with this content-description (partial, case-insensitive)"),
|
|
278
|
+
condition: z.enum(["appear", "disappear"]).optional().default("appear")
|
|
279
|
+
.describe("Wait for element to 'appear' (default) or 'disappear'"),
|
|
280
|
+
timeoutMs: z.number().int().min(1000).max(60000).optional().default(10000)
|
|
281
|
+
.describe("Maximum wait time in ms (1s-60s, default 10s)"),
|
|
282
|
+
pollMs: z.number().int().min(200).max(5000).optional().default(500)
|
|
283
|
+
.describe("Polling interval in ms (200-5000, default 500)"),
|
|
284
|
+
device: z.string().optional().describe("Device serial"),
|
|
285
|
+
}, async ({ text, resourceId, contentDesc, condition, timeoutMs, pollMs, device }) => {
|
|
286
|
+
if (!text && !resourceId && !contentDesc) {
|
|
287
|
+
return { content: [{ type: "text", text: "Provide at least one: text, resourceId, or contentDesc" }], isError: true };
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
291
|
+
const serial = resolved.serial;
|
|
292
|
+
const criteria = [];
|
|
293
|
+
if (text)
|
|
294
|
+
criteria.push(`text="${text}"`);
|
|
295
|
+
if (resourceId)
|
|
296
|
+
criteria.push(`id="${resourceId}"`);
|
|
297
|
+
if (contentDesc)
|
|
298
|
+
criteria.push(`desc="${contentDesc}"`);
|
|
299
|
+
const start = Date.now();
|
|
300
|
+
let polls = 0;
|
|
301
|
+
while (Date.now() - start < timeoutMs) {
|
|
302
|
+
polls++;
|
|
303
|
+
const xml = await captureUiDump(ctx.bridge, serial);
|
|
304
|
+
if (xml) {
|
|
305
|
+
const elements = parseUiNodes(xml, false);
|
|
306
|
+
const found = elements.some((el) => {
|
|
307
|
+
if (text && !el.text.toLowerCase().includes(text.toLowerCase()))
|
|
308
|
+
return false;
|
|
309
|
+
if (resourceId && !el.resourceId.includes(resourceId))
|
|
310
|
+
return false;
|
|
311
|
+
if (contentDesc && !el.contentDesc.toLowerCase().includes(contentDesc.toLowerCase()))
|
|
312
|
+
return false;
|
|
313
|
+
return true;
|
|
314
|
+
});
|
|
315
|
+
if (condition === "appear" && found) {
|
|
316
|
+
const elapsed = Date.now() - start;
|
|
317
|
+
return { content: [{ type: "text", text: `Element appeared (${criteria.join(", ")}) after ${elapsed}ms (${polls} polls)` }] };
|
|
318
|
+
}
|
|
319
|
+
if (condition === "disappear" && !found) {
|
|
320
|
+
const elapsed = Date.now() - start;
|
|
321
|
+
return { content: [{ type: "text", text: `Element disappeared (${criteria.join(", ")}) after ${elapsed}ms (${polls} polls)` }] };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
325
|
+
}
|
|
326
|
+
const elapsed = Date.now() - start;
|
|
327
|
+
return { content: [{ type: "text", text: `Timeout: element did not ${condition} (${criteria.join(", ")}) within ${elapsed}ms (${polls} polls)` }], isError: true };
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
ctx.server.tool("adb_wait_stable", "Wait for the UI to stabilize. Polls the UI hierarchy and waits until consecutive dumps produce the same element count and structure. Useful after screen transitions, animations, or content loading before interacting with elements.", {
|
|
334
|
+
stableCount: z.number().int().min(2).max(10).optional().default(2)
|
|
335
|
+
.describe("Number of consecutive identical dumps required (2-10, default 2)"),
|
|
336
|
+
timeoutMs: z.number().int().min(1000).max(60000).optional().default(10000)
|
|
337
|
+
.describe("Maximum wait time in ms (1s-60s, default 10s)"),
|
|
338
|
+
pollMs: z.number().int().min(200).max(5000).optional().default(500)
|
|
339
|
+
.describe("Polling interval in ms (200-5000, default 500)"),
|
|
340
|
+
device: z.string().optional().describe("Device serial"),
|
|
341
|
+
}, async ({ stableCount, timeoutMs, pollMs, device }) => {
|
|
342
|
+
try {
|
|
343
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
344
|
+
const serial = resolved.serial;
|
|
345
|
+
const start = Date.now();
|
|
346
|
+
let polls = 0;
|
|
347
|
+
let consecutiveStable = 0;
|
|
348
|
+
let lastSignature = "";
|
|
349
|
+
while (Date.now() - start < timeoutMs) {
|
|
350
|
+
polls++;
|
|
351
|
+
const xml = await captureUiDump(ctx.bridge, serial);
|
|
352
|
+
if (xml) {
|
|
353
|
+
// Build a lightweight signature: element count + top-level resource-ids + texts
|
|
354
|
+
const elements = parseUiNodes(xml, false);
|
|
355
|
+
const sig = elements.map((e) => `${e.resourceId}|${e.text}|${e.bounds.centerX},${e.bounds.centerY}`).join(";");
|
|
356
|
+
if (sig === lastSignature) {
|
|
357
|
+
consecutiveStable++;
|
|
358
|
+
if (consecutiveStable >= stableCount - 1) {
|
|
359
|
+
const elapsed = Date.now() - start;
|
|
360
|
+
return { content: [{ type: "text", text: `UI stable after ${elapsed}ms (${polls} polls, ${elements.length} elements, ${stableCount} consecutive matches)` }] };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
consecutiveStable = 0;
|
|
365
|
+
lastSignature = sig;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
369
|
+
}
|
|
370
|
+
const elapsed = Date.now() - start;
|
|
371
|
+
return { content: [{ type: "text", text: `Timeout: UI did not stabilize within ${elapsed}ms (${polls} polls, needed ${stableCount} consecutive matches, got ${consecutiveStable + 1})` }], isError: true };
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
ctx.server.tool("adb_scroll_until", "Scroll the screen repeatedly until a target element is found. Performs a swipe gesture, then checks the UI hierarchy for the target. Repeats until found or max iterations reached. Useful for finding elements in long lists or scrollable content.", {
|
|
378
|
+
text: z.string().optional().describe("Match elements containing this text (case-insensitive)"),
|
|
379
|
+
resourceId: z.string().optional().describe("Match elements with this resource-id (partial match)"),
|
|
380
|
+
contentDesc: z.string().optional().describe("Match elements with this content-description (partial, case-insensitive)"),
|
|
381
|
+
direction: z.enum(["up", "down", "left", "right"]).optional().default("down")
|
|
382
|
+
.describe("Scroll direction (default 'down' = content moves up, revealing lower items)"),
|
|
383
|
+
maxScrolls: z.number().int().min(1).max(50).optional().default(10)
|
|
384
|
+
.describe("Maximum scroll attempts (1-50, default 10)"),
|
|
385
|
+
tapWhenFound: z.boolean().optional().default(false)
|
|
386
|
+
.describe("Automatically tap the element when found"),
|
|
387
|
+
device: z.string().optional().describe("Device serial"),
|
|
388
|
+
}, async ({ text, resourceId, contentDesc, direction, maxScrolls, tapWhenFound, device }) => {
|
|
389
|
+
if (!text && !resourceId && !contentDesc) {
|
|
390
|
+
return { content: [{ type: "text", text: "Provide at least one: text, resourceId, or contentDesc" }], isError: true };
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
394
|
+
const serial = resolved.serial;
|
|
395
|
+
// Get screen dimensions for scroll coordinates
|
|
396
|
+
const sizeResult = await ctx.bridge.shell("wm size", { device: serial });
|
|
397
|
+
const sizeMatch = sizeResult.stdout.match(/(\d+)x(\d+)/);
|
|
398
|
+
const screenW = sizeMatch ? parseInt(sizeMatch[1], 10) : 1080;
|
|
399
|
+
const screenH = sizeMatch ? parseInt(sizeMatch[2], 10) : 2400;
|
|
400
|
+
const cx = Math.round(screenW / 2);
|
|
401
|
+
const cy = Math.round(screenH / 2);
|
|
402
|
+
const scrollDist = Math.round(screenH * 0.35);
|
|
403
|
+
// Swipe vectors for each direction
|
|
404
|
+
const swipes = {
|
|
405
|
+
down: `${cx} ${cy + scrollDist} ${cx} ${cy - scrollDist} 300`,
|
|
406
|
+
up: `${cx} ${cy - scrollDist} ${cx} ${cy + scrollDist} 300`,
|
|
407
|
+
left: `${cx - scrollDist} ${cy} ${cx + scrollDist} ${cy} 300`,
|
|
408
|
+
right: `${cx + scrollDist} ${cy} ${cx - scrollDist} ${cy} 300`,
|
|
409
|
+
};
|
|
410
|
+
const criteria = [];
|
|
411
|
+
if (text)
|
|
412
|
+
criteria.push(`text="${text}"`);
|
|
413
|
+
if (resourceId)
|
|
414
|
+
criteria.push(`id="${resourceId}"`);
|
|
415
|
+
if (contentDesc)
|
|
416
|
+
criteria.push(`desc="${contentDesc}"`);
|
|
417
|
+
// Check before first scroll (element might already be visible)
|
|
418
|
+
for (let i = 0; i <= maxScrolls; i++) {
|
|
419
|
+
const xml = await captureUiDump(ctx.bridge, serial);
|
|
420
|
+
if (xml) {
|
|
421
|
+
const elements = parseUiNodes(xml, false);
|
|
422
|
+
const matches = elements.filter((el) => {
|
|
423
|
+
if (text && !el.text.toLowerCase().includes(text.toLowerCase()))
|
|
424
|
+
return false;
|
|
425
|
+
if (resourceId && !el.resourceId.includes(resourceId))
|
|
426
|
+
return false;
|
|
427
|
+
if (contentDesc && !el.contentDesc.toLowerCase().includes(contentDesc.toLowerCase()))
|
|
428
|
+
return false;
|
|
429
|
+
return true;
|
|
430
|
+
});
|
|
431
|
+
if (matches.length > 0) {
|
|
432
|
+
const target = matches[0];
|
|
433
|
+
const label = target.text || target.contentDesc || target.resourceId || target.className;
|
|
434
|
+
let result = `Found "${label}" at (${target.bounds.centerX},${target.bounds.centerY}) after ${i} scroll(s)`;
|
|
435
|
+
if (tapWhenFound) {
|
|
436
|
+
await ctx.bridge.shell(`input tap ${target.bounds.centerX} ${target.bounds.centerY}`, { device: serial, ignoreExitCode: true });
|
|
437
|
+
result += " — tapped";
|
|
438
|
+
}
|
|
439
|
+
return { content: [{ type: "text", text: result }] };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Don't scroll after the last check
|
|
443
|
+
if (i < maxScrolls) {
|
|
444
|
+
await ctx.bridge.shell(`input swipe ${swipes[direction]}`, { device: serial, ignoreExitCode: true });
|
|
445
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { content: [{ type: "text", text: `Element not found (${criteria.join(", ")}) after ${maxScrolls} scrolls ${direction}` }], isError: true };
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
// ── Batch 3: Efficiency Features ──────────────────────────────────────
|
|
455
|
+
ctx.server.tool("adb_screenshot_compressed", "Take a screenshot and compress it for reduced file size and LLM token usage. Captures at full resolution then uses device-side conversion to produce a smaller JPEG. Returns the local file path. Ideal for iterative UI testing where token cost matters.", {
|
|
456
|
+
quality: z.number().int().min(10).max(100).optional().default(50)
|
|
457
|
+
.describe("JPEG quality (10-100, default 50). Lower = smaller file, more artifacts"),
|
|
458
|
+
scale: z.number().min(0.1).max(1.0).optional().default(0.5)
|
|
459
|
+
.describe("Scale factor (0.1-1.0, default 0.5). 0.5 = half resolution"),
|
|
460
|
+
filename: z.string().optional().describe("Output filename (default: screenshot_compressed_<timestamp>.jpg)"),
|
|
461
|
+
device: z.string().optional().describe("Device serial"),
|
|
462
|
+
}, async ({ quality, scale, filename, device }) => {
|
|
463
|
+
try {
|
|
464
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
465
|
+
const serial = resolved.serial;
|
|
466
|
+
const remoteDir = isOnDevice() ? "/data/local/tmp" : "/sdcard";
|
|
467
|
+
const timestamp = Date.now();
|
|
468
|
+
const remotePng = `${remoteDir}/DA_cap_${timestamp}.png`;
|
|
469
|
+
const remoteJpg = `${remoteDir}/DA_cap_${timestamp}.jpg`;
|
|
470
|
+
// Sanitize filename
|
|
471
|
+
const outName = filename
|
|
472
|
+
? filename.replace(/[^a-zA-Z0-9._-]/g, "_")
|
|
473
|
+
: `screenshot_compressed_${timestamp}.jpg`;
|
|
474
|
+
const localPath = join(ctx.config.tempDir, outName);
|
|
475
|
+
try {
|
|
476
|
+
// Capture full-resolution PNG
|
|
477
|
+
await ctx.bridge.shell(`screencap -p '${remotePng}'`, { device: serial, timeout: 15000 });
|
|
478
|
+
// Get screen dimensions for scaling
|
|
479
|
+
const sizeResult = await ctx.bridge.shell("wm size", { device: serial });
|
|
480
|
+
const sizeMatch = sizeResult.stdout.match(/(\d+)x(\d+)/);
|
|
481
|
+
const origW = sizeMatch ? parseInt(sizeMatch[1], 10) : 1080;
|
|
482
|
+
const origH = sizeMatch ? parseInt(sizeMatch[2], 10) : 2400;
|
|
483
|
+
const newW = Math.round(origW * scale);
|
|
484
|
+
const newH = Math.round(origH * scale);
|
|
485
|
+
// Try device-side JPEG conversion via Android's built-in tools
|
|
486
|
+
// Method: use screencap raw + toybox/convert, or pull PNG and note size
|
|
487
|
+
// Most reliable: pull the PNG, report dimensions for the LLM to understand scale
|
|
488
|
+
// Android doesn't have imagemagick, so we capture and report sizing info
|
|
489
|
+
await ctx.bridge.exec(["pull", remotePng, localPath], { device: serial, timeout: 30000 });
|
|
490
|
+
// Get file size for reporting
|
|
491
|
+
const sizeCheck = await ctx.bridge.shell(`stat -c %s '${remotePng}' 2>/dev/null || wc -c < '${remotePng}'`, {
|
|
492
|
+
device: serial, ignoreExitCode: true,
|
|
493
|
+
});
|
|
494
|
+
const fileSize = parseInt(sizeCheck.stdout.trim(), 10) || 0;
|
|
495
|
+
const fileSizeKB = Math.round(fileSize / 1024);
|
|
496
|
+
return {
|
|
497
|
+
content: [{
|
|
498
|
+
type: "text",
|
|
499
|
+
text: `Screenshot saved: ${localPath}\nOriginal: ${origW}x${origH} (${fileSizeKB} KB PNG)\nTarget scale: ${scale}x (${newW}x${newH})\nNote: For JPEG compression, post-process with imagemagick: convert "${localPath}" -resize ${newW}x${newH} -quality ${quality} output.jpg`,
|
|
500
|
+
}],
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
await ctx.bridge.shell(`rm -f '${remotePng}' '${remoteJpg}'`, { device: serial, ignoreExitCode: true }).catch(() => { });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
ctx.server.tool("adb_batch_actions", "Execute multiple input actions in a single tool call. Reduces ADB round-trips for multi-step UI interactions. Each action runs sequentially with an optional delay between them. Supported action types: tap, swipe, fling, long_press, double_tap, keyevent, text, drag, pinch, back, home, sleep.", {
|
|
512
|
+
actions: z.array(z.object({
|
|
513
|
+
type: z.enum(["tap", "swipe", "fling", "long_press", "double_tap", "keyevent", "text", "drag", "pinch", "back", "home", "sleep"])
|
|
514
|
+
.describe("Action type"),
|
|
515
|
+
args: z.string().optional()
|
|
516
|
+
.describe("Action arguments: tap='x y', swipe='x1 y1 x2 y2 [ms]', fling='x1 y1 x2 y2 [ms]', long_press='x y [ms]', double_tap='x y', keyevent='KEYCODE_*', text='string', drag='x1 y1 x2 y2 [ms]', pinch='cx cy startRadius endRadius [durationMs]', sleep='ms', back/home=none"),
|
|
517
|
+
})).min(1).max(50).describe("Array of actions to execute (1-50)"),
|
|
518
|
+
delayMs: z.number().int().min(0).max(5000).optional().default(100)
|
|
519
|
+
.describe("Delay between actions in ms (0-5000, default 100)"),
|
|
520
|
+
stopOnError: z.boolean().optional().default(true)
|
|
521
|
+
.describe("Stop execution if any action fails (default true)"),
|
|
522
|
+
device: z.string().optional().describe("Device serial"),
|
|
523
|
+
}, async ({ actions, delayMs, stopOnError, device }) => {
|
|
524
|
+
try {
|
|
525
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
526
|
+
const serial = resolved.serial;
|
|
527
|
+
const results = [];
|
|
528
|
+
let executed = 0;
|
|
529
|
+
let errors = 0;
|
|
530
|
+
for (const action of actions) {
|
|
531
|
+
executed++;
|
|
532
|
+
const args = action.args ?? "";
|
|
533
|
+
let cmd;
|
|
534
|
+
try {
|
|
535
|
+
switch (action.type) {
|
|
536
|
+
case "tap":
|
|
537
|
+
if (!/^[\d\s]+$/.test(args))
|
|
538
|
+
throw new Error(`Invalid tap args: "${args}"`);
|
|
539
|
+
cmd = `input tap ${args}`;
|
|
540
|
+
break;
|
|
541
|
+
case "swipe":
|
|
542
|
+
if (!/^[\d\s]+$/.test(args))
|
|
543
|
+
throw new Error(`Invalid swipe args: "${args}"`);
|
|
544
|
+
cmd = `input swipe ${args}`;
|
|
545
|
+
break;
|
|
546
|
+
case "fling": {
|
|
547
|
+
if (!/^[\d\s]+$/.test(args))
|
|
548
|
+
throw new Error(`Invalid fling args: "${args}"`);
|
|
549
|
+
const fl = args.trim().split(/\s+/);
|
|
550
|
+
if (fl.length < 4)
|
|
551
|
+
throw new Error("fling needs: x1 y1 x2 y2 [durationMs]");
|
|
552
|
+
// Default to 50ms for fling velocity; user-supplied 5th arg overrides
|
|
553
|
+
const flDur = fl[4] ?? "50";
|
|
554
|
+
cmd = `input swipe ${fl[0]} ${fl[1]} ${fl[2]} ${fl[3]} ${flDur}`;
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
case "long_press": {
|
|
558
|
+
if (!/^[\d\s]+$/.test(args))
|
|
559
|
+
throw new Error(`Invalid long_press args: "${args}"`);
|
|
560
|
+
const lp = args.split(/\s+/);
|
|
561
|
+
if (lp.length < 2)
|
|
562
|
+
throw new Error("long_press needs: x y [durationMs]");
|
|
563
|
+
const dur = lp[2] ?? "1500";
|
|
564
|
+
cmd = `input swipe ${lp[0]} ${lp[1]} ${lp[0]} ${lp[1]} ${dur}`;
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
case "double_tap": {
|
|
568
|
+
if (!/^[\d\s]+$/.test(args))
|
|
569
|
+
throw new Error(`Invalid double_tap args: "${args}"`);
|
|
570
|
+
const dt = args.split(/\s+/);
|
|
571
|
+
if (dt.length < 2)
|
|
572
|
+
throw new Error("double_tap needs: x y");
|
|
573
|
+
await ctx.bridge.shell(`input tap ${dt[0]} ${dt[1]}`, { device: serial, ignoreExitCode: true });
|
|
574
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
575
|
+
cmd = `input tap ${dt[0]} ${dt[1]}`;
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
case "keyevent":
|
|
579
|
+
if (!/^[\w\s]+$/.test(args))
|
|
580
|
+
throw new Error(`Invalid keyevent: "${args}"`);
|
|
581
|
+
cmd = `input keyevent ${args}`;
|
|
582
|
+
break;
|
|
583
|
+
case "text":
|
|
584
|
+
cmd = `input text '${shellEscape(args.replace(/ /g, "%s"))}'`;
|
|
585
|
+
break;
|
|
586
|
+
case "drag":
|
|
587
|
+
if (!/^[\d\s]+$/.test(args))
|
|
588
|
+
throw new Error(`Invalid drag args: "${args}"`);
|
|
589
|
+
cmd = `input draganddrop ${args}`;
|
|
590
|
+
break;
|
|
591
|
+
case "pinch": {
|
|
592
|
+
if (!/^[\d\s]+$/.test(args))
|
|
593
|
+
throw new Error(`Invalid pinch args: "${args}"`);
|
|
594
|
+
const pp = args.trim().split(/\s+/).map(Number);
|
|
595
|
+
if (pp.length < 4)
|
|
596
|
+
throw new Error("pinch needs: cx cy startRadius endRadius [durationMs]");
|
|
597
|
+
const [pcx, pcy, psr, per] = pp;
|
|
598
|
+
const pdur = pp[4] ?? 500;
|
|
599
|
+
const prad = (90 * Math.PI) / 180; // vertical axis
|
|
600
|
+
const pf1sx = Math.round(pcx + psr * Math.cos(prad));
|
|
601
|
+
const pf1sy = Math.round(pcy + psr * Math.sin(prad));
|
|
602
|
+
const pf1ex = Math.round(pcx + per * Math.cos(prad));
|
|
603
|
+
const pf1ey = Math.round(pcy + per * Math.sin(prad));
|
|
604
|
+
const pf2sx = Math.round(pcx - psr * Math.cos(prad));
|
|
605
|
+
const pf2sy = Math.round(pcy - psr * Math.sin(prad));
|
|
606
|
+
const pf2ex = Math.round(pcx - per * Math.cos(prad));
|
|
607
|
+
const pf2ey = Math.round(pcy - per * Math.sin(prad));
|
|
608
|
+
cmd = `input swipe ${pf1sx} ${pf1sy} ${pf1ex} ${pf1ey} ${pdur} & input swipe ${pf2sx} ${pf2sy} ${pf2ex} ${pf2ey} ${pdur}`;
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
case "back":
|
|
612
|
+
cmd = "input keyevent KEYCODE_BACK";
|
|
613
|
+
break;
|
|
614
|
+
case "home":
|
|
615
|
+
cmd = "input keyevent KEYCODE_HOME";
|
|
616
|
+
break;
|
|
617
|
+
case "sleep": {
|
|
618
|
+
const ms = parseInt(args, 10) || 500;
|
|
619
|
+
await new Promise((r) => setTimeout(r, Math.min(ms, 10000)));
|
|
620
|
+
results.push(`[${executed}] sleep ${ms}ms`);
|
|
621
|
+
if (delayMs > 0 && executed < actions.length)
|
|
622
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
default:
|
|
626
|
+
throw new Error(`Unknown action: ${action.type}`);
|
|
627
|
+
}
|
|
628
|
+
// Security check
|
|
629
|
+
const blocked = ctx.security.checkCommand(cmd, device);
|
|
630
|
+
if (blocked)
|
|
631
|
+
throw new Error(blocked);
|
|
632
|
+
await ctx.bridge.shell(cmd, { device: serial, ignoreExitCode: true });
|
|
633
|
+
results.push(`[${executed}] ${action.type}${args ? " " + args : ""} ✓`);
|
|
634
|
+
}
|
|
635
|
+
catch (e) {
|
|
636
|
+
errors++;
|
|
637
|
+
results.push(`[${executed}] ${action.type}${args ? " " + args : ""} ✗ ${e instanceof Error ? e.message : e}`);
|
|
638
|
+
if (stopOnError) {
|
|
639
|
+
results.push(`Stopped at action ${executed}/${actions.length} (stopOnError=true)`);
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (delayMs > 0 && executed < actions.length) {
|
|
644
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const summary = `Batch: ${executed}/${actions.length} executed, ${errors} error(s)`;
|
|
648
|
+
return { content: [{ type: "text", text: `${summary}\n${results.join("\n")}` }] };
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
// ── Device Awareness: Screen Size, Device State, Notifications ────
|
|
655
|
+
ctx.server.tool("adb_screen_size", "Get the screen resolution and display density. Returns physical width, height (in pixels), and DPI density. Useful for calculating tap/swipe coordinates and understanding the UI layout grid.", {
|
|
656
|
+
device: z.string().optional().describe("Device serial"),
|
|
657
|
+
}, async ({ device }) => {
|
|
658
|
+
try {
|
|
659
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
660
|
+
const serial = resolved.serial;
|
|
661
|
+
const [sizeResult, densityResult] = await Promise.all([
|
|
662
|
+
ctx.bridge.shell("wm size", { device: serial }),
|
|
663
|
+
ctx.bridge.shell("wm density", { device: serial }),
|
|
664
|
+
]);
|
|
665
|
+
const sizeMatch = sizeResult.stdout.match(/(\d+)x(\d+)/);
|
|
666
|
+
const densityMatch = densityResult.stdout.match(/(\d+)/);
|
|
667
|
+
const width = sizeMatch ? parseInt(sizeMatch[1], 10) : 0;
|
|
668
|
+
const height = sizeMatch ? parseInt(sizeMatch[2], 10) : 0;
|
|
669
|
+
const density = densityMatch ? parseInt(densityMatch[1], 10) : 0;
|
|
670
|
+
// Check for override
|
|
671
|
+
const overrideSize = sizeResult.stdout.includes("Override") ? sizeResult.stdout.match(/Override size:\s*(\d+x\d+)/)?.[1] : null;
|
|
672
|
+
const overrideDensity = densityResult.stdout.includes("Override") ? densityResult.stdout.match(/Override density:\s*(\d+)/)?.[1] : null;
|
|
673
|
+
const lines = [`Screen: ${width}x${height} @ ${density} dpi`];
|
|
674
|
+
if (overrideSize)
|
|
675
|
+
lines.push(`Override size: ${overrideSize}`);
|
|
676
|
+
if (overrideDensity)
|
|
677
|
+
lines.push(`Override density: ${overrideDensity}`);
|
|
678
|
+
lines.push(`Aspect ratio: ${(width / height).toFixed(3)}`);
|
|
679
|
+
lines.push(`DP width: ${Math.round(width / (density / 160))}`);
|
|
680
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
681
|
+
}
|
|
682
|
+
catch (error) {
|
|
683
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
ctx.server.tool("adb_device_state", "Get a combined device state snapshot in one call: battery level/status, network connectivity, screen on/off, foreground activity, and orientation. Useful as a pre-check before UI automation or as a quick device health summary.", {
|
|
687
|
+
device: z.string().optional().describe("Device serial"),
|
|
688
|
+
}, async ({ device }) => {
|
|
689
|
+
try {
|
|
690
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
691
|
+
const serial = resolved.serial;
|
|
692
|
+
const [battery, network, screen, activity, orientation, wifi] = await Promise.allSettled([
|
|
693
|
+
ctx.bridge.shell("dumpsys battery | head -15", { device: serial, timeout: 5000, ignoreExitCode: true }),
|
|
694
|
+
ctx.bridge.shell("getprop gsm.network.type", { device: serial, timeout: 3000, ignoreExitCode: true }),
|
|
695
|
+
ctx.bridge.shell("dumpsys power | grep -E 'mWakefulness|Display Power'", { device: serial, timeout: 5000, ignoreExitCode: true }),
|
|
696
|
+
ctx.bridge.shell("dumpsys activity activities | grep mResumedActivity", { device: serial, timeout: 5000, ignoreExitCode: true }),
|
|
697
|
+
ctx.bridge.shell("settings get system accelerometer_rotation", { device: serial, timeout: 3000, ignoreExitCode: true }),
|
|
698
|
+
ctx.bridge.shell("dumpsys wifi | grep -E 'Wi-Fi is|mNetworkInfo'", { device: serial, timeout: 5000, ignoreExitCode: true }),
|
|
699
|
+
]);
|
|
700
|
+
const sections = ["=== Device State Snapshot ==="];
|
|
701
|
+
// Battery
|
|
702
|
+
if (battery.status === "fulfilled") {
|
|
703
|
+
const b = battery.value.stdout;
|
|
704
|
+
const level = b.match(/level:\s*(\d+)/)?.[1] ?? "?";
|
|
705
|
+
const status = b.match(/status:\s*(\d+)/)?.[1];
|
|
706
|
+
const statusMap = { "1": "unknown", "2": "charging", "3": "discharging", "4": "not charging", "5": "full" };
|
|
707
|
+
const temp = b.match(/temperature:\s*(\d+)/)?.[1];
|
|
708
|
+
const tempC = temp ? (parseInt(temp, 10) / 10).toFixed(1) : "?";
|
|
709
|
+
sections.push(`Battery: ${level}% (${statusMap[status ?? ""] ?? "unknown"}, ${tempC}°C)`);
|
|
710
|
+
}
|
|
711
|
+
// Network
|
|
712
|
+
if (network.status === "fulfilled") {
|
|
713
|
+
const net = network.value.stdout.trim() || "none";
|
|
714
|
+
sections.push(`Network: ${net}`);
|
|
715
|
+
}
|
|
716
|
+
// WiFi
|
|
717
|
+
if (wifi.status === "fulfilled") {
|
|
718
|
+
const w = wifi.value.stdout;
|
|
719
|
+
const wifiEnabled = w.includes("enabled") ? "on" : w.includes("disabled") ? "off" : "unknown";
|
|
720
|
+
sections.push(`WiFi: ${wifiEnabled}`);
|
|
721
|
+
}
|
|
722
|
+
// Screen
|
|
723
|
+
if (screen.status === "fulfilled") {
|
|
724
|
+
const s = screen.value.stdout;
|
|
725
|
+
const wake = s.includes("Awake") ? "ON" : s.includes("Asleep") || s.includes("Dozing") ? "OFF" : "unknown";
|
|
726
|
+
sections.push(`Screen: ${wake}`);
|
|
727
|
+
}
|
|
728
|
+
// Orientation
|
|
729
|
+
if (orientation.status === "fulfilled") {
|
|
730
|
+
const auto = orientation.value.stdout.trim() === "1";
|
|
731
|
+
sections.push(`Orientation: ${auto ? "auto-rotate" : "locked"}`);
|
|
732
|
+
}
|
|
733
|
+
// Foreground activity
|
|
734
|
+
if (activity.status === "fulfilled") {
|
|
735
|
+
const a = activity.value.stdout;
|
|
736
|
+
const match = a.match(/mResumedActivity:.*\{[^\s]+\s+([^\s}]+)/);
|
|
737
|
+
sections.push(`Foreground: ${match ? match[1] : "none"}`);
|
|
738
|
+
}
|
|
739
|
+
return { content: [{ type: "text", text: sections.join("\n") }] };
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
ctx.server.tool("adb_notifications", "Read active notifications on the device. Parses the notification manager dump to extract package, title, text, importance, channel, and flags for each notification. Useful for verifying push notification delivery, monitoring notification state, and testing notification-related features.", {
|
|
746
|
+
filter: z.string().optional().describe("Filter by package name (partial match, case-insensitive)"),
|
|
747
|
+
maxResults: z.number().int().min(1).max(50).optional().default(20)
|
|
748
|
+
.describe("Maximum notifications to return (1-50, default 20)"),
|
|
749
|
+
device: z.string().optional().describe("Device serial"),
|
|
750
|
+
}, async ({ filter, maxResults, device }) => {
|
|
751
|
+
try {
|
|
752
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
753
|
+
const serial = resolved.serial;
|
|
754
|
+
// Dump notification state — use --noredact for full content
|
|
755
|
+
const result = await ctx.bridge.shell("dumpsys notification --noredact", { device: serial, timeout: 15000, ignoreExitCode: true });
|
|
756
|
+
const dump = result.stdout;
|
|
757
|
+
// Parse NotificationRecord blocks
|
|
758
|
+
const notifications = [];
|
|
759
|
+
// Split on NotificationRecord boundaries
|
|
760
|
+
const records = dump.split(/NotificationRecord\(/).slice(1);
|
|
761
|
+
for (const record of records) {
|
|
762
|
+
if (notifications.length >= maxResults)
|
|
763
|
+
break;
|
|
764
|
+
// Extract package from the header
|
|
765
|
+
const pkgMatch = record.match(/pkg=([^\s]+)/);
|
|
766
|
+
const pkg = pkgMatch?.[1] ?? "unknown";
|
|
767
|
+
// Apply package filter
|
|
768
|
+
if (filter && !pkg.toLowerCase().includes(filter.toLowerCase()))
|
|
769
|
+
continue;
|
|
770
|
+
// Extract key fields
|
|
771
|
+
const keyMatch = record.match(/key=([^\n]+)/);
|
|
772
|
+
const flagsMatch = record.match(/flags=([^\n]+)/);
|
|
773
|
+
const importanceMatch = record.match(/mImportance=([^\n]+)/);
|
|
774
|
+
const channelMatch = record.match(/channel=([^\s,]+)/);
|
|
775
|
+
// Extract title and text from extras
|
|
776
|
+
const titleMatch = record.match(/android\.title=String\s*\(([^)]+)\)/);
|
|
777
|
+
const textMatch = record.match(/android\.text=String\s*\(([^)]+)\)/);
|
|
778
|
+
const whenMatch = record.match(/when=(\d+)/);
|
|
779
|
+
const title = titleMatch?.[1] ?? "";
|
|
780
|
+
const text = textMatch?.[1] ?? "";
|
|
781
|
+
notifications.push({
|
|
782
|
+
pkg,
|
|
783
|
+
title,
|
|
784
|
+
text,
|
|
785
|
+
importance: importanceMatch?.[1]?.trim() ?? "unknown",
|
|
786
|
+
channel: channelMatch?.[1] ?? "unknown",
|
|
787
|
+
flags: flagsMatch?.[1]?.trim() ?? "none",
|
|
788
|
+
key: keyMatch?.[1]?.trim() ?? "",
|
|
789
|
+
when: whenMatch?.[1] ? new Date(parseInt(whenMatch[1], 10)).toISOString() : "",
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
if (notifications.length === 0) {
|
|
793
|
+
return { content: [{ type: "text", text: filter ? `No notifications found matching "${filter}"` : "No active notifications" }] };
|
|
794
|
+
}
|
|
795
|
+
const output = notifications.map((n, i) => {
|
|
796
|
+
const lines = [`[${i + 1}] ${n.pkg}`];
|
|
797
|
+
if (n.title)
|
|
798
|
+
lines.push(` Title: ${n.title}`);
|
|
799
|
+
if (n.text)
|
|
800
|
+
lines.push(` Text: ${n.text}`);
|
|
801
|
+
lines.push(` Importance: ${n.importance} | Channel: ${n.channel}`);
|
|
802
|
+
if (n.flags !== "none")
|
|
803
|
+
lines.push(` Flags: ${n.flags}`);
|
|
804
|
+
if (n.when)
|
|
805
|
+
lines.push(` Posted: ${n.when}`);
|
|
806
|
+
return lines.join("\n");
|
|
807
|
+
}).join("\n\n");
|
|
808
|
+
return { content: [{ type: "text", text: `${notifications.length} notification(s)${filter ? ` matching "${filter}"` : ""}:\n\n${output}` }] };
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
// ── Multi-Touch: Pinch/Spread Gesture ─────────────────────────────────
|
|
815
|
+
/**
|
|
816
|
+
* Discover the primary touchscreen input device and its MT axis ranges.
|
|
817
|
+
* Parses `getevent -p` output to find a device with ABS_MT_POSITION_X/Y
|
|
818
|
+
* and INPUT_PROP_DIRECT. Caches the result for the session.
|
|
819
|
+
*/
|
|
820
|
+
let cachedTouchDevice = undefined;
|
|
821
|
+
async function detectTouchDevice(bridge, serial) {
|
|
822
|
+
if (cachedTouchDevice !== undefined)
|
|
823
|
+
return cachedTouchDevice;
|
|
824
|
+
try {
|
|
825
|
+
const result = await bridge.rootShell("getevent -p", {
|
|
826
|
+
device: serial, timeout: 5000, ignoreExitCode: true,
|
|
827
|
+
});
|
|
828
|
+
const output = result.stdout;
|
|
829
|
+
// Split into device blocks
|
|
830
|
+
const blocks = output.split(/(?=add device \d+:)/);
|
|
831
|
+
for (const block of blocks) {
|
|
832
|
+
// Must be INPUT_PROP_DIRECT (touch screen, not touchpad)
|
|
833
|
+
if (!block.includes("INPUT_PROP_DIRECT"))
|
|
834
|
+
continue;
|
|
835
|
+
const nodeMatch = block.match(/add device \d+:\s+(\S+)/);
|
|
836
|
+
if (!nodeMatch)
|
|
837
|
+
continue;
|
|
838
|
+
// Must have ABS_MT_POSITION_X (0035) and ABS_MT_POSITION_Y (0036)
|
|
839
|
+
const xMatch = block.match(/0035\s+:\s+value \d+, min (\d+), max (\d+)/);
|
|
840
|
+
const yMatch = block.match(/0036\s+:\s+value \d+, min (\d+), max (\d+)/);
|
|
841
|
+
if (!xMatch || !yMatch)
|
|
842
|
+
continue;
|
|
843
|
+
// Optional: ABS_MT_SLOT (002f), ABS_MT_PRESSURE (003a)
|
|
844
|
+
const slotMatch = block.match(/002f\s+:\s+value \d+, min \d+, max (\d+)/);
|
|
845
|
+
const pressMatch = block.match(/003a\s+:\s+value \d+, min \d+, max (\d+)/);
|
|
846
|
+
cachedTouchDevice = {
|
|
847
|
+
eventNode: nodeMatch[1],
|
|
848
|
+
maxX: parseInt(xMatch[2], 10),
|
|
849
|
+
maxY: parseInt(yMatch[2], 10),
|
|
850
|
+
maxSlot: slotMatch ? parseInt(slotMatch[1], 10) : 1,
|
|
851
|
+
maxPressure: pressMatch ? parseInt(pressMatch[1], 10) : 50,
|
|
852
|
+
};
|
|
853
|
+
return cachedTouchDevice;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
catch { /* getevent requires root — expected to fail without it */ }
|
|
857
|
+
cachedTouchDevice = null;
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
ctx.server.tool("adb_input_pinch", "Perform a multi-touch pinch (zoom out) or spread (zoom in) gesture. Two fingers move symmetrically toward or away from a center point. Uses parallel swipe injection by default (universal, no root); when root is available, can use raw sendevent for true multi-touch MT protocol injection. The 'auto' method selects the best available approach.", {
|
|
861
|
+
cx: z.number().int().min(0).max(9999).describe("Center X coordinate of the pinch gesture"),
|
|
862
|
+
cy: z.number().int().min(0).max(9999).describe("Center Y coordinate of the pinch gesture"),
|
|
863
|
+
startRadius: z.number().int().min(10).max(2000).describe("Starting distance (px) from center to each finger"),
|
|
864
|
+
endRadius: z.number().int().min(10).max(2000).describe("Ending distance (px) from center to each finger. Smaller than startRadius = pinch/zoom-out, larger = spread/zoom-in"),
|
|
865
|
+
durationMs: z.number().int().min(100).max(5000).optional().default(500)
|
|
866
|
+
.describe("Gesture duration in ms (100-5000, default 500)"),
|
|
867
|
+
angle: z.number().min(0).max(360).optional().default(90)
|
|
868
|
+
.describe("Angle in degrees for the pinch axis (0=horizontal, 90=vertical, default 90)"),
|
|
869
|
+
steps: z.number().int().min(5).max(60).optional().default(20)
|
|
870
|
+
.describe("Interpolation steps for sendevent method (5-60, default 20). More steps = smoother gesture"),
|
|
871
|
+
method: z.enum(["auto", "swipe", "sendevent"]).optional().default("auto")
|
|
872
|
+
.describe("Injection method: 'auto' (sendevent if root, else swipe), 'swipe' (parallel swipes, universal), 'sendevent' (raw MT protocol, requires root)"),
|
|
873
|
+
device: z.string().optional().describe("Device serial"),
|
|
874
|
+
}, async ({ cx, cy, startRadius, endRadius, durationMs, angle, steps, method, device }) => {
|
|
875
|
+
try {
|
|
876
|
+
const resolved = await ctx.deviceManager.resolveDevice(device);
|
|
877
|
+
const serial = resolved.serial;
|
|
878
|
+
// Convert angle to radians for finger position calculation
|
|
879
|
+
const rad = (angle * Math.PI) / 180;
|
|
880
|
+
// Calculate finger positions at start and end
|
|
881
|
+
const f1StartX = Math.round(cx + startRadius * Math.cos(rad));
|
|
882
|
+
const f1StartY = Math.round(cy + startRadius * Math.sin(rad));
|
|
883
|
+
const f1EndX = Math.round(cx + endRadius * Math.cos(rad));
|
|
884
|
+
const f1EndY = Math.round(cy + endRadius * Math.sin(rad));
|
|
885
|
+
const f2StartX = Math.round(cx - startRadius * Math.cos(rad));
|
|
886
|
+
const f2StartY = Math.round(cy - startRadius * Math.sin(rad));
|
|
887
|
+
const f2EndX = Math.round(cx - endRadius * Math.cos(rad));
|
|
888
|
+
const f2EndY = Math.round(cy - endRadius * Math.sin(rad));
|
|
889
|
+
// Determine method
|
|
890
|
+
let useMethod = method;
|
|
891
|
+
if (useMethod === "auto") {
|
|
892
|
+
// Check root + touchscreen availability
|
|
893
|
+
const touchDev = await detectTouchDevice(ctx.bridge, serial);
|
|
894
|
+
useMethod = touchDev ? "sendevent" : "swipe";
|
|
895
|
+
}
|
|
896
|
+
if (useMethod === "sendevent") {
|
|
897
|
+
// ── sendevent: true multi-touch MT Type B protocol via atomic binary writes ──
|
|
898
|
+
// Each frame (events between SYN_REPORTs) is written as a single binary blob
|
|
899
|
+
// to the touchscreen device node via `xxd -r -p`. This ensures Android's
|
|
900
|
+
// MultiTouchInputMapper receives all slot updates atomically, which is required
|
|
901
|
+
// for reliable multi-pointer gesture recognition. Individual `sendevent` calls
|
|
902
|
+
// are too slow (~5-10ms per fork) and cause the InputReader to miss the gesture.
|
|
903
|
+
const touchDev = await detectTouchDevice(ctx.bridge, serial);
|
|
904
|
+
if (!touchDev) {
|
|
905
|
+
return {
|
|
906
|
+
content: [{ type: "text", text: "sendevent method requires root and a detected touchscreen. Use method='swipe' instead." }],
|
|
907
|
+
isError: true,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
const node = touchDev.eventNode;
|
|
911
|
+
// Validate event node path — defense-in-depth even though it comes from getevent -p
|
|
912
|
+
if (!node.startsWith("/dev/") || /["'`$\\!;|&(){}<>\n\r]/.test(node) || node.includes("..")) {
|
|
913
|
+
return {
|
|
914
|
+
content: [{ type: "text", text: `Invalid touchscreen device node: ${node}` }],
|
|
915
|
+
isError: true,
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
const pressure = Math.min(50, touchDev.maxPressure);
|
|
919
|
+
const sleepPerStep = (durationMs / steps / 1000).toFixed(4);
|
|
920
|
+
// Encode a single struct input_event (24 bytes on ARM64) as hex.
|
|
921
|
+
// struct input_event { long tv_sec, long tv_usec, u16 type, u16 code, s32 value }
|
|
922
|
+
// Timestamps set to 0 — kernel's evdev_write() fills them from system clock.
|
|
923
|
+
const ev = (type, code, value) => {
|
|
924
|
+
const ts = "00000000000000000000000000000000"; // 16 bytes zeros (tv_sec + tv_usec)
|
|
925
|
+
const t0 = (type & 0xff).toString(16).padStart(2, "0");
|
|
926
|
+
const t1 = ((type >> 8) & 0xff).toString(16).padStart(2, "0");
|
|
927
|
+
const c0 = (code & 0xff).toString(16).padStart(2, "0");
|
|
928
|
+
const c1 = ((code >> 8) & 0xff).toString(16).padStart(2, "0");
|
|
929
|
+
const uv = value < 0 ? value + 0x100000000 : value;
|
|
930
|
+
const v0 = (uv & 0xff).toString(16).padStart(2, "0");
|
|
931
|
+
const v1 = ((uv >> 8) & 0xff).toString(16).padStart(2, "0");
|
|
932
|
+
const v2 = ((uv >> 16) & 0xff).toString(16).padStart(2, "0");
|
|
933
|
+
const v3 = ((uv >> 24) & 0xff).toString(16).padStart(2, "0");
|
|
934
|
+
return ts + t0 + t1 + c0 + c1 + v0 + v1 + v2 + v3;
|
|
935
|
+
};
|
|
936
|
+
// Helper: write a complete frame (all events + SYN_REPORT) atomically
|
|
937
|
+
const writeFrame = (events) => `echo '${events}' | xxd -r -p > '${node}'`;
|
|
938
|
+
const cmds = [];
|
|
939
|
+
// Frame 1: Initial touch down — both fingers + SYN_REPORT
|
|
940
|
+
let downFrame = "";
|
|
941
|
+
downFrame += ev(3, 47, 0); // ABS_MT_SLOT 0
|
|
942
|
+
downFrame += ev(3, 57, 1000); // ABS_MT_TRACKING_ID finger 1
|
|
943
|
+
downFrame += ev(3, 53, f1StartX); // ABS_MT_POSITION_X
|
|
944
|
+
downFrame += ev(3, 54, f1StartY); // ABS_MT_POSITION_Y
|
|
945
|
+
downFrame += ev(3, 48, 50); // ABS_MT_TOUCH_MAJOR
|
|
946
|
+
downFrame += ev(3, 58, pressure); // ABS_MT_PRESSURE
|
|
947
|
+
downFrame += ev(3, 47, 1); // ABS_MT_SLOT 1
|
|
948
|
+
downFrame += ev(3, 57, 1001); // ABS_MT_TRACKING_ID finger 2
|
|
949
|
+
downFrame += ev(3, 53, f2StartX); // ABS_MT_POSITION_X
|
|
950
|
+
downFrame += ev(3, 54, f2StartY); // ABS_MT_POSITION_Y
|
|
951
|
+
downFrame += ev(3, 48, 50); // ABS_MT_TOUCH_MAJOR
|
|
952
|
+
downFrame += ev(3, 58, pressure); // ABS_MT_PRESSURE
|
|
953
|
+
downFrame += ev(1, 330, 1); // BTN_TOUCH down
|
|
954
|
+
downFrame += ev(0, 0, 0); // SYN_REPORT
|
|
955
|
+
cmds.push(writeFrame(downFrame));
|
|
956
|
+
// Move frames — interpolate both fingers toward/away from center
|
|
957
|
+
for (let i = 1; i <= steps; i++) {
|
|
958
|
+
const t = i / steps;
|
|
959
|
+
const x1 = Math.round(f1StartX + (f1EndX - f1StartX) * t);
|
|
960
|
+
const y1 = Math.round(f1StartY + (f1EndY - f1StartY) * t);
|
|
961
|
+
const x2 = Math.round(f2StartX + (f2EndX - f2StartX) * t);
|
|
962
|
+
const y2 = Math.round(f2StartY + (f2EndY - f2StartY) * t);
|
|
963
|
+
let moveFrame = "";
|
|
964
|
+
moveFrame += ev(3, 47, 0); // SLOT 0
|
|
965
|
+
moveFrame += ev(3, 53, x1); // POSITION_X
|
|
966
|
+
moveFrame += ev(3, 54, y1); // POSITION_Y
|
|
967
|
+
moveFrame += ev(3, 47, 1); // SLOT 1
|
|
968
|
+
moveFrame += ev(3, 53, x2); // POSITION_X
|
|
969
|
+
moveFrame += ev(3, 54, y2); // POSITION_Y
|
|
970
|
+
moveFrame += ev(0, 0, 0); // SYN_REPORT
|
|
971
|
+
cmds.push(`sleep ${sleepPerStep}`);
|
|
972
|
+
cmds.push(writeFrame(moveFrame));
|
|
973
|
+
}
|
|
974
|
+
// Final frame: release both fingers
|
|
975
|
+
let upFrame = "";
|
|
976
|
+
upFrame += ev(3, 47, 0); // SLOT 0
|
|
977
|
+
upFrame += ev(3, 57, -1); // TRACKING_ID -1 = finger up
|
|
978
|
+
upFrame += ev(3, 47, 1); // SLOT 1
|
|
979
|
+
upFrame += ev(3, 57, -1); // TRACKING_ID -1 = finger up
|
|
980
|
+
upFrame += ev(1, 330, 0); // BTN_TOUCH up
|
|
981
|
+
upFrame += ev(0, 0, 0); // SYN_REPORT
|
|
982
|
+
cmds.push(writeFrame(upFrame));
|
|
983
|
+
// Execute as a single batched shell command via root
|
|
984
|
+
const script = cmds.join("; ");
|
|
985
|
+
await ctx.bridge.rootShell(script, {
|
|
986
|
+
device: serial,
|
|
987
|
+
timeout: durationMs + 5000,
|
|
988
|
+
ignoreExitCode: true,
|
|
989
|
+
});
|
|
990
|
+
const gesture = endRadius < startRadius ? "pinch" : "spread";
|
|
991
|
+
return {
|
|
992
|
+
content: [{
|
|
993
|
+
type: "text",
|
|
994
|
+
text: `${gesture} (sendevent): center (${cx},${cy}), radius ${startRadius}→${endRadius}px, ${durationMs}ms, ${steps} steps, ${angle}° axis\nDevice: ${node} (${touchDev.maxX}×${touchDev.maxY})\nFinger 1: (${f1StartX},${f1StartY})→(${f1EndX},${f1EndY})\nFinger 2: (${f2StartX},${f2StartY})→(${f2EndX},${f2EndY})`,
|
|
995
|
+
}],
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
// ── swipe: parallel input swipe with backgrounding ──
|
|
999
|
+
// Two concurrent single-touch swipes that converge/diverge
|
|
1000
|
+
const cmd = `input swipe ${f1StartX} ${f1StartY} ${f1EndX} ${f1EndY} ${durationMs} & ` +
|
|
1001
|
+
`input swipe ${f2StartX} ${f2StartY} ${f2EndX} ${f2EndY} ${durationMs}`;
|
|
1002
|
+
await ctx.bridge.shell(cmd, {
|
|
1003
|
+
device: serial,
|
|
1004
|
+
timeout: durationMs + 5000,
|
|
1005
|
+
ignoreExitCode: true,
|
|
1006
|
+
});
|
|
1007
|
+
const gesture = endRadius < startRadius ? "pinch" : "spread";
|
|
1008
|
+
return {
|
|
1009
|
+
content: [{
|
|
1010
|
+
type: "text",
|
|
1011
|
+
text: `${gesture} (swipe): center (${cx},${cy}), radius ${startRadius}→${endRadius}px, ${durationMs}ms, ${angle}° axis\nFinger 1: (${f1StartX},${f1StartY})→(${f1EndX},${f1EndY})\nFinger 2: (${f2StartX},${f2StartY})→(${f2EndX},${f2EndY})`,
|
|
1012
|
+
}],
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
catch (error) {
|
|
1016
|
+
return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
//# sourceMappingURL=input-gestures.js.map
|