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,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})