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"])