279 lines
9.5 KiB
Python
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()
|