react-native-ai-devtools 1.1.4
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/LICENSE +32 -0
- package/README.md +1250 -0
- package/build/__tests__/helpers/fake-cdp-server.d.ts +56 -0
- package/build/__tests__/helpers/fake-cdp-server.d.ts.map +1 -0
- package/build/__tests__/helpers/fake-cdp-server.js +108 -0
- package/build/__tests__/helpers/fake-cdp-server.js.map +1 -0
- package/build/__tests__/integration/connection-health.test.d.ts +2 -0
- package/build/__tests__/integration/connection-health.test.d.ts.map +1 -0
- package/build/__tests__/integration/connection-health.test.js +151 -0
- package/build/__tests__/integration/connection-health.test.js.map +1 -0
- package/build/__tests__/integration/execute-in-app.test.d.ts +2 -0
- package/build/__tests__/integration/execute-in-app.test.d.ts.map +1 -0
- package/build/__tests__/integration/execute-in-app.test.js +115 -0
- package/build/__tests__/integration/execute-in-app.test.js.map +1 -0
- package/build/__tests__/integration/tools.test.d.ts +2 -0
- package/build/__tests__/integration/tools.test.d.ts.map +1 -0
- package/build/__tests__/integration/tools.test.js +228 -0
- package/build/__tests__/integration/tools.test.js.map +1 -0
- package/build/__tests__/setup.d.ts +2 -0
- package/build/__tests__/setup.d.ts.map +1 -0
- package/build/__tests__/setup.js +11 -0
- package/build/__tests__/setup.js.map +1 -0
- package/build/__tests__/unit/bundle.test.d.ts +2 -0
- package/build/__tests__/unit/bundle.test.d.ts.map +1 -0
- package/build/__tests__/unit/bundle.test.js +53 -0
- package/build/__tests__/unit/bundle.test.js.map +1 -0
- package/build/__tests__/unit/connection-health.test.d.ts +2 -0
- package/build/__tests__/unit/connection-health.test.d.ts.map +1 -0
- package/build/__tests__/unit/connection-health.test.js +28 -0
- package/build/__tests__/unit/connection-health.test.js.map +1 -0
- package/build/__tests__/unit/executor.test.d.ts +2 -0
- package/build/__tests__/unit/executor.test.d.ts.map +1 -0
- package/build/__tests__/unit/executor.test.js +79 -0
- package/build/__tests__/unit/executor.test.js.map +1 -0
- package/build/__tests__/unit/logs.test.d.ts +2 -0
- package/build/__tests__/unit/logs.test.d.ts.map +1 -0
- package/build/__tests__/unit/logs.test.js +81 -0
- package/build/__tests__/unit/logs.test.js.map +1 -0
- package/build/__tests__/unit/metro.test.d.ts +2 -0
- package/build/__tests__/unit/metro.test.d.ts.map +1 -0
- package/build/__tests__/unit/metro.test.js +61 -0
- package/build/__tests__/unit/metro.test.js.map +1 -0
- package/build/__tests__/unit/network.test.d.ts +2 -0
- package/build/__tests__/unit/network.test.d.ts.map +1 -0
- package/build/__tests__/unit/network.test.js +102 -0
- package/build/__tests__/unit/network.test.js.map +1 -0
- package/build/__tests__/unit/tap.test.d.ts +2 -0
- package/build/__tests__/unit/tap.test.d.ts.map +1 -0
- package/build/__tests__/unit/tap.test.js +157 -0
- package/build/__tests__/unit/tap.test.js.map +1 -0
- package/build/core/android.d.ts +265 -0
- package/build/core/android.d.ts.map +1 -0
- package/build/core/android.js +1413 -0
- package/build/core/android.js.map +1 -0
- package/build/core/bundle.d.ts +49 -0
- package/build/core/bundle.d.ts.map +1 -0
- package/build/core/bundle.js +368 -0
- package/build/core/bundle.js.map +1 -0
- package/build/core/connection.d.ts +43 -0
- package/build/core/connection.d.ts.map +1 -0
- package/build/core/connection.js +963 -0
- package/build/core/connection.js.map +1 -0
- package/build/core/connectionState.d.ts +108 -0
- package/build/core/connectionState.d.ts.map +1 -0
- package/build/core/connectionState.js +284 -0
- package/build/core/connectionState.js.map +1 -0
- package/build/core/errorScreenParser.d.ts +30 -0
- package/build/core/errorScreenParser.d.ts.map +1 -0
- package/build/core/errorScreenParser.js +198 -0
- package/build/core/errorScreenParser.js.map +1 -0
- package/build/core/executor.d.ts +113 -0
- package/build/core/executor.d.ts.map +1 -0
- package/build/core/executor.js +1877 -0
- package/build/core/executor.js.map +1 -0
- package/build/core/format.d.ts +8 -0
- package/build/core/format.d.ts.map +1 -0
- package/build/core/format.js +34 -0
- package/build/core/format.js.map +1 -0
- package/build/core/guides.d.ts +14 -0
- package/build/core/guides.d.ts.map +1 -0
- package/build/core/guides.js +261 -0
- package/build/core/guides.js.map +1 -0
- package/build/core/httpServer.d.ts +14 -0
- package/build/core/httpServer.d.ts.map +1 -0
- package/build/core/httpServer.js +2459 -0
- package/build/core/httpServer.js.map +1 -0
- package/build/core/httpServerProcess.d.ts +25 -0
- package/build/core/httpServerProcess.d.ts.map +1 -0
- package/build/core/httpServerProcess.js +153 -0
- package/build/core/httpServerProcess.js.map +1 -0
- package/build/core/index.d.ts +25 -0
- package/build/core/index.d.ts.map +1 -0
- package/build/core/index.js +53 -0
- package/build/core/index.js.map +1 -0
- package/build/core/ios.d.ts +214 -0
- package/build/core/ios.d.ts.map +1 -0
- package/build/core/ios.js +1232 -0
- package/build/core/ios.js.map +1 -0
- package/build/core/logs.d.ts +43 -0
- package/build/core/logs.d.ts.map +1 -0
- package/build/core/logs.js +144 -0
- package/build/core/logs.js.map +1 -0
- package/build/core/metro.d.ts +23 -0
- package/build/core/metro.d.ts.map +1 -0
- package/build/core/metro.js +96 -0
- package/build/core/metro.js.map +1 -0
- package/build/core/network.d.ts +43 -0
- package/build/core/network.d.ts.map +1 -0
- package/build/core/network.js +217 -0
- package/build/core/network.js.map +1 -0
- package/build/core/networkInterceptor.d.ts +3 -0
- package/build/core/networkInterceptor.d.ts.map +1 -0
- package/build/core/networkInterceptor.js +203 -0
- package/build/core/networkInterceptor.js.map +1 -0
- package/build/core/ocr.d.ts +69 -0
- package/build/core/ocr.d.ts.map +1 -0
- package/build/core/ocr.js +212 -0
- package/build/core/ocr.js.map +1 -0
- package/build/core/state.d.ts +17 -0
- package/build/core/state.d.ts.map +1 -0
- package/build/core/state.js +50 -0
- package/build/core/state.js.map +1 -0
- package/build/core/tap.d.ts +91 -0
- package/build/core/tap.d.ts.map +1 -0
- package/build/core/tap.js +542 -0
- package/build/core/tap.js.map +1 -0
- package/build/core/telemetry.d.ts +4 -0
- package/build/core/telemetry.d.ts.map +1 -0
- package/build/core/telemetry.js +289 -0
- package/build/core/telemetry.js.map +1 -0
- package/build/core/types.d.ts +134 -0
- package/build/core/types.d.ts.map +1 -0
- package/build/core/types.js +2 -0
- package/build/core/types.js.map +1 -0
- package/build/httpServerStandalone.d.ts +7 -0
- package/build/httpServerStandalone.d.ts.map +1 -0
- package/build/httpServerStandalone.js +31 -0
- package/build/httpServerStandalone.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3012 -0
- package/build/index.js.map +1 -0
- package/build/pro/tap.d.ts +91 -0
- package/build/pro/tap.d.ts.map +1 -0
- package/build/pro/tap.js +542 -0
- package/build/pro/tap.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
import { exec } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
// XML parsing for uiautomator dump
|
|
9
|
+
import { XMLParser } from "fast-xml-parser";
|
|
10
|
+
// ADB command timeout in milliseconds
|
|
11
|
+
const ADB_TIMEOUT = 30000;
|
|
12
|
+
/**
|
|
13
|
+
* Check if ADB is available in PATH
|
|
14
|
+
*/
|
|
15
|
+
export async function isAdbAvailable() {
|
|
16
|
+
try {
|
|
17
|
+
await execAsync("adb version", { timeout: 5000 });
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* List connected Android devices
|
|
26
|
+
*/
|
|
27
|
+
export async function listAndroidDevices() {
|
|
28
|
+
try {
|
|
29
|
+
const adbAvailable = await isAdbAvailable();
|
|
30
|
+
if (!adbAvailable) {
|
|
31
|
+
return {
|
|
32
|
+
success: false,
|
|
33
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const { stdout } = await execAsync("adb devices -l", { timeout: ADB_TIMEOUT });
|
|
37
|
+
const lines = stdout.trim().split("\n");
|
|
38
|
+
// Skip the "List of devices attached" header
|
|
39
|
+
const deviceLines = lines.slice(1).filter((line) => line.trim().length > 0);
|
|
40
|
+
if (deviceLines.length === 0) {
|
|
41
|
+
return {
|
|
42
|
+
success: true,
|
|
43
|
+
result: "No Android devices connected."
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const devices = deviceLines.map((line) => {
|
|
47
|
+
const parts = line.trim().split(/\s+/);
|
|
48
|
+
const id = parts[0];
|
|
49
|
+
const status = parts[1];
|
|
50
|
+
const device = { id, status };
|
|
51
|
+
// Parse additional info like product:xxx model:xxx device:xxx transport_id:xxx
|
|
52
|
+
for (let i = 2; i < parts.length; i++) {
|
|
53
|
+
const [key, value] = parts[i].split(":");
|
|
54
|
+
if (key === "product")
|
|
55
|
+
device.product = value;
|
|
56
|
+
else if (key === "model")
|
|
57
|
+
device.model = value;
|
|
58
|
+
else if (key === "device")
|
|
59
|
+
device.device = value;
|
|
60
|
+
else if (key === "transport_id")
|
|
61
|
+
device.transportId = value;
|
|
62
|
+
}
|
|
63
|
+
return device;
|
|
64
|
+
});
|
|
65
|
+
const formatted = devices
|
|
66
|
+
.map((d) => {
|
|
67
|
+
let info = `${d.id} (${d.status})`;
|
|
68
|
+
if (d.model)
|
|
69
|
+
info += ` - ${d.model.replace(/_/g, " ")}`;
|
|
70
|
+
if (d.product)
|
|
71
|
+
info += ` [${d.product}]`;
|
|
72
|
+
return info;
|
|
73
|
+
})
|
|
74
|
+
.join("\n");
|
|
75
|
+
return {
|
|
76
|
+
success: true,
|
|
77
|
+
result: `Connected Android devices:\n${formatted}`,
|
|
78
|
+
devices
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return {
|
|
83
|
+
success: false,
|
|
84
|
+
error: `Failed to list devices: ${error instanceof Error ? error.message : String(error)}`
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Get the first connected Android device ID
|
|
90
|
+
*/
|
|
91
|
+
export async function getDefaultAndroidDevice() {
|
|
92
|
+
try {
|
|
93
|
+
const { stdout } = await execAsync("adb devices", { timeout: ADB_TIMEOUT });
|
|
94
|
+
const lines = stdout.trim().split("\n");
|
|
95
|
+
const deviceLines = lines.slice(1).filter((line) => line.trim().length > 0);
|
|
96
|
+
for (const line of deviceLines) {
|
|
97
|
+
const [id, status] = line.trim().split(/\s+/);
|
|
98
|
+
if (status === "device") {
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Build device selector for ADB command
|
|
110
|
+
*/
|
|
111
|
+
function buildDeviceArg(deviceId) {
|
|
112
|
+
return deviceId ? `-s ${deviceId}` : "";
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Take a screenshot from an Android device
|
|
116
|
+
*/
|
|
117
|
+
export async function androidScreenshot(outputPath, deviceId) {
|
|
118
|
+
try {
|
|
119
|
+
const adbAvailable = await isAdbAvailable();
|
|
120
|
+
if (!adbAvailable) {
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
127
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
128
|
+
if (!device) {
|
|
129
|
+
return {
|
|
130
|
+
success: false,
|
|
131
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Generate output path if not provided
|
|
135
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
136
|
+
const finalOutputPath = outputPath || path.join(os.tmpdir(), `android-screenshot-${timestamp}.png`);
|
|
137
|
+
// Capture screenshot on device
|
|
138
|
+
const remotePath = "/sdcard/screenshot-temp.png";
|
|
139
|
+
await execAsync(`adb ${deviceArg} shell screencap -p ${remotePath}`, {
|
|
140
|
+
timeout: ADB_TIMEOUT
|
|
141
|
+
});
|
|
142
|
+
// Pull screenshot to local machine
|
|
143
|
+
await execAsync(`adb ${deviceArg} pull ${remotePath} "${finalOutputPath}"`, {
|
|
144
|
+
timeout: ADB_TIMEOUT
|
|
145
|
+
});
|
|
146
|
+
// Clean up remote file
|
|
147
|
+
await execAsync(`adb ${deviceArg} shell rm ${remotePath}`, {
|
|
148
|
+
timeout: ADB_TIMEOUT
|
|
149
|
+
}).catch(() => {
|
|
150
|
+
// Ignore cleanup errors
|
|
151
|
+
});
|
|
152
|
+
// Resize image if needed (API limit: 2000px max for multi-image requests)
|
|
153
|
+
// Return scale factor so AI can convert image coords to device coords
|
|
154
|
+
const MAX_DIMENSION = 2000;
|
|
155
|
+
const image = sharp(finalOutputPath);
|
|
156
|
+
const metadata = await image.metadata();
|
|
157
|
+
const originalWidth = metadata.width || 0;
|
|
158
|
+
const originalHeight = metadata.height || 0;
|
|
159
|
+
let imageData;
|
|
160
|
+
let scaleFactor = 1;
|
|
161
|
+
if (originalWidth > MAX_DIMENSION || originalHeight > MAX_DIMENSION) {
|
|
162
|
+
// Calculate scale to fit within MAX_DIMENSION
|
|
163
|
+
scaleFactor = Math.max(originalWidth, originalHeight) / MAX_DIMENSION;
|
|
164
|
+
imageData = await image
|
|
165
|
+
.resize(MAX_DIMENSION, MAX_DIMENSION, {
|
|
166
|
+
fit: "inside",
|
|
167
|
+
withoutEnlargement: true
|
|
168
|
+
})
|
|
169
|
+
.jpeg({ quality: 85 })
|
|
170
|
+
.toBuffer();
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
imageData = await image
|
|
174
|
+
.jpeg({ quality: 85 })
|
|
175
|
+
.toBuffer();
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
success: true,
|
|
179
|
+
result: finalOutputPath,
|
|
180
|
+
data: imageData,
|
|
181
|
+
scaleFactor,
|
|
182
|
+
originalWidth,
|
|
183
|
+
originalHeight
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
error: `Failed to capture screenshot: ${error instanceof Error ? error.message : String(error)}`
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Install an APK on an Android device
|
|
195
|
+
*/
|
|
196
|
+
export async function androidInstallApp(apkPath, deviceId, options) {
|
|
197
|
+
try {
|
|
198
|
+
const adbAvailable = await isAdbAvailable();
|
|
199
|
+
if (!adbAvailable) {
|
|
200
|
+
return {
|
|
201
|
+
success: false,
|
|
202
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// Verify APK exists
|
|
206
|
+
if (!existsSync(apkPath)) {
|
|
207
|
+
return {
|
|
208
|
+
success: false,
|
|
209
|
+
error: `APK file not found: ${apkPath}`
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
213
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
214
|
+
if (!device) {
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// Build install flags
|
|
221
|
+
const flags = [];
|
|
222
|
+
if (options?.replace)
|
|
223
|
+
flags.push("-r");
|
|
224
|
+
if (options?.grantPermissions)
|
|
225
|
+
flags.push("-g");
|
|
226
|
+
const flagsStr = flags.length > 0 ? flags.join(" ") + " " : "";
|
|
227
|
+
const { stdout, stderr } = await execAsync(`adb ${deviceArg} install ${flagsStr}"${apkPath}"`, { timeout: 120000 } // 2 minute timeout for install
|
|
228
|
+
);
|
|
229
|
+
const output = stdout + stderr;
|
|
230
|
+
if (output.includes("Success")) {
|
|
231
|
+
return {
|
|
232
|
+
success: true,
|
|
233
|
+
result: `Successfully installed ${path.basename(apkPath)}`
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
error: output.trim() || "Installation failed with unknown error"
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
return {
|
|
245
|
+
success: false,
|
|
246
|
+
error: `Failed to install app: ${error instanceof Error ? error.message : String(error)}`
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Launch an app on an Android device
|
|
252
|
+
*/
|
|
253
|
+
export async function androidLaunchApp(packageName, activityName, deviceId) {
|
|
254
|
+
try {
|
|
255
|
+
const adbAvailable = await isAdbAvailable();
|
|
256
|
+
if (!adbAvailable) {
|
|
257
|
+
return {
|
|
258
|
+
success: false,
|
|
259
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
263
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
264
|
+
if (!device) {
|
|
265
|
+
return {
|
|
266
|
+
success: false,
|
|
267
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
let command;
|
|
271
|
+
if (activityName) {
|
|
272
|
+
// Launch specific activity
|
|
273
|
+
command = `adb ${deviceArg} shell am start -n ${packageName}/${activityName}`;
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Launch main/launcher activity
|
|
277
|
+
command = `adb ${deviceArg} shell monkey -p ${packageName} -c android.intent.category.LAUNCHER 1`;
|
|
278
|
+
}
|
|
279
|
+
const { stdout, stderr } = await execAsync(command, { timeout: ADB_TIMEOUT });
|
|
280
|
+
const output = stdout + stderr;
|
|
281
|
+
// Check for errors
|
|
282
|
+
if (output.includes("Error") || output.includes("Exception")) {
|
|
283
|
+
return {
|
|
284
|
+
success: false,
|
|
285
|
+
error: output.trim()
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
success: true,
|
|
290
|
+
result: `Launched ${packageName}${activityName ? `/${activityName}` : ""}`
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
return {
|
|
295
|
+
success: false,
|
|
296
|
+
error: `Failed to launch app: ${error instanceof Error ? error.message : String(error)}`
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Get list of installed packages on the device
|
|
302
|
+
*/
|
|
303
|
+
export async function androidListPackages(deviceId, filter) {
|
|
304
|
+
try {
|
|
305
|
+
const adbAvailable = await isAdbAvailable();
|
|
306
|
+
if (!adbAvailable) {
|
|
307
|
+
return {
|
|
308
|
+
success: false,
|
|
309
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
313
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
314
|
+
if (!device) {
|
|
315
|
+
return {
|
|
316
|
+
success: false,
|
|
317
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const { stdout } = await execAsync(`adb ${deviceArg} shell pm list packages`, {
|
|
321
|
+
timeout: ADB_TIMEOUT
|
|
322
|
+
});
|
|
323
|
+
let packages = stdout
|
|
324
|
+
.trim()
|
|
325
|
+
.split("\n")
|
|
326
|
+
.map((line) => line.replace("package:", "").trim())
|
|
327
|
+
.filter((pkg) => pkg.length > 0);
|
|
328
|
+
if (filter) {
|
|
329
|
+
const filterLower = filter.toLowerCase();
|
|
330
|
+
packages = packages.filter((pkg) => pkg.toLowerCase().includes(filterLower));
|
|
331
|
+
}
|
|
332
|
+
if (packages.length === 0) {
|
|
333
|
+
return {
|
|
334
|
+
success: true,
|
|
335
|
+
result: filter ? `No packages found matching "${filter}"` : "No packages found"
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
success: true,
|
|
340
|
+
result: `Installed packages${filter ? ` matching "${filter}"` : ""}:\n${packages.join("\n")}`
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
return {
|
|
345
|
+
success: false,
|
|
346
|
+
error: `Failed to list packages: ${error instanceof Error ? error.message : String(error)}`
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// UI Input Functions (Phase 2)
|
|
352
|
+
// ============================================================================
|
|
353
|
+
/**
|
|
354
|
+
* Common key event codes for Android
|
|
355
|
+
*/
|
|
356
|
+
export const ANDROID_KEY_EVENTS = {
|
|
357
|
+
HOME: 3,
|
|
358
|
+
BACK: 4,
|
|
359
|
+
CALL: 5,
|
|
360
|
+
END_CALL: 6,
|
|
361
|
+
VOLUME_UP: 24,
|
|
362
|
+
VOLUME_DOWN: 25,
|
|
363
|
+
POWER: 26,
|
|
364
|
+
CAMERA: 27,
|
|
365
|
+
CLEAR: 28,
|
|
366
|
+
TAB: 61,
|
|
367
|
+
ENTER: 66,
|
|
368
|
+
DEL: 67,
|
|
369
|
+
MENU: 82,
|
|
370
|
+
SEARCH: 84,
|
|
371
|
+
MEDIA_PLAY_PAUSE: 85,
|
|
372
|
+
MEDIA_STOP: 86,
|
|
373
|
+
MEDIA_NEXT: 87,
|
|
374
|
+
MEDIA_PREVIOUS: 88,
|
|
375
|
+
MOVE_HOME: 122,
|
|
376
|
+
MOVE_END: 123,
|
|
377
|
+
APP_SWITCH: 187,
|
|
378
|
+
ESCAPE: 111
|
|
379
|
+
};
|
|
380
|
+
/**
|
|
381
|
+
* Tap at coordinates on an Android device
|
|
382
|
+
*/
|
|
383
|
+
export async function androidTap(x, y, deviceId) {
|
|
384
|
+
try {
|
|
385
|
+
const adbAvailable = await isAdbAvailable();
|
|
386
|
+
if (!adbAvailable) {
|
|
387
|
+
return {
|
|
388
|
+
success: false,
|
|
389
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
393
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
394
|
+
if (!device) {
|
|
395
|
+
return {
|
|
396
|
+
success: false,
|
|
397
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
await execAsync(`adb ${deviceArg} shell input tap ${Math.round(x)} ${Math.round(y)}`, {
|
|
401
|
+
timeout: ADB_TIMEOUT
|
|
402
|
+
});
|
|
403
|
+
return {
|
|
404
|
+
success: true,
|
|
405
|
+
result: `Tapped at (${Math.round(x)}, ${Math.round(y)})`
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
return {
|
|
410
|
+
success: false,
|
|
411
|
+
error: `Failed to tap: ${error instanceof Error ? error.message : String(error)}`
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Long press at coordinates on an Android device
|
|
417
|
+
*/
|
|
418
|
+
export async function androidLongPress(x, y, durationMs = 1000, deviceId) {
|
|
419
|
+
try {
|
|
420
|
+
const adbAvailable = await isAdbAvailable();
|
|
421
|
+
if (!adbAvailable) {
|
|
422
|
+
return {
|
|
423
|
+
success: false,
|
|
424
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
428
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
429
|
+
if (!device) {
|
|
430
|
+
return {
|
|
431
|
+
success: false,
|
|
432
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
// Long press is implemented as a swipe from the same point to the same point
|
|
436
|
+
const xRounded = Math.round(x);
|
|
437
|
+
const yRounded = Math.round(y);
|
|
438
|
+
await execAsync(`adb ${deviceArg} shell input swipe ${xRounded} ${yRounded} ${xRounded} ${yRounded} ${durationMs}`, { timeout: ADB_TIMEOUT + durationMs });
|
|
439
|
+
return {
|
|
440
|
+
success: true,
|
|
441
|
+
result: `Long pressed at (${xRounded}, ${yRounded}) for ${durationMs}ms`
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
return {
|
|
446
|
+
success: false,
|
|
447
|
+
error: `Failed to long press: ${error instanceof Error ? error.message : String(error)}`
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Swipe on an Android device
|
|
453
|
+
*/
|
|
454
|
+
export async function androidSwipe(startX, startY, endX, endY, durationMs = 300, deviceId) {
|
|
455
|
+
try {
|
|
456
|
+
const adbAvailable = await isAdbAvailable();
|
|
457
|
+
if (!adbAvailable) {
|
|
458
|
+
return {
|
|
459
|
+
success: false,
|
|
460
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
464
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
465
|
+
if (!device) {
|
|
466
|
+
return {
|
|
467
|
+
success: false,
|
|
468
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
const x1 = Math.round(startX);
|
|
472
|
+
const y1 = Math.round(startY);
|
|
473
|
+
const x2 = Math.round(endX);
|
|
474
|
+
const y2 = Math.round(endY);
|
|
475
|
+
await execAsync(`adb ${deviceArg} shell input swipe ${x1} ${y1} ${x2} ${y2} ${durationMs}`, { timeout: ADB_TIMEOUT + durationMs });
|
|
476
|
+
return {
|
|
477
|
+
success: true,
|
|
478
|
+
result: `Swiped from (${x1}, ${y1}) to (${x2}, ${y2}) in ${durationMs}ms`
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
return {
|
|
483
|
+
success: false,
|
|
484
|
+
error: `Failed to swipe: ${error instanceof Error ? error.message : String(error)}`
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Input text on an Android device
|
|
490
|
+
*
|
|
491
|
+
* ADB input text has limitations with special characters.
|
|
492
|
+
* This function handles escaping properly for URLs, emails, and special strings.
|
|
493
|
+
*/
|
|
494
|
+
export async function androidInputText(text, deviceId) {
|
|
495
|
+
try {
|
|
496
|
+
const adbAvailable = await isAdbAvailable();
|
|
497
|
+
if (!adbAvailable) {
|
|
498
|
+
return {
|
|
499
|
+
success: false,
|
|
500
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
504
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
505
|
+
if (!device) {
|
|
506
|
+
return {
|
|
507
|
+
success: false,
|
|
508
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
// For complex strings with special characters, type character by character
|
|
512
|
+
// using key events for reliability
|
|
513
|
+
const hasComplexChars = /[/:?=&#@%+]/.test(text);
|
|
514
|
+
if (hasComplexChars) {
|
|
515
|
+
// Use character-by-character input for strings with special chars
|
|
516
|
+
// This is slower but more reliable for URLs, emails, etc.
|
|
517
|
+
for (const char of text) {
|
|
518
|
+
let keyCmd;
|
|
519
|
+
// Map special characters to their escaped form or use direct input
|
|
520
|
+
switch (char) {
|
|
521
|
+
case " ":
|
|
522
|
+
keyCmd = `adb ${deviceArg} shell input text "%s"`;
|
|
523
|
+
break;
|
|
524
|
+
case "'":
|
|
525
|
+
// Single quote needs special handling
|
|
526
|
+
keyCmd = `adb ${deviceArg} shell input text "\\'"`;
|
|
527
|
+
break;
|
|
528
|
+
case '"':
|
|
529
|
+
keyCmd = `adb ${deviceArg} shell input text '\\"'`;
|
|
530
|
+
break;
|
|
531
|
+
case "\\":
|
|
532
|
+
keyCmd = `adb ${deviceArg} shell input text "\\\\"`;
|
|
533
|
+
break;
|
|
534
|
+
case "&":
|
|
535
|
+
keyCmd = `adb ${deviceArg} shell input text "\\&"`;
|
|
536
|
+
break;
|
|
537
|
+
case "|":
|
|
538
|
+
keyCmd = `adb ${deviceArg} shell input text "\\|"`;
|
|
539
|
+
break;
|
|
540
|
+
case ";":
|
|
541
|
+
keyCmd = `adb ${deviceArg} shell input text "\\;"`;
|
|
542
|
+
break;
|
|
543
|
+
case "<":
|
|
544
|
+
keyCmd = `adb ${deviceArg} shell input text "\\<"`;
|
|
545
|
+
break;
|
|
546
|
+
case ">":
|
|
547
|
+
keyCmd = `adb ${deviceArg} shell input text "\\>"`;
|
|
548
|
+
break;
|
|
549
|
+
case "(":
|
|
550
|
+
keyCmd = `adb ${deviceArg} shell input text "\\("`;
|
|
551
|
+
break;
|
|
552
|
+
case ")":
|
|
553
|
+
keyCmd = `adb ${deviceArg} shell input text "\\)"`;
|
|
554
|
+
break;
|
|
555
|
+
case "$":
|
|
556
|
+
keyCmd = `adb ${deviceArg} shell input text "\\$"`;
|
|
557
|
+
break;
|
|
558
|
+
case "`":
|
|
559
|
+
keyCmd = `adb ${deviceArg} shell input text "\\\`"`;
|
|
560
|
+
break;
|
|
561
|
+
default:
|
|
562
|
+
// For most characters, wrap in single quotes to prevent shell interpretation
|
|
563
|
+
// Single quotes preserve literal meaning of all characters except single quote itself
|
|
564
|
+
keyCmd = `adb ${deviceArg} shell input text '${char}'`;
|
|
565
|
+
}
|
|
566
|
+
await execAsync(keyCmd, { timeout: 5000 });
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
success: true,
|
|
570
|
+
result: `Typed: "${text}"`
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
// For simple alphanumeric strings, use the faster bulk input
|
|
574
|
+
// Escape basic special characters
|
|
575
|
+
const escapedText = text
|
|
576
|
+
.replace(/\\/g, "\\\\")
|
|
577
|
+
.replace(/"/g, '\\"')
|
|
578
|
+
.replace(/'/g, "\\'")
|
|
579
|
+
.replace(/`/g, "\\`")
|
|
580
|
+
.replace(/\$/g, "\\$")
|
|
581
|
+
.replace(/ /g, "%s");
|
|
582
|
+
await execAsync(`adb ${deviceArg} shell input text "${escapedText}"`, {
|
|
583
|
+
timeout: ADB_TIMEOUT
|
|
584
|
+
});
|
|
585
|
+
return {
|
|
586
|
+
success: true,
|
|
587
|
+
result: `Typed: "${text}"`
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
return {
|
|
592
|
+
success: false,
|
|
593
|
+
error: `Failed to input text: ${error instanceof Error ? error.message : String(error)}`
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Send a key event to an Android device
|
|
599
|
+
*/
|
|
600
|
+
export async function androidKeyEvent(keyCode, deviceId) {
|
|
601
|
+
try {
|
|
602
|
+
const adbAvailable = await isAdbAvailable();
|
|
603
|
+
if (!adbAvailable) {
|
|
604
|
+
return {
|
|
605
|
+
success: false,
|
|
606
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
610
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
611
|
+
if (!device) {
|
|
612
|
+
return {
|
|
613
|
+
success: false,
|
|
614
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
// Resolve key code from name if needed
|
|
618
|
+
const resolvedKeyCode = typeof keyCode === "string" ? ANDROID_KEY_EVENTS[keyCode] : keyCode;
|
|
619
|
+
if (resolvedKeyCode === undefined) {
|
|
620
|
+
return {
|
|
621
|
+
success: false,
|
|
622
|
+
error: `Invalid key code: ${keyCode}`
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
await execAsync(`adb ${deviceArg} shell input keyevent ${resolvedKeyCode}`, {
|
|
626
|
+
timeout: ADB_TIMEOUT
|
|
627
|
+
});
|
|
628
|
+
// Get key name for display
|
|
629
|
+
const keyName = typeof keyCode === "string"
|
|
630
|
+
? keyCode
|
|
631
|
+
: Object.entries(ANDROID_KEY_EVENTS).find(([_, v]) => v === keyCode)?.[0] ||
|
|
632
|
+
`keycode ${keyCode}`;
|
|
633
|
+
return {
|
|
634
|
+
success: true,
|
|
635
|
+
result: `Sent key event: ${keyName}`
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
return {
|
|
640
|
+
success: false,
|
|
641
|
+
error: `Failed to send key event: ${error instanceof Error ? error.message : String(error)}`
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Parse bounds string like "[0,0][1080,1920]" to AndroidUIElement bounds
|
|
647
|
+
*/
|
|
648
|
+
function parseBoundsForUIElement(boundsStr) {
|
|
649
|
+
const match = boundsStr.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
650
|
+
if (!match)
|
|
651
|
+
return null;
|
|
652
|
+
const left = parseInt(match[1], 10);
|
|
653
|
+
const top = parseInt(match[2], 10);
|
|
654
|
+
const right = parseInt(match[3], 10);
|
|
655
|
+
const bottom = parseInt(match[4], 10);
|
|
656
|
+
return {
|
|
657
|
+
left,
|
|
658
|
+
top,
|
|
659
|
+
right,
|
|
660
|
+
bottom,
|
|
661
|
+
width: right - left,
|
|
662
|
+
height: bottom - top
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Parse uiautomator XML dump into element array
|
|
667
|
+
*/
|
|
668
|
+
function parseUIAutomatorXML(xml) {
|
|
669
|
+
const elements = [];
|
|
670
|
+
// Match all node elements with their attributes
|
|
671
|
+
const nodeRegex = /<node\s+([^>]+)\/?>|<node\s+([^>]+)>/g;
|
|
672
|
+
let match;
|
|
673
|
+
while ((match = nodeRegex.exec(xml)) !== null) {
|
|
674
|
+
const attrStr = match[1] || match[2];
|
|
675
|
+
if (!attrStr)
|
|
676
|
+
continue;
|
|
677
|
+
// Extract attributes
|
|
678
|
+
const getAttr = (name) => {
|
|
679
|
+
const attrMatch = attrStr.match(new RegExp(`${name}="([^"]*)"`));
|
|
680
|
+
return attrMatch ? attrMatch[1] : "";
|
|
681
|
+
};
|
|
682
|
+
const boundsStr = getAttr("bounds");
|
|
683
|
+
const bounds = parseBoundsForUIElement(boundsStr);
|
|
684
|
+
if (!bounds)
|
|
685
|
+
continue;
|
|
686
|
+
const element = {
|
|
687
|
+
text: getAttr("text"),
|
|
688
|
+
contentDesc: getAttr("content-desc"),
|
|
689
|
+
resourceId: getAttr("resource-id"),
|
|
690
|
+
className: getAttr("class"),
|
|
691
|
+
bounds,
|
|
692
|
+
center: {
|
|
693
|
+
x: Math.round((bounds.left + bounds.right) / 2),
|
|
694
|
+
y: Math.round((bounds.top + bounds.bottom) / 2)
|
|
695
|
+
},
|
|
696
|
+
clickable: getAttr("clickable") === "true",
|
|
697
|
+
enabled: getAttr("enabled") === "true",
|
|
698
|
+
focused: getAttr("focused") === "true",
|
|
699
|
+
scrollable: getAttr("scrollable") === "true",
|
|
700
|
+
selected: getAttr("selected") === "true"
|
|
701
|
+
};
|
|
702
|
+
elements.push(element);
|
|
703
|
+
}
|
|
704
|
+
return elements;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Match element against find options
|
|
708
|
+
*/
|
|
709
|
+
function matchesElement(element, options) {
|
|
710
|
+
if (options.text !== undefined) {
|
|
711
|
+
if (element.text !== options.text)
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
if (options.textContains !== undefined) {
|
|
715
|
+
if (!element.text.toLowerCase().includes(options.textContains.toLowerCase()))
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
if (options.contentDesc !== undefined) {
|
|
719
|
+
if (element.contentDesc !== options.contentDesc)
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
if (options.contentDescContains !== undefined) {
|
|
723
|
+
if (!element.contentDesc.toLowerCase().includes(options.contentDescContains.toLowerCase()))
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
if (options.resourceId !== undefined) {
|
|
727
|
+
// Support both full "com.app:id/button" and short "button" forms
|
|
728
|
+
const shortId = element.resourceId.split("/").pop() || "";
|
|
729
|
+
if (element.resourceId !== options.resourceId && shortId !== options.resourceId)
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Get UI accessibility tree from Android device using uiautomator
|
|
736
|
+
*/
|
|
737
|
+
export async function androidGetUITree(deviceId) {
|
|
738
|
+
try {
|
|
739
|
+
const adbAvailable = await isAdbAvailable();
|
|
740
|
+
if (!adbAvailable) {
|
|
741
|
+
return {
|
|
742
|
+
success: false,
|
|
743
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
747
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
748
|
+
if (!device) {
|
|
749
|
+
return {
|
|
750
|
+
success: false,
|
|
751
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
// Dump UI hierarchy to device
|
|
755
|
+
const remotePath = "/sdcard/ui_dump.xml";
|
|
756
|
+
await execAsync(`adb ${deviceArg} shell uiautomator dump ${remotePath}`, {
|
|
757
|
+
timeout: ADB_TIMEOUT
|
|
758
|
+
});
|
|
759
|
+
// Read the XML content
|
|
760
|
+
const { stdout } = await execAsync(`adb ${deviceArg} shell cat ${remotePath}`, {
|
|
761
|
+
timeout: ADB_TIMEOUT
|
|
762
|
+
});
|
|
763
|
+
// Clean up remote file
|
|
764
|
+
await execAsync(`adb ${deviceArg} shell rm ${remotePath}`, {
|
|
765
|
+
timeout: ADB_TIMEOUT
|
|
766
|
+
}).catch(() => { });
|
|
767
|
+
const elements = parseUIAutomatorXML(stdout);
|
|
768
|
+
return {
|
|
769
|
+
success: true,
|
|
770
|
+
elements,
|
|
771
|
+
rawXml: stdout
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
return {
|
|
776
|
+
success: false,
|
|
777
|
+
error: `Failed to get UI tree: ${error instanceof Error ? error.message : String(error)}`
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Find element(s) in the UI tree matching the given criteria
|
|
783
|
+
*/
|
|
784
|
+
export async function androidFindElement(options, deviceId) {
|
|
785
|
+
try {
|
|
786
|
+
// Validate that at least one search criteria is provided
|
|
787
|
+
if (!options.text && !options.textContains && !options.contentDesc &&
|
|
788
|
+
!options.contentDescContains && !options.resourceId) {
|
|
789
|
+
return {
|
|
790
|
+
success: false,
|
|
791
|
+
found: false,
|
|
792
|
+
error: "At least one search criteria (text, textContains, contentDesc, contentDescContains, or resourceId) must be provided"
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
const treeResult = await androidGetUITree(deviceId);
|
|
796
|
+
if (!treeResult.success || !treeResult.elements) {
|
|
797
|
+
return {
|
|
798
|
+
success: false,
|
|
799
|
+
found: false,
|
|
800
|
+
error: treeResult.error
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
// Find matching elements
|
|
804
|
+
const matches = treeResult.elements.filter(el => matchesElement(el, options));
|
|
805
|
+
if (matches.length === 0) {
|
|
806
|
+
return {
|
|
807
|
+
success: true,
|
|
808
|
+
found: false,
|
|
809
|
+
matchCount: 0
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
// Select the element at the specified index (default 0)
|
|
813
|
+
const index = options.index ?? 0;
|
|
814
|
+
const selectedElement = matches[index];
|
|
815
|
+
if (!selectedElement) {
|
|
816
|
+
return {
|
|
817
|
+
success: true,
|
|
818
|
+
found: false,
|
|
819
|
+
matchCount: matches.length,
|
|
820
|
+
error: `Index ${index} out of bounds. Found ${matches.length} matching element(s).`
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
success: true,
|
|
825
|
+
found: true,
|
|
826
|
+
element: selectedElement,
|
|
827
|
+
allMatches: matches,
|
|
828
|
+
matchCount: matches.length
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
return {
|
|
833
|
+
success: false,
|
|
834
|
+
found: false,
|
|
835
|
+
error: `Failed to find element: ${error instanceof Error ? error.message : String(error)}`
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Wait for element to appear on screen with polling
|
|
841
|
+
*/
|
|
842
|
+
export async function androidWaitForElement(options, deviceId) {
|
|
843
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
844
|
+
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
845
|
+
const startTime = Date.now();
|
|
846
|
+
// Validate that at least one search criteria is provided
|
|
847
|
+
if (!options.text && !options.textContains && !options.contentDesc &&
|
|
848
|
+
!options.contentDescContains && !options.resourceId) {
|
|
849
|
+
return {
|
|
850
|
+
success: false,
|
|
851
|
+
found: false,
|
|
852
|
+
timedOut: false,
|
|
853
|
+
error: "At least one search criteria (text, textContains, contentDesc, contentDescContains, or resourceId) must be provided"
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
857
|
+
const result = await androidFindElement(options, deviceId);
|
|
858
|
+
if (result.found && result.element) {
|
|
859
|
+
return {
|
|
860
|
+
...result,
|
|
861
|
+
elapsedMs: Date.now() - startTime,
|
|
862
|
+
timedOut: false
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
// If there was an error (not just "not found"), return it
|
|
866
|
+
if (!result.success) {
|
|
867
|
+
return {
|
|
868
|
+
...result,
|
|
869
|
+
elapsedMs: Date.now() - startTime,
|
|
870
|
+
timedOut: false
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
// Wait before next poll
|
|
874
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
success: true,
|
|
878
|
+
found: false,
|
|
879
|
+
elapsedMs: Date.now() - startTime,
|
|
880
|
+
timedOut: true,
|
|
881
|
+
error: `Timed out after ${timeoutMs}ms waiting for element`
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Get device screen size
|
|
886
|
+
*/
|
|
887
|
+
export async function androidGetScreenSize(deviceId) {
|
|
888
|
+
try {
|
|
889
|
+
const adbAvailable = await isAdbAvailable();
|
|
890
|
+
if (!adbAvailable) {
|
|
891
|
+
return {
|
|
892
|
+
success: false,
|
|
893
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
897
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
898
|
+
if (!device) {
|
|
899
|
+
return {
|
|
900
|
+
success: false,
|
|
901
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
const { stdout } = await execAsync(`adb ${deviceArg} shell wm size`, {
|
|
905
|
+
timeout: ADB_TIMEOUT
|
|
906
|
+
});
|
|
907
|
+
// Parse output like "Physical size: 1080x1920"
|
|
908
|
+
const match = stdout.match(/(\d+)x(\d+)/);
|
|
909
|
+
if (match) {
|
|
910
|
+
return {
|
|
911
|
+
success: true,
|
|
912
|
+
width: parseInt(match[1], 10),
|
|
913
|
+
height: parseInt(match[2], 10)
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
return {
|
|
917
|
+
success: false,
|
|
918
|
+
error: `Could not parse screen size from: ${stdout.trim()}`
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
return {
|
|
923
|
+
success: false,
|
|
924
|
+
error: `Failed to get screen size: ${error instanceof Error ? error.message : String(error)}`
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Get device display density (dpi)
|
|
930
|
+
*/
|
|
931
|
+
export async function androidGetDensity(deviceId) {
|
|
932
|
+
try {
|
|
933
|
+
const adbAvailable = await isAdbAvailable();
|
|
934
|
+
if (!adbAvailable) {
|
|
935
|
+
return {
|
|
936
|
+
success: false,
|
|
937
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
941
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
942
|
+
if (!device) {
|
|
943
|
+
return {
|
|
944
|
+
success: false,
|
|
945
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
const { stdout } = await execAsync(`adb ${deviceArg} shell wm density`, {
|
|
949
|
+
timeout: ADB_TIMEOUT
|
|
950
|
+
});
|
|
951
|
+
// Parse output like "Physical density: 440" or "Override density: 440"
|
|
952
|
+
const match = stdout.match(/density:\s*(\d+)/i);
|
|
953
|
+
if (match) {
|
|
954
|
+
return {
|
|
955
|
+
success: true,
|
|
956
|
+
density: parseInt(match[1], 10)
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
success: false,
|
|
961
|
+
error: `Could not parse density from: ${stdout.trim()}`
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
return {
|
|
966
|
+
success: false,
|
|
967
|
+
error: `Failed to get density: ${error instanceof Error ? error.message : String(error)}`
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Get status bar height in pixels
|
|
973
|
+
* Android status bar is typically 24dp, but can vary by device/OS version
|
|
974
|
+
*/
|
|
975
|
+
export async function androidGetStatusBarHeight(deviceId) {
|
|
976
|
+
try {
|
|
977
|
+
// Get density first
|
|
978
|
+
const densityResult = await androidGetDensity(deviceId);
|
|
979
|
+
if (!densityResult.success || !densityResult.density) {
|
|
980
|
+
// Fallback to common estimate
|
|
981
|
+
return {
|
|
982
|
+
success: true,
|
|
983
|
+
heightPixels: 63, // Common for 420dpi devices (24dp * 2.625)
|
|
984
|
+
heightDp: 24
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
const density = densityResult.density;
|
|
988
|
+
const densityScale = density / 160; // Android baseline is 160dpi
|
|
989
|
+
// Try to get actual status bar height from resources
|
|
990
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
991
|
+
try {
|
|
992
|
+
const { stdout } = await execAsync(`adb ${deviceArg} shell "dumpsys window | grep -E 'statusBars|mStatusBarLayer|InsetsSource.*statusBars'"`, { timeout: ADB_TIMEOUT });
|
|
993
|
+
// Try to parse status bar height from dumpsys output
|
|
994
|
+
// Look for patterns like "statusBars frame=[0,0][1080,63]"
|
|
995
|
+
const frameMatch = stdout.match(/statusBars.*frame=\[[\d,]+\]\[(\d+),(\d+)\]/);
|
|
996
|
+
if (frameMatch) {
|
|
997
|
+
const heightPixels = parseInt(frameMatch[2], 10);
|
|
998
|
+
return {
|
|
999
|
+
success: true,
|
|
1000
|
+
heightPixels,
|
|
1001
|
+
heightDp: Math.round(heightPixels / densityScale)
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
// Fallback to standard calculation
|
|
1007
|
+
}
|
|
1008
|
+
// Standard status bar height is 24dp on most devices
|
|
1009
|
+
const statusBarDp = 24;
|
|
1010
|
+
const statusBarPixels = Math.round(statusBarDp * densityScale);
|
|
1011
|
+
return {
|
|
1012
|
+
success: true,
|
|
1013
|
+
heightPixels: statusBarPixels,
|
|
1014
|
+
heightDp: statusBarDp
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
catch (error) {
|
|
1018
|
+
return {
|
|
1019
|
+
success: false,
|
|
1020
|
+
error: `Failed to get status bar height: ${error instanceof Error ? error.message : String(error)}`
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Simplify Android class name for display
|
|
1026
|
+
* android.widget.Button -> Button
|
|
1027
|
+
* android.widget.TextView -> TextView
|
|
1028
|
+
*/
|
|
1029
|
+
function simplifyClassName(className) {
|
|
1030
|
+
if (!className)
|
|
1031
|
+
return "Unknown";
|
|
1032
|
+
const parts = className.split(".");
|
|
1033
|
+
return parts[parts.length - 1];
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Parse bounds string "[left,top][right,bottom]" to object
|
|
1037
|
+
*/
|
|
1038
|
+
function parseBounds(boundsStr) {
|
|
1039
|
+
const match = boundsStr?.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
1040
|
+
if (!match)
|
|
1041
|
+
return null;
|
|
1042
|
+
return {
|
|
1043
|
+
left: parseInt(match[1], 10),
|
|
1044
|
+
top: parseInt(match[2], 10),
|
|
1045
|
+
right: parseInt(match[3], 10),
|
|
1046
|
+
bottom: parseInt(match[4], 10)
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Parse a single node from uiautomator XML
|
|
1051
|
+
*/
|
|
1052
|
+
function parseUiNode(node) {
|
|
1053
|
+
const attrs = node["@_bounds"]
|
|
1054
|
+
? node
|
|
1055
|
+
: node.node
|
|
1056
|
+
? (Array.isArray(node.node) ? node.node[0] : node.node)
|
|
1057
|
+
: null;
|
|
1058
|
+
if (!attrs)
|
|
1059
|
+
return null;
|
|
1060
|
+
const boundsStr = attrs["@_bounds"];
|
|
1061
|
+
const bounds = parseBounds(boundsStr);
|
|
1062
|
+
if (!bounds)
|
|
1063
|
+
return null;
|
|
1064
|
+
const width = bounds.right - bounds.left;
|
|
1065
|
+
const height = bounds.bottom - bounds.top;
|
|
1066
|
+
const centerX = Math.round(bounds.left + width / 2);
|
|
1067
|
+
const centerY = Math.round(bounds.top + height / 2);
|
|
1068
|
+
const element = {
|
|
1069
|
+
class: simplifyClassName(attrs["@_class"] || ""),
|
|
1070
|
+
bounds,
|
|
1071
|
+
frame: {
|
|
1072
|
+
x: bounds.left,
|
|
1073
|
+
y: bounds.top,
|
|
1074
|
+
width,
|
|
1075
|
+
height
|
|
1076
|
+
},
|
|
1077
|
+
tap: {
|
|
1078
|
+
x: centerX,
|
|
1079
|
+
y: centerY
|
|
1080
|
+
},
|
|
1081
|
+
children: []
|
|
1082
|
+
};
|
|
1083
|
+
// Add optional attributes
|
|
1084
|
+
if (attrs["@_text"])
|
|
1085
|
+
element.text = attrs["@_text"];
|
|
1086
|
+
if (attrs["@_content-desc"])
|
|
1087
|
+
element.contentDesc = attrs["@_content-desc"];
|
|
1088
|
+
if (attrs["@_resource-id"])
|
|
1089
|
+
element.resourceId = attrs["@_resource-id"];
|
|
1090
|
+
if (attrs["@_checkable"] === "true")
|
|
1091
|
+
element.checkable = true;
|
|
1092
|
+
if (attrs["@_checked"] === "true")
|
|
1093
|
+
element.checked = true;
|
|
1094
|
+
if (attrs["@_clickable"] === "true")
|
|
1095
|
+
element.clickable = true;
|
|
1096
|
+
if (attrs["@_enabled"] === "true")
|
|
1097
|
+
element.enabled = true;
|
|
1098
|
+
if (attrs["@_focusable"] === "true")
|
|
1099
|
+
element.focusable = true;
|
|
1100
|
+
if (attrs["@_focused"] === "true")
|
|
1101
|
+
element.focused = true;
|
|
1102
|
+
if (attrs["@_scrollable"] === "true")
|
|
1103
|
+
element.scrollable = true;
|
|
1104
|
+
if (attrs["@_selected"] === "true")
|
|
1105
|
+
element.selected = true;
|
|
1106
|
+
return element;
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Recursively parse UI hierarchy from XML node
|
|
1110
|
+
*/
|
|
1111
|
+
function parseHierarchy(node) {
|
|
1112
|
+
const results = [];
|
|
1113
|
+
// Handle the node itself
|
|
1114
|
+
if (node["@_bounds"]) {
|
|
1115
|
+
const element = parseUiNode(node);
|
|
1116
|
+
if (element) {
|
|
1117
|
+
// Parse children
|
|
1118
|
+
if (node.node) {
|
|
1119
|
+
const children = Array.isArray(node.node) ? node.node : [node.node];
|
|
1120
|
+
for (const child of children) {
|
|
1121
|
+
element.children.push(...parseHierarchy(child));
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
results.push(element);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
else if (node.node) {
|
|
1128
|
+
// This is a container without bounds (like hierarchy root)
|
|
1129
|
+
const children = Array.isArray(node.node) ? node.node : [node.node];
|
|
1130
|
+
for (const child of children) {
|
|
1131
|
+
results.push(...parseHierarchy(child));
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return results;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Format accessibility tree for display (similar to iOS format)
|
|
1138
|
+
*/
|
|
1139
|
+
function formatAndroidAccessibilityTree(elements, indent = 0) {
|
|
1140
|
+
const lines = [];
|
|
1141
|
+
const prefix = " ".repeat(indent);
|
|
1142
|
+
for (const element of elements) {
|
|
1143
|
+
const parts = [];
|
|
1144
|
+
// [ClassName] "text" or "content-desc"
|
|
1145
|
+
parts.push(`[${element.class}]`);
|
|
1146
|
+
// Add label (text or content-desc)
|
|
1147
|
+
const label = element.text || element.contentDesc;
|
|
1148
|
+
if (label) {
|
|
1149
|
+
parts.push(`"${label}"`);
|
|
1150
|
+
}
|
|
1151
|
+
// Add frame and tap coordinates
|
|
1152
|
+
const f = element.frame;
|
|
1153
|
+
parts.push(`frame=(${f.x}, ${f.y}, ${f.width}x${f.height}) tap=(${element.tap.x}, ${element.tap.y})`);
|
|
1154
|
+
lines.push(`${prefix}${parts.join(" ")}`);
|
|
1155
|
+
// Recurse into children
|
|
1156
|
+
if (element.children.length > 0) {
|
|
1157
|
+
lines.push(formatAndroidAccessibilityTree(element.children, indent + 1));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return lines.join("\n");
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Flatten element tree to array for searching
|
|
1164
|
+
*/
|
|
1165
|
+
function flattenElements(elements) {
|
|
1166
|
+
const result = [];
|
|
1167
|
+
for (const element of elements) {
|
|
1168
|
+
result.push(element);
|
|
1169
|
+
if (element.children.length > 0) {
|
|
1170
|
+
result.push(...flattenElements(element.children));
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return result;
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Get the UI hierarchy from the connected Android device using uiautomator dump
|
|
1177
|
+
*/
|
|
1178
|
+
export async function androidDescribeAll(deviceId) {
|
|
1179
|
+
try {
|
|
1180
|
+
const adbAvailable = await isAdbAvailable();
|
|
1181
|
+
if (!adbAvailable) {
|
|
1182
|
+
return {
|
|
1183
|
+
success: false,
|
|
1184
|
+
error: "ADB is not installed or not in PATH. Install Android SDK Platform Tools."
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
const deviceArg = buildDeviceArg(deviceId);
|
|
1188
|
+
const device = deviceId || (await getDefaultAndroidDevice());
|
|
1189
|
+
if (!device) {
|
|
1190
|
+
return {
|
|
1191
|
+
success: false,
|
|
1192
|
+
error: "No Android device connected. Connect a device or start an emulator."
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
// Use file-based approach (most reliable across devices)
|
|
1196
|
+
// /dev/tty doesn't work on most emulators/devices
|
|
1197
|
+
const remotePath = "/sdcard/ui_dump.xml";
|
|
1198
|
+
await execAsync(`adb ${deviceArg} shell uiautomator dump ${remotePath}`, {
|
|
1199
|
+
timeout: ADB_TIMEOUT
|
|
1200
|
+
});
|
|
1201
|
+
const { stdout } = await execAsync(`adb ${deviceArg} shell cat ${remotePath}`, {
|
|
1202
|
+
timeout: ADB_TIMEOUT,
|
|
1203
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1204
|
+
});
|
|
1205
|
+
const xmlContent = stdout.trim();
|
|
1206
|
+
// Clean up
|
|
1207
|
+
await execAsync(`adb ${deviceArg} shell rm ${remotePath}`, {
|
|
1208
|
+
timeout: 5000
|
|
1209
|
+
}).catch(() => { });
|
|
1210
|
+
if (!xmlContent || !xmlContent.includes("<hierarchy")) {
|
|
1211
|
+
return {
|
|
1212
|
+
success: false,
|
|
1213
|
+
error: "Failed to get UI hierarchy. Make sure the device screen is unlocked and the app is in foreground."
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
// Parse XML
|
|
1217
|
+
const parser = new XMLParser({
|
|
1218
|
+
ignoreAttributes: false,
|
|
1219
|
+
attributeNamePrefix: "@_"
|
|
1220
|
+
});
|
|
1221
|
+
const parsed = parser.parse(xmlContent);
|
|
1222
|
+
if (!parsed.hierarchy) {
|
|
1223
|
+
return {
|
|
1224
|
+
success: false,
|
|
1225
|
+
error: "Invalid UI hierarchy XML structure"
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
const elements = parseHierarchy(parsed.hierarchy);
|
|
1229
|
+
const formatted = formatAndroidAccessibilityTree(elements);
|
|
1230
|
+
return {
|
|
1231
|
+
success: true,
|
|
1232
|
+
elements,
|
|
1233
|
+
formatted
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
catch (error) {
|
|
1237
|
+
return {
|
|
1238
|
+
success: false,
|
|
1239
|
+
error: `Failed to get UI hierarchy: ${error instanceof Error ? error.message : String(error)}`
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Get accessibility info for the UI element at specific coordinates
|
|
1245
|
+
*/
|
|
1246
|
+
export async function androidDescribePoint(x, y, deviceId) {
|
|
1247
|
+
try {
|
|
1248
|
+
// First get the full hierarchy
|
|
1249
|
+
const result = await androidDescribeAll(deviceId);
|
|
1250
|
+
if (!result.success || !result.elements) {
|
|
1251
|
+
return result;
|
|
1252
|
+
}
|
|
1253
|
+
// Flatten and find elements containing the point
|
|
1254
|
+
const allElements = flattenElements(result.elements);
|
|
1255
|
+
// Find all elements whose bounds contain the point
|
|
1256
|
+
const matchingElements = allElements.filter((el) => {
|
|
1257
|
+
const b = el.bounds;
|
|
1258
|
+
return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom;
|
|
1259
|
+
});
|
|
1260
|
+
if (matchingElements.length === 0) {
|
|
1261
|
+
return {
|
|
1262
|
+
success: true,
|
|
1263
|
+
formatted: `No element found at (${x}, ${y})`
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
// Return the deepest (smallest) element that contains the point
|
|
1267
|
+
// Sort by area (smallest first) to get the most specific element
|
|
1268
|
+
matchingElements.sort((a, b) => {
|
|
1269
|
+
const areaA = a.frame.width * a.frame.height;
|
|
1270
|
+
const areaB = b.frame.width * b.frame.height;
|
|
1271
|
+
return areaA - areaB;
|
|
1272
|
+
});
|
|
1273
|
+
const element = matchingElements[0];
|
|
1274
|
+
// Format detailed output
|
|
1275
|
+
const lines = [];
|
|
1276
|
+
const label = element.text || element.contentDesc;
|
|
1277
|
+
lines.push(`[${element.class}]${label ? ` "${label}"` : ""} frame=(${element.frame.x}, ${element.frame.y}, ${element.frame.width}x${element.frame.height}) tap=(${element.tap.x}, ${element.tap.y})`);
|
|
1278
|
+
if (element.resourceId) {
|
|
1279
|
+
lines.push(` resource-id: ${element.resourceId}`);
|
|
1280
|
+
}
|
|
1281
|
+
if (element.contentDesc && element.text) {
|
|
1282
|
+
// Show content-desc separately if we showed text as label
|
|
1283
|
+
lines.push(` content-desc: ${element.contentDesc}`);
|
|
1284
|
+
}
|
|
1285
|
+
if (element.text && element.contentDesc) {
|
|
1286
|
+
// Show text separately if we showed content-desc as label
|
|
1287
|
+
lines.push(` text: ${element.text}`);
|
|
1288
|
+
}
|
|
1289
|
+
// Show state flags
|
|
1290
|
+
const flags = [];
|
|
1291
|
+
if (element.clickable)
|
|
1292
|
+
flags.push("clickable");
|
|
1293
|
+
if (element.enabled)
|
|
1294
|
+
flags.push("enabled");
|
|
1295
|
+
if (element.focusable)
|
|
1296
|
+
flags.push("focusable");
|
|
1297
|
+
if (element.focused)
|
|
1298
|
+
flags.push("focused");
|
|
1299
|
+
if (element.scrollable)
|
|
1300
|
+
flags.push("scrollable");
|
|
1301
|
+
if (element.selected)
|
|
1302
|
+
flags.push("selected");
|
|
1303
|
+
if (element.checked)
|
|
1304
|
+
flags.push("checked");
|
|
1305
|
+
if (flags.length > 0) {
|
|
1306
|
+
lines.push(` state: ${flags.join(", ")}`);
|
|
1307
|
+
}
|
|
1308
|
+
return {
|
|
1309
|
+
success: true,
|
|
1310
|
+
elements: [element],
|
|
1311
|
+
formatted: lines.join("\n")
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
catch (error) {
|
|
1315
|
+
return {
|
|
1316
|
+
success: false,
|
|
1317
|
+
error: `Failed to describe point: ${error instanceof Error ? error.message : String(error)}`
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Tap an element by its text, content-description, or resource-id
|
|
1323
|
+
*/
|
|
1324
|
+
export async function androidTapElement(options) {
|
|
1325
|
+
try {
|
|
1326
|
+
const { text, textContains, contentDesc, contentDescContains, resourceId, index = 0, deviceId } = options;
|
|
1327
|
+
// Validate that at least one search criterion is provided
|
|
1328
|
+
if (!text && !textContains && !contentDesc && !contentDescContains && !resourceId) {
|
|
1329
|
+
return {
|
|
1330
|
+
success: false,
|
|
1331
|
+
error: "At least one of text, textContains, contentDesc, contentDescContains, or resourceId must be provided"
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
// Get the UI hierarchy
|
|
1335
|
+
const result = await androidDescribeAll(deviceId);
|
|
1336
|
+
if (!result.success || !result.elements) {
|
|
1337
|
+
return {
|
|
1338
|
+
success: false,
|
|
1339
|
+
error: result.error || "Failed to get UI hierarchy"
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
// Flatten and search
|
|
1343
|
+
const allElements = flattenElements(result.elements);
|
|
1344
|
+
// Filter elements based on search criteria
|
|
1345
|
+
const matchingElements = allElements.filter((el) => {
|
|
1346
|
+
if (text && el.text !== text)
|
|
1347
|
+
return false;
|
|
1348
|
+
if (textContains && (!el.text || !el.text.toLowerCase().includes(textContains.toLowerCase())))
|
|
1349
|
+
return false;
|
|
1350
|
+
if (contentDesc && el.contentDesc !== contentDesc)
|
|
1351
|
+
return false;
|
|
1352
|
+
if (contentDescContains && (!el.contentDesc || !el.contentDesc.toLowerCase().includes(contentDescContains.toLowerCase())))
|
|
1353
|
+
return false;
|
|
1354
|
+
if (resourceId) {
|
|
1355
|
+
// Support both full resource-id and short form
|
|
1356
|
+
if (!el.resourceId)
|
|
1357
|
+
return false;
|
|
1358
|
+
if (el.resourceId !== resourceId && !el.resourceId.endsWith(`:id/${resourceId}`))
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
1361
|
+
return true;
|
|
1362
|
+
});
|
|
1363
|
+
if (matchingElements.length === 0) {
|
|
1364
|
+
const criteria = [];
|
|
1365
|
+
if (text)
|
|
1366
|
+
criteria.push(`text="${text}"`);
|
|
1367
|
+
if (textContains)
|
|
1368
|
+
criteria.push(`textContains="${textContains}"`);
|
|
1369
|
+
if (contentDesc)
|
|
1370
|
+
criteria.push(`contentDesc="${contentDesc}"`);
|
|
1371
|
+
if (contentDescContains)
|
|
1372
|
+
criteria.push(`contentDescContains="${contentDescContains}"`);
|
|
1373
|
+
if (resourceId)
|
|
1374
|
+
criteria.push(`resourceId="${resourceId}"`);
|
|
1375
|
+
return {
|
|
1376
|
+
success: false,
|
|
1377
|
+
error: `Element not found: ${criteria.join(", ")}`
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
if (index >= matchingElements.length) {
|
|
1381
|
+
return {
|
|
1382
|
+
success: false,
|
|
1383
|
+
error: `Index ${index} out of range. Found ${matchingElements.length} matching element(s).`
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
const element = matchingElements[index];
|
|
1387
|
+
const label = element.text || element.contentDesc || element.resourceId || element.class;
|
|
1388
|
+
// Log if multiple matches
|
|
1389
|
+
let resultMessage;
|
|
1390
|
+
if (matchingElements.length > 1) {
|
|
1391
|
+
resultMessage = `Found ${matchingElements.length} elements, tapping "${label}" (index ${index}) at (${element.tap.x}, ${element.tap.y})`;
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
resultMessage = `Tapped "${label}" at (${element.tap.x}, ${element.tap.y})`;
|
|
1395
|
+
}
|
|
1396
|
+
// Perform the tap
|
|
1397
|
+
const tapResult = await androidTap(element.tap.x, element.tap.y, deviceId);
|
|
1398
|
+
if (!tapResult.success) {
|
|
1399
|
+
return tapResult;
|
|
1400
|
+
}
|
|
1401
|
+
return {
|
|
1402
|
+
success: true,
|
|
1403
|
+
result: resultMessage
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
catch (error) {
|
|
1407
|
+
return {
|
|
1408
|
+
success: false,
|
|
1409
|
+
error: `Failed to tap element: ${error instanceof Error ? error.message : String(error)}`
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
//# sourceMappingURL=android.js.map
|