Summary
.NET for Android should move toward resolving Java dependencies once, at the final application graph boundary, using Gradle as the resolution authority. Binding projects and NuGet packages should publish declarative Maven requirements and artifact provenance instead of independently embedding or downloading their own copies of AAR/JAR files.
The proposed direction is:
- Preserve the existing
AndroidMavenLibrary and AndroidGradleProject behaviors for compatibility.
- Add a separate declarative contract, tentatively named
AndroidMavenDependency, for dependencies that flow transitively from projects and NuGet packages to an app.
- Aggregate all declarations into one normalized request graph per app and target framework.
- Run a small SDK-owned Gradle build, using an SDK-owned binary plugin, Android-compatible variant attributes, and a pinned Gradle distribution.
- Export a machine-readable graph plus separate compile and runtime AAR/JAR artifact views, then feed those files into the appropriate existing binding and application pipelines.
- Publish dependency manifests through NuGet
buildTransitive assets and an equivalent recursive project-reference contract.
- Reconcile Gradle-selected components with legacy embedded/downloaded payloads before the current filename-based duplicate checks.
- Roll out first in report-only, dual-payload mode. Do not remove AAR/JAR files from existing packages until resolution, locking, offline behavior, and mixed-mode suppression have proven reliable.
NuGet packages must not contribute arbitrary Gradle script fragments. Package inputs should be declarative data only. An application could opt into an app-owned Gradle plugin or script as an explicit full-trust escape hatch.
This would not replace NuGet restore. NuGet remains responsible for managed dependencies; the Android SDK would own a second, post-NuGet Java dependency resolution phase.
Motivation
Existing pieces stop short of app-wide resolution
.NET 9 added useful primitives:
AndroidMavenLibrary downloads one exact Maven artifact and its POM, then uses Java Dependency Verification to check that dependencies were fulfilled elsewhere.
AndroidGradleProject invokes a project's Gradle Wrapper, builds an AAR or APK, and injects AAR outputs into AndroidLibrary.
JavaArtifact metadata and artifact=g:a:v NuGet tags identify Java components supplied by a project or package.
AndroidIgnoredJavaDependency, POM parent/import handling, Maven version range parsing, and a reusable Maven cache already exist.
These solve acquisition and validation inside one binding project, but they do not resolve the combined application graph.
Native Library Interop exposes the missing boundary
CommunityToolkit/Maui.NativeLibraryInterop now delegates Android builds to AndroidGradleProject. A binding project builds and binds its wrapper AAR, but AGP does not bundle that module's Maven dependencies into the AAR. The consuming app must repeat those dependencies manually as AndroidMavenLibrary and PackageReference items.
This creates two independent graphs:
- Gradle's compile-time graph for the wrapper module.
- The .NET app's NuGet/AAR/JAR graph for runtime packaging.
Nothing guarantees that the selected AndroidX, Kotlin, Material, or vendor SDK versions match.
Existing issues demonstrate related failure modes:
Small proof of concept
A disposable resolver using the repository's Gradle 9.5 wrapper, Gradle's base plugin, and one resolvable configuration requested:
androidx.appcompat:appcompat:1.6.1
androidx.appcompat:appcompat:1.7.0
com.squareup.okio:okio-jvm:3.9.0
Gradle selected appcompat:1.7.0, honored AndroidX alignment metadata, produced 29 AARs plus 14 JARs, and reported both request origins and conflict selection through dependencyInsight. A second offline invocation completed from cache.
This proves that resolving AAR/JAR payloads does not inherently require applying AGP. It does not prove every Android variant-selection case: a production resolver must set AGP-equivalent attributes such as Android JVM environment, Kotlin Android platform, build type, usage, and artifact type. The synthetic build need not apply AGP, but it does need an SDK-owned Android attribute schema and compatibility/disambiguation rules.
Goals
- Resolve one coherent Java component graph for the final Android app.
- Preserve Maven POM and Gradle Module Metadata semantics rather than reimplementing a subset in C#.
- Let app projects, project references, transitive NuGet packages, and
AndroidGradleProject modules contribute requirements.
- Support AARs, JARs, BOMs/platforms, rich versions, exclusions, custom repositories, and authenticated repositories.
- Make every selected component traceable to the project or NuGet package that requested it.
- Detect and explain version conflicts before D8/R8 or runtime failures.
- Provide deterministic, locked, checksum-verified, offline-capable builds.
- Preserve the existing ecosystem throughout a staged migration.
- Remove the dependency duplication currently required by Native Library Interop.
- Avoid invoking Gradle on no-op incremental builds.
Non-goals
- Replacing NuGet restore or making Maven dependencies participate in NuGet's solver.
- Translating Maven versions into NuGet versions.
- Automatically generating or validating managed binding APIs.
- Allowing arbitrary Gradle code from restored NuGet packages.
- Silently inferring Maven coordinates for unknown/shaded binaries and changing behavior based on that inference.
- Guaranteeing binary compatibility between different versions of the same Java component.
- Merging independent customer Gradle builds, wrappers, settings files, or AGP versions into one multi-project build.
Architecture
SDK-owned synthetic resolver build
The SDK would generate a normalized request file and invoke a constant, SDK-owned Gradle build. An SDK-owned binary settings/project plugin from the local workload would:
- Configure approved repositories.
- Create distinct compile and runtime resolution configurations.
- Apply Android JVM, Kotlin Android, build-type, usage, category, library-elements, and artifact-type attributes.
- Apply dependencies, constraints, platforms, exclusions, capabilities, and app overrides.
- Resolve with Gradle's public
ResolutionResult, ArtifactCollection, and ArtifactView APIs.
- Copy exact selected AAR/JAR files into deterministic compile/runtime output directories.
- Write stable JSON containing request paths, selected versions, selection reasons, repositories, checksums, local-provider decisions, and unresolved edges.
Dependency data should live in sorted JSON, not generated executable Kotlin/Groovy statements. Generated scripts should remain nearly constant.
Why not extend the current C# POM verifier into a solver?
Java.Interop.Tools.Maven remains useful for POM parsing, diagnostics, and compatibility, but should not become the graph authority:
- Gradle Module Metadata contains variants, constraints, capabilities, and alignment rules not representable in POM.
- Repository authentication, content filtering, metadata source selection, BOMs, changing modules, and conflict selection would all need to be recreated.
- Maven, NuGet, and Gradle have different conflict semantics.
- The current verifier intentionally validates a direct artifact rather than selecting a closure.
Why not import every contributed Gradle file?
Independent Gradle files cannot be safely concatenated or applied into a synthetic build:
- Build/settings scripts execute arbitrary code during configuration.
- Script plugins are discouraged for production Gradle logic.
- Multiple fragments can mutate repositories, configurations, credentials, and resolution timing in incompatible/order-dependent ways.
- Existing native projects can require different Gradle, AGP, Kotlin, plugin, and JDK versions.
- Gradle has no sandbox for untrusted scripts.
Application-owned binary plugins or scripts can remain an explicit full-trust escape hatch, but restored packages should contribute data only.
Proposed MSBuild model
Names are provisional and should go through API review.
Preserve AndroidMavenLibrary
Keep its current direct acquisition/binding/packing behavior. It remains valuable for binding-project authoring and compatibility. During pack, the SDK could translate it into a published Maven dependency/provision manifest.
Add AndroidMavenDependency
<ItemGroup>
<AndroidMavenDependency
Include="androidx.core:core"
Version="1.13.1"
Repository="Google" />
</ItemGroup>
Suggested metadata:
| Metadata |
Meaning |
Version |
Gradle require; participates in normal conflict resolution. |
VersionStrictly |
Exact version/range; incompatible resolution fails. |
VersionPrefer |
Soft preference. |
VersionReject |
Rejected versions/ranges. |
Repository |
Stable repository ID. |
Scope |
Runtime by default; Compile/CompileOnly for binding classpaths. |
Exclude |
Path-local group:artifact exclusions. |
Optional |
Do not add unless requested elsewhere. |
PrivateAssets |
Prevent requirement flow where appropriate. |
Reason |
Human-readable origin for dependency insight. |
Versions remain in Maven/Gradle version space and must never be parsed as NuGetVersion.
Add constraints, platforms, repositories, and app overrides
<AndroidMavenConstraint
Include="org.jetbrains.kotlin:kotlin-stdlib"
Version="[1.9,2.0)" />
<AndroidMavenPlatform
Include="com.google.firebase:firebase-bom"
Version="34.2.0"
Repository="Google" />
<AndroidMavenRepository
Include="Mapbox"
Url="https://api.mapbox.com/downloads/v2/releases/maven"
IncludeGroup="com.mapbox.*"
Authentication="Header"
CredentialIdentity="MapboxDownloads" />
Applications also need explicit final-graph escape hatches such as:
AndroidMavenVersionOverride
AndroidMavenGlobalExclude
AndroidMavenSubstitution
AndroidMavenCapabilitySelection
Using an override should be visible in the graph and diagnostics.
Version and compatibility semantics
Use Gradle's normal rule: select the highest version satisfying active requirements, constraints, rejects, and strict bounds. Do not add a "compatible major" heuristic; Android libraries do not uniformly follow semantic versioning.
A managed binding was generated against a concrete Java binary. Its manifest should record:
- Exact
boundAgainst coordinate, version, and checksum.
- A normal
require constraint unless the package author chooses strictly.
- A package-author-declared tested compatibility range, when known.
- POM/GMM runtime dependency constraints.
If Gradle selects a version other than boundAgainst, resolve mode should fail unless the package declared that selection compatible or the app supplies an explicit unsafe override. This produces a binding-compatibility diagnostic rather than overloading Gradle's strict-version conflict message.
Dynamic and changing/SNAPSHOT versions should be rejected by default because they undermine MSBuild incrementality and lock-file reproducibility.
Path-local exclusions should preserve Gradle semantics. App-owned global exclusions should warn when removing a component another dependency declared required.
Capabilities/substitutions should cover curated replacement and relocation cases such as old support libraries versus AndroidX, Kotlin stdlib consolidation, renamed artifacts, and placeholder compatibility packages.
Package and project propagation
Versioned manifest
A package/project/file sidecar should distinguish requirements from local providers:
{
"schemaVersion": 1,
"producer": {
"kind": "package",
"id": "Xamarin.AndroidX.Activity",
"version": "1.13.0.1",
"targetFramework": "net11.0-android"
},
"dependencies": [
{
"coordinate": "androidx.activity:activity",
"require": "1.13.0",
"boundAgainst": "1.13.0",
"compatibleWith": "[1.13.0]",
"repository": "Google",
"scope": "Runtime",
"role": "BoundPrimary"
}
],
"constraints": [],
"platforms": [],
"repositories": [],
"provides": [
{
"coordinate": "androidx.activity:activity:1.13.0",
"path": "aar/androidx.activity.activity.aar",
"sha256": "...",
"payloadKind": "aar",
"modified": false
}
]
}
dependencies: what the consumer needs.
provides: what the package already carries locally.
constraints: requirements that do not add a component.
platforms: BOMs.
repositories: repository requirements without secrets.
NuGet
Pack the JSON manifest plus a conventionally named buildTransitive/<tfm>/<PackageId>.props, or import the data fragment from an existing <PackageId>.props/.targets. NuGet only auto-imports conventionally named package files.
The props should only contribute an AndroidMavenManifest item and be guarded by an SDK capability property so older SDKs continue using packaged payloads.
Continue parsing artifact=/artifact_versioned= package tags as a fallback for already published packages, but do not use tags as the long-term contract.
Project references
Add a recursive target contract such as GetAndroidMavenManifests, modeled after GetCopyToOutputDirectoryItems, preserving exact project origins and defining behavior for ReferenceOutputAssembly=false, private references, and references that become package references during pack.
File references
Optionally discover Binding.dll.android-maven.json beside a file-referenced binding assembly. Unknown file references remain on the legacy path.
Integration with AndroidGradleProject
Do not merge native projects into the synthetic resolver build. Instead, extend the Gradle integration so the selected module exports its requested and resolved dependency graph alongside AAR/APK output.
For a com.android.library module, export:
- Direct requested runtime dependencies and constraints.
- Resolved runtime components and selected versions.
- Configuration/build variant.
- Selection reasons.
- Artifact checksums where available.
The module AAR remains the direct Bind=true artifact. External dependencies become Bind=false, Pack=false Maven requirements. During dotnet pack, the exported graph becomes the package manifest. This removes the current requirement for Native Library Interop apps to repeat dependencies manually.
Graph export must use a separate, always-applied SDK init/plugin input rather than _AGPInitScriptPath, because that existing build-directory script is user-overridable.
MSBuild target flow
Resolution and reconciliation must occur at different points. Package AARs are discovered by _ResolveAars after ResolveReferences, while classic embedded resources are consumed later by _ResolveLibraryProjectImports.
NuGet restore / ResolvePackageAssets
-> collect app and buildTransitive declarations
-> collect recursive project-reference manifests
-> build AndroidGradleProject modules and export their manifests
-> normalize one request model
-> run Gradle resolution when inputs changed
-> emit selected direct AndroidLibrary items with JavaArtifact metadata
-> _CategorizeAndroidLibraries
-> ResolveReferences / _ResolveAars discovers package AAR providers
-> inventory AndroidAarLibrary, AndroidJavaLibrary, and package/reference paths
-> reconcile Gradle selections with local and legacy providers
-> _ResolveLibraryProjectImports
-> existing extraction, manifest/resource merge, duplicate checks, D8/R8
The request/resolve phase can run after _BuildAndroidGradleProjects and before _CategorizeAndroidLibraries. A separate reconciliation phase must run after _ResolveAars and before _ResolveLibraryProjectImports; NuGet package AARs are added directly to AndroidAarLibrary, not AndroidLibrary.
Explicit DependsOnTargets relationships should order the new targets relative to _MavenRestore, _VerifyJavaDependencies, _ResolveAars, and _ResolveLibraryProjectImports. Import order should not define behavior.
Resolver-emitted items must carry JavaArtifact so Java Dependency Verification recognizes them.
Application versus library builds
The full runtime graph should resolve only for an application head.
Library/binding projects should:
- Continue using existing direct Maven/Gradle acquisition for binding inputs.
- Invoke the synthetic resolver only when explicitly needing a compile/compile-only graph.
- Publish requirements and
boundAgainst data rather than embedding the runtime closure.
- Mark resolver-produced inputs
Bind=false, Pack=false, and exclude them from _CreateAarInputs.
This avoids one Gradle invocation per class library and prevents transitive runtime dependencies from leaking into every binding AAR/NuGet package.
Incrementality and performance
Inputs should include only the normalized data actually consumed:
project.assets.json
- Imported package manifests
- Recursive project-reference manifests
- Exported
AndroidGradleProject manifests
- App declarations/overrides
- Repository/mirror configuration
- Lock and verification policy
- Resolver plugin and pinned Gradle version
Real outputs should be used rather than a stamp:
obj/<configuration>/<tfm>/android-maven/graph.json
obj/<configuration>/<tfm>/android-maven/compile/
obj/<configuration>/<tfm>/android-maven/runtime/
JSON and scripts should be byte-stable: stable ordering, no timestamps, no avoidable absolute paths, and write-only-when-changed behavior.
The Gradle task should:
- Use distinct compile/runtime artifact views.
- Use deterministic
Sync outputs.
- Avoid applying AGP.
- Avoid invocation when MSBuild inputs are unchanged.
- Map runtime artifacts to
AndroidAarLibrary/AndroidJavaLibrary.
- Map compile-only artifacts to reference/binding classpaths without sending them to D8/R8.
- Set
AndroidSkipResourceProcessing=false on runtime AARs to match _ResolveAars.
Design-time builds must not initiate network access; they should reuse the last graph and let a normal build refresh it.
Gradle provisioning
The synthetic resolver should not depend on a system Gradle installation, a customer wrapper, or a wrapper restored from a package.
Use an SDK-pinned, checksum-verified Gradle distribution and local SDK plugin. Tie the resolver version to the workload rather than current Gradle.
Run it with an SDK-controlled Gradle user home, separate from ~/.gradle, so user init.d scripts cannot mutate resolution. Credentials should be bridged explicitly through approved environment/property inputs.
A dedicated resolver daemon under the isolated home could improve local builds, with a CI/no-daemon mode. The implementation spike should compare this with the Tooling API and pin/test the Gradle/JDK compatibility matrix.
AndroidGradleProject continues honoring the native project's own wrapper because it builds that project's code/plugins.
Locking, verification, offline use, and repositories
Expose an SDK-owned stable lock such as android.dependencies.lock.json, recording:
- Requested and selected versions.
- Repository identity/canonical source.
- Artifact and metadata SHA-256.
- Provider: Maven, package, project, or local artifact.
- Contributing dependency paths.
- Resolver schema/version.
Use Gradle's native dependency locking in strict mode as enforcement. The SDK JSON is the stable UX/provenance projection and can generate native Gradle lock/verification files under obj.
Policy:
- Apps check in locks; libraries publish requirements/ranges.
- Locked mode fails for missing, extra, changed, or checksum-mismatched components.
- Provide explicit update tooling.
- Require SHA-256 in locked mode.
- Treat the same coordinate/version with different content as a supply-chain error.
--offline must fail clearly on a missing coordinate.
- Support a shared read-only cache plus writable delta.
- Support repository mirrors, including the existing dnceng
dotnet-public-maven model.
For custom/private repositories:
- Packages may declare URL, stable ID, and mandatory group/module filters.
- Central and Google are pre-approved.
- A transitive custom repository is not contacted until the app approves it.
- Apps can replace URLs with enterprise mirrors.
- Credentials are referenced by identity and bridged from environment variables, an approved property-file path, or a provider.
- Secrets never enter generated JSON, configuration-cache fingerprints, binlogs, or normal diagnostics.
- HTTP requires explicit insecure opt-in.
- Content filters reduce dependency confusion and private-name leakage.
The synthetic resolver should not need remote Gradle plugins because the SDK plugin is local.
Mixed-mode compatibility
The application can contain:
- New coordinate-only packages.
- Coordinate-plus-payload packages.
- Modern packages with loose AAR/JAR and
artifact= tags.
- Xamarin.Build.Download packages.
- Classic embedded-resource bindings.
- Unknown local AAR/JAR files.
- Patched, shaded, or source-built binaries without public Maven equivalents.
Identity precedence:
- Explicit manifest or
JavaArtifact coordinates.
- Known package mapping data.
- Existing NuGet
artifact= tags.
- In-archive Maven metadata for diagnostics only.
- Optional checksum lookup for diagnostics only.
Never alter the graph based only on inferred identity.
Reconciliation rules:
- If a local provider declares the selected coordinate/version and expected checksum, use it and avoid the downloaded duplicate.
- A known modified/patched provider is used only when explicitly declared with its checksum.
- Selecting a different version than a binding's
boundAgainst is an error unless declared compatible or explicitly overridden unsafely.
- Unknown artifacts remain and flow to existing duplicate checks.
- Two providers claiming the same coordinate/version with different hashes fail.
Reconciliation must enumerate the complete restored package graph from project.assets.json and package nuspecs. Looking only at direct PackageReference items misses transitive legacy packages.
Coordinate-aware reconciliation should run before _CheckDuplicateJavaLibraries; the current filename/content check remains as fallback.
dotnet/android-libraries migration
The repository currently generates about 701 NuGet packages from config.json:
- Roughly 520 carry AAR/JAR payloads.
- Roughly 180 proprietary-license families already use Xamarin.Build.Download at app build time.
- AndroidX is the largest family.
- Several packages include source-built shim JARs or modified AARs.
All generated packages already carry Maven coordinates in PackageTags, and target files already go to build/ and buildTransitive/ TFM folders.
Phase A: metadata only
Add Binderator support for:
android-maven-manifest.json
- Conventionally imported buildTransitive data
provides hashes and modified flags
- Repository identity
- Direct/transitive requirements
- Exclusions and extra dependencies
- Multi-artifact manifests
No payload change.
Phase B: dual mode
Keep the payload and publish coordinates. New resolution can use the packaged file as a local provider; old SDKs remain unchanged.
Add global/per-artifact embedArtifacts configuration and explicit modified/published-artifact identity.
Phase C: low-risk coordinate-only pilot
Pilot a small Maven Central family with redistributable licensing, no shim, no AAR mutation, simple packaging, and good runtime tests. Do not begin with AndroidX or proprietary Google packages.
Phase D: replace Xamarin.Build.Download
The proprietary set is already downloaded at app build. Replace XBD declarations while preserving no-redistribution behavior, hash verification, Google mirror redirection, cache/offline diagnostics, and license notices.
Phase E: AndroidX
Before removing payloads, verify with regression tests that resolver-supplied AARs retain current behavior:
- Consumer extraction already skips non-runtime AAR JARs such as
lint.jar and api.jar.
- Consumer extraction already handles
proguard.txt; verify AGP 9 consumer-rule behavior.
- AndroidX atomic/alignment metadata may select a different coherent family version.
- Existing all-package tests document known collisions.
Permanent local-provider exceptions likely remain for source-built com.xamarin.* shims, patched/shaded binaries, frozen/relocated artifacts, and special vendor licensing/authentication cases. Exceptions should still publish provenance and requirements.
Diagnostics
Always write obj/<tfm>/android-maven/graph.json containing:
- Selected components.
- Every request/constraint and origin.
- Selection reasons.
- Repository and checksums.
- Local-provider decisions.
- Overrides, exclusions, substitutions, and capabilities.
- Unresolved dependencies.
Provide:
dotnet build -t:AndroidMavenDependencyInsight -p:AndroidMavenInsight=group:artifact
- A graph/list target for support bundles.
- Stable XA diagnostics for conflicts, unknown provenance, unapproved repositories, lock drift, checksum mismatch, offline misses, and unsafe overrides.
- Existing Microsoft NuGet package suggestions where useful.
Every Gradle declaration should include because("contributed by ...") so native dependencyInsight is useful.
Suggested modes:
Legacy: current behavior only.
Report: resolve/analyze but do not replace legacy payloads.
Resolve: Gradle graph owns known-coordinate selection.
Start with Legacy or Report; consider a future TFM-gated Resolve default only after package metadata and compatibility goals are met. Older TFMs keep legacy defaults.
Proposed workstreams
- Schema/API: item names, manifest/graph schemas, version and override semantics.
- Gradle resolver: local binary plugin, Android attributes, compile/runtime graphs, provisioning, isolated home, process model, cache/offline.
- MSBuild: collection, early resolution, late reconciliation, item mapping, incrementality, and preventing library closure leakage.
- Propagation: NuGet buildTransitive manifests, recursive project-reference outputs, file sidecars.
- Mixed mode: complete package inventory, coordinate/checksum matching, fallback diagnostics.
- Supply chain: native locking, stable lock UX, checksums, repository approval/filtering, credentials, mirrors.
- Diagnostics: graph JSON, dependency insight, stable XA messages, support bundles, rollout modes.
- Ecosystem pilot: Binderator packages, Native Library Interop, MAUI, then one low-risk Maven Central family.
Validation matrix
Resolver correctness:
- Direct/transitive AAR/JAR closure.
- Android attributes: Guava Android vs JRE, Kotlin Android vs JVM, KMP Android variants.
- POM and Gradle Module Metadata.
- Parent POMs/imported BOMs.
- Rich versions/rejects/strict ranges/platforms.
- Optional/provided/runtime scopes.
- Classifiers/nonstandard names.
- Relocations/substitutions/capabilities/exclusions.
Compatibility:
- Legacy embedded package.
- Modern loose payload.
- PackageTags-only identity.
- Xamarin.Build.Download.
- Classic embedded resources.
- Unknown local artifact.
- Patched AAR/source-built shim.
- Multi-artifact packages.
- Package, project, and file references.
Build/security/runtime:
- Central, Google, authenticated Basic/header repository, mirrors, and content filters.
- HTTP denial, credential failures without disclosure, checksum mismatch.
- Cold/warm/offline/locked builds.
- No-op incremental build with no Gradle invocation.
- Large solution with many class libraries but one app resolver invocation.
- Design-time build with no network.
- Windows/macOS/Linux; CoreCLR/NativeAOT; APK/AAB.
- Device tests for class availability, AndroidX type movement, Kotlin alignment, manifests/resources/consumer rules, native ABIs, and R8/D8 failures.
No default-mode change should occur until no-op builds invoke neither Gradle nor the network.
Rollout gates
- Design approval: declarative-only boundary, API names, Gradle-highest semantics,
boundAgainst policy, repository approval.
- Resolver spike: correct Android/JVM/KMP variant selection without applying AGP, stable graph, task integration, warm offline, no-op incrementality.
- Report-only preview: no payload suppression and identical existing builds.
- Metadata coverage: generated manifests and dual-mode packages compatible with old/new SDKs.
- Opt-in resolve pilot: Native Library Interop, MAUI, low-risk Maven Central family, authenticated fixture.
- Coordinate-only pilot: remove payload only from validated packages with rollback by package version/mode.
- Future TFM default: only after mixed-mode provenance, locks/offline/mirrors, performance, AndroidX behavior, and first-party package coverage are proven.
Open design decisions
- Extend
AndroidMavenLibrary or add AndroidMavenDependency?
- What tested compatibility declaration is required before a coordinate-only package can float from
boundAgainst?
- Ship Gradle in the workload or checksum-download it on first use?
- Final lock-file schema and whether native verification metadata is user-visible.
- Custom repository approval UX for noninteractive CI.
- Is an app-owned binary plugin sufficient, or is an app-owned script escape hatch also required?
- File-reference sidecar discovery/copy behavior.
- Should report mode initially be default or opt-in?
- Which capability/substitution rules belong in the SDK versus package manifests?
- Long-term relationship between Java Dependency Verification and full graph resolution.
Recommended first implementation slice
AndroidMavenDependency with exact/require versions and Central/Google.
- SDK-owned resolver without applying AGP, but with Android variant attributes.
- One JSON request and graph output.
- Separate compile/runtime outputs mapped into existing item paths with
Bind=false, Pack=false, JavaArtifact, and correct resource metadata.
- One recursive project-reference manifest and one buildTransitive package manifest.
- Report-only conflict diagnostics with origins.
- Preserve schema extension points for later lock/auth/custom-repository support.
- Mirror-backed fixtures and a no-op incremental test.
After that validates target ordering and packaging, add locking/auth and mixed-mode suppression before any package drops its payload.
References
Summary
.NET for Android should move toward resolving Java dependencies once, at the final application graph boundary, using Gradle as the resolution authority. Binding projects and NuGet packages should publish declarative Maven requirements and artifact provenance instead of independently embedding or downloading their own copies of AAR/JAR files.
The proposed direction is:
AndroidMavenLibraryandAndroidGradleProjectbehaviors for compatibility.AndroidMavenDependency, for dependencies that flow transitively from projects and NuGet packages to an app.buildTransitiveassets and an equivalent recursive project-reference contract.NuGet packages must not contribute arbitrary Gradle script fragments. Package inputs should be declarative data only. An application could opt into an app-owned Gradle plugin or script as an explicit full-trust escape hatch.
This would not replace NuGet restore. NuGet remains responsible for managed dependencies; the Android SDK would own a second, post-NuGet Java dependency resolution phase.
Motivation
Existing pieces stop short of app-wide resolution
.NET 9 added useful primitives:
AndroidMavenLibrarydownloads one exact Maven artifact and its POM, then uses Java Dependency Verification to check that dependencies were fulfilled elsewhere.AndroidGradleProjectinvokes a project's Gradle Wrapper, builds an AAR or APK, and injects AAR outputs intoAndroidLibrary.JavaArtifactmetadata andartifact=g:a:vNuGet tags identify Java components supplied by a project or package.AndroidIgnoredJavaDependency, POM parent/import handling, Maven version range parsing, and a reusable Maven cache already exist.These solve acquisition and validation inside one binding project, but they do not resolve the combined application graph.
Native Library Interop exposes the missing boundary
CommunityToolkit/Maui.NativeLibraryInteropnow delegates Android builds toAndroidGradleProject. A binding project builds and binds its wrapper AAR, but AGP does not bundle that module's Maven dependencies into the AAR. The consuming app must repeat those dependencies manually asAndroidMavenLibraryandPackageReferenceitems.This creates two independent graphs:
Nothing guarantees that the selected AndroidX, Kotlin, Material, or vendor SDK versions match.
Existing issues demonstrate related failure modes:
.nupkg#9974 and Building a binding NuGet package does not include JAR files - use of the package results injava.lang.NoClassDefFoundError#10481: missing JARs leading toNoClassDefFoundErrorafter package consumption.Small proof of concept
A disposable resolver using the repository's Gradle 9.5 wrapper, Gradle's
baseplugin, and one resolvable configuration requested:androidx.appcompat:appcompat:1.6.1androidx.appcompat:appcompat:1.7.0com.squareup.okio:okio-jvm:3.9.0Gradle selected
appcompat:1.7.0, honored AndroidX alignment metadata, produced 29 AARs plus 14 JARs, and reported both request origins and conflict selection throughdependencyInsight. A second offline invocation completed from cache.This proves that resolving AAR/JAR payloads does not inherently require applying AGP. It does not prove every Android variant-selection case: a production resolver must set AGP-equivalent attributes such as Android JVM environment, Kotlin Android platform, build type, usage, and artifact type. The synthetic build need not apply AGP, but it does need an SDK-owned Android attribute schema and compatibility/disambiguation rules.
Goals
AndroidGradleProjectmodules contribute requirements.Non-goals
Architecture
SDK-owned synthetic resolver build
The SDK would generate a normalized request file and invoke a constant, SDK-owned Gradle build. An SDK-owned binary settings/project plugin from the local workload would:
ResolutionResult,ArtifactCollection, andArtifactViewAPIs.Dependency data should live in sorted JSON, not generated executable Kotlin/Groovy statements. Generated scripts should remain nearly constant.
Why not extend the current C# POM verifier into a solver?
Java.Interop.Tools.Mavenremains useful for POM parsing, diagnostics, and compatibility, but should not become the graph authority:Why not import every contributed Gradle file?
Independent Gradle files cannot be safely concatenated or applied into a synthetic build:
Application-owned binary plugins or scripts can remain an explicit full-trust escape hatch, but restored packages should contribute data only.
Proposed MSBuild model
Names are provisional and should go through API review.
Preserve
AndroidMavenLibraryKeep its current direct acquisition/binding/packing behavior. It remains valuable for binding-project authoring and compatibility. During pack, the SDK could translate it into a published Maven dependency/provision manifest.
Add
AndroidMavenDependencySuggested metadata:
Versionrequire; participates in normal conflict resolution.VersionStrictlyVersionPreferVersionRejectRepositoryScopeRuntimeby default;Compile/CompileOnlyfor binding classpaths.Excludegroup:artifactexclusions.OptionalPrivateAssetsReasonVersions remain in Maven/Gradle version space and must never be parsed as
NuGetVersion.Add constraints, platforms, repositories, and app overrides
Applications also need explicit final-graph escape hatches such as:
AndroidMavenVersionOverrideAndroidMavenGlobalExcludeAndroidMavenSubstitutionAndroidMavenCapabilitySelectionUsing an override should be visible in the graph and diagnostics.
Version and compatibility semantics
Use Gradle's normal rule: select the highest version satisfying active requirements, constraints, rejects, and strict bounds. Do not add a "compatible major" heuristic; Android libraries do not uniformly follow semantic versioning.
A managed binding was generated against a concrete Java binary. Its manifest should record:
boundAgainstcoordinate, version, and checksum.requireconstraint unless the package author choosesstrictly.If Gradle selects a version other than
boundAgainst, resolve mode should fail unless the package declared that selection compatible or the app supplies an explicit unsafe override. This produces a binding-compatibility diagnostic rather than overloading Gradle's strict-version conflict message.Dynamic and changing/SNAPSHOT versions should be rejected by default because they undermine MSBuild incrementality and lock-file reproducibility.
Path-local exclusions should preserve Gradle semantics. App-owned global exclusions should warn when removing a component another dependency declared required.
Capabilities/substitutions should cover curated replacement and relocation cases such as old support libraries versus AndroidX, Kotlin stdlib consolidation, renamed artifacts, and placeholder compatibility packages.
Package and project propagation
Versioned manifest
A package/project/file sidecar should distinguish requirements from local providers:
{ "schemaVersion": 1, "producer": { "kind": "package", "id": "Xamarin.AndroidX.Activity", "version": "1.13.0.1", "targetFramework": "net11.0-android" }, "dependencies": [ { "coordinate": "androidx.activity:activity", "require": "1.13.0", "boundAgainst": "1.13.0", "compatibleWith": "[1.13.0]", "repository": "Google", "scope": "Runtime", "role": "BoundPrimary" } ], "constraints": [], "platforms": [], "repositories": [], "provides": [ { "coordinate": "androidx.activity:activity:1.13.0", "path": "aar/androidx.activity.activity.aar", "sha256": "...", "payloadKind": "aar", "modified": false } ] }dependencies: what the consumer needs.provides: what the package already carries locally.constraints: requirements that do not add a component.platforms: BOMs.repositories: repository requirements without secrets.NuGet
Pack the JSON manifest plus a conventionally named
buildTransitive/<tfm>/<PackageId>.props, or import the data fragment from an existing<PackageId>.props/.targets. NuGet only auto-imports conventionally named package files.The props should only contribute an
AndroidMavenManifestitem and be guarded by an SDK capability property so older SDKs continue using packaged payloads.Continue parsing
artifact=/artifact_versioned=package tags as a fallback for already published packages, but do not use tags as the long-term contract.Project references
Add a recursive target contract such as
GetAndroidMavenManifests, modeled afterGetCopyToOutputDirectoryItems, preserving exact project origins and defining behavior forReferenceOutputAssembly=false, private references, and references that become package references during pack.File references
Optionally discover
Binding.dll.android-maven.jsonbeside a file-referenced binding assembly. Unknown file references remain on the legacy path.Integration with
AndroidGradleProjectDo not merge native projects into the synthetic resolver build. Instead, extend the Gradle integration so the selected module exports its requested and resolved dependency graph alongside AAR/APK output.
For a
com.android.librarymodule, export:The module AAR remains the direct
Bind=trueartifact. External dependencies becomeBind=false,Pack=falseMaven requirements. Duringdotnet pack, the exported graph becomes the package manifest. This removes the current requirement for Native Library Interop apps to repeat dependencies manually.Graph export must use a separate, always-applied SDK init/plugin input rather than
_AGPInitScriptPath, because that existing build-directory script is user-overridable.MSBuild target flow
Resolution and reconciliation must occur at different points. Package AARs are discovered by
_ResolveAarsafterResolveReferences, while classic embedded resources are consumed later by_ResolveLibraryProjectImports.The request/resolve phase can run after
_BuildAndroidGradleProjectsand before_CategorizeAndroidLibraries. A separate reconciliation phase must run after_ResolveAarsand before_ResolveLibraryProjectImports; NuGet package AARs are added directly toAndroidAarLibrary, notAndroidLibrary.Explicit
DependsOnTargetsrelationships should order the new targets relative to_MavenRestore,_VerifyJavaDependencies,_ResolveAars, and_ResolveLibraryProjectImports. Import order should not define behavior.Resolver-emitted items must carry
JavaArtifactso Java Dependency Verification recognizes them.Application versus library builds
The full runtime graph should resolve only for an application head.
Library/binding projects should:
boundAgainstdata rather than embedding the runtime closure.Bind=false,Pack=false, and exclude them from_CreateAarInputs.This avoids one Gradle invocation per class library and prevents transitive runtime dependencies from leaking into every binding AAR/NuGet package.
Incrementality and performance
Inputs should include only the normalized data actually consumed:
project.assets.jsonAndroidGradleProjectmanifestsReal outputs should be used rather than a stamp:
obj/<configuration>/<tfm>/android-maven/graph.jsonobj/<configuration>/<tfm>/android-maven/compile/obj/<configuration>/<tfm>/android-maven/runtime/JSON and scripts should be byte-stable: stable ordering, no timestamps, no avoidable absolute paths, and write-only-when-changed behavior.
The Gradle task should:
Syncoutputs.AndroidAarLibrary/AndroidJavaLibrary.AndroidSkipResourceProcessing=falseon runtime AARs to match_ResolveAars.Design-time builds must not initiate network access; they should reuse the last graph and let a normal build refresh it.
Gradle provisioning
The synthetic resolver should not depend on a system Gradle installation, a customer wrapper, or a wrapper restored from a package.
Use an SDK-pinned, checksum-verified Gradle distribution and local SDK plugin. Tie the resolver version to the workload rather than
currentGradle.Run it with an SDK-controlled Gradle user home, separate from
~/.gradle, so userinit.dscripts cannot mutate resolution. Credentials should be bridged explicitly through approved environment/property inputs.A dedicated resolver daemon under the isolated home could improve local builds, with a CI/no-daemon mode. The implementation spike should compare this with the Tooling API and pin/test the Gradle/JDK compatibility matrix.
AndroidGradleProjectcontinues honoring the native project's own wrapper because it builds that project's code/plugins.Locking, verification, offline use, and repositories
Expose an SDK-owned stable lock such as
android.dependencies.lock.json, recording:Use Gradle's native dependency locking in strict mode as enforcement. The SDK JSON is the stable UX/provenance projection and can generate native Gradle lock/verification files under
obj.Policy:
--offlinemust fail clearly on a missing coordinate.dotnet-public-mavenmodel.For custom/private repositories:
The synthetic resolver should not need remote Gradle plugins because the SDK plugin is local.
Mixed-mode compatibility
The application can contain:
artifact=tags.Identity precedence:
JavaArtifactcoordinates.artifact=tags.Never alter the graph based only on inferred identity.
Reconciliation rules:
boundAgainstis an error unless declared compatible or explicitly overridden unsafely.Reconciliation must enumerate the complete restored package graph from
project.assets.jsonand package nuspecs. Looking only at directPackageReferenceitems misses transitive legacy packages.Coordinate-aware reconciliation should run before
_CheckDuplicateJavaLibraries; the current filename/content check remains as fallback.dotnet/android-libraries migration
The repository currently generates about 701 NuGet packages from
config.json:All generated packages already carry Maven coordinates in
PackageTags, and target files already go tobuild/andbuildTransitive/TFM folders.Phase A: metadata only
Add Binderator support for:
android-maven-manifest.jsonprovideshashes and modified flagsNo payload change.
Phase B: dual mode
Keep the payload and publish coordinates. New resolution can use the packaged file as a local provider; old SDKs remain unchanged.
Add global/per-artifact
embedArtifactsconfiguration and explicit modified/published-artifact identity.Phase C: low-risk coordinate-only pilot
Pilot a small Maven Central family with redistributable licensing, no shim, no AAR mutation, simple packaging, and good runtime tests. Do not begin with AndroidX or proprietary Google packages.
Phase D: replace Xamarin.Build.Download
The proprietary set is already downloaded at app build. Replace XBD declarations while preserving no-redistribution behavior, hash verification, Google mirror redirection, cache/offline diagnostics, and license notices.
Phase E: AndroidX
Before removing payloads, verify with regression tests that resolver-supplied AARs retain current behavior:
lint.jarandapi.jar.proguard.txt; verify AGP 9 consumer-rule behavior.Permanent local-provider exceptions likely remain for source-built
com.xamarin.*shims, patched/shaded binaries, frozen/relocated artifacts, and special vendor licensing/authentication cases. Exceptions should still publish provenance and requirements.Diagnostics
Always write
obj/<tfm>/android-maven/graph.jsoncontaining:Provide:
dotnet build -t:AndroidMavenDependencyInsight -p:AndroidMavenInsight=group:artifactEvery Gradle declaration should include
because("contributed by ...")so nativedependencyInsightis useful.Suggested modes:
Legacy: current behavior only.Report: resolve/analyze but do not replace legacy payloads.Resolve: Gradle graph owns known-coordinate selection.Start with
LegacyorReport; consider a future TFM-gatedResolvedefault only after package metadata and compatibility goals are met. Older TFMs keep legacy defaults.Proposed workstreams
Validation matrix
Resolver correctness:
Compatibility:
Build/security/runtime:
No default-mode change should occur until no-op builds invoke neither Gradle nor the network.
Rollout gates
boundAgainstpolicy, repository approval.Open design decisions
AndroidMavenLibraryor addAndroidMavenDependency?boundAgainst?Recommended first implementation slice
AndroidMavenDependencywith exact/require versions and Central/Google.Bind=false,Pack=false,JavaArtifact, and correct resource metadata.After that validates target ordering and packaging, add locking/auth and mixed-mode suppression before any package drops its payload.
References
AndroidMavenLibrarydotnet/android-libraries