commit d9e78d4c39ea0e877fb6637bf7712e5df2e272b2 Author: oxmc Date: Wed Jun 17 05:25:19 2026 -0700 Init: base diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..33aa72c --- /dev/null +++ b/config.example.yml @@ -0,0 +1,73 @@ +# prebuilt-app-maker config +# +# Run: +# python prebuilt-app-maker.py # download all apps +# python prebuilt-app-maker.py --app Cromite # single app +# python prebuilt-app-maker.py --git-init # also commit on version branch +# python prebuilt-app-maker.py --output /path/repos # custom base output dir +# +# Each app becomes its own repo (prebuilt_{Name}) with the version as the branch: +# +# +# If an app has system_props, a {Name}.mk is generated alongside Android.bp. +# Include it from your device tree or vendor common.mk: +# $(call inherit-product-if-exists, $(LOCAL_PATH)/../../prebuilts/apps/Cromite/Cromite.mk) + +defaults: + product_specific: true + privileged: false + dex_preopt: false + optional_uses_libs: + - androidx.window.extensions + - androidx.window.sidecar + # system_props: {} # global props applied to every app (usually empty) + # product_packages_extras: [] # extra PRODUCT_PACKAGES entries per app + +apps: + + # ── Cromite (browser + system WebView) ──────────────────────────────────── + # Cromite can serve as both a browser and the system WebView provider. + # To activate as WebView, system_props generates Cromite.mk — include it + # from your device tree or vendor common.mk: + # $(call inherit-product-if-exists, $(LOCAL_PATH)/../../prebuilts/apps/Cromite/Cromite.mk) + # - name: Cromite + # source: + # type: github + # owner: uazo + # repo: cromite + # asset: "arm64_*.apk" # Pi 4/5 are arm64 + # system_props: + # ro.webkit.package.name: org.cromite.cromite + # android_bp: + # optional_uses_libs: [] # Cromite manages its own deps + + # ── Material Files (file manager) ───────────────────────────────────────── + - name: MaterialFiles + source: + type: fdroid + repo: https://www.f-droid.org/repo + package_id: me.zhanghai.android.files + + # ── Example: privileged system app with a prop ──────────────────────────── + # - name: MyPrivilegedApp + # source: + # type: github + # owner: example + # repo: myapp + # asset: "app-release.apk" + # android_bp: + # privileged: true + # system_props: + # ro.myapp.enabled: "1" + + # ── Example: app that adds extra PRODUCT_PACKAGES ───────────────────────── + # - name: SomeApp + # source: + # type: github + # owner: example + # repo: someapp + # asset: "app-arm64.apk" + # product_packages_extras: + # - SomeAppHelper diff --git a/prebuilt-app-maker.py b/prebuilt-app-maker.py new file mode 100644 index 0000000..3f735d1 --- /dev/null +++ b/prebuilt-app-maker.py @@ -0,0 +1,278 @@ +#!/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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7daf2ab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.28.0 +PyYAML>=6.0