ui-design-workbench-cli 0.5.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 (73) hide show
  1. android_resource_resolver.py +370 -0
  2. android_xml_support.py +230 -0
  3. apple_resource_resolver.py +141 -0
  4. coverage_report.py +134 -0
  5. fidelity_adapter_api.py +89 -0
  6. fidelity_adapters.py +508 -0
  7. fidelity_core.py +241 -0
  8. fidelity_platform_adapters.py +902 -0
  9. ir_contracts.py +76 -0
  10. native_render_android.py +160 -0
  11. native_render_apple.py +165 -0
  12. native_render_contracts.py +148 -0
  13. native_render_registry.py +86 -0
  14. quality_common.py +217 -0
  15. render_preview.py +1797 -0
  16. scan_ui.py +1485 -0
  17. scoped_context.py +458 -0
  18. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/SKILL.md +52 -0
  19. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/adapter-api.md +20 -0
  20. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/agent-integrations.md +39 -0
  21. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/cache-protocol.md +61 -0
  22. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/design-modes.md +109 -0
  23. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/fidelity.md +53 -0
  24. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ir-schema.md +412 -0
  25. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/native-rendering.md +40 -0
  26. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-profiles.json +84 -0
  27. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-standards.md +131 -0
  28. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platforms.md +40 -0
  29. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/quality-automation.md +77 -0
  30. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/review-workflow.md +135 -0
  31. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-guidance-mode.md +22 -0
  32. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-policy-schema.md +83 -0
  33. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-reviewer.md +175 -0
  34. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/validation.md +34 -0
  35. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/workbench-ui.md +108 -0
  36. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/native-render-state.schema.json +71 -0
  37. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-agent-job.schema.json +36 -0
  38. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-graph.schema.json +46 -0
  39. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.patch.schema.json +36 -0
  40. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.schema.json +296 -0
  41. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/uidw-config.schema.json +68 -0
  42. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_resource_resolver.py +370 -0
  43. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_xml_support.py +230 -0
  44. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/apple_resource_resolver.py +141 -0
  45. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/coverage_report.py +134 -0
  46. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapter_api.py +89 -0
  47. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapters.py +508 -0
  48. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_core.py +241 -0
  49. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_platform_adapters.py +902 -0
  50. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/generate_interaction_matrix.py +166 -0
  51. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/ir_contracts.py +76 -0
  52. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/merge_review_state.py +267 -0
  53. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_android.py +160 -0
  54. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_apple.py +165 -0
  55. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_contracts.py +148 -0
  56. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_registry.py +86 -0
  57. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/quality_common.py +217 -0
  58. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/render_preview.py +1797 -0
  59. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scan_ui.py +1485 -0
  60. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scoped_context.py +458 -0
  61. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/smoke_preview.js +978 -0
  62. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw.py +2821 -0
  63. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw_mcp.py +282 -0
  64. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/validate_platform_profiles.py +93 -0
  65. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/visual_regression.py +141 -0
  66. ui_design_workbench_cli-0.5.0.dist-info/METADATA +133 -0
  67. ui_design_workbench_cli-0.5.0.dist-info/RECORD +73 -0
  68. ui_design_workbench_cli-0.5.0.dist-info/WHEEL +5 -0
  69. ui_design_workbench_cli-0.5.0.dist-info/entry_points.txt +3 -0
  70. ui_design_workbench_cli-0.5.0.dist-info/top_level.txt +20 -0
  71. uidw.py +2821 -0
  72. uidw_mcp.py +282 -0
  73. validate_platform_profiles.py +93 -0
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic Android resource resolution for structural HTML projection.
3
+
4
+ The resolver reads source resources only. It never invokes aapt, Gradle, or
5
+ Layoutlib and therefore keeps unresolved qualifiers/runtime attributes explicit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import os
12
+ import re
13
+ import xml.etree.ElementTree as ET
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ IGNORED_DIRS = {".git", ".gradle", ".idea", ".ui-design-workbench", "build", "dist", "node_modules", "out", "vendor"}
20
+ FILE_LIMIT = 30000
21
+ TEXT_LIMIT = 2 * 1024 * 1024
22
+
23
+ ANDROID_SCREEN_LAYOUT_PREFIXES = (
24
+ "activity_", "fragment_", "dialog_", "scene_", "screen_", "page_",
25
+ "bottom_sheet_", "sheet_",
26
+ )
27
+ ANDROID_COMPONENT_LAYOUT_PREFIXES = (
28
+ "cell_", "item_", "row_", "list_item_", "grid_item_", "include_",
29
+ "partial_", "content_", "merge_", "component_", "widget_",
30
+ )
31
+
32
+
33
+ def _local(value: str) -> str:
34
+ return value.rsplit("}", 1)[-1].rsplit(":", 1)[-1]
35
+
36
+
37
+ def _attrs(element: ET.Element) -> dict[str, str]:
38
+ return {_local(key): str(value) for key, value in element.attrib.items()}
39
+
40
+
41
+ def _line(text: str, expression: str) -> int:
42
+ offset = text.find(expression)
43
+ return text.count("\n", 0, max(0, offset)) + 1 if offset >= 0 else 1
44
+
45
+
46
+ def _normalize_dimension(value: str) -> str:
47
+ match = re.fullmatch(r"(-?\d+(?:\.\d+)?)(dp|dip|sp|pt|px)", value.strip())
48
+ return f"{match.group(1)}px" if match else value.strip()
49
+
50
+
51
+ def _color_literal(value: str) -> str:
52
+ raw = value.strip()
53
+ argb = re.fullmatch(r"#([0-9a-fA-F]{8})", raw)
54
+ if argb:
55
+ alpha, red, green, blue = (argb.group(1)[index:index + 2] for index in (0, 2, 4, 6))
56
+ if alpha.lower() == "ff":
57
+ return f"#{red}{green}{blue}"
58
+ return f"#{red}{green}{blue}{alpha}"
59
+ return raw
60
+
61
+
62
+ def android_layout_role(path: Path, text: str) -> str:
63
+ """Conservatively distinguish Android screens from reusable layouts.
64
+
65
+ Unprefixed layouts remain screens for backwards compatibility. Explicit
66
+ component/partial prefixes and ``<merge>`` roots are never promoted into
67
+ the screen tree merely because they live under ``res/layout``.
68
+ """
69
+ normalized = path.as_posix().lower()
70
+ if path.suffix.lower() != ".xml" or "/res/layout" not in f"/{normalized}":
71
+ return "unknown"
72
+ stem = path.stem.lower()
73
+ if stem.startswith(ANDROID_COMPONENT_LAYOUT_PREFIXES):
74
+ return "component"
75
+ if stem.startswith(ANDROID_SCREEN_LAYOUT_PREFIXES):
76
+ return "screen"
77
+ try:
78
+ root = ET.fromstring(text)
79
+ except ET.ParseError:
80
+ return "screen"
81
+ if _local(root.tag) == "merge":
82
+ return "component"
83
+ return "screen"
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class ResolvedAndroidValue:
88
+ value: Any
89
+ source: str
90
+ line: int
91
+ kind: str
92
+ confidence: str = "exact"
93
+ resolved: bool = True
94
+
95
+
96
+ @dataclass
97
+ class AndroidDrawable:
98
+ asset: str | None = None
99
+ style: dict[str, Any] = field(default_factory=dict)
100
+ source: str = ""
101
+ line: int = 1
102
+ kind: str = "unresolved"
103
+ confidence: str = "exact"
104
+
105
+
106
+ @dataclass
107
+ class _ResourceEntry:
108
+ value: str
109
+ source: str
110
+ line: int
111
+ qualifier: str
112
+ priority: int
113
+
114
+
115
+ @dataclass
116
+ class _StyleEntry:
117
+ name: str
118
+ parent: str
119
+ items: dict[str, str]
120
+ source: str
121
+ line: int
122
+ priority: int
123
+
124
+
125
+ class AndroidResourceCatalog:
126
+ def __init__(self, root: Path):
127
+ self.root = root.resolve()
128
+ self.values: dict[tuple[str, str], _ResourceEntry] = {}
129
+ self.styles: dict[str, _StyleEntry] = {}
130
+ self.drawables: dict[tuple[str, str], Path] = {}
131
+ self.layouts: dict[str, Path] = {}
132
+ self.theme_items: dict[str, str] = {}
133
+ self.diagnostics: list[dict[str, Any]] = []
134
+
135
+ @classmethod
136
+ def discover(cls, root: Path) -> "AndroidResourceCatalog":
137
+ catalog = cls(root)
138
+ seen = 0
139
+ for directory, names, files in os.walk(catalog.root):
140
+ names[:] = [name for name in names if name not in IGNORED_DIRS and not name.startswith(".")]
141
+ folder = Path(directory)
142
+ resource_dir = folder.name.lower()
143
+ if not (resource_dir.startswith("values") or resource_dir.startswith("drawable") or resource_dir.startswith("mipmap") or resource_dir.startswith("layout")):
144
+ continue
145
+ qualifier = resource_dir.split("-", 1)[1] if "-" in resource_dir else ""
146
+ priority = 0 if not qualifier else 10
147
+ for name in files:
148
+ seen += 1
149
+ if seen > FILE_LIMIT:
150
+ catalog.diagnostics.append({"reason": "android-resource-file-limit", "limit": FILE_LIMIT})
151
+ break
152
+ path = folder / name
153
+ if resource_dir.startswith("values") and path.suffix.lower() == ".xml":
154
+ catalog._read_values(path, qualifier, priority)
155
+ elif resource_dir.startswith("layout") and path.suffix.lower() == ".xml":
156
+ catalog._prefer_path(catalog.layouts, path.stem, path, priority)
157
+ elif resource_dir.startswith(("drawable", "mipmap")):
158
+ kind = "mipmap" if resource_dir.startswith("mipmap") else "drawable"
159
+ catalog._prefer_path(catalog.drawables, (kind, path.stem), path, priority)
160
+ catalog._select_theme_items()
161
+ return catalog
162
+
163
+ def _relative(self, path: Path) -> str:
164
+ try:
165
+ return path.resolve().relative_to(self.root).as_posix()
166
+ except ValueError:
167
+ return path.name
168
+
169
+ @staticmethod
170
+ def _prefer_path(bucket: dict[Any, Path], key: Any, path: Path, priority: int) -> None:
171
+ current = bucket.get(key)
172
+ if current is None or (priority == 0 and "-" in current.parent.name):
173
+ bucket[key] = path
174
+
175
+ def _read_text(self, path: Path) -> str:
176
+ try:
177
+ if path.stat().st_size > TEXT_LIMIT:
178
+ return ""
179
+ return path.read_text(encoding="utf-8", errors="replace")
180
+ except OSError:
181
+ return ""
182
+
183
+ def _read_values(self, path: Path, qualifier: str, priority: int) -> None:
184
+ text = self._read_text(path)
185
+ if not text:
186
+ return
187
+ try:
188
+ root = ET.fromstring(text)
189
+ except ET.ParseError as exc:
190
+ self.diagnostics.append({"reason": "invalid-android-resource-xml", "file": self._relative(path), "line": getattr(exc, "position", (1,))[0]})
191
+ return
192
+ if _local(root.tag) != "resources":
193
+ return
194
+ source = self._relative(path)
195
+ for child in root:
196
+ tag, attrs = _local(child.tag), _attrs(child)
197
+ name = attrs.get("name")
198
+ if not name:
199
+ continue
200
+ if tag == "style":
201
+ items = {
202
+ _attrs(item).get("name", ""): (item.text or "").strip()
203
+ for item in child if _local(item.tag) == "item" and _attrs(item).get("name")
204
+ }
205
+ current = self.styles.get(name)
206
+ if current is None or priority < current.priority:
207
+ self.styles[name] = _StyleEntry(name, attrs.get("parent", ""), items, source, _line(text, name), priority)
208
+ continue
209
+ kind = attrs.get("type") if tag == "item" else tag
210
+ value = "".join(child.itertext()).strip()
211
+ if not kind or not value:
212
+ continue
213
+ key = (kind, name)
214
+ current = self.values.get(key)
215
+ if current is None or priority < current.priority:
216
+ self.values[key] = _ResourceEntry(value, source, _line(text, name), qualifier, priority)
217
+
218
+ def _style_items(self, name: str, seen: set[str] | None = None) -> dict[str, str]:
219
+ clean = name.removeprefix("@style/")
220
+ if not clean:
221
+ return {}
222
+ seen = seen or set()
223
+ if clean in seen:
224
+ return {}
225
+ seen.add(clean)
226
+ entry = self.styles.get(clean)
227
+ if not entry:
228
+ return {}
229
+ parent = entry.parent.removeprefix("@style/")
230
+ if not parent and "." in clean:
231
+ parent = clean.rsplit(".", 1)[0]
232
+ merged = self._style_items(parent, seen) if parent else {}
233
+ merged.update(entry.items)
234
+ return merged
235
+
236
+ def style(self, reference: str | None) -> tuple[dict[str, str], _StyleEntry | None]:
237
+ name = str(reference or "").removeprefix("@style/")
238
+ return self._style_items(name), self.styles.get(name)
239
+
240
+ def _select_theme_items(self) -> None:
241
+ candidates = sorted((entry for name, entry in self.styles.items() if "theme" in name.lower()), key=lambda item: (item.priority, item.name))
242
+ if candidates:
243
+ self.theme_items = self._style_items(candidates[0].name)
244
+
245
+ def resolve(self, raw: Any, preferred_kind: str = "", style_items: dict[str, str] | None = None, _seen: set[tuple[str, str]] | None = None) -> ResolvedAndroidValue:
246
+ value = str(raw or "").strip()
247
+ if not value:
248
+ return ResolvedAndroidValue(value, "", 1, preferred_kind or "literal", resolved=False)
249
+ attr = re.fullmatch(r"\?(?:android:)?attr/([\w.-]+)", value)
250
+ if attr:
251
+ name = attr.group(1)
252
+ candidate = (style_items or {}).get(name) or self.theme_items.get(name)
253
+ if candidate:
254
+ resolved = self.resolve(candidate, preferred_kind, style_items, _seen)
255
+ return ResolvedAndroidValue(resolved.value, resolved.source, resolved.line, resolved.kind, "approximate", resolved.resolved)
256
+ return ResolvedAndroidValue(value, "", 1, "attr", "approximate", False)
257
+ match = re.fullmatch(r"@(?:(android):)?([\w.-]+)/([\w.-]+)", value)
258
+ if not match:
259
+ normalized = _normalize_dimension(_color_literal(value))
260
+ return ResolvedAndroidValue(normalized, "", 1, preferred_kind or "literal")
261
+ system, kind, name = match.groups()
262
+ if system:
263
+ system_values = {("color", "transparent"): "transparent", ("color", "black"): "#000000", ("color", "white"): "#ffffff"}
264
+ if (kind, name) in system_values:
265
+ return ResolvedAndroidValue(system_values[(kind, name)], "android", 1, kind, "high")
266
+ return ResolvedAndroidValue(value, "android", 1, kind, "approximate", False)
267
+ key = (kind, name)
268
+ seen = _seen or set()
269
+ if key in seen:
270
+ return ResolvedAndroidValue(value, "", 1, kind, "approximate", False)
271
+ seen.add(key)
272
+ entry = self.values.get(key)
273
+ if not entry:
274
+ return ResolvedAndroidValue(value, "", 1, kind, "approximate", False)
275
+ resolved = self.resolve(entry.value, kind, style_items, seen)
276
+ return ResolvedAndroidValue(resolved.value, entry.source, entry.line, kind, resolved.confidence, resolved.resolved)
277
+
278
+ def layout(self, reference: str) -> Path | None:
279
+ match = re.fullmatch(r"@layout/([\w.-]+)", str(reference or "").strip())
280
+ return self.layouts.get(match.group(1)) if match else None
281
+
282
+ def drawable(self, reference: str, style_items: dict[str, str] | None = None) -> AndroidDrawable:
283
+ match = re.fullmatch(r"@(?:(drawable|mipmap))/([\w.-]+)", str(reference or "").strip())
284
+ if not match:
285
+ return AndroidDrawable(kind="unresolved", confidence="approximate")
286
+ kind, name = match.groups()
287
+ path = self.drawables.get((kind, name)) or self.drawables.get(("drawable", name))
288
+ if not path:
289
+ return AndroidDrawable(source="", kind="unresolved", confidence="approximate")
290
+ source = self._relative(path)
291
+ if path.suffix.lower() != ".xml":
292
+ return AndroidDrawable(asset=source, source=source, kind="bitmap")
293
+ text = self._read_text(path)
294
+ try:
295
+ root = ET.fromstring(text)
296
+ except ET.ParseError:
297
+ return AndroidDrawable(source=source, kind="unresolved", confidence="approximate")
298
+ tag = _local(root.tag)
299
+ if tag == "vector":
300
+ asset = self._vector_data_uri(root, style_items)
301
+ return AndroidDrawable(asset=asset, source=source, line=1, kind="vector", confidence="high" if asset else "approximate")
302
+ if tag == "shape":
303
+ return AndroidDrawable(style=self._shape_style(root, style_items), source=source, line=1, kind="shape", confidence="high")
304
+ return AndroidDrawable(source=source, kind=tag, confidence="approximate")
305
+
306
+ def _vector_data_uri(self, root: ET.Element, style_items: dict[str, str] | None) -> str | None:
307
+ attrs = _attrs(root)
308
+ width = _normalize_dimension(attrs.get("width", "24dp"))
309
+ height = _normalize_dimension(attrs.get("height", "24dp"))
310
+ viewport_width = attrs.get("viewportWidth", "24")
311
+ viewport_height = attrs.get("viewportHeight", "24")
312
+ paths: list[str] = []
313
+ for child in root:
314
+ if _local(child.tag) != "path":
315
+ return None
316
+ item = _attrs(child)
317
+ data = item.get("pathData")
318
+ if not data:
319
+ continue
320
+ fill = self.resolve(item.get("fillColor", "#000000"), "color", style_items)
321
+ stroke = self.resolve(item.get("strokeColor", ""), "color", style_items) if item.get("strokeColor") else None
322
+ attributes = [f'd="{data}"', f'fill="{fill.value if fill.resolved else "currentColor"}"']
323
+ if item.get("fillAlpha"): attributes.append(f'fill-opacity="{item["fillAlpha"]}"')
324
+ if stroke and stroke.resolved: attributes.append(f'stroke="{stroke.value}"')
325
+ if item.get("strokeWidth"): attributes.append(f'stroke-width="{item["strokeWidth"]}"')
326
+ paths.append(f"<path {' '.join(attributes)}/>")
327
+ if not paths:
328
+ return None
329
+ svg = f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {viewport_width} {viewport_height}">{"".join(paths)}</svg>'
330
+ return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
331
+
332
+ def _shape_style(self, root: ET.Element, style_items: dict[str, str] | None) -> dict[str, Any]:
333
+ style: dict[str, Any] = {}
334
+ for child in root:
335
+ tag, attrs = _local(child.tag), _attrs(child)
336
+ if tag == "solid" and attrs.get("color"):
337
+ resolved = self.resolve(attrs["color"], "color", style_items)
338
+ if resolved.resolved: style["backgroundColor"] = resolved.value
339
+ elif tag == "corners":
340
+ radius = attrs.get("radius")
341
+ if radius: style["radius"] = _normalize_dimension(radius)
342
+ elif tag == "stroke":
343
+ if attrs.get("width"): style["borderWidth"] = _normalize_dimension(attrs["width"])
344
+ if attrs.get("color"):
345
+ resolved = self.resolve(attrs["color"], "color", style_items)
346
+ if resolved.resolved: style["borderColor"] = resolved.value
347
+ style["borderStyle"] = "solid"
348
+ return style
349
+
350
+
351
+ MATERIAL_ICON_PATHS = {
352
+ "add": "M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z",
353
+ "check": "M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z",
354
+ "close": "M18.3 5.71 12 12l6.3 6.29-1.41 1.42L10.59 13.41 4.29 19.71 2.88 18.3 9.17 12 2.88 5.7 4.29 4.29 10.59 10.59 16.89 4.29z",
355
+ "delete": "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM8 9h8v10H8V9zm7.5-5-1-1h-5l-1 1H5v2h14V4z",
356
+ "edit": "M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.995 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z",
357
+ "home": "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z",
358
+ "menu": "M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z",
359
+ "search": "M9.5 3a6.5 6.5 0 1 0 3.98 11.64L19.85 21 21 19.85l-6.36-6.37A6.5 6.5 0 0 0 9.5 3zm0 2a4.5 4.5 0 1 1 0 9 4.5 4.5 0 0 1 0-9z",
360
+ "settings": "M19.43 12.98c.04-.32.07-.65.07-.98s-.03-.66-.08-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.37-.31-.6-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98L14.5 2.42A.488.488 0 0 0 14 2h-4c-.25 0-.46.18-.49.42L9.13 5.07c-.61.25-1.18.59-1.69.98l-2.49-1a.49.49 0 0 0-.6.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.05.32-.08.66-.08.98s.03.66.08.98l-2.11 1.65a.49.49 0 0 0-.12.64l2 3.46c.12.22.37.31.6.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.18-.58 1.69-.98l2.49 1c.23.08.48 0 .6-.22l2-3.46a.49.49 0 0 0-.12-.64l-2.11-1.65zM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5z",
361
+ }
362
+
363
+
364
+ def material_icon_asset(name: str) -> str | None:
365
+ key = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name).replace("_", " ").strip().lower().replace(" ", "")
366
+ path = MATERIAL_ICON_PATHS.get(key)
367
+ if not path:
368
+ return None
369
+ svg = f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="{path}"/></svg>'
370
+ return "data:image/svg+xml;base64," + base64.b64encode(svg.encode("utf-8")).decode("ascii")
android_xml_support.py ADDED
@@ -0,0 +1,230 @@
1
+ """Conservative Android XML discovery helpers shared by the scanner and adapter.
2
+
3
+ The helpers deliberately stop at source evidence. They do not execute Android
4
+ resource resolution, Data Binding expressions, custom views, or Layoutlib.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import xml.etree.ElementTree as ET
11
+ from pathlib import Path
12
+ from typing import Any, Iterable
13
+
14
+
15
+ SCREEN_PREFIXES = (
16
+ "activity_", "fragment_", "dialog_", "scene_", "screen_", "page_",
17
+ "bottom_sheet_", "sheet_",
18
+ )
19
+ COLLECTION_PREFIXES = ("cell_", "item_", "row_", "list_item_", "grid_item_")
20
+ PARTIAL_PREFIXES = ("include_", "partial_", "content_", "merge_")
21
+
22
+
23
+ def _local(value: str) -> str:
24
+ return value.rsplit("}", 1)[-1].rsplit(":", 1)[-1]
25
+
26
+
27
+ def _attrs(element: ET.Element) -> dict[str, str]:
28
+ return {_local(key): str(value) for key, value in element.attrib.items()}
29
+
30
+
31
+ def _line(text: str, expression: str, start: int = 0) -> int:
32
+ offset = text.find(expression, start)
33
+ return text.count("\n", 0, max(0, offset)) + 1 if offset >= 0 else 1
34
+
35
+
36
+ def _pascal(value: str) -> str:
37
+ return "".join(part[:1].upper() + part[1:] for part in re.split(r"[^A-Za-z0-9]+", value) if part)
38
+
39
+
40
+ def _snake(value: str) -> str:
41
+ separated = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", value)
42
+ return re.sub(r"[^a-z0-9]+", "_", separated.lower()).strip("_")
43
+
44
+
45
+ def canonical_android_id(value: str | None) -> str:
46
+ raw = str(value or "").strip()
47
+ match = re.fullmatch(r"@\+?id/([\w.:-]+)", raw)
48
+ return f"@id/{match.group(1)}" if match else raw
49
+
50
+
51
+ def android_layout_metadata(path: Path) -> dict[str, str] | None:
52
+ """Classify one res/layout* file without treating every partial as a screen."""
53
+ parts = [part.lower() for part in path.parts]
54
+ layout_dir = next((part for part in reversed(parts[:-1]) if part == "layout" or part.startswith("layout-")), "")
55
+ if path.suffix.lower() != ".xml" or not layout_dir:
56
+ return None
57
+ stem = path.stem.lower()
58
+ qualifier = layout_dir.removeprefix("layout").lstrip("-")
59
+ if stem.startswith(SCREEN_PREFIXES):
60
+ role = "screen"
61
+ elif stem.startswith(COLLECTION_PREFIXES):
62
+ role = "collection-item"
63
+ elif stem.startswith(PARTIAL_PREFIXES) or stem == "include":
64
+ role = "partial"
65
+ else:
66
+ role = "component"
67
+ label = _pascal(stem)
68
+ if qualifier:
69
+ label = f"{label} [{qualifier}]"
70
+ return {
71
+ "role": role,
72
+ "resource": stem,
73
+ "qualifier": qualifier,
74
+ "name": label,
75
+ "sourceKind": "android-layout-variant" if qualifier else "android-layout",
76
+ }
77
+
78
+
79
+ def android_layout_refs(text: str) -> list[str]:
80
+ """Extract layout resources referenced directly or through generated bindings."""
81
+ refs = {match.group(1) for match in re.finditer(r"\bR\.layout\.([A-Za-z0-9_]+)", text)}
82
+ if refs:
83
+ return sorted(refs)
84
+ binding_patterns = (
85
+ r"\bBase[A-Za-z0-9_]*\s*<\s*([A-Z][A-Za-z0-9]+Binding)",
86
+ r"\b([A-Z][A-Za-z0-9]+Binding)\.inflate\s*\(",
87
+ )
88
+ for pattern in binding_patterns:
89
+ for match in re.finditer(pattern, text):
90
+ candidate = _snake(match.group(1).removesuffix("Binding"))
91
+ if candidate:
92
+ refs.add(candidate)
93
+ if refs:
94
+ return sorted(refs)
95
+ for match in re.finditer(r"\b([A-Z][A-Za-z0-9]+)Binding\b", text):
96
+ candidate = _snake(match.group(1))
97
+ if candidate:
98
+ refs.add(candidate)
99
+ return sorted(refs)
100
+
101
+
102
+ def parse_android_navigation(text: str, source: str) -> dict[str, list[dict[str, Any]]]:
103
+ """Read destinations, actions, and deep links from an Android nav graph."""
104
+ result: dict[str, list[dict[str, Any]]] = {"screens": [], "routes": [], "navigationTargets": []}
105
+ try:
106
+ root = ET.fromstring(text)
107
+ except ET.ParseError:
108
+ return result
109
+ if _local(root.tag) != "navigation":
110
+ return result
111
+
112
+ def walk(container: ET.Element, groups: list[str]) -> None:
113
+ container_attrs = _attrs(container)
114
+ graph_label = container_attrs.get("label") or canonical_android_id(container_attrs.get("id")).removeprefix("@id/")
115
+ nested_groups = [*groups, graph_label] if graph_label else groups
116
+ for child in container:
117
+ tag = _local(child.tag)
118
+ attrs = _attrs(child)
119
+ destination_id = canonical_android_id(attrs.get("id"))
120
+ if tag == "navigation":
121
+ walk(child, nested_groups)
122
+ continue
123
+ if tag not in {"fragment", "activity", "dialog"}:
124
+ continue
125
+ class_name = attrs.get("name", "")
126
+ short_name = class_name.rsplit(".", 1)[-1] if class_name else destination_id.removeprefix("@id/")
127
+ label = attrs.get("label") or short_name or destination_id
128
+ line = _line(text, attrs.get("id") or f"<{tag}")
129
+ if destination_id:
130
+ result["screens"].append({
131
+ "name": short_name or _pascal(destination_id.removeprefix("@id/")),
132
+ "label": label,
133
+ "file": source,
134
+ "line": line,
135
+ "platform": "android-views",
136
+ "confidence": "high",
137
+ "fragment": destination_id,
138
+ "route": destination_id,
139
+ "androidClass": class_name,
140
+ "groupPath": [value for value in nested_groups if value],
141
+ "sourceKind": "android-navigation-destination",
142
+ })
143
+ result["routes"].append({"route": destination_id, "file": source, "line": line})
144
+ for nested in child:
145
+ nested_tag = _local(nested.tag)
146
+ nested_attrs = _attrs(nested)
147
+ if nested_tag == "action":
148
+ target = canonical_android_id(nested_attrs.get("destination"))
149
+ if target:
150
+ result["navigationTargets"].append({
151
+ "source": destination_id,
152
+ "target": target,
153
+ "actionId": canonical_android_id(nested_attrs.get("id")),
154
+ "label": canonical_android_id(nested_attrs.get("id")) or target,
155
+ "file": source,
156
+ "line": _line(text, nested_attrs.get("id") or target),
157
+ "sourceKind": "android-navigation-action",
158
+ })
159
+ elif nested_tag == "deepLink" and nested_attrs.get("uri"):
160
+ result["routes"].append({
161
+ "route": nested_attrs["uri"],
162
+ "file": source,
163
+ "line": _line(text, nested_attrs["uri"]),
164
+ "screenTarget": destination_id,
165
+ })
166
+
167
+ walk(root, [])
168
+ return result
169
+
170
+
171
+ def reconcile_android_screen_candidates(candidates: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
172
+ """Merge nav destinations, Android classes, and referenced layout resources."""
173
+ items = [dict(item) for item in candidates]
174
+ class_items = {
175
+ str(item.get("androidClass") or item.get("name") or "").rsplit(".", 1)[-1]: item
176
+ for item in items
177
+ if item.get("sourceKind") in {"android-class", "android-class-template"}
178
+ }
179
+
180
+ def inherited_layouts(item: dict[str, Any]) -> list[str]:
181
+ current = item
182
+ seen: set[str] = set()
183
+ while current:
184
+ refs = [str(value) for value in current.get("layoutRefs", []) if value]
185
+ if refs:
186
+ return refs
187
+ super_name = str(current.get("androidSuperClass") or "")
188
+ if not super_name or super_name in seen:
189
+ break
190
+ seen.add(super_name)
191
+ current = class_items.get(super_name, {})
192
+ return []
193
+ claimed_layouts: set[str] = set()
194
+ claimed_classes: set[int] = set()
195
+ for item in items:
196
+ if item.get("sourceKind") != "android-navigation-destination":
197
+ continue
198
+ class_name = str(item.get("androidClass") or item.get("name") or "").rsplit(".", 1)[-1]
199
+ class_item = class_items.get(class_name)
200
+ if not class_item:
201
+ continue
202
+ refs = inherited_layouts(class_item)
203
+ if refs:
204
+ item["layoutRefs"] = refs
205
+ claimed_layouts.update(refs)
206
+ item["implementationSource"] = class_item.get("file", "")
207
+ claimed_classes.add(id(class_item))
208
+
209
+ for item in items:
210
+ if item.get("sourceKind") not in {"android-class", "android-class-template"} or id(item) in claimed_classes:
211
+ continue
212
+ if item.get("sourceKind") == "android-class-template":
213
+ claimed_classes.add(id(item))
214
+ continue
215
+ refs = inherited_layouts(item)
216
+ if refs:
217
+ item["layoutRefs"] = refs
218
+ if refs:
219
+ claimed_layouts.update(refs)
220
+ else:
221
+ claimed_classes.add(id(item))
222
+
223
+ reconciled: list[dict[str, Any]] = []
224
+ for item in items:
225
+ if id(item) in claimed_classes:
226
+ continue
227
+ if item.get("sourceKind", "").startswith("android-layout") and item.get("layoutResource") in claimed_layouts:
228
+ continue
229
+ reconciled.append(item)
230
+ return reconciled