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