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.
Files changed (50) hide show
  1. android_emu_agent/__init__.py +3 -0
  2. android_emu_agent/actions/__init__.py +1 -0
  3. android_emu_agent/actions/executor.py +288 -0
  4. android_emu_agent/actions/selector.py +122 -0
  5. android_emu_agent/actions/wait.py +193 -0
  6. android_emu_agent/artifacts/__init__.py +1 -0
  7. android_emu_agent/artifacts/manager.py +125 -0
  8. android_emu_agent/artifacts/py.typed +0 -0
  9. android_emu_agent/cli/__init__.py +1 -0
  10. android_emu_agent/cli/commands/__init__.py +1 -0
  11. android_emu_agent/cli/commands/action.py +158 -0
  12. android_emu_agent/cli/commands/app_cmd.py +95 -0
  13. android_emu_agent/cli/commands/artifact.py +81 -0
  14. android_emu_agent/cli/commands/daemon.py +62 -0
  15. android_emu_agent/cli/commands/device.py +122 -0
  16. android_emu_agent/cli/commands/emulator.py +46 -0
  17. android_emu_agent/cli/commands/file.py +139 -0
  18. android_emu_agent/cli/commands/reliability.py +310 -0
  19. android_emu_agent/cli/commands/session.py +65 -0
  20. android_emu_agent/cli/commands/ui.py +112 -0
  21. android_emu_agent/cli/commands/wait.py +132 -0
  22. android_emu_agent/cli/daemon_client.py +185 -0
  23. android_emu_agent/cli/main.py +52 -0
  24. android_emu_agent/cli/utils.py +171 -0
  25. android_emu_agent/daemon/__init__.py +1 -0
  26. android_emu_agent/daemon/core.py +62 -0
  27. android_emu_agent/daemon/health.py +177 -0
  28. android_emu_agent/daemon/models.py +244 -0
  29. android_emu_agent/daemon/server.py +1644 -0
  30. android_emu_agent/db/__init__.py +1 -0
  31. android_emu_agent/db/models.py +229 -0
  32. android_emu_agent/device/__init__.py +1 -0
  33. android_emu_agent/device/manager.py +522 -0
  34. android_emu_agent/device/session.py +129 -0
  35. android_emu_agent/errors.py +232 -0
  36. android_emu_agent/files/__init__.py +1 -0
  37. android_emu_agent/files/manager.py +311 -0
  38. android_emu_agent/py.typed +0 -0
  39. android_emu_agent/reliability/__init__.py +1 -0
  40. android_emu_agent/reliability/manager.py +244 -0
  41. android_emu_agent/ui/__init__.py +1 -0
  42. android_emu_agent/ui/context.py +169 -0
  43. android_emu_agent/ui/ref_resolver.py +149 -0
  44. android_emu_agent/ui/snapshotter.py +236 -0
  45. android_emu_agent/validation.py +59 -0
  46. android_emu_agent-0.1.3.dist-info/METADATA +375 -0
  47. android_emu_agent-0.1.3.dist-info/RECORD +50 -0
  48. android_emu_agent-0.1.3.dist-info/WHEEL +4 -0
  49. android_emu_agent-0.1.3.dist-info/entry_points.txt +2 -0
  50. 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}