@11agents/cli 0.1.24 → 0.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +52 -0
  2. package/bin/11agents.js +12 -0
  3. package/mobile-runtime/README.md +19 -0
  4. package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
  5. package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
  6. package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
  7. package/mobile-runtime/configs/publish_policy.json +40 -0
  8. package/mobile-runtime/data-templates/README.md +4 -0
  9. package/mobile-runtime/data-templates/accounts.example.csv +6 -0
  10. package/mobile-runtime/data-templates/devices.example.csv +2 -0
  11. package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
  12. package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
  13. package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
  14. package/mobile-runtime/python/pyproject.toml +34 -0
  15. package/mobile-runtime/python/src/device_control/__init__.py +5 -0
  16. package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
  17. package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
  18. package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
  19. package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
  20. package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
  21. package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
  22. package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
  23. package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
  24. package/mobile-runtime/python/src/device_control/adb.py +161 -0
  25. package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
  26. package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
  27. package/mobile-runtime/python/src/device_control/cli.py +1608 -0
  28. package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
  29. package/mobile-runtime/python/src/device_control/locks.py +162 -0
  30. package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
  31. package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
  32. package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
  33. package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
  34. package/mobile-runtime/python/src/device_control/models.py +439 -0
  35. package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
  36. package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
  37. package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
  38. package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
  39. package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
  40. package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
  41. package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
  42. package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
  43. package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
  44. package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
  45. package/mobile-runtime/python/src/device_control/store.py +137 -0
  46. package/mobile-runtime/scripts/appium_smoke.py +71 -0
  47. package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
  48. package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
  49. package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
  50. package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
  51. package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
  52. package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
  53. package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
  54. package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
  55. package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
  56. package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
  57. package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
  58. package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
  59. package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
  60. package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
  61. package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
  62. package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
  63. package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
  64. package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
  65. package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
  66. package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
  67. package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
  68. package/package.json +4 -1
  69. package/scripts/mobile-postinstall.js +26 -0
  70. package/src/commands/mobile.js +695 -0
  71. package/src/commands/runtime.js +21 -5
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from device_control.adb import AdbClient
10
+ from device_control.appium_client import AppiumClient
11
+ from device_control.models import Device, PublishRecord, utc_now_iso
12
+ from device_control.publishers import ui_helpers as ui
13
+ from device_control.publishers.tiktok_adb import TIKTOK_PACKAGE, TikTokAdbPublisher
14
+ from device_control.store import append_publish_record
15
+
16
+
17
+ TIKTOK_ACTIVITY = "com.ss.android.ugc.aweme.splash.SplashActivity"
18
+
19
+
20
+ @dataclass
21
+ class TikTokAppiumPublishResult:
22
+ device_id: str
23
+ status: str
24
+ record_id: str
25
+ started_at: str
26
+ ended_at: str
27
+ duration_seconds: int
28
+ remote_media_path: str
29
+ screenshot_path: str
30
+ error: str = ""
31
+
32
+
33
+ class TikTokAppiumPublisher:
34
+ """TikTok publisher that uses Appium for UI steps and ADB for device/media primitives."""
35
+
36
+ def __init__(
37
+ self,
38
+ adb: AdbClient,
39
+ *,
40
+ appium_server: str = "http://127.0.0.1:4723",
41
+ records_path: str | Path = "data/publish_records.jsonl",
42
+ artifact_root: str | Path = "artifacts/screenshots",
43
+ ) -> None:
44
+ self.adb = adb
45
+ self.appium_server = appium_server
46
+ self.records_path = Path(records_path)
47
+ self.artifact_root = Path(artifact_root)
48
+ self.media_helper = TikTokAdbPublisher(adb, records_path=records_path, artifact_root=artifact_root)
49
+
50
+ def publish_video(
51
+ self,
52
+ device: Device,
53
+ *,
54
+ video_path: str | Path,
55
+ account_id: str,
56
+ caption: str = "",
57
+ dry_run: bool = False,
58
+ ) -> TikTokAppiumPublishResult:
59
+ local = Path(video_path).expanduser()
60
+ if not local.exists():
61
+ raise FileNotFoundError(f"video not found: {local}")
62
+
63
+ started_epoch = int(time.time())
64
+ started_at = _now_shanghai()
65
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
66
+ run_dir = self.artifact_root / f"{device.device_id}-tiktok-appium-{stamp}"
67
+ run_dir.mkdir(parents=True, exist_ok=True)
68
+ remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-tiktok-appium-{stamp}{local.suffix or '.mp4'}"
69
+ record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_tiktok_appium"
70
+ appium = AppiumClient(self.appium_server)
71
+
72
+ try:
73
+ self._prepare_device(device)
74
+ ui.grant_publish_permissions(self.adb, device, TIKTOK_PACKAGE)
75
+ self.media_helper._push_media(device, local, remote)
76
+ self.adb.shell(device.adb_serial, "am", "force-stop", TIKTOK_PACKAGE)
77
+ time.sleep(1)
78
+
79
+ appium.start_session(_capabilities(device.adb_serial))
80
+ time.sleep(5)
81
+
82
+ appium.source(run_dir / "home.xml")
83
+ self._click_first(appium, [("accessibility id", "创建"), ("id", f"{TIKTOK_PACKAGE}:id/neq")], "open create")
84
+ time.sleep(3)
85
+ self.adb.screenshot(device.adb_serial, run_dir / "create.png")
86
+ self._raise_if_live_surface(device, run_dir, "after opening TikTok create")
87
+
88
+ self._tap_album_entry(device)
89
+ time.sleep(5)
90
+ self._raise_if_live_surface(device, run_dir, "after opening TikTok gallery")
91
+ ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "gallery-permission.xml")
92
+ self.adb.screenshot(device.adb_serial, run_dir / "gallery.png")
93
+ self._raise_if_live_surface(device, run_dir, "after opening TikTok gallery")
94
+
95
+ self._tap_first_gallery_video(device)
96
+ time.sleep(1)
97
+ self.adb.screenshot(device.adb_serial, run_dir / "gallery-selected.png")
98
+ self._raise_if_live_surface(device, run_dir, "after selecting TikTok media")
99
+
100
+ self._tap_gallery_next(device)
101
+ time.sleep(6)
102
+ self.adb.screenshot(device.adb_serial, run_dir / "editor.png")
103
+ self._raise_if_live_surface(device, run_dir, "after TikTok gallery next")
104
+
105
+ self._tap_editor_next(device)
106
+ time.sleep(7)
107
+ final_shot = run_dir / "post-form.png"
108
+ self.adb.screenshot(device.adb_serial, final_shot)
109
+ self._raise_if_live_surface(device, run_dir, "after TikTok editor next")
110
+
111
+ if caption:
112
+ self._set_caption(appium, device, caption, run_dir)
113
+
114
+ if dry_run:
115
+ status = "dry_run"
116
+ else:
117
+ self._tap_publish_button(device)
118
+ final_shot = self.media_helper._wait_for_publish_confirmation(device, run_dir)
119
+ status = "published"
120
+
121
+ ended_epoch = int(time.time())
122
+ if status == "published":
123
+ append_publish_record(
124
+ self.records_path,
125
+ PublishRecord(
126
+ record_id=record_id,
127
+ platform="tiktok",
128
+ account_id=account_id,
129
+ device_id=device.device_id,
130
+ post_type="video",
131
+ local_media_path=str(local),
132
+ remote_media_path=remote,
133
+ caption=caption,
134
+ published_at=utc_now_iso(),
135
+ result_screenshot_path=str(final_shot),
136
+ status="published",
137
+ ),
138
+ )
139
+ return TikTokAppiumPublishResult(
140
+ device_id=device.device_id,
141
+ status=status,
142
+ record_id=record_id,
143
+ started_at=started_at,
144
+ ended_at=_now_shanghai(),
145
+ duration_seconds=ended_epoch - started_epoch,
146
+ remote_media_path=remote,
147
+ screenshot_path=str(final_shot),
148
+ )
149
+ except Exception as exc:
150
+ failure_shot = run_dir / "failed.png"
151
+ try:
152
+ appium.screenshot(failure_shot)
153
+ except Exception:
154
+ self.adb.screenshot(device.adb_serial, failure_shot)
155
+ return TikTokAppiumPublishResult(
156
+ device_id=device.device_id,
157
+ status="failed",
158
+ record_id=record_id,
159
+ started_at=started_at,
160
+ ended_at=_now_shanghai(),
161
+ duration_seconds=int(time.time()) - started_epoch,
162
+ remote_media_path=remote,
163
+ screenshot_path=str(failure_shot),
164
+ error=str(exc),
165
+ )
166
+ finally:
167
+ try:
168
+ appium.delete_session()
169
+ except Exception:
170
+ pass
171
+
172
+ def _prepare_device(self, device: Device) -> None:
173
+ self._ok(self.adb.wake(device.adb_serial), "wake device")
174
+ time.sleep(1)
175
+ self.adb.swipe(device.adb_serial, 540, 1850, 540, 550, 350)
176
+ time.sleep(1)
177
+
178
+ def _click_first(self, appium: AppiumClient, locators: list[tuple[str, str]], action: str) -> None:
179
+ last_error = ""
180
+ for using, value in locators:
181
+ try:
182
+ appium.click(appium.find_element(using, value))
183
+ return
184
+ except Exception as exc:
185
+ last_error = str(exc)
186
+ raise RuntimeError(f"{action} failed: {last_error}")
187
+
188
+ def _set_caption(self, appium: AppiumClient, device: Device, caption: str, run_dir: Path) -> None:
189
+ try:
190
+ field = appium.find_element("id", f"{TIKTOK_PACKAGE}:id/gjp")
191
+ appium.click(field)
192
+ time.sleep(1)
193
+ appium.send_keys(field, caption)
194
+ time.sleep(1)
195
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
196
+ time.sleep(1)
197
+ self.adb.screenshot(device.adb_serial, run_dir / "after-caption.png")
198
+ except Exception as exc:
199
+ self.adb.screenshot(device.adb_serial, run_dir / "caption-failed.png")
200
+ raise RuntimeError(f"caption input failed: {exc}") from exc
201
+
202
+ def _tap_album_entry(self, device: Device) -> None:
203
+ self._ok(self.adb.tap(device.adb_serial, 105, 2050), "open album")
204
+
205
+ def _tap_first_gallery_video(self, device: Device) -> None:
206
+ self._ok(self.adb.tap(device.adb_serial, 305, 455), "select first gallery video")
207
+
208
+ def _tap_gallery_next(self, device: Device) -> None:
209
+ self._ok(self.adb.tap(device.adb_serial, 790, 2208), "next from gallery")
210
+
211
+ def _tap_editor_next(self, device: Device) -> None:
212
+ self._ok(self.adb.tap(device.adb_serial, 795, 2234), "next from editor")
213
+
214
+ def _tap_publish_button(self, device: Device) -> None:
215
+ self._ok(self.adb.tap(device.adb_serial, 790, 2240), "publish")
216
+
217
+ def _raise_if_live_surface(self, device: Device, run_dir: Path, action: str) -> None:
218
+ try:
219
+ root = ui.dump_ui(self.adb, device, run_dir / f"{_slug(action)}.xml")
220
+ except Exception:
221
+ return
222
+ text = ui.all_text(root)
223
+ if (
224
+ ("访问你的麦克风" in text or "访问麦克风" in text)
225
+ and ("直播" in text or "LIVE" in text.upper())
226
+ ):
227
+ raise RuntimeError(f"{action} failed: TikTok opened the LIVE microphone permission surface, not video publish")
228
+ if "直播" in text and "发布" in text and "创作" in text and "访问麦克风" in text:
229
+ raise RuntimeError(f"{action} failed: TikTok is on the LIVE tab, not video publish")
230
+
231
+ @staticmethod
232
+ def _ok(result, action: str) -> None:
233
+ if not result.ok:
234
+ raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
235
+
236
+
237
+ def _capabilities(serial: str) -> dict:
238
+ return {
239
+ "platformName": "Android",
240
+ "appium:automationName": "UiAutomator2",
241
+ "appium:udid": serial,
242
+ "appium:noReset": True,
243
+ "appium:newCommandTimeout": 120,
244
+ "appium:disableWindowAnimation": True,
245
+ "appium:skipDeviceInitialization": True,
246
+ "appium:ignoreHiddenApiPolicyError": True,
247
+ "appium:disableSuppressAccessibilityService": True,
248
+ "appium:settings[enableNotificationListener]": False,
249
+ "appium:appPackage": TIKTOK_PACKAGE,
250
+ "appium:appActivity": TIKTOK_ACTIVITY,
251
+ }
252
+
253
+
254
+ def _now_shanghai() -> str:
255
+ return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")
256
+
257
+
258
+ def _slug(value: str) -> str:
259
+ return "".join(char.lower() if char.isalnum() else "-" for char in value).strip("-")
@@ -0,0 +1,372 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ import xml.etree.ElementTree as ET
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ from device_control.adb import AdbClient
10
+ from device_control.models import Device
11
+
12
+
13
+ Bounds = tuple[int, int, int, int]
14
+ PUBLISH_RUNTIME_PERMISSIONS = (
15
+ "android.permission.CAMERA",
16
+ "android.permission.RECORD_AUDIO",
17
+ "android.permission.READ_EXTERNAL_STORAGE",
18
+ "android.permission.WRITE_EXTERNAL_STORAGE",
19
+ "android.permission.READ_MEDIA_IMAGES",
20
+ "android.permission.READ_MEDIA_VIDEO",
21
+ "android.permission.READ_MEDIA_AUDIO",
22
+ "android.permission.READ_CONTACTS",
23
+ "android.permission.WRITE_CONTACTS",
24
+ "android.permission.GET_ACCOUNTS",
25
+ "android.permission.ACCESS_COARSE_LOCATION",
26
+ "android.permission.ACCESS_FINE_LOCATION",
27
+ "android.permission.POST_NOTIFICATIONS",
28
+ )
29
+ PUBLISH_APP_OPS = (
30
+ "CAMERA",
31
+ "RECORD_AUDIO",
32
+ "READ_EXTERNAL_STORAGE",
33
+ "WRITE_EXTERNAL_STORAGE",
34
+ "READ_MEDIA_IMAGES",
35
+ "READ_MEDIA_VIDEO",
36
+ "READ_MEDIA_AUDIO",
37
+ "READ_CONTACTS",
38
+ "WRITE_CONTACTS",
39
+ "GET_ACCOUNTS",
40
+ "ACCESS_COARSE_LOCATION",
41
+ "ACCESS_FINE_LOCATION",
42
+ "POST_NOTIFICATION",
43
+ )
44
+ PERMISSION_ALLOW_IDS = {
45
+ "com.android.permissioncontroller:id/permission_allow_button",
46
+ "com.android.permissioncontroller:id/permission_allow_foreground_only_button",
47
+ "com.android.permissioncontroller:id/permission_allow_one_time_button",
48
+ "com.android.permissioncontroller:id/permission_allow_all_button",
49
+ }
50
+ PERMISSION_ALLOW_ID_SUFFIXES = {
51
+ "permission_allow_button",
52
+ "permission_allow_foreground_only_button",
53
+ "permission_allow_one_time_button",
54
+ "permission_allow_all_button",
55
+ }
56
+ PERMISSION_ALLOW_TEXTS = {
57
+ "允许",
58
+ "确定",
59
+ "始终允许",
60
+ "仅在使用该应用时允许",
61
+ "仅限本次使用",
62
+ "全部照片和视频",
63
+ "允许访问所有照片和视频",
64
+ "允许访问所有照片",
65
+ "允许所有照片",
66
+ "Allow",
67
+ "OK",
68
+ "While using the app",
69
+ "Only this time",
70
+ "Allow all photos",
71
+ "Allow all photos and videos",
72
+ }
73
+ PERMISSION_ALLOW_PARTIALS = {
74
+ "始终允许",
75
+ "在使用该应用时允许",
76
+ "本次使用",
77
+ "全部照片",
78
+ "照片和视频",
79
+ "allow",
80
+ "while using",
81
+ "only this time",
82
+ "all photos",
83
+ "all photos and videos",
84
+ }
85
+ PERMISSION_DENY_PARTIALS = {
86
+ "不允许",
87
+ "拒绝",
88
+ "deny",
89
+ "don't allow",
90
+ "do not allow",
91
+ "not allow",
92
+ }
93
+ PERMISSION_LIMITED_MEDIA_PARTIALS = {
94
+ "选择照片",
95
+ "选择要允许",
96
+ "仅允许访问照片",
97
+ "仅允许访问部分",
98
+ "select photos",
99
+ "selected photos",
100
+ "select photos and videos",
101
+ }
102
+
103
+
104
+ def dump_ui(adb: AdbClient, device: Device, xml_path: Path) -> ET.Element:
105
+ result = adb.dump_ui(device.adb_serial, xml_path)
106
+ if not result.ok:
107
+ raise RuntimeError(f"dump ui failed: {result.stderr or result.stdout}")
108
+ return ET.parse(xml_path).getroot()
109
+
110
+
111
+ def bounds(node: ET.Element) -> Bounds | None:
112
+ match = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", node.attrib.get("bounds", ""))
113
+ if not match:
114
+ return None
115
+ return tuple(int(part) for part in match.groups())
116
+
117
+
118
+ def center(node_or_bounds: ET.Element | Bounds) -> tuple[int, int]:
119
+ value = bounds(node_or_bounds) if isinstance(node_or_bounds, ET.Element) else node_or_bounds
120
+ if not value:
121
+ raise RuntimeError("node has no valid bounds")
122
+ left, top, right, bottom = value
123
+ return (left + right) // 2, (top + bottom) // 2
124
+
125
+
126
+ def is_visible(node: ET.Element) -> bool:
127
+ value = bounds(node)
128
+ if not value:
129
+ return False
130
+ left, top, right, bottom = value
131
+ return right > left and bottom > top
132
+
133
+
134
+ def all_text(root: ET.Element) -> str:
135
+ parts: list[str] = []
136
+ for node in root.iter("node"):
137
+ for key in ("text", "content-desc", "resource-id"):
138
+ value = node.attrib.get(key, "")
139
+ if value:
140
+ parts.append(value)
141
+ return "\n".join(parts)
142
+
143
+
144
+ def contains_any(text: str, needles: Iterable[str]) -> bool:
145
+ lowered = text.lower()
146
+ return any(needle.lower() in lowered for needle in needles)
147
+
148
+
149
+ def find_node(
150
+ root: ET.Element,
151
+ *,
152
+ ids: set[str] | None = None,
153
+ id_suffixes: set[str] | None = None,
154
+ texts: set[str] | None = None,
155
+ descs: set[str] | None = None,
156
+ partial_texts: set[str] | None = None,
157
+ clickable_only: bool = False,
158
+ visible_only: bool = True,
159
+ ) -> ET.Element | None:
160
+ for node in root.iter("node"):
161
+ if clickable_only and node.attrib.get("clickable") != "true":
162
+ continue
163
+ if visible_only and not is_visible(node):
164
+ continue
165
+ resource_id = node.attrib.get("resource-id", "")
166
+ text = node.attrib.get("text", "")
167
+ desc = node.attrib.get("content-desc", "")
168
+ if ids and resource_id in ids:
169
+ return node
170
+ if id_suffixes and any(resource_id == suffix or resource_id.endswith(f":id/{suffix}") for suffix in id_suffixes):
171
+ return node
172
+ if texts and (text in texts or desc in texts):
173
+ return node
174
+ if descs and desc in descs:
175
+ return node
176
+ if partial_texts:
177
+ haystack = f"{text}\n{desc}".lower()
178
+ if any(part.lower() in haystack for part in partial_texts):
179
+ return node
180
+ return None
181
+
182
+
183
+ def find_nodes(
184
+ root: ET.Element,
185
+ *,
186
+ ids: set[str] | None = None,
187
+ id_suffixes: set[str] | None = None,
188
+ partial_texts: set[str] | None = None,
189
+ visible_only: bool = True,
190
+ ) -> list[ET.Element]:
191
+ nodes: list[ET.Element] = []
192
+ for node in root.iter("node"):
193
+ if visible_only and not is_visible(node):
194
+ continue
195
+ resource_id = node.attrib.get("resource-id", "")
196
+ text = node.attrib.get("text", "")
197
+ desc = node.attrib.get("content-desc", "")
198
+ if ids and resource_id in ids:
199
+ nodes.append(node)
200
+ continue
201
+ if id_suffixes and any(resource_id == suffix or resource_id.endswith(f":id/{suffix}") for suffix in id_suffixes):
202
+ nodes.append(node)
203
+ continue
204
+ if partial_texts:
205
+ haystack = f"{text}\n{desc}".lower()
206
+ if any(part.lower() in haystack for part in partial_texts):
207
+ nodes.append(node)
208
+ return nodes
209
+
210
+
211
+ def tap_node(adb: AdbClient, device: Device, node: ET.Element, *, sleep_s: float = 0) -> None:
212
+ x, y = center(node)
213
+ result = adb.tap(device.adb_serial, x, y)
214
+ if not result.ok:
215
+ raise RuntimeError(f"tap {x},{y} failed: {result.stderr or result.stdout}")
216
+ if sleep_s:
217
+ time.sleep(sleep_s)
218
+
219
+
220
+ def tap_first(
221
+ adb: AdbClient,
222
+ device: Device,
223
+ root: ET.Element,
224
+ *,
225
+ ids: set[str] | None = None,
226
+ id_suffixes: set[str] | None = None,
227
+ texts: set[str] | None = None,
228
+ descs: set[str] | None = None,
229
+ partial_texts: set[str] | None = None,
230
+ sleep_s: float = 0,
231
+ ) -> bool:
232
+ node = find_node(
233
+ root,
234
+ ids=ids,
235
+ id_suffixes=id_suffixes,
236
+ texts=texts,
237
+ descs=descs,
238
+ partial_texts=partial_texts,
239
+ clickable_only=False,
240
+ )
241
+ if node is None:
242
+ return False
243
+ tap_node(adb, device, node, sleep_s=sleep_s)
244
+ return True
245
+
246
+
247
+ def grant_publish_permissions(adb: AdbClient, device_or_serial: Device | str, package: str) -> None:
248
+ serial = device_or_serial.adb_serial if isinstance(device_or_serial, Device) else device_or_serial
249
+ for permission in PUBLISH_RUNTIME_PERMISSIONS:
250
+ adb.shell(serial, "pm", "grant", package, permission)
251
+ for op in PUBLISH_APP_OPS:
252
+ adb.shell(serial, "appops", "set", package, op, "allow")
253
+
254
+
255
+ def tap_permission_prompt_if_present(
256
+ adb: AdbClient,
257
+ device: Device,
258
+ xml_path: Path,
259
+ *,
260
+ retries: int = 2,
261
+ sleep_s: float = 1.0,
262
+ ) -> bool:
263
+ tapped = False
264
+ for attempt in range(max(1, retries)):
265
+ try:
266
+ root = dump_ui(adb, device, _retry_path(xml_path, attempt))
267
+ except Exception:
268
+ return tapped
269
+ node = find_permission_allow_node(root)
270
+ if node is None:
271
+ return tapped
272
+ tap_node(adb, device, node, sleep_s=sleep_s)
273
+ tapped = True
274
+ return tapped
275
+
276
+
277
+ def find_permission_allow_node(root: ET.Element) -> ET.Element | None:
278
+ candidates: list[tuple[int, ET.Element]] = []
279
+ for node in root.iter("node"):
280
+ if not is_visible(node):
281
+ continue
282
+ text = node.attrib.get("text", "")
283
+ desc = node.attrib.get("content-desc", "")
284
+ label = f"{text}\n{desc}".strip()
285
+ lowered = label.lower()
286
+ resource_id = node.attrib.get("resource-id", "")
287
+ if any(part in lowered for part in PERMISSION_DENY_PARTIALS):
288
+ continue
289
+ if any(part in lowered for part in PERMISSION_LIMITED_MEDIA_PARTIALS):
290
+ continue
291
+ if resource_id in PERMISSION_ALLOW_IDS:
292
+ candidates.append((300, node))
293
+ continue
294
+ if any(resource_id.endswith(f":id/{suffix}") for suffix in PERMISSION_ALLOW_ID_SUFFIXES):
295
+ candidates.append((280, node))
296
+ continue
297
+ if text in PERMISSION_ALLOW_TEXTS or desc in PERMISSION_ALLOW_TEXTS:
298
+ candidates.append((200, node))
299
+ continue
300
+ if any(part.lower() in lowered for part in PERMISSION_ALLOW_PARTIALS):
301
+ candidates.append((100, node))
302
+ if not candidates:
303
+ return None
304
+ return sorted(candidates, key=lambda item: (item[0], _node_bottom(item[1])), reverse=True)[0][1]
305
+
306
+
307
+ def _retry_path(path: Path, attempt: int) -> Path:
308
+ if attempt == 0:
309
+ return path
310
+ return path.with_name(f"{path.stem}-{attempt + 1}{path.suffix}")
311
+
312
+
313
+ def _node_bottom(node: ET.Element) -> int:
314
+ value = bounds(node)
315
+ if not value:
316
+ return 0
317
+ return value[3]
318
+
319
+
320
+ def sorted_visible_nodes(nodes: list[ET.Element]) -> list[ET.Element]:
321
+ def key(node: ET.Element) -> tuple[int, int]:
322
+ left, top, _right, _bottom = bounds(node) or (10**9, 10**9, 10**9, 10**9)
323
+ return top, left
324
+
325
+ return sorted(nodes, key=key)
326
+
327
+
328
+ def wait_for_node(
329
+ adb: AdbClient,
330
+ device: Device,
331
+ xml_path: Path,
332
+ *,
333
+ timeout_s: float,
334
+ interval_s: float = 1,
335
+ ids: set[str] | None = None,
336
+ id_suffixes: set[str] | None = None,
337
+ texts: set[str] | None = None,
338
+ partial_texts: set[str] | None = None,
339
+ ) -> ET.Element | None:
340
+ deadline = time.time() + timeout_s
341
+ while time.time() < deadline:
342
+ root = dump_ui(adb, device, xml_path)
343
+ node = find_node(root, ids=ids, id_suffixes=id_suffixes, texts=texts, partial_texts=partial_texts)
344
+ if node is not None:
345
+ return node
346
+ time.sleep(interval_s)
347
+ return None
348
+
349
+
350
+ def media_kind_from_path(path: Path) -> str:
351
+ suffix = path.suffix.lower()
352
+ if suffix in {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}:
353
+ return "image"
354
+ if suffix in {".mp4", ".mov", ".m4v", ".3gp", ".webm"}:
355
+ return "video"
356
+ raise ValueError(f"unsupported media suffix: {path.suffix}")
357
+
358
+
359
+ def adb_text_safe(text: str) -> bool:
360
+ allowed = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?:;'\"_@#%()+-/|&=")
361
+ return all(char in allowed for char in text)
362
+
363
+
364
+ def require_adb_text_safe(value: str, field_name: str, *, required: bool = True) -> None:
365
+ if required and not value.strip():
366
+ raise ValueError(f"{field_name} is required")
367
+ if not value:
368
+ return
369
+ if "\n" in value or "\r" in value or "\t" in value:
370
+ raise ValueError(f"{field_name} must be a single line for the current POC")
371
+ if not value.isascii():
372
+ raise ValueError(f"{field_name} must be ASCII for the current POC")