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.
- android_resource_resolver.py +370 -0
- android_xml_support.py +230 -0
- apple_resource_resolver.py +141 -0
- coverage_report.py +134 -0
- fidelity_adapter_api.py +89 -0
- fidelity_adapters.py +508 -0
- fidelity_core.py +241 -0
- fidelity_platform_adapters.py +902 -0
- ir_contracts.py +76 -0
- native_render_android.py +160 -0
- native_render_apple.py +165 -0
- native_render_contracts.py +148 -0
- native_render_registry.py +86 -0
- quality_common.py +217 -0
- render_preview.py +1797 -0
- scan_ui.py +1485 -0
- scoped_context.py +458 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/SKILL.md +52 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/adapter-api.md +20 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/agent-integrations.md +39 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/cache-protocol.md +61 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/design-modes.md +109 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/fidelity.md +53 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ir-schema.md +412 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/native-rendering.md +40 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-profiles.json +84 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-standards.md +131 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platforms.md +40 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/quality-automation.md +77 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/review-workflow.md +135 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-guidance-mode.md +22 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-policy-schema.md +83 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-reviewer.md +175 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/validation.md +34 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/workbench-ui.md +108 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/native-render-state.schema.json +71 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-agent-job.schema.json +36 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-graph.schema.json +46 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.patch.schema.json +36 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.schema.json +296 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/uidw-config.schema.json +68 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_resource_resolver.py +370 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_xml_support.py +230 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/apple_resource_resolver.py +141 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/coverage_report.py +134 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapter_api.py +89 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapters.py +508 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_core.py +241 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_platform_adapters.py +902 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/generate_interaction_matrix.py +166 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/ir_contracts.py +76 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/merge_review_state.py +267 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_android.py +160 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_apple.py +165 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_contracts.py +148 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_registry.py +86 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/quality_common.py +217 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/render_preview.py +1797 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scan_ui.py +1485 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scoped_context.py +458 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/smoke_preview.js +978 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw.py +2821 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw_mcp.py +282 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/validate_platform_profiles.py +93 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/visual_regression.py +141 -0
- ui_design_workbench_cli-0.5.0.dist-info/METADATA +133 -0
- ui_design_workbench_cli-0.5.0.dist-info/RECORD +73 -0
- ui_design_workbench_cli-0.5.0.dist-info/WHEEL +5 -0
- ui_design_workbench_cli-0.5.0.dist-info/entry_points.txt +3 -0
- ui_design_workbench_cli-0.5.0.dist-info/top_level.txt +20 -0
- uidw.py +2821 -0
- uidw_mcp.py +282 -0
- 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
|