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,220 @@
|
|
|
1
|
+
"""Adapters from device boundary DTOs into runtime types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from androidctld.device.errors import (
|
|
8
|
+
DeviceBootstrapError,
|
|
9
|
+
device_agent_unauthorized,
|
|
10
|
+
device_rpc_failed,
|
|
11
|
+
)
|
|
12
|
+
from androidctld.device.schema import (
|
|
13
|
+
ActionPerformResultPayload,
|
|
14
|
+
ActionResolvedTargetPayload,
|
|
15
|
+
DeviceCapabilitiesPayload,
|
|
16
|
+
DeviceEventPayload,
|
|
17
|
+
EventsPollResultPayload,
|
|
18
|
+
MetaPayload,
|
|
19
|
+
NodeHandlePayload,
|
|
20
|
+
ObservedAppPayload,
|
|
21
|
+
ResolvedCoordinatesTargetPayload,
|
|
22
|
+
ResolvedHandleTargetPayload,
|
|
23
|
+
ResolvedNoneTargetPayload,
|
|
24
|
+
RpcErrorPayload,
|
|
25
|
+
ScreenshotCaptureResultPayload,
|
|
26
|
+
)
|
|
27
|
+
from androidctld.device.types import (
|
|
28
|
+
ActionPerformResult,
|
|
29
|
+
ActionStatus,
|
|
30
|
+
DeviceCapabilities,
|
|
31
|
+
DeviceEvent,
|
|
32
|
+
EventsPollResult,
|
|
33
|
+
MetaInfo,
|
|
34
|
+
ObservedApp,
|
|
35
|
+
ResolvedCoordinatesTarget,
|
|
36
|
+
ResolvedHandleTarget,
|
|
37
|
+
ResolvedNoneTarget,
|
|
38
|
+
ResolvedTarget,
|
|
39
|
+
ScreenshotCaptureResult,
|
|
40
|
+
)
|
|
41
|
+
from androidctld.refs.models import NodeHandle
|
|
42
|
+
from androidctld.schema.base import dump_api_model
|
|
43
|
+
from androidctld.schema.core import SchemaDecodeError
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def adapt_meta_payload(
|
|
47
|
+
payload: MetaPayload,
|
|
48
|
+
*,
|
|
49
|
+
field_name: str = "result",
|
|
50
|
+
) -> MetaInfo:
|
|
51
|
+
return MetaInfo(
|
|
52
|
+
service=payload.service,
|
|
53
|
+
version=payload.version,
|
|
54
|
+
capabilities=adapt_capabilities(
|
|
55
|
+
payload.capabilities,
|
|
56
|
+
field_name=f"{field_name}.capabilities",
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def adapt_capabilities(
|
|
62
|
+
payload: DeviceCapabilitiesPayload,
|
|
63
|
+
*,
|
|
64
|
+
field_name: str = "result.capabilities",
|
|
65
|
+
) -> DeviceCapabilities:
|
|
66
|
+
del field_name
|
|
67
|
+
return DeviceCapabilities(
|
|
68
|
+
supports_events_poll=payload.supports_events_poll,
|
|
69
|
+
supports_screenshot=payload.supports_screenshot,
|
|
70
|
+
action_kinds=list(payload.action_kinds),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def adapt_rpc_error_payload(payload: RpcErrorPayload) -> DeviceBootstrapError:
|
|
75
|
+
details = {
|
|
76
|
+
"deviceCode": payload.code,
|
|
77
|
+
"retryable": payload.retryable,
|
|
78
|
+
"details": dict(payload.details),
|
|
79
|
+
}
|
|
80
|
+
if payload.code == "UNAUTHORIZED":
|
|
81
|
+
return device_agent_unauthorized(payload.message, details)
|
|
82
|
+
return device_rpc_failed(
|
|
83
|
+
payload.message,
|
|
84
|
+
details,
|
|
85
|
+
retryable=payload.retryable,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def adapt_action_perform_result(
|
|
90
|
+
payload: ActionPerformResultPayload,
|
|
91
|
+
*,
|
|
92
|
+
field_name: str = "result",
|
|
93
|
+
) -> ActionPerformResult:
|
|
94
|
+
resolved_target = None
|
|
95
|
+
if payload.resolved_target is not None:
|
|
96
|
+
resolved_target = adapt_resolved_target(
|
|
97
|
+
payload.resolved_target,
|
|
98
|
+
field_name=f"{field_name}.resolvedTarget",
|
|
99
|
+
)
|
|
100
|
+
observed = None
|
|
101
|
+
if payload.observed is not None:
|
|
102
|
+
observed = adapt_observed_app(
|
|
103
|
+
payload.observed,
|
|
104
|
+
field_name=f"{field_name}.observed",
|
|
105
|
+
)
|
|
106
|
+
return ActionPerformResult(
|
|
107
|
+
action_id=payload.action_id,
|
|
108
|
+
status=adapt_action_status(payload.status, field_name=f"{field_name}.status"),
|
|
109
|
+
duration_ms=payload.duration_ms,
|
|
110
|
+
resolved_target=resolved_target,
|
|
111
|
+
observed=observed,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def build_node_handle_payload(handle: NodeHandle) -> NodeHandlePayload:
|
|
116
|
+
return NodeHandlePayload(snapshot_id=handle.snapshot_id, rid=handle.rid)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def dump_node_handle(handle: NodeHandle) -> dict[str, Any]:
|
|
120
|
+
return dump_api_model(build_node_handle_payload(handle))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def adapt_action_status(
|
|
124
|
+
status: str,
|
|
125
|
+
*,
|
|
126
|
+
field_name: str,
|
|
127
|
+
) -> ActionStatus:
|
|
128
|
+
try:
|
|
129
|
+
return ActionStatus(status)
|
|
130
|
+
except ValueError as error:
|
|
131
|
+
raise SchemaDecodeError(
|
|
132
|
+
field_name,
|
|
133
|
+
"must be one of done|partial|timeout",
|
|
134
|
+
) from error
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def adapt_observed_app(
|
|
138
|
+
payload: ObservedAppPayload,
|
|
139
|
+
*,
|
|
140
|
+
field_name: str = "result.observed",
|
|
141
|
+
) -> ObservedApp:
|
|
142
|
+
del field_name
|
|
143
|
+
return ObservedApp(
|
|
144
|
+
package_name=payload.package_name,
|
|
145
|
+
activity_name=payload.activity_name,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def adapt_resolved_target(
|
|
150
|
+
payload: ActionResolvedTargetPayload,
|
|
151
|
+
*,
|
|
152
|
+
field_name: str = "resolvedTarget",
|
|
153
|
+
) -> ResolvedTarget:
|
|
154
|
+
if isinstance(payload, ResolvedHandleTargetPayload):
|
|
155
|
+
return ResolvedHandleTarget(
|
|
156
|
+
handle=adapt_node_handle(payload.handle, field_name=f"{field_name}.handle"),
|
|
157
|
+
)
|
|
158
|
+
if isinstance(payload, ResolvedCoordinatesTargetPayload):
|
|
159
|
+
return ResolvedCoordinatesTarget(x=payload.x, y=payload.y)
|
|
160
|
+
if isinstance(payload, ResolvedNoneTargetPayload):
|
|
161
|
+
return ResolvedNoneTarget()
|
|
162
|
+
raise SchemaDecodeError(
|
|
163
|
+
f"{field_name}.kind", "must be one of handle|coordinates|none"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def adapt_node_handle(
|
|
168
|
+
payload: NodeHandlePayload,
|
|
169
|
+
*,
|
|
170
|
+
field_name: str = "handle",
|
|
171
|
+
) -> NodeHandle:
|
|
172
|
+
del field_name
|
|
173
|
+
return NodeHandle(
|
|
174
|
+
snapshot_id=payload.snapshot_id,
|
|
175
|
+
rid=payload.rid,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def adapt_events_poll_result(
|
|
180
|
+
payload: EventsPollResultPayload,
|
|
181
|
+
*,
|
|
182
|
+
field_name: str = "result",
|
|
183
|
+
) -> EventsPollResult:
|
|
184
|
+
return EventsPollResult(
|
|
185
|
+
events=tuple(
|
|
186
|
+
adapt_device_event(event, field_name=f"{field_name}.events[{index}]")
|
|
187
|
+
for index, event in enumerate(payload.events)
|
|
188
|
+
),
|
|
189
|
+
latest_seq=payload.latest_seq,
|
|
190
|
+
need_resync=payload.need_resync,
|
|
191
|
+
timed_out=payload.timed_out,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def adapt_device_event(
|
|
196
|
+
payload: DeviceEventPayload,
|
|
197
|
+
*,
|
|
198
|
+
field_name: str = "result.events[0]",
|
|
199
|
+
) -> DeviceEvent:
|
|
200
|
+
del field_name
|
|
201
|
+
return DeviceEvent(
|
|
202
|
+
seq=payload.seq,
|
|
203
|
+
type=payload.type,
|
|
204
|
+
timestamp=payload.timestamp,
|
|
205
|
+
data=dict(payload.data),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def adapt_screenshot_capture_result(
|
|
210
|
+
payload: ScreenshotCaptureResultPayload,
|
|
211
|
+
*,
|
|
212
|
+
field_name: str = "result",
|
|
213
|
+
) -> ScreenshotCaptureResult:
|
|
214
|
+
del field_name
|
|
215
|
+
return ScreenshotCaptureResult(
|
|
216
|
+
content_type=payload.content_type,
|
|
217
|
+
width_px=payload.width_px,
|
|
218
|
+
height_px=payload.height_px,
|
|
219
|
+
body_base64=payload.body_base64,
|
|
220
|
+
)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Bootstrap and readiness probing for the Android device agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from androidctld import __version__ as ANDROIDCTLD_VERSION
|
|
6
|
+
from androidctld.device.connectors import ConnectorHandle, DeviceConnectorFactory
|
|
7
|
+
from androidctld.device.errors import (
|
|
8
|
+
DeviceBootstrapError,
|
|
9
|
+
accessibility_not_ready,
|
|
10
|
+
capability_mismatch,
|
|
11
|
+
version_mismatch,
|
|
12
|
+
)
|
|
13
|
+
from androidctld.device.rpc import DeviceRpcClient
|
|
14
|
+
from androidctld.device.types import (
|
|
15
|
+
BootstrapResult,
|
|
16
|
+
ConnectionConfig,
|
|
17
|
+
MetaInfo,
|
|
18
|
+
RuntimeTransport,
|
|
19
|
+
)
|
|
20
|
+
from androidctld.errors import DaemonError
|
|
21
|
+
from androidctld.protocol import DeviceRpcErrorCode
|
|
22
|
+
|
|
23
|
+
MINIMUM_CONNECT_ACTION_KINDS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
"tap",
|
|
26
|
+
"type",
|
|
27
|
+
"global",
|
|
28
|
+
"launchApp",
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DeviceBootstrapper:
|
|
34
|
+
def __init__(self, connector_factory: DeviceConnectorFactory | None = None) -> None:
|
|
35
|
+
self._connector_factory = connector_factory or DeviceConnectorFactory()
|
|
36
|
+
|
|
37
|
+
def bootstrap(self, config: ConnectionConfig) -> BootstrapResult:
|
|
38
|
+
handle = self.establish_transport(config)
|
|
39
|
+
try:
|
|
40
|
+
return self.bootstrap_runtime(handle, config)
|
|
41
|
+
except Exception:
|
|
42
|
+
handle.close()
|
|
43
|
+
raise
|
|
44
|
+
|
|
45
|
+
def bootstrap_rpc_only(self, config: ConnectionConfig) -> BootstrapResult:
|
|
46
|
+
handle = self.establish_transport(config)
|
|
47
|
+
try:
|
|
48
|
+
return self.bootstrap_runtime_rpc_only(handle, config)
|
|
49
|
+
except Exception:
|
|
50
|
+
handle.close()
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
def establish_transport(self, config: ConnectionConfig) -> ConnectorHandle:
|
|
54
|
+
return self._connector_factory.connect(config)
|
|
55
|
+
|
|
56
|
+
def bootstrap_runtime(
|
|
57
|
+
self,
|
|
58
|
+
handle: ConnectorHandle,
|
|
59
|
+
config: ConnectionConfig,
|
|
60
|
+
) -> BootstrapResult:
|
|
61
|
+
client = DeviceRpcClient(endpoint=handle.endpoint, token=config.token)
|
|
62
|
+
meta = self._fetch_release_compatible_meta(client)
|
|
63
|
+
self._validate_capabilities(meta)
|
|
64
|
+
self._probe_readiness(client)
|
|
65
|
+
return BootstrapResult(
|
|
66
|
+
connection=handle.connection,
|
|
67
|
+
transport=RuntimeTransport(
|
|
68
|
+
endpoint=handle.endpoint,
|
|
69
|
+
close=handle.close,
|
|
70
|
+
),
|
|
71
|
+
meta=meta,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def bootstrap_runtime_rpc_only(
|
|
75
|
+
self,
|
|
76
|
+
handle: ConnectorHandle,
|
|
77
|
+
config: ConnectionConfig,
|
|
78
|
+
) -> BootstrapResult:
|
|
79
|
+
client = DeviceRpcClient(endpoint=handle.endpoint, token=config.token)
|
|
80
|
+
meta = self._fetch_release_compatible_meta(client)
|
|
81
|
+
return BootstrapResult(
|
|
82
|
+
connection=handle.connection,
|
|
83
|
+
transport=RuntimeTransport(
|
|
84
|
+
endpoint=handle.endpoint,
|
|
85
|
+
close=handle.close,
|
|
86
|
+
),
|
|
87
|
+
meta=meta,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _fetch_release_compatible_meta(self, client: DeviceRpcClient) -> MetaInfo:
|
|
91
|
+
try:
|
|
92
|
+
meta = client.meta_get()
|
|
93
|
+
except DeviceBootstrapError as error:
|
|
94
|
+
if _is_legacy_rpc_version_schema_failure(error):
|
|
95
|
+
raise version_mismatch(
|
|
96
|
+
"device agent meta.get payload is incompatible with this "
|
|
97
|
+
"androidctld release; install matching androidctld and Android "
|
|
98
|
+
"agent/APK versions",
|
|
99
|
+
{"reason": "legacy_rpc_version_field"},
|
|
100
|
+
) from error
|
|
101
|
+
raise
|
|
102
|
+
if meta.version != ANDROIDCTLD_VERSION:
|
|
103
|
+
raise version_mismatch(
|
|
104
|
+
"device agent release version mismatch: "
|
|
105
|
+
f"daemon={ANDROIDCTLD_VERSION} agent={meta.version}; "
|
|
106
|
+
"install matching androidctld and Android agent/APK versions",
|
|
107
|
+
{
|
|
108
|
+
"expectedReleaseVersion": ANDROIDCTLD_VERSION,
|
|
109
|
+
"actualReleaseVersion": meta.version,
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
return meta
|
|
113
|
+
|
|
114
|
+
def _validate_capabilities(self, meta: MetaInfo) -> None:
|
|
115
|
+
missing_capabilities: list[str] = []
|
|
116
|
+
if not meta.capabilities.supports_events_poll:
|
|
117
|
+
missing_capabilities.append("supportsEventsPoll")
|
|
118
|
+
missing_action_kinds = sorted(
|
|
119
|
+
MINIMUM_CONNECT_ACTION_KINDS.difference(meta.capabilities.action_kinds)
|
|
120
|
+
)
|
|
121
|
+
if not missing_capabilities and not missing_action_kinds:
|
|
122
|
+
return
|
|
123
|
+
raise capability_mismatch(
|
|
124
|
+
"device agent capability handshake failed",
|
|
125
|
+
{
|
|
126
|
+
"missingCapabilities": missing_capabilities,
|
|
127
|
+
"missingActionKinds": missing_action_kinds,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _probe_readiness(self, client: DeviceRpcClient) -> None:
|
|
132
|
+
try:
|
|
133
|
+
client.snapshot_get()
|
|
134
|
+
except DaemonError as error:
|
|
135
|
+
device_code = error.details.get("deviceCode")
|
|
136
|
+
if device_code in (
|
|
137
|
+
DeviceRpcErrorCode.RUNTIME_NOT_READY.value,
|
|
138
|
+
DeviceRpcErrorCode.ACCESSIBILITY_DISABLED.value,
|
|
139
|
+
):
|
|
140
|
+
raise accessibility_not_ready(
|
|
141
|
+
"accessibility runtime is not ready",
|
|
142
|
+
{"deviceCode": device_code},
|
|
143
|
+
) from error
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _is_legacy_rpc_version_schema_failure(error: DeviceBootstrapError) -> bool:
|
|
148
|
+
return (
|
|
149
|
+
error.code == "DEVICE_RPC_FAILED"
|
|
150
|
+
and error.details.get("field") == "result"
|
|
151
|
+
and error.details.get("reason") == "invalid_payload"
|
|
152
|
+
and error.details.get("unknownFields") == ["rpcVersion"]
|
|
153
|
+
)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Transport connectors for reaching the Android device agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from androidctld.config import DEFAULT_HOST
|
|
11
|
+
from androidctld.device.errors import device_agent_unavailable
|
|
12
|
+
from androidctld.device.types import ConnectionConfig, ConnectionSpec, DeviceEndpoint
|
|
13
|
+
from androidctld.protocol import ConnectionMode
|
|
14
|
+
from androidctld.runtime_policy import ADB_COMMAND_TIMEOUT_SECONDS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ConnectorHandle:
|
|
19
|
+
endpoint: DeviceEndpoint
|
|
20
|
+
close: Callable[[], None]
|
|
21
|
+
connection: ConnectionSpec
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LanConnector:
|
|
25
|
+
def connect(self, config: ConnectionConfig) -> ConnectorHandle:
|
|
26
|
+
if not config.host:
|
|
27
|
+
raise device_agent_unavailable(
|
|
28
|
+
"LAN connection requires host", {"mode": config.mode.value}
|
|
29
|
+
)
|
|
30
|
+
return ConnectorHandle(
|
|
31
|
+
endpoint=DeviceEndpoint(host=config.host, port=config.port),
|
|
32
|
+
close=lambda: None,
|
|
33
|
+
connection=ConnectionSpec.from_config(config),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AdbConnector:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._runner = runner
|
|
43
|
+
|
|
44
|
+
def connect(self, config: ConnectionConfig) -> ConnectorHandle:
|
|
45
|
+
serial = config.serial or self._select_serial()
|
|
46
|
+
local_port = self._forward(serial, config.port)
|
|
47
|
+
endpoint = DeviceEndpoint(host=DEFAULT_HOST, port=local_port)
|
|
48
|
+
|
|
49
|
+
def close() -> None:
|
|
50
|
+
self._remove_forward(serial, local_port)
|
|
51
|
+
|
|
52
|
+
return ConnectorHandle(
|
|
53
|
+
endpoint=endpoint,
|
|
54
|
+
close=close,
|
|
55
|
+
connection=ConnectionSpec(
|
|
56
|
+
mode=ConnectionMode.ADB,
|
|
57
|
+
serial=serial,
|
|
58
|
+
port=config.port,
|
|
59
|
+
host=None,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _select_serial(self) -> str:
|
|
64
|
+
completed = self._run_adb(
|
|
65
|
+
["adb", "devices"],
|
|
66
|
+
operation="devices",
|
|
67
|
+
)
|
|
68
|
+
if completed.returncode != 0:
|
|
69
|
+
raise device_agent_unavailable(
|
|
70
|
+
"adb devices failed",
|
|
71
|
+
{
|
|
72
|
+
"reason": "adb_devices_failed",
|
|
73
|
+
"stderr": completed.stderr.strip(),
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
rows = _parse_adb_devices(completed.stdout)
|
|
77
|
+
eligible_serials = [row.serial for row in rows if row.state == "device"]
|
|
78
|
+
if len(eligible_serials) == 1:
|
|
79
|
+
return eligible_serials[0]
|
|
80
|
+
if not eligible_serials:
|
|
81
|
+
raise device_agent_unavailable(
|
|
82
|
+
"no eligible ADB devices found",
|
|
83
|
+
{
|
|
84
|
+
"reason": "no_eligible_adb_device",
|
|
85
|
+
"deviceStates": _state_counts(rows),
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
raise device_agent_unavailable(
|
|
89
|
+
"multiple eligible ADB devices found; pass explicit --serial",
|
|
90
|
+
{
|
|
91
|
+
"reason": "multiple_eligible_adb_devices",
|
|
92
|
+
"eligibleSerials": eligible_serials,
|
|
93
|
+
"hint": "pass explicit --serial",
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _forward(self, serial: str, remote_port: int) -> int:
|
|
98
|
+
command = [
|
|
99
|
+
"adb",
|
|
100
|
+
"-s",
|
|
101
|
+
serial,
|
|
102
|
+
"forward",
|
|
103
|
+
"tcp:0",
|
|
104
|
+
f"tcp:{remote_port}",
|
|
105
|
+
]
|
|
106
|
+
completed = self._run_adb(
|
|
107
|
+
command,
|
|
108
|
+
operation="forward",
|
|
109
|
+
serial=serial,
|
|
110
|
+
)
|
|
111
|
+
if completed.returncode != 0:
|
|
112
|
+
raise device_agent_unavailable(
|
|
113
|
+
"adb forward failed",
|
|
114
|
+
{
|
|
115
|
+
"serial": serial,
|
|
116
|
+
"stderr": completed.stderr.strip(),
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
output = completed.stdout.strip()
|
|
120
|
+
if not re.match(r"^\d+$", output):
|
|
121
|
+
raise device_agent_unavailable(
|
|
122
|
+
"adb forward did not return a local port",
|
|
123
|
+
{
|
|
124
|
+
"serial": serial,
|
|
125
|
+
"stdout": output,
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
return int(output)
|
|
129
|
+
|
|
130
|
+
def _remove_forward(self, serial: str, local_port: int) -> None:
|
|
131
|
+
command = [
|
|
132
|
+
"adb",
|
|
133
|
+
"-s",
|
|
134
|
+
serial,
|
|
135
|
+
"forward",
|
|
136
|
+
"--remove",
|
|
137
|
+
f"tcp:{local_port}",
|
|
138
|
+
]
|
|
139
|
+
self._run_adb(
|
|
140
|
+
command,
|
|
141
|
+
operation="forward_remove",
|
|
142
|
+
serial=serial,
|
|
143
|
+
suppress_timeout=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _run_adb(
|
|
147
|
+
self,
|
|
148
|
+
command: list[str],
|
|
149
|
+
*,
|
|
150
|
+
operation: str,
|
|
151
|
+
serial: str | None = None,
|
|
152
|
+
suppress_timeout: bool = False,
|
|
153
|
+
) -> subprocess.CompletedProcess[str]:
|
|
154
|
+
try:
|
|
155
|
+
return self._runner(
|
|
156
|
+
command,
|
|
157
|
+
check=False,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
timeout=ADB_COMMAND_TIMEOUT_SECONDS,
|
|
161
|
+
)
|
|
162
|
+
except subprocess.TimeoutExpired as exc:
|
|
163
|
+
if suppress_timeout:
|
|
164
|
+
stdout = exc.stdout if isinstance(exc.stdout, str) else ""
|
|
165
|
+
stderr = exc.stderr if isinstance(exc.stderr, str) else ""
|
|
166
|
+
return subprocess.CompletedProcess(
|
|
167
|
+
exc.cmd,
|
|
168
|
+
124,
|
|
169
|
+
stdout=stdout,
|
|
170
|
+
stderr=stderr,
|
|
171
|
+
)
|
|
172
|
+
details: dict[str, object] = {
|
|
173
|
+
"reason": "adb_command_timeout",
|
|
174
|
+
"operation": operation,
|
|
175
|
+
"timeoutSeconds": ADB_COMMAND_TIMEOUT_SECONDS,
|
|
176
|
+
}
|
|
177
|
+
if serial is not None:
|
|
178
|
+
details["serial"] = serial
|
|
179
|
+
raise device_agent_unavailable("ADB command timed out", details) from exc
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True)
|
|
183
|
+
class _AdbDeviceRow:
|
|
184
|
+
serial: str
|
|
185
|
+
state: str
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_adb_devices(output: str) -> list[_AdbDeviceRow]:
|
|
189
|
+
rows: list[_AdbDeviceRow] = []
|
|
190
|
+
saw_header = False
|
|
191
|
+
for raw_line in output.splitlines():
|
|
192
|
+
line = raw_line.strip()
|
|
193
|
+
if not line:
|
|
194
|
+
continue
|
|
195
|
+
if line == "List of devices attached":
|
|
196
|
+
saw_header = True
|
|
197
|
+
continue
|
|
198
|
+
if not saw_header:
|
|
199
|
+
continue
|
|
200
|
+
parts = line.split()
|
|
201
|
+
if len(parts) < 2:
|
|
202
|
+
continue
|
|
203
|
+
serial, state = parts[0], parts[1]
|
|
204
|
+
rows.append(_AdbDeviceRow(serial=serial, state=state))
|
|
205
|
+
return rows
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _state_counts(rows: list[_AdbDeviceRow]) -> dict[str, int]:
|
|
209
|
+
counts: dict[str, int] = {}
|
|
210
|
+
for row in rows:
|
|
211
|
+
counts[row.state] = counts.get(row.state, 0) + 1
|
|
212
|
+
return counts
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class DeviceConnectorFactory:
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
adb_connector: AdbConnector | None = None,
|
|
219
|
+
lan_connector: LanConnector | None = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
self._adb_connector = adb_connector or AdbConnector()
|
|
222
|
+
self._lan_connector = lan_connector or LanConnector()
|
|
223
|
+
|
|
224
|
+
def connect(self, config: ConnectionConfig) -> ConnectorHandle:
|
|
225
|
+
if config.mode is ConnectionMode.ADB:
|
|
226
|
+
return self._adb_connector.connect(config)
|
|
227
|
+
if config.mode is ConnectionMode.LAN:
|
|
228
|
+
return self._lan_connector.connect(config)
|
|
229
|
+
raise device_agent_unavailable(
|
|
230
|
+
"unsupported connection mode", {"mode": config.mode.value}
|
|
231
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Device bootstrap and RPC errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from androidctld.errors import DaemonError, DaemonErrorCode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DeviceBootstrapError(DaemonError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def device_agent_unavailable(
|
|
15
|
+
message: str, details: dict[str, Any] | None = None
|
|
16
|
+
) -> DeviceBootstrapError:
|
|
17
|
+
return DeviceBootstrapError(
|
|
18
|
+
code=DaemonErrorCode.DEVICE_AGENT_UNAVAILABLE,
|
|
19
|
+
message=message,
|
|
20
|
+
retryable=True,
|
|
21
|
+
details=details or {},
|
|
22
|
+
http_status=200,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def device_agent_unauthorized(
|
|
27
|
+
message: str,
|
|
28
|
+
details: dict[str, Any] | None = None,
|
|
29
|
+
*,
|
|
30
|
+
retryable: bool = False,
|
|
31
|
+
) -> DeviceBootstrapError:
|
|
32
|
+
return DeviceBootstrapError(
|
|
33
|
+
code=DaemonErrorCode.DEVICE_AGENT_UNAUTHORIZED,
|
|
34
|
+
message=message,
|
|
35
|
+
retryable=retryable,
|
|
36
|
+
details=details or {},
|
|
37
|
+
http_status=200,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def version_mismatch(
|
|
42
|
+
message: str, details: dict[str, Any] | None = None
|
|
43
|
+
) -> DeviceBootstrapError:
|
|
44
|
+
return DeviceBootstrapError(
|
|
45
|
+
code=DaemonErrorCode.DEVICE_AGENT_VERSION_MISMATCH,
|
|
46
|
+
message=message,
|
|
47
|
+
retryable=False,
|
|
48
|
+
details=details or {},
|
|
49
|
+
http_status=200,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def capability_mismatch(
|
|
54
|
+
message: str, details: dict[str, Any] | None = None
|
|
55
|
+
) -> DeviceBootstrapError:
|
|
56
|
+
return DeviceBootstrapError(
|
|
57
|
+
code=DaemonErrorCode.DEVICE_AGENT_CAPABILITY_MISMATCH,
|
|
58
|
+
message=message,
|
|
59
|
+
retryable=False,
|
|
60
|
+
details=details or {},
|
|
61
|
+
http_status=200,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def accessibility_not_ready(
|
|
66
|
+
message: str, details: dict[str, Any] | None = None
|
|
67
|
+
) -> DeviceBootstrapError:
|
|
68
|
+
return DeviceBootstrapError(
|
|
69
|
+
code=DaemonErrorCode.ACCESSIBILITY_NOT_READY,
|
|
70
|
+
message=message,
|
|
71
|
+
retryable=True,
|
|
72
|
+
details=details or {},
|
|
73
|
+
http_status=200,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def device_rpc_failed(
|
|
78
|
+
message: str,
|
|
79
|
+
details: dict[str, Any] | None = None,
|
|
80
|
+
retryable: bool = True,
|
|
81
|
+
) -> DeviceBootstrapError:
|
|
82
|
+
return DeviceBootstrapError(
|
|
83
|
+
code=DaemonErrorCode.DEVICE_RPC_FAILED,
|
|
84
|
+
message=message,
|
|
85
|
+
retryable=retryable,
|
|
86
|
+
details=details or {},
|
|
87
|
+
http_status=200,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def device_rpc_transport_reset(
|
|
92
|
+
message: str, details: dict[str, Any] | None = None
|
|
93
|
+
) -> DeviceBootstrapError:
|
|
94
|
+
return DeviceBootstrapError(
|
|
95
|
+
code=DaemonErrorCode.DEVICE_RPC_TRANSPORT_RESET,
|
|
96
|
+
message=message,
|
|
97
|
+
retryable=True,
|
|
98
|
+
details=details or {},
|
|
99
|
+
http_status=200,
|
|
100
|
+
)
|