mobile-mcp-ai 2.6.1__py3-none-any.whl → 2.6.3__py3-none-any.whl
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.
- mobile_mcp/core/basic_tools_lite.py +527 -189
- mobile_mcp/mcp_tools/mcp_server.py +112 -16
- {mobile_mcp_ai-2.6.1.dist-info → mobile_mcp_ai-2.6.3.dist-info}/METADATA +1 -1
- {mobile_mcp_ai-2.6.1.dist-info → mobile_mcp_ai-2.6.3.dist-info}/RECORD +8 -8
- {mobile_mcp_ai-2.6.1.dist-info → mobile_mcp_ai-2.6.3.dist-info}/WHEEL +0 -0
- {mobile_mcp_ai-2.6.1.dist-info → mobile_mcp_ai-2.6.3.dist-info}/entry_points.txt +0 -0
- {mobile_mcp_ai-2.6.1.dist-info → mobile_mcp_ai-2.6.3.dist-info}/licenses/LICENSE +0 -0
- {mobile_mcp_ai-2.6.1.dist-info → mobile_mcp_ai-2.6.3.dist-info}/top_level.txt +0 -0
|
@@ -500,7 +500,7 @@ class BasicMobileToolsLite:
|
|
|
500
500
|
# 左侧标注 Y 坐标
|
|
501
501
|
draw.text((2, y + 2), str(y), fill=text_color, font=font_small)
|
|
502
502
|
|
|
503
|
-
# 第3
|
|
503
|
+
# 第3步:检测弹窗并标注(使用严格的置信度检测,避免误识别)
|
|
504
504
|
popup_info = None
|
|
505
505
|
close_positions = []
|
|
506
506
|
|
|
@@ -510,35 +510,12 @@ class BasicMobileToolsLite:
|
|
|
510
510
|
xml_string = self.client.u2.dump_hierarchy(compressed=False)
|
|
511
511
|
root = ET.fromstring(xml_string)
|
|
512
512
|
|
|
513
|
-
#
|
|
514
|
-
popup_bounds =
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
class_name = elem.attrib.get('class', '')
|
|
518
|
-
|
|
519
|
-
if not bounds_str:
|
|
520
|
-
continue
|
|
521
|
-
|
|
522
|
-
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
523
|
-
if not match:
|
|
524
|
-
continue
|
|
525
|
-
|
|
526
|
-
x1, y1, x2, y2 = map(int, match.groups())
|
|
527
|
-
width = x2 - x1
|
|
528
|
-
height = y2 - y1
|
|
529
|
-
area = width * height
|
|
530
|
-
screen_area = screen_width * screen_height
|
|
531
|
-
|
|
532
|
-
is_container = any(kw in class_name for kw in ['Layout', 'View', 'Dialog', 'Card'])
|
|
533
|
-
area_ratio = area / screen_area if screen_area > 0 else 0
|
|
534
|
-
is_not_fullscreen = (width < screen_width * 0.98 or height < screen_height * 0.98)
|
|
535
|
-
is_reasonable_size = 0.08 < area_ratio < 0.85
|
|
536
|
-
|
|
537
|
-
if is_container and is_not_fullscreen and is_reasonable_size and y1 > 50:
|
|
538
|
-
if popup_bounds is None or area > (popup_bounds[2] - popup_bounds[0]) * (popup_bounds[3] - popup_bounds[1]):
|
|
539
|
-
popup_bounds = (x1, y1, x2, y2)
|
|
513
|
+
# 使用严格的弹窗检测(置信度 >= 0.6 才认为是弹窗)
|
|
514
|
+
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
515
|
+
root, screen_width, screen_height
|
|
516
|
+
)
|
|
540
517
|
|
|
541
|
-
if popup_bounds:
|
|
518
|
+
if popup_bounds and popup_confidence >= 0.6:
|
|
542
519
|
px1, py1, px2, py2 = popup_bounds
|
|
543
520
|
popup_width = px2 - px1
|
|
544
521
|
popup_height = py2 - py1
|
|
@@ -769,41 +746,19 @@ class BasicMobileToolsLite:
|
|
|
769
746
|
'desc': elem['desc']
|
|
770
747
|
})
|
|
771
748
|
|
|
772
|
-
# 第3.5
|
|
749
|
+
# 第3.5步:检测弹窗区域(使用严格的置信度检测,避免误识别普通页面)
|
|
773
750
|
popup_bounds = None
|
|
751
|
+
popup_confidence = 0
|
|
774
752
|
|
|
775
753
|
if not self._is_ios():
|
|
776
754
|
try:
|
|
777
|
-
#
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
if not bounds_str:
|
|
783
|
-
continue
|
|
784
|
-
|
|
785
|
-
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
786
|
-
if not match:
|
|
787
|
-
continue
|
|
788
|
-
|
|
789
|
-
px1, py1, px2, py2 = map(int, match.groups())
|
|
790
|
-
p_width = px2 - px1
|
|
791
|
-
p_height = py2 - py1
|
|
792
|
-
p_area = p_width * p_height
|
|
793
|
-
screen_area = screen_width * screen_height
|
|
794
|
-
|
|
795
|
-
is_container = any(kw in class_name for kw in ['Layout', 'View', 'Dialog', 'Card', 'Frame'])
|
|
796
|
-
area_ratio = p_area / screen_area if screen_area > 0 else 0
|
|
797
|
-
is_not_fullscreen = (p_width < screen_width * 0.99 or p_height < screen_height * 0.95)
|
|
798
|
-
# 放宽面积范围:5% - 95%
|
|
799
|
-
is_reasonable_size = 0.05 < area_ratio < 0.95
|
|
800
|
-
|
|
801
|
-
if is_container and is_not_fullscreen and is_reasonable_size and py1 > 30:
|
|
802
|
-
if popup_bounds is None or p_area > (popup_bounds[2] - popup_bounds[0]) * (popup_bounds[3] - popup_bounds[1]):
|
|
803
|
-
popup_bounds = (px1, py1, px2, py2)
|
|
755
|
+
# 使用严格的弹窗检测(置信度 >= 0.6 才认为是弹窗)
|
|
756
|
+
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
757
|
+
root, screen_width, screen_height
|
|
758
|
+
)
|
|
804
759
|
|
|
805
760
|
# 如果检测到弹窗,标注弹窗边界(不再猜测X按钮位置)
|
|
806
|
-
if popup_bounds:
|
|
761
|
+
if popup_bounds and popup_confidence >= 0.6:
|
|
807
762
|
px1, py1, px2, py2 = popup_bounds
|
|
808
763
|
|
|
809
764
|
# 只画弹窗边框(蓝色),不再猜测X按钮位置
|
|
@@ -1200,8 +1155,16 @@ class BasicMobileToolsLite:
|
|
|
1200
1155
|
except Exception as e:
|
|
1201
1156
|
return {"success": False, "message": f"❌ 百分比点击失败: {e}"}
|
|
1202
1157
|
|
|
1203
|
-
def click_by_text(self, text: str, timeout: float = 3.0) -> Dict:
|
|
1204
|
-
"""通过文本点击 - 先查 XML 树,再精准匹配
|
|
1158
|
+
def click_by_text(self, text: str, timeout: float = 3.0, position: Optional[str] = None) -> Dict:
|
|
1159
|
+
"""通过文本点击 - 先查 XML 树,再精准匹配
|
|
1160
|
+
|
|
1161
|
+
Args:
|
|
1162
|
+
text: 元素的文本内容
|
|
1163
|
+
timeout: 超时时间
|
|
1164
|
+
position: 位置信息,当有多个相同文案时使用。支持:
|
|
1165
|
+
- 垂直方向: "top"/"upper"/"上", "bottom"/"lower"/"下", "middle"/"center"/"中"
|
|
1166
|
+
- 水平方向: "left"/"左", "right"/"右", "center"/"中"
|
|
1167
|
+
"""
|
|
1205
1168
|
try:
|
|
1206
1169
|
if self._is_ios():
|
|
1207
1170
|
ios_client = self._get_ios_client()
|
|
@@ -1217,14 +1180,24 @@ class BasicMobileToolsLite:
|
|
|
1217
1180
|
return {"success": False, "message": f"❌ 文本不存在: {text}"}
|
|
1218
1181
|
else:
|
|
1219
1182
|
# 🔍 先查 XML 树,找到元素及其属性
|
|
1220
|
-
found_elem = self._find_element_in_tree(text)
|
|
1183
|
+
found_elem = self._find_element_in_tree(text, position=position)
|
|
1221
1184
|
|
|
1222
1185
|
if found_elem:
|
|
1223
1186
|
attr_type = found_elem['attr_type']
|
|
1224
1187
|
attr_value = found_elem['attr_value']
|
|
1225
1188
|
bounds = found_elem.get('bounds')
|
|
1226
1189
|
|
|
1227
|
-
#
|
|
1190
|
+
# 如果有位置参数,直接使用坐标点击(避免 u2 选择器匹配到错误的元素)
|
|
1191
|
+
if position and bounds:
|
|
1192
|
+
x = (bounds[0] + bounds[2]) // 2
|
|
1193
|
+
y = (bounds[1] + bounds[3]) // 2
|
|
1194
|
+
self.client.u2.click(x, y)
|
|
1195
|
+
time.sleep(0.3)
|
|
1196
|
+
position_info = f" ({position})" if position else ""
|
|
1197
|
+
self._record_operation('click', element=text, x=x, y=y, ref=f"coords:{x},{y}")
|
|
1198
|
+
return {"success": True, "message": f"✅ 点击成功(坐标定位): '{text}'{position_info} @ ({x},{y})"}
|
|
1199
|
+
|
|
1200
|
+
# 没有位置参数时,使用选择器定位
|
|
1228
1201
|
if attr_type == 'text':
|
|
1229
1202
|
elem = self.client.u2(text=attr_value)
|
|
1230
1203
|
elif attr_type == 'textContains':
|
|
@@ -1239,8 +1212,9 @@ class BasicMobileToolsLite:
|
|
|
1239
1212
|
if elem and elem.exists(timeout=1):
|
|
1240
1213
|
elem.click()
|
|
1241
1214
|
time.sleep(0.3)
|
|
1215
|
+
position_info = f" ({position})" if position else ""
|
|
1242
1216
|
self._record_operation('click', element=text, ref=f"{attr_type}:{attr_value}")
|
|
1243
|
-
return {"success": True, "message": f"✅ 点击成功({attr_type}): '{text}'"}
|
|
1217
|
+
return {"success": True, "message": f"✅ 点击成功({attr_type}): '{text}'{position_info}"}
|
|
1244
1218
|
|
|
1245
1219
|
# 如果选择器失败,用坐标兜底
|
|
1246
1220
|
if bounds:
|
|
@@ -1248,24 +1222,37 @@ class BasicMobileToolsLite:
|
|
|
1248
1222
|
y = (bounds[1] + bounds[3]) // 2
|
|
1249
1223
|
self.client.u2.click(x, y)
|
|
1250
1224
|
time.sleep(0.3)
|
|
1225
|
+
position_info = f" ({position})" if position else ""
|
|
1251
1226
|
self._record_operation('click', element=text, x=x, y=y, ref=f"coords:{x},{y}")
|
|
1252
|
-
return {"success": True, "message": f"✅ 点击成功(坐标兜底): '{text}' @ ({x},{y})"}
|
|
1227
|
+
return {"success": True, "message": f"✅ 点击成功(坐标兜底): '{text}'{position_info} @ ({x},{y})"}
|
|
1253
1228
|
|
|
1254
1229
|
return {"success": False, "message": f"❌ 文本不存在: {text}"}
|
|
1255
1230
|
except Exception as e:
|
|
1256
1231
|
return {"success": False, "message": f"❌ 点击失败: {e}"}
|
|
1257
1232
|
|
|
1258
|
-
def _find_element_in_tree(self, text: str) -> Optional[Dict]:
|
|
1259
|
-
"""在 XML
|
|
1233
|
+
def _find_element_in_tree(self, text: str, position: Optional[str] = None) -> Optional[Dict]:
|
|
1234
|
+
"""在 XML 树中查找包含指定文本的元素,优先返回可点击的元素
|
|
1235
|
+
|
|
1236
|
+
Args:
|
|
1237
|
+
text: 要查找的文本
|
|
1238
|
+
position: 位置信息,用于在有多个相同文案时筛选
|
|
1239
|
+
"""
|
|
1260
1240
|
try:
|
|
1261
1241
|
xml = self.client.u2.dump_hierarchy(compressed=False)
|
|
1262
1242
|
import xml.etree.ElementTree as ET
|
|
1263
1243
|
root = ET.fromstring(xml)
|
|
1264
1244
|
|
|
1245
|
+
# 获取屏幕尺寸
|
|
1246
|
+
screen_width, screen_height = self.client.u2.window_size()
|
|
1247
|
+
|
|
1248
|
+
# 存储所有匹配的元素(包括不可点击的)
|
|
1249
|
+
matched_elements = []
|
|
1250
|
+
|
|
1265
1251
|
for elem in root.iter():
|
|
1266
1252
|
elem_text = elem.attrib.get('text', '')
|
|
1267
1253
|
elem_desc = elem.attrib.get('content-desc', '')
|
|
1268
1254
|
bounds_str = elem.attrib.get('bounds', '')
|
|
1255
|
+
clickable = elem.attrib.get('clickable', 'false').lower() == 'true'
|
|
1269
1256
|
|
|
1270
1257
|
# 解析 bounds
|
|
1271
1258
|
bounds = None
|
|
@@ -1275,24 +1262,108 @@ class BasicMobileToolsLite:
|
|
|
1275
1262
|
if len(match) == 4:
|
|
1276
1263
|
bounds = [int(x) for x in match]
|
|
1277
1264
|
|
|
1265
|
+
# 判断是否匹配
|
|
1266
|
+
is_match = False
|
|
1267
|
+
attr_type = None
|
|
1268
|
+
attr_value = None
|
|
1269
|
+
|
|
1278
1270
|
# 精确匹配 text
|
|
1279
1271
|
if elem_text == text:
|
|
1280
|
-
|
|
1281
|
-
|
|
1272
|
+
is_match = True
|
|
1273
|
+
attr_type = 'text'
|
|
1274
|
+
attr_value = text
|
|
1282
1275
|
# 精确匹配 content-desc
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1276
|
+
elif elem_desc == text:
|
|
1277
|
+
is_match = True
|
|
1278
|
+
attr_type = 'description'
|
|
1279
|
+
attr_value = text
|
|
1286
1280
|
# 模糊匹配 text
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1281
|
+
elif text in elem_text:
|
|
1282
|
+
is_match = True
|
|
1283
|
+
attr_type = 'textContains'
|
|
1284
|
+
attr_value = text
|
|
1290
1285
|
# 模糊匹配 content-desc
|
|
1291
|
-
|
|
1292
|
-
|
|
1286
|
+
elif text in elem_desc:
|
|
1287
|
+
is_match = True
|
|
1288
|
+
attr_type = 'descriptionContains'
|
|
1289
|
+
attr_value = text
|
|
1290
|
+
|
|
1291
|
+
if is_match and bounds:
|
|
1292
|
+
# 计算元素的中心点坐标
|
|
1293
|
+
center_x = (bounds[0] + bounds[2]) / 2
|
|
1294
|
+
center_y = (bounds[1] + bounds[3]) / 2
|
|
1295
|
+
|
|
1296
|
+
matched_elements.append({
|
|
1297
|
+
'attr_type': attr_type,
|
|
1298
|
+
'attr_value': attr_value,
|
|
1299
|
+
'bounds': bounds,
|
|
1300
|
+
'clickable': clickable,
|
|
1301
|
+
'center_x': center_x,
|
|
1302
|
+
'center_y': center_y
|
|
1303
|
+
})
|
|
1304
|
+
|
|
1305
|
+
if not matched_elements:
|
|
1306
|
+
return None
|
|
1307
|
+
|
|
1308
|
+
# 如果有位置信息,根据位置筛选
|
|
1309
|
+
if position and len(matched_elements) > 1:
|
|
1310
|
+
position_lower = position.lower()
|
|
1311
|
+
|
|
1312
|
+
# 根据位置信息排序
|
|
1313
|
+
if position_lower in ['top', 'upper', '上', '上方']:
|
|
1314
|
+
# 选择 y 坐标最小的(最上面的)
|
|
1315
|
+
matched_elements = sorted(matched_elements, key=lambda x: x['center_y'])
|
|
1316
|
+
elif position_lower in ['bottom', 'lower', '下', '下方', '底部']:
|
|
1317
|
+
# 选择 y 坐标最大的(最下面的)
|
|
1318
|
+
matched_elements = sorted(matched_elements, key=lambda x: x['center_y'], reverse=True)
|
|
1319
|
+
elif position_lower in ['left', '左', '左侧']:
|
|
1320
|
+
# 选择 x 坐标最小的(最左边的)
|
|
1321
|
+
matched_elements = sorted(matched_elements, key=lambda x: x['center_x'])
|
|
1322
|
+
elif position_lower in ['right', '右', '右侧']:
|
|
1323
|
+
# 选择 x 坐标最大的(最右边的)
|
|
1324
|
+
matched_elements = sorted(matched_elements, key=lambda x: x['center_x'], reverse=True)
|
|
1325
|
+
elif position_lower in ['middle', 'center', '中', '中间']:
|
|
1326
|
+
# 选择最接近屏幕中心的
|
|
1327
|
+
screen_mid_x = screen_width / 2
|
|
1328
|
+
screen_mid_y = screen_height / 2
|
|
1329
|
+
matched_elements = sorted(
|
|
1330
|
+
matched_elements,
|
|
1331
|
+
key=lambda x: abs(x['center_x'] - screen_mid_x) + abs(x['center_y'] - screen_mid_y)
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
# 如果有位置信息,优先返回排序后的第一个元素(最符合位置要求的)
|
|
1335
|
+
# 如果没有位置信息,优先返回可点击的元素
|
|
1336
|
+
if position and matched_elements:
|
|
1337
|
+
# 有位置信息时,直接返回排序后的第一个(最符合位置要求的)
|
|
1338
|
+
first_match = matched_elements[0]
|
|
1339
|
+
return {
|
|
1340
|
+
'attr_type': first_match['attr_type'],
|
|
1341
|
+
'attr_value': first_match['attr_value'],
|
|
1342
|
+
'bounds': first_match['bounds']
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
# 没有位置信息时,优先返回可点击的元素
|
|
1346
|
+
for match in matched_elements:
|
|
1347
|
+
if match['clickable']:
|
|
1348
|
+
return {
|
|
1349
|
+
'attr_type': match['attr_type'],
|
|
1350
|
+
'attr_value': match['attr_value'],
|
|
1351
|
+
'bounds': match['bounds']
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
# 如果没有可点击的元素,直接返回第一个匹配元素的 bounds(使用坐标点击)
|
|
1355
|
+
if matched_elements:
|
|
1356
|
+
first_match = matched_elements[0]
|
|
1357
|
+
return {
|
|
1358
|
+
'attr_type': first_match['attr_type'],
|
|
1359
|
+
'attr_value': first_match['attr_value'],
|
|
1360
|
+
'bounds': first_match['bounds']
|
|
1361
|
+
}
|
|
1293
1362
|
|
|
1294
1363
|
return None
|
|
1295
|
-
except Exception:
|
|
1364
|
+
except Exception as e:
|
|
1365
|
+
import traceback
|
|
1366
|
+
traceback.print_exc()
|
|
1296
1367
|
return None
|
|
1297
1368
|
|
|
1298
1369
|
def click_by_id(self, resource_id: str, index: int = 0) -> Dict:
|
|
@@ -2349,7 +2420,6 @@ class BasicMobileToolsLite:
|
|
|
2349
2420
|
for elem in root.iter():
|
|
2350
2421
|
text = elem.attrib.get('text', '')
|
|
2351
2422
|
content_desc = elem.attrib.get('content-desc', '')
|
|
2352
|
-
resource_id = elem.attrib.get('resource-id', '')
|
|
2353
2423
|
bounds_str = elem.attrib.get('bounds', '')
|
|
2354
2424
|
class_name = elem.attrib.get('class', '')
|
|
2355
2425
|
clickable = elem.attrib.get('clickable', 'false') == 'true'
|
|
@@ -2384,13 +2454,6 @@ class BasicMobileToolsLite:
|
|
|
2384
2454
|
score = 90
|
|
2385
2455
|
reason = f"描述='{content_desc}'"
|
|
2386
2456
|
|
|
2387
|
-
# 策略2.5:resource-id 包含关闭关键词(如 close_icon, ad_close 等)
|
|
2388
|
-
elif resource_id and any(kw in resource_id.lower() for kw in ['close', 'dismiss', 'skip', 'cancel']):
|
|
2389
|
-
score = 95
|
|
2390
|
-
# 提取简短的 id 名
|
|
2391
|
-
short_id = resource_id.split('/')[-1] if '/' in resource_id else resource_id
|
|
2392
|
-
reason = f"resource-id='{short_id}'"
|
|
2393
|
-
|
|
2394
2457
|
# 策略3:小尺寸的 clickable 元素(可能是 X 图标)
|
|
2395
2458
|
elif clickable:
|
|
2396
2459
|
min_size = max(20, int(screen_width * 0.03))
|
|
@@ -2425,9 +2488,7 @@ class BasicMobileToolsLite:
|
|
|
2425
2488
|
'center_y': center_y,
|
|
2426
2489
|
'x_percent': x_percent,
|
|
2427
2490
|
'y_percent': y_percent,
|
|
2428
|
-
'size': f"{width}x{height}"
|
|
2429
|
-
'resource_id': resource_id,
|
|
2430
|
-
'text': text
|
|
2491
|
+
'size': f"{width}x{height}"
|
|
2431
2492
|
})
|
|
2432
2493
|
|
|
2433
2494
|
if not candidates:
|
|
@@ -2453,16 +2514,7 @@ class BasicMobileToolsLite:
|
|
|
2453
2514
|
candidates.sort(key=lambda x: x['score'], reverse=True)
|
|
2454
2515
|
best = candidates[0]
|
|
2455
2516
|
|
|
2456
|
-
|
|
2457
|
-
if best.get('resource_id'):
|
|
2458
|
-
short_id = best['resource_id'].split('/')[-1] if '/' in best['resource_id'] else best['resource_id']
|
|
2459
|
-
click_cmd = f"mobile_click_by_id('{best['resource_id']}')"
|
|
2460
|
-
elif best.get('text') and best['text'] in ['×', 'X', 'x', '关闭', '取消', '跳过', '知道了']:
|
|
2461
|
-
click_cmd = f"mobile_click_by_text('{best['text']}')"
|
|
2462
|
-
else:
|
|
2463
|
-
click_cmd = f"mobile_click_by_percent({best['x_percent']}, {best['y_percent']})"
|
|
2464
|
-
|
|
2465
|
-
result = {
|
|
2517
|
+
return {
|
|
2466
2518
|
"success": True,
|
|
2467
2519
|
"message": f"✅ 找到可能的关闭按钮",
|
|
2468
2520
|
"best_candidate": {
|
|
@@ -2473,7 +2525,7 @@ class BasicMobileToolsLite:
|
|
|
2473
2525
|
"size": best['size'],
|
|
2474
2526
|
"score": best['score']
|
|
2475
2527
|
},
|
|
2476
|
-
"click_command":
|
|
2528
|
+
"click_command": f"mobile_click_by_percent({best['x_percent']}, {best['y_percent']})",
|
|
2477
2529
|
"other_candidates": [
|
|
2478
2530
|
{"reason": c['reason'], "percent": f"({c['x_percent']}%, {c['y_percent']}%)", "score": c['score']}
|
|
2479
2531
|
for c in candidates[1:4]
|
|
@@ -2481,14 +2533,6 @@ class BasicMobileToolsLite:
|
|
|
2481
2533
|
"screen_size": {"width": screen_width, "height": screen_height}
|
|
2482
2534
|
}
|
|
2483
2535
|
|
|
2484
|
-
# 如果有 resource-id,额外提供
|
|
2485
|
-
if best.get('resource_id'):
|
|
2486
|
-
result["best_candidate"]["resource_id"] = best['resource_id']
|
|
2487
|
-
if best.get('text'):
|
|
2488
|
-
result["best_candidate"]["text"] = best['text']
|
|
2489
|
-
|
|
2490
|
-
return result
|
|
2491
|
-
|
|
2492
2536
|
except Exception as e:
|
|
2493
2537
|
return {"success": False, "message": f"❌ 查找关闭按钮失败: {e}"}
|
|
2494
2538
|
|
|
@@ -2533,53 +2577,13 @@ class BasicMobileToolsLite:
|
|
|
2533
2577
|
root = ET.fromstring(xml_string)
|
|
2534
2578
|
all_elements = list(root.iter())
|
|
2535
2579
|
|
|
2536
|
-
# =====
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
bounds_str = elem.attrib.get('bounds', '')
|
|
2541
|
-
class_name = elem.attrib.get('class', '')
|
|
2542
|
-
|
|
2543
|
-
if not bounds_str:
|
|
2544
|
-
continue
|
|
2545
|
-
|
|
2546
|
-
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
2547
|
-
if not match:
|
|
2548
|
-
continue
|
|
2549
|
-
|
|
2550
|
-
x1, y1, x2, y2 = map(int, match.groups())
|
|
2551
|
-
width = x2 - x1
|
|
2552
|
-
height = y2 - y1
|
|
2553
|
-
area = width * height
|
|
2554
|
-
screen_area = screen_width * screen_height
|
|
2555
|
-
|
|
2556
|
-
# 弹窗容器特征:
|
|
2557
|
-
# 1. 面积在屏幕的 10%-90% 之间(非全屏)
|
|
2558
|
-
# 2. 宽度或高度不等于屏幕尺寸
|
|
2559
|
-
# 3. 是容器类型(Layout/View/Dialog)
|
|
2560
|
-
is_container = any(kw in class_name for kw in ['Layout', 'View', 'Dialog', 'Card', 'Container'])
|
|
2561
|
-
area_ratio = area / screen_area
|
|
2562
|
-
is_not_fullscreen = (width < screen_width * 0.98 or height < screen_height * 0.98)
|
|
2563
|
-
is_reasonable_size = 0.08 < area_ratio < 0.9
|
|
2564
|
-
|
|
2565
|
-
# 排除状态栏区域(y1 通常很小)
|
|
2566
|
-
is_below_statusbar = y1 > 50
|
|
2567
|
-
|
|
2568
|
-
if is_container and is_not_fullscreen and is_reasonable_size and is_below_statusbar:
|
|
2569
|
-
popup_containers.append({
|
|
2570
|
-
'bounds': (x1, y1, x2, y2),
|
|
2571
|
-
'bounds_str': bounds_str,
|
|
2572
|
-
'area': area,
|
|
2573
|
-
'area_ratio': area_ratio,
|
|
2574
|
-
'idx': idx, # 元素在 XML 中的顺序(越后越上层)
|
|
2575
|
-
'class': class_name
|
|
2576
|
-
})
|
|
2580
|
+
# ===== 第一步:使用严格的置信度检测弹窗区域 =====
|
|
2581
|
+
popup_bounds, popup_confidence = self._detect_popup_with_confidence(
|
|
2582
|
+
root, screen_width, screen_height
|
|
2583
|
+
)
|
|
2577
2584
|
|
|
2578
|
-
#
|
|
2579
|
-
|
|
2580
|
-
# 按 XML 顺序倒序(后出现的在上层),然后按面积适中程度排序
|
|
2581
|
-
popup_containers.sort(key=lambda x: (x['idx'], -abs(x['area_ratio'] - 0.3)), reverse=True)
|
|
2582
|
-
popup_bounds = popup_containers[0]['bounds']
|
|
2585
|
+
# 如果置信度不够高,记录但继续尝试查找关闭按钮
|
|
2586
|
+
popup_detected = popup_bounds is not None and popup_confidence >= 0.6
|
|
2583
2587
|
|
|
2584
2588
|
# ===== 第二步:在弹窗范围内查找关闭按钮 =====
|
|
2585
2589
|
for idx, elem in enumerate(all_elements):
|
|
@@ -2711,15 +2715,15 @@ class BasicMobileToolsLite:
|
|
|
2711
2715
|
'content_desc': content_desc,
|
|
2712
2716
|
'x_percent': round(rel_x * 100, 1),
|
|
2713
2717
|
'y_percent': round(rel_y * 100, 1),
|
|
2714
|
-
'in_popup':
|
|
2718
|
+
'in_popup': popup_detected
|
|
2715
2719
|
})
|
|
2716
2720
|
|
|
2717
2721
|
except ET.ParseError:
|
|
2718
2722
|
pass
|
|
2719
2723
|
|
|
2720
2724
|
if not close_candidates:
|
|
2721
|
-
#
|
|
2722
|
-
if popup_bounds:
|
|
2725
|
+
# 如果检测到高置信度的弹窗区域,先尝试点击常见的关闭按钮位置
|
|
2726
|
+
if popup_detected and popup_bounds:
|
|
2723
2727
|
px1, py1, px2, py2 = popup_bounds
|
|
2724
2728
|
popup_width = px2 - px1
|
|
2725
2729
|
popup_height = py2 - py1
|
|
@@ -2852,8 +2856,9 @@ class BasicMobileToolsLite:
|
|
|
2852
2856
|
"percent": (best['x_percent'], best['y_percent'])
|
|
2853
2857
|
},
|
|
2854
2858
|
"screenshot": screenshot_result.get("screenshot_path", ""),
|
|
2855
|
-
"popup_detected":
|
|
2856
|
-
"
|
|
2859
|
+
"popup_detected": popup_detected,
|
|
2860
|
+
"popup_confidence": popup_confidence if popup_bounds else 0,
|
|
2861
|
+
"popup_bounds": f"[{popup_bounds[0]},{popup_bounds[1]}][{popup_bounds[2]},{popup_bounds[3]}]" if popup_detected else None,
|
|
2857
2862
|
"app_check": app_check,
|
|
2858
2863
|
"return_to_app": return_result,
|
|
2859
2864
|
"other_candidates": [
|
|
@@ -2912,6 +2917,348 @@ class BasicMobileToolsLite:
|
|
|
2912
2917
|
return 0.8
|
|
2913
2918
|
else: # 中间区域
|
|
2914
2919
|
return 0.5
|
|
2920
|
+
|
|
2921
|
+
def _detect_popup_with_confidence(self, root, screen_width: int, screen_height: int) -> tuple:
|
|
2922
|
+
"""严格的弹窗检测 - 使用置信度评分,避免误识别普通页面
|
|
2923
|
+
|
|
2924
|
+
真正的弹窗特征:
|
|
2925
|
+
1. class 名称包含 Dialog/Popup/Alert/Modal/BottomSheet(强特征)
|
|
2926
|
+
2. resource-id 包含 dialog/popup/alert/modal(强特征)
|
|
2927
|
+
3. 有遮罩层(大面积半透明 View 在弹窗之前)
|
|
2928
|
+
4. 居中显示且非全屏
|
|
2929
|
+
5. XML 层级靠后且包含可交互元素
|
|
2930
|
+
|
|
2931
|
+
Returns:
|
|
2932
|
+
(popup_bounds, confidence) 或 (None, 0)
|
|
2933
|
+
confidence >= 0.6 才认为是弹窗
|
|
2934
|
+
"""
|
|
2935
|
+
import re
|
|
2936
|
+
|
|
2937
|
+
screen_area = screen_width * screen_height
|
|
2938
|
+
|
|
2939
|
+
# 收集所有元素信息
|
|
2940
|
+
all_elements = []
|
|
2941
|
+
for idx, elem in enumerate(root.iter()):
|
|
2942
|
+
bounds_str = elem.attrib.get('bounds', '')
|
|
2943
|
+
if not bounds_str:
|
|
2944
|
+
continue
|
|
2945
|
+
|
|
2946
|
+
match = re.match(r'\[(\d+),(\d+)\]\[(\d+),(\d+)\]', bounds_str)
|
|
2947
|
+
if not match:
|
|
2948
|
+
continue
|
|
2949
|
+
|
|
2950
|
+
x1, y1, x2, y2 = map(int, match.groups())
|
|
2951
|
+
width = x2 - x1
|
|
2952
|
+
height = y2 - y1
|
|
2953
|
+
area = width * height
|
|
2954
|
+
|
|
2955
|
+
class_name = elem.attrib.get('class', '')
|
|
2956
|
+
resource_id = elem.attrib.get('resource-id', '')
|
|
2957
|
+
clickable = elem.attrib.get('clickable', 'false') == 'true'
|
|
2958
|
+
|
|
2959
|
+
all_elements.append({
|
|
2960
|
+
'idx': idx,
|
|
2961
|
+
'bounds': (x1, y1, x2, y2),
|
|
2962
|
+
'width': width,
|
|
2963
|
+
'height': height,
|
|
2964
|
+
'area': area,
|
|
2965
|
+
'area_ratio': area / screen_area if screen_area > 0 else 0,
|
|
2966
|
+
'class': class_name,
|
|
2967
|
+
'resource_id': resource_id,
|
|
2968
|
+
'clickable': clickable,
|
|
2969
|
+
'center_x': (x1 + x2) // 2,
|
|
2970
|
+
'center_y': (y1 + y2) // 2,
|
|
2971
|
+
})
|
|
2972
|
+
|
|
2973
|
+
if not all_elements:
|
|
2974
|
+
return None, 0
|
|
2975
|
+
|
|
2976
|
+
# 弹窗检测关键词
|
|
2977
|
+
dialog_class_keywords = ['Dialog', 'Popup', 'Alert', 'Modal', 'BottomSheet', 'PopupWindow']
|
|
2978
|
+
dialog_id_keywords = ['dialog', 'popup', 'alert', 'modal', 'bottom_sheet', 'overlay', 'mask']
|
|
2979
|
+
|
|
2980
|
+
popup_candidates = []
|
|
2981
|
+
has_mask_layer = False
|
|
2982
|
+
mask_idx = -1
|
|
2983
|
+
|
|
2984
|
+
# 【新增】检测浮动关闭按钮(小尺寸 clickable ImageView,位于屏幕中央偏上)
|
|
2985
|
+
floating_close_buttons = []
|
|
2986
|
+
for elem in all_elements:
|
|
2987
|
+
x1, y1, x2, y2 = elem['bounds']
|
|
2988
|
+
class_name = elem['class']
|
|
2989
|
+
width = elem['width']
|
|
2990
|
+
height = elem['height']
|
|
2991
|
+
|
|
2992
|
+
# 浮动关闭按钮特征:
|
|
2993
|
+
# 1. 小尺寸(50-200px)
|
|
2994
|
+
# 2. clickable 或 ImageView
|
|
2995
|
+
# 3. 位于屏幕中央区域的上半部分
|
|
2996
|
+
# 4. 接近正方形
|
|
2997
|
+
is_small = 50 < width < 200 and 50 < height < 200
|
|
2998
|
+
is_square_like = 0.5 < (width / height if height > 0 else 0) < 2.0
|
|
2999
|
+
is_clickable_image = elem['clickable'] or 'Image' in class_name
|
|
3000
|
+
is_upper_center = (screen_width * 0.2 < x1 < screen_width * 0.8 and
|
|
3001
|
+
y1 < screen_height * 0.5)
|
|
3002
|
+
|
|
3003
|
+
if is_small and is_square_like and is_clickable_image and is_upper_center:
|
|
3004
|
+
floating_close_buttons.append({
|
|
3005
|
+
'bounds': elem['bounds'],
|
|
3006
|
+
'center_x': elem['center_x'],
|
|
3007
|
+
'center_y': elem['center_y'],
|
|
3008
|
+
'idx': elem['idx']
|
|
3009
|
+
})
|
|
3010
|
+
|
|
3011
|
+
for elem in all_elements:
|
|
3012
|
+
x1, y1, x2, y2 = elem['bounds']
|
|
3013
|
+
class_name = elem['class']
|
|
3014
|
+
resource_id = elem['resource_id']
|
|
3015
|
+
area_ratio = elem['area_ratio']
|
|
3016
|
+
|
|
3017
|
+
# 检测遮罩层(大面积、几乎全屏、通常是 FrameLayout/View)
|
|
3018
|
+
if area_ratio > 0.85 and elem['width'] >= screen_width * 0.95:
|
|
3019
|
+
# 可能是遮罩层,记录位置
|
|
3020
|
+
if 'FrameLayout' in class_name or 'View' in class_name:
|
|
3021
|
+
has_mask_layer = True
|
|
3022
|
+
mask_idx = elem['idx']
|
|
3023
|
+
|
|
3024
|
+
# 跳过全屏元素
|
|
3025
|
+
if area_ratio > 0.9:
|
|
3026
|
+
continue
|
|
3027
|
+
|
|
3028
|
+
# 跳过太小的元素
|
|
3029
|
+
if area_ratio < 0.05:
|
|
3030
|
+
continue
|
|
3031
|
+
|
|
3032
|
+
# 跳过状态栏区域
|
|
3033
|
+
if y1 < 50:
|
|
3034
|
+
continue
|
|
3035
|
+
|
|
3036
|
+
confidence = 0.0
|
|
3037
|
+
|
|
3038
|
+
# 【强特征】class 名称包含弹窗关键词 (+0.5)
|
|
3039
|
+
if any(kw in class_name for kw in dialog_class_keywords):
|
|
3040
|
+
confidence += 0.5
|
|
3041
|
+
|
|
3042
|
+
# 【强特征】resource-id 包含弹窗关键词 (+0.4)
|
|
3043
|
+
if any(kw in resource_id.lower() for kw in dialog_id_keywords):
|
|
3044
|
+
confidence += 0.4
|
|
3045
|
+
|
|
3046
|
+
# 【中等特征】居中显示 (+0.2)
|
|
3047
|
+
center_x = elem['center_x']
|
|
3048
|
+
center_y = elem['center_y']
|
|
3049
|
+
is_centered_x = abs(center_x - screen_width / 2) < screen_width * 0.15
|
|
3050
|
+
is_centered_y = abs(center_y - screen_height / 2) < screen_height * 0.25
|
|
3051
|
+
if is_centered_x and is_centered_y:
|
|
3052
|
+
confidence += 0.2
|
|
3053
|
+
elif is_centered_x:
|
|
3054
|
+
confidence += 0.1
|
|
3055
|
+
|
|
3056
|
+
# 【中等特征】非全屏但有一定大小 (+0.15)
|
|
3057
|
+
if 0.15 < area_ratio < 0.75:
|
|
3058
|
+
confidence += 0.15
|
|
3059
|
+
|
|
3060
|
+
# 【弱特征】XML 顺序靠后(在视图层级上层)(+0.1)
|
|
3061
|
+
if elem['idx'] > len(all_elements) * 0.5:
|
|
3062
|
+
confidence += 0.1
|
|
3063
|
+
|
|
3064
|
+
# 【弱特征】有遮罩层且在遮罩层之后 (+0.15)
|
|
3065
|
+
if has_mask_layer and elem['idx'] > mask_idx:
|
|
3066
|
+
confidence += 0.15
|
|
3067
|
+
|
|
3068
|
+
# 【新增强特征】有浮动关闭按钮在此容器上方附近 (+0.4)
|
|
3069
|
+
# 这是很多 App 弹窗的典型设计:内容区域 + 上方的 X 按钮
|
|
3070
|
+
for close_btn in floating_close_buttons:
|
|
3071
|
+
btn_x, btn_y = close_btn['center_x'], close_btn['center_y']
|
|
3072
|
+
# 检查关闭按钮是否在容器的上方(扩大范围到 400px)
|
|
3073
|
+
is_above_container = (
|
|
3074
|
+
x1 - 100 < btn_x < x2 + 100 and # 在容器水平范围内
|
|
3075
|
+
y1 - 400 < btn_y < y1 + 100 # 在容器上方 400px 范围内
|
|
3076
|
+
)
|
|
3077
|
+
if is_above_container:
|
|
3078
|
+
confidence += 0.4
|
|
3079
|
+
break # 只加一次分
|
|
3080
|
+
|
|
3081
|
+
# 只有达到阈值才加入候选
|
|
3082
|
+
if confidence >= 0.3:
|
|
3083
|
+
popup_candidates.append({
|
|
3084
|
+
'bounds': elem['bounds'],
|
|
3085
|
+
'confidence': confidence,
|
|
3086
|
+
'class': class_name,
|
|
3087
|
+
'resource_id': resource_id,
|
|
3088
|
+
'idx': elem['idx']
|
|
3089
|
+
})
|
|
3090
|
+
|
|
3091
|
+
if not popup_candidates:
|
|
3092
|
+
return None, 0
|
|
3093
|
+
|
|
3094
|
+
# 选择置信度最高的
|
|
3095
|
+
popup_candidates.sort(key=lambda x: (x['confidence'], x['idx']), reverse=True)
|
|
3096
|
+
best = popup_candidates[0]
|
|
3097
|
+
|
|
3098
|
+
# 只有置信度 >= 0.6 才返回弹窗
|
|
3099
|
+
if best['confidence'] >= 0.6:
|
|
3100
|
+
return best['bounds'], best['confidence']
|
|
3101
|
+
|
|
3102
|
+
return None, best['confidence']
|
|
3103
|
+
|
|
3104
|
+
def start_toast_watch(self) -> Dict:
|
|
3105
|
+
"""开始监听 Toast(仅 Android)
|
|
3106
|
+
|
|
3107
|
+
⚠️ 必须在执行操作之前调用!
|
|
3108
|
+
|
|
3109
|
+
正确流程:
|
|
3110
|
+
1. 调用 mobile_start_toast_watch() 开始监听
|
|
3111
|
+
2. 执行操作(如点击提交按钮)
|
|
3112
|
+
3. 调用 mobile_get_toast() 获取 Toast 内容
|
|
3113
|
+
|
|
3114
|
+
Returns:
|
|
3115
|
+
监听状态
|
|
3116
|
+
"""
|
|
3117
|
+
if self._is_ios():
|
|
3118
|
+
return {
|
|
3119
|
+
"success": False,
|
|
3120
|
+
"message": "❌ iOS 不支持 Toast 检测,Toast 是 Android 特有功能"
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
try:
|
|
3124
|
+
# 清除缓存并开始监听
|
|
3125
|
+
self.client.u2.toast.reset()
|
|
3126
|
+
return {
|
|
3127
|
+
"success": True,
|
|
3128
|
+
"message": "✅ Toast 监听已开启,请立即执行操作,然后调用 mobile_get_toast 获取结果"
|
|
3129
|
+
}
|
|
3130
|
+
except Exception as e:
|
|
3131
|
+
return {
|
|
3132
|
+
"success": False,
|
|
3133
|
+
"message": f"❌ 开启 Toast 监听失败: {e}"
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
def get_toast(self, timeout: float = 5.0, reset_first: bool = False) -> Dict:
|
|
3137
|
+
"""获取 Toast 消息(仅 Android)
|
|
3138
|
+
|
|
3139
|
+
Toast 是 Android 系统级的短暂提示消息,常用于显示操作结果。
|
|
3140
|
+
|
|
3141
|
+
⚠️ 推荐用法(两步走):
|
|
3142
|
+
1. 先调用 mobile_start_toast_watch() 开始监听
|
|
3143
|
+
2. 执行操作(如点击提交按钮)
|
|
3144
|
+
3. 调用 mobile_get_toast() 获取 Toast
|
|
3145
|
+
|
|
3146
|
+
或者设置 reset_first=True,会自动 reset 后等待(适合操作已自动触发的场景)
|
|
3147
|
+
|
|
3148
|
+
Args:
|
|
3149
|
+
timeout: 等待 Toast 出现的超时时间(秒),默认 5 秒
|
|
3150
|
+
reset_first: 是否先 reset(清除旧缓存),默认 False
|
|
3151
|
+
|
|
3152
|
+
Returns:
|
|
3153
|
+
包含 Toast 消息的字典
|
|
3154
|
+
"""
|
|
3155
|
+
if self._is_ios():
|
|
3156
|
+
return {
|
|
3157
|
+
"success": False,
|
|
3158
|
+
"message": "❌ iOS 不支持 Toast 检测,Toast 是 Android 特有功能"
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
try:
|
|
3162
|
+
if reset_first:
|
|
3163
|
+
# 清除旧缓存,适合等待即将出现的 Toast
|
|
3164
|
+
self.client.u2.toast.reset()
|
|
3165
|
+
|
|
3166
|
+
# 等待并获取 Toast 消息
|
|
3167
|
+
toast_message = self.client.u2.toast.get_message(
|
|
3168
|
+
wait_timeout=timeout,
|
|
3169
|
+
default=None
|
|
3170
|
+
)
|
|
3171
|
+
|
|
3172
|
+
if toast_message:
|
|
3173
|
+
return {
|
|
3174
|
+
"success": True,
|
|
3175
|
+
"toast_found": True,
|
|
3176
|
+
"message": toast_message,
|
|
3177
|
+
"tip": "Toast 消息获取成功"
|
|
3178
|
+
}
|
|
3179
|
+
else:
|
|
3180
|
+
return {
|
|
3181
|
+
"success": True,
|
|
3182
|
+
"toast_found": False,
|
|
3183
|
+
"message": None,
|
|
3184
|
+
"tip": f"在 {timeout} 秒内未检测到 Toast。提示:先调用 mobile_start_toast_watch,再执行操作,最后调用此工具"
|
|
3185
|
+
}
|
|
3186
|
+
except Exception as e:
|
|
3187
|
+
return {
|
|
3188
|
+
"success": False,
|
|
3189
|
+
"message": f"❌ 获取 Toast 失败: {e}"
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
def assert_toast(self, expected_text: str, timeout: float = 5.0, contains: bool = True) -> Dict:
|
|
3193
|
+
"""断言 Toast 消息(仅 Android)
|
|
3194
|
+
|
|
3195
|
+
等待 Toast 出现并验证内容是否符合预期。
|
|
3196
|
+
|
|
3197
|
+
⚠️ 推荐用法:先调用 mobile_start_toast_watch,再执行操作,最后调用此工具
|
|
3198
|
+
|
|
3199
|
+
Args:
|
|
3200
|
+
expected_text: 期望的 Toast 文本
|
|
3201
|
+
timeout: 等待超时时间(秒)
|
|
3202
|
+
contains: True 表示包含匹配,False 表示精确匹配
|
|
3203
|
+
|
|
3204
|
+
Returns:
|
|
3205
|
+
断言结果
|
|
3206
|
+
"""
|
|
3207
|
+
if self._is_ios():
|
|
3208
|
+
return {
|
|
3209
|
+
"success": False,
|
|
3210
|
+
"passed": False,
|
|
3211
|
+
"message": "❌ iOS 不支持 Toast 检测"
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
try:
|
|
3215
|
+
# 获取 Toast(不 reset,假设之前已经调用过 start_toast_watch)
|
|
3216
|
+
toast_message = self.client.u2.toast.get_message(
|
|
3217
|
+
wait_timeout=timeout,
|
|
3218
|
+
default=None
|
|
3219
|
+
)
|
|
3220
|
+
|
|
3221
|
+
if toast_message is None:
|
|
3222
|
+
return {
|
|
3223
|
+
"success": True,
|
|
3224
|
+
"passed": False,
|
|
3225
|
+
"expected": expected_text,
|
|
3226
|
+
"actual": None,
|
|
3227
|
+
"message": f"❌ 断言失败:未检测到 Toast 消息"
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
# 匹配检查
|
|
3231
|
+
if contains:
|
|
3232
|
+
passed = expected_text in toast_message
|
|
3233
|
+
match_type = "包含"
|
|
3234
|
+
else:
|
|
3235
|
+
passed = expected_text == toast_message
|
|
3236
|
+
match_type = "精确"
|
|
3237
|
+
|
|
3238
|
+
if passed:
|
|
3239
|
+
return {
|
|
3240
|
+
"success": True,
|
|
3241
|
+
"passed": True,
|
|
3242
|
+
"expected": expected_text,
|
|
3243
|
+
"actual": toast_message,
|
|
3244
|
+
"match_type": match_type,
|
|
3245
|
+
"message": f"✅ Toast 断言通过:'{toast_message}'"
|
|
3246
|
+
}
|
|
3247
|
+
else:
|
|
3248
|
+
return {
|
|
3249
|
+
"success": True,
|
|
3250
|
+
"passed": False,
|
|
3251
|
+
"expected": expected_text,
|
|
3252
|
+
"actual": toast_message,
|
|
3253
|
+
"match_type": match_type,
|
|
3254
|
+
"message": f"❌ Toast 断言失败:期望 '{expected_text}',实际 '{toast_message}'"
|
|
3255
|
+
}
|
|
3256
|
+
except Exception as e:
|
|
3257
|
+
return {
|
|
3258
|
+
"success": False,
|
|
3259
|
+
"passed": False,
|
|
3260
|
+
"message": f"❌ Toast 断言异常: {e}"
|
|
3261
|
+
}
|
|
2915
3262
|
|
|
2916
3263
|
def assert_text(self, text: str) -> Dict:
|
|
2917
3264
|
"""检查页面是否包含文本(支持精确匹配和包含匹配)"""
|
|
@@ -2997,8 +3344,8 @@ class BasicMobileToolsLite:
|
|
|
2997
3344
|
f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
2998
3345
|
"",
|
|
2999
3346
|
"定位策略(按优先级):",
|
|
3000
|
-
"1.
|
|
3001
|
-
"2.
|
|
3347
|
+
"1. 文本定位 - 最稳定,跨设备兼容",
|
|
3348
|
+
"2. ID 定位 - 稳定,跨设备兼容",
|
|
3002
3349
|
"3. 百分比定位 - 跨分辨率兼容(坐标自动转换)",
|
|
3003
3350
|
f'"""',
|
|
3004
3351
|
"import time",
|
|
@@ -3113,21 +3460,21 @@ class BasicMobileToolsLite:
|
|
|
3113
3460
|
is_coords_ref = ref.startswith('coords_') or ref.startswith('coords:')
|
|
3114
3461
|
is_percent_ref = ref.startswith('percent_')
|
|
3115
3462
|
|
|
3116
|
-
#
|
|
3117
|
-
if ref and
|
|
3118
|
-
# 1️⃣
|
|
3119
|
-
script_lines.append(f" # 步骤{step_num}:
|
|
3120
|
-
script_lines.append(f" safe_click(d, d(resourceId='{ref}'))")
|
|
3121
|
-
elif ref and not is_coords_ref and not is_percent_ref and ':' not in ref:
|
|
3122
|
-
# 2️⃣ 使用文本(稳定)- 排除 "text:xxx" 等带冒号的格式
|
|
3123
|
-
script_lines.append(f" # 步骤{step_num}: 点击文本 '{ref}' (文本定位)")
|
|
3463
|
+
# 优先级:文本 > ID > 百分比 > 坐标(兜底)
|
|
3464
|
+
if ref and not is_coords_ref and not is_percent_ref and ':' not in ref:
|
|
3465
|
+
# 1️⃣ 使用文本(最稳定,优先)- 排除 "text:xxx" 等带冒号的格式
|
|
3466
|
+
script_lines.append(f" # 步骤{step_num}: 点击文本 '{ref}' (文本定位,最稳定)")
|
|
3124
3467
|
script_lines.append(f" safe_click(d, d(text='{ref}'))")
|
|
3125
3468
|
elif ref and ':' in ref and not is_coords_ref and not is_percent_ref:
|
|
3126
|
-
#
|
|
3469
|
+
# 1️⃣-b 使用文本(Android 的 text:xxx 或 description:xxx 格式)
|
|
3127
3470
|
# 提取冒号后面的实际文本值
|
|
3128
3471
|
actual_text = ref.split(':', 1)[1] if ':' in ref else ref
|
|
3129
|
-
script_lines.append(f" # 步骤{step_num}: 点击文本 '{actual_text}' (
|
|
3472
|
+
script_lines.append(f" # 步骤{step_num}: 点击文本 '{actual_text}' (文本定位,最稳定)")
|
|
3130
3473
|
script_lines.append(f" safe_click(d, d(text='{actual_text}'))")
|
|
3474
|
+
elif ref and (':id/' in ref or ref.startswith('com.')):
|
|
3475
|
+
# 2️⃣ 使用 resource-id(稳定)
|
|
3476
|
+
script_lines.append(f" # 步骤{step_num}: 点击元素 (ID定位)")
|
|
3477
|
+
script_lines.append(f" safe_click(d, d(resourceId='{ref}'))")
|
|
3131
3478
|
elif has_percent:
|
|
3132
3479
|
# 3️⃣ 使用百分比(跨分辨率兼容)
|
|
3133
3480
|
x_pct = op['x_percent']
|
|
@@ -3193,19 +3540,20 @@ class BasicMobileToolsLite:
|
|
|
3193
3540
|
is_coords_ref = ref.startswith('coords_') or ref.startswith('coords:')
|
|
3194
3541
|
is_percent_ref = ref.startswith('percent_')
|
|
3195
3542
|
|
|
3196
|
-
#
|
|
3197
|
-
if ref and
|
|
3198
|
-
#
|
|
3199
|
-
script_lines.append(f" # 步骤{step_num}:
|
|
3200
|
-
script_lines.append(f" d(resourceId='{ref}').long_click(duration={duration})")
|
|
3201
|
-
elif ref and not is_coords_ref and not is_percent_ref and ':' not in ref:
|
|
3202
|
-
# 使用文本
|
|
3203
|
-
script_lines.append(f" # 步骤{step_num}: 长按文本 '{ref}' (文本定位)")
|
|
3543
|
+
# 优先级:文本 > ID > 百分比 > 坐标
|
|
3544
|
+
if ref and not is_coords_ref and not is_percent_ref and ':' not in ref:
|
|
3545
|
+
# 1️⃣ 使用文本(最稳定,优先)
|
|
3546
|
+
script_lines.append(f" # 步骤{step_num}: 长按文本 '{ref}' (文本定位,最稳定)")
|
|
3204
3547
|
script_lines.append(f" d(text='{ref}').long_click(duration={duration})")
|
|
3205
3548
|
elif ref and ':' in ref and not is_coords_ref and not is_percent_ref:
|
|
3549
|
+
# 1️⃣-b 使用文本(Android 的 text:xxx 或 description:xxx 格式)
|
|
3206
3550
|
actual_text = ref.split(':', 1)[1] if ':' in ref else ref
|
|
3207
|
-
script_lines.append(f" # 步骤{step_num}: 长按文本 '{actual_text}' (
|
|
3551
|
+
script_lines.append(f" # 步骤{step_num}: 长按文本 '{actual_text}' (文本定位,最稳定)")
|
|
3208
3552
|
script_lines.append(f" d(text='{actual_text}').long_click(duration={duration})")
|
|
3553
|
+
elif ref and (':id/' in ref or ref.startswith('com.')):
|
|
3554
|
+
# 2️⃣ 使用 resource-id(稳定)
|
|
3555
|
+
script_lines.append(f" # 步骤{step_num}: 长按元素 (ID定位)")
|
|
3556
|
+
script_lines.append(f" d(resourceId='{ref}').long_click(duration={duration})")
|
|
3209
3557
|
elif has_percent:
|
|
3210
3558
|
# 使用百分比
|
|
3211
3559
|
x_pct = op['x_percent']
|
|
@@ -3469,16 +3817,6 @@ class BasicMobileToolsLite:
|
|
|
3469
3817
|
reason = f"文本含'{kw}'"
|
|
3470
3818
|
break
|
|
3471
3819
|
|
|
3472
|
-
# resource-id 匹配(如 close_icon, ad_close 等)
|
|
3473
|
-
if resource_id:
|
|
3474
|
-
res_id_lower = resource_id.lower()
|
|
3475
|
-
for kw in ['close', 'dismiss', 'skip', 'cancel']:
|
|
3476
|
-
if kw in res_id_lower:
|
|
3477
|
-
score += 9
|
|
3478
|
-
short_id = resource_id.split('/')[-1] if '/' in resource_id else resource_id
|
|
3479
|
-
reason = f"resource-id='{short_id}'"
|
|
3480
|
-
break
|
|
3481
|
-
|
|
3482
3820
|
# content-desc 匹配
|
|
3483
3821
|
for kw in close_content_desc:
|
|
3484
3822
|
if kw.lower() in content_desc.lower():
|
|
@@ -206,7 +206,7 @@ class MobileMCPServer:
|
|
|
206
206
|
name="mobile_list_elements",
|
|
207
207
|
description="📋 列出页面所有可交互元素\n\n"
|
|
208
208
|
"⚠️ 【重要】点击元素前必须先调用此工具!\n"
|
|
209
|
-
"如果元素在控件树中存在,使用
|
|
209
|
+
"如果元素在控件树中存在,使用 click_by_text 或 click_by_id 定位。\n"
|
|
210
210
|
"只有当此工具返回空或找不到目标元素时,才使用截图+坐标方式。\n\n"
|
|
211
211
|
"📌 控件树定位优势:\n"
|
|
212
212
|
"- 实时检测元素是否存在\n"
|
|
@@ -313,14 +313,21 @@ class MobileMCPServer:
|
|
|
313
313
|
# ==================== 点击操作 ====================
|
|
314
314
|
tools.append(Tool(
|
|
315
315
|
name="mobile_click_by_text",
|
|
316
|
-
description="👆
|
|
316
|
+
description="👆 通过文本点击元素(最推荐)\n\n"
|
|
317
|
+
"✅ 最稳定的定位方式,跨设备兼容\n"
|
|
317
318
|
"✅ 实时检测元素是否存在,元素不存在会报错\n"
|
|
318
319
|
"✅ 不会误点击到其他位置\n"
|
|
319
|
-
"📋 使用前先调用 mobile_list_elements
|
|
320
|
+
"📋 使用前先调用 mobile_list_elements 确认元素文本\n"
|
|
321
|
+
"💡 定位优先级:文本 > ID > 百分比 > 坐标\n\n"
|
|
322
|
+
"📍 当页面有多个相同文案时,可使用 position 参数指定位置:\n"
|
|
323
|
+
" - 垂直方向: \"top\"/\"upper\"/\"上\", \"bottom\"/\"lower\"/\"下\", \"middle\"/\"center\"/\"中\"\n"
|
|
324
|
+
" - 水平方向: \"left\"/\"左\", \"right\"/\"右\", \"center\"/\"中\"\n"
|
|
325
|
+
" 例如:点击\"底部\"的\"微剧\"tab,使用 position=\"bottom\"",
|
|
320
326
|
inputSchema={
|
|
321
327
|
"type": "object",
|
|
322
328
|
"properties": {
|
|
323
|
-
"text": {"type": "string", "description": "元素的文本内容(精确匹配)"}
|
|
329
|
+
"text": {"type": "string", "description": "元素的文本内容(精确匹配)"},
|
|
330
|
+
"position": {"type": "string", "description": "位置信息(可选)。当有多个相同文案时使用,支持:top/bottom/left/right/middle 或 上/下/左/右/中"}
|
|
324
331
|
},
|
|
325
332
|
"required": ["text"]
|
|
326
333
|
}
|
|
@@ -328,11 +335,12 @@ class MobileMCPServer:
|
|
|
328
335
|
|
|
329
336
|
tools.append(Tool(
|
|
330
337
|
name="mobile_click_by_id",
|
|
331
|
-
description="👆 通过 resource-id
|
|
332
|
-
"✅
|
|
338
|
+
description="👆 通过 resource-id 点击元素(推荐)\n\n"
|
|
339
|
+
"✅ 稳定的定位方式\n"
|
|
333
340
|
"✅ 实时检测元素是否存在,元素不存在会报错\n"
|
|
334
341
|
"📋 使用前先调用 mobile_list_elements 获取元素 ID\n"
|
|
335
|
-
"💡 当有多个相同 ID 的元素时,用 index 指定第几个(从 0
|
|
342
|
+
"💡 当有多个相同 ID 的元素时,用 index 指定第几个(从 0 开始)\n"
|
|
343
|
+
"💡 定位优先级:文本 > ID > 百分比 > 坐标",
|
|
336
344
|
inputSchema={
|
|
337
345
|
"type": "object",
|
|
338
346
|
"properties": {
|
|
@@ -346,7 +354,7 @@ class MobileMCPServer:
|
|
|
346
354
|
tools.append(Tool(
|
|
347
355
|
name="mobile_click_at_coords",
|
|
348
356
|
description="👆 点击指定坐标(兜底方案)\n\n"
|
|
349
|
-
"⚠️ 【重要】优先使用
|
|
357
|
+
"⚠️ 【重要】优先使用 mobile_click_by_text 或 mobile_click_by_id!\n"
|
|
350
358
|
"仅在 mobile_list_elements 无法获取元素时使用此工具。\n\n"
|
|
351
359
|
"⚠️ 【时序限制】截图分析期间页面可能变化:\n"
|
|
352
360
|
"- 坐标是基于截图时刻的,点击时页面可能已不同\n"
|
|
@@ -447,7 +455,7 @@ class MobileMCPServer:
|
|
|
447
455
|
|
|
448
456
|
tools.append(Tool(
|
|
449
457
|
name="mobile_long_press_at_coords",
|
|
450
|
-
description="👆 长按指定坐标(⚠️
|
|
458
|
+
description="👆 长按指定坐标(⚠️ 兜底方案,优先用文本/ID定位!)\n\n"
|
|
451
459
|
"🎯 仅在以下场景使用:\n"
|
|
452
460
|
"- 游戏(Unity/Cocos)无法获取元素\n"
|
|
453
461
|
"- mobile_list_elements 返回空\n"
|
|
@@ -623,7 +631,7 @@ class MobileMCPServer:
|
|
|
623
631
|
✅ 返回内容:
|
|
624
632
|
- 坐标 (x, y) 和百分比 (x%, y%)
|
|
625
633
|
- resource-id(如果有)
|
|
626
|
-
- 推荐的点击命令(优先
|
|
634
|
+
- 推荐的点击命令(优先 click_by_text,其次 click_by_id,最后 click_by_percent)
|
|
627
635
|
|
|
628
636
|
💡 使用流程:
|
|
629
637
|
1. 直接调用此工具(无需先截图/列元素)
|
|
@@ -663,6 +671,72 @@ class MobileMCPServer:
|
|
|
663
671
|
}
|
|
664
672
|
))
|
|
665
673
|
|
|
674
|
+
# ==================== Toast 检测工具(仅 Android)====================
|
|
675
|
+
tools.append(Tool(
|
|
676
|
+
name="mobile_start_toast_watch",
|
|
677
|
+
description="""🔔 开始监听 Toast(仅 Android)
|
|
678
|
+
|
|
679
|
+
⚠️ 【重要】必须在执行操作之前调用!
|
|
680
|
+
|
|
681
|
+
📋 正确流程(三步走):
|
|
682
|
+
1️⃣ 调用 mobile_start_toast_watch() 开始监听
|
|
683
|
+
2️⃣ 执行操作(如点击提交按钮)
|
|
684
|
+
3️⃣ 调用 mobile_get_toast() 或 mobile_assert_toast() 获取结果
|
|
685
|
+
|
|
686
|
+
❌ 错误用法:先点击按钮,再调用此工具(Toast 可能已消失)""",
|
|
687
|
+
inputSchema={
|
|
688
|
+
"type": "object",
|
|
689
|
+
"properties": {},
|
|
690
|
+
"required": []
|
|
691
|
+
}
|
|
692
|
+
))
|
|
693
|
+
|
|
694
|
+
tools.append(Tool(
|
|
695
|
+
name="mobile_get_toast",
|
|
696
|
+
description="""🍞 获取 Toast 消息(仅 Android)
|
|
697
|
+
|
|
698
|
+
Toast 是 Android 系统级的短暂提示消息,常用于显示操作结果。
|
|
699
|
+
⚠️ Toast 不在控件树中,无法通过 mobile_list_elements 获取。
|
|
700
|
+
|
|
701
|
+
📋 推荐用法(三步走):
|
|
702
|
+
1️⃣ mobile_start_toast_watch() - 开始监听
|
|
703
|
+
2️⃣ 执行操作(点击按钮等)
|
|
704
|
+
3️⃣ mobile_get_toast() - 获取 Toast
|
|
705
|
+
|
|
706
|
+
⏱️ timeout 设置等待时间,默认 5 秒。""",
|
|
707
|
+
inputSchema={
|
|
708
|
+
"type": "object",
|
|
709
|
+
"properties": {
|
|
710
|
+
"timeout": {"type": "number", "description": "等待 Toast 出现的超时时间(秒),默认 5"},
|
|
711
|
+
"reset_first": {"type": "boolean", "description": "是否先清除旧缓存,默认 False"}
|
|
712
|
+
},
|
|
713
|
+
"required": []
|
|
714
|
+
}
|
|
715
|
+
))
|
|
716
|
+
|
|
717
|
+
tools.append(Tool(
|
|
718
|
+
name="mobile_assert_toast",
|
|
719
|
+
description="""✅ 断言 Toast 消息(仅 Android)
|
|
720
|
+
|
|
721
|
+
等待 Toast 出现并验证内容是否符合预期。
|
|
722
|
+
|
|
723
|
+
📋 推荐用法(三步走):
|
|
724
|
+
1️⃣ mobile_start_toast_watch() - 开始监听
|
|
725
|
+
2️⃣ 执行操作(点击按钮等)
|
|
726
|
+
3️⃣ mobile_assert_toast(expected_text="成功") - 断言
|
|
727
|
+
|
|
728
|
+
💡 支持包含匹配(默认)和精确匹配。""",
|
|
729
|
+
inputSchema={
|
|
730
|
+
"type": "object",
|
|
731
|
+
"properties": {
|
|
732
|
+
"expected_text": {"type": "string", "description": "期望的 Toast 文本"},
|
|
733
|
+
"timeout": {"type": "number", "description": "等待超时时间(秒),默认 5"},
|
|
734
|
+
"contains": {"type": "boolean", "description": "True=包含匹配(默认),False=精确匹配"}
|
|
735
|
+
},
|
|
736
|
+
"required": ["expected_text"]
|
|
737
|
+
}
|
|
738
|
+
))
|
|
739
|
+
|
|
666
740
|
# ==================== pytest 脚本生成 ====================
|
|
667
741
|
tools.append(Tool(
|
|
668
742
|
name="mobile_get_operation_history",
|
|
@@ -680,7 +754,7 @@ class MobileMCPServer:
|
|
|
680
754
|
name="mobile_clear_operation_history",
|
|
681
755
|
description="🗑️ 清空操作历史记录。\n\n"
|
|
682
756
|
"⚠️ 开始新的测试录制前必须调用!\n"
|
|
683
|
-
"📋 录制流程:清空历史 →
|
|
757
|
+
"📋 录制流程:清空历史 → 执行操作(优先用文本/ID定位)→ 生成脚本",
|
|
684
758
|
inputSchema={"type": "object", "properties": {}, "required": []}
|
|
685
759
|
))
|
|
686
760
|
|
|
@@ -689,15 +763,15 @@ class MobileMCPServer:
|
|
|
689
763
|
description="📝 生成 pytest 测试脚本。基于操作历史自动生成。\n\n"
|
|
690
764
|
"⚠️ 【重要】录制操作时请优先使用稳定定位:\n"
|
|
691
765
|
"1️⃣ 先调用 mobile_list_elements 获取元素列表\n"
|
|
692
|
-
"2️⃣ 优先用
|
|
693
|
-
"3️⃣ 其次用
|
|
766
|
+
"2️⃣ 优先用 mobile_click_by_text(最稳定,跨设备兼容)\n"
|
|
767
|
+
"3️⃣ 其次用 mobile_click_by_id(稳定)\n"
|
|
694
768
|
"4️⃣ 最后才用坐标点击(会自动转百分比,跨分辨率兼容)\n\n"
|
|
695
769
|
"使用流程:\n"
|
|
696
770
|
"1. 清空历史 mobile_clear_operation_history\n"
|
|
697
|
-
"2.
|
|
771
|
+
"2. 执行操作(优先用文本/ID定位)\n"
|
|
698
772
|
"3. 调用此工具生成脚本\n"
|
|
699
773
|
"4. 脚本保存到 tests/ 目录\n\n"
|
|
700
|
-
"💡
|
|
774
|
+
"💡 定位优先级:文本 > ID > 百分比 > 坐标",
|
|
701
775
|
inputSchema={
|
|
702
776
|
"type": "object",
|
|
703
777
|
"properties": {
|
|
@@ -863,7 +937,10 @@ class MobileMCPServer:
|
|
|
863
937
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
864
938
|
|
|
865
939
|
elif name == "mobile_click_by_text":
|
|
866
|
-
result = self.tools.click_by_text(
|
|
940
|
+
result = self.tools.click_by_text(
|
|
941
|
+
arguments["text"],
|
|
942
|
+
position=arguments.get("position")
|
|
943
|
+
)
|
|
867
944
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
868
945
|
|
|
869
946
|
elif name == "mobile_click_by_id":
|
|
@@ -979,6 +1056,25 @@ class MobileMCPServer:
|
|
|
979
1056
|
result = self.tools.assert_text(arguments["text"])
|
|
980
1057
|
return [TextContent(type="text", text=self.format_response(result))]
|
|
981
1058
|
|
|
1059
|
+
# Toast 检测(仅 Android)
|
|
1060
|
+
elif name == "mobile_start_toast_watch":
|
|
1061
|
+
result = self.tools.start_toast_watch()
|
|
1062
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
1063
|
+
|
|
1064
|
+
elif name == "mobile_get_toast":
|
|
1065
|
+
timeout = arguments.get("timeout", 5.0)
|
|
1066
|
+
reset_first = arguments.get("reset_first", False)
|
|
1067
|
+
result = self.tools.get_toast(timeout=timeout, reset_first=reset_first)
|
|
1068
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
1069
|
+
|
|
1070
|
+
elif name == "mobile_assert_toast":
|
|
1071
|
+
result = self.tools.assert_toast(
|
|
1072
|
+
expected_text=arguments["expected_text"],
|
|
1073
|
+
timeout=arguments.get("timeout", 5.0),
|
|
1074
|
+
contains=arguments.get("contains", True)
|
|
1075
|
+
)
|
|
1076
|
+
return [TextContent(type="text", text=self.format_response(result))]
|
|
1077
|
+
|
|
982
1078
|
# 脚本生成
|
|
983
1079
|
elif name == "mobile_get_operation_history":
|
|
984
1080
|
result = self.tools.get_operation_history(arguments.get("limit"))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
mobile_mcp/__init__.py,sha256=sQJZTL_sxQFzmcS7jOtS2AHCfUySz40vhX96N6u1qy4,816
|
|
2
2
|
mobile_mcp/config.py,sha256=yaFLAV4bc2wX0GQPtZDo7OYF9E88tXV-av41fQsJwK4,4480
|
|
3
3
|
mobile_mcp/core/__init__.py,sha256=ndMy-cLAIsQDG5op7gM_AIplycqZSZPWEkec1pEhvEY,170
|
|
4
|
-
mobile_mcp/core/basic_tools_lite.py,sha256=
|
|
4
|
+
mobile_mcp/core/basic_tools_lite.py,sha256=_hN1gsno7fa3pQYOIkzquGYwRvgIOLp1h8bYpZnwaNE,194014
|
|
5
5
|
mobile_mcp/core/device_manager.py,sha256=xG5DoeNFs45pl-FTEhEWblqVwxtFK-FmVEGlNL6EqRI,8798
|
|
6
6
|
mobile_mcp/core/dynamic_config.py,sha256=Ja1n1pfb0HspGByqk2_A472mYVniKmGtNEWyjUjmgK8,9811
|
|
7
7
|
mobile_mcp/core/ios_client_wda.py,sha256=Nq9WxevhTWpVpolM-Ymp-b0nUQV3tXLFszmJHbDC4wA,18770
|
|
@@ -19,14 +19,14 @@ mobile_mcp/core/utils/logger.py,sha256=XXQAHUwT1jc70pq_tYFmL6f_nKrFlYm3hcgl-5RYR
|
|
|
19
19
|
mobile_mcp/core/utils/operation_history_manager.py,sha256=gi8S8HJAMqvkUrY7_-kVbko3Xt7c4GAUziEujRd-N-Y,4792
|
|
20
20
|
mobile_mcp/core/utils/smart_wait.py,sha256=N5wKTUYrNWPruBILqrAjpvtso8Z3GRWCfMIR_aZxPLg,8649
|
|
21
21
|
mobile_mcp/mcp_tools/__init__.py,sha256=xkro8Rwqv_55YlVyhh-3DgRFSsLE3h1r31VIb3bpM6E,143
|
|
22
|
-
mobile_mcp/mcp_tools/mcp_server.py,sha256=
|
|
22
|
+
mobile_mcp/mcp_tools/mcp_server.py,sha256=x_qvP94FjTeFkk_Tc3rwXtN0Fmp4yblm8b5eoeTOR8w,54744
|
|
23
23
|
mobile_mcp/utils/__init__.py,sha256=8EH0i7UGtx1y_j_GEgdN-cZdWn2sRtZSEOLlNF9HRnY,158
|
|
24
24
|
mobile_mcp/utils/logger.py,sha256=Sqq2Nr0Y4p03erqcrbYKVPCGiFaNGHMcE_JwCkeOfU4,3626
|
|
25
25
|
mobile_mcp/utils/xml_formatter.py,sha256=uwTRb3vLbqhT8O-udzWT7s7LsV-DyDUz2DkofD3hXOE,4556
|
|
26
26
|
mobile_mcp/utils/xml_parser.py,sha256=QhL8CWbdmNDzmBLjtx6mEnjHgMFZzJeHpCL15qfXSpI,3926
|
|
27
|
-
mobile_mcp_ai-2.6.
|
|
28
|
-
mobile_mcp_ai-2.6.
|
|
29
|
-
mobile_mcp_ai-2.6.
|
|
30
|
-
mobile_mcp_ai-2.6.
|
|
31
|
-
mobile_mcp_ai-2.6.
|
|
32
|
-
mobile_mcp_ai-2.6.
|
|
27
|
+
mobile_mcp_ai-2.6.3.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
|
|
28
|
+
mobile_mcp_ai-2.6.3.dist-info/METADATA,sha256=j4pJ-FkSVtIy9YEW4HbmVa0u-WSc5LqKqUj7_Sd27cU,10495
|
|
29
|
+
mobile_mcp_ai-2.6.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
30
|
+
mobile_mcp_ai-2.6.3.dist-info/entry_points.txt,sha256=KB_FglozgPHBprSM1vFbIzGyheFuHFmGanscRdMJ_8A,68
|
|
31
|
+
mobile_mcp_ai-2.6.3.dist-info/top_level.txt,sha256=lLm6YpbTv855Lbh8BIA0rPxhybIrvYUzMEk9OErHT94,11
|
|
32
|
+
mobile_mcp_ai-2.6.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|