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,522 @@
|
|
|
1
|
+
"""Device manager - ADB connections, root checks, determinism controls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from android_emu_agent.errors import console_connect_error, snapshot_failed_error
|
|
14
|
+
from android_emu_agent.validation import get_console_port
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Orientation(Enum):
|
|
18
|
+
"""Screen orientation values."""
|
|
19
|
+
|
|
20
|
+
PORTRAIT = 0
|
|
21
|
+
LANDSCAPE = 1
|
|
22
|
+
REVERSE_PORTRAIT = 2
|
|
23
|
+
REVERSE_LANDSCAPE = 3
|
|
24
|
+
AUTO = -1 # Special: re-enable auto-rotate
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
import uiautomator2 as u2
|
|
29
|
+
from adbutils import AdbDevice
|
|
30
|
+
|
|
31
|
+
logger = structlog.get_logger()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DeviceInfo:
|
|
36
|
+
"""Device information."""
|
|
37
|
+
|
|
38
|
+
serial: str
|
|
39
|
+
model: str
|
|
40
|
+
sdk_version: int
|
|
41
|
+
is_rooted: bool
|
|
42
|
+
is_emulator: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DeviceManager:
|
|
46
|
+
"""Manages ADB device connections and state."""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._devices: dict[str, DeviceInfo] = {}
|
|
50
|
+
self._adb_devices: dict[str, AdbDevice] = {}
|
|
51
|
+
self._u2_devices: dict[str, u2.Device] = {}
|
|
52
|
+
self._lock = asyncio.Lock()
|
|
53
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
54
|
+
|
|
55
|
+
async def start(self) -> None:
|
|
56
|
+
"""Start device manager and begin device discovery."""
|
|
57
|
+
logger.info("device_manager_starting")
|
|
58
|
+
await self._discover_devices()
|
|
59
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
60
|
+
logger.info("device_manager_started", device_count=len(self._devices))
|
|
61
|
+
|
|
62
|
+
async def stop(self) -> None:
|
|
63
|
+
"""Stop device manager and cleanup connections."""
|
|
64
|
+
logger.info("device_manager_stopping")
|
|
65
|
+
if self._heartbeat_task:
|
|
66
|
+
self._heartbeat_task.cancel()
|
|
67
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
68
|
+
await self._heartbeat_task
|
|
69
|
+
self._adb_devices.clear()
|
|
70
|
+
self._u2_devices.clear()
|
|
71
|
+
self._devices.clear()
|
|
72
|
+
logger.info("device_manager_stopped")
|
|
73
|
+
|
|
74
|
+
async def list_devices(self) -> list[dict[str, str]]:
|
|
75
|
+
"""List all connected devices."""
|
|
76
|
+
await self._discover_devices()
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
"serial": info.serial,
|
|
80
|
+
"model": info.model,
|
|
81
|
+
"sdk_version": str(info.sdk_version),
|
|
82
|
+
"is_rooted": str(info.is_rooted),
|
|
83
|
+
"is_emulator": str(info.is_emulator),
|
|
84
|
+
}
|
|
85
|
+
for info in self._devices.values()
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
async def get_device(self, serial: str) -> DeviceInfo | None:
|
|
89
|
+
"""Get device info by serial."""
|
|
90
|
+
return self._devices.get(serial)
|
|
91
|
+
|
|
92
|
+
async def refresh(self) -> None:
|
|
93
|
+
"""Force device discovery refresh."""
|
|
94
|
+
await self._discover_devices()
|
|
95
|
+
|
|
96
|
+
async def get_adb_device(self, serial: str) -> AdbDevice | None:
|
|
97
|
+
"""Get or create an adbutils device connection."""
|
|
98
|
+
await self._discover_devices()
|
|
99
|
+
if serial not in self._devices:
|
|
100
|
+
return None
|
|
101
|
+
if serial in self._adb_devices:
|
|
102
|
+
return self._adb_devices[serial]
|
|
103
|
+
|
|
104
|
+
from adbutils import adb
|
|
105
|
+
|
|
106
|
+
def _connect() -> AdbDevice:
|
|
107
|
+
return adb.device(serial)
|
|
108
|
+
|
|
109
|
+
device = await asyncio.to_thread(_connect)
|
|
110
|
+
self._adb_devices[serial] = device
|
|
111
|
+
return device
|
|
112
|
+
|
|
113
|
+
async def get_u2_device(self, serial: str) -> u2.Device | None:
|
|
114
|
+
"""Get or create a uiautomator2 device connection."""
|
|
115
|
+
await self._discover_devices()
|
|
116
|
+
if serial not in self._devices:
|
|
117
|
+
return None
|
|
118
|
+
if serial in self._u2_devices:
|
|
119
|
+
return self._u2_devices[serial]
|
|
120
|
+
|
|
121
|
+
import uiautomator2 as u2
|
|
122
|
+
|
|
123
|
+
def _connect() -> u2.Device:
|
|
124
|
+
return u2.connect(serial)
|
|
125
|
+
|
|
126
|
+
device = await asyncio.to_thread(_connect)
|
|
127
|
+
self._u2_devices[serial] = device
|
|
128
|
+
return device
|
|
129
|
+
|
|
130
|
+
async def describe_device(self, serial: str) -> dict[str, Any]:
|
|
131
|
+
"""Return device info suitable for snapshot metadata."""
|
|
132
|
+
info = self._devices.get(serial)
|
|
133
|
+
if info is None:
|
|
134
|
+
return {"serial": serial}
|
|
135
|
+
return {
|
|
136
|
+
"serial": info.serial,
|
|
137
|
+
"sdk": info.sdk_version,
|
|
138
|
+
"model": info.model,
|
|
139
|
+
"is_rooted": info.is_rooted,
|
|
140
|
+
"is_emulator": info.is_emulator,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async def set_animations(self, serial: str, enabled: bool) -> None:
|
|
144
|
+
"""Enable or disable system animations."""
|
|
145
|
+
device = await self.get_adb_device(serial)
|
|
146
|
+
if not device:
|
|
147
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
148
|
+
|
|
149
|
+
scale = "1" if enabled else "0"
|
|
150
|
+
|
|
151
|
+
def _apply() -> None:
|
|
152
|
+
device.shell(f"settings put global window_animation_scale {scale}")
|
|
153
|
+
device.shell(f"settings put global transition_animation_scale {scale}")
|
|
154
|
+
device.shell(f"settings put global animator_duration_scale {scale}")
|
|
155
|
+
|
|
156
|
+
await asyncio.to_thread(_apply)
|
|
157
|
+
|
|
158
|
+
async def set_stay_awake(self, serial: str, enabled: bool) -> None:
|
|
159
|
+
"""Keep device awake while plugged in."""
|
|
160
|
+
device = await self.get_adb_device(serial)
|
|
161
|
+
if not device:
|
|
162
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
163
|
+
|
|
164
|
+
value = "3" if enabled else "0"
|
|
165
|
+
|
|
166
|
+
def _apply() -> None:
|
|
167
|
+
device.shell(f"settings put global stay_on_while_plugged_in {value}")
|
|
168
|
+
|
|
169
|
+
await asyncio.to_thread(_apply)
|
|
170
|
+
|
|
171
|
+
async def app_reset(self, serial: str, package: str) -> None:
|
|
172
|
+
"""Clear app data for a package."""
|
|
173
|
+
device = await self.get_adb_device(serial)
|
|
174
|
+
if not device:
|
|
175
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
176
|
+
|
|
177
|
+
def _apply() -> None:
|
|
178
|
+
device.shell(f"pm clear {package}")
|
|
179
|
+
|
|
180
|
+
await asyncio.to_thread(_apply)
|
|
181
|
+
|
|
182
|
+
async def set_rotation(self, serial: str, orientation: Orientation) -> None:
|
|
183
|
+
"""Set screen rotation.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
serial: Device serial
|
|
187
|
+
orientation: Target orientation (or AUTO to enable auto-rotate)
|
|
188
|
+
"""
|
|
189
|
+
device = await self.get_adb_device(serial)
|
|
190
|
+
if not device:
|
|
191
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
192
|
+
|
|
193
|
+
def _apply() -> None:
|
|
194
|
+
if orientation == Orientation.AUTO:
|
|
195
|
+
# Enable auto-rotate
|
|
196
|
+
device.shell("settings put system accelerometer_rotation 1")
|
|
197
|
+
else:
|
|
198
|
+
# Disable auto-rotate and set fixed orientation
|
|
199
|
+
device.shell("settings put system accelerometer_rotation 0")
|
|
200
|
+
device.shell(f"settings put system user_rotation {orientation.value}")
|
|
201
|
+
|
|
202
|
+
await asyncio.to_thread(_apply)
|
|
203
|
+
|
|
204
|
+
async def set_wifi(self, serial: str, enabled: bool) -> None:
|
|
205
|
+
"""Enable or disable WiFi.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
serial: Device serial
|
|
209
|
+
enabled: True to enable, False to disable
|
|
210
|
+
"""
|
|
211
|
+
device = await self.get_adb_device(serial)
|
|
212
|
+
if not device:
|
|
213
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
214
|
+
|
|
215
|
+
state = "enable" if enabled else "disable"
|
|
216
|
+
|
|
217
|
+
def _apply() -> None:
|
|
218
|
+
device.shell(f"svc wifi {state}")
|
|
219
|
+
|
|
220
|
+
await asyncio.to_thread(_apply)
|
|
221
|
+
|
|
222
|
+
async def set_mobile(self, serial: str, enabled: bool) -> None:
|
|
223
|
+
"""Enable or disable mobile data.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
serial: Device serial
|
|
227
|
+
enabled: True to enable, False to disable
|
|
228
|
+
"""
|
|
229
|
+
device = await self.get_adb_device(serial)
|
|
230
|
+
if not device:
|
|
231
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
232
|
+
|
|
233
|
+
state = "enable" if enabled else "disable"
|
|
234
|
+
|
|
235
|
+
def _apply() -> None:
|
|
236
|
+
device.shell(f"svc data {state}")
|
|
237
|
+
|
|
238
|
+
await asyncio.to_thread(_apply)
|
|
239
|
+
|
|
240
|
+
async def set_doze(self, serial: str, enabled: bool) -> None:
|
|
241
|
+
"""Force device into or out of doze mode.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
serial: Device serial
|
|
245
|
+
enabled: True to force doze, False to exit doze
|
|
246
|
+
"""
|
|
247
|
+
device = await self.get_adb_device(serial)
|
|
248
|
+
if not device:
|
|
249
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
250
|
+
|
|
251
|
+
def _apply() -> None:
|
|
252
|
+
if enabled:
|
|
253
|
+
device.shell("dumpsys deviceidle force-idle")
|
|
254
|
+
else:
|
|
255
|
+
device.shell("dumpsys deviceidle unforce")
|
|
256
|
+
|
|
257
|
+
await asyncio.to_thread(_apply)
|
|
258
|
+
|
|
259
|
+
async def app_launch(self, serial: str, package: str, activity: str | None = None) -> str:
|
|
260
|
+
"""Launch an app.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
serial: Device serial
|
|
264
|
+
package: Package name
|
|
265
|
+
activity: Activity name (optional, will resolve launcher if not provided)
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Activity that was launched
|
|
269
|
+
"""
|
|
270
|
+
device = await self.get_adb_device(serial)
|
|
271
|
+
if not device:
|
|
272
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
273
|
+
|
|
274
|
+
def _launch() -> str:
|
|
275
|
+
target_activity = activity
|
|
276
|
+
if not target_activity:
|
|
277
|
+
# Resolve launcher activity
|
|
278
|
+
output = device.shell(
|
|
279
|
+
f"cmd package resolve-activity --brief "
|
|
280
|
+
f"-c android.intent.category.LAUNCHER {package}"
|
|
281
|
+
)
|
|
282
|
+
lines = output.strip().split("\n")
|
|
283
|
+
if len(lines) >= 2:
|
|
284
|
+
# Second line contains package/activity
|
|
285
|
+
resolved = lines[-1].strip()
|
|
286
|
+
if "/" in resolved:
|
|
287
|
+
target_activity = resolved.split("/")[1]
|
|
288
|
+
|
|
289
|
+
if not target_activity:
|
|
290
|
+
raise RuntimeError(f"Could not resolve launcher activity for {package}")
|
|
291
|
+
|
|
292
|
+
# Normalize activity name
|
|
293
|
+
if not target_activity.startswith(".") and "/" not in target_activity:
|
|
294
|
+
target_activity = f".{target_activity}"
|
|
295
|
+
|
|
296
|
+
component = f"{package}/{target_activity}"
|
|
297
|
+
device.shell(f"am start -n {component}")
|
|
298
|
+
return target_activity
|
|
299
|
+
|
|
300
|
+
return await asyncio.to_thread(_launch)
|
|
301
|
+
|
|
302
|
+
async def app_force_stop(self, serial: str, package: str) -> None:
|
|
303
|
+
"""Force stop an app.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
serial: Device serial
|
|
307
|
+
package: Package name
|
|
308
|
+
"""
|
|
309
|
+
device = await self.get_adb_device(serial)
|
|
310
|
+
if not device:
|
|
311
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
312
|
+
|
|
313
|
+
def _stop() -> None:
|
|
314
|
+
device.shell(f"am force-stop {package}")
|
|
315
|
+
|
|
316
|
+
await asyncio.to_thread(_stop)
|
|
317
|
+
|
|
318
|
+
async def app_deeplink(self, serial: str, uri: str) -> None:
|
|
319
|
+
"""Open a deeplink URI.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
serial: Device serial
|
|
323
|
+
uri: URI to open (e.g., 'myapp://path' or 'https://example.com')
|
|
324
|
+
"""
|
|
325
|
+
device = await self.get_adb_device(serial)
|
|
326
|
+
if not device:
|
|
327
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
328
|
+
|
|
329
|
+
def _open() -> None:
|
|
330
|
+
device.shell(f'am start -a android.intent.action.VIEW -d "{uri}"')
|
|
331
|
+
|
|
332
|
+
await asyncio.to_thread(_open)
|
|
333
|
+
|
|
334
|
+
async def list_packages(self, serial: str, scope: str = "all") -> list[str]:
|
|
335
|
+
"""List installed packages.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
serial: Device serial
|
|
339
|
+
scope: all|system|third-party
|
|
340
|
+
"""
|
|
341
|
+
device = await self.get_adb_device(serial)
|
|
342
|
+
if not device:
|
|
343
|
+
raise RuntimeError(f"Device not found: {serial}")
|
|
344
|
+
|
|
345
|
+
scope_key = scope.lower()
|
|
346
|
+
if scope_key not in {"all", "system", "third-party"}:
|
|
347
|
+
raise ValueError(f"Invalid package scope: {scope}")
|
|
348
|
+
|
|
349
|
+
def _list() -> str:
|
|
350
|
+
args = ["pm", "list", "packages"]
|
|
351
|
+
if scope_key == "system":
|
|
352
|
+
args.append("-s")
|
|
353
|
+
elif scope_key == "third-party":
|
|
354
|
+
args.append("-3")
|
|
355
|
+
return str(device.shell(" ".join(args)))
|
|
356
|
+
|
|
357
|
+
output = await asyncio.to_thread(_list)
|
|
358
|
+
packages: list[str] = []
|
|
359
|
+
for line in output.splitlines():
|
|
360
|
+
line = line.strip()
|
|
361
|
+
if not line:
|
|
362
|
+
continue
|
|
363
|
+
if line.startswith("package:"):
|
|
364
|
+
packages.append(line.removeprefix("package:"))
|
|
365
|
+
return packages
|
|
366
|
+
|
|
367
|
+
async def _discover_devices(self) -> None:
|
|
368
|
+
"""Discover connected ADB devices."""
|
|
369
|
+
from adbutils import adb
|
|
370
|
+
|
|
371
|
+
async with self._lock:
|
|
372
|
+
|
|
373
|
+
def _list() -> list[AdbDevice]:
|
|
374
|
+
return list(adb.device_list())
|
|
375
|
+
|
|
376
|
+
devices = await asyncio.to_thread(_list)
|
|
377
|
+
seen: set[str] = set()
|
|
378
|
+
|
|
379
|
+
for dev in devices:
|
|
380
|
+
serial = dev.serial
|
|
381
|
+
if not serial:
|
|
382
|
+
logger.warning("device_missing_serial")
|
|
383
|
+
continue
|
|
384
|
+
seen.add(serial)
|
|
385
|
+
self._adb_devices[serial] = dev
|
|
386
|
+
if serial not in self._devices:
|
|
387
|
+
info = await self._build_device_info(dev)
|
|
388
|
+
self._devices[serial] = info
|
|
389
|
+
logger.info("device_discovered", serial=serial, model=info.model)
|
|
390
|
+
|
|
391
|
+
# Remove disconnected devices
|
|
392
|
+
disconnected = set(self._devices.keys()) - seen
|
|
393
|
+
for serial in disconnected:
|
|
394
|
+
self._devices.pop(serial, None)
|
|
395
|
+
self._adb_devices.pop(serial, None)
|
|
396
|
+
self._u2_devices.pop(serial, None)
|
|
397
|
+
logger.info("device_disconnected", serial=serial)
|
|
398
|
+
|
|
399
|
+
async def _build_device_info(self, device: AdbDevice) -> DeviceInfo:
|
|
400
|
+
"""Build DeviceInfo from adb device."""
|
|
401
|
+
|
|
402
|
+
def _props() -> dict[str, str]:
|
|
403
|
+
props = device.prop
|
|
404
|
+
return {
|
|
405
|
+
"model": props.model or props.get("ro.product.model") or "unknown",
|
|
406
|
+
"sdk": props.get("ro.build.version.sdk") or "0",
|
|
407
|
+
"is_emulator": props.get("ro.kernel.qemu") or "0",
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
props = await asyncio.to_thread(_props)
|
|
411
|
+
|
|
412
|
+
serial = device.serial
|
|
413
|
+
if not serial:
|
|
414
|
+
raise RuntimeError("Device serial missing")
|
|
415
|
+
model = props["model"]
|
|
416
|
+
sdk_version = int(props["sdk"]) if props["sdk"].isdigit() else 0
|
|
417
|
+
is_emulator = serial.startswith("emulator-") or props["is_emulator"] == "1"
|
|
418
|
+
is_rooted = await self._check_root(device)
|
|
419
|
+
|
|
420
|
+
return DeviceInfo(
|
|
421
|
+
serial=serial,
|
|
422
|
+
model=model,
|
|
423
|
+
sdk_version=sdk_version,
|
|
424
|
+
is_rooted=is_rooted,
|
|
425
|
+
is_emulator=is_emulator,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
async def _check_root(self, device: AdbDevice) -> bool:
|
|
429
|
+
"""Best-effort root detection."""
|
|
430
|
+
|
|
431
|
+
def _run() -> str:
|
|
432
|
+
try:
|
|
433
|
+
return str(device.shell("su -c id"))
|
|
434
|
+
except Exception:
|
|
435
|
+
return ""
|
|
436
|
+
|
|
437
|
+
output = await asyncio.to_thread(_run)
|
|
438
|
+
return "uid=0" in output
|
|
439
|
+
|
|
440
|
+
async def _heartbeat_loop(self) -> None:
|
|
441
|
+
"""Periodic heartbeat to check device connectivity."""
|
|
442
|
+
while True:
|
|
443
|
+
await asyncio.sleep(30)
|
|
444
|
+
try:
|
|
445
|
+
await self._discover_devices()
|
|
446
|
+
except Exception:
|
|
447
|
+
logger.exception("heartbeat_error")
|
|
448
|
+
|
|
449
|
+
async def evict_device(self, serial: str) -> None:
|
|
450
|
+
"""Remove cached connections for a device, forcing reconnect on next use.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
serial: Device serial to evict
|
|
454
|
+
"""
|
|
455
|
+
async with self._lock:
|
|
456
|
+
evicted_adb = self._adb_devices.pop(serial, None) is not None
|
|
457
|
+
evicted_u2 = self._u2_devices.pop(serial, None) is not None
|
|
458
|
+
|
|
459
|
+
if evicted_adb or evicted_u2:
|
|
460
|
+
logger.info("device_evicted", serial=serial, adb=evicted_adb, u2=evicted_u2)
|
|
461
|
+
|
|
462
|
+
async def emulator_snapshot_save(self, serial: str, name: str) -> None:
|
|
463
|
+
"""Save emulator snapshot.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
serial: Emulator serial (e.g., 'emulator-5554')
|
|
467
|
+
name: Snapshot name
|
|
468
|
+
"""
|
|
469
|
+
port = get_console_port(serial)
|
|
470
|
+
await self._send_console_command(port, f"avd snapshot save {name}")
|
|
471
|
+
logger.info("snapshot_saved", serial=serial, name=name)
|
|
472
|
+
|
|
473
|
+
async def emulator_snapshot_restore(self, serial: str, name: str) -> None:
|
|
474
|
+
"""Restore emulator snapshot.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
serial: Emulator serial (e.g., 'emulator-5554')
|
|
478
|
+
name: Snapshot name
|
|
479
|
+
"""
|
|
480
|
+
port = get_console_port(serial)
|
|
481
|
+
await self._send_console_command(port, f"avd snapshot load {name}")
|
|
482
|
+
logger.info("snapshot_restored", serial=serial, name=name)
|
|
483
|
+
|
|
484
|
+
async def _send_console_command(self, port: int, command: str) -> str:
|
|
485
|
+
"""Send command to emulator console.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
port: Console port number
|
|
489
|
+
command: Command to send
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
Console response
|
|
493
|
+
|
|
494
|
+
Raises:
|
|
495
|
+
AgentError: If connection fails or command returns error
|
|
496
|
+
"""
|
|
497
|
+
try:
|
|
498
|
+
reader, writer = await asyncio.open_connection("localhost", port)
|
|
499
|
+
except OSError as e:
|
|
500
|
+
raise console_connect_error(port) from e
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
# Read initial banner until OK
|
|
504
|
+
await asyncio.wait_for(reader.read(1024), timeout=5.0)
|
|
505
|
+
|
|
506
|
+
# Send command
|
|
507
|
+
writer.write(f"{command}\n".encode())
|
|
508
|
+
await writer.drain()
|
|
509
|
+
|
|
510
|
+
# Read response
|
|
511
|
+
response = await asyncio.wait_for(reader.read(1024), timeout=30.0)
|
|
512
|
+
response_str = response.decode()
|
|
513
|
+
|
|
514
|
+
if "OK" not in response_str:
|
|
515
|
+
# Extract error message
|
|
516
|
+
error_msg = response_str.strip() or "Unknown error"
|
|
517
|
+
raise snapshot_failed_error(command.split()[-1], error_msg)
|
|
518
|
+
|
|
519
|
+
return response_str
|
|
520
|
+
finally:
|
|
521
|
+
writer.close()
|
|
522
|
+
await writer.wait_closed()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Session manager - session lifecycle and state persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import TYPE_CHECKING, Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
|
|
12
|
+
from android_emu_agent.db.models import Database
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Session:
|
|
22
|
+
"""Represents an active session with a device."""
|
|
23
|
+
|
|
24
|
+
session_id: str
|
|
25
|
+
device_serial: str
|
|
26
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
27
|
+
generation: int = 0 # Snapshot generation counter
|
|
28
|
+
last_snapshot: dict[str, Any] | None = None
|
|
29
|
+
last_snapshot_json: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionManager:
|
|
33
|
+
"""Manages session lifecycle and persistence."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, database: Database) -> None:
|
|
36
|
+
self._sessions: dict[str, Session] = {}
|
|
37
|
+
self._db = database
|
|
38
|
+
|
|
39
|
+
async def start(self) -> None:
|
|
40
|
+
"""Start session manager and restore persisted sessions."""
|
|
41
|
+
logger.info("session_manager_starting")
|
|
42
|
+
sessions = await self._db.list_sessions()
|
|
43
|
+
for row in sessions:
|
|
44
|
+
created_at = datetime.fromisoformat(row["created_at"])
|
|
45
|
+
session = Session(
|
|
46
|
+
session_id=row["session_id"],
|
|
47
|
+
device_serial=row["device_serial"],
|
|
48
|
+
created_at=created_at,
|
|
49
|
+
generation=row.get("generation", 0),
|
|
50
|
+
)
|
|
51
|
+
self._sessions[session.session_id] = session
|
|
52
|
+
logger.info("session_manager_started")
|
|
53
|
+
|
|
54
|
+
async def stop(self) -> None:
|
|
55
|
+
"""Stop session manager and cleanup."""
|
|
56
|
+
logger.info("session_manager_stopping")
|
|
57
|
+
for session in self._sessions.values():
|
|
58
|
+
await self._db.save_session(
|
|
59
|
+
session.session_id,
|
|
60
|
+
session.device_serial,
|
|
61
|
+
generation=session.generation,
|
|
62
|
+
)
|
|
63
|
+
self._sessions.clear()
|
|
64
|
+
logger.info("session_manager_stopped")
|
|
65
|
+
|
|
66
|
+
async def create_session(self, device_serial: str) -> Session:
|
|
67
|
+
"""Create a new session for a device."""
|
|
68
|
+
session_id = f"s-{uuid.uuid4().hex[:8]}"
|
|
69
|
+
session = Session(session_id=session_id, device_serial=device_serial)
|
|
70
|
+
self._sessions[session_id] = session
|
|
71
|
+
await self._db.save_session(session_id, device_serial, generation=session.generation)
|
|
72
|
+
logger.info("session_created", session_id=session_id, device=device_serial)
|
|
73
|
+
return session
|
|
74
|
+
|
|
75
|
+
async def get_session(self, session_id: str) -> Session | None:
|
|
76
|
+
"""Get session by ID."""
|
|
77
|
+
return self._sessions.get(session_id)
|
|
78
|
+
|
|
79
|
+
async def list_sessions(self) -> list[Session]:
|
|
80
|
+
"""List active sessions."""
|
|
81
|
+
return list(self._sessions.values())
|
|
82
|
+
|
|
83
|
+
async def close_session(self, session_id: str) -> bool:
|
|
84
|
+
"""Close and remove a session."""
|
|
85
|
+
if session_id in self._sessions:
|
|
86
|
+
del self._sessions[session_id]
|
|
87
|
+
await self._db.delete_session(session_id)
|
|
88
|
+
logger.info("session_closed", session_id=session_id)
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
async def increment_generation(self, session_id: str) -> int:
|
|
93
|
+
"""Increment and return the new snapshot generation."""
|
|
94
|
+
session = self._sessions.get(session_id)
|
|
95
|
+
if session:
|
|
96
|
+
session.generation += 1
|
|
97
|
+
await self._db.save_session(
|
|
98
|
+
session.session_id,
|
|
99
|
+
session.device_serial,
|
|
100
|
+
generation=session.generation,
|
|
101
|
+
)
|
|
102
|
+
return session.generation
|
|
103
|
+
raise ValueError(f"Session not found: {session_id}")
|
|
104
|
+
|
|
105
|
+
async def update_snapshot(
|
|
106
|
+
self,
|
|
107
|
+
session_id: str,
|
|
108
|
+
snapshot: dict[str, Any],
|
|
109
|
+
snapshot_json: str,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Store last snapshot in memory for artifacts/debugging."""
|
|
112
|
+
session = self._sessions.get(session_id)
|
|
113
|
+
if session:
|
|
114
|
+
session.last_snapshot = snapshot
|
|
115
|
+
session.last_snapshot_json = snapshot_json
|
|
116
|
+
|
|
117
|
+
async def get_last_snapshot(self, session_id: str) -> dict[str, Any] | None:
|
|
118
|
+
"""Get the last snapshot for a session."""
|
|
119
|
+
session = self._sessions.get(session_id)
|
|
120
|
+
if session:
|
|
121
|
+
return session.last_snapshot
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
async def get_last_snapshot_json(self, session_id: str) -> str | None:
|
|
125
|
+
"""Get the last snapshot JSON for a session."""
|
|
126
|
+
session = self._sessions.get(session_id)
|
|
127
|
+
if session:
|
|
128
|
+
return session.last_snapshot_json
|
|
129
|
+
return None
|