layernav-android 0.3.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 +29 -0
- layernav_android/_protocol.py +18 -0
- layernav_android/base.py +402 -0
- layernav_android/cold_start.py +224 -0
- layernav_android/contrib/__init__.py +0 -0
- layernav_android/contrib/wechat.py +293 -0
- layernav_android/contrib/xhs.py +69 -0
- layernav_android-0.3.0.dist-info/METADATA +340 -0
- layernav_android-0.3.0.dist-info/RECORD +11 -0
- layernav_android-0.3.0.dist-info/WHEEL +4 -0
- layernav_android-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from layernav_android._protocol import AdbProtocol
|
|
2
|
+
from layernav_android.base import (
|
|
3
|
+
BaseLayerModel,
|
|
4
|
+
DetectResult,
|
|
5
|
+
KEYCODE_BACK,
|
|
6
|
+
KEYCODE_HOME,
|
|
7
|
+
LayerDef,
|
|
8
|
+
LayerListener,
|
|
9
|
+
POST_TRANSITION_SLEEP,
|
|
10
|
+
)
|
|
11
|
+
from layernav_android.cold_start import (
|
|
12
|
+
APP_DEFAULTS,
|
|
13
|
+
cold_start_app_from_launcher,
|
|
14
|
+
dock_app_icon_coords,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AdbProtocol",
|
|
19
|
+
"APP_DEFAULTS",
|
|
20
|
+
"BaseLayerModel",
|
|
21
|
+
"cold_start_app_from_launcher",
|
|
22
|
+
"DetectResult",
|
|
23
|
+
"dock_app_icon_coords",
|
|
24
|
+
"KEYCODE_BACK",
|
|
25
|
+
"KEYCODE_HOME",
|
|
26
|
+
"LayerDef",
|
|
27
|
+
"LayerListener",
|
|
28
|
+
"POST_TRANSITION_SLEEP",
|
|
29
|
+
]
|
|
@@ -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,402 @@
|
|
|
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
|
+
page_name: str = ""
|
|
50
|
+
"""Optional custom page name. Callers can set this per-layer to
|
|
51
|
+
distinguish sub-states within a layer (e.g. main_list vs recent_page for L1).
|
|
52
|
+
Default empty string means no sub-page distinction."""
|
|
53
|
+
|
|
54
|
+
detection_extra: str = ""
|
|
55
|
+
"""Optional detail about detection (human-readable, complementary to
|
|
56
|
+
*detection*). Callers can append custom context strings."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Observer / Listener (inspired by python-statemachine's Listener pattern) ───
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LayerListener(Protocol):
|
|
63
|
+
"""Observer interface for layer model lifecycle events.
|
|
64
|
+
|
|
65
|
+
All methods are optional — implement only what you need.
|
|
66
|
+
Inspired by `python-statemachine
|
|
67
|
+
<https://github.com/fgmacedo/python-statemachine>`_'s Listener pattern.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def on_transition(
|
|
71
|
+
self, from_layer: str, to_layer: str, method: str,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Called after an atomic layer transition completes.
|
|
74
|
+
|
|
75
|
+
*method* is one of ``"enter_next"`` or ``"back_one"``.
|
|
76
|
+
"""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
def on_timeout(
|
|
80
|
+
self, from_layer: str, target_layer: str, elapsed_s: float,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Called when :meth:`BaseLayerModel.enter_next` polling times out."""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
def on_recovery(self, target_layer: str, ok: bool) -> None:
|
|
86
|
+
"""Called after :meth:`BaseLayerModel.back_recover` completes.
|
|
87
|
+
|
|
88
|
+
*ok* indicates whether the recovery succeeded.
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Layer detection result ──────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class DetectResult:
|
|
98
|
+
"""Result of :meth:`BaseLayerModel.detect_detail`.
|
|
99
|
+
|
|
100
|
+
Combines layer key with optional page_name from :class:`LayerDef`.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
layer_key: str
|
|
104
|
+
"""Layer key: ``"L0"`` | ``"L1"`` | ``"L2"`` | ``"L3"``."""
|
|
105
|
+
|
|
106
|
+
page_name: str = ""
|
|
107
|
+
"""Custom page name from :attr:`LayerDef.page_name`, or ``""``."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Abstract base ─────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class BaseLayerModel:
|
|
114
|
+
"""Abstract Android task-stack layer model.
|
|
115
|
+
|
|
116
|
+
Subclass contract:
|
|
117
|
+
- Override :attr:`layers`.
|
|
118
|
+
- Override :meth:`detect`.
|
|
119
|
+
- Override ``_on_L0`` / ``_on_L1`` / ``_on_L2`` / ``_on_L3``.
|
|
120
|
+
- Optionally override :meth:`_cold_start`.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
layers: list[LayerDef] = []
|
|
124
|
+
_ON_METHODS: tuple[str, ...] = ("_on_L0", "_on_L1", "_on_L2", "_on_L3")
|
|
125
|
+
|
|
126
|
+
def __init__(self) -> None:
|
|
127
|
+
self._listeners: list[LayerListener] = []
|
|
128
|
+
|
|
129
|
+
def add_listener(self, listener: LayerListener) -> None:
|
|
130
|
+
"""Register a :class:`LayerListener` to observe lifecycle events."""
|
|
131
|
+
self._listeners.append(listener)
|
|
132
|
+
|
|
133
|
+
def _notify_transition(
|
|
134
|
+
self, from_layer: str, to_layer: str, method: str,
|
|
135
|
+
) -> None:
|
|
136
|
+
for lst in self._listeners:
|
|
137
|
+
lst.on_transition(from_layer, to_layer, method)
|
|
138
|
+
|
|
139
|
+
def _notify_timeout(
|
|
140
|
+
self, from_layer: str, target_layer: str, elapsed_s: float,
|
|
141
|
+
) -> None:
|
|
142
|
+
for lst in self._listeners:
|
|
143
|
+
lst.on_timeout(from_layer, target_layer, elapsed_s)
|
|
144
|
+
|
|
145
|
+
def _notify_recovery(self, target_layer: str, ok: bool) -> None:
|
|
146
|
+
for lst in self._listeners:
|
|
147
|
+
lst.on_recovery(target_layer, ok)
|
|
148
|
+
|
|
149
|
+
# ── Subclass overrides ────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def detect(self, adb: AdbProtocol, scale_w: float) -> str:
|
|
152
|
+
"""Return current layer key. Task MUST override."""
|
|
153
|
+
raise NotImplementedError("subclass must override detect()")
|
|
154
|
+
|
|
155
|
+
def detect_detail(self, adb: AdbProtocol, scale_w: float) -> DetectResult:
|
|
156
|
+
"""Return current layer key + custom page_name.
|
|
157
|
+
|
|
158
|
+
Default implementation calls :meth:`detect` and looks up
|
|
159
|
+
:attr:`LayerDef.page_name` from :attr:`layers`. Subclasses
|
|
160
|
+
may override to set page_name dynamically.
|
|
161
|
+
"""
|
|
162
|
+
layer_key = self.detect(adb, scale_w)
|
|
163
|
+
page_name = ""
|
|
164
|
+
for ld in self.layers:
|
|
165
|
+
if ld.key == layer_key:
|
|
166
|
+
page_name = ld.page_name
|
|
167
|
+
break
|
|
168
|
+
return DetectResult(layer_key=layer_key, page_name=page_name)
|
|
169
|
+
|
|
170
|
+
def _recover_to_page(
|
|
171
|
+
self,
|
|
172
|
+
layer: str,
|
|
173
|
+
page_name: str,
|
|
174
|
+
adb: AdbProtocol,
|
|
175
|
+
scale_w: float,
|
|
176
|
+
) -> bool:
|
|
177
|
+
"""Navigate to a specific sub-page within *layer* after recovery.
|
|
178
|
+
|
|
179
|
+
Called by :meth:`back_recover` (and :meth:`restore` when already
|
|
180
|
+
on the target layer) after reaching the correct layer. Override
|
|
181
|
+
to handle sub-page navigation (e.g. switching tabs within L1).
|
|
182
|
+
|
|
183
|
+
Default: verify current page via :meth:`detect_detail` — returns
|
|
184
|
+
``True`` if ``detect_detail().page_name == page_name``.
|
|
185
|
+
"""
|
|
186
|
+
result = self.detect_detail(adb, scale_w)
|
|
187
|
+
return result.page_name == page_name
|
|
188
|
+
|
|
189
|
+
def _on_L0(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
190
|
+
"""L0 handler: home screen → cold-start App."""
|
|
191
|
+
raise NotImplementedError("subclass must override _on_L0")
|
|
192
|
+
|
|
193
|
+
def _on_L1(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
194
|
+
"""L1 handler: App main screen → pick content, tap."""
|
|
195
|
+
raise NotImplementedError("subclass must override _on_L1")
|
|
196
|
+
|
|
197
|
+
def _on_L2(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
198
|
+
"""L2 handler: content page → pick sub-content, tap."""
|
|
199
|
+
raise NotImplementedError("subclass must override _on_L2")
|
|
200
|
+
|
|
201
|
+
def _on_L3(self, adb: AdbProtocol, scale_w: float, *, quick: bool = False) -> str | None:
|
|
202
|
+
"""L3 handler: deepest layer — typically no further advance."""
|
|
203
|
+
raise NotImplementedError("subclass must override _on_L3")
|
|
204
|
+
|
|
205
|
+
def _call_on_layer(
|
|
206
|
+
self, layer_key: str, adb: AdbProtocol, scale_w: float, *, quick: bool
|
|
207
|
+
) -> str | None:
|
|
208
|
+
i = self._layer_index(layer_key)
|
|
209
|
+
if i < 0:
|
|
210
|
+
LOG.error("_call_on_layer: unknown layer %s", layer_key)
|
|
211
|
+
return None
|
|
212
|
+
method = getattr(self, self._ON_METHODS[i])
|
|
213
|
+
return method(adb, scale_w, quick=quick)
|
|
214
|
+
|
|
215
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
def _layer_index(self, layer_key: str) -> int:
|
|
218
|
+
for i, ld in enumerate(self.layers):
|
|
219
|
+
if ld.key == layer_key:
|
|
220
|
+
return i
|
|
221
|
+
return -1
|
|
222
|
+
|
|
223
|
+
def init(self, adb: AdbProtocol) -> None:
|
|
224
|
+
"""One-time initialisation (optional)."""
|
|
225
|
+
pass
|
|
226
|
+
|
|
227
|
+
def _cold_start(
|
|
228
|
+
self, adb: AdbProtocol, target_layer: str, scale_w: float
|
|
229
|
+
) -> None:
|
|
230
|
+
"""Cold-start the target app (override in subclass)."""
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
# ── Atomic API ────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def enter_next(
|
|
236
|
+
self,
|
|
237
|
+
adb: AdbProtocol,
|
|
238
|
+
scale_w: float,
|
|
239
|
+
*,
|
|
240
|
+
quick: bool = False,
|
|
241
|
+
max_wait_s: float = 8.0,
|
|
242
|
+
) -> bool:
|
|
243
|
+
"""Advance ONE layer from current position.
|
|
244
|
+
|
|
245
|
+
1. detect current layer ← **guard** (pre-check)
|
|
246
|
+
2. call _on_L[cur](quick) — handler does business + tap
|
|
247
|
+
3. if handler returns None or same layer → stop (success)
|
|
248
|
+
4. wait POST_TRANSITION_SLEEP, then detect ← **validator** (post-check)
|
|
249
|
+
5. if not yet on target, poll with increasing intervals up to max_wait_s
|
|
250
|
+
|
|
251
|
+
This handles variable transition times (network loading, animations).
|
|
252
|
+
"""
|
|
253
|
+
cur = self.detect(adb, scale_w)
|
|
254
|
+
target = self._call_on_layer(cur, adb, scale_w, quick=quick)
|
|
255
|
+
if target is None or target == cur:
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
time.sleep(POST_TRANSITION_SLEEP)
|
|
259
|
+
next_cur = self.detect(adb, scale_w)
|
|
260
|
+
if next_cur == target:
|
|
261
|
+
self._notify_transition(cur, next_cur, "enter_next")
|
|
262
|
+
return True
|
|
263
|
+
|
|
264
|
+
poll_start = time.monotonic()
|
|
265
|
+
deadline = poll_start + max_wait_s
|
|
266
|
+
interval = 0.5
|
|
267
|
+
while time.monotonic() < deadline:
|
|
268
|
+
time.sleep(interval)
|
|
269
|
+
next_cur = self.detect(adb, scale_w)
|
|
270
|
+
if next_cur == target:
|
|
271
|
+
self._notify_transition(cur, next_cur, "enter_next")
|
|
272
|
+
return True
|
|
273
|
+
interval = min(interval + 0.5, 2.0)
|
|
274
|
+
|
|
275
|
+
elapsed = time.monotonic() - poll_start
|
|
276
|
+
LOG.warning(
|
|
277
|
+
"enter_next: %s→%s timeout after %.1fs — still on %s",
|
|
278
|
+
cur, target, max_wait_s, next_cur,
|
|
279
|
+
)
|
|
280
|
+
self._notify_timeout(cur, target, elapsed)
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
def back_one(self, adb: AdbProtocol, scale_w: float) -> str:
|
|
284
|
+
"""Send KEYCODE_BACK once, return new layer.
|
|
285
|
+
|
|
286
|
+
1. detect current layer ← **guard** (pre-check)
|
|
287
|
+
2. KEYCODE_BACK
|
|
288
|
+
3. sleep, detect new layer ← **validator** (post-check)
|
|
289
|
+
"""
|
|
290
|
+
cur = self.detect(adb, scale_w)
|
|
291
|
+
LOG.debug("back_one: from %s", cur)
|
|
292
|
+
adb.key_event(KEYCODE_BACK)
|
|
293
|
+
time.sleep(1.0)
|
|
294
|
+
next_cur = self.detect(adb, scale_w)
|
|
295
|
+
LOG.debug("back_one: %s → %s", cur, next_cur)
|
|
296
|
+
self._notify_transition(cur, next_cur, "back_one")
|
|
297
|
+
return next_cur
|
|
298
|
+
|
|
299
|
+
def back_recover(
|
|
300
|
+
self,
|
|
301
|
+
adb: AdbProtocol,
|
|
302
|
+
target_layer: str,
|
|
303
|
+
scale_w: float,
|
|
304
|
+
*,
|
|
305
|
+
target_page: str | None = None,
|
|
306
|
+
) -> bool:
|
|
307
|
+
"""Recover after BACK exhaustion: cold-start → fast-forward → page.
|
|
308
|
+
|
|
309
|
+
If *target_page* is given, calls :meth:`_recover_to_page` after
|
|
310
|
+
reaching *target_layer*.
|
|
311
|
+
"""
|
|
312
|
+
LOG.warning("back_recover: cold-start → fast-forward → %s (page=%s)",
|
|
313
|
+
target_layer, target_page)
|
|
314
|
+
adb.key_event(KEYCODE_HOME)
|
|
315
|
+
time.sleep(0.8)
|
|
316
|
+
self._cold_start(adb, "L1", scale_w)
|
|
317
|
+
|
|
318
|
+
ok = self.advance(adb, target_layer, scale_w, quick=True)
|
|
319
|
+
if not ok:
|
|
320
|
+
self._notify_recovery(target_layer, False)
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
if target_page is not None:
|
|
324
|
+
page_ok = self._recover_to_page(
|
|
325
|
+
target_layer, target_page, adb, scale_w,
|
|
326
|
+
)
|
|
327
|
+
if not page_ok:
|
|
328
|
+
LOG.error(
|
|
329
|
+
"back_recover: reached L%s but page=%s recovery failed",
|
|
330
|
+
target_layer, target_page,
|
|
331
|
+
)
|
|
332
|
+
self._notify_recovery(target_layer, False)
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
result = self.detect(adb, scale_w) == target_layer
|
|
336
|
+
self._notify_recovery(target_layer, result)
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
# ── Combined API ──────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
def back(
|
|
342
|
+
self, adb: AdbProtocol, to_layer: str, scale_w: float, *,
|
|
343
|
+
target_page: str | None = None,
|
|
344
|
+
) -> bool:
|
|
345
|
+
"""Retreat to *to_layer* via repeated BACK."""
|
|
346
|
+
for _ in range(3):
|
|
347
|
+
cur = self.detect(adb, scale_w)
|
|
348
|
+
if cur == to_layer:
|
|
349
|
+
self._call_on_layer(to_layer, adb, scale_w, quick=False)
|
|
350
|
+
if target_page is not None:
|
|
351
|
+
return self._recover_to_page(to_layer, target_page, adb, scale_w)
|
|
352
|
+
return True
|
|
353
|
+
if cur == "L0":
|
|
354
|
+
break
|
|
355
|
+
self.back_one(adb, scale_w)
|
|
356
|
+
return self.back_recover(adb, to_layer, scale_w, target_page=target_page)
|
|
357
|
+
|
|
358
|
+
def advance(
|
|
359
|
+
self, adb: AdbProtocol, target_layer: str, scale_w: float, *,
|
|
360
|
+
quick: bool = False,
|
|
361
|
+
max_wait_s: float = 8.0,
|
|
362
|
+
) -> bool:
|
|
363
|
+
"""Advance layer-by-layer to *target_layer*.
|
|
364
|
+
|
|
365
|
+
Uses :meth:`enter_next` for each step. *quick* is forwarded to
|
|
366
|
+
intermediate layers' handlers. At the target layer, handler is
|
|
367
|
+
always called with ``quick=False``.
|
|
368
|
+
"""
|
|
369
|
+
while True:
|
|
370
|
+
cur = self.detect(adb, scale_w)
|
|
371
|
+
if cur == target_layer:
|
|
372
|
+
self._call_on_layer(target_layer, adb, scale_w, quick=False)
|
|
373
|
+
return True
|
|
374
|
+
ok = self.enter_next(adb, scale_w, quick=quick, max_wait_s=max_wait_s)
|
|
375
|
+
if not ok:
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
def restore(
|
|
379
|
+
self, adb: AdbProtocol, target_layer: str, scale_w: float, *,
|
|
380
|
+
target_page: str | None = None,
|
|
381
|
+
) -> bool:
|
|
382
|
+
"""Restore to *target_layer* (and optionally *target_page*) from any position.
|
|
383
|
+
|
|
384
|
+
If already on *target_layer* but page mismatch, calls
|
|
385
|
+
:meth:`_recover_to_page` without cold-start.
|
|
386
|
+
"""
|
|
387
|
+
cur = self.detect(adb, scale_w)
|
|
388
|
+
if cur == target_layer:
|
|
389
|
+
if target_page is not None:
|
|
390
|
+
return self._recover_to_page(target_layer, target_page, adb, scale_w)
|
|
391
|
+
return True
|
|
392
|
+
ci = self._layer_index(cur)
|
|
393
|
+
ti = self._layer_index(target_layer)
|
|
394
|
+
if ci > ti:
|
|
395
|
+
return self.back(adb, target_layer, scale_w, target_page=target_page)
|
|
396
|
+
else:
|
|
397
|
+
ok = self.advance(adb, target_layer, scale_w, quick=True)
|
|
398
|
+
if not ok:
|
|
399
|
+
return False
|
|
400
|
+
if target_page is not None:
|
|
401
|
+
return self._recover_to_page(target_layer, target_page, adb, scale_w)
|
|
402
|
+
return True
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Generic cold-start: launch an Android app from the launcher home screen.
|
|
2
|
+
|
|
3
|
+
Three paths tried in priority order:
|
|
4
|
+
|
|
5
|
+
1. **monkey** — ``monkey -p <package> -c LAUNCHER 1`` (primary)
|
|
6
|
+
2. **am start Intent** — ``am start -a MAIN -c LAUNCHER <package>`` (backup
|
|
7
|
+
for custom ROMs like MIUI / ColorOS that may restrict monkey)
|
|
8
|
+
3. **Dock icon tap** — calculated via ``dock_app_icon_coords`` (last resort,
|
|
9
|
+
with 0.5 s pre‑wait and up to 2 retries)
|
|
10
|
+
|
|
11
|
+
After the app enters foreground, optionally taps a session tab to reach the
|
|
12
|
+
app's main content list (e.g. WeChat's bottom "微信" tab).
|
|
13
|
+
|
|
14
|
+
Screen dimensions are always auto‑detected via ``adb shell wm size``.
|
|
15
|
+
|
|
16
|
+
.. code-block:: python
|
|
17
|
+
|
|
18
|
+
from layernav_android.cold_start import cold_start_app_from_launcher
|
|
19
|
+
|
|
20
|
+
ok = cold_start_app_from_launcher(
|
|
21
|
+
adb, "com.tencent.mm",
|
|
22
|
+
app_name="wechat", M=4, N=3,
|
|
23
|
+
session_tab_x=108, session_tab_y=2192,
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import re
|
|
31
|
+
import time
|
|
32
|
+
|
|
33
|
+
from layernav_android._protocol import AdbProtocol
|
|
34
|
+
|
|
35
|
+
LOG = logging.getLogger("layernav.cold_start")
|
|
36
|
+
|
|
37
|
+
_SZ_RE = re.compile(r"(\d{3,})\s*x\s*(\d{3,})")
|
|
38
|
+
|
|
39
|
+
APP_DEFAULTS: dict[str, dict[str, int]] = {
|
|
40
|
+
"wechat": {"M": 4, "N": 3},
|
|
41
|
+
"xhs": {"M": 4, "N": 1},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_DOCK_RETRIES = 2
|
|
45
|
+
_DOCK_PRE_WAIT_S = 0.5
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dock_app_icon_coords(
|
|
49
|
+
screen_w: int,
|
|
50
|
+
screen_h: int,
|
|
51
|
+
scale_w: float,
|
|
52
|
+
*,
|
|
53
|
+
app_name: str = "wechat",
|
|
54
|
+
M: int = 4,
|
|
55
|
+
N: int | None = None,
|
|
56
|
+
) -> tuple[int, int]:
|
|
57
|
+
"""Calculate the centre of the *N*-th Dock slot (1‑indexed) in a Dock with *M* equal-width slots.
|
|
58
|
+
|
|
59
|
+
- *M*: total number of Dock slots (default 4).
|
|
60
|
+
- *N*: 1‑based slot index; defaults from ``APP_DEFAULTS`` (wechat→3, xhs→1).
|
|
61
|
+
- Formula: ``x = round(W * (N - 0.5) / M)``.
|
|
62
|
+
"""
|
|
63
|
+
if N is None:
|
|
64
|
+
defaults = APP_DEFAULTS.get(app_name, {})
|
|
65
|
+
N = defaults.get("N", 1)
|
|
66
|
+
M = defaults.get("M", M)
|
|
67
|
+
N = max(1, min(M, N))
|
|
68
|
+
pad_x = max(12, screen_w // 8)
|
|
69
|
+
dx = int(round(screen_w * (N - 0.5) / M))
|
|
70
|
+
dx = max(pad_x, min(screen_w - pad_x, dx))
|
|
71
|
+
dy = screen_h - max(48, int(round(52 * max(scale_w, 1e-6))))
|
|
72
|
+
return dx, dy
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cold_start_app_from_launcher(
|
|
76
|
+
adb: AdbProtocol,
|
|
77
|
+
package: str,
|
|
78
|
+
*,
|
|
79
|
+
app_name: str = "wechat",
|
|
80
|
+
M: int = 4,
|
|
81
|
+
N: int | None = None,
|
|
82
|
+
session_tab_x: int | None = None,
|
|
83
|
+
session_tab_y: int | None = None,
|
|
84
|
+
force_stop_before: bool = True,
|
|
85
|
+
deadline_s: float = 25.0,
|
|
86
|
+
) -> bool:
|
|
87
|
+
"""Cold-start *package* from the Android launcher and optionally tap a session tab.
|
|
88
|
+
|
|
89
|
+
Screen dimensions are always auto‑detected via ``adb shell wm size``.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
adb: :class:`AdbProtocol` client.
|
|
93
|
+
package: Android package name (e.g. ``"com.tencent.mm"``).
|
|
94
|
+
app_name: key in :data:`APP_DEFAULTS` — drives default *M* / *N*.
|
|
95
|
+
M: number of Dock slots.
|
|
96
|
+
N: 1‑based slot index where the app icon lives.
|
|
97
|
+
session_tab_x, session_tab_y: if both are non-``None``, tap this
|
|
98
|
+
coordinate after the app enters foreground (e.g. WeChat's
|
|
99
|
+
「微信」 bottom tab).
|
|
100
|
+
force_stop_before: issue ``am force-stop`` before cold-start.
|
|
101
|
+
deadline_s: maximum time budget for the whole cold-start.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
``True`` if *package* is the foreground app after cold-start.
|
|
105
|
+
"""
|
|
106
|
+
screen_w, screen_h = _resolve_screen_size(adb)
|
|
107
|
+
scale_w = screen_w / 1080.0
|
|
108
|
+
LOG.info("cold_start: screen %dx%d scale_w=%.3f", screen_w, screen_h, scale_w)
|
|
109
|
+
|
|
110
|
+
deadline = time.monotonic() + deadline_s
|
|
111
|
+
|
|
112
|
+
if force_stop_before:
|
|
113
|
+
LOG.info("cold_start: force-stop %s", package)
|
|
114
|
+
try:
|
|
115
|
+
adb._run(["shell", "am", "force-stop", package])
|
|
116
|
+
except Exception:
|
|
117
|
+
LOG.warning("cold_start: force-stop %s failed (non-fatal)", package)
|
|
118
|
+
time.sleep(0.65)
|
|
119
|
+
|
|
120
|
+
# -- path 1: monkey LAUNCHER --
|
|
121
|
+
if _try_monkey(adb, package):
|
|
122
|
+
time.sleep(1.5)
|
|
123
|
+
_tap_session_tab(adb, session_tab_x, session_tab_y)
|
|
124
|
+
if time.monotonic() < deadline and _check_foreground(adb, package):
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
# -- path 2: am start Intent (backup for custom ROMs) --
|
|
128
|
+
if _try_am_start(adb, package):
|
|
129
|
+
time.sleep(1.5)
|
|
130
|
+
_tap_session_tab(adb, session_tab_x, session_tab_y)
|
|
131
|
+
if time.monotonic() < deadline and _check_foreground(adb, package):
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
# -- path 3: Dock icon tap (last resort, with pre-wait + retry) --
|
|
135
|
+
dx, dy = dock_app_icon_coords(
|
|
136
|
+
screen_w, screen_h, scale_w, app_name=app_name, M=M, N=N,
|
|
137
|
+
)
|
|
138
|
+
if _try_dock_tap_with_retry(adb, package, dx, dy, session_tab_x, session_tab_y):
|
|
139
|
+
if time.monotonic() < deadline and _check_foreground(adb, package):
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Internal helpers
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _resolve_screen_size(adb: AdbProtocol) -> tuple[int, int]:
|
|
151
|
+
out = adb._run(["shell", "wm", "size"])
|
|
152
|
+
m = _SZ_RE.search(out)
|
|
153
|
+
if m:
|
|
154
|
+
return int(m.group(1)), int(m.group(2))
|
|
155
|
+
return 1080, 1920
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _try_monkey(adb: AdbProtocol, package: str) -> bool:
|
|
159
|
+
LOG.info("cold_start: monkey LAUNCHER for %s", package)
|
|
160
|
+
try:
|
|
161
|
+
adb._run(
|
|
162
|
+
["shell", "monkey", "-p", package, "-c",
|
|
163
|
+
"android.intent.category.LAUNCHER", "1"],
|
|
164
|
+
)
|
|
165
|
+
return True
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
LOG.warning("cold_start: monkey failed (%s)", exc)
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _try_am_start(adb: AdbProtocol, package: str) -> bool:
|
|
172
|
+
LOG.info("cold_start: am start LAUNCHER for %s", package)
|
|
173
|
+
try:
|
|
174
|
+
adb._run(
|
|
175
|
+
["shell", "am", "start",
|
|
176
|
+
"-a", "android.intent.action.MAIN",
|
|
177
|
+
"-c", "android.intent.category.LAUNCHER",
|
|
178
|
+
package],
|
|
179
|
+
)
|
|
180
|
+
return True
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
LOG.warning("cold_start: am start failed (%s)", exc)
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _tap_session_tab(
|
|
187
|
+
adb: AdbProtocol,
|
|
188
|
+
session_tab_x: int | None,
|
|
189
|
+
session_tab_y: int | None,
|
|
190
|
+
) -> None:
|
|
191
|
+
if session_tab_x is not None and session_tab_y is not None:
|
|
192
|
+
LOG.info("cold_start: tap session tab (%d, %d)", session_tab_x, session_tab_y)
|
|
193
|
+
adb.tap(session_tab_x, session_tab_y)
|
|
194
|
+
time.sleep(0.55)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _try_dock_tap_with_retry(
|
|
198
|
+
adb: AdbProtocol,
|
|
199
|
+
package: str,
|
|
200
|
+
dx: int,
|
|
201
|
+
dy: int,
|
|
202
|
+
session_tab_x: int | None,
|
|
203
|
+
session_tab_y: int | None,
|
|
204
|
+
) -> bool:
|
|
205
|
+
LOG.info("cold_start: Dock tap at (%d, %d) for %s (pre-wait %.1fs, max %d retries)",
|
|
206
|
+
dx, dy, package, _DOCK_PRE_WAIT_S, _DOCK_RETRIES)
|
|
207
|
+
for attempt in range(1 + _DOCK_RETRIES):
|
|
208
|
+
if attempt > 0:
|
|
209
|
+
time.sleep(_DOCK_PRE_WAIT_S)
|
|
210
|
+
adb.tap(dx, dy)
|
|
211
|
+
time.sleep(1.2)
|
|
212
|
+
_tap_session_tab(adb, session_tab_x, session_tab_y)
|
|
213
|
+
if _check_foreground(adb, package):
|
|
214
|
+
LOG.info("cold_start: Dock tap succeeded on attempt %d", attempt + 1)
|
|
215
|
+
return True
|
|
216
|
+
LOG.warning("cold_start: Dock tap attempt %d failed", attempt + 1)
|
|
217
|
+
return False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _check_foreground(adb: AdbProtocol, package: str) -> bool:
|
|
221
|
+
try:
|
|
222
|
+
return adb.foreground_package() == package
|
|
223
|
+
except Exception:
|
|
224
|
+
return False
|
|
File without changes
|