android-emu-agent 0.1.3__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.
- android_emu_agent/__init__.py +3 -0
- android_emu_agent/actions/__init__.py +1 -0
- android_emu_agent/actions/executor.py +288 -0
- android_emu_agent/actions/selector.py +122 -0
- android_emu_agent/actions/wait.py +193 -0
- android_emu_agent/artifacts/__init__.py +1 -0
- android_emu_agent/artifacts/manager.py +125 -0
- android_emu_agent/artifacts/py.typed +0 -0
- android_emu_agent/cli/__init__.py +1 -0
- android_emu_agent/cli/commands/__init__.py +1 -0
- android_emu_agent/cli/commands/action.py +158 -0
- android_emu_agent/cli/commands/app_cmd.py +95 -0
- android_emu_agent/cli/commands/artifact.py +81 -0
- android_emu_agent/cli/commands/daemon.py +62 -0
- android_emu_agent/cli/commands/device.py +122 -0
- android_emu_agent/cli/commands/emulator.py +46 -0
- android_emu_agent/cli/commands/file.py +139 -0
- android_emu_agent/cli/commands/reliability.py +310 -0
- android_emu_agent/cli/commands/session.py +65 -0
- android_emu_agent/cli/commands/ui.py +112 -0
- android_emu_agent/cli/commands/wait.py +132 -0
- android_emu_agent/cli/daemon_client.py +185 -0
- android_emu_agent/cli/main.py +52 -0
- android_emu_agent/cli/utils.py +171 -0
- android_emu_agent/daemon/__init__.py +1 -0
- android_emu_agent/daemon/core.py +62 -0
- android_emu_agent/daemon/health.py +177 -0
- android_emu_agent/daemon/models.py +244 -0
- android_emu_agent/daemon/server.py +1644 -0
- android_emu_agent/db/__init__.py +1 -0
- android_emu_agent/db/models.py +229 -0
- android_emu_agent/device/__init__.py +1 -0
- android_emu_agent/device/manager.py +522 -0
- android_emu_agent/device/session.py +129 -0
- android_emu_agent/errors.py +232 -0
- android_emu_agent/files/__init__.py +1 -0
- android_emu_agent/files/manager.py +311 -0
- android_emu_agent/py.typed +0 -0
- android_emu_agent/reliability/__init__.py +1 -0
- android_emu_agent/reliability/manager.py +244 -0
- android_emu_agent/ui/__init__.py +1 -0
- android_emu_agent/ui/context.py +169 -0
- android_emu_agent/ui/ref_resolver.py +149 -0
- android_emu_agent/ui/snapshotter.py +236 -0
- android_emu_agent/validation.py +59 -0
- android_emu_agent-0.1.3.dist-info/METADATA +375 -0
- android_emu_agent-0.1.3.dist-info/RECORD +50 -0
- android_emu_agent-0.1.3.dist-info/WHEEL +4 -0
- android_emu_agent-0.1.3.dist-info/entry_points.txt +2 -0
- android_emu_agent-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Reliability manager - ADB diagnostics and forensics commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
from android_emu_agent.errors import (
|
|
18
|
+
AgentError,
|
|
19
|
+
adb_command_error,
|
|
20
|
+
adb_not_found_error,
|
|
21
|
+
permission_error,
|
|
22
|
+
process_not_found_error,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from adbutils import AdbDevice
|
|
27
|
+
|
|
28
|
+
logger = structlog.get_logger()
|
|
29
|
+
|
|
30
|
+
DEFAULT_EVENTS_PATTERN = r"am_proc_died|am_anr|am_crash|am_low_memory|wm_on_paused|wm_on_resumed"
|
|
31
|
+
|
|
32
|
+
TRIM_LEVELS = {
|
|
33
|
+
"RUNNING_MODERATE",
|
|
34
|
+
"RUNNING_LOW",
|
|
35
|
+
"RUNNING_CRITICAL",
|
|
36
|
+
"UI_HIDDEN",
|
|
37
|
+
"BACKGROUND",
|
|
38
|
+
"MODERATE",
|
|
39
|
+
"COMPLETE",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class CommandOutput:
|
|
45
|
+
output: str
|
|
46
|
+
line_count: int
|
|
47
|
+
total_lines: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ReliabilityManager:
|
|
51
|
+
"""Runs reliability diagnostics via ADB."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, output_dir: Path | None = None) -> None:
|
|
54
|
+
default_dir = Path.home() / ".android-emu-agent" / "artifacts" / "reliability"
|
|
55
|
+
self.output_dir = output_dir or default_dir
|
|
56
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
async def exit_info(self, device: AdbDevice, package: str, list_only: bool) -> str:
|
|
59
|
+
cmd = f"dumpsys activity exit-info {package}"
|
|
60
|
+
if list_only:
|
|
61
|
+
cmd += " list"
|
|
62
|
+
return await self._shell(device, cmd)
|
|
63
|
+
|
|
64
|
+
async def bugreport(self, serial: str, filename: str | None = None) -> Path:
|
|
65
|
+
timestamp = self._timestamp()
|
|
66
|
+
name = filename or f"bugreport_{serial}_{timestamp}.zip"
|
|
67
|
+
if not name.endswith(".zip"):
|
|
68
|
+
name = f"{name}.zip"
|
|
69
|
+
output_path = self.output_dir / name
|
|
70
|
+
await self._run_adb(serial, ["bugreport", str(output_path)])
|
|
71
|
+
logger.info("bugreport_saved", serial=serial, path=str(output_path))
|
|
72
|
+
return output_path
|
|
73
|
+
|
|
74
|
+
async def logcat_events(
|
|
75
|
+
self, device: AdbDevice, pattern: str | None, since: str | None
|
|
76
|
+
) -> CommandOutput:
|
|
77
|
+
cmd = "logcat -b events -d"
|
|
78
|
+
if since:
|
|
79
|
+
cmd += f" -t {shlex.quote(since)}"
|
|
80
|
+
output = await self._shell(device, cmd)
|
|
81
|
+
lines = output.splitlines()
|
|
82
|
+
if pattern:
|
|
83
|
+
try:
|
|
84
|
+
regex = re.compile(pattern)
|
|
85
|
+
except re.error as exc: # pragma: no cover - depends on user input
|
|
86
|
+
raise AgentError(
|
|
87
|
+
code="ERR_INVALID_PATTERN",
|
|
88
|
+
message=f"Invalid regex pattern: {pattern}",
|
|
89
|
+
context={"pattern": pattern},
|
|
90
|
+
remediation="Provide a valid regex for --pattern",
|
|
91
|
+
) from exc
|
|
92
|
+
filtered = [line for line in lines if regex.search(line)]
|
|
93
|
+
else:
|
|
94
|
+
filtered = lines
|
|
95
|
+
return CommandOutput(
|
|
96
|
+
output="\n".join(filtered),
|
|
97
|
+
line_count=len(filtered),
|
|
98
|
+
total_lines=len(lines),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
async def dropbox_list(self, device: AdbDevice, package: str | None) -> str:
|
|
102
|
+
output = await self._shell(device, "dumpsys dropbox")
|
|
103
|
+
if package:
|
|
104
|
+
filtered = [line for line in output.splitlines() if package in line]
|
|
105
|
+
return "\n".join(filtered)
|
|
106
|
+
return output
|
|
107
|
+
|
|
108
|
+
async def dropbox_print(self, device: AdbDevice, tag: str) -> str:
|
|
109
|
+
return await self._shell(device, f"dumpsys dropbox --print {tag}")
|
|
110
|
+
|
|
111
|
+
async def background_restrictions(self, device: AdbDevice, package: str) -> dict[str, str]:
|
|
112
|
+
appops = await self._shell(device, f"cmd appops get {package} RUN_IN_BACKGROUND")
|
|
113
|
+
standby = await self._shell(device, f"am get-standby-bucket {package}")
|
|
114
|
+
return {"appops": appops, "standby_bucket": standby}
|
|
115
|
+
|
|
116
|
+
async def last_anr(self, device: AdbDevice) -> str:
|
|
117
|
+
return await self._shell(device, "dumpsys activity lastanr")
|
|
118
|
+
|
|
119
|
+
async def jobscheduler(self, device: AdbDevice, package: str) -> str:
|
|
120
|
+
return await self._shell(device, f"dumpsys jobscheduler {package}")
|
|
121
|
+
|
|
122
|
+
async def compile_package(self, device: AdbDevice, package: str, mode: str) -> str:
|
|
123
|
+
if mode == "reset":
|
|
124
|
+
return await self._shell(device, f"cmd package compile --reset {package}")
|
|
125
|
+
if mode == "speed":
|
|
126
|
+
return await self._shell(device, f"cmd package compile -m speed -f {package}")
|
|
127
|
+
raise AgentError(
|
|
128
|
+
code="ERR_INVALID_MODE",
|
|
129
|
+
message=f"Invalid compile mode: {mode}",
|
|
130
|
+
context={"mode": mode},
|
|
131
|
+
remediation="Use 'reset' or 'speed'",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def always_finish_activities(self, device: AdbDevice, enabled: bool) -> str:
|
|
135
|
+
value = "1" if enabled else "0"
|
|
136
|
+
return await self._shell(device, f"settings put global always_finish_activities {value}")
|
|
137
|
+
|
|
138
|
+
async def run_as_ls(self, device: AdbDevice, package: str, path: str) -> str:
|
|
139
|
+
safe_path = shlex.quote(path)
|
|
140
|
+
return await self._shell(device, f"run-as {package} ls -R {safe_path}")
|
|
141
|
+
|
|
142
|
+
async def dump_heap(
|
|
143
|
+
self,
|
|
144
|
+
device: AdbDevice,
|
|
145
|
+
serial: str,
|
|
146
|
+
package: str,
|
|
147
|
+
keep_remote: bool,
|
|
148
|
+
) -> Path:
|
|
149
|
+
timestamp = self._timestamp()
|
|
150
|
+
safe_pkg = package.replace(".", "_")
|
|
151
|
+
remote_path = f"/data/local/tmp/{safe_pkg}_{timestamp}.hprof"
|
|
152
|
+
await self._shell(device, f"am dumpheap {package} {remote_path}")
|
|
153
|
+
|
|
154
|
+
local_path = self.output_dir / f"heap_{safe_pkg}_{timestamp}.hprof"
|
|
155
|
+
await self._run_adb(serial, ["pull", remote_path, str(local_path)])
|
|
156
|
+
|
|
157
|
+
if not keep_remote:
|
|
158
|
+
await self._shell(device, f"rm -f {remote_path}")
|
|
159
|
+
|
|
160
|
+
logger.info("heap_dump_saved", serial=serial, path=str(local_path))
|
|
161
|
+
return local_path
|
|
162
|
+
|
|
163
|
+
async def sigquit(self, device: AdbDevice, package: str) -> int:
|
|
164
|
+
pid = await self._pidof(device, package)
|
|
165
|
+
await self._shell(device, f"kill -3 {pid}")
|
|
166
|
+
return pid
|
|
167
|
+
|
|
168
|
+
async def oom_score_adj(self, device: AdbDevice, package: str, score: int) -> int:
|
|
169
|
+
pid = await self._pidof(device, package)
|
|
170
|
+
await self._shell_su(device, f"echo {score} > /proc/{pid}/oom_score_adj")
|
|
171
|
+
return pid
|
|
172
|
+
|
|
173
|
+
async def trim_memory(self, device: AdbDevice, package: str, level: str) -> str:
|
|
174
|
+
return await self._shell(device, f"am send-trim-memory {package} {level}")
|
|
175
|
+
|
|
176
|
+
async def pull_root_dir(
|
|
177
|
+
self,
|
|
178
|
+
device: AdbDevice,
|
|
179
|
+
serial: str,
|
|
180
|
+
remote_dir: str,
|
|
181
|
+
name: str,
|
|
182
|
+
) -> Path:
|
|
183
|
+
stage_parent = "/data/local/tmp/android-emu-agent"
|
|
184
|
+
stage_dir = f"{stage_parent}/{name}"
|
|
185
|
+
stage_cmd = (
|
|
186
|
+
f"rm -rf {stage_dir} && mkdir -p {stage_parent} && cp -r {remote_dir} {stage_parent}"
|
|
187
|
+
)
|
|
188
|
+
await self._shell_su(device, stage_cmd)
|
|
189
|
+
|
|
190
|
+
local_path = self.output_dir / f"{serial}_{self._timestamp()}_{name}"
|
|
191
|
+
await self._run_adb(serial, ["pull", stage_dir, str(local_path)])
|
|
192
|
+
|
|
193
|
+
await self._shell(device, f"rm -rf {stage_dir}")
|
|
194
|
+
logger.info("root_dir_pulled", serial=serial, remote=remote_dir, local=str(local_path))
|
|
195
|
+
return local_path
|
|
196
|
+
|
|
197
|
+
async def _shell(self, device: AdbDevice, command: str) -> str:
|
|
198
|
+
def _run() -> str:
|
|
199
|
+
result = device.shell(command)
|
|
200
|
+
output = getattr(result, "output", None)
|
|
201
|
+
return output if isinstance(output, str) else str(result)
|
|
202
|
+
|
|
203
|
+
return await asyncio.to_thread(_run)
|
|
204
|
+
|
|
205
|
+
async def _shell_su(self, device: AdbDevice, command: str) -> str:
|
|
206
|
+
return await self._shell(device, f"su -c {shlex.quote(command)}")
|
|
207
|
+
|
|
208
|
+
async def _pidof(self, device: AdbDevice, package: str) -> int:
|
|
209
|
+
output = await self._shell(device, f"pidof {package}")
|
|
210
|
+
pid = output.strip().split(" ")[0] if output.strip() else ""
|
|
211
|
+
if not pid.isdigit():
|
|
212
|
+
raise process_not_found_error(package)
|
|
213
|
+
return int(pid)
|
|
214
|
+
|
|
215
|
+
async def _run_adb(self, serial: str, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
216
|
+
def _run() -> subprocess.CompletedProcess[str]:
|
|
217
|
+
adb_path = shutil.which("adb")
|
|
218
|
+
if not adb_path:
|
|
219
|
+
raise adb_not_found_error()
|
|
220
|
+
return subprocess.run(
|
|
221
|
+
[adb_path, "-s", serial, *args],
|
|
222
|
+
check=True,
|
|
223
|
+
capture_output=True,
|
|
224
|
+
text=True,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
return await asyncio.to_thread(_run)
|
|
229
|
+
except AgentError:
|
|
230
|
+
raise
|
|
231
|
+
except FileNotFoundError as exc:
|
|
232
|
+
raise adb_not_found_error() from exc
|
|
233
|
+
except subprocess.CalledProcessError as exc:
|
|
234
|
+
reason = (exc.stderr or exc.stdout or str(exc)).strip()
|
|
235
|
+
raise adb_command_error(" ".join(args), reason) from exc
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _timestamp() -> str:
|
|
239
|
+
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def require_root(is_rooted: bool, operation: str) -> None:
|
|
243
|
+
if not is_rooted:
|
|
244
|
+
raise permission_error(operation)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""UI - Snapshot generation, context resolution, ref management."""
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Context resolver - Activity, window, IME, and dialog detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TYPE_CHECKING, TypeVar, cast
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from adbutils import AdbDevice
|
|
14
|
+
|
|
15
|
+
logger = structlog.get_logger()
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class UIContext:
|
|
21
|
+
"""Current UI context from the device."""
|
|
22
|
+
|
|
23
|
+
package: str | None
|
|
24
|
+
activity: str | None
|
|
25
|
+
top_resumed_activity: str | None
|
|
26
|
+
top_window: str | None
|
|
27
|
+
orientation: str
|
|
28
|
+
window_focused: bool
|
|
29
|
+
ime_visible: bool
|
|
30
|
+
ime_package: str | None
|
|
31
|
+
system_dialogs: list[str]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ContextResolver:
|
|
35
|
+
"""Resolves current UI context from device state."""
|
|
36
|
+
|
|
37
|
+
async def resolve(self, device: AdbDevice) -> UIContext:
|
|
38
|
+
"""Resolve current UI context from device."""
|
|
39
|
+
# Run multiple shell commands in parallel
|
|
40
|
+
results = await asyncio.gather(
|
|
41
|
+
self._get_focused_activity(device),
|
|
42
|
+
self._get_window_info(device),
|
|
43
|
+
self._get_ime_info(device),
|
|
44
|
+
return_exceptions=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
activity_info = self._unwrap_result(results[0], default={})
|
|
48
|
+
window_info = self._unwrap_result(results[1], default={})
|
|
49
|
+
ime_info = self._unwrap_result(results[2], default={})
|
|
50
|
+
|
|
51
|
+
return UIContext(
|
|
52
|
+
package=activity_info.get("package"),
|
|
53
|
+
activity=activity_info.get("activity"),
|
|
54
|
+
top_resumed_activity=activity_info.get("top_resumed"),
|
|
55
|
+
top_window=self._string_or_none(window_info.get("top_window")),
|
|
56
|
+
orientation=self._string_value(window_info.get("orientation"), "PORTRAIT"),
|
|
57
|
+
window_focused=bool(window_info.get("focused", True)),
|
|
58
|
+
ime_visible=bool(ime_info.get("visible", False)),
|
|
59
|
+
ime_package=self._string_or_none(ime_info.get("package")),
|
|
60
|
+
system_dialogs=self._detect_system_dialogs(activity_info, window_info),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def _get_focused_activity(self, device: AdbDevice) -> dict[str, str]:
|
|
64
|
+
"""Get focused activity info via dumpsys."""
|
|
65
|
+
|
|
66
|
+
def _run() -> str:
|
|
67
|
+
return cast(
|
|
68
|
+
str,
|
|
69
|
+
device.shell(
|
|
70
|
+
"dumpsys activity activities | grep -E 'mResumedActivity|mFocusedActivity'"
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
output = await asyncio.to_thread(_run)
|
|
75
|
+
result: dict[str, str] = {}
|
|
76
|
+
|
|
77
|
+
# Parse mResumedActivity: ActivityRecord{... com.foo/.MainActivity ...}
|
|
78
|
+
resumed_match = re.search(r"mResumedActivity.*?(\S+/\S+)", output)
|
|
79
|
+
if resumed_match:
|
|
80
|
+
full = resumed_match.group(1)
|
|
81
|
+
result["top_resumed"] = full
|
|
82
|
+
if "/" in full:
|
|
83
|
+
pkg, activity = full.split("/", 1)
|
|
84
|
+
result["package"] = pkg
|
|
85
|
+
result["activity"] = activity
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
async def _get_window_info(self, device: AdbDevice) -> dict[str, str | bool]:
|
|
90
|
+
"""Get window focus and orientation info."""
|
|
91
|
+
|
|
92
|
+
def _run() -> str:
|
|
93
|
+
focus = device.shell("dumpsys window windows | grep -E 'mCurrentFocus|mFocusedApp'")
|
|
94
|
+
orientation = device.shell("dumpsys input | grep -E 'SurfaceOrientation'")
|
|
95
|
+
return f"{focus}\n{orientation}"
|
|
96
|
+
|
|
97
|
+
output = await asyncio.to_thread(_run)
|
|
98
|
+
result: dict[str, str | bool] = {"focused": True}
|
|
99
|
+
|
|
100
|
+
focus_match = re.search(r"mCurrentFocus=Window\{([^}]+)\}", output)
|
|
101
|
+
if focus_match:
|
|
102
|
+
result["top_window"] = focus_match.group(1)
|
|
103
|
+
|
|
104
|
+
orientation = "PORTRAIT"
|
|
105
|
+
orientation_match = re.search(r"SurfaceOrientation:\s*(\d)", output)
|
|
106
|
+
if orientation_match:
|
|
107
|
+
value = orientation_match.group(1)
|
|
108
|
+
if value in {"1", "3"}:
|
|
109
|
+
orientation = "LANDSCAPE"
|
|
110
|
+
result["orientation"] = orientation
|
|
111
|
+
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
async def _get_ime_info(self, device: AdbDevice) -> dict[str, str | bool]:
|
|
115
|
+
"""Get IME (keyboard) visibility info."""
|
|
116
|
+
|
|
117
|
+
def _run() -> str:
|
|
118
|
+
return cast(str, device.shell("dumpsys input_method | grep -E 'mInputShown|mCurId'"))
|
|
119
|
+
|
|
120
|
+
output = await asyncio.to_thread(_run)
|
|
121
|
+
result: dict[str, str | bool] = {"visible": False}
|
|
122
|
+
|
|
123
|
+
if "mInputShown=true" in output:
|
|
124
|
+
result["visible"] = True
|
|
125
|
+
|
|
126
|
+
id_match = re.search(r"mCurId=(\S+)", output)
|
|
127
|
+
if id_match:
|
|
128
|
+
result["package"] = id_match.group(1)
|
|
129
|
+
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def _detect_system_dialogs(
|
|
133
|
+
self,
|
|
134
|
+
activity_info: dict[str, str], # noqa: ARG002
|
|
135
|
+
window_info: dict[str, str | bool],
|
|
136
|
+
) -> list[str]:
|
|
137
|
+
"""Detect common system dialogs."""
|
|
138
|
+
dialogs: list[str] = []
|
|
139
|
+
top_window = str(window_info.get("top_window", ""))
|
|
140
|
+
|
|
141
|
+
# Common system dialog patterns
|
|
142
|
+
if "GrantPermissions" in top_window:
|
|
143
|
+
dialogs.append("runtime_permission")
|
|
144
|
+
if "Chooser" in top_window:
|
|
145
|
+
dialogs.append("chooser")
|
|
146
|
+
if "ResolverActivity" in top_window:
|
|
147
|
+
dialogs.append("app_chooser")
|
|
148
|
+
|
|
149
|
+
return dialogs
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _unwrap_result(result: T | BaseException, *, default: T) -> T:
|
|
153
|
+
if isinstance(result, BaseException):
|
|
154
|
+
return default
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _string_or_none(value: object | None) -> str | None:
|
|
159
|
+
if value is None:
|
|
160
|
+
return None
|
|
161
|
+
return str(value)
|
|
162
|
+
|
|
163
|
+
@staticmethod
|
|
164
|
+
def _string_value(value: object | None, fallback: str) -> str:
|
|
165
|
+
if value is None:
|
|
166
|
+
return fallback
|
|
167
|
+
if isinstance(value, bool):
|
|
168
|
+
return fallback
|
|
169
|
+
return str(value)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Ref resolver - Locator bundles, drift detection, stale ref handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class LocatorBundle:
|
|
16
|
+
"""Bundle of locator strategies for an element."""
|
|
17
|
+
|
|
18
|
+
ref: str
|
|
19
|
+
generation: int
|
|
20
|
+
resource_id: str | None
|
|
21
|
+
content_desc: str | None
|
|
22
|
+
text: str | None
|
|
23
|
+
class_name: str
|
|
24
|
+
bounds: list[int]
|
|
25
|
+
ancestry_hash: str
|
|
26
|
+
index: int # Position among siblings
|
|
27
|
+
ancestry_path: str = "" # Path from root like "FrameLayout/LinearLayout/Button"
|
|
28
|
+
element_hash: str = "" # Stable hash for identification
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
"""Convert to dict for storage."""
|
|
32
|
+
return {
|
|
33
|
+
"ref": self.ref,
|
|
34
|
+
"generation": self.generation,
|
|
35
|
+
"resource_id": self.resource_id,
|
|
36
|
+
"content_desc": self.content_desc,
|
|
37
|
+
"text": self.text,
|
|
38
|
+
"class_name": self.class_name,
|
|
39
|
+
"bounds": self.bounds,
|
|
40
|
+
"ancestry_hash": self.ancestry_hash,
|
|
41
|
+
"index": self.index,
|
|
42
|
+
"ancestry_path": self.ancestry_path,
|
|
43
|
+
"element_hash": self.element_hash,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RefResolver:
|
|
48
|
+
"""Manages element refs and locator resolution."""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
# session_id -> (generation -> (ref -> LocatorBundle))
|
|
52
|
+
self._ref_maps: dict[str, dict[int, dict[str, LocatorBundle]]] = {}
|
|
53
|
+
|
|
54
|
+
def store_refs(
|
|
55
|
+
self,
|
|
56
|
+
session_id: str,
|
|
57
|
+
generation: int,
|
|
58
|
+
elements: list[dict[str, Any]],
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Store ref -> locator mappings for a snapshot generation."""
|
|
61
|
+
if session_id not in self._ref_maps:
|
|
62
|
+
self._ref_maps[session_id] = {}
|
|
63
|
+
|
|
64
|
+
ref_map: dict[str, LocatorBundle] = {}
|
|
65
|
+
for i, elem in enumerate(elements):
|
|
66
|
+
bundle = LocatorBundle(
|
|
67
|
+
ref=elem["ref"],
|
|
68
|
+
generation=generation,
|
|
69
|
+
resource_id=elem.get("resource_id"),
|
|
70
|
+
content_desc=elem.get("content_desc"),
|
|
71
|
+
text=elem.get("text"),
|
|
72
|
+
class_name=elem.get("class", ""),
|
|
73
|
+
bounds=elem.get("bounds", [0, 0, 0, 0]),
|
|
74
|
+
ancestry_hash=self._compute_ancestry_hash(elem),
|
|
75
|
+
index=i,
|
|
76
|
+
)
|
|
77
|
+
ref_map[elem["ref"]] = bundle
|
|
78
|
+
|
|
79
|
+
self._ref_maps[session_id][generation] = ref_map
|
|
80
|
+
logger.info(
|
|
81
|
+
"refs_stored",
|
|
82
|
+
session_id=session_id,
|
|
83
|
+
generation=generation,
|
|
84
|
+
count=len(ref_map),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Cleanup old generations (keep last 3)
|
|
88
|
+
self._cleanup_old_generations(session_id, generation)
|
|
89
|
+
|
|
90
|
+
def resolve_ref(
|
|
91
|
+
self,
|
|
92
|
+
session_id: str,
|
|
93
|
+
ref: str,
|
|
94
|
+
current_generation: int,
|
|
95
|
+
) -> tuple[LocatorBundle | None, bool]:
|
|
96
|
+
"""
|
|
97
|
+
Resolve a ref to its locator bundle.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
(bundle, is_stale): Bundle if found, and whether it's from an old generation.
|
|
101
|
+
"""
|
|
102
|
+
if session_id not in self._ref_maps:
|
|
103
|
+
return None, False
|
|
104
|
+
|
|
105
|
+
session_refs = self._ref_maps[session_id]
|
|
106
|
+
|
|
107
|
+
# Check current generation first
|
|
108
|
+
if current_generation in session_refs and ref in session_refs[current_generation]:
|
|
109
|
+
return session_refs[current_generation][ref], False
|
|
110
|
+
|
|
111
|
+
# Check previous generations (stale but might work)
|
|
112
|
+
for gen in sorted(session_refs.keys(), reverse=True):
|
|
113
|
+
if gen < current_generation and ref in session_refs[gen]:
|
|
114
|
+
logger.warning(
|
|
115
|
+
"stale_ref_resolved",
|
|
116
|
+
ref=ref,
|
|
117
|
+
ref_generation=gen,
|
|
118
|
+
current_generation=current_generation,
|
|
119
|
+
)
|
|
120
|
+
return session_refs[gen][ref], True
|
|
121
|
+
|
|
122
|
+
return None, False
|
|
123
|
+
|
|
124
|
+
def clear_session(self, session_id: str) -> None:
|
|
125
|
+
"""Clear all refs for a session."""
|
|
126
|
+
if session_id in self._ref_maps:
|
|
127
|
+
del self._ref_maps[session_id]
|
|
128
|
+
logger.info("session_refs_cleared", session_id=session_id)
|
|
129
|
+
|
|
130
|
+
def _compute_ancestry_hash(self, elem: dict[str, Any]) -> str:
|
|
131
|
+
"""Compute hash of element's identifying features."""
|
|
132
|
+
key_parts = [
|
|
133
|
+
elem.get("resource_id", ""),
|
|
134
|
+
elem.get("class", ""),
|
|
135
|
+
str(elem.get("bounds", [])),
|
|
136
|
+
]
|
|
137
|
+
key = "|".join(key_parts)
|
|
138
|
+
return hashlib.md5(key.encode()).hexdigest()[:8]
|
|
139
|
+
|
|
140
|
+
def _cleanup_old_generations(self, session_id: str, current: int) -> None:
|
|
141
|
+
"""Remove generations older than current - 2."""
|
|
142
|
+
if session_id not in self._ref_maps:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
session_refs = self._ref_maps[session_id]
|
|
146
|
+
old_gens = [g for g in session_refs if g < current - 2]
|
|
147
|
+
for gen in old_gens:
|
|
148
|
+
del session_refs[gen]
|
|
149
|
+
logger.debug("generation_cleaned", session_id=session_id, generation=gen)
|