#!/usr/bin/env python3 """ prebuilt-app-maker — Download APKs and generate Android.bp prebuilt app repos. Each app defined in config.yml becomes a directory containing the APK plus a generated Android.bp with an android_app_import module, ready to be pushed as a prebuilt app repo for AOSP local manifest inclusion: """ import argparse import fnmatch import subprocess import sys import urllib.request from pathlib import Path import requests import yaml # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- def load_config(path: str) -> dict: with open(path, encoding="utf-8") as f: return yaml.safe_load(f) # --------------------------------------------------------------------------- # Sources # --------------------------------------------------------------------------- def _fetch(url: str) -> requests.Response: r = requests.get(url, timeout=30) r.raise_for_status() return r def resolve_fdroid(source: dict) -> tuple: """Return (version_name, version_code, download_url) from an F-Droid repo.""" from xml.dom import minidom, pulldom repo = source["repo"].rstrip("/") pkg = source["package_id"] index_url = f"{repo}/index.xml" def text(el, tag): return el.getElementsByTagName(tag).item(0).firstChild.data r = _fetch(index_url) doc = pulldom.parseString(r.text) for event, node in doc: if event == pulldom.START_ELEMENT and node.tagName == "application": if node.getAttribute("id") == pkg: doc.expandNode(node) vercode = text(node, "marketvercode") for p in node.getElementsByTagName("package"): if text(p, "versioncode") == vercode: return ( text(p, "version"), int(vercode), f"{repo}/{text(p, 'apkname')}", ) raise RuntimeError(f"Package {pkg} not found in {repo}") def resolve_github(source: dict) -> tuple: """ Return (version_name, version_code, download_url) from a GitHub release. asset: glob pattern to pick the right APK (e.g. "arm64_*.apk"). version_code is the GitHub release ID — monotonically increasing. """ owner = source["owner"] repo = source["repo"] asset = source.get("asset", "*.apk") url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest" data = _fetch(url).json() matched = [a for a in data.get("assets", []) if fnmatch.fnmatch(a["name"], asset)] if not matched: raise RuntimeError(f"No asset matching '{asset}' in {owner}/{repo} latest release") return ( data["tag_name"].lstrip("v"), int(data["id"]), matched[0]["browser_download_url"], ) def resolve_source(source: dict) -> tuple: kind = source["type"] if kind == "fdroid": return resolve_fdroid(source) if kind == "github": return resolve_github(source) raise ValueError(f"Unknown source type: {kind}") # --------------------------------------------------------------------------- # Android.bp generation # --------------------------------------------------------------------------- def generate_android_bp(app: dict, apk_filename: str, defaults: dict) -> str: name = app["name"] bp_cfg = app.get("android_bp", {}) privileged = bp_cfg.get("privileged", defaults.get("privileged", False)) prod_spec = bp_cfg.get("product_specific", defaults.get("product_specific", True)) dex_preopt = bp_cfg.get("dex_preopt", defaults.get("dex_preopt", False)) uses_libs = bp_cfg.get("optional_uses_libs", defaults.get("optional_uses_libs", [ "androidx.window.extensions", "androidx.window.sidecar", ])) comment = bp_cfg.get("comment") lines = [] if comment: for c in comment.strip().splitlines(): lines.append(f"// {c}") lines.append("android_app_import {") lines.append(f' name: "{name}",') lines.append(f' apk: "{apk_filename}",') lines.append( ' presigned: true,') if privileged: lines.append(" privileged: true,") lines.append( ' dex_preopt: {') lines.append(f' enabled: {"true" if dex_preopt else "false"},') lines.append( ' },') if prod_spec: lines.append(" product_specific: true,") if uses_libs: lines.append(" optional_uses_libs: [") for lib in uses_libs: lines.append(f' "{lib}",') lines.append(" ],") lines.append("}") return "\n".join(lines) + "\n" # --------------------------------------------------------------------------- # {Name}.mk generation # --------------------------------------------------------------------------- def generate_mk(app: dict, defaults: dict) -> str | None: """Return makefile content if system_props or product_packages_extras are set, else None.""" props = {**defaults.get("system_props", {}), **app.get("system_props", {})} pkgs = defaults.get("product_packages_extras", []) + app.get("product_packages_extras", []) if not props and not pkgs: return None lines = [f"# Generated by prebuilt-app-maker — do not edit manually"] if props: pairs = [f" {k}={v}" for k, v in props.items()] lines.append("PRODUCT_SYSTEM_PROPERTIES += \\") lines.append(" \\\n".join(pairs)) if pkgs: lines.append("PRODUCT_PACKAGES += \\") lines.append(" \\\n".join(f" {p}" for p in pkgs)) return "\n".join(lines) + "\n" # --------------------------------------------------------------------------- # Git # --------------------------------------------------------------------------- def _git(cwd: Path, *args: str) -> str: r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) if r.returncode != 0: raise RuntimeError(f"git {args[0]}: {r.stderr.strip()}") return r.stdout.strip() def git_commit(output_dir: Path, app_name: str, version: str) -> None: if not (output_dir / ".git").exists(): _git(output_dir, "init") branches = _git(output_dir, "branch", "--list", version) if branches: _git(output_dir, "checkout", version) else: _git(output_dir, "checkout", "-b", version) _git(output_dir, "add", ".") status = _git(output_dir, "status", "--porcelain") if status: _git(output_dir, "commit", "-m", f"{app_name} {version}") print(f" committed on branch '{version}'") else: print(f" branch '{version}' already up to date") # --------------------------------------------------------------------------- # Core # --------------------------------------------------------------------------- def process_app(app: dict, defaults: dict, git_init: bool, base_output: Path) -> None: name = app["name"] output_dir = Path(app.get("output_dir", base_output / name)) output_dir.mkdir(parents=True, exist_ok=True) print(f"\n[{name}]") version_name, version_code, download_url = resolve_source(app["source"]) print(f" version: {version_name}") apk_filename = f"{name}.apk" apk_path = output_dir / apk_filename print(f" downloading {download_url}") urllib.request.urlretrieve(download_url, apk_path) print(f" saved {apk_filename}") bp = generate_android_bp(app, apk_filename, defaults) (output_dir / "Android.bp").write_text(bp, encoding="utf-8") print(f" wrote Android.bp") mk = generate_mk(app, defaults) if mk: mk_path = output_dir / f"{name}.mk" mk_path.write_text(mk, encoding="utf-8") print(f" wrote {name}.mk (include from device tree to apply system props)") if git_init: git_commit(output_dir, name, version_name) print(f" output → {output_dir}") # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser( description="Download APKs and generate AOSP prebuilt app repos.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument("--config", default="config.yml", help="Config file (default: config.yml)") parser.add_argument("--app", action="append", metavar="NAME", help="Process only this app (repeatable)") parser.add_argument("--git-init", action="store_true", help="Init git repo and commit on a version-named branch") parser.add_argument("--output", default="./output", help="Base output directory (default: ./output)") args = parser.parse_args() config = load_config(args.config) defaults = config.get("defaults", {}) apps = config.get("apps", []) base_output = Path(args.output) filter_apps = set(args.app) if args.app else None failed = [] for app in apps: if filter_apps and app["name"] not in filter_apps: continue try: process_app(app, defaults, args.git_init, base_output) except Exception as e: print(f" ERROR: {e}", file=sys.stderr) failed.append(app["name"]) print() if failed: print(f"Failed: {', '.join(failed)}", file=sys.stderr) sys.exit(1) print("Done.") if __name__ == "__main__": main()