deepadb 1.0.7 → 1.0.8

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.
@@ -0,0 +1,766 @@
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_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.", {
42
+ x: z.number().int().min(0).max(9999).describe("X coordinate"),
43
+ y: z.number().int().min(0).max(9999).describe("Y coordinate"),
44
+ durationMs: z.number().int().min(300).max(10000).optional().default(1500)
45
+ .describe("Hold duration in ms (300-10000, default 1500)"),
46
+ device: z.string().optional().describe("Device serial"),
47
+ }, async ({ x, y, durationMs, device }) => {
48
+ try {
49
+ const resolved = await ctx.deviceManager.resolveDevice(device);
50
+ // Zero-distance swipe = long press
51
+ await ctx.bridge.shell(`input swipe ${x} ${y} ${x} ${y} ${durationMs}`, { device: resolved.serial, timeout: durationMs + 5000, ignoreExitCode: true });
52
+ return { content: [{ type: "text", text: `Long press: (${x},${y}) held ${durationMs}ms` }] };
53
+ }
54
+ catch (error) {
55
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
56
+ }
57
+ });
58
+ 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.", {
59
+ x: z.number().int().min(0).max(9999).describe("X coordinate"),
60
+ y: z.number().int().min(0).max(9999).describe("Y coordinate"),
61
+ intervalMs: z.number().int().min(20).max(500).optional().default(80)
62
+ .describe("Interval between taps in ms (20-500, default 80)"),
63
+ device: z.string().optional().describe("Device serial"),
64
+ }, async ({ x, y, intervalMs, device }) => {
65
+ try {
66
+ const resolved = await ctx.deviceManager.resolveDevice(device);
67
+ const serial = resolved.serial;
68
+ await ctx.bridge.shell(`input tap ${x} ${y}`, { device: serial, ignoreExitCode: true });
69
+ await new Promise((r) => setTimeout(r, intervalMs));
70
+ await ctx.bridge.shell(`input tap ${x} ${y}`, { device: serial, ignoreExitCode: true });
71
+ return { content: [{ type: "text", text: `Double tap: (${x},${y}) with ${intervalMs}ms interval` }] };
72
+ }
73
+ catch (error) {
74
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
75
+ }
76
+ });
77
+ 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.", {
78
+ text: z.string().min(1).max(5000).describe("Text to type on the device"),
79
+ device: z.string().optional().describe("Device serial"),
80
+ }, async ({ text, device }) => {
81
+ try {
82
+ const resolved = await ctx.deviceManager.resolveDevice(device);
83
+ // Android's input text treats spaces as argument separators — must use %s
84
+ // Also need to escape shell special characters
85
+ const escaped = text.replace(/ /g, "%s");
86
+ const shellCmd = `input text '${shellEscape(escaped)}'`;
87
+ await ctx.bridge.shell(shellCmd, { device: resolved.serial, ignoreExitCode: true });
88
+ return { content: [{ type: "text", text: `Typed ${text.length} character(s)` }] };
89
+ }
90
+ catch (error) {
91
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
92
+ }
93
+ });
94
+ 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.", {
95
+ url: z.string().url().describe("URL to open (must be a valid URL with scheme)"),
96
+ device: z.string().optional().describe("Device serial"),
97
+ }, async ({ url, device }) => {
98
+ try {
99
+ const resolved = await ctx.deviceManager.resolveDevice(device);
100
+ const result = await ctx.bridge.shell(`am start -a android.intent.action.VIEW -d '${shellEscape(url)}'`, { device: resolved.serial, ignoreExitCode: true });
101
+ const output = result.stdout.trim();
102
+ if (output.includes("Error") || output.includes("Exception")) {
103
+ return { content: [{ type: "text", text: `Failed to open URL: ${output}` }], isError: true };
104
+ }
105
+ return { content: [{ type: "text", text: `Opened: ${url}` }] };
106
+ }
107
+ catch (error) {
108
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
109
+ }
110
+ });
111
+ ctx.server.tool("adb_orientation", "Get or set screen orientation. Can lock to portrait, landscape, reverse portrait, reverse landscape, or restore auto-rotate.", {
112
+ action: z.enum(["get", "set"]).describe("'get' to read current orientation, 'set' to change it"),
113
+ orientation: z.enum(["auto", "portrait", "landscape", "reverse_portrait", "reverse_landscape"]).optional()
114
+ .describe("Orientation to set (required when action='set'): auto=sensor, portrait=0°, landscape=90°, reverse_portrait=180°, reverse_landscape=270°"),
115
+ device: z.string().optional().describe("Device serial"),
116
+ }, async ({ action, orientation, device }) => {
117
+ try {
118
+ const resolved = await ctx.deviceManager.resolveDevice(device);
119
+ const serial = resolved.serial;
120
+ if (action === "get") {
121
+ const [autoRotate, rotation] = await Promise.all([
122
+ ctx.bridge.shell("settings get system accelerometer_rotation", { device: serial }),
123
+ ctx.bridge.shell("settings get system user_rotation", { device: serial }),
124
+ ]);
125
+ const auto = autoRotate.stdout.trim() === "1";
126
+ const rot = parseInt(rotation.stdout.trim(), 10) || 0;
127
+ const rotNames = { 0: "portrait (0°)", 1: "landscape (90°)", 2: "reverse portrait (180°)", 3: "reverse landscape (270°)" };
128
+ const current = auto ? `auto-rotate (currently ${rotNames[rot] ?? "unknown"})` : `locked: ${rotNames[rot] ?? "unknown"}`;
129
+ return { content: [{ type: "text", text: `Orientation: ${current}` }] };
130
+ }
131
+ // action === "set"
132
+ if (!orientation) {
133
+ return { content: [{ type: "text", text: "orientation parameter is required when action='set'" }], isError: true };
134
+ }
135
+ if (orientation === "auto") {
136
+ await ctx.bridge.shell("settings put system accelerometer_rotation 1", { device: serial });
137
+ return { content: [{ type: "text", text: "Orientation: auto-rotate enabled" }] };
138
+ }
139
+ // Lock orientation: disable auto-rotate, then set user_rotation
140
+ const rotMap = {
141
+ portrait: "0", landscape: "1", reverse_portrait: "2", reverse_landscape: "3",
142
+ };
143
+ await ctx.bridge.shell("settings put system accelerometer_rotation 0", { device: serial });
144
+ await ctx.bridge.shell(`settings put system user_rotation ${rotMap[orientation]}`, { device: serial });
145
+ return { content: [{ type: "text", text: `Orientation: locked to ${orientation}` }] };
146
+ }
147
+ catch (error) {
148
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
149
+ }
150
+ });
151
+ 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+.", {
152
+ action: z.enum(["read", "write"]).describe("'read' to get clipboard, 'write' to set it"),
153
+ text: z.string().max(10000).optional().describe("Text to write to clipboard (required when action='write')"),
154
+ device: z.string().optional().describe("Device serial"),
155
+ }, async ({ action, text, device }) => {
156
+ try {
157
+ const resolved = await ctx.deviceManager.resolveDevice(device);
158
+ const serial = resolved.serial;
159
+ if (action === "write") {
160
+ if (!text) {
161
+ return { content: [{ type: "text", text: "text parameter is required when action='write'" }], isError: true };
162
+ }
163
+ // Use am broadcast to set clipboard via a helper — this is the most reliable cross-version approach
164
+ await ctx.bridge.shell(`am broadcast -a clipper.set -e text '${shellEscape(text)}'`, { device: serial, ignoreExitCode: true });
165
+ // Fallback: use service call if clipper app isn't installed
166
+ // service call clipboard 2 (SET_PRIMARY_CLIP) requires parcel construction
167
+ // For simplicity, also try the settings-based approach
168
+ 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 });
169
+ return { content: [{ type: "text", text: `Clipboard set (${text.length} chars). Note: if no clipper app is installed, use 'input text' as alternative.` }] };
170
+ }
171
+ // action === "read"
172
+ // Try multiple methods to read clipboard
173
+ const methods = [
174
+ // Method 1: dumpsys clipboard
175
+ { cmd: "dumpsys clipboard | grep -A 2 'mPrimaryClip'", parse: (s) => {
176
+ const textMatch = s.match(/Text\s*\{(.+?)\}/);
177
+ return textMatch ? textMatch[1] : null;
178
+ } },
179
+ // Method 2: service call clipboard (returns parcel data)
180
+ { cmd: "service call clipboard 1 2>/dev/null", parse: (s) => {
181
+ // Parse parcel text output — extract quoted strings
182
+ const parts = s.match(/'([^']*)'/g);
183
+ return parts ? parts.map(p => p.replace(/'/g, "").replace(/\.\s*$/g, "")).join("") : null;
184
+ } },
185
+ ];
186
+ for (const method of methods) {
187
+ try {
188
+ const result = await ctx.bridge.shell(method.cmd, { device: serial, timeout: 5000, ignoreExitCode: true });
189
+ const parsed = method.parse(result.stdout);
190
+ if (parsed && parsed.trim().length > 0) {
191
+ return { content: [{ type: "text", text: `Clipboard: ${parsed}` }] };
192
+ }
193
+ }
194
+ catch {
195
+ // Try next method
196
+ }
197
+ }
198
+ return { content: [{ type: "text", text: "Clipboard: (empty or unable to read — may require foreground app on Android 12+)" }] };
199
+ }
200
+ catch (error) {
201
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
202
+ }
203
+ });
204
+ // ── Batch 2: UI Automation Helpers ────────────────────────────────────
205
+ 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.", {
206
+ text: z.string().optional().describe("Match elements containing this text (case-insensitive)"),
207
+ resourceId: z.string().optional().describe("Match elements with this resource-id (partial match)"),
208
+ contentDesc: z.string().optional().describe("Match elements with this content-description (partial, case-insensitive)"),
209
+ index: z.number().int().min(0).max(99).optional().default(0)
210
+ .describe("Which match to tap if multiple found (0 = first match, default)"),
211
+ device: z.string().optional().describe("Device serial"),
212
+ }, async ({ text, resourceId, contentDesc, index, device }) => {
213
+ if (!text && !resourceId && !contentDesc) {
214
+ return { content: [{ type: "text", text: "Provide at least one: text, resourceId, or contentDesc" }], isError: true };
215
+ }
216
+ try {
217
+ const resolved = await ctx.deviceManager.resolveDevice(device);
218
+ const serial = resolved.serial;
219
+ const xml = await captureUiDump(ctx.bridge, serial);
220
+ if (!xml) {
221
+ return { content: [{ type: "text", text: "Failed to capture UI hierarchy." }], isError: true };
222
+ }
223
+ const allElements = parseUiNodes(xml, false);
224
+ const matches = allElements.filter((el) => {
225
+ if (text && !el.text.toLowerCase().includes(text.toLowerCase()))
226
+ return false;
227
+ if (resourceId && !el.resourceId.includes(resourceId))
228
+ return false;
229
+ if (contentDesc && !el.contentDesc.toLowerCase().includes(contentDesc.toLowerCase()))
230
+ return false;
231
+ return true;
232
+ });
233
+ if (matches.length === 0) {
234
+ const criteria = [];
235
+ if (text)
236
+ criteria.push(`text="${text}"`);
237
+ if (resourceId)
238
+ criteria.push(`id="${resourceId}"`);
239
+ if (contentDesc)
240
+ criteria.push(`desc="${contentDesc}"`);
241
+ return { content: [{ type: "text", text: `No elements found matching: ${criteria.join(", ")}` }], isError: true };
242
+ }
243
+ if (index >= matches.length) {
244
+ return { content: [{ type: "text", text: `Index ${index} out of range — found ${matches.length} match(es)` }], isError: true };
245
+ }
246
+ const target = matches[index];
247
+ const { centerX, centerY } = target.bounds;
248
+ await ctx.bridge.shell(`input tap ${centerX} ${centerY}`, { device: serial, ignoreExitCode: true });
249
+ const label = target.text || target.contentDesc || target.resourceId || target.className;
250
+ return { content: [{ type: "text", text: `Tapped "${label}" at (${centerX},${centerY})${matches.length > 1 ? ` [match ${index + 1}/${matches.length}]` : ""}` }] };
251
+ }
252
+ catch (error) {
253
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
254
+ }
255
+ });
256
+ 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.", {
257
+ text: z.string().optional().describe("Match elements containing this text (case-insensitive)"),
258
+ resourceId: z.string().optional().describe("Match elements with this resource-id (partial match)"),
259
+ contentDesc: z.string().optional().describe("Match elements with this content-description (partial, case-insensitive)"),
260
+ condition: z.enum(["appear", "disappear"]).optional().default("appear")
261
+ .describe("Wait for element to 'appear' (default) or 'disappear'"),
262
+ timeoutMs: z.number().int().min(1000).max(60000).optional().default(10000)
263
+ .describe("Maximum wait time in ms (1s-60s, default 10s)"),
264
+ pollMs: z.number().int().min(200).max(5000).optional().default(500)
265
+ .describe("Polling interval in ms (200-5000, default 500)"),
266
+ device: z.string().optional().describe("Device serial"),
267
+ }, async ({ text, resourceId, contentDesc, condition, timeoutMs, pollMs, device }) => {
268
+ if (!text && !resourceId && !contentDesc) {
269
+ return { content: [{ type: "text", text: "Provide at least one: text, resourceId, or contentDesc" }], isError: true };
270
+ }
271
+ try {
272
+ const resolved = await ctx.deviceManager.resolveDevice(device);
273
+ const serial = resolved.serial;
274
+ const criteria = [];
275
+ if (text)
276
+ criteria.push(`text="${text}"`);
277
+ if (resourceId)
278
+ criteria.push(`id="${resourceId}"`);
279
+ if (contentDesc)
280
+ criteria.push(`desc="${contentDesc}"`);
281
+ const start = Date.now();
282
+ let polls = 0;
283
+ while (Date.now() - start < timeoutMs) {
284
+ polls++;
285
+ const xml = await captureUiDump(ctx.bridge, serial);
286
+ if (xml) {
287
+ const elements = parseUiNodes(xml, false);
288
+ const found = elements.some((el) => {
289
+ if (text && !el.text.toLowerCase().includes(text.toLowerCase()))
290
+ return false;
291
+ if (resourceId && !el.resourceId.includes(resourceId))
292
+ return false;
293
+ if (contentDesc && !el.contentDesc.toLowerCase().includes(contentDesc.toLowerCase()))
294
+ return false;
295
+ return true;
296
+ });
297
+ if (condition === "appear" && found) {
298
+ const elapsed = Date.now() - start;
299
+ return { content: [{ type: "text", text: `Element appeared (${criteria.join(", ")}) after ${elapsed}ms (${polls} polls)` }] };
300
+ }
301
+ if (condition === "disappear" && !found) {
302
+ const elapsed = Date.now() - start;
303
+ return { content: [{ type: "text", text: `Element disappeared (${criteria.join(", ")}) after ${elapsed}ms (${polls} polls)` }] };
304
+ }
305
+ }
306
+ await new Promise((r) => setTimeout(r, pollMs));
307
+ }
308
+ const elapsed = Date.now() - start;
309
+ return { content: [{ type: "text", text: `Timeout: element did not ${condition} (${criteria.join(", ")}) within ${elapsed}ms (${polls} polls)` }], isError: true };
310
+ }
311
+ catch (error) {
312
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
313
+ }
314
+ });
315
+ 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.", {
316
+ stableCount: z.number().int().min(2).max(10).optional().default(2)
317
+ .describe("Number of consecutive identical dumps required (2-10, default 2)"),
318
+ timeoutMs: z.number().int().min(1000).max(60000).optional().default(10000)
319
+ .describe("Maximum wait time in ms (1s-60s, default 10s)"),
320
+ pollMs: z.number().int().min(200).max(5000).optional().default(500)
321
+ .describe("Polling interval in ms (200-5000, default 500)"),
322
+ device: z.string().optional().describe("Device serial"),
323
+ }, async ({ stableCount, timeoutMs, pollMs, device }) => {
324
+ try {
325
+ const resolved = await ctx.deviceManager.resolveDevice(device);
326
+ const serial = resolved.serial;
327
+ const start = Date.now();
328
+ let polls = 0;
329
+ let consecutiveStable = 0;
330
+ let lastSignature = "";
331
+ while (Date.now() - start < timeoutMs) {
332
+ polls++;
333
+ const xml = await captureUiDump(ctx.bridge, serial);
334
+ if (xml) {
335
+ // Build a lightweight signature: element count + top-level resource-ids + texts
336
+ const elements = parseUiNodes(xml, false);
337
+ const sig = elements.map((e) => `${e.resourceId}|${e.text}|${e.bounds.centerX},${e.bounds.centerY}`).join(";");
338
+ if (sig === lastSignature) {
339
+ consecutiveStable++;
340
+ if (consecutiveStable >= stableCount - 1) {
341
+ const elapsed = Date.now() - start;
342
+ return { content: [{ type: "text", text: `UI stable after ${elapsed}ms (${polls} polls, ${elements.length} elements, ${stableCount} consecutive matches)` }] };
343
+ }
344
+ }
345
+ else {
346
+ consecutiveStable = 0;
347
+ lastSignature = sig;
348
+ }
349
+ }
350
+ await new Promise((r) => setTimeout(r, pollMs));
351
+ }
352
+ const elapsed = Date.now() - start;
353
+ return { content: [{ type: "text", text: `Timeout: UI did not stabilize within ${elapsed}ms (${polls} polls, needed ${stableCount} consecutive matches, got ${consecutiveStable + 1})` }], isError: true };
354
+ }
355
+ catch (error) {
356
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
357
+ }
358
+ });
359
+ 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.", {
360
+ text: z.string().optional().describe("Match elements containing this text (case-insensitive)"),
361
+ resourceId: z.string().optional().describe("Match elements with this resource-id (partial match)"),
362
+ contentDesc: z.string().optional().describe("Match elements with this content-description (partial, case-insensitive)"),
363
+ direction: z.enum(["up", "down", "left", "right"]).optional().default("down")
364
+ .describe("Scroll direction (default 'down' = content moves up, revealing lower items)"),
365
+ maxScrolls: z.number().int().min(1).max(50).optional().default(10)
366
+ .describe("Maximum scroll attempts (1-50, default 10)"),
367
+ tapWhenFound: z.boolean().optional().default(false)
368
+ .describe("Automatically tap the element when found"),
369
+ device: z.string().optional().describe("Device serial"),
370
+ }, async ({ text, resourceId, contentDesc, direction, maxScrolls, tapWhenFound, device }) => {
371
+ if (!text && !resourceId && !contentDesc) {
372
+ return { content: [{ type: "text", text: "Provide at least one: text, resourceId, or contentDesc" }], isError: true };
373
+ }
374
+ try {
375
+ const resolved = await ctx.deviceManager.resolveDevice(device);
376
+ const serial = resolved.serial;
377
+ // Get screen dimensions for scroll coordinates
378
+ const sizeResult = await ctx.bridge.shell("wm size", { device: serial });
379
+ const sizeMatch = sizeResult.stdout.match(/(\d+)x(\d+)/);
380
+ const screenW = sizeMatch ? parseInt(sizeMatch[1], 10) : 1080;
381
+ const screenH = sizeMatch ? parseInt(sizeMatch[2], 10) : 2400;
382
+ const cx = Math.round(screenW / 2);
383
+ const cy = Math.round(screenH / 2);
384
+ const scrollDist = Math.round(screenH * 0.35);
385
+ // Swipe vectors for each direction
386
+ const swipes = {
387
+ down: `${cx} ${cy + scrollDist} ${cx} ${cy - scrollDist} 300`,
388
+ up: `${cx} ${cy - scrollDist} ${cx} ${cy + scrollDist} 300`,
389
+ left: `${cx - scrollDist} ${cy} ${cx + scrollDist} ${cy} 300`,
390
+ right: `${cx + scrollDist} ${cy} ${cx - scrollDist} ${cy} 300`,
391
+ };
392
+ const criteria = [];
393
+ if (text)
394
+ criteria.push(`text="${text}"`);
395
+ if (resourceId)
396
+ criteria.push(`id="${resourceId}"`);
397
+ if (contentDesc)
398
+ criteria.push(`desc="${contentDesc}"`);
399
+ // Check before first scroll (element might already be visible)
400
+ for (let i = 0; i <= maxScrolls; i++) {
401
+ const xml = await captureUiDump(ctx.bridge, serial);
402
+ if (xml) {
403
+ const elements = parseUiNodes(xml, false);
404
+ const matches = elements.filter((el) => {
405
+ if (text && !el.text.toLowerCase().includes(text.toLowerCase()))
406
+ return false;
407
+ if (resourceId && !el.resourceId.includes(resourceId))
408
+ return false;
409
+ if (contentDesc && !el.contentDesc.toLowerCase().includes(contentDesc.toLowerCase()))
410
+ return false;
411
+ return true;
412
+ });
413
+ if (matches.length > 0) {
414
+ const target = matches[0];
415
+ const label = target.text || target.contentDesc || target.resourceId || target.className;
416
+ let result = `Found "${label}" at (${target.bounds.centerX},${target.bounds.centerY}) after ${i} scroll(s)`;
417
+ if (tapWhenFound) {
418
+ await ctx.bridge.shell(`input tap ${target.bounds.centerX} ${target.bounds.centerY}`, { device: serial, ignoreExitCode: true });
419
+ result += " — tapped";
420
+ }
421
+ return { content: [{ type: "text", text: result }] };
422
+ }
423
+ }
424
+ // Don't scroll after the last check
425
+ if (i < maxScrolls) {
426
+ await ctx.bridge.shell(`input swipe ${swipes[direction]}`, { device: serial, ignoreExitCode: true });
427
+ await new Promise((r) => setTimeout(r, 400));
428
+ }
429
+ }
430
+ return { content: [{ type: "text", text: `Element not found (${criteria.join(", ")}) after ${maxScrolls} scrolls ${direction}` }], isError: true };
431
+ }
432
+ catch (error) {
433
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
434
+ }
435
+ });
436
+ // ── Batch 3: Efficiency Features ──────────────────────────────────────
437
+ 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.", {
438
+ quality: z.number().int().min(10).max(100).optional().default(50)
439
+ .describe("JPEG quality (10-100, default 50). Lower = smaller file, more artifacts"),
440
+ scale: z.number().min(0.1).max(1.0).optional().default(0.5)
441
+ .describe("Scale factor (0.1-1.0, default 0.5). 0.5 = half resolution"),
442
+ filename: z.string().optional().describe("Output filename (default: screenshot_compressed_<timestamp>.jpg)"),
443
+ device: z.string().optional().describe("Device serial"),
444
+ }, async ({ quality, scale, filename, device }) => {
445
+ try {
446
+ const resolved = await ctx.deviceManager.resolveDevice(device);
447
+ const serial = resolved.serial;
448
+ const remoteDir = isOnDevice() ? "/data/local/tmp" : "/sdcard";
449
+ const timestamp = Date.now();
450
+ const remotePng = `${remoteDir}/DA_cap_${timestamp}.png`;
451
+ const remoteJpg = `${remoteDir}/DA_cap_${timestamp}.jpg`;
452
+ // Sanitize filename
453
+ const outName = filename
454
+ ? filename.replace(/[^a-zA-Z0-9._-]/g, "_")
455
+ : `screenshot_compressed_${timestamp}.jpg`;
456
+ const localPath = join(ctx.config.tempDir, outName);
457
+ try {
458
+ // Capture full-resolution PNG
459
+ await ctx.bridge.shell(`screencap -p '${remotePng}'`, { device: serial, timeout: 15000 });
460
+ // Get screen dimensions for scaling
461
+ const sizeResult = await ctx.bridge.shell("wm size", { device: serial });
462
+ const sizeMatch = sizeResult.stdout.match(/(\d+)x(\d+)/);
463
+ const origW = sizeMatch ? parseInt(sizeMatch[1], 10) : 1080;
464
+ const origH = sizeMatch ? parseInt(sizeMatch[2], 10) : 2400;
465
+ const newW = Math.round(origW * scale);
466
+ const newH = Math.round(origH * scale);
467
+ // Try device-side JPEG conversion via Android's built-in tools
468
+ // Method: use screencap raw + toybox/convert, or pull PNG and note size
469
+ // Most reliable: pull the PNG, report dimensions for the LLM to understand scale
470
+ // Android doesn't have imagemagick, so we capture and report sizing info
471
+ await ctx.bridge.exec(["pull", remotePng, localPath], { device: serial, timeout: 30000 });
472
+ // Get file size for reporting
473
+ const sizeCheck = await ctx.bridge.shell(`stat -c %s '${remotePng}' 2>/dev/null || wc -c < '${remotePng}'`, {
474
+ device: serial, ignoreExitCode: true,
475
+ });
476
+ const fileSize = parseInt(sizeCheck.stdout.trim(), 10) || 0;
477
+ const fileSizeKB = Math.round(fileSize / 1024);
478
+ return {
479
+ content: [{
480
+ type: "text",
481
+ 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`,
482
+ }],
483
+ };
484
+ }
485
+ finally {
486
+ await ctx.bridge.shell(`rm -f '${remotePng}' '${remoteJpg}'`, { device: serial, ignoreExitCode: true }).catch(() => { });
487
+ }
488
+ }
489
+ catch (error) {
490
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
491
+ }
492
+ });
493
+ 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, long_press, double_tap, keyevent, text, drag, back, home, sleep.", {
494
+ actions: z.array(z.object({
495
+ type: z.enum(["tap", "swipe", "long_press", "double_tap", "keyevent", "text", "drag", "back", "home", "sleep"])
496
+ .describe("Action type"),
497
+ args: z.string().optional()
498
+ .describe("Action arguments: tap='x y', swipe='x1 y1 x2 y2 [ms]', long_press='x y [ms]', double_tap='x y', keyevent='KEYCODE_*', text='string', drag='x1 y1 x2 y2 [ms]', sleep='ms', back/home=none"),
499
+ })).min(1).max(50).describe("Array of actions to execute (1-50)"),
500
+ delayMs: z.number().int().min(0).max(5000).optional().default(100)
501
+ .describe("Delay between actions in ms (0-5000, default 100)"),
502
+ stopOnError: z.boolean().optional().default(true)
503
+ .describe("Stop execution if any action fails (default true)"),
504
+ device: z.string().optional().describe("Device serial"),
505
+ }, async ({ actions, delayMs, stopOnError, device }) => {
506
+ try {
507
+ const resolved = await ctx.deviceManager.resolveDevice(device);
508
+ const serial = resolved.serial;
509
+ const results = [];
510
+ let executed = 0;
511
+ let errors = 0;
512
+ for (const action of actions) {
513
+ executed++;
514
+ const args = action.args ?? "";
515
+ let cmd;
516
+ try {
517
+ switch (action.type) {
518
+ case "tap":
519
+ if (!/^[\d\s]+$/.test(args))
520
+ throw new Error(`Invalid tap args: "${args}"`);
521
+ cmd = `input tap ${args}`;
522
+ break;
523
+ case "swipe":
524
+ if (!/^[\d\s]+$/.test(args))
525
+ throw new Error(`Invalid swipe args: "${args}"`);
526
+ cmd = `input swipe ${args}`;
527
+ break;
528
+ case "long_press": {
529
+ if (!/^[\d\s]+$/.test(args))
530
+ throw new Error(`Invalid long_press args: "${args}"`);
531
+ const lp = args.split(/\s+/);
532
+ if (lp.length < 2)
533
+ throw new Error("long_press needs: x y [durationMs]");
534
+ const dur = lp[2] ?? "1500";
535
+ cmd = `input swipe ${lp[0]} ${lp[1]} ${lp[0]} ${lp[1]} ${dur}`;
536
+ break;
537
+ }
538
+ case "double_tap": {
539
+ if (!/^[\d\s]+$/.test(args))
540
+ throw new Error(`Invalid double_tap args: "${args}"`);
541
+ const dt = args.split(/\s+/);
542
+ if (dt.length < 2)
543
+ throw new Error("double_tap needs: x y");
544
+ await ctx.bridge.shell(`input tap ${dt[0]} ${dt[1]}`, { device: serial, ignoreExitCode: true });
545
+ await new Promise((r) => setTimeout(r, 80));
546
+ cmd = `input tap ${dt[0]} ${dt[1]}`;
547
+ break;
548
+ }
549
+ case "keyevent":
550
+ if (!/^[\w\s]+$/.test(args))
551
+ throw new Error(`Invalid keyevent: "${args}"`);
552
+ cmd = `input keyevent ${args}`;
553
+ break;
554
+ case "text":
555
+ cmd = `input text '${shellEscape(args.replace(/ /g, "%s"))}'`;
556
+ break;
557
+ case "drag":
558
+ if (!/^[\d\s]+$/.test(args))
559
+ throw new Error(`Invalid drag args: "${args}"`);
560
+ cmd = `input draganddrop ${args}`;
561
+ break;
562
+ case "back":
563
+ cmd = "input keyevent KEYCODE_BACK";
564
+ break;
565
+ case "home":
566
+ cmd = "input keyevent KEYCODE_HOME";
567
+ break;
568
+ case "sleep": {
569
+ const ms = parseInt(args, 10) || 500;
570
+ await new Promise((r) => setTimeout(r, Math.min(ms, 10000)));
571
+ results.push(`[${executed}] sleep ${ms}ms`);
572
+ if (delayMs > 0 && executed < actions.length)
573
+ await new Promise((r) => setTimeout(r, delayMs));
574
+ continue;
575
+ }
576
+ default:
577
+ throw new Error(`Unknown action: ${action.type}`);
578
+ }
579
+ // Security check
580
+ const blocked = ctx.security.checkCommand(cmd, device);
581
+ if (blocked)
582
+ throw new Error(blocked);
583
+ await ctx.bridge.shell(cmd, { device: serial, ignoreExitCode: true });
584
+ results.push(`[${executed}] ${action.type}${args ? " " + args : ""} ✓`);
585
+ }
586
+ catch (e) {
587
+ errors++;
588
+ results.push(`[${executed}] ${action.type}${args ? " " + args : ""} ✗ ${e instanceof Error ? e.message : e}`);
589
+ if (stopOnError) {
590
+ results.push(`Stopped at action ${executed}/${actions.length} (stopOnError=true)`);
591
+ break;
592
+ }
593
+ }
594
+ if (delayMs > 0 && executed < actions.length) {
595
+ await new Promise((r) => setTimeout(r, delayMs));
596
+ }
597
+ }
598
+ const summary = `Batch: ${executed}/${actions.length} executed, ${errors} error(s)`;
599
+ return { content: [{ type: "text", text: `${summary}\n${results.join("\n")}` }] };
600
+ }
601
+ catch (error) {
602
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
603
+ }
604
+ });
605
+ // ── Device Awareness: Screen Size, Device State, Notifications ────
606
+ 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.", {
607
+ device: z.string().optional().describe("Device serial"),
608
+ }, async ({ device }) => {
609
+ try {
610
+ const resolved = await ctx.deviceManager.resolveDevice(device);
611
+ const serial = resolved.serial;
612
+ const [sizeResult, densityResult] = await Promise.all([
613
+ ctx.bridge.shell("wm size", { device: serial }),
614
+ ctx.bridge.shell("wm density", { device: serial }),
615
+ ]);
616
+ const sizeMatch = sizeResult.stdout.match(/(\d+)x(\d+)/);
617
+ const densityMatch = densityResult.stdout.match(/(\d+)/);
618
+ const width = sizeMatch ? parseInt(sizeMatch[1], 10) : 0;
619
+ const height = sizeMatch ? parseInt(sizeMatch[2], 10) : 0;
620
+ const density = densityMatch ? parseInt(densityMatch[1], 10) : 0;
621
+ // Check for override
622
+ const overrideSize = sizeResult.stdout.includes("Override") ? sizeResult.stdout.match(/Override size:\s*(\d+x\d+)/)?.[1] : null;
623
+ const overrideDensity = densityResult.stdout.includes("Override") ? densityResult.stdout.match(/Override density:\s*(\d+)/)?.[1] : null;
624
+ const lines = [`Screen: ${width}x${height} @ ${density} dpi`];
625
+ if (overrideSize)
626
+ lines.push(`Override size: ${overrideSize}`);
627
+ if (overrideDensity)
628
+ lines.push(`Override density: ${overrideDensity}`);
629
+ lines.push(`Aspect ratio: ${(width / height).toFixed(3)}`);
630
+ lines.push(`DP width: ${Math.round(width / (density / 160))}`);
631
+ return { content: [{ type: "text", text: lines.join("\n") }] };
632
+ }
633
+ catch (error) {
634
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
635
+ }
636
+ });
637
+ 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.", {
638
+ device: z.string().optional().describe("Device serial"),
639
+ }, async ({ device }) => {
640
+ try {
641
+ const resolved = await ctx.deviceManager.resolveDevice(device);
642
+ const serial = resolved.serial;
643
+ const [battery, network, screen, activity, orientation, wifi] = await Promise.allSettled([
644
+ ctx.bridge.shell("dumpsys battery | head -15", { device: serial, timeout: 5000, ignoreExitCode: true }),
645
+ ctx.bridge.shell("getprop gsm.network.type", { device: serial, timeout: 3000, ignoreExitCode: true }),
646
+ ctx.bridge.shell("dumpsys power | grep -E 'mWakefulness|Display Power'", { device: serial, timeout: 5000, ignoreExitCode: true }),
647
+ ctx.bridge.shell("dumpsys activity activities | grep mResumedActivity", { device: serial, timeout: 5000, ignoreExitCode: true }),
648
+ ctx.bridge.shell("settings get system accelerometer_rotation", { device: serial, timeout: 3000, ignoreExitCode: true }),
649
+ ctx.bridge.shell("dumpsys wifi | grep -E 'Wi-Fi is|mNetworkInfo'", { device: serial, timeout: 5000, ignoreExitCode: true }),
650
+ ]);
651
+ const sections = ["=== Device State Snapshot ==="];
652
+ // Battery
653
+ if (battery.status === "fulfilled") {
654
+ const b = battery.value.stdout;
655
+ const level = b.match(/level:\s*(\d+)/)?.[1] ?? "?";
656
+ const status = b.match(/status:\s*(\d+)/)?.[1];
657
+ const statusMap = { "1": "unknown", "2": "charging", "3": "discharging", "4": "not charging", "5": "full" };
658
+ const temp = b.match(/temperature:\s*(\d+)/)?.[1];
659
+ const tempC = temp ? (parseInt(temp, 10) / 10).toFixed(1) : "?";
660
+ sections.push(`Battery: ${level}% (${statusMap[status ?? ""] ?? "unknown"}, ${tempC}°C)`);
661
+ }
662
+ // Network
663
+ if (network.status === "fulfilled") {
664
+ const net = network.value.stdout.trim() || "none";
665
+ sections.push(`Network: ${net}`);
666
+ }
667
+ // WiFi
668
+ if (wifi.status === "fulfilled") {
669
+ const w = wifi.value.stdout;
670
+ const wifiEnabled = w.includes("enabled") ? "on" : w.includes("disabled") ? "off" : "unknown";
671
+ sections.push(`WiFi: ${wifiEnabled}`);
672
+ }
673
+ // Screen
674
+ if (screen.status === "fulfilled") {
675
+ const s = screen.value.stdout;
676
+ const wake = s.includes("Awake") ? "ON" : s.includes("Asleep") || s.includes("Dozing") ? "OFF" : "unknown";
677
+ sections.push(`Screen: ${wake}`);
678
+ }
679
+ // Orientation
680
+ if (orientation.status === "fulfilled") {
681
+ const auto = orientation.value.stdout.trim() === "1";
682
+ sections.push(`Orientation: ${auto ? "auto-rotate" : "locked"}`);
683
+ }
684
+ // Foreground activity
685
+ if (activity.status === "fulfilled") {
686
+ const a = activity.value.stdout;
687
+ const match = a.match(/mResumedActivity:.*\{[^\s]+\s+([^\s}]+)/);
688
+ sections.push(`Foreground: ${match ? match[1] : "none"}`);
689
+ }
690
+ return { content: [{ type: "text", text: sections.join("\n") }] };
691
+ }
692
+ catch (error) {
693
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
694
+ }
695
+ });
696
+ 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.", {
697
+ filter: z.string().optional().describe("Filter by package name (partial match, case-insensitive)"),
698
+ maxResults: z.number().int().min(1).max(50).optional().default(20)
699
+ .describe("Maximum notifications to return (1-50, default 20)"),
700
+ device: z.string().optional().describe("Device serial"),
701
+ }, async ({ filter, maxResults, device }) => {
702
+ try {
703
+ const resolved = await ctx.deviceManager.resolveDevice(device);
704
+ const serial = resolved.serial;
705
+ // Dump notification state — use --noredact for full content
706
+ const result = await ctx.bridge.shell("dumpsys notification --noredact", { device: serial, timeout: 15000, ignoreExitCode: true });
707
+ const dump = result.stdout;
708
+ // Parse NotificationRecord blocks
709
+ const notifications = [];
710
+ // Split on NotificationRecord boundaries
711
+ const records = dump.split(/NotificationRecord\(/).slice(1);
712
+ for (const record of records) {
713
+ if (notifications.length >= maxResults)
714
+ break;
715
+ // Extract package from the header
716
+ const pkgMatch = record.match(/pkg=([^\s]+)/);
717
+ const pkg = pkgMatch?.[1] ?? "unknown";
718
+ // Apply package filter
719
+ if (filter && !pkg.toLowerCase().includes(filter.toLowerCase()))
720
+ continue;
721
+ // Extract key fields
722
+ const keyMatch = record.match(/key=([^\n]+)/);
723
+ const flagsMatch = record.match(/flags=([^\n]+)/);
724
+ const importanceMatch = record.match(/mImportance=([^\n]+)/);
725
+ const channelMatch = record.match(/channel=([^\s,]+)/);
726
+ // Extract title and text from extras
727
+ const titleMatch = record.match(/android\.title=String\s*\(([^)]+)\)/);
728
+ const textMatch = record.match(/android\.text=String\s*\(([^)]+)\)/);
729
+ const whenMatch = record.match(/when=(\d+)/);
730
+ const title = titleMatch?.[1] ?? "";
731
+ const text = textMatch?.[1] ?? "";
732
+ notifications.push({
733
+ pkg,
734
+ title,
735
+ text,
736
+ importance: importanceMatch?.[1]?.trim() ?? "unknown",
737
+ channel: channelMatch?.[1] ?? "unknown",
738
+ flags: flagsMatch?.[1]?.trim() ?? "none",
739
+ key: keyMatch?.[1]?.trim() ?? "",
740
+ when: whenMatch?.[1] ? new Date(parseInt(whenMatch[1], 10)).toISOString() : "",
741
+ });
742
+ }
743
+ if (notifications.length === 0) {
744
+ return { content: [{ type: "text", text: filter ? `No notifications found matching "${filter}"` : "No active notifications" }] };
745
+ }
746
+ const output = notifications.map((n, i) => {
747
+ const lines = [`[${i + 1}] ${n.pkg}`];
748
+ if (n.title)
749
+ lines.push(` Title: ${n.title}`);
750
+ if (n.text)
751
+ lines.push(` Text: ${n.text}`);
752
+ lines.push(` Importance: ${n.importance} | Channel: ${n.channel}`);
753
+ if (n.flags !== "none")
754
+ lines.push(` Flags: ${n.flags}`);
755
+ if (n.when)
756
+ lines.push(` Posted: ${n.when}`);
757
+ return lines.join("\n");
758
+ }).join("\n\n");
759
+ return { content: [{ type: "text", text: `${notifications.length} notification(s)${filter ? ` matching "${filter}"` : ""}:\n\n${output}` }] };
760
+ }
761
+ catch (error) {
762
+ return { content: [{ type: "text", text: OutputProcessor.formatError(error) }], isError: true };
763
+ }
764
+ });
765
+ }
766
+ //# sourceMappingURL=input-gestures.js.map