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,610 @@
1
+ """Semantic compiler for screen-first flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import cast
7
+
8
+ from androidctld.semantics.labels import (
9
+ GENERIC_SEMANTIC_ROLES,
10
+ LABEL_QUALITY_CONTENT_DESC,
11
+ LABEL_QUALITY_HINT,
12
+ LABEL_QUALITY_NEARBY,
13
+ LABEL_QUALITY_RESOURCE_ID,
14
+ LABEL_QUALITY_STATE_DESCRIPTION,
15
+ LABEL_QUALITY_TEXT,
16
+ LabelInfo,
17
+ extract_state,
18
+ fallback_label,
19
+ infer_role,
20
+ label_from_state_description,
21
+ near_label_for_node,
22
+ normalize_text,
23
+ parent_node_for,
24
+ relative_bounds_for_node,
25
+ semantic_role,
26
+ sibling_labels_for_node,
27
+ state_description_parts,
28
+ states_from_state_description,
29
+ synthesize_label_info,
30
+ )
31
+ from androidctld.semantics.models import RelationScopeNode, SemanticMeta
32
+ from androidctld.semantics.public_models import (
33
+ BlockingGroupName,
34
+ PublicApp,
35
+ PublicFocus,
36
+ PublicGroupName,
37
+ PublicNode,
38
+ PublicScreen,
39
+ PublicSurface,
40
+ build_public_groups,
41
+ )
42
+ from androidctld.semantics.registries import GROUP_ORDER
43
+ from androidctld.semantics.submit_refs import assign_submit_ref_relations
44
+ from androidctld.semantics.surface import (
45
+ apply_blocking_policy,
46
+ build_action_surface_fingerprint,
47
+ context_sort_key,
48
+ infer_group,
49
+ node_to_public_node,
50
+ passive_node_dedup_key,
51
+ resolve_blocking_group,
52
+ score_node,
53
+ scroll_directions_for_raw_node,
54
+ semantic_node_fingerprint,
55
+ semantic_relation_identity,
56
+ semantic_relation_key,
57
+ stable_screen_id,
58
+ target_sort_key,
59
+ )
60
+ from androidctld.semantics.targets import (
61
+ CLICK_ACTIONS,
62
+ LONG_CLICK_ACTIONS,
63
+ SCROLL_ACTIONS,
64
+ TYPE_ACTIONS,
65
+ actionable_anchor_for,
66
+ ancestor_distance,
67
+ can_promote_to_actionable_ancestor,
68
+ normalize_action_name,
69
+ public_primary_actions_for,
70
+ secondary_public_actions_for,
71
+ select_target_sources,
72
+ semantic_actions_for,
73
+ target_source_sort_key,
74
+ )
75
+ from androidctld.snapshots.models import RawNode, RawSnapshot
76
+
77
+
78
+ @dataclass
79
+ class SemanticNode:
80
+ raw_rid: str
81
+ role: str
82
+ label: str
83
+ state: list[str]
84
+ actions: list[str]
85
+ bounds: tuple[int, int, int, int]
86
+ meta: SemanticMeta
87
+ targetable: bool
88
+ score: int
89
+ group: str
90
+ parent_role: str
91
+ parent_label: str
92
+ sibling_labels: list[str]
93
+ relative_bounds: tuple[int, int, int, int]
94
+ scroll_directions: list[str] = field(default_factory=list)
95
+ text_children: list[str] = field(default_factory=list)
96
+ submit_target_keys: list[tuple[str, str]] = field(default_factory=list)
97
+ submit_relation_tokens: list[str] = field(default_factory=list)
98
+ label_quality: int = 0
99
+ ref: str = ""
100
+ relation_anchor_rid: str = ""
101
+ relation_window_id: str = ""
102
+ relation_window_root_rid: str | None = None
103
+ relation_parent_rid: str | None = None
104
+ relation_ancestor_scopes: tuple[RelationScopeNode, ...] = field(
105
+ default_factory=tuple
106
+ )
107
+
108
+ @property
109
+ def resource_id(self) -> str:
110
+ return self.meta.resource_id or ""
111
+
112
+ @property
113
+ def class_name(self) -> str:
114
+ return self.meta.class_name
115
+
116
+
117
+ @dataclass
118
+ class CompiledScreen:
119
+ screen_id: str
120
+ sequence: int
121
+ source_snapshot_id: int
122
+ captured_at: str
123
+ package_name: str | None
124
+ activity_name: str | None
125
+ keyboard_visible: bool
126
+ action_surface_fingerprint: str = ""
127
+ blocking_group: BlockingGroupName | None = None
128
+ targets: list[SemanticNode] = field(default_factory=list)
129
+ context: list[SemanticNode] = field(default_factory=list)
130
+ dialog: list[SemanticNode] = field(default_factory=list)
131
+ keyboard: list[SemanticNode] = field(default_factory=list)
132
+ system: list[SemanticNode] = field(default_factory=list)
133
+
134
+ def focused_input_node(self) -> SemanticNode | None:
135
+ for group_name in cast(
136
+ tuple[PublicGroupName, ...],
137
+ ("targets", "dialog", "keyboard", "system", "context"),
138
+ ):
139
+ nodes = cast(list[SemanticNode], getattr(self, group_name))
140
+ for node in nodes:
141
+ if node.role != "input":
142
+ continue
143
+ if "focused" not in node.state:
144
+ continue
145
+ return node
146
+ return None
147
+
148
+ def ref_candidates(self) -> list[SemanticNode]:
149
+ candidate_groups: tuple[PublicGroupName, ...] = (
150
+ (self.blocking_group,)
151
+ if self.blocking_group is not None
152
+ else cast(
153
+ tuple[PublicGroupName, ...],
154
+ ("targets", "dialog", "keyboard", "system"),
155
+ )
156
+ )
157
+ candidates: list[SemanticNode] = []
158
+ seen_candidates: set[tuple[str, str]] = set()
159
+ for group_name in candidate_groups:
160
+ nodes = cast(list[SemanticNode], getattr(self, group_name))
161
+ for node in nodes:
162
+ candidate_key = (node.group, node.raw_rid)
163
+ if not node.actions or candidate_key in seen_candidates:
164
+ continue
165
+ candidates.append(node)
166
+ seen_candidates.add(candidate_key)
167
+ focused_input = self.focused_input_node()
168
+ if focused_input is not None:
169
+ candidate_key = (focused_input.group, focused_input.raw_rid)
170
+ if candidate_key not in seen_candidates:
171
+ candidates.append(focused_input)
172
+ return candidates
173
+
174
+ def group_order(self) -> tuple[PublicGroupName, ...]:
175
+ if self.blocking_group is None:
176
+ return cast(tuple[PublicGroupName, ...], GROUP_ORDER)
177
+ return (
178
+ self.blocking_group,
179
+ *(
180
+ cast(PublicGroupName, name)
181
+ for name in GROUP_ORDER
182
+ if name != self.blocking_group
183
+ ),
184
+ )
185
+
186
+ def focused_input_ref(self) -> str | None:
187
+ focused_input = self.focused_input_node()
188
+ if focused_input is None or not focused_input.ref:
189
+ return None
190
+ return focused_input.ref
191
+
192
+ def to_public_screen(self) -> PublicScreen:
193
+ submit_refs_by_node_id = self._submit_refs_by_node_id()
194
+ return PublicScreen(
195
+ screen_id=self.screen_id,
196
+ app=PublicApp(
197
+ package_name=self.package_name,
198
+ activity_name=self.activity_name,
199
+ ),
200
+ surface=PublicSurface(
201
+ keyboard_visible=self.keyboard_visible,
202
+ blocking_group=self.blocking_group,
203
+ focus=PublicFocus(input_ref=self.focused_input_ref()),
204
+ ),
205
+ groups=build_public_groups(
206
+ order=self.group_order(),
207
+ targets=self._public_nodes_for(self.targets, submit_refs_by_node_id),
208
+ keyboard=self._public_nodes_for(self.keyboard, submit_refs_by_node_id),
209
+ system=self._public_nodes_for(self.system, submit_refs_by_node_id),
210
+ context=self._public_nodes_for(self.context, submit_refs_by_node_id),
211
+ dialog=self._public_nodes_for(self.dialog, submit_refs_by_node_id),
212
+ ),
213
+ omitted=(),
214
+ visible_windows=(),
215
+ transient=(),
216
+ )
217
+
218
+ def _public_nodes_for(
219
+ self,
220
+ nodes: list[SemanticNode],
221
+ submit_refs_by_node_id: dict[int, tuple[str, ...]],
222
+ ) -> tuple[PublicNode, ...]:
223
+ return tuple(
224
+ node_to_public_node(
225
+ node,
226
+ submit_refs=submit_refs_by_node_id.get(id(node), ()),
227
+ )
228
+ for node in nodes
229
+ )
230
+
231
+ def _submit_refs_by_node_id(self) -> dict[int, tuple[str, ...]]:
232
+ nodes = [
233
+ node
234
+ for group_name in self.group_order()
235
+ for node in cast(list[SemanticNode], getattr(self, group_name))
236
+ ]
237
+ nodes_by_key: dict[tuple[str, str], list[SemanticNode]] = {}
238
+ for node in nodes:
239
+ for node_key in _submit_relation_lookup_keys(node.group, node):
240
+ nodes_by_key.setdefault(node_key, []).append(node)
241
+
242
+ resolved: dict[int, tuple[str, ...]] = {}
243
+ for node in nodes:
244
+ if node.role != "input" or not node.ref or not node.submit_target_keys:
245
+ continue
246
+ target_refs: list[str] = []
247
+ complete = True
248
+ for target_key in node.submit_target_keys:
249
+ matches = nodes_by_key.get(target_key, [])
250
+ if len(matches) != 1 or not matches[0].ref:
251
+ complete = False
252
+ break
253
+ target_refs.append(matches[0].ref)
254
+ if complete:
255
+ resolved[id(node)] = tuple(target_refs)
256
+ return resolved
257
+
258
+
259
+ class SemanticCompiler:
260
+ def compile(self, sequence: int, snapshot: RawSnapshot) -> CompiledScreen:
261
+ ime_window_id = snapshot.ime.window_id if snapshot.ime.visible else None
262
+ raw_nodes_by_rid = {node.rid: node for node in snapshot.nodes}
263
+ root_rid_by_window = {
264
+ window.window_id: window.root_rid for window in snapshot.windows
265
+ }
266
+ label_infos = {
267
+ node.rid: synthesize_label_info(node, raw_nodes_by_rid)
268
+ for node in snapshot.nodes
269
+ }
270
+ actionability = {
271
+ node.rid: semantic_actions_for(node) for node in snapshot.nodes
272
+ }
273
+ target_sources = select_target_sources(
274
+ snapshot.nodes,
275
+ raw_nodes_by_rid,
276
+ label_infos,
277
+ actionability,
278
+ )
279
+ promoted_source_rids = {
280
+ source_rid
281
+ for anchor_rid, source_rid in target_sources.items()
282
+ if source_rid != anchor_rid
283
+ }
284
+ semantic_nodes = []
285
+ seen_passive_nodes: set[tuple[str, str, str, str, str]] = set()
286
+ for raw_node in snapshot.nodes:
287
+ if raw_node.rid in target_sources or raw_node.rid in promoted_source_rids:
288
+ continue
289
+ semantic_node = compile_node(
290
+ raw_node,
291
+ raw_nodes_by_rid,
292
+ label_infos,
293
+ actionability,
294
+ ime_window_id=ime_window_id,
295
+ root_rid_by_window=root_rid_by_window,
296
+ )
297
+ if semantic_node is not None:
298
+ duplicate_key = passive_node_dedup_key(semantic_node)
299
+ if duplicate_key is not None:
300
+ if duplicate_key in seen_passive_nodes:
301
+ continue
302
+ seen_passive_nodes.add(duplicate_key)
303
+ semantic_nodes.append(semantic_node)
304
+ for anchor_rid, source_rid in target_sources.items():
305
+ semantic_node = compile_node(
306
+ raw_nodes_by_rid[source_rid],
307
+ raw_nodes_by_rid,
308
+ label_infos,
309
+ actionability,
310
+ ime_window_id=ime_window_id,
311
+ root_rid_by_window=root_rid_by_window,
312
+ action_node=raw_nodes_by_rid[anchor_rid],
313
+ )
314
+ if semantic_node is not None:
315
+ semantic_nodes.append(semantic_node)
316
+
317
+ grouped_nodes = {
318
+ "targets": sorted(
319
+ [node for node in semantic_nodes if node.group == "targets"],
320
+ key=target_sort_key,
321
+ ),
322
+ "keyboard": sorted(
323
+ [node for node in semantic_nodes if node.group == "keyboard"],
324
+ key=target_sort_key,
325
+ ),
326
+ "system": sorted(
327
+ [node for node in semantic_nodes if node.group == "system"],
328
+ key=target_sort_key,
329
+ ),
330
+ "context": sorted(
331
+ [node for node in semantic_nodes if node.group == "context"],
332
+ key=context_sort_key,
333
+ ),
334
+ "dialog": sorted(
335
+ [node for node in semantic_nodes if node.group == "dialog"],
336
+ key=target_sort_key,
337
+ ),
338
+ }
339
+ blocking_group = resolve_blocking_group(
340
+ snapshot=snapshot,
341
+ grouped_nodes=grouped_nodes,
342
+ )
343
+ apply_blocking_policy(grouped_nodes, blocking_group)
344
+ assign_submit_ref_relations(
345
+ grouped_nodes=grouped_nodes,
346
+ blocking_group=blocking_group,
347
+ )
348
+ action_surface_fingerprint = build_action_surface_fingerprint(
349
+ snapshot=snapshot,
350
+ grouped_nodes=grouped_nodes,
351
+ blocking_group=blocking_group,
352
+ )
353
+ return CompiledScreen(
354
+ screen_id=stable_screen_id(action_surface_fingerprint),
355
+ sequence=sequence,
356
+ source_snapshot_id=snapshot.snapshot_id,
357
+ captured_at=snapshot.captured_at,
358
+ package_name=snapshot.package_name,
359
+ activity_name=snapshot.activity_name,
360
+ keyboard_visible=snapshot.ime.visible,
361
+ action_surface_fingerprint=action_surface_fingerprint,
362
+ blocking_group=blocking_group,
363
+ targets=grouped_nodes["targets"],
364
+ context=grouped_nodes["context"],
365
+ dialog=grouped_nodes["dialog"],
366
+ keyboard=grouped_nodes["keyboard"],
367
+ system=grouped_nodes["system"],
368
+ )
369
+
370
+
371
+ __all__ = [
372
+ "CLICK_ACTIONS",
373
+ "GENERIC_SEMANTIC_ROLES",
374
+ "LABEL_QUALITY_CONTENT_DESC",
375
+ "LABEL_QUALITY_HINT",
376
+ "LABEL_QUALITY_NEARBY",
377
+ "LABEL_QUALITY_RESOURCE_ID",
378
+ "LABEL_QUALITY_STATE_DESCRIPTION",
379
+ "LABEL_QUALITY_TEXT",
380
+ "LONG_CLICK_ACTIONS",
381
+ "SCROLL_ACTIONS",
382
+ "TYPE_ACTIONS",
383
+ "CompiledScreen",
384
+ "LabelInfo",
385
+ "SemanticCompiler",
386
+ "SemanticNode",
387
+ "actionable_anchor_for",
388
+ "ancestor_distance",
389
+ "apply_blocking_policy",
390
+ "build_action_surface_fingerprint",
391
+ "can_promote_to_actionable_ancestor",
392
+ "compile_node",
393
+ "context_sort_key",
394
+ "extract_state",
395
+ "fallback_label",
396
+ "infer_group",
397
+ "infer_role",
398
+ "label_from_state_description",
399
+ "near_label_for_node",
400
+ "node_to_public_node",
401
+ "normalize_action_name",
402
+ "normalize_text",
403
+ "parent_node_for",
404
+ "passive_node_dedup_key",
405
+ "relative_bounds_for_node",
406
+ "resolve_blocking_group",
407
+ "score_node",
408
+ "secondary_public_actions_for",
409
+ "select_target_sources",
410
+ "semantic_actions_for",
411
+ "semantic_node_fingerprint",
412
+ "semantic_role",
413
+ "should_filter_node",
414
+ "sibling_labels_for_node",
415
+ "stable_screen_id",
416
+ "state_description_parts",
417
+ "states_from_state_description",
418
+ "synthesize_label_info",
419
+ "target_sort_key",
420
+ "target_source_sort_key",
421
+ ]
422
+
423
+
424
+ def compile_node(
425
+ raw_node: RawNode,
426
+ raw_nodes_by_rid: dict[str, RawNode],
427
+ label_infos: dict[str, LabelInfo],
428
+ actionability: dict[str, list[str]],
429
+ *,
430
+ ime_window_id: str | None,
431
+ root_rid_by_window: dict[str, str],
432
+ action_node: RawNode | None = None,
433
+ ) -> SemanticNode | None:
434
+ if not raw_node.visible_to_user:
435
+ return None
436
+ anchor_node = action_node or raw_node
437
+ primary_actions = actionability[anchor_node.rid]
438
+ role = semantic_role(raw_node, anchor_node, primary_actions)
439
+ public_primary_actions = public_primary_actions_for(
440
+ anchor_node=anchor_node,
441
+ role=role,
442
+ primary_actions=primary_actions,
443
+ )
444
+ secondary_actions = secondary_public_actions_for(
445
+ anchor_node=anchor_node,
446
+ role=role,
447
+ primary_actions=public_primary_actions,
448
+ )
449
+ public_actions = [*public_primary_actions, *secondary_actions]
450
+ public_role = role
451
+ scroll_directions: list[str] = []
452
+ text_children: list[str] = []
453
+ if "scroll" in public_actions and role == "container":
454
+ public_role = "scroll-container"
455
+ scroll_directions = list(scroll_directions_for_raw_node(anchor_node))
456
+ text_children = list(
457
+ scroll_container_text_children(
458
+ raw_node=anchor_node,
459
+ raw_nodes_by_rid=raw_nodes_by_rid,
460
+ label_infos=label_infos,
461
+ )
462
+ )
463
+ label_info = label_infos[raw_node.rid]
464
+ label = label_info.label
465
+ targetable = bool(public_actions)
466
+ if should_filter_node(raw_node, public_role, label, targetable):
467
+ return None
468
+ parent_node = parent_node_for(raw_node, raw_nodes_by_rid)
469
+ parent_role = infer_role(parent_node) if parent_node is not None else ""
470
+ parent_label = (
471
+ label_infos[parent_node.rid].label or fallback_label(parent_node)
472
+ if parent_node is not None
473
+ else ""
474
+ )
475
+ return SemanticNode(
476
+ raw_rid=anchor_node.rid,
477
+ role=public_role,
478
+ label=label or fallback_label(raw_node),
479
+ state=extract_state(anchor_node),
480
+ actions=public_actions,
481
+ bounds=anchor_node.bounds,
482
+ meta=SemanticMeta(
483
+ resource_id=raw_node.resource_id,
484
+ class_name=raw_node.class_name,
485
+ ),
486
+ targetable=targetable,
487
+ score=score_node(anchor_node, public_role, targetable),
488
+ group=(
489
+ "dialog"
490
+ if parent_role == "dialog"
491
+ else infer_group(
492
+ anchor_node,
493
+ public_role,
494
+ targetable,
495
+ ime_window_id=ime_window_id,
496
+ )
497
+ ),
498
+ parent_role=parent_role,
499
+ parent_label=parent_label,
500
+ sibling_labels=sibling_labels_for_node(raw_node, raw_nodes_by_rid, label_infos),
501
+ relative_bounds=relative_bounds_for_node(raw_node, parent_node),
502
+ scroll_directions=scroll_directions,
503
+ text_children=text_children,
504
+ label_quality=label_info.quality,
505
+ relation_anchor_rid=anchor_node.rid,
506
+ relation_window_id=anchor_node.window_id,
507
+ relation_window_root_rid=root_rid_by_window.get(anchor_node.window_id),
508
+ relation_parent_rid=anchor_node.parent_rid,
509
+ relation_ancestor_scopes=_relation_ancestor_scopes(
510
+ anchor_node=anchor_node,
511
+ raw_nodes_by_rid=raw_nodes_by_rid,
512
+ root_rid_by_window=root_rid_by_window,
513
+ ),
514
+ )
515
+
516
+
517
+ def _submit_relation_lookup_keys(
518
+ group_name: str,
519
+ node: SemanticNode,
520
+ ) -> tuple[tuple[str, str], ...]:
521
+ keys = [semantic_relation_key(group_name, node)]
522
+ if node.relation_anchor_rid:
523
+ keys.append(
524
+ (
525
+ group_name,
526
+ "|".join(
527
+ (
528
+ semantic_relation_identity(group_name, node),
529
+ f"anchor:{node.relation_anchor_rid}",
530
+ )
531
+ ),
532
+ )
533
+ )
534
+ return tuple(keys)
535
+
536
+
537
+ def _relation_ancestor_scopes(
538
+ *,
539
+ anchor_node: RawNode,
540
+ raw_nodes_by_rid: dict[str, RawNode],
541
+ root_rid_by_window: dict[str, str],
542
+ ) -> tuple[RelationScopeNode, ...]:
543
+ scopes: list[RelationScopeNode] = []
544
+ seen_rids: set[str] = set()
545
+ parent_rid = anchor_node.parent_rid
546
+ while parent_rid is not None and parent_rid not in seen_rids:
547
+ seen_rids.add(parent_rid)
548
+ parent_node = raw_nodes_by_rid.get(parent_rid)
549
+ if parent_node is None:
550
+ break
551
+ scopes.append(_relation_scope_node(parent_node, root_rid_by_window))
552
+ parent_rid = parent_node.parent_rid
553
+ return tuple(scopes)
554
+
555
+
556
+ def _relation_scope_node(
557
+ node: RawNode,
558
+ root_rid_by_window: dict[str, str],
559
+ ) -> RelationScopeNode:
560
+ return RelationScopeNode(
561
+ rid=node.rid,
562
+ window_id=node.window_id,
563
+ parent_rid=node.parent_rid,
564
+ bounds=node.bounds,
565
+ resource_id=node.resource_id or "",
566
+ class_name=node.class_name,
567
+ text=node.text or "",
568
+ content_desc=node.content_desc or "",
569
+ pane_title=node.pane_title or "",
570
+ is_window_root=node.rid == root_rid_by_window.get(node.window_id),
571
+ )
572
+
573
+
574
+ def scroll_container_text_children(
575
+ *,
576
+ raw_node: RawNode,
577
+ raw_nodes_by_rid: dict[str, RawNode],
578
+ label_infos: dict[str, LabelInfo],
579
+ ) -> tuple[str, ...]:
580
+ texts: list[str] = []
581
+ for child_rid in raw_node.child_rids:
582
+ child = raw_nodes_by_rid.get(child_rid)
583
+ if child is None or not child.visible_to_user:
584
+ continue
585
+ text = label_infos[child.rid].label or fallback_label(child)
586
+ normalized = normalize_text(text)
587
+ if not normalized or normalized in texts:
588
+ continue
589
+ texts.append(normalized)
590
+ if texts:
591
+ return tuple(texts)
592
+
593
+ fallback_text = label_infos[raw_node.rid].label or fallback_label(raw_node)
594
+ normalized_fallback = normalize_text(fallback_text)
595
+ if not normalized_fallback:
596
+ return ()
597
+ return (normalized_fallback,)
598
+
599
+
600
+ def should_filter_node(
601
+ raw_node: RawNode,
602
+ role: str,
603
+ label: str,
604
+ targetable: bool,
605
+ ) -> bool:
606
+ if not targetable and not raw_node.important_for_accessibility:
607
+ return True
608
+ if role == "container" and not label and not targetable:
609
+ return True
610
+ return not label and not targetable and role in {"image", "text"}
@@ -0,0 +1,107 @@
1
+ """Semantic screen continuity decisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from androidctld.refs.service import best_candidate_for_binding, binding_for_candidate
8
+ from androidctld.semantics.compiler import CompiledScreen
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ContinuityDecision:
13
+ next_screen_id: str
14
+ continuity_status: str
15
+ changed: bool | None
16
+ code: str | None = None
17
+
18
+ @classmethod
19
+ def bootstrap(cls, candidate_screen: CompiledScreen) -> ContinuityDecision:
20
+ return cls(
21
+ next_screen_id=candidate_screen.screen_id,
22
+ continuity_status="none",
23
+ changed=None,
24
+ )
25
+
26
+ @classmethod
27
+ def stable(
28
+ cls,
29
+ *,
30
+ next_screen_id: str,
31
+ changed: bool,
32
+ ) -> ContinuityDecision:
33
+ return cls(
34
+ next_screen_id=next_screen_id,
35
+ continuity_status="stable",
36
+ changed=changed,
37
+ )
38
+
39
+ @classmethod
40
+ def stale(
41
+ cls,
42
+ *,
43
+ next_screen_id: str,
44
+ changed: bool,
45
+ code: str,
46
+ ) -> ContinuityDecision:
47
+ return cls(
48
+ next_screen_id=next_screen_id,
49
+ continuity_status="stale",
50
+ changed=changed,
51
+ code=code,
52
+ )
53
+
54
+
55
+ def evaluate_continuity(
56
+ *,
57
+ source_screen: CompiledScreen | None,
58
+ candidate_screen: CompiledScreen,
59
+ ) -> ContinuityDecision:
60
+ if source_screen is None:
61
+ return ContinuityDecision.bootstrap(candidate_screen)
62
+ if (
63
+ candidate_screen.action_surface_fingerprint
64
+ == source_screen.action_surface_fingerprint
65
+ ):
66
+ return ContinuityDecision.stable(
67
+ next_screen_id=source_screen.screen_id,
68
+ changed=False,
69
+ )
70
+ if _strict_repair_succeeds(
71
+ source_screen=source_screen,
72
+ candidate_screen=candidate_screen,
73
+ ):
74
+ return ContinuityDecision.stable(
75
+ next_screen_id=candidate_screen.screen_id,
76
+ changed=True,
77
+ )
78
+ return ContinuityDecision.stale(
79
+ next_screen_id=candidate_screen.screen_id,
80
+ changed=True,
81
+ code="REF_STALE",
82
+ )
83
+
84
+
85
+ def _strict_repair_succeeds(
86
+ *,
87
+ source_screen: CompiledScreen,
88
+ candidate_screen: CompiledScreen,
89
+ ) -> bool:
90
+ source_candidates = [
91
+ candidate for candidate in source_screen.ref_candidates() if candidate.ref
92
+ ]
93
+ if not source_candidates:
94
+ return False
95
+ remaining_candidates = list(candidate_screen.ref_candidates())
96
+ for source_candidate in source_candidates:
97
+ binding = binding_for_candidate(
98
+ ref=source_candidate.ref,
99
+ candidate=source_candidate,
100
+ snapshot_id=0,
101
+ reused=True,
102
+ )
103
+ match = best_candidate_for_binding(binding, remaining_candidates)
104
+ if match is None:
105
+ return False
106
+ remaining_candidates.remove(match[0])
107
+ return True