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,473 @@
1
+ """Post-action validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from urllib.parse import urlsplit
7
+
8
+ from androidctld.actions.focus_confirmation import (
9
+ FocusConfirmationContext,
10
+ FocusConfirmationOutcome,
11
+ validate_focus_confirmation,
12
+ )
13
+ from androidctld.app_targets import AppTargetMatch, require_app_target_match
14
+ from androidctld.commands.command_models import (
15
+ ActionCommand,
16
+ FocusCommand,
17
+ LongTapCommand,
18
+ OpenCommand,
19
+ ScrollCommand,
20
+ )
21
+ from androidctld.commands.open_targets import OpenAppTarget, OpenUrlTarget
22
+ from androidctld.commands.results import screen_changed
23
+ from androidctld.device.types import ActionPerformResult
24
+ from androidctld.errors import DaemonError, DaemonErrorCode
25
+ from androidctld.runtime.models import WorkspaceRuntime
26
+ from androidctld.runtime.screen_state import current_compiled_screen
27
+ from androidctld.semantics.public_models import (
28
+ PublicNode,
29
+ PublicScreen,
30
+ iter_public_nodes,
31
+ )
32
+ from androidctld.snapshots.models import RawSnapshot
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PostconditionOutcome:
37
+ app_match: AppTargetMatch | None = None
38
+ focus_confirmation: FocusConfirmationOutcome | None = None
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class RefActionPostconditionContext:
43
+ target_ref: str | None
44
+ baseline_screen: PublicScreen | None
45
+ baseline_target: PublicNode | None
46
+
47
+
48
+ def validate_postcondition(
49
+ command: ActionCommand,
50
+ previous_snapshot: RawSnapshot | None,
51
+ snapshot: RawSnapshot,
52
+ previous_screen: PublicScreen | None,
53
+ public_screen: PublicScreen,
54
+ *,
55
+ session: WorkspaceRuntime,
56
+ focus_context: FocusConfirmationContext | None,
57
+ action_result: ActionPerformResult,
58
+ ref_context: RefActionPostconditionContext | None = None,
59
+ ) -> PostconditionOutcome:
60
+ if isinstance(command, FocusCommand):
61
+ if focus_context is None:
62
+ raise RuntimeError("focus confirmation context was not prepared")
63
+ focus_confirmation = validate_focus_confirmation(
64
+ session=session,
65
+ previous_snapshot=previous_snapshot,
66
+ snapshot=snapshot,
67
+ context=FocusConfirmationContext(
68
+ request_handle=focus_context.request_handle,
69
+ binding=focus_context.binding,
70
+ resolved_target=action_result.resolved_target,
71
+ ),
72
+ )
73
+ if not _focus_postcondition_matches(
74
+ session=session,
75
+ public_screen=public_screen,
76
+ confirmation=focus_confirmation,
77
+ ):
78
+ raise DaemonError(
79
+ code=DaemonErrorCode.TARGET_NOT_ACTIONABLE,
80
+ message="focus did not land on the requested input target",
81
+ retryable=True,
82
+ details={
83
+ "reason": "focus_mismatch",
84
+ "ref": command.ref,
85
+ "focusedInputRef": public_screen.surface.focus.input_ref,
86
+ },
87
+ http_status=200,
88
+ )
89
+ return PostconditionOutcome(focus_confirmation=focus_confirmation)
90
+ if isinstance(command, ScrollCommand):
91
+ _validate_scroll_confirmation(
92
+ command,
93
+ context=_ref_context_or_source_ref(
94
+ command.ref,
95
+ previous_screen=previous_screen,
96
+ context=ref_context,
97
+ ),
98
+ public_screen=public_screen,
99
+ )
100
+ return PostconditionOutcome()
101
+ if isinstance(command, LongTapCommand):
102
+ _validate_long_tap_confirmation(
103
+ command,
104
+ context=_ref_context_or_source_ref(
105
+ command.ref,
106
+ previous_screen=previous_screen,
107
+ context=ref_context,
108
+ ),
109
+ public_screen=public_screen,
110
+ )
111
+ return PostconditionOutcome()
112
+ if not isinstance(command, OpenCommand):
113
+ return PostconditionOutcome()
114
+ if isinstance(command.target, OpenAppTarget):
115
+ match = require_app_target_match(
116
+ command.target.package_name,
117
+ snapshot.package_name,
118
+ )
119
+ return PostconditionOutcome(app_match=match)
120
+ if not isinstance(command.target, OpenUrlTarget):
121
+ raise DaemonError(
122
+ code=DaemonErrorCode.DAEMON_BAD_REQUEST,
123
+ message="open requires target.kind app|url and target.value",
124
+ http_status=400,
125
+ )
126
+ _validate_open_url_navigation(
127
+ command.target,
128
+ previous_snapshot=previous_snapshot,
129
+ snapshot=snapshot,
130
+ previous_screen=previous_screen,
131
+ public_screen=public_screen,
132
+ )
133
+ return PostconditionOutcome()
134
+
135
+
136
+ def _focus_postcondition_matches(
137
+ *,
138
+ session: WorkspaceRuntime,
139
+ public_screen: PublicScreen,
140
+ confirmation: FocusConfirmationOutcome,
141
+ ) -> bool:
142
+ focused_ref = public_screen.surface.focus.input_ref
143
+ if focused_ref is None or not _public_ref_exists(public_screen, focused_ref):
144
+ return False
145
+ compiled_screen = current_compiled_screen(session)
146
+ if compiled_screen is None:
147
+ return False
148
+ if compiled_screen.source_snapshot_id != confirmation.target_handle.snapshot_id:
149
+ return False
150
+ focused_node = compiled_screen.focused_input_node()
151
+ focused_input_ref = compiled_screen.focused_input_ref()
152
+ return (
153
+ focused_node is not None
154
+ and focused_input_ref == focused_ref
155
+ and focused_node.raw_rid == confirmation.target_handle.rid
156
+ )
157
+
158
+
159
+ def _public_ref_exists(public_screen: PublicScreen, ref: str) -> bool:
160
+ for group in public_screen.groups:
161
+ for node in iter_public_nodes(group.nodes):
162
+ if node.ref == ref:
163
+ return True
164
+ return False
165
+
166
+
167
+ def _ref_context_or_source_ref(
168
+ ref: str,
169
+ *,
170
+ previous_screen: PublicScreen | None,
171
+ context: RefActionPostconditionContext | None,
172
+ ) -> RefActionPostconditionContext:
173
+ if context is not None:
174
+ return context
175
+ return RefActionPostconditionContext(
176
+ target_ref=ref,
177
+ baseline_screen=previous_screen,
178
+ baseline_target=(
179
+ None
180
+ if previous_screen is None
181
+ else _public_node_by_ref(previous_screen, ref)
182
+ ),
183
+ )
184
+
185
+
186
+ def _validate_scroll_confirmation(
187
+ command: ScrollCommand,
188
+ *,
189
+ context: RefActionPostconditionContext,
190
+ public_screen: PublicScreen,
191
+ ) -> None:
192
+ previous_target = context.baseline_target
193
+ current_target = _current_ref_context_target(public_screen, context)
194
+ if (
195
+ previous_target is not None
196
+ and current_target is not None
197
+ and previous_target.role == "scroll-container"
198
+ and current_target.role == "scroll-container"
199
+ and "scroll" in previous_target.actions
200
+ and "scroll" in current_target.actions
201
+ and _scroll_content_signature(previous_target)
202
+ != _scroll_content_signature(current_target)
203
+ ):
204
+ return
205
+ if (
206
+ previous_target is not None
207
+ and current_target is not None
208
+ and previous_target.role == "scroll-container"
209
+ and current_target.role == "scroll-container"
210
+ and "scroll" in previous_target.actions
211
+ and "scroll" in current_target.actions
212
+ and _scroll_direction_change_confirms(
213
+ command.direction,
214
+ previous_target.scroll_directions,
215
+ current_target.scroll_directions,
216
+ )
217
+ ):
218
+ return
219
+ raise DaemonError(
220
+ code=DaemonErrorCode.ACTION_NOT_CONFIRMED,
221
+ message="scroll was not confirmed on the refreshed screen",
222
+ retryable=True,
223
+ details=_ref_action_error_details(
224
+ command,
225
+ context,
226
+ reason="scroll_target_content_unchanged",
227
+ direction=command.direction,
228
+ ),
229
+ http_status=200,
230
+ )
231
+
232
+
233
+ def _public_node_by_ref(public_screen: PublicScreen, ref: str) -> PublicNode | None:
234
+ for group in public_screen.groups:
235
+ for node in iter_public_nodes(group.nodes):
236
+ if node.ref == ref:
237
+ return node
238
+ return None
239
+
240
+
241
+ def _validate_long_tap_confirmation(
242
+ command: LongTapCommand,
243
+ *,
244
+ context: RefActionPostconditionContext,
245
+ public_screen: PublicScreen,
246
+ ) -> None:
247
+ if context.baseline_screen is not None:
248
+ previous_screen = context.baseline_screen
249
+ previous_target = context.baseline_target
250
+ if previous_target is not None:
251
+ if _long_tap_context_or_dialog_changed(
252
+ previous_screen,
253
+ public_screen,
254
+ ) or _long_tap_transient_changed(previous_screen, public_screen):
255
+ return
256
+ current_target = _current_ref_context_target(public_screen, context)
257
+ if current_target is not None and _same_target_long_tap_feedback_changed(
258
+ previous_target,
259
+ current_target,
260
+ ):
261
+ return
262
+ raise DaemonError(
263
+ code=DaemonErrorCode.ACTION_NOT_CONFIRMED,
264
+ message="long-tap was not confirmed on the refreshed screen",
265
+ retryable=True,
266
+ details=_ref_action_error_details(
267
+ command,
268
+ context,
269
+ reason="long_tap_feedback_not_observed",
270
+ ),
271
+ http_status=200,
272
+ )
273
+
274
+
275
+ def _current_ref_context_target(
276
+ public_screen: PublicScreen,
277
+ context: RefActionPostconditionContext,
278
+ ) -> PublicNode | None:
279
+ if context.target_ref is None:
280
+ return None
281
+ return _public_node_by_ref(public_screen, context.target_ref)
282
+
283
+
284
+ def _ref_action_error_details(
285
+ command: ScrollCommand | LongTapCommand,
286
+ context: RefActionPostconditionContext,
287
+ **details: object,
288
+ ) -> dict[str, object]:
289
+ payload = {
290
+ **details,
291
+ "ref": command.ref,
292
+ }
293
+ if context.target_ref is not None and context.target_ref != command.ref:
294
+ payload["targetRef"] = context.target_ref
295
+ return payload
296
+
297
+
298
+ def _long_tap_context_or_dialog_changed(
299
+ previous_screen: PublicScreen,
300
+ public_screen: PublicScreen,
301
+ ) -> bool:
302
+ return any(
303
+ _long_tap_context_dialog_group_signature(previous_screen, group_name)
304
+ != _long_tap_context_dialog_group_signature(public_screen, group_name)
305
+ for group_name in ("context", "dialog")
306
+ )
307
+
308
+
309
+ def _long_tap_transient_changed(
310
+ previous_screen: PublicScreen,
311
+ public_screen: PublicScreen,
312
+ ) -> bool:
313
+ return _transient_signature(previous_screen) != _transient_signature(public_screen)
314
+
315
+
316
+ def _long_tap_context_dialog_group_signature(
317
+ screen: PublicScreen,
318
+ group_name: str,
319
+ ) -> tuple[object, ...]:
320
+ for group in screen.groups:
321
+ if group.name == group_name:
322
+ return tuple(
323
+ _long_tap_context_dialog_feedback_signature(node)
324
+ for node in group.nodes
325
+ )
326
+ return ()
327
+
328
+
329
+ def _long_tap_context_dialog_feedback_signature(
330
+ node: PublicNode,
331
+ ) -> tuple[object, ...]:
332
+ if node.kind == "text":
333
+ return (
334
+ "text",
335
+ node.text,
336
+ node.value,
337
+ )
338
+ return (
339
+ node.kind,
340
+ node.role,
341
+ node.text,
342
+ node.value,
343
+ tuple(sorted(node.state)),
344
+ tuple(sorted(node.actions)),
345
+ tuple(
346
+ _long_tap_context_dialog_feedback_signature(child)
347
+ for child in node.children
348
+ ),
349
+ )
350
+
351
+
352
+ def _transient_signature(screen: PublicScreen) -> tuple[object, ...]:
353
+ return tuple((item.kind, item.text) for item in screen.transient)
354
+
355
+
356
+ def _same_target_long_tap_feedback_changed(
357
+ previous_target: PublicNode,
358
+ current_target: PublicNode,
359
+ ) -> bool:
360
+ if tuple(sorted(previous_target.state)) != tuple(sorted(current_target.state)):
361
+ return True
362
+ if tuple(sorted(previous_target.actions)) != tuple(sorted(current_target.actions)):
363
+ return True
364
+ return _children_feedback_signature(
365
+ previous_target.children
366
+ ) != _children_feedback_signature(current_target.children)
367
+
368
+
369
+ def _children_feedback_signature(nodes: tuple[PublicNode, ...]) -> tuple[object, ...]:
370
+ return tuple(_child_feedback_signature(node) for node in nodes)
371
+
372
+
373
+ def _child_feedback_signature(node: PublicNode) -> tuple[object, ...]:
374
+ return (
375
+ node.text,
376
+ node.value,
377
+ tuple(sorted(node.state)),
378
+ tuple(sorted(node.actions)),
379
+ _children_feedback_signature(node.children),
380
+ )
381
+
382
+
383
+ def _scroll_content_signature(node: PublicNode) -> tuple[object, ...]:
384
+ return tuple(_scroll_child_content_signature(child) for child in node.children)
385
+
386
+
387
+ def _scroll_direction_change_confirms(
388
+ direction: str,
389
+ previous_directions: tuple[str, ...],
390
+ current_directions: tuple[str, ...],
391
+ ) -> bool:
392
+ previous = set(previous_directions)
393
+ current = set(current_directions)
394
+ if previous == current:
395
+ return False
396
+ return bool(_opposite_scroll_directions(direction).intersection(current))
397
+
398
+
399
+ def _opposite_scroll_directions(direction: str) -> set[str]:
400
+ if direction == "down":
401
+ return {"up", "backward"}
402
+ if direction in {"up", "backward"}:
403
+ return {"down"}
404
+ if direction == "left":
405
+ return {"right"}
406
+ if direction == "right":
407
+ return {"left"}
408
+ return set()
409
+
410
+
411
+ def _scroll_child_content_signature(node: PublicNode) -> tuple[object, ...]:
412
+ return (
413
+ node.text,
414
+ node.value,
415
+ tuple(_scroll_child_content_signature(child) for child in node.children),
416
+ )
417
+
418
+
419
+ def _requires_visible_open_url_navigation(target: OpenUrlTarget) -> bool:
420
+ return urlsplit(target.url).scheme.lower() in {"http", "https"}
421
+
422
+
423
+ def _validate_open_url_navigation(
424
+ target: OpenUrlTarget,
425
+ *,
426
+ previous_snapshot: RawSnapshot | None,
427
+ snapshot: RawSnapshot,
428
+ previous_screen: PublicScreen | None,
429
+ public_screen: PublicScreen,
430
+ ) -> None:
431
+ if not _requires_visible_open_url_navigation(target):
432
+ return
433
+ if previous_snapshot is None:
434
+ if screen_changed(previous_screen, public_screen):
435
+ return
436
+ raise DaemonError(
437
+ code=DaemonErrorCode.OPEN_FAILED,
438
+ message="open url did not produce a visible navigation change",
439
+ retryable=True,
440
+ details={
441
+ "packageName": snapshot.package_name,
442
+ "activityName": snapshot.activity_name,
443
+ },
444
+ http_status=200,
445
+ )
446
+ previous_package = previous_snapshot.package_name
447
+ previous_activity = previous_snapshot.activity_name
448
+ current_package = snapshot.package_name
449
+ current_activity = snapshot.activity_name
450
+ if current_package and current_package != previous_package:
451
+ return
452
+ if current_activity and current_activity != previous_activity:
453
+ return
454
+ if screen_changed(previous_screen, public_screen):
455
+ return
456
+ if not current_package:
457
+ raise DaemonError(
458
+ code=DaemonErrorCode.OPEN_FAILED,
459
+ message="open url did not produce a visible foreground change",
460
+ retryable=True,
461
+ details={"packageName": current_package},
462
+ http_status=200,
463
+ )
464
+ raise DaemonError(
465
+ code=DaemonErrorCode.OPEN_FAILED,
466
+ message="open url did not produce a visible navigation change",
467
+ retryable=True,
468
+ details={
469
+ "packageName": current_package,
470
+ "activityName": current_activity,
471
+ },
472
+ http_status=200,
473
+ )
@@ -0,0 +1,101 @@
1
+ """Ref repair for ref-based mutating actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from androidctld.actions.request_builder import (
6
+ build_action_request_for_binding,
7
+ ensure_screen_ready,
8
+ )
9
+ from androidctld.commands.command_models import RefBoundActionCommand
10
+ from androidctld.commands.models import CommandRecord
11
+ from androidctld.device.action_models import BuiltDeviceActionRequest
12
+ from androidctld.refs.models import NodeHandle
13
+ from androidctld.refs.repair import (
14
+ ref_repair_error,
15
+ repair_source_signature_decision,
16
+ resolve_source_binding_decision,
17
+ )
18
+ from androidctld.runtime import RuntimeLifecycleLease
19
+ from androidctld.runtime.models import WorkspaceRuntime
20
+ from androidctld.runtime.screen_state import current_artifacts, current_public_screen
21
+ from androidctld.snapshots.refresh import ScreenRefreshService
22
+ from androidctld.snapshots.service import SnapshotService
23
+
24
+
25
+ class ActionCommandRepairer:
26
+ def __init__(
27
+ self,
28
+ snapshot_service: SnapshotService,
29
+ screen_refresh: ScreenRefreshService,
30
+ ) -> None:
31
+ self._snapshot_service = snapshot_service
32
+ self._screen_refresh = screen_refresh
33
+
34
+ def repair_action_command(
35
+ self,
36
+ session: WorkspaceRuntime,
37
+ record: CommandRecord,
38
+ command: RefBoundActionCommand,
39
+ *,
40
+ lifecycle_lease: RuntimeLifecycleLease,
41
+ ) -> BuiltDeviceActionRequest:
42
+ handle = self.repair_action_binding(
43
+ session,
44
+ record,
45
+ command,
46
+ lifecycle_lease=lifecycle_lease,
47
+ )
48
+ return build_action_request_for_binding(handle, command)
49
+
50
+ def repair_action_binding(
51
+ self,
52
+ session: WorkspaceRuntime,
53
+ record: CommandRecord,
54
+ command: RefBoundActionCommand,
55
+ *,
56
+ lifecycle_lease: RuntimeLifecycleLease,
57
+ ) -> NodeHandle:
58
+ ensure_screen_ready(session)
59
+ ref = command.ref
60
+ source_decision = resolve_source_binding_decision(
61
+ session,
62
+ ref,
63
+ command.source_screen_id,
64
+ )
65
+ if not source_decision.is_resolved:
66
+ raise ref_repair_error(
67
+ source_decision,
68
+ public_screen=current_public_screen(session),
69
+ artifacts=current_artifacts(session),
70
+ )
71
+ source_signature = source_decision.source_signature
72
+ if source_signature is None:
73
+ raise RuntimeError("resolved source binding is missing its signature")
74
+
75
+ snapshot = self._snapshot_service.fetch(
76
+ session,
77
+ force_refresh=True,
78
+ lifecycle_lease=lifecycle_lease,
79
+ )
80
+ _, public_screen, artifacts = self._screen_refresh.refresh(
81
+ session,
82
+ snapshot,
83
+ lifecycle_lease=lifecycle_lease,
84
+ command_kind=command.kind,
85
+ record=record,
86
+ )
87
+ repair_decision = repair_source_signature_decision(
88
+ session,
89
+ source_signature,
90
+ source_screen_id=command.source_screen_id,
91
+ )
92
+ if not repair_decision.is_resolved:
93
+ raise ref_repair_error(
94
+ repair_decision,
95
+ public_screen=public_screen,
96
+ artifacts=artifacts or current_artifacts(session),
97
+ )
98
+ repaired_binding = repair_decision.binding
99
+ if repaired_binding is None:
100
+ raise RuntimeError("resolved repair decision is missing its binding")
101
+ return repaired_binding.handle