@11agents/cli 0.1.24 → 0.1.25
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/README.md +52 -0
- package/bin/11agents.js +12 -0
- package/mobile-runtime/README.md +19 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
- package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
- package/mobile-runtime/configs/publish_policy.json +40 -0
- package/mobile-runtime/data-templates/README.md +4 -0
- package/mobile-runtime/data-templates/accounts.example.csv +6 -0
- package/mobile-runtime/data-templates/devices.example.csv +2 -0
- package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
- package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
- package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
- package/mobile-runtime/python/pyproject.toml +34 -0
- package/mobile-runtime/python/src/device_control/__init__.py +5 -0
- package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
- package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
- package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
- package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
- package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
- package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
- package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
- package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
- package/mobile-runtime/python/src/device_control/adb.py +161 -0
- package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
- package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
- package/mobile-runtime/python/src/device_control/cli.py +1608 -0
- package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
- package/mobile-runtime/python/src/device_control/locks.py +162 -0
- package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
- package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
- package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
- package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
- package/mobile-runtime/python/src/device_control/models.py +439 -0
- package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
- package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
- package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
- package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
- package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
- package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
- package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
- package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
- package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
- package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
- package/mobile-runtime/python/src/device_control/store.py +137 -0
- package/mobile-runtime/scripts/appium_smoke.py +71 -0
- package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
- package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
- package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
- package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
- package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
- package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
- package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
- package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
- package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
- package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
- package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
- package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
- package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
- package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
- package/package.json +4 -1
- package/scripts/mobile-postinstall.js +26 -0
- package/src/commands/mobile.js +695 -0
- package/src/commands/runtime.js +6 -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")
|