androidctl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. androidctl/__init__.py +5 -0
  2. androidctl/__main__.py +4 -0
  3. androidctl/_version.py +1 -0
  4. androidctl/app.py +73 -0
  5. androidctl/cli_options.py +27 -0
  6. androidctl/command_payloads.py +264 -0
  7. androidctl/command_views.py +157 -0
  8. androidctl/commands/__init__.py +1 -0
  9. androidctl/commands/actions.py +236 -0
  10. androidctl/commands/adb_wireless.py +157 -0
  11. androidctl/commands/close.py +30 -0
  12. androidctl/commands/connect.py +69 -0
  13. androidctl/commands/execute.py +179 -0
  14. androidctl/commands/list_apps.py +26 -0
  15. androidctl/commands/observe.py +26 -0
  16. androidctl/commands/open.py +41 -0
  17. androidctl/commands/plumbing.py +58 -0
  18. androidctl/commands/run_pipeline.py +307 -0
  19. androidctl/commands/screenshot.py +29 -0
  20. androidctl/commands/setup.py +301 -0
  21. androidctl/commands/wait.py +60 -0
  22. androidctl/daemon/__init__.py +1 -0
  23. androidctl/daemon/client.py +348 -0
  24. androidctl/daemon/discovery.py +190 -0
  25. androidctl/daemon/launcher.py +26 -0
  26. androidctl/daemon/owner.py +349 -0
  27. androidctl/errors/__init__.py +1 -0
  28. androidctl/errors/mapping.py +149 -0
  29. androidctl/errors/models.py +16 -0
  30. androidctl/exit_codes.py +8 -0
  31. androidctl/output.py +147 -0
  32. androidctl/parsing/__init__.py +1 -0
  33. androidctl/parsing/duration.py +17 -0
  34. androidctl/parsing/open_target.py +51 -0
  35. androidctl/parsing/refs.py +12 -0
  36. androidctl/parsing/screen_id.py +10 -0
  37. androidctl/parsing/wait.py +70 -0
  38. androidctl/renderers/__init__.py +110 -0
  39. androidctl/renderers/_paths.py +109 -0
  40. androidctl/renderers/xml.py +234 -0
  41. androidctl/renderers/xml_projection.py +732 -0
  42. androidctl/resources/__init__.py +1 -0
  43. androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
  44. androidctl/setup/__init__.py +1 -0
  45. androidctl/setup/accessibility.py +159 -0
  46. androidctl/setup/adb.py +586 -0
  47. androidctl/setup/apk_resource.py +29 -0
  48. androidctl/setup/pairing.py +70 -0
  49. androidctl/setup/verify.py +175 -0
  50. androidctl/workspace/__init__.py +3 -0
  51. androidctl/workspace/resolve.py +27 -0
  52. androidctl-0.1.0.dist-info/METADATA +217 -0
  53. androidctl-0.1.0.dist-info/RECORD +187 -0
  54. androidctl-0.1.0.dist-info/WHEEL +5 -0
  55. androidctl-0.1.0.dist-info/entry_points.txt +3 -0
  56. androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
  57. androidctl-0.1.0.dist-info/top_level.txt +3 -0
  58. androidctl_contracts/__init__.py +55 -0
  59. androidctl_contracts/_version.py +1 -0
  60. androidctl_contracts/_wire_helpers.py +31 -0
  61. androidctl_contracts/base.py +142 -0
  62. androidctl_contracts/command_catalog.py +414 -0
  63. androidctl_contracts/command_results.py +630 -0
  64. androidctl_contracts/daemon_api.py +335 -0
  65. androidctl_contracts/errors.py +44 -0
  66. androidctl_contracts/paths.py +5 -0
  67. androidctl_contracts/public_screen.py +579 -0
  68. androidctl_contracts/user_state.py +23 -0
  69. androidctl_contracts/vocabulary.py +82 -0
  70. androidctld/__init__.py +5 -0
  71. androidctld/__main__.py +63 -0
  72. androidctld/_version.py +1 -0
  73. androidctld/actions/__init__.py +1 -0
  74. androidctld/actions/action_target.py +142 -0
  75. androidctld/actions/capabilities.py +539 -0
  76. androidctld/actions/executor.py +894 -0
  77. androidctld/actions/focus_confirmation.py +177 -0
  78. androidctld/actions/focused_input_admissibility.py +120 -0
  79. androidctld/actions/fresh_current.py +176 -0
  80. androidctld/actions/postconditions.py +473 -0
  81. androidctld/actions/repair.py +101 -0
  82. androidctld/actions/request_builder.py +204 -0
  83. androidctld/actions/settle.py +146 -0
  84. androidctld/actions/submit_confirmation.py +211 -0
  85. androidctld/actions/submit_routing.py +311 -0
  86. androidctld/actions/type_confirmation.py +257 -0
  87. androidctld/app_targets.py +71 -0
  88. androidctld/artifacts/__init__.py +1 -0
  89. androidctld/artifacts/models.py +26 -0
  90. androidctld/artifacts/screen_lookup.py +241 -0
  91. androidctld/artifacts/screen_payloads.py +109 -0
  92. androidctld/artifacts/writer.py +286 -0
  93. androidctld/auth/__init__.py +1 -0
  94. androidctld/auth/active_registry.py +266 -0
  95. androidctld/auth/secret_files.py +52 -0
  96. androidctld/auth/token_store.py +59 -0
  97. androidctld/commands/__init__.py +1 -0
  98. androidctld/commands/assembly.py +231 -0
  99. androidctld/commands/command_models.py +254 -0
  100. androidctld/commands/dispatch.py +99 -0
  101. androidctld/commands/executor.py +31 -0
  102. androidctld/commands/from_boundary.py +175 -0
  103. androidctld/commands/handlers/__init__.py +15 -0
  104. androidctld/commands/handlers/action.py +439 -0
  105. androidctld/commands/handlers/connect.py +94 -0
  106. androidctld/commands/handlers/list_apps.py +215 -0
  107. androidctld/commands/handlers/observe.py +121 -0
  108. androidctld/commands/handlers/screenshot.py +105 -0
  109. androidctld/commands/handlers/wait.py +286 -0
  110. androidctld/commands/models.py +65 -0
  111. androidctld/commands/open_targets.py +56 -0
  112. androidctld/commands/orchestration.py +353 -0
  113. androidctld/commands/registry.py +116 -0
  114. androidctld/commands/result_builders.py +40 -0
  115. androidctld/commands/result_models.py +555 -0
  116. androidctld/commands/results.py +108 -0
  117. androidctld/commands/semantic_command_names.py +17 -0
  118. androidctld/commands/semantic_error_mapping.py +93 -0
  119. androidctld/commands/semantic_truth.py +135 -0
  120. androidctld/commands/service.py +67 -0
  121. androidctld/config.py +75 -0
  122. androidctld/daemon/__init__.py +1 -0
  123. androidctld/daemon/active_slot.py +326 -0
  124. androidctld/daemon/envelope.py +30 -0
  125. androidctld/daemon/http_host.py +123 -0
  126. androidctld/daemon/ingress.py +112 -0
  127. androidctld/daemon/ownership_probe.py +204 -0
  128. androidctld/daemon/server.py +286 -0
  129. androidctld/daemon/service.py +99 -0
  130. androidctld/device/__init__.py +1 -0
  131. androidctld/device/action_models.py +154 -0
  132. androidctld/device/action_serialization.py +121 -0
  133. androidctld/device/adapters.py +220 -0
  134. androidctld/device/bootstrap.py +153 -0
  135. androidctld/device/connectors.py +231 -0
  136. androidctld/device/errors.py +100 -0
  137. androidctld/device/interfaces.py +58 -0
  138. androidctld/device/parsing.py +320 -0
  139. androidctld/device/rpc.py +483 -0
  140. androidctld/device/schema.py +114 -0
  141. androidctld/device/types.py +161 -0
  142. androidctld/errors/__init__.py +94 -0
  143. androidctld/logging/__init__.py +22 -0
  144. androidctld/observation.py +98 -0
  145. androidctld/protocol.py +53 -0
  146. androidctld/refs/__init__.py +1 -0
  147. androidctld/refs/models.py +54 -0
  148. androidctld/refs/repair.py +284 -0
  149. androidctld/refs/service.py +422 -0
  150. androidctld/rendering/__init__.py +1 -0
  151. androidctld/rendering/screen_xml.py +256 -0
  152. androidctld/runtime/__init__.py +21 -0
  153. androidctld/runtime/kernel.py +548 -0
  154. androidctld/runtime/lifecycle.py +19 -0
  155. androidctld/runtime/models.py +48 -0
  156. androidctld/runtime/screen_state.py +117 -0
  157. androidctld/runtime/state_repo.py +70 -0
  158. androidctld/runtime/store.py +76 -0
  159. androidctld/runtime_policy.py +127 -0
  160. androidctld/schema/__init__.py +5 -0
  161. androidctld/schema/base.py +132 -0
  162. androidctld/schema/core.py +35 -0
  163. androidctld/schema/daemon_api.py +108 -0
  164. androidctld/schema/persistence.py +161 -0
  165. androidctld/schema/persistence_io.py +41 -0
  166. androidctld/schema/validation_errors.py +309 -0
  167. androidctld/semantics/__init__.py +1 -0
  168. androidctld/semantics/compiler.py +610 -0
  169. androidctld/semantics/continuity.py +107 -0
  170. androidctld/semantics/labels.py +252 -0
  171. androidctld/semantics/models.py +25 -0
  172. androidctld/semantics/policy.py +23 -0
  173. androidctld/semantics/public_models.py +123 -0
  174. androidctld/semantics/registries.py +13 -0
  175. androidctld/semantics/submit_refs.py +417 -0
  176. androidctld/semantics/surface.py +254 -0
  177. androidctld/semantics/targets.py +167 -0
  178. androidctld/snapshots/__init__.py +1 -0
  179. androidctld/snapshots/models.py +219 -0
  180. androidctld/snapshots/refresh.py +273 -0
  181. androidctld/snapshots/schema.py +74 -0
  182. androidctld/snapshots/service.py +138 -0
  183. androidctld/text_equivalence.py +67 -0
  184. androidctld/waits/__init__.py +1 -0
  185. androidctld/waits/evaluators.py +216 -0
  186. androidctld/waits/loop.py +305 -0
  187. androidctld/waits/matcher.py +41 -0
@@ -0,0 +1,732 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Callable, Mapping, Sequence
5
+ from typing import Literal, TypeAlias, TypedDict, cast
6
+
7
+ from typing_extensions import NotRequired
8
+
9
+ from androidctl.renderers import (
10
+ ProjectionDict,
11
+ ProjectionValue,
12
+ RenderPayload,
13
+ projection_dict,
14
+ )
15
+ from androidctl_contracts.public_screen import (
16
+ BLOCKING_GROUP_NAMES,
17
+ OMITTED_REASON_VALUES,
18
+ PUBLIC_GROUP_NAMES,
19
+ PUBLIC_NODE_ROLE_VALUES,
20
+ TRANSIENT_KIND_VALUES,
21
+ BlockingGroupName,
22
+ OmittedReason,
23
+ PublicGroupName,
24
+ TransientKind,
25
+ )
26
+
27
+ XmlScalarAttrs: TypeAlias = dict[str, str]
28
+ XmlGroupName: TypeAlias = PublicGroupName
29
+ XmlAttrRule: TypeAlias = tuple[str, Callable[[object], str | None]]
30
+ XmlGroupItemTag: TypeAlias = str
31
+
32
+
33
+ class XmlSurfaceProjection(TypedDict):
34
+ attrs: XmlScalarAttrs
35
+ focus: XmlScalarAttrs
36
+
37
+
38
+ class XmlNodeGroupItemProjection(TypedDict):
39
+ tag: XmlGroupItemTag
40
+ attrs: XmlScalarAttrs
41
+ children: list[XmlNodeGroupItemProjection]
42
+ text: NotRequired[str]
43
+
44
+
45
+ XmlGroupItemProjection: TypeAlias = XmlNodeGroupItemProjection
46
+
47
+
48
+ class XmlGroupProjection(TypedDict):
49
+ name: XmlGroupName
50
+ items: list[XmlGroupItemProjection]
51
+
52
+
53
+ class XmlOmittedEntryProjection(TypedDict):
54
+ group: XmlGroupName
55
+ reason: OmittedReason
56
+ count: NotRequired[str]
57
+
58
+
59
+ class XmlTransientItemProjection(TypedDict):
60
+ text: str
61
+ kind: NotRequired[TransientKind]
62
+
63
+
64
+ class XmlScreenProjection(TypedDict):
65
+ attrs: XmlScalarAttrs
66
+ app: XmlScalarAttrs
67
+ surface: XmlSurfaceProjection
68
+ groups: list[XmlGroupProjection]
69
+ omitted: list[XmlOmittedEntryProjection]
70
+ visibleWindows: list[XmlScalarAttrs]
71
+ transient: list[XmlTransientItemProjection]
72
+
73
+
74
+ class XmlActionTargetProjection(TypedDict):
75
+ attrs: XmlScalarAttrs
76
+
77
+
78
+ class XmlSemanticProjection(TypedDict):
79
+ kind: Literal["semantic"]
80
+ attrs: XmlScalarAttrs
81
+ message: str | None
82
+ truth: XmlScalarAttrs
83
+ actionTarget: XmlActionTargetProjection | None
84
+ uncertainty: list[str]
85
+ warnings: list[str]
86
+ screen: XmlScreenProjection | None
87
+ artifacts: XmlScalarAttrs
88
+
89
+
90
+ class XmlRetainedProjection(TypedDict):
91
+ kind: Literal["retained"]
92
+ attrs: XmlScalarAttrs
93
+ message: str | None
94
+ details: XmlScalarAttrs
95
+ artifacts: XmlScalarAttrs
96
+
97
+
98
+ class XmlListAppsProjection(TypedDict):
99
+ kind: Literal["listApps"]
100
+ attrs: XmlScalarAttrs
101
+ apps: list[XmlScalarAttrs]
102
+
103
+
104
+ XmlProjection: TypeAlias = (
105
+ XmlSemanticProjection | XmlRetainedProjection | XmlListAppsProjection
106
+ )
107
+
108
+
109
+ _TRUTH_ATTRS = (
110
+ "executionOutcome",
111
+ "continuityStatus",
112
+ "observationQuality",
113
+ "changed",
114
+ )
115
+ _ACTION_TARGET_ATTRS = (
116
+ "sourceRef",
117
+ "sourceScreenId",
118
+ "subjectRef",
119
+ "dispatchedRef",
120
+ "nextScreenId",
121
+ "nextRef",
122
+ "identityStatus",
123
+ "evidence",
124
+ )
125
+ _APP_ATTRS = (
126
+ "packageName",
127
+ "activityName",
128
+ "requestedPackageName",
129
+ "resolvedPackageName",
130
+ "matchType",
131
+ )
132
+ _FOCUS_ATTRS = ("inputRef",)
133
+ _SEMANTIC_ARTIFACT_ATTRS = ("screenshotPng", "screenXml")
134
+ _RETAINED_ARTIFACT_ATTRS = ("screenshotPng",)
135
+ _LIST_APP_ATTRS = ("packageName", "appLabel")
136
+ _RETAINED_DETAILS_ATTRS = (
137
+ "sourceCode",
138
+ "sourceKind",
139
+ "operation",
140
+ "reason",
141
+ "expectedReleaseVersion",
142
+ "actualReleaseVersion",
143
+ )
144
+ _RETAINED_SOURCE_CODE_RE = re.compile(r"^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$")
145
+ _RETAINED_DETAIL_SLUG_RE = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
146
+ _RETAINED_RELEASE_VERSION_RE = re.compile(
147
+ r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$"
148
+ )
149
+ _DEVICE_SERIAL_DETAIL_RE = re.compile(r"^(?:emulator-\d+|[A-Z0-9]{6,})$")
150
+ _FINGERPRINT_DETAIL_RE = re.compile(r"^(?:[0-9a-f]{16,}|[0-9a-f]{2}:){7,}[0-9a-f]{2}$")
151
+ _SAFE_TOKEN_DETAIL_VALUES = frozenset({"wrong-token"})
152
+ _VISIBLE_WINDOW_ATTRS = ("windowRef", "role", "focused", "blocking")
153
+ _GROUP_ORDER: tuple[XmlGroupName, ...] = PUBLIC_GROUP_NAMES
154
+ _BLOCKING_GROUPS: set[BlockingGroupName] = set(BLOCKING_GROUP_NAMES)
155
+ _OMITTED_REASONS: set[OmittedReason] = set(OMITTED_REASON_VALUES)
156
+ _TRANSIENT_KINDS: set[TransientKind] = set(TRANSIENT_KIND_VALUES)
157
+ _NODE_ROLE_TAGS = frozenset(PUBLIC_NODE_ROLE_VALUES)
158
+ _NODE_TEXT_ATTRS = ("label", "within", "value")
159
+ _TEXT_ITEM_ATTRS = ("origin", "windowRef", "ambiguity", "within", "value")
160
+ _NODE_SCALAR_ATTRS = (
161
+ "ref",
162
+ "role",
163
+ "origin",
164
+ "windowRef",
165
+ "ambiguity",
166
+ )
167
+ _NODE_SEQUENCE_ATTRS = (
168
+ "actions",
169
+ "state",
170
+ "scrollDirections",
171
+ "submitRefs",
172
+ )
173
+
174
+
175
+ def project_xml_payload(payload: RenderPayload) -> XmlProjection:
176
+ projected = projection_dict(payload)
177
+ if "envelope" in projected:
178
+ return _project_retained_payload(projected)
179
+ if projected.get("command") == "list-apps":
180
+ return _project_list_apps_payload(projected)
181
+ return _project_semantic_payload(projected)
182
+
183
+
184
+ def _project_retained_payload(result: ProjectionDict) -> XmlRetainedProjection:
185
+ attrs = _project_retained_top_level_attrs(result)
186
+ message = result.get("message")
187
+ return {
188
+ "kind": "retained",
189
+ "attrs": attrs,
190
+ "message": message if isinstance(message, str) and message else None,
191
+ "details": _project_retained_details_attrs(_mapping(result.get("details"))),
192
+ "artifacts": _project_non_empty_string_attrs(
193
+ _mapping(result.get("artifacts")),
194
+ _RETAINED_ARTIFACT_ATTRS,
195
+ ),
196
+ }
197
+
198
+
199
+ def _project_list_apps_payload(result: ProjectionDict) -> XmlListAppsProjection:
200
+ return {
201
+ "kind": "listApps",
202
+ "attrs": _project_required_attr_rules(
203
+ result,
204
+ (
205
+ ("ok", _required_ok_attr),
206
+ ("command", _scalar_attr),
207
+ ),
208
+ ),
209
+ "apps": _project_list_app_items(result.get("apps")),
210
+ }
211
+
212
+
213
+ def _project_semantic_payload(result: ProjectionDict) -> XmlSemanticProjection:
214
+ screen_value = result.get("screen")
215
+ screen = (
216
+ _project_screen(screen_value) if isinstance(screen_value, Mapping) else None
217
+ )
218
+ message = result.get("message")
219
+ return {
220
+ "kind": "semantic",
221
+ "attrs": _project_top_level_attrs(result),
222
+ "message": message if isinstance(message, str) and message else None,
223
+ "truth": _project_attrs(_mapping(result.get("truth")), _TRUTH_ATTRS),
224
+ "actionTarget": _project_action_target(result.get("actionTarget")),
225
+ "uncertainty": _project_string_items(result.get("uncertainty")),
226
+ "warnings": _project_string_items(result.get("warnings")),
227
+ "screen": screen,
228
+ "artifacts": _project_non_empty_string_attrs(
229
+ _mapping(result.get("artifacts")),
230
+ _SEMANTIC_ARTIFACT_ATTRS,
231
+ ),
232
+ }
233
+
234
+
235
+ def _project_list_app_items(items_value: object) -> list[XmlScalarAttrs]:
236
+ if not isinstance(items_value, list):
237
+ return []
238
+ items: list[XmlScalarAttrs] = []
239
+ for item in items_value:
240
+ if not isinstance(item, Mapping):
241
+ continue
242
+ items.append(_project_attrs(item, _LIST_APP_ATTRS))
243
+ return items
244
+
245
+
246
+ def _project_action_target(value: object) -> XmlActionTargetProjection | None:
247
+ if not isinstance(value, Mapping):
248
+ return None
249
+ attrs = _project_attrs(value, _ACTION_TARGET_ATTRS)
250
+ return {"attrs": attrs} if attrs else None
251
+
252
+
253
+ def _project_screen(screen: Mapping[str, ProjectionValue]) -> XmlScreenProjection:
254
+ screen_id = screen.get("screenId")
255
+ attrs = {"screenId": str(screen_id)}
256
+ surface = _mapping(screen.get("surface"))
257
+ surface_attrs = _project_surface_attrs(surface)
258
+ return {
259
+ "attrs": attrs,
260
+ "app": _project_non_empty_string_attrs(_mapping(screen.get("app")), _APP_ATTRS),
261
+ "surface": {
262
+ "attrs": surface_attrs,
263
+ "focus": _project_non_empty_string_attrs(
264
+ _mapping(surface.get("focus")),
265
+ _FOCUS_ATTRS,
266
+ ),
267
+ },
268
+ "groups": _project_groups(
269
+ screen.get("groups"),
270
+ blocking_group=_projected_blocking_group(surface_attrs),
271
+ ),
272
+ "omitted": _project_omitted_entries(screen.get("omitted")),
273
+ "visibleWindows": _project_visible_windows(screen.get("visibleWindows")),
274
+ "transient": _project_transient_items(screen.get("transient")),
275
+ }
276
+
277
+
278
+ def _project_surface_attrs(surface: Mapping[str, ProjectionValue]) -> XmlScalarAttrs:
279
+ return _project_attr_rules(surface, _SURFACE_ATTR_RULES)
280
+
281
+
282
+ def _project_groups(
283
+ groups_value: object,
284
+ *,
285
+ blocking_group: BlockingGroupName | None,
286
+ ) -> list[XmlGroupProjection]:
287
+ entries_by_name: dict[str, list[XmlGroupItemProjection]] = {
288
+ group_name: [] for group_name in _GROUP_ORDER
289
+ }
290
+
291
+ if isinstance(groups_value, list):
292
+ for item in groups_value:
293
+ if not isinstance(item, Mapping):
294
+ continue
295
+ group_name = item.get("name")
296
+ nodes = item.get("nodes")
297
+ if not isinstance(group_name, str) or group_name not in entries_by_name:
298
+ continue
299
+ entries_by_name[group_name] = _project_group_items(nodes)
300
+ group_order = list(_GROUP_ORDER)
301
+ if blocking_group in _BLOCKING_GROUPS:
302
+ group_order.remove(blocking_group)
303
+ group_order.insert(0, blocking_group)
304
+ return [
305
+ {
306
+ "name": group_name,
307
+ "items": entries_by_name[group_name],
308
+ }
309
+ for group_name in group_order
310
+ ]
311
+
312
+
313
+ def _project_group_items(items_value: object) -> list[XmlGroupItemProjection]:
314
+ if not isinstance(items_value, list):
315
+ return []
316
+
317
+ items: list[XmlGroupItemProjection] = []
318
+ for item in items_value:
319
+ projected = _project_group_item(item)
320
+ if projected is not None:
321
+ items.append(projected)
322
+ return items
323
+
324
+
325
+ def _project_group_item(item: object) -> XmlGroupItemProjection | None:
326
+ if not isinstance(item, Mapping):
327
+ return None
328
+
329
+ kind = _group_item_tag(item)
330
+ if kind == "text":
331
+ text_values = _project_text_values(item.get("text"))
332
+ if not text_values:
333
+ return None
334
+ return {
335
+ "tag": "literal",
336
+ "attrs": _project_attrs(item, _TEXT_ITEM_ATTRS),
337
+ "children": [],
338
+ "text": text_values[0],
339
+ }
340
+
341
+ tag = _node_role_tag(item.get("role"))
342
+ if tag is None:
343
+ return None
344
+ attrs = _project_node_attrs(item)
345
+ attrs.pop("role", None)
346
+ attrs.update(_project_attrs(item, _NODE_TEXT_ATTRS))
347
+ children = _project_group_items(item.get("children"))
348
+ if not attrs and not children:
349
+ return None
350
+ return {
351
+ "tag": tag,
352
+ "attrs": attrs,
353
+ "children": children,
354
+ }
355
+
356
+
357
+ def _project_visible_windows(windows_value: object) -> list[XmlScalarAttrs]:
358
+ if not isinstance(windows_value, list):
359
+ return []
360
+
361
+ windows: list[XmlScalarAttrs] = []
362
+ for item in windows_value:
363
+ if not isinstance(item, Mapping):
364
+ continue
365
+ attrs = _project_attrs(item, _VISIBLE_WINDOW_ATTRS)
366
+ if attrs:
367
+ windows.append(attrs)
368
+ return windows
369
+
370
+
371
+ def _project_omitted_entries(items_value: object) -> list[XmlOmittedEntryProjection]:
372
+ if not isinstance(items_value, list):
373
+ return []
374
+
375
+ items: list[XmlOmittedEntryProjection] = []
376
+ for item in items_value:
377
+ projected = _project_omitted_entry(item)
378
+ if projected is not None:
379
+ items.append(projected)
380
+ return items
381
+
382
+
383
+ def _project_omitted_entry(item: object) -> XmlOmittedEntryProjection | None:
384
+ if not isinstance(item, Mapping):
385
+ return None
386
+ group = _group_name(item.get("group"))
387
+ reason = _omitted_reason(item.get("reason"))
388
+ if group is None or reason is None:
389
+ return None
390
+
391
+ entry: XmlOmittedEntryProjection = {
392
+ "group": group,
393
+ "reason": reason,
394
+ }
395
+ count = _count_attr(item.get("count"))
396
+ if count is not None:
397
+ entry["count"] = count
398
+ return entry
399
+
400
+
401
+ def _project_transient_items(
402
+ items_value: object,
403
+ ) -> list[XmlTransientItemProjection]:
404
+ if not isinstance(items_value, list):
405
+ return []
406
+
407
+ items: list[XmlTransientItemProjection] = []
408
+ for item in items_value:
409
+ projected = _project_transient_item(item)
410
+ if projected is not None:
411
+ items.append(projected)
412
+ return items
413
+
414
+
415
+ def _project_transient_item(item: object) -> XmlTransientItemProjection | None:
416
+ if not isinstance(item, Mapping):
417
+ return None
418
+
419
+ text = item.get("text")
420
+ if not isinstance(text, str) or not text:
421
+ return None
422
+
423
+ projected: XmlTransientItemProjection = {"text": text}
424
+ kind = _transient_kind(item.get("kind"))
425
+ if kind is not None:
426
+ projected["kind"] = kind
427
+ return projected
428
+
429
+
430
+ def _project_string_items(items_value: object) -> list[str]:
431
+ if not isinstance(items_value, list):
432
+ return []
433
+ return [str(item) for item in items_value if item is not None]
434
+
435
+
436
+ def _project_text_values(value: object) -> list[str]:
437
+ if isinstance(value, str):
438
+ return [value]
439
+ if isinstance(value, list):
440
+ return [str(item) for item in value if item is not None]
441
+ return []
442
+
443
+
444
+ def _project_attrs(
445
+ value: Mapping[str, object],
446
+ keys: Sequence[str],
447
+ ) -> XmlScalarAttrs:
448
+ attrs: XmlScalarAttrs = {}
449
+ for key in keys:
450
+ if key not in value:
451
+ continue
452
+ projected = _scalar_attr(value.get(key))
453
+ if projected is not None:
454
+ attrs[key] = projected
455
+ return attrs
456
+
457
+
458
+ def _project_node_attrs(value: Mapping[str, object]) -> XmlScalarAttrs:
459
+ attrs = _project_attrs(value, _NODE_SCALAR_ATTRS)
460
+ attrs.update(_project_non_empty_sequence_attrs(value, _NODE_SEQUENCE_ATTRS))
461
+ return attrs
462
+
463
+
464
+ def _project_non_empty_sequence_attrs(
465
+ value: Mapping[str, object],
466
+ keys: Sequence[str],
467
+ ) -> XmlScalarAttrs:
468
+ attrs: XmlScalarAttrs = {}
469
+ for key in keys:
470
+ if key not in value:
471
+ continue
472
+ projected = _non_empty_sequence_attr(value.get(key))
473
+ if projected is not None:
474
+ attrs[key] = projected
475
+ return attrs
476
+
477
+
478
+ def _project_top_level_attrs(result: Mapping[str, object]) -> XmlScalarAttrs:
479
+ attrs = _project_required_attr_rules(result, _TOP_LEVEL_REQUIRED_ATTR_RULES)
480
+ attrs.update(_project_attr_rules(result, _TOP_LEVEL_OPTIONAL_ATTR_RULES))
481
+ return attrs
482
+
483
+
484
+ def _project_retained_top_level_attrs(result: Mapping[str, object]) -> XmlScalarAttrs:
485
+ attrs = _project_required_attr_rules(
486
+ result,
487
+ _RETAINED_TOP_LEVEL_REQUIRED_ATTR_RULES,
488
+ )
489
+ attrs.update(_project_attr_rules(result, _RETAINED_TOP_LEVEL_OPTIONAL_ATTR_RULES))
490
+ return attrs
491
+
492
+
493
+ def _project_non_empty_string_attrs(
494
+ value: Mapping[str, object],
495
+ keys: Sequence[str],
496
+ ) -> XmlScalarAttrs:
497
+ attrs: XmlScalarAttrs = {}
498
+ for key in keys:
499
+ projected = _non_empty_string_attr(value.get(key))
500
+ if projected is not None:
501
+ attrs[key] = projected
502
+ return attrs
503
+
504
+
505
+ def _project_retained_details_attrs(value: Mapping[str, object]) -> XmlScalarAttrs:
506
+ attrs: XmlScalarAttrs = {}
507
+ for key in _RETAINED_DETAILS_ATTRS:
508
+ projected = _retained_detail_attr(key, value.get(key))
509
+ if projected is not None:
510
+ attrs[key] = projected
511
+ return attrs
512
+
513
+
514
+ def _project_attr_rules(
515
+ value: Mapping[str, object],
516
+ rules: Sequence[XmlAttrRule],
517
+ ) -> XmlScalarAttrs:
518
+ attrs: XmlScalarAttrs = {}
519
+ for key, projector in rules:
520
+ if key not in value:
521
+ continue
522
+ projected = projector(value.get(key))
523
+ if projected is not None:
524
+ attrs[key] = projected
525
+ return attrs
526
+
527
+
528
+ def _project_required_attr_rules(
529
+ value: Mapping[str, object],
530
+ rules: Sequence[XmlAttrRule],
531
+ ) -> XmlScalarAttrs:
532
+ attrs: XmlScalarAttrs = {}
533
+ for key, projector in rules:
534
+ projected = projector(value[key])
535
+ if projected is not None:
536
+ attrs[key] = projected
537
+ return attrs
538
+
539
+
540
+ def _scalar_attr(value: object) -> str | None:
541
+ if value is None:
542
+ return None
543
+ if isinstance(value, bool):
544
+ return _bool_string(value)
545
+ if isinstance(value, Sequence) and not isinstance(value, str):
546
+ return " ".join(str(item) for item in value)
547
+ if isinstance(value, (str, int, float)):
548
+ return str(value)
549
+ return None
550
+
551
+
552
+ def _non_empty_string_attr(value: object) -> str | None:
553
+ if isinstance(value, str) and value:
554
+ return value
555
+ return None
556
+
557
+
558
+ def _non_empty_sequence_attr(value: object) -> str | None:
559
+ if isinstance(value, Sequence) and not isinstance(value, str) and value:
560
+ return " ".join(str(item) for item in value)
561
+ return None
562
+
563
+
564
+ def _retained_detail_attr(key: str, value: object) -> str | None:
565
+ if not isinstance(value, str):
566
+ return None
567
+ normalized = value.strip()
568
+ if not normalized or normalized != value:
569
+ return None
570
+ if key == "sourceCode":
571
+ return normalized if _safe_retained_source_code(normalized) else None
572
+ if key in {"sourceKind", "operation", "reason"}:
573
+ return normalized if _safe_retained_detail_slug(normalized) else None
574
+ if key in {"expectedReleaseVersion", "actualReleaseVersion"}:
575
+ return normalized if _safe_retained_release_version(normalized) else None
576
+ return None
577
+
578
+
579
+ def _safe_retained_source_code(value: str) -> bool:
580
+ if len(value) > 64:
581
+ return False
582
+ if _DEVICE_SERIAL_DETAIL_RE.fullmatch(value):
583
+ return False
584
+ if _FINGERPRINT_DETAIL_RE.fullmatch(value.lower()):
585
+ return False
586
+ return _RETAINED_SOURCE_CODE_RE.fullmatch(value) is not None
587
+
588
+
589
+ def _safe_retained_detail_slug(value: str) -> bool:
590
+ if not _RETAINED_DETAIL_SLUG_RE.fullmatch(value):
591
+ return False
592
+ lower = value.lower()
593
+ if _DEVICE_SERIAL_DETAIL_RE.fullmatch(value):
594
+ return False
595
+ if _FINGERPRINT_DETAIL_RE.fullmatch(lower):
596
+ return False
597
+ if "token" in lower and lower not in _SAFE_TOKEN_DETAIL_VALUES:
598
+ return False
599
+ if any(
600
+ marker in lower
601
+ for marker in (
602
+ "bearer",
603
+ "://",
604
+ "www.",
605
+ ".androidctl",
606
+ "artifact-root",
607
+ "artifact_path",
608
+ "artifact-path",
609
+ "raw-rid",
610
+ "raw_rid",
611
+ "rawrid",
612
+ "snapshot",
613
+ "fingerprint",
614
+ )
615
+ ):
616
+ return False
617
+ return not lower.startswith(("rid-", "rid_", "snapshot-", "snapshot_"))
618
+
619
+
620
+ def _safe_retained_release_version(value: str) -> bool:
621
+ if len(value) > 32:
622
+ return False
623
+ return _RETAINED_RELEASE_VERSION_RE.fullmatch(value) is not None
624
+
625
+
626
+ def _required_ok_attr(value: object) -> str | None:
627
+ return _scalar_attr(value) or "false"
628
+
629
+
630
+ def _required_stringified_attr(value: object) -> str | None:
631
+ return str(value)
632
+
633
+
634
+ def _bool_attr(value: object) -> str | None:
635
+ if isinstance(value, bool):
636
+ return _bool_string(value)
637
+ return None
638
+
639
+
640
+ def _blocking_group_attr(value: object) -> str | None:
641
+ if isinstance(value, str) and value in _BLOCKING_GROUPS:
642
+ return value
643
+ return None
644
+
645
+
646
+ def _group_name(value: object) -> XmlGroupName | None:
647
+ if isinstance(value, str) and value in _GROUP_ORDER:
648
+ return cast(XmlGroupName, value)
649
+ return None
650
+
651
+
652
+ def _omitted_reason(value: object) -> OmittedReason | None:
653
+ if isinstance(value, str) and value in _OMITTED_REASONS:
654
+ return cast(OmittedReason, value)
655
+ return None
656
+
657
+
658
+ def _transient_kind(value: object) -> TransientKind | None:
659
+ if isinstance(value, str) and value in _TRANSIENT_KINDS:
660
+ return cast(TransientKind, value)
661
+ return None
662
+
663
+
664
+ def _count_attr(value: object) -> str | None:
665
+ if isinstance(value, bool):
666
+ return None
667
+ if isinstance(value, int) and value >= 0:
668
+ return str(value)
669
+ return None
670
+
671
+
672
+ _TOP_LEVEL_REQUIRED_ATTR_RULES: tuple[XmlAttrRule, ...] = (
673
+ ("ok", _required_ok_attr),
674
+ ("command", _required_stringified_attr),
675
+ ("category", _required_stringified_attr),
676
+ ("payloadMode", _required_stringified_attr),
677
+ )
678
+ _TOP_LEVEL_OPTIONAL_ATTR_RULES: tuple[XmlAttrRule, ...] = (
679
+ ("sourceScreenId", _non_empty_string_attr),
680
+ ("nextScreenId", _non_empty_string_attr),
681
+ ("code", _non_empty_string_attr),
682
+ )
683
+ _RETAINED_TOP_LEVEL_REQUIRED_ATTR_RULES: tuple[XmlAttrRule, ...] = (
684
+ ("ok", _required_ok_attr),
685
+ ("command", _required_stringified_attr),
686
+ ("envelope", _required_stringified_attr),
687
+ )
688
+ _RETAINED_TOP_LEVEL_OPTIONAL_ATTR_RULES: tuple[XmlAttrRule, ...] = (
689
+ ("code", _non_empty_string_attr),
690
+ )
691
+ _SURFACE_ATTR_RULES: tuple[XmlAttrRule, ...] = (
692
+ ("keyboardVisible", _bool_attr),
693
+ ("blockingGroup", _blocking_group_attr),
694
+ )
695
+
696
+
697
+ def _bool_string(value: bool) -> str:
698
+ return "true" if value else "false"
699
+
700
+
701
+ def _projected_blocking_group(
702
+ surface_attrs: XmlScalarAttrs,
703
+ ) -> BlockingGroupName | None:
704
+ blocking_group = surface_attrs.get("blockingGroup")
705
+ if blocking_group == "dialog":
706
+ return "dialog"
707
+ if blocking_group == "keyboard":
708
+ return "keyboard"
709
+ if blocking_group == "system":
710
+ return "system"
711
+ return None
712
+
713
+
714
+ def _mapping(value: object) -> Mapping[str, ProjectionValue]:
715
+ if not isinstance(value, Mapping):
716
+ return {}
717
+ return {key: item for key, item in value.items() if isinstance(key, str)}
718
+
719
+
720
+ def _group_item_tag(item: Mapping[str, object]) -> XmlGroupItemTag:
721
+ kind = item.get("kind")
722
+ if kind == "container":
723
+ return "container"
724
+ if kind == "text":
725
+ return "text"
726
+ return "node"
727
+
728
+
729
+ def _node_role_tag(role: object) -> str | None:
730
+ if isinstance(role, str) and role in _NODE_ROLE_TAGS:
731
+ return role
732
+ return None