mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-21 11:15:18 -04:00
155 lines
6.2 KiB
Python
Executable File
155 lines
6.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Validate ``fastlane/metadata/android`` against the rules of every store that reads it.
|
|
|
|
The tree is a Crowdin mirror (see ``.github/workflows/scheduled-updates.yml``) read by
|
|
three stores: ``supply`` uploads it to Google Play, and F-Droid and IzzyOnDroid read it
|
|
straight from git. Each check below is one way a store rejects it:
|
|
|
|
- **Locale directories.** ``supply`` uploads every directory it finds (uploader.rb's
|
|
``all_languages`` is a bare ``Dir.entries``) and the Crowdin CLI silently ignores
|
|
mapping keys it does not recognise, so an unmapped code is rejected only by the Play
|
|
API, partway through an upload, after earlier locales have already been written.
|
|
- **Lengths.** Crowdin's max-length toggle only blocks *new* submissions; translations
|
|
entered before it was enabled are grandfathered in and keep syncing down. Measured in
|
|
Unicode code points, which is what Play, F-Droid and IzzyOnDroid count -- a byte count
|
|
badly over-reports Cyrillic and CJK.
|
|
- **HTML.** fdroidserver's lint rejects a fixed list of tags in a description.
|
|
|
|
Exit status is non-zero on any violation, so it can gate CI. Under GitHub Actions each
|
|
violation is also a ``::error`` annotation on the offending file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections.abc import Iterator
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
# Repo root = parent of this script's directory (scripts/).
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
METADATA_DIR = REPO_ROOT / "fastlane" / "metadata" / "android"
|
|
|
|
# Running inside GitHub Actions enables ::error:: annotations on the PR.
|
|
IN_GITHUB_ACTIONS = os.environ.get("GITHUB_ACTIONS") == "true"
|
|
|
|
# Play's published set (Play Console Help, "Add your own translations"), not fastlane's
|
|
# Supply::Languages::ALL_LANGUAGES. They disagree: fastlane omits Albanian, which Play
|
|
# lists. Play is the authority here, because Play is what rejects the upload.
|
|
PLAY_LANGUAGES = frozenset(
|
|
{
|
|
"af", "am", "ar", "az-AZ", "be", "bg", "bn-BD", "ca", "cs-CZ", "da-DK", "de-DE",
|
|
"el-GR", "en-AU", "en-CA", "en-GB", "en-IN", "en-SG", "en-US", "en-ZA", "es-419",
|
|
"es-ES", "es-US", "et", "eu-ES", "fa", "fi-FI", "fil", "fr-CA", "fr-FR", "gl-ES",
|
|
"hi-IN", "hr", "hu-HU", "hy-AM", "id", "is-IS", "it-IT", "iw-IL", "ja-JP", "ka-GE",
|
|
"km-KH", "kn-IN", "ko-KR", "ky-KG", "lo-LA", "lt", "lv", "mk-MK", "ml-IN", "mn-MN",
|
|
"mr-IN", "ms", "ms-MY", "my-MM", "ne-NP", "nl-NL", "no-NO", "pl-PL", "pt-BR",
|
|
"pt-PT", "rm", "ro", "ru-RU", "si-LK", "sk", "sl", "sq", "sr", "sv-SE", "sw",
|
|
"ta-IN", "te-IN", "th", "tr-TR", "uk", "vi", "zh-CN", "zh-HK", "zh-TW", "zu",
|
|
}
|
|
)
|
|
|
|
# Per-file character limits, as globs under each locale directory. Each value is the
|
|
# tighter of Google Play's limit and F-Droid's (fdroidserver/common.py `char_limits`):
|
|
# 30 is Play's title limit (F-Droid allows 50); 80, 4000 and 500 are the same on both.
|
|
LIMITS = {
|
|
"title.txt": 30,
|
|
"short_description.txt": 80,
|
|
"full_description.txt": 4000,
|
|
"changelogs/*.txt": 500,
|
|
}
|
|
|
|
# The tag list fdroidserver/lint.py rejects as "Forbidden HTML tags". F-Droid and
|
|
# IzzyOnDroid read full_description.txt straight from this tree, so a hit here is a
|
|
# listing failure there, not a warning.
|
|
FORBIDDEN_HTML = re.compile(
|
|
r"<(applet|base|body|button|embed|form|head|html|iframe|img|input|link"
|
|
r"|object|picture|script|source|style|svg|video)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
CROWDIN_HINT = (
|
|
"These files are mirrored from Crowdin. Fix them at the source (shorten or remove "
|
|
"the offending translation so it is re-translated), then re-sync -- editing the "
|
|
"mirror here is overwritten on the next sync."
|
|
)
|
|
LOCALE_HINT = "Map the locale in crowdin.yml languages_mapping, or exclude it there."
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Violation:
|
|
path: Path # relative to the repo root, so it doubles as the annotation target
|
|
problem: str
|
|
hint: str
|
|
|
|
@property
|
|
def message(self) -> str:
|
|
return f"{self.path} {self.problem}"
|
|
|
|
|
|
def locale_dirs() -> list[Path]:
|
|
return sorted(p for p in METADATA_DIR.iterdir() if p.is_dir() and not p.name.startswith("."))
|
|
|
|
|
|
def check_locales() -> Iterator[Violation]:
|
|
for directory in locale_dirs():
|
|
if directory.name not in PLAY_LANGUAGES:
|
|
yield Violation(
|
|
directory.relative_to(REPO_ROOT),
|
|
f"is not a language code Google Play accepts ('{directory.name}')",
|
|
LOCALE_HINT,
|
|
)
|
|
|
|
|
|
def check_lengths() -> Iterator[Violation]:
|
|
for pattern, limit in LIMITS.items():
|
|
for path in sorted(METADATA_DIR.glob(f"*/{pattern}")):
|
|
# Trailing whitespace is not content; stores strip it too.
|
|
count = len(path.read_text(encoding="utf-8").rstrip())
|
|
if count > limit:
|
|
yield Violation(
|
|
path.relative_to(REPO_ROOT), f"is {count} chars (limit {limit})", CROWDIN_HINT
|
|
)
|
|
|
|
|
|
def check_html() -> Iterator[Violation]:
|
|
for path in sorted(METADATA_DIR.glob("*/full_description.txt")):
|
|
for match in FORBIDDEN_HTML.finditer(path.read_text(encoding="utf-8")):
|
|
yield Violation(
|
|
path.relative_to(REPO_ROOT),
|
|
f"uses <{match.group(1)}>, which F-Droid forbids",
|
|
CROWDIN_HINT,
|
|
)
|
|
|
|
|
|
CHECKS = (check_locales, check_lengths, check_html)
|
|
|
|
|
|
def main() -> int:
|
|
if not METADATA_DIR.is_dir():
|
|
print(f"error: metadata directory not found: {METADATA_DIR}", file=sys.stderr)
|
|
return 2
|
|
|
|
violations = [violation for check in CHECKS for violation in check()]
|
|
if not violations:
|
|
print(f"All {len(locale_dirs())} store-listing locales are within every store's rules.")
|
|
return 0
|
|
|
|
print("Store-listing metadata breaks store rules:\n")
|
|
for violation in violations:
|
|
print(f" - {violation.message}")
|
|
if IN_GITHUB_ACTIONS:
|
|
# Annotate the offending file directly in the PR diff view.
|
|
print(f"::error file={violation.path}::{violation.problem}")
|
|
|
|
# One hint per cause, in first-seen order.
|
|
for hint in dict.fromkeys(violation.hint for violation in violations):
|
|
print(f"\n{hint}")
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|