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,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
+ )