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,167 @@
1
+ """Semantic compiler target and promotion rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from androidctld.semantics.labels import (
6
+ GENERIC_SEMANTIC_ROLES,
7
+ LabelInfo,
8
+ infer_role,
9
+ normalize_text,
10
+ parent_node_for,
11
+ synthesize_label_info,
12
+ )
13
+ from androidctld.snapshots.models import RawNode
14
+
15
+ CLICK_ACTIONS = {"tap", "click"}
16
+ LONG_CLICK_ACTIONS = {"longTap", "longClick"}
17
+ TYPE_ACTIONS = {"setText"}
18
+ SCROLL_ACTIONS = {
19
+ "scrollForward",
20
+ "scrollBackward",
21
+ "scrollUp",
22
+ "scrollDown",
23
+ "scrollLeft",
24
+ "scrollRight",
25
+ }
26
+ SUBMIT_ACTIONS = {"submit"}
27
+
28
+
29
+ def semantic_actions_for(raw_node: RawNode) -> list[str]:
30
+ raw_actions = {normalize_action_name(action) for action in raw_node.actions}
31
+ semantic_actions: list[str] = []
32
+ if raw_actions.intersection(CLICK_ACTIONS):
33
+ semantic_actions.append("tap")
34
+ if raw_actions.intersection(LONG_CLICK_ACTIONS):
35
+ semantic_actions.append("longTap")
36
+ if raw_node.editable and raw_actions.intersection(TYPE_ACTIONS):
37
+ semantic_actions.append("type")
38
+ if raw_node.scrollable and raw_actions.intersection(SCROLL_ACTIONS):
39
+ semantic_actions.append("scroll")
40
+ return semantic_actions
41
+
42
+
43
+ def public_primary_actions_for(
44
+ *,
45
+ anchor_node: RawNode,
46
+ role: str,
47
+ primary_actions: list[str],
48
+ ) -> list[str]:
49
+ if role != "input" or not anchor_node.editable:
50
+ return list(primary_actions)
51
+ if anchor_node.focused:
52
+ return list(primary_actions)
53
+ return []
54
+
55
+
56
+ def secondary_public_actions_for(
57
+ *,
58
+ anchor_node: RawNode,
59
+ role: str,
60
+ primary_actions: list[str],
61
+ ) -> list[str]:
62
+ del primary_actions
63
+ anchor_actions = {normalize_action_name(action) for action in anchor_node.actions}
64
+ if role != "input" or not anchor_node.editable:
65
+ return []
66
+ secondary_actions: list[str] = []
67
+ if anchor_node.focused:
68
+ if anchor_actions.intersection(SUBMIT_ACTIONS):
69
+ secondary_actions.append("submit")
70
+ return secondary_actions
71
+ if "focus" not in anchor_actions:
72
+ return secondary_actions
73
+ secondary_actions.append("focus")
74
+ return secondary_actions
75
+
76
+
77
+ def select_target_sources(
78
+ raw_nodes: tuple[RawNode, ...],
79
+ raw_nodes_by_rid: dict[str, RawNode],
80
+ label_infos: dict[str, LabelInfo],
81
+ actionability: dict[str, list[str]],
82
+ ) -> dict[str, str]:
83
+ target_sources: dict[str, str] = {}
84
+ for raw_node in raw_nodes:
85
+ anchor_node = actionable_anchor_for(raw_node, raw_nodes_by_rid, actionability)
86
+ if anchor_node is None:
87
+ continue
88
+ existing_source = target_sources.get(anchor_node.rid)
89
+ if existing_source is None or target_source_sort_key(
90
+ raw_nodes_by_rid[existing_source],
91
+ anchor_node,
92
+ raw_nodes_by_rid,
93
+ label_infos,
94
+ ) < target_source_sort_key(
95
+ raw_node,
96
+ anchor_node,
97
+ raw_nodes_by_rid,
98
+ label_infos,
99
+ ):
100
+ target_sources[anchor_node.rid] = raw_node.rid
101
+ return target_sources
102
+
103
+
104
+ def actionable_anchor_for(
105
+ raw_node: RawNode,
106
+ raw_nodes_by_rid: dict[str, RawNode],
107
+ actionability: dict[str, list[str]],
108
+ ) -> RawNode | None:
109
+ if actionability[raw_node.rid]:
110
+ return raw_node
111
+ current = parent_node_for(raw_node, raw_nodes_by_rid)
112
+ while current is not None:
113
+ anchor_actions = actionability[current.rid]
114
+ if anchor_actions:
115
+ if can_promote_to_actionable_ancestor(raw_node, anchor_actions):
116
+ return current
117
+ return None
118
+ current = parent_node_for(current, raw_nodes_by_rid)
119
+ return None
120
+
121
+
122
+ def can_promote_to_actionable_ancestor(
123
+ raw_node: RawNode,
124
+ anchor_actions: list[str],
125
+ ) -> bool:
126
+ if "scroll" in anchor_actions:
127
+ return False
128
+ label_quality = synthesize_label_info(raw_node, {raw_node.rid: raw_node}).quality
129
+ role = infer_role(raw_node)
130
+ return label_quality > 0 or role not in GENERIC_SEMANTIC_ROLES
131
+
132
+
133
+ def target_source_sort_key(
134
+ raw_node: RawNode,
135
+ anchor_node: RawNode,
136
+ raw_nodes_by_rid: dict[str, RawNode],
137
+ label_infos: dict[str, LabelInfo],
138
+ ) -> tuple[int, int, int, int, int, str]:
139
+ label_info = label_infos[raw_node.rid]
140
+ role = infer_role(raw_node)
141
+ actionable_role_bonus = int(role not in GENERIC_SEMANTIC_ROLES)
142
+ return (
143
+ label_info.quality,
144
+ actionable_role_bonus,
145
+ int(raw_node.rid != anchor_node.rid),
146
+ -ancestor_distance(raw_node, anchor_node.rid, raw_nodes_by_rid),
147
+ len(normalize_text(label_info.label)),
148
+ raw_node.rid,
149
+ )
150
+
151
+
152
+ def ancestor_distance(
153
+ raw_node: RawNode,
154
+ ancestor_rid: str,
155
+ raw_nodes_by_rid: dict[str, RawNode],
156
+ ) -> int:
157
+ distance = 0
158
+ current: RawNode | None = raw_node
159
+ while current is not None and current.rid != ancestor_rid:
160
+ current = parent_node_for(current, raw_nodes_by_rid)
161
+ distance += 1
162
+ return distance
163
+
164
+
165
+ def normalize_action_name(value: str) -> str:
166
+ normalized = normalize_text(value)
167
+ return normalized[:1].lower() + normalized[1:]
@@ -0,0 +1 @@
1
+ """Raw snapshot fetching and caching."""
@@ -0,0 +1,219 @@
1
+ """Validated raw snapshot domain models and parsing helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from pydantic import ValidationError
8
+
9
+ from androidctld.device.errors import DeviceBootstrapError, device_rpc_failed
10
+ from androidctld.schema.core import SchemaDecodeError
11
+ from androidctld.schema.validation_errors import validation_error_to_schema_decode_error
12
+ from androidctld.snapshots.schema import (
13
+ RawDisplayPayload,
14
+ RawImePayload,
15
+ RawNodePayload,
16
+ RawSnapshotPayload,
17
+ RawWindowPayload,
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RawIme:
23
+ visible: bool
24
+ window_id: str | None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RawWindow:
29
+ window_id: str
30
+ type: str
31
+ layer: int
32
+ package_name: str | None
33
+ bounds: tuple[int, int, int, int]
34
+ root_rid: str
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class RawNode:
39
+ rid: str
40
+ window_id: str
41
+ parent_rid: str | None
42
+ child_rids: tuple[str, ...]
43
+ class_name: str
44
+ resource_id: str | None
45
+ text: str | None
46
+ content_desc: str | None
47
+ hint_text: str | None
48
+ state_description: str | None
49
+ pane_title: str | None
50
+ package_name: str | None
51
+ bounds: tuple[int, int, int, int]
52
+ visible_to_user: bool
53
+ important_for_accessibility: bool
54
+ clickable: bool
55
+ enabled: bool
56
+ editable: bool
57
+ focusable: bool
58
+ focused: bool
59
+ checkable: bool
60
+ checked: bool
61
+ selected: bool
62
+ scrollable: bool
63
+ password: bool
64
+ actions: tuple[str, ...]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class RawSnapshot:
69
+ snapshot_id: int
70
+ captured_at: str
71
+ package_name: str | None
72
+ activity_name: str | None
73
+ ime: RawIme
74
+ windows: tuple[RawWindow, ...]
75
+ nodes: tuple[RawNode, ...]
76
+ display: dict[str, int]
77
+
78
+
79
+ def parse_raw_snapshot(payload: object) -> RawSnapshot:
80
+ try:
81
+ boundary = RawSnapshotPayload.model_validate(payload)
82
+ except ValidationError as error:
83
+ raise invalid_snapshot_validation_error(error, field_name="result") from error
84
+ try:
85
+ return adapt_raw_snapshot(boundary, field_name="result")
86
+ except SchemaDecodeError as error:
87
+ raise invalid_snapshot_payload(error.field, error.problem) from error
88
+
89
+
90
+ def adapt_raw_snapshot(
91
+ payload: RawSnapshotPayload,
92
+ *,
93
+ field_name: str = "result",
94
+ ) -> RawSnapshot:
95
+ return RawSnapshot(
96
+ snapshot_id=payload.snapshot_id,
97
+ captured_at=payload.captured_at,
98
+ package_name=payload.package_name,
99
+ activity_name=payload.activity_name,
100
+ ime=adapt_ime(payload.ime, field_name=f"{field_name}.ime"),
101
+ windows=tuple(
102
+ adapt_raw_window(window, field_name=f"{field_name}.windows[{index}]")
103
+ for index, window in enumerate(payload.windows)
104
+ ),
105
+ nodes=tuple(
106
+ adapt_raw_node(node, field_name=f"{field_name}.nodes[{index}]")
107
+ for index, node in enumerate(payload.nodes)
108
+ ),
109
+ display=adapt_display(payload.display, field_name=f"{field_name}.display"),
110
+ )
111
+
112
+
113
+ def adapt_display(
114
+ payload: RawDisplayPayload,
115
+ *,
116
+ field_name: str = "result.display",
117
+ ) -> dict[str, int]:
118
+ del field_name
119
+ return {
120
+ "widthPx": payload.width_px,
121
+ "heightPx": payload.height_px,
122
+ "densityDpi": payload.density_dpi,
123
+ "rotation": payload.rotation,
124
+ }
125
+
126
+
127
+ def adapt_ime(
128
+ payload: RawImePayload,
129
+ *,
130
+ field_name: str = "result.ime",
131
+ ) -> RawIme:
132
+ del field_name
133
+ return RawIme(
134
+ visible=payload.visible,
135
+ window_id=payload.window_id,
136
+ )
137
+
138
+
139
+ def adapt_raw_window(
140
+ payload: RawWindowPayload,
141
+ *,
142
+ field_name: str,
143
+ ) -> RawWindow:
144
+ return RawWindow(
145
+ window_id=payload.window_id,
146
+ type=payload.type,
147
+ layer=payload.layer,
148
+ package_name=payload.package_name,
149
+ bounds=adapt_bounds(payload.bounds, field_name=f"{field_name}.bounds"),
150
+ root_rid=payload.root_rid,
151
+ )
152
+
153
+
154
+ def adapt_raw_node(
155
+ payload: RawNodePayload,
156
+ *,
157
+ field_name: str,
158
+ ) -> RawNode:
159
+ return RawNode(
160
+ rid=payload.rid,
161
+ window_id=payload.window_id,
162
+ parent_rid=payload.parent_rid,
163
+ child_rids=tuple(payload.child_rids),
164
+ class_name=payload.class_name,
165
+ resource_id=payload.resource_id,
166
+ text=payload.text,
167
+ content_desc=payload.content_desc,
168
+ hint_text=payload.hint_text,
169
+ state_description=payload.state_description,
170
+ pane_title=payload.pane_title,
171
+ package_name=payload.package_name,
172
+ bounds=adapt_bounds(payload.bounds, field_name=f"{field_name}.bounds"),
173
+ visible_to_user=payload.visible_to_user,
174
+ important_for_accessibility=payload.important_for_accessibility,
175
+ clickable=payload.clickable,
176
+ enabled=payload.enabled,
177
+ editable=payload.editable,
178
+ focusable=payload.focusable,
179
+ focused=payload.focused,
180
+ checkable=payload.checkable,
181
+ checked=payload.checked,
182
+ selected=payload.selected,
183
+ scrollable=payload.scrollable,
184
+ password=payload.password,
185
+ actions=tuple(payload.actions),
186
+ )
187
+
188
+
189
+ def adapt_bounds(
190
+ bounds: list[int],
191
+ *,
192
+ field_name: str,
193
+ ) -> tuple[int, int, int, int]:
194
+ if len(bounds) != 4:
195
+ raise SchemaDecodeError(field_name, "must contain exactly 4 integers")
196
+ return (bounds[0], bounds[1], bounds[2], bounds[3])
197
+
198
+
199
+ def invalid_snapshot_validation_error(
200
+ error: ValidationError,
201
+ *,
202
+ field_name: str,
203
+ ) -> DeviceBootstrapError:
204
+ schema_error = validation_error_to_schema_decode_error(
205
+ error,
206
+ field_name=field_name,
207
+ )
208
+ return invalid_snapshot_payload(schema_error.field, schema_error.problem)
209
+
210
+
211
+ def invalid_snapshot_payload(field_name: str, problem: str) -> DeviceBootstrapError:
212
+ return device_rpc_failed(
213
+ f"device RPC {field_name} {problem}",
214
+ {
215
+ "field": field_name,
216
+ "reason": "invalid_snapshot",
217
+ },
218
+ retryable=False,
219
+ )
@@ -0,0 +1,273 @@
1
+ """Shared screen refresh transaction and signature helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from androidctld.artifacts.models import ScreenArtifacts
11
+ from androidctld.artifacts.writer import ArtifactWriter
12
+ from androidctld.commands.models import CommandRecord
13
+ from androidctld.errors import DaemonError, DaemonErrorCode
14
+ from androidctld.protocol import CommandKind
15
+ from androidctld.refs.models import RefRegistry
16
+ from androidctld.refs.service import RefRegistryBuilder
17
+ from androidctld.runtime import RuntimeKernel, RuntimeLifecycleLease
18
+ from androidctld.runtime.kernel import ScreenRefreshUpdate
19
+ from androidctld.runtime.models import WorkspaceRuntime
20
+ from androidctld.semantics.compiler import (
21
+ CompiledScreen,
22
+ SemanticCompiler,
23
+ SemanticNode,
24
+ )
25
+ from androidctld.semantics.public_models import PublicScreen
26
+ from androidctld.snapshots.models import RawSnapshot
27
+ from androidctld.text_equivalence import canonical_text_key, searchable_raw_node_texts
28
+
29
+ RefreshScreenResult = tuple[
30
+ RawSnapshot,
31
+ PublicScreen,
32
+ ScreenArtifacts,
33
+ ]
34
+ RefreshCandidateValidator = Callable[[RawSnapshot, PublicScreen, CompiledScreen], None]
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class ScreenRefreshBasis:
39
+ sequence: int
40
+ previous_registry: RefRegistry
41
+ lifecycle_lease: RuntimeLifecycleLease | None
42
+ command_kind: CommandKind | None
43
+ wait_kind: object | None
44
+ record: CommandRecord | None
45
+
46
+
47
+ def _normalize_wait_kind(wait_kind: object | None) -> str | None:
48
+ if wait_kind is None:
49
+ return None
50
+ return str(getattr(wait_kind, "value", wait_kind))
51
+
52
+
53
+ class ScreenRefreshService:
54
+ def __init__(
55
+ self,
56
+ runtime_kernel: RuntimeKernel,
57
+ semantic_compiler: SemanticCompiler | None = None,
58
+ artifact_writer: ArtifactWriter | None = None,
59
+ ref_registry_builder: RefRegistryBuilder | None = None,
60
+ ) -> None:
61
+ self._runtime_kernel = runtime_kernel
62
+ self._semantic_compiler = semantic_compiler or SemanticCompiler()
63
+ self._artifact_writer = artifact_writer or ArtifactWriter()
64
+ self._ref_registry_builder = ref_registry_builder or RefRegistryBuilder()
65
+
66
+ @property
67
+ def runtime_kernel(self) -> RuntimeKernel:
68
+ return self._runtime_kernel
69
+
70
+ def refresh(
71
+ self,
72
+ session: WorkspaceRuntime,
73
+ snapshot: RawSnapshot,
74
+ *,
75
+ lifecycle_lease: RuntimeLifecycleLease | None = None,
76
+ command_kind: CommandKind | None = None,
77
+ wait_kind: object | None = None,
78
+ record: CommandRecord | None = None,
79
+ candidate_validator: RefreshCandidateValidator | None = None,
80
+ ) -> RefreshScreenResult:
81
+ basis = _capture_refresh_basis(
82
+ session,
83
+ lifecycle_lease=lifecycle_lease,
84
+ command_kind=command_kind,
85
+ wait_kind=wait_kind,
86
+ record=record,
87
+ )
88
+ compiled_screen = self._semantic_compiler.compile(basis.sequence, snapshot)
89
+ reconcile_result = self._ref_registry_builder.finalize_compiled_screen(
90
+ compiled_screen=compiled_screen,
91
+ snapshot_id=snapshot.snapshot_id,
92
+ previous_registry=basis.previous_registry,
93
+ )
94
+ ref_registry = reconcile_result.registry
95
+ finalized_compiled_screen = reconcile_result.compiled_screen
96
+ public_screen = finalized_compiled_screen.to_public_screen()
97
+ staged_artifacts = self._artifact_writer.stage_screen(
98
+ session,
99
+ public_screen,
100
+ sequence=finalized_compiled_screen.sequence,
101
+ source_snapshot_id=finalized_compiled_screen.source_snapshot_id,
102
+ captured_at=finalized_compiled_screen.captured_at,
103
+ ref_registry=ref_registry,
104
+ )
105
+ artifacts = staged_artifacts.artifacts
106
+
107
+ def raise_if_refresh_stale(active_session: WorkspaceRuntime) -> None:
108
+ raise_if_stale(
109
+ active_session,
110
+ basis.lifecycle_lease,
111
+ kind=basis.command_kind,
112
+ wait_kind=basis.wait_kind,
113
+ record=basis.record,
114
+ )
115
+ if candidate_validator is not None:
116
+ candidate_validator(snapshot, public_screen, finalized_compiled_screen)
117
+
118
+ self._runtime_kernel.commit_screen_refresh(
119
+ session,
120
+ update=ScreenRefreshUpdate(
121
+ sequence=basis.sequence,
122
+ snapshot=snapshot,
123
+ public_screen=public_screen,
124
+ compiled_screen=finalized_compiled_screen,
125
+ artifacts=artifacts,
126
+ ref_registry=ref_registry,
127
+ staged_artifacts=staged_artifacts,
128
+ ),
129
+ pre_commit=raise_if_refresh_stale,
130
+ )
131
+ return snapshot, public_screen, artifacts
132
+
133
+
134
+ def _capture_refresh_basis(
135
+ session: WorkspaceRuntime,
136
+ *,
137
+ lifecycle_lease: RuntimeLifecycleLease | None,
138
+ command_kind: CommandKind | None,
139
+ wait_kind: object | None,
140
+ record: CommandRecord | None,
141
+ ) -> ScreenRefreshBasis:
142
+ with session.lock:
143
+ raise_if_stale(
144
+ session,
145
+ lifecycle_lease,
146
+ kind=command_kind,
147
+ wait_kind=wait_kind,
148
+ record=record,
149
+ )
150
+ return ScreenRefreshBasis(
151
+ sequence=session.screen_sequence + 1,
152
+ previous_registry=deepcopy(session.ref_registry),
153
+ lifecycle_lease=lifecycle_lease,
154
+ command_kind=command_kind,
155
+ wait_kind=deepcopy(wait_kind),
156
+ record=deepcopy(record),
157
+ )
158
+
159
+
160
+ def raise_if_stale(
161
+ session: WorkspaceRuntime,
162
+ lease: RuntimeLifecycleLease | None,
163
+ *,
164
+ kind: CommandKind | None = None,
165
+ wait_kind: object | None = None,
166
+ record: CommandRecord | None = None,
167
+ ) -> None:
168
+ if lease is None or lease.is_current(session):
169
+ return
170
+ details = {"workspaceRoot": session.workspace_root.as_posix()}
171
+ if record is not None:
172
+ details["commandId"] = record.command_id
173
+ if kind is not None:
174
+ details["kind"] = kind.value
175
+ normalized_wait_kind = _normalize_wait_kind(wait_kind)
176
+ if kind is CommandKind.WAIT and normalized_wait_kind is not None:
177
+ details["waitKind"] = normalized_wait_kind
178
+ raise DaemonError(
179
+ code=DaemonErrorCode.COMMAND_CANCELLED,
180
+ message=(
181
+ "command was canceled"
182
+ if kind is None
183
+ else (
184
+ f"wait {normalized_wait_kind} was canceled"
185
+ if kind is CommandKind.WAIT and normalized_wait_kind is not None
186
+ else f"{kind.value} was canceled"
187
+ )
188
+ ),
189
+ retryable=False,
190
+ details=details,
191
+ http_status=200,
192
+ )
193
+
194
+
195
+ def compiled_screen_nodes(compiled_screen: CompiledScreen) -> tuple[SemanticNode, ...]:
196
+ return (
197
+ *compiled_screen.targets,
198
+ *compiled_screen.context,
199
+ *compiled_screen.dialog,
200
+ *compiled_screen.keyboard,
201
+ *compiled_screen.system,
202
+ )
203
+
204
+
205
+ def compiled_screen_signature(
206
+ compiled_screen: CompiledScreen | None,
207
+ snapshot: RawSnapshot,
208
+ ) -> tuple[Any, ...]:
209
+ if compiled_screen is None:
210
+ return (
211
+ snapshot.package_name,
212
+ snapshot.activity_name,
213
+ tuple(
214
+ tuple(
215
+ canonical_text_key(value)
216
+ for value in searchable_raw_node_texts(node)
217
+ )
218
+ for node in snapshot.nodes
219
+ if node.visible_to_user
220
+ ),
221
+ )
222
+ return (
223
+ compiled_screen.package_name,
224
+ compiled_screen.activity_name,
225
+ compiled_screen.keyboard_visible,
226
+ tuple(
227
+ (
228
+ node.group,
229
+ node.role,
230
+ canonical_text_key(node.label),
231
+ tuple(canonical_text_key(value) for value in node.state),
232
+ tuple(node.actions),
233
+ node.ref,
234
+ None if node.bounds is None else tuple(node.bounds),
235
+ )
236
+ for node in compiled_screen_nodes(compiled_screen)
237
+ ),
238
+ )
239
+
240
+
241
+ def settle_screen_signature(
242
+ compiled_screen: CompiledScreen | None,
243
+ snapshot: RawSnapshot,
244
+ ) -> tuple[Any, ...]:
245
+ if compiled_screen is None:
246
+ return (
247
+ snapshot.package_name,
248
+ snapshot.activity_name,
249
+ tuple(
250
+ tuple(
251
+ canonical_text_key(value)
252
+ for value in searchable_raw_node_texts(node)
253
+ )
254
+ for node in snapshot.nodes
255
+ if node.visible_to_user
256
+ ),
257
+ )
258
+ return (
259
+ compiled_screen.package_name,
260
+ compiled_screen.activity_name,
261
+ compiled_screen.keyboard_visible,
262
+ tuple(
263
+ (
264
+ node.group,
265
+ node.role,
266
+ canonical_text_key(node.label),
267
+ tuple(canonical_text_key(value) for value in node.state),
268
+ tuple(node.actions),
269
+ None if node.bounds is None else tuple(node.bounds),
270
+ )
271
+ for node in compiled_screen_nodes(compiled_screen)
272
+ ),
273
+ )