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,284 @@
|
|
|
1
|
+
"""Internal ref repair decisions and diagnostic conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from androidctld.artifacts.models import ScreenArtifacts
|
|
10
|
+
from androidctld.artifacts.screen_lookup import lookup_source_screen_artifact
|
|
11
|
+
from androidctld.commands.results import screen_summary
|
|
12
|
+
from androidctld.errors import DaemonError, DaemonErrorCode
|
|
13
|
+
from androidctld.refs.models import RefBinding, RefRepairSourceSignature
|
|
14
|
+
from androidctld.refs.service import (
|
|
15
|
+
repair_source_signature_to_current_snapshot,
|
|
16
|
+
source_signature_from_artifact_payload,
|
|
17
|
+
source_signature_from_binding,
|
|
18
|
+
)
|
|
19
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
20
|
+
from androidctld.runtime.screen_state import current_compiled_screen
|
|
21
|
+
from androidctld.schema.base import dump_api_model
|
|
22
|
+
from androidctld.semantics.public_models import PublicScreen
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RepairDecisionStatus(str, Enum):
|
|
26
|
+
RESOLVED = "resolved"
|
|
27
|
+
LIVE_REF_MISSING = "live_ref_missing"
|
|
28
|
+
SOURCE_UNAVAILABLE = "source_unavailable"
|
|
29
|
+
INVALID_ARTIFACT = "invalid_artifact"
|
|
30
|
+
REPAIR_FAILED = "repair_failed"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_DIAGNOSTIC_SOURCE_ARTIFACT_STATUSES = {
|
|
34
|
+
RepairDecisionStatus.SOURCE_UNAVAILABLE,
|
|
35
|
+
RepairDecisionStatus.INVALID_ARTIFACT,
|
|
36
|
+
RepairDecisionStatus.REPAIR_FAILED,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RepairDecision:
|
|
42
|
+
ref: str | None
|
|
43
|
+
source_screen_id: str | None
|
|
44
|
+
status: RepairDecisionStatus
|
|
45
|
+
binding: RefBinding | None = None
|
|
46
|
+
source_signature: RefRepairSourceSignature | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_resolved(self) -> bool:
|
|
50
|
+
return self.status == RepairDecisionStatus.RESOLVED
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def diagnostic_source_artifact_status(self) -> str | None:
|
|
54
|
+
if self.status not in _DIAGNOSTIC_SOURCE_ARTIFACT_STATUSES:
|
|
55
|
+
return None
|
|
56
|
+
return self.status.value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolved_repair_decision(
|
|
60
|
+
*,
|
|
61
|
+
ref: str,
|
|
62
|
+
source_screen_id: str,
|
|
63
|
+
binding: RefBinding,
|
|
64
|
+
source_signature: RefRepairSourceSignature | None = None,
|
|
65
|
+
) -> RepairDecision:
|
|
66
|
+
return RepairDecision(
|
|
67
|
+
ref=ref,
|
|
68
|
+
source_screen_id=source_screen_id,
|
|
69
|
+
status=RepairDecisionStatus.RESOLVED,
|
|
70
|
+
binding=binding,
|
|
71
|
+
source_signature=source_signature,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolved_source_signature_decision(
|
|
76
|
+
*,
|
|
77
|
+
ref: str,
|
|
78
|
+
source_screen_id: str,
|
|
79
|
+
source_signature: RefRepairSourceSignature,
|
|
80
|
+
) -> RepairDecision:
|
|
81
|
+
return RepairDecision(
|
|
82
|
+
ref=ref,
|
|
83
|
+
source_screen_id=source_screen_id,
|
|
84
|
+
status=RepairDecisionStatus.RESOLVED,
|
|
85
|
+
source_signature=source_signature,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def failed_repair_decision(
|
|
90
|
+
*,
|
|
91
|
+
ref: str | None,
|
|
92
|
+
source_screen_id: str | None,
|
|
93
|
+
) -> RepairDecision:
|
|
94
|
+
return RepairDecision(
|
|
95
|
+
ref=ref,
|
|
96
|
+
source_screen_id=source_screen_id,
|
|
97
|
+
status=RepairDecisionStatus.REPAIR_FAILED,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_ref_decision(
|
|
102
|
+
session: WorkspaceRuntime,
|
|
103
|
+
ref: str,
|
|
104
|
+
source_screen_id: str,
|
|
105
|
+
) -> RepairDecision:
|
|
106
|
+
source_decision = resolve_source_binding_decision(
|
|
107
|
+
session,
|
|
108
|
+
ref,
|
|
109
|
+
source_screen_id,
|
|
110
|
+
)
|
|
111
|
+
if not source_decision.is_resolved:
|
|
112
|
+
return source_decision
|
|
113
|
+
if source_screen_id == session.current_screen_id:
|
|
114
|
+
return source_decision
|
|
115
|
+
source_signature = source_decision.source_signature
|
|
116
|
+
if source_signature is None:
|
|
117
|
+
raise RuntimeError("resolved source binding is missing its signature")
|
|
118
|
+
return repair_source_signature_decision(
|
|
119
|
+
session,
|
|
120
|
+
source_signature,
|
|
121
|
+
source_screen_id=source_screen_id,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_source_binding_decision(
|
|
126
|
+
session: WorkspaceRuntime,
|
|
127
|
+
ref: str,
|
|
128
|
+
source_screen_id: str,
|
|
129
|
+
) -> RepairDecision:
|
|
130
|
+
if source_screen_id == session.current_screen_id:
|
|
131
|
+
binding = session.ref_registry.get(ref)
|
|
132
|
+
if binding is None:
|
|
133
|
+
return RepairDecision(
|
|
134
|
+
ref=ref,
|
|
135
|
+
source_screen_id=source_screen_id,
|
|
136
|
+
status=RepairDecisionStatus.LIVE_REF_MISSING,
|
|
137
|
+
)
|
|
138
|
+
return resolved_repair_decision(
|
|
139
|
+
ref=ref,
|
|
140
|
+
source_screen_id=source_screen_id,
|
|
141
|
+
binding=binding,
|
|
142
|
+
source_signature=source_signature_from_binding(binding),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return load_source_artifact_binding_decision(session, ref, source_screen_id)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_source_artifact_binding_decision(
|
|
149
|
+
session: WorkspaceRuntime,
|
|
150
|
+
ref: str,
|
|
151
|
+
source_screen_id: str,
|
|
152
|
+
) -> RepairDecision:
|
|
153
|
+
source_screen_lookup = lookup_source_screen_artifact(session, source_screen_id)
|
|
154
|
+
if source_screen_lookup.status == "not_found":
|
|
155
|
+
return RepairDecision(
|
|
156
|
+
ref=ref,
|
|
157
|
+
source_screen_id=source_screen_id,
|
|
158
|
+
status=RepairDecisionStatus.SOURCE_UNAVAILABLE,
|
|
159
|
+
)
|
|
160
|
+
if source_screen_lookup.status == "invalid_artifact":
|
|
161
|
+
return RepairDecision(
|
|
162
|
+
ref=ref,
|
|
163
|
+
source_screen_id=source_screen_id,
|
|
164
|
+
status=RepairDecisionStatus.INVALID_ARTIFACT,
|
|
165
|
+
)
|
|
166
|
+
payload = source_screen_lookup.payload
|
|
167
|
+
if payload is None or payload.screen_id != source_screen_id:
|
|
168
|
+
return RepairDecision(
|
|
169
|
+
ref=ref,
|
|
170
|
+
source_screen_id=source_screen_id,
|
|
171
|
+
status=RepairDecisionStatus.INVALID_ARTIFACT,
|
|
172
|
+
)
|
|
173
|
+
binding_payload = payload.repair_bindings.get(ref)
|
|
174
|
+
if binding_payload is None:
|
|
175
|
+
return RepairDecision(
|
|
176
|
+
ref=ref,
|
|
177
|
+
source_screen_id=source_screen_id,
|
|
178
|
+
status=RepairDecisionStatus.INVALID_ARTIFACT,
|
|
179
|
+
)
|
|
180
|
+
return resolved_source_signature_decision(
|
|
181
|
+
ref=ref,
|
|
182
|
+
source_screen_id=source_screen_id,
|
|
183
|
+
source_signature=source_signature_from_artifact_payload(ref, binding_payload),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def repair_source_signature_decision(
|
|
188
|
+
session: WorkspaceRuntime,
|
|
189
|
+
source_signature: RefRepairSourceSignature,
|
|
190
|
+
*,
|
|
191
|
+
source_screen_id: str,
|
|
192
|
+
) -> RepairDecision:
|
|
193
|
+
compiled_screen = current_compiled_screen(session)
|
|
194
|
+
if compiled_screen is None or session.latest_snapshot is None:
|
|
195
|
+
return failed_repair_decision(
|
|
196
|
+
ref=source_signature.ref,
|
|
197
|
+
source_screen_id=source_screen_id,
|
|
198
|
+
)
|
|
199
|
+
repaired_binding = repair_source_signature_to_current_snapshot(
|
|
200
|
+
source_signature,
|
|
201
|
+
compiled_screen=compiled_screen,
|
|
202
|
+
snapshot_id=session.latest_snapshot.snapshot_id,
|
|
203
|
+
)
|
|
204
|
+
if repaired_binding is None:
|
|
205
|
+
return failed_repair_decision(
|
|
206
|
+
ref=source_signature.ref,
|
|
207
|
+
source_screen_id=source_screen_id,
|
|
208
|
+
)
|
|
209
|
+
return resolved_repair_decision(
|
|
210
|
+
ref=source_signature.ref,
|
|
211
|
+
source_screen_id=source_screen_id,
|
|
212
|
+
binding=repaired_binding,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def ref_repair_error(
|
|
217
|
+
decision: RepairDecision,
|
|
218
|
+
public_screen: PublicScreen | None = None,
|
|
219
|
+
artifacts: ScreenArtifacts | None = None,
|
|
220
|
+
) -> DaemonError:
|
|
221
|
+
if decision.status == RepairDecisionStatus.LIVE_REF_MISSING:
|
|
222
|
+
return DaemonError(
|
|
223
|
+
code=DaemonErrorCode.REF_RESOLUTION_FAILED,
|
|
224
|
+
message="ref does not exist on the current screen",
|
|
225
|
+
retryable=False,
|
|
226
|
+
details={"ref": decision.ref},
|
|
227
|
+
http_status=200,
|
|
228
|
+
)
|
|
229
|
+
return ref_stale_error(
|
|
230
|
+
decision.ref,
|
|
231
|
+
public_screen=public_screen,
|
|
232
|
+
artifacts=artifacts,
|
|
233
|
+
source_screen_id=decision.source_screen_id,
|
|
234
|
+
source_artifact_status=decision.diagnostic_source_artifact_status,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def ref_stale_error(
|
|
239
|
+
ref: str | None,
|
|
240
|
+
public_screen: PublicScreen | None = None,
|
|
241
|
+
artifacts: ScreenArtifacts | None = None,
|
|
242
|
+
*,
|
|
243
|
+
source_screen_id: str | None = None,
|
|
244
|
+
source_artifact_status: str | RepairDecisionStatus | None = None,
|
|
245
|
+
) -> DaemonError:
|
|
246
|
+
artifact_payload = artifacts or ScreenArtifacts(screen_json=None)
|
|
247
|
+
normalized_status = _normalize_source_artifact_status(source_artifact_status)
|
|
248
|
+
details: dict[str, Any] = {
|
|
249
|
+
"ref": ref,
|
|
250
|
+
"screen": (
|
|
251
|
+
screen_summary(
|
|
252
|
+
public_screen,
|
|
253
|
+
artifact_payload,
|
|
254
|
+
)
|
|
255
|
+
if public_screen is not None
|
|
256
|
+
else None
|
|
257
|
+
),
|
|
258
|
+
"artifacts": dump_api_model(artifact_payload),
|
|
259
|
+
}
|
|
260
|
+
if source_screen_id is not None:
|
|
261
|
+
details["sourceScreenId"] = source_screen_id
|
|
262
|
+
if normalized_status is not None:
|
|
263
|
+
details["sourceArtifactStatus"] = normalized_status
|
|
264
|
+
return DaemonError(
|
|
265
|
+
code=DaemonErrorCode.REF_STALE,
|
|
266
|
+
message="ref could not be repaired",
|
|
267
|
+
retryable=normalized_status in (None, RepairDecisionStatus.REPAIR_FAILED.value),
|
|
268
|
+
details=details,
|
|
269
|
+
http_status=200,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _normalize_source_artifact_status(
|
|
274
|
+
source_artifact_status: str | RepairDecisionStatus | None,
|
|
275
|
+
) -> str | None:
|
|
276
|
+
if source_artifact_status is None:
|
|
277
|
+
return None
|
|
278
|
+
if isinstance(source_artifact_status, RepairDecisionStatus):
|
|
279
|
+
return source_artifact_status.value
|
|
280
|
+
if source_artifact_status in {
|
|
281
|
+
status.value for status in _DIAGNOSTIC_SOURCE_ARTIFACT_STATUSES
|
|
282
|
+
}:
|
|
283
|
+
return source_artifact_status
|
|
284
|
+
raise ValueError(f"unknown sourceArtifactStatus: {source_artifact_status}")
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Ref registry reconciliation and repair helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from androidctld.artifacts.screen_payloads import RepairBindingPayload
|
|
11
|
+
from androidctld.refs.models import (
|
|
12
|
+
NodeHandle,
|
|
13
|
+
RefBinding,
|
|
14
|
+
RefFingerprint,
|
|
15
|
+
RefRegistry,
|
|
16
|
+
RefRepairSourceSignature,
|
|
17
|
+
SemanticProfile,
|
|
18
|
+
)
|
|
19
|
+
from androidctld.runtime_policy import NON_NUMERIC_REF_SORT_BUCKET
|
|
20
|
+
from androidctld.semantics.compiler import CompiledScreen, SemanticNode
|
|
21
|
+
from androidctld.text_equivalence import canonical_text_key
|
|
22
|
+
|
|
23
|
+
HIGH_CONFIDENCE_BUCKET = 2
|
|
24
|
+
MEDIUM_CONFIDENCE_BUCKET = 1
|
|
25
|
+
STRONG_BOUNDS_DISTANCE = 8
|
|
26
|
+
GAP_THRESHOLD_ANCHORED = 2
|
|
27
|
+
GAP_THRESHOLD_CONTEXTUAL = 3
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class RepairEvidence:
|
|
32
|
+
label_match: bool
|
|
33
|
+
resource_match: bool
|
|
34
|
+
class_match: bool
|
|
35
|
+
parent_match: bool
|
|
36
|
+
sibling_overlap: int
|
|
37
|
+
state_overlap: int
|
|
38
|
+
actions_overlap: int
|
|
39
|
+
bounds_distance: int
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def identity_anchor_count(self) -> int:
|
|
43
|
+
return int(self.label_match) + int(self.resource_match)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def semantic_signal_count(self) -> int:
|
|
47
|
+
return (
|
|
48
|
+
int(self.sibling_overlap > 0)
|
|
49
|
+
+ int(self.state_overlap > 0)
|
|
50
|
+
+ int(self.actions_overlap > 0)
|
|
51
|
+
+ int(self.strong_bounds_match)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def corroboration_count(self) -> int:
|
|
56
|
+
return (
|
|
57
|
+
int(self.class_match)
|
|
58
|
+
+ int(self.parent_match)
|
|
59
|
+
+ int(self.sibling_overlap > 0)
|
|
60
|
+
+ int(self.state_overlap > 0)
|
|
61
|
+
+ int(self.actions_overlap > 0)
|
|
62
|
+
+ int(self.strong_bounds_match)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def strong_bounds_match(self) -> bool:
|
|
67
|
+
return self.bounds_distance <= STRONG_BOUNDS_DISTANCE
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def contextual_anchor(self) -> bool:
|
|
71
|
+
return self.class_match and self.parent_match and self.semantic_signal_count > 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class RepairConfidence:
|
|
76
|
+
bucket: int
|
|
77
|
+
score: int
|
|
78
|
+
evidence: RepairEvidence
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def sort_key(self) -> tuple[int, int, int, int, int, int, int, int]:
|
|
82
|
+
return (
|
|
83
|
+
self.bucket,
|
|
84
|
+
self.score,
|
|
85
|
+
self.evidence.identity_anchor_count,
|
|
86
|
+
self.evidence.corroboration_count,
|
|
87
|
+
self.evidence.sibling_overlap,
|
|
88
|
+
self.evidence.state_overlap,
|
|
89
|
+
self.evidence.actions_overlap,
|
|
90
|
+
-self.evidence.bounds_distance,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def is_high_confidence(self) -> bool:
|
|
95
|
+
return self.bucket >= HIGH_CONFIDENCE_BUCKET
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class RefReconcileResult:
|
|
100
|
+
registry: RefRegistry
|
|
101
|
+
compiled_screen: CompiledScreen
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class RefRegistryBuilder:
|
|
105
|
+
def finalize_compiled_screen(
|
|
106
|
+
self,
|
|
107
|
+
*,
|
|
108
|
+
compiled_screen: CompiledScreen,
|
|
109
|
+
snapshot_id: int,
|
|
110
|
+
previous_registry: RefRegistry | None,
|
|
111
|
+
) -> RefReconcileResult:
|
|
112
|
+
finalized_compiled_screen = deepcopy(compiled_screen)
|
|
113
|
+
registry = self.reconcile(
|
|
114
|
+
compiled_screen=finalized_compiled_screen,
|
|
115
|
+
snapshot_id=snapshot_id,
|
|
116
|
+
previous_registry=previous_registry,
|
|
117
|
+
)
|
|
118
|
+
return RefReconcileResult(
|
|
119
|
+
registry=registry,
|
|
120
|
+
compiled_screen=finalized_compiled_screen,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def reconcile(
|
|
124
|
+
self,
|
|
125
|
+
compiled_screen: CompiledScreen,
|
|
126
|
+
snapshot_id: int,
|
|
127
|
+
previous_registry: RefRegistry | None,
|
|
128
|
+
) -> RefRegistry:
|
|
129
|
+
clear_candidate_refs(compiled_screen)
|
|
130
|
+
registry = RefRegistry()
|
|
131
|
+
candidates = list(compiled_screen.ref_candidates())
|
|
132
|
+
remaining_candidates = list(candidates)
|
|
133
|
+
used_refs = set()
|
|
134
|
+
|
|
135
|
+
if previous_registry is not None:
|
|
136
|
+
for binding in sorted(
|
|
137
|
+
previous_registry.bindings.values(),
|
|
138
|
+
key=lambda item: ref_sort_key(item.ref),
|
|
139
|
+
):
|
|
140
|
+
match = best_candidate_for_binding(binding, remaining_candidates)
|
|
141
|
+
if match is None:
|
|
142
|
+
continue
|
|
143
|
+
candidate, _ = match
|
|
144
|
+
candidate.ref = binding.ref
|
|
145
|
+
used_refs.add(binding.ref)
|
|
146
|
+
registry.bindings[binding.ref] = binding_for_candidate(
|
|
147
|
+
ref=binding.ref,
|
|
148
|
+
candidate=candidate,
|
|
149
|
+
snapshot_id=snapshot_id,
|
|
150
|
+
reused=True,
|
|
151
|
+
)
|
|
152
|
+
remaining_candidates.remove(candidate)
|
|
153
|
+
|
|
154
|
+
next_index = 1
|
|
155
|
+
for candidate in candidates:
|
|
156
|
+
if candidate.ref:
|
|
157
|
+
continue
|
|
158
|
+
while f"n{next_index}" in used_refs:
|
|
159
|
+
next_index += 1
|
|
160
|
+
ref = f"n{next_index}"
|
|
161
|
+
candidate.ref = ref
|
|
162
|
+
used_refs.add(ref)
|
|
163
|
+
registry.bindings[ref] = binding_for_candidate(
|
|
164
|
+
ref=ref,
|
|
165
|
+
candidate=candidate,
|
|
166
|
+
snapshot_id=snapshot_id,
|
|
167
|
+
reused=False,
|
|
168
|
+
)
|
|
169
|
+
return registry
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def clear_candidate_refs(compiled_screen: CompiledScreen) -> None:
|
|
173
|
+
for candidate in compiled_screen.ref_candidates():
|
|
174
|
+
candidate.ref = ""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def binding_for_candidate(
|
|
178
|
+
ref: str,
|
|
179
|
+
candidate: SemanticNode,
|
|
180
|
+
snapshot_id: int,
|
|
181
|
+
reused: bool,
|
|
182
|
+
) -> RefBinding:
|
|
183
|
+
return RefBinding(
|
|
184
|
+
ref=ref,
|
|
185
|
+
handle=NodeHandle(
|
|
186
|
+
snapshot_id=snapshot_id,
|
|
187
|
+
rid=candidate.raw_rid,
|
|
188
|
+
),
|
|
189
|
+
fingerprint=fingerprint_for_candidate(candidate),
|
|
190
|
+
semantic_profile=SemanticProfile(
|
|
191
|
+
state=tuple(candidate.state),
|
|
192
|
+
actions=tuple(candidate.actions),
|
|
193
|
+
),
|
|
194
|
+
reused=reused,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def fingerprint_for_candidate(candidate: SemanticNode) -> RefFingerprint:
|
|
199
|
+
return RefFingerprint(
|
|
200
|
+
role=candidate.role,
|
|
201
|
+
normalized_label=canonical_text_key(candidate.label),
|
|
202
|
+
resource_id=canonical_text_key(candidate.resource_id),
|
|
203
|
+
class_name=canonical_text_key(candidate.class_name),
|
|
204
|
+
parent_role=canonical_text_key(candidate.parent_role),
|
|
205
|
+
parent_label=canonical_text_key(candidate.parent_label),
|
|
206
|
+
sibling_labels=tuple(
|
|
207
|
+
canonical_text_key(label)
|
|
208
|
+
for label in candidate.sibling_labels
|
|
209
|
+
if canonical_text_key(label)
|
|
210
|
+
),
|
|
211
|
+
relative_bounds=candidate.relative_bounds,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def best_candidate_for_binding(
|
|
216
|
+
binding: RefBinding, candidates: Sequence[SemanticNode]
|
|
217
|
+
) -> tuple[SemanticNode, RepairConfidence] | None:
|
|
218
|
+
return best_candidate_for_source_signature(
|
|
219
|
+
source_signature_from_binding(binding),
|
|
220
|
+
candidates,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def best_candidate_for_source_signature(
|
|
225
|
+
source: RefRepairSourceSignature,
|
|
226
|
+
candidates: Sequence[SemanticNode],
|
|
227
|
+
) -> tuple[SemanticNode, RepairConfidence] | None:
|
|
228
|
+
scored: list[tuple[RepairConfidence, str, SemanticNode]] = []
|
|
229
|
+
for candidate in candidates:
|
|
230
|
+
confidence = candidate_match_confidence(source, candidate)
|
|
231
|
+
if confidence is None:
|
|
232
|
+
continue
|
|
233
|
+
scored.append((confidence, candidate.raw_rid, candidate))
|
|
234
|
+
if not scored:
|
|
235
|
+
return None
|
|
236
|
+
scored.sort(key=lambda item: (item[0].sort_key, item[1]), reverse=True)
|
|
237
|
+
best_confidence, _, best_candidate = scored[0]
|
|
238
|
+
if not best_confidence.is_high_confidence:
|
|
239
|
+
return None
|
|
240
|
+
if len(scored) > 1 and repair_gap_too_small(best_confidence, scored[1][0]):
|
|
241
|
+
return None
|
|
242
|
+
return best_candidate, best_confidence
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def candidate_match_confidence(
|
|
246
|
+
source: RefRepairSourceSignature, candidate: SemanticNode
|
|
247
|
+
) -> RepairConfidence | None:
|
|
248
|
+
fingerprint = source.fingerprint
|
|
249
|
+
candidate_fingerprint = fingerprint_for_candidate(candidate)
|
|
250
|
+
if candidate_fingerprint.role != fingerprint.role:
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
label_match = (
|
|
254
|
+
candidate_fingerprint.normalized_label == fingerprint.normalized_label
|
|
255
|
+
and bool(fingerprint.normalized_label)
|
|
256
|
+
)
|
|
257
|
+
resource_match = (
|
|
258
|
+
candidate_fingerprint.resource_id == fingerprint.resource_id
|
|
259
|
+
and bool(fingerprint.resource_id)
|
|
260
|
+
)
|
|
261
|
+
class_match = candidate_fingerprint.class_name == fingerprint.class_name and bool(
|
|
262
|
+
fingerprint.class_name
|
|
263
|
+
)
|
|
264
|
+
parent_match = (
|
|
265
|
+
candidate_fingerprint.parent_role == fingerprint.parent_role
|
|
266
|
+
and candidate_fingerprint.parent_label == fingerprint.parent_label
|
|
267
|
+
and bool(fingerprint.parent_role or fingerprint.parent_label)
|
|
268
|
+
)
|
|
269
|
+
sibling_overlap = len(
|
|
270
|
+
set(candidate_fingerprint.sibling_labels).intersection(
|
|
271
|
+
fingerprint.sibling_labels
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
bounds_distance = bounds_distance_score(
|
|
275
|
+
candidate_fingerprint.relative_bounds, fingerprint.relative_bounds
|
|
276
|
+
)
|
|
277
|
+
state_overlap = set_overlap(
|
|
278
|
+
source.state,
|
|
279
|
+
candidate.state,
|
|
280
|
+
)
|
|
281
|
+
actions_overlap = set_overlap(
|
|
282
|
+
source.actions,
|
|
283
|
+
candidate.actions,
|
|
284
|
+
)
|
|
285
|
+
evidence = RepairEvidence(
|
|
286
|
+
label_match=label_match,
|
|
287
|
+
resource_match=resource_match,
|
|
288
|
+
class_match=class_match,
|
|
289
|
+
parent_match=parent_match,
|
|
290
|
+
sibling_overlap=sibling_overlap,
|
|
291
|
+
state_overlap=state_overlap,
|
|
292
|
+
actions_overlap=actions_overlap,
|
|
293
|
+
bounds_distance=bounds_distance,
|
|
294
|
+
)
|
|
295
|
+
if not (evidence.identity_anchor_count or evidence.contextual_anchor):
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
bucket = confidence_bucket(evidence)
|
|
299
|
+
if bucket is None:
|
|
300
|
+
return None
|
|
301
|
+
return RepairConfidence(
|
|
302
|
+
bucket=bucket,
|
|
303
|
+
score=confidence_score(evidence),
|
|
304
|
+
evidence=evidence,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def source_signature_from_binding(binding: RefBinding) -> RefRepairSourceSignature:
|
|
309
|
+
return RefRepairSourceSignature(
|
|
310
|
+
ref=binding.ref,
|
|
311
|
+
fingerprint=binding.fingerprint,
|
|
312
|
+
state=binding.semantic_profile.state,
|
|
313
|
+
actions=binding.semantic_profile.actions,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def source_signature_from_artifact_payload(
|
|
318
|
+
ref: str,
|
|
319
|
+
payload: RepairBindingPayload,
|
|
320
|
+
) -> RefRepairSourceSignature:
|
|
321
|
+
return RefRepairSourceSignature(
|
|
322
|
+
ref=ref,
|
|
323
|
+
fingerprint=RefFingerprint(
|
|
324
|
+
role=payload.fingerprint.role,
|
|
325
|
+
normalized_label=payload.fingerprint.normalized_label,
|
|
326
|
+
resource_id=payload.fingerprint.resource_id,
|
|
327
|
+
class_name=payload.fingerprint.class_name,
|
|
328
|
+
parent_role=payload.fingerprint.parent_role,
|
|
329
|
+
parent_label=payload.fingerprint.parent_label,
|
|
330
|
+
sibling_labels=payload.fingerprint.sibling_labels,
|
|
331
|
+
relative_bounds=payload.fingerprint.relative_bounds,
|
|
332
|
+
),
|
|
333
|
+
state=payload.semantic_profile.state,
|
|
334
|
+
actions=payload.semantic_profile.actions,
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def repair_source_signature_to_current_snapshot(
|
|
339
|
+
source: RefRepairSourceSignature,
|
|
340
|
+
*,
|
|
341
|
+
compiled_screen: CompiledScreen,
|
|
342
|
+
snapshot_id: int,
|
|
343
|
+
) -> RefBinding | None:
|
|
344
|
+
match = best_candidate_for_source_signature(
|
|
345
|
+
source,
|
|
346
|
+
compiled_screen.ref_candidates(),
|
|
347
|
+
)
|
|
348
|
+
if match is None:
|
|
349
|
+
return None
|
|
350
|
+
candidate, _ = match
|
|
351
|
+
return binding_for_candidate(
|
|
352
|
+
ref=source.ref,
|
|
353
|
+
candidate=candidate,
|
|
354
|
+
snapshot_id=snapshot_id,
|
|
355
|
+
reused=True,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def bounds_distance_score(
|
|
360
|
+
left: tuple[int, int, int, int], right: tuple[int, int, int, int]
|
|
361
|
+
) -> int:
|
|
362
|
+
return sum(abs(a - b) for a, b in zip(left, right, strict=True))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def set_overlap(left: Sequence[str], right: Sequence[str]) -> int:
|
|
366
|
+
normalized_left = {
|
|
367
|
+
canonical_text_key(value) for value in left if canonical_text_key(value)
|
|
368
|
+
}
|
|
369
|
+
normalized_right = {
|
|
370
|
+
canonical_text_key(value) for value in right if canonical_text_key(value)
|
|
371
|
+
}
|
|
372
|
+
return len(normalized_left.intersection(normalized_right))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def confidence_bucket(evidence: RepairEvidence) -> int | None:
|
|
376
|
+
if evidence.identity_anchor_count >= 2:
|
|
377
|
+
return HIGH_CONFIDENCE_BUCKET
|
|
378
|
+
if evidence.identity_anchor_count == 1:
|
|
379
|
+
if evidence.semantic_signal_count >= 1 or evidence.corroboration_count >= 2:
|
|
380
|
+
return HIGH_CONFIDENCE_BUCKET
|
|
381
|
+
return MEDIUM_CONFIDENCE_BUCKET
|
|
382
|
+
if evidence.contextual_anchor:
|
|
383
|
+
if evidence.semantic_signal_count >= 2 and evidence.corroboration_count >= 4:
|
|
384
|
+
return HIGH_CONFIDENCE_BUCKET
|
|
385
|
+
return MEDIUM_CONFIDENCE_BUCKET
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def confidence_score(evidence: RepairEvidence) -> int:
|
|
390
|
+
return (
|
|
391
|
+
evidence.identity_anchor_count * 8
|
|
392
|
+
+ int(evidence.class_match) * 3
|
|
393
|
+
+ int(evidence.parent_match) * 3
|
|
394
|
+
+ min(evidence.sibling_overlap, 2) * 2
|
|
395
|
+
+ min(evidence.state_overlap, 2) * 2
|
|
396
|
+
+ min(evidence.actions_overlap, 2) * 2
|
|
397
|
+
+ int(evidence.strong_bounds_match) * 2
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def repair_gap_too_small(
|
|
402
|
+
best_confidence: RepairConfidence,
|
|
403
|
+
runner_up: RepairConfidence,
|
|
404
|
+
) -> bool:
|
|
405
|
+
if runner_up.bucket != best_confidence.bucket:
|
|
406
|
+
return False
|
|
407
|
+
gap_threshold = (
|
|
408
|
+
GAP_THRESHOLD_ANCHORED
|
|
409
|
+
if best_confidence.evidence.identity_anchor_count > 0
|
|
410
|
+
else GAP_THRESHOLD_CONTEXTUAL
|
|
411
|
+
)
|
|
412
|
+
return (best_confidence.score - runner_up.score) < gap_threshold
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
_REF_RE = re.compile(r"^n(\d+)$")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def ref_sort_key(ref: str) -> tuple[int, str]:
|
|
419
|
+
match = _REF_RE.match(ref)
|
|
420
|
+
if match is None:
|
|
421
|
+
return (NON_NUMERIC_REF_SORT_BUCKET, ref)
|
|
422
|
+
return (int(match.group(1)), ref)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Daemon-owned public rendering helpers."""
|