From b130d0f77c9dab8d984be849f32fded0b566d786 Mon Sep 17 00:00:00 2001 From: oxmc <67136658+oxmc@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:30:25 -0700 Subject: [PATCH] Resolve dependencies from POMs; add deps:/coordinate_map wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soong needs every transitive edge spelled out as static_libs — the old generator emitted bare imports, so consumers compiled but failed at dex/runtime (this bit coil). Now each artifact's POM is parsed: compile/runtime deps become static_libs; in-group deps are auto-downloaded into the same repo (KMP -android variants probed); out-of-group deps map to in-tree module names via coordinate_map (null = ignore), with warnings for anything unmapped. Explicit deps: lists merge in; pom_deps: false or --no-pom opts out. Config: coil pinned at 3.5.0 with the full six-artifact graph, splashscreen 1.0.1 added, accompanist gains drawablepainter. --- config.example.yml | 87 +++++++++++++++++-- maven-to-lib.py | 212 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 276 insertions(+), 23 deletions(-) diff --git a/config.example.yml b/config.example.yml index f5fae35..2423297 100644 --- a/config.example.yml +++ b/config.example.yml @@ -5,6 +5,18 @@ # python maven-to-lib.py --lib coil # single lib # python maven-to-lib.py --git-init # also commit on version branch # python maven-to-lib.py --output /path/to/repos # custom base output dir +# python maven-to-lib.py --no-pom # skip POM dep resolution +# +# Dependency wiring — Soong has no Maven resolution, so every dependency an +# artifact needs must appear as a static_libs edge on its import module: +# * By default each artifact's POM is parsed. In-group deps (same Maven +# group as the lib) are downloaded into the same repo automatically +# (KMP "-android" variants are probed). Out-of-group deps are translated +# to in-tree Soong module names through coordinate_map below; anything +# unmapped prints a warning. +# * `deps: [module, ...]` on an artifact adds manual edges (merged with +# POM-derived ones; also the only source of edges with pom_deps: false +# on the lib or --no-pom on the command line). # Applied to every lib unless the lib overrides them. defaults: @@ -17,17 +29,67 @@ maven_repos: - https://repo1.maven.org/maven2 - https://dl.google.com/dl/android/maven2 +# Maven coordinate (group:artifact) -> Soong module name, used to wire +# POM dependencies that live OUTSIDE the lib being generated (AOSP prebuilts, +# external/ projects, or other prebuilt_libs repos). Map a coordinate to null +# to acknowledge-and-ignore it (no warning, no edge). Artifact names may be +# written without the KMP -android suffix — both spellings match. +coordinate_map: + # other prebuilt_libs repos + com.squareup.okio:okio: okio + com.squareup.okio:okio-jvm: okio + com.squareup.okhttp3:okhttp: okhttp3 + com.google.accompanist:accompanist-drawablepainter: accompanist-drawablepainter + # AOSP external/ + org.jetbrains.kotlin:kotlin-stdlib: kotlin-stdlib + org.jetbrains.kotlin:kotlin-stdlib-jdk8: kotlin-stdlib-jdk8 + org.jetbrains.kotlinx:kotlinx-coroutines-core: kotlinx-coroutines-core + org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm: kotlinx-coroutines-core-jvm + org.jetbrains.kotlinx:kotlinx-coroutines-android: kotlinx-coroutines-android + com.google.code.gson:gson: gson + # AOSP prebuilts/sdk androidx + androidx.annotation:annotation: androidx.annotation_annotation + androidx.annotation:annotation-jvm: androidx.annotation_annotation + androidx.activity:activity: androidx.activity_activity + androidx.activity:activity-compose: androidx.activity_activity-compose + androidx.appcompat:appcompat: androidx.appcompat_appcompat + androidx.appcompat:appcompat-resources: androidx.appcompat_appcompat-resources + androidx.core:core: androidx.core_core + androidx.core:core-ktx: androidx.core_core-ktx + androidx.collection:collection: androidx.collection_collection + androidx.collection:collection-ktx: androidx.collection_collection-ktx + androidx.lifecycle:lifecycle-runtime: androidx.lifecycle_lifecycle-runtime + androidx.lifecycle:lifecycle-runtime-ktx: androidx.lifecycle_lifecycle-runtime-ktx + androidx.exifinterface:exifinterface: androidx.exifinterface_exifinterface + androidx.profileinstaller:profileinstaller: androidx.profileinstaller_profileinstaller + androidx.compose.foundation:foundation: androidx.compose.foundation_foundation + androidx.compose.ui:ui: androidx.compose.ui_ui + androidx.compose.runtime:runtime: androidx.compose.runtime_runtime + # JetBrains' KMP compose coordinates = the androidx artifacts on Android + org.jetbrains.compose.foundation:foundation: androidx.compose.foundation_foundation + org.jetbrains.compose.ui:ui: androidx.compose.ui_ui + org.jetbrains.compose.runtime:runtime: androidx.compose.runtime_runtime + # annotation-only / compile-time-only artifacts: no runtime payload to bundle + org.jetbrains:annotations: null + libs: # ── Image loading ────────────────────────────────────────────────────────── - # Coil 3.x is Kotlin Multiplatform — the Android-specific artifacts use the - # -android suffix. bp_name keeps the module names stable for apps that depend - # on "coil-base" and "coil-compose" in their Android.bp static_libs. + # Coil 3.x is Kotlin Multiplatform — the Android payloads use the -android + # artifact suffix, and module names drop it (coil-core, coil-compose, ...). + # Version is PINNED: the manifest revision (prebuilt_libs_coil @ 3.5.0) and + # app code (coil3.* imports) move together, never via 'latest'. + # Consumers need "coil-compose" + "coil-network-okhttp" (Coil 3 core ships + # no network fetcher; the okhttp one self-registers via ServiceLoader). - name: coil - version: latest + version: "3.5.0" artifacts: - - { group: io.coil-kt.coil3, artifact: coil-android, bp_name: coil-base } - - { group: io.coil-kt.coil3, artifact: coil-compose-android, bp_name: coil-compose } + - { group: io.coil-kt.coil3, artifact: coil-core-android, bp_name: coil-core } + - { group: io.coil-kt.coil3, artifact: coil-android, bp_name: coil } + - { group: io.coil-kt.coil3, artifact: coil-compose-core-android, bp_name: coil-compose-core } + - { group: io.coil-kt.coil3, artifact: coil-compose-android, bp_name: coil-compose } + - { group: io.coil-kt.coil3, artifact: coil-network-core-android, bp_name: coil-network-core } + - { group: io.coil-kt.coil3, artifact: coil-network-okhttp-android, bp_name: coil-network-okhttp } - name: glide version: latest @@ -44,6 +106,7 @@ libs: - name: okhttp version: latest artifacts: + # bp_name okhttp3: "okhttp" collides with the ART module SDK - { group: com.squareup.okhttp3, artifact: okhttp, bp_name: okhttp3 } - { group: com.squareup.okhttp3, artifact: logging-interceptor, bp_name: okhttp-logging-interceptor } @@ -86,6 +149,13 @@ libs: version: latest artifacts: - { group: com.google.accompanist, artifact: accompanist-permissions, bp_name: accompanist-permissions } + # runtime dep of coil-compose-core + - { group: com.google.accompanist, artifact: accompanist-drawablepainter, bp_name: accompanist-drawablepainter } + + - name: splashscreen # androidx core-splashscreen is NOT in AOSP prebuilts/sdk + version: "1.0.1" + artifacts: + - { group: androidx.core, artifact: core-splashscreen, bp_name: androidx.core_core-splashscreen } # ── Utilities ────────────────────────────────────────────────────────────── - name: timber @@ -117,10 +187,11 @@ libs: - { group: io.ktor, artifact: ktor-client-content-negotiation, bp_name: ktor-client-content-negotiation } - { group: io.ktor, artifact: ktor-serialization-kotlinx-json, bp_name: ktor-serialization-kotlinx-json } - # ── Custom Maven repo example ────────────────────────────────────────────── + # ── Custom Maven repo / manual deps example ──────────────────────────────── # - name: mylib # version: "1.2.0" + # pom_deps: false # opt out of POM parsing for this lib # maven_repos: # - https://maven.example.com/releases # artifacts: - # - { group: com.example, artifact: mylib } + # - { group: com.example, artifact: mylib, deps: [okhttp3, gson] } diff --git a/maven-to-lib.py b/maven-to-lib.py index e891b88..ee69b5d 100644 --- a/maven-to-lib.py +++ b/maven-to-lib.py @@ -5,12 +5,27 @@ maven-to-lib — Download Maven artifacts and generate Android.bp prebuilt lib r Each lib defined in config.yml becomes a directory containing its AAR(s)/JAR(s) plus a generated Android.bp with android_library_import / java_import modules. +Dependency wiring (Soong has no POM resolution — every edge must be a +static_libs entry on the import module): + + * POM mode (default): each downloaded artifact's .pom is parsed. + - compile/runtime, non-optional dependencies become static_libs edges + - deps whose group belongs to this lib are auto-downloaded into the same + repo (KMP "-android" variants are probed automatically) + - deps outside the lib are mapped to in-tree Soong module names via the + top-level `coordinate_map` (map a coordinate to null to ignore it); + unmapped coordinates produce a warning so the wiring is never silently + incomplete + - disable per lib with `pom_deps: false`, or globally with --no-pom + * Manual mode: give any artifact an explicit `deps: [module, ...]` list; + these merge with (and work without) POM-derived edges. + Run with --git-init to have each output directory committed on a version-named branch, ready to push as a prebuilt lib repo for AOSP local manifest inclusion: + revision="3.5.0" /> """ import argparse @@ -23,6 +38,11 @@ from pathlib import Path import requests import yaml +# Windows consoles default to a legacy codepage that can't print arrows etc. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + DEFAULT_MAVEN_REPOS = [ "https://repo1.maven.org/maven2", @@ -47,6 +67,11 @@ def _group_path(group: str) -> str: return group.replace(".", "/") +def _strip_android(artifact: str) -> str: + """KMP android artifacts are '-android'; module names drop the suffix.""" + return artifact[:-8] if artifact.endswith("-android") else artifact + + def resolve_version(group: str, artifact: str, version: str, repos: list) -> str: """Return the concrete version string, resolving 'latest' via Maven metadata.""" if version != "latest": @@ -102,6 +127,90 @@ def download_artifact(group: str, artifact: str, version: str, ext: str, repos: raise RuntimeError(f"Could not download {group}:{artifact}:{version} (.aar or .jar)") +def artifact_exists(group: str, artifact: str, version: str, repos: list) -> bool: + gp = _group_path(group) + for ext in ("aar", "jar"): + filename = f"{artifact}-{version}.{ext}" + for repo in repos: + url = f"{repo.rstrip('/')}/{gp}/{artifact}/{version}/{filename}" + try: + r = requests.head(url, timeout=10, allow_redirects=True) + if r.status_code == 200: + return True + except Exception: + pass + return False + + +def resolve_kmp_variant(group: str, artifact: str, version: str, repos: list) -> str: + """ + POMs of KMP libraries reference the root artifact (e.g. coil-core), but the + Android payload lives in the '-android' variant. Prefer it when published. + """ + if not artifact.endswith("-android"): + candidate = f"{artifact}-android" + if artifact_exists(group, candidate, version, repos): + return candidate + return artifact + + +# --------------------------------------------------------------------------- +# POM parsing +# --------------------------------------------------------------------------- + +def _local_tag(tag: str) -> str: + """Strip any XML namespace: '{http://...}artifactId' -> 'artifactId'.""" + return tag.rsplit("}", 1)[-1] + + +def fetch_pom(group: str, artifact: str, version: str, repos: list): + gp = _group_path(group) + filename = f"{artifact}-{version}.pom" + for repo in repos: + url = f"{repo.rstrip('/')}/{gp}/{artifact}/{version}/{filename}" + try: + r = requests.get(url, timeout=15) + if r.status_code == 200: + return ET.fromstring(r.content) + except Exception as e: + print(f" warn: pom fetch failed ({url}): {e}") + return None + + +def parse_pom_deps(pom_root) -> list: + """ + Return [(group, artifact, version-or-None), ...] for the POM's direct + compile/runtime, non-optional dependencies. + + Only that is a direct child of is read — + pins versions but declares no actual dependencies. + """ + deps = [] + for child in pom_root: + if _local_tag(child.tag) != "dependencies": + continue + for dep in child: + if _local_tag(dep.tag) != "dependency": + continue + fields = {_local_tag(f.tag): (f.text or "").strip() for f in dep} + scope = fields.get("scope") or "compile" + if scope not in ("compile", "runtime"): + continue + if fields.get("optional") == "true": + continue + group = fields.get("groupId") + artifact = fields.get("artifactId") + if not group or not artifact: + continue + version = fields.get("version") or None + # Property references (${...}) can't be resolved without the parent + # POM chain; the caller falls back to the depending artifact's version. + if version and version.startswith("${"): + version = None + deps.append((group, artifact, version)) + return deps + + # --------------------------------------------------------------------------- # Android.bp generation # --------------------------------------------------------------------------- @@ -110,11 +219,12 @@ def _bp_module_name(artifact_cfg: dict) -> str: return artifact_cfg.get("bp_name", artifact_cfg["artifact"]) -def generate_android_bp(lib: dict, resolved: list, defaults: dict) -> str: +def generate_android_bp(lib: dict, resolved: list, defaults: dict, dep_edges: dict) -> str: """ Generate an Android.bp string. - resolved: list of (artifact_cfg, output_filename, resolved_version, actual_ext) + resolved: list of (artifact_cfg, output_filename, resolved_version, actual_ext) + dep_edges: bp module name -> set of static_libs module names """ sdk_version = str(lib.get("sdk_version", defaults.get("sdk_version", "current"))) min_sdk = str(lib.get("min_sdk_version", defaults.get("min_sdk_version", "36"))) @@ -124,11 +234,18 @@ def generate_android_bp(lib: dict, resolved: list, defaults: dict) -> str: ext = actual_ext # use what was actually downloaded, not what was requested name = _bp_module_name(artifact_cfg) + deps = sorted(dep_edges.get(name, ())) + static_libs = "" + if deps: + entries = "".join(f' "{d}",\n' for d in deps) + static_libs = f" static_libs: [\n{entries} ],\n" + if ext == "aar": block = ( f'android_library_import {{\n' f' name: "{name}",\n' f' aars: ["{out_name}"],\n' + f'{static_libs}' f' sdk_version: "{sdk_version}",\n' f' min_sdk_version: "{min_sdk}",\n' f' visibility: ["//visibility:public"],\n' @@ -139,6 +256,7 @@ def generate_android_bp(lib: dict, resolved: list, defaults: dict) -> str: f'java_import {{\n' f' name: "{name}",\n' f' jars: ["{out_name}"],\n' + f'{static_libs}' f' sdk_version: "{sdk_version}",\n' f' visibility: ["//visibility:public"],\n' f'}}' @@ -191,7 +309,15 @@ def git_commit(output_dir: Path, lib_name: str, version: str) -> None: # Core processing # --------------------------------------------------------------------------- -def process_lib(lib: dict, defaults: dict, global_repos: list, git_init: bool, base_output: Path) -> None: +def process_lib( + lib: dict, + defaults: dict, + global_repos: list, + git_init: bool, + base_output: Path, + coordinate_map: dict, + use_poms: bool, +) -> None: name = lib["name"] print(f"\n[{name}]") @@ -200,9 +326,20 @@ def process_lib(lib: dict, defaults: dict, global_repos: list, git_init: bool, b output_dir.mkdir(parents=True, exist_ok=True) lib_version = lib.get("version") # default version for all artifacts in this lib - resolved = [] + lib_groups = {a["group"] for a in lib["artifacts"]} + pom_enabled = use_poms and lib.get("pom_deps", True) - for artifact_cfg in lib["artifacts"]: + # Worklist: explicit artifacts first, POM-discovered in-group transitives + # appended behind them. Keyed on (group, suffix-stripped artifact) so a POM + # reference to "coil-core" dedups against a configured "coil-core-android". + queue = [dict(a) for a in lib["artifacts"]] + index = {(a["group"], _strip_android(a["artifact"])): a for a in queue} + + resolved = [] + dep_edges = {} # bp module name -> set of static_libs names + + while queue: + artifact_cfg = queue.pop(0) group = artifact_cfg["group"] artifact = artifact_cfg["artifact"] ext = artifact_cfg.get("type", "aar") @@ -211,7 +348,7 @@ def process_lib(lib: dict, defaults: dict, global_repos: list, git_init: bool, b if version is None: raise ValueError(f"No version for {group}:{artifact} — set it on the artifact or the lib") - ver = resolve_version(group, artifact, version, repos) + ver = resolve_version(group, artifact, version, repos) data, actual_ext = download_artifact(group, artifact, ver, ext, repos) # Output filename: explicit output_name, or "{artifact}.{actual_ext}" @@ -221,8 +358,47 @@ def process_lib(lib: dict, defaults: dict, global_repos: list, git_init: bool, b resolved.append((artifact_cfg, out_name, ver, actual_ext)) + mod_name = _bp_module_name(artifact_cfg) + edges = set(artifact_cfg.get("deps", [])) + + if pom_enabled: + pom = fetch_pom(group, artifact, ver, repos) + if pom is None: + print(f" warn: no POM for {group}:{artifact}:{ver} — POM deps skipped") + else: + for dg, da, dv in parse_pom_deps(pom): + key = (dg, _strip_android(da)) + if dg in lib_groups: + dep_cfg = index.get(key) + if dep_cfg is None: + dver = dv or ver + actual_da = resolve_kmp_variant(dg, da, dver, repos) + dep_cfg = { + "group": dg, + "artifact": actual_da, + "bp_name": _strip_android(actual_da), + "version": dver, + } + index[key] = dep_cfg + queue.append(dep_cfg) + print(f" + transitive {dg}:{actual_da}:{dver} (from {artifact} POM)") + edges.add(_bp_module_name(dep_cfg)) + else: + for coord in (f"{dg}:{da}", f"{dg}:{_strip_android(da)}"): + if coord in coordinate_map: + mapped = coordinate_map[coord] + if mapped: # null/empty mapping = deliberately ignored + edges.add(mapped) + break + else: + print(f" warn: unmapped dep {dg}:{da} (of {artifact}) — " + f"add it to coordinate_map (or map it to null to ignore)") + + edges.discard(mod_name) + dep_edges[mod_name] = edges + # Android.bp - bp = generate_android_bp(lib, resolved, defaults) + bp = generate_android_bp(lib, resolved, defaults, dep_edges) (output_dir / "Android.bp").write_text(bp, encoding="utf-8") print(f" wrote Android.bp") @@ -261,21 +437,27 @@ def main() -> None: "--output", default="./output", help="Base output directory when lib has no output_dir set (default: ./output)", ) + parser.add_argument( + "--no-pom", action="store_true", + help="Skip POM parsing entirely; only explicit deps: lists are emitted", + ) args = parser.parse_args() - config = load_config(args.config) - defaults = config.get("defaults", {}) - global_repos = config.get("maven_repos", DEFAULT_MAVEN_REPOS) - libs = config.get("libs", []) - base_output = Path(args.output) - filter_libs = set(args.lib) if args.lib else None + config = load_config(args.config) + defaults = config.get("defaults", {}) + global_repos = config.get("maven_repos", DEFAULT_MAVEN_REPOS) + coordinate_map = config.get("coordinate_map", {}) or {} + libs = config.get("libs", []) + base_output = Path(args.output) + filter_libs = set(args.lib) if args.lib else None failed = [] for lib in libs: if filter_libs and lib["name"] not in filter_libs: continue try: - process_lib(lib, defaults, global_repos, args.git_init, base_output) + process_lib(lib, defaults, global_repos, args.git_init, base_output, + coordinate_map, not args.no_pom) except Exception as e: print(f" ERROR: {e}", file=sys.stderr) failed.append(lib["name"])