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,1644 @@
|
|
|
1
|
+
"""FastAPI server running over Unix Domain Socket."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import AsyncGenerator
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from hashlib import md5
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
from fastapi.responses import JSONResponse, Response
|
|
16
|
+
|
|
17
|
+
from android_emu_agent.actions.executor import ActionType, SwipeDirection
|
|
18
|
+
from android_emu_agent.actions.selector import (
|
|
19
|
+
CoordsSelector,
|
|
20
|
+
RefSelector,
|
|
21
|
+
parse_selector,
|
|
22
|
+
)
|
|
23
|
+
from android_emu_agent.daemon.core import DaemonCore
|
|
24
|
+
from android_emu_agent.daemon.models import (
|
|
25
|
+
ActionRequest,
|
|
26
|
+
AppDeeplinkRequest,
|
|
27
|
+
AppForceStopRequest,
|
|
28
|
+
AppLaunchRequest,
|
|
29
|
+
AppListRequest,
|
|
30
|
+
AppResetRequest,
|
|
31
|
+
ArtifactLogsRequest,
|
|
32
|
+
DeviceSettingRequest,
|
|
33
|
+
DeviceTargetRequest,
|
|
34
|
+
DozeRequest,
|
|
35
|
+
EmulatorSnapshotRequest,
|
|
36
|
+
FileAppPullRequest,
|
|
37
|
+
FileAppPushRequest,
|
|
38
|
+
FileFindRequest,
|
|
39
|
+
FileListRequest,
|
|
40
|
+
FilePullRequest,
|
|
41
|
+
FilePushRequest,
|
|
42
|
+
MobileRequest,
|
|
43
|
+
ReliabilityBackgroundRequest,
|
|
44
|
+
ReliabilityBugreportRequest,
|
|
45
|
+
ReliabilityCompileRequest,
|
|
46
|
+
ReliabilityDropboxListRequest,
|
|
47
|
+
ReliabilityDropboxPrintRequest,
|
|
48
|
+
ReliabilityDumpheapRequest,
|
|
49
|
+
ReliabilityEventsRequest,
|
|
50
|
+
ReliabilityExitInfoRequest,
|
|
51
|
+
ReliabilityOomAdjRequest,
|
|
52
|
+
ReliabilityPackageRequest,
|
|
53
|
+
ReliabilityRunAsRequest,
|
|
54
|
+
ReliabilitySigquitRequest,
|
|
55
|
+
ReliabilityToggleRequest,
|
|
56
|
+
ReliabilityTrimMemoryRequest,
|
|
57
|
+
RotationRequest,
|
|
58
|
+
SessionRequest,
|
|
59
|
+
SessionStartRequest,
|
|
60
|
+
SessionStopRequest,
|
|
61
|
+
SetTextRequest,
|
|
62
|
+
SnapshotRequest,
|
|
63
|
+
SwipeRequest,
|
|
64
|
+
WaitActivityRequest,
|
|
65
|
+
WaitIdleRequest,
|
|
66
|
+
WaitSelectorRequest,
|
|
67
|
+
WaitTextRequest,
|
|
68
|
+
WifiRequest,
|
|
69
|
+
)
|
|
70
|
+
from android_emu_agent.device.manager import Orientation
|
|
71
|
+
from android_emu_agent.errors import (
|
|
72
|
+
AgentError,
|
|
73
|
+
device_offline_error,
|
|
74
|
+
not_found_error,
|
|
75
|
+
session_expired_error,
|
|
76
|
+
stale_ref_error,
|
|
77
|
+
)
|
|
78
|
+
from android_emu_agent.files.manager import FileMatch
|
|
79
|
+
from android_emu_agent.reliability.manager import DEFAULT_EVENTS_PATTERN, TRIM_LEVELS, require_root
|
|
80
|
+
from android_emu_agent.ui.ref_resolver import LocatorBundle
|
|
81
|
+
from android_emu_agent.validation import validate_package, validate_uri
|
|
82
|
+
|
|
83
|
+
logger = structlog.get_logger()
|
|
84
|
+
|
|
85
|
+
ResponsePayload = dict[str, Any]
|
|
86
|
+
EndpointResponse = Response | ResponsePayload
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@asynccontextmanager
|
|
90
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
91
|
+
"""Manage daemon lifecycle."""
|
|
92
|
+
logger.info("daemon_starting")
|
|
93
|
+
app.state.core = DaemonCore()
|
|
94
|
+
await app.state.core.start()
|
|
95
|
+
yield
|
|
96
|
+
logger.info("daemon_stopping")
|
|
97
|
+
await app.state.core.stop()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
app = FastAPI(
|
|
101
|
+
title="Android Emu Agent Daemon",
|
|
102
|
+
version="0.1.0",
|
|
103
|
+
lifespan=lifespan,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _error_response(error: AgentError, status_code: int = 400) -> JSONResponse:
|
|
108
|
+
return JSONResponse(
|
|
109
|
+
status_code=status_code,
|
|
110
|
+
content={"status": "error", "error": error.to_dict()},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _bundle_from_dict(ref_dict: dict[str, Any], generation: int) -> LocatorBundle:
|
|
115
|
+
key_parts = [
|
|
116
|
+
ref_dict.get("resource_id", ""),
|
|
117
|
+
ref_dict.get("class", ""),
|
|
118
|
+
str(ref_dict.get("bounds", [])),
|
|
119
|
+
]
|
|
120
|
+
ancestry_hash = md5("|".join(key_parts).encode()).hexdigest()[:8]
|
|
121
|
+
return LocatorBundle(
|
|
122
|
+
ref=ref_dict["ref"],
|
|
123
|
+
generation=generation,
|
|
124
|
+
resource_id=ref_dict.get("resource_id"),
|
|
125
|
+
content_desc=ref_dict.get("content_desc"),
|
|
126
|
+
text=ref_dict.get("text"),
|
|
127
|
+
class_name=ref_dict.get("class", ""),
|
|
128
|
+
bounds=ref_dict.get("bounds", [0, 0, 0, 0]),
|
|
129
|
+
ancestry_hash=ancestry_hash,
|
|
130
|
+
index=ref_dict.get("index", 0),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _format_file_matches(matches: list[FileMatch]) -> str:
|
|
135
|
+
if not matches:
|
|
136
|
+
return "No matches."
|
|
137
|
+
header = "PATH\tTYPE\tSIZE\tMODE\tUID:GID\tMTIME_EPOCH"
|
|
138
|
+
lines = [header]
|
|
139
|
+
for match in matches:
|
|
140
|
+
lines.append(
|
|
141
|
+
f"{match['path']}\t{match['kind']}\t{match['size_bytes']}\t"
|
|
142
|
+
f"{match['mode']}\t{match['uid']}:{match['gid']}\t{match['mtime_epoch']}"
|
|
143
|
+
)
|
|
144
|
+
return "\n".join(lines)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _selector_from_locator(locator: LocatorBundle) -> dict[str, str] | None:
|
|
148
|
+
if locator.resource_id:
|
|
149
|
+
return {"resourceId": locator.resource_id}
|
|
150
|
+
if locator.content_desc:
|
|
151
|
+
return {"description": locator.content_desc}
|
|
152
|
+
if locator.text:
|
|
153
|
+
return {"text": locator.text}
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _resolve_device_target(
|
|
158
|
+
core: DaemonCore, session_id: str | None, serial: str | None
|
|
159
|
+
) -> tuple[str, Any, Any] | JSONResponse:
|
|
160
|
+
if session_id:
|
|
161
|
+
session = await core.session_manager.get_session(session_id)
|
|
162
|
+
if not session:
|
|
163
|
+
return _error_response(session_expired_error(session_id), status_code=404)
|
|
164
|
+
serial = session.device_serial
|
|
165
|
+
|
|
166
|
+
if not serial:
|
|
167
|
+
return _error_response(
|
|
168
|
+
AgentError(
|
|
169
|
+
code="ERR_TARGET_REQUIRED",
|
|
170
|
+
message="session_id or serial is required",
|
|
171
|
+
context={},
|
|
172
|
+
remediation="Provide --session or --device",
|
|
173
|
+
),
|
|
174
|
+
status_code=400,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
device = await core.device_manager.get_adb_device(serial)
|
|
178
|
+
if not device:
|
|
179
|
+
return _error_response(device_offline_error(serial), status_code=404)
|
|
180
|
+
|
|
181
|
+
info = await core.device_manager.get_device(serial)
|
|
182
|
+
return serial, device, info
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def _resolve_locator(
|
|
186
|
+
core: DaemonCore,
|
|
187
|
+
session_id: str,
|
|
188
|
+
ref: str,
|
|
189
|
+
current_generation: int,
|
|
190
|
+
) -> tuple[LocatorBundle | None, bool]:
|
|
191
|
+
bundle, is_stale = core.ref_resolver.resolve_ref(session_id, ref, current_generation)
|
|
192
|
+
if bundle:
|
|
193
|
+
return bundle, is_stale
|
|
194
|
+
|
|
195
|
+
stored = await core.database.get_ref_any_generation(session_id, ref)
|
|
196
|
+
if stored:
|
|
197
|
+
generation, ref_dict = stored
|
|
198
|
+
return _bundle_from_dict(ref_dict, generation), generation < current_generation
|
|
199
|
+
|
|
200
|
+
return None, False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.get("/health")
|
|
204
|
+
async def health() -> dict[str, Any]:
|
|
205
|
+
"""Health check endpoint with device status."""
|
|
206
|
+
core: DaemonCore = app.state.core
|
|
207
|
+
status = core.health_monitor.get_status()
|
|
208
|
+
|
|
209
|
+
# Determine overall status
|
|
210
|
+
devices = status.get("devices", {})
|
|
211
|
+
all_healthy = all(d.get("adb_ok", False) and d.get("u2_ok", False) for d in devices.values())
|
|
212
|
+
|
|
213
|
+
sessions = await core.session_manager.list_sessions()
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
"status": "ok" if (all_healthy or not devices) else "degraded",
|
|
217
|
+
"running": core.is_running,
|
|
218
|
+
"active_sessions": len(sessions),
|
|
219
|
+
"devices": devices,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.get("/devices")
|
|
224
|
+
async def list_devices() -> dict[str, list[dict[str, str]]]:
|
|
225
|
+
"""List connected devices."""
|
|
226
|
+
core: DaemonCore = app.state.core
|
|
227
|
+
devices = await core.device_manager.list_devices()
|
|
228
|
+
return {"devices": devices}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.post("/devices/animations", response_model=None)
|
|
232
|
+
async def set_animations(req: DeviceSettingRequest) -> EndpointResponse:
|
|
233
|
+
"""Enable or disable animations on a device."""
|
|
234
|
+
core: DaemonCore = app.state.core
|
|
235
|
+
enabled = req.state.lower() == "on"
|
|
236
|
+
try:
|
|
237
|
+
await core.device_manager.set_animations(req.serial, enabled)
|
|
238
|
+
except Exception:
|
|
239
|
+
return _error_response(device_offline_error(req.serial), status_code=404)
|
|
240
|
+
return {"status": "done", "serial": req.serial, "animations": "on" if enabled else "off"}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.post("/devices/stay_awake", response_model=None)
|
|
244
|
+
async def set_stay_awake(req: DeviceSettingRequest) -> EndpointResponse:
|
|
245
|
+
"""Enable or disable stay-awake on a device."""
|
|
246
|
+
core: DaemonCore = app.state.core
|
|
247
|
+
enabled = req.state.lower() == "on"
|
|
248
|
+
try:
|
|
249
|
+
await core.device_manager.set_stay_awake(req.serial, enabled)
|
|
250
|
+
except Exception:
|
|
251
|
+
return _error_response(device_offline_error(req.serial), status_code=404)
|
|
252
|
+
return {"status": "done", "serial": req.serial, "stay_awake": "on" if enabled else "off"}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.post("/devices/rotation", response_model=None)
|
|
256
|
+
async def set_rotation(req: RotationRequest) -> EndpointResponse:
|
|
257
|
+
"""Set device rotation."""
|
|
258
|
+
core: DaemonCore = app.state.core
|
|
259
|
+
|
|
260
|
+
orientation_map = {
|
|
261
|
+
"portrait": Orientation.PORTRAIT,
|
|
262
|
+
"landscape": Orientation.LANDSCAPE,
|
|
263
|
+
"reverse-portrait": Orientation.REVERSE_PORTRAIT,
|
|
264
|
+
"reverse-landscape": Orientation.REVERSE_LANDSCAPE,
|
|
265
|
+
"auto": Orientation.AUTO,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
orientation = orientation_map.get(req.orientation.lower())
|
|
269
|
+
if not orientation:
|
|
270
|
+
return _error_response(
|
|
271
|
+
AgentError(
|
|
272
|
+
code="ERR_INVALID_ORIENTATION",
|
|
273
|
+
message=f"Invalid orientation: {req.orientation}",
|
|
274
|
+
context={"orientation": req.orientation},
|
|
275
|
+
remediation="Use: portrait, landscape, reverse-portrait, reverse-landscape, or auto",
|
|
276
|
+
),
|
|
277
|
+
status_code=400,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
await core.device_manager.set_rotation(req.serial, orientation)
|
|
282
|
+
except Exception:
|
|
283
|
+
return _error_response(device_offline_error(req.serial), status_code=404)
|
|
284
|
+
|
|
285
|
+
return {"status": "done", "serial": req.serial, "orientation": req.orientation}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.post("/devices/wifi", response_model=None)
|
|
289
|
+
async def set_wifi(req: WifiRequest) -> EndpointResponse:
|
|
290
|
+
"""Enable or disable WiFi."""
|
|
291
|
+
core: DaemonCore = app.state.core
|
|
292
|
+
try:
|
|
293
|
+
await core.device_manager.set_wifi(req.serial, req.enabled)
|
|
294
|
+
except Exception:
|
|
295
|
+
return _error_response(device_offline_error(req.serial), status_code=404)
|
|
296
|
+
return {"status": "done", "serial": req.serial, "wifi": "on" if req.enabled else "off"}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@app.post("/devices/mobile", response_model=None)
|
|
300
|
+
async def set_mobile(req: MobileRequest) -> EndpointResponse:
|
|
301
|
+
"""Enable or disable mobile data."""
|
|
302
|
+
core: DaemonCore = app.state.core
|
|
303
|
+
try:
|
|
304
|
+
await core.device_manager.set_mobile(req.serial, req.enabled)
|
|
305
|
+
except Exception:
|
|
306
|
+
return _error_response(device_offline_error(req.serial), status_code=404)
|
|
307
|
+
return {"status": "done", "serial": req.serial, "mobile": "on" if req.enabled else "off"}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.post("/devices/doze", response_model=None)
|
|
311
|
+
async def set_doze(req: DozeRequest) -> EndpointResponse:
|
|
312
|
+
"""Force device into or out of doze mode."""
|
|
313
|
+
core: DaemonCore = app.state.core
|
|
314
|
+
try:
|
|
315
|
+
await core.device_manager.set_doze(req.serial, req.enabled)
|
|
316
|
+
except Exception:
|
|
317
|
+
return _error_response(device_offline_error(req.serial), status_code=404)
|
|
318
|
+
return {"status": "done", "serial": req.serial, "doze": "on" if req.enabled else "off"}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.post("/sessions/start", response_model=None)
|
|
322
|
+
async def session_start(req: SessionStartRequest) -> EndpointResponse:
|
|
323
|
+
"""Start a new session on a device."""
|
|
324
|
+
core: DaemonCore = app.state.core
|
|
325
|
+
await core.device_manager.refresh()
|
|
326
|
+
device_info = await core.device_manager.get_device(req.device_serial)
|
|
327
|
+
if not device_info:
|
|
328
|
+
return _error_response(device_offline_error(req.device_serial), status_code=404)
|
|
329
|
+
|
|
330
|
+
session = await core.session_manager.create_session(req.device_serial)
|
|
331
|
+
return {
|
|
332
|
+
"status": "done",
|
|
333
|
+
"session_id": session.session_id,
|
|
334
|
+
"device_serial": session.device_serial,
|
|
335
|
+
"generation": session.generation,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@app.post("/sessions/stop", response_model=None)
|
|
340
|
+
async def session_stop(req: SessionStopRequest) -> EndpointResponse:
|
|
341
|
+
"""Stop a session."""
|
|
342
|
+
core: DaemonCore = app.state.core
|
|
343
|
+
closed = await core.session_manager.close_session(req.session_id)
|
|
344
|
+
if not closed:
|
|
345
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
346
|
+
core.ref_resolver.clear_session(req.session_id)
|
|
347
|
+
return {"status": "done"}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@app.get("/sessions")
|
|
351
|
+
async def session_list() -> dict[str, Any]:
|
|
352
|
+
"""List active sessions."""
|
|
353
|
+
core: DaemonCore = app.state.core
|
|
354
|
+
sessions = await core.session_manager.list_sessions()
|
|
355
|
+
return {
|
|
356
|
+
"sessions": [
|
|
357
|
+
{
|
|
358
|
+
"session_id": s.session_id,
|
|
359
|
+
"device_serial": s.device_serial,
|
|
360
|
+
"generation": s.generation,
|
|
361
|
+
"created_at": s.created_at.isoformat(),
|
|
362
|
+
}
|
|
363
|
+
for s in sessions
|
|
364
|
+
]
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@app.get("/sessions/{session_id}", response_model=None)
|
|
369
|
+
async def session_info(session_id: str) -> EndpointResponse:
|
|
370
|
+
"""Get session info."""
|
|
371
|
+
core: DaemonCore = app.state.core
|
|
372
|
+
session = await core.session_manager.get_session(session_id)
|
|
373
|
+
if not session:
|
|
374
|
+
return _error_response(session_expired_error(session_id), status_code=404)
|
|
375
|
+
return {
|
|
376
|
+
"session_id": session.session_id,
|
|
377
|
+
"device_serial": session.device_serial,
|
|
378
|
+
"generation": session.generation,
|
|
379
|
+
"created_at": session.created_at.isoformat(),
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@app.post("/ui/snapshot", response_model=None)
|
|
384
|
+
async def ui_snapshot(req: SnapshotRequest) -> EndpointResponse:
|
|
385
|
+
"""Take a UI snapshot.
|
|
386
|
+
|
|
387
|
+
Modes:
|
|
388
|
+
- compact (default): Interactive elements only, JSON format
|
|
389
|
+
- full: All elements, JSON format
|
|
390
|
+
- raw: Original XML hierarchy string
|
|
391
|
+
"""
|
|
392
|
+
core: DaemonCore = app.state.core
|
|
393
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
394
|
+
if not session:
|
|
395
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
396
|
+
|
|
397
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
398
|
+
adb_device = await core.device_manager.get_adb_device(session.device_serial)
|
|
399
|
+
if not device or not adb_device:
|
|
400
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
401
|
+
|
|
402
|
+
# Determine if we need compressed hierarchy (compact mode only)
|
|
403
|
+
use_compressed = req.mode == "compact"
|
|
404
|
+
interactive_only = req.mode == "compact"
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
generation = await core.session_manager.increment_generation(req.session_id)
|
|
408
|
+
xml = await asyncio.to_thread(
|
|
409
|
+
device.dump_hierarchy,
|
|
410
|
+
compressed=use_compressed,
|
|
411
|
+
pretty=False,
|
|
412
|
+
)
|
|
413
|
+
xml_str = xml if isinstance(xml, str) else xml.decode()
|
|
414
|
+
xml_bytes = xml.encode() if isinstance(xml, str) else xml
|
|
415
|
+
|
|
416
|
+
# For raw mode, return XML directly without parsing
|
|
417
|
+
if req.mode == "raw":
|
|
418
|
+
return Response(content=xml_str, media_type="application/xml")
|
|
419
|
+
|
|
420
|
+
context = await core.context_resolver.resolve(adb_device)
|
|
421
|
+
device_info = await core.device_manager.describe_device(session.device_serial)
|
|
422
|
+
snapshot = core.snapshotter.parse_hierarchy(
|
|
423
|
+
xml_bytes,
|
|
424
|
+
session_id=req.session_id,
|
|
425
|
+
generation=generation,
|
|
426
|
+
device_info=device_info,
|
|
427
|
+
context_info=asdict(context),
|
|
428
|
+
interactive_only=interactive_only,
|
|
429
|
+
)
|
|
430
|
+
except Exception as exc:
|
|
431
|
+
logger.exception("snapshot_failed", session_id=req.session_id)
|
|
432
|
+
return _error_response(
|
|
433
|
+
AgentError(
|
|
434
|
+
code="ERR_SNAPSHOT_FAILED",
|
|
435
|
+
message=str(exc),
|
|
436
|
+
context={"session_id": req.session_id},
|
|
437
|
+
remediation="Verify device is online and try again",
|
|
438
|
+
),
|
|
439
|
+
status_code=500,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
snapshot_dict = snapshot.to_dict()
|
|
443
|
+
core.ref_resolver.store_refs(req.session_id, generation, snapshot_dict["elements"])
|
|
444
|
+
await core.database.save_refs(req.session_id, generation, snapshot_dict["elements"])
|
|
445
|
+
|
|
446
|
+
snapshot_json = json.dumps(snapshot_dict, ensure_ascii=True)
|
|
447
|
+
await core.session_manager.update_snapshot(req.session_id, snapshot_dict, snapshot_json)
|
|
448
|
+
|
|
449
|
+
return snapshot_dict
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@app.post("/ui/screenshot", response_model=None)
|
|
453
|
+
async def ui_screenshot(req: DeviceTargetRequest) -> EndpointResponse:
|
|
454
|
+
"""Capture a screenshot."""
|
|
455
|
+
core: DaemonCore = app.state.core
|
|
456
|
+
serial = req.serial
|
|
457
|
+
session_id = req.session_id
|
|
458
|
+
if session_id:
|
|
459
|
+
session = await core.session_manager.get_session(session_id)
|
|
460
|
+
if not session:
|
|
461
|
+
return _error_response(session_expired_error(session_id), status_code=404)
|
|
462
|
+
serial = session.device_serial
|
|
463
|
+
|
|
464
|
+
if not serial:
|
|
465
|
+
return _error_response(
|
|
466
|
+
AgentError(
|
|
467
|
+
code="ERR_TARGET_REQUIRED",
|
|
468
|
+
message="session_id or serial is required",
|
|
469
|
+
context={},
|
|
470
|
+
remediation="Provide --session or --device",
|
|
471
|
+
),
|
|
472
|
+
status_code=400,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
device = await core.device_manager.get_u2_device(serial)
|
|
476
|
+
if not device:
|
|
477
|
+
return _error_response(device_offline_error(serial), status_code=404)
|
|
478
|
+
|
|
479
|
+
label = session_id or serial
|
|
480
|
+
safe_label = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in label)
|
|
481
|
+
path = await core.artifact_manager.screenshot(device, safe_label)
|
|
482
|
+
return {"status": "done", "serial": serial, "path": str(path)}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@app.post("/actions/tap", response_model=None)
|
|
486
|
+
async def action_tap(req: ActionRequest) -> EndpointResponse:
|
|
487
|
+
"""Tap an element.
|
|
488
|
+
|
|
489
|
+
Supports multiple selector formats:
|
|
490
|
+
- @ref (e.g., @a1): Use element ref from snapshot
|
|
491
|
+
- text:"..." : Find by text content
|
|
492
|
+
- id:resource_id : Find by resource ID
|
|
493
|
+
- desc:"..." : Find by content description
|
|
494
|
+
- coords:x,y : Tap at coordinates directly
|
|
495
|
+
"""
|
|
496
|
+
core: DaemonCore = app.state.core
|
|
497
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
498
|
+
if not session:
|
|
499
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
500
|
+
|
|
501
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
502
|
+
if not device:
|
|
503
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
504
|
+
|
|
505
|
+
# Parse the selector
|
|
506
|
+
try:
|
|
507
|
+
selector = parse_selector(req.ref)
|
|
508
|
+
except AgentError as e:
|
|
509
|
+
return _error_response(e, status_code=400)
|
|
510
|
+
|
|
511
|
+
# Handle different selector types
|
|
512
|
+
if isinstance(selector, RefSelector):
|
|
513
|
+
# Use existing ref resolution logic
|
|
514
|
+
locator, is_stale = await _resolve_locator(
|
|
515
|
+
core, req.session_id, selector.ref, session.generation
|
|
516
|
+
)
|
|
517
|
+
if not locator:
|
|
518
|
+
return _error_response(not_found_error(selector.ref), status_code=404)
|
|
519
|
+
|
|
520
|
+
if is_stale:
|
|
521
|
+
# Try re-identification using resource_id (conservative approach)
|
|
522
|
+
if locator.resource_id:
|
|
523
|
+
element = device(resourceId=locator.resource_id)
|
|
524
|
+
exists = await asyncio.to_thread(element.exists)
|
|
525
|
+
if exists:
|
|
526
|
+
# Found via resource_id, proceed with warning
|
|
527
|
+
await asyncio.to_thread(element.click)
|
|
528
|
+
return {
|
|
529
|
+
"status": "done",
|
|
530
|
+
"selector": selector.ref,
|
|
531
|
+
"warning": f"Used stale ref {selector.ref}; take a new snapshot for reliable refs",
|
|
532
|
+
}
|
|
533
|
+
# Could not re-identify, fail with stale ref error
|
|
534
|
+
return _error_response(
|
|
535
|
+
stale_ref_error(selector.ref, locator.generation, session.generation),
|
|
536
|
+
status_code=409,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
result = await core.action_executor.execute(device, ActionType.TAP, locator)
|
|
540
|
+
return result.to_dict()
|
|
541
|
+
|
|
542
|
+
elif isinstance(selector, CoordsSelector):
|
|
543
|
+
# Direct coordinate tap without element lookup
|
|
544
|
+
await asyncio.to_thread(device.click, selector.x, selector.y)
|
|
545
|
+
return {"status": "done", "selector": f"coords:{selector.x},{selector.y}"}
|
|
546
|
+
|
|
547
|
+
else:
|
|
548
|
+
# TextSelector, ResourceIdSelector, DescSelector - use u2 kwargs
|
|
549
|
+
kwargs = selector.to_u2_kwargs()
|
|
550
|
+
element = device(**kwargs)
|
|
551
|
+
exists = await asyncio.to_thread(element.exists)
|
|
552
|
+
if not exists:
|
|
553
|
+
return _error_response(not_found_error(req.ref), status_code=404)
|
|
554
|
+
await asyncio.to_thread(element.click)
|
|
555
|
+
return {"status": "done", "selector": req.ref}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@app.post("/actions/long_tap", response_model=None)
|
|
559
|
+
async def action_long_tap(req: ActionRequest) -> EndpointResponse:
|
|
560
|
+
"""Long tap an element."""
|
|
561
|
+
return await _action_with_locator(req, ActionType.LONG_TAP)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@app.post("/actions/set_text", response_model=None)
|
|
565
|
+
async def action_set_text(req: SetTextRequest) -> EndpointResponse:
|
|
566
|
+
"""Set text on an element."""
|
|
567
|
+
return await _action_with_locator(req, ActionType.SET_TEXT, text=req.text)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@app.post("/actions/clear", response_model=None)
|
|
571
|
+
async def action_clear(req: ActionRequest) -> EndpointResponse:
|
|
572
|
+
"""Clear text on an element."""
|
|
573
|
+
return await _action_with_locator(req, ActionType.CLEAR)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
@app.post("/actions/back", response_model=None)
|
|
577
|
+
async def action_back(req: SessionRequest) -> EndpointResponse:
|
|
578
|
+
"""Press back button."""
|
|
579
|
+
return await _action_simple(req, ActionType.BACK)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@app.post("/actions/home", response_model=None)
|
|
583
|
+
async def action_home(req: SessionRequest) -> EndpointResponse:
|
|
584
|
+
"""Press home button."""
|
|
585
|
+
return await _action_simple(req, ActionType.HOME)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
@app.post("/actions/recents", response_model=None)
|
|
589
|
+
async def action_recents(req: SessionRequest) -> EndpointResponse:
|
|
590
|
+
"""Press recents button."""
|
|
591
|
+
return await _action_simple(req, ActionType.RECENTS)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
async def _action_simple(req: SessionRequest, action: ActionType) -> EndpointResponse:
|
|
595
|
+
core: DaemonCore = app.state.core
|
|
596
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
597
|
+
if not session:
|
|
598
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
599
|
+
|
|
600
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
601
|
+
if not device:
|
|
602
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
603
|
+
|
|
604
|
+
result = await core.action_executor.execute(device, action)
|
|
605
|
+
return result.to_dict()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
async def _action_with_locator(
|
|
609
|
+
req: ActionRequest | SetTextRequest,
|
|
610
|
+
action: ActionType,
|
|
611
|
+
**kwargs: Any,
|
|
612
|
+
) -> EndpointResponse:
|
|
613
|
+
core: DaemonCore = app.state.core
|
|
614
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
615
|
+
if not session:
|
|
616
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
617
|
+
|
|
618
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
619
|
+
if not device:
|
|
620
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
621
|
+
|
|
622
|
+
locator, is_stale = await _resolve_locator(core, req.session_id, req.ref, session.generation)
|
|
623
|
+
if not locator:
|
|
624
|
+
return _error_response(not_found_error(req.ref), status_code=404)
|
|
625
|
+
if is_stale:
|
|
626
|
+
return _error_response(
|
|
627
|
+
stale_ref_error(req.ref, locator.generation, session.generation),
|
|
628
|
+
status_code=409,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
result = await core.action_executor.execute(device, action, locator, **kwargs)
|
|
632
|
+
return result.to_dict()
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
@app.post("/wait/idle", response_model=None)
|
|
636
|
+
async def wait_idle(req: WaitIdleRequest) -> EndpointResponse:
|
|
637
|
+
core: DaemonCore = app.state.core
|
|
638
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
639
|
+
if not session:
|
|
640
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
641
|
+
|
|
642
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
643
|
+
if not device:
|
|
644
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
645
|
+
|
|
646
|
+
timeout = (req.timeout_ms or 0) / 1000 if req.timeout_ms else None
|
|
647
|
+
result = await core.wait_engine.wait_idle(device, timeout=timeout)
|
|
648
|
+
return result.to_dict()
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
@app.post("/wait/activity", response_model=None)
|
|
652
|
+
async def wait_activity(req: WaitActivityRequest) -> EndpointResponse:
|
|
653
|
+
core: DaemonCore = app.state.core
|
|
654
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
655
|
+
if not session:
|
|
656
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
657
|
+
|
|
658
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
659
|
+
if not device:
|
|
660
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
661
|
+
|
|
662
|
+
timeout = (req.timeout_ms or 0) / 1000 if req.timeout_ms else None
|
|
663
|
+
result = await core.wait_engine.wait_activity(device, req.activity, timeout=timeout)
|
|
664
|
+
return result.to_dict()
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@app.post("/wait/text", response_model=None)
|
|
668
|
+
async def wait_text(req: WaitTextRequest) -> EndpointResponse:
|
|
669
|
+
core: DaemonCore = app.state.core
|
|
670
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
671
|
+
if not session:
|
|
672
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
673
|
+
|
|
674
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
675
|
+
if not device:
|
|
676
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
677
|
+
|
|
678
|
+
timeout = (req.timeout_ms or 0) / 1000 if req.timeout_ms else None
|
|
679
|
+
result = await core.wait_engine.wait_text(device, req.text, timeout=timeout)
|
|
680
|
+
return result.to_dict()
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@app.post("/wait/exists", response_model=None)
|
|
684
|
+
async def wait_exists(req: WaitSelectorRequest) -> EndpointResponse:
|
|
685
|
+
core: DaemonCore = app.state.core
|
|
686
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
687
|
+
if not session:
|
|
688
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
689
|
+
|
|
690
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
691
|
+
if not device:
|
|
692
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
693
|
+
|
|
694
|
+
selector = req.selector
|
|
695
|
+
if req.ref:
|
|
696
|
+
locator, is_stale = await _resolve_locator(
|
|
697
|
+
core, req.session_id, req.ref, session.generation
|
|
698
|
+
)
|
|
699
|
+
if not locator:
|
|
700
|
+
return _error_response(not_found_error(req.ref), status_code=404)
|
|
701
|
+
if is_stale:
|
|
702
|
+
return _error_response(
|
|
703
|
+
stale_ref_error(req.ref, locator.generation, session.generation),
|
|
704
|
+
status_code=409,
|
|
705
|
+
)
|
|
706
|
+
selector = _selector_from_locator(locator)
|
|
707
|
+
|
|
708
|
+
if not selector:
|
|
709
|
+
return _error_response(
|
|
710
|
+
AgentError(
|
|
711
|
+
code="ERR_SELECTOR_REQUIRED",
|
|
712
|
+
message="wait exists requires a selector or @ref",
|
|
713
|
+
context={"session_id": req.session_id},
|
|
714
|
+
remediation="Provide --ref or a selector dict",
|
|
715
|
+
),
|
|
716
|
+
status_code=400,
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
timeout = (req.timeout_ms or 0) / 1000 if req.timeout_ms else None
|
|
720
|
+
result = await core.wait_engine.wait_exists(device, selector, timeout=timeout)
|
|
721
|
+
return result.to_dict()
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
@app.post("/wait/gone", response_model=None)
|
|
725
|
+
async def wait_gone(req: WaitSelectorRequest) -> EndpointResponse:
|
|
726
|
+
core: DaemonCore = app.state.core
|
|
727
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
728
|
+
if not session:
|
|
729
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
730
|
+
|
|
731
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
732
|
+
if not device:
|
|
733
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
734
|
+
|
|
735
|
+
selector = req.selector
|
|
736
|
+
if req.ref:
|
|
737
|
+
locator, is_stale = await _resolve_locator(
|
|
738
|
+
core, req.session_id, req.ref, session.generation
|
|
739
|
+
)
|
|
740
|
+
if not locator:
|
|
741
|
+
return _error_response(not_found_error(req.ref), status_code=404)
|
|
742
|
+
if is_stale:
|
|
743
|
+
return _error_response(
|
|
744
|
+
stale_ref_error(req.ref, locator.generation, session.generation),
|
|
745
|
+
status_code=409,
|
|
746
|
+
)
|
|
747
|
+
selector = _selector_from_locator(locator)
|
|
748
|
+
|
|
749
|
+
if not selector:
|
|
750
|
+
return _error_response(
|
|
751
|
+
AgentError(
|
|
752
|
+
code="ERR_SELECTOR_REQUIRED",
|
|
753
|
+
message="wait gone requires a selector or @ref",
|
|
754
|
+
context={"session_id": req.session_id},
|
|
755
|
+
remediation="Provide --ref or a selector dict",
|
|
756
|
+
),
|
|
757
|
+
status_code=400,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
timeout = (req.timeout_ms or 0) / 1000 if req.timeout_ms else None
|
|
761
|
+
result = await core.wait_engine.wait_gone(device, selector, timeout=timeout)
|
|
762
|
+
return result.to_dict()
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@app.post("/artifacts/save_snapshot", response_model=None)
|
|
766
|
+
async def save_snapshot(req: SessionRequest) -> EndpointResponse:
|
|
767
|
+
core: DaemonCore = app.state.core
|
|
768
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
769
|
+
if not session:
|
|
770
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
771
|
+
|
|
772
|
+
snapshot_json = await core.session_manager.get_last_snapshot_json(req.session_id)
|
|
773
|
+
if not snapshot_json:
|
|
774
|
+
return _error_response(
|
|
775
|
+
AgentError(
|
|
776
|
+
code="ERR_NO_SNAPSHOT",
|
|
777
|
+
message="No snapshot available for this session",
|
|
778
|
+
context={"session_id": req.session_id},
|
|
779
|
+
remediation="Run 'ui snapshot' first",
|
|
780
|
+
),
|
|
781
|
+
status_code=400,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
path = await core.artifact_manager.save_snapshot(
|
|
785
|
+
snapshot_json, req.session_id, session.generation
|
|
786
|
+
)
|
|
787
|
+
return {"status": "done", "path": str(path)}
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@app.post("/artifacts/logs", response_model=None)
|
|
791
|
+
async def pull_logs(req: ArtifactLogsRequest) -> EndpointResponse:
|
|
792
|
+
core: DaemonCore = app.state.core
|
|
793
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
794
|
+
if not session:
|
|
795
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
796
|
+
|
|
797
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
798
|
+
if not device:
|
|
799
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
800
|
+
|
|
801
|
+
path = await core.artifact_manager.pull_logs(device, req.session_id, since=req.since)
|
|
802
|
+
return {"status": "done", "path": str(path)}
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
@app.post("/artifacts/debug_bundle", response_model=None)
|
|
806
|
+
async def debug_bundle(req: SessionRequest) -> EndpointResponse:
|
|
807
|
+
core: DaemonCore = app.state.core
|
|
808
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
809
|
+
if not session:
|
|
810
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
811
|
+
|
|
812
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
813
|
+
if not device:
|
|
814
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
815
|
+
|
|
816
|
+
snapshot_json = await core.session_manager.get_last_snapshot_json(req.session_id)
|
|
817
|
+
path = await core.artifact_manager.create_debug_bundle(device, req.session_id, snapshot_json)
|
|
818
|
+
return {"status": "done", "path": str(path)}
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
# Reliability commands
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
@app.post("/reliability/exit_info", response_model=None)
|
|
825
|
+
async def reliability_exit_info(req: ReliabilityExitInfoRequest) -> EndpointResponse:
|
|
826
|
+
core: DaemonCore = app.state.core
|
|
827
|
+
try:
|
|
828
|
+
validate_package(req.package)
|
|
829
|
+
except AgentError as exc:
|
|
830
|
+
return _error_response(exc, status_code=400)
|
|
831
|
+
|
|
832
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
833
|
+
if isinstance(resolved, JSONResponse):
|
|
834
|
+
return resolved
|
|
835
|
+
serial, device, _info = resolved
|
|
836
|
+
|
|
837
|
+
try:
|
|
838
|
+
output = await core.reliability_manager.exit_info(device, req.package, req.list_only)
|
|
839
|
+
except AgentError as exc:
|
|
840
|
+
return _error_response(exc, status_code=400)
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
"status": "done",
|
|
844
|
+
"serial": serial,
|
|
845
|
+
"package": req.package,
|
|
846
|
+
"output": output,
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@app.post("/reliability/bugreport", response_model=None)
|
|
851
|
+
async def reliability_bugreport(req: ReliabilityBugreportRequest) -> EndpointResponse:
|
|
852
|
+
core: DaemonCore = app.state.core
|
|
853
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
854
|
+
if isinstance(resolved, JSONResponse):
|
|
855
|
+
return resolved
|
|
856
|
+
serial, _device, _info = resolved
|
|
857
|
+
|
|
858
|
+
try:
|
|
859
|
+
path = await core.reliability_manager.bugreport(serial, filename=req.filename)
|
|
860
|
+
except AgentError as exc:
|
|
861
|
+
return _error_response(exc, status_code=400)
|
|
862
|
+
|
|
863
|
+
return {"status": "done", "serial": serial, "path": str(path)}
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
@app.post("/reliability/events", response_model=None)
|
|
867
|
+
async def reliability_events(req: ReliabilityEventsRequest) -> EndpointResponse:
|
|
868
|
+
core: DaemonCore = app.state.core
|
|
869
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
870
|
+
if isinstance(resolved, JSONResponse):
|
|
871
|
+
return resolved
|
|
872
|
+
serial, device, _info = resolved
|
|
873
|
+
|
|
874
|
+
pattern = req.pattern or DEFAULT_EVENTS_PATTERN
|
|
875
|
+
try:
|
|
876
|
+
result = await core.reliability_manager.logcat_events(device, pattern, req.since)
|
|
877
|
+
except AgentError as exc:
|
|
878
|
+
return _error_response(exc, status_code=400)
|
|
879
|
+
|
|
880
|
+
output = result.output
|
|
881
|
+
line_count = result.line_count
|
|
882
|
+
if req.package:
|
|
883
|
+
filtered = [line for line in output.splitlines() if req.package in line]
|
|
884
|
+
output = "\n".join(filtered)
|
|
885
|
+
line_count = len(filtered)
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
"status": "done",
|
|
889
|
+
"serial": serial,
|
|
890
|
+
"pattern": pattern,
|
|
891
|
+
"package": req.package,
|
|
892
|
+
"line_count": line_count,
|
|
893
|
+
"total_lines": result.total_lines,
|
|
894
|
+
"output": output,
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
@app.post("/reliability/dropbox_list", response_model=None)
|
|
899
|
+
async def reliability_dropbox_list(req: ReliabilityDropboxListRequest) -> EndpointResponse:
|
|
900
|
+
core: DaemonCore = app.state.core
|
|
901
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
902
|
+
if isinstance(resolved, JSONResponse):
|
|
903
|
+
return resolved
|
|
904
|
+
serial, device, _info = resolved
|
|
905
|
+
|
|
906
|
+
try:
|
|
907
|
+
output = await core.reliability_manager.dropbox_list(device, req.package)
|
|
908
|
+
except AgentError as exc:
|
|
909
|
+
return _error_response(exc, status_code=400)
|
|
910
|
+
|
|
911
|
+
return {
|
|
912
|
+
"status": "done",
|
|
913
|
+
"serial": serial,
|
|
914
|
+
"package": req.package,
|
|
915
|
+
"output": output,
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
@app.post("/reliability/dropbox_print", response_model=None)
|
|
920
|
+
async def reliability_dropbox_print(req: ReliabilityDropboxPrintRequest) -> EndpointResponse:
|
|
921
|
+
core: DaemonCore = app.state.core
|
|
922
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
923
|
+
if isinstance(resolved, JSONResponse):
|
|
924
|
+
return resolved
|
|
925
|
+
serial, device, _info = resolved
|
|
926
|
+
|
|
927
|
+
try:
|
|
928
|
+
output = await core.reliability_manager.dropbox_print(device, req.tag)
|
|
929
|
+
except AgentError as exc:
|
|
930
|
+
return _error_response(exc, status_code=400)
|
|
931
|
+
|
|
932
|
+
return {"status": "done", "serial": serial, "tag": req.tag, "output": output}
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
@app.post("/reliability/background", response_model=None)
|
|
936
|
+
async def reliability_background(req: ReliabilityBackgroundRequest) -> EndpointResponse:
|
|
937
|
+
core: DaemonCore = app.state.core
|
|
938
|
+
try:
|
|
939
|
+
validate_package(req.package)
|
|
940
|
+
except AgentError as exc:
|
|
941
|
+
return _error_response(exc, status_code=400)
|
|
942
|
+
|
|
943
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
944
|
+
if isinstance(resolved, JSONResponse):
|
|
945
|
+
return resolved
|
|
946
|
+
serial, device, _info = resolved
|
|
947
|
+
|
|
948
|
+
try:
|
|
949
|
+
data = await core.reliability_manager.background_restrictions(device, req.package)
|
|
950
|
+
except AgentError as exc:
|
|
951
|
+
return _error_response(exc, status_code=400)
|
|
952
|
+
|
|
953
|
+
output = f"RUN_IN_BACKGROUND:\n{data['appops']}\n\nSTANDBY_BUCKET:\n{data['standby_bucket']}"
|
|
954
|
+
return {
|
|
955
|
+
"status": "done",
|
|
956
|
+
"serial": serial,
|
|
957
|
+
"package": req.package,
|
|
958
|
+
"appops": data["appops"],
|
|
959
|
+
"standby_bucket": data["standby_bucket"],
|
|
960
|
+
"output": output,
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
@app.post("/reliability/last_anr", response_model=None)
|
|
965
|
+
async def reliability_last_anr(req: DeviceTargetRequest) -> EndpointResponse:
|
|
966
|
+
core: DaemonCore = app.state.core
|
|
967
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
968
|
+
if isinstance(resolved, JSONResponse):
|
|
969
|
+
return resolved
|
|
970
|
+
serial, device, _info = resolved
|
|
971
|
+
|
|
972
|
+
try:
|
|
973
|
+
output = await core.reliability_manager.last_anr(device)
|
|
974
|
+
except AgentError as exc:
|
|
975
|
+
return _error_response(exc, status_code=400)
|
|
976
|
+
|
|
977
|
+
return {"status": "done", "serial": serial, "output": output}
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@app.post("/reliability/jobscheduler", response_model=None)
|
|
981
|
+
async def reliability_jobscheduler(req: ReliabilityPackageRequest) -> EndpointResponse:
|
|
982
|
+
core: DaemonCore = app.state.core
|
|
983
|
+
try:
|
|
984
|
+
validate_package(req.package)
|
|
985
|
+
except AgentError as exc:
|
|
986
|
+
return _error_response(exc, status_code=400)
|
|
987
|
+
|
|
988
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
989
|
+
if isinstance(resolved, JSONResponse):
|
|
990
|
+
return resolved
|
|
991
|
+
serial, device, _info = resolved
|
|
992
|
+
|
|
993
|
+
try:
|
|
994
|
+
output = await core.reliability_manager.jobscheduler(device, req.package)
|
|
995
|
+
except AgentError as exc:
|
|
996
|
+
return _error_response(exc, status_code=400)
|
|
997
|
+
|
|
998
|
+
return {
|
|
999
|
+
"status": "done",
|
|
1000
|
+
"serial": serial,
|
|
1001
|
+
"package": req.package,
|
|
1002
|
+
"output": output,
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
@app.post("/reliability/compile", response_model=None)
|
|
1007
|
+
async def reliability_compile(req: ReliabilityCompileRequest) -> EndpointResponse:
|
|
1008
|
+
core: DaemonCore = app.state.core
|
|
1009
|
+
try:
|
|
1010
|
+
validate_package(req.package)
|
|
1011
|
+
except AgentError as exc:
|
|
1012
|
+
return _error_response(exc, status_code=400)
|
|
1013
|
+
|
|
1014
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1015
|
+
if isinstance(resolved, JSONResponse):
|
|
1016
|
+
return resolved
|
|
1017
|
+
serial, device, _info = resolved
|
|
1018
|
+
|
|
1019
|
+
try:
|
|
1020
|
+
output = await core.reliability_manager.compile_package(device, req.package, req.mode)
|
|
1021
|
+
except AgentError as exc:
|
|
1022
|
+
return _error_response(exc, status_code=400)
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
"status": "done",
|
|
1026
|
+
"serial": serial,
|
|
1027
|
+
"package": req.package,
|
|
1028
|
+
"mode": req.mode,
|
|
1029
|
+
"output": output,
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
@app.post("/reliability/always_finish", response_model=None)
|
|
1034
|
+
async def reliability_always_finish(req: ReliabilityToggleRequest) -> EndpointResponse:
|
|
1035
|
+
core: DaemonCore = app.state.core
|
|
1036
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1037
|
+
if isinstance(resolved, JSONResponse):
|
|
1038
|
+
return resolved
|
|
1039
|
+
serial, device, _info = resolved
|
|
1040
|
+
|
|
1041
|
+
state = req.state.lower()
|
|
1042
|
+
if state not in {"on", "off"}:
|
|
1043
|
+
return _error_response(
|
|
1044
|
+
AgentError(
|
|
1045
|
+
code="ERR_INVALID_STATE",
|
|
1046
|
+
message=f"Invalid state: {req.state}",
|
|
1047
|
+
context={"state": req.state},
|
|
1048
|
+
remediation="Use 'on' or 'off'",
|
|
1049
|
+
),
|
|
1050
|
+
status_code=400,
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
enabled = state == "on"
|
|
1054
|
+
try:
|
|
1055
|
+
await core.reliability_manager.always_finish_activities(device, enabled)
|
|
1056
|
+
except AgentError as exc:
|
|
1057
|
+
return _error_response(exc, status_code=400)
|
|
1058
|
+
|
|
1059
|
+
return {"status": "done", "serial": serial, "enabled": enabled}
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
@app.post("/reliability/run_as_ls", response_model=None)
|
|
1063
|
+
async def reliability_run_as_ls(req: ReliabilityRunAsRequest) -> EndpointResponse:
|
|
1064
|
+
core: DaemonCore = app.state.core
|
|
1065
|
+
try:
|
|
1066
|
+
validate_package(req.package)
|
|
1067
|
+
except AgentError as exc:
|
|
1068
|
+
return _error_response(exc, status_code=400)
|
|
1069
|
+
|
|
1070
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1071
|
+
if isinstance(resolved, JSONResponse):
|
|
1072
|
+
return resolved
|
|
1073
|
+
serial, device, _info = resolved
|
|
1074
|
+
|
|
1075
|
+
try:
|
|
1076
|
+
output = await core.reliability_manager.run_as_ls(device, req.package, req.path)
|
|
1077
|
+
except AgentError as exc:
|
|
1078
|
+
return _error_response(exc, status_code=400)
|
|
1079
|
+
|
|
1080
|
+
return {
|
|
1081
|
+
"status": "done",
|
|
1082
|
+
"serial": serial,
|
|
1083
|
+
"package": req.package,
|
|
1084
|
+
"path": req.path,
|
|
1085
|
+
"output": output,
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
@app.post("/reliability/dumpheap", response_model=None)
|
|
1090
|
+
async def reliability_dumpheap(req: ReliabilityDumpheapRequest) -> EndpointResponse:
|
|
1091
|
+
core: DaemonCore = app.state.core
|
|
1092
|
+
try:
|
|
1093
|
+
validate_package(req.package)
|
|
1094
|
+
except AgentError as exc:
|
|
1095
|
+
return _error_response(exc, status_code=400)
|
|
1096
|
+
|
|
1097
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1098
|
+
if isinstance(resolved, JSONResponse):
|
|
1099
|
+
return resolved
|
|
1100
|
+
serial, device, _info = resolved
|
|
1101
|
+
|
|
1102
|
+
try:
|
|
1103
|
+
path = await core.reliability_manager.dump_heap(
|
|
1104
|
+
device, serial, req.package, keep_remote=req.keep_remote
|
|
1105
|
+
)
|
|
1106
|
+
except AgentError as exc:
|
|
1107
|
+
return _error_response(exc, status_code=400)
|
|
1108
|
+
|
|
1109
|
+
return {
|
|
1110
|
+
"status": "done",
|
|
1111
|
+
"serial": serial,
|
|
1112
|
+
"package": req.package,
|
|
1113
|
+
"path": str(path),
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
@app.post("/reliability/sigquit", response_model=None)
|
|
1118
|
+
async def reliability_sigquit(req: ReliabilitySigquitRequest) -> EndpointResponse:
|
|
1119
|
+
core: DaemonCore = app.state.core
|
|
1120
|
+
try:
|
|
1121
|
+
validate_package(req.package)
|
|
1122
|
+
except AgentError as exc:
|
|
1123
|
+
return _error_response(exc, status_code=400)
|
|
1124
|
+
|
|
1125
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1126
|
+
if isinstance(resolved, JSONResponse):
|
|
1127
|
+
return resolved
|
|
1128
|
+
serial, device, _info = resolved
|
|
1129
|
+
|
|
1130
|
+
try:
|
|
1131
|
+
pid = await core.reliability_manager.sigquit(device, req.package)
|
|
1132
|
+
except AgentError as exc:
|
|
1133
|
+
return _error_response(exc, status_code=400)
|
|
1134
|
+
|
|
1135
|
+
return {"status": "done", "serial": serial, "package": req.package, "pid": pid}
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
@app.post("/reliability/oom_adj", response_model=None)
|
|
1139
|
+
async def reliability_oom_adj(req: ReliabilityOomAdjRequest) -> EndpointResponse:
|
|
1140
|
+
core: DaemonCore = app.state.core
|
|
1141
|
+
try:
|
|
1142
|
+
validate_package(req.package)
|
|
1143
|
+
except AgentError as exc:
|
|
1144
|
+
return _error_response(exc, status_code=400)
|
|
1145
|
+
|
|
1146
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1147
|
+
if isinstance(resolved, JSONResponse):
|
|
1148
|
+
return resolved
|
|
1149
|
+
serial, device, info = resolved
|
|
1150
|
+
|
|
1151
|
+
try:
|
|
1152
|
+
require_root(bool(info and info.is_rooted), "set oom_score_adj")
|
|
1153
|
+
except AgentError as exc:
|
|
1154
|
+
return _error_response(exc, status_code=403)
|
|
1155
|
+
|
|
1156
|
+
try:
|
|
1157
|
+
pid = await core.reliability_manager.oom_score_adj(device, req.package, req.score)
|
|
1158
|
+
except AgentError as exc:
|
|
1159
|
+
return _error_response(exc, status_code=400)
|
|
1160
|
+
|
|
1161
|
+
return {
|
|
1162
|
+
"status": "done",
|
|
1163
|
+
"serial": serial,
|
|
1164
|
+
"package": req.package,
|
|
1165
|
+
"pid": pid,
|
|
1166
|
+
"score": req.score,
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
@app.post("/reliability/trim_memory", response_model=None)
|
|
1171
|
+
async def reliability_trim_memory(req: ReliabilityTrimMemoryRequest) -> EndpointResponse:
|
|
1172
|
+
core: DaemonCore = app.state.core
|
|
1173
|
+
try:
|
|
1174
|
+
validate_package(req.package)
|
|
1175
|
+
except AgentError as exc:
|
|
1176
|
+
return _error_response(exc, status_code=400)
|
|
1177
|
+
|
|
1178
|
+
if req.level not in TRIM_LEVELS:
|
|
1179
|
+
return _error_response(
|
|
1180
|
+
AgentError(
|
|
1181
|
+
code="ERR_INVALID_LEVEL",
|
|
1182
|
+
message=f"Invalid trim level: {req.level}",
|
|
1183
|
+
context={"level": req.level},
|
|
1184
|
+
remediation=f"Choose one of: {', '.join(sorted(TRIM_LEVELS))}",
|
|
1185
|
+
),
|
|
1186
|
+
status_code=400,
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1190
|
+
if isinstance(resolved, JSONResponse):
|
|
1191
|
+
return resolved
|
|
1192
|
+
serial, device, _info = resolved
|
|
1193
|
+
|
|
1194
|
+
try:
|
|
1195
|
+
output = await core.reliability_manager.trim_memory(device, req.package, req.level)
|
|
1196
|
+
except AgentError as exc:
|
|
1197
|
+
return _error_response(exc, status_code=400)
|
|
1198
|
+
|
|
1199
|
+
return {
|
|
1200
|
+
"status": "done",
|
|
1201
|
+
"serial": serial,
|
|
1202
|
+
"package": req.package,
|
|
1203
|
+
"level": req.level,
|
|
1204
|
+
"output": output,
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@app.post("/reliability/pull_anr", response_model=None)
|
|
1209
|
+
async def reliability_pull_anr(req: DeviceTargetRequest) -> EndpointResponse:
|
|
1210
|
+
core: DaemonCore = app.state.core
|
|
1211
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1212
|
+
if isinstance(resolved, JSONResponse):
|
|
1213
|
+
return resolved
|
|
1214
|
+
serial, device, info = resolved
|
|
1215
|
+
|
|
1216
|
+
try:
|
|
1217
|
+
require_root(bool(info and info.is_rooted), "pull /data/anr")
|
|
1218
|
+
except AgentError as exc:
|
|
1219
|
+
return _error_response(exc, status_code=403)
|
|
1220
|
+
|
|
1221
|
+
try:
|
|
1222
|
+
path = await core.reliability_manager.pull_root_dir(device, serial, "/data/anr", "anr")
|
|
1223
|
+
except AgentError as exc:
|
|
1224
|
+
return _error_response(exc, status_code=400)
|
|
1225
|
+
|
|
1226
|
+
return {"status": "done", "serial": serial, "path": str(path)}
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
@app.post("/reliability/pull_tombstones", response_model=None)
|
|
1230
|
+
async def reliability_pull_tombstones(req: DeviceTargetRequest) -> EndpointResponse:
|
|
1231
|
+
core: DaemonCore = app.state.core
|
|
1232
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1233
|
+
if isinstance(resolved, JSONResponse):
|
|
1234
|
+
return resolved
|
|
1235
|
+
serial, device, info = resolved
|
|
1236
|
+
|
|
1237
|
+
try:
|
|
1238
|
+
require_root(bool(info and info.is_rooted), "pull /data/tombstones")
|
|
1239
|
+
except AgentError as exc:
|
|
1240
|
+
return _error_response(exc, status_code=403)
|
|
1241
|
+
|
|
1242
|
+
try:
|
|
1243
|
+
path = await core.reliability_manager.pull_root_dir(
|
|
1244
|
+
device, serial, "/data/tombstones", "tombstones"
|
|
1245
|
+
)
|
|
1246
|
+
except AgentError as exc:
|
|
1247
|
+
return _error_response(exc, status_code=400)
|
|
1248
|
+
|
|
1249
|
+
return {"status": "done", "serial": serial, "path": str(path)}
|
|
1250
|
+
|
|
1251
|
+
|
|
1252
|
+
@app.post("/reliability/pull_dropbox", response_model=None)
|
|
1253
|
+
async def reliability_pull_dropbox(req: DeviceTargetRequest) -> EndpointResponse:
|
|
1254
|
+
core: DaemonCore = app.state.core
|
|
1255
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1256
|
+
if isinstance(resolved, JSONResponse):
|
|
1257
|
+
return resolved
|
|
1258
|
+
serial, device, info = resolved
|
|
1259
|
+
|
|
1260
|
+
try:
|
|
1261
|
+
require_root(bool(info and info.is_rooted), "pull /data/system/dropbox")
|
|
1262
|
+
except AgentError as exc:
|
|
1263
|
+
return _error_response(exc, status_code=403)
|
|
1264
|
+
|
|
1265
|
+
try:
|
|
1266
|
+
path = await core.reliability_manager.pull_root_dir(
|
|
1267
|
+
device, serial, "/data/system/dropbox", "dropbox"
|
|
1268
|
+
)
|
|
1269
|
+
except AgentError as exc:
|
|
1270
|
+
return _error_response(exc, status_code=400)
|
|
1271
|
+
|
|
1272
|
+
return {"status": "done", "serial": serial, "path": str(path)}
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
@app.post("/files/push", response_model=None)
|
|
1276
|
+
async def files_push(req: FilePushRequest) -> EndpointResponse:
|
|
1277
|
+
core: DaemonCore = app.state.core
|
|
1278
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1279
|
+
if isinstance(resolved, JSONResponse):
|
|
1280
|
+
return resolved
|
|
1281
|
+
serial, _device, _info = resolved
|
|
1282
|
+
|
|
1283
|
+
try:
|
|
1284
|
+
remote = await core.file_manager.push(serial, req.local_path, req.remote_path)
|
|
1285
|
+
except AgentError as exc:
|
|
1286
|
+
return _error_response(exc, status_code=400)
|
|
1287
|
+
|
|
1288
|
+
return {"status": "done", "serial": serial, "remote": remote, "path": remote}
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
@app.post("/files/pull", response_model=None)
|
|
1292
|
+
async def files_pull(req: FilePullRequest) -> EndpointResponse:
|
|
1293
|
+
core: DaemonCore = app.state.core
|
|
1294
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1295
|
+
if isinstance(resolved, JSONResponse):
|
|
1296
|
+
return resolved
|
|
1297
|
+
serial, _device, _info = resolved
|
|
1298
|
+
|
|
1299
|
+
try:
|
|
1300
|
+
path = await core.file_manager.pull(serial, req.remote_path, req.local_path)
|
|
1301
|
+
except AgentError as exc:
|
|
1302
|
+
return _error_response(exc, status_code=400)
|
|
1303
|
+
|
|
1304
|
+
return {"status": "done", "serial": serial, "remote": req.remote_path, "path": str(path)}
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
@app.post("/files/app_push", response_model=None)
|
|
1308
|
+
async def files_app_push(req: FileAppPushRequest) -> EndpointResponse:
|
|
1309
|
+
core: DaemonCore = app.state.core
|
|
1310
|
+
try:
|
|
1311
|
+
validate_package(req.package)
|
|
1312
|
+
except AgentError as exc:
|
|
1313
|
+
return _error_response(exc, status_code=400)
|
|
1314
|
+
|
|
1315
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1316
|
+
if isinstance(resolved, JSONResponse):
|
|
1317
|
+
return resolved
|
|
1318
|
+
serial, device, info = resolved
|
|
1319
|
+
|
|
1320
|
+
try:
|
|
1321
|
+
require_root(bool(info and info.is_rooted), "push app data")
|
|
1322
|
+
except AgentError as exc:
|
|
1323
|
+
return _error_response(exc, status_code=403)
|
|
1324
|
+
|
|
1325
|
+
try:
|
|
1326
|
+
remote = await core.file_manager.app_push(
|
|
1327
|
+
device, serial, req.package, req.local_path, req.remote_path
|
|
1328
|
+
)
|
|
1329
|
+
except AgentError as exc:
|
|
1330
|
+
return _error_response(exc, status_code=400)
|
|
1331
|
+
|
|
1332
|
+
return {
|
|
1333
|
+
"status": "done",
|
|
1334
|
+
"serial": serial,
|
|
1335
|
+
"package": req.package,
|
|
1336
|
+
"remote": remote,
|
|
1337
|
+
"path": remote,
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
@app.post("/files/app_pull", response_model=None)
|
|
1342
|
+
async def files_app_pull(req: FileAppPullRequest) -> EndpointResponse:
|
|
1343
|
+
core: DaemonCore = app.state.core
|
|
1344
|
+
try:
|
|
1345
|
+
validate_package(req.package)
|
|
1346
|
+
except AgentError as exc:
|
|
1347
|
+
return _error_response(exc, status_code=400)
|
|
1348
|
+
|
|
1349
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1350
|
+
if isinstance(resolved, JSONResponse):
|
|
1351
|
+
return resolved
|
|
1352
|
+
serial, device, info = resolved
|
|
1353
|
+
|
|
1354
|
+
try:
|
|
1355
|
+
require_root(bool(info and info.is_rooted), "pull app data")
|
|
1356
|
+
except AgentError as exc:
|
|
1357
|
+
return _error_response(exc, status_code=403)
|
|
1358
|
+
|
|
1359
|
+
try:
|
|
1360
|
+
path = await core.file_manager.app_pull(
|
|
1361
|
+
device, serial, req.package, req.remote_path, req.local_path
|
|
1362
|
+
)
|
|
1363
|
+
except AgentError as exc:
|
|
1364
|
+
return _error_response(exc, status_code=400)
|
|
1365
|
+
|
|
1366
|
+
return {
|
|
1367
|
+
"status": "done",
|
|
1368
|
+
"serial": serial,
|
|
1369
|
+
"package": req.package,
|
|
1370
|
+
"remote": req.remote_path,
|
|
1371
|
+
"path": str(path),
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
@app.post("/files/find", response_model=None)
|
|
1376
|
+
async def files_find(req: FileFindRequest) -> EndpointResponse:
|
|
1377
|
+
core: DaemonCore = app.state.core
|
|
1378
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1379
|
+
if isinstance(resolved, JSONResponse):
|
|
1380
|
+
return resolved
|
|
1381
|
+
serial, device, info = resolved
|
|
1382
|
+
|
|
1383
|
+
try:
|
|
1384
|
+
require_root(bool(info and info.is_rooted), "find files")
|
|
1385
|
+
except AgentError as exc:
|
|
1386
|
+
return _error_response(exc, status_code=403)
|
|
1387
|
+
|
|
1388
|
+
try:
|
|
1389
|
+
matches = await core.file_manager.find_metadata(
|
|
1390
|
+
device, req.path, req.name, req.kind, req.max_depth
|
|
1391
|
+
)
|
|
1392
|
+
except AgentError as exc:
|
|
1393
|
+
return _error_response(exc, status_code=400)
|
|
1394
|
+
|
|
1395
|
+
return {
|
|
1396
|
+
"status": "done",
|
|
1397
|
+
"serial": serial,
|
|
1398
|
+
"path": req.path,
|
|
1399
|
+
"name": req.name,
|
|
1400
|
+
"kind": req.kind,
|
|
1401
|
+
"max_depth": req.max_depth,
|
|
1402
|
+
"count": len(matches),
|
|
1403
|
+
"results": matches,
|
|
1404
|
+
"output": _format_file_matches(matches),
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
@app.post("/files/list", response_model=None)
|
|
1409
|
+
async def files_list(req: FileListRequest) -> EndpointResponse:
|
|
1410
|
+
core: DaemonCore = app.state.core
|
|
1411
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1412
|
+
if isinstance(resolved, JSONResponse):
|
|
1413
|
+
return resolved
|
|
1414
|
+
serial, device, info = resolved
|
|
1415
|
+
|
|
1416
|
+
try:
|
|
1417
|
+
require_root(bool(info and info.is_rooted), "list files")
|
|
1418
|
+
except AgentError as exc:
|
|
1419
|
+
return _error_response(exc, status_code=403)
|
|
1420
|
+
|
|
1421
|
+
try:
|
|
1422
|
+
matches = await core.file_manager.list_metadata(device, req.path, req.kind)
|
|
1423
|
+
except AgentError as exc:
|
|
1424
|
+
return _error_response(exc, status_code=400)
|
|
1425
|
+
|
|
1426
|
+
return {
|
|
1427
|
+
"status": "done",
|
|
1428
|
+
"serial": serial,
|
|
1429
|
+
"path": req.path,
|
|
1430
|
+
"kind": req.kind,
|
|
1431
|
+
"count": len(matches),
|
|
1432
|
+
"results": matches,
|
|
1433
|
+
"output": _format_file_matches(matches),
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
@app.post("/app/list", response_model=None)
|
|
1438
|
+
async def app_list(req: AppListRequest) -> EndpointResponse:
|
|
1439
|
+
"""List installed packages."""
|
|
1440
|
+
core: DaemonCore = app.state.core
|
|
1441
|
+
resolved = await _resolve_device_target(core, req.session_id, req.serial)
|
|
1442
|
+
if isinstance(resolved, JSONResponse):
|
|
1443
|
+
return resolved
|
|
1444
|
+
serial, _, _ = resolved
|
|
1445
|
+
|
|
1446
|
+
scope_raw = req.scope.strip().lower()
|
|
1447
|
+
scope_map = {
|
|
1448
|
+
"all": "all",
|
|
1449
|
+
"system": "system",
|
|
1450
|
+
"third-party": "third-party",
|
|
1451
|
+
"third_party": "third-party",
|
|
1452
|
+
"thirdparty": "third-party",
|
|
1453
|
+
"user": "third-party",
|
|
1454
|
+
}
|
|
1455
|
+
scope = scope_map.get(scope_raw)
|
|
1456
|
+
if not scope:
|
|
1457
|
+
return _error_response(
|
|
1458
|
+
AgentError(
|
|
1459
|
+
code="ERR_INVALID_PACKAGE_SCOPE",
|
|
1460
|
+
message=f"Invalid package scope: {req.scope}",
|
|
1461
|
+
context={"scope": req.scope},
|
|
1462
|
+
remediation="Use: all, system, or third-party",
|
|
1463
|
+
),
|
|
1464
|
+
status_code=400,
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
try:
|
|
1468
|
+
packages = await core.device_manager.list_packages(serial, scope=scope)
|
|
1469
|
+
except ValueError as exc:
|
|
1470
|
+
return _error_response(
|
|
1471
|
+
AgentError(
|
|
1472
|
+
code="ERR_INVALID_PACKAGE_SCOPE",
|
|
1473
|
+
message=str(exc),
|
|
1474
|
+
context={"scope": req.scope},
|
|
1475
|
+
remediation="Use: all, system, or third-party",
|
|
1476
|
+
),
|
|
1477
|
+
status_code=400,
|
|
1478
|
+
)
|
|
1479
|
+
except Exception:
|
|
1480
|
+
return _error_response(device_offline_error(serial), status_code=404)
|
|
1481
|
+
|
|
1482
|
+
output = "\n".join(packages) if packages else "No packages found."
|
|
1483
|
+
return {
|
|
1484
|
+
"status": "done",
|
|
1485
|
+
"serial": serial,
|
|
1486
|
+
"scope": scope,
|
|
1487
|
+
"count": len(packages),
|
|
1488
|
+
"packages": packages,
|
|
1489
|
+
"output": output,
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
@app.post("/app/reset", response_model=None)
|
|
1494
|
+
async def app_reset(req: AppResetRequest) -> EndpointResponse:
|
|
1495
|
+
core: DaemonCore = app.state.core
|
|
1496
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
1497
|
+
if not session:
|
|
1498
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
1499
|
+
|
|
1500
|
+
try:
|
|
1501
|
+
await core.device_manager.app_reset(session.device_serial, req.package)
|
|
1502
|
+
except Exception:
|
|
1503
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
1504
|
+
|
|
1505
|
+
return {"status": "done", "package": req.package}
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
@app.post("/app/launch", response_model=None)
|
|
1509
|
+
async def app_launch(req: AppLaunchRequest) -> EndpointResponse:
|
|
1510
|
+
"""Launch an app."""
|
|
1511
|
+
core: DaemonCore = app.state.core
|
|
1512
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
1513
|
+
if not session:
|
|
1514
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
1515
|
+
|
|
1516
|
+
try:
|
|
1517
|
+
validate_package(req.package)
|
|
1518
|
+
except AgentError as e:
|
|
1519
|
+
return _error_response(e, status_code=400)
|
|
1520
|
+
|
|
1521
|
+
try:
|
|
1522
|
+
activity = await core.device_manager.app_launch(
|
|
1523
|
+
session.device_serial, req.package, req.activity
|
|
1524
|
+
)
|
|
1525
|
+
except Exception as e:
|
|
1526
|
+
return _error_response(
|
|
1527
|
+
AgentError(
|
|
1528
|
+
code="ERR_LAUNCH_FAILED",
|
|
1529
|
+
message=str(e),
|
|
1530
|
+
context={"package": req.package},
|
|
1531
|
+
remediation="Verify package is installed and has a launchable activity",
|
|
1532
|
+
),
|
|
1533
|
+
status_code=500,
|
|
1534
|
+
)
|
|
1535
|
+
|
|
1536
|
+
return {"status": "done", "package": req.package, "activity": activity}
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
@app.post("/app/force_stop", response_model=None)
|
|
1540
|
+
async def app_force_stop(req: AppForceStopRequest) -> EndpointResponse:
|
|
1541
|
+
"""Force stop an app."""
|
|
1542
|
+
core: DaemonCore = app.state.core
|
|
1543
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
1544
|
+
if not session:
|
|
1545
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
1546
|
+
|
|
1547
|
+
try:
|
|
1548
|
+
validate_package(req.package)
|
|
1549
|
+
except AgentError as e:
|
|
1550
|
+
return _error_response(e, status_code=400)
|
|
1551
|
+
|
|
1552
|
+
try:
|
|
1553
|
+
await core.device_manager.app_force_stop(session.device_serial, req.package)
|
|
1554
|
+
except Exception:
|
|
1555
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
1556
|
+
|
|
1557
|
+
return {"status": "done", "package": req.package}
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
@app.post("/app/deeplink", response_model=None)
|
|
1561
|
+
async def app_deeplink(req: AppDeeplinkRequest) -> EndpointResponse:
|
|
1562
|
+
"""Open a deeplink URI."""
|
|
1563
|
+
core: DaemonCore = app.state.core
|
|
1564
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
1565
|
+
if not session:
|
|
1566
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
1567
|
+
|
|
1568
|
+
try:
|
|
1569
|
+
validate_uri(req.uri)
|
|
1570
|
+
except AgentError as e:
|
|
1571
|
+
return _error_response(e, status_code=400)
|
|
1572
|
+
|
|
1573
|
+
try:
|
|
1574
|
+
await core.device_manager.app_deeplink(session.device_serial, req.uri)
|
|
1575
|
+
except Exception:
|
|
1576
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
1577
|
+
|
|
1578
|
+
return {"status": "done", "uri": req.uri}
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
@app.post("/emulator/snapshot_save", response_model=None)
|
|
1582
|
+
async def emulator_snapshot_save(req: EmulatorSnapshotRequest) -> EndpointResponse:
|
|
1583
|
+
"""Save emulator snapshot."""
|
|
1584
|
+
core: DaemonCore = app.state.core
|
|
1585
|
+
try:
|
|
1586
|
+
await core.device_manager.emulator_snapshot_save(req.serial, req.name)
|
|
1587
|
+
except AgentError as e:
|
|
1588
|
+
return _error_response(e, status_code=400)
|
|
1589
|
+
except Exception:
|
|
1590
|
+
return _error_response(device_offline_error(req.serial), status_code=404)
|
|
1591
|
+
|
|
1592
|
+
return {"status": "done", "serial": req.serial, "snapshot": req.name}
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
@app.post("/emulator/snapshot_restore", response_model=None)
|
|
1596
|
+
async def emulator_snapshot_restore(req: EmulatorSnapshotRequest) -> EndpointResponse:
|
|
1597
|
+
"""Restore emulator snapshot."""
|
|
1598
|
+
core: DaemonCore = app.state.core
|
|
1599
|
+
try:
|
|
1600
|
+
await core.device_manager.emulator_snapshot_restore(req.serial, req.name)
|
|
1601
|
+
except AgentError as e:
|
|
1602
|
+
return _error_response(e, status_code=400)
|
|
1603
|
+
except Exception:
|
|
1604
|
+
return _error_response(device_offline_error(req.serial), status_code=404)
|
|
1605
|
+
|
|
1606
|
+
return {"status": "done", "serial": req.serial, "snapshot": req.name}
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
@app.post("/actions/swipe", response_model=None)
|
|
1610
|
+
async def action_swipe(req: SwipeRequest) -> EndpointResponse:
|
|
1611
|
+
"""Perform swipe action."""
|
|
1612
|
+
core: DaemonCore = app.state.core
|
|
1613
|
+
|
|
1614
|
+
try:
|
|
1615
|
+
direction = SwipeDirection(req.direction)
|
|
1616
|
+
except ValueError:
|
|
1617
|
+
return {
|
|
1618
|
+
"status": "error",
|
|
1619
|
+
"error": {
|
|
1620
|
+
"code": "ERR_INVALID_DIRECTION",
|
|
1621
|
+
"message": f"Invalid direction: {req.direction}",
|
|
1622
|
+
"remediation": "Use up, down, left, or right",
|
|
1623
|
+
},
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
session = await core.session_manager.get_session(req.session_id)
|
|
1627
|
+
if not session:
|
|
1628
|
+
return _error_response(session_expired_error(req.session_id), status_code=404)
|
|
1629
|
+
|
|
1630
|
+
device = await core.device_manager.get_u2_device(session.device_serial)
|
|
1631
|
+
if not device:
|
|
1632
|
+
return _error_response(device_offline_error(session.device_serial), status_code=404)
|
|
1633
|
+
|
|
1634
|
+
# Get bounds (full screen if no container)
|
|
1635
|
+
info = await asyncio.to_thread(lambda: device.info)
|
|
1636
|
+
bounds = [0, 0, info["displayWidth"], info["displayHeight"]]
|
|
1637
|
+
|
|
1638
|
+
start, end = core.action_executor._calculate_swipe_coords(bounds, direction, req.distance)
|
|
1639
|
+
|
|
1640
|
+
await asyncio.to_thread(
|
|
1641
|
+
device.swipe, start[0], start[1], end[0], end[1], req.duration_ms / 1000
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
return {"status": "done", "direction": req.direction}
|