@sensaiorg/adapter-android 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/android-adapter.d.ts.map +1 -0
  2. package/dist/android-adapter.js +89 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +12 -0
  5. package/dist/tools/accessibility.d.ts.map +1 -0
  6. package/dist/tools/accessibility.js +85 -0
  7. package/dist/tools/adb.d.ts.map +1 -0
  8. package/dist/tools/adb.js +66 -0
  9. package/dist/tools/app-state.d.ts.map +1 -0
  10. package/dist/tools/app-state.js +173 -0
  11. package/dist/tools/diagnose.d.ts.map +1 -0
  12. package/dist/tools/diagnose.js +128 -0
  13. package/dist/tools/hot-reload.d.ts.map +1 -0
  14. package/dist/tools/hot-reload.js +97 -0
  15. package/dist/tools/index.d.ts.map +1 -0
  16. package/dist/tools/index.js +66 -0
  17. package/dist/tools/interaction.d.ts.map +1 -0
  18. package/dist/tools/interaction.js +395 -0
  19. package/dist/tools/logcat.d.ts.map +1 -0
  20. package/dist/tools/logcat.js +216 -0
  21. package/dist/tools/network.d.ts.map +1 -0
  22. package/dist/tools/network.js +123 -0
  23. package/dist/tools/performance.d.ts.map +1 -0
  24. package/dist/tools/performance.js +143 -0
  25. package/dist/tools/recording.d.ts.map +1 -0
  26. package/dist/tools/recording.js +102 -0
  27. package/dist/tools/rn-tools.d.ts.map +1 -0
  28. package/dist/tools/rn-tools.js +120 -0
  29. package/dist/tools/smart-actions.d.ts.map +1 -0
  30. package/dist/tools/smart-actions.js +506 -0
  31. package/dist/tools/ui-tree.d.ts.map +1 -0
  32. package/dist/tools/ui-tree.js +226 -0
  33. package/dist/transport/adb-client.d.ts.map +1 -0
  34. package/dist/transport/adb-client.js +124 -0
  35. package/dist/transport/adb-client.test.d.ts.map +1 -0
  36. package/dist/transport/adb-client.test.js +153 -0
  37. package/dist/transport/agent-client.d.ts.map +1 -0
  38. package/dist/transport/agent-client.js +157 -0
  39. package/dist/transport/agent-client.test.d.ts.map +1 -0
  40. package/dist/transport/agent-client.test.js +199 -0
  41. package/dist/transport/connection-manager.d.ts.map +1 -0
  42. package/dist/transport/connection-manager.js +119 -0
  43. package/dist/util/logcat-parser.d.ts.map +1 -0
  44. package/dist/util/logcat-parser.js +79 -0
  45. package/dist/util/safety.d.ts.map +1 -0
  46. package/dist/util/safety.js +132 -0
  47. package/dist/util/safety.test.d.ts.map +1 -0
  48. package/dist/util/safety.test.js +205 -0
  49. package/dist/util/text-extractor.d.ts.map +1 -0
  50. package/dist/util/text-extractor.js +71 -0
  51. package/dist/util/ui-tree-cache.d.ts.map +1 -0
  52. package/dist/util/ui-tree-cache.js +46 -0
  53. package/dist/util/ui-tree-cache.test.d.ts.map +1 -0
  54. package/dist/util/ui-tree-cache.test.js +84 -0
  55. package/dist/util/ui-tree-parser.d.ts.map +1 -0
  56. package/dist/util/ui-tree-parser.js +123 -0
  57. package/dist/util/ui-tree-parser.test.d.ts.map +1 -0
  58. package/dist/util/ui-tree-parser.test.js +167 -0
  59. package/package.json +22 -0
  60. package/src/android-adapter.ts +124 -0
  61. package/src/index.ts +8 -0
  62. package/src/tools/accessibility.ts +94 -0
  63. package/src/tools/adb.ts +75 -0
  64. package/src/tools/app-state.ts +193 -0
  65. package/src/tools/diagnose.ts +146 -0
  66. package/src/tools/hot-reload.ts +103 -0
  67. package/src/tools/index.ts +66 -0
  68. package/src/tools/interaction.ts +448 -0
  69. package/src/tools/logcat.ts +252 -0
  70. package/src/tools/network.ts +145 -0
  71. package/src/tools/performance.ts +169 -0
  72. package/src/tools/recording.ts +123 -0
  73. package/src/tools/rn-tools.ts +143 -0
  74. package/src/tools/smart-actions.ts +593 -0
  75. package/src/tools/ui-tree.ts +258 -0
  76. package/src/transport/adb-client.test.ts +228 -0
  77. package/src/transport/adb-client.ts +139 -0
  78. package/src/transport/agent-client.test.ts +267 -0
  79. package/src/transport/agent-client.ts +188 -0
  80. package/src/transport/connection-manager.ts +140 -0
  81. package/src/util/logcat-parser.ts +94 -0
  82. package/src/util/safety.test.ts +251 -0
  83. package/src/util/safety.ts +143 -0
  84. package/src/util/text-extractor.ts +87 -0
  85. package/src/util/ui-tree-cache.test.ts +105 -0
  86. package/src/util/ui-tree-cache.ts +54 -0
  87. package/src/util/ui-tree-parser.test.ts +182 -0
  88. package/src/util/ui-tree-parser.ts +169 -0
  89. package/tsconfig.json +11 -0
@@ -0,0 +1,258 @@
1
+ /**
2
+ * UI Tree Tools - Inspect the Android view hierarchy.
3
+ *
4
+ * Provides three tools:
5
+ * - get_ui_tree: Full UI hierarchy as JSON
6
+ * - get_screen_text: Extract all visible text in reading order
7
+ * - get_element_details: Deep inspection of a specific element
8
+ */
9
+
10
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { z } from "zod";
12
+ import type { ConnectionManager } from "../transport/connection-manager.js";
13
+ import { parseUiTree, flattenTree, type UiNode } from "../util/ui-tree-parser.js";
14
+ import { extractText, extractTextStrings, findByText } from "../util/text-extractor.js";
15
+
16
+ /**
17
+ * Dump the UI hierarchy via ADB uiautomator (raw XML).
18
+ * Uses a unique filename per dump to prevent race conditions on concurrent requests.
19
+ */
20
+ async function dumpUiTreeRaw(cm: ConnectionManager): Promise<string> {
21
+ const dumpPath = `/sdcard/sensai_dump_${Date.now()}.xml`;
22
+ const dumpOutput = await cm.adb.shell(`uiautomator dump ${dumpPath}`);
23
+ if (dumpOutput.includes("ERROR") || !dumpOutput.includes("dumped")) {
24
+ throw new Error(`uiautomator dump failed: ${dumpOutput.trim()}`);
25
+ }
26
+ const xml = await cm.adb.shell(`cat ${dumpPath}`);
27
+ // Clean up the dump file (fire and forget)
28
+ cm.adb.shell(`rm -f ${dumpPath}`).catch(() => {});
29
+ if (!xml.includes("<hierarchy")) {
30
+ throw new Error("UI dump produced invalid XML");
31
+ }
32
+ return xml;
33
+ }
34
+
35
+ /**
36
+ * Get the UI tree with caching support. Shared by all tools that need UI data.
37
+ * Exported so other tool modules can reuse without redundant dumps.
38
+ */
39
+ export async function getCachedTree(
40
+ cm: ConnectionManager,
41
+ options: { maxDepth?: number; visibleOnly?: boolean; includeSystemUI?: boolean; forceRefresh?: boolean },
42
+ ): Promise<UiNode[]> {
43
+ // Phase 2: prefer agent for richer data
44
+ if (cm.agent.isConnected()) {
45
+ try {
46
+ const result = await cm.agent.call("getUiTree", options);
47
+ return result as UiNode[];
48
+ } catch {
49
+ // Fall through to ADB
50
+ }
51
+ }
52
+
53
+ // Check cache unless force refresh requested
54
+ if (!options.forceRefresh) {
55
+ const cached = cm.uiCache.get();
56
+ if (cached) {
57
+ // Re-parse with current options from cached XML
58
+ return parseUiTree(cached.xml, options);
59
+ }
60
+ }
61
+
62
+ // Phase 1: ADB uiautomator dump
63
+ const xml = await dumpUiTreeRaw(cm);
64
+ const tree = parseUiTree(xml, options);
65
+
66
+ // Cache the raw XML and default-options tree
67
+ cm.uiCache.set(tree, xml);
68
+
69
+ return tree;
70
+ }
71
+
72
+ export function registerUiTreeTools(server: McpServer, cm: ConnectionManager): void {
73
+ /**
74
+ * get_ui_tree - Returns the full UI hierarchy as structured JSON.
75
+ */
76
+ server.tool(
77
+ "get_ui_tree",
78
+ "Get the Android UI view hierarchy as structured JSON. Returns class names, resource IDs, text, bounds, and interactive state for every view element.",
79
+ {
80
+ maxDepth: z.number().optional().describe("Maximum depth to traverse (0 = unlimited)"),
81
+ visibleOnly: z.boolean().optional().describe("Only include visible nodes"),
82
+ includeSystemUI: z.boolean().optional().describe("Include status bar, nav bar, etc."),
83
+ interactableOnly: z.boolean().optional().describe("Only return nodes that are clickable, focusable, or have text content. Reduces noise significantly."),
84
+ format: z.enum(["full", "compact"]).optional().describe("Output format: 'full' (default) returns UiNode objects, 'compact' returns [text, resourceId, bounds, clickable] tuples for ~60% token savings"),
85
+ },
86
+ async (params) => {
87
+ try {
88
+ const tree = await getCachedTree(cm, {
89
+ maxDepth: params.maxDepth,
90
+ visibleOnly: params.visibleOnly,
91
+ includeSystemUI: params.includeSystemUI,
92
+ });
93
+
94
+ let flat = flattenTree(tree);
95
+ const totalNodes = flat.length;
96
+
97
+ // Filter to interactable nodes only
98
+ if (params.interactableOnly) {
99
+ flat = flat.filter((n) => n.clickable || n.focusable || !!n.text);
100
+ }
101
+
102
+ const summary = {
103
+ totalNodes,
104
+ returnedNodes: flat.length,
105
+ clickableNodes: flat.filter((n) => n.clickable).length,
106
+ textNodes: flat.filter((n) => n.text).length,
107
+ filtered: !!params.interactableOnly,
108
+ format: params.format ?? "full",
109
+ };
110
+
111
+ let treeData: unknown;
112
+ if (params.format === "compact") {
113
+ // Compact format: [text, resourceId, boundsStr, clickable] tuples
114
+ // Saves ~60% tokens vs full UiNode objects
115
+ treeData = flat.map((n) => [
116
+ n.text || n.contentDescription || "",
117
+ n.resourceId || "",
118
+ n.bounds ? `[${n.bounds.left},${n.bounds.top}][${n.bounds.right},${n.bounds.bottom}]` : "",
119
+ n.clickable,
120
+ ]);
121
+ } else {
122
+ // Full format: return tree if not filtered, flat array if filtered
123
+ treeData = params.interactableOnly ? flat : tree;
124
+ }
125
+
126
+ return {
127
+ content: [
128
+ {
129
+ type: "text" as const,
130
+ text: JSON.stringify({ summary, tree: treeData }),
131
+ },
132
+ ],
133
+ };
134
+ } catch (err) {
135
+ return {
136
+ content: [
137
+ {
138
+ type: "text" as const,
139
+ text: `Error getting UI tree: ${err instanceof Error ? err.message : String(err)}`,
140
+ },
141
+ ],
142
+ isError: true,
143
+ };
144
+ }
145
+ },
146
+ );
147
+
148
+ /**
149
+ * get_screen_text - Extract all visible text from the screen in reading order.
150
+ */
151
+ server.tool(
152
+ "get_screen_text",
153
+ "Extract all visible text from the current screen, ordered top-to-bottom left-to-right (reading order). Useful for understanding what the user sees.",
154
+ {},
155
+ async () => {
156
+ try {
157
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
158
+ const textEntries = extractText(tree);
159
+ const textStrings = extractTextStrings(tree);
160
+
161
+ // Deduplicate: trim whitespace, remove empty strings, remove duplicates
162
+ const deduped = [...new Set(
163
+ textStrings
164
+ .map((s) => s.trim())
165
+ .filter((s) => s.length > 0),
166
+ )];
167
+
168
+ return {
169
+ content: [
170
+ {
171
+ type: "text" as const,
172
+ text: JSON.stringify({
173
+ screenText: deduped,
174
+ detailed: textEntries,
175
+ }),
176
+ },
177
+ ],
178
+ };
179
+ } catch (err) {
180
+ return {
181
+ content: [
182
+ {
183
+ type: "text" as const,
184
+ text: `Error extracting screen text: ${err instanceof Error ? err.message : String(err)}`,
185
+ },
186
+ ],
187
+ isError: true,
188
+ };
189
+ }
190
+ },
191
+ );
192
+
193
+ /**
194
+ * get_element_details - Deep inspection of elements matching a search query.
195
+ */
196
+ server.tool(
197
+ "get_element_details",
198
+ "Find and inspect UI elements matching a text query. Returns full details including bounds, class, interactive state, and nearby elements.",
199
+ {
200
+ query: z.string().describe("Text to search for (case-insensitive substring match)"),
201
+ includeParent: z.boolean().optional().describe("Include parent context for matched elements"),
202
+ },
203
+ async (params) => {
204
+ try {
205
+ const tree = await getCachedTree(cm, { visibleOnly: true, includeSystemUI: false });
206
+ const matches = findByText(tree, params.query);
207
+
208
+ if (matches.length === 0) {
209
+ return {
210
+ content: [
211
+ {
212
+ type: "text" as const,
213
+ text: JSON.stringify({
214
+ found: 0,
215
+ message: `No elements found matching "${params.query}"`,
216
+ hint: "Try using get_screen_text to see all visible text first.",
217
+ }),
218
+ },
219
+ ],
220
+ };
221
+ }
222
+
223
+ const details = matches.map((node) => ({
224
+ text: node.text,
225
+ contentDescription: node.contentDescription,
226
+ className: node.className,
227
+ resourceId: node.resourceId,
228
+ bounds: node.bounds,
229
+ clickable: node.clickable,
230
+ enabled: node.enabled,
231
+ focusable: node.focusable,
232
+ scrollable: node.scrollable,
233
+ centerX: node.bounds ? Math.round((node.bounds.left + node.bounds.right) / 2) : null,
234
+ centerY: node.bounds ? Math.round((node.bounds.top + node.bounds.bottom) / 2) : null,
235
+ }));
236
+
237
+ return {
238
+ content: [
239
+ {
240
+ type: "text" as const,
241
+ text: JSON.stringify({ found: matches.length, elements: details }),
242
+ },
243
+ ],
244
+ };
245
+ } catch (err) {
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text" as const,
250
+ text: `Error inspecting elements: ${err instanceof Error ? err.message : String(err)}`,
251
+ },
252
+ ],
253
+ isError: true,
254
+ };
255
+ }
256
+ },
257
+ );
258
+ }
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // vi.hoisted runs before vi.mock hoisting, so the fn is available in mock factories
4
+ const mockExecFileAsync = vi.hoisted(() => vi.fn());
5
+
6
+ vi.mock("node:child_process", () => ({
7
+ execFile: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("node:util", () => ({
11
+ promisify: () => mockExecFileAsync,
12
+ }));
13
+
14
+ import { AdbClient } from "./adb-client.js";
15
+
16
+ describe("AdbClient", () => {
17
+ let client: AdbClient;
18
+
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ client = new AdbClient("/usr/bin/adb", "emulator-5554");
22
+ });
23
+
24
+ describe("exec()", () => {
25
+ it("runs adb with correct device serial and args", async () => {
26
+ mockExecFileAsync.mockResolvedValue({ stdout: "ok\n" });
27
+
28
+ await client.exec(["shell", "ls"]);
29
+
30
+ expect(mockExecFileAsync).toHaveBeenCalledWith(
31
+ "/usr/bin/adb",
32
+ ["-s", "emulator-5554", "shell", "ls"],
33
+ { timeout: 30_000, maxBuffer: 10 * 1024 * 1024 },
34
+ );
35
+ });
36
+
37
+ it("returns stdout from the command", async () => {
38
+ mockExecFileAsync.mockResolvedValue({ stdout: "file1.txt\nfile2.txt\n" });
39
+
40
+ const result = await client.exec(["shell", "ls"]);
41
+
42
+ expect(result).toBe("file1.txt\nfile2.txt\n");
43
+ });
44
+
45
+ it("throws with descriptive error on failure", async () => {
46
+ mockExecFileAsync.mockRejectedValue(new Error("command not found"));
47
+
48
+ await expect(client.exec(["shell", "badcmd"])).rejects.toThrow(
49
+ "ADB command failed [adb -s emulator-5554 shell badcmd]: command not found",
50
+ );
51
+ });
52
+
53
+ it("sets connected=true on success", async () => {
54
+ mockExecFileAsync.mockResolvedValue({ stdout: "ok" });
55
+
56
+ expect(client.isDeviceConnected).toBe(false);
57
+ await client.exec(["get-state"]);
58
+ expect(client.isDeviceConnected).toBe(true);
59
+ });
60
+
61
+ it('sets connected=false on "device not found" error', async () => {
62
+ // First succeed to set connected=true
63
+ mockExecFileAsync.mockResolvedValueOnce({ stdout: "ok" });
64
+ await client.exec(["get-state"]);
65
+ expect(client.isDeviceConnected).toBe(true);
66
+
67
+ // Then fail with device not found
68
+ mockExecFileAsync.mockRejectedValue(
69
+ new Error("error: device 'emulator-5554' not found"),
70
+ );
71
+ await expect(client.exec(["get-state"])).rejects.toThrow();
72
+ expect(client.isDeviceConnected).toBe(false);
73
+ });
74
+
75
+ it("respects custom timeout", async () => {
76
+ mockExecFileAsync.mockResolvedValue({ stdout: "" });
77
+
78
+ await client.exec(["shell", "slow-cmd"], 5_000);
79
+
80
+ expect(mockExecFileAsync).toHaveBeenCalledWith(
81
+ "/usr/bin/adb",
82
+ ["-s", "emulator-5554", "shell", "slow-cmd"],
83
+ { timeout: 5_000, maxBuffer: 10 * 1024 * 1024 },
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("shell()", () => {
89
+ it('delegates to exec with "shell" prefix', async () => {
90
+ mockExecFileAsync.mockResolvedValue({ stdout: "result\n" });
91
+
92
+ const result = await client.shell("getprop ro.product.model");
93
+
94
+ expect(mockExecFileAsync).toHaveBeenCalledWith(
95
+ "/usr/bin/adb",
96
+ ["-s", "emulator-5554", "shell", "getprop ro.product.model"],
97
+ expect.objectContaining({ timeout: 30_000 }),
98
+ );
99
+ expect(result).toBe("result\n");
100
+ });
101
+ });
102
+
103
+ describe("isConnected()", () => {
104
+ it('returns true when device responds "device"', async () => {
105
+ mockExecFileAsync.mockResolvedValue({ stdout: "device\n" });
106
+
107
+ const result = await client.isConnected();
108
+
109
+ expect(result).toBe(true);
110
+ expect(client.isDeviceConnected).toBe(true);
111
+ });
112
+
113
+ it("returns false on timeout/error", async () => {
114
+ mockExecFileAsync.mockRejectedValue(new Error("timeout"));
115
+
116
+ const result = await client.isConnected();
117
+
118
+ expect(result).toBe(false);
119
+ expect(client.isDeviceConnected).toBe(false);
120
+ });
121
+
122
+ it("returns false when device state is not 'device'", async () => {
123
+ mockExecFileAsync.mockResolvedValue({ stdout: "offline\n" });
124
+
125
+ const result = await client.isConnected();
126
+
127
+ expect(result).toBe(false);
128
+ });
129
+
130
+ it("calls get-state with 5s timeout", async () => {
131
+ mockExecFileAsync.mockResolvedValue({ stdout: "device\n" });
132
+
133
+ await client.isConnected();
134
+
135
+ expect(mockExecFileAsync).toHaveBeenCalledWith(
136
+ "/usr/bin/adb",
137
+ ["-s", "emulator-5554", "get-state"],
138
+ expect.objectContaining({ timeout: 5_000 }),
139
+ );
140
+ });
141
+ });
142
+
143
+ describe("getDeviceInfo()", () => {
144
+ it("returns structured device info", async () => {
145
+ mockExecFileAsync.mockImplementation((_path: string, args: string[]) => {
146
+ const cmd = args.join(" ");
147
+ if (cmd.includes("getprop ro.product.model"))
148
+ return Promise.resolve({ stdout: "Pixel 7\n" });
149
+ if (cmd.includes("getprop ro.build.version.release"))
150
+ return Promise.resolve({ stdout: "14\n" });
151
+ if (cmd.includes("getprop ro.build.version.sdk"))
152
+ return Promise.resolve({ stdout: "34\n" });
153
+ if (cmd.includes("wm size"))
154
+ return Promise.resolve({ stdout: "Physical size: 1080x2400\n" });
155
+ if (cmd.includes("wm density"))
156
+ return Promise.resolve({ stdout: "Physical density: 420\n" });
157
+ return Promise.resolve({ stdout: "" });
158
+ });
159
+
160
+ const info = await client.getDeviceInfo();
161
+
162
+ expect(info).toEqual({
163
+ serial: "emulator-5554",
164
+ model: "Pixel 7",
165
+ androidVersion: "14",
166
+ sdkVersion: "34",
167
+ screenSize: "1080x2400",
168
+ density: "420",
169
+ });
170
+ });
171
+
172
+ it('returns "unknown" for failed property reads', async () => {
173
+ mockExecFileAsync.mockRejectedValue(new Error("device offline"));
174
+
175
+ const info = await client.getDeviceInfo();
176
+
177
+ expect(info).toEqual({
178
+ serial: "emulator-5554",
179
+ model: "unknown",
180
+ androidVersion: "unknown",
181
+ sdkVersion: "unknown",
182
+ screenSize: "unknown",
183
+ density: "unknown",
184
+ });
185
+ });
186
+ });
187
+
188
+ describe("pull()", () => {
189
+ it("delegates to exec with correct args", async () => {
190
+ mockExecFileAsync.mockResolvedValue({ stdout: "1 file pulled.\n" });
191
+
192
+ const result = await client.pull("/sdcard/screenshot.png", "/tmp/shot.png");
193
+
194
+ expect(mockExecFileAsync).toHaveBeenCalledWith(
195
+ "/usr/bin/adb",
196
+ ["-s", "emulator-5554", "pull", "/sdcard/screenshot.png", "/tmp/shot.png"],
197
+ expect.objectContaining({ timeout: 30_000 }),
198
+ );
199
+ expect(result).toBe("1 file pulled.\n");
200
+ });
201
+ });
202
+
203
+ describe("forward()", () => {
204
+ it("delegates to exec with tcp format", async () => {
205
+ mockExecFileAsync.mockResolvedValue({ stdout: "" });
206
+
207
+ await client.forward(8081, 8081);
208
+
209
+ expect(mockExecFileAsync).toHaveBeenCalledWith(
210
+ "/usr/bin/adb",
211
+ ["-s", "emulator-5554", "forward", "tcp:8081", "tcp:8081"],
212
+ expect.objectContaining({ timeout: 30_000 }),
213
+ );
214
+ });
215
+ });
216
+
217
+ describe("isDeviceConnected getter", () => {
218
+ it("returns cached state (false initially)", () => {
219
+ expect(client.isDeviceConnected).toBe(false);
220
+ });
221
+
222
+ it("returns true after a successful exec", async () => {
223
+ mockExecFileAsync.mockResolvedValue({ stdout: "ok" });
224
+ await client.exec(["version"]);
225
+ expect(client.isDeviceConnected).toBe(true);
226
+ });
227
+ });
228
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * ADB Client - Executes ADB commands against Android devices/emulators.
3
+ *
4
+ * Provides a typed interface around the `adb` CLI for shell commands,
5
+ * device info queries, and general command execution.
6
+ */
7
+
8
+ import { execFile } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ /** Maximum time (ms) to wait for an ADB command before killing it. */
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+
16
+ /** Information about the connected Android device. */
17
+ export interface DeviceInfo {
18
+ serial: string;
19
+ model: string;
20
+ androidVersion: string;
21
+ sdkVersion: string;
22
+ screenSize: string;
23
+ density: string;
24
+ }
25
+
26
+ export class AdbClient {
27
+ private readonly adbPath: string;
28
+ private readonly device: string;
29
+ private connected = false;
30
+
31
+ constructor(adbPath: string, device: string) {
32
+ this.adbPath = adbPath;
33
+ this.device = device;
34
+ }
35
+
36
+ /**
37
+ * Execute an arbitrary ADB command (not a shell command).
38
+ * @param args - Arguments passed directly to `adb -s <device> ...args`.
39
+ * @param timeoutMs - Optional timeout override.
40
+ * @returns stdout of the command.
41
+ */
42
+ async exec(args: string[], timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string> {
43
+ const fullArgs = ["-s", this.device, ...args];
44
+ try {
45
+ const { stdout } = await execFileAsync(this.adbPath, fullArgs, {
46
+ timeout: timeoutMs,
47
+ maxBuffer: 10 * 1024 * 1024, // 10 MB
48
+ });
49
+ this.connected = true;
50
+ return stdout;
51
+ } catch (error: unknown) {
52
+ const msg = error instanceof Error ? error.message : String(error);
53
+ if (msg.includes("device") && msg.includes("not found")) {
54
+ this.connected = false;
55
+ }
56
+ throw new Error(`ADB command failed [adb ${fullArgs.join(" ")}]: ${msg}`);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Execute a shell command on the device.
62
+ * @param command - Shell command string to run via `adb shell`.
63
+ * @param timeoutMs - Optional timeout override.
64
+ */
65
+ async shell(command: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<string> {
66
+ return this.exec(["shell", command], timeoutMs);
67
+ }
68
+
69
+ /**
70
+ * Check whether the device is reachable.
71
+ */
72
+ async isConnected(): Promise<boolean> {
73
+ try {
74
+ const output = await this.exec(["get-state"], 5_000);
75
+ this.connected = output.trim() === "device";
76
+ return this.connected;
77
+ } catch {
78
+ this.connected = false;
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Retrieve structured information about the connected device.
85
+ */
86
+ async getDeviceInfo(): Promise<DeviceInfo> {
87
+ const prop = async (key: string): Promise<string> => {
88
+ try {
89
+ return (await this.shell(`getprop ${key}`)).trim();
90
+ } catch {
91
+ return "unknown";
92
+ }
93
+ };
94
+
95
+ const screenSize = await (async () => {
96
+ try {
97
+ const raw = await this.shell("wm size");
98
+ const match = raw.match(/(\d+x\d+)/);
99
+ return match ? match[1] : "unknown";
100
+ } catch {
101
+ return "unknown";
102
+ }
103
+ })();
104
+
105
+ const density = await (async () => {
106
+ try {
107
+ const raw = await this.shell("wm density");
108
+ const match = raw.match(/(\d+)/);
109
+ return match ? match[1] : "unknown";
110
+ } catch {
111
+ return "unknown";
112
+ }
113
+ })();
114
+
115
+ return {
116
+ serial: this.device,
117
+ model: await prop("ro.product.model"),
118
+ androidVersion: await prop("ro.build.version.release"),
119
+ sdkVersion: await prop("ro.build.version.sdk"),
120
+ screenSize,
121
+ density,
122
+ };
123
+ }
124
+
125
+ /** Pull a file from the device to a local path. */
126
+ async pull(remotePath: string, localPath: string): Promise<string> {
127
+ return this.exec(["pull", remotePath, localPath]);
128
+ }
129
+
130
+ /** Forward a TCP port from host to device. */
131
+ async forward(hostPort: number, devicePort: number): Promise<void> {
132
+ await this.exec(["forward", `tcp:${hostPort}`, `tcp:${devicePort}`]);
133
+ }
134
+
135
+ /** Get the cached connection state (does not ping the device). */
136
+ get isDeviceConnected(): boolean {
137
+ return this.connected;
138
+ }
139
+ }