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,117 @@
1
+ """Shared accessors for current runtime screen state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+
8
+ from androidctld.artifacts.models import ScreenArtifacts
9
+ from androidctld.protocol import RuntimeStatus
10
+ from androidctld.runtime.models import WorkspaceRuntime
11
+ from androidctld.semantics.compiler import CompiledScreen
12
+ from androidctld.semantics.public_models import (
13
+ PublicScreen,
14
+ iter_public_nodes,
15
+ )
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class AuthoritativeCurrentBasis:
20
+ screen_id: str
21
+ screen_sequence: int
22
+ snapshot_id: int
23
+ captured_at: str
24
+ package_name: str | None
25
+ activity_name: str | None
26
+ public_screen: PublicScreen
27
+ compiled_screen: CompiledScreen
28
+ artifacts: ScreenArtifacts | None
29
+ public_refs: frozenset[str]
30
+
31
+
32
+ def get_authoritative_current_basis(
33
+ runtime: WorkspaceRuntime,
34
+ ) -> AuthoritativeCurrentBasis | None:
35
+ with runtime.lock:
36
+ if runtime.status is not RuntimeStatus.READY:
37
+ return None
38
+ current_screen_id = runtime.current_screen_id
39
+ if not isinstance(current_screen_id, str) or not current_screen_id:
40
+ return None
41
+ latest_snapshot = runtime.latest_snapshot
42
+ screen_state = runtime.screen_state
43
+ if latest_snapshot is None or screen_state is None:
44
+ return None
45
+ public_screen = screen_state.public_screen
46
+ compiled_screen = screen_state.compiled_screen
47
+ if public_screen is None or compiled_screen is None:
48
+ return None
49
+ if public_screen.screen_id != current_screen_id:
50
+ return None
51
+ if compiled_screen.screen_id != current_screen_id:
52
+ return None
53
+ if compiled_screen.source_snapshot_id != latest_snapshot.snapshot_id:
54
+ return None
55
+ if compiled_screen.sequence != runtime.screen_sequence:
56
+ return None
57
+ if compiled_screen.captured_at != latest_snapshot.captured_at:
58
+ return None
59
+ if compiled_screen.package_name != latest_snapshot.package_name:
60
+ return None
61
+ if compiled_screen.activity_name != latest_snapshot.activity_name:
62
+ return None
63
+ if public_screen.app.package_name != latest_snapshot.package_name:
64
+ return None
65
+ if public_screen.app.activity_name != latest_snapshot.activity_name:
66
+ return None
67
+
68
+ public_screen_copy = deepcopy(public_screen)
69
+ compiled_screen_copy = deepcopy(compiled_screen)
70
+ artifacts_copy = deepcopy(screen_state.artifacts)
71
+ return AuthoritativeCurrentBasis(
72
+ screen_id=current_screen_id,
73
+ screen_sequence=runtime.screen_sequence,
74
+ snapshot_id=latest_snapshot.snapshot_id,
75
+ captured_at=latest_snapshot.captured_at,
76
+ package_name=latest_snapshot.package_name,
77
+ activity_name=latest_snapshot.activity_name,
78
+ public_screen=public_screen_copy,
79
+ compiled_screen=compiled_screen_copy,
80
+ artifacts=artifacts_copy,
81
+ public_refs=frozenset(
82
+ node.ref
83
+ for group in public_screen_copy.groups
84
+ for node in iter_public_nodes(group.nodes)
85
+ if node.ref
86
+ ),
87
+ )
88
+
89
+
90
+ def current_public_screen(
91
+ runtime: WorkspaceRuntime, *, copy_value: bool = True
92
+ ) -> PublicScreen | None:
93
+ if runtime.screen_state is None or runtime.screen_state.public_screen is None:
94
+ return None
95
+ if copy_value:
96
+ return deepcopy(runtime.screen_state.public_screen)
97
+ return runtime.screen_state.public_screen
98
+
99
+
100
+ def current_compiled_screen(
101
+ runtime: WorkspaceRuntime, *, copy_value: bool = True
102
+ ) -> CompiledScreen | None:
103
+ if runtime.screen_state is None or runtime.screen_state.compiled_screen is None:
104
+ return None
105
+ if copy_value:
106
+ return deepcopy(runtime.screen_state.compiled_screen)
107
+ return runtime.screen_state.compiled_screen
108
+
109
+
110
+ def current_artifacts(
111
+ runtime: WorkspaceRuntime, *, copy_value: bool = True
112
+ ) -> ScreenArtifacts | None:
113
+ if runtime.screen_state is None or runtime.screen_state.artifacts is None:
114
+ return None
115
+ if copy_value:
116
+ return deepcopy(runtime.screen_state.artifacts)
117
+ return runtime.screen_state.artifacts
@@ -0,0 +1,70 @@
1
+ """Runtime state persistence repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from androidctld.protocol import RuntimeStatus
9
+ from androidctld.runtime.models import WorkspaceRuntime
10
+ from androidctld.schema.persistence import (
11
+ RUNTIME_STATE_SCHEMA_VERSION,
12
+ RuntimeStateFile,
13
+ build_persistence_model,
14
+ validate_persistence_payload,
15
+ )
16
+ from androidctld.schema.persistence_io import atomic_write_json, load_json_object
17
+
18
+
19
+ class RuntimeStateRepository:
20
+ def load(self, runtime_path: Path) -> WorkspaceRuntime | None:
21
+ if not runtime_path.exists():
22
+ return None
23
+ state_payload = load_json_object(runtime_path)
24
+ parsed = validate_persistence_payload(
25
+ RuntimeStateFile,
26
+ state_payload,
27
+ field_name="runtime",
28
+ schema_version=RUNTIME_STATE_SCHEMA_VERSION,
29
+ )
30
+ artifact_root = runtime_path.parent.resolve()
31
+ workspace_root = artifact_root.parent.resolve()
32
+ return WorkspaceRuntime(
33
+ workspace_root=workspace_root,
34
+ artifact_root=artifact_root,
35
+ runtime_path=runtime_path.resolve(),
36
+ status=parsed.status,
37
+ screen_sequence=parsed.screen_sequence,
38
+ current_screen_id=None,
39
+ )
40
+
41
+ def persist(
42
+ self,
43
+ runtime: WorkspaceRuntime,
44
+ *,
45
+ runtime_path: Path | None = None,
46
+ ) -> None:
47
+ target_runtime_path = runtime_path or runtime.runtime_path
48
+ state_payload = build_persistence_model(
49
+ RuntimeStateFile,
50
+ status=_status_for_persisted_runtime(runtime.status),
51
+ screen_sequence=runtime.screen_sequence,
52
+ updated_at=now_isoformat(),
53
+ ).model_dump(by_alias=True, mode="json")
54
+ state_payload["schemaVersion"] = RUNTIME_STATE_SCHEMA_VERSION
55
+ atomic_write_json(target_runtime_path, state_payload)
56
+
57
+
58
+ def now_isoformat() -> str:
59
+ return (
60
+ datetime.now(timezone.utc)
61
+ .replace(microsecond=0)
62
+ .isoformat()
63
+ .replace("+00:00", "Z")
64
+ )
65
+
66
+
67
+ def _status_for_persisted_runtime(status: RuntimeStatus) -> RuntimeStatus:
68
+ if status is RuntimeStatus.READY:
69
+ return RuntimeStatus.BROKEN
70
+ return status
@@ -0,0 +1,76 @@
1
+ """Workspace runtime store and persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+
9
+ from androidctld.config import DaemonConfig
10
+ from androidctld.protocol import RuntimeStatus
11
+ from androidctld.runtime.models import WorkspaceRuntime
12
+ from androidctld.runtime.state_repo import RuntimeStateRepository
13
+ from androidctld.schema.persistence import RUNTIME_STATE_FILE_NAME
14
+
15
+
16
+ class RuntimeSerialCommandBusyError(RuntimeError):
17
+ """Raised when a public runtime command is already active."""
18
+
19
+
20
+ class RuntimeStore:
21
+ def __init__(
22
+ self,
23
+ config: DaemonConfig,
24
+ state_repo: RuntimeStateRepository | None = None,
25
+ ) -> None:
26
+ self._config = config
27
+ self._state_repo = state_repo or RuntimeStateRepository()
28
+ self._lock = threading.RLock()
29
+ self._runtime: WorkspaceRuntime | None = None
30
+ self._active_command: str | None = None
31
+
32
+ def get_runtime(self) -> WorkspaceRuntime:
33
+ with self._lock:
34
+ if self._runtime is None:
35
+ runtime_path = (
36
+ self._config.workspace_root
37
+ / ".androidctl"
38
+ / RUNTIME_STATE_FILE_NAME
39
+ )
40
+ loaded = self._state_repo.load(runtime_path)
41
+ if loaded is None:
42
+ artifact_root = runtime_path.parent
43
+ self._runtime = WorkspaceRuntime(
44
+ workspace_root=self._config.workspace_root,
45
+ artifact_root=artifact_root,
46
+ runtime_path=runtime_path,
47
+ status=RuntimeStatus.NEW,
48
+ )
49
+ else:
50
+ self._runtime = loaded
51
+ return self._runtime
52
+
53
+ def persist_runtime(self, runtime: WorkspaceRuntime) -> None:
54
+ artifact_root = self._config.workspace_root / ".androidctl"
55
+ self._state_repo.persist(
56
+ runtime,
57
+ runtime_path=artifact_root / RUNTIME_STATE_FILE_NAME,
58
+ )
59
+
60
+ def ensure_runtime(self) -> WorkspaceRuntime:
61
+ return self.get_runtime()
62
+
63
+ @contextmanager
64
+ def begin_serial_command(self, command_name: str) -> Iterator[None]:
65
+ with self._lock:
66
+ if self._active_command is not None:
67
+ raise RuntimeSerialCommandBusyError(
68
+ "overlapping control requests are not allowed"
69
+ )
70
+ self._active_command = command_name
71
+ try:
72
+ yield
73
+ finally:
74
+ with self._lock:
75
+ if self._active_command == command_name:
76
+ self._active_command = None
@@ -0,0 +1,127 @@
1
+ """Runtime policy constants shared across androidctld subsystems."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Final
6
+
7
+ from androidctld.protocol import CommandKind
8
+
9
+ DEFAULT_DEVICE_PORT: Final[int] = 17171
10
+ DEFAULT_DEVICE_RPC_TIMEOUT_SECONDS: Final[float] = 5.0
11
+ ANDROID_SCREENSHOT_METHOD_TIMEOUT_SECONDS: Final[float] = 11.0
12
+ SCREENSHOT_DEVICE_RPC_TRANSPORT_MARGIN_SECONDS: Final[float] = 1.0
13
+ SCREENSHOT_DEVICE_RPC_TIMEOUT_SECONDS: Final[float] = (
14
+ ANDROID_SCREENSHOT_METHOD_TIMEOUT_SECONDS
15
+ + SCREENSHOT_DEVICE_RPC_TRANSPORT_MARGIN_SECONDS
16
+ )
17
+ ADB_COMMAND_TIMEOUT_SECONDS: Final[float] = 10.0
18
+ DAEMON_HTTP_MAX_REQUEST_BODY_BYTES: Final[int] = 1 * 1024 * 1024
19
+ DAEMON_HTTP_SOCKET_TIMEOUT_SECONDS: Final[float] = 5.0
20
+ DEVICE_RPC_MAX_RESPONSE_BYTES: Final[int] = 4 * 1024 * 1024
21
+ SCREENSHOT_MAX_BINARY_BYTES: Final[int] = 32 * 1024 * 1024
22
+ SCREENSHOT_MAX_BASE64_CHARS: Final[int] = ((SCREENSHOT_MAX_BINARY_BYTES + 2) // 3) * 4
23
+ SCREENSHOT_MAX_RPC_RESPONSE_BYTES: Final[int] = 48 * 1024 * 1024
24
+ SCREENSHOT_MAX_OUTPUT_PIXELS: Final[int] = 16_777_216
25
+ MAIN_LOOP_SLEEP_SECONDS: Final[float] = 0.1
26
+ NON_NUMERIC_REF_SORT_BUCKET: Final[int] = 1_000_000_000
27
+
28
+ DEVICE_RPC_REQUEST_ID_BOOTSTRAP: Final[str] = "androidctld-bootstrap"
29
+ DEVICE_RPC_REQUEST_ID_SNAPSHOT: Final[str] = "androidctld-snapshot"
30
+ DEVICE_RPC_REQUEST_ID_SETTLE: Final[str] = "androidctld-settle"
31
+ DEVICE_RPC_REQUEST_ID_ACTION: Final[str] = "androidctld-action"
32
+ DEVICE_RPC_REQUEST_ID_ACTION_REPAIRED: Final[str] = "androidctld-action-repaired"
33
+ DEVICE_RPC_REQUEST_ID_WAIT: Final[str] = "androidctld-wait"
34
+ DEVICE_RPC_REQUEST_ID_SCREENSHOT: Final[str] = "androidctld-screenshot"
35
+ DEVICE_RPC_REQUEST_ID_LIST_APPS: Final[str] = "androidctld-list-apps"
36
+
37
+ DEFAULT_SNAPSHOT_INCLUDE_INVISIBLE: Final[bool] = True
38
+ DEFAULT_SNAPSHOT_INCLUDE_SYSTEM_WINDOWS: Final[bool] = True
39
+ DEFAULT_SCREENSHOT_FORMAT: Final[str] = "png"
40
+ DEFAULT_SCREENSHOT_SCALE: Final[float] = 1.0
41
+
42
+ DEFAULT_SETTLE_MIN_GRACE_MS: Final[int] = 200
43
+ SETTLE_MIN_GRACE_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
44
+ CommandKind.OPEN: 500,
45
+ }
46
+ DEFAULT_SETTLE_MAX_TOTAL_MS: Final[int] = 1200
47
+ SETTLE_MAX_TOTAL_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
48
+ CommandKind.OPEN: 4000,
49
+ }
50
+ DEFAULT_SETTLE_STABLE_WINDOW_MS: Final[int] = 300
51
+ SETTLE_STABLE_WINDOW_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
52
+ CommandKind.OPEN: 500,
53
+ }
54
+ DEFAULT_SETTLE_SNAPSHOT_MAX_INTERVAL_MS: Final[int] = 250
55
+ SETTLE_SNAPSHOT_MAX_INTERVAL_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
56
+ CommandKind.OPEN: 500,
57
+ }
58
+ SETTLE_POLL_SLICE_MS: Final[int] = 100
59
+
60
+ WAIT_TIMEOUT_MS_BY_KIND: Final[dict[str, int]] = {
61
+ "text": 3000,
62
+ "screen-change": 3000,
63
+ "gone": 3000,
64
+ "app": 3000,
65
+ "idle": 3000,
66
+ }
67
+
68
+ ACTION_TIMEOUT_MS_BY_COMMAND: Final[dict[CommandKind, int]] = {
69
+ CommandKind.OPEN: 5000,
70
+ CommandKind.TAP: 5000,
71
+ CommandKind.LONG_TAP: 5000,
72
+ CommandKind.TYPE: 8000,
73
+ CommandKind.GLOBAL: 5000,
74
+ CommandKind.SCROLL: 5000,
75
+ CommandKind.FOCUS: 5000,
76
+ CommandKind.SUBMIT: 5000,
77
+ }
78
+
79
+ WAIT_EVENT_POLL_SLICE_MS: Final[int] = 250
80
+ WAIT_SNAPSHOT_MAX_INTERVAL_MS: Final[int] = 500
81
+ WAIT_LOOP_SLEEP_SECONDS: Final[float] = 0.05
82
+ TRANSIENT_INVALID_SNAPSHOT_RETRY_SECONDS: Final[float] = 0.05
83
+ TRANSIENT_INVALID_SNAPSHOT_MAX_RETRIES: Final[int] = 2
84
+ WAIT_IDLE_STABLE_WINDOW_MS: Final[int] = 500
85
+ QUERY_PROGRESS_WAIT_SECONDS: Final[float] = 0.2
86
+ QUERY_PROGRESS_POLL_SECONDS: Final[float] = 0.02
87
+
88
+
89
+ def settle_min_grace_ms(kind: CommandKind) -> int:
90
+ return SETTLE_MIN_GRACE_MS_BY_COMMAND.get(kind, DEFAULT_SETTLE_MIN_GRACE_MS)
91
+
92
+
93
+ def settle_max_total_ms(kind: CommandKind) -> int:
94
+ return SETTLE_MAX_TOTAL_MS_BY_COMMAND.get(kind, DEFAULT_SETTLE_MAX_TOTAL_MS)
95
+
96
+
97
+ def settle_stable_window_ms(kind: CommandKind) -> int:
98
+ return SETTLE_STABLE_WINDOW_MS_BY_COMMAND.get(kind, DEFAULT_SETTLE_STABLE_WINDOW_MS)
99
+
100
+
101
+ def settle_snapshot_max_interval_ms(kind: CommandKind) -> int:
102
+ return SETTLE_SNAPSHOT_MAX_INTERVAL_MS_BY_COMMAND.get(
103
+ kind, DEFAULT_SETTLE_SNAPSHOT_MAX_INTERVAL_MS
104
+ )
105
+
106
+
107
+ def default_wait_timeout_ms(wait_kind: object) -> int:
108
+ normalized = str(getattr(wait_kind, "value", wait_kind))
109
+ return WAIT_TIMEOUT_MS_BY_KIND[normalized]
110
+
111
+
112
+ def action_timeout_ms(kind: CommandKind) -> int:
113
+ return ACTION_TIMEOUT_MS_BY_COMMAND[kind]
114
+
115
+
116
+ def default_snapshot_params() -> dict[str, bool]:
117
+ return {
118
+ "includeInvisible": DEFAULT_SNAPSHOT_INCLUDE_INVISIBLE,
119
+ "includeSystemWindows": DEFAULT_SNAPSHOT_INCLUDE_SYSTEM_WINDOWS,
120
+ }
121
+
122
+
123
+ def default_screenshot_params() -> dict[str, Any]:
124
+ return {
125
+ "format": DEFAULT_SCREENSHOT_FORMAT,
126
+ "scale": DEFAULT_SCREENSHOT_SCALE,
127
+ }
@@ -0,0 +1,5 @@
1
+ """Schema helpers for daemon ingress, persistence, and device payloads."""
2
+
3
+ from androidctld.schema.base import ApiModel, to_camel
4
+
5
+ __all__ = ["ApiModel", "to_camel"]
@@ -0,0 +1,132 @@
1
+ """Shared boundary-model conventions for androidctld schemas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, Any, Literal, cast
7
+
8
+ from pydantic import BaseModel, ConfigDict, ValidationError
9
+ from pydantic_core import InitErrorDetails
10
+ from typing_extensions import Self
11
+
12
+ if TYPE_CHECKING:
13
+ from pydantic._internal._model_construction import ModelMetaclass as _ModelMetaclass
14
+ else:
15
+ _ModelMetaclass = type(BaseModel)
16
+
17
+
18
+ def to_camel(name: str) -> str:
19
+ parts = name.split("_")
20
+ if len(parts) == 1:
21
+ return name
22
+ head, *tail = parts
23
+ return head + "".join(
24
+ segment[:1].upper() + segment[1:] for segment in tail if segment
25
+ )
26
+
27
+
28
+ class _ApiModelMeta(_ModelMetaclass):
29
+ def __call__(cls, *args: Any, **kwargs: Any) -> Any:
30
+ model_cls = cast(type[ApiModel], cls)
31
+ if kwargs and model_cls._contains_alias_kwargs(kwargs):
32
+ raise ValidationError.from_exception_data(
33
+ model_cls.__name__,
34
+ model_cls._alias_init_errors(kwargs),
35
+ )
36
+ return super().__call__(*args, **kwargs)
37
+
38
+
39
+ class ApiModel(BaseModel, metaclass=_ApiModelMeta):
40
+ model_config = ConfigDict(
41
+ strict=True,
42
+ extra="forbid",
43
+ alias_generator=to_camel,
44
+ validate_by_alias=True,
45
+ validate_by_name=True,
46
+ use_enum_values=False,
47
+ )
48
+
49
+ @classmethod
50
+ def model_validate(
51
+ cls,
52
+ obj: Any,
53
+ *,
54
+ strict: bool | None = None,
55
+ extra: Literal["allow", "ignore", "forbid"] | None = None,
56
+ from_attributes: bool | None = None,
57
+ context: Any | None = None,
58
+ by_alias: bool | None = None,
59
+ by_name: bool | None = None,
60
+ ) -> Self:
61
+ # External payload parsing stays alias-only by default, while internal
62
+ # code can still use normal snake_case keyword construction.
63
+ return super().model_validate(
64
+ obj,
65
+ strict=strict,
66
+ extra=extra,
67
+ from_attributes=from_attributes,
68
+ context=context,
69
+ by_alias=True if by_alias is None else by_alias,
70
+ by_name=False if by_name is None else by_name,
71
+ )
72
+
73
+ @classmethod
74
+ def model_validate_json(
75
+ cls,
76
+ json_data: str | bytes | bytearray,
77
+ *,
78
+ strict: bool | None = None,
79
+ extra: Literal["allow", "ignore", "forbid"] | None = None,
80
+ context: Any | None = None,
81
+ by_alias: bool | None = None,
82
+ by_name: bool | None = None,
83
+ ) -> Self:
84
+ return super().model_validate_json(
85
+ json_data,
86
+ strict=strict,
87
+ extra=extra,
88
+ context=context,
89
+ by_alias=True if by_alias is None else by_alias,
90
+ by_name=False if by_name is None else by_name,
91
+ )
92
+
93
+ def model_copy(
94
+ self,
95
+ *,
96
+ update: Mapping[str, Any] | None = None,
97
+ deep: bool = False,
98
+ ) -> Self:
99
+ model_type = type(self)
100
+ if update and model_type._contains_alias_kwargs(update):
101
+ raise ValidationError.from_exception_data(
102
+ model_type.__name__,
103
+ model_type._alias_init_errors(update),
104
+ )
105
+ return super().model_copy(update=update, deep=deep)
106
+
107
+ @classmethod
108
+ def _contains_alias_kwargs(cls, data: Mapping[str, Any]) -> bool:
109
+ return any(
110
+ field.alias is not None
111
+ and field.alias != field_name
112
+ and field.alias in data
113
+ for field_name, field in cls.model_fields.items()
114
+ )
115
+
116
+ @classmethod
117
+ def _alias_init_errors(cls, data: Mapping[str, Any]) -> list[InitErrorDetails]:
118
+ return [
119
+ InitErrorDetails(
120
+ type="extra_forbidden",
121
+ loc=(field.alias,),
122
+ input=data[field.alias],
123
+ )
124
+ for field_name, field in cls.model_fields.items()
125
+ if field.alias is not None
126
+ and field.alias != field_name
127
+ and field.alias in data
128
+ ]
129
+
130
+
131
+ def dump_api_model(model: ApiModel) -> dict[str, Any]:
132
+ return model.model_dump(by_alias=True, mode="json")
@@ -0,0 +1,35 @@
1
+ """Shared schema decoding helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class SchemaDecodeError(ValueError):
11
+ field: str
12
+ problem: str
13
+
14
+ def __str__(self) -> str:
15
+ return f"{self.field} {self.problem}"
16
+
17
+
18
+ def expect_field(payload: dict[str, Any], key: str, field_name: str) -> object:
19
+ if key not in payload:
20
+ raise SchemaDecodeError(field_name, "is required")
21
+ return payload[key]
22
+
23
+
24
+ def expect_object(value: object, field_name: str) -> dict[str, Any]:
25
+ if not isinstance(value, dict):
26
+ raise SchemaDecodeError(field_name, "must be a JSON object")
27
+ return dict(value)
28
+
29
+
30
+ def expect_int(value: object, field_name: str, minimum: int | None = None) -> int:
31
+ if not isinstance(value, int) or isinstance(value, bool):
32
+ raise SchemaDecodeError(field_name, "must be an integer")
33
+ if minimum is not None and value < minimum:
34
+ raise SchemaDecodeError(field_name, f"must be an integer >= {minimum}")
35
+ return value
@@ -0,0 +1,108 @@
1
+ """Typed daemon ingress parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from pydantic import ValidationError
9
+
10
+ from androidctl_contracts import daemon_api as wire_api
11
+ from androidctl_contracts.command_catalog import is_daemon_command_kind
12
+ from androidctl_contracts.daemon_api import HealthResult
13
+ from androidctld.commands.command_models import InternalCommand
14
+ from androidctld.commands.from_boundary import (
15
+ compile_connect_command,
16
+ compile_global_action_command,
17
+ compile_list_apps_command,
18
+ compile_observe_command,
19
+ compile_open_command,
20
+ compile_ref_action_command,
21
+ compile_screenshot_command,
22
+ compile_service_wait_command,
23
+ )
24
+ from androidctld.errors import bad_request
25
+ from androidctld.schema.validation_errors import validation_error_to_bad_request
26
+
27
+ __all__ = [
28
+ "HealthResult",
29
+ "ParsedCommandRun",
30
+ "parse_command_run_request",
31
+ "require_empty_payload",
32
+ ]
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ParsedCommandRun:
37
+ command: InternalCommand
38
+
39
+
40
+ def require_empty_payload(payload: dict[str, Any], route: str) -> None:
41
+ if payload:
42
+ raise bad_request(f"{route} does not accept request payload")
43
+
44
+
45
+ def parse_command_run_request(payload: dict[str, Any]) -> ParsedCommandRun:
46
+ _validate_command_run_payload_shape(payload)
47
+ try:
48
+ boundary = wire_api.CommandRunRequest.model_validate(
49
+ payload,
50
+ strict=True,
51
+ )
52
+ except ValidationError as error:
53
+ raise validation_error_to_bad_request(error, field_name=None) from error
54
+ return ParsedCommandRun(
55
+ command=_adapt_wire_command_payload(boundary.command),
56
+ )
57
+
58
+
59
+ def _validate_command_run_payload_shape(payload: dict[str, Any]) -> None:
60
+ if "command" not in payload:
61
+ raise bad_request("command is required", {"field": "command"})
62
+
63
+ command = payload["command"]
64
+ if not isinstance(command, dict):
65
+ raise bad_request("command must be a JSON object", {"field": "command"})
66
+
67
+ kind_raw = command.get("kind")
68
+ if not isinstance(kind_raw, str):
69
+ raise bad_request("command.kind must be a string", {"field": "command.kind"})
70
+
71
+ kind = kind_raw.strip()
72
+ if not kind:
73
+ raise bad_request(
74
+ "command.kind must be a non-empty string",
75
+ {"field": "command.kind"},
76
+ )
77
+
78
+ if not is_daemon_command_kind(kind):
79
+ raise bad_request(
80
+ "unsupported command kind",
81
+ {"field": "command.kind", "kind": kind},
82
+ )
83
+
84
+
85
+ def _adapt_wire_command_payload(
86
+ payload: wire_api.DaemonCommandPayload,
87
+ ) -> InternalCommand:
88
+ if isinstance(payload, wire_api.ConnectCommandPayload):
89
+ return compile_connect_command(payload)
90
+ if isinstance(payload, wire_api.ObserveCommandPayload):
91
+ return compile_observe_command(payload)
92
+ if isinstance(payload, wire_api.ListAppsCommandPayload):
93
+ return compile_list_apps_command(payload)
94
+ if isinstance(payload, wire_api.OpenCommandPayload):
95
+ return compile_open_command(payload)
96
+ if isinstance(payload, wire_api.RefActionCommandPayload):
97
+ return compile_ref_action_command(payload)
98
+ if isinstance(payload, wire_api.TypeCommandPayload):
99
+ return compile_ref_action_command(payload)
100
+ if isinstance(payload, wire_api.ScrollCommandPayload):
101
+ return compile_ref_action_command(payload)
102
+ if isinstance(payload, wire_api.GlobalActionCommandPayload):
103
+ return compile_global_action_command(payload)
104
+ if isinstance(payload, wire_api.WaitCommandPayload):
105
+ return compile_service_wait_command(payload)
106
+ if isinstance(payload, wire_api.ScreenshotCommandPayload):
107
+ return compile_screenshot_command(payload)
108
+ raise TypeError(f"unsupported wire command payload: {type(payload)!r}")