"""Rule 1: the launcher name every Flutter app shows on the phone. kuhy's rules (2026-10-23): a name starts with a capital, holds only ASCII letters (no spaces, underscores or punctuation) and has at most 10 characters, so the Pixel launcher never truncates it with an ellipsis. Its width in the launcher font is capped too (`label_widths.py`): FocusOwner, 10 characters, was still cut. The same rules live in `~/src/testsAndMisc/python_pkg/app_icons/labels.py` (`label_problems`); the messages are mirrored here, not imported, because utils must not depend on another repo. A desktop entry's `Name=` (rule 2) may hold single spaces between words (`words=True`); the phone launcher may not. The name is `` of the main manifest, plus the `android:label` of any activity (or alias) whose intent-filter holds MAIN + LAUNCHER, because that one overrides the application label on the launcher. Each is resolved from a literal, `@string/` (main res/values/strings.xml) or `${placeholder}`: every `manifestPlaceholders` assignment in android/app/build.gradle[.kts] outside a variant that sets applicationIdSuffix (rule 3 hides those from the launcher, so they may say "SANDBOX"). Anything that cannot be resolved statically fails: never a silent pass. """ from __future__ import annotations import re from pathlib import Path from repo_contract import gradle_blocks from repo_contract.android_manifest import ( ANDROID, MAIN, ManifestError, gradle_script, main_launchers, parse, ) from repo_contract.label_widths import MAX_WIDTH, label_width MAX_LENGTH = 10 STRINGS = Path("android/app/src/main/res/values/strings.xml") _ASCII_LETTERS = re.compile(r"[A-Za-z]+") _ASCII_WORDS = re.compile(r"[A-Za-z]+( [A-Za-z]+)*") _STRING_REF = re.compile(r"@string/(\w+)") _PLACEHOLDER = re.compile(r"\$\{(\w+)\}") _UNRESOLVED = "cannot resolve the launcher name" def label_problems(name: str, *, words: bool = False) -> list[str]: """Every launcher-name rule `name` breaks (app_icons labels.py wording). `words=True` is for a desktop entry's `Name=` (rule 2), shown in a menu or dmenu where "Sick days" reads better than "Sickdays": one space between words is fine. The phone launcher (rule 1) never passes it. """ if not name: return ["label is empty"] problems: list[str] = [] if words: valid = _ASCII_WORDS.fullmatch(name) is not None message = ( f"{name!r} must be ASCII letters, one space between words (no _ or symbols)" ) else: valid = _ASCII_LETTERS.fullmatch(name) is not None message = f"{name!r} must contain only ASCII letters (no spaces, _ or symbols)" if not valid: problems.append(message) if not name[0].isupper(): problems.append(f"{name!r} must start with a capital letter") # The width table only knows ASCII letters; other names already failed. letters = name.replace(" ", "") if valid and label_width(letters) > MAX_WIDTH: problems.append( f"{name!r} is {label_width(letters)} wide in the launcher font; " f"at most {MAX_WIDTH} fit" ) if len(name) > MAX_LENGTH: problems.append( f"{name!r} has {len(name)} characters; at most {MAX_LENGTH} fit" ) return problems def _string(app: Path, attr: str, name: str) -> list[str]: try: root = parse(app, STRINGS) except ManifestError: return [f"{attr}: {STRINGS} is missing or unreadable; {_UNRESOLVED}"] for node in root.iter("string"): if node.get("name") == name: value = "".join(node.itertext()) return [f"{attr} = {p}" for p in label_problems(value)] return [f'{attr}: no in {STRINGS}; {_UNRESOLVED}'] def _placeholder(app: Path, attr: str, key: str) -> list[str]: rel, gradle = gradle_script(app) if rel is None: return [f"{attr}: no android/app/build.gradle(.kts); {_UNRESOLVED}"] values = gradle_blocks.placeholder_values(gradle, key, unsuffixed_only=True) if not values: scope = ( " for an unsuffixed variant" if gradle_blocks.placeholder_values(gradle, key) else "" ) return [ ( f'{attr}: no manifestPlaceholders["{key}"] assignment{scope}' f" in {rel}; {_UNRESOLVED}" ) ] out = [] for where, value in values: if value is None: out.append( f"{attr}: the value set in {where} is not a string literal;" f" {_UNRESOLVED}" ) else: out += [f"{attr} in {where} = {p}" for p in label_problems(value)] return out def _resolve(app: Path, attr: str, label: str) -> list[str]: """Problems with one android:label value; `attr` prefixes each message.""" if string := _STRING_REF.fullmatch(label): return _string(app, f"{attr} {label}", string.group(1)) if placeholder := _PLACEHOLDER.fullmatch(label): return _placeholder(app, f"{attr} {label}", placeholder.group(1)) if label.startswith("@") or "${" in label: return [ ( f"{attr} {label!r} cannot be resolved statically:" " use a literal, @string/ or ${}" ) ] return [f"{attr} {p}" for p in label_problems(label)] def problems(app: Path) -> list[str]: """Rule 1 for one Flutter app directory.""" try: root = parse(app, MAIN) except ManifestError as exc: return [f"{exc}; {_UNRESOLVED}"] application = root.find("application") label = None if application is None else application.get(f"{ANDROID}label") if label is None: return [f"{MAIN}: has no android:label; {_UNRESOLVED}"] out = _resolve(app, "android:label", label) for activity in main_launchers(root): own = activity.get(f"{ANDROID}label") if own is not None: name = activity.get(f"{ANDROID}name", "?") out += _resolve(app, f" android:label", own) return out