layernav-android 0.2.0__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.
- layernav_android/__init__.py +27 -0
- layernav_android/_protocol.py +18 -0
- layernav_android/base.py +304 -0
- layernav_android/cold_start.py +162 -0
- layernav_android/contrib/__init__.py +0 -0
- layernav_android/contrib/wechat.py +223 -0
- layernav_android/contrib/xhs.py +69 -0
- layernav_android-0.2.0.dist-info/METADATA +334 -0
- layernav_android-0.2.0.dist-info/RECORD +11 -0
- layernav_android-0.2.0.dist-info/WHEEL +4 -0
- layernav_android-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from layernav_android._protocol import AdbProtocol
|
|
2
|
+
from layernav_android.base import (
|
|
3
|
+
BaseLayerModel,
|
|
4
|
+
KEYCODE_BACK,
|
|
5
|
+
KEYCODE_HOME,
|
|
6
|
+
LayerDef,
|
|
7
|
+
LayerListener,
|
|
8
|
+
POST_TRANSITION_SLEEP,
|
|
9
|
+
)
|
|
10
|
+
from layernav_android.cold_start import (
|
|
11
|
+
APP_DEFAULTS,
|
|
12
|
+
cold_start_app_from_launcher,
|
|
13
|
+
dock_app_icon_coords,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AdbProtocol",
|
|
18
|
+
"APP_DEFAULTS",
|
|
19
|
+
"BaseLayerModel",
|
|
20
|
+
"cold_start_app_from_launcher",
|
|
21
|
+
"dock_app_icon_coords",
|
|
22
|
+
"KEYCODE_BACK",
|
|
23
|
+
"KEYCODE_HOME",
|
|
24
|
+
"LayerDef",
|
|
25
|
+
"LayerListener",
|
|
26
|
+
"POST_TRANSITION_SLEEP",
|
|
27
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AdbProtocol(Protocol):
|
|
7
|
+
"""Minimal ADB interface for layer navigation.
|
|
8
|
+
|
|
9
|
+
Users implement or inject their existing ADB client.
|
|
10
|
+
``collector_phone_android``'s ``AdbClient`` already satisfies
|
|
11
|
+
this protocol — no adapter needed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def screencap(self) -> bytes: ...
|
|
15
|
+
def key_event(self, code: int) -> None: ...
|
|
16
|
+
def foreground_package(self) -> str: ...
|
|
17
|
+
def tap(self, x: int, y: int) -> None: ...
|
|
18
|
+
def _run(self, args: list[str]) -> str: ...
|
layernav_android/base.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Multi-layer Android task-stack navigation framework.
|
|
2
|
+
|
|
3
|
+
Framework—Task contract:
|
|
4
|
+
Task subclass overrides:
|
|
5
|
+
- ``layers`` — list of :class:`LayerDef`
|
|
6
|
+
- ``detect`` — screenshot-based layer detection
|
|
7
|
+
- ``_on_Lx`` — per-layer handler (business logic + tap)
|
|
8
|
+
|
|
9
|
+
Framework provides:
|
|
10
|
+
Atomic: ``detect`` ``enter_next`` ``back_one`` ``back_recover``
|
|
11
|
+
Combined: ``back`` ``advance`` ``restore``
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Protocol
|
|
20
|
+
|
|
21
|
+
from layernav_android._protocol import AdbProtocol
|
|
22
|
+
|
|
23
|
+
LOG = logging.getLogger("layernav")
|
|
24
|
+
|
|
25
|
+
POST_TRANSITION_SLEEP = 1.5
|
|
26
|
+
|
|
27
|
+
KEYCODE_BACK = 4
|
|
28
|
+
KEYCODE_HOME = 3
|
|
29
|
+
|
|
30
|
+
# ── Layer definition ──────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class LayerDef:
|
|
35
|
+
"""Description of one layer in the Android task stack."""
|
|
36
|
+
|
|
37
|
+
key: str
|
|
38
|
+
"""Layer key: ``"L0"`` | ``"L1"`` | ``"L2"`` | ``"L3"``."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
"""Machine-readable name."""
|
|
42
|
+
|
|
43
|
+
label_cn: str
|
|
44
|
+
"""Human-readable Chinese label."""
|
|
45
|
+
|
|
46
|
+
detection: str
|
|
47
|
+
"""How this layer is detected (human-readable)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Observer / Listener (inspired by python-statemachine's Listener pattern) ───
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class LayerListener(Protocol):
|
|
54
|
+
"""Observer interface for layer model lifecycle events.
|
|
55
|
+
|
|
56
|
+
All methods are optional — implement only what you need.
|
|
57
|
+
Inspired by `python-statemachine
|
|
58
|
+
<https://github.com/fgmacedo/python-statemachine>`_'s Listener pattern.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def on_transition(
|
|
62
|
+
self, from_layer: str, to_layer: str, method: str,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Called after an atomic layer transition completes.
|
|
65
|
+
|
|
66
|
+
*method* is one of ``"enter_next"`` or ``"back_one"``.
|
|
67
|
+
"""
|
|
68
|
+
...
|
|
69
|
+
|
|
70
|
+
def on_timeout(
|
|
71
|
+
self, from_layer: str, target_layer: str, elapsed_s: float,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Called when :meth:`BaseLayerModel.enter_next` polling times out."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def on_recovery(self, target_layer: str, ok: bool) -> None:
|
|
77
|
+
"""Called after :meth:`BaseLayerModel.back_recover` completes.
|
|
78
|
+
|
|
79
|
+
*ok* indicates whether the recovery succeeded.
|
|
80
|
+
"""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Abstract base ─────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BaseLayerModel:
|
|
88
|
+
"""Abstract Android task-stack layer model.
|
|
89
|
+
|
|
90
|
+
Subclass contract:
|
|
91
|
+
- Override :attr:`layers`.
|
|
92
|
+
- Override :meth:`detect`.
|
|
93
|
+
- Override ``_on_L0`` / ``_on_L1`` / ``_on_L2`` / ``_on_L3``.
|
|
94
|
+
- Optionally override :meth:`_cold_start`.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
layers: list[LayerDef] = []
|
|
98
|
+
_ON_METHODS: tuple[str, ...] = ("_on_L0", "_on_L1", "_on_L2", "_on_L3")
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self._listeners: list[LayerListener] = []
|
|
102
|
+
|
|
103
|
+
def add_listener(self, listener: LayerListener) -> None:
|
|
104
|
+
"""Register a :class:`LayerListener` to observe lifecycle events."""
|
|
105
|
+
self._listeners.append(listener)
|
|
106
|
+
|
|
107
|
+
def _notify_transition(
|
|
108
|
+
self, from_layer: str, to_layer: str, method: str,
|
|
109
|
+
) -> None:
|
|
110
|
+
for lst in self._listeners:
|
|
111
|
+
lst.on_transition(from_layer, to_layer, method)
|
|
112
|
+
|
|
113
|
+
def _notify_timeout(
|
|
114
|
+
self, from_layer: str, target_layer: str, elapsed_s: float,
|
|
115
|
+
) -> None:
|
|
116
|
+
for lst in self._listeners:
|
|
117
|
+
lst.on_timeout(from_layer, target_layer, elapsed_s)
|
|
118
|
+
|
|
119
|
+
def _notify_recovery(self, target_layer: str, ok: bool) -> None:
|
|
120
|
+
for lst in self._listeners:
|
|
121
|
+
lst.on_recovery(target_layer, ok)
|
|
122
|
+
|
|
123
|
+
# ── Subclass overrides ────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def detect(self, adb: AdbProtocol, scale_w: float) -> str:
|
|
126
|
+
"""Return current layer key. Task MUST override."""
|
|
127
|
+
raise NotImplementedError("subclass must override detect()")
|
|
128
|
+
|
|
129
|
+
def _on_L0(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
130
|
+
"""L0 handler: home screen → cold-start App."""
|
|
131
|
+
raise NotImplementedError("subclass must override _on_L0")
|
|
132
|
+
|
|
133
|
+
def _on_L1(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
134
|
+
"""L1 handler: App main screen → pick content, tap."""
|
|
135
|
+
raise NotImplementedError("subclass must override _on_L1")
|
|
136
|
+
|
|
137
|
+
def _on_L2(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
138
|
+
"""L2 handler: content page → pick sub-content, tap."""
|
|
139
|
+
raise NotImplementedError("subclass must override _on_L2")
|
|
140
|
+
|
|
141
|
+
def _on_L3(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
142
|
+
"""L3 handler: deepest layer — typically no further advance."""
|
|
143
|
+
raise NotImplementedError("subclass must override _on_L3")
|
|
144
|
+
|
|
145
|
+
def _call_on_layer(
|
|
146
|
+
self, layer_key: str, adb: AdbProtocol, scale_w: float, *, quick: bool
|
|
147
|
+
) -> str | None:
|
|
148
|
+
i = self._layer_index(layer_key)
|
|
149
|
+
if i < 0:
|
|
150
|
+
LOG.error("_call_on_layer: unknown layer %s", layer_key)
|
|
151
|
+
return None
|
|
152
|
+
method = getattr(self, self._ON_METHODS[i])
|
|
153
|
+
return method(adb, scale_w, quick=quick)
|
|
154
|
+
|
|
155
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def _layer_index(self, layer_key: str) -> int:
|
|
158
|
+
for i, ld in enumerate(self.layers):
|
|
159
|
+
if ld.key == layer_key:
|
|
160
|
+
return i
|
|
161
|
+
return -1
|
|
162
|
+
|
|
163
|
+
def init(self, adb: AdbProtocol) -> None:
|
|
164
|
+
"""One-time initialisation (optional)."""
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
def _cold_start(
|
|
168
|
+
self, adb: AdbProtocol, target_layer: str, scale_w: float
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Cold-start the target app (override in subclass)."""
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
# ── Atomic API ────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def enter_next(
|
|
176
|
+
self,
|
|
177
|
+
adb: AdbProtocol,
|
|
178
|
+
scale_w: float,
|
|
179
|
+
*,
|
|
180
|
+
quick: bool = False,
|
|
181
|
+
max_wait_s: float = 8.0,
|
|
182
|
+
) -> bool:
|
|
183
|
+
"""Advance ONE layer from current position.
|
|
184
|
+
|
|
185
|
+
1. detect current layer ← **guard** (pre-check)
|
|
186
|
+
2. call _on_L[cur](quick) — handler does business + tap
|
|
187
|
+
3. if handler returns None or same layer → stop (success)
|
|
188
|
+
4. wait POST_TRANSITION_SLEEP, then detect ← **validator** (post-check)
|
|
189
|
+
5. if not yet on target, poll with increasing intervals up to max_wait_s
|
|
190
|
+
|
|
191
|
+
This handles variable transition times (network loading, animations).
|
|
192
|
+
"""
|
|
193
|
+
cur = self.detect(adb, scale_w)
|
|
194
|
+
target = self._call_on_layer(cur, adb, scale_w, quick=quick)
|
|
195
|
+
if target is None or target == cur:
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
time.sleep(POST_TRANSITION_SLEEP)
|
|
199
|
+
next_cur = self.detect(adb, scale_w)
|
|
200
|
+
if next_cur == target:
|
|
201
|
+
self._notify_transition(cur, next_cur, "enter_next")
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
poll_start = time.monotonic()
|
|
205
|
+
deadline = poll_start + max_wait_s
|
|
206
|
+
interval = 0.5
|
|
207
|
+
while time.monotonic() < deadline:
|
|
208
|
+
time.sleep(interval)
|
|
209
|
+
next_cur = self.detect(adb, scale_w)
|
|
210
|
+
if next_cur == target:
|
|
211
|
+
self._notify_transition(cur, next_cur, "enter_next")
|
|
212
|
+
return True
|
|
213
|
+
interval = min(interval + 0.5, 2.0)
|
|
214
|
+
|
|
215
|
+
elapsed = time.monotonic() - poll_start
|
|
216
|
+
LOG.warning(
|
|
217
|
+
"enter_next: %s→%s timeout after %.1fs — still on %s",
|
|
218
|
+
cur, target, max_wait_s, next_cur,
|
|
219
|
+
)
|
|
220
|
+
self._notify_timeout(cur, target, elapsed)
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
def back_one(self, adb: AdbProtocol, scale_w: float) -> str:
|
|
224
|
+
"""Send KEYCODE_BACK once, return new layer.
|
|
225
|
+
|
|
226
|
+
1. detect current layer ← **guard** (pre-check)
|
|
227
|
+
2. KEYCODE_BACK
|
|
228
|
+
3. sleep, detect new layer ← **validator** (post-check)
|
|
229
|
+
"""
|
|
230
|
+
cur = self.detect(adb, scale_w)
|
|
231
|
+
LOG.debug("back_one: from %s", cur)
|
|
232
|
+
adb.key_event(KEYCODE_BACK)
|
|
233
|
+
time.sleep(1.0)
|
|
234
|
+
next_cur = self.detect(adb, scale_w)
|
|
235
|
+
LOG.debug("back_one: %s → %s", cur, next_cur)
|
|
236
|
+
self._notify_transition(cur, next_cur, "back_one")
|
|
237
|
+
return next_cur
|
|
238
|
+
|
|
239
|
+
def back_recover(
|
|
240
|
+
self, adb: AdbProtocol, target_layer: str, scale_w: float
|
|
241
|
+
) -> bool:
|
|
242
|
+
"""Recover after BACK exhaustion: cold-start → fast-forward → normal
|
|
243
|
+
resume."""
|
|
244
|
+
LOG.warning("back_recover: cold-start → fast-forward → %s", target_layer)
|
|
245
|
+
adb.key_event(KEYCODE_HOME)
|
|
246
|
+
time.sleep(0.8)
|
|
247
|
+
self._cold_start(adb, "L1", scale_w)
|
|
248
|
+
|
|
249
|
+
ok = self.advance(adb, target_layer, scale_w, quick=True)
|
|
250
|
+
if not ok:
|
|
251
|
+
self._notify_recovery(target_layer, False)
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
result = self.detect(adb, scale_w) == target_layer
|
|
255
|
+
self._notify_recovery(target_layer, result)
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
# ── Combined API ──────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def back(self, adb: AdbProtocol, to_layer: str, scale_w: float) -> bool:
|
|
261
|
+
"""Retreat to *to_layer* via repeated BACK."""
|
|
262
|
+
for _ in range(3):
|
|
263
|
+
cur = self.detect(adb, scale_w)
|
|
264
|
+
if cur == to_layer:
|
|
265
|
+
self._call_on_layer(to_layer, adb, scale_w, quick=False)
|
|
266
|
+
return True
|
|
267
|
+
if cur == "L0":
|
|
268
|
+
break
|
|
269
|
+
self.back_one(adb, scale_w)
|
|
270
|
+
return self.back_recover(adb, to_layer, scale_w)
|
|
271
|
+
|
|
272
|
+
def advance(
|
|
273
|
+
self, adb: AdbProtocol, target_layer: str, scale_w: float, *,
|
|
274
|
+
quick: bool = False,
|
|
275
|
+
max_wait_s: float = 8.0,
|
|
276
|
+
) -> bool:
|
|
277
|
+
"""Advance layer-by-layer to *target_layer*.
|
|
278
|
+
|
|
279
|
+
Uses :meth:`enter_next` for each step. *quick* is forwarded to
|
|
280
|
+
intermediate layers' handlers. At the target layer, handler is
|
|
281
|
+
always called with ``quick=False``.
|
|
282
|
+
"""
|
|
283
|
+
while True:
|
|
284
|
+
cur = self.detect(adb, scale_w)
|
|
285
|
+
if cur == target_layer:
|
|
286
|
+
self._call_on_layer(target_layer, adb, scale_w, quick=False)
|
|
287
|
+
return True
|
|
288
|
+
ok = self.enter_next(adb, scale_w, quick=quick, max_wait_s=max_wait_s)
|
|
289
|
+
if not ok:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
def restore(
|
|
293
|
+
self, adb: AdbProtocol, target_layer: str, scale_w: float
|
|
294
|
+
) -> bool:
|
|
295
|
+
"""Restore to *target_layer* from any position."""
|
|
296
|
+
cur = self.detect(adb, scale_w)
|
|
297
|
+
if cur == target_layer:
|
|
298
|
+
return True
|
|
299
|
+
ci = self._layer_index(cur)
|
|
300
|
+
ti = self._layer_index(target_layer)
|
|
301
|
+
if ci > ti:
|
|
302
|
+
return self.back(adb, target_layer, scale_w)
|
|
303
|
+
else:
|
|
304
|
+
return self.advance(adb, target_layer, scale_w, quick=True)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Generic cold-start: launch an Android app from the launcher home screen.
|
|
2
|
+
|
|
3
|
+
Supports two paths in priority order:
|
|
4
|
+
|
|
5
|
+
1. **monkey** — ``monkey -p <package> -c LAUNCHER 1`` (primary)
|
|
6
|
+
2. **Dock icon tap** — calculated via ``dock_app_icon_coords`` (fallback)
|
|
7
|
+
|
|
8
|
+
After the app enters foreground, optionally taps a session tab to reach the
|
|
9
|
+
app's main content list (e.g. WeChat's bottom "微信" tab).
|
|
10
|
+
|
|
11
|
+
.. code-block:: python
|
|
12
|
+
|
|
13
|
+
from layernav_android.cold_start import cold_start_app_from_launcher
|
|
14
|
+
|
|
15
|
+
ok = cold_start_app_from_launcher(
|
|
16
|
+
adb, "com.tencent.mm", 1080, 2248, 1.0,
|
|
17
|
+
app_name="wechat", M=4, N=3,
|
|
18
|
+
session_tab_x=108, session_tab_y=2192,
|
|
19
|
+
)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import time
|
|
26
|
+
|
|
27
|
+
from layernav_android._protocol import AdbProtocol
|
|
28
|
+
|
|
29
|
+
LOG = logging.getLogger("layernav.cold_start")
|
|
30
|
+
|
|
31
|
+
APP_DEFAULTS: dict[str, dict[str, int]] = {
|
|
32
|
+
"wechat": {"M": 4, "N": 3},
|
|
33
|
+
"xhs": {"M": 4, "N": 1},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def dock_app_icon_coords(
|
|
38
|
+
screen_w: int,
|
|
39
|
+
screen_h: int,
|
|
40
|
+
scale_w: float,
|
|
41
|
+
*,
|
|
42
|
+
app_name: str = "wechat",
|
|
43
|
+
M: int = 4,
|
|
44
|
+
N: int | None = None,
|
|
45
|
+
) -> tuple[int, int]:
|
|
46
|
+
"""Calculate the centre of the *N*-th Dock slot (1‑indexed) in a Dock with *M* equal-width slots.
|
|
47
|
+
|
|
48
|
+
- *M*: total number of Dock slots (default 4).
|
|
49
|
+
- *N*: 1‑based slot index; defaults from ``APP_DEFAULTS`` (wechat→3, xhs→1).
|
|
50
|
+
- Formula: ``x = round(W * (N - 0.5) / M)``.
|
|
51
|
+
"""
|
|
52
|
+
if N is None:
|
|
53
|
+
defaults = APP_DEFAULTS.get(app_name, {})
|
|
54
|
+
N = defaults.get("N", 1)
|
|
55
|
+
M = defaults.get("M", M)
|
|
56
|
+
N = max(1, min(M, N))
|
|
57
|
+
pad_x = max(12, screen_w // 8)
|
|
58
|
+
dx = int(round(screen_w * (N - 0.5) / M))
|
|
59
|
+
dx = max(pad_x, min(screen_w - pad_x, dx))
|
|
60
|
+
dy = screen_h - max(48, int(round(52 * max(scale_w, 1e-6))))
|
|
61
|
+
return dx, dy
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cold_start_app_from_launcher(
|
|
65
|
+
adb: AdbProtocol,
|
|
66
|
+
package: str,
|
|
67
|
+
screen_w: int,
|
|
68
|
+
screen_h: int,
|
|
69
|
+
scale_w: float,
|
|
70
|
+
*,
|
|
71
|
+
app_name: str = "wechat",
|
|
72
|
+
M: int = 4,
|
|
73
|
+
N: int | None = None,
|
|
74
|
+
session_tab_x: int | None = None,
|
|
75
|
+
session_tab_y: int | None = None,
|
|
76
|
+
force_stop_before: bool = True,
|
|
77
|
+
deadline_s: float = 25.0,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Cold-start *package* from the Android launcher and optionally tap a session tab.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
adb: :class:`AdbProtocol` client.
|
|
83
|
+
package: Android package name (e.g. ``"com.tencent.mm"``).
|
|
84
|
+
screen_w, screen_h: device resolution in pixels.
|
|
85
|
+
scale_w: ``screen_w / 1080.0``.
|
|
86
|
+
app_name: key in :data:`APP_DEFAULTS` — drives default *M* / *N*.
|
|
87
|
+
M: number of Dock slots.
|
|
88
|
+
N: 1‑based slot index where the app icon lives.
|
|
89
|
+
session_tab_x, session_tab_y: if both are non-``None``, tap this
|
|
90
|
+
coordinate after the app enters foreground (e.g. WeChat's
|
|
91
|
+
「微信」 bottom tab).
|
|
92
|
+
force_stop_before: issue ``am force-stop`` before cold-start.
|
|
93
|
+
deadline_s: maximum time budget for the whole cold-start.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
``True`` if *package* is the foreground app after cold-start.
|
|
97
|
+
"""
|
|
98
|
+
deadline = time.monotonic() + deadline_s
|
|
99
|
+
|
|
100
|
+
if force_stop_before:
|
|
101
|
+
LOG.info("cold_start: force-stop %s", package)
|
|
102
|
+
try:
|
|
103
|
+
adb._run(["shell", "am", "force-stop", package])
|
|
104
|
+
except Exception:
|
|
105
|
+
LOG.warning("cold_start: force-stop %s failed (non-fatal)", package)
|
|
106
|
+
time.sleep(0.65)
|
|
107
|
+
|
|
108
|
+
# -- primary: monkey LAUNCHER --
|
|
109
|
+
LOG.info("cold_start: monkey LAUNCHER for %s", package)
|
|
110
|
+
try:
|
|
111
|
+
adb._run(
|
|
112
|
+
["shell", "monkey", "-p", package, "-c",
|
|
113
|
+
"android.intent.category.LAUNCHER", "1"],
|
|
114
|
+
)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
LOG.warning("cold_start: monkey primary failed (%s) — fallback to Dock tap", exc)
|
|
117
|
+
else:
|
|
118
|
+
time.sleep(1.5)
|
|
119
|
+
if session_tab_x is not None and session_tab_y is not None:
|
|
120
|
+
adb.tap(session_tab_x, session_tab_y)
|
|
121
|
+
time.sleep(0.55)
|
|
122
|
+
|
|
123
|
+
if _check_foreground(adb, package):
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
# -- fallback: Dock icon tap --
|
|
127
|
+
dx, dy = dock_app_icon_coords(
|
|
128
|
+
screen_w, screen_h, scale_w, app_name=app_name, M=M, N=N,
|
|
129
|
+
)
|
|
130
|
+
LOG.info("cold_start: Dock tap fallback at (%d, %d) for %s", dx, dy, package)
|
|
131
|
+
adb.tap(dx, dy)
|
|
132
|
+
time.sleep(1.2)
|
|
133
|
+
if session_tab_x is not None and session_tab_y is not None:
|
|
134
|
+
adb.tap(session_tab_x, session_tab_y)
|
|
135
|
+
time.sleep(0.55)
|
|
136
|
+
|
|
137
|
+
if _check_foreground(adb, package):
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
# -- retry monkey --
|
|
141
|
+
LOG.info("cold_start: monkey retry for %s", package)
|
|
142
|
+
try:
|
|
143
|
+
adb._run(
|
|
144
|
+
["shell", "monkey", "-p", package, "-c",
|
|
145
|
+
"android.intent.category.LAUNCHER", "1"],
|
|
146
|
+
)
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
LOG.warning("cold_start: monkey retry failed: %s", exc)
|
|
149
|
+
else:
|
|
150
|
+
time.sleep(1.5)
|
|
151
|
+
if session_tab_x is not None and session_tab_y is not None:
|
|
152
|
+
adb.tap(session_tab_x, session_tab_y)
|
|
153
|
+
time.sleep(0.55)
|
|
154
|
+
|
|
155
|
+
return _check_foreground(adb, package)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_foreground(adb: AdbProtocol, package: str) -> bool:
|
|
159
|
+
try:
|
|
160
|
+
return adb.foreground_package() == package
|
|
161
|
+
except Exception:
|
|
162
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""WeChat 4-layer model for group screenshot collection.
|
|
2
|
+
|
|
3
|
+
Requires ``pip install layernav_android[wechat]`` and an existing vision backend
|
|
4
|
+
(typically ``collector_phone_android.vision.template_matcher``).
|
|
5
|
+
|
|
6
|
+
.. code-block:: python
|
|
7
|
+
|
|
8
|
+
from layernav_android.contrib.wechat import WeChatGroupLayerModel
|
|
9
|
+
|
|
10
|
+
model = WeChatGroupLayerModel()
|
|
11
|
+
model.restore(adb, "L1", scale_w)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from layernav_android._protocol import AdbProtocol
|
|
23
|
+
from layernav_android.base import KEYCODE_BACK, KEYCODE_HOME, BaseLayerModel, LayerDef
|
|
24
|
+
from layernav_android.cold_start import cold_start_app_from_launcher
|
|
25
|
+
|
|
26
|
+
LOG = logging.getLogger("layernav.wechat")
|
|
27
|
+
|
|
28
|
+
WECHAT_PACKAGE = "com.tencent.mm"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _decode_png(data: bytes) -> np.ndarray:
|
|
32
|
+
buf = np.frombuffer(data, dtype=np.uint8)
|
|
33
|
+
return __import__("cv2").imdecode(buf, __import__("cv2").IMREAD_COLOR)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _calc_wechat_session_tab(screen_w: int, screen_h: int, scale_w: float) -> tuple[int, int]:
|
|
37
|
+
tab_x = max(24, min(screen_w - 24, int(round(screen_w * 0.10))))
|
|
38
|
+
tab_y = max(screen_h // 2, screen_h - int(round(56 * max(scale_w, 1e-6))))
|
|
39
|
+
return tab_x, tab_y
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WeChatGroupLayerModel(BaseLayerModel):
|
|
43
|
+
"""4-layer model for WeChat group screenshot collection.
|
|
44
|
+
|
|
45
|
+
Layer stack::
|
|
46
|
+
|
|
47
|
+
L3 微信笔记 detect_wechat_note_header() → score > 0
|
|
48
|
+
L2 群聊天界面 WeChat FG + bottom-4-tab absent + no note-header
|
|
49
|
+
L1 微信主界面 is_wechat_main_conversation_list_chrome()
|
|
50
|
+
L0 手机主屏幕 foreground_package() ≠ com.tencent.mm
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
layers = [
|
|
54
|
+
LayerDef("L0", "home", "手机主屏幕", "foreground ≠ com.tencent.mm"),
|
|
55
|
+
LayerDef("L1", "main_list", "微信主会话列表", "is_main_list_chrome()"),
|
|
56
|
+
LayerDef("L2", "chat", "群聊天界面", "WeChat FG + no tabs4 + no notes"),
|
|
57
|
+
LayerDef("L3", "notes", "微信笔记", "detect_note_header()"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
self._device_id: str = "unknown"
|
|
63
|
+
|
|
64
|
+
def init(self, adb: AdbProtocol) -> None:
|
|
65
|
+
self._device_id: str = getattr(adb, "_serial", "unknown")
|
|
66
|
+
|
|
67
|
+
def _ensure_vision(self):
|
|
68
|
+
try:
|
|
69
|
+
from collector_phone_android.vision.template_matcher import (
|
|
70
|
+
detect_wechat_main_bottom_tab_bar_four_columns,
|
|
71
|
+
detect_wechat_note_header,
|
|
72
|
+
is_wechat_main_conversation_list_chrome,
|
|
73
|
+
)
|
|
74
|
+
return (
|
|
75
|
+
detect_wechat_note_header,
|
|
76
|
+
is_wechat_main_conversation_list_chrome,
|
|
77
|
+
detect_wechat_main_bottom_tab_bar_four_columns,
|
|
78
|
+
)
|
|
79
|
+
except ImportError:
|
|
80
|
+
raise ImportError(
|
|
81
|
+
"WeChatGroupLayerModel.detect() requires "
|
|
82
|
+
"collector_phone_android.vision.template_matcher. "
|
|
83
|
+
"Install with: pip install collector_phone_android"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ── detect ────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def detect(self, adb: AdbProtocol, scale_w: float) -> str:
|
|
89
|
+
fg = adb.foreground_package()
|
|
90
|
+
if fg != WECHAT_PACKAGE:
|
|
91
|
+
return "L0"
|
|
92
|
+
(
|
|
93
|
+
detect_wechat_note_header,
|
|
94
|
+
is_wechat_main_conversation_list_chrome,
|
|
95
|
+
detect_wechat_main_bottom_tab_bar_four_columns,
|
|
96
|
+
) = self._ensure_vision()
|
|
97
|
+
png = adb.screencap()
|
|
98
|
+
arr = _decode_png(png)
|
|
99
|
+
if detect_wechat_note_header(arr, scale_w) is not None:
|
|
100
|
+
return "L3"
|
|
101
|
+
if is_wechat_main_conversation_list_chrome(
|
|
102
|
+
arr, scale_w, require_visible_pinned_row=False,
|
|
103
|
+
):
|
|
104
|
+
return "L1"
|
|
105
|
+
if detect_wechat_main_bottom_tab_bar_four_columns(arr, scale_w):
|
|
106
|
+
return "L1"
|
|
107
|
+
return "L2"
|
|
108
|
+
|
|
109
|
+
def detect_from_png(self, png: bytes, scale_w: float, fg: str) -> str:
|
|
110
|
+
"""Detect from already-captured PNG (no extra screencap)."""
|
|
111
|
+
if fg != WECHAT_PACKAGE:
|
|
112
|
+
return "L0"
|
|
113
|
+
(
|
|
114
|
+
detect_wechat_note_header,
|
|
115
|
+
is_wechat_main_conversation_list_chrome,
|
|
116
|
+
detect_wechat_main_bottom_tab_bar_four_columns,
|
|
117
|
+
) = self._ensure_vision()
|
|
118
|
+
arr = _decode_png(png)
|
|
119
|
+
if detect_wechat_note_header(arr, scale_w) is not None:
|
|
120
|
+
return "L3"
|
|
121
|
+
if is_wechat_main_conversation_list_chrome(
|
|
122
|
+
arr, scale_w, require_visible_pinned_row=False,
|
|
123
|
+
):
|
|
124
|
+
return "L1"
|
|
125
|
+
if detect_wechat_main_bottom_tab_bar_four_columns(arr, scale_w):
|
|
126
|
+
return "L1"
|
|
127
|
+
return "L2"
|
|
128
|
+
|
|
129
|
+
# ── Layer handlers ────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def _on_L0(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
132
|
+
self._cold_start(adb, "L1", scale_w)
|
|
133
|
+
return "L1"
|
|
134
|
+
|
|
135
|
+
def _on_L1(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
136
|
+
if quick:
|
|
137
|
+
row = self._pick_first_unread(adb, scale_w)
|
|
138
|
+
else:
|
|
139
|
+
row = self._scan_and_select(adb, scale_w)
|
|
140
|
+
if row is None:
|
|
141
|
+
return None
|
|
142
|
+
self._tap_row(row, adb)
|
|
143
|
+
return "L2"
|
|
144
|
+
|
|
145
|
+
def _on_L2(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
146
|
+
if quick:
|
|
147
|
+
card = self._pick_first_card(adb, scale_w)
|
|
148
|
+
else:
|
|
149
|
+
card = self._scan_and_select_card(adb, scale_w)
|
|
150
|
+
if card is None:
|
|
151
|
+
return None
|
|
152
|
+
adb.tap(card.click_x, card.click_y)
|
|
153
|
+
return "L3"
|
|
154
|
+
|
|
155
|
+
def _on_L3(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# ── Internal helpers ──────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
def _tap_row(self, row: Any, adb: AdbProtocol) -> None:
|
|
161
|
+
x1, y1, x2, y2 = row.bbox
|
|
162
|
+
if getattr(row, "unread_dots", None):
|
|
163
|
+
badge = row.unread_dots[0]
|
|
164
|
+
row_h = max(1, y2 - y1)
|
|
165
|
+
badge_cx = badge.x + badge.w // 2
|
|
166
|
+
badge_cy = badge.y + badge.h // 2
|
|
167
|
+
cy = badge_cy + int(row_h * 0.35)
|
|
168
|
+
cy = max(y1 + 10, min(y2 - 10, cy))
|
|
169
|
+
cx = badge_cx
|
|
170
|
+
else:
|
|
171
|
+
cx = (x1 + x2) // 2
|
|
172
|
+
cy = (y1 + y2) // 2
|
|
173
|
+
adb.tap(cx, cy)
|
|
174
|
+
|
|
175
|
+
def _pick_first_unread(self, adb: AdbProtocol, scale_w: float) -> Any:
|
|
176
|
+
return None # TODO: wire real scan from driver
|
|
177
|
+
|
|
178
|
+
def _scan_and_select(self, adb: AdbProtocol, scale_w: float) -> Any:
|
|
179
|
+
return None # TODO: wire real scan — caller holds the logic
|
|
180
|
+
|
|
181
|
+
def _pick_first_card(self, adb: AdbProtocol, scale_w: float) -> Any:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def _scan_and_select_card(self, adb: AdbProtocol, scale_w: float) -> Any:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
# ── Cold-start ────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
def _cold_start(
|
|
190
|
+
self,
|
|
191
|
+
adb: AdbProtocol,
|
|
192
|
+
target_layer: str,
|
|
193
|
+
scale_w: float,
|
|
194
|
+
deadline_s: float = 20.0,
|
|
195
|
+
) -> None:
|
|
196
|
+
LOG.info("_cold_start: HOME → WeChat → poll %s", target_layer)
|
|
197
|
+
|
|
198
|
+
png = adb.screencap()
|
|
199
|
+
arr = _decode_png(png)
|
|
200
|
+
h, w = arr.shape[:2]
|
|
201
|
+
|
|
202
|
+
tab_x, tab_y = _calc_wechat_session_tab(w, h, scale_w)
|
|
203
|
+
|
|
204
|
+
adb.key_event(KEYCODE_HOME)
|
|
205
|
+
time.sleep(0.8)
|
|
206
|
+
|
|
207
|
+
cold_start_app_from_launcher(
|
|
208
|
+
adb, WECHAT_PACKAGE, w, h, scale_w,
|
|
209
|
+
app_name="wechat", M=4, N=3,
|
|
210
|
+
session_tab_x=tab_x, session_tab_y=tab_y,
|
|
211
|
+
force_stop_before=True,
|
|
212
|
+
deadline_s=deadline_s,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
deadline = time.monotonic() + deadline_s
|
|
216
|
+
while time.monotonic() < deadline:
|
|
217
|
+
if self.detect(adb, scale_w) == target_layer:
|
|
218
|
+
LOG.info("_cold_start: reached %s", target_layer)
|
|
219
|
+
return
|
|
220
|
+
time.sleep(1.0)
|
|
221
|
+
raise TimeoutError(
|
|
222
|
+
f"cold-start WeChat: did not reach {target_layer} within {deadline_s}s"
|
|
223
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Xiaohongshu placeholder layer model.
|
|
2
|
+
|
|
3
|
+
.. code-block:: python
|
|
4
|
+
|
|
5
|
+
from layernav_android.contrib.xhs import XhsLayerModel
|
|
6
|
+
|
|
7
|
+
model = XhsLayerModel()
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from layernav_android._protocol import AdbProtocol
|
|
16
|
+
from layernav_android.base import KEYCODE_BACK, KEYCODE_HOME, BaseLayerModel, LayerDef
|
|
17
|
+
from layernav_android.cold_start import cold_start_app_from_launcher
|
|
18
|
+
|
|
19
|
+
LOG = logging.getLogger("layernav.xhs")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class XhsLayerModel(BaseLayerModel):
|
|
23
|
+
"""Placeholder layer model for Xiaohongshu feed collection."""
|
|
24
|
+
|
|
25
|
+
layers = [
|
|
26
|
+
LayerDef("L0", "home", "手机主屏幕", "foreground ≠ com.xingin.xhs"),
|
|
27
|
+
LayerDef("L1", "feed", "小红书首页推荐流", "TODO"),
|
|
28
|
+
LayerDef("L2", "note_detail", "笔记详情页", "TODO"),
|
|
29
|
+
LayerDef("L3", "comments", "评论浮层", "TODO"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def detect(self, adb: AdbProtocol, scale_w: float) -> str:
|
|
33
|
+
return "L0"
|
|
34
|
+
|
|
35
|
+
def _on_L0(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
36
|
+
self._cold_start(adb, "L1", scale_w)
|
|
37
|
+
return "L1"
|
|
38
|
+
|
|
39
|
+
def _on_L1(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def _on_L2(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def _on_L3(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def _cold_start(self, adb: AdbProtocol, target_layer: str, scale_w: float) -> None:
|
|
49
|
+
adb.key_event(KEYCODE_HOME)
|
|
50
|
+
time.sleep(0.8)
|
|
51
|
+
|
|
52
|
+
png = adb.screencap()
|
|
53
|
+
import numpy as np
|
|
54
|
+
arr = np.frombuffer(png, dtype=np.uint8)
|
|
55
|
+
arr = __import__("cv2").imdecode(arr, __import__("cv2").IMREAD_COLOR)
|
|
56
|
+
h, w = arr.shape[:2]
|
|
57
|
+
|
|
58
|
+
cold_start_app_from_launcher(
|
|
59
|
+
adb, "com.xingin.xhs", w, h, scale_w,
|
|
60
|
+
app_name="xhs", M=4, N=1,
|
|
61
|
+
force_stop_before=True,
|
|
62
|
+
deadline_s=20.0,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
deadline = time.monotonic() + 20.0
|
|
66
|
+
while time.monotonic() < deadline:
|
|
67
|
+
if self.detect(adb, scale_w) == target_layer:
|
|
68
|
+
return
|
|
69
|
+
time.sleep(1.0)
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: layernav_android
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Multi-layer task-stack navigation framework for Android ADB automation. Define N layers, register per-layer handlers, and let the framework handle BACK recovery, cold-start, and cross-layer verification.
|
|
5
|
+
Project-URL: Homepage, https://github.com/yuyidream/layernav_android
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: adb,android,app-crawler,automation,crawler,data-collection,fault-tolerance,layer-model,mobile-rpa,navigation,python,task-automation
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
19
|
+
Provides-Extra: wechat
|
|
20
|
+
Requires-Dist: numpy>=1.24; extra == 'wechat'
|
|
21
|
+
Requires-Dist: opencv-python>=4.8; extra == 'wechat'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# LayerNav_Android
|
|
25
|
+
|
|
26
|
+
> A stable page layer navigation framework for Android ADB automation.
|
|
27
|
+
> 基于 ADB 的安卓页面层级导航框架,主打**强校验、自动容错、故障恢复**,适用于 APP 数据采集、移动端 RPA、UI 自动化测试场景。
|
|
28
|
+
>
|
|
29
|
+
> **Python 3.12+** · 零外部依赖 · MIT License
|
|
30
|
+
|
|
31
|
+
[](https://badge.fury.io/py/layernav_android)
|
|
32
|
+
[](https://www.python.org/downloads/)
|
|
33
|
+
[](https://github.com/yuyidream/layernav_android/actions/workflows/ci.yml)
|
|
34
|
+
[](https://codecov.io/gh/yuyidream/layernav_android)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 一、项目介绍
|
|
40
|
+
|
|
41
|
+
市面上主流 ADB / UI 自动化库仅提供点击、滑动、返回等基础原子能力,**缺少页面状态校验、层级管理、异常恢复**,脚本极易因页面跳转失败、APP 卡死、返回失灵而中断。
|
|
42
|
+
|
|
43
|
+
本框架基于 **L0~Ln 页面层级模型** 设计,将手机桌面、APP 主页、内容页、详情页抽象为标准化层级,内置「动作执行 → 截屏校验 → 自动重试 → 冷启动恢复」全链路能力,大幅提升自动化脚本稳定性与开发效率。
|
|
44
|
+
|
|
45
|
+
### 核心定位
|
|
46
|
+
|
|
47
|
+
- 不是通用 UI 自动化库,而是 **导航调度 + 容错引擎**
|
|
48
|
+
- 框架负责:层级检测、跳转校验、后退/前进/恢复逻辑、异常兜底
|
|
49
|
+
- 业务脚本负责:控件点击、数据采集、业务逻辑(职责彻底分离)
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 二、核心特性
|
|
54
|
+
|
|
55
|
+
✅ **标准化层级模型**
|
|
56
|
+
统一抽象 `L0(手机桌面) / L1(APP主页) / L2(内容页) / L3(详情页) ... Ln`,一套模型适配绝大多数 APP。
|
|
57
|
+
|
|
58
|
+
✅ **闭环跳转校验**
|
|
59
|
+
执行操作后自动截屏检测页面,**拒绝盲操作**,跳转失败即时感知。guard(前置校验)+ validator(后置轮询)语义分离。
|
|
60
|
+
|
|
61
|
+
✅ **完整导航原子 API**
|
|
62
|
+
内置 `detect / enter_next / back_one / back_recover` 四原子操作 + `advance / back / restore` 三组合操作,一行代码完成跨层级跳转。
|
|
63
|
+
|
|
64
|
+
✅ **故障自动恢复**
|
|
65
|
+
返回键失效、页面卡死、意外退回桌面时,自动执行 `back_recover`:HOME → 冷启动 APP → 快速前进至目标层级 → 正常恢复业务。
|
|
66
|
+
|
|
67
|
+
✅ **Quick 快速模式**
|
|
68
|
+
专为恢复场景设计,handlers 收到 `quick=True` 时可精简业务逻辑(如选第一个未读),提升导航速度。
|
|
69
|
+
|
|
70
|
+
✅ **可观测监听器**
|
|
71
|
+
内置 `LayerListener` 观察者接口,零侵入监控层切换、超时、恢复事件,方便接入指标采集与告警。
|
|
72
|
+
|
|
73
|
+
✅ **解耦设计**
|
|
74
|
+
- 页面检测 `detect` 接口可自由接入:OCR / 图像匹配 / UI 控件解析
|
|
75
|
+
- ADB 客户端通过 `AdbProtocol` 完全抽象,原生 ADB / 风控加固 ADB 均可无缝接入
|
|
76
|
+
- 分层 Handler 机制(`_on_Lx`),业务代码与框架逻辑完全隔离
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 三、架构设计
|
|
81
|
+
|
|
82
|
+
### 1. 层级模型
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
L0 手机主屏幕(非 APP 前台)
|
|
86
|
+
L1 APP 主界面
|
|
87
|
+
L2 二级内容页
|
|
88
|
+
L3 三级详情页
|
|
89
|
+
...
|
|
90
|
+
Ln 最深业务层级
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. 职责划分
|
|
94
|
+
|
|
95
|
+
| 模块 | 框架能力 | Task 能力 |
|
|
96
|
+
|------|---------|----------|
|
|
97
|
+
| 状态检测 | 调用 `detect()`、校验结果 | 实现截图/识别逻辑 |
|
|
98
|
+
| 页面动作 | 流程调度、等待、重试 | 实现 `_on_Lx` 点击/滑动等业务动作 |
|
|
99
|
+
| 导航逻辑 | `advance` / `back` / `restore` / 恢复 | 无 |
|
|
100
|
+
| 点击 | **不负责** — 框架不 `tap` | handler 内 `adb.tap()` |
|
|
101
|
+
|
|
102
|
+
### 3. 核心流程
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
1. detect() 实时识别当前页面层级
|
|
106
|
+
2. 调用对应层级 _on_Lx handler 执行业务操作
|
|
107
|
+
3. 二次校验页面是否到达目标层级(截屏 + 轮询)
|
|
108
|
+
4. 跳转失败 → 自动重试 → 重试失败 → 冷启动恢复
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 四、快速上手
|
|
114
|
+
|
|
115
|
+
### 1. 安装
|
|
116
|
+
|
|
117
|
+
**方式一:pip 安装(推荐)**
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
pip install layernav_android
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
如需使用 WeChat contrib 模块,需额外安装 `opencv-python` 和 `numpy`:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pip install layernav_android[wechat]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**方式二:从源码安装**
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
git clone https://github.com/yuyidream/layernav_android.git
|
|
133
|
+
cd layernav_android
|
|
134
|
+
pip install -e .
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
如需运行测试:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
pip install -e ".[dev]"
|
|
141
|
+
pytest tests/ -v --tb=short
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 2. 基础使用
|
|
145
|
+
|
|
146
|
+
继承 `BaseLayerModel`,实现层级检测与页面处理器,即可使用全套导航能力:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from layernav_android import BaseLayerModel, LayerDef
|
|
150
|
+
|
|
151
|
+
class DemoAppModel(BaseLayerModel):
|
|
152
|
+
layers = [
|
|
153
|
+
LayerDef(key="L0", name="desktop", label_cn="手机桌面", detection="截屏识别桌面图标"),
|
|
154
|
+
LayerDef(key="L1", name="app_home", label_cn="APP 主页", detection="OCR 识别主页文字"),
|
|
155
|
+
LayerDef(key="L2", name="content", label_cn="内容列表页", detection="图像特征匹配"),
|
|
156
|
+
LayerDef(key="L3", name="detail", label_cn="详情页", detection="模板匹配"),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
def detect(self, adb, scale_w: float) -> str:
|
|
160
|
+
screenshot = adb.screencap()
|
|
161
|
+
if is_desktop(screenshot):
|
|
162
|
+
return "L0"
|
|
163
|
+
elif is_app_home(screenshot):
|
|
164
|
+
return "L1"
|
|
165
|
+
elif is_content_list(screenshot):
|
|
166
|
+
return "L2"
|
|
167
|
+
return "L3"
|
|
168
|
+
|
|
169
|
+
def _on_L0(self, adb, scale_w, *, quick=False):
|
|
170
|
+
self._cold_start(adb, "L1", scale_w)
|
|
171
|
+
return "L1"
|
|
172
|
+
|
|
173
|
+
def _on_L1(self, adb, scale_w, *, quick=False) -> str | None:
|
|
174
|
+
if quick:
|
|
175
|
+
row = self._pick_first_row(adb, scale_w)
|
|
176
|
+
else:
|
|
177
|
+
row = self._scan_and_select(adb, scale_w)
|
|
178
|
+
if row is None:
|
|
179
|
+
return None
|
|
180
|
+
adb.tap(row.x, row.y)
|
|
181
|
+
return "L2"
|
|
182
|
+
|
|
183
|
+
def _on_L2(self, adb, scale_w, *, quick=False) -> str | None:
|
|
184
|
+
item = self._pick_item(adb, scale_w, quick=quick)
|
|
185
|
+
if item is None:
|
|
186
|
+
return None
|
|
187
|
+
adb.tap(item.x, item.y)
|
|
188
|
+
return "L3"
|
|
189
|
+
|
|
190
|
+
def _on_L3(self, adb, scale_w, *, quick=False) -> str | None:
|
|
191
|
+
return None # 最深层,不再前进
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# 执行导航流程
|
|
195
|
+
model = DemoAppModel()
|
|
196
|
+
adb = get_adb_client()
|
|
197
|
+
|
|
198
|
+
# 智能恢复到 L1
|
|
199
|
+
model.restore(adb, target_layer="L1", scale_w=1.0)
|
|
200
|
+
# 逐层前进到 L3
|
|
201
|
+
model.advance(adb, target_layer="L3", scale_w=1.0)
|
|
202
|
+
# 后退回 L1
|
|
203
|
+
model.back(adb, to_layer="L1", scale_w=1.0)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### 3. 核心 API
|
|
207
|
+
|
|
208
|
+
**原子操作**
|
|
209
|
+
|
|
210
|
+
| 方法 | 说明 |
|
|
211
|
+
|------|------|
|
|
212
|
+
| `detect(adb, scale_w) → str` | 检测当前所在层级(Task 覆盖实现) |
|
|
213
|
+
| `enter_next(adb, scale_w, *, quick, max_wait_s) → bool` | 单步进入下一层 ← guard + validator + 轮询 |
|
|
214
|
+
| `back_one(adb, scale_w) → str` | 单步 `KEYCODE_BACK`,返回新层级 |
|
|
215
|
+
| `back_recover(adb, target, scale_w) → bool` | 故障恢复:HOME → 冷启动 → 快速前进 → 正常恢复 |
|
|
216
|
+
|
|
217
|
+
**组合操作**
|
|
218
|
+
|
|
219
|
+
| 方法 | 说明 |
|
|
220
|
+
|------|------|
|
|
221
|
+
| `back(adb, to_layer, scale_w) → bool` | 逐层后退至目标(3 次重试 → 恢复) |
|
|
222
|
+
| `advance(adb, target, scale_w, *, quick, max_wait_s) → bool` | 逐层前进至目标(目标层 always `quick=False`) |
|
|
223
|
+
| `restore(adb, target, scale_w) → bool` | 智能判断方向,从任意位置恢复至目标 |
|
|
224
|
+
|
|
225
|
+
**可观测**
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
from layernav_android import LayerListener
|
|
229
|
+
|
|
230
|
+
class MetricsListener:
|
|
231
|
+
def on_transition(self, from_layer, to_layer, method):
|
|
232
|
+
print(f"{from_layer} → {to_layer} via {method}")
|
|
233
|
+
|
|
234
|
+
def on_timeout(self, from_layer, target_layer, elapsed_s):
|
|
235
|
+
print(f"Timeout {from_layer}→{target_layer} after {elapsed_s:.1f}s")
|
|
236
|
+
|
|
237
|
+
def on_recovery(self, target_layer, ok):
|
|
238
|
+
print(f"Recovery to {target_layer}: {'OK' if ok else 'FAILED'}")
|
|
239
|
+
|
|
240
|
+
model.add_listener(MetricsListener())
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## 五、通用冷启动工具
|
|
246
|
+
|
|
247
|
+
`cold_start_app_from_launcher` 提供统一的 APP 冷启动能力,支持 monkey 主路径 + Dock 图标兜底 + session tab 点击:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from layernav_android.cold_start import cold_start_app_from_launcher
|
|
251
|
+
|
|
252
|
+
# 微信冷启动
|
|
253
|
+
ok = cold_start_app_from_launcher(
|
|
254
|
+
adb, "com.tencent.mm", 1080, 2248, 1.0,
|
|
255
|
+
app_name="wechat", M=4, N=3,
|
|
256
|
+
session_tab_x=108, session_tab_y=2192,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# 小红书冷启动
|
|
260
|
+
ok = cold_start_app_from_launcher(
|
|
261
|
+
adb, "com.xingin.xhs", 1080, 2248, 1.0,
|
|
262
|
+
app_name="xhs", M=4, N=1,
|
|
263
|
+
)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**关键设计**:使用普通 ADB tap(非防风控触控),因为是系统级操作(桌面 Dock 图标点击),不涉及 APP 内反爬检测,方便所有系统集成。
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## 六、适用场景
|
|
271
|
+
|
|
272
|
+
- **APP 合规数据采集** — 内容抓取、列表遍历、批量浏览
|
|
273
|
+
- **移动端 RPA 自动化** — 账号矩阵、批量操作、运营工具
|
|
274
|
+
- **Android UI 自动化测试** — 提升脚本稳定性,减少维护成本
|
|
275
|
+
- **APP 流程逆向 / 行为模拟** — 稳定进入深层页面
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## 七、优势对比
|
|
280
|
+
|
|
281
|
+
| 能力 | 本框架 | Appium / uiautomator2 / Airtest |
|
|
282
|
+
|------|--------|---------------------------------|
|
|
283
|
+
| 标准化页面层级 | ✅ 内置模型 | ❌ 无统一抽象 |
|
|
284
|
+
| 操作后页面校验 | ✅ 闭环 guard + validator | ❌ 仅执行动作,不校验结果 |
|
|
285
|
+
| 自动后退恢复 | ✅ 3 次重试 + 冷启动兜底 | ❌ 需手动编写重试逻辑 |
|
|
286
|
+
| 层级穿越 API | ✅ `advance` / `back` / `restore` | ❌ 仅基础点击/返回 |
|
|
287
|
+
| 可观测监听器 | ✅ `LayerListener` 事件回调 | ❌ 需自行埋点 |
|
|
288
|
+
| ADB 解耦 | ✅ `AdbProtocol` 接口抽象 | ⚠️ 部分耦合 |
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## 八、拓展建议
|
|
293
|
+
|
|
294
|
+
- **页面检测能力**:可接入 PaddleOCR / EasyOCR / OpenCV 图像匹配
|
|
295
|
+
- **ADB 加固**:对接自定义风控 ADB 客户端,模拟真人操作轨迹
|
|
296
|
+
- **状态机拓展**:可结合 `python-statemachine` 优化状态管理(本框架的 `LayerListener` 即借鉴其设计)
|
|
297
|
+
- **多设备并行**:每设备独立 `BaseLayerModel` 实例即可天然支持多设备
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## 九、目录结构
|
|
302
|
+
|
|
303
|
+
```
|
|
304
|
+
layernav_android/
|
|
305
|
+
├── src/layernav_android/
|
|
306
|
+
│ ├── __init__.py # 公开导出
|
|
307
|
+
│ ├── _protocol.py # AdbProtocol 接口
|
|
308
|
+
│ ├── base.py # LayerDef, LayerListener, BaseLayerModel
|
|
309
|
+
│ ├── cold_start.py # 通用冷启动工具
|
|
310
|
+
│ └── contrib/
|
|
311
|
+
│ ├── __init__.py
|
|
312
|
+
│ ├── wechat.py # WeChatGroupLayerModel(微信示例)
|
|
313
|
+
│ └── xhs.py # XhsLayerModel(小红书占位)
|
|
314
|
+
├── tests/
|
|
315
|
+
│ └── test_base.py # 23 个单元测试
|
|
316
|
+
├── pyproject.toml
|
|
317
|
+
├── README.md
|
|
318
|
+
└── LICENSE
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## 十、参与贡献
|
|
324
|
+
|
|
325
|
+
欢迎提交 Issue、PR,共建安卓自动化导航生态:
|
|
326
|
+
|
|
327
|
+
- **Bug 反馈、功能建议** → [Issues](https://github.com/yuyidream/layernav_android/issues)
|
|
328
|
+
- **代码优化、新增示例** → Pull Request
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## 十一、开源协议
|
|
333
|
+
|
|
334
|
+
本项目基于 [MIT License](LICENSE) 开源,可自由用于个人、商业项目。
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
layernav_android/__init__.py,sha256=WVWd4xwoaewDOerZyrzQa_SN6aR2R-pa7EamsQmyDPQ,573
|
|
2
|
+
layernav_android/_protocol.py,sha256=NLQuU3RLD-n1l5LAEc7ZAM-CNegTO5bnxHO80gDw0yk,546
|
|
3
|
+
layernav_android/base.py,sha256=T9UHXyBriEAJQvqjryGJoeja8V4f2mfxMIX-wATlIFE,11283
|
|
4
|
+
layernav_android/cold_start.py,sha256=9Tx21k7axRRG6dsydbeYrhPeWZ9jb8CUChFgvv8tmhE,5170
|
|
5
|
+
layernav_android/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
layernav_android/contrib/wechat.py,sha256=YIUG91Zyx3fCdmKDOvkKLgRdBRcX_gWoxAddTcq5yHw,8410
|
|
7
|
+
layernav_android/contrib/xhs.py,sha256=9Oi-TSvh6yfMQNPa4u3HKz4brstMx7VhWPcilFOatH4,2226
|
|
8
|
+
layernav_android-0.2.0.dist-info/METADATA,sha256=-nVHKju44Tk0_YqSPBuOQR6GEoLAv40r-t3VjlgvlwA,12094
|
|
9
|
+
layernav_android-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
10
|
+
layernav_android-0.2.0.dist-info/licenses/LICENSE,sha256=jqDbAPajfDAMxAm4FAolM8I7CwnMdVNT6TfQNx2Th8U,1066
|
|
11
|
+
layernav_android-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 yuyidream
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|