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,483 @@
1
+ """Device RPC client for the Android agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from http.client import RemoteDisconnected
8
+ from typing import Any
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.request import Request, urlopen
11
+
12
+ from androidctld.device.action_models import DeviceActionRequest
13
+ from androidctld.device.action_serialization import dump_device_action_request
14
+ from androidctld.device.errors import (
15
+ device_agent_unauthorized,
16
+ device_agent_unavailable,
17
+ device_rpc_failed,
18
+ device_rpc_transport_reset,
19
+ )
20
+ from androidctld.device.parsing import (
21
+ parse_action_perform_result,
22
+ parse_events_poll_result,
23
+ parse_meta_payload,
24
+ parse_rpc_error_payload,
25
+ parse_screenshot_capture_result,
26
+ )
27
+ from androidctld.device.types import (
28
+ ActionPerformResult,
29
+ DeviceEndpoint,
30
+ EventsPollResult,
31
+ MetaInfo,
32
+ ScreenshotCaptureResult,
33
+ )
34
+ from androidctld.protocol import DeviceRpcMethod
35
+ from androidctld.runtime_policy import (
36
+ DEFAULT_DEVICE_RPC_TIMEOUT_SECONDS,
37
+ DEVICE_RPC_MAX_RESPONSE_BYTES,
38
+ DEVICE_RPC_REQUEST_ID_BOOTSTRAP,
39
+ SCREENSHOT_DEVICE_RPC_TIMEOUT_SECONDS,
40
+ SCREENSHOT_MAX_RPC_RESPONSE_BYTES,
41
+ default_screenshot_params,
42
+ default_snapshot_params,
43
+ )
44
+ from androidctld.snapshots.models import RawSnapshot, parse_raw_snapshot
45
+
46
+ _RESPONSE_READ_CHUNK_BYTES = 64 * 1024
47
+ _HTTP_ERROR_ENVELOPE_CONTEXT_MAX_BYTES = 16 * 1024
48
+ _HTTP_ERROR_CONTEXT_STRING_MAX_CHARS = 512
49
+ _HTTP_ERROR_SAFE_DETAIL_KEYS = (
50
+ "reason",
51
+ "path",
52
+ "method",
53
+ "max",
54
+ "maxBytes",
55
+ "contentLength",
56
+ "field",
57
+ )
58
+ _SENSITIVE_DETAIL_KEY_PARTS = (
59
+ "authorization",
60
+ "bearer",
61
+ "token",
62
+ "password",
63
+ "passwd",
64
+ "secret",
65
+ "credential",
66
+ "apiKey",
67
+ "api_key",
68
+ "accessKey",
69
+ "access_key",
70
+ )
71
+ _SENSITIVE_VALUE_PARTS = (
72
+ "authorization:",
73
+ "bearer ",
74
+ "token=",
75
+ "password=",
76
+ "secret=",
77
+ "http://",
78
+ "https://",
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class _SafeHttpErrorEnvelope:
84
+ details: dict[str, Any]
85
+ device_code: str
86
+ device_retryable: bool
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class _HttpErrorContext:
91
+ details: dict[str, Any]
92
+ envelope: _SafeHttpErrorEnvelope | None = None
93
+
94
+
95
+ def _transport_reset_reason(error: URLError | BaseException) -> tuple[str, str] | None:
96
+ candidate: object = error.reason if isinstance(error, URLError) else error
97
+ if isinstance(
98
+ candidate,
99
+ (ConnectionResetError, ConnectionAbortedError, RemoteDisconnected),
100
+ ):
101
+ return ("transport_reset", type(candidate).__name__)
102
+ if isinstance(candidate, OSError) and "reset" in str(candidate).lower():
103
+ return ("transport_reset", type(candidate).__name__)
104
+ return None
105
+
106
+
107
+ class DeviceRpcClient:
108
+ def __init__(
109
+ self,
110
+ endpoint: DeviceEndpoint,
111
+ token: str,
112
+ timeout: float = DEFAULT_DEVICE_RPC_TIMEOUT_SECONDS,
113
+ ) -> None:
114
+ self._endpoint = endpoint
115
+ self._token = token
116
+ self._timeout = timeout
117
+
118
+ def call_result_payload(
119
+ self,
120
+ method: str,
121
+ params: dict[str, Any] | None = None,
122
+ *,
123
+ request_id: str,
124
+ ) -> object:
125
+ if type(method) is not str:
126
+ raise TypeError("device rpc method must be a string")
127
+ return self._call_result_payload(method, params=params, request_id=request_id)
128
+
129
+ def meta_get(self, request_id: str = DEVICE_RPC_REQUEST_ID_BOOTSTRAP) -> MetaInfo:
130
+ return parse_meta_payload(
131
+ self._call_result_payload(
132
+ DeviceRpcMethod.META_GET,
133
+ params=None,
134
+ request_id=request_id,
135
+ )
136
+ )
137
+
138
+ def snapshot_get(
139
+ self,
140
+ request_id: str = DEVICE_RPC_REQUEST_ID_BOOTSTRAP,
141
+ params: dict[str, Any] | None = None,
142
+ ) -> RawSnapshot:
143
+ return parse_raw_snapshot(
144
+ self._call_result_payload(
145
+ DeviceRpcMethod.SNAPSHOT_GET,
146
+ params=default_snapshot_params() if params is None else params,
147
+ request_id=request_id,
148
+ )
149
+ )
150
+
151
+ def action_perform(
152
+ self, request: DeviceActionRequest, request_id: str
153
+ ) -> ActionPerformResult:
154
+ return parse_action_perform_result(
155
+ self._call_result_payload(
156
+ DeviceRpcMethod.ACTION_PERFORM,
157
+ params=dump_device_action_request(request),
158
+ request_id=request_id,
159
+ )
160
+ )
161
+
162
+ def events_poll(
163
+ self, after_seq: int, wait_ms: int, limit: int, request_id: str
164
+ ) -> EventsPollResult:
165
+ return parse_events_poll_result(
166
+ self._call_result_payload(
167
+ DeviceRpcMethod.EVENTS_POLL,
168
+ params={
169
+ "afterSeq": after_seq,
170
+ "waitMs": wait_ms,
171
+ "limit": limit,
172
+ },
173
+ request_id=request_id,
174
+ )
175
+ )
176
+
177
+ def screenshot_capture(self, request_id: str) -> ScreenshotCaptureResult:
178
+ return parse_screenshot_capture_result(
179
+ self._call_result_payload(
180
+ DeviceRpcMethod.SCREENSHOT_CAPTURE,
181
+ params=default_screenshot_params(),
182
+ request_id=request_id,
183
+ )
184
+ )
185
+
186
+ def _call_result_payload(
187
+ self,
188
+ method: DeviceRpcMethod | str,
189
+ params: dict[str, Any] | None,
190
+ request_id: str,
191
+ ) -> object:
192
+ method_name = method.value if isinstance(method, DeviceRpcMethod) else method
193
+ body = json.dumps(
194
+ {
195
+ "id": request_id,
196
+ "method": method_name,
197
+ "params": {} if params is None else params,
198
+ },
199
+ separators=(",", ":"),
200
+ ).encode("utf-8")
201
+ request = Request(
202
+ f"{self._endpoint.base_url}/rpc",
203
+ method="POST",
204
+ data=body,
205
+ headers={
206
+ "Authorization": f"Bearer {self._token}",
207
+ "Content-Type": "application/json",
208
+ },
209
+ )
210
+ try:
211
+ timeout_seconds = _timeout_seconds(
212
+ method_name,
213
+ default_timeout=self._timeout,
214
+ )
215
+ with urlopen(request, timeout=timeout_seconds) as response:
216
+ try:
217
+ response_body = _read_limited_response(
218
+ response,
219
+ method_name=method_name,
220
+ max_bytes=_max_response_bytes(method_name),
221
+ )
222
+ payload = json.loads(response_body.decode("utf-8"))
223
+ except ValueError as error:
224
+ raise device_rpc_failed(
225
+ "device RPC response must be valid JSON",
226
+ {"reason": str(error)},
227
+ retryable=False,
228
+ ) from error
229
+ except HTTPError as error:
230
+ context = _http_error_context(error)
231
+ if context.envelope is not None:
232
+ if context.envelope.device_code == "UNAUTHORIZED":
233
+ raise device_agent_unauthorized(
234
+ "device agent rejected HTTP request",
235
+ context.details,
236
+ retryable=context.envelope.device_retryable,
237
+ ) from error
238
+ raise device_rpc_failed(
239
+ "device agent rejected HTTP request",
240
+ context.details,
241
+ retryable=context.envelope.device_retryable,
242
+ ) from error
243
+ if error.code in {401, 403}:
244
+ raise device_agent_unauthorized(
245
+ "device agent rejected HTTP request", context.details
246
+ ) from error
247
+ raise device_agent_unavailable(
248
+ "device agent rejected HTTP request", context.details
249
+ ) from error
250
+ except (
251
+ ConnectionResetError,
252
+ ConnectionAbortedError,
253
+ RemoteDisconnected,
254
+ ) as error:
255
+ reason, exception_name = _transport_reset_reason(error) or (
256
+ "transport_reset",
257
+ type(error).__name__,
258
+ )
259
+ raise device_rpc_transport_reset(
260
+ "device RPC transport was reset",
261
+ {"reason": reason, "exception": exception_name},
262
+ ) from error
263
+ except TimeoutError as error:
264
+ raise device_agent_unavailable(
265
+ "device RPC timed out",
266
+ {
267
+ "reason": "device_rpc_timeout",
268
+ "method": method_name,
269
+ "timeoutSeconds": timeout_seconds,
270
+ },
271
+ ) from error
272
+ except URLError as error:
273
+ reset = _transport_reset_reason(error)
274
+ if reset is not None:
275
+ reason, exception_name = reset
276
+ raise device_rpc_transport_reset(
277
+ "device RPC transport was reset",
278
+ {"reason": reason, "exception": exception_name},
279
+ ) from error
280
+ if isinstance(error.reason, TimeoutError):
281
+ raise device_agent_unavailable(
282
+ "device RPC timed out",
283
+ {
284
+ "reason": "device_rpc_timeout",
285
+ "method": method_name,
286
+ "timeoutSeconds": timeout_seconds,
287
+ },
288
+ ) from error
289
+ raise device_agent_unavailable(
290
+ "device agent is unavailable",
291
+ {"reason": str(error.reason)},
292
+ ) from error
293
+
294
+ if not isinstance(payload, dict):
295
+ raise device_rpc_failed(
296
+ "device RPC response must be a JSON object", retryable=False
297
+ )
298
+ ok = payload.get("ok")
299
+ if ok is True:
300
+ return payload.get("result")
301
+ if ok is False:
302
+ raise parse_rpc_error_payload(payload.get("error"))
303
+ raise device_rpc_failed(
304
+ "device RPC ok must be a boolean", {"field": "ok"}, retryable=False
305
+ )
306
+
307
+
308
+ def _max_response_bytes(method_name: str) -> int:
309
+ if method_name == DeviceRpcMethod.SCREENSHOT_CAPTURE.value:
310
+ return SCREENSHOT_MAX_RPC_RESPONSE_BYTES
311
+ return DEVICE_RPC_MAX_RESPONSE_BYTES
312
+
313
+
314
+ def _timeout_seconds(method_name: str, *, default_timeout: float) -> float:
315
+ if method_name == DeviceRpcMethod.SCREENSHOT_CAPTURE.value:
316
+ return SCREENSHOT_DEVICE_RPC_TIMEOUT_SECONDS
317
+ return default_timeout
318
+
319
+
320
+ def _http_error_context(error: HTTPError) -> _HttpErrorContext:
321
+ details: dict[str, Any] = {"status": error.code}
322
+ read_result = _read_limited_http_error_body(
323
+ error.fp,
324
+ max_bytes=_HTTP_ERROR_ENVELOPE_CONTEXT_MAX_BYTES,
325
+ )
326
+ if read_result is None:
327
+ return _HttpErrorContext(details)
328
+ body, truncated = read_result
329
+ if truncated:
330
+ details.update(
331
+ {
332
+ "reason": "device_rpc_http_error_body_too_large",
333
+ "maxBytes": _HTTP_ERROR_ENVELOPE_CONTEXT_MAX_BYTES,
334
+ }
335
+ )
336
+ return _HttpErrorContext(details)
337
+ if not body:
338
+ return _HttpErrorContext(details)
339
+ try:
340
+ payload = json.loads(body.decode("utf-8"))
341
+ except (UnicodeDecodeError, ValueError):
342
+ return _HttpErrorContext(details)
343
+ envelope = _safe_http_error_envelope(payload)
344
+ if envelope is None:
345
+ return _HttpErrorContext(details)
346
+ details.update(envelope.details)
347
+ return _HttpErrorContext(details=details, envelope=envelope)
348
+
349
+
350
+ def _read_limited_http_error_body(
351
+ fp: Any,
352
+ *,
353
+ max_bytes: int,
354
+ ) -> tuple[bytes, bool] | None:
355
+ if fp is None:
356
+ return None
357
+ try:
358
+ chunk = fp.read(max_bytes + 1)
359
+ except (OSError, ValueError):
360
+ return None
361
+ if not isinstance(chunk, bytes):
362
+ return None
363
+ if len(chunk) > max_bytes:
364
+ return chunk[:max_bytes], True
365
+ return chunk, False
366
+
367
+
368
+ def _safe_http_error_envelope(payload: object) -> _SafeHttpErrorEnvelope | None:
369
+ if not isinstance(payload, dict):
370
+ return None
371
+ if payload.get("ok") is not False:
372
+ return None
373
+ error = payload.get("error")
374
+ if not isinstance(error, dict):
375
+ return None
376
+ code = _trimmed_context_string(error.get("code"))
377
+ raw_message = _trimmed_context_string(error.get("message"))
378
+ retryable = error.get("retryable")
379
+ error_details = error.get("details")
380
+ if (
381
+ code is None
382
+ or raw_message is None
383
+ or not isinstance(retryable, bool)
384
+ or not isinstance(error_details, dict)
385
+ ):
386
+ return None
387
+
388
+ details: dict[str, Any] = {
389
+ "deviceCode": code,
390
+ "deviceRetryable": retryable,
391
+ }
392
+ message = _safe_context_string(raw_message)
393
+ if message is not None:
394
+ details["deviceMessage"] = message
395
+ for key in _HTTP_ERROR_SAFE_DETAIL_KEYS:
396
+ if _is_sensitive_detail_key(key):
397
+ continue
398
+ value = _safe_http_error_detail_value(key, error_details.get(key))
399
+ if value is not None:
400
+ details[key] = value
401
+ return _SafeHttpErrorEnvelope(
402
+ details=details,
403
+ device_code=code,
404
+ device_retryable=retryable,
405
+ )
406
+
407
+
408
+ def _safe_http_error_detail_value(
409
+ key: str, value: object
410
+ ) -> str | int | float | bool | None:
411
+ if isinstance(value, bool):
412
+ return value
413
+ if isinstance(value, int):
414
+ return value
415
+ if isinstance(value, float):
416
+ if value != value or value in {float("inf"), float("-inf")}:
417
+ return None
418
+ return value
419
+ if isinstance(value, str):
420
+ safe_value = _safe_context_string(value)
421
+ if safe_value is None:
422
+ return None
423
+ if key == "path" and (
424
+ not safe_value.startswith("/")
425
+ or "?" in safe_value
426
+ or "#" in safe_value
427
+ or "://" in safe_value
428
+ ):
429
+ return None
430
+ return safe_value
431
+ return None
432
+
433
+
434
+ def _safe_context_string(value: object) -> str | None:
435
+ normalized = _trimmed_context_string(value)
436
+ if normalized is None or _contains_sensitive_value(normalized):
437
+ return None
438
+ return normalized
439
+
440
+
441
+ def _trimmed_context_string(value: object) -> str | None:
442
+ if not isinstance(value, str):
443
+ return None
444
+ normalized = value.strip()
445
+ if not normalized or len(normalized) > _HTTP_ERROR_CONTEXT_STRING_MAX_CHARS:
446
+ return None
447
+ return normalized
448
+
449
+
450
+ def _is_sensitive_detail_key(key: str) -> bool:
451
+ normalized = key.lower()
452
+ return any(part.lower() in normalized for part in _SENSITIVE_DETAIL_KEY_PARTS)
453
+
454
+
455
+ def _contains_sensitive_value(value: str) -> bool:
456
+ normalized = value.lower()
457
+ return any(part in normalized for part in _SENSITIVE_VALUE_PARTS)
458
+
459
+
460
+ def _read_limited_response(
461
+ response: Any,
462
+ *,
463
+ method_name: str,
464
+ max_bytes: int,
465
+ ) -> bytes:
466
+ chunks: list[bytes] = []
467
+ total = 0
468
+ while True:
469
+ chunk = response.read(min(_RESPONSE_READ_CHUNK_BYTES, max_bytes + 1 - total))
470
+ if not chunk:
471
+ return b"".join(chunks)
472
+ chunks.append(chunk)
473
+ total += len(chunk)
474
+ if total > max_bytes:
475
+ raise device_rpc_failed(
476
+ "device RPC response exceeds size budget",
477
+ {
478
+ "reason": "device_rpc_response_too_large",
479
+ "method": method_name,
480
+ "maxBytes": max_bytes,
481
+ },
482
+ retryable=False,
483
+ )
@@ -0,0 +1,114 @@
1
+ """Boundary DTOs for device RPC payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Any, Literal
6
+
7
+ from pydantic import Field, StringConstraints, ValidationInfo, field_validator
8
+
9
+ from androidctld.schema import ApiModel
10
+
11
+ TrimmedString = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
12
+ NonNegativeInt = Annotated[int, Field(ge=0)]
13
+ PositiveInt = Annotated[int, Field(ge=1)]
14
+
15
+
16
+ ActionStatusValue = Literal["done", "partial", "timeout"]
17
+
18
+
19
+ class DeviceCapabilitiesPayload(ApiModel):
20
+ supports_events_poll: bool
21
+ supports_screenshot: bool
22
+ action_kinds: list[TrimmedString]
23
+
24
+
25
+ class MetaPayload(ApiModel):
26
+ service: TrimmedString
27
+ version: TrimmedString
28
+ capabilities: DeviceCapabilitiesPayload
29
+
30
+
31
+ class RpcErrorPayload(ApiModel):
32
+ code: TrimmedString
33
+ message: TrimmedString
34
+ retryable: bool
35
+ details: dict[str, Any]
36
+
37
+
38
+ class NodeHandlePayload(ApiModel):
39
+ snapshot_id: NonNegativeInt
40
+ rid: TrimmedString
41
+
42
+
43
+ class ResolvedHandleTargetPayload(ApiModel):
44
+ kind: Literal["handle"]
45
+ handle: NodeHandlePayload
46
+
47
+
48
+ class ResolvedCoordinatesTargetPayload(ApiModel):
49
+ kind: Literal["coordinates"]
50
+ x: float
51
+ y: float
52
+
53
+
54
+ class ResolvedNoneTargetPayload(ApiModel):
55
+ kind: Literal["none"]
56
+
57
+
58
+ ActionResolvedTargetPayload = Annotated[
59
+ ResolvedHandleTargetPayload
60
+ | ResolvedCoordinatesTargetPayload
61
+ | ResolvedNoneTargetPayload,
62
+ Field(discriminator="kind"),
63
+ ]
64
+
65
+
66
+ class ObservedAppPayload(ApiModel):
67
+ package_name: str | None = None
68
+ activity_name: str | None = None
69
+
70
+ @field_validator("package_name", "activity_name", mode="before")
71
+ @classmethod
72
+ def _normalize_blank_strings(
73
+ cls,
74
+ value: object,
75
+ info: ValidationInfo,
76
+ ) -> object:
77
+ if isinstance(info.context, dict) and not info.context.get(
78
+ "normalize_blank_observed_strings", True
79
+ ):
80
+ return value
81
+ if value is None:
82
+ return None
83
+ if isinstance(value, str) and not value.strip():
84
+ return None
85
+ return value
86
+
87
+
88
+ class ActionPerformResultPayload(ApiModel):
89
+ action_id: TrimmedString
90
+ status: ActionStatusValue
91
+ duration_ms: NonNegativeInt | None = None
92
+ resolved_target: ActionResolvedTargetPayload | None = None
93
+ observed: ObservedAppPayload | None = None
94
+
95
+
96
+ class DeviceEventPayload(ApiModel):
97
+ seq: NonNegativeInt
98
+ type: TrimmedString
99
+ timestamp: TrimmedString
100
+ data: dict[str, Any]
101
+
102
+
103
+ class EventsPollResultPayload(ApiModel):
104
+ events: list[DeviceEventPayload]
105
+ latest_seq: NonNegativeInt
106
+ need_resync: bool
107
+ timed_out: bool
108
+
109
+
110
+ class ScreenshotCaptureResultPayload(ApiModel):
111
+ content_type: TrimmedString
112
+ width_px: PositiveInt
113
+ height_px: PositiveInt
114
+ body_base64: TrimmedString