diff --git a/.github/skin-generation-log.json b/.github/skin-generation-log.json new file mode 100644 index 0000000..38a35db --- /dev/null +++ b/.github/skin-generation-log.json @@ -0,0 +1,233 @@ +{ + "generated": "2025-10-30T17:21:06Z", + "skins": { + "Phones/GooglePixel": { + "archive": "OTA/GooglePixel.skin", + "fingerprint": "af444bb2ed0f66f8bd0fff12ba66541dd834cb438cfec14b7d5660a5a915e50c", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/GooglePixel" + }, + "Phones/GooglePixel2": { + "archive": "OTA/GooglePixel2.skin", + "fingerprint": "8d3f0a2fb622bee004be9b81d4a9dae8f48876181941159a604a849e53e632cf", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/GooglePixel2" + }, + "Phones/GooglePixel2XL": { + "archive": "OTA/GooglePixel2XL.skin", + "fingerprint": "43e04b87630d1ecf12cef4f57a18be78184b9399b65007ccc37d604552546e0a", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/GooglePixel2XL" + }, + "Phones/HTCOneA9": { + "archive": "OTA/HTCOneA9.skin", + "fingerprint": "0b6c33bb5eed17a941917dbca90ca0dd2f2110947c2662785808777c36473cea", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/HTCOneA9" + }, + "Phones/HTCOneM8": { + "archive": "OTA/HTCOneM8.skin", + "fingerprint": "b9a0e401fd5facb4711f0efee941eeae46578e1250b933a9a14528e3ef33266d", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/HTCOneM8" + }, + "Phones/HuaweiP8": { + "archive": "OTA/HuaweiP8.skin", + "fingerprint": "2395dd1fe19ee072d99a847260985065cb19d7a56d927d7f69de6b6835a1ce10", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/HuaweiP8" + }, + "Phones/IPhone15": { + "archive": "tmp/generated_skins/IPhone15.skin", + "fingerprint": "99ed4508ab767939e57935d042d49aed16c215c3e5d5b189b866931bfe18dbfb", + "generated_at": "2025-10-30T17:21:06Z", + "source": "Phones/IPhone15" + }, + "Phones/IPhone15Plus": { + "archive": "tmp/generated_skins/IPhone15Plus.skin", + "fingerprint": "985ac8e585511bdc2fcc8f5ae63a3c3988c4c5849acf09e01be8566def4be0e9", + "generated_at": "2025-10-30T17:21:06Z", + "source": "Phones/IPhone15Plus" + }, + "Phones/IPhone15Pro": { + "archive": "tmp/generated_skins/IPhone15Pro.skin", + "fingerprint": "f524be6dacea494a8a0574beb10cc693812261a5d8bde5c92adc2aaab15839b9", + "generated_at": "2025-10-30T17:21:06Z", + "source": "Phones/IPhone15Pro" + }, + "Phones/IPhone15ProMax": { + "archive": "tmp/generated_skins/IPhone15ProMax.skin", + "fingerprint": "8172516a62880883ac6916fb0a04850a4916bd5846e0f9e237654a7aef96e1fc", + "generated_at": "2025-10-30T17:21:06Z", + "source": "Phones/IPhone15ProMax" + }, + "Phones/MicrosoftLumia950": { + "archive": "OTA/MicrosoftLumia950.skin", + "fingerprint": "8f72f6e802c0613d12021c31710b1fb30e7a7efa6cdeb2e4266be3041de04e56", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/MicrosoftLumia950" + }, + "Phones/MotoE": { + "archive": "OTA/MotoE.skin", + "fingerprint": "af44a8948d6e43248757ff53ef74cbbfd5eb9121ad5d81edc32ed38c564cf3d0", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/MotoE" + }, + "Phones/MotoG": { + "archive": "OTA/MotoG.skin", + "fingerprint": "e9edd5a6b01d311af18d777d39b9ae74dc8d7ce46953e3f1fcd31cafa76c3c95", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/MotoG" + }, + "Phones/Nexus4": { + "archive": "OTA/Nexus4.skin", + "fingerprint": "e802798d311c04302e54caddedbf9991e2bd016e6cf233a41885e97c534a55df", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/Nexus4" + }, + "Phones/Nexus5X": { + "archive": "OTA/Nexus5X.skin", + "fingerprint": "a923722fcf583afef15b2d77fccbd9083c6446ff89f14a6d7456a628a68f3f5c", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/Nexus5X" + }, + "Phones/Nexus6P": { + "archive": "OTA/Nexus6P.skin", + "fingerprint": "a61065691024419ab3162bc83e5b15e6ec61282ae37892642a83b21bba634fde", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/Nexus6P" + }, + "Phones/SamsungGalaxyGrandPrime": { + "archive": "OTA/SamsungGalaxyGrandPrime.skin", + "fingerprint": "563d2cb3f41d2ad2512a6eb1c5053d8c8a58f912d0bdbbf311dc513ecd152bae", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/SamsungGalaxyGrandPrime" + }, + "Phones/SamsungGalaxyNote5": { + "archive": "OTA/SamsungGalaxyNote5.skin", + "fingerprint": "0d72660276783ca1739da56227db0176c0515e1e641eb9b964da4b8fa00615e1", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/SamsungGalaxyNote5" + }, + "Phones/SamsungGalaxyS21Ultra": { + "archive": "OTA/SamsungGalaxyS21Ultra.skin", + "fingerprint": "4cfee65a636cf766312f829d48933bbe8a8cc2e7f9d15827d16d8c0764967da4", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/SamsungGalaxyS21Ultra" + }, + "Phones/SamsungGalaxyS3": { + "archive": "OTA/SamsungGalaxyS3.skin", + "fingerprint": "f7d5b0fdccac553199fa587c797d81a3fa1c381563244cbb8f43c46c4024d272", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/SamsungGalaxyS3" + }, + "Phones/SamsungGalaxyS5": { + "archive": "OTA/SamsungGalaxyS5.skin", + "fingerprint": "37086e57f9757387d83ae6e301f66072c1fe4cda14630882fef91944d6fb88d4", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/SamsungGalaxyS5" + }, + "Phones/SamsungGalaxyS7": { + "archive": "OTA/SamsungGalaxyS7.skin", + "fingerprint": "d144504d90921ed11435c15a3f85793bc6435c124071102493860d6350b789f7", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/SamsungGalaxyS7" + }, + "Phones/SamsungGalaxyS8": { + "archive": "OTA/SamsungGalaxyS8.skin", + "fingerprint": "11cd40b5ed466f2030a0aadd25e713468289e2f1b369fe41efe0dc870ed2f683", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/SamsungGalaxyS8" + }, + "Phones/iPhone5c": { + "archive": "OTA/iPhone5c.skin", + "fingerprint": "16673ae3766e20b9b8f770b2b34e0dd9faf5a805c9c940c041352f77131b4816", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/iPhone5c" + }, + "Phones/iPhone5s": { + "archive": "OTA/iPhone5s.skin", + "fingerprint": "fb7d355fe069ec4f52150b449cb736bf9632871c1c0541dbba03d04fdcdf7bd3", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/iPhone5s" + }, + "Phones/iPhone6s": { + "archive": "OTA/iPhone6s.skin", + "fingerprint": "f76a535b5aa5dda713b4c884e143eacd33eca168d6f702ec363a0dc48a600148", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/iPhone6s" + }, + "Phones/iPhone6sPlus": { + "archive": "OTA/iPhone6sPlus.skin", + "fingerprint": "add8e8105620f554646af51e32e900e3b9d0f0b3ada20fa86ef3e175e6a3129f", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/iPhone6sPlus" + }, + "Phones/iPhone7": { + "archive": "OTA/iPhone7.skin", + "fingerprint": "acfb2e503755b34589c8ec62c524420d3cafe0252584ac38f154d4400d9859c3", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/iPhone7" + }, + "Phones/iPhone7Plus": { + "archive": "OTA/iPhone7Plus.skin", + "fingerprint": "5f6457d8a680cc26f3060a37dc5467acadc57c0b1ad257bd31109023efcc527c", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/iPhone7Plus" + }, + "Phones/iPhone8": { + "archive": "OTA/iPhone8.skin", + "fingerprint": "ecf77841d0f7d3a1fa4c0b8c310660ab40b5aaf4e58c063a1389d90569a67a1b", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/iPhone8" + }, + "Phones/iPhone8Plus": { + "archive": "OTA/iPhone8Plus.skin", + "fingerprint": "53cf8a43750f9a3c69340d24a35756e64a5fa10f13c7006373f9cea8e0abb06b", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/iPhone8Plus" + }, + "Phones/iPhoneX": { + "archive": "OTA/iPhoneX.skin", + "fingerprint": "0969bafc7d2d421e0bc6caffd4e0591e145daf57567eea02dfa212e4e0680bc9", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Phones/iPhoneX" + }, + "Tablets/MicrosoftSurface3": { + "archive": "OTA/MicrosoftSurface3.skin", + "fingerprint": "fe6e9ea8612deda90f9d3566ebad25a88a58dc99c646b7976f314283e841d1a1", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Tablets/MicrosoftSurface3" + }, + "Tablets/MicrosoftSurfacePro4": { + "archive": "OTA/MicrosoftSurfacePro4.skin", + "fingerprint": "b37e750f60b509f201dba0cb1b65b3c2c3a0402637a05c72b56a8be7dbdf3394", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Tablets/MicrosoftSurfacePro4" + }, + "Tablets/Nexus9": { + "archive": "OTA/Nexus9.skin", + "fingerprint": "f2f064b7495712b9d6d5d63461ab9485e7045c149dd581ecd60a96782725ae97", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Tablets/Nexus9" + }, + "Tablets/iPadAir2": { + "archive": "OTA/iPadAir2.skin", + "fingerprint": "2ea144f50999e9e63f9d2344a7d176f7f2acbc8c97da90c282f8dc034db1a46e", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Tablets/iPadAir2" + }, + "Tablets/iPadMini4": { + "archive": "OTA/iPadMini4.skin", + "fingerprint": "17fe1854982356b01d77218ec4261459d183327681814ab2546cd602204df9fd", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Tablets/iPadMini4" + }, + "Tablets/iPadPro": { + "archive": "OTA/iPadPro.skin", + "fingerprint": "b90d501a309efb305d1142e8022224f1317d178738b945e2595592521763a01e", + "generated_at": "2025-10-30T14:20:19Z", + "source": "Tablets/iPadPro" + } + } +} diff --git a/.github/workflows/blank.yml b/.github/workflows/blank.yml deleted file mode 100644 index 0df0da6..0000000 --- a/.github/workflows/blank.yml +++ /dev/null @@ -1,37 +0,0 @@ -# This is a basic workflow to help you get started with Actions - -name: CI - -# Controls when the workflow will run -on: - # Triggers the workflow on push or pull request events but only for the "master" branch - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - # This workflow contains a single job called "build" - build: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 - - # Runs a single command using the runners shell - - name: Run a one-line script - run: ./build_skins.sh - - - name: 'Upload Artifact' - uses: actions/upload-artifact@v3 - with: - name: SamsungGalaxyS7.skin - path: OTA/SamsungGalaxyS7.skin - retention-days: 5 diff --git a/.github/workflows/generate-codenameone-skins.yml b/.github/workflows/generate-codenameone-skins.yml new file mode 100644 index 0000000..1300ea0 --- /dev/null +++ b/.github/workflows/generate-codenameone-skins.yml @@ -0,0 +1,99 @@ +name: Generate Codename One skins + +on: + workflow_dispatch: + push: + branches: + - main + - master + paths: + - '.github/workflows/**' + - '**/*.yml' + - '**/*.yaml' + - 'scripts/**' + pull_request: + paths: + - '.github/workflows/**' + - '**/*.yml' + - '**/*.yaml' + - 'scripts/**' + +permissions: + contents: write + pull-requests: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Python dependencies + run: pip install Pillow + + - name: Generate missing Codename One skins + run: | + python scripts/generate_missing_skins.py \ + --output-dir tmp/generated_skins \ + --report-file tmp/generated_skins/report.json + + - name: Show generation report + run: cat tmp/generated_skins/report.json + + - name: Verify generated skins with Codename One + run: | + python scripts/verify_skins_with_codenameone.py \ + --report-file tmp/generated_skins/report.json \ + --work-dir tmp/codenameone + + - name: Show git status + run: git status --short + + - name: Configure git user + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + - name: Stage changes + run: git add -A + + - name: Detect repository changes + id: git-status + run: | + if git diff --cached --quiet; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + + - name: Upload generated skins artifact + if: steps.git-status.outputs.has_changes == 'true' + uses: actions/upload-artifact@v4 + with: + name: generated-skins + path: tmp/generated_skins + + - name: Create pull request with generated skins + if: steps.git-status.outputs.has_changes == 'true' && github.event_name != 'pull_request' + uses: peter-evans/create-pull-request@v6 + with: + commit-message: 'chore: generate missing Codename One skins' + branch: automation/generate-skins + title: 'chore: generate missing Codename One skins' + body: | + Automated Codename One skin generation. + - Triggered by `${{ github.event_name }}` event. + + - name: Skip PR creation on pull_request events + if: steps.git-status.outputs.has_changes == 'true' && github.event_name == 'pull_request' + run: | + echo "Detected generated skins but not creating a pull request when running on pull_request events." diff --git a/.gitignore b/.gitignore index 7bc9045..c2f452d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,71 +1,6 @@ - - -android.skin - -AppleIPadMini.skin - -BlackberryBold9790.skin - -feature_phone.skin - -GoogleNexus7.skin - -HTCOne.skin - -ipad_os7.skin - -ipad.skin - -ipad3_os7.skin - -ipad3.skin - -iphone3gs_os7.skin - -iphone3gs.skin - -iphone4_os7.skin - -iphone4.skin - -iphone5_os7.skin - -iphone5.skin - -iphone6.skin - -iphone6Plus.skin - -lumia.skin - -nexus.skin - -NokiaAsha311.skin - -NokiaAsha501.skin - -NokiaE71.skin - -NokiaLumia920.skin - -torch.skin - -xoom.skin - -HTC10.skin - -Nexus5.skin - -OTA/HTC10.skin - -OTA/Nexus5.skin - -OTA/SamsungS7.skin - -SamsungS7.skin - -OTA/HTC10.skin -.DS_Store +# Ignore editor and OS artifacts .DS_Store +.idea/ -.idea \ No newline at end of file +# Temporary directories created during CI runs +/tmp/ diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py new file mode 100755 index 0000000..6775ba2 --- /dev/null +++ b/scripts/generate_missing_skins.py @@ -0,0 +1,1020 @@ +#!/usr/bin/env python3 +"""Generate Codename One skin archives from Android emulator skins.""" +from __future__ import annotations + +import argparse +import dataclasses +import datetime as _dt +import hashlib +import io +import json +import math +import os +import re +import shutil +import tempfile +from pathlib import Path +from typing import Callable, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple +from urllib.parse import urlparse, urlunparse +from urllib.request import Request, urlopen +from zipfile import ZIP_DEFLATED, ZipFile +import subprocess + +try: + from PIL import Image, ImageDraw +except ModuleNotFoundError as exc: # pragma: no cover - handled at runtime + raise SystemExit( + "Pillow is required to generate Codename One skins. Install it with 'pip install Pillow'." + ) from exc + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUTPUT_DIR = REPO_ROOT / "tmp" / "generated_skins" +DEFAULT_METADATA_PATH = REPO_ROOT / ".github" / "skin-generation-log.json" +ISO_8601_Z_SUFFIX = "%Y-%m-%dT%H:%M:%SZ" + +EXCLUDED_TOP_LEVEL = {".git", "OTA", "tmp", ".github"} + +ANDROID_THEME_TEMPLATE = REPO_ROOT / "android" / "androidTheme.res" +DEFAULT_ANDROID_FONTS: Dict[str, Path] = { + "DroidSans.ttf": REPO_ROOT / "Phones" / "GooglePixel" / "DroidSans.ttf", + "DroidSans-Bold.ttf": REPO_ROOT / "Phones" / "GooglePixel" / "DroidSans-Bold.ttf", + "DroidSansMono.ttf": REPO_ROOT / "Phones" / "GooglePixel" / "DroidSansMono.ttf", + "DroidSerif.ttf": REPO_ROOT / "Phones" / "GooglePixel" / "DroidSerif.ttf", + "DroidSerif-Bold.ttf": REPO_ROOT / "Phones" / "GooglePixel" / "DroidSerif-Bold.ttf", + "DroidSerif-BoldItalic.ttf": REPO_ROOT / "Phones" / "GooglePixel" / "DroidSerif-BoldItalic.ttf", + "DroidSerif-Italic.ttf": REPO_ROOT / "Phones" / "GooglePixel" / "DroidSerif-Italic.ttf", +} + + +@dataclasses.dataclass(frozen=True) +class AndroidSkinSource: + """Description of a remote Android emulator skin source.""" + + name: str + slug: str + url: str + metadata_prefix: str + allowed_roots: Tuple[str, ...] = () + subdirectory: Optional[str] = None + alternate_urls: Tuple[str, ...] = () + required: bool = True + + +ANDROID_SKIN_SOURCES: Tuple[AndroidSkinSource, ...] = ( + AndroidSkinSource( + name="Lars Kristian Haga Android emulator skins", + slug="Android", + url="https://github.com/larskristianhaga/Android-emulator-skins", + metadata_prefix="", + allowed_roots=("Phones", "Tablets", "phones", "tablets"), + required=True, + ), + AndroidSkinSource( + name="Suess Labs Android emulator skins", + slug="SuessLabs", + url="https://github.com/SuessLabs/Android-Emulator-Skins", + metadata_prefix="suesslabs/", + allowed_roots=("phones", "tablets"), + required=False, + ), + AndroidSkinSource( + name="Ming Chen Android emulator skins", + slug="MingChen", + url="https://github.com/mingchen/android-emulator-skins", + metadata_prefix="mingchen/", + allowed_roots=("phones", "tablets", "skins", "Skins"), + required=False, + ), +) + +DEPRECATED_SOURCE_FRAGMENTS = ( + "device-art-generator", + "HiDeoo/avd-samsung-skins", + "android-emulator-samsung-skins", +) + +for _fragment in DEPRECATED_SOURCE_FRAGMENTS: + for _spec in ANDROID_SKIN_SOURCES: + if _fragment in _spec.url: + raise RuntimeError( + f"Deprecated Android skin source configured: {_fragment} appears in {_spec.url}" + ) + + +def _github_token() -> Optional[str]: + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + return token.strip() + return None + + +def _github_headers(url: Optional[str] = None) -> Dict[str, str]: + headers: Dict[str, str] = {"User-Agent": "codenameone-skin-generator/1.0"} + token = _github_token() + if not token: + return headers + if url is None: + headers["Authorization"] = f"Bearer {token}" + return headers + host = urlparse(url).netloc.lower() + if "github.com" in host or "githubusercontent.com" in host or host.startswith("api.github"): + headers["Authorization"] = f"Bearer {token}" + return headers + + +def _authenticated_git_url(url: str) -> str: + token = _github_token() + if not token: + return url + parsed = urlparse(url) + host = parsed.netloc.lower() + if "github.com" not in host: + return url + safe_netloc = f"{token}:x-oauth-basic@{parsed.netloc}" + return urlunparse(parsed._replace(netloc=safe_netloc)) + + +def _sanitize_url(url: str) -> str: + token = _github_token() + if not token: + return url + sanitized = url.replace(token, "***") + return sanitized.replace(f"{token}:x-oauth-basic", "***:x-oauth-basic") + + +@dataclasses.dataclass(frozen=True) +class SkinGeneration: + """Description of a generated skin archive.""" + + name: str + source_dir: Path + source_relative: str + archive_path: Path + + +@dataclasses.dataclass(frozen=True) +class OrientationAssets: + """Assets required to render a specific device orientation.""" + + image_bytes: bytes + width: int + height: int + screen_x: int + screen_y: int + screen_width: int + screen_height: int + + def rotate_clockwise(self) -> "OrientationAssets": + """Return a copy rotated 90 degrees clockwise.""" + + with Image.open(io.BytesIO(self.image_bytes)) as img: + rotated = img.rotate(-90, expand=True) + buffer = io.BytesIO() + rotated.save(buffer, format="PNG") + buffer.seek(0) + width, height = rotated.size + + new_x = self.height - (self.screen_y + self.screen_height) + new_y = self.screen_x + return OrientationAssets( + image_bytes=buffer.getvalue(), + width=width, + height=height, + screen_x=new_x, + screen_y=new_y, + screen_width=self.screen_height, + screen_height=self.screen_width, + ) + + +@dataclasses.dataclass(frozen=True) +class ResolvedSource: + """A resolved Android skin source on disk with cleanup support.""" + + spec: AndroidSkinSource + root: Path + cleanup: Callable[[], None] + + +def _load_metadata(path: Path) -> Dict[str, Dict[str, str]]: + if path.exists(): + with path.open("r", encoding="utf-8") as fh: + try: + raw = json.load(fh) + except json.JSONDecodeError: + return {} + if isinstance(raw, dict): + return {str(k): dict(v) for k, v in raw.get("skins", {}).items()} + return {} + + +def _save_metadata(path: Path, records: Dict[str, Dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = {"generated": _current_utc_isoformat(), "skins": records} + with path.open("w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + + +def _current_utc_isoformat() -> str: + return ( + _dt.datetime.now(tz=_dt.timezone.utc) + .astimezone(_dt.timezone.utc) + .replace(microsecond=0) + .strftime(ISO_8601_Z_SUFFIX) + ) + + +def _directory_fingerprint(directory: Path) -> str: + sha = hashlib.sha256() + for entry in sorted(directory.rglob("*")): + if entry.name.startswith("."): + continue + if entry.is_dir(): + sha.update(entry.name.encode("utf-8")) + continue + sha.update(entry.relative_to(directory).as_posix().encode("utf-8")) + sha.update(entry.read_bytes()) + return sha.hexdigest() + + +def _isoformat_from_timestamp(timestamp: float) -> str: + return ( + _dt.datetime.fromtimestamp(timestamp, tz=_dt.timezone.utc) + .replace(microsecond=0) + .strftime(ISO_8601_Z_SUFFIX) + ) + + +def _parse_ini_file(path: Path) -> Dict[str, str]: + data: Dict[str, str] = {} + if not path.exists(): + return data + for raw_line in path.read_text(encoding="utf-8", errors="ignore").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + data[key.strip()] = value.strip() + return data + + +def _parse_layout_file(path: Path) -> Dict[str, object]: + text = path.read_text(encoding="utf-8", errors="ignore") + tokens = re.findall(r"\w+|\{|\}|=|[^\s{}=]+", text) + stack: List[Dict[str, object]] = [{}] + key_stack: List[str] = [] + i = 0 + while i < len(tokens): + token = tokens[i] + if token == "}": + if len(stack) > 1: + stack.pop() + key_stack.pop() + i += 1 + continue + if token == "{": + i += 1 + continue + if i + 1 < len(tokens) and tokens[i + 1] == "{": + key = token + parent = stack[-1] + container: Dict[str, object] = {} + existing = parent.get(key) + if existing is None: + parent[key] = container + elif isinstance(existing, list): + existing.append(container) + else: + parent[key] = [existing, container] + stack.append(container) + key_stack.append(key) + i += 2 + continue + if i + 1 < len(tokens) and tokens[i + 1] == "=": + key = token + value = tokens[i + 2] + parent = stack[-1] + if key not in parent: + parent[key] = value + i += 3 + continue + i += 1 + return stack[0] + + +def _locate_layout_file(skin_dir: Path) -> Optional[Path]: + candidates = [ + skin_dir / "layout", + skin_dir / "layout.ini", + skin_dir / "skin.layout", + ] + for candidate in candidates: + if candidate.is_file(): + return candidate + nested = skin_dir / "layout" + if nested.is_dir(): + for name in ("layout", "layout.ini"): + candidate = nested / name + if candidate.is_file(): + return candidate + return None + + +def _ensure_iterable(value: object) -> Iterable[Dict[str, object]]: + if isinstance(value, list): + return (item for item in value if isinstance(item, dict)) + if isinstance(value, dict): + return (value,) + return () + + +def _normalise_orientation_names() -> Dict[str, Tuple[str, ...]]: + return { + "portrait": ("portrait", "vertical", "default", "upright"), + "landscape": ("landscape", "horizontal", "sideways"), + } + + +def _parse_int(value: Optional[str]) -> Optional[int]: + if value is None: + return None + try: + return int(float(value)) + except ValueError: + return None + + +def _resolve_image_file(base_dir: Path, part: Dict[str, object]) -> Optional[Path]: + candidates: List[Tuple[Optional[str], Optional[str], str]] = [] + nodes: List[Dict[str, object]] = [] + for key in ("background", "image", "images"): + node = part.get(key) + if isinstance(node, dict): + nodes.append(node) + nodes.append(part) + for node in nodes: + if not isinstance(node, dict): + continue + folder = node.get("folder") if isinstance(node.get("folder"), str) else None + prefix = node.get("prefix") if isinstance(node.get("prefix"), str) else "" + image_node = node.get("image") if isinstance(node.get("image"), dict) else node + for key in ("file", "filename", "src"): + candidate = image_node.get(key) if isinstance(image_node, dict) else None + if isinstance(candidate, str): + candidates.append((folder, prefix, candidate)) + for folder, prefix, raw_path in candidates: + parts = [part for part in re.split(r"[\\/]+", raw_path) if part] + if not parts: + continue + filename = parts[-1] + search_roots: List[Path] = [base_dir] + if folder: + search_roots.append(base_dir / folder) + for root in search_roots: + candidate_path = root / f"{prefix}{filename}" + if candidate_path.exists(): + return candidate_path + matches = list(base_dir.rglob(filename)) + if matches: + return matches[0] + return None + + +def _extract_orientation(layout: Dict[str, object], orientation: str, base_dir: Path) -> Optional[OrientationAssets]: + orientation_names = _normalise_orientation_names()[orientation] + parts_container = layout.get("parts") + for candidate in _ensure_iterable(parts_container): + for name in orientation_names: + part = candidate.get(name) if isinstance(candidate, dict) else None + if isinstance(part, dict): + assets = _orientation_from_part(part, base_dir) + if assets: + return assets + for name in orientation_names: + part = layout.get(name) + if isinstance(part, dict): + assets = _orientation_from_part(part, base_dir) + if assets: + return assets + return None + + +def _orientation_from_part(part: Dict[str, object], base_dir: Path) -> Optional[OrientationAssets]: + display = part.get("display") + if not isinstance(display, dict): + display = part.get("screen") if isinstance(part.get("screen"), dict) else None + if not isinstance(display, dict): + return None + x = _parse_int(str(display.get("x"))) if display.get("x") is not None else _parse_int(display.get("xOffset")) + y = _parse_int(str(display.get("y"))) if display.get("y") is not None else _parse_int(display.get("yOffset")) + width = _parse_int(display.get("width")) + height = _parse_int(display.get("height")) + if None in {x, y, width, height}: + return None + if width <= 0 or height <= 0: + return None + image_path = _resolve_image_file(base_dir, part) + if image_path is None or not image_path.exists(): + return None + with Image.open(image_path) as img: + rgba = img.convert("RGBA") + width_px, height_px = rgba.size + buffer = io.BytesIO() + rgba.save(buffer, format="PNG") + buffer.seek(0) + return OrientationAssets( + image_bytes=buffer.getvalue(), + width=width_px, + height=height_px, + screen_x=x or 0, + screen_y=y or 0, + screen_width=width or width_px, + screen_height=height or height_px, + ) + + +def _render_overlay(width: int, height: int, rect: Tuple[int, int, int, int]) -> bytes: + overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + x, y, w, h = rect + x = max(0, min(x, width - 1)) + y = max(0, min(y, height - 1)) + w = max(1, min(w, width - x)) + h = max(1, min(h, height - y)) + draw.rectangle([x, y, x + w - 1, y + h - 1], fill=(0, 0, 0, 255)) + buffer = io.BytesIO() + overlay.save(buffer, format="PNG") + buffer.seek(0) + return buffer.getvalue() + + +def _derive_pixel_ratio(hardware: Dict[str, str]) -> Optional[float]: + density_keys = ( + "hw.lcd.density", + "hw.display.density", + "lcd.density", + "density", + "ro.sf.lcd_density", + "config_lcdDensity", + "hw.gpu.density", + ) + for key in density_keys: + if key in hardware: + try: + density = float(hardware[key]) + if density > 0: + return density / 25.4 + except ValueError: + continue + width = _parse_int(hardware.get("hw.lcd.width")) + height = _parse_int(hardware.get("hw.lcd.height")) + diag_mm = hardware.get("hw.device.display_diagonal") or hardware.get("device.display_diagonal") + if width and height and diag_mm: + try: + diag_inches = float(diag_mm) + except ValueError: + diag_inches = None + if diag_inches: + diag_pixels = math.hypot(width, height) + if diag_pixels > 0 and diag_inches > 0: + dpi = diag_pixels / diag_inches + return dpi / 25.4 + return None + + +def _determine_override_names(tablet: bool) -> str: + if tablet: + return "tablet,android,android-tablet" + return "phone,android,android-phone" + + +def _is_tablet(width: int, height: int) -> bool: + longest = max(width, height) + return longest >= 1200 + + +def _normalise_name_component(component: str) -> str: + tokens = re.split(r"[^A-Za-z0-9]+", component) + return "".join(token.capitalize() for token in tokens if token) + + +def _derive_skin_name(relative_parts: Sequence[str], used: set[str], prefix: Optional[str] = None) -> str: + filtered = [part for part in relative_parts if part.lower() not in {"phones", "phone", "tablets", "tablet", "skins", "skin"}] + if not filtered: + filtered = list(relative_parts) + components = list(filtered) + if prefix and prefix.lower() not in {component.lower() for component in components}: + components.insert(0, prefix) + name = "".join(_normalise_name_component(part) for part in components) + if not name: + name = "Skin" + base = name + counter = 1 + while name in used: + counter += 1 + name = f"{base}{counter}" + used.add(name) + return name + + +def _relative_to_repo(path: Path) -> str: + try: + return path.relative_to(REPO_ROOT).as_posix() + except ValueError: + return path.resolve().as_posix() + + +def _build_skin_properties( + *, + device_name: str, + source: AndroidSkinSource, + relative_source: str, + pixel_ratio: Optional[float], + tablet: bool, + hardware: Dict[str, str], + screen_width: int, + screen_height: int, +) -> str: + lines = [ + f"#Generated by Codename One skin pipeline from {source.name}", + f"#Source: {source.url}::{relative_source}", + f"#Resolution: {screen_width}x{screen_height}", + "touch=true", + f"platformName=and", + f"tablet={'true' if tablet else 'false'}", + "systemFontFamily=DroidSans", + "proportionalFontFamily=DroidSans", + "monospaceFontFamily=DroidSansMono", + "smallFontSize=11", + "mediumFontSize=14", + "largeFontSize=20", + f"overrideNames={_determine_override_names(tablet)}", + ] + diagonal = hardware.get("hw.device.display_diagonal") or hardware.get("device.display_diagonal") + if diagonal: + try: + diagonal_value = float(diagonal) + lines.insert(2, f"#Diagonal: {diagonal_value}\"") + except ValueError: + pass + lines.insert(2, f"#Device: {device_name}") + dpi = hardware.get("hw.lcd.density") or hardware.get("hw.display.density") + if dpi: + lines.append(f"dpi={dpi}") + if pixel_ratio: + formatted = f"{pixel_ratio:.12f}".rstrip("0").rstrip(".") + lines.append(f"pixelRatio={formatted}") + ppi_value = pixel_ratio * 25.4 + lines.append(f"ppi={ppi_value:.2f}") + return "\n".join(lines) + "\n" + + +def _write_android_resources(zip_file: ZipFile) -> None: + if ANDROID_THEME_TEMPLATE.exists(): + zip_file.write(ANDROID_THEME_TEMPLATE, arcname="androidTheme.res") + for name, path in DEFAULT_ANDROID_FONTS.items(): + if path.exists(): + zip_file.write(path, arcname=name) + + +def _convert_skin_directory( + *, + skin_dir: Path, + target_zip: Path, + source: AndroidSkinSource, + relative_source: str, + device_name: str, + hardware: Dict[str, str], +) -> None: + layout_path = _locate_layout_file(skin_dir) + if layout_path is None: + raise RuntimeError("Missing layout file") + layout = _parse_layout_file(layout_path) + portrait = _extract_orientation(layout, "portrait", skin_dir) + landscape = _extract_orientation(layout, "landscape", skin_dir) + if portrait is None: + raise RuntimeError("Unable to resolve portrait orientation assets") + if landscape is None: + landscape = portrait.rotate_clockwise() + + portrait_overlay = _render_overlay( + portrait.width, + portrait.height, + (portrait.screen_x, portrait.screen_y, portrait.screen_width, portrait.screen_height), + ) + landscape_overlay = _render_overlay( + landscape.width, + landscape.height, + (landscape.screen_x, landscape.screen_y, landscape.screen_width, landscape.screen_height), + ) + + pixel_ratio = _derive_pixel_ratio(hardware) + if pixel_ratio is None: + raise RuntimeError("Unable to determine pixel density for skin") + + tablet = _is_tablet(portrait.screen_width, portrait.screen_height) + + properties = _build_skin_properties( + device_name=device_name, + source=source, + relative_source=relative, + pixel_ratio=pixel_ratio, + tablet=tablet, + hardware=hardware, + screen_width=portrait.screen_width, + screen_height=portrait.screen_height, + ) + + target_zip.parent.mkdir(parents=True, exist_ok=True) + with ZipFile(target_zip, "w", compression=ZIP_DEFLATED, compresslevel=9) as zf: + zf.writestr("skin.png", portrait.image_bytes) + zf.writestr("skin_l.png", landscape.image_bytes) + zf.writestr("skin_map.png", portrait_overlay) + zf.writestr("skin_map_l.png", landscape_overlay) + zf.writestr("skin.properties", properties) + _write_android_resources(zf) + + +def _github_repo_from_url(url: str) -> Optional[Tuple[str, str]]: + parsed = urlparse(url) + if parsed.netloc != "github.com": + return None + parts = [p for p in parsed.path.strip("/").split("/") if p] + if len(parts) < 2: + return None + owner, repo = parts[0], parts[1] + if repo.endswith(".git"): + repo = repo[:-4] + return owner, repo + + +def _branch_candidates(base_url: str) -> List[str]: + owner_repo = _github_repo_from_url(base_url) + candidates: List[str] = [] + if owner_repo: + owner, repo = owner_repo + api_url = f"https://api.github.com/repos/{owner}/{repo}" + try: + request = Request(api_url, headers=_github_headers(api_url)) + with urlopen(request) as response: # type: ignore[arg-type] + payload = json.load(response) + default_branch = payload.get("default_branch") + if isinstance(default_branch, str) and default_branch: + candidates.append(default_branch) + except Exception: # pragma: no cover - network dependent + pass + + for branch in ("main", "master", "dev", "develop"): + if branch not in candidates: + candidates.append(branch) + return candidates + + +def _download_android_skin_repo(source: AndroidSkinSource) -> Tuple[Path, Path]: + parsed = urlparse(source.url) + if parsed.scheme not in {"http", "https"}: + raise ValueError(f"Unsupported URL scheme for Android skin source: {source.url}") + + tmp_root = Path(tempfile.mkdtemp(prefix="android-skins-")) + archive_path = tmp_root / "repo.zip" + errors: List[str] = [] + + base_urls: Tuple[str, ...] = (source.url, *source.alternate_urls) + + for base_url in base_urls: + base = base_url.rstrip("/") + branch_candidates = _branch_candidates(base) + archive_candidates: List[str] + if base.endswith(".zip"): + archive_candidates = [base] + else: + archive_candidates = [ + f"{base}/archive/refs/heads/{branch}.zip" for branch in branch_candidates + ] + + for candidate in archive_candidates: + try: + request = Request(candidate, headers=_github_headers(candidate)) + with urlopen(request) as response, archive_path.open("wb") as fh: # type: ignore[arg-type] + shutil.copyfileobj(response, fh) + with ZipFile(archive_path) as zf: + zf.extractall(tmp_root) + top_level: List[Path] = [] + for name in zf.namelist(): + if not name: + continue + root = Path(name.split("/", 1)[0]) + if root not in top_level: + top_level.append(root) + if source.subdirectory: + candidate_dir = tmp_root / source.subdirectory + if candidate_dir.exists(): + repo_root = candidate_dir + elif len(top_level) == 1: + candidate_dir = tmp_root / top_level[0] / source.subdirectory + repo_root = ( + candidate_dir if candidate_dir.exists() else tmp_root / top_level[0] + ) + elif top_level: + repo_root = tmp_root / top_level[0] + else: + repo_root = tmp_root + elif len(top_level) == 1: + repo_root = tmp_root / top_level[0] + else: + repo_root = tmp_root + return repo_root, tmp_root + except Exception as exc: # pylint: disable=broad-except + errors.append(f"{_sanitize_url(candidate)}: {exc}") + + # Zip downloads failed, try shallow git clones as fallbacks + for attempt_index, base_url in enumerate(base_urls): + branch_candidates = _branch_candidates(base_url) + for branch in branch_candidates: + clone_dir = tmp_root / f"repo-{attempt_index}-{branch}" + try: + clone_url = _authenticated_git_url(base_url) + env = os.environ.copy() + env.setdefault("GIT_TERMINAL_PROMPT", "0") + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "--single-branch", + "--filter=blob:none", + "--branch", + branch, + clone_url, + str(clone_dir), + ], + check=True, + capture_output=True, + text=True, + env=env, + ) + except subprocess.CalledProcessError as exc: # pragma: no cover - network dependent + stderr = exc.stderr.strip() + stdout = exc.stdout.strip() + details = stderr or stdout or str(exc) + errors.append(f"git clone ({_sanitize_url(base_url)}@{branch}): {details}") + continue + except FileNotFoundError as exc: # pragma: no cover - git missing + errors.append(f"git clone unavailable: {exc}") + break + else: + repo_root = clone_dir + if source.subdirectory: + candidate_dir = clone_dir / source.subdirectory + if candidate_dir.exists(): + repo_root = candidate_dir + return repo_root, tmp_root + + shutil.rmtree(tmp_root, ignore_errors=True) + raise RuntimeError( + f"Failed to download Android emulator skins from {source.url}. Attempts: " + "; ".join(errors) + ) + + +def _iter_android_skin_directories(root: Path, allowed_roots: Tuple[str, ...]) -> Iterator[Path]: + seen: set[Path] = set() + for marker in ("hardware.ini", "skin.ini", "layout"): + for path in root.rglob(marker): + parent = path.parent + try: + relative_parts = parent.relative_to(root).parts + except ValueError: + continue + if not relative_parts: + continue + if relative_parts[0] in EXCLUDED_TOP_LEVEL: + continue + if allowed_roots and relative_parts[0] not in allowed_roots: + continue + if parent not in seen: + seen.add(parent) + yield parent + + +def _resolve_sources() -> Tuple[List[ResolvedSource], List[str], List[str]]: + resolved: List[ResolvedSource] = [] + errors: List[str] = [] + warnings: List[str] = [] + for spec in ANDROID_SKIN_SOURCES: + try: + root, tmp_root = _download_android_skin_repo(spec) + cleanup = lambda tmp=tmp_root: shutil.rmtree(tmp, ignore_errors=True) + resolved.append(ResolvedSource(spec=spec, root=root, cleanup=cleanup)) + except Exception as exc: # pylint: disable=broad-except + message = f"{spec.name}: {exc}" + if spec.required: + errors.append(message) + else: + warnings.append(message) + return resolved, errors, warnings + + +def process_skins( + *, + output_dir: Path, + metadata_path: Path, + dry_run: bool = False, + force: bool = False, +) -> Tuple[List[SkinGeneration], List[str], List[str], List[str]]: + output_dir = output_dir.resolve() + metadata_path = metadata_path.resolve() + metadata = _load_metadata(metadata_path) + + resolved_sources, resolve_errors, resolve_warnings = _resolve_sources() + generated: List[SkinGeneration] = [] + skipped: List[str] = [] + errors: List[str] = resolve_errors + warnings: List[str] = resolve_warnings + used_names: set[str] = set() + + try: + for resolved_source in resolved_sources: + spec = resolved_source.spec + for skin_dir in sorted( + _iter_android_skin_directories(resolved_source.root, spec.allowed_roots), + key=lambda p: p.relative_to(resolved_source.root).as_posix(), + ): + relative = skin_dir.relative_to(resolved_source.root).as_posix() + metadata_key = f"{spec.metadata_prefix}{relative}" if spec.metadata_prefix else relative + relative_parts = skin_dir.relative_to(resolved_source.root).parts + prefix = spec.slug if spec.slug else None + skin_name = _derive_skin_name(relative_parts, used_names, prefix) + archive_path = output_dir / f"{skin_name}.skin" + fingerprint = _directory_fingerprint(skin_dir) + record = metadata.get(metadata_key) + existing_archive = _find_existing_archive(skin_name) + + if not force and record and record.get("fingerprint") == fingerprint: + skipped.append(skin_name) + continue + + if not force and record is None and existing_archive is not None: + if not dry_run: + metadata[metadata_key] = { + "generated_at": _isoformat_from_timestamp(existing_archive.stat().st_mtime), + "source": relative, + "fingerprint": fingerprint, + "archive": _relative_to_repo(existing_archive), + "source_repository": spec.url, + "skin": skin_name, + } + skipped.append(skin_name) + continue + + if dry_run: + generated.append( + SkinGeneration( + name=skin_name, + source_dir=skin_dir, + source_relative=f"{spec.name}:{relative}", + archive_path=archive_path, + ) + ) + continue + + hardware = _parse_ini_file(skin_dir / "hardware.ini") + try: + _convert_skin_directory( + skin_dir=skin_dir, + target_zip=archive_path, + source=spec, + relative_source=relative, + device_name=skin_name, + hardware=hardware, + ) + except Exception as exc: # pylint: disable=broad-except + errors.append(f"{spec.name}::{relative}: {exc}") + continue + + metadata[metadata_key] = { + "generated_at": _current_utc_isoformat(), + "source": relative, + "fingerprint": fingerprint, + "archive": _relative_to_repo(archive_path), + "source_repository": spec.url, + "skin": skin_name, + } + generated.append( + SkinGeneration( + name=skin_name, + source_dir=skin_dir, + source_relative=f"{spec.name}:{relative}", + archive_path=archive_path, + ) + ) + finally: + for resolved in resolved_sources: + try: + resolved.cleanup() + except Exception: # pylint: disable=broad-except + pass + + if not dry_run: + ordered = dict(sorted(metadata.items())) + _save_metadata(metadata_path, ordered) + + return generated, skipped, errors, warnings + + +def _find_existing_archive(name: str) -> Optional[Path]: + ota_dir = REPO_ROOT / "OTA" + candidate = ota_dir / f"{name}.skin" + if candidate.exists(): + return candidate + return None + + +def _write_report( + report_path: Path, + generated: List[SkinGeneration], + skipped: List[str], + errors: List[str], + warnings: List[str], +) -> None: + report_payload = { + "generated": [ + { + "skin": entry.name, + "archive": entry.archive_path.as_posix(), + "source": entry.source_relative, + } + for entry in generated + ], + "skipped": sorted(skipped), + "errors": errors, + "warnings": warnings, + } + report_path.parent.mkdir(parents=True, exist_ok=True) + with report_path.open("w", encoding="utf-8") as fh: + json.dump(report_payload, fh, indent=2, sort_keys=True) + fh.write("\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate Codename One skin archives when missing") + parser.add_argument("--dry-run", action="store_true", help="Only report the work that would be performed") + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help="Directory where generated .skin archives should be written (default: tmp/generated_skins)", + ) + parser.add_argument( + "--metadata", + type=Path, + default=DEFAULT_METADATA_PATH, + help="Location of the metadata ledger tracking previously generated skins", + ) + parser.add_argument( + "--report-file", + type=Path, + help="Optional path for a JSON report describing generated and skipped skins", + ) + parser.add_argument( + "--force", + action="store_true", + help="Regenerate skins even if the metadata fingerprint matches", + ) + args = parser.parse_args() + + generated, skipped, errors, warnings = process_skins( + output_dir=args.output_dir, + metadata_path=args.metadata, + dry_run=args.dry_run, + force=args.force, + ) + + if generated: + print("Generated/updated skins:\n - " + "\n - ".join(entry.name for entry in sorted(generated, key=lambda e: e.name))) + else: + print("No skins required regeneration.") + + if skipped: + print("Skipped skins (up-to-date):\n - " + "\n - ".join(sorted(skipped))) + + if errors: + print("Encountered issues:\n - " + "\n - ".join(errors)) + + if warnings: + print("Warnings:\n - " + "\n - ".join(warnings)) + + if args.report_file: + _write_report(args.report_file, generated, skipped, errors, warnings) + + if errors and not args.dry_run: + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py new file mode 100755 index 0000000..54a2c06 --- /dev/null +++ b/scripts/verify_skins_with_codenameone.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""Validate generated Codename One skin archives.""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import sys +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +REQUIRED_PNG_ENTRIES = ( + "skin.png", + "skin_l.png", + "skin_map.png", + "skin_map_l.png", +) +ESSENTIAL_PROPERTIES = ( + "touch", + "platformName", + "overrideNames", +) +SIZE_PROPERTIES = ("smallFontSize", "mediumFontSize", "largeFontSize") +OPTIONAL_BOOL_PROPERTIES = ("tablet", "roundScreen", "rotateKeys") +KNOWN_PLATFORMS = {"ios", "and", "win", "rim", "se", "me"} +MANUAL_PIXEL_RATIOS: Dict[str, float] = { + "BlackberryBold9790": 9.681166903317413, + "Tablets/MicrosoftSurface3": 8.411901488396593, + "Tablets/MicrosoftSurfacePro4": 10.525135276945004, + "android": 6.299212598425197, + "feature_phone": 7.158196134574087, + "NokiaE71": 6.672894701721608, + "lumia": 8.54195479926065, + "nexus": 9.927136658600213, +} + + +@dataclass +class PngInfo: + width: int + height: int + + +class VerificationError(RuntimeError): + """Raised when a verification step fails.""" + + +def _load_report(path: Path) -> List[dict]: + with path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + generated = data.get("generated", []) + if not isinstance(generated, list): + raise VerificationError("Report file is malformed: 'generated' should be a list") + return [entry for entry in generated if isinstance(entry, dict)] + + +def _read_zip_entry(zip_file: zipfile.ZipFile, name: str) -> bytes: + try: + with zip_file.open(name) as fh: + return fh.read() + except KeyError as exc: + raise VerificationError(f"Skin archive missing required entry: {name}") from exc + + +def _parse_png_info(data: bytes, entry_name: str) -> PngInfo: + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": + raise VerificationError(f"{entry_name} is not a valid PNG file") + width = int.from_bytes(data[16:20], "big") + height = int.from_bytes(data[20:24], "big") + if width <= 0 or height <= 0: + raise VerificationError(f"{entry_name} has invalid dimensions {width}x{height}") + return PngInfo(width=width, height=height) + + +def _parse_properties(data: bytes) -> Tuple[Dict[str, str], List[str], str]: + try: + text = data.decode("utf-8") + except UnicodeDecodeError as exc: + raise VerificationError(f"Unable to decode skin.properties: {exc}") from exc + + props: Dict[str, str] = {} + comments: List[str] = [] + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + if line.startswith("#"): + comments.append(line) + continue + if "=" not in raw_line: + continue + key, value = raw_line.split("=", 1) + key = key.strip() + value = value.strip() + if key not in props: + props[key] = value + return props, comments, text + + +def _validate_override_names(raw_value: str) -> None: + parts = [part.strip() for part in raw_value.split(",") if part.strip()] + if len(parts) < 2: + raise VerificationError( + "skin.properties overrideNames must contain at least two comma-separated values" + ) + + +def _ensure_bool(name: str, value: str) -> None: + if value.lower() not in {"true", "false"}: + raise VerificationError(f"skin.properties {name} must be 'true' or 'false', found '{value}'") + + +def _ensure_int(name: str, value: str, minimum: int = 1) -> None: + try: + parsed = int(value) + except ValueError as exc: + raise VerificationError(f"skin.properties {name} must be an integer, found '{value}'") from exc + if parsed < minimum: + raise VerificationError(f"skin.properties {name} must be >= {minimum}, found {parsed}") + + +def _ensure_float(name: str, value: str, minimum: float = 0.0) -> None: + try: + parsed = float(value) + except ValueError as exc: + raise VerificationError(f"skin.properties {name} must be a number, found '{value}'") from exc + if parsed <= minimum: + raise VerificationError(f"skin.properties {name} must be greater than {minimum}, found {parsed}") + + +def _ensure_non_empty(name: str, value: str) -> None: + if not value.strip(): + raise VerificationError(f"skin.properties {name} must not be empty") + + +def _derive_pixel_ratio(props: Dict[str, str], comments: Iterable[str], source_hint: Optional[str]) -> Optional[float]: + if "pixelRatio" in props: + try: + parsed = float(props["pixelRatio"]) + except ValueError: + raise VerificationError(f"skin.properties pixelRatio is not a number: {props['pixelRatio']}") + if parsed <= 0: + raise VerificationError(f"skin.properties pixelRatio must be positive, found {parsed}") + return parsed + + def _parse_float(value: Optional[str]) -> Optional[float]: + if value is None: + return None + try: + parsed = float(value) + except ValueError: + return None + if parsed <= 0: + return None + return parsed + + ppi = _parse_float(props.get("ppi")) or _parse_float(props.get("dpi")) + if ppi is None: + for line in comments: + match = re.search(r"(\d+(?:\.\d+)?)\s*(?:pp|dp)i", line, flags=re.IGNORECASE) + if match: + ppi = _parse_float(match.group(1)) + if ppi: + break + if ppi is None: + diag = None + width = height = None + for line in comments: + if diag is None: + diag_match = re.search(r"(\d+(?:\.\d+)?)\"", line) + if diag_match: + diag = _parse_float(diag_match.group(1)) + if width is None or height is None: + res_match = re.search(r"(\d+)\s*[xX]\s*(\d+)", line) + if res_match: + width = int(res_match.group(1)) + height = int(res_match.group(2)) + if diag and width and height: + break + if diag and width and height: + diag_pixels = math.hypot(width, height) + if diag_pixels > 0 and diag > 0: + ppi = diag_pixels / diag + + if ppi is None and source_hint: + ratio = MANUAL_PIXEL_RATIOS.get(source_hint) + if ratio is not None: + return ratio + + if ppi is None: + return None + + return ppi / 25.4 + + +def _validate_properties(props: Dict[str, str], comments: List[str], source_hint: Optional[str]) -> None: + missing = [key for key in ESSENTIAL_PROPERTIES if key not in props] + if missing: + raise VerificationError("skin.properties missing required keys: " + ", ".join(sorted(missing))) + + _ensure_bool("touch", props["touch"]) + for opt_bool in OPTIONAL_BOOL_PROPERTIES: + if opt_bool in props: + _ensure_bool(opt_bool, props[opt_bool]) + + if "systemFontFamily" in props: + _ensure_non_empty("systemFontFamily", props["systemFontFamily"]) + if "proportionalFontFamily" in props: + _ensure_non_empty("proportionalFontFamily", props["proportionalFontFamily"]) + if "monospaceFontFamily" in props: + _ensure_non_empty("monospaceFontFamily", props["monospaceFontFamily"]) + + for size_key in SIZE_PROPERTIES: + value = props.get(size_key) + if value is not None: + _ensure_int(size_key, value) + + if "nativeThemeAttribute" in props: + _ensure_non_empty("nativeThemeAttribute", props["nativeThemeAttribute"]) + + derived_ratio = _derive_pixel_ratio(props, comments, source_hint) + if derived_ratio is None: + raise VerificationError( + "Unable to determine pixel ratio; provide pixelRatio, ppi, or include resolution/diagonal hints in comments" + ) + + existing_ratio = props.get("pixelRatio") + if existing_ratio is not None: + parsed_existing = float(existing_ratio) + if abs(parsed_existing - derived_ratio) > 0.25: + raise VerificationError( + f"skin.properties pixelRatio {parsed_existing:.6f} disagrees with derived value {derived_ratio:.6f}" + ) + else: + print(f" Derived pixel ratio from metadata: {derived_ratio:.6f}") + + _validate_override_names(props["overrideNames"]) + + platform = props["platformName"].strip() + if platform not in KNOWN_PLATFORMS: + raise VerificationError( + "skin.properties platformName must be one of " + ", ".join(sorted(KNOWN_PLATFORMS)) + f"; found '{platform}'" + ) + + +def verify_skins(report_file: Path, _unused_work_dir: Path) -> None: + generated = _load_report(report_file) + if not generated: + print("No generated skins to verify.") + return + + for entry in generated: + skin_path = Path(entry["archive"]).resolve() + if not skin_path.is_file(): + raise VerificationError(f"Skin archive not found: {skin_path}") + source_hint = entry.get("source") + print(f"Verifying skin {entry.get('skin')} at {skin_path}") + _validate_skin_archive(skin_path, source_hint) + + +def _validate_skin_archive(path: Path, source_hint: Optional[str]) -> None: + try: + with zipfile.ZipFile(path) as zf: + names = set(zf.namelist()) + missing = [name for name in REQUIRED_PNG_ENTRIES if name not in names] + if missing: + raise VerificationError(f"Skin archive missing required PNG assets: {', '.join(sorted(missing))}") + + pngs: dict[str, PngInfo] = {} + for entry in REQUIRED_PNG_ENTRIES: + data = _read_zip_entry(zf, entry) + pngs[entry] = _parse_png_info(data, entry) + + if pngs["skin.png"] != pngs["skin_map.png"]: + raise VerificationError("skin_map.png dimensions must match skin.png") + if pngs["skin_l.png"] != pngs["skin_map_l.png"]: + raise VerificationError("skin_map_l.png dimensions must match skin_l.png") + + try: + props_data = _read_zip_entry(zf, "skin.properties") + except VerificationError: + raise + else: + props, comments, _ = _parse_properties(props_data) + theme_present = any(name.lower().endswith(".res") for name in names) + if not theme_present and "nativeThemeAttribute" not in props: + raise VerificationError( + "Skin archive must provide a theme resource (.res) or define nativeThemeAttribute" + ) + _validate_properties(props, comments, source_hint) + except zipfile.BadZipFile as exc: + raise VerificationError(f"{path} is not a valid Codename One skin archive: {exc}") from exc + + +def parse_args(argv: List[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Codename One skin verification") + parser.add_argument("--report-file", type=Path, required=True, help="Path to the JSON report emitted by the generator") + parser.add_argument( + "--work-dir", + type=Path, + default=Path("tmp") / "codenameone", + help="Optional workspace retained for backwards compatibility (unused)", + ) + return parser.parse_args(argv) + + +def main(argv: List[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + try: + verify_skins(args.report_file, args.work_dir) + except VerificationError as exc: + print(f"Verification failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())