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,311 @@
|
|
|
1
|
+
"""Submit-only route admission for direct and attributed dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from androidctl_contracts.command_results import ActionTargetEvidence
|
|
9
|
+
from androidctld.actions.focused_input_admissibility import (
|
|
10
|
+
keyboard_blocker_allows_submit_subject,
|
|
11
|
+
public_focused_input_ref,
|
|
12
|
+
public_node_is_focused_input,
|
|
13
|
+
)
|
|
14
|
+
from androidctld.commands.command_models import SubmitCommand
|
|
15
|
+
from androidctld.errors import DaemonError, DaemonErrorCode
|
|
16
|
+
from androidctld.refs.models import NodeHandle
|
|
17
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
18
|
+
from androidctld.runtime.screen_state import (
|
|
19
|
+
current_compiled_screen,
|
|
20
|
+
current_public_screen,
|
|
21
|
+
)
|
|
22
|
+
from androidctld.semantics.compiler import CompiledScreen, SemanticNode
|
|
23
|
+
from androidctld.semantics.public_models import (
|
|
24
|
+
PublicNode,
|
|
25
|
+
PublicScreen,
|
|
26
|
+
iter_public_nodes,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
SubmitRouteKind = Literal["direct", "attributed"]
|
|
30
|
+
SubmitRouteFailureCode = Literal["TARGET_NOT_ACTIONABLE", "TARGET_BLOCKED"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SubmitRouteOutcome:
|
|
35
|
+
route: SubmitRouteKind
|
|
36
|
+
source_ref: str
|
|
37
|
+
source_screen_id: str
|
|
38
|
+
source_evidence: ActionTargetEvidence
|
|
39
|
+
route_screen_id: str
|
|
40
|
+
subject_ref: str
|
|
41
|
+
subject_handle: NodeHandle
|
|
42
|
+
dispatched_ref: str
|
|
43
|
+
dispatched_handle: NodeHandle
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SubmitRouteFailure:
|
|
48
|
+
code: SubmitRouteFailureCode
|
|
49
|
+
reason: str
|
|
50
|
+
ref: str | None
|
|
51
|
+
action: str | None = None
|
|
52
|
+
blocking_group: str | None = None
|
|
53
|
+
focused_input_ref: str | None = None
|
|
54
|
+
|
|
55
|
+
def to_error(self) -> DaemonError:
|
|
56
|
+
details: dict[str, object] = {"reason": self.reason, "ref": self.ref}
|
|
57
|
+
if self.action is not None:
|
|
58
|
+
details["action"] = self.action
|
|
59
|
+
if self.blocking_group is not None:
|
|
60
|
+
details["blockingGroup"] = self.blocking_group
|
|
61
|
+
if self.focused_input_ref is not None:
|
|
62
|
+
details["focusedInputRef"] = self.focused_input_ref
|
|
63
|
+
if self.code == "TARGET_BLOCKED":
|
|
64
|
+
return DaemonError(
|
|
65
|
+
code=DaemonErrorCode.TARGET_BLOCKED,
|
|
66
|
+
message="target is blocked on the current screen",
|
|
67
|
+
retryable=False,
|
|
68
|
+
details=details,
|
|
69
|
+
http_status=200,
|
|
70
|
+
)
|
|
71
|
+
return DaemonError(
|
|
72
|
+
code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
|
|
73
|
+
message="submit is not available for the requested target",
|
|
74
|
+
retryable=False,
|
|
75
|
+
details=details,
|
|
76
|
+
http_status=200,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def resolve_submit_route(
|
|
81
|
+
session: WorkspaceRuntime,
|
|
82
|
+
command: SubmitCommand,
|
|
83
|
+
*,
|
|
84
|
+
subject_handle: NodeHandle | None,
|
|
85
|
+
source_evidence: ActionTargetEvidence,
|
|
86
|
+
) -> SubmitRouteOutcome:
|
|
87
|
+
screen = current_public_screen(session)
|
|
88
|
+
compiled_screen = current_compiled_screen(session)
|
|
89
|
+
if (
|
|
90
|
+
screen is None
|
|
91
|
+
or compiled_screen is None
|
|
92
|
+
or session.latest_snapshot is None
|
|
93
|
+
or subject_handle is None
|
|
94
|
+
):
|
|
95
|
+
raise _failure(
|
|
96
|
+
"submit_route_unresolved_subject",
|
|
97
|
+
ref=command.ref,
|
|
98
|
+
).to_error()
|
|
99
|
+
if screen.screen_id != compiled_screen.screen_id:
|
|
100
|
+
raise _failure(
|
|
101
|
+
"submit_route_basis_mismatch",
|
|
102
|
+
ref=command.ref,
|
|
103
|
+
).to_error()
|
|
104
|
+
if subject_handle.snapshot_id != session.latest_snapshot.snapshot_id:
|
|
105
|
+
raise _failure(
|
|
106
|
+
"submit_route_stale_subject_handle",
|
|
107
|
+
ref=command.ref,
|
|
108
|
+
).to_error()
|
|
109
|
+
|
|
110
|
+
subject_node = _semantic_node_for_handle(compiled_screen, subject_handle)
|
|
111
|
+
subject_ref = None if subject_node is None else subject_node.ref
|
|
112
|
+
if subject_node is None or subject_ref is None:
|
|
113
|
+
raise _failure(
|
|
114
|
+
"submit_route_unresolved_subject",
|
|
115
|
+
ref=command.ref,
|
|
116
|
+
).to_error()
|
|
117
|
+
subject_public = _unique_public_node(screen, subject_ref)
|
|
118
|
+
if subject_public is None:
|
|
119
|
+
raise _failure(
|
|
120
|
+
"submit_route_unresolved_subject",
|
|
121
|
+
ref=subject_ref,
|
|
122
|
+
).to_error()
|
|
123
|
+
_ensure_subject_admissible(screen, compiled_screen, subject_node, subject_public)
|
|
124
|
+
|
|
125
|
+
submit_refs = subject_public.submit_refs
|
|
126
|
+
if len(submit_refs) > 1:
|
|
127
|
+
raise _failure(
|
|
128
|
+
"submit_route_ambiguous",
|
|
129
|
+
ref=subject_ref,
|
|
130
|
+
action="submit",
|
|
131
|
+
).to_error()
|
|
132
|
+
if len(submit_refs) == 1:
|
|
133
|
+
dispatched_ref = submit_refs[0]
|
|
134
|
+
dispatched_public = _unique_public_node(screen, dispatched_ref)
|
|
135
|
+
dispatched_node = _unique_semantic_node_for_ref(compiled_screen, dispatched_ref)
|
|
136
|
+
if dispatched_public is None or dispatched_node is None:
|
|
137
|
+
raise _failure(
|
|
138
|
+
"submit_route_unresolved_target",
|
|
139
|
+
ref=dispatched_ref,
|
|
140
|
+
action="tap",
|
|
141
|
+
).to_error()
|
|
142
|
+
_ensure_unblocked(screen, dispatched_node, ref=dispatched_ref)
|
|
143
|
+
if (
|
|
144
|
+
"tap" not in dispatched_public.actions
|
|
145
|
+
or "tap" not in dispatched_node.actions
|
|
146
|
+
):
|
|
147
|
+
raise _failure(
|
|
148
|
+
"submit_route_target_not_tap_capable",
|
|
149
|
+
ref=dispatched_ref,
|
|
150
|
+
action="tap",
|
|
151
|
+
).to_error()
|
|
152
|
+
|
|
153
|
+
return SubmitRouteOutcome(
|
|
154
|
+
route="attributed",
|
|
155
|
+
source_ref=command.ref,
|
|
156
|
+
source_screen_id=command.source_screen_id,
|
|
157
|
+
source_evidence=source_evidence,
|
|
158
|
+
route_screen_id=screen.screen_id,
|
|
159
|
+
subject_ref=subject_ref,
|
|
160
|
+
subject_handle=_current_handle(session, subject_node),
|
|
161
|
+
dispatched_ref=dispatched_ref,
|
|
162
|
+
dispatched_handle=_current_handle(session, dispatched_node),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if "submit" not in subject_public.actions:
|
|
166
|
+
raise _failure(
|
|
167
|
+
"submit_route_missing",
|
|
168
|
+
ref=subject_ref,
|
|
169
|
+
action="submit",
|
|
170
|
+
).to_error()
|
|
171
|
+
|
|
172
|
+
return SubmitRouteOutcome(
|
|
173
|
+
route="direct",
|
|
174
|
+
source_ref=command.ref,
|
|
175
|
+
source_screen_id=command.source_screen_id,
|
|
176
|
+
source_evidence=source_evidence,
|
|
177
|
+
route_screen_id=screen.screen_id,
|
|
178
|
+
subject_ref=subject_ref,
|
|
179
|
+
subject_handle=_current_handle(session, subject_node),
|
|
180
|
+
dispatched_ref=subject_ref,
|
|
181
|
+
dispatched_handle=_current_handle(session, subject_node),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _ensure_subject_admissible(
|
|
186
|
+
screen: PublicScreen,
|
|
187
|
+
compiled_screen: CompiledScreen,
|
|
188
|
+
subject_node: SemanticNode,
|
|
189
|
+
subject_public: PublicNode,
|
|
190
|
+
) -> None:
|
|
191
|
+
_ensure_subject_unblocked(screen, compiled_screen, subject_node, subject_public)
|
|
192
|
+
if subject_public.role != "input" or subject_node.role != "input":
|
|
193
|
+
raise _failure(
|
|
194
|
+
"not_input_capable",
|
|
195
|
+
ref=subject_public.ref,
|
|
196
|
+
action="submit",
|
|
197
|
+
).to_error()
|
|
198
|
+
if not public_node_is_focused_input(screen, subject_public):
|
|
199
|
+
raise _failure(
|
|
200
|
+
"focus_mismatch",
|
|
201
|
+
ref=subject_public.ref,
|
|
202
|
+
focused_input_ref=public_focused_input_ref(screen),
|
|
203
|
+
).to_error()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _ensure_subject_unblocked(
|
|
207
|
+
screen: PublicScreen,
|
|
208
|
+
compiled_screen: CompiledScreen,
|
|
209
|
+
subject_node: SemanticNode,
|
|
210
|
+
subject_public: PublicNode,
|
|
211
|
+
) -> None:
|
|
212
|
+
blocking_group = screen.surface.blocking_group
|
|
213
|
+
if blocking_group is None or subject_node.group == blocking_group:
|
|
214
|
+
return
|
|
215
|
+
if keyboard_blocker_allows_submit_subject(
|
|
216
|
+
blocking_group=blocking_group,
|
|
217
|
+
public_screen=screen,
|
|
218
|
+
compiled_screen=compiled_screen,
|
|
219
|
+
public_node=subject_public,
|
|
220
|
+
semantic_node=subject_node,
|
|
221
|
+
):
|
|
222
|
+
return
|
|
223
|
+
raise _failure(
|
|
224
|
+
f"blocked_by_{blocking_group}",
|
|
225
|
+
code="TARGET_BLOCKED",
|
|
226
|
+
ref=subject_public.ref,
|
|
227
|
+
blocking_group=blocking_group,
|
|
228
|
+
).to_error()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _ensure_unblocked(
|
|
232
|
+
screen: PublicScreen,
|
|
233
|
+
node: SemanticNode,
|
|
234
|
+
*,
|
|
235
|
+
ref: str | None,
|
|
236
|
+
) -> None:
|
|
237
|
+
blocking_group = screen.surface.blocking_group
|
|
238
|
+
if blocking_group is None or node.group == blocking_group:
|
|
239
|
+
return
|
|
240
|
+
raise _failure(
|
|
241
|
+
f"blocked_by_{blocking_group}",
|
|
242
|
+
code="TARGET_BLOCKED",
|
|
243
|
+
ref=ref,
|
|
244
|
+
blocking_group=blocking_group,
|
|
245
|
+
).to_error()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _failure(
|
|
249
|
+
reason: str,
|
|
250
|
+
*,
|
|
251
|
+
ref: str | None,
|
|
252
|
+
code: SubmitRouteFailureCode = "TARGET_NOT_ACTIONABLE",
|
|
253
|
+
action: str | None = None,
|
|
254
|
+
blocking_group: str | None = None,
|
|
255
|
+
focused_input_ref: str | None = None,
|
|
256
|
+
) -> SubmitRouteFailure:
|
|
257
|
+
return SubmitRouteFailure(
|
|
258
|
+
code=code,
|
|
259
|
+
reason=reason,
|
|
260
|
+
ref=ref,
|
|
261
|
+
action=action,
|
|
262
|
+
blocking_group=blocking_group,
|
|
263
|
+
focused_input_ref=focused_input_ref,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _semantic_node_for_handle(
|
|
268
|
+
screen: CompiledScreen,
|
|
269
|
+
handle: NodeHandle,
|
|
270
|
+
) -> SemanticNode | None:
|
|
271
|
+
for node in _compiled_nodes(screen):
|
|
272
|
+
if node.raw_rid == handle.rid:
|
|
273
|
+
return node
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _current_handle(session: WorkspaceRuntime, node: SemanticNode) -> NodeHandle:
|
|
278
|
+
if session.latest_snapshot is None:
|
|
279
|
+
raise DaemonError(
|
|
280
|
+
code=DaemonErrorCode.SCREEN_NOT_READY,
|
|
281
|
+
message="screen is not ready yet",
|
|
282
|
+
)
|
|
283
|
+
return NodeHandle(snapshot_id=session.latest_snapshot.snapshot_id, rid=node.raw_rid)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _unique_semantic_node_for_ref(
|
|
287
|
+
screen: CompiledScreen,
|
|
288
|
+
ref: str,
|
|
289
|
+
) -> SemanticNode | None:
|
|
290
|
+
nodes = [node for node in _compiled_nodes(screen) if node.ref == ref]
|
|
291
|
+
return nodes[0] if len(nodes) == 1 else None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _unique_public_node(screen: PublicScreen, ref: str) -> PublicNode | None:
|
|
295
|
+
nodes = [
|
|
296
|
+
node
|
|
297
|
+
for group in screen.groups
|
|
298
|
+
for node in iter_public_nodes(group.nodes)
|
|
299
|
+
if node.ref == ref
|
|
300
|
+
]
|
|
301
|
+
return nodes[0] if len(nodes) == 1 else None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _compiled_nodes(screen: CompiledScreen) -> tuple[SemanticNode, ...]:
|
|
305
|
+
return (
|
|
306
|
+
*screen.targets,
|
|
307
|
+
*screen.context,
|
|
308
|
+
*screen.dialog,
|
|
309
|
+
*screen.keyboard,
|
|
310
|
+
*screen.system,
|
|
311
|
+
)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Type confirmation helpers for post-action validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from androidctld.commands.command_models import TypeCommand
|
|
10
|
+
from androidctld.device.types import (
|
|
11
|
+
ActionPerformResult,
|
|
12
|
+
ResolvedHandleTarget,
|
|
13
|
+
ResolvedTarget,
|
|
14
|
+
)
|
|
15
|
+
from androidctld.errors import DaemonError, DaemonErrorCode
|
|
16
|
+
from androidctld.refs.models import NodeHandle, RefBinding
|
|
17
|
+
from androidctld.refs.service import best_candidate_for_binding
|
|
18
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
19
|
+
from androidctld.semantics.compiler import CompiledScreen, SemanticNode
|
|
20
|
+
from androidctld.snapshots.models import RawNode, RawSnapshot
|
|
21
|
+
from androidctld.text_equivalence import (
|
|
22
|
+
canonical_text_key,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class TypeConfirmationContext:
|
|
28
|
+
ref: str | None
|
|
29
|
+
request_handle: NodeHandle | None
|
|
30
|
+
binding: RefBinding | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class TypeConfirmationCandidate:
|
|
35
|
+
strategy: str
|
|
36
|
+
node: RawNode | None
|
|
37
|
+
target_handle: NodeHandle | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_type_confirmation(
|
|
41
|
+
session: WorkspaceRuntime,
|
|
42
|
+
command: TypeCommand,
|
|
43
|
+
snapshot: RawSnapshot,
|
|
44
|
+
context: TypeConfirmationContext,
|
|
45
|
+
action_result: ActionPerformResult,
|
|
46
|
+
) -> TypeConfirmationCandidate:
|
|
47
|
+
candidates = type_confirmation_candidates(
|
|
48
|
+
session,
|
|
49
|
+
snapshot,
|
|
50
|
+
context,
|
|
51
|
+
action_result,
|
|
52
|
+
)
|
|
53
|
+
for candidate in candidates:
|
|
54
|
+
if candidate.node is None:
|
|
55
|
+
raise RuntimeError("type confirmation candidate is missing node")
|
|
56
|
+
if matches_typed_value(command, observed_input_value(candidate.node)):
|
|
57
|
+
return candidate
|
|
58
|
+
raise DaemonError(
|
|
59
|
+
code=DaemonErrorCode.TYPE_NOT_CONFIRMED,
|
|
60
|
+
message="typed text was not confirmed on the refreshed screen",
|
|
61
|
+
retryable=True,
|
|
62
|
+
details=type_confirmation_error_details(command, context, candidates),
|
|
63
|
+
http_status=200,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_type_confirmation_context(
|
|
68
|
+
session: WorkspaceRuntime,
|
|
69
|
+
command: TypeCommand,
|
|
70
|
+
request_handle: NodeHandle | None,
|
|
71
|
+
) -> TypeConfirmationContext:
|
|
72
|
+
binding = session.ref_registry.get(command.ref)
|
|
73
|
+
return TypeConfirmationContext(
|
|
74
|
+
ref=command.ref,
|
|
75
|
+
request_handle=request_handle,
|
|
76
|
+
binding=None if binding is None else deepcopy(binding),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def type_confirmation_candidates(
|
|
81
|
+
session: WorkspaceRuntime,
|
|
82
|
+
snapshot: RawSnapshot,
|
|
83
|
+
context: TypeConfirmationContext,
|
|
84
|
+
action_result: ActionPerformResult,
|
|
85
|
+
) -> list[TypeConfirmationCandidate]:
|
|
86
|
+
candidate_nodes: list[TypeConfirmationCandidate] = []
|
|
87
|
+
seen_rids: set[str] = set()
|
|
88
|
+
|
|
89
|
+
def add_candidate(
|
|
90
|
+
strategy: str,
|
|
91
|
+
node: RawNode | None,
|
|
92
|
+
target_handle: NodeHandle | None,
|
|
93
|
+
) -> None:
|
|
94
|
+
if node is None or node.rid in seen_rids:
|
|
95
|
+
return
|
|
96
|
+
seen_rids.add(node.rid)
|
|
97
|
+
candidate_nodes.append(
|
|
98
|
+
TypeConfirmationCandidate(
|
|
99
|
+
strategy=strategy,
|
|
100
|
+
node=node,
|
|
101
|
+
target_handle=target_handle,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
resolved_handle = action_result_target_handle(action_result)
|
|
106
|
+
add_candidate(
|
|
107
|
+
"resolvedTarget",
|
|
108
|
+
snapshot_node_for_handle(snapshot, resolved_handle),
|
|
109
|
+
resolved_handle,
|
|
110
|
+
)
|
|
111
|
+
add_candidate(
|
|
112
|
+
"requestTarget",
|
|
113
|
+
snapshot_node_for_handle(snapshot, context.request_handle),
|
|
114
|
+
context.request_handle,
|
|
115
|
+
)
|
|
116
|
+
reused_node = reused_ref_confirmation_node(session, snapshot, context)
|
|
117
|
+
add_candidate("reusedRef", reused_node, _snapshot_handle(snapshot, reused_node))
|
|
118
|
+
rematch_node = fingerprint_rematch_confirmation_node(session, snapshot, context)
|
|
119
|
+
add_candidate(
|
|
120
|
+
"fingerprintRematch",
|
|
121
|
+
rematch_node,
|
|
122
|
+
_snapshot_handle(snapshot, rematch_node),
|
|
123
|
+
)
|
|
124
|
+
return candidate_nodes
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def action_result_target_handle(
|
|
128
|
+
action_result: ActionPerformResult,
|
|
129
|
+
) -> NodeHandle | None:
|
|
130
|
+
resolved_target = action_result.resolved_target
|
|
131
|
+
if resolved_target is None:
|
|
132
|
+
return None
|
|
133
|
+
return resolved_target_handle(resolved_target)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def resolved_target_handle(target: ResolvedTarget) -> NodeHandle | None:
|
|
137
|
+
if not isinstance(target, ResolvedHandleTarget):
|
|
138
|
+
return None
|
|
139
|
+
return target.handle
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def snapshot_node_for_handle(
|
|
143
|
+
snapshot: RawSnapshot, handle: NodeHandle | None
|
|
144
|
+
) -> RawNode | None:
|
|
145
|
+
if handle is None:
|
|
146
|
+
return None
|
|
147
|
+
for node in snapshot.nodes:
|
|
148
|
+
if node.rid == handle.rid:
|
|
149
|
+
return node
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _snapshot_handle(snapshot: RawSnapshot, node: RawNode | None) -> NodeHandle | None:
|
|
154
|
+
if node is None:
|
|
155
|
+
return None
|
|
156
|
+
return NodeHandle(snapshot_id=snapshot.snapshot_id, rid=node.rid)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def is_type_confirmation_candidate(node: RawNode) -> bool:
|
|
160
|
+
return bool(node.visible_to_user and node.editable)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def observed_input_value(node: RawNode) -> str:
|
|
164
|
+
if node.text is None:
|
|
165
|
+
return ""
|
|
166
|
+
return str(node.text)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def matches_typed_value(command: TypeCommand, actual_value: str) -> bool:
|
|
170
|
+
return canonical_text_key(actual_value) == canonical_text_key(command.text)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def reused_ref_confirmation_node(
|
|
174
|
+
session: WorkspaceRuntime,
|
|
175
|
+
snapshot: RawSnapshot,
|
|
176
|
+
context: TypeConfirmationContext,
|
|
177
|
+
) -> RawNode | None:
|
|
178
|
+
if context.ref is None:
|
|
179
|
+
return None
|
|
180
|
+
binding = session.ref_registry.get(context.ref)
|
|
181
|
+
if binding is None or not binding.reused:
|
|
182
|
+
return None
|
|
183
|
+
node = snapshot_node_for_handle(snapshot, binding.handle)
|
|
184
|
+
if node is None or not is_type_confirmation_candidate(node):
|
|
185
|
+
return None
|
|
186
|
+
return node
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def fingerprint_rematch_confirmation_node(
|
|
190
|
+
session: WorkspaceRuntime,
|
|
191
|
+
snapshot: RawSnapshot,
|
|
192
|
+
context: TypeConfirmationContext,
|
|
193
|
+
) -> RawNode | None:
|
|
194
|
+
if context.binding is None or session.screen_state is None:
|
|
195
|
+
return None
|
|
196
|
+
compiled_screen = session.screen_state.compiled_screen
|
|
197
|
+
if compiled_screen is None:
|
|
198
|
+
return None
|
|
199
|
+
match = best_candidate_for_binding(
|
|
200
|
+
context.binding,
|
|
201
|
+
type_confirmation_semantic_candidates(compiled_screen, snapshot),
|
|
202
|
+
)
|
|
203
|
+
if match is None:
|
|
204
|
+
return None
|
|
205
|
+
candidate, _ = match
|
|
206
|
+
return snapshot_node_for_rid(snapshot, candidate.raw_rid)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def type_confirmation_semantic_candidates(
|
|
210
|
+
compiled_screen: CompiledScreen,
|
|
211
|
+
snapshot: RawSnapshot,
|
|
212
|
+
) -> list[SemanticNode]:
|
|
213
|
+
nodes_by_rid = {node.rid: node for node in snapshot.nodes}
|
|
214
|
+
candidates = []
|
|
215
|
+
for semantic_node in compiled_screen_nodes(compiled_screen):
|
|
216
|
+
raw_node = nodes_by_rid.get(semantic_node.raw_rid)
|
|
217
|
+
if raw_node is None or not is_type_confirmation_candidate(raw_node):
|
|
218
|
+
continue
|
|
219
|
+
candidates.append(semantic_node)
|
|
220
|
+
return candidates
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def snapshot_node_for_rid(snapshot: RawSnapshot, rid: str) -> RawNode | None:
|
|
224
|
+
for node in snapshot.nodes:
|
|
225
|
+
if node.rid == rid:
|
|
226
|
+
return node
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def type_confirmation_error_details(
|
|
231
|
+
command: TypeCommand,
|
|
232
|
+
context: TypeConfirmationContext,
|
|
233
|
+
candidates: list[TypeConfirmationCandidate],
|
|
234
|
+
) -> dict[str, Any]:
|
|
235
|
+
return {
|
|
236
|
+
"ref": context.ref,
|
|
237
|
+
"text": command.text,
|
|
238
|
+
"replace": True,
|
|
239
|
+
"candidateCount": len(candidates),
|
|
240
|
+
"confirmationStrategy": (
|
|
241
|
+
"resolvedTarget>requestTarget>reusedRef>" + "fingerprintRematch"
|
|
242
|
+
),
|
|
243
|
+
"candidateRids": [
|
|
244
|
+
None if candidate.node is None else candidate.node.rid
|
|
245
|
+
for candidate in candidates
|
|
246
|
+
],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def compiled_screen_nodes(compiled_screen: CompiledScreen) -> tuple[SemanticNode, ...]:
|
|
251
|
+
return (
|
|
252
|
+
*compiled_screen.targets,
|
|
253
|
+
*compiled_screen.context,
|
|
254
|
+
*compiled_screen.dialog,
|
|
255
|
+
*compiled_screen.keyboard,
|
|
256
|
+
*compiled_screen.system,
|
|
257
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared app-target matching for open/wait surfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from androidctld.errors import DaemonError, DaemonErrorCode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AppTargetMatch:
|
|
13
|
+
requested_package_name: str
|
|
14
|
+
resolved_package_name: str
|
|
15
|
+
match_type: Literal["exact", "alias"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
APP_TARGET_ALIASES: frozenset[tuple[str, str]] = frozenset(
|
|
19
|
+
{
|
|
20
|
+
("com.android.settings", "com.android.settings.intelligence"),
|
|
21
|
+
("com.android.settings", "com.google.android.settings.intelligence"),
|
|
22
|
+
(
|
|
23
|
+
"com.android.settings.intelligence",
|
|
24
|
+
"com.google.android.settings.intelligence",
|
|
25
|
+
),
|
|
26
|
+
(
|
|
27
|
+
"com.google.android.settings.intelligence",
|
|
28
|
+
"com.android.settings.intelligence",
|
|
29
|
+
),
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def match_app_target(
|
|
35
|
+
requested_package_name: str,
|
|
36
|
+
actual_package_name: str | None,
|
|
37
|
+
) -> AppTargetMatch | None:
|
|
38
|
+
if actual_package_name is None:
|
|
39
|
+
return None
|
|
40
|
+
if actual_package_name == requested_package_name:
|
|
41
|
+
return AppTargetMatch(
|
|
42
|
+
requested_package_name=requested_package_name,
|
|
43
|
+
resolved_package_name=actual_package_name,
|
|
44
|
+
match_type="exact",
|
|
45
|
+
)
|
|
46
|
+
if (requested_package_name, actual_package_name) in APP_TARGET_ALIASES:
|
|
47
|
+
return AppTargetMatch(
|
|
48
|
+
requested_package_name=requested_package_name,
|
|
49
|
+
resolved_package_name=actual_package_name,
|
|
50
|
+
match_type="alias",
|
|
51
|
+
)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def require_app_target_match(
|
|
56
|
+
requested_package_name: str,
|
|
57
|
+
actual_package_name: str | None,
|
|
58
|
+
) -> AppTargetMatch:
|
|
59
|
+
match = match_app_target(requested_package_name, actual_package_name)
|
|
60
|
+
if match is not None:
|
|
61
|
+
return match
|
|
62
|
+
raise DaemonError(
|
|
63
|
+
code=DaemonErrorCode.OPEN_FAILED,
|
|
64
|
+
message="open did not reach the requested application",
|
|
65
|
+
retryable=True,
|
|
66
|
+
details={
|
|
67
|
+
"expectedPackageName": requested_package_name,
|
|
68
|
+
"actualPackageName": actual_package_name,
|
|
69
|
+
},
|
|
70
|
+
http_status=200,
|
|
71
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Artifact writers for androidctld."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Typed artifact models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import ConfigDict
|
|
6
|
+
|
|
7
|
+
from androidctld.schema import ApiModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScreenArtifacts(ApiModel):
|
|
11
|
+
model_config = ConfigDict(
|
|
12
|
+
strict=True,
|
|
13
|
+
extra="forbid",
|
|
14
|
+
alias_generator=ApiModel.model_config["alias_generator"],
|
|
15
|
+
validate_by_alias=True,
|
|
16
|
+
validate_by_name=True,
|
|
17
|
+
use_enum_values=False,
|
|
18
|
+
frozen=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
screen_json: str | None = None
|
|
22
|
+
screen_xml: str | None = None
|
|
23
|
+
screenshot_png: str | None = None
|
|
24
|
+
|
|
25
|
+
def with_screenshot(self, screenshot_png: str) -> ScreenArtifacts:
|
|
26
|
+
return self.model_copy(update={"screenshot_png": screenshot_png})
|