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 +8 -0
- mum/android/__init__.py +0 -0
- mum/android/base/__init__.py +0 -0
- mum/android/base/adb/__init__.py +53 -0
- mum/android/base/adb/_protocol.py +20 -0
- mum/android/base/adb/_raw_adb.py +414 -0
- mum/android/base/adb/coordinate.py +18 -0
- mum/android/base/adb/disguise_client.py +177 -0
- mum/android/base/adb/random_delay.py +18 -0
- mum/android/base/adb/scroll.py +262 -0
- mum/android/wechat/__init__.py +0 -0
- mum/android/wechat/reposition.py +281 -0
- mumdad-0.1.4.dist-info/METADATA +149 -0
- mumdad-0.1.4.dist-info/RECORD +16 -0
- mumdad-0.1.4.dist-info/WHEEL +5 -0
- mumdad-0.1.4.dist-info/top_level.txt +1 -0
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"
|
mum/android/__init__.py
ADDED
|
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
|
+
]
|