Resolve dependencies from POMs; add deps:/coordinate_map wiring

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.
This commit is contained in:
oxmc
2026-07-15 16:30:25 -07:00
parent 99d9961632
commit b130d0f77c
2 changed files with 276 additions and 23 deletions
+79 -8
View File
@@ -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] }
+197 -15
View File
@@ -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:
<project path="prebuilts/application_libs/coil"
name="PawletOS/prebuilt_libs_coil"
revision="3.1.4" />
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 '<name>-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 <dependencies> that is a direct child of <project> is read —
<dependencyManagement> 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"])