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,266 @@
1
+ """active.json persistence for androidctld."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import time
9
+ from collections.abc import Callable, Iterator
10
+ from contextlib import contextmanager, suppress
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ from pydantic import ValidationError
16
+
17
+ from androidctl_contracts.user_state import ActiveDaemonRecord
18
+ from androidctld.auth.secret_files import write_secret_json_file_atomically
19
+ from androidctld.config import DaemonConfig, normalize_loopback_host
20
+ from androidctld.daemon.ownership_probe import (
21
+ OwnershipHealthProbe,
22
+ OwnershipHealthProbeResult,
23
+ OwnershipHealthStatus,
24
+ )
25
+ from androidctld.schema.persistence import (
26
+ ActiveDaemonFile,
27
+ build_persistence_model,
28
+ )
29
+
30
+ __all__ = ["ActiveDaemonRecord", "ActiveDaemonRegistry"]
31
+
32
+
33
+ class ActiveDaemonRegistry:
34
+ _LOCK_ACQUIRE_TIMEOUT_SECONDS = 2.0
35
+ _LOCK_RETRY_INTERVAL_SECONDS = 0.01
36
+ _LOCK_STALE_SECONDS = 30.0
37
+
38
+ def __init__(
39
+ self,
40
+ config: DaemonConfig,
41
+ *,
42
+ live_checker: (
43
+ Callable[[ActiveDaemonRecord], OwnershipHealthProbeResult] | None
44
+ ) = None,
45
+ ) -> None:
46
+ self._config = config
47
+ self._live_checker = live_checker or self._default_live_checker
48
+
49
+ def build_record(self, host: str, port: int, token: str) -> ActiveDaemonRecord:
50
+ pid = os.getpid()
51
+ started_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
52
+ return ActiveDaemonRecord(
53
+ pid=pid,
54
+ host=normalize_loopback_host(host),
55
+ port=port,
56
+ token=token,
57
+ started_at=started_at,
58
+ workspace_root=self._config.workspace_root.as_posix(),
59
+ owner_id=self._config.owner_id,
60
+ )
61
+
62
+ def publish(self, record: ActiveDaemonRecord) -> None:
63
+ self._config.state_dir.mkdir(parents=True, exist_ok=True)
64
+ with self._active_lock():
65
+ existing = self.read()
66
+ if existing is not None and self._active_record_conflicts(
67
+ existing,
68
+ record,
69
+ ):
70
+ raise RuntimeError("live daemon already owns active slot")
71
+ payload = self._build_active_file_payload(record)
72
+ self._write_active_file_atomically(payload)
73
+
74
+ def clear(self, *, record: ActiveDaemonRecord | None = None) -> None:
75
+ with self._active_lock():
76
+ if record is not None:
77
+ try:
78
+ active_file = self._read_active_file_model()
79
+ except FileNotFoundError:
80
+ return
81
+ except OSError:
82
+ return
83
+ except (ValueError, json.JSONDecodeError, ValidationError):
84
+ pass
85
+ else:
86
+ if (
87
+ active_file.pid != record.pid
88
+ or active_file.started_at != record.started_at
89
+ ):
90
+ return
91
+ try:
92
+ self._config.active_file_path.unlink()
93
+ except FileNotFoundError:
94
+ return
95
+
96
+ def restore(self, record: ActiveDaemonRecord) -> None:
97
+ payload = self._build_active_file_payload(record)
98
+ with self._active_lock():
99
+ self._write_active_file_atomically(payload)
100
+
101
+ def read(self) -> ActiveDaemonRecord | None:
102
+ if not self._config.active_file_path.exists():
103
+ return None
104
+ try:
105
+ record = self._read_active_file_model()
106
+ except OSError:
107
+ return None
108
+ except (ValueError, json.JSONDecodeError, ValidationError):
109
+ with suppress(OSError):
110
+ self._config.active_file_path.unlink()
111
+ return None
112
+ return ActiveDaemonRecord(
113
+ pid=record.pid,
114
+ host=record.host,
115
+ port=record.port,
116
+ token=record.token,
117
+ started_at=record.started_at,
118
+ workspace_root=record.workspace_root,
119
+ owner_id=record.owner_id,
120
+ )
121
+
122
+ def _active_record_conflicts(
123
+ self,
124
+ existing: ActiveDaemonRecord,
125
+ record: ActiveDaemonRecord,
126
+ ) -> bool:
127
+ if existing.identity == record.identity:
128
+ return False
129
+ probe_result = self._live_checker(existing)
130
+ if probe_result.is_live:
131
+ return True
132
+ if probe_result.status == OwnershipHealthStatus.UNREACHABLE:
133
+ return False
134
+ return self._is_pid_live(existing.pid)
135
+
136
+ def _default_live_checker(
137
+ self,
138
+ record: ActiveDaemonRecord,
139
+ ) -> OwnershipHealthProbeResult:
140
+ return OwnershipHealthProbe().probe_active_record(
141
+ record,
142
+ expected_workspace_root=self._config.workspace_root.as_posix(),
143
+ expected_owner_id=self._config.owner_id,
144
+ )
145
+
146
+ def _read_active_file_model(self) -> ActiveDaemonFile:
147
+ with self._config.active_file_path.open("r", encoding="utf-8") as handle:
148
+ payload = json.load(handle)
149
+ return ActiveDaemonFile.model_validate(payload)
150
+
151
+ @staticmethod
152
+ def _build_active_file_payload(record: ActiveDaemonRecord) -> dict[str, object]:
153
+ return cast(
154
+ dict[str, object],
155
+ build_persistence_model(
156
+ ActiveDaemonFile,
157
+ pid=record.pid,
158
+ host=normalize_loopback_host(record.host),
159
+ port=record.port,
160
+ token=record.token,
161
+ started_at=record.started_at,
162
+ workspace_root=record.workspace_root,
163
+ owner_id=record.owner_id,
164
+ ).model_dump(by_alias=True, mode="json"),
165
+ )
166
+
167
+ @staticmethod
168
+ def _is_pid_live(pid: int) -> bool:
169
+ if pid <= 0:
170
+ return False
171
+ if platform.system() == "Windows":
172
+ return ActiveDaemonRegistry._windows_is_pid_live(pid)
173
+ try:
174
+ os.kill(pid, 0)
175
+ except ProcessLookupError:
176
+ return False
177
+ except PermissionError:
178
+ return True
179
+ except OSError:
180
+ return False
181
+ return True
182
+
183
+ @staticmethod
184
+ def _windows_is_pid_live(pid: int, kernel32: Any | None = None) -> bool:
185
+ import ctypes
186
+
187
+ process_query_limited_information = 0x1000
188
+ still_active = 259
189
+ if kernel32 is None:
190
+ windll = getattr(ctypes, "windll", None)
191
+ if windll is None:
192
+ return False
193
+ kernel32 = windll.kernel32
194
+
195
+ process_handle = kernel32.OpenProcess(
196
+ process_query_limited_information,
197
+ False,
198
+ pid,
199
+ )
200
+ if not process_handle:
201
+ return False
202
+
203
+ try:
204
+ exit_code = ctypes.c_ulong(0)
205
+ if not kernel32.GetExitCodeProcess(process_handle, ctypes.byref(exit_code)):
206
+ return False
207
+ return exit_code.value == still_active
208
+ finally:
209
+ kernel32.CloseHandle(process_handle)
210
+
211
+ @contextmanager
212
+ def _active_lock(self) -> Iterator[None]:
213
+ lock_path = self._config.active_lock_path
214
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
215
+ deadline = time.monotonic() + self._LOCK_ACQUIRE_TIMEOUT_SECONDS
216
+ while True:
217
+ fd: int | None = None
218
+ try:
219
+ fd = os.open(
220
+ lock_path,
221
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
222
+ 0o600,
223
+ )
224
+ os.write(fd, str(os.getpid()).encode("utf-8"))
225
+ os.close(fd)
226
+ fd = None
227
+ break
228
+ except FileExistsError:
229
+ if self._recover_stale_lock(lock_path):
230
+ continue
231
+ if time.monotonic() >= deadline:
232
+ raise RuntimeError("timed out acquiring active slot lock") from None
233
+ time.sleep(self._LOCK_RETRY_INTERVAL_SECONDS)
234
+ finally:
235
+ if fd is not None:
236
+ os.close(fd)
237
+ try:
238
+ yield
239
+ finally:
240
+ with suppress(FileNotFoundError):
241
+ lock_path.unlink()
242
+
243
+ def _recover_stale_lock(self, lock_path: Path) -> bool:
244
+ try:
245
+ raw = lock_path.read_text(encoding="utf-8").strip()
246
+ pid = int(raw) if raw else -1
247
+ except (OSError, ValueError):
248
+ pid = -1
249
+ if pid > 0 and self._is_pid_live(pid):
250
+ return False
251
+ try:
252
+ lock_age_seconds = time.time() - lock_path.stat().st_mtime
253
+ except OSError:
254
+ return False
255
+ if pid <= 0 and lock_age_seconds < self._LOCK_STALE_SECONDS:
256
+ return False
257
+ try:
258
+ lock_path.unlink()
259
+ except FileNotFoundError:
260
+ return True
261
+ except OSError:
262
+ return False
263
+ return True
264
+
265
+ def _write_active_file_atomically(self, payload: dict[str, object]) -> None:
266
+ write_secret_json_file_atomically(self._config.active_file_path, payload)
@@ -0,0 +1,52 @@
1
+ """Restricted atomic writers for daemon-local sensitive state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import uuid
7
+ from contextlib import suppress
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from androidctld.schema.persistence_io import dump_formatted_json
12
+
13
+ SECRET_FILE_MODE = 0o600
14
+ SECRET_DIR_MODE = 0o700
15
+
16
+
17
+ def write_secret_json_file_atomically(path: Path, payload: dict[str, Any]) -> None:
18
+ """Write sensitive daemon JSON state via a restricted atomic sidecar."""
19
+ _ensure_secret_parent_dir(path.parent)
20
+ temp_path = _unique_temp_path(path)
21
+ fd: int | None = None
22
+ try:
23
+ fd = os.open(
24
+ temp_path,
25
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY,
26
+ SECRET_FILE_MODE,
27
+ )
28
+ if os.name == "posix":
29
+ os.fchmod(fd, SECRET_FILE_MODE)
30
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
31
+ fd = None
32
+ dump_formatted_json(handle, payload)
33
+ handle.flush()
34
+ os.fsync(handle.fileno())
35
+ os.replace(temp_path, path)
36
+ except BaseException:
37
+ if fd is not None:
38
+ os.close(fd)
39
+ raise
40
+ finally:
41
+ with suppress(FileNotFoundError):
42
+ temp_path.unlink()
43
+
44
+
45
+ def _unique_temp_path(path: Path) -> Path:
46
+ return path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
47
+
48
+
49
+ def _ensure_secret_parent_dir(path: Path) -> None:
50
+ path.mkdir(parents=True, exist_ok=True)
51
+ if os.name == "posix":
52
+ os.chmod(path, SECRET_DIR_MODE)
@@ -0,0 +1,59 @@
1
+ """Daemon token management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import secrets
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from androidctld.auth.secret_files import write_secret_json_file_atomically
11
+ from androidctld.config import DaemonConfig
12
+
13
+
14
+ @dataclass
15
+ class DaemonTokenStore:
16
+ config: DaemonConfig | None = None
17
+ _token: str = ""
18
+
19
+ def current_token(self) -> str:
20
+ if self._token:
21
+ return self._token
22
+ token_path = self._token_path()
23
+ if token_path is not None:
24
+ loaded = self._load_token(token_path)
25
+ if loaded:
26
+ self._token = loaded
27
+ return self._token
28
+ self._token = secrets.token_urlsafe(32)
29
+ if token_path is not None:
30
+ self._persist_token(token_path, self._token)
31
+ return self._token
32
+
33
+ @classmethod
34
+ def load_existing_token(cls, path: Path) -> str | None:
35
+ return cls._load_token(path)
36
+
37
+ def _token_path(self) -> Path | None:
38
+ if self.config is None:
39
+ return None
40
+ return self.config.token_file_path
41
+
42
+ @staticmethod
43
+ def _load_token(path: Path) -> str | None:
44
+ try:
45
+ payload = json.loads(path.read_text(encoding="utf-8"))
46
+ except (OSError, ValueError, json.JSONDecodeError):
47
+ return None
48
+ if not isinstance(payload, dict):
49
+ return None
50
+ token = payload.get("token")
51
+ if isinstance(token, str):
52
+ token = token.strip()
53
+ if token:
54
+ return token
55
+ return None
56
+
57
+ @staticmethod
58
+ def _persist_token(path: Path, token: str) -> None:
59
+ write_secret_json_file_atomically(path, {"token": token})
@@ -0,0 +1 @@
1
+ """Command handling for androidctld."""
@@ -0,0 +1,231 @@
1
+ """Command service object-graph assembly helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+
9
+ from androidctld.actions.executor import ActionExecutor
10
+ from androidctld.actions.repair import ActionCommandRepairer
11
+ from androidctld.actions.settle import ActionSettler
12
+ from androidctld.artifacts.writer import ArtifactWriter
13
+ from androidctld.commands.dispatch import CommandDispatch
14
+ from androidctld.commands.executor import CommandExecutor
15
+ from androidctld.commands.handlers.action import ActionCommandHandler
16
+ from androidctld.commands.handlers.connect import ConnectCommandHandler
17
+ from androidctld.commands.handlers.list_apps import ListAppsCommandHandler
18
+ from androidctld.commands.handlers.observe import ObserveCommandHandler
19
+ from androidctld.commands.handlers.screenshot import ScreenshotCommandHandler
20
+ from androidctld.commands.handlers.wait import WaitCommandHandler
21
+ from androidctld.commands.orchestration import CommandRunOrchestrator
22
+ from androidctld.device.bootstrap import DeviceBootstrapper
23
+ from androidctld.device.interfaces import (
24
+ DeviceClientFactory,
25
+ DeviceClientProvider,
26
+ DeviceRuntimeClient,
27
+ )
28
+ from androidctld.runtime import RuntimeKernel, RuntimeLifecycleLease, RuntimeStore
29
+ from androidctld.runtime.models import WorkspaceRuntime
30
+ from androidctld.semantics.compiler import SemanticCompiler
31
+ from androidctld.snapshots.refresh import ScreenRefreshService
32
+ from androidctld.snapshots.service import SnapshotService
33
+ from androidctld.waits.loop import WaitRuntimeLoop
34
+
35
+ SleepFn = Callable[[float], None]
36
+ TimeFn = Callable[[], float]
37
+
38
+ __all__ = ["CommandServiceAssembly", "SleepFn", "assemble_command_service"]
39
+
40
+
41
+ class _RuntimePersistencePort:
42
+ def __init__(
43
+ self,
44
+ *,
45
+ runtime_kernel: RuntimeKernel,
46
+ runtime_store: RuntimeStore,
47
+ ) -> None:
48
+ self._runtime_kernel = runtime_kernel
49
+ self._runtime_store = runtime_store
50
+
51
+ def ensure_runtime(self) -> WorkspaceRuntime:
52
+ return self._runtime_kernel.ensure_runtime()
53
+
54
+ def persist_runtime(self, runtime: WorkspaceRuntime) -> None:
55
+ self._runtime_store.persist_runtime(runtime)
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class CommandServiceAssembly:
60
+ sleep_fn: SleepFn
61
+ time_fn: TimeFn
62
+ runtime_kernel: RuntimeKernel
63
+ runtime_store: _RuntimePersistencePort
64
+ bootstrapper: DeviceBootstrapper
65
+ snapshot_service: SnapshotService
66
+ semantic_compiler: SemanticCompiler
67
+ device_client_factory: DeviceClientFactory
68
+ artifact_writer: ArtifactWriter
69
+ screen_refresh: ScreenRefreshService
70
+ action_executor: ActionExecutor
71
+ wait_runtime_loop: WaitRuntimeLoop
72
+ connect_handler: ConnectCommandHandler
73
+ observe_handler: ObserveCommandHandler
74
+ list_apps_handler: ListAppsCommandHandler
75
+ action_handler: ActionCommandHandler
76
+ wait_handler: WaitCommandHandler
77
+ screenshot_handler: ScreenshotCommandHandler
78
+ orchestrator: CommandRunOrchestrator
79
+ dispatch: CommandDispatch
80
+ executor: CommandExecutor
81
+
82
+
83
+ def assemble_command_service(
84
+ *,
85
+ runtime_store: RuntimeStore,
86
+ bootstrapper: DeviceBootstrapper | None = None,
87
+ snapshot_service: SnapshotService | None = None,
88
+ semantic_compiler: SemanticCompiler | None = None,
89
+ artifact_writer: ArtifactWriter | None = None,
90
+ device_client_factory: DeviceClientFactory | None = None,
91
+ sleep_fn: SleepFn | None = None,
92
+ time_fn: TimeFn | None = None,
93
+ ) -> CommandServiceAssembly:
94
+ resolved_sleep_fn = sleep_fn or time.sleep
95
+ resolved_time_fn = time_fn or time.monotonic
96
+ runtime_kernel = RuntimeKernel(
97
+ runtime_store,
98
+ sleep_fn=resolved_sleep_fn,
99
+ time_fn=resolved_time_fn,
100
+ )
101
+ persistence_port = _RuntimePersistencePort(
102
+ runtime_kernel=runtime_kernel,
103
+ runtime_store=runtime_store,
104
+ )
105
+ resolved_bootstrapper = bootstrapper or DeviceBootstrapper()
106
+ default_snapshot_service = SnapshotService(
107
+ bootstrapper=resolved_bootstrapper,
108
+ runtime_kernel=runtime_kernel,
109
+ )
110
+ resolved_snapshot_service = snapshot_service or default_snapshot_service
111
+ resolved_semantic_compiler = semantic_compiler or SemanticCompiler()
112
+ resolved_device_client_factory = _resolve_device_client_factory(
113
+ snapshot_service=resolved_snapshot_service,
114
+ explicit_factory=device_client_factory,
115
+ )
116
+ resolved_artifact_writer = artifact_writer or ArtifactWriter()
117
+ screen_refresh = ScreenRefreshService(
118
+ runtime_kernel=runtime_kernel,
119
+ semantic_compiler=resolved_semantic_compiler,
120
+ artifact_writer=resolved_artifact_writer,
121
+ )
122
+ action_executor = ActionExecutor(
123
+ device_client_factory=resolved_device_client_factory,
124
+ screen_refresh=screen_refresh,
125
+ settler=ActionSettler(
126
+ snapshot_service=resolved_snapshot_service,
127
+ semantic_compiler=resolved_semantic_compiler,
128
+ sleep_fn=resolved_sleep_fn,
129
+ time_fn=resolved_time_fn,
130
+ ),
131
+ repairer=ActionCommandRepairer(
132
+ snapshot_service=resolved_snapshot_service,
133
+ screen_refresh=screen_refresh,
134
+ ),
135
+ runtime_kernel=runtime_kernel,
136
+ )
137
+ wait_runtime_loop = WaitRuntimeLoop(
138
+ snapshot_service=resolved_snapshot_service,
139
+ screen_refresh=screen_refresh,
140
+ device_client_factory=resolved_device_client_factory,
141
+ sleep_fn=resolved_sleep_fn,
142
+ time_fn=resolved_time_fn,
143
+ )
144
+ connect_handler = ConnectCommandHandler(
145
+ runtime_kernel=runtime_kernel,
146
+ bootstrapper=resolved_bootstrapper,
147
+ snapshot_service=resolved_snapshot_service,
148
+ screen_refresh=screen_refresh,
149
+ )
150
+ observe_handler = ObserveCommandHandler(
151
+ runtime_kernel=runtime_kernel,
152
+ snapshot_service=resolved_snapshot_service,
153
+ screen_refresh=screen_refresh,
154
+ )
155
+ list_apps_handler = ListAppsCommandHandler(
156
+ runtime_kernel=runtime_kernel,
157
+ bootstrapper=resolved_bootstrapper,
158
+ )
159
+ action_handler = ActionCommandHandler(
160
+ runtime_kernel=runtime_kernel,
161
+ action_executor=action_executor,
162
+ )
163
+ wait_handler = WaitCommandHandler(
164
+ runtime_kernel=runtime_kernel,
165
+ wait_runtime_loop=wait_runtime_loop,
166
+ )
167
+ screenshot_handler = ScreenshotCommandHandler(
168
+ runtime_kernel=runtime_kernel,
169
+ device_client_factory=resolved_device_client_factory,
170
+ artifact_writer=resolved_artifact_writer,
171
+ )
172
+ orchestrator = CommandRunOrchestrator(
173
+ serial_admission=runtime_store.begin_serial_command,
174
+ time_fn=resolved_time_fn,
175
+ )
176
+ dispatch = CommandDispatch(
177
+ connect_handler=connect_handler,
178
+ observe_handler=observe_handler,
179
+ list_apps_handler=list_apps_handler,
180
+ action_handler=action_handler,
181
+ wait_handler=wait_handler,
182
+ screenshot_handler=screenshot_handler,
183
+ )
184
+ executor = CommandExecutor(handlers=dispatch.build_handlers())
185
+ return CommandServiceAssembly(
186
+ sleep_fn=resolved_sleep_fn,
187
+ time_fn=resolved_time_fn,
188
+ runtime_kernel=runtime_kernel,
189
+ runtime_store=persistence_port,
190
+ bootstrapper=resolved_bootstrapper,
191
+ snapshot_service=resolved_snapshot_service,
192
+ semantic_compiler=resolved_semantic_compiler,
193
+ device_client_factory=resolved_device_client_factory,
194
+ artifact_writer=resolved_artifact_writer,
195
+ screen_refresh=screen_refresh,
196
+ action_executor=action_executor,
197
+ wait_runtime_loop=wait_runtime_loop,
198
+ connect_handler=connect_handler,
199
+ observe_handler=observe_handler,
200
+ list_apps_handler=list_apps_handler,
201
+ action_handler=action_handler,
202
+ wait_handler=wait_handler,
203
+ screenshot_handler=screenshot_handler,
204
+ orchestrator=orchestrator,
205
+ dispatch=dispatch,
206
+ executor=executor,
207
+ )
208
+
209
+
210
+ def _resolve_device_client_factory(
211
+ *,
212
+ snapshot_service: object,
213
+ explicit_factory: DeviceClientFactory | None,
214
+ ) -> DeviceClientFactory:
215
+ if explicit_factory is not None:
216
+ return explicit_factory
217
+ if isinstance(snapshot_service, DeviceClientProvider):
218
+ return snapshot_service.device_client
219
+ return _missing_device_client_factory
220
+
221
+
222
+ def _missing_device_client_factory(
223
+ session: WorkspaceRuntime,
224
+ *,
225
+ lifecycle_lease: RuntimeLifecycleLease | None = None,
226
+ ) -> DeviceRuntimeClient:
227
+ del session, lifecycle_lease
228
+ raise RuntimeError(
229
+ "CommandService requires device_client_factory when snapshot_service "
230
+ "does not provide device_client()"
231
+ )