dynamic-test-engine 0.1.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,347 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import subprocess
5
+ import time
6
+ from typing import TYPE_CHECKING
7
+
8
+ from automation import config
9
+ from automation.utils import spatial
10
+
11
+ if TYPE_CHECKING:
12
+ from automation.utils.windows_host import WindowsHost
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class TextNotFoundError(RuntimeError):
18
+ """Echter Miss: Text nach vollständiger UIA-Suche nicht gefunden, getrennt von
19
+ technischen Fehlern damit engine_manager.auto nur diesen Fall als Fallback behandelt."""
20
+
21
+
22
+ class FieldNotFoundError(RuntimeError):
23
+ """Echter Miss: Eingabefeld oder Anker-Text nicht gefunden, oder der Treffer ist ein
24
+ Container ohne editierbares Child. Mehrdeutigkeit ohne anchor bleibt RuntimeError."""
25
+
26
+
27
+ class IconNotFoundError(RuntimeError):
28
+ """Echter Miss: Anker-Text oder Icon-Element für Tap Icon Near Text nicht gefunden, oder
29
+ kein Kandidat erfüllt 'near' innerhalb von SPATIAL_NEAR_MAX_DISTANCE."""
30
+
31
+
32
+ class WindowsUiaEngine:
33
+
34
+ def __init__(self, host: "WindowsHost") -> None:
35
+ """host ist die einzige Quelle des pywinauto.Application-Handles."""
36
+ self._host = host
37
+
38
+ def _require_connected(self) -> None:
39
+ if self._host.app is None:
40
+ raise RuntimeError("Kein Windows-Prozess verbunden. Connect Device zuerst aufrufen.")
41
+
42
+ @staticmethod
43
+ def _tasklist_pid(exe_name: str) -> int | None:
44
+ """Liest die PID von exe_name per tasklist, None wenn (noch) nicht gefunden."""
45
+ result = subprocess.run(
46
+ ["tasklist", "/fi", f"IMAGENAME eq {exe_name}", "/fo", "csv", "/nh"],
47
+ capture_output=True,
48
+ encoding="oem",
49
+ errors="replace",
50
+ )
51
+ matches = [ln for ln in result.stdout.strip().splitlines() if exe_name.lower() in ln.lower()]
52
+ return int(matches[0].split('","')[1]) if matches else None
53
+
54
+ @classmethod
55
+ def _poll(cls, fn, timeout: float, interval: float):
56
+ """Ruft fn() wiederholt auf bis ein truthy Ergebnis vorliegt oder timeout erreicht ist."""
57
+ deadline = time.time() + timeout
58
+ while True:
59
+ result = fn()
60
+ if result:
61
+ return result
62
+ if time.time() >= deadline:
63
+ return None
64
+ time.sleep(interval)
65
+
66
+ @classmethod
67
+ def _pid_by_name(cls, exe_name: str, timeout: float) -> int:
68
+ """Ermittelt die aktuelle PID eines laufenden Prozesses per tasklist, mit Retry bis
69
+ timeout, statt der ggf. veralteten PID von Application.start()."""
70
+ pid = cls._poll(lambda: cls._tasklist_pid(exe_name), timeout, interval=0.3)
71
+ if pid is None:
72
+ raise RuntimeError(f"Prozess '{exe_name}' nach dem Start nicht gefunden.")
73
+ return pid
74
+
75
+ def _wait_for_window(self, timeout: float):
76
+ """Wartet per Retry auf das erste Top-Level-Fenster des Prozesses, da top_window()
77
+ ohne eingebautes Polling sofort scheitert, solange noch keins existiert."""
78
+ def try_top_window():
79
+ try:
80
+ return self._host.app.top_window()
81
+ except Exception:
82
+ return None
83
+
84
+ window = self._poll(try_top_window, timeout, interval=0.3)
85
+ if window is None:
86
+ raise RuntimeError("Kein Fenster für den gestarteten Prozess gefunden.")
87
+ return window
88
+
89
+ def launch_app(self, exe_path: str, activity: str | None = None, fullscreen: bool = False) -> None:
90
+ """Beendet eine ggf. laufende Instanz von exe_path und startet sie neu, damit jeder
91
+ Testlauf von einem definierten Ausgangszustand beginnt. activity wird ignoriert."""
92
+ import os
93
+ import subprocess as sp
94
+ from pywinauto import Application # lazy: nur auf Windows installierbar
95
+
96
+ exe_name = os.path.basename(exe_path)
97
+ sp.run(["taskkill", "/f", "/im", exe_name], capture_output=True)
98
+
99
+ Application(backend="uia").start(exe_path, work_dir=os.path.dirname(exe_path))
100
+ pid = self._pid_by_name(exe_name, timeout=config.UIA_APP_START_TIMEOUT)
101
+ self._host.connect(str(pid), process_name=exe_name)
102
+
103
+ window = self._wait_for_window(config.UIA_APP_START_TIMEOUT)
104
+ window.wait("ready", timeout=config.UIA_APP_START_TIMEOUT)
105
+ if fullscreen:
106
+ try:
107
+ window.maximize()
108
+ except Exception:
109
+ # Manche Fenster exponieren kein UIA-WindowPattern. Fallback über die
110
+ # native Windows-API, die nur den Fensterhandle braucht statt UIA-Patterns.
111
+ import win32con
112
+ import win32gui
113
+ win32gui.ShowWindow(window.handle, win32con.SW_MAXIMIZE)
114
+
115
+ def close_app(self) -> None:
116
+ """Beendet die aktuell verbundene App PID-scoped, siehe WindowsHost.close."""
117
+ self._host.close()
118
+
119
+ def _window(self):
120
+ """Delegiert an WindowsHost.window(), damit UIA-Suche und Vision-Crop garantiert
121
+ dasselbe Fenster verwenden."""
122
+ return self._host.window()
123
+
124
+ @staticmethod
125
+ def _descendants(window, **kwargs):
126
+ """auto_id/title sind keine gültigen build_condition()-Properties, werden deshalb
127
+ vorab entfernt und danach lokal gegen automation_id()/window_text() abgeglichen."""
128
+ auto_id = kwargs.pop("auto_id", None)
129
+ title = kwargs.pop("title", None)
130
+ matches = window.descendants(cache_enable=True, **kwargs)
131
+ if auto_id is not None:
132
+ matches = [m for m in matches if m.automation_id() == auto_id]
133
+ if title is not None:
134
+ matches = [m for m in matches if m.window_text() == title]
135
+ return matches
136
+
137
+ @classmethod
138
+ def _find_descendant(cls, window, timeout: float, **kwargs):
139
+ """Sucht ein Nachfahren-Element per descendants() mit Retry bis timeout, da
140
+ UIAWrapper-Fenster kein eingebautes child_window()/.exists()-Polling kennen."""
141
+ matches = cls._poll(lambda: cls._descendants(window, **kwargs), timeout, interval=0.2)
142
+ return matches[0] if matches else None
143
+
144
+ @classmethod
145
+ def _find_by_text(cls, window, timeout: float, text: str):
146
+ """Sucht zuerst per title, dann per auto_id, damit beide Arten sprechender
147
+ UIA-Properties abgedeckt sind."""
148
+ found = cls._find_descendant(window, timeout, title=text)
149
+ if found is not None:
150
+ return found
151
+ return cls._find_descendant(window, timeout, auto_id=text)
152
+
153
+ def text_exists(self, text: str) -> bool:
154
+ """Prüft UIA-Tree-Existenz von sichtbarem Text (title-Property), keine pixelgenaue
155
+ Sichtbarkeitsprüfung. Technische Fehler laufen ungefangen durch, kein stiller False."""
156
+ self._require_connected()
157
+ return self._find_descendant(self._window(), config.UIA_TIMEOUT, title=text) is not None
158
+
159
+ def tap_text(self, text: str) -> None:
160
+ """Klickt Text per UIA. Ein echter Miss wirft TextNotFoundError statt RuntimeError,
161
+ damit engine_manager im auto-Modus nur diesen Fall als Fallback behandelt."""
162
+ self._require_connected()
163
+ el = self._find_by_text(self._window(), config.UIA_TIMEOUT, text)
164
+ if el is None:
165
+ raise TextNotFoundError(f"Text nicht gefunden: '{text}'")
166
+ el.click_input()
167
+
168
+ @staticmethod
169
+ def _closest_by_point(candidates, ref_x: int, ref_y: int):
170
+ """Wählt aus candidates das Element mit dem geringsten 2D-Abstand zu (ref_x, ref_y).
171
+ Reine Y-Distanz reicht nicht bei horizontal angeordneten Elementen gleicher Höhe."""
172
+ def dist_sq(el) -> int:
173
+ rect = el.rectangle()
174
+ cx = (rect.left + rect.right) // 2
175
+ cy = (rect.top + rect.bottom) // 2
176
+ return (cx - ref_x) ** 2 + (cy - ref_y) ** 2
177
+
178
+ return min(candidates, key=dist_sq)
179
+
180
+ @staticmethod
181
+ def _rect_to_box(rect) -> dict:
182
+ """UIA-Rectangle in das von spatial.py erwartete Box-Format (left/top/width/height)
183
+ übersetzen. Keine Koordinatenumrechnung nötig, UIA liefert bereits Desktop-Pixel."""
184
+ return {
185
+ "left": rect.left,
186
+ "top": rect.top,
187
+ "width": rect.right - rect.left,
188
+ "height": rect.bottom - rect.top,
189
+ }
190
+
191
+ @classmethod
192
+ def _resolve_editable(cls, el, locator: str):
193
+ """Löst el auf das tatsächlich editierbare Element auf. Ist el bereits Edit, wird es
194
+ unverändert zurückgegeben, sonst gezielt nach dem einen Edit-Kind im Container gesucht."""
195
+ if el.element_info.control_type == "Edit":
196
+ return el
197
+ children = cls._descendants(el, control_type="Edit")
198
+ if not children:
199
+ raise FieldNotFoundError(
200
+ f"Kein editierbares Child (ControlType=Edit) in Container '{locator}' gefunden."
201
+ )
202
+ if len(children) > 1:
203
+ raise RuntimeError(
204
+ f"Mehrdeutig: {len(children)} editierbare Children in Container '{locator}'."
205
+ )
206
+ return children[0]
207
+
208
+ def _resolve_field_element(self, locator: str, anchor: str | None = None):
209
+ """Löst locator (+ optionalem anchor) auf das editierbare Zielelement auf; von
210
+ input_text() und field_value() geteilt, damit beide dasselbe Element treffen."""
211
+ window = self._window()
212
+ candidates = self._descendants(window, auto_id=locator)
213
+ if not candidates:
214
+ raise FieldNotFoundError(f"Eingabefeld nicht gefunden: '{locator}'")
215
+ if len(candidates) == 1:
216
+ el = candidates[0]
217
+ elif anchor is not None:
218
+ anchor_el = self._find_by_text(window, config.UIA_TIMEOUT, anchor)
219
+ if anchor_el is None:
220
+ raise FieldNotFoundError(f"Anker-Text nicht gefunden: '{anchor}'")
221
+ rect = anchor_el.rectangle()
222
+ ref_x = (rect.left + rect.right) // 2
223
+ ref_y = (rect.top + rect.bottom) // 2
224
+ el = self._closest_by_point(candidates, ref_x, ref_y)
225
+ else:
226
+ raise RuntimeError(
227
+ f"Mehrdeutig: {len(candidates)} Elemente mit AutomationId='{locator}'. anchor angeben."
228
+ )
229
+ return self._resolve_editable(el, locator)
230
+
231
+ def input_text(self, locator: str, text: str, anchor: str | None = None) -> None:
232
+ """Setzt den Wert eines per AutomationId gefundenen Feldes (ValuePattern, kein
233
+ Keyboard-Event)."""
234
+ self._require_connected()
235
+ el = self._resolve_field_element(locator, anchor)
236
+ el.set_edit_text(text)
237
+
238
+ def field_value(self, locator: str, anchor: str | None = None) -> str:
239
+ """Liest den aktuellen Wert eines Feldes per AutomationId. get_value() primär,
240
+ window_text() nur als Fallback (siehe WindowsHost.value_region())."""
241
+ self._require_connected()
242
+ el = self._resolve_field_element(locator, anchor)
243
+ try:
244
+ return el.get_value()
245
+ except Exception:
246
+ return el.window_text()
247
+
248
+ def field_value_should_be(self, locator: str, expected: str, anchor: str | None = None) -> None:
249
+ """Prüft den tatsächlichen UIA-Feldwert gegen expected. AssertionError mit Ist/Soll
250
+ bei Abweichung."""
251
+ actual = self.field_value(locator, anchor)
252
+ if actual != expected:
253
+ raise AssertionError(
254
+ f"Feldwert von '{locator}': erwartet '{expected}', tatsächlich '{actual}'"
255
+ )
256
+
257
+ def tap_icon_near_text(self, icon_path: str, text: str) -> None:
258
+ """Klickt das Element mit AutomationId==icon_path, das 'near' zum Anker-Text liegt.
259
+ Ein echter Miss wirft IconNotFoundError, damit engine_manager im auto-Modus nur
260
+ diesen Fall als Fallback behandelt."""
261
+ self._require_connected()
262
+ window = self._window()
263
+ text_el = self._find_by_text(window, config.UIA_TIMEOUT, text)
264
+ if text_el is None:
265
+ raise IconNotFoundError(f"Anker-Text nicht gefunden: '{text}'")
266
+
267
+ candidates = self._descendants(window, auto_id=icon_path)
268
+ if not candidates:
269
+ raise IconNotFoundError(
270
+ f"Kein Element mit AutomationId='{icon_path}' gefunden. "
271
+ "Für Apps ohne AutomationIds engine='vision' verwenden."
272
+ )
273
+
274
+ anchor_box = self._rect_to_box(text_el.rectangle())
275
+ pairs = [(self._rect_to_box(c.rectangle()), c) for c in candidates]
276
+ candidate_boxes = [box for box, _ in pairs]
277
+
278
+ best = spatial.resolve(
279
+ anchor_box,
280
+ candidate_boxes,
281
+ "near",
282
+ overlap_tol=config.SPATIAL_OVERLAP_TOLERANCE,
283
+ max_distance=config.SPATIAL_NEAR_MAX_DISTANCE,
284
+ )
285
+ if best is None:
286
+ raise IconNotFoundError(
287
+ f"Kein Element mit AutomationId='{icon_path}' erfüllt 'near' zu '{text}' "
288
+ f"(SPATIAL_NEAR_MAX_DISTANCE={config.SPATIAL_NEAR_MAX_DISTANCE})."
289
+ )
290
+
291
+ for box, el in pairs:
292
+ if box == best:
293
+ el.click_input()
294
+ return
295
+
296
+ raise RuntimeError("Interner Fehler: spatial-Treffer konnte keinem UIA-Element zugeordnet werden.")
297
+
298
+ def scroll_to_text(self, text: str) -> bool:
299
+ """Scrollt per UIA-ScrollPattern bis text sichtbar. Gibt False zurück wenn nach max. Swipes nicht gefunden."""
300
+ self._require_connected()
301
+ window = self._window()
302
+
303
+ try:
304
+ if self._find_by_text(window, config.UIA_TIMEOUT, text) is not None:
305
+ return True
306
+ except Exception:
307
+ pass
308
+
309
+ seen_rects = set()
310
+ candidates = []
311
+ for scrollbar in window.descendants(control_type="ScrollBar"):
312
+ try:
313
+ container = scrollbar.parent()
314
+ except Exception:
315
+ continue
316
+ rect = container.rectangle()
317
+ key = (rect.left, rect.top, rect.right, rect.bottom)
318
+ if key in seen_rects:
319
+ continue
320
+ seen_rects.add(key)
321
+ candidates.append(container)
322
+ if not candidates:
323
+ candidates = [window]
324
+
325
+ for candidate in candidates:
326
+ if self._try_scroll_candidate(candidate, window, text):
327
+ return True
328
+
329
+ return False
330
+
331
+ def _try_scroll_candidate(self, candidate, window, text: str) -> bool:
332
+ """Scrollt candidate bis zu UIA_SCROLL_MAX_SWIPES mal und sucht text nach jedem Schritt.
333
+ False auch wenn candidate kein ScrollPattern hat (nächster Kandidat wird dann versucht)."""
334
+ for _ in range(config.UIA_SCROLL_MAX_SWIPES):
335
+ try:
336
+ candidate.scroll("down", "page")
337
+ except Exception:
338
+ return False
339
+
340
+ time.sleep(0.2)
341
+ try:
342
+ if self._find_by_text(window, 1, text) is not None:
343
+ return True
344
+ except Exception:
345
+ pass
346
+
347
+ return False
File without changes
@@ -0,0 +1,83 @@
1
+ from robot.api.deco import keyword
2
+
3
+ from automation.engines.engine_manager import EngineManager
4
+
5
+ _manager = EngineManager()
6
+
7
+
8
+ class AutomationKeywords:
9
+ ROBOT_LIBRARY_SCOPE = "GLOBAL"
10
+
11
+ @keyword
12
+ def connect_device(self, device_id: str | None = None, platform: str = "android") -> None:
13
+ _manager.connect_device(device_id, platform=platform)
14
+
15
+ @keyword
16
+ def install_app(self, apk_path: str) -> None:
17
+ _manager.install_app(apk_path)
18
+
19
+ @keyword
20
+ def launch_app(self, package: str, activity: str | None = None, fullscreen: bool = False) -> None:
21
+ """fullscreen: nur auf Windows wirksam, maximiert das Fenster nach dem Start."""
22
+ _manager.launch_app(package, activity=activity, fullscreen=fullscreen)
23
+
24
+ @keyword
25
+ def close_app(self) -> None:
26
+ """Nur auf Windows verfügbar, beendet die aktuell verbundene App PID-scoped."""
27
+ _manager.close_app()
28
+
29
+ @keyword
30
+ def tap_text(self, text: str, engine: str = "auto") -> None:
31
+ _manager.tap_text(text, engine=engine)
32
+
33
+ @keyword
34
+ def input_text(self, locator: str, text: str, anchor: str | None = None,
35
+ engine: str = "auto") -> None:
36
+ """anchor: optionale Disambiguierung bei mehreren Elementen mit derselben AutomationId
37
+ (Abstand zum Anker-Text). Gilt nur für engine=uia, bei engine=vision wird er ignoriert."""
38
+ _manager.input_text(locator, text, anchor=anchor, engine=engine)
39
+
40
+ @keyword
41
+ def tap_icon_near_text(self, icon_path: str, text: str, engine: str = "auto") -> None:
42
+ _manager.tap_icon_near_text(icon_path, text, engine=engine)
43
+
44
+ @keyword
45
+ def field_value_should_be(self, locator: str, expected: str, anchor: str | None = None,
46
+ engine: str = "uia") -> None:
47
+ """Prüft den tatsächlichen UIA-Feldwert (ValuePattern) gegen expected. Windows-only,
48
+ kein Vision-Pfad, kein engine=auto."""
49
+ _manager.field_value_should_be(locator, expected, anchor=anchor, engine=engine)
50
+
51
+ @keyword
52
+ def scroll_to_text(self, text: str, anchor: str | None = None, relation: str = "near",
53
+ engine: str = "auto") -> bool:
54
+ """anchor/relation disambiguieren bei Duplikat-Text und verschieben die Scroll-Region
55
+ weg von der Bildschirmmitte. Gelten nur für engine=vision, siehe Find Element."""
56
+ return _manager.scroll_to_text(text, anchor=anchor, relation=relation, engine=engine)
57
+
58
+ @keyword
59
+ def text_should_exist(self, text: str, engine: str = "auto") -> None:
60
+ # Kein Error-Screenshot: läuft als Poll-Primitiv (Wait Until Keyword Succeeds). Siehe BP-NEG-SCREENSHOT.
61
+ if not _manager.text_exists(text, engine=engine):
62
+ raise AssertionError(f"Text not found: '{text}'")
63
+
64
+ @keyword
65
+ def take_screenshot(self, name: str = "screenshot") -> str:
66
+ return _manager.take_screenshot(name)
67
+
68
+ @keyword
69
+ def move_mouse(self, x: int, y: int) -> None:
70
+ _manager.move_mouse(x, y)
71
+
72
+ @keyword
73
+ def find_element(self, target: str, anchor: str, relation: str = "near",
74
+ kind: str = "auto", engine: str = "auto") -> tuple[int, int]:
75
+ """Lokalisiert ein Ziel live relativ zu einem Anker-Text und gibt (x, y) zurück.
76
+ target: Text oder .png (Custom Element) · relation: near|left_of|right_of|above|below · kind: auto|text|icon."""
77
+ return _manager.find_element(target, anchor, relation=relation, kind=kind, engine=engine)
78
+
79
+ @keyword
80
+ def tap_element(self, target: str, anchor: str, relation: str = "near",
81
+ kind: str = "auto", engine: str = "auto") -> None:
82
+ """Tippt ein Ziel, das live relativ zu einem Anker-Text lokalisiert wird (siehe Find Element)."""
83
+ _manager.tap_element(target, anchor, relation=relation, kind=kind, engine=engine)
File without changes
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import subprocess
6
+ import warnings
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ import numpy as np
11
+
12
+ from automation import config
13
+ from automation.utils.device_interface import DeviceInterface
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _device_id: str | None = None
18
+ # Session-konstante Werte memoisieren: App-Version und Geräte-Fingerprint ändern sich
19
+ # innerhalb eines Laufs nicht, die Activity dagegen pro Aktion.
20
+ _app_version_cache: dict[str, str] = {}
21
+ _device_fp_cache: str | None = None
22
+
23
+ def _run(cmd: list[str], timeout: int | None = None) -> subprocess.CompletedProcess:
24
+ prefix = ["-s", _device_id] if _device_id else []
25
+ return subprocess.run(
26
+ ["adb"] + prefix + cmd,
27
+ capture_output=True,
28
+ timeout=timeout if timeout is not None else config.ADB_TIMEOUT,
29
+ )
30
+
31
+ def _list_devices() -> list[str]:
32
+ result = subprocess.run(
33
+ ["adb", "devices"],
34
+ capture_output=True,
35
+ text=True,
36
+ )
37
+ return result.stdout.splitlines()
38
+
39
+ def connect(device_id: str | None = None) -> None:
40
+ global _device_id
41
+
42
+ if device_id:
43
+ # TCP/IP Device (z. B. 192.168.0.10:5555)
44
+ if ":" in device_id:
45
+ result = subprocess.run(
46
+ ["adb", "connect", device_id],
47
+ capture_output=True,
48
+ text=True,
49
+ )
50
+ if "connected" not in result.stdout:
51
+ raise RuntimeError(f"ADB connect failed: {result.stdout.strip()}")
52
+ else:
53
+ # USB Device prüfen
54
+ device_lines = [ln for ln in _list_devices() if ln.startswith(device_id + "\t")]
55
+ if not device_lines or not any("\tdevice" in ln for ln in device_lines):
56
+ status = device_lines[0].split("\t")[1] if device_lines else "not found"
57
+ raise RuntimeError(f"Device '{device_id}' not ready: {status}")
58
+
59
+ _device_id = device_id
60
+ return
61
+
62
+ lines = [
63
+ line for line in _list_devices()
64
+ if "device" in line and "List" not in line
65
+ ]
66
+
67
+ if not lines:
68
+ raise RuntimeError(f"Device not found: {device_id or '(any)'}")
69
+
70
+ if len(lines) > 1:
71
+ raise RuntimeError(
72
+ "Multiple ADB devices detected. Please set ANDROID_DEVICE_ID explicitly."
73
+ )
74
+
75
+ # Genau ein Device -> automatisch verwenden
76
+ detected_device = lines[0].split()[0]
77
+ _device_id = detected_device
78
+
79
+ warnings.warn(f"No device_id provided. Using detected device: {detected_device}")
80
+ logger.info("connect event=auto_detect device_id=%r", detected_device)
81
+
82
+ def screenshot() -> np.ndarray:
83
+ import cv2
84
+ import numpy as np
85
+ result = _run(["exec-out", "screencap", "-p"], timeout=config.ADB_SCREENSHOT_TIMEOUT)
86
+ if result.returncode != 0:
87
+ raise RuntimeError(
88
+ f"Screenshot failed (returncode={result.returncode}): {result.stderr.decode().strip()}"
89
+ )
90
+ arr = np.frombuffer(result.stdout, np.uint8)
91
+ img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
92
+ if img is None:
93
+ raise RuntimeError("Screenshot failed, image decode returned None")
94
+ return img
95
+
96
+ def tap(x: int, y: int) -> None:
97
+ result = _run(["shell", "input", "tap", str(x), str(y)])
98
+ if result.returncode != 0:
99
+ raise RuntimeError(f"tap({x}, {y}) failed: {result.stderr.decode().strip()}")
100
+
101
+ def swipe(x1: int, y1: int, x2: int, y2: int, duration_ms: int | None = None) -> None:
102
+ if duration_ms is None:
103
+ duration_ms = config.ADB_SWIPE_DURATION_MS
104
+ result = _run(["shell", "input", "swipe", str(x1), str(y1), str(x2), str(y2), str(duration_ms)])
105
+ if result.returncode != 0:
106
+ raise RuntimeError(f"swipe failed: {result.stderr.decode().strip()}")
107
+
108
+ def type_text(text: str) -> None:
109
+ # 'adb shell input text' ersetzt Leerzeichen durch %s. Nur Leerzeichen werden behandelt;
110
+ # Sonderzeichen-Escaping ist für den PoC bewusst nicht abgedeckt (Showcase-Strings sind kontrolliert).
111
+ escaped = text.replace(" ", "%s")
112
+ result = _run(["shell", "input", "text", escaped])
113
+ if result.returncode != 0:
114
+ raise RuntimeError(f"type_text failed: {result.stderr.decode().strip()}")
115
+
116
+ def _activity_token(segment: str) -> str | None:
117
+ """Erstes Token in segment im Format 'com.pkg/.Activity', None wenn keins vorhanden."""
118
+ for tok in segment.split():
119
+ if "/" in tok and "." in tok:
120
+ return tok.strip("{}")
121
+ return None
122
+
123
+ def current_activity() -> str:
124
+ """Aktuell sichtbare Activity als 'package/activity' (z. B. 'com.android.settings/.Settings'),
125
+ Leerstring wenn keine gefunden. Quelle: 'dumpsys activity activities', ohne uiautomator2."""
126
+ result = _run(["shell", "dumpsys", "activity", "activities"])
127
+ text = result.stdout.decode(errors="ignore")
128
+ # Feldname variiert je Android-Version.
129
+ for marker in ("topResumedActivity=", "mResumedActivity=", "ResumedActivity:"):
130
+ idx = text.find(marker)
131
+ if idx == -1:
132
+ continue
133
+ # Format: ...ActivityRecord{<hash> u0 com.pkg/.Activity tNNN}
134
+ token = _activity_token(text[idx:idx + 300])
135
+ if token is not None:
136
+ return token
137
+ return ""
138
+
139
+ def app_version(package: str) -> str:
140
+ """versionName der installierten App. Session-konstant -> memoisiert pro Package."""
141
+ if package in _app_version_cache:
142
+ logger.debug("ADB_CACHE event=hit keyword=app_version package=%r", package)
143
+ return _app_version_cache[package]
144
+ result = _run(["shell", "dumpsys", "package", package])
145
+ version = ""
146
+ for line in result.stdout.decode(errors="ignore").splitlines():
147
+ line = line.strip()
148
+ if line.startswith("versionName="):
149
+ version = line.split("=", 1)[1].strip()
150
+ break
151
+ _app_version_cache[package] = version
152
+ logger.info("ADB_CACHE event=miss keyword=app_version package=%r version=%r", package, version)
153
+ return version
154
+
155
+ def _first_value_after(output: str, marker: str) -> str | None:
156
+ """Wert der ersten Zeile, die marker enthält (Format 'marker: wert'), None wenn nicht gefunden."""
157
+ for line in output.splitlines():
158
+ if marker in line:
159
+ return line.split(":", 1)[1].strip()
160
+ return None
161
+
162
+ def device_fingerprint() -> str:
163
+ """Geräte-Identität 'id_BxH_density_orientation' für den Screen-Key (damit der Cache nicht
164
+ über Geräte/Auflösungen hinweg falsch trifft). Session-konstant -> memoisiert."""
165
+ global _device_fp_cache
166
+ if _device_fp_cache is not None:
167
+ logger.debug("ADB_CACHE event=hit keyword=device_fingerprint")
168
+ return _device_fp_cache
169
+ size_out = _run(["shell", "wm", "size"]).stdout.decode(errors="ignore")
170
+ dens_out = _run(["shell", "wm", "density"]).stdout.decode(errors="ignore")
171
+ size_val = _first_value_after(size_out, "Physical size:")
172
+ w, h = (int(p) for p in size_val.split("x")[:2]) if size_val and "x" in size_val else (0, 0)
173
+ dens = _first_value_after(dens_out, "Physical density:") or ""
174
+ orientation = "portrait" if h >= w else "landscape"
175
+ _device_fp_cache = f"{_device_id or ''}_{w}x{h}_{dens}_{orientation}"
176
+ logger.info("ADB_CACHE event=miss keyword=device_fingerprint fingerprint=%r", _device_fp_cache)
177
+ return _device_fp_cache
178
+
179
+ def install_app(apk_path: str = "") -> None:
180
+ if not apk_path:
181
+ apk_path = config.APK_PATH
182
+ if not os.path.exists(apk_path):
183
+ raise FileNotFoundError(f"APK not found: {apk_path}")
184
+
185
+ prefix = ["-s", _device_id] if _device_id else []
186
+ result = subprocess.run(
187
+ ["adb"] + prefix + ["install", "-r", apk_path],
188
+ capture_output=True,
189
+ )
190
+ if result.returncode != 0:
191
+ raise RuntimeError(f"Installation failed: {result.stderr.decode().strip()}")
192
+
193
+
194
+ class AdbDevice(DeviceInterface):
195
+ """DeviceInterface-Implementierung für Android über ADB."""
196
+
197
+ def screenshot(self) -> np.ndarray:
198
+ return screenshot()
199
+
200
+ def tap(self, x: int, y: int) -> None:
201
+ tap(x, y)
202
+
203
+ def type_text(self, text: str) -> None:
204
+ type_text(text)
205
+
206
+ def scroll(self, x: int, y: int, amount: int, duration_ms: int | None = None) -> None:
207
+ swipe(x, y + amount // 2, x, y - amount // 2, duration_ms)
208
+
209
+ def connect(self, target: str | None = None) -> None:
210
+ connect(target)
211
+
212
+ def screen_key(self) -> tuple[str, str, str, str] | None:
213
+ try:
214
+ activity = current_activity()
215
+ if not activity or "/" not in activity:
216
+ return None
217
+ package = activity.split("/", 1)[0]
218
+ return package, activity, app_version(package), device_fingerprint()
219
+ except Exception:
220
+ return None