androidctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. androidctl/__init__.py +5 -0
  2. androidctl/__main__.py +4 -0
  3. androidctl/_version.py +1 -0
  4. androidctl/app.py +73 -0
  5. androidctl/cli_options.py +27 -0
  6. androidctl/command_payloads.py +264 -0
  7. androidctl/command_views.py +157 -0
  8. androidctl/commands/__init__.py +1 -0
  9. androidctl/commands/actions.py +236 -0
  10. androidctl/commands/adb_wireless.py +157 -0
  11. androidctl/commands/close.py +30 -0
  12. androidctl/commands/connect.py +69 -0
  13. androidctl/commands/execute.py +179 -0
  14. androidctl/commands/list_apps.py +26 -0
  15. androidctl/commands/observe.py +26 -0
  16. androidctl/commands/open.py +41 -0
  17. androidctl/commands/plumbing.py +58 -0
  18. androidctl/commands/run_pipeline.py +307 -0
  19. androidctl/commands/screenshot.py +29 -0
  20. androidctl/commands/setup.py +301 -0
  21. androidctl/commands/wait.py +60 -0
  22. androidctl/daemon/__init__.py +1 -0
  23. androidctl/daemon/client.py +348 -0
  24. androidctl/daemon/discovery.py +190 -0
  25. androidctl/daemon/launcher.py +26 -0
  26. androidctl/daemon/owner.py +349 -0
  27. androidctl/errors/__init__.py +1 -0
  28. androidctl/errors/mapping.py +149 -0
  29. androidctl/errors/models.py +16 -0
  30. androidctl/exit_codes.py +8 -0
  31. androidctl/output.py +147 -0
  32. androidctl/parsing/__init__.py +1 -0
  33. androidctl/parsing/duration.py +17 -0
  34. androidctl/parsing/open_target.py +51 -0
  35. androidctl/parsing/refs.py +12 -0
  36. androidctl/parsing/screen_id.py +10 -0
  37. androidctl/parsing/wait.py +70 -0
  38. androidctl/renderers/__init__.py +110 -0
  39. androidctl/renderers/_paths.py +109 -0
  40. androidctl/renderers/xml.py +234 -0
  41. androidctl/renderers/xml_projection.py +732 -0
  42. androidctl/resources/__init__.py +1 -0
  43. androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
  44. androidctl/setup/__init__.py +1 -0
  45. androidctl/setup/accessibility.py +159 -0
  46. androidctl/setup/adb.py +586 -0
  47. androidctl/setup/apk_resource.py +29 -0
  48. androidctl/setup/pairing.py +70 -0
  49. androidctl/setup/verify.py +175 -0
  50. androidctl/workspace/__init__.py +3 -0
  51. androidctl/workspace/resolve.py +27 -0
  52. androidctl-0.1.0.dist-info/METADATA +217 -0
  53. androidctl-0.1.0.dist-info/RECORD +187 -0
  54. androidctl-0.1.0.dist-info/WHEEL +5 -0
  55. androidctl-0.1.0.dist-info/entry_points.txt +3 -0
  56. androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
  57. androidctl-0.1.0.dist-info/top_level.txt +3 -0
  58. androidctl_contracts/__init__.py +55 -0
  59. androidctl_contracts/_version.py +1 -0
  60. androidctl_contracts/_wire_helpers.py +31 -0
  61. androidctl_contracts/base.py +142 -0
  62. androidctl_contracts/command_catalog.py +414 -0
  63. androidctl_contracts/command_results.py +630 -0
  64. androidctl_contracts/daemon_api.py +335 -0
  65. androidctl_contracts/errors.py +44 -0
  66. androidctl_contracts/paths.py +5 -0
  67. androidctl_contracts/public_screen.py +579 -0
  68. androidctl_contracts/user_state.py +23 -0
  69. androidctl_contracts/vocabulary.py +82 -0
  70. androidctld/__init__.py +5 -0
  71. androidctld/__main__.py +63 -0
  72. androidctld/_version.py +1 -0
  73. androidctld/actions/__init__.py +1 -0
  74. androidctld/actions/action_target.py +142 -0
  75. androidctld/actions/capabilities.py +539 -0
  76. androidctld/actions/executor.py +894 -0
  77. androidctld/actions/focus_confirmation.py +177 -0
  78. androidctld/actions/focused_input_admissibility.py +120 -0
  79. androidctld/actions/fresh_current.py +176 -0
  80. androidctld/actions/postconditions.py +473 -0
  81. androidctld/actions/repair.py +101 -0
  82. androidctld/actions/request_builder.py +204 -0
  83. androidctld/actions/settle.py +146 -0
  84. androidctld/actions/submit_confirmation.py +211 -0
  85. androidctld/actions/submit_routing.py +311 -0
  86. androidctld/actions/type_confirmation.py +257 -0
  87. androidctld/app_targets.py +71 -0
  88. androidctld/artifacts/__init__.py +1 -0
  89. androidctld/artifacts/models.py +26 -0
  90. androidctld/artifacts/screen_lookup.py +241 -0
  91. androidctld/artifacts/screen_payloads.py +109 -0
  92. androidctld/artifacts/writer.py +286 -0
  93. androidctld/auth/__init__.py +1 -0
  94. androidctld/auth/active_registry.py +266 -0
  95. androidctld/auth/secret_files.py +52 -0
  96. androidctld/auth/token_store.py +59 -0
  97. androidctld/commands/__init__.py +1 -0
  98. androidctld/commands/assembly.py +231 -0
  99. androidctld/commands/command_models.py +254 -0
  100. androidctld/commands/dispatch.py +99 -0
  101. androidctld/commands/executor.py +31 -0
  102. androidctld/commands/from_boundary.py +175 -0
  103. androidctld/commands/handlers/__init__.py +15 -0
  104. androidctld/commands/handlers/action.py +439 -0
  105. androidctld/commands/handlers/connect.py +94 -0
  106. androidctld/commands/handlers/list_apps.py +215 -0
  107. androidctld/commands/handlers/observe.py +121 -0
  108. androidctld/commands/handlers/screenshot.py +105 -0
  109. androidctld/commands/handlers/wait.py +286 -0
  110. androidctld/commands/models.py +65 -0
  111. androidctld/commands/open_targets.py +56 -0
  112. androidctld/commands/orchestration.py +353 -0
  113. androidctld/commands/registry.py +116 -0
  114. androidctld/commands/result_builders.py +40 -0
  115. androidctld/commands/result_models.py +555 -0
  116. androidctld/commands/results.py +108 -0
  117. androidctld/commands/semantic_command_names.py +17 -0
  118. androidctld/commands/semantic_error_mapping.py +93 -0
  119. androidctld/commands/semantic_truth.py +135 -0
  120. androidctld/commands/service.py +67 -0
  121. androidctld/config.py +75 -0
  122. androidctld/daemon/__init__.py +1 -0
  123. androidctld/daemon/active_slot.py +326 -0
  124. androidctld/daemon/envelope.py +30 -0
  125. androidctld/daemon/http_host.py +123 -0
  126. androidctld/daemon/ingress.py +112 -0
  127. androidctld/daemon/ownership_probe.py +204 -0
  128. androidctld/daemon/server.py +286 -0
  129. androidctld/daemon/service.py +99 -0
  130. androidctld/device/__init__.py +1 -0
  131. androidctld/device/action_models.py +154 -0
  132. androidctld/device/action_serialization.py +121 -0
  133. androidctld/device/adapters.py +220 -0
  134. androidctld/device/bootstrap.py +153 -0
  135. androidctld/device/connectors.py +231 -0
  136. androidctld/device/errors.py +100 -0
  137. androidctld/device/interfaces.py +58 -0
  138. androidctld/device/parsing.py +320 -0
  139. androidctld/device/rpc.py +483 -0
  140. androidctld/device/schema.py +114 -0
  141. androidctld/device/types.py +161 -0
  142. androidctld/errors/__init__.py +94 -0
  143. androidctld/logging/__init__.py +22 -0
  144. androidctld/observation.py +98 -0
  145. androidctld/protocol.py +53 -0
  146. androidctld/refs/__init__.py +1 -0
  147. androidctld/refs/models.py +54 -0
  148. androidctld/refs/repair.py +284 -0
  149. androidctld/refs/service.py +422 -0
  150. androidctld/rendering/__init__.py +1 -0
  151. androidctld/rendering/screen_xml.py +256 -0
  152. androidctld/runtime/__init__.py +21 -0
  153. androidctld/runtime/kernel.py +548 -0
  154. androidctld/runtime/lifecycle.py +19 -0
  155. androidctld/runtime/models.py +48 -0
  156. androidctld/runtime/screen_state.py +117 -0
  157. androidctld/runtime/state_repo.py +70 -0
  158. androidctld/runtime/store.py +76 -0
  159. androidctld/runtime_policy.py +127 -0
  160. androidctld/schema/__init__.py +5 -0
  161. androidctld/schema/base.py +132 -0
  162. androidctld/schema/core.py +35 -0
  163. androidctld/schema/daemon_api.py +108 -0
  164. androidctld/schema/persistence.py +161 -0
  165. androidctld/schema/persistence_io.py +41 -0
  166. androidctld/schema/validation_errors.py +309 -0
  167. androidctld/semantics/__init__.py +1 -0
  168. androidctld/semantics/compiler.py +610 -0
  169. androidctld/semantics/continuity.py +107 -0
  170. androidctld/semantics/labels.py +252 -0
  171. androidctld/semantics/models.py +25 -0
  172. androidctld/semantics/policy.py +23 -0
  173. androidctld/semantics/public_models.py +123 -0
  174. androidctld/semantics/registries.py +13 -0
  175. androidctld/semantics/submit_refs.py +417 -0
  176. androidctld/semantics/surface.py +254 -0
  177. androidctld/semantics/targets.py +167 -0
  178. androidctld/snapshots/__init__.py +1 -0
  179. androidctld/snapshots/models.py +219 -0
  180. androidctld/snapshots/refresh.py +273 -0
  181. androidctld/snapshots/schema.py +74 -0
  182. androidctld/snapshots/service.py +138 -0
  183. androidctld/text_equivalence.py +67 -0
  184. androidctld/waits/__init__.py +1 -0
  185. androidctld/waits/evaluators.py +216 -0
  186. androidctld/waits/loop.py +305 -0
  187. androidctld/waits/matcher.py +41 -0
@@ -0,0 +1,286 @@
1
+ """HTTP server for androidctld."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import threading
8
+ from collections.abc import Callable
9
+ from http.server import BaseHTTPRequestHandler
10
+ from typing import Any
11
+
12
+ from androidctld import SERVICE_NAME, __version__
13
+ from androidctld.auth.active_registry import ActiveDaemonRecord, ActiveDaemonRegistry
14
+ from androidctld.auth.token_store import DaemonTokenStore
15
+ from androidctld.commands.service import CommandService
16
+ from androidctld.config import DaemonConfig
17
+ from androidctld.daemon.active_slot import ActiveSlotCoordinator
18
+ from androidctld.daemon.envelope import error_envelope, success_envelope
19
+ from androidctld.daemon.http_host import DaemonHttpHost
20
+ from androidctld.daemon.ingress import DaemonIngress
21
+ from androidctld.daemon.service import DaemonService
22
+ from androidctld.errors import DaemonError, DaemonErrorCode, bad_request
23
+ from androidctld.logging import configure_logging
24
+ from androidctld.runtime.store import RuntimeStore
25
+ from androidctld.runtime_policy import (
26
+ DAEMON_HTTP_MAX_REQUEST_BODY_BYTES,
27
+ DAEMON_HTTP_SOCKET_TIMEOUT_SECONDS,
28
+ )
29
+
30
+
31
+ class AndroidctldHttpServer:
32
+ def __init__(
33
+ self,
34
+ config: DaemonConfig,
35
+ token_store: DaemonTokenStore | None = None,
36
+ active_registry: ActiveDaemonRegistry | None = None,
37
+ runtime_store: RuntimeStore | None = None,
38
+ command_service: CommandService | None = None,
39
+ logger: logging.Logger | None = None,
40
+ shutdown_callback: Callable[[], None] | None = None,
41
+ ) -> None:
42
+ self._config = config
43
+ self._token_store = token_store or DaemonTokenStore(self._config)
44
+ self._active_registry = active_registry or ActiveDaemonRegistry(self._config)
45
+ self._logger = logger or configure_logging()
46
+ self._runtime_store = runtime_store or RuntimeStore(self._config)
47
+ self._shutdown_callback = shutdown_callback or (lambda: None)
48
+ self._closing = False
49
+ self._shutdown_after_close_requested = False
50
+ self._stop_lock = threading.Lock()
51
+ self._stop_completed = False
52
+ self._service = DaemonService(
53
+ runtime_store=self._runtime_store,
54
+ command_service=command_service or CommandService(self._runtime_store),
55
+ bound_owner_id=self._config.owner_id,
56
+ )
57
+ self._ingress = DaemonIngress(
58
+ token_provider=self._token_store.current_token,
59
+ owner_id_provider=lambda: self._config.owner_id,
60
+ dispatcher=self,
61
+ )
62
+ self._active_slot = ActiveSlotCoordinator(
63
+ config=self._config,
64
+ active_registry=self._active_registry,
65
+ existing_token_reader=lambda: DaemonTokenStore.load_existing_token(
66
+ self._config.token_file_path
67
+ ),
68
+ )
69
+ self._http_host = DaemonHttpHost(config=self._config, logger=self._logger)
70
+
71
+ @property
72
+ def active_record(self) -> ActiveDaemonRecord | None:
73
+ return self._active_slot.active_record
74
+
75
+ def handle(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ headers: dict[str, str],
80
+ body: bytes,
81
+ ) -> tuple[int, dict[str, Any]]:
82
+ if (
83
+ self._closing
84
+ and method == "POST"
85
+ and path
86
+ in {
87
+ "/runtime/get",
88
+ "/runtime/close",
89
+ "/commands/run",
90
+ }
91
+ ):
92
+ raise DaemonError(
93
+ code=DaemonErrorCode.RUNTIME_BUSY,
94
+ message="daemon is shutting down",
95
+ retryable=True,
96
+ details={"reason": "daemon_shutting_down"},
97
+ http_status=200,
98
+ )
99
+ return self._service.handle(
100
+ method=method,
101
+ path=path,
102
+ headers=headers,
103
+ body=body,
104
+ )
105
+
106
+ def start(self) -> ActiveDaemonRecord:
107
+ with self._stop_lock:
108
+ if self._http_host.is_running:
109
+ raise RuntimeError("androidctld server is already running")
110
+
111
+ self._closing = False
112
+ self._shutdown_after_close_requested = False
113
+ self._stop_completed = False
114
+ self._active_slot.acquire()
115
+ try:
116
+ host, port = self._http_host.start(self._build_handler())
117
+ active_record = self._active_slot.prepare(
118
+ host=host,
119
+ port=port,
120
+ token=self._token_store.current_token(),
121
+ )
122
+ self._http_host.wait_until_ready(record=active_record)
123
+ self._active_slot.publish(active_record)
124
+ self._logger.info("androidctld listening on %s:%s", host, port)
125
+ return active_record
126
+ except Exception:
127
+ self._active_slot.clear_record()
128
+ self._http_host.stop()
129
+ self._active_slot.release_owner()
130
+ self._stop_completed = True
131
+ raise
132
+
133
+ def stop(self) -> None:
134
+ with self._stop_lock:
135
+ if self._stop_completed:
136
+ return
137
+ try:
138
+ if self._http_host.is_running:
139
+ self._http_host.stop()
140
+ finally:
141
+ self._active_slot.clear_record()
142
+ self._active_slot.release_owner()
143
+ self._stop_completed = True
144
+ self._logger.info("androidctld stopped")
145
+
146
+ def _build_handler(self) -> type[BaseHTTPRequestHandler]:
147
+ outer = self
148
+
149
+ class RequestHandler(BaseHTTPRequestHandler):
150
+ server_version = f"{SERVICE_NAME}/{__version__}"
151
+ protocol_version = "HTTP/1.1"
152
+
153
+ def setup(self) -> None:
154
+ super().setup()
155
+ self.connection.settimeout(DAEMON_HTTP_SOCKET_TIMEOUT_SECONDS)
156
+
157
+ def do_POST(self) -> None:
158
+ outer._handle(self)
159
+
160
+ def do_GET(self) -> None:
161
+ outer._handle(self)
162
+
163
+ def log_message(self, fmt: str, *args: Any) -> None:
164
+ outer._logger.info("http %s", fmt % args)
165
+
166
+ return RequestHandler
167
+
168
+ def _handle(self, handler: BaseHTTPRequestHandler) -> None:
169
+ try:
170
+ body = self._read_body(handler)
171
+ result = self._ingress.handle(
172
+ method=handler.command,
173
+ path=handler.path,
174
+ headers=self._normalize_headers(handler),
175
+ body=body,
176
+ )
177
+ if result.shutdown_after_write:
178
+ self._enter_closing_gate()
179
+ try:
180
+ self._write_json(
181
+ handler,
182
+ result.status_code,
183
+ success_envelope(result.payload),
184
+ )
185
+ except OSError:
186
+ self._logger.info("close response write failed", exc_info=True)
187
+ finally:
188
+ self._request_shutdown_after_close()
189
+ return
190
+ self._write_json(
191
+ handler,
192
+ result.status_code,
193
+ success_envelope(result.payload),
194
+ )
195
+ except DaemonError as error:
196
+ self._write_json(handler, error.http_status, error_envelope(error))
197
+ except Exception: # pragma: no cover - defensive fallback
198
+ self._logger.exception("unexpected daemon failure")
199
+ daemon_error = DaemonError(
200
+ code=DaemonErrorCode.INTERNAL_COMMAND_FAILURE,
201
+ message="unexpected daemon failure",
202
+ retryable=False,
203
+ details={},
204
+ http_status=500,
205
+ )
206
+ self._write_json(
207
+ handler, daemon_error.http_status, error_envelope(daemon_error)
208
+ )
209
+
210
+ def _enter_closing_gate(self) -> None:
211
+ if self._closing:
212
+ return
213
+ self._closing = True
214
+
215
+ def _request_shutdown_after_close(self) -> None:
216
+ if self._shutdown_after_close_requested:
217
+ return
218
+ self._shutdown_after_close_requested = True
219
+ self._shutdown_callback()
220
+
221
+ def _read_body(self, handler: BaseHTTPRequestHandler) -> bytes:
222
+ content_length_header = handler.headers.get("Content-Length")
223
+ if content_length_header is None:
224
+ return b""
225
+ content_length_raw = content_length_header.strip()
226
+ if not content_length_raw:
227
+ raise bad_request("invalid Content-Length header")
228
+ try:
229
+ content_length = int(content_length_raw)
230
+ except ValueError as error:
231
+ raise bad_request("invalid Content-Length header") from error
232
+ if content_length < 0:
233
+ raise bad_request("invalid Content-Length header")
234
+ if content_length > DAEMON_HTTP_MAX_REQUEST_BODY_BYTES:
235
+ handler.close_connection = True
236
+ raise DaemonError(
237
+ code=DaemonErrorCode.DAEMON_BAD_REQUEST,
238
+ message="request body too large",
239
+ retryable=False,
240
+ details={
241
+ "reason": "request_body_too_large",
242
+ "max": DAEMON_HTTP_MAX_REQUEST_BODY_BYTES,
243
+ "contentLength": content_length,
244
+ },
245
+ http_status=413,
246
+ )
247
+ try:
248
+ body = handler.rfile.read(content_length)
249
+ except TimeoutError as error:
250
+ handler.close_connection = True
251
+ raise DaemonError(
252
+ code=DaemonErrorCode.DAEMON_BAD_REQUEST,
253
+ message="request body read timed out",
254
+ retryable=False,
255
+ details={"reason": "request_body_timeout"},
256
+ http_status=408,
257
+ ) from error
258
+ if len(body) != content_length:
259
+ handler.close_connection = True
260
+ raise bad_request(
261
+ "incomplete request body",
262
+ {
263
+ "reason": "incomplete_body",
264
+ "contentLength": content_length,
265
+ "bytesRead": len(body),
266
+ },
267
+ )
268
+ return body
269
+
270
+ def _normalize_headers(self, handler: BaseHTTPRequestHandler) -> dict[str, str]:
271
+ return dict(handler.headers.items())
272
+
273
+ def _write_json(
274
+ self,
275
+ handler: BaseHTTPRequestHandler,
276
+ status_code: int,
277
+ payload: dict[str, Any],
278
+ ) -> None:
279
+ body = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode(
280
+ "utf-8"
281
+ )
282
+ handler.send_response(status_code)
283
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
284
+ handler.send_header("Content-Length", str(len(body)))
285
+ handler.end_headers()
286
+ handler.wfile.write(body)
@@ -0,0 +1,99 @@
1
+ """Route dispatch for androidctld daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from androidctl_contracts.daemon_api import RuntimeGetResult, RuntimePayload
9
+ from androidctld import __version__
10
+ from androidctld.commands.service import CommandService
11
+ from androidctld.errors import bad_request
12
+ from androidctld.runtime import RuntimeKernel
13
+ from androidctld.runtime.screen_state import get_authoritative_current_basis
14
+ from androidctld.runtime.store import RuntimeStore
15
+ from androidctld.schema.daemon_api import (
16
+ HealthResult,
17
+ parse_command_run_request,
18
+ require_empty_payload,
19
+ )
20
+
21
+
22
+ class DaemonService:
23
+ def __init__(
24
+ self,
25
+ runtime_store: RuntimeStore,
26
+ command_service: CommandService,
27
+ bound_owner_id: str | None = None,
28
+ ) -> None:
29
+ self._runtime_store = runtime_store
30
+ self._runtime_kernel = RuntimeKernel(runtime_store)
31
+ self._command_service = command_service
32
+ self._bound_owner_id = bound_owner_id
33
+
34
+ def handle(
35
+ self,
36
+ method: str,
37
+ path: str,
38
+ headers: dict[str, str],
39
+ body: bytes,
40
+ ) -> tuple[int, dict[str, Any]]:
41
+ del headers
42
+ if method != "POST":
43
+ raise bad_request(
44
+ "use POST for daemon endpoints", {"path": path, "method": method}
45
+ )
46
+ payload = self._parse_body(body)
47
+ if path == "/health":
48
+ return 200, self._handle_health(payload)
49
+ if path == "/runtime/get":
50
+ return 200, self._handle_runtime_get(payload)
51
+ if path == "/runtime/close":
52
+ return 200, self._handle_runtime_close(payload)
53
+ if path == "/commands/run":
54
+ return 200, self._handle_commands_run(payload)
55
+ raise bad_request("path not found", {"path": path})
56
+
57
+ def _parse_body(self, body: bytes) -> dict[str, Any]:
58
+ if not body or not body.strip():
59
+ return {}
60
+ try:
61
+ payload = json.loads(body.decode("utf-8"))
62
+ except ValueError as error:
63
+ raise bad_request("request body must be valid JSON") from error
64
+ if not isinstance(payload, dict):
65
+ raise bad_request("request body must be a JSON object")
66
+ return payload
67
+
68
+ def _handle_health(self, payload: dict[str, Any]) -> dict[str, Any]:
69
+ require_empty_payload(payload, "health")
70
+ runtime = self._runtime_store.get_runtime()
71
+ return HealthResult(
72
+ service="androidctld",
73
+ version=__version__,
74
+ workspace_root=runtime.workspace_root.as_posix(),
75
+ owner_id=self._bound_owner_id or "",
76
+ ).model_dump(mode="json")
77
+
78
+ def _handle_runtime_get(self, payload: dict[str, Any]) -> dict[str, Any]:
79
+ require_empty_payload(payload, "runtime/get")
80
+ runtime = self._runtime_kernel.ensure_runtime()
81
+ basis = get_authoritative_current_basis(runtime)
82
+ runtime_payload_kwargs: dict[str, Any] = {
83
+ "workspace_root": runtime.workspace_root.as_posix(),
84
+ "artifact_root": runtime.artifact_root.as_posix(),
85
+ "status": runtime.status,
86
+ }
87
+ if basis is not None:
88
+ runtime_payload_kwargs["current_screen_id"] = basis.screen_id
89
+ return RuntimeGetResult(
90
+ runtime=RuntimePayload(**runtime_payload_kwargs)
91
+ ).model_dump(mode="json")
92
+
93
+ def _handle_runtime_close(self, payload: dict[str, Any]) -> dict[str, Any]:
94
+ require_empty_payload(payload, "runtime/close")
95
+ return self._command_service.close_runtime()
96
+
97
+ def _handle_commands_run(self, payload: dict[str, Any]) -> dict[str, Any]:
98
+ request = parse_command_run_request(payload)
99
+ return self._command_service.run(command=request.command)
@@ -0,0 +1 @@
1
+ """Device transport and RPC primitives for androidctld."""
@@ -0,0 +1,154 @@
1
+ """Typed outbound device action request models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TypeAlias
7
+
8
+ from androidctld.refs.models import NodeHandle
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class HandleTarget:
13
+ handle: NodeHandle
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class CoordinatesTarget:
18
+ x: int
19
+ y: int
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class NoneTarget:
24
+ pass
25
+
26
+
27
+ TapTarget: TypeAlias = HandleTarget | CoordinatesTarget
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TapActionRequest:
32
+ target: TapTarget
33
+ timeout_ms: int
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class LongTapActionRequest:
38
+ target: TapTarget
39
+ timeout_ms: int
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class TypeActionRequest:
44
+ target: HandleTarget
45
+ text: str
46
+ timeout_ms: int
47
+ submit: bool = False
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class NodeActionRequest:
52
+ target: HandleTarget
53
+ action: str
54
+ timeout_ms: int
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ScrollActionRequest:
59
+ target: HandleTarget
60
+ direction: str
61
+ timeout_ms: int
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class SwipeActionRequest:
66
+ target: NoneTarget
67
+ direction: str
68
+ timeout_ms: int
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class GlobalActionRequest:
73
+ target: NoneTarget
74
+ action: str
75
+ timeout_ms: int
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class LaunchAppActionRequest:
80
+ target: NoneTarget
81
+ package_name: str
82
+ timeout_ms: int
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class OpenUrlActionRequest:
87
+ target: NoneTarget
88
+ url: str
89
+ timeout_ms: int
90
+
91
+
92
+ DeviceActionTarget: TypeAlias = HandleTarget | CoordinatesTarget | NoneTarget
93
+
94
+ DeviceActionRequest: TypeAlias = (
95
+ TapActionRequest
96
+ | LongTapActionRequest
97
+ | TypeActionRequest
98
+ | NodeActionRequest
99
+ | ScrollActionRequest
100
+ | SwipeActionRequest
101
+ | GlobalActionRequest
102
+ | LaunchAppActionRequest
103
+ | OpenUrlActionRequest
104
+ )
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class BuiltDeviceActionRequest:
109
+ payload: DeviceActionRequest
110
+ request_handle: NodeHandle | None = None
111
+ dispatched_handle: NodeHandle | None = None
112
+ submit_route: str | None = None
113
+
114
+
115
+ def required_action_kind_for_request(request: DeviceActionRequest) -> str:
116
+ if isinstance(request, TapActionRequest):
117
+ return "tap"
118
+ if isinstance(request, LongTapActionRequest):
119
+ return "longTap"
120
+ if isinstance(request, TypeActionRequest):
121
+ return "type"
122
+ if isinstance(request, NodeActionRequest):
123
+ return "node"
124
+ if isinstance(request, ScrollActionRequest):
125
+ return "scroll"
126
+ if isinstance(request, SwipeActionRequest):
127
+ return "gesture"
128
+ if isinstance(request, GlobalActionRequest):
129
+ return "global"
130
+ if isinstance(request, LaunchAppActionRequest):
131
+ return "launchApp"
132
+ if isinstance(request, OpenUrlActionRequest):
133
+ return "openUrl"
134
+ raise TypeError(f"unsupported device action request: {type(request)!r}")
135
+
136
+
137
+ __all__ = [
138
+ "CoordinatesTarget",
139
+ "DeviceActionRequest",
140
+ "DeviceActionTarget",
141
+ "GlobalActionRequest",
142
+ "HandleTarget",
143
+ "LaunchAppActionRequest",
144
+ "LongTapActionRequest",
145
+ "NodeActionRequest",
146
+ "NoneTarget",
147
+ "OpenUrlActionRequest",
148
+ "ScrollActionRequest",
149
+ "SwipeActionRequest",
150
+ "TapActionRequest",
151
+ "TapTarget",
152
+ "TypeActionRequest",
153
+ "required_action_kind_for_request",
154
+ ]
@@ -0,0 +1,121 @@
1
+ """Serialization helpers for outbound device action requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from androidctld.device.action_models import (
8
+ CoordinatesTarget,
9
+ DeviceActionRequest,
10
+ DeviceActionTarget,
11
+ GlobalActionRequest,
12
+ HandleTarget,
13
+ LaunchAppActionRequest,
14
+ LongTapActionRequest,
15
+ NodeActionRequest,
16
+ NoneTarget,
17
+ OpenUrlActionRequest,
18
+ ScrollActionRequest,
19
+ SwipeActionRequest,
20
+ TapActionRequest,
21
+ TypeActionRequest,
22
+ )
23
+ from androidctld.device.adapters import dump_node_handle
24
+
25
+
26
+ def dump_device_action_request(
27
+ request: DeviceActionRequest,
28
+ ) -> dict[str, Any]:
29
+ if isinstance(request, TapActionRequest):
30
+ return {
31
+ "kind": "tap",
32
+ "target": dump_device_action_target(request.target),
33
+ "options": {"timeoutMs": request.timeout_ms},
34
+ }
35
+ if isinstance(request, LongTapActionRequest):
36
+ return {
37
+ "kind": "longTap",
38
+ "target": dump_device_action_target(request.target),
39
+ "options": {"timeoutMs": request.timeout_ms},
40
+ }
41
+ if isinstance(request, TypeActionRequest):
42
+ return {
43
+ "kind": "type",
44
+ "target": dump_device_action_target(request.target),
45
+ "input": {
46
+ "text": request.text,
47
+ "replace": True,
48
+ "submit": request.submit,
49
+ "ensureFocused": True,
50
+ },
51
+ "options": {"timeoutMs": request.timeout_ms},
52
+ }
53
+ if isinstance(request, NodeActionRequest):
54
+ return {
55
+ "kind": "node",
56
+ "target": dump_device_action_target(request.target),
57
+ "node": {"action": request.action},
58
+ "options": {"timeoutMs": request.timeout_ms},
59
+ }
60
+ if isinstance(request, ScrollActionRequest):
61
+ return {
62
+ "kind": "scroll",
63
+ "target": dump_device_action_target(request.target),
64
+ "scroll": {"direction": request.direction},
65
+ "options": {"timeoutMs": request.timeout_ms},
66
+ }
67
+ if isinstance(request, SwipeActionRequest):
68
+ if not isinstance(request.target, NoneTarget):
69
+ raise TypeError("swipe action requires none target")
70
+ return {
71
+ "kind": "gesture",
72
+ "target": dump_device_action_target(request.target),
73
+ "gesture": {"direction": request.direction},
74
+ "options": {"timeoutMs": request.timeout_ms},
75
+ }
76
+ if isinstance(request, GlobalActionRequest):
77
+ return {
78
+ "kind": "global",
79
+ "target": dump_device_action_target(request.target),
80
+ "global": {"action": request.action},
81
+ "options": {"timeoutMs": request.timeout_ms},
82
+ }
83
+ if isinstance(request, LaunchAppActionRequest):
84
+ return {
85
+ "kind": "launchApp",
86
+ "target": dump_device_action_target(request.target),
87
+ "intent": {
88
+ "packageName": request.package_name,
89
+ },
90
+ "options": {"timeoutMs": request.timeout_ms},
91
+ }
92
+ if isinstance(request, OpenUrlActionRequest):
93
+ return {
94
+ "kind": "openUrl",
95
+ "target": dump_device_action_target(request.target),
96
+ "intent": {
97
+ "url": request.url,
98
+ },
99
+ "options": {"timeoutMs": request.timeout_ms},
100
+ }
101
+ raise TypeError(f"unsupported device action request: {type(request)!r}")
102
+
103
+
104
+ def dump_device_action_target(target: DeviceActionTarget) -> dict[str, Any]:
105
+ if isinstance(target, HandleTarget):
106
+ return {
107
+ "kind": "handle",
108
+ "handle": dump_node_handle(target.handle),
109
+ }
110
+ if isinstance(target, CoordinatesTarget):
111
+ return {
112
+ "kind": "coordinates",
113
+ "x": target.x,
114
+ "y": target.y,
115
+ }
116
+ if isinstance(target, NoneTarget):
117
+ return {"kind": "none"}
118
+ raise TypeError(f"unsupported device action target: {type(target)!r}")
119
+
120
+
121
+ __all__ = ["dump_device_action_request", "dump_device_action_target"]