mumdad 0.1.4__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.
mum/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """mum — Multi-platform disguise automation toolkit.
2
+
3
+ - ADB gesture disguise (``DisguiseAdbClient``, ``DisguiseAdbClientDebug``)
4
+ - WeChat session list scroll navigation (from×L model, device-adaptive)
5
+ - Duration randomization (0~80ms) for anti-detection
6
+ """
7
+
8
+ __version__ = "0.1.0"
File without changes
File without changes
@@ -0,0 +1,53 @@
1
+ """mum ADB 伪装模块 — 统一对外导出。
2
+
3
+ - ``DisguiseAdbClient``(生产):自动注入随机延迟 + 坐标偏移 + duration 随机化
4
+ - ``DisguiseAdbClientDebug``(调试):跳过所有伪装,直接走原生 ADB
5
+ - ``scroll_down_base`` / ``scroll_up_base``:会话列表手势翻页
6
+ - ``scroll_up_reposition``:归位专用下拉
7
+ - ``scroll_down_note``:笔记详情页上滑
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from mum.android.base.adb.disguise_client import (
13
+ DisguiseAdbClient,
14
+ DisguiseAdbClientDebug,
15
+ )
16
+ from mum.android.base.adb._raw_adb import (
17
+ LOCKED_ADB_VERSION,
18
+ AdbClient,
19
+ AdbError,
20
+ AdbResult,
21
+ KEYCODE_BACK,
22
+ KEYCODE_HOME,
23
+ )
24
+ from mum.android.base.adb.scroll import (
25
+ scroll_down_base,
26
+ scroll_down_note,
27
+ scroll_up_base,
28
+ scroll_up_reposition,
29
+ )
30
+
31
+ # 向后兼容别名
32
+ session_list_content_scroll_down = scroll_down_base
33
+ session_list_content_scroll_up = scroll_up_base
34
+
35
+ RiskAdbClient = DisguiseAdbClient
36
+
37
+ __all__ = [
38
+ "LOCKED_ADB_VERSION",
39
+ "AdbClient",
40
+ "AdbError",
41
+ "AdbResult",
42
+ "KEYCODE_BACK",
43
+ "KEYCODE_HOME",
44
+ "DisguiseAdbClient",
45
+ "DisguiseAdbClientDebug",
46
+ "RiskAdbClient",
47
+ "scroll_down_base",
48
+ "scroll_down_note",
49
+ "scroll_up_base",
50
+ "scroll_up_reposition",
51
+ "session_list_content_scroll_down",
52
+ "session_list_content_scroll_up",
53
+ ]
@@ -0,0 +1,20 @@
1
+ """Minimal ADB interface for duck-typing compatibility.
2
+
3
+ ``DisguiseAdbClient`` satisfies this protocol, as does any object with
4
+ ``swipe()`` and ``_run()`` methods.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol
10
+
11
+
12
+ class AdbProtocol(Protocol):
13
+ """Minimal ADB interface for scroll gesture functions.
14
+
15
+ Compatible with ``layernav_android._protocol.AdbProtocol``.
16
+ """
17
+ def swipe(
18
+ self, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300,
19
+ ) -> None: ...
20
+ def _run(self, args: list[str]) -> str: ...
@@ -0,0 +1,414 @@
1
+ """ADB command-line wrapper — stdlib ``subprocess`` only.
2
+
3
+ No third-party ADB libraries (adbutils / ppadb). No Airtest runtime.
4
+
5
+ Design constraints:
6
+ - Every invocation has a hard timeout (``default_timeout``, 10s).
7
+ - ``screencap()`` returns raw PNG bytes via ``exec-out screencap -p``.
8
+ - ``wm_size`` is cached per-instance.
9
+ - No retries here; higher layers own degradation logic.
10
+ - **ADB version is locked** via ``LOCKED_ADB_VERSION`` (r35.0.2).
11
+ ``_locked_adb_path()`` resolves the locked binary relative to the
12
+ consumer project root; callers should prefer ``_resolve_adb_bin()``
13
+ (which auto-locks) over raw ``adb_bin="adb"``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import os as _os
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import sys as _sys
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ LOG = logging.getLogger("mum.adb")
29
+
30
+ KEYCODE_BACK = 4
31
+ KEYCODE_HOME = 3
32
+
33
+ _DEFAULT_TIMEOUT = 10.0
34
+ _WM_OVERRIDE_PATTERN = re.compile(r"Override size:\s*(\d+)x(\d+)")
35
+ _WM_PHYSICAL_PATTERN = re.compile(r"Physical size:\s*(\d+)x(\d+)")
36
+ _WINDOW_FOCUS_PATTERN = re.compile(
37
+ r"(?:mCurrentFocus|mFocusedApp|mResumedActivity)[^\n]*?([a-zA-Z0-9_.]+)/([a-zA-Z0-9_.$]+)"
38
+ )
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # ADB version locking
42
+ # ---------------------------------------------------------------------------
43
+
44
+ LOCKED_ADB_VERSION = "35.0.2"
45
+ """Locked platform-tools version. ADB binary must match this exact version."""
46
+
47
+ _LOCKED_ADB_BINARY: Optional[str] = None
48
+ """Cached locked ADB binary path, resolved once at module level."""
49
+
50
+
51
+ def _project_root() -> Optional[Path]:
52
+ """Walk upward from cwd for a project-root marker."""
53
+ cwd = Path.cwd()
54
+ markers = [".git", "pyproject.toml"]
55
+ for parent in [cwd, *cwd.parents]:
56
+ if any((parent / m).exists() for m in markers):
57
+ return parent
58
+ return None
59
+
60
+
61
+ def _locked_adb_path() -> Optional[Path]:
62
+ """Resolve the locked ADB binary for the locked platform-tools version.
63
+
64
+ Resolution order:
65
+ 1. ``MUM_ADB_TOOLS_DIR`` env var — use ``adb`` / ``adb.exe`` inside it.
66
+ 2. Project root ``tools/platform-tools_r{version}/adb.exe``.
67
+ 3. Fallback: ``shutil.which("adb")`` (logged at WARNING).
68
+ """
69
+ # 1. Explicit env override
70
+ tools_dir = _os.getenv("MUM_ADB_TOOLS_DIR")
71
+ if tools_dir:
72
+ adb_exe = "adb.exe" if _sys.platform == "win32" else "adb"
73
+ p = Path(tools_dir) / adb_exe
74
+ if p.is_file():
75
+ return p
76
+ LOG.warning("MUM_ADB_TOOLS_DIR=%s but %s not found inside", tools_dir, adb_exe)
77
+
78
+ # 2. Project-root-relative locked directory
79
+ root = _project_root()
80
+ if root:
81
+ plat = "windows" if _sys.platform == "win32" else "linux"
82
+ subdir = f"tools/platform-tools_r{LOCKED_ADB_VERSION}_{plat}"
83
+ adb_exe = "adb.exe" if _sys.platform == "win32" else "adb"
84
+ p = root / subdir / adb_exe
85
+ if p.is_file():
86
+ return p
87
+
88
+ # 3. Fallback: system ADB (not locked)
89
+ system_adb = shutil.which("adb")
90
+ if system_adb:
91
+ LOG.warning(
92
+ "locked ADB (r%s) not found; falling back to system adb: %s",
93
+ LOCKED_ADB_VERSION, system_adb,
94
+ )
95
+ return Path(system_adb)
96
+
97
+ LOG.warning("no ADB binary found (locked r%s or system)", LOCKED_ADB_VERSION)
98
+ return None
99
+
100
+
101
+ def _verify_version_or_die(adb_path: Path) -> None:
102
+ """Verify the ADB binary reports version == ``LOCKED_ADB_VERSION``.
103
+
104
+ Raises :class:`AdbError` on mismatch or failure to parse.
105
+ """
106
+ result = subprocess.run(
107
+ [str(adb_path), "version"],
108
+ capture_output=True,
109
+ text=True,
110
+ timeout=10.0,
111
+ check=False,
112
+ )
113
+ if result.returncode != 0:
114
+ raise AdbError(
115
+ [str(adb_path), "version"],
116
+ result.returncode,
117
+ result.stderr,
118
+ )
119
+
120
+ match = re.search(r"Version\s+(\d+\.\d+\.\d+)", result.stdout)
121
+ if not match:
122
+ LOG.warning(
123
+ "cannot parse ADB version from: %s",
124
+ result.stdout.splitlines()[0] if result.stdout else "(empty)",
125
+ )
126
+ return
127
+
128
+ actual = match.group(1)
129
+ if actual != LOCKED_ADB_VERSION:
130
+ raise AdbError(
131
+ [str(adb_path)],
132
+ 1,
133
+ f"ADB version mismatch: expected r{LOCKED_ADB_VERSION}, got r{actual}. "
134
+ f"Place the correct version under tools/platform-tools_r{LOCKED_ADB_VERSION}_*/ "
135
+ f"or set MUM_ADB_TOOLS_DIR.",
136
+ )
137
+
138
+
139
+ def _resolve_adb_bin(adb_bin: Optional[str] = None) -> str:
140
+ """Resolve the ADB binary to use, preferring the locked version.
141
+
142
+ * If *adb_bin* is explicitly provided (non-default), use it as-is.
143
+ * Otherwise resolve via ``_locked_adb_path()`` with fallback to ``"adb"``.
144
+
145
+ Callers should pass their ``adb_bin`` kwarg through this function
146
+ to automatically enforce version locking.
147
+ """
148
+ if adb_bin is not None and adb_bin != "adb":
149
+ return adb_bin
150
+ locked = _locked_adb_path()
151
+ return str(locked) if locked else "adb"
152
+
153
+
154
+ # Eagerly resolve locked ADB at import time (non-fatal if missing).
155
+ try:
156
+ _locked = _locked_adb_path()
157
+ if _locked:
158
+ _LOCKED_ADB_BINARY = str(_locked)
159
+ LOG.debug("locked ADB: %s", _LOCKED_ADB_BINARY)
160
+ except Exception:
161
+ LOG.debug("could not resolve locked ADB at import time", exc_info=True)
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # AdbClient
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ class AdbError(RuntimeError):
170
+ """Raised when an ADB command returns non-zero or times out."""
171
+
172
+ def __init__(self, cmd: list[str], returncode: int, stderr: str) -> None:
173
+ super().__init__(
174
+ f"adb command failed (rc={returncode}): {' '.join(cmd)!r}\n"
175
+ f" stderr: {stderr.strip()!r}"
176
+ )
177
+ self.cmd = cmd
178
+ self.returncode = returncode
179
+ self.stderr = stderr
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class AdbResult:
184
+ stdout: str
185
+ stderr: str
186
+ returncode: int
187
+
188
+ @property
189
+ def ok(self) -> bool:
190
+ return self.returncode == 0
191
+
192
+
193
+ class AdbClient:
194
+ """Thin wrapper around the ``adb`` CLI bound to a specific ``device_id``."""
195
+
196
+ def __init__(
197
+ self,
198
+ device_id: str,
199
+ adb_bin: str = "adb",
200
+ default_timeout: float = _DEFAULT_TIMEOUT,
201
+ ) -> None:
202
+ self.device_id = device_id
203
+ self.adb_bin = adb_bin
204
+ self.default_timeout = default_timeout
205
+ self._wm_size_cache: Optional[tuple[int, int]] = None
206
+
207
+ # ---- low-level invocation ------------------------------------------
208
+
209
+ def _run(
210
+ self,
211
+ args: list[str],
212
+ *,
213
+ timeout: Optional[float] = None,
214
+ ) -> AdbResult:
215
+ cmd = [self.adb_bin, "-s", self.device_id, *args]
216
+ LOG.debug("adb: %s", " ".join(cmd))
217
+ try:
218
+ completed = subprocess.run(
219
+ cmd,
220
+ capture_output=True,
221
+ timeout=timeout or self.default_timeout,
222
+ check=False,
223
+ )
224
+ except FileNotFoundError as e:
225
+ raise AdbError(cmd, 127, f"adb binary not found: {e}") from e
226
+ except subprocess.TimeoutExpired as e:
227
+ raise AdbError(cmd, -1, f"timed out after {e.timeout}s") from e
228
+
229
+ stdout = completed.stdout.decode("utf-8", errors="replace") if completed.stdout else ""
230
+ stderr = completed.stderr.decode("utf-8", errors="replace") if completed.stderr else ""
231
+ return AdbResult(stdout=stdout, stderr=stderr, returncode=completed.returncode)
232
+
233
+ def _run_binary(
234
+ self, args: list[str], *, timeout: Optional[float] = None
235
+ ) -> bytes:
236
+ cmd = [self.adb_bin, "-s", self.device_id, *args]
237
+ LOG.debug("adb: %s (binary)", " ".join(cmd))
238
+ try:
239
+ completed = subprocess.run(
240
+ cmd,
241
+ capture_output=True,
242
+ timeout=timeout or self.default_timeout,
243
+ check=False,
244
+ )
245
+ except FileNotFoundError as e:
246
+ raise AdbError(cmd, 127, f"adb binary not found: {e}") from e
247
+ except subprocess.TimeoutExpired as e:
248
+ raise AdbError(cmd, -1, f"timed out after {e.timeout}s") from e
249
+
250
+ if completed.returncode != 0:
251
+ stderr = completed.stderr.decode("utf-8", errors="replace") if completed.stderr else ""
252
+ raise AdbError(cmd, completed.returncode, stderr)
253
+ return completed.stdout or b""
254
+
255
+ def _require(self, result: AdbResult, cmd_hint: str) -> AdbResult:
256
+ if not result.ok:
257
+ raise AdbError([cmd_hint], result.returncode, result.stderr)
258
+ return result
259
+
260
+ # ---- preflight ------------------------------------------------------
261
+
262
+ def is_device_connected(self) -> bool:
263
+ result = subprocess.run(
264
+ [self.adb_bin, "devices"],
265
+ capture_output=True,
266
+ timeout=self.default_timeout,
267
+ check=False,
268
+ )
269
+ if result.returncode != 0:
270
+ return False
271
+ out = result.stdout.decode("utf-8", errors="replace")
272
+ for line in out.splitlines():
273
+ parts = line.strip().split("\t")
274
+ if len(parts) == 2 and parts[0] == self.device_id and parts[1] == "device":
275
+ return True
276
+ return False
277
+
278
+ # ---- display --------------------------------------------------------
279
+
280
+ def wm_size(self) -> tuple[int, int]:
281
+ if self._wm_size_cache is not None:
282
+ return self._wm_size_cache
283
+ result = self._require(
284
+ self._run(["shell", "wm", "size"]),
285
+ cmd_hint="adb shell wm size",
286
+ )
287
+ match = _WM_OVERRIDE_PATTERN.search(result.stdout) or _WM_PHYSICAL_PATTERN.search(result.stdout)
288
+ if not match:
289
+ raise AdbError(
290
+ ["adb", "shell", "wm", "size"],
291
+ result.returncode,
292
+ f"could not parse wm size: {result.stdout!r}",
293
+ )
294
+ w, h = int(match.group(1)), int(match.group(2))
295
+ self._wm_size_cache = (w, h)
296
+ return (w, h)
297
+
298
+ def wm_density(self) -> int:
299
+ result = self._require(
300
+ self._run(["shell", "wm", "density"]),
301
+ cmd_hint="adb shell wm density",
302
+ )
303
+ match = (
304
+ re.search(r"Override density:\s*(\d+)", result.stdout)
305
+ or re.search(r"Physical density:\s*(\d+)", result.stdout)
306
+ )
307
+ if not match:
308
+ raise AdbError(
309
+ ["adb", "shell", "wm", "density"],
310
+ result.returncode,
311
+ f"could not parse wm density: {result.stdout!r}",
312
+ )
313
+ return int(match.group(1))
314
+
315
+ # ---- capture --------------------------------------------------------
316
+
317
+ def screencap(self, save_to: Optional[Path] = None, timeout: float = 15.0) -> bytes:
318
+ data = self._run_binary(["exec-out", "screencap", "-p"], timeout=timeout)
319
+ if not data.startswith(b"\x89PNG\r\n\x1a\n"):
320
+ raise AdbError(
321
+ ["adb", "exec-out", "screencap", "-p"],
322
+ 0,
323
+ f"unexpected non-PNG prefix (len={len(data)})",
324
+ )
325
+ if save_to is not None:
326
+ save_to.write_bytes(data)
327
+ LOG.debug("screencap saved to %s (%d bytes)", save_to, len(data))
328
+ return data
329
+
330
+ # ---- input ----------------------------------------------------------
331
+
332
+ def tap(self, x: int, y: int) -> None:
333
+ self._require(
334
+ self._run(["shell", "input", "tap", str(int(x)), str(int(y))]),
335
+ cmd_hint="adb shell input tap",
336
+ )
337
+
338
+ def swipe(
339
+ self, x1: int, y1: int, x2: int, y2: int, duration_ms: int = 300,
340
+ ) -> None:
341
+ self._require(
342
+ self._run([
343
+ "shell", "input", "swipe",
344
+ str(int(x1)), str(int(y1)),
345
+ str(int(x2)), str(int(y2)),
346
+ str(int(duration_ms)),
347
+ ]),
348
+ cmd_hint="adb shell input swipe",
349
+ )
350
+
351
+ def key_event(self, code: int) -> None:
352
+ self._require(
353
+ self._run(["shell", "input", "keyevent", str(int(code))]),
354
+ cmd_hint="adb shell input keyevent",
355
+ )
356
+
357
+ # ---- app control ----------------------------------------------------
358
+
359
+ def force_stop(self, package: str, *, timeout: Optional[float] = None) -> None:
360
+ self._require(
361
+ self._run(["shell", "am", "force-stop", package], timeout=timeout),
362
+ cmd_hint=f"adb shell am force-stop {package}",
363
+ )
364
+
365
+ def monkey_launch_launcher_category(
366
+ self, package: str, *, timeout: Optional[float] = None,
367
+ ) -> None:
368
+ self._require(
369
+ self._run(
370
+ ["shell", "monkey", "-p", package, "-c",
371
+ "android.intent.category.LAUNCHER", "1"],
372
+ timeout=timeout or self.default_timeout,
373
+ ),
374
+ cmd_hint=f"adb shell monkey -p {package} … LAUNCHER 1",
375
+ )
376
+
377
+ # ---- window inspection ---------------------------------------------
378
+
379
+ def dumpsys_window_focus(self) -> str:
380
+ result = self._require(
381
+ self._run(["shell", "dumpsys", "window"], timeout=15.0),
382
+ cmd_hint="adb shell dumpsys window",
383
+ )
384
+ matches = []
385
+ for line in result.stdout.splitlines():
386
+ stripped = line.strip()
387
+ if stripped.startswith(("mCurrentFocus", "mFocusedApp", "mResumedActivity")):
388
+ matches.append(stripped)
389
+ return "\n".join(matches)
390
+
391
+ def foreground_package(self) -> Optional[str]:
392
+ focus = self.dumpsys_window_focus()
393
+ match = _WINDOW_FOCUS_PATTERN.search(focus)
394
+ if not match:
395
+ return None
396
+ return match.group(1)
397
+
398
+ def is_foreground_app(self, package: str) -> bool:
399
+ fg = self.foreground_package()
400
+ return fg == package
401
+
402
+
403
+ __all__ = [
404
+ "AdbClient",
405
+ "AdbError",
406
+ "AdbResult",
407
+ "KEYCODE_BACK",
408
+ "KEYCODE_HOME",
409
+ "LOCKED_ADB_VERSION",
410
+ "_LOCKED_ADB_BINARY",
411
+ "_locked_adb_path",
412
+ "_verify_version_or_die",
413
+ "_resolve_adb_bin",
414
+ ]
@@ -0,0 +1,18 @@
1
+ """坐标微偏移 — 供 ``DisguiseAdbClient`` 内部调用。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+
7
+ _DEFAULT_OFFSET = 5
8
+
9
+
10
+ def jitter(x: int, y: int, offset: int = _DEFAULT_OFFSET) -> tuple[int, int]:
11
+ """在坐标 (x, y) 上添加 ±offset 范围内的随机偏移。
12
+
13
+ Returns:
14
+ (x2, y2): 偏移后的新坐标。
15
+ """
16
+ dx = random.randint(-offset, offset)
17
+ dy = random.randint(-offset, offset)
18
+ return x + dx, y + dy
@@ -0,0 +1,177 @@
1
+ """防伪装客户端 — 自动注入人类操作模拟。
2
+
3
+ - ``DisguiseAdbClient``(生产):每次操作前注入随机延迟 + 坐标偏移。
4
+ - ``DisguiseAdbClientDebug``(子类):跳过伪装注入,直接走原生 ADB。
5
+
6
+ 贝塞尔曲线:sendevent / input motionevent 在 MI 8 UD (Android 10)、Xiaomi HyperOS (Android 16)、
7
+ Huawei (Android 13) 上均不产生触摸效果,故保持 ``input swipe`` 直线。
8
+ 防检测通过随机起止坐标 + jitter + 随机延迟 + duration 随机化实现。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import random
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from mum.android.base.adb._raw_adb import (
18
+ KEYCODE_BACK,
19
+ KEYCODE_HOME,
20
+ _resolve_adb_bin,
21
+ AdbClient,
22
+ AdbError,
23
+ AdbResult,
24
+ )
25
+ from mum.android.base.adb.random_delay import random_sleep
26
+ from mum.android.base.adb.coordinate import jitter
27
+
28
+
29
+ class DisguiseAdbClient:
30
+ """生产环境 ADB 客户端,自动注入人类操作模拟。
31
+
32
+ 每次操作前自动执行:随机延迟 → 坐标微偏移。
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ device_id: str,
38
+ adb_bin: str = "adb",
39
+ default_timeout: float = 10.0,
40
+ ) -> None:
41
+ # Enforce locked ADB unless caller explicitly overrides
42
+ resolved = _resolve_adb_bin(adb_bin)
43
+ self._raw = AdbClient(
44
+ device_id=device_id,
45
+ adb_bin=resolved,
46
+ default_timeout=default_timeout,
47
+ )
48
+
49
+ @property
50
+ def device_id(self) -> str:
51
+ return self._raw.device_id
52
+
53
+ # ---- 伪装操作入口 ----
54
+
55
+ def click(self, x: int, y: int) -> None:
56
+ """带随机延迟和坐标偏移的 tap。
57
+
58
+ 内部调用顺序:随机延迟 → 坐标微偏移 → adb tap。
59
+ """
60
+ random_sleep()
61
+ x2, y2 = jitter(x, y)
62
+ self._raw.tap(x2, y2)
63
+
64
+ def swipe(
65
+ self,
66
+ x1: int,
67
+ y1: int,
68
+ x2: int,
69
+ y2: int,
70
+ duration_ms: int = 400,
71
+ ) -> None:
72
+ """单次连续 swipe(含随机延迟 + duration 随机化)。
73
+
74
+ 调用一次 ``adb shell input swipe`` 产生 **1 次** 完整的 DOWN→MOVE→UP
75
+ 触摸序列。sendevent / input motionevent 贝塞尔路径在三设备上均
76
+ 不产生触摸效果(D1/D2 Xiaomi 定制内核 + D3 Huawei),故保持直线。
77
+
78
+ 防风控:duration 0~80ms 随机 + 随机延迟 + tap 坐标 jitter。
79
+ """
80
+ random_sleep()
81
+ d = max(50, duration_ms + int(random.uniform(0, 80)))
82
+ self._raw.swipe(x1, y1, x2, y2, duration_ms=d)
83
+
84
+ def screencap(self, save_to: Optional[Path] = None, timeout: float = 15.0) -> bytes:
85
+ return self._raw.screencap(save_to=save_to, timeout=timeout)
86
+
87
+ def keyevent(self, code: str | int) -> None:
88
+ """发送按键事件(兼容字符串和整数 code)。
89
+
90
+ 内部调用顺序:随机延迟 → adb key_event。
91
+ 延迟范围 80~200ms,避免与 screencap 等操作形成固定时序模式。
92
+
93
+ Args:
94
+ code: 按键名 (str) 或键码 (int),如 ``"BACK"``、``"HOME"``、``4``、``3``。
95
+ """
96
+ random_sleep(0.08, 0.20)
97
+ key_map = {"BACK": KEYCODE_BACK, "HOME": KEYCODE_HOME}
98
+ if isinstance(code, str) and code.upper() in key_map:
99
+ code = key_map[code.upper()]
100
+ self._raw.key_event(int(code))
101
+
102
+ # ---- 别名(兼容旧调用) ----
103
+
104
+ tap = click
105
+ key_event = keyevent
106
+
107
+ # ---- 委托给 _raw 的只读方法 ----
108
+
109
+ def wm_size(self) -> tuple[int, int]:
110
+ return self._raw.wm_size()
111
+
112
+ def wm_density(self) -> int:
113
+ return self._raw.wm_density()
114
+
115
+ def is_device_connected(self) -> bool:
116
+ return self._raw.is_device_connected()
117
+
118
+ def foreground_package(self) -> Optional[str]:
119
+ return self._raw.foreground_package()
120
+
121
+ def dumpsys_window_focus(self) -> str:
122
+ return self._raw.dumpsys_window_focus()
123
+
124
+ def is_foreground_app(self, package: str) -> bool:
125
+ return self._raw.is_foreground_app(package)
126
+
127
+ def _run(self, args: list[str]) -> str:
128
+ """Raw ADB command (required by AdbProtocol)."""
129
+ result = self._raw._run(args)
130
+ return result.stdout
131
+
132
+ # ---- app control(不经伪装,直接委托) ----
133
+
134
+ def force_stop(self, package: str, *, timeout: Optional[float] = None) -> None:
135
+ self._raw.force_stop(package, timeout=timeout)
136
+
137
+ def monkey_launch_launcher_category(
138
+ self, package: str, *, timeout: Optional[float] = None,
139
+ ) -> None:
140
+ self._raw.monkey_launch_launcher_category(package, timeout=timeout)
141
+
142
+
143
+ class DisguiseAdbClientDebug(DisguiseAdbClient):
144
+ """开发调试 ADB 客户端,跳过所有伪装注入。
145
+
146
+ 所有操作直接走原生 ADB,不注入随机延迟、坐标偏移。
147
+ 任务脚本通过 import 此类替代魔法环境变量。
148
+ """
149
+
150
+ def click(self, x: int, y: int) -> None:
151
+ self._raw.tap(x, y)
152
+
153
+ def swipe(
154
+ self,
155
+ x1: int,
156
+ y1: int,
157
+ x2: int,
158
+ y2: int,
159
+ duration_ms: int = 400,
160
+ ) -> None:
161
+ self._raw.swipe(x1, y1, x2, y2, duration_ms=duration_ms)
162
+
163
+ def keyevent(self, code: str | int) -> None:
164
+ key_map = {"BACK": KEYCODE_BACK, "HOME": KEYCODE_HOME}
165
+ if isinstance(code, str) and code.upper() in key_map:
166
+ code = key_map[code.upper()]
167
+ self._raw.key_event(int(code))
168
+
169
+ tap = click
170
+
171
+
172
+ __all__ = [
173
+ "DisguiseAdbClient",
174
+ "DisguiseAdbClientDebug",
175
+ "AdbError",
176
+ "AdbResult",
177
+ ]