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.
- androidctl/__init__.py +5 -0
- androidctl/__main__.py +4 -0
- androidctl/_version.py +1 -0
- androidctl/app.py +73 -0
- androidctl/cli_options.py +27 -0
- androidctl/command_payloads.py +264 -0
- androidctl/command_views.py +157 -0
- androidctl/commands/__init__.py +1 -0
- androidctl/commands/actions.py +236 -0
- androidctl/commands/adb_wireless.py +157 -0
- androidctl/commands/close.py +30 -0
- androidctl/commands/connect.py +69 -0
- androidctl/commands/execute.py +179 -0
- androidctl/commands/list_apps.py +26 -0
- androidctl/commands/observe.py +26 -0
- androidctl/commands/open.py +41 -0
- androidctl/commands/plumbing.py +58 -0
- androidctl/commands/run_pipeline.py +307 -0
- androidctl/commands/screenshot.py +29 -0
- androidctl/commands/setup.py +301 -0
- androidctl/commands/wait.py +60 -0
- androidctl/daemon/__init__.py +1 -0
- androidctl/daemon/client.py +348 -0
- androidctl/daemon/discovery.py +190 -0
- androidctl/daemon/launcher.py +26 -0
- androidctl/daemon/owner.py +349 -0
- androidctl/errors/__init__.py +1 -0
- androidctl/errors/mapping.py +149 -0
- androidctl/errors/models.py +16 -0
- androidctl/exit_codes.py +8 -0
- androidctl/output.py +147 -0
- androidctl/parsing/__init__.py +1 -0
- androidctl/parsing/duration.py +17 -0
- androidctl/parsing/open_target.py +51 -0
- androidctl/parsing/refs.py +12 -0
- androidctl/parsing/screen_id.py +10 -0
- androidctl/parsing/wait.py +70 -0
- androidctl/renderers/__init__.py +110 -0
- androidctl/renderers/_paths.py +109 -0
- androidctl/renderers/xml.py +234 -0
- androidctl/renderers/xml_projection.py +732 -0
- androidctl/resources/__init__.py +1 -0
- androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
- androidctl/setup/__init__.py +1 -0
- androidctl/setup/accessibility.py +159 -0
- androidctl/setup/adb.py +586 -0
- androidctl/setup/apk_resource.py +29 -0
- androidctl/setup/pairing.py +70 -0
- androidctl/setup/verify.py +175 -0
- androidctl/workspace/__init__.py +3 -0
- androidctl/workspace/resolve.py +27 -0
- androidctl-0.1.0.dist-info/METADATA +217 -0
- androidctl-0.1.0.dist-info/RECORD +187 -0
- androidctl-0.1.0.dist-info/WHEEL +5 -0
- androidctl-0.1.0.dist-info/entry_points.txt +3 -0
- androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- androidctl-0.1.0.dist-info/top_level.txt +3 -0
- androidctl_contracts/__init__.py +55 -0
- androidctl_contracts/_version.py +1 -0
- androidctl_contracts/_wire_helpers.py +31 -0
- androidctl_contracts/base.py +142 -0
- androidctl_contracts/command_catalog.py +414 -0
- androidctl_contracts/command_results.py +630 -0
- androidctl_contracts/daemon_api.py +335 -0
- androidctl_contracts/errors.py +44 -0
- androidctl_contracts/paths.py +5 -0
- androidctl_contracts/public_screen.py +579 -0
- androidctl_contracts/user_state.py +23 -0
- androidctl_contracts/vocabulary.py +82 -0
- androidctld/__init__.py +5 -0
- androidctld/__main__.py +63 -0
- androidctld/_version.py +1 -0
- androidctld/actions/__init__.py +1 -0
- androidctld/actions/action_target.py +142 -0
- androidctld/actions/capabilities.py +539 -0
- androidctld/actions/executor.py +894 -0
- androidctld/actions/focus_confirmation.py +177 -0
- androidctld/actions/focused_input_admissibility.py +120 -0
- androidctld/actions/fresh_current.py +176 -0
- androidctld/actions/postconditions.py +473 -0
- androidctld/actions/repair.py +101 -0
- androidctld/actions/request_builder.py +204 -0
- androidctld/actions/settle.py +146 -0
- androidctld/actions/submit_confirmation.py +211 -0
- androidctld/actions/submit_routing.py +311 -0
- androidctld/actions/type_confirmation.py +257 -0
- androidctld/app_targets.py +71 -0
- androidctld/artifacts/__init__.py +1 -0
- androidctld/artifacts/models.py +26 -0
- androidctld/artifacts/screen_lookup.py +241 -0
- androidctld/artifacts/screen_payloads.py +109 -0
- androidctld/artifacts/writer.py +286 -0
- androidctld/auth/__init__.py +1 -0
- androidctld/auth/active_registry.py +266 -0
- androidctld/auth/secret_files.py +52 -0
- androidctld/auth/token_store.py +59 -0
- androidctld/commands/__init__.py +1 -0
- androidctld/commands/assembly.py +231 -0
- androidctld/commands/command_models.py +254 -0
- androidctld/commands/dispatch.py +99 -0
- androidctld/commands/executor.py +31 -0
- androidctld/commands/from_boundary.py +175 -0
- androidctld/commands/handlers/__init__.py +15 -0
- androidctld/commands/handlers/action.py +439 -0
- androidctld/commands/handlers/connect.py +94 -0
- androidctld/commands/handlers/list_apps.py +215 -0
- androidctld/commands/handlers/observe.py +121 -0
- androidctld/commands/handlers/screenshot.py +105 -0
- androidctld/commands/handlers/wait.py +286 -0
- androidctld/commands/models.py +65 -0
- androidctld/commands/open_targets.py +56 -0
- androidctld/commands/orchestration.py +353 -0
- androidctld/commands/registry.py +116 -0
- androidctld/commands/result_builders.py +40 -0
- androidctld/commands/result_models.py +555 -0
- androidctld/commands/results.py +108 -0
- androidctld/commands/semantic_command_names.py +17 -0
- androidctld/commands/semantic_error_mapping.py +93 -0
- androidctld/commands/semantic_truth.py +135 -0
- androidctld/commands/service.py +67 -0
- androidctld/config.py +75 -0
- androidctld/daemon/__init__.py +1 -0
- androidctld/daemon/active_slot.py +326 -0
- androidctld/daemon/envelope.py +30 -0
- androidctld/daemon/http_host.py +123 -0
- androidctld/daemon/ingress.py +112 -0
- androidctld/daemon/ownership_probe.py +204 -0
- androidctld/daemon/server.py +286 -0
- androidctld/daemon/service.py +99 -0
- androidctld/device/__init__.py +1 -0
- androidctld/device/action_models.py +154 -0
- androidctld/device/action_serialization.py +121 -0
- androidctld/device/adapters.py +220 -0
- androidctld/device/bootstrap.py +153 -0
- androidctld/device/connectors.py +231 -0
- androidctld/device/errors.py +100 -0
- androidctld/device/interfaces.py +58 -0
- androidctld/device/parsing.py +320 -0
- androidctld/device/rpc.py +483 -0
- androidctld/device/schema.py +114 -0
- androidctld/device/types.py +161 -0
- androidctld/errors/__init__.py +94 -0
- androidctld/logging/__init__.py +22 -0
- androidctld/observation.py +98 -0
- androidctld/protocol.py +53 -0
- androidctld/refs/__init__.py +1 -0
- androidctld/refs/models.py +54 -0
- androidctld/refs/repair.py +284 -0
- androidctld/refs/service.py +422 -0
- androidctld/rendering/__init__.py +1 -0
- androidctld/rendering/screen_xml.py +256 -0
- androidctld/runtime/__init__.py +21 -0
- androidctld/runtime/kernel.py +548 -0
- androidctld/runtime/lifecycle.py +19 -0
- androidctld/runtime/models.py +48 -0
- androidctld/runtime/screen_state.py +117 -0
- androidctld/runtime/state_repo.py +70 -0
- androidctld/runtime/store.py +76 -0
- androidctld/runtime_policy.py +127 -0
- androidctld/schema/__init__.py +5 -0
- androidctld/schema/base.py +132 -0
- androidctld/schema/core.py +35 -0
- androidctld/schema/daemon_api.py +108 -0
- androidctld/schema/persistence.py +161 -0
- androidctld/schema/persistence_io.py +41 -0
- androidctld/schema/validation_errors.py +309 -0
- androidctld/semantics/__init__.py +1 -0
- androidctld/semantics/compiler.py +610 -0
- androidctld/semantics/continuity.py +107 -0
- androidctld/semantics/labels.py +252 -0
- androidctld/semantics/models.py +25 -0
- androidctld/semantics/policy.py +23 -0
- androidctld/semantics/public_models.py +123 -0
- androidctld/semantics/registries.py +13 -0
- androidctld/semantics/submit_refs.py +417 -0
- androidctld/semantics/surface.py +254 -0
- androidctld/semantics/targets.py +167 -0
- androidctld/snapshots/__init__.py +1 -0
- androidctld/snapshots/models.py +219 -0
- androidctld/snapshots/refresh.py +273 -0
- androidctld/snapshots/schema.py +74 -0
- androidctld/snapshots/service.py +138 -0
- androidctld/text_equivalence.py +67 -0
- androidctld/waits/__init__.py +1 -0
- androidctld/waits/evaluators.py +216 -0
- androidctld/waits/loop.py +305 -0
- 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)
|