mcp-android-emulator 1.2.3 → 1.3.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.
- package/dist/index.js +219 -7
- package/package.json +1 -1
- package/src/index.ts +252 -7
package/dist/index.js
CHANGED
|
@@ -41,7 +41,7 @@ async function shell(command) {
|
|
|
41
41
|
// Create MCP server
|
|
42
42
|
const server = new McpServer({
|
|
43
43
|
name: "android-emulator",
|
|
44
|
-
version: "1.
|
|
44
|
+
version: "1.3.0",
|
|
45
45
|
});
|
|
46
46
|
// =====================================================
|
|
47
47
|
// TOOL: screenshot
|
|
@@ -398,12 +398,47 @@ server.tool("force_stop", "Force stop an application", {
|
|
|
398
398
|
// TOOL: get_current_activity
|
|
399
399
|
// =====================================================
|
|
400
400
|
server.tool("get_current_activity", "Get the currently focused activity/screen", {}, async () => {
|
|
401
|
-
|
|
401
|
+
let activity = "Unknown";
|
|
402
|
+
// Try multiple methods for compatibility across emulators
|
|
403
|
+
try {
|
|
404
|
+
// Method 1: mResumedActivity (standard Android)
|
|
405
|
+
const result1 = await shell("dumpsys activity activities | grep -E 'mResumedActivity|mCurrentFocus' || true");
|
|
406
|
+
if (result1 && result1.trim()) {
|
|
407
|
+
activity = result1.trim();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// Ignore
|
|
412
|
+
}
|
|
413
|
+
if (activity === "Unknown") {
|
|
414
|
+
try {
|
|
415
|
+
// Method 2: topActivity (alternative)
|
|
416
|
+
const result2 = await shell("dumpsys activity top | head -5 || true");
|
|
417
|
+
if (result2 && result2.trim()) {
|
|
418
|
+
activity = result2.trim();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// Ignore
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (activity === "Unknown") {
|
|
426
|
+
try {
|
|
427
|
+
// Method 3: window focus (Redroid/Docker compatible)
|
|
428
|
+
const result3 = await shell("dumpsys window | grep -E 'mCurrentFocus|mFocusedApp' || true");
|
|
429
|
+
if (result3 && result3.trim()) {
|
|
430
|
+
activity = result3.trim();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
// Ignore
|
|
435
|
+
}
|
|
436
|
+
}
|
|
402
437
|
return {
|
|
403
438
|
content: [
|
|
404
439
|
{
|
|
405
440
|
type: "text",
|
|
406
|
-
text: `Current activity
|
|
441
|
+
text: `Current activity:\n${activity}`,
|
|
407
442
|
},
|
|
408
443
|
],
|
|
409
444
|
};
|
|
@@ -777,24 +812,44 @@ server.tool("scroll_to_text", "Scroll the screen until an element with specific
|
|
|
777
812
|
// =====================================================
|
|
778
813
|
// TOOL: wait_for_ui_stable
|
|
779
814
|
// =====================================================
|
|
815
|
+
/**
|
|
816
|
+
* Extract a normalized fingerprint of UI elements from XML
|
|
817
|
+
* Only considers text, bounds, and class - ignores dynamic attributes
|
|
818
|
+
*/
|
|
819
|
+
function extractUIFingerprint(xml) {
|
|
820
|
+
const elements = [];
|
|
821
|
+
// Match elements with text or class and bounds
|
|
822
|
+
const regex = /(?:text="([^"]*)")?[^>]*(?:class="([^"]*)")?[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g;
|
|
823
|
+
let match;
|
|
824
|
+
while ((match = regex.exec(xml)) !== null) {
|
|
825
|
+
const [, text, className, x1, y1, x2, y2] = match;
|
|
826
|
+
// Only include elements with text or meaningful classes
|
|
827
|
+
if (text || className) {
|
|
828
|
+
elements.push(`${text || ""}|${className || ""}|${x1},${y1},${x2},${y2}`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return elements.sort().join("\n");
|
|
832
|
+
}
|
|
780
833
|
server.tool("wait_for_ui_stable", "Wait for the UI to stop changing (useful after animations)", {
|
|
781
834
|
timeout: z.number().optional().describe("Timeout in milliseconds (default: 5000)"),
|
|
782
835
|
checkInterval: z.number().optional().describe("Check interval in milliseconds (default: 500)"),
|
|
783
836
|
}, async ({ timeout = 5000, checkInterval = 500 }) => {
|
|
784
837
|
const startTime = Date.now();
|
|
785
|
-
let
|
|
838
|
+
let lastFingerprint = "";
|
|
786
839
|
let stableCount = 0;
|
|
787
840
|
while (Date.now() - startTime < timeout) {
|
|
788
841
|
await shell("uiautomator dump /sdcard/ui_dump.xml");
|
|
789
842
|
const currentXml = await shell("cat /sdcard/ui_dump.xml");
|
|
790
|
-
|
|
843
|
+
const currentFingerprint = extractUIFingerprint(currentXml);
|
|
844
|
+
if (currentFingerprint === lastFingerprint) {
|
|
791
845
|
stableCount++;
|
|
792
846
|
if (stableCount >= 2) {
|
|
847
|
+
const elapsed = Date.now() - startTime;
|
|
793
848
|
return {
|
|
794
849
|
content: [
|
|
795
850
|
{
|
|
796
851
|
type: "text",
|
|
797
|
-
text: `UI stable after ${Math.round(
|
|
852
|
+
text: `UI stable after ${elapsed < 1000 ? elapsed + "ms" : Math.round(elapsed / 1000) + "s"}`,
|
|
798
853
|
},
|
|
799
854
|
],
|
|
800
855
|
};
|
|
@@ -802,7 +857,7 @@ server.tool("wait_for_ui_stable", "Wait for the UI to stop changing (useful afte
|
|
|
802
857
|
}
|
|
803
858
|
else {
|
|
804
859
|
stableCount = 0;
|
|
805
|
-
|
|
860
|
+
lastFingerprint = currentFingerprint;
|
|
806
861
|
}
|
|
807
862
|
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
|
808
863
|
}
|
|
@@ -1217,6 +1272,163 @@ server.tool("assert_screen_contains", "Assert that specific text is visible on s
|
|
|
1217
1272
|
],
|
|
1218
1273
|
};
|
|
1219
1274
|
});
|
|
1275
|
+
// =====================================================
|
|
1276
|
+
// TOOL: get_all_text
|
|
1277
|
+
// =====================================================
|
|
1278
|
+
server.tool("get_all_text", "Get all visible text elements on screen (useful for debugging and verification)", {
|
|
1279
|
+
includeEmpty: z.boolean().optional().describe("Include elements with empty text (default: false)"),
|
|
1280
|
+
}, async ({ includeEmpty = false }) => {
|
|
1281
|
+
await shell("uiautomator dump /sdcard/ui_dump.xml");
|
|
1282
|
+
const xml = await shell("cat /sdcard/ui_dump.xml");
|
|
1283
|
+
const texts = [];
|
|
1284
|
+
const regex = /text="([^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g;
|
|
1285
|
+
let match;
|
|
1286
|
+
while ((match = regex.exec(xml)) !== null) {
|
|
1287
|
+
const [, text, x1, y1, x2, y2] = match;
|
|
1288
|
+
if (text || includeEmpty) {
|
|
1289
|
+
texts.push({
|
|
1290
|
+
text: text || "(empty)",
|
|
1291
|
+
centerX: Math.round((parseInt(x1) + parseInt(x2)) / 2),
|
|
1292
|
+
centerY: Math.round((parseInt(y1) + parseInt(y2)) / 2),
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
// Sort by Y position (top to bottom), then X (left to right)
|
|
1297
|
+
texts.sort((a, b) => a.centerY - b.centerY || a.centerX - b.centerX);
|
|
1298
|
+
const textList = texts.map((t) => `"${t.text}" at (${t.centerX}, ${t.centerY})`).join("\n");
|
|
1299
|
+
return {
|
|
1300
|
+
content: [
|
|
1301
|
+
{
|
|
1302
|
+
type: "text",
|
|
1303
|
+
text: `Found ${texts.length} text elements:\n${textList}`,
|
|
1304
|
+
},
|
|
1305
|
+
],
|
|
1306
|
+
};
|
|
1307
|
+
});
|
|
1308
|
+
// =====================================================
|
|
1309
|
+
// TOOL: is_keyboard_visible
|
|
1310
|
+
// =====================================================
|
|
1311
|
+
server.tool("is_keyboard_visible", "Check if the soft keyboard is currently visible on screen", {}, async () => {
|
|
1312
|
+
let isShowingViaIme = false;
|
|
1313
|
+
let hasKeyboardWindow = false;
|
|
1314
|
+
let heightMethod = false;
|
|
1315
|
+
// Method 1: Check InputMethod visibility via dumpsys
|
|
1316
|
+
try {
|
|
1317
|
+
const imeDump = await shell("dumpsys input_method | grep mInputShown || true");
|
|
1318
|
+
isShowingViaIme = imeDump.includes("mInputShown=true");
|
|
1319
|
+
}
|
|
1320
|
+
catch {
|
|
1321
|
+
// Ignore errors
|
|
1322
|
+
}
|
|
1323
|
+
// Method 2: Check if keyboard window is visible
|
|
1324
|
+
try {
|
|
1325
|
+
const windowDump = await shell("dumpsys window windows | grep -i inputmethod || true");
|
|
1326
|
+
hasKeyboardWindow = windowDump.toLowerCase().includes("inputmethod") &&
|
|
1327
|
+
windowDump.includes("mHasSurface=true");
|
|
1328
|
+
}
|
|
1329
|
+
catch {
|
|
1330
|
+
// Ignore errors
|
|
1331
|
+
}
|
|
1332
|
+
// Method 3: Check visible height vs screen height
|
|
1333
|
+
try {
|
|
1334
|
+
const visibleFrame = await shell("dumpsys window | grep 'mVisibleFrame' || true");
|
|
1335
|
+
const sizeOutput = await shell("wm size");
|
|
1336
|
+
const sizeMatch = sizeOutput.match(/(\d+)x(\d+)/);
|
|
1337
|
+
if (sizeMatch && visibleFrame) {
|
|
1338
|
+
const screenHeight = parseInt(sizeMatch[2]);
|
|
1339
|
+
const frameMatch = visibleFrame.match(/mVisibleFrame=\[\d+,\d+\]\[\d+,(\d+)\]/);
|
|
1340
|
+
if (frameMatch) {
|
|
1341
|
+
const visibleHeight = parseInt(frameMatch[1]);
|
|
1342
|
+
// If visible area is significantly less than screen, keyboard is likely shown
|
|
1343
|
+
heightMethod = visibleHeight < screenHeight * 0.8;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
catch {
|
|
1348
|
+
// Ignore height method errors
|
|
1349
|
+
}
|
|
1350
|
+
const isVisible = isShowingViaIme || hasKeyboardWindow || heightMethod;
|
|
1351
|
+
return {
|
|
1352
|
+
content: [
|
|
1353
|
+
{
|
|
1354
|
+
type: "text",
|
|
1355
|
+
text: JSON.stringify({
|
|
1356
|
+
visible: isVisible,
|
|
1357
|
+
checks: {
|
|
1358
|
+
inputMethodShown: isShowingViaIme,
|
|
1359
|
+
keyboardWindowVisible: hasKeyboardWindow,
|
|
1360
|
+
heightReduced: heightMethod,
|
|
1361
|
+
},
|
|
1362
|
+
}, null, 2),
|
|
1363
|
+
},
|
|
1364
|
+
],
|
|
1365
|
+
};
|
|
1366
|
+
});
|
|
1367
|
+
// =====================================================
|
|
1368
|
+
// TOOL: get_focused_input_value
|
|
1369
|
+
// =====================================================
|
|
1370
|
+
server.tool("get_focused_input_value", "Get the current text value of the focused input field", {}, async () => {
|
|
1371
|
+
await shell("uiautomator dump /sdcard/ui_dump.xml");
|
|
1372
|
+
const xml = await shell("cat /sdcard/ui_dump.xml");
|
|
1373
|
+
// Look for focused element that is an input field (EditText or similar)
|
|
1374
|
+
// Pattern matches focused="true" along with text attribute
|
|
1375
|
+
const patterns = [
|
|
1376
|
+
// Pattern 1: focused before text
|
|
1377
|
+
/class="[^"]*(?:Edit|Input|Text)[^"]*"[^>]*focused="true"[^>]*text="([^"]*)"/gi,
|
|
1378
|
+
// Pattern 2: text before focused
|
|
1379
|
+
/class="[^"]*(?:Edit|Input|Text)[^"]*"[^>]*text="([^"]*)"[^>]*focused="true"/gi,
|
|
1380
|
+
// Pattern 3: Generic focused with text
|
|
1381
|
+
/focused="true"[^>]*text="([^"]*)"[^>]*class="[^"]*(?:Edit|Input|Text)[^"]*"/gi,
|
|
1382
|
+
];
|
|
1383
|
+
for (const pattern of patterns) {
|
|
1384
|
+
const match = pattern.exec(xml);
|
|
1385
|
+
if (match) {
|
|
1386
|
+
return {
|
|
1387
|
+
content: [
|
|
1388
|
+
{
|
|
1389
|
+
type: "text",
|
|
1390
|
+
text: JSON.stringify({
|
|
1391
|
+
found: true,
|
|
1392
|
+
value: match[1],
|
|
1393
|
+
isEmpty: match[1] === "",
|
|
1394
|
+
}, null, 2),
|
|
1395
|
+
},
|
|
1396
|
+
],
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
// Try broader search for any focused element with text
|
|
1401
|
+
const broadPattern = /focused="true"[^>]*text="([^"]*)"|text="([^"]*)"[^>]*focused="true"/gi;
|
|
1402
|
+
const broadMatch = broadPattern.exec(xml);
|
|
1403
|
+
if (broadMatch) {
|
|
1404
|
+
const value = broadMatch[1] || broadMatch[2] || "";
|
|
1405
|
+
return {
|
|
1406
|
+
content: [
|
|
1407
|
+
{
|
|
1408
|
+
type: "text",
|
|
1409
|
+
text: JSON.stringify({
|
|
1410
|
+
found: true,
|
|
1411
|
+
value,
|
|
1412
|
+
isEmpty: value === "",
|
|
1413
|
+
note: "Found focused element (may not be an input field)",
|
|
1414
|
+
}, null, 2),
|
|
1415
|
+
},
|
|
1416
|
+
],
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
content: [
|
|
1421
|
+
{
|
|
1422
|
+
type: "text",
|
|
1423
|
+
text: JSON.stringify({
|
|
1424
|
+
found: false,
|
|
1425
|
+
value: null,
|
|
1426
|
+
error: "No focused input field found",
|
|
1427
|
+
}, null, 2),
|
|
1428
|
+
},
|
|
1429
|
+
],
|
|
1430
|
+
};
|
|
1431
|
+
});
|
|
1220
1432
|
// Start server
|
|
1221
1433
|
async function main() {
|
|
1222
1434
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ async function shell(command: string): Promise<string> {
|
|
|
47
47
|
// Create MCP server
|
|
48
48
|
const server = new McpServer({
|
|
49
49
|
name: "android-emulator",
|
|
50
|
-
version: "1.
|
|
50
|
+
version: "1.3.0",
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
// =====================================================
|
|
@@ -530,13 +530,48 @@ server.tool(
|
|
|
530
530
|
"Get the currently focused activity/screen",
|
|
531
531
|
{},
|
|
532
532
|
async () => {
|
|
533
|
-
|
|
533
|
+
let activity = "Unknown";
|
|
534
|
+
|
|
535
|
+
// Try multiple methods for compatibility across emulators
|
|
536
|
+
try {
|
|
537
|
+
// Method 1: mResumedActivity (standard Android)
|
|
538
|
+
const result1 = await shell("dumpsys activity activities | grep -E 'mResumedActivity|mCurrentFocus' || true");
|
|
539
|
+
if (result1 && result1.trim()) {
|
|
540
|
+
activity = result1.trim();
|
|
541
|
+
}
|
|
542
|
+
} catch {
|
|
543
|
+
// Ignore
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (activity === "Unknown") {
|
|
547
|
+
try {
|
|
548
|
+
// Method 2: topActivity (alternative)
|
|
549
|
+
const result2 = await shell("dumpsys activity top | head -5 || true");
|
|
550
|
+
if (result2 && result2.trim()) {
|
|
551
|
+
activity = result2.trim();
|
|
552
|
+
}
|
|
553
|
+
} catch {
|
|
554
|
+
// Ignore
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (activity === "Unknown") {
|
|
559
|
+
try {
|
|
560
|
+
// Method 3: window focus (Redroid/Docker compatible)
|
|
561
|
+
const result3 = await shell("dumpsys window | grep -E 'mCurrentFocus|mFocusedApp' || true");
|
|
562
|
+
if (result3 && result3.trim()) {
|
|
563
|
+
activity = result3.trim();
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
// Ignore
|
|
567
|
+
}
|
|
568
|
+
}
|
|
534
569
|
|
|
535
570
|
return {
|
|
536
571
|
content: [
|
|
537
572
|
{
|
|
538
573
|
type: "text",
|
|
539
|
-
text: `Current activity
|
|
574
|
+
text: `Current activity:\n${activity}`,
|
|
540
575
|
},
|
|
541
576
|
],
|
|
542
577
|
};
|
|
@@ -1016,6 +1051,27 @@ server.tool(
|
|
|
1016
1051
|
// =====================================================
|
|
1017
1052
|
// TOOL: wait_for_ui_stable
|
|
1018
1053
|
// =====================================================
|
|
1054
|
+
/**
|
|
1055
|
+
* Extract a normalized fingerprint of UI elements from XML
|
|
1056
|
+
* Only considers text, bounds, and class - ignores dynamic attributes
|
|
1057
|
+
*/
|
|
1058
|
+
function extractUIFingerprint(xml: string): string {
|
|
1059
|
+
const elements: string[] = [];
|
|
1060
|
+
// Match elements with text or class and bounds
|
|
1061
|
+
const regex = /(?:text="([^"]*)")?[^>]*(?:class="([^"]*)")?[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g;
|
|
1062
|
+
let match;
|
|
1063
|
+
|
|
1064
|
+
while ((match = regex.exec(xml)) !== null) {
|
|
1065
|
+
const [, text, className, x1, y1, x2, y2] = match;
|
|
1066
|
+
// Only include elements with text or meaningful classes
|
|
1067
|
+
if (text || className) {
|
|
1068
|
+
elements.push(`${text || ""}|${className || ""}|${x1},${y1},${x2},${y2}`);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
return elements.sort().join("\n");
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1019
1075
|
server.tool(
|
|
1020
1076
|
"wait_for_ui_stable",
|
|
1021
1077
|
"Wait for the UI to stop changing (useful after animations)",
|
|
@@ -1025,28 +1081,30 @@ server.tool(
|
|
|
1025
1081
|
},
|
|
1026
1082
|
async ({ timeout = 5000, checkInterval = 500 }) => {
|
|
1027
1083
|
const startTime = Date.now();
|
|
1028
|
-
let
|
|
1084
|
+
let lastFingerprint = "";
|
|
1029
1085
|
let stableCount = 0;
|
|
1030
1086
|
|
|
1031
1087
|
while (Date.now() - startTime < timeout) {
|
|
1032
1088
|
await shell("uiautomator dump /sdcard/ui_dump.xml");
|
|
1033
1089
|
const currentXml = await shell("cat /sdcard/ui_dump.xml");
|
|
1090
|
+
const currentFingerprint = extractUIFingerprint(currentXml);
|
|
1034
1091
|
|
|
1035
|
-
if (
|
|
1092
|
+
if (currentFingerprint === lastFingerprint) {
|
|
1036
1093
|
stableCount++;
|
|
1037
1094
|
if (stableCount >= 2) {
|
|
1095
|
+
const elapsed = Date.now() - startTime;
|
|
1038
1096
|
return {
|
|
1039
1097
|
content: [
|
|
1040
1098
|
{
|
|
1041
1099
|
type: "text",
|
|
1042
|
-
text: `UI stable after ${Math.round(
|
|
1100
|
+
text: `UI stable after ${elapsed < 1000 ? elapsed + "ms" : Math.round(elapsed / 1000) + "s"}`,
|
|
1043
1101
|
},
|
|
1044
1102
|
],
|
|
1045
1103
|
};
|
|
1046
1104
|
}
|
|
1047
1105
|
} else {
|
|
1048
1106
|
stableCount = 0;
|
|
1049
|
-
|
|
1107
|
+
lastFingerprint = currentFingerprint;
|
|
1050
1108
|
}
|
|
1051
1109
|
|
|
1052
1110
|
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
|
@@ -1559,6 +1617,193 @@ server.tool(
|
|
|
1559
1617
|
}
|
|
1560
1618
|
);
|
|
1561
1619
|
|
|
1620
|
+
// =====================================================
|
|
1621
|
+
// TOOL: get_all_text
|
|
1622
|
+
// =====================================================
|
|
1623
|
+
server.tool(
|
|
1624
|
+
"get_all_text",
|
|
1625
|
+
"Get all visible text elements on screen (useful for debugging and verification)",
|
|
1626
|
+
{
|
|
1627
|
+
includeEmpty: z.boolean().optional().describe("Include elements with empty text (default: false)"),
|
|
1628
|
+
},
|
|
1629
|
+
async ({ includeEmpty = false }) => {
|
|
1630
|
+
await shell("uiautomator dump /sdcard/ui_dump.xml");
|
|
1631
|
+
const xml = await shell("cat /sdcard/ui_dump.xml");
|
|
1632
|
+
|
|
1633
|
+
const texts: Array<{ text: string; centerX: number; centerY: number }> = [];
|
|
1634
|
+
const regex = /text="([^"]*)"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/g;
|
|
1635
|
+
let match;
|
|
1636
|
+
|
|
1637
|
+
while ((match = regex.exec(xml)) !== null) {
|
|
1638
|
+
const [, text, x1, y1, x2, y2] = match;
|
|
1639
|
+
if (text || includeEmpty) {
|
|
1640
|
+
texts.push({
|
|
1641
|
+
text: text || "(empty)",
|
|
1642
|
+
centerX: Math.round((parseInt(x1) + parseInt(x2)) / 2),
|
|
1643
|
+
centerY: Math.round((parseInt(y1) + parseInt(y2)) / 2),
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
// Sort by Y position (top to bottom), then X (left to right)
|
|
1649
|
+
texts.sort((a, b) => a.centerY - b.centerY || a.centerX - b.centerX);
|
|
1650
|
+
|
|
1651
|
+
const textList = texts.map((t) => `"${t.text}" at (${t.centerX}, ${t.centerY})`).join("\n");
|
|
1652
|
+
|
|
1653
|
+
return {
|
|
1654
|
+
content: [
|
|
1655
|
+
{
|
|
1656
|
+
type: "text",
|
|
1657
|
+
text: `Found ${texts.length} text elements:\n${textList}`,
|
|
1658
|
+
},
|
|
1659
|
+
],
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
);
|
|
1663
|
+
|
|
1664
|
+
// =====================================================
|
|
1665
|
+
// TOOL: is_keyboard_visible
|
|
1666
|
+
// =====================================================
|
|
1667
|
+
server.tool(
|
|
1668
|
+
"is_keyboard_visible",
|
|
1669
|
+
"Check if the soft keyboard is currently visible on screen",
|
|
1670
|
+
{},
|
|
1671
|
+
async () => {
|
|
1672
|
+
let isShowingViaIme = false;
|
|
1673
|
+
let hasKeyboardWindow = false;
|
|
1674
|
+
let heightMethod = false;
|
|
1675
|
+
|
|
1676
|
+
// Method 1: Check InputMethod visibility via dumpsys
|
|
1677
|
+
try {
|
|
1678
|
+
const imeDump = await shell("dumpsys input_method | grep mInputShown || true");
|
|
1679
|
+
isShowingViaIme = imeDump.includes("mInputShown=true");
|
|
1680
|
+
} catch {
|
|
1681
|
+
// Ignore errors
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Method 2: Check if keyboard window is visible
|
|
1685
|
+
try {
|
|
1686
|
+
const windowDump = await shell("dumpsys window windows | grep -i inputmethod || true");
|
|
1687
|
+
hasKeyboardWindow = windowDump.toLowerCase().includes("inputmethod") &&
|
|
1688
|
+
windowDump.includes("mHasSurface=true");
|
|
1689
|
+
} catch {
|
|
1690
|
+
// Ignore errors
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// Method 3: Check visible height vs screen height
|
|
1694
|
+
try {
|
|
1695
|
+
const visibleFrame = await shell("dumpsys window | grep 'mVisibleFrame' || true");
|
|
1696
|
+
const sizeOutput = await shell("wm size");
|
|
1697
|
+
const sizeMatch = sizeOutput.match(/(\d+)x(\d+)/);
|
|
1698
|
+
if (sizeMatch && visibleFrame) {
|
|
1699
|
+
const screenHeight = parseInt(sizeMatch[2]);
|
|
1700
|
+
const frameMatch = visibleFrame.match(/mVisibleFrame=\[\d+,\d+\]\[\d+,(\d+)\]/);
|
|
1701
|
+
if (frameMatch) {
|
|
1702
|
+
const visibleHeight = parseInt(frameMatch[1]);
|
|
1703
|
+
// If visible area is significantly less than screen, keyboard is likely shown
|
|
1704
|
+
heightMethod = visibleHeight < screenHeight * 0.8;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
} catch {
|
|
1708
|
+
// Ignore height method errors
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const isVisible = isShowingViaIme || hasKeyboardWindow || heightMethod;
|
|
1712
|
+
|
|
1713
|
+
return {
|
|
1714
|
+
content: [
|
|
1715
|
+
{
|
|
1716
|
+
type: "text",
|
|
1717
|
+
text: JSON.stringify({
|
|
1718
|
+
visible: isVisible,
|
|
1719
|
+
checks: {
|
|
1720
|
+
inputMethodShown: isShowingViaIme,
|
|
1721
|
+
keyboardWindowVisible: hasKeyboardWindow,
|
|
1722
|
+
heightReduced: heightMethod,
|
|
1723
|
+
},
|
|
1724
|
+
}, null, 2),
|
|
1725
|
+
},
|
|
1726
|
+
],
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
);
|
|
1730
|
+
|
|
1731
|
+
// =====================================================
|
|
1732
|
+
// TOOL: get_focused_input_value
|
|
1733
|
+
// =====================================================
|
|
1734
|
+
server.tool(
|
|
1735
|
+
"get_focused_input_value",
|
|
1736
|
+
"Get the current text value of the focused input field",
|
|
1737
|
+
{},
|
|
1738
|
+
async () => {
|
|
1739
|
+
await shell("uiautomator dump /sdcard/ui_dump.xml");
|
|
1740
|
+
const xml = await shell("cat /sdcard/ui_dump.xml");
|
|
1741
|
+
|
|
1742
|
+
// Look for focused element that is an input field (EditText or similar)
|
|
1743
|
+
// Pattern matches focused="true" along with text attribute
|
|
1744
|
+
const patterns = [
|
|
1745
|
+
// Pattern 1: focused before text
|
|
1746
|
+
/class="[^"]*(?:Edit|Input|Text)[^"]*"[^>]*focused="true"[^>]*text="([^"]*)"/gi,
|
|
1747
|
+
// Pattern 2: text before focused
|
|
1748
|
+
/class="[^"]*(?:Edit|Input|Text)[^"]*"[^>]*text="([^"]*)"[^>]*focused="true"/gi,
|
|
1749
|
+
// Pattern 3: Generic focused with text
|
|
1750
|
+
/focused="true"[^>]*text="([^"]*)"[^>]*class="[^"]*(?:Edit|Input|Text)[^"]*"/gi,
|
|
1751
|
+
];
|
|
1752
|
+
|
|
1753
|
+
for (const pattern of patterns) {
|
|
1754
|
+
const match = pattern.exec(xml);
|
|
1755
|
+
if (match) {
|
|
1756
|
+
return {
|
|
1757
|
+
content: [
|
|
1758
|
+
{
|
|
1759
|
+
type: "text",
|
|
1760
|
+
text: JSON.stringify({
|
|
1761
|
+
found: true,
|
|
1762
|
+
value: match[1],
|
|
1763
|
+
isEmpty: match[1] === "",
|
|
1764
|
+
}, null, 2),
|
|
1765
|
+
},
|
|
1766
|
+
],
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Try broader search for any focused element with text
|
|
1772
|
+
const broadPattern = /focused="true"[^>]*text="([^"]*)"|text="([^"]*)"[^>]*focused="true"/gi;
|
|
1773
|
+
const broadMatch = broadPattern.exec(xml);
|
|
1774
|
+
|
|
1775
|
+
if (broadMatch) {
|
|
1776
|
+
const value = broadMatch[1] || broadMatch[2] || "";
|
|
1777
|
+
return {
|
|
1778
|
+
content: [
|
|
1779
|
+
{
|
|
1780
|
+
type: "text",
|
|
1781
|
+
text: JSON.stringify({
|
|
1782
|
+
found: true,
|
|
1783
|
+
value,
|
|
1784
|
+
isEmpty: value === "",
|
|
1785
|
+
note: "Found focused element (may not be an input field)",
|
|
1786
|
+
}, null, 2),
|
|
1787
|
+
},
|
|
1788
|
+
],
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
return {
|
|
1793
|
+
content: [
|
|
1794
|
+
{
|
|
1795
|
+
type: "text",
|
|
1796
|
+
text: JSON.stringify({
|
|
1797
|
+
found: false,
|
|
1798
|
+
value: null,
|
|
1799
|
+
error: "No focused input field found",
|
|
1800
|
+
}, null, 2),
|
|
1801
|
+
},
|
|
1802
|
+
],
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
);
|
|
1806
|
+
|
|
1562
1807
|
// Start server
|
|
1563
1808
|
async function main() {
|
|
1564
1809
|
const transport = new StdioServerTransport();
|