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,417 @@
1
+ """High-confidence input-to-submit-control attribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from androidctld.semantics.labels import (
8
+ LABEL_QUALITY_CONTENT_DESC,
9
+ LABEL_QUALITY_HINT,
10
+ LABEL_QUALITY_TEXT,
11
+ )
12
+ from androidctld.semantics.models import RelationScopeNode
13
+ from androidctld.semantics.public_models import BlockingGroupName, PublicGroupName
14
+ from androidctld.semantics.registries import GROUP_ORDER
15
+ from androidctld.semantics.surface import (
16
+ semantic_relation_identity,
17
+ semantic_relation_key,
18
+ )
19
+ from androidctld.text_equivalence import canonical_text_key
20
+
21
+ if TYPE_CHECKING:
22
+ from androidctld.semantics.compiler import SemanticNode
23
+
24
+
25
+ NodeKey = tuple[str, str]
26
+
27
+ MAX_SCOPE_DISTANCE = 4
28
+ EDGE_TOLERANCE_PX = 4
29
+ MIN_STACKED_HORIZONTAL_OVERLAP = 0.35
30
+ MIN_INLINE_VERTICAL_OVERLAP = 0.50
31
+ MAX_STACKED_GAP_MULTIPLIER = 2.5
32
+ MAX_INLINE_GAP_MULTIPLIER = 1.5
33
+ MIN_GAP_CAP_PX = 24
34
+
35
+ _GLOBAL_SCOPE_TOKENS = (
36
+ "toolbar",
37
+ "actionbar",
38
+ "statusbar",
39
+ "navigationbar",
40
+ "systembar",
41
+ "urlbar",
42
+ "omnibox",
43
+ "addressbar",
44
+ "tabstrip",
45
+ "appbar",
46
+ )
47
+ _FORM_CONTAINER_CLASSES = frozenset({"android.webkit.webview"})
48
+
49
+ _SUBMIT_LABELS = frozenset(
50
+ {
51
+ "submit",
52
+ "send",
53
+ "search",
54
+ "go",
55
+ "done",
56
+ "next",
57
+ "enter",
58
+ "continue",
59
+ "sign in",
60
+ "signin",
61
+ "log in",
62
+ "login",
63
+ }
64
+ )
65
+ _HIGH_CONFIDENCE_LABEL_QUALITIES = {
66
+ LABEL_QUALITY_HINT,
67
+ LABEL_QUALITY_CONTENT_DESC,
68
+ LABEL_QUALITY_TEXT,
69
+ }
70
+
71
+
72
+ def assign_submit_ref_relations(
73
+ *,
74
+ grouped_nodes: dict[str, list[SemanticNode]],
75
+ blocking_group: BlockingGroupName | None,
76
+ ) -> None:
77
+ """Attach daemon-internal submit attribution when a scope is unambiguous."""
78
+
79
+ clear_submit_ref_relations(grouped_nodes)
80
+ for group_name in _active_relation_groups(blocking_group):
81
+ nodes = grouped_nodes.get(group_name, [])
82
+ focused_inputs = [node for node in nodes if _eligible_focused_input(node)]
83
+ if len(focused_inputs) != 1:
84
+ continue
85
+ source = focused_inputs[0]
86
+ candidates = _submit_control_candidates(
87
+ source=source,
88
+ nodes=nodes,
89
+ group_name=group_name,
90
+ )
91
+ if len(candidates) != 1:
92
+ continue
93
+ target = candidates[0]
94
+ source.submit_target_keys.append(_node_key(group_name, target))
95
+ source.submit_relation_tokens.append(
96
+ submit_relation_token(group_name, source, target)
97
+ )
98
+
99
+
100
+ def clear_submit_ref_relations(grouped_nodes: dict[str, list[SemanticNode]]) -> None:
101
+ for nodes in grouped_nodes.values():
102
+ for node in nodes:
103
+ node.submit_target_keys.clear()
104
+ node.submit_relation_tokens.clear()
105
+
106
+
107
+ def _active_relation_groups(
108
+ blocking_group: BlockingGroupName | None,
109
+ ) -> tuple[PublicGroupName, ...]:
110
+ if blocking_group is not None:
111
+ return (blocking_group,)
112
+ return cast(
113
+ tuple[PublicGroupName, ...],
114
+ tuple(group_name for group_name in GROUP_ORDER if group_name != "context"),
115
+ )
116
+
117
+
118
+ def _eligible_focused_input(node: SemanticNode) -> bool:
119
+ return (
120
+ node.role == "input"
121
+ and "disabled" not in node.state
122
+ and "focused" in node.state
123
+ and bool(node.actions or "focused" in node.state)
124
+ )
125
+
126
+
127
+ def _submit_control_candidates(
128
+ *,
129
+ source: SemanticNode,
130
+ nodes: list[SemanticNode],
131
+ group_name: PublicGroupName,
132
+ ) -> list[SemanticNode]:
133
+ hard_candidates: list[tuple[SemanticNode, RelationScopeNode]] = []
134
+ for target in nodes:
135
+ if target is source:
136
+ continue
137
+ if not _eligible_submit_control(target, group_name=group_name):
138
+ continue
139
+ if source.relation_window_id != target.relation_window_id:
140
+ continue
141
+ relation_scope = relation_scope_for(source, target)
142
+ if relation_scope is None:
143
+ continue
144
+ hard_candidates.append((target, relation_scope))
145
+ return [
146
+ target
147
+ for target, relation_scope in hard_candidates
148
+ if is_plausible_page_submit_geometry(source, target, relation_scope)
149
+ ]
150
+
151
+
152
+ def _eligible_submit_control(
153
+ node: SemanticNode,
154
+ *,
155
+ group_name: PublicGroupName,
156
+ ) -> bool:
157
+ if group_name == "keyboard":
158
+ eligible_role = node.role in {"button", "keyboard-key"}
159
+ else:
160
+ eligible_role = node.role == "button"
161
+ return (
162
+ eligible_role
163
+ and "tap" in node.actions
164
+ and "disabled" not in node.state
165
+ and node.label_quality in _HIGH_CONFIDENCE_LABEL_QUALITIES
166
+ and canonical_text_key(node.label) in _SUBMIT_LABELS
167
+ )
168
+
169
+
170
+ def relation_scope_for(
171
+ source: SemanticNode,
172
+ target: SemanticNode,
173
+ ) -> RelationScopeNode | None:
174
+ if not source.relation_window_id or not target.relation_window_id:
175
+ return None
176
+ if source.relation_window_id != target.relation_window_id:
177
+ return None
178
+ if (
179
+ source.relation_parent_rid
180
+ and source.relation_parent_rid == target.relation_parent_rid
181
+ ):
182
+ direct_scope = _ancestor_by_rid(source, source.relation_parent_rid)
183
+ if direct_scope is not None and meaningful_relation_scope(
184
+ direct_scope,
185
+ source=source,
186
+ target=target,
187
+ ):
188
+ return direct_scope
189
+
190
+ target_distances = {
191
+ scope.rid: distance
192
+ for distance, scope in enumerate(target.relation_ancestor_scopes, start=1)
193
+ }
194
+ for source_distance, scope in enumerate(
195
+ source.relation_ancestor_scopes,
196
+ start=1,
197
+ ):
198
+ target_distance = target_distances.get(scope.rid)
199
+ if target_distance is None:
200
+ continue
201
+ if source_distance > MAX_SCOPE_DISTANCE or target_distance > MAX_SCOPE_DISTANCE:
202
+ continue
203
+ if meaningful_relation_scope(scope, source=source, target=target):
204
+ return scope
205
+ return None
206
+
207
+
208
+ def meaningful_relation_scope(
209
+ scope: RelationScopeNode,
210
+ *,
211
+ source: SemanticNode,
212
+ target: SemanticNode,
213
+ ) -> bool:
214
+ if scope.window_id != source.relation_window_id:
215
+ return False
216
+ if scope.window_id != target.relation_window_id:
217
+ return False
218
+ if scope.is_window_root:
219
+ return False
220
+ if scope.rid in {
221
+ source.relation_anchor_rid,
222
+ target.relation_anchor_rid,
223
+ source.relation_window_root_rid,
224
+ target.relation_window_root_rid,
225
+ }:
226
+ return False
227
+ if _is_rejected_global_scope(scope):
228
+ return False
229
+ return _has_meaningful_structural_signal(scope)
230
+
231
+
232
+ def is_plausible_page_submit_geometry(
233
+ source: SemanticNode,
234
+ target: SemanticNode,
235
+ scope: RelationScopeNode,
236
+ ) -> bool:
237
+ if not _center_inside(source.bounds, scope.bounds):
238
+ return False
239
+ if not _center_inside(target.bounds, scope.bounds):
240
+ return False
241
+ return _accepted_stacked_geometry(source.bounds, target.bounds) or (
242
+ _accepted_inline_geometry(source.bounds, target.bounds)
243
+ )
244
+
245
+
246
+ def _node_key(group_name: PublicGroupName, node: SemanticNode) -> NodeKey:
247
+ if not node.relation_anchor_rid:
248
+ return semantic_relation_key(node.group, node)
249
+ return (
250
+ group_name,
251
+ "|".join(
252
+ (
253
+ semantic_relation_identity(group_name, node),
254
+ f"anchor:{node.relation_anchor_rid}",
255
+ )
256
+ ),
257
+ )
258
+
259
+
260
+ def submit_relation_token(
261
+ group_name: PublicGroupName,
262
+ source: SemanticNode,
263
+ target: SemanticNode,
264
+ ) -> str:
265
+ source_identity = semantic_relation_identity(group_name, source)
266
+ target_identity = semantic_relation_identity(group_name, target)
267
+ return f"{source_identity}->{target_identity}"
268
+
269
+
270
+ def _ancestor_by_rid(
271
+ node: SemanticNode,
272
+ rid: str,
273
+ ) -> RelationScopeNode | None:
274
+ for scope in node.relation_ancestor_scopes:
275
+ if scope.rid == rid:
276
+ return scope
277
+ return None
278
+
279
+
280
+ def _is_rejected_global_scope(scope: RelationScopeNode) -> bool:
281
+ if _resource_id_is_root_content(scope.resource_id):
282
+ return True
283
+ if _class_name_is_decor_view(scope.class_name):
284
+ return True
285
+ return any(
286
+ token in _canonical_scope_value(value)
287
+ for value in (
288
+ scope.resource_id,
289
+ scope.class_name,
290
+ scope.text,
291
+ scope.content_desc,
292
+ scope.pane_title,
293
+ )
294
+ for token in _GLOBAL_SCOPE_TOKENS
295
+ )
296
+
297
+
298
+ def _has_meaningful_structural_signal(scope: RelationScopeNode) -> bool:
299
+ if scope.resource_id and not _resource_id_is_root_content(scope.resource_id):
300
+ return True
301
+ if scope.pane_title:
302
+ return True
303
+ if scope.text or scope.content_desc:
304
+ return True
305
+ return _canonical_class_name(scope.class_name) in _FORM_CONTAINER_CLASSES
306
+
307
+
308
+ def _resource_id_is_root_content(resource_id: str) -> bool:
309
+ normalized = resource_id.strip().lower()
310
+ if normalized == "android:id/content" or normalized.endswith("android:id/content"):
311
+ return True
312
+ key = canonical_text_key(resource_id)
313
+ return key == "android id content" or key.endswith("android id content")
314
+
315
+
316
+ def _class_name_is_decor_view(class_name: str) -> bool:
317
+ normalized = class_name.strip().lower()
318
+ return normalized == "decorview" or normalized.endswith(".decorview")
319
+
320
+
321
+ def _canonical_scope_value(value: str) -> str:
322
+ return canonical_text_key(value).replace(" ", "")
323
+
324
+
325
+ def _canonical_class_name(value: str) -> str:
326
+ return value.strip().lower()
327
+
328
+
329
+ def _accepted_stacked_geometry(
330
+ source_bounds: tuple[int, int, int, int],
331
+ target_bounds: tuple[int, int, int, int],
332
+ ) -> bool:
333
+ source_width = _width(source_bounds)
334
+ target_width = _width(target_bounds)
335
+ source_height = _height(source_bounds)
336
+ target_height = _height(target_bounds)
337
+ if min(source_width, target_width, source_height, target_height) <= 0:
338
+ return False
339
+ if target_bounds[1] < source_bounds[3] - EDGE_TOLERANCE_PX:
340
+ return False
341
+ vertical_gap = max(0, target_bounds[1] - source_bounds[3])
342
+ max_gap = max(
343
+ MIN_GAP_CAP_PX,
344
+ MAX_STACKED_GAP_MULTIPLIER * max(source_height, target_height),
345
+ )
346
+ if vertical_gap > max_gap:
347
+ return False
348
+ horizontal_overlap = _overlap(
349
+ source_bounds[0],
350
+ source_bounds[2],
351
+ target_bounds[0],
352
+ target_bounds[2],
353
+ )
354
+ return (
355
+ horizontal_overlap / min(source_width, target_width)
356
+ >= MIN_STACKED_HORIZONTAL_OVERLAP
357
+ )
358
+
359
+
360
+ def _accepted_inline_geometry(
361
+ source_bounds: tuple[int, int, int, int],
362
+ target_bounds: tuple[int, int, int, int],
363
+ ) -> bool:
364
+ source_width = _width(source_bounds)
365
+ target_width = _width(target_bounds)
366
+ source_height = _height(source_bounds)
367
+ target_height = _height(target_bounds)
368
+ if min(source_width, target_width, source_height, target_height) <= 0:
369
+ return False
370
+ if target_bounds[0] < source_bounds[2] - EDGE_TOLERANCE_PX:
371
+ return False
372
+ horizontal_gap = max(0, target_bounds[0] - source_bounds[2])
373
+ max_gap = max(
374
+ MIN_GAP_CAP_PX,
375
+ MAX_INLINE_GAP_MULTIPLIER * max(source_height, target_height),
376
+ )
377
+ if horizontal_gap > max_gap:
378
+ return False
379
+ vertical_overlap = _overlap(
380
+ source_bounds[1],
381
+ source_bounds[3],
382
+ target_bounds[1],
383
+ target_bounds[3],
384
+ )
385
+ return (
386
+ vertical_overlap / min(source_height, target_height)
387
+ >= MIN_INLINE_VERTICAL_OVERLAP
388
+ )
389
+
390
+
391
+ def _center_inside(
392
+ bounds: tuple[int, int, int, int],
393
+ container_bounds: tuple[int, int, int, int],
394
+ ) -> bool:
395
+ center_x = (bounds[0] + bounds[2]) / 2
396
+ center_y = (bounds[1] + bounds[3]) / 2
397
+ return (
398
+ container_bounds[0] <= center_x <= container_bounds[2]
399
+ and container_bounds[1] <= center_y <= container_bounds[3]
400
+ )
401
+
402
+
403
+ def _width(bounds: tuple[int, int, int, int]) -> int:
404
+ return bounds[2] - bounds[0]
405
+
406
+
407
+ def _height(bounds: tuple[int, int, int, int]) -> int:
408
+ return bounds[3] - bounds[1]
409
+
410
+
411
+ def _overlap(
412
+ start_a: int,
413
+ end_a: int,
414
+ start_b: int,
415
+ end_b: int,
416
+ ) -> int:
417
+ return max(0, min(end_a, end_b) - max(start_a, start_b))
@@ -0,0 +1,254 @@
1
+ """Semantic compiler surface-shape helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from typing import TYPE_CHECKING
7
+
8
+ from androidctld.semantics.labels import normalize_text
9
+ from androidctld.semantics.policy import (
10
+ ENABLED_SCORE_BONUS,
11
+ FOCUSED_SCORE_BONUS,
12
+ ROLE_BASE_SCORES,
13
+ TARGETABLE_SCORE_BONUS,
14
+ )
15
+ from androidctld.semantics.public_models import (
16
+ BlockingGroupName,
17
+ PublicNode,
18
+ PublicSemanticMeta,
19
+ )
20
+ from androidctld.semantics.registries import GROUP_ORDER
21
+ from androidctld.semantics.targets import normalize_action_name
22
+ from androidctld.snapshots.models import RawNode, RawSnapshot
23
+ from androidctld.text_equivalence import canonical_text_key
24
+
25
+ if TYPE_CHECKING:
26
+ from androidctld.semantics.compiler import SemanticNode
27
+
28
+
29
+ _SCROLL_DIRECTION_BY_ACTION = {
30
+ "scrollForward": "down",
31
+ "scrollBackward": "backward",
32
+ "scrollUp": "up",
33
+ "scrollDown": "down",
34
+ "scrollLeft": "left",
35
+ "scrollRight": "right",
36
+ }
37
+
38
+
39
+ def passive_node_dedup_key(
40
+ node: SemanticNode,
41
+ ) -> tuple[str, str, str, str, str] | None:
42
+ if node.targetable:
43
+ return None
44
+ normalized_label = normalize_text(node.label).lower()
45
+ if not normalized_label:
46
+ return None
47
+ return (
48
+ node.group,
49
+ node.role,
50
+ normalized_label,
51
+ normalize_text(node.parent_role).lower(),
52
+ normalize_text(node.parent_label).lower(),
53
+ )
54
+
55
+
56
+ def infer_group(
57
+ raw_node: RawNode,
58
+ role: str,
59
+ targetable: bool,
60
+ *,
61
+ ime_window_id: str | None,
62
+ ) -> str:
63
+ if role == "dialog" or raw_node.pane_title:
64
+ return "dialog"
65
+ normalized_ime_window_id = (
66
+ None if ime_window_id is None else normalize_text(ime_window_id)
67
+ )
68
+ if role == "keyboard-key" or (
69
+ normalized_ime_window_id is not None
70
+ and normalize_text(raw_node.window_id) == normalized_ime_window_id
71
+ ):
72
+ return "keyboard"
73
+ package_name = raw_node.package_name
74
+ if package_name is not None and package_name.startswith("com.android.systemui"):
75
+ return "system"
76
+ if targetable:
77
+ return "targets"
78
+ return "context"
79
+
80
+
81
+ def score_node(raw_node: RawNode, role: str, targetable: bool) -> int:
82
+ score = 0
83
+ if targetable:
84
+ score += TARGETABLE_SCORE_BONUS
85
+ score += ROLE_BASE_SCORES.get(role, 0)
86
+ if raw_node.focused:
87
+ score += FOCUSED_SCORE_BONUS
88
+ if raw_node.enabled:
89
+ score += ENABLED_SCORE_BONUS
90
+ return score
91
+
92
+
93
+ def target_sort_key(node: SemanticNode) -> tuple[int, int, int, str, str]:
94
+ return (
95
+ -node.score,
96
+ node.bounds[1],
97
+ node.bounds[0],
98
+ node.label.lower(),
99
+ node.raw_rid,
100
+ )
101
+
102
+
103
+ def context_sort_key(node: SemanticNode) -> tuple[int, int, str]:
104
+ return (
105
+ node.bounds[1],
106
+ node.bounds[0],
107
+ node.label.lower(),
108
+ )
109
+
110
+
111
+ def ime_owned_surface(
112
+ *,
113
+ snapshot: RawSnapshot,
114
+ grouped_nodes: dict[str, list[SemanticNode]],
115
+ ) -> bool:
116
+ if not snapshot.ime.visible:
117
+ return False
118
+ return any(
119
+ node.role == "input" and "focused" in node.state
120
+ for node in grouped_nodes["keyboard"]
121
+ )
122
+
123
+
124
+ def resolve_blocking_group(
125
+ *,
126
+ snapshot: RawSnapshot,
127
+ grouped_nodes: dict[str, list[SemanticNode]],
128
+ ) -> BlockingGroupName | None:
129
+ if any(node.actions for node in grouped_nodes["dialog"]):
130
+ return "dialog"
131
+ if ime_owned_surface(snapshot=snapshot, grouped_nodes=grouped_nodes) and any(
132
+ node.actions for node in grouped_nodes["keyboard"]
133
+ ):
134
+ return "keyboard"
135
+ if any(node.actions for node in grouped_nodes["system"]):
136
+ return "system"
137
+ return None
138
+
139
+
140
+ def apply_blocking_policy(
141
+ grouped_nodes: dict[str, list[SemanticNode]],
142
+ blocking_group: BlockingGroupName | None,
143
+ ) -> None:
144
+ if blocking_group is None:
145
+ return
146
+ for group_name, nodes in grouped_nodes.items():
147
+ if group_name == "context":
148
+ continue
149
+ if group_name == blocking_group:
150
+ continue
151
+ for node in nodes:
152
+ node.actions = []
153
+
154
+
155
+ def build_action_surface_fingerprint(
156
+ *,
157
+ snapshot: RawSnapshot,
158
+ grouped_nodes: dict[str, list[SemanticNode]],
159
+ blocking_group: BlockingGroupName | None,
160
+ ) -> str:
161
+ parts = [
162
+ canonical_text_key(snapshot.package_name),
163
+ canonical_text_key(snapshot.activity_name),
164
+ "keyboard-visible" if snapshot.ime.visible else "keyboard-hidden",
165
+ f"blocking:{blocking_group or ''}",
166
+ ]
167
+ for group_name in GROUP_ORDER:
168
+ parts.append(f"group:{group_name}")
169
+ for node in grouped_nodes[group_name]:
170
+ parts.append(semantic_node_fingerprint(group_name, node))
171
+ return "\n".join(parts)
172
+
173
+
174
+ def semantic_node_fingerprint(group_name: str, node: SemanticNode) -> str:
175
+ parts = [_semantic_node_base_fingerprint(group_name, node)]
176
+ parts.extend(f"submitRefs:{token}" for token in sorted(node.submit_relation_tokens))
177
+ return "|".join(parts)
178
+
179
+
180
+ def _semantic_node_base_fingerprint(group_name: str, node: SemanticNode) -> str:
181
+ state = ",".join(sorted(canonical_text_key(token) for token in node.state))
182
+ actions = ",".join(sorted(canonical_text_key(token) for token in node.actions))
183
+ siblings = ",".join(
184
+ canonical_text_key(label)
185
+ for label in node.sibling_labels
186
+ if canonical_text_key(label)
187
+ )
188
+ bounds = ",".join(str(value) for value in node.relative_bounds)
189
+ return "|".join(
190
+ (
191
+ group_name,
192
+ canonical_text_key(node.role),
193
+ canonical_text_key(node.label),
194
+ state,
195
+ actions,
196
+ canonical_text_key(node.resource_id),
197
+ canonical_text_key(node.class_name),
198
+ canonical_text_key(node.parent_role),
199
+ canonical_text_key(node.parent_label),
200
+ siblings,
201
+ bounds,
202
+ )
203
+ )
204
+
205
+
206
+ def semantic_relation_identity(group_name: str, node: SemanticNode) -> str:
207
+ return _semantic_node_base_fingerprint(group_name, node)
208
+
209
+
210
+ def semantic_relation_key(group_name: str, node: SemanticNode) -> tuple[str, str]:
211
+ return (group_name, semantic_relation_identity(group_name, node))
212
+
213
+
214
+ def stable_screen_id(action_surface_fingerprint: str) -> str:
215
+ digest = hashlib.sha256(action_surface_fingerprint.encode("utf-8")).digest()
216
+ numeric = int.from_bytes(digest[:8], "big") % (10**16)
217
+ return f"screen-{numeric:016d}"
218
+
219
+
220
+ def node_to_public_node(
221
+ node: SemanticNode,
222
+ *,
223
+ submit_refs: tuple[str, ...] = (),
224
+ ) -> PublicNode:
225
+ return PublicNode(
226
+ kind="container" if node.role == "scroll-container" else "node",
227
+ role=node.role,
228
+ label=node.label,
229
+ ref=node.ref or None,
230
+ state=tuple(node.state),
231
+ actions=tuple(node.actions),
232
+ bounds=node.bounds,
233
+ meta=PublicSemanticMeta(
234
+ resource_id=node.meta.resource_id,
235
+ class_name=node.meta.class_name,
236
+ ),
237
+ scroll_directions=tuple(node.scroll_directions),
238
+ submit_refs=submit_refs,
239
+ children=tuple(
240
+ PublicNode(kind="text", text=child_text)
241
+ for child_text in node.text_children
242
+ ),
243
+ )
244
+
245
+
246
+ def scroll_directions_for_raw_node(raw_node: RawNode) -> tuple[str, ...]:
247
+ directions: list[str] = []
248
+ for action in raw_node.actions:
249
+ normalized = normalize_action_name(action)
250
+ direction = _SCROLL_DIRECTION_BY_ACTION.get(normalized)
251
+ if direction is None or direction in directions:
252
+ continue
253
+ directions.append(direction)
254
+ return tuple(directions)