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,204 @@
1
+ """Helpers for building device action requests and resolving ref targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from androidctld.actions.submit_routing import SubmitRouteOutcome
6
+ from androidctld.commands.command_models import (
7
+ ActionCommand,
8
+ FocusCommand,
9
+ GlobalCommand,
10
+ LongTapCommand,
11
+ OpenCommand,
12
+ RefBoundActionCommand,
13
+ ScrollCommand,
14
+ SubmitCommand,
15
+ TapCommand,
16
+ TypeCommand,
17
+ is_ref_bound_action_command,
18
+ )
19
+ from androidctld.commands.open_targets import (
20
+ OpenAppTarget,
21
+ OpenUrlTarget,
22
+ validate_open_target,
23
+ )
24
+ from androidctld.device.action_models import (
25
+ BuiltDeviceActionRequest,
26
+ GlobalActionRequest,
27
+ HandleTarget,
28
+ LaunchAppActionRequest,
29
+ LongTapActionRequest,
30
+ NodeActionRequest,
31
+ NoneTarget,
32
+ OpenUrlActionRequest,
33
+ ScrollActionRequest,
34
+ TapActionRequest,
35
+ TypeActionRequest,
36
+ )
37
+ from androidctld.errors import DaemonError, DaemonErrorCode, bad_request
38
+ from androidctld.protocol import CommandKind
39
+ from androidctld.refs.models import NodeHandle
40
+ from androidctld.refs.repair import (
41
+ ref_repair_error,
42
+ resolve_ref_decision,
43
+ )
44
+ from androidctld.runtime.models import WorkspaceRuntime
45
+ from androidctld.runtime.screen_state import (
46
+ current_artifacts,
47
+ current_public_screen,
48
+ )
49
+ from androidctld.runtime_policy import action_timeout_ms
50
+
51
+
52
+ def build_action_request(
53
+ session: WorkspaceRuntime, command: ActionCommand
54
+ ) -> BuiltDeviceActionRequest:
55
+ if isinstance(command, OpenCommand):
56
+ return build_open_action_request(command)
57
+ if isinstance(command, GlobalCommand):
58
+ return BuiltDeviceActionRequest(
59
+ payload=GlobalActionRequest(
60
+ target=NoneTarget(),
61
+ action=command.action,
62
+ timeout_ms=action_timeout_ms(command.kind),
63
+ )
64
+ )
65
+ if is_ref_bound_action_command(command):
66
+ handle = resolve_ref_target(session, command.ref, command.source_screen_id)
67
+ return build_action_request_for_binding(handle, command)
68
+ raise bad_request("unsupported action kind", {"kind": command.kind.value})
69
+
70
+
71
+ def build_action_request_for_binding(
72
+ handle: NodeHandle, command: RefBoundActionCommand
73
+ ) -> BuiltDeviceActionRequest:
74
+ if isinstance(command, TapCommand):
75
+ return BuiltDeviceActionRequest(
76
+ payload=TapActionRequest(
77
+ target=HandleTarget(handle),
78
+ timeout_ms=action_timeout_ms(command.kind),
79
+ ),
80
+ request_handle=handle,
81
+ )
82
+ if isinstance(command, LongTapCommand):
83
+ return BuiltDeviceActionRequest(
84
+ payload=LongTapActionRequest(
85
+ target=HandleTarget(handle),
86
+ timeout_ms=action_timeout_ms(command.kind),
87
+ ),
88
+ request_handle=handle,
89
+ )
90
+ if isinstance(command, TypeCommand):
91
+ return BuiltDeviceActionRequest(
92
+ payload=TypeActionRequest(
93
+ target=HandleTarget(handle),
94
+ text=command.text,
95
+ timeout_ms=action_timeout_ms(command.kind),
96
+ ),
97
+ request_handle=handle,
98
+ )
99
+ if isinstance(command, FocusCommand):
100
+ return BuiltDeviceActionRequest(
101
+ payload=NodeActionRequest(
102
+ target=HandleTarget(handle),
103
+ action=command.kind.value,
104
+ timeout_ms=action_timeout_ms(command.kind),
105
+ ),
106
+ request_handle=handle,
107
+ )
108
+ if isinstance(command, SubmitCommand):
109
+ return BuiltDeviceActionRequest(
110
+ payload=NodeActionRequest(
111
+ target=HandleTarget(handle),
112
+ action=command.kind.value,
113
+ timeout_ms=action_timeout_ms(command.kind),
114
+ ),
115
+ request_handle=handle,
116
+ )
117
+ if isinstance(command, ScrollCommand):
118
+ return BuiltDeviceActionRequest(
119
+ payload=ScrollActionRequest(
120
+ target=HandleTarget(handle),
121
+ direction=command.direction,
122
+ timeout_ms=action_timeout_ms(command.kind),
123
+ ),
124
+ request_handle=handle,
125
+ )
126
+ raise bad_request("unsupported action kind", {"kind": command.kind.value})
127
+
128
+
129
+ def build_submit_action_request_for_route(
130
+ route: SubmitRouteOutcome,
131
+ ) -> BuiltDeviceActionRequest:
132
+ if route.route == "direct":
133
+ return BuiltDeviceActionRequest(
134
+ payload=NodeActionRequest(
135
+ target=HandleTarget(route.subject_handle),
136
+ action=CommandKind.SUBMIT.value,
137
+ timeout_ms=action_timeout_ms(CommandKind.SUBMIT),
138
+ ),
139
+ request_handle=route.subject_handle,
140
+ dispatched_handle=route.dispatched_handle,
141
+ submit_route=route.route,
142
+ )
143
+ return BuiltDeviceActionRequest(
144
+ payload=TapActionRequest(
145
+ target=HandleTarget(route.dispatched_handle),
146
+ timeout_ms=action_timeout_ms(CommandKind.SUBMIT),
147
+ ),
148
+ request_handle=route.subject_handle,
149
+ dispatched_handle=route.dispatched_handle,
150
+ submit_route=route.route,
151
+ )
152
+
153
+
154
+ def build_open_action_request(command: ActionCommand) -> BuiltDeviceActionRequest:
155
+ if not isinstance(command, OpenCommand):
156
+ raise bad_request("open action request requires an open command")
157
+ target = validate_open_target(command.target)
158
+ if isinstance(target, OpenAppTarget):
159
+ return BuiltDeviceActionRequest(
160
+ payload=LaunchAppActionRequest(
161
+ target=NoneTarget(),
162
+ package_name=target.package_name,
163
+ timeout_ms=action_timeout_ms(CommandKind.OPEN),
164
+ )
165
+ )
166
+ if isinstance(target, OpenUrlTarget):
167
+ return BuiltDeviceActionRequest(
168
+ payload=OpenUrlActionRequest(
169
+ target=NoneTarget(),
170
+ url=target.url,
171
+ timeout_ms=action_timeout_ms(CommandKind.OPEN),
172
+ )
173
+ )
174
+ raise bad_request("open requires target.kind app|url and target.value")
175
+
176
+
177
+ def resolve_ref_target(
178
+ session: WorkspaceRuntime,
179
+ ref: str,
180
+ source_screen_id: str,
181
+ ) -> NodeHandle:
182
+ ensure_screen_ready(session)
183
+ decision = resolve_ref_decision(session, ref, source_screen_id)
184
+ if not decision.is_resolved:
185
+ raise ref_repair_error(
186
+ decision,
187
+ public_screen=current_public_screen(session),
188
+ artifacts=current_artifacts(session),
189
+ )
190
+ binding = decision.binding
191
+ if binding is None:
192
+ raise RuntimeError("resolved ref decision is missing its binding")
193
+ return binding.handle
194
+
195
+
196
+ def ensure_screen_ready(session: WorkspaceRuntime) -> None:
197
+ if session.screen_state is None or session.latest_snapshot is None:
198
+ raise DaemonError(
199
+ code=DaemonErrorCode.SCREEN_NOT_READY,
200
+ message="screen is not ready yet",
201
+ retryable=False,
202
+ details={"workspaceRoot": session.workspace_root.as_posix()},
203
+ http_status=200,
204
+ )
@@ -0,0 +1,146 @@
1
+ """Action settle loop for post-mutation stabilization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+
8
+ from androidctld.device.interfaces import EventPollingClient
9
+ from androidctld.errors import DaemonError
10
+ from androidctld.observation import (
11
+ ObservationLoop,
12
+ ObservationPolicy,
13
+ ObservationPollOutcome,
14
+ )
15
+ from androidctld.protocol import CommandKind
16
+ from androidctld.runtime import RuntimeLifecycleLease
17
+ from androidctld.runtime.models import WorkspaceRuntime
18
+ from androidctld.runtime_policy import (
19
+ DEVICE_RPC_REQUEST_ID_SETTLE,
20
+ SETTLE_POLL_SLICE_MS,
21
+ TRANSIENT_INVALID_SNAPSHOT_MAX_RETRIES,
22
+ settle_max_total_ms,
23
+ settle_min_grace_ms,
24
+ settle_snapshot_max_interval_ms,
25
+ settle_stable_window_ms,
26
+ )
27
+ from androidctld.semantics.compiler import SemanticCompiler
28
+ from androidctld.snapshots.models import RawSnapshot
29
+ from androidctld.snapshots.refresh import settle_screen_signature
30
+ from androidctld.snapshots.service import (
31
+ SnapshotService,
32
+ fetch_with_transient_invalid_snapshot_retry,
33
+ )
34
+
35
+ SleepFn = Callable[[float], None]
36
+ TimeFn = Callable[[], float]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SettledSnapshot:
41
+ snapshot: RawSnapshot
42
+ timed_out: bool
43
+
44
+
45
+ class ActionSettler:
46
+ def __init__(
47
+ self,
48
+ snapshot_service: SnapshotService,
49
+ semantic_compiler: SemanticCompiler,
50
+ sleep_fn: SleepFn,
51
+ time_fn: TimeFn,
52
+ ) -> None:
53
+ self._snapshot_service = snapshot_service
54
+ self._semantic_compiler = semantic_compiler
55
+ self._sleep_fn = sleep_fn
56
+ self._time_fn = time_fn
57
+
58
+ def settle(
59
+ self,
60
+ session: WorkspaceRuntime,
61
+ client: EventPollingClient,
62
+ kind: CommandKind,
63
+ baseline_signature: tuple[object, ...],
64
+ *,
65
+ lifecycle_lease: RuntimeLifecycleLease,
66
+ ) -> SettledSnapshot:
67
+ observation = ObservationLoop.begin(
68
+ ObservationPolicy(
69
+ min_grace_ms=settle_min_grace_ms(kind),
70
+ snapshot_max_interval_ms=settle_snapshot_max_interval_ms(kind),
71
+ stable_window_ms=settle_stable_window_ms(kind),
72
+ max_total_ms=settle_max_total_ms(kind),
73
+ poll_slice_ms=SETTLE_POLL_SLICE_MS,
74
+ ),
75
+ started_at=self._time_fn(),
76
+ )
77
+ latest_snapshot: RawSnapshot | None = None
78
+ latest_signature = baseline_signature
79
+ while True:
80
+ now = self._time_fn()
81
+ poll_wait_ms = observation.poll_wait_ms(now)
82
+ poll_outcome = ObservationPollOutcome(
83
+ saw_events=False,
84
+ need_resync=False,
85
+ latest_seq=observation.after_seq,
86
+ )
87
+ if poll_wait_ms > 0:
88
+ polled_at = self._time_fn()
89
+ try:
90
+ poll_result = client.events_poll(
91
+ after_seq=observation.after_seq,
92
+ wait_ms=poll_wait_ms,
93
+ limit=1,
94
+ request_id=DEVICE_RPC_REQUEST_ID_SETTLE,
95
+ )
96
+ poll_outcome = observation.apply_poll_result(poll_result)
97
+ except DaemonError:
98
+ self._sleep_fn(poll_wait_ms / 1000.0)
99
+ else:
100
+ if not poll_outcome.saw_events and self._time_fn() <= polled_at:
101
+ self._sleep_fn(poll_wait_ms / 1000.0)
102
+ now = self._time_fn()
103
+ if not observation.should_refresh(
104
+ now,
105
+ saw_events=poll_outcome.saw_events,
106
+ need_resync=poll_outcome.need_resync,
107
+ ):
108
+ if observation.timed_out(now) and latest_snapshot is not None:
109
+ return SettledSnapshot(
110
+ snapshot=latest_snapshot,
111
+ timed_out=True,
112
+ )
113
+ continue
114
+
115
+ snapshot = fetch_with_transient_invalid_snapshot_retry(
116
+ self._snapshot_service,
117
+ session=session,
118
+ force_refresh=True,
119
+ lifecycle_lease=lifecycle_lease,
120
+ deadline_at=observation.deadline_at,
121
+ max_retries=TRANSIENT_INVALID_SNAPSHOT_MAX_RETRIES,
122
+ sleep_fn=self._sleep_fn,
123
+ time_fn=self._time_fn,
124
+ )
125
+ observation.mark_refreshed(self._time_fn())
126
+ compiled_screen = self._semantic_compiler.compile(
127
+ session.screen_sequence + 1,
128
+ snapshot,
129
+ )
130
+ signature = settle_screen_signature(compiled_screen, snapshot)
131
+ latest_snapshot = snapshot
132
+ is_stable = observation.observe_stability(
133
+ self._time_fn(),
134
+ changed=signature != latest_signature,
135
+ )
136
+ latest_signature = signature
137
+ if is_stable:
138
+ return SettledSnapshot(
139
+ snapshot=snapshot,
140
+ timed_out=False,
141
+ )
142
+ if observation.timed_out(self._time_fn()):
143
+ return SettledSnapshot(
144
+ snapshot=snapshot,
145
+ timed_out=True,
146
+ )
@@ -0,0 +1,211 @@
1
+ """Shared submit confirmation helpers for standalone submit commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ from androidctld.actions.type_confirmation import (
10
+ TypeConfirmationContext,
11
+ fingerprint_rematch_confirmation_node,
12
+ reused_ref_confirmation_node,
13
+ snapshot_node_for_handle,
14
+ )
15
+ from androidctld.commands.command_models import SubmitCommand
16
+ from androidctld.device.types import ActionPerformResult, ActionStatus
17
+ from androidctld.errors import DaemonError, DaemonErrorCode
18
+ from androidctld.refs.models import NodeHandle, RefBinding
19
+ from androidctld.runtime.models import WorkspaceRuntime
20
+ from androidctld.semantics.public_models import (
21
+ PublicNode,
22
+ PublicScreen,
23
+ public_group_nodes,
24
+ )
25
+ from androidctld.snapshots.models import RawNode, RawSnapshot
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SubmitConfirmationContext:
30
+ ref: str | None
31
+ request_handle: NodeHandle | None
32
+ binding: RefBinding | None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SubmitConfirmationOutcome:
37
+ status: Literal["sameTarget", "targetGone", "publicChange", "unconfirmed"]
38
+ node: RawNode | None
39
+ target_handle: NodeHandle
40
+
41
+
42
+ def build_submit_confirmation_context(
43
+ session: WorkspaceRuntime,
44
+ command: SubmitCommand,
45
+ request_handle: NodeHandle | None,
46
+ ) -> SubmitConfirmationContext:
47
+ binding = session.ref_registry.get(command.ref)
48
+ return SubmitConfirmationContext(
49
+ ref=command.ref,
50
+ request_handle=request_handle,
51
+ binding=None if binding is None else deepcopy(binding),
52
+ )
53
+
54
+
55
+ def submit_confirmation_node(
56
+ *,
57
+ session: WorkspaceRuntime | None,
58
+ snapshot: RawSnapshot,
59
+ context: SubmitConfirmationContext | None,
60
+ command_target_handle: NodeHandle,
61
+ ) -> RawNode | None:
62
+ direct_node = snapshot_node_for_handle(snapshot, command_target_handle)
63
+ if direct_node is not None:
64
+ return direct_node
65
+ if session is None or context is None:
66
+ return None
67
+ if (
68
+ context.request_handle is not None
69
+ and command_target_handle != context.request_handle
70
+ ):
71
+ return None
72
+ type_context = TypeConfirmationContext(
73
+ ref=context.ref,
74
+ request_handle=context.request_handle,
75
+ binding=context.binding,
76
+ )
77
+ for node in (
78
+ reused_ref_confirmation_node(session, snapshot, type_context),
79
+ fingerprint_rematch_confirmation_node(session, snapshot, type_context),
80
+ ):
81
+ if node is not None:
82
+ return node
83
+ return None
84
+
85
+
86
+ def submit_public_change_is_attributable(
87
+ previous_screen: PublicScreen | None,
88
+ public_screen: PublicScreen,
89
+ ) -> bool:
90
+ if previous_screen is None:
91
+ return False
92
+
93
+ def structural_groups(
94
+ screen: PublicScreen,
95
+ ) -> tuple[tuple[tuple[object, ...], ...], ...]:
96
+ return (
97
+ _structural_node_signatures(public_group_nodes(screen, "targets")),
98
+ _structural_node_signatures(public_group_nodes(screen, "context")),
99
+ _structural_node_signatures(public_group_nodes(screen, "dialog")),
100
+ )
101
+
102
+ return structural_groups(previous_screen) != structural_groups(public_screen)
103
+
104
+
105
+ def _structural_node_signatures(
106
+ nodes: tuple[PublicNode, ...],
107
+ ) -> tuple[tuple[object, ...], ...]:
108
+ return tuple(_structural_node_signature(node) for node in nodes)
109
+
110
+
111
+ def _structural_node_signature(node: PublicNode) -> tuple[object, ...]:
112
+ return (
113
+ node.kind,
114
+ node.role,
115
+ node.label,
116
+ node.text,
117
+ tuple(_structural_node_signature(child) for child in node.children),
118
+ tuple(node.scroll_directions),
119
+ (
120
+ None
121
+ if node.meta is None
122
+ else (
123
+ node.meta.resource_id,
124
+ node.meta.class_name,
125
+ )
126
+ ),
127
+ )
128
+
129
+
130
+ def validate_submit_confirmation(
131
+ *,
132
+ session: WorkspaceRuntime | None = None,
133
+ route_kind: Literal["direct", "attributed"],
134
+ action_result: ActionPerformResult,
135
+ previous_snapshot: RawSnapshot | None,
136
+ snapshot: RawSnapshot,
137
+ previous_screen: PublicScreen | None,
138
+ public_screen: PublicScreen,
139
+ context: SubmitConfirmationContext | None = None,
140
+ command_target_handle: NodeHandle | None,
141
+ ) -> SubmitConfirmationOutcome:
142
+ if command_target_handle is None:
143
+ raise DaemonError(
144
+ code=DaemonErrorCode.SUBMIT_NOT_CONFIRMED,
145
+ message="submit effect could not be confirmed",
146
+ retryable=True,
147
+ details={"reason": "missing_command_target_identity"},
148
+ http_status=200,
149
+ )
150
+ if action_result.status is not ActionStatus.DONE:
151
+ raise DaemonError(
152
+ code=DaemonErrorCode.SUBMIT_NOT_CONFIRMED,
153
+ message="submit effect could not be confirmed",
154
+ retryable=True,
155
+ details={
156
+ "reason": "device_submit_not_accepted",
157
+ "status": action_result.status.value,
158
+ },
159
+ http_status=200,
160
+ )
161
+ if previous_snapshot is not None and (
162
+ snapshot.package_name != previous_snapshot.package_name
163
+ or snapshot.activity_name != previous_snapshot.activity_name
164
+ ):
165
+ return SubmitConfirmationOutcome(
166
+ status="unconfirmed",
167
+ node=None,
168
+ target_handle=command_target_handle,
169
+ )
170
+ confirmation_node = submit_confirmation_node(
171
+ session=session,
172
+ snapshot=snapshot,
173
+ context=context,
174
+ command_target_handle=command_target_handle,
175
+ )
176
+ if confirmation_node is None:
177
+ return SubmitConfirmationOutcome(
178
+ status="targetGone",
179
+ node=None,
180
+ target_handle=command_target_handle,
181
+ )
182
+ if submit_public_change_is_attributable(
183
+ previous_screen,
184
+ public_screen,
185
+ ):
186
+ return SubmitConfirmationOutcome(
187
+ status="publicChange",
188
+ node=confirmation_node,
189
+ target_handle=command_target_handle,
190
+ )
191
+ if not confirmation_node.focused or not confirmation_node.editable:
192
+ if route_kind == "attributed":
193
+ raise DaemonError(
194
+ code=DaemonErrorCode.SUBMIT_NOT_CONFIRMED,
195
+ message="submit effect could not be confirmed",
196
+ retryable=True,
197
+ details={"reason": "attributed_submit_blur_only"},
198
+ http_status=200,
199
+ )
200
+ return SubmitConfirmationOutcome(
201
+ status="sameTarget",
202
+ node=confirmation_node,
203
+ target_handle=command_target_handle,
204
+ )
205
+ raise DaemonError(
206
+ code=DaemonErrorCode.SUBMIT_NOT_CONFIRMED,
207
+ message="submit effect could not be confirmed",
208
+ retryable=True,
209
+ details={"reason": "target_still_focused_editable"},
210
+ http_status=200,
211
+ )