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.
- androidctl/__init__.py +5 -0
- androidctl/__main__.py +4 -0
- androidctl/_version.py +1 -0
- androidctl/app.py +73 -0
- androidctl/cli_options.py +27 -0
- androidctl/command_payloads.py +264 -0
- androidctl/command_views.py +157 -0
- androidctl/commands/__init__.py +1 -0
- androidctl/commands/actions.py +236 -0
- androidctl/commands/adb_wireless.py +157 -0
- androidctl/commands/close.py +30 -0
- androidctl/commands/connect.py +69 -0
- androidctl/commands/execute.py +179 -0
- androidctl/commands/list_apps.py +26 -0
- androidctl/commands/observe.py +26 -0
- androidctl/commands/open.py +41 -0
- androidctl/commands/plumbing.py +58 -0
- androidctl/commands/run_pipeline.py +307 -0
- androidctl/commands/screenshot.py +29 -0
- androidctl/commands/setup.py +301 -0
- androidctl/commands/wait.py +60 -0
- androidctl/daemon/__init__.py +1 -0
- androidctl/daemon/client.py +348 -0
- androidctl/daemon/discovery.py +190 -0
- androidctl/daemon/launcher.py +26 -0
- androidctl/daemon/owner.py +349 -0
- androidctl/errors/__init__.py +1 -0
- androidctl/errors/mapping.py +149 -0
- androidctl/errors/models.py +16 -0
- androidctl/exit_codes.py +8 -0
- androidctl/output.py +147 -0
- androidctl/parsing/__init__.py +1 -0
- androidctl/parsing/duration.py +17 -0
- androidctl/parsing/open_target.py +51 -0
- androidctl/parsing/refs.py +12 -0
- androidctl/parsing/screen_id.py +10 -0
- androidctl/parsing/wait.py +70 -0
- androidctl/renderers/__init__.py +110 -0
- androidctl/renderers/_paths.py +109 -0
- androidctl/renderers/xml.py +234 -0
- androidctl/renderers/xml_projection.py +732 -0
- androidctl/resources/__init__.py +1 -0
- androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
- androidctl/setup/__init__.py +1 -0
- androidctl/setup/accessibility.py +159 -0
- androidctl/setup/adb.py +586 -0
- androidctl/setup/apk_resource.py +29 -0
- androidctl/setup/pairing.py +70 -0
- androidctl/setup/verify.py +175 -0
- androidctl/workspace/__init__.py +3 -0
- androidctl/workspace/resolve.py +27 -0
- androidctl-0.1.0.dist-info/METADATA +217 -0
- androidctl-0.1.0.dist-info/RECORD +187 -0
- androidctl-0.1.0.dist-info/WHEEL +5 -0
- androidctl-0.1.0.dist-info/entry_points.txt +3 -0
- androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- androidctl-0.1.0.dist-info/top_level.txt +3 -0
- androidctl_contracts/__init__.py +55 -0
- androidctl_contracts/_version.py +1 -0
- androidctl_contracts/_wire_helpers.py +31 -0
- androidctl_contracts/base.py +142 -0
- androidctl_contracts/command_catalog.py +414 -0
- androidctl_contracts/command_results.py +630 -0
- androidctl_contracts/daemon_api.py +335 -0
- androidctl_contracts/errors.py +44 -0
- androidctl_contracts/paths.py +5 -0
- androidctl_contracts/public_screen.py +579 -0
- androidctl_contracts/user_state.py +23 -0
- androidctl_contracts/vocabulary.py +82 -0
- androidctld/__init__.py +5 -0
- androidctld/__main__.py +63 -0
- androidctld/_version.py +1 -0
- androidctld/actions/__init__.py +1 -0
- androidctld/actions/action_target.py +142 -0
- androidctld/actions/capabilities.py +539 -0
- androidctld/actions/executor.py +894 -0
- androidctld/actions/focus_confirmation.py +177 -0
- androidctld/actions/focused_input_admissibility.py +120 -0
- androidctld/actions/fresh_current.py +176 -0
- androidctld/actions/postconditions.py +473 -0
- androidctld/actions/repair.py +101 -0
- androidctld/actions/request_builder.py +204 -0
- androidctld/actions/settle.py +146 -0
- androidctld/actions/submit_confirmation.py +211 -0
- androidctld/actions/submit_routing.py +311 -0
- androidctld/actions/type_confirmation.py +257 -0
- androidctld/app_targets.py +71 -0
- androidctld/artifacts/__init__.py +1 -0
- androidctld/artifacts/models.py +26 -0
- androidctld/artifacts/screen_lookup.py +241 -0
- androidctld/artifacts/screen_payloads.py +109 -0
- androidctld/artifacts/writer.py +286 -0
- androidctld/auth/__init__.py +1 -0
- androidctld/auth/active_registry.py +266 -0
- androidctld/auth/secret_files.py +52 -0
- androidctld/auth/token_store.py +59 -0
- androidctld/commands/__init__.py +1 -0
- androidctld/commands/assembly.py +231 -0
- androidctld/commands/command_models.py +254 -0
- androidctld/commands/dispatch.py +99 -0
- androidctld/commands/executor.py +31 -0
- androidctld/commands/from_boundary.py +175 -0
- androidctld/commands/handlers/__init__.py +15 -0
- androidctld/commands/handlers/action.py +439 -0
- androidctld/commands/handlers/connect.py +94 -0
- androidctld/commands/handlers/list_apps.py +215 -0
- androidctld/commands/handlers/observe.py +121 -0
- androidctld/commands/handlers/screenshot.py +105 -0
- androidctld/commands/handlers/wait.py +286 -0
- androidctld/commands/models.py +65 -0
- androidctld/commands/open_targets.py +56 -0
- androidctld/commands/orchestration.py +353 -0
- androidctld/commands/registry.py +116 -0
- androidctld/commands/result_builders.py +40 -0
- androidctld/commands/result_models.py +555 -0
- androidctld/commands/results.py +108 -0
- androidctld/commands/semantic_command_names.py +17 -0
- androidctld/commands/semantic_error_mapping.py +93 -0
- androidctld/commands/semantic_truth.py +135 -0
- androidctld/commands/service.py +67 -0
- androidctld/config.py +75 -0
- androidctld/daemon/__init__.py +1 -0
- androidctld/daemon/active_slot.py +326 -0
- androidctld/daemon/envelope.py +30 -0
- androidctld/daemon/http_host.py +123 -0
- androidctld/daemon/ingress.py +112 -0
- androidctld/daemon/ownership_probe.py +204 -0
- androidctld/daemon/server.py +286 -0
- androidctld/daemon/service.py +99 -0
- androidctld/device/__init__.py +1 -0
- androidctld/device/action_models.py +154 -0
- androidctld/device/action_serialization.py +121 -0
- androidctld/device/adapters.py +220 -0
- androidctld/device/bootstrap.py +153 -0
- androidctld/device/connectors.py +231 -0
- androidctld/device/errors.py +100 -0
- androidctld/device/interfaces.py +58 -0
- androidctld/device/parsing.py +320 -0
- androidctld/device/rpc.py +483 -0
- androidctld/device/schema.py +114 -0
- androidctld/device/types.py +161 -0
- androidctld/errors/__init__.py +94 -0
- androidctld/logging/__init__.py +22 -0
- androidctld/observation.py +98 -0
- androidctld/protocol.py +53 -0
- androidctld/refs/__init__.py +1 -0
- androidctld/refs/models.py +54 -0
- androidctld/refs/repair.py +284 -0
- androidctld/refs/service.py +422 -0
- androidctld/rendering/__init__.py +1 -0
- androidctld/rendering/screen_xml.py +256 -0
- androidctld/runtime/__init__.py +21 -0
- androidctld/runtime/kernel.py +548 -0
- androidctld/runtime/lifecycle.py +19 -0
- androidctld/runtime/models.py +48 -0
- androidctld/runtime/screen_state.py +117 -0
- androidctld/runtime/state_repo.py +70 -0
- androidctld/runtime/store.py +76 -0
- androidctld/runtime_policy.py +127 -0
- androidctld/schema/__init__.py +5 -0
- androidctld/schema/base.py +132 -0
- androidctld/schema/core.py +35 -0
- androidctld/schema/daemon_api.py +108 -0
- androidctld/schema/persistence.py +161 -0
- androidctld/schema/persistence_io.py +41 -0
- androidctld/schema/validation_errors.py +309 -0
- androidctld/semantics/__init__.py +1 -0
- androidctld/semantics/compiler.py +610 -0
- androidctld/semantics/continuity.py +107 -0
- androidctld/semantics/labels.py +252 -0
- androidctld/semantics/models.py +25 -0
- androidctld/semantics/policy.py +23 -0
- androidctld/semantics/public_models.py +123 -0
- androidctld/semantics/registries.py +13 -0
- androidctld/semantics/submit_refs.py +417 -0
- androidctld/semantics/surface.py +254 -0
- androidctld/semantics/targets.py +167 -0
- androidctld/snapshots/__init__.py +1 -0
- androidctld/snapshots/models.py +219 -0
- androidctld/snapshots/refresh.py +273 -0
- androidctld/snapshots/schema.py +74 -0
- androidctld/snapshots/service.py +138 -0
- androidctld/text_equivalence.py +67 -0
- androidctld/waits/__init__.py +1 -0
- androidctld/waits/evaluators.py +216 -0
- androidctld/waits/loop.py +305 -0
- 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
|