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,241 @@
|
|
|
1
|
+
"""Read-only source screen artifact lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from androidctld.artifacts.screen_payloads import ScreenArtifactPayload
|
|
13
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
14
|
+
from androidctld.runtime.screen_state import current_artifacts
|
|
15
|
+
|
|
16
|
+
ArtifactScanStatus = Literal["valid", "malformed", "unreadable"]
|
|
17
|
+
SourceScreenArtifactStatus = Literal["found", "not_found", "invalid_artifact"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ScannedArtifactPath:
|
|
22
|
+
path: Path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ScreenArtifactScan:
|
|
27
|
+
path: Path
|
|
28
|
+
status: ArtifactScanStatus
|
|
29
|
+
screen_id: str | None
|
|
30
|
+
sequence: int | None
|
|
31
|
+
payload: ScreenArtifactPayload | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ScannedScreenArtifact:
|
|
36
|
+
candidate: ScannedArtifactPath
|
|
37
|
+
artifact: ScreenArtifactScan
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class SourceScreenArtifactLookup:
|
|
42
|
+
status: SourceScreenArtifactStatus
|
|
43
|
+
source_screen_id: str
|
|
44
|
+
path: Path | None = None
|
|
45
|
+
payload: ScreenArtifactPayload | None = None
|
|
46
|
+
scanned: tuple[ScannedScreenArtifact, ...] = ()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def lookup_source_screen_artifact(
|
|
50
|
+
session: WorkspaceRuntime,
|
|
51
|
+
source_screen_id: str,
|
|
52
|
+
) -> SourceScreenArtifactLookup:
|
|
53
|
+
"""Find the newest source artifact without mutating artifact storage."""
|
|
54
|
+
|
|
55
|
+
matching_candidates: list[ScreenArtifactScan] = []
|
|
56
|
+
unknown_screen_candidates: list[ScreenArtifactScan] = []
|
|
57
|
+
scanned_candidates: list[ScannedScreenArtifact] = []
|
|
58
|
+
for candidate in screen_artifact_candidates(session):
|
|
59
|
+
if not candidate.path.exists():
|
|
60
|
+
continue
|
|
61
|
+
artifact = scan_screen_artifact(candidate.path)
|
|
62
|
+
scanned_candidates.append(
|
|
63
|
+
ScannedScreenArtifact(candidate=candidate, artifact=artifact)
|
|
64
|
+
)
|
|
65
|
+
if artifact.screen_id == source_screen_id:
|
|
66
|
+
matching_candidates.append(artifact)
|
|
67
|
+
continue
|
|
68
|
+
if artifact.screen_id is None and artifact.status != "valid":
|
|
69
|
+
unknown_screen_candidates.append(artifact)
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
scanned = tuple(scanned_candidates)
|
|
73
|
+
if not matching_candidates:
|
|
74
|
+
return SourceScreenArtifactLookup(
|
|
75
|
+
status="not_found",
|
|
76
|
+
source_screen_id=source_screen_id,
|
|
77
|
+
scanned=scanned,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
unknown_sequence_invalid = [
|
|
81
|
+
artifact for artifact in unknown_screen_candidates if artifact.sequence is None
|
|
82
|
+
]
|
|
83
|
+
if unknown_sequence_invalid:
|
|
84
|
+
selected = sorted(
|
|
85
|
+
unknown_sequence_invalid,
|
|
86
|
+
key=lambda artifact: artifact.path.name,
|
|
87
|
+
reverse=True,
|
|
88
|
+
)[0]
|
|
89
|
+
return SourceScreenArtifactLookup(
|
|
90
|
+
status="invalid_artifact",
|
|
91
|
+
source_screen_id=source_screen_id,
|
|
92
|
+
path=selected.path,
|
|
93
|
+
scanned=scanned,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
matching_candidates.sort(
|
|
97
|
+
key=lambda artifact: (
|
|
98
|
+
-1 if artifact.sequence is None else artifact.sequence,
|
|
99
|
+
artifact.path.name,
|
|
100
|
+
),
|
|
101
|
+
reverse=True,
|
|
102
|
+
)
|
|
103
|
+
selected_match = matching_candidates[0]
|
|
104
|
+
|
|
105
|
+
if unknown_screen_candidates:
|
|
106
|
+
unknown_screen_candidates.sort(
|
|
107
|
+
key=lambda artifact: (
|
|
108
|
+
-1 if artifact.sequence is None else artifact.sequence,
|
|
109
|
+
artifact.path.name,
|
|
110
|
+
),
|
|
111
|
+
reverse=True,
|
|
112
|
+
)
|
|
113
|
+
selected_unknown = unknown_screen_candidates[0]
|
|
114
|
+
else:
|
|
115
|
+
selected_unknown = None
|
|
116
|
+
|
|
117
|
+
if selected_unknown is not None:
|
|
118
|
+
unknown_sequence = selected_unknown.sequence
|
|
119
|
+
match_sequence = selected_match.sequence
|
|
120
|
+
if (
|
|
121
|
+
unknown_sequence is None
|
|
122
|
+
or match_sequence is None
|
|
123
|
+
or unknown_sequence >= match_sequence
|
|
124
|
+
):
|
|
125
|
+
return SourceScreenArtifactLookup(
|
|
126
|
+
status="invalid_artifact",
|
|
127
|
+
source_screen_id=source_screen_id,
|
|
128
|
+
path=selected_unknown.path,
|
|
129
|
+
scanned=scanned,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
selected = selected_match
|
|
133
|
+
if selected.status != "valid":
|
|
134
|
+
return SourceScreenArtifactLookup(
|
|
135
|
+
status="invalid_artifact",
|
|
136
|
+
source_screen_id=source_screen_id,
|
|
137
|
+
path=selected.path,
|
|
138
|
+
scanned=scanned,
|
|
139
|
+
)
|
|
140
|
+
if selected.payload is None:
|
|
141
|
+
raise RuntimeError("valid source screen artifact is missing payload")
|
|
142
|
+
return SourceScreenArtifactLookup(
|
|
143
|
+
status="found",
|
|
144
|
+
source_screen_id=source_screen_id,
|
|
145
|
+
path=selected.path,
|
|
146
|
+
payload=selected.payload,
|
|
147
|
+
scanned=scanned,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def screen_artifact_candidates(
|
|
152
|
+
session: WorkspaceRuntime,
|
|
153
|
+
) -> list[ScannedArtifactPath]:
|
|
154
|
+
candidates: dict[Path, ScannedArtifactPath] = {}
|
|
155
|
+
|
|
156
|
+
def add_candidate(path: Path) -> None:
|
|
157
|
+
resolved = path.resolve()
|
|
158
|
+
candidates.setdefault(resolved, ScannedArtifactPath(path=path))
|
|
159
|
+
|
|
160
|
+
current_artifact_bag = current_artifacts(session)
|
|
161
|
+
current_artifact = (
|
|
162
|
+
None if current_artifact_bag is None else current_artifact_bag.screen_json
|
|
163
|
+
)
|
|
164
|
+
if current_artifact is not None:
|
|
165
|
+
current_artifact_path = Path(current_artifact)
|
|
166
|
+
if is_internal_screen_artifact_path(current_artifact_path):
|
|
167
|
+
add_candidate(current_artifact_path)
|
|
168
|
+
|
|
169
|
+
screens_dir = session.artifact_root / "screens"
|
|
170
|
+
if screens_dir.exists():
|
|
171
|
+
for path in sorted(screens_dir.glob("obs-*.json")):
|
|
172
|
+
add_candidate(path)
|
|
173
|
+
|
|
174
|
+
return list(candidates.values())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def scan_screen_artifact(path: Path) -> ScreenArtifactScan:
|
|
178
|
+
try:
|
|
179
|
+
raw_payload = json.loads(path.read_text(encoding="utf-8"))
|
|
180
|
+
except OSError:
|
|
181
|
+
return ScreenArtifactScan(
|
|
182
|
+
path=path,
|
|
183
|
+
status="unreadable",
|
|
184
|
+
screen_id=None,
|
|
185
|
+
sequence=sequence_from_artifact_path(path),
|
|
186
|
+
)
|
|
187
|
+
except ValueError:
|
|
188
|
+
return ScreenArtifactScan(
|
|
189
|
+
path=path,
|
|
190
|
+
status="malformed",
|
|
191
|
+
screen_id=None,
|
|
192
|
+
sequence=sequence_from_artifact_path(path),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if not isinstance(raw_payload, dict):
|
|
196
|
+
return ScreenArtifactScan(
|
|
197
|
+
path=path,
|
|
198
|
+
status="malformed",
|
|
199
|
+
screen_id=None,
|
|
200
|
+
sequence=sequence_from_artifact_path(path),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
screen_id = raw_payload.get("screenId")
|
|
204
|
+
screen_id_hint = screen_id if isinstance(screen_id, str) else None
|
|
205
|
+
sequence = raw_payload.get("sequence")
|
|
206
|
+
sequence_hint = (
|
|
207
|
+
sequence
|
|
208
|
+
if isinstance(sequence, int) and not isinstance(sequence, bool)
|
|
209
|
+
else sequence_from_artifact_path(path)
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
payload = ScreenArtifactPayload.model_validate_json(json.dumps(raw_payload))
|
|
213
|
+
except ValidationError:
|
|
214
|
+
return ScreenArtifactScan(
|
|
215
|
+
path=path,
|
|
216
|
+
status="malformed",
|
|
217
|
+
screen_id=screen_id_hint,
|
|
218
|
+
sequence=sequence_hint,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return ScreenArtifactScan(
|
|
222
|
+
path=path,
|
|
223
|
+
status="valid",
|
|
224
|
+
screen_id=payload.screen_id,
|
|
225
|
+
sequence=payload.sequence,
|
|
226
|
+
payload=payload,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def sequence_from_artifact_path(path: Path) -> int | None:
|
|
231
|
+
name = path.name
|
|
232
|
+
if not name.startswith("obs-") or not name.endswith(".json"):
|
|
233
|
+
return None
|
|
234
|
+
digits = name[4:-5]
|
|
235
|
+
if not digits.isdigit():
|
|
236
|
+
return None
|
|
237
|
+
return int(digits)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def is_internal_screen_artifact_path(path: Path) -> bool:
|
|
241
|
+
return sequence_from_artifact_path(path) is not None
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Payload builders for persisted screen artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from androidctld.refs.models import (
|
|
10
|
+
RefBinding,
|
|
11
|
+
RefFingerprint,
|
|
12
|
+
RefRegistry,
|
|
13
|
+
SemanticProfile,
|
|
14
|
+
)
|
|
15
|
+
from androidctld.schema import ApiModel
|
|
16
|
+
from androidctld.semantics.public_models import PublicGroup, PublicScreen
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RepairFingerprintPayload(ApiModel):
|
|
20
|
+
role: str
|
|
21
|
+
normalized_label: str
|
|
22
|
+
resource_id: str
|
|
23
|
+
class_name: str
|
|
24
|
+
parent_role: str
|
|
25
|
+
parent_label: str
|
|
26
|
+
sibling_labels: tuple[str, ...] = Field(default_factory=tuple)
|
|
27
|
+
relative_bounds: tuple[int, int, int, int]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RepairSemanticProfilePayload(ApiModel):
|
|
31
|
+
state: tuple[str, ...] = Field(default_factory=tuple)
|
|
32
|
+
actions: tuple[str, ...] = Field(default_factory=tuple)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RepairBindingPayload(ApiModel):
|
|
36
|
+
fingerprint: RepairFingerprintPayload
|
|
37
|
+
semantic_profile: RepairSemanticProfilePayload
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ScreenArtifactPayload(ApiModel):
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
|
|
43
|
+
screen_id: str
|
|
44
|
+
sequence: int
|
|
45
|
+
source_snapshot_id: int
|
|
46
|
+
captured_at: str
|
|
47
|
+
package_name: str | None
|
|
48
|
+
activity_name: str | None
|
|
49
|
+
keyboard_visible: bool
|
|
50
|
+
groups: tuple[PublicGroup, ...]
|
|
51
|
+
repair_bindings: dict[str, RepairBindingPayload] = Field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_repair_fingerprint_payload(
|
|
55
|
+
fingerprint: RefFingerprint,
|
|
56
|
+
) -> RepairFingerprintPayload:
|
|
57
|
+
return RepairFingerprintPayload(
|
|
58
|
+
role=fingerprint.role,
|
|
59
|
+
normalized_label=fingerprint.normalized_label,
|
|
60
|
+
resource_id=fingerprint.resource_id,
|
|
61
|
+
class_name=fingerprint.class_name,
|
|
62
|
+
parent_role=fingerprint.parent_role,
|
|
63
|
+
parent_label=fingerprint.parent_label,
|
|
64
|
+
sibling_labels=fingerprint.sibling_labels,
|
|
65
|
+
relative_bounds=fingerprint.relative_bounds,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_repair_semantic_profile_payload(
|
|
70
|
+
semantic_profile: SemanticProfile,
|
|
71
|
+
) -> RepairSemanticProfilePayload:
|
|
72
|
+
return RepairSemanticProfilePayload(
|
|
73
|
+
state=semantic_profile.state,
|
|
74
|
+
actions=semantic_profile.actions,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_repair_binding_payload(binding: RefBinding) -> RepairBindingPayload:
|
|
79
|
+
return RepairBindingPayload(
|
|
80
|
+
fingerprint=build_repair_fingerprint_payload(binding.fingerprint),
|
|
81
|
+
semantic_profile=build_repair_semantic_profile_payload(
|
|
82
|
+
binding.semantic_profile
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def build_screen_artifact_payload(
|
|
88
|
+
public_screen: PublicScreen,
|
|
89
|
+
ref_registry: RefRegistry,
|
|
90
|
+
*,
|
|
91
|
+
sequence: int,
|
|
92
|
+
source_snapshot_id: int,
|
|
93
|
+
captured_at: str,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
payload = ScreenArtifactPayload(
|
|
96
|
+
screen_id=public_screen.screen_id,
|
|
97
|
+
sequence=sequence,
|
|
98
|
+
source_snapshot_id=source_snapshot_id,
|
|
99
|
+
captured_at=captured_at,
|
|
100
|
+
package_name=public_screen.app.package_name,
|
|
101
|
+
activity_name=public_screen.app.activity_name,
|
|
102
|
+
keyboard_visible=public_screen.surface.keyboard_visible,
|
|
103
|
+
groups=public_screen.groups,
|
|
104
|
+
repair_bindings={
|
|
105
|
+
ref: build_repair_binding_payload(binding)
|
|
106
|
+
for ref, binding in ref_registry.bindings.items()
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
return payload.model_dump(by_alias=True, mode="json")
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Screen artifact rendering and persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from androidctld.artifacts.models import ScreenArtifacts
|
|
13
|
+
from androidctld.artifacts.screen_payloads import build_screen_artifact_payload
|
|
14
|
+
from androidctld.errors import DaemonError, DaemonErrorCode
|
|
15
|
+
from androidctld.refs.models import RefRegistry
|
|
16
|
+
from androidctld.rendering.screen_xml import render_screen_xml
|
|
17
|
+
from androidctld.runtime.models import WorkspaceRuntime
|
|
18
|
+
from androidctld.runtime_policy import SCREENSHOT_MAX_BINARY_BYTES
|
|
19
|
+
from androidctld.semantics.public_models import PublicScreen
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class StagedArtifactWrite:
|
|
24
|
+
artifacts: ScreenArtifacts
|
|
25
|
+
file_updates: tuple[StagedFileUpdate, ...] = ()
|
|
26
|
+
|
|
27
|
+
def commit(self) -> None:
|
|
28
|
+
for update in self.file_updates:
|
|
29
|
+
update.commit()
|
|
30
|
+
|
|
31
|
+
def discard(self) -> None:
|
|
32
|
+
for update in self.file_updates:
|
|
33
|
+
update.discard()
|
|
34
|
+
|
|
35
|
+
def rollback(self) -> None:
|
|
36
|
+
for update in reversed(self.file_updates):
|
|
37
|
+
update.rollback()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class StagedFileUpdate:
|
|
42
|
+
staged_path: Path
|
|
43
|
+
final_path: Path
|
|
44
|
+
backup_path: Path | None = None
|
|
45
|
+
original_backed_up: bool = False
|
|
46
|
+
final_replaced: bool = False
|
|
47
|
+
|
|
48
|
+
def commit(self) -> None:
|
|
49
|
+
self.final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
if self.backup_path is not None:
|
|
51
|
+
try:
|
|
52
|
+
self.final_path.replace(self.backup_path)
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
pass
|
|
55
|
+
else:
|
|
56
|
+
self.original_backed_up = True
|
|
57
|
+
try:
|
|
58
|
+
self.staged_path.replace(self.final_path)
|
|
59
|
+
except Exception:
|
|
60
|
+
if self.original_backed_up and self.backup_path is not None:
|
|
61
|
+
self.backup_path.replace(self.final_path)
|
|
62
|
+
self.original_backed_up = False
|
|
63
|
+
raise
|
|
64
|
+
self.final_replaced = True
|
|
65
|
+
|
|
66
|
+
def rollback(self) -> None:
|
|
67
|
+
if not self.final_replaced:
|
|
68
|
+
return
|
|
69
|
+
if self.original_backed_up and self.backup_path is not None:
|
|
70
|
+
with suppress(FileNotFoundError):
|
|
71
|
+
self.final_path.unlink()
|
|
72
|
+
self.backup_path.replace(self.final_path)
|
|
73
|
+
self.original_backed_up = False
|
|
74
|
+
self.final_replaced = False
|
|
75
|
+
return
|
|
76
|
+
with suppress(FileNotFoundError):
|
|
77
|
+
self.final_path.unlink()
|
|
78
|
+
self.final_replaced = False
|
|
79
|
+
|
|
80
|
+
def discard(self) -> None:
|
|
81
|
+
for path in (self.staged_path, self.backup_path):
|
|
82
|
+
if path is None:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
path.unlink()
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ArtifactWriter:
|
|
91
|
+
def stage_screen(
|
|
92
|
+
self,
|
|
93
|
+
session: WorkspaceRuntime,
|
|
94
|
+
public_screen: PublicScreen,
|
|
95
|
+
*,
|
|
96
|
+
sequence: int,
|
|
97
|
+
source_snapshot_id: int,
|
|
98
|
+
captured_at: str,
|
|
99
|
+
ref_registry: RefRegistry | None = None,
|
|
100
|
+
) -> StagedArtifactWrite:
|
|
101
|
+
token = uuid.uuid4().hex
|
|
102
|
+
staged_screen_json = self.stage_screen_json_update(
|
|
103
|
+
session,
|
|
104
|
+
public_screen,
|
|
105
|
+
sequence=sequence,
|
|
106
|
+
source_snapshot_id=source_snapshot_id,
|
|
107
|
+
captured_at=captured_at,
|
|
108
|
+
ref_registry=ref_registry,
|
|
109
|
+
token=token,
|
|
110
|
+
)
|
|
111
|
+
staged_screen_xml = self.stage_screen_xml_update(
|
|
112
|
+
session,
|
|
113
|
+
public_screen,
|
|
114
|
+
sequence=sequence,
|
|
115
|
+
token=token,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
artifacts = ScreenArtifacts(
|
|
119
|
+
screen_json=staged_screen_json.final_path.as_posix(),
|
|
120
|
+
screen_xml=staged_screen_xml.final_path.as_posix(),
|
|
121
|
+
)
|
|
122
|
+
return StagedArtifactWrite(
|
|
123
|
+
artifacts=artifacts,
|
|
124
|
+
file_updates=(staged_screen_json, staged_screen_xml),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def stage_screen_json_update(
|
|
128
|
+
self,
|
|
129
|
+
session: WorkspaceRuntime,
|
|
130
|
+
public_screen: PublicScreen,
|
|
131
|
+
*,
|
|
132
|
+
sequence: int,
|
|
133
|
+
source_snapshot_id: int,
|
|
134
|
+
captured_at: str,
|
|
135
|
+
ref_registry: RefRegistry | None = None,
|
|
136
|
+
token: str | None = None,
|
|
137
|
+
) -> StagedFileUpdate:
|
|
138
|
+
screens_dir = session.artifact_root / "screens"
|
|
139
|
+
screens_dir.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
write_token = token or uuid.uuid4().hex
|
|
141
|
+
json_path = screens_dir / f"{artifact_stem(sequence)}.json"
|
|
142
|
+
staged_json_path = staged_path_for(json_path, write_token)
|
|
143
|
+
write_json_file(
|
|
144
|
+
staged_json_path,
|
|
145
|
+
build_screen_artifact_payload(
|
|
146
|
+
public_screen,
|
|
147
|
+
ref_registry or RefRegistry(),
|
|
148
|
+
sequence=sequence,
|
|
149
|
+
source_snapshot_id=source_snapshot_id,
|
|
150
|
+
captured_at=captured_at,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
return StagedFileUpdate(
|
|
154
|
+
staged_path=staged_json_path,
|
|
155
|
+
final_path=json_path,
|
|
156
|
+
backup_path=backup_path_for(json_path, write_token),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def stage_screen_xml_update(
|
|
160
|
+
self,
|
|
161
|
+
session: WorkspaceRuntime,
|
|
162
|
+
public_screen: PublicScreen,
|
|
163
|
+
*,
|
|
164
|
+
sequence: int,
|
|
165
|
+
token: str | None = None,
|
|
166
|
+
) -> StagedFileUpdate:
|
|
167
|
+
screens_dir = session.artifact_root / "artifacts" / "screens"
|
|
168
|
+
screens_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
write_token = token or uuid.uuid4().hex
|
|
170
|
+
xml_path = screens_dir / f"{artifact_stem(sequence)}.xml"
|
|
171
|
+
staged_xml_path = staged_path_for(xml_path, write_token)
|
|
172
|
+
write_text_file(staged_xml_path, render_screen_xml(public_screen))
|
|
173
|
+
return StagedFileUpdate(
|
|
174
|
+
staged_path=staged_xml_path,
|
|
175
|
+
final_path=xml_path,
|
|
176
|
+
backup_path=backup_path_for(xml_path, write_token),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def write_screenshot_png(self, session: WorkspaceRuntime, body: bytes) -> Path:
|
|
180
|
+
if len(body) > SCREENSHOT_MAX_BINARY_BYTES:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"screenshot PNG exceeds {SCREENSHOT_MAX_BINARY_BYTES} byte budget"
|
|
183
|
+
)
|
|
184
|
+
screenshots_dir = session.artifact_root / "screenshots"
|
|
185
|
+
try:
|
|
186
|
+
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
if not screenshots_dir.is_dir():
|
|
188
|
+
raise _artifact_root_unwritable("namespace-not-directory")
|
|
189
|
+
except DaemonError:
|
|
190
|
+
raise
|
|
191
|
+
except Exception as error:
|
|
192
|
+
raise _artifact_root_unwritable("namespace-create-failed") from error
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
next_index = _next_screenshot_index(screenshots_dir)
|
|
196
|
+
except Exception as error:
|
|
197
|
+
raise _artifact_root_unwritable("namespace-scan-failed") from error
|
|
198
|
+
binary_flag = getattr(os, "O_BINARY", 0)
|
|
199
|
+
while True:
|
|
200
|
+
try:
|
|
201
|
+
candidate = (screenshots_dir / f"shot-{next_index:05d}.png").resolve()
|
|
202
|
+
except Exception as error:
|
|
203
|
+
raise _artifact_write_failed("candidate-resolve-failed") from error
|
|
204
|
+
fd: int | None = None
|
|
205
|
+
handle = None
|
|
206
|
+
try:
|
|
207
|
+
fd = os.open(
|
|
208
|
+
candidate,
|
|
209
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY | binary_flag,
|
|
210
|
+
)
|
|
211
|
+
except FileExistsError:
|
|
212
|
+
next_index += 1
|
|
213
|
+
continue
|
|
214
|
+
except Exception as error:
|
|
215
|
+
raise _artifact_write_failed("candidate-open-failed") from error
|
|
216
|
+
failure_reason = "candidate-fdopen-failed"
|
|
217
|
+
try:
|
|
218
|
+
handle = os.fdopen(fd, "wb")
|
|
219
|
+
fd = None
|
|
220
|
+
failure_reason = "candidate-write-failed"
|
|
221
|
+
handle.write(body)
|
|
222
|
+
failure_reason = "candidate-flush-failed"
|
|
223
|
+
handle.flush()
|
|
224
|
+
failure_reason = "candidate-close-failed"
|
|
225
|
+
handle.close()
|
|
226
|
+
return candidate
|
|
227
|
+
except Exception as error:
|
|
228
|
+
if handle is not None:
|
|
229
|
+
with suppress(Exception):
|
|
230
|
+
handle.close()
|
|
231
|
+
if fd is not None:
|
|
232
|
+
with suppress(OSError):
|
|
233
|
+
os.close(fd)
|
|
234
|
+
with suppress(OSError):
|
|
235
|
+
candidate.unlink()
|
|
236
|
+
raise _artifact_write_failed(failure_reason) from error
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _next_screenshot_index(screenshots_dir: Path) -> int:
|
|
240
|
+
highest_index = 0
|
|
241
|
+
for path in screenshots_dir.glob("shot-*.png"):
|
|
242
|
+
suffix = path.stem.removeprefix("shot-")
|
|
243
|
+
if not suffix.isdigit():
|
|
244
|
+
continue
|
|
245
|
+
highest_index = max(highest_index, int(suffix))
|
|
246
|
+
return highest_index + 1
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _artifact_root_unwritable(reason: str) -> DaemonError:
|
|
250
|
+
return DaemonError(
|
|
251
|
+
code=DaemonErrorCode.ARTIFACT_ROOT_UNWRITABLE,
|
|
252
|
+
message="artifact root is not writable",
|
|
253
|
+
details={"reason": reason},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _artifact_write_failed(reason: str) -> DaemonError:
|
|
258
|
+
return DaemonError(
|
|
259
|
+
code=DaemonErrorCode.ARTIFACT_WRITE_FAILED,
|
|
260
|
+
message="artifact write failed",
|
|
261
|
+
details={"reason": reason},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def staged_path_for(final_path: Path, token: str) -> Path:
|
|
266
|
+
return final_path.with_name(f"{final_path.name}.tmp-{token}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def backup_path_for(final_path: Path, token: str) -> Path:
|
|
270
|
+
return final_path.with_name(f"{final_path.name}.bak-{token}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def artifact_stem(sequence: int) -> str:
|
|
274
|
+
return f"obs-{sequence:05d}"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def write_json_file(path: Path, payload: dict[str, object]) -> None:
|
|
278
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
279
|
+
json.dump(payload, handle, indent=2, sort_keys=True)
|
|
280
|
+
handle.write("\n")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def write_text_file(path: Path, payload: str) -> None:
|
|
284
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
285
|
+
handle.write(payload)
|
|
286
|
+
handle.write("\n")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Authentication helpers for androidctld."""
|