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.
@@ -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: ...
@@ -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
+ [![PyPI](https://badge.fury.io/py/layernav_android.svg)](https://badge.fury.io/py/layernav_android)
32
+ [![Python](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
33
+ [![CI](https://github.com/yuyidream/layernav_android/actions/workflows/ci.yml/badge.svg)](https://github.com/yuyidream/layernav_android/actions/workflows/ci.yml)
34
+ [![codecov](https://codecov.io/gh/yuyidream/layernav_android/branch/main/graph/badge.svg)](https://codecov.io/gh/yuyidream/layernav_android)
35
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.