Files
prebuilt-app-maker/prebuilt-app-maker.py
T
2026-06-17 05:25:19 -07:00

279 lines
9.5 KiB
Python

#!/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:
<project path="prebuilts/apps/Cromite"
name="PawletOS/prebuilt_Cromite"
revision="134.0.6998.170" />
"""
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()