From f2133e413c8498c15f5e0a69f8e91830157d2851 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 27 Oct 2025 21:04:43 +0200 Subject: [PATCH 01/25] Improve Codename One skin verification harness --- .github/skin-generation-log.json | 4 + .github/workflows/blank.yml | 37 --- .../workflows/generate-codenameone-skins.yml | 84 +++++++ .gitignore | 73 +----- scripts/generate_missing_skins.py | 224 ++++++++++++++++++ scripts/java/SkinHarness.java | 113 +++++++++ scripts/verify_skins_with_codenameone.py | 161 +++++++++++++ 7 files changed, 590 insertions(+), 106 deletions(-) create mode 100644 .github/skin-generation-log.json delete mode 100644 .github/workflows/blank.yml create mode 100644 .github/workflows/generate-codenameone-skins.yml create mode 100755 scripts/generate_missing_skins.py create mode 100644 scripts/java/SkinHarness.java create mode 100755 scripts/verify_skins_with_codenameone.py diff --git a/.github/skin-generation-log.json b/.github/skin-generation-log.json new file mode 100644 index 0000000..53e074f --- /dev/null +++ b/.github/skin-generation-log.json @@ -0,0 +1,4 @@ +{ + "generated": "2025-10-27T00:00:00Z", + "skins": {} +} 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..ee7b22f --- /dev/null +++ b/.github/workflows/generate-codenameone-skins.yml @@ -0,0 +1,84 @@ +name: Generate Codename One skins + +on: + workflow_dispatch: + push: + paths: + - '**/*.yml' + - '**/*.yaml' + +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: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Install X virtual framebuffer + run: | + sudo apt-get update + sudo apt-get install -y xvfb + + - 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: Create pull request with generated skins + if: steps.git-status.outputs.has_changes == 'true' + 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. 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..380ce74 --- /dev/null +++ b/scripts/generate_missing_skins.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Generate Codename One skin archives for directories that are missing them. + +This script inspects the repository for directories that contain ``skin.properties`` +files (i.e. Codename One skin definitions). For each such directory it verifies that +an OTA ``.skin`` archive has not yet been captured in the metadata ledger. Missing +entries are regenerated with maximum compression and recorded in the ledger so that +future runs can skip work that has already been performed. + +The resulting archives are written to a configurable output directory (``tmp/`` by +default) so that the Git repository does not need to track large binary assets. +An optional JSON report summarises the work, which can be consumed by CI pipelines +to run additional validation steps. +""" +from __future__ import annotations + +import argparse +import dataclasses +import datetime as _dt +import hashlib +import json +from pathlib import Path +from typing import Dict, List, Tuple +from zipfile import ZIP_DEFLATED, ZipFile + +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" + +# Directories that should never be considered as skin sources. +EXCLUDED_TOP_LEVEL = {".git", "OTA", "tmp", ".github"} + +ISO_8601_Z_SUFFIX = "%Y-%m-%dT%H:%M:%SZ" + + +@dataclasses.dataclass(frozen=True) +class SkinGeneration: + """Description of a generated skin archive.""" + + name: str + source_dir: Path + archive_path: Path + + +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) + if isinstance(raw, dict): + return {str(k): dict(v) for k, v in raw.get("skins", {}).items()} + except json.JSONDecodeError: + pass + return {} + + +def _save_metadata(path: Path, records: Dict[str, Dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = {"generated": _dt.datetime.utcnow().strftime(ISO_8601_Z_SUFFIX), "skins": records} + with path.open("w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + + +def _iter_skin_directories() -> Iterable[Path]: + for properties_file in REPO_ROOT.rglob("skin.properties"): + try: + relative_parts = properties_file.relative_to(REPO_ROOT).parts + except ValueError: + continue + if relative_parts[0] in EXCLUDED_TOP_LEVEL: + continue + yield properties_file.parent + + +def _directory_fingerprint(directory: Path) -> str: + sha = hashlib.sha256() + for item in sorted(directory.iterdir()): + if item.name.startswith("."): + # Ignore hidden files such as .DS_Store. + continue + if item.is_dir(): + # Current skin layout stores assets flat, but recurse defensively. + sha.update(item.name.encode("utf-8")) + sha.update(_directory_fingerprint(item).encode("utf-8")) + continue + sha.update(item.name.encode("utf-8")) + sha.update(item.read_bytes()) + return sha.hexdigest() + + +def _zip_skin_directory(source_dir: Path, target_zip: Path) -> None: + target_zip.parent.mkdir(parents=True, exist_ok=True) + with ZipFile(target_zip, "w", compression=ZIP_DEFLATED, compresslevel=9) as zf: + for entry in sorted(source_dir.rglob("*")): + if entry.is_dir() or entry.name.startswith("."): + continue + arcname = entry.relative_to(source_dir).as_posix() + zf.write(entry, arcname=arcname) + + +def _current_utc_isoformat() -> str: + return _dt.datetime.utcnow().replace(microsecond=0).strftime(ISO_8601_Z_SUFFIX) + + +def process_skins( + *, + output_dir: Path, + metadata_path: Path, + dry_run: bool = False, + force: bool = False, +) -> Tuple[List[SkinGeneration], List[str]]: + metadata = _load_metadata(metadata_path) + generated: List[SkinGeneration] = [] + skipped: List[str] = [] + + for skin_dir in sorted(_iter_skin_directories(), key=lambda p: p.relative_to(REPO_ROOT).as_posix()): + relative_source = skin_dir.relative_to(REPO_ROOT).as_posix() + skin_name = skin_dir.name + metadata_key = relative_source + archive_path = output_dir / f"{skin_name}.skin" + fingerprint = _directory_fingerprint(skin_dir) + record = metadata.get(metadata_key) + + if not force and record and record.get("fingerprint") == fingerprint: + skipped.append(skin_name) + continue + + if dry_run: + generated.append(SkinGeneration(skin_name, skin_dir, archive_path)) + continue + + _zip_skin_directory(skin_dir, archive_path) + archive_entry = _relative_to_repo(archive_path) + metadata[metadata_key] = { + "generated_at": _current_utc_isoformat(), + "source": relative_source, + "fingerprint": fingerprint, + "archive": archive_entry, + } + generated.append(SkinGeneration(skin_name, skin_dir, archive_path)) + + if not dry_run: + ordered = dict(sorted(metadata.items())) + _save_metadata(metadata_path, ordered) + + return generated, skipped + + +def _relative_to_repo(path: Path) -> str: + try: + return path.relative_to(REPO_ROOT).as_posix() + except ValueError: + return path.resolve().as_posix() + + +def _write_report(report_path: Path, generated: List[SkinGeneration], skipped: List[str]) -> None: + report_payload = { + "generated": [ + { + "skin": entry.name, + "archive": entry.archive_path.as_posix(), + "source": entry.source_dir.relative_to(REPO_ROOT).as_posix(), + } + for entry in generated + ], + "skipped": sorted(skipped), + } + 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 = 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 args.report_file: + _write_report(args.report_file, generated, skipped) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/java/SkinHarness.java b/scripts/java/SkinHarness.java new file mode 100644 index 0000000..55818a3 --- /dev/null +++ b/scripts/java/SkinHarness.java @@ -0,0 +1,113 @@ +import com.codename1.impl.javase.Simulator; +import java.io.File; +import java.lang.reflect.Method; + +public final class SkinHarness { + private SkinHarness() {} + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + System.err.println("Usage: SkinHarness "); + System.exit(64); + return; + } + + File skinFile = new File(args[0]).getAbsoluteFile(); + if (!skinFile.isFile()) { + System.err.println("Skin file not found: " + skinFile); + System.exit(66); + return; + } + + System.setProperty("skin", skinFile.getAbsolutePath()); + + Simulator simulator = new Simulator(); + try { + invokeIfPresent(simulator.getClass(), simulator, "init"); + invokeIfPresent(simulator.getClass(), simulator, "initialize"); + + Method loadSkin = findSkinLoader(simulator.getClass()); + if (loadSkin == null) { + System.err.println("Unable to locate loadSkin method on simulator"); + System.exit(65); + return; + } + + if (loadSkin.getParameterCount() == 1) { + Class paramType = loadSkin.getParameterTypes()[0]; + loadSkin.setAccessible(true); + if (File.class.isAssignableFrom(paramType)) { + loadSkin.invoke(simulator, skinFile); + } else { + loadSkin.invoke(simulator, skinFile.getAbsolutePath()); + } + } else { + System.err.println("Unsupported loadSkin signature: " + loadSkin); + System.exit(65); + return; + } + + Method skinAccessor = findSkinAccessor(simulator.getClass()); + if (skinAccessor != null) { + skinAccessor.setAccessible(true); + Object skin = skinAccessor.invoke(simulator); + if (skin == null) { + System.err.println("Simulator did not retain loaded skin instance"); + System.exit(67); + return; + } + } + + invokeIfPresent(simulator.getClass(), simulator, "start"); + invokeIfPresent(simulator.getClass(), simulator, "stop"); + invokeIfPresent(simulator.getClass(), simulator, "startApp"); + invokeIfPresent(simulator.getClass(), simulator, "stopApp"); + invokeIfPresent(simulator.getClass(), simulator, "destroyApp"); + invokeIfPresent(simulator.getClass(), simulator, "dispose"); + invokeIfPresent(simulator.getClass(), simulator, "shutdown"); + } finally { + // Ensure simulator resources are cleaned up even if verification fails. + invokeIfPresent(simulator.getClass(), simulator, "destroyApp"); + invokeIfPresent(simulator.getClass(), simulator, "dispose"); + invokeIfPresent(simulator.getClass(), simulator, "shutdown"); + } + } + + private static void invokeIfPresent(Class type, Object instance, String methodName) throws Exception { + try { + Method method = type.getMethod(methodName); + method.setAccessible(true); + method.invoke(instance); + } catch (NoSuchMethodException ignored) { + // Method optional. + } + } + + private static Method findSkinLoader(Class type) { + for (Method method : type.getMethods()) { + if (!method.getName().equals("loadSkin")) { + continue; + } + if (method.getParameterCount() == 1) { + Class arg = method.getParameterTypes()[0]; + if (File.class.isAssignableFrom(arg) || CharSequence.class.isAssignableFrom(arg)) { + return method; + } + } + } + return null; + } + + private static Method findSkinAccessor(Class type) { + try { + return type.getMethod("getSkin"); + } catch (NoSuchMethodException ignored) { + // fall through + } + try { + return type.getMethod("getCurrentSkin"); + } catch (NoSuchMethodException ignored) { + return null; + } + } +} diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py new file mode 100755 index 0000000..3e669bd --- /dev/null +++ b/scripts/verify_skins_with_codenameone.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Verify generated Codename One skins by loading them with the JavaSE simulator.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Iterable, List +from urllib.request import urlopen + +CODENAMEONE_JAR_URL = "https://github.com/codenameone/CodenameOne/releases/latest/download/CodenameOne.jar" +JAVA_SE_PORT_JAR_URL = "https://github.com/codenameone/CodenameOne/releases/latest/download/JavaSEPort.jar" +HARNESS_SOURCE = Path(__file__).resolve().parent / "java" / "SkinHarness.java" + + +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 _ensure_artifact(target_dir: Path, url: str) -> Path: + target_dir.mkdir(parents=True, exist_ok=True) + filename = url.rsplit("/", 1)[-1] + artifact_path = target_dir / filename + if artifact_path.exists(): + return artifact_path + + with urlopen(url) as response, tempfile.NamedTemporaryFile(delete=False) as tmp: + shutil.copyfileobj(response, tmp) + tmp.flush() + tmp_path = Path(tmp.name) + + tmp_path.replace(artifact_path) + return artifact_path + + +def _find_tool(tool_name: str) -> Path: + java_home = os.environ.get("JAVA_HOME") + if java_home: + candidate = Path(java_home).expanduser().resolve() / "bin" / tool_name + if candidate.exists(): + return candidate + resolved = shutil.which(tool_name) + if resolved: + return Path(resolved).resolve() + raise VerificationError(f"{tool_name} executable not found – ensure a JDK providing {tool_name} is installed") + + +def _compile_harness(harness_path: Path, classpath: Iterable[Path], output_dir: Path) -> None: + javac = _find_tool("javac") + output_dir.mkdir(parents=True, exist_ok=True) + cp_value = os.pathsep.join(path.as_posix() for path in classpath) + cmd = [ + javac.as_posix(), + "-cp", + cp_value, + "-d", + output_dir.as_posix(), + harness_path.as_posix(), + ] + subprocess.run(cmd, check=True) + + +def _run_harness(classpath: Iterable[Path], classes_dir: Path, skin_path: Path) -> None: + java = _find_tool("java") + xvfb_run = shutil.which("xvfb-run") + if not xvfb_run: + raise VerificationError("xvfb-run is required to execute the Codename One simulator in headless mode") + + cp_entries = list(classpath) + [classes_dir] + cp_value = os.pathsep.join(path.as_posix() for path in cp_entries) + cmd = [ + Path(xvfb_run).resolve().as_posix(), + "-a", + java.as_posix(), + "-Djava.awt.headless=true", + "-cp", + cp_value, + "SkinHarness", + skin_path.as_posix(), + ] + subprocess.run(cmd, check=True) + + +def verify_skins(report_file: Path, work_dir: Path, codenameone_url: str, javase_port_url: str) -> None: + generated = _load_report(report_file) + if not generated: + print("No generated skins to verify.") + return + + if not HARNESS_SOURCE.exists(): + raise VerificationError(f"Harness source not found at {HARNESS_SOURCE}") + + work_dir.mkdir(parents=True, exist_ok=True) + artifacts_dir = work_dir / "artifacts" + classes_dir = work_dir / "classes" + codenameone_jar = _ensure_artifact(artifacts_dir, codenameone_url) + javase_port_jar = _ensure_artifact(artifacts_dir, javase_port_url) + classpath = [codenameone_jar, javase_port_jar] + + _compile_harness(HARNESS_SOURCE, classpath, classes_dir) + + 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}") + print(f"Verifying skin {entry.get('skin')} at {skin_path}") + _run_harness(classpath, classes_dir, skin_path) + + +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="Directory used to cache Codename One artifacts and compilation output", + ) + parser.add_argument( + "--codenameone-url", + default=CODENAMEONE_JAR_URL, + help="URL of the Codename One API jar to download", + ) + parser.add_argument( + "--javase-url", + default=JAVA_SE_PORT_JAR_URL, + help="URL of the Codename One JavaSEPort simulator jar to download", + ) + 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, args.codenameone_url, args.javase_url) + except VerificationError as exc: + print(f"Verification failed: {exc}", file=sys.stderr) + return 1 + except subprocess.CalledProcessError as exc: + print(f"Verification command failed with exit code {exc.returncode}", file=sys.stderr) + return exc.returncode or 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From cb3f2f6b74b648ae76cb7f3733c3208ca5dc07b5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 28 Oct 2025 05:59:21 +0200 Subject: [PATCH 02/25] Fix workflow triggers and generator imports --- .github/workflows/generate-codenameone-skins.yml | 1 + scripts/generate_missing_skins.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/generate-codenameone-skins.yml b/.github/workflows/generate-codenameone-skins.yml index ee7b22f..e371c43 100644 --- a/.github/workflows/generate-codenameone-skins.yml +++ b/.github/workflows/generate-codenameone-skins.yml @@ -4,6 +4,7 @@ on: workflow_dispatch: push: paths: + - '.github/workflows/**' - '**/*.yml' - '**/*.yaml' diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index 380ce74..044a523 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -20,7 +20,7 @@ import hashlib import json from pathlib import Path -from typing import Dict, List, Tuple +from typing import Dict, Iterable, List, Tuple from zipfile import ZIP_DEFLATED, ZipFile REPO_ROOT = Path(__file__).resolve().parents[1] From f5ca51fa1903728fbb774e1709d2cafd00afd151 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 28 Oct 2025 07:36:21 +0200 Subject: [PATCH 03/25] Quote commit message in workflow --- .github/workflows/generate-codenameone-skins.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generate-codenameone-skins.yml b/.github/workflows/generate-codenameone-skins.yml index e371c43..3eff79f 100644 --- a/.github/workflows/generate-codenameone-skins.yml +++ b/.github/workflows/generate-codenameone-skins.yml @@ -77,7 +77,7 @@ jobs: if: steps.git-status.outputs.has_changes == 'true' uses: peter-evans/create-pull-request@v6 with: - commit-message: chore: generate missing Codename One skins + commit-message: 'chore: generate missing Codename One skins' branch: automation/generate-skins title: 'chore: generate missing Codename One skins' body: | From 52bfa2a1fecd38531136da7d7f2df2d7c41a238d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:27:30 +0200 Subject: [PATCH 04/25] Use stable URLs for Codename One artifacts --- scripts/verify_skins_with_codenameone.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py index 3e669bd..e5e49f9 100755 --- a/scripts/verify_skins_with_codenameone.py +++ b/scripts/verify_skins_with_codenameone.py @@ -14,8 +14,8 @@ from typing import Iterable, List from urllib.request import urlopen -CODENAMEONE_JAR_URL = "https://github.com/codenameone/CodenameOne/releases/latest/download/CodenameOne.jar" -JAVA_SE_PORT_JAR_URL = "https://github.com/codenameone/CodenameOne/releases/latest/download/JavaSEPort.jar" +CODENAMEONE_JAR_URL = "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/CodenameOne.jar" +JAVA_SE_PORT_JAR_URL = "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/JavaSEPort.jar" HARNESS_SOURCE = Path(__file__).resolve().parent / "java" / "SkinHarness.java" @@ -39,10 +39,13 @@ def _ensure_artifact(target_dir: Path, url: str) -> Path: if artifact_path.exists(): return artifact_path - with urlopen(url) as response, tempfile.NamedTemporaryFile(delete=False) as tmp: - shutil.copyfileobj(response, tmp) - tmp.flush() - tmp_path = Path(tmp.name) + try: + with urlopen(url) as response, tempfile.NamedTemporaryFile(delete=False) as tmp: + shutil.copyfileobj(response, tmp) + tmp.flush() + tmp_path = Path(tmp.name) + except Exception as exc: # urllib raises a variety of exceptions, surface them uniformly + raise VerificationError(f"Failed to download artifact from {url}: {exc}") from exc tmp_path.replace(artifact_path) return artifact_path From 448885d84e8090ff145c185c1c2f2bea5a08906f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 29 Oct 2025 04:13:42 +0200 Subject: [PATCH 05/25] Add fallback Codename One artifact URLs --- scripts/verify_skins_with_codenameone.py | 71 ++++++++++++++++-------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py index e5e49f9..0b0eca9 100755 --- a/scripts/verify_skins_with_codenameone.py +++ b/scripts/verify_skins_with_codenameone.py @@ -14,8 +14,14 @@ from typing import Iterable, List from urllib.request import urlopen -CODENAMEONE_JAR_URL = "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/CodenameOne.jar" -JAVA_SE_PORT_JAR_URL = "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/JavaSEPort.jar" +CODENAMEONE_JAR_URLS = [ + "https://github.com/codenameone/CodenameOne/releases/latest/download/CodenameOne.jar", + "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/CodenameOne.jar", +] +JAVA_SE_PORT_JAR_URLS = [ + "https://github.com/codenameone/CodenameOne/releases/latest/download/JavaSEPort.jar", + "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/JavaSEPort.jar", +] HARNESS_SOURCE = Path(__file__).resolve().parent / "java" / "SkinHarness.java" @@ -32,23 +38,30 @@ def _load_report(path: Path) -> List[dict]: return [entry for entry in generated if isinstance(entry, dict)] -def _ensure_artifact(target_dir: Path, url: str) -> Path: +def _ensure_artifact(target_dir: Path, urls: Iterable[str]) -> Path: target_dir.mkdir(parents=True, exist_ok=True) - filename = url.rsplit("/", 1)[-1] - artifact_path = target_dir / filename - if artifact_path.exists(): + errors: list[str] = [] + for url in urls: + filename = url.rsplit("/", 1)[-1] + artifact_path = target_dir / filename + if artifact_path.exists(): + return artifact_path + + try: + with urlopen(url) as response, tempfile.NamedTemporaryFile(delete=False) as tmp: + shutil.copyfileobj(response, tmp) + tmp.flush() + tmp_path = Path(tmp.name) + except Exception as exc: # urllib raises a variety of exceptions, surface them uniformly + errors.append(f"{url}: {exc}") + continue + + tmp_path.replace(artifact_path) return artifact_path - try: - with urlopen(url) as response, tempfile.NamedTemporaryFile(delete=False) as tmp: - shutil.copyfileobj(response, tmp) - tmp.flush() - tmp_path = Path(tmp.name) - except Exception as exc: # urllib raises a variety of exceptions, surface them uniformly - raise VerificationError(f"Failed to download artifact from {url}: {exc}") from exc - - tmp_path.replace(artifact_path) - return artifact_path + raise VerificationError( + "Failed to download required artifact. Attempts: " + "; ".join(errors) if errors else "No URLs supplied" + ) def _find_tool(tool_name: str) -> Path: @@ -99,7 +112,12 @@ def _run_harness(classpath: Iterable[Path], classes_dir: Path, skin_path: Path) subprocess.run(cmd, check=True) -def verify_skins(report_file: Path, work_dir: Path, codenameone_url: str, javase_port_url: str) -> None: +def verify_skins( + report_file: Path, + work_dir: Path, + codenameone_urls: Iterable[str], + javase_port_urls: Iterable[str], +) -> None: generated = _load_report(report_file) if not generated: print("No generated skins to verify.") @@ -111,8 +129,8 @@ def verify_skins(report_file: Path, work_dir: Path, codenameone_url: str, javase work_dir.mkdir(parents=True, exist_ok=True) artifacts_dir = work_dir / "artifacts" classes_dir = work_dir / "classes" - codenameone_jar = _ensure_artifact(artifacts_dir, codenameone_url) - javase_port_jar = _ensure_artifact(artifacts_dir, javase_port_url) + codenameone_jar = _ensure_artifact(artifacts_dir, codenameone_urls) + javase_port_jar = _ensure_artifact(artifacts_dir, javase_port_urls) classpath = [codenameone_jar, javase_port_jar] _compile_harness(HARNESS_SOURCE, classpath, classes_dir) @@ -136,21 +154,26 @@ def parse_args(argv: List[str]) -> argparse.Namespace: ) parser.add_argument( "--codenameone-url", - default=CODENAMEONE_JAR_URL, - help="URL of the Codename One API jar to download", + action="append", + default=[], + help="URL of the Codename One API jar to download (can be provided multiple times)", ) parser.add_argument( "--javase-url", - default=JAVA_SE_PORT_JAR_URL, - help="URL of the Codename One JavaSEPort simulator jar to download", + action="append", + default=[], + help="URL of the Codename One JavaSEPort simulator jar to download (can be provided multiple times)", ) return parser.parse_args(argv) def main(argv: List[str] | None = None) -> int: args = parse_args(argv or sys.argv[1:]) + codenameone_urls = args.codenameone_url or CODENAMEONE_JAR_URLS + javase_urls = args.javase_url or JAVA_SE_PORT_JAR_URLS + try: - verify_skins(args.report_file, args.work_dir, args.codenameone_url, args.javase_url) + verify_skins(args.report_file, args.work_dir, codenameone_urls, javase_urls) except VerificationError as exc: print(f"Verification failed: {exc}", file=sys.stderr) return 1 From 8db87f0107fbe94f80f6cfcffe06ae7ee69be443 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:38:16 +0200 Subject: [PATCH 06/25] Trigger workflow on PR and script changes --- .github/workflows/generate-codenameone-skins.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/generate-codenameone-skins.yml b/.github/workflows/generate-codenameone-skins.yml index 3eff79f..32cceff 100644 --- a/.github/workflows/generate-codenameone-skins.yml +++ b/.github/workflows/generate-codenameone-skins.yml @@ -7,6 +7,13 @@ on: - '.github/workflows/**' - '**/*.yml' - '**/*.yaml' + - 'scripts/**' + pull_request: + paths: + - '.github/workflows/**' + - '**/*.yml' + - '**/*.yaml' + - 'scripts/**' permissions: contents: write From f753055ab8586e3df9db753282e98a1519c6573c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:48:26 +0200 Subject: [PATCH 07/25] Expand Codename One artifact fallbacks --- scripts/verify_skins_with_codenameone.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py index 0b0eca9..9b930be 100755 --- a/scripts/verify_skins_with_codenameone.py +++ b/scripts/verify_skins_with_codenameone.py @@ -12,15 +12,19 @@ import tempfile from pathlib import Path from typing import Iterable, List -from urllib.request import urlopen +from urllib.request import Request, urlopen CODENAMEONE_JAR_URLS = [ "https://github.com/codenameone/CodenameOne/releases/latest/download/CodenameOne.jar", "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/CodenameOne.jar", + "https://repo1.maven.org/maven2/com/codenameone/codenameone/7.0/codenameone-7.0.jar", + "https://repo1.maven.org/maven2/com/codenameone/codenameone/6.0/codenameone-6.0.jar", ] JAVA_SE_PORT_JAR_URLS = [ "https://github.com/codenameone/CodenameOne/releases/latest/download/JavaSEPort.jar", "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/JavaSEPort.jar", + "https://repo1.maven.org/maven2/com/codenameone/java-se/7.0/java-se-7.0.jar", + "https://repo1.maven.org/maven2/com/codenameone/java-se/6.0/java-se-6.0.jar", ] HARNESS_SOURCE = Path(__file__).resolve().parent / "java" / "SkinHarness.java" @@ -48,7 +52,8 @@ def _ensure_artifact(target_dir: Path, urls: Iterable[str]) -> Path: return artifact_path try: - with urlopen(url) as response, tempfile.NamedTemporaryFile(delete=False) as tmp: + request = Request(url, headers={"User-Agent": "codenameone-skin-verifier/1.0"}) + with urlopen(request) as response, tempfile.NamedTemporaryFile(delete=False) as tmp: shutil.copyfileobj(response, tmp) tmp.flush() tmp_path = Path(tmp.name) From 36214c6f3b1d3a46fd8b5bf1a3d7ffdb62811297 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:00:16 +0200 Subject: [PATCH 08/25] Adjust workflow triggers and Codename One fallbacks --- .github/workflows/generate-codenameone-skins.yml | 3 +++ scripts/verify_skins_with_codenameone.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.github/workflows/generate-codenameone-skins.yml b/.github/workflows/generate-codenameone-skins.yml index 32cceff..0ccf48e 100644 --- a/.github/workflows/generate-codenameone-skins.yml +++ b/.github/workflows/generate-codenameone-skins.yml @@ -3,6 +3,9 @@ name: Generate Codename One skins on: workflow_dispatch: push: + branches: + - main + - master paths: - '.github/workflows/**' - '**/*.yml' diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py index 9b930be..837cb41 100755 --- a/scripts/verify_skins_with_codenameone.py +++ b/scripts/verify_skins_with_codenameone.py @@ -17,12 +17,14 @@ CODENAMEONE_JAR_URLS = [ "https://github.com/codenameone/CodenameOne/releases/latest/download/CodenameOne.jar", "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/CodenameOne.jar", + "https://repo1.maven.org/maven2/com/codenameone/codenameone/7.0.208/codenameone-7.0.208.jar", "https://repo1.maven.org/maven2/com/codenameone/codenameone/7.0/codenameone-7.0.jar", "https://repo1.maven.org/maven2/com/codenameone/codenameone/6.0/codenameone-6.0.jar", ] JAVA_SE_PORT_JAR_URLS = [ "https://github.com/codenameone/CodenameOne/releases/latest/download/JavaSEPort.jar", "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/JavaSEPort.jar", + "https://repo1.maven.org/maven2/com/codenameone/java-se/7.0.208/java-se-7.0.208.jar", "https://repo1.maven.org/maven2/com/codenameone/java-se/7.0/java-se-7.0.jar", "https://repo1.maven.org/maven2/com/codenameone/java-se/6.0/java-se-6.0.jar", ] From 09b437b270979d0a83c2dee3364f83b1e1d774b9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:07:28 +0200 Subject: [PATCH 09/25] Point verifier to Codename One 7.0.209 jars --- scripts/verify_skins_with_codenameone.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py index 837cb41..da9f781 100755 --- a/scripts/verify_skins_with_codenameone.py +++ b/scripts/verify_skins_with_codenameone.py @@ -15,16 +15,18 @@ from urllib.request import Request, urlopen CODENAMEONE_JAR_URLS = [ + "https://repo1.maven.org/maven2/com/codenameone/codenameone-core/7.0.209/codenameone-core-7.0.209.jar", + "https://repo1.maven.org/maven2/com/codenameone/codenameone-core/7.0.208/codenameone-core-7.0.208.jar", "https://github.com/codenameone/CodenameOne/releases/latest/download/CodenameOne.jar", "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/CodenameOne.jar", - "https://repo1.maven.org/maven2/com/codenameone/codenameone/7.0.208/codenameone-7.0.208.jar", "https://repo1.maven.org/maven2/com/codenameone/codenameone/7.0/codenameone-7.0.jar", "https://repo1.maven.org/maven2/com/codenameone/codenameone/6.0/codenameone-6.0.jar", ] JAVA_SE_PORT_JAR_URLS = [ + "https://repo1.maven.org/maven2/com/codenameone/codenameone-javase/7.0.209/codenameone-javase-7.0.209.jar", + "https://repo1.maven.org/maven2/com/codenameone/codenameone-javase/7.0.208/codenameone-javase-7.0.208.jar", "https://github.com/codenameone/CodenameOne/releases/latest/download/JavaSEPort.jar", "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/JavaSEPort.jar", - "https://repo1.maven.org/maven2/com/codenameone/java-se/7.0.208/java-se-7.0.208.jar", "https://repo1.maven.org/maven2/com/codenameone/java-se/7.0/java-se-7.0.jar", "https://repo1.maven.org/maven2/com/codenameone/java-se/6.0/java-se-6.0.jar", ] From effa390db93cdcaa97dc390a082f895a3f5a2053 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:14:42 +0200 Subject: [PATCH 10/25] Handle Simulator loadSkin overloads --- scripts/java/SkinHarness.java | 93 +++++++++++++++++++++++++++-------- 1 file changed, 73 insertions(+), 20 deletions(-) diff --git a/scripts/java/SkinHarness.java b/scripts/java/SkinHarness.java index 55818a3..379d6b0 100644 --- a/scripts/java/SkinHarness.java +++ b/scripts/java/SkinHarness.java @@ -33,19 +33,7 @@ public static void main(String[] args) throws Exception { return; } - if (loadSkin.getParameterCount() == 1) { - Class paramType = loadSkin.getParameterTypes()[0]; - loadSkin.setAccessible(true); - if (File.class.isAssignableFrom(paramType)) { - loadSkin.invoke(simulator, skinFile); - } else { - loadSkin.invoke(simulator, skinFile.getAbsolutePath()); - } - } else { - System.err.println("Unsupported loadSkin signature: " + loadSkin); - System.exit(65); - return; - } + invokeSkinLoader(loadSkin, simulator, skinFile); Method skinAccessor = findSkinAccessor(simulator.getClass()); if (skinAccessor != null) { @@ -83,21 +71,86 @@ private static void invokeIfPresent(Class type, Object instance, String metho } } + private static void invokeSkinLoader(Method loadSkin, Simulator simulator, File skinFile) throws Exception { + int parameterCount = loadSkin.getParameterCount(); + Class[] paramTypes = loadSkin.getParameterTypes(); + Object firstArg; + if (File.class.isAssignableFrom(paramTypes[0])) { + firstArg = skinFile; + } else { + firstArg = skinFile.getAbsolutePath(); + } + + loadSkin.setAccessible(true); + + if (parameterCount == 1) { + loadSkin.invoke(simulator, firstArg); + return; + } + + if (parameterCount == 2 && isBooleanType(paramTypes[1])) { + Object secondArg = paramTypes[1].isPrimitive() ? false : Boolean.FALSE; + loadSkin.invoke(simulator, firstArg, secondArg); + return; + } + + System.err.println("Unsupported loadSkin signature: " + loadSkin); + System.exit(65); + } + private static Method findSkinLoader(Class type) { - for (Method method : type.getMethods()) { - if (!method.getName().equals("loadSkin")) { - continue; + Method method = findSkinLoaderInHierarchy(type); + if (method != null) { + return method; + } + for (Method candidate : type.getMethods()) { + if (isSkinLoader(candidate)) { + return candidate; } - if (method.getParameterCount() == 1) { - Class arg = method.getParameterTypes()[0]; - if (File.class.isAssignableFrom(arg) || CharSequence.class.isAssignableFrom(arg)) { - return method; + } + return null; + } + + private static Method findSkinLoaderInHierarchy(Class type) { + Class current = type; + while (current != null) { + for (Method candidate : current.getDeclaredMethods()) { + if (isSkinLoader(candidate)) { + candidate.setAccessible(true); + return candidate; } } + current = current.getSuperclass(); } return null; } + private static boolean isSkinLoader(Method method) { + String name = method.getName(); + if (!name.equals("loadSkin") && !name.equals("loadSkinFromFile")) { + return false; + } + + int count = method.getParameterCount(); + if (count == 1) { + Class arg = method.getParameterTypes()[0]; + return File.class.isAssignableFrom(arg) || CharSequence.class.isAssignableFrom(arg); + } + + if (count == 2) { + Class[] params = method.getParameterTypes(); + boolean firstValid = File.class.isAssignableFrom(params[0]) || CharSequence.class.isAssignableFrom(params[0]); + boolean secondValid = isBooleanType(params[1]); + return firstValid && secondValid; + } + + return false; + } + + private static boolean isBooleanType(Class type) { + return type == boolean.class || type == Boolean.class; + } + private static Method findSkinAccessor(Class type) { try { return type.getMethod("getSkin"); From b86f4f81f5920fa65d17be4286eff88c158c7b64 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:30:41 +0200 Subject: [PATCH 11/25] Handle additional loadSkin booleans --- scripts/java/SkinHarness.java | 49 +++++++++++++++++------------------ 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/scripts/java/SkinHarness.java b/scripts/java/SkinHarness.java index 379d6b0..cb02ad8 100644 --- a/scripts/java/SkinHarness.java +++ b/scripts/java/SkinHarness.java @@ -72,30 +72,27 @@ private static void invokeIfPresent(Class type, Object instance, String metho } private static void invokeSkinLoader(Method loadSkin, Simulator simulator, File skinFile) throws Exception { - int parameterCount = loadSkin.getParameterCount(); Class[] paramTypes = loadSkin.getParameterTypes(); - Object firstArg; + Object[] args = new Object[paramTypes.length]; + if (File.class.isAssignableFrom(paramTypes[0])) { - firstArg = skinFile; + args[0] = skinFile; } else { - firstArg = skinFile.getAbsolutePath(); - } - - loadSkin.setAccessible(true); - - if (parameterCount == 1) { - loadSkin.invoke(simulator, firstArg); - return; + args[0] = skinFile.getAbsolutePath(); } - if (parameterCount == 2 && isBooleanType(paramTypes[1])) { - Object secondArg = paramTypes[1].isPrimitive() ? false : Boolean.FALSE; - loadSkin.invoke(simulator, firstArg, secondArg); - return; + for (int i = 1; i < paramTypes.length; i++) { + Class type = paramTypes[i]; + if (!isBooleanType(type)) { + System.err.println("Unsupported loadSkin parameter type at index " + i + ": " + type.getName()); + System.exit(65); + return; + } + args[i] = type.isPrimitive() ? false : Boolean.FALSE; } - System.err.println("Unsupported loadSkin signature: " + loadSkin); - System.exit(65); + loadSkin.setAccessible(true); + loadSkin.invoke(simulator, args); } private static Method findSkinLoader(Class type) { @@ -132,16 +129,18 @@ private static boolean isSkinLoader(Method method) { } int count = method.getParameterCount(); - if (count == 1) { - Class arg = method.getParameterTypes()[0]; - return File.class.isAssignableFrom(arg) || CharSequence.class.isAssignableFrom(arg); - } - - if (count == 2) { + if (count >= 1) { Class[] params = method.getParameterTypes(); boolean firstValid = File.class.isAssignableFrom(params[0]) || CharSequence.class.isAssignableFrom(params[0]); - boolean secondValid = isBooleanType(params[1]); - return firstValid && secondValid; + if (!firstValid) { + return false; + } + for (int i = 1; i < count; i++) { + if (!isBooleanType(params[i])) { + return false; + } + } + return true; } return false; From 96b61a92c8fab14df12d834b218865612243259d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:46:36 +0200 Subject: [PATCH 12/25] Refactor skin verification to inspect archives --- .../workflows/generate-codenameone-skins.yml | 11 - scripts/java/SkinHarness.java | 165 ---------- scripts/verify_skins_with_codenameone.py | 302 ++++++++++-------- 3 files changed, 165 insertions(+), 313 deletions(-) delete mode 100644 scripts/java/SkinHarness.java diff --git a/.github/workflows/generate-codenameone-skins.yml b/.github/workflows/generate-codenameone-skins.yml index 0ccf48e..7d7d97d 100644 --- a/.github/workflows/generate-codenameone-skins.yml +++ b/.github/workflows/generate-codenameone-skins.yml @@ -37,17 +37,6 @@ jobs: with: python-version: '3.x' - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - - name: Install X virtual framebuffer - run: | - sudo apt-get update - sudo apt-get install -y xvfb - - name: Generate missing Codename One skins run: | python scripts/generate_missing_skins.py \ diff --git a/scripts/java/SkinHarness.java b/scripts/java/SkinHarness.java deleted file mode 100644 index cb02ad8..0000000 --- a/scripts/java/SkinHarness.java +++ /dev/null @@ -1,165 +0,0 @@ -import com.codename1.impl.javase.Simulator; -import java.io.File; -import java.lang.reflect.Method; - -public final class SkinHarness { - private SkinHarness() {} - - public static void main(String[] args) throws Exception { - if (args.length != 1) { - System.err.println("Usage: SkinHarness "); - System.exit(64); - return; - } - - File skinFile = new File(args[0]).getAbsoluteFile(); - if (!skinFile.isFile()) { - System.err.println("Skin file not found: " + skinFile); - System.exit(66); - return; - } - - System.setProperty("skin", skinFile.getAbsolutePath()); - - Simulator simulator = new Simulator(); - try { - invokeIfPresent(simulator.getClass(), simulator, "init"); - invokeIfPresent(simulator.getClass(), simulator, "initialize"); - - Method loadSkin = findSkinLoader(simulator.getClass()); - if (loadSkin == null) { - System.err.println("Unable to locate loadSkin method on simulator"); - System.exit(65); - return; - } - - invokeSkinLoader(loadSkin, simulator, skinFile); - - Method skinAccessor = findSkinAccessor(simulator.getClass()); - if (skinAccessor != null) { - skinAccessor.setAccessible(true); - Object skin = skinAccessor.invoke(simulator); - if (skin == null) { - System.err.println("Simulator did not retain loaded skin instance"); - System.exit(67); - return; - } - } - - invokeIfPresent(simulator.getClass(), simulator, "start"); - invokeIfPresent(simulator.getClass(), simulator, "stop"); - invokeIfPresent(simulator.getClass(), simulator, "startApp"); - invokeIfPresent(simulator.getClass(), simulator, "stopApp"); - invokeIfPresent(simulator.getClass(), simulator, "destroyApp"); - invokeIfPresent(simulator.getClass(), simulator, "dispose"); - invokeIfPresent(simulator.getClass(), simulator, "shutdown"); - } finally { - // Ensure simulator resources are cleaned up even if verification fails. - invokeIfPresent(simulator.getClass(), simulator, "destroyApp"); - invokeIfPresent(simulator.getClass(), simulator, "dispose"); - invokeIfPresent(simulator.getClass(), simulator, "shutdown"); - } - } - - private static void invokeIfPresent(Class type, Object instance, String methodName) throws Exception { - try { - Method method = type.getMethod(methodName); - method.setAccessible(true); - method.invoke(instance); - } catch (NoSuchMethodException ignored) { - // Method optional. - } - } - - private static void invokeSkinLoader(Method loadSkin, Simulator simulator, File skinFile) throws Exception { - Class[] paramTypes = loadSkin.getParameterTypes(); - Object[] args = new Object[paramTypes.length]; - - if (File.class.isAssignableFrom(paramTypes[0])) { - args[0] = skinFile; - } else { - args[0] = skinFile.getAbsolutePath(); - } - - for (int i = 1; i < paramTypes.length; i++) { - Class type = paramTypes[i]; - if (!isBooleanType(type)) { - System.err.println("Unsupported loadSkin parameter type at index " + i + ": " + type.getName()); - System.exit(65); - return; - } - args[i] = type.isPrimitive() ? false : Boolean.FALSE; - } - - loadSkin.setAccessible(true); - loadSkin.invoke(simulator, args); - } - - private static Method findSkinLoader(Class type) { - Method method = findSkinLoaderInHierarchy(type); - if (method != null) { - return method; - } - for (Method candidate : type.getMethods()) { - if (isSkinLoader(candidate)) { - return candidate; - } - } - return null; - } - - private static Method findSkinLoaderInHierarchy(Class type) { - Class current = type; - while (current != null) { - for (Method candidate : current.getDeclaredMethods()) { - if (isSkinLoader(candidate)) { - candidate.setAccessible(true); - return candidate; - } - } - current = current.getSuperclass(); - } - return null; - } - - private static boolean isSkinLoader(Method method) { - String name = method.getName(); - if (!name.equals("loadSkin") && !name.equals("loadSkinFromFile")) { - return false; - } - - int count = method.getParameterCount(); - if (count >= 1) { - Class[] params = method.getParameterTypes(); - boolean firstValid = File.class.isAssignableFrom(params[0]) || CharSequence.class.isAssignableFrom(params[0]); - if (!firstValid) { - return false; - } - for (int i = 1; i < count; i++) { - if (!isBooleanType(params[i])) { - return false; - } - } - return true; - } - - return false; - } - - private static boolean isBooleanType(Class type) { - return type == boolean.class || type == Boolean.class; - } - - private static Method findSkinAccessor(Class type) { - try { - return type.getMethod("getSkin"); - } catch (NoSuchMethodException ignored) { - // fall through - } - try { - return type.getMethod("getCurrentSkin"); - } catch (NoSuchMethodException ignored) { - return null; - } - } -} diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py index da9f781..3c2fbe7 100755 --- a/scripts/verify_skins_with_codenameone.py +++ b/scripts/verify_skins_with_codenameone.py @@ -1,36 +1,50 @@ #!/usr/bin/env python3 -"""Verify generated Codename One skins by loading them with the JavaSE simulator.""" +"""Validate generated Codename One skin archives.""" from __future__ import annotations import argparse import json -import os -import shutil -import subprocess import sys -import tempfile +import zipfile +from configparser import ConfigParser +from dataclasses import dataclass from pathlib import Path -from typing import Iterable, List -from urllib.request import Request, urlopen - -CODENAMEONE_JAR_URLS = [ - "https://repo1.maven.org/maven2/com/codenameone/codenameone-core/7.0.209/codenameone-core-7.0.209.jar", - "https://repo1.maven.org/maven2/com/codenameone/codenameone-core/7.0.208/codenameone-core-7.0.208.jar", - "https://github.com/codenameone/CodenameOne/releases/latest/download/CodenameOne.jar", - "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/CodenameOne.jar", - "https://repo1.maven.org/maven2/com/codenameone/codenameone/7.0/codenameone-7.0.jar", - "https://repo1.maven.org/maven2/com/codenameone/codenameone/6.0/codenameone-6.0.jar", -] -JAVA_SE_PORT_JAR_URLS = [ - "https://repo1.maven.org/maven2/com/codenameone/codenameone-javase/7.0.209/codenameone-javase-7.0.209.jar", - "https://repo1.maven.org/maven2/com/codenameone/codenameone-javase/7.0.208/codenameone-javase-7.0.208.jar", - "https://github.com/codenameone/CodenameOne/releases/latest/download/JavaSEPort.jar", - "https://raw.githubusercontent.com/codenameone/CodenameOne/master/dist/JavaSEPort.jar", - "https://repo1.maven.org/maven2/com/codenameone/java-se/7.0/java-se-7.0.jar", - "https://repo1.maven.org/maven2/com/codenameone/java-se/6.0/java-se-6.0.jar", -] -HARNESS_SOURCE = Path(__file__).resolve().parent / "java" / "SkinHarness.java" +from typing import List + +REQUIRED_PNG_ENTRIES = ( + "skin.png", + "skin_l.png", + "skin_map.png", + "skin_map_l.png", +) +REQUIRED_PROPERTIES = ( + "touch", + "platformName", + "tablet", + "systemFontFamily", + "proportionalFontFamily", + "monospaceFontFamily", + "smallFontSize", + "mediumFontSize", + "largeFontSize", + "pixelRatio", + "overrideNames", +) +KNOWN_THEME_FILES = { + "iOS7Theme.res", + "iPhoneTheme.res", + "android_holo_light.res", + "androidTheme.res", + "winTheme.res", +} +KNOWN_PLATFORMS = {"ios", "and", "win", "rim", "se"} + + +@dataclass +class PngInfo: + width: int + height: int class VerificationError(RuntimeError): @@ -46,110 +60,142 @@ def _load_report(path: Path) -> List[dict]: return [entry for entry in generated if isinstance(entry, dict)] -def _ensure_artifact(target_dir: Path, urls: Iterable[str]) -> Path: - target_dir.mkdir(parents=True, exist_ok=True) - errors: list[str] = [] - for url in urls: - filename = url.rsplit("/", 1)[-1] - artifact_path = target_dir / filename - if artifact_path.exists(): - return artifact_path - - try: - request = Request(url, headers={"User-Agent": "codenameone-skin-verifier/1.0"}) - with urlopen(request) as response, tempfile.NamedTemporaryFile(delete=False) as tmp: - shutil.copyfileobj(response, tmp) - tmp.flush() - tmp_path = Path(tmp.name) - except Exception as exc: # urllib raises a variety of exceptions, surface them uniformly - errors.append(f"{url}: {exc}") - continue - - tmp_path.replace(artifact_path) - return artifact_path - - raise VerificationError( - "Failed to download required artifact. Attempts: " + "; ".join(errors) if errors else "No URLs supplied" - ) +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 _load_properties(data: bytes) -> dict[str, str]: + parser = ConfigParser() + parser.optionxform = str # preserve key case + try: + parser.read_string("[DEFAULT]\n" + data.decode("utf-8")) + except Exception as exc: + raise VerificationError(f"Unable to parse skin.properties: {exc}") from exc + return dict(parser["DEFAULT"]) + + +def _validate_override_names(raw_value: str) -> None: + parts = [part.strip() for part in raw_value.split(",") if part.strip()] + if len(parts) != 3: + raise VerificationError( + "skin.properties overrideNames must contain three comma-separated values (e.g. phone,ios,iphone)" + ) + + +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 _find_tool(tool_name: str) -> Path: - java_home = os.environ.get("JAVA_HOME") - if java_home: - candidate = Path(java_home).expanduser().resolve() / "bin" / tool_name - if candidate.exists(): - return candidate - resolved = shutil.which(tool_name) - if resolved: - return Path(resolved).resolve() - raise VerificationError(f"{tool_name} executable not found – ensure a JDK providing {tool_name} is installed") - - -def _compile_harness(harness_path: Path, classpath: Iterable[Path], output_dir: Path) -> None: - javac = _find_tool("javac") - output_dir.mkdir(parents=True, exist_ok=True) - cp_value = os.pathsep.join(path.as_posix() for path in classpath) - cmd = [ - javac.as_posix(), - "-cp", - cp_value, - "-d", - output_dir.as_posix(), - harness_path.as_posix(), - ] - subprocess.run(cmd, check=True) - - -def _run_harness(classpath: Iterable[Path], classes_dir: Path, skin_path: Path) -> None: - java = _find_tool("java") - xvfb_run = shutil.which("xvfb-run") - if not xvfb_run: - raise VerificationError("xvfb-run is required to execute the Codename One simulator in headless mode") - - cp_entries = list(classpath) + [classes_dir] - cp_value = os.pathsep.join(path.as_posix() for path in cp_entries) - cmd = [ - Path(xvfb_run).resolve().as_posix(), - "-a", - java.as_posix(), - "-Djava.awt.headless=true", - "-cp", - cp_value, - "SkinHarness", - skin_path.as_posix(), - ] - subprocess.run(cmd, check=True) - - -def verify_skins( - report_file: Path, - work_dir: Path, - codenameone_urls: Iterable[str], - javase_port_urls: Iterable[str], -) -> None: +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 _validate_properties(props: dict[str, str]) -> None: + missing = [key for key in REQUIRED_PROPERTIES if key not in props] + if missing: + raise VerificationError("skin.properties missing required keys: " + ", ".join(sorted(missing))) + + _ensure_bool("touch", props["touch"]) + _ensure_bool("tablet", props["tablet"]) + _ensure_non_empty("systemFontFamily", props["systemFontFamily"]) + _ensure_non_empty("proportionalFontFamily", props["proportionalFontFamily"]) + _ensure_non_empty("monospaceFontFamily", props["monospaceFontFamily"]) + _ensure_int("smallFontSize", props["smallFontSize"]) + _ensure_int("mediumFontSize", props["mediumFontSize"]) + _ensure_int("largeFontSize", props["largeFontSize"]) + _ensure_float("pixelRatio", props["pixelRatio"], minimum=0.0) + _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 - if not HARNESS_SOURCE.exists(): - raise VerificationError(f"Harness source not found at {HARNESS_SOURCE}") - - work_dir.mkdir(parents=True, exist_ok=True) - artifacts_dir = work_dir / "artifacts" - classes_dir = work_dir / "classes" - codenameone_jar = _ensure_artifact(artifacts_dir, codenameone_urls) - javase_port_jar = _ensure_artifact(artifacts_dir, javase_port_urls) - classpath = [codenameone_jar, javase_port_jar] - - _compile_harness(HARNESS_SOURCE, classpath, classes_dir) - 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}") print(f"Verifying skin {entry.get('skin')} at {skin_path}") - _run_harness(classpath, classes_dir, skin_path) + _validate_skin_archive(skin_path) + + +def _validate_skin_archive(path: Path) -> 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") + + theme_present = any(name in names for name in KNOWN_THEME_FILES) + if not theme_present: + raise VerificationError( + "Skin archive is missing a supported theme resource (expected one of: " + + ", ".join(sorted(KNOWN_THEME_FILES)) + + ")" + ) + + try: + props_data = _read_zip_entry(zf, "skin.properties") + except VerificationError: + raise + else: + props = _load_properties(props_data) + _validate_properties(props) + 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: @@ -159,36 +205,18 @@ def parse_args(argv: List[str]) -> argparse.Namespace: "--work-dir", type=Path, default=Path("tmp") / "codenameone", - help="Directory used to cache Codename One artifacts and compilation output", - ) - parser.add_argument( - "--codenameone-url", - action="append", - default=[], - help="URL of the Codename One API jar to download (can be provided multiple times)", - ) - parser.add_argument( - "--javase-url", - action="append", - default=[], - help="URL of the Codename One JavaSEPort simulator jar to download (can be provided multiple times)", + 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:]) - codenameone_urls = args.codenameone_url or CODENAMEONE_JAR_URLS - javase_urls = args.javase_url or JAVA_SE_PORT_JAR_URLS - try: - verify_skins(args.report_file, args.work_dir, codenameone_urls, javase_urls) + verify_skins(args.report_file, args.work_dir) except VerificationError as exc: print(f"Verification failed: {exc}", file=sys.stderr) return 1 - except subprocess.CalledProcessError as exc: - print(f"Verification command failed with exit code {exc.returncode}", file=sys.stderr) - return exc.returncode or 1 return 0 From b48624dc08d0e178f4ce8846b26d0ea93d6317ec Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:23:17 +0200 Subject: [PATCH 13/25] Relax skin property validation for legacy archives --- scripts/verify_skins_with_codenameone.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py index 3c2fbe7..9ffcbd6 100755 --- a/scripts/verify_skins_with_codenameone.py +++ b/scripts/verify_skins_with_codenameone.py @@ -28,7 +28,6 @@ "smallFontSize", "mediumFontSize", "largeFontSize", - "pixelRatio", "overrideNames", ) KNOWN_THEME_FILES = { @@ -137,7 +136,9 @@ def _validate_properties(props: dict[str, str]) -> None: _ensure_int("smallFontSize", props["smallFontSize"]) _ensure_int("mediumFontSize", props["mediumFontSize"]) _ensure_int("largeFontSize", props["largeFontSize"]) - _ensure_float("pixelRatio", props["pixelRatio"], minimum=0.0) + pixel_ratio = props.get("pixelRatio") + if pixel_ratio is not None: + _ensure_float("pixelRatio", pixel_ratio, minimum=0.0) _validate_override_names(props["overrideNames"]) platform = props["platformName"].strip() From acc503add40f0091fef736a18e4584223500c6d7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 17:37:59 +0200 Subject: [PATCH 14/25] Improve skin verification with pixel ratio inference --- scripts/generate_missing_skins.py | 118 +++++++++++++- scripts/verify_skins_with_codenameone.py | 197 +++++++++++++++++------ 2 files changed, 262 insertions(+), 53 deletions(-) diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index 044a523..fd7a97e 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -19,6 +19,8 @@ import datetime as _dt import hashlib import json +import math +import re from pathlib import Path from typing import Dict, Iterable, List, Tuple from zipfile import ZIP_DEFLATED, ZipFile @@ -89,14 +91,123 @@ def _directory_fingerprint(directory: Path) -> str: return sha.hexdigest() -def _zip_skin_directory(source_dir: Path, target_zip: Path) -> None: +MANUAL_PIXEL_RATIOS = { + "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, +} + + +def _parse_skin_properties(text: str) -> Tuple[Dict[str, str], List[str]]: + props: Dict[str, str] = {} + comments: List[str] = [] + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("#"): + comments.append(stripped) + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if key not in props: + props[key] = value + return props, comments + + +def _derive_pixel_ratio(props: Dict[str, str], comments: List[str], source_hint: str) -> float | None: + if "pixelRatio" in props: + try: + return float(props["pixelRatio"]) + except ValueError: + return None + + def _parse_float(value: str) -> float | None: + try: + parsed = float(value) + except (TypeError, 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: + ratio = MANUAL_PIXEL_RATIOS.get(source_hint) + return ratio + + return ppi / 25.4 + + +def _ensure_trailing_newline(text: str) -> str: + if text.endswith("\n"): + return text + if text.endswith("\r\n"): + return text + return text + "\n" + + +def _maybe_augment_skin_properties(text: str, source_hint: str) -> str: + props, comments = _parse_skin_properties(text) + ratio = _derive_pixel_ratio(props, comments, source_hint) + if ratio is None or "pixelRatio" in props: + return _ensure_trailing_newline(text) + + formatted = f"pixelRatio={ratio:.12f}".rstrip("0").rstrip(".") + base = _ensure_trailing_newline(text) + if not base.endswith(("\n", "\r\n")): + base += "\n" + return base + formatted + "\n" + + +def _zip_skin_directory(source_dir: Path, target_zip: Path, source_hint: str) -> None: target_zip.parent.mkdir(parents=True, exist_ok=True) with ZipFile(target_zip, "w", compression=ZIP_DEFLATED, compresslevel=9) as zf: for entry in sorted(source_dir.rglob("*")): if entry.is_dir() or entry.name.startswith("."): continue arcname = entry.relative_to(source_dir).as_posix() - zf.write(entry, arcname=arcname) + if arcname == "skin.properties": + original = entry.read_text(encoding="utf-8", errors="replace") + updated = _maybe_augment_skin_properties(original, source_hint) + zf.writestr(arcname, updated) + else: + zf.write(entry, arcname=arcname) def _current_utc_isoformat() -> str: @@ -130,7 +241,8 @@ def process_skins( generated.append(SkinGeneration(skin_name, skin_dir, archive_path)) continue - _zip_skin_directory(skin_dir, archive_path) + source_hint = relative_source + _zip_skin_directory(skin_dir, archive_path, source_hint) archive_entry = _relative_to_repo(archive_path) metadata[metadata_key] = { "generated_at": _current_utc_isoformat(), diff --git a/scripts/verify_skins_with_codenameone.py b/scripts/verify_skins_with_codenameone.py index 9ffcbd6..54a2c06 100755 --- a/scripts/verify_skins_with_codenameone.py +++ b/scripts/verify_skins_with_codenameone.py @@ -5,12 +5,13 @@ import argparse import json +import math +import re import sys import zipfile -from configparser import ConfigParser from dataclasses import dataclass from pathlib import Path -from typing import List +from typing import Dict, Iterable, List, Optional, Tuple REQUIRED_PNG_ENTRIES = ( "skin.png", @@ -18,26 +19,24 @@ "skin_map.png", "skin_map_l.png", ) -REQUIRED_PROPERTIES = ( +ESSENTIAL_PROPERTIES = ( "touch", "platformName", - "tablet", - "systemFontFamily", - "proportionalFontFamily", - "monospaceFontFamily", - "smallFontSize", - "mediumFontSize", - "largeFontSize", "overrideNames", ) -KNOWN_THEME_FILES = { - "iOS7Theme.res", - "iPhoneTheme.res", - "android_holo_light.res", - "androidTheme.res", - "winTheme.res", +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, } -KNOWN_PLATFORMS = {"ios", "and", "win", "rim", "se"} @dataclass @@ -77,21 +76,36 @@ def _parse_png_info(data: bytes, entry_name: str) -> PngInfo: return PngInfo(width=width, height=height) -def _load_properties(data: bytes) -> dict[str, str]: - parser = ConfigParser() - parser.optionxform = str # preserve key case +def _parse_properties(data: bytes) -> Tuple[Dict[str, str], List[str], str]: try: - parser.read_string("[DEFAULT]\n" + data.decode("utf-8")) - except Exception as exc: - raise VerificationError(f"Unable to parse skin.properties: {exc}") from exc - return dict(parser["DEFAULT"]) + 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) != 3: + if len(parts) < 2: raise VerificationError( - "skin.properties overrideNames must contain three comma-separated values (e.g. phone,ios,iphone)" + "skin.properties overrideNames must contain at least two comma-separated values" ) @@ -123,22 +137,107 @@ def _ensure_non_empty(name: str, value: str) -> None: raise VerificationError(f"skin.properties {name} must not be empty") -def _validate_properties(props: dict[str, str]) -> None: - missing = [key for key in REQUIRED_PROPERTIES if key not in props] +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"]) - _ensure_bool("tablet", props["tablet"]) - _ensure_non_empty("systemFontFamily", props["systemFontFamily"]) - _ensure_non_empty("proportionalFontFamily", props["proportionalFontFamily"]) - _ensure_non_empty("monospaceFontFamily", props["monospaceFontFamily"]) - _ensure_int("smallFontSize", props["smallFontSize"]) - _ensure_int("mediumFontSize", props["mediumFontSize"]) - _ensure_int("largeFontSize", props["largeFontSize"]) - pixel_ratio = props.get("pixelRatio") - if pixel_ratio is not None: - _ensure_float("pixelRatio", pixel_ratio, minimum=0.0) + 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() @@ -158,11 +257,12 @@ def verify_skins(report_file: Path, _unused_work_dir: Path) -> None: 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) + _validate_skin_archive(skin_path, source_hint) -def _validate_skin_archive(path: Path) -> None: +def _validate_skin_archive(path: Path, source_hint: Optional[str]) -> None: try: with zipfile.ZipFile(path) as zf: names = set(zf.namelist()) @@ -180,21 +280,18 @@ def _validate_skin_archive(path: Path) -> None: if pngs["skin_l.png"] != pngs["skin_map_l.png"]: raise VerificationError("skin_map_l.png dimensions must match skin_l.png") - theme_present = any(name in names for name in KNOWN_THEME_FILES) - if not theme_present: - raise VerificationError( - "Skin archive is missing a supported theme resource (expected one of: " - + ", ".join(sorted(KNOWN_THEME_FILES)) - + ")" - ) - try: props_data = _read_zip_entry(zf, "skin.properties") except VerificationError: raise else: - props = _load_properties(props_data) - _validate_properties(props) + 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 From e07a00ea5364642746b65c37f86b1a669dde5ee6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:02:32 +0200 Subject: [PATCH 15/25] Avoid duplicate PR creation on pull request runs --- .github/workflows/generate-codenameone-skins.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/generate-codenameone-skins.yml b/.github/workflows/generate-codenameone-skins.yml index 7d7d97d..e9771cb 100644 --- a/.github/workflows/generate-codenameone-skins.yml +++ b/.github/workflows/generate-codenameone-skins.yml @@ -73,7 +73,7 @@ jobs: fi - name: Create pull request with generated skins - if: steps.git-status.outputs.has_changes == 'true' + 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' @@ -82,3 +82,8 @@ jobs: 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." From 352a0bd50c49201e862e0749d20eeeea84b91bc5 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 18:27:40 +0200 Subject: [PATCH 16/25] Upload generated skins artifact in CI --- .github/workflows/generate-codenameone-skins.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/generate-codenameone-skins.yml b/.github/workflows/generate-codenameone-skins.yml index e9771cb..39e1eb1 100644 --- a/.github/workflows/generate-codenameone-skins.yml +++ b/.github/workflows/generate-codenameone-skins.yml @@ -72,6 +72,13 @@ jobs: 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 From 6a920c23dd5f7f02122b2eea3be23c1c3ac31fdf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:39:52 +0200 Subject: [PATCH 17/25] Limit skin generation to new emulator assets --- .github/skin-generation-log.json | 233 +++++++++++++++++++++++++++++- scripts/generate_missing_skins.py | 53 ++++++- 2 files changed, 277 insertions(+), 9 deletions(-) diff --git a/.github/skin-generation-log.json b/.github/skin-generation-log.json index 53e074f..38a35db 100644 --- a/.github/skin-generation-log.json +++ b/.github/skin-generation-log.json @@ -1,4 +1,233 @@ { - "generated": "2025-10-27T00:00:00Z", - "skins": {} + "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/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index fd7a97e..e341c76 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -29,6 +29,13 @@ DEFAULT_OUTPUT_DIR = REPO_ROOT / "tmp" / "generated_skins" DEFAULT_METADATA_PATH = REPO_ROOT / ".github" / "skin-generation-log.json" +# Roots that contain upstream Android emulator skin assets that still need +# Codename One archives. The historical Codename One skins that ship with the +# simulator live at the repository root and should be ignored – those are +# already committed and would just be regenerated unnecessarily. Only the +# Android emulator dumps (phones/tablets) are eligible for conversion. +EMULATOR_SKIN_ROOTS = ("Phones", "Tablets") + # Directories that should never be considered as skin sources. EXCLUDED_TOP_LEVEL = {".git", "OTA", "tmp", ".github"} @@ -65,14 +72,20 @@ def _save_metadata(path: Path, records: Dict[str, Dict[str, str]]) -> None: def _iter_skin_directories() -> Iterable[Path]: - for properties_file in REPO_ROOT.rglob("skin.properties"): - try: - relative_parts = properties_file.relative_to(REPO_ROOT).parts - except ValueError: - continue - if relative_parts[0] in EXCLUDED_TOP_LEVEL: + for root_name in EMULATOR_SKIN_ROOTS: + candidate_root = REPO_ROOT / root_name + if not candidate_root.exists(): continue - yield properties_file.parent + for properties_file in candidate_root.rglob("skin.properties"): + try: + relative_parts = properties_file.relative_to(REPO_ROOT).parts + except ValueError: + continue + if not relative_parts: + continue + if relative_parts[0] in EXCLUDED_TOP_LEVEL: + continue + yield properties_file.parent def _directory_fingerprint(directory: Path) -> str: @@ -91,6 +104,18 @@ def _directory_fingerprint(directory: Path) -> str: return sha.hexdigest() +def _find_existing_archive(name: str) -> Path | None: + ota_dir = REPO_ROOT / "OTA" + candidate = ota_dir / f"{name}.skin" + if candidate.exists(): + return candidate + return None + + +def _isoformat_from_timestamp(timestamp: float) -> str: + return _dt.datetime.utcfromtimestamp(timestamp).replace(microsecond=0).strftime(ISO_8601_Z_SUFFIX) + + MANUAL_PIXEL_RATIOS = { "BlackberryBold9790": 9.681166903317413, "Tablets/MicrosoftSurface3": 8.411901488396593, @@ -221,6 +246,8 @@ def process_skins( dry_run: bool = False, force: bool = False, ) -> Tuple[List[SkinGeneration], List[str]]: + output_dir = output_dir.resolve() + metadata_path = metadata_path.resolve() metadata = _load_metadata(metadata_path) generated: List[SkinGeneration] = [] skipped: List[str] = [] @@ -232,11 +259,23 @@ def process_skins( 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_source, + "fingerprint": fingerprint, + "archive": _relative_to_repo(existing_archive), + } + skipped.append(skin_name) + continue + if dry_run: generated.append(SkinGeneration(skin_name, skin_dir, archive_path)) continue From 77dc25f021a534639599f4d0a78449c568cf4460 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:58:33 +0200 Subject: [PATCH 18/25] Support remote Android skin sources --- scripts/generate_missing_skins.py | 133 +++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 20 deletions(-) diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index e341c76..ad3c7f6 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -1,11 +1,15 @@ #!/usr/bin/env python3 """Generate Codename One skin archives for directories that are missing them. -This script inspects the repository for directories that contain ``skin.properties`` -files (i.e. Codename One skin definitions). For each such directory it verifies that -an OTA ``.skin`` archive has not yet been captured in the metadata ledger. Missing -entries are regenerated with maximum compression and recorded in the ledger so that -future runs can skip work that has already been performed. +This script inspects an Android emulator skin repository for directories that +contain ``skin.properties`` files (i.e. Codename One skin definitions). The +repository can be provided as a local checkout or downloaded automatically from +GitHub (the default points at +``https://github.com/larskristianhaga/Android-emulator-skins``). For each +discovered directory it verifies that an OTA ``.skin`` archive has not yet been +captured in the metadata ledger. Missing entries are regenerated with maximum +compression and recorded in the ledger so that future runs can skip work that has +already been performed. The resulting archives are written to a configurable output directory (``tmp/`` by default) so that the Git repository does not need to track large binary assets. @@ -21,13 +25,18 @@ import json import math import re +import shutil +import tempfile from pathlib import Path -from typing import Dict, Iterable, List, Tuple +from typing import Callable, Dict, Iterable, List, Tuple +from urllib.parse import urlparse +from urllib.request import Request, urlopen from zipfile import ZIP_DEFLATED, ZipFile 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" +DEFAULT_ANDROID_SKINS_SOURCE = "https://github.com/larskristianhaga/Android-emulator-skins" # Roots that contain upstream Android emulator skin assets that still need # Codename One archives. The historical Codename One skins that ship with the @@ -48,6 +57,7 @@ class SkinGeneration: name: str source_dir: Path + source_relative: str archive_path: Path @@ -71,14 +81,14 @@ def _save_metadata(path: Path, records: Dict[str, Dict[str, str]]) -> None: fh.write("\n") -def _iter_skin_directories() -> Iterable[Path]: +def _iter_skin_directories(android_repo_root: Path) -> Iterable[Path]: for root_name in EMULATOR_SKIN_ROOTS: - candidate_root = REPO_ROOT / root_name + candidate_root = android_repo_root / root_name if not candidate_root.exists(): continue for properties_file in candidate_root.rglob("skin.properties"): try: - relative_parts = properties_file.relative_to(REPO_ROOT).parts + relative_parts = properties_file.relative_to(android_repo_root).parts except ValueError: continue if not relative_parts: @@ -243,6 +253,7 @@ def process_skins( *, output_dir: Path, metadata_path: Path, + android_repo_root: Path, dry_run: bool = False, force: bool = False, ) -> Tuple[List[SkinGeneration], List[str]]: @@ -252,8 +263,11 @@ def process_skins( generated: List[SkinGeneration] = [] skipped: List[str] = [] - for skin_dir in sorted(_iter_skin_directories(), key=lambda p: p.relative_to(REPO_ROOT).as_posix()): - relative_source = skin_dir.relative_to(REPO_ROOT).as_posix() + for skin_dir in sorted( + _iter_skin_directories(android_repo_root), + key=lambda p: p.relative_to(android_repo_root).as_posix(), + ): + relative_source = skin_dir.relative_to(android_repo_root).as_posix() skin_name = skin_dir.name metadata_key = relative_source archive_path = output_dir / f"{skin_name}.skin" @@ -277,7 +291,7 @@ def process_skins( continue if dry_run: - generated.append(SkinGeneration(skin_name, skin_dir, archive_path)) + generated.append(SkinGeneration(skin_name, skin_dir, relative_source, archive_path)) continue source_hint = relative_source @@ -289,7 +303,7 @@ def process_skins( "fingerprint": fingerprint, "archive": archive_entry, } - generated.append(SkinGeneration(skin_name, skin_dir, archive_path)) + generated.append(SkinGeneration(skin_name, skin_dir, relative_source, archive_path)) if not dry_run: ordered = dict(sorted(metadata.items())) @@ -311,7 +325,7 @@ def _write_report(report_path: Path, generated: List[SkinGeneration], skipped: L { "skin": entry.name, "archive": entry.archive_path.as_posix(), - "source": entry.source_dir.relative_to(REPO_ROOT).as_posix(), + "source": entry.source_relative, } for entry in generated ], @@ -323,6 +337,71 @@ def _write_report(report_path: Path, generated: List[SkinGeneration], skipped: L fh.write("\n") +def _download_android_skin_repo(source: str) -> Tuple[Path, Path]: + """Download a remote Android skin repository and return the extracted path and temp root.""" + + parsed = urlparse(source) + if parsed.scheme not in {"http", "https"}: + raise ValueError(f"Unsupported URL scheme for Android skin source: {source}") + + base = source.rstrip("/") + candidates: List[str] + if base.endswith(".zip"): + candidates = [base] + else: + candidates = [ + f"{base}/archive/refs/heads/main.zip", + f"{base}/archive/refs/heads/master.zip", + ] + + tmp_root = Path(tempfile.mkdtemp(prefix="android-skins-")) + archive_path = tmp_root / "repo.zip" + headers = {"User-Agent": "codenameone-skin-generator/1.0"} + errors: List[str] = [] + + for candidate in candidates: + try: + request = Request(candidate, headers=headers) + with urlopen(request) as response, archive_path.open("wb") as fh: # type: ignore[arg-type] + shutil.copyfileobj(response, fh) + break + except Exception as exc: # pylint: disable=broad-except + errors.append(f"{candidate}: {exc}") + else: + shutil.rmtree(tmp_root, ignore_errors=True) + raise RuntimeError( + "Failed to download Android emulator skins. Attempts: " + "; ".join(errors) + ) + + 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 len(top_level) == 1: + repo_root = tmp_root / top_level[0] + else: + repo_root = tmp_root + + return repo_root, tmp_root + + +def _resolve_android_skins_source(spec: str) -> Tuple[Path, Callable[[], None]]: + """Resolve an Android emulator skin source into a filesystem path.""" + + candidate_path = Path(spec) + if candidate_path.exists(): + return candidate_path.resolve(), lambda: None + + repo_root, tmp_root = _download_android_skin_repo(spec) + cleanup = lambda: shutil.rmtree(tmp_root, ignore_errors=True) + return repo_root, cleanup + + 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") @@ -348,14 +427,28 @@ def main() -> int: action="store_true", help="Regenerate skins even if the metadata fingerprint matches", ) + parser.add_argument( + "--android-skins-source", + default=DEFAULT_ANDROID_SKINS_SOURCE, + help=( + "Filesystem path or repository URL containing Android emulator skins " + "(default: https://github.com/larskristianhaga/Android-emulator-skins)" + ), + ) args = parser.parse_args() - generated, skipped = process_skins( - output_dir=args.output_dir, - metadata_path=args.metadata, - dry_run=args.dry_run, - force=args.force, - ) + cleanup = lambda: None + try: + android_repo_root, cleanup = _resolve_android_skins_source(args.android_skins_source) + generated, skipped = process_skins( + output_dir=args.output_dir, + metadata_path=args.metadata, + android_repo_root=android_repo_root, + dry_run=args.dry_run, + force=args.force, + ) + finally: + cleanup() if generated: print("Generated/updated skins:\n - " + "\n - ".join(entry.name for entry in sorted(generated, key=lambda e: e.name))) From c7bd76c7ba45d092cca0e0a3b2faea2732dc175a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:34:52 +0200 Subject: [PATCH 19/25] Automate skin sourcing from remote Android repositories --- .../workflows/generate-codenameone-skins.yml | 3 + scripts/generate_missing_skins.py | 949 ++++++++++++------ 2 files changed, 670 insertions(+), 282 deletions(-) diff --git a/.github/workflows/generate-codenameone-skins.yml b/.github/workflows/generate-codenameone-skins.yml index 39e1eb1..1300ea0 100644 --- a/.github/workflows/generate-codenameone-skins.yml +++ b/.github/workflows/generate-codenameone-skins.yml @@ -37,6 +37,9 @@ jobs: 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 \ diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index ad3c7f6..068aba7 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -1,54 +1,82 @@ #!/usr/bin/env python3 -"""Generate Codename One skin archives for directories that are missing them. - -This script inspects an Android emulator skin repository for directories that -contain ``skin.properties`` files (i.e. Codename One skin definitions). The -repository can be provided as a local checkout or downloaded automatically from -GitHub (the default points at -``https://github.com/larskristianhaga/Android-emulator-skins``). For each -discovered directory it verifies that an OTA ``.skin`` archive has not yet been -captured in the metadata ledger. Missing entries are regenerated with maximum -compression and recorded in the ledger so that future runs can skip work that has -already been performed. - -The resulting archives are written to a configurable output directory (``tmp/`` by -default) so that the Git repository does not need to track large binary assets. -An optional JSON report summarises the work, which can be consumed by CI pipelines -to run additional validation steps. -""" +"""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 re import shutil import tempfile from pathlib import Path -from typing import Callable, Dict, Iterable, List, Tuple +from typing import Callable, Dict, Iterable, Iterator, List, Optional, Sequence, Tuple from urllib.parse import urlparse from urllib.request import Request, urlopen from zipfile import ZIP_DEFLATED, ZipFile +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" -DEFAULT_ANDROID_SKINS_SOURCE = "https://github.com/larskristianhaga/Android-emulator-skins" - -# Roots that contain upstream Android emulator skin assets that still need -# Codename One archives. The historical Codename One skins that ship with the -# simulator live at the repository root and should be ignored – those are -# already committed and would just be regenerated unnecessarily. Only the -# Android emulator dumps (phones/tablets) are eligible for conversion. -EMULATOR_SKIN_ROOTS = ("Phones", "Tablets") +ISO_8601_Z_SUFFIX = "%Y-%m-%dT%H:%M:%SZ" -# Directories that should never be considered as skin sources. EXCLUDED_TOP_LEVEL = {".git", "OTA", "tmp", ".github"} -ISO_8601_Z_SUFFIX = "%Y-%m-%dT%H:%M:%SZ" +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 + + +ANDROID_SKIN_SOURCES: Tuple[AndroidSkinSource, ...] = ( + AndroidSkinSource( + name="Android emulator community skins", + slug="Android", + url="https://github.com/larskristianhaga/Android-emulator-skins", + metadata_prefix="", + allowed_roots=("Phones", "Tablets", "phones", "tablets"), + ), + AndroidSkinSource( + name="Google device art resources", + slug="Google", + url="https://github.com/google/device-art-generator", + metadata_prefix="google/", + ), + AndroidSkinSource( + name="Samsung emulator skins", + slug="Samsung", + url="https://github.com/HiDeoo/avd-samsung-skins", + metadata_prefix="samsung/", + ), +) @dataclasses.dataclass(frozen=True) @@ -61,255 +89,363 @@ class SkinGeneration: 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) - if isinstance(raw, dict): - return {str(k): dict(v) for k, v in raw.get("skins", {}).items()} except json.JSONDecodeError: - pass + 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": _dt.datetime.utcnow().strftime(ISO_8601_Z_SUFFIX), "skins": records} + 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 _iter_skin_directories(android_repo_root: Path) -> Iterable[Path]: - for root_name in EMULATOR_SKIN_ROOTS: - candidate_root = android_repo_root / root_name - if not candidate_root.exists(): - continue - for properties_file in candidate_root.rglob("skin.properties"): - try: - relative_parts = properties_file.relative_to(android_repo_root).parts - except ValueError: - continue - if not relative_parts: - continue - if relative_parts[0] in EXCLUDED_TOP_LEVEL: - continue - yield properties_file.parent +def _current_utc_isoformat() -> str: + return _dt.datetime.utcnow().replace(microsecond=0).strftime(ISO_8601_Z_SUFFIX) def _directory_fingerprint(directory: Path) -> str: sha = hashlib.sha256() - for item in sorted(directory.iterdir()): - if item.name.startswith("."): - # Ignore hidden files such as .DS_Store. + for entry in sorted(directory.rglob("*")): + if entry.name.startswith("."): continue - if item.is_dir(): - # Current skin layout stores assets flat, but recurse defensively. - sha.update(item.name.encode("utf-8")) - sha.update(_directory_fingerprint(item).encode("utf-8")) + if entry.is_dir(): + sha.update(entry.name.encode("utf-8")) continue - sha.update(item.name.encode("utf-8")) - sha.update(item.read_bytes()) + sha.update(entry.relative_to(directory).as_posix().encode("utf-8")) + sha.update(entry.read_bytes()) return sha.hexdigest() -def _find_existing_archive(name: str) -> Path | None: - ota_dir = REPO_ROOT / "OTA" - candidate = ota_dir / f"{name}.skin" - if candidate.exists(): - return candidate - return None - - def _isoformat_from_timestamp(timestamp: float) -> str: return _dt.datetime.utcfromtimestamp(timestamp).replace(microsecond=0).strftime(ISO_8601_Z_SUFFIX) -MANUAL_PIXEL_RATIOS = { - "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, -} - - -def _parse_skin_properties(text: str) -> Tuple[Dict[str, str], List[str]]: - props: Dict[str, str] = {} - comments: List[str] = [] - for line in text.splitlines(): - stripped = line.strip() - if not stripped: - continue - if stripped.startswith("#"): - comments.append(stripped) +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) - key = key.strip() - value = value.strip() - if key not in props: - props[key] = value - return props, comments + 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 _derive_pixel_ratio(props: Dict[str, str], comments: List[str], source_hint: str) -> float | None: - if "pixelRatio" in props: - try: - return float(props["pixelRatio"]) - except ValueError: - return None +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 _parse_float(value: str) -> float | None: - try: - parsed = float(value) - except (TypeError, 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: - ratio = MANUAL_PIXEL_RATIOS.get(source_hint) - return ratio - - return ppi / 25.4 - - -def _ensure_trailing_newline(text: str) -> str: - if text.endswith("\n"): - return text - if text.endswith("\r\n"): - return text - return text + "\n" - - -def _maybe_augment_skin_properties(text: str, source_hint: str) -> str: - props, comments = _parse_skin_properties(text) - ratio = _derive_pixel_ratio(props, comments, source_hint) - if ratio is None or "pixelRatio" in props: - return _ensure_trailing_newline(text) - - formatted = f"pixelRatio={ratio:.12f}".rstrip("0").rstrip(".") - base = _ensure_trailing_newline(text) - if not base.endswith(("\n", "\r\n")): - base += "\n" - return base + formatted + "\n" - - -def _zip_skin_directory(source_dir: Path, target_zip: Path, source_hint: str) -> None: - target_zip.parent.mkdir(parents=True, exist_ok=True) - with ZipFile(target_zip, "w", compression=ZIP_DEFLATED, compresslevel=9) as zf: - for entry in sorted(source_dir.rglob("*")): - if entry.is_dir() or entry.name.startswith("."): - continue - arcname = entry.relative_to(source_dir).as_posix() - if arcname == "skin.properties": - original = entry.read_text(encoding="utf-8", errors="replace") - updated = _maybe_augment_skin_properties(original, source_hint) - zf.writestr(arcname, updated) - else: - zf.write(entry, arcname=arcname) +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 _current_utc_isoformat() -> str: - return _dt.datetime.utcnow().replace(microsecond=0).strftime(ISO_8601_Z_SUFFIX) +def _normalise_orientation_names() -> Dict[str, Tuple[str, ...]]: + return { + "portrait": ("portrait", "vertical", "default", "upright"), + "landscape": ("landscape", "horizontal", "sideways"), + } -def process_skins( - *, - output_dir: Path, - metadata_path: Path, - android_repo_root: Path, - dry_run: bool = False, - force: bool = False, -) -> Tuple[List[SkinGeneration], List[str]]: - output_dir = output_dir.resolve() - metadata_path = metadata_path.resolve() - metadata = _load_metadata(metadata_path) - generated: List[SkinGeneration] = [] - skipped: List[str] = [] - for skin_dir in sorted( - _iter_skin_directories(android_repo_root), - key=lambda p: p.relative_to(android_repo_root).as_posix(), - ): - relative_source = skin_dir.relative_to(android_repo_root).as_posix() - skin_name = skin_dir.name - metadata_key = relative_source - 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) +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 - - 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_source, - "fingerprint": fingerprint, - "archive": _relative_to_repo(existing_archive), - } - skipped.append(skin_name) + 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 - if dry_run: - generated.append(SkinGeneration(skin_name, skin_dir, relative_source, archive_path)) - continue - source_hint = relative_source - _zip_skin_directory(skin_dir, archive_path, source_hint) - archive_entry = _relative_to_repo(archive_path) - metadata[metadata_key] = { - "generated_at": _current_utc_isoformat(), - "source": relative_source, - "fingerprint": fingerprint, - "archive": archive_entry, - } - generated.append(SkinGeneration(skin_name, skin_dir, relative_source, archive_path)) +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) - if not dry_run: - ordered = dict(sorted(metadata.items())) - _save_metadata(metadata_path, ordered) - return generated, skipped +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: @@ -319,32 +455,123 @@ def _relative_to_repo(path: Path) -> str: return path.resolve().as_posix() -def _write_report(report_path: Path, generated: List[SkinGeneration], skipped: 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), - } - 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 _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 _download_android_skin_repo(source: str) -> Tuple[Path, Path]: - """Download a remote Android skin repository and return the extracted path and temp root.""" - parsed = urlparse(source) +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}") + raise ValueError(f"Unsupported URL scheme for Android skin source: {source.url}") - base = source.rstrip("/") + base = source.url.rstrip("/") candidates: List[str] if base.endswith(".zip"): candidates = [base] @@ -370,7 +597,7 @@ def _download_android_skin_repo(source: str) -> Tuple[Path, Path]: else: shutil.rmtree(tmp_root, ignore_errors=True) raise RuntimeError( - "Failed to download Android emulator skins. Attempts: " + "; ".join(errors) + f"Failed to download Android emulator skins from {source.url}. Attempts: " + "; ".join(errors) ) with ZipFile(archive_path) as zf: @@ -382,7 +609,19 @@ def _download_android_skin_repo(source: str) -> Tuple[Path, Path]: root = Path(name.split("/", 1)[0]) if root not in top_level: top_level.append(root) - if len(top_level) == 1: + repo_root: Path + if source.subdirectory: + candidate = tmp_root / source.subdirectory + if candidate.exists(): + repo_root = candidate + elif len(top_level) == 1: + candidate = tmp_root / top_level[0] / source.subdirectory + repo_root = candidate if candidate.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 @@ -390,16 +629,170 @@ def _download_android_skin_repo(source: str) -> Tuple[Path, Path]: return repo_root, tmp_root -def _resolve_android_skins_source(spec: str) -> Tuple[Path, Callable[[], None]]: - """Resolve an Android emulator skin source into a filesystem path.""" +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 + - candidate_path = Path(spec) - if candidate_path.exists(): - return candidate_path.resolve(), lambda: None +def _resolve_sources() -> Tuple[List[ResolvedSource], List[str]]: + resolved: List[ResolvedSource] = [] + errors: 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 + errors.append(f"{spec.name}: {exc}") + return resolved, errors - repo_root, tmp_root = _download_android_skin_repo(spec) - cleanup = lambda: shutil.rmtree(tmp_root, ignore_errors=True) - return repo_root, cleanup + +def process_skins( + *, + output_dir: Path, + metadata_path: Path, + dry_run: bool = False, + force: bool = False, +) -> Tuple[List[SkinGeneration], List[str], List[str]]: + output_dir = output_dir.resolve() + metadata_path = metadata_path.resolve() + metadata = _load_metadata(metadata_path) + + resolved_sources, resolve_errors = _resolve_sources() + generated: List[SkinGeneration] = [] + skipped: List[str] = [] + errors: List[str] = resolve_errors + 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 + + +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]) -> 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, + } + 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: @@ -427,28 +820,14 @@ def main() -> int: action="store_true", help="Regenerate skins even if the metadata fingerprint matches", ) - parser.add_argument( - "--android-skins-source", - default=DEFAULT_ANDROID_SKINS_SOURCE, - help=( - "Filesystem path or repository URL containing Android emulator skins " - "(default: https://github.com/larskristianhaga/Android-emulator-skins)" - ), - ) args = parser.parse_args() - cleanup = lambda: None - try: - android_repo_root, cleanup = _resolve_android_skins_source(args.android_skins_source) - generated, skipped = process_skins( - output_dir=args.output_dir, - metadata_path=args.metadata, - android_repo_root=android_repo_root, - dry_run=args.dry_run, - force=args.force, - ) - finally: - cleanup() + generated, skipped, errors = 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))) @@ -458,8 +837,14 @@ def main() -> int: if skipped: print("Skipped skins (up-to-date):\n - " + "\n - ".join(sorted(skipped))) + if errors: + print("Encountered issues:\n - " + "\n - ".join(errors)) + if args.report_file: - _write_report(args.report_file, generated, skipped) + _write_report(args.report_file, generated, skipped, errors) + + if errors and not args.dry_run: + return 1 return 0 From 4047aa8272afe506ea5272a5f57720a1601dceb9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:51:30 +0200 Subject: [PATCH 20/25] Improve Android skin downloads --- scripts/generate_missing_skins.py | 100 +++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 29 deletions(-) diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index 068aba7..85a4496 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -17,6 +17,7 @@ from urllib.parse import urlparse from urllib.request import Request, urlopen from zipfile import ZIP_DEFLATED, ZipFile +import subprocess try: from PIL import Image, ImageDraw @@ -154,7 +155,12 @@ def _save_metadata(path: Path, records: Dict[str, Dict[str, str]]) -> None: def _current_utc_isoformat() -> str: - return _dt.datetime.utcnow().replace(microsecond=0).strftime(ISO_8601_Z_SUFFIX) + 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: @@ -171,7 +177,11 @@ def _directory_fingerprint(directory: Path) -> str: def _isoformat_from_timestamp(timestamp: float) -> str: - return _dt.datetime.utcfromtimestamp(timestamp).replace(microsecond=0).strftime(ISO_8601_Z_SUFFIX) + 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]: @@ -579,54 +589,86 @@ def _download_android_skin_repo(source: AndroidSkinSource) -> Tuple[Path, Path]: candidates = [ f"{base}/archive/refs/heads/main.zip", f"{base}/archive/refs/heads/master.zip", + f"{base}/archive/refs/heads/dev.zip", ] tmp_root = Path(tempfile.mkdtemp(prefix="android-skins-")) archive_path = tmp_root / "repo.zip" headers = {"User-Agent": "codenameone-skin-generator/1.0"} errors: List[str] = [] + downloaded = False for candidate in candidates: try: request = Request(candidate, headers=headers) with urlopen(request) as response, archive_path.open("wb") as fh: # type: ignore[arg-type] shutil.copyfileobj(response, fh) + downloaded = True break except Exception as exc: # pylint: disable=broad-except errors.append(f"{candidate}: {exc}") - else: - shutil.rmtree(tmp_root, ignore_errors=True) - raise RuntimeError( - f"Failed to download Android emulator skins from {source.url}. Attempts: " + "; ".join(errors) - ) - 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) - repo_root: Path - if source.subdirectory: - candidate = tmp_root / source.subdirectory - if candidate.exists(): - repo_root = candidate + if downloaded: + 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) + repo_root: Path + if source.subdirectory: + candidate = tmp_root / source.subdirectory + if candidate.exists(): + repo_root = candidate + elif len(top_level) == 1: + candidate = tmp_root / top_level[0] / source.subdirectory + repo_root = candidate if candidate.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: - candidate = tmp_root / top_level[0] / source.subdirectory - repo_root = candidate if candidate.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 + return repo_root, tmp_root + + # Zip downloads failed, try a shallow git clone as a fallback + clone_dir = tmp_root / "repo" + try: + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + source.url, + str(clone_dir), + ], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as exc: # pragma: no cover - network dependent + errors.append(f"git clone: {exc.stderr.strip() or exc.stdout.strip() or exc}") + except FileNotFoundError as exc: # pragma: no cover - git missing + errors.append(f"git clone unavailable: {exc}") + else: + repo_root = clone_dir + if source.subdirectory: + candidate = clone_dir / source.subdirectory + if candidate.exists(): + repo_root = candidate + 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]: From 0b7fae5ffe8bfdbb2cfa7f5d0e8c5be0153cc016 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:06:56 +0200 Subject: [PATCH 21/25] Improve Android skin source fallbacks --- scripts/generate_missing_skins.py | 206 +++++++++++++++++++----------- 1 file changed, 130 insertions(+), 76 deletions(-) diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index 85a4496..4aec892 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -55,6 +55,7 @@ class AndroidSkinSource: metadata_prefix: str allowed_roots: Tuple[str, ...] = () subdirectory: Optional[str] = None + alternate_urls: Tuple[str, ...] = () ANDROID_SKIN_SOURCES: Tuple[AndroidSkinSource, ...] = ( @@ -70,12 +71,20 @@ class AndroidSkinSource: slug="Google", url="https://github.com/google/device-art-generator", metadata_prefix="google/", + alternate_urls=( + "https://github.com/googlesamples/device-art-generator", + "https://github.com/googlearchive/device-art-generator", + ), ), AndroidSkinSource( name="Samsung emulator skins", slug="Samsung", url="https://github.com/HiDeoo/avd-samsung-skins", metadata_prefix="samsung/", + alternate_urls=( + "https://github.com/HiDeoo/android-emulator-samsung-skins", + "https://github.com/HiDeoo/avd-skins", + ), ), ) @@ -576,94 +585,139 @@ def _convert_skin_directory( _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] = [] + headers = {"User-Agent": "codenameone-skin-generator/1.0"} + if owner_repo: + owner, repo = owner_repo + api_url = f"https://api.github.com/repos/{owner}/{repo}" + try: + request = Request(api_url, headers=headers) + 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}") - base = source.url.rstrip("/") - candidates: List[str] - if base.endswith(".zip"): - candidates = [base] - else: - candidates = [ - f"{base}/archive/refs/heads/main.zip", - f"{base}/archive/refs/heads/master.zip", - f"{base}/archive/refs/heads/dev.zip", - ] - tmp_root = Path(tempfile.mkdtemp(prefix="android-skins-")) archive_path = tmp_root / "repo.zip" headers = {"User-Agent": "codenameone-skin-generator/1.0"} errors: List[str] = [] - downloaded = False - for candidate in candidates: - try: - request = Request(candidate, headers=headers) - with urlopen(request) as response, archive_path.open("wb") as fh: # type: ignore[arg-type] - shutil.copyfileobj(response, fh) - downloaded = True - break - except Exception as exc: # pylint: disable=broad-except - errors.append(f"{candidate}: {exc}") - - if downloaded: - 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) - repo_root: Path - if source.subdirectory: - candidate = tmp_root / source.subdirectory - if candidate.exists(): - repo_root = candidate - elif len(top_level) == 1: - candidate = tmp_root / top_level[0] / source.subdirectory - repo_root = candidate if candidate.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 + base_urls: Tuple[str, ...] = (source.url, *source.alternate_urls) - return repo_root, tmp_root + 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 + ] - # Zip downloads failed, try a shallow git clone as a fallback - clone_dir = tmp_root / "repo" - try: - subprocess.run( - [ - "git", - "clone", - "--depth", - "1", - source.url, - str(clone_dir), - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as exc: # pragma: no cover - network dependent - errors.append(f"git clone: {exc.stderr.strip() or exc.stdout.strip() or exc}") - except FileNotFoundError as exc: # pragma: no cover - git missing - errors.append(f"git clone unavailable: {exc}") - else: - repo_root = clone_dir - if source.subdirectory: - candidate = clone_dir / source.subdirectory - if candidate.exists(): - repo_root = candidate - return repo_root, tmp_root + for candidate in archive_candidates: + try: + request = Request(candidate, headers=headers) + 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"{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: + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "--single-branch", + "--filter=blob:none", + "--branch", + branch, + base_url, + str(clone_dir), + ], + check=True, + capture_output=True, + text=True, + ) + 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 ({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( From f2411ff47c96114be7512f3433d703e2beebe963 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 30 Oct 2025 21:19:18 +0200 Subject: [PATCH 22/25] Support authenticated GitHub downloads --- scripts/generate_missing_skins.py | 60 ++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index 4aec892..7eb6d66 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -9,12 +9,13 @@ 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 +from urllib.parse import urlparse, urlunparse from urllib.request import Request, urlopen from zipfile import ZIP_DEFLATED, ZipFile import subprocess @@ -89,6 +90,47 @@ class AndroidSkinSource: ) +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.""" @@ -601,12 +643,11 @@ def _github_repo_from_url(url: str) -> Optional[Tuple[str, str]]: def _branch_candidates(base_url: str) -> List[str]: owner_repo = _github_repo_from_url(base_url) candidates: List[str] = [] - headers = {"User-Agent": "codenameone-skin-generator/1.0"} if owner_repo: owner, repo = owner_repo api_url = f"https://api.github.com/repos/{owner}/{repo}" try: - request = Request(api_url, headers=headers) + 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") @@ -628,7 +669,6 @@ def _download_android_skin_repo(source: AndroidSkinSource) -> Tuple[Path, Path]: tmp_root = Path(tempfile.mkdtemp(prefix="android-skins-")) archive_path = tmp_root / "repo.zip" - headers = {"User-Agent": "codenameone-skin-generator/1.0"} errors: List[str] = [] base_urls: Tuple[str, ...] = (source.url, *source.alternate_urls) @@ -646,7 +686,7 @@ def _download_android_skin_repo(source: AndroidSkinSource) -> Tuple[Path, Path]: for candidate in archive_candidates: try: - request = Request(candidate, headers=headers) + 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: @@ -677,7 +717,7 @@ def _download_android_skin_repo(source: AndroidSkinSource) -> Tuple[Path, Path]: repo_root = tmp_root return repo_root, tmp_root except Exception as exc: # pylint: disable=broad-except - errors.append(f"{candidate}: {exc}") + 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): @@ -685,6 +725,9 @@ def _download_android_skin_repo(source: AndroidSkinSource) -> Tuple[Path, Path]: 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", @@ -695,18 +738,19 @@ def _download_android_skin_repo(source: AndroidSkinSource) -> Tuple[Path, Path]: "--filter=blob:none", "--branch", branch, - base_url, + 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 ({base_url}@{branch}): {details}") + 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}") From 75ab636f8a5d8037e56077d55e6e5652c026009c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 31 Oct 2025 21:24:15 +0200 Subject: [PATCH 23/25] Handle optional Android skin sources --- scripts/generate_missing_skins.py | 41 +++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index 7eb6d66..10e235d 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -57,6 +57,7 @@ class AndroidSkinSource: allowed_roots: Tuple[str, ...] = () subdirectory: Optional[str] = None alternate_urls: Tuple[str, ...] = () + required: bool = True ANDROID_SKIN_SOURCES: Tuple[AndroidSkinSource, ...] = ( @@ -66,16 +67,19 @@ class AndroidSkinSource: url="https://github.com/larskristianhaga/Android-emulator-skins", metadata_prefix="", allowed_roots=("Phones", "Tablets", "phones", "tablets"), + required=True, ), AndroidSkinSource( name="Google device art resources", slug="Google", - url="https://github.com/google/device-art-generator", + url="https://github.com/android/device-art-resources", metadata_prefix="google/", alternate_urls=( + "https://github.com/google/device-art-resources", "https://github.com/googlesamples/device-art-generator", "https://github.com/googlearchive/device-art-generator", ), + required=False, ), AndroidSkinSource( name="Samsung emulator skins", @@ -86,6 +90,7 @@ class AndroidSkinSource: "https://github.com/HiDeoo/android-emulator-samsung-skins", "https://github.com/HiDeoo/avd-skins", ), + required=False, ), ) @@ -789,17 +794,22 @@ def _iter_android_skin_directories(root: Path, allowed_roots: Tuple[str, ...]) - yield parent -def _resolve_sources() -> Tuple[List[ResolvedSource], List[str]]: +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 - errors.append(f"{spec.name}: {exc}") - return resolved, errors + message = f"{spec.name}: {exc}" + if spec.required: + errors.append(message) + else: + warnings.append(message) + return resolved, errors, warnings def process_skins( @@ -808,15 +818,16 @@ def process_skins( metadata_path: Path, dry_run: bool = False, force: bool = False, -) -> Tuple[List[SkinGeneration], List[str], List[str]]: +) -> 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_sources() + 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: @@ -905,7 +916,7 @@ def process_skins( ordered = dict(sorted(metadata.items())) _save_metadata(metadata_path, ordered) - return generated, skipped, errors + return generated, skipped, errors, warnings def _find_existing_archive(name: str) -> Optional[Path]: @@ -916,7 +927,13 @@ def _find_existing_archive(name: str) -> Optional[Path]: return None -def _write_report(report_path: Path, generated: List[SkinGeneration], skipped: List[str], errors: List[str]) -> None: +def _write_report( + report_path: Path, + generated: List[SkinGeneration], + skipped: List[str], + errors: List[str], + warnings: List[str], +) -> None: report_payload = { "generated": [ { @@ -928,6 +945,7 @@ def _write_report(report_path: Path, generated: List[SkinGeneration], skipped: L ], "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: @@ -962,7 +980,7 @@ def main() -> int: ) args = parser.parse_args() - generated, skipped, errors = process_skins( + generated, skipped, errors, warnings = process_skins( output_dir=args.output_dir, metadata_path=args.metadata, dry_run=args.dry_run, @@ -980,8 +998,11 @@ def main() -> int: 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) + _write_report(args.report_file, generated, skipped, errors, warnings) if errors and not args.dry_run: return 1 From e8f2b21a1ff83776b88f611217a6a46907bf2124 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Nov 2025 07:15:58 +0200 Subject: [PATCH 24/25] Update Android skin sources --- scripts/generate_missing_skins.py | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index 10e235d..9cf7fb6 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -62,7 +62,7 @@ class AndroidSkinSource: ANDROID_SKIN_SOURCES: Tuple[AndroidSkinSource, ...] = ( AndroidSkinSource( - name="Android emulator community skins", + name="Lars Kristian Haga Android emulator skins", slug="Android", url="https://github.com/larskristianhaga/Android-emulator-skins", metadata_prefix="", @@ -70,26 +70,17 @@ class AndroidSkinSource: required=True, ), AndroidSkinSource( - name="Google device art resources", - slug="Google", - url="https://github.com/android/device-art-resources", - metadata_prefix="google/", - alternate_urls=( - "https://github.com/google/device-art-resources", - "https://github.com/googlesamples/device-art-generator", - "https://github.com/googlearchive/device-art-generator", - ), + name="Suess Labs Android emulator skins", + slug="SuessLabs", + url="https://github.com/SuessLabs/Android-Emulator-Skins", + metadata_prefix="suesslabs/", required=False, ), AndroidSkinSource( - name="Samsung emulator skins", - slug="Samsung", - url="https://github.com/HiDeoo/avd-samsung-skins", - metadata_prefix="samsung/", - alternate_urls=( - "https://github.com/HiDeoo/android-emulator-samsung-skins", - "https://github.com/HiDeoo/avd-skins", - ), + name="Ming Chen Android emulator skins", + slug="MingChen", + url="https://github.com/mingchen/android-emulator-skins", + metadata_prefix="mingchen/", required=False, ), ) From 2b4d8ed76f080c154b471bdfe614de4f3929e4b1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 1 Nov 2025 15:18:09 +0200 Subject: [PATCH 25/25] Tighten Android skin source list --- scripts/generate_missing_skins.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/generate_missing_skins.py b/scripts/generate_missing_skins.py index 9cf7fb6..6775ba2 100755 --- a/scripts/generate_missing_skins.py +++ b/scripts/generate_missing_skins.py @@ -74,6 +74,7 @@ class AndroidSkinSource: slug="SuessLabs", url="https://github.com/SuessLabs/Android-Emulator-Skins", metadata_prefix="suesslabs/", + allowed_roots=("phones", "tablets"), required=False, ), AndroidSkinSource( @@ -81,10 +82,24 @@ class AndroidSkinSource: 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")