From 33a37d971700ec822922261852283a4d02a05f26 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Mon, 27 Apr 2026 10:12:25 -0500 Subject: [PATCH 1/6] [xabt] Fix wrong-TFM assembly loading -> empty `.jlo.xml` files (#11208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: https://github.com/dotnet/android/issues/10858 In .NET 9, `GenerateJavaStubs` scanned for Java types using `XAJavaTypeScanner.GetJavaTypes`, which called `resolver.Load(asmItem.ItemSpec)` to load each assembly from its exact file path. This ensured the correct TFM version was always used. In ea399edb (#9893), JCW scanning was moved into `AssemblyModifierPipeline` as `FindJavaObjectsStep`. The pipeline's `RunPipeline` method uses `resolver.GetAssembly(source.ItemSpec)`, which strips the path and resolves by assembly **name** through search directories. When a multi-TFM library (e.g. `net11.0;net11.0-android`) is transitively referenced through a `net11.0`-only intermediary, the intermediary's output directory contains a CopyLocal'd `net11.0` copy of the assembly. If that directory appears in the resolver's search path before the correct `net11.0-android` directory, `GetAssembly` loads the wrong version — one without any Android types — producing empty `.jlo.xml` files with 0 JCW types. The fix pre-loads all `ResolvedAssemblies` into the resolver cache from their exact `ItemSpec` paths during resolver setup. This ensures that both `GetAssembly` (which checks the cache first) and Cecil's lazy reference resolution always find the correct TFM version. A regression test `MultiTfmTransitiveReference` reproduces the exact scenario: App -> CoreLib (net11.0) -> MultiTfmLib (net11.0; net11.0-android), where MultiTfmLib has an Android `BroadcastReceiver` behind `#if ANDROID`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/AssemblyModifierPipeline.cs | 8 +- .../BuildWithLibraryTests.cs | 127 ++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs b/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs index 08a558de918..86b3543ea98 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs @@ -102,13 +102,19 @@ public override bool RunTask () var resolver = new DirectoryAssemblyResolver (this.CreateTaskLogger (), loadDebugSymbols: ReadSymbols, loadReaderParameters: readerParameters); - // Add SearchDirectories for the current architecture's ResolvedAssemblies + // Add SearchDirectories and pre-load ResolvedAssemblies into the resolver cache. + // Pre-loading ensures the correct TFM version is cached before any Cecil lazy + // reference resolution can find wrong-TFM copies from search directories (e.g., + // a net10.0 copy in a referencing project's output directory). foreach (var kvp in perArchAssemblies [sourceArch]) { ITaskItem assembly = kvp.Value; var path = Path.GetFullPath (Path.GetDirectoryName (assembly.ItemSpec)); if (!resolver.SearchDirectories.Contains (path)) { resolver.SearchDirectories.Add (path); } + if (resolver.Load (assembly.ItemSpec) == null) { + Log.LogDebugMessage ($"Could not pre-load assembly '{assembly.ItemSpec}' into resolver cache."); + } } // Set up the FixAbstractMethodsStep and AddKeepAlivesStep diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs index ad947b5faf0..74052f203a2 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs @@ -9,6 +9,8 @@ using Mono.Cecil; using NUnit.Framework; using Xamarin.ProjectTools; +using Xamarin.Android.Tasks; +using Xamarin.Android.Tools; namespace Xamarin.Android.Build.Tests { @@ -895,5 +897,130 @@ public void DotNetLibraryAarChanges () } } + /// + /// Regression test for https://github.com/dotnet/android/issues/10858 + /// + /// When a solution has the structure: + /// App (net10.0-android) → CoreLib (net10.0) → MultiTfmLib (net10.0;net10.0-android) + /// + /// And MultiTfmLib contains Java-interop types (e.g. BroadcastReceiver) that only + /// exist in the net10.0-android TFM, the build tasks must load the Android-TFM assembly + /// and generate JCWs for those types. Previously, the net10.0 (non-Android) assembly + /// could be loaded instead, causing FindJavaObjectsStep to report "Found 0 Java types" + /// and producing empty .jlo.xml files. + /// + [Test] + public void MultiTfmTransitiveReference ([Values] AndroidRuntime runtime) + { + if (IgnoreUnsupportedConfiguration (runtime, release: false)) { + return; + } + + var path = Path.Combine ("temp", TestName); + var dotnetVersion = XABuildConfig.LatestDotNetTargetFramework; + + // 1. Multi-TFM library (net10.0 + net10.0-android) with a BroadcastReceiver + var multiTfmLib = new DotNetStandard { + ProjectName = "MultiTfmLib", + Sdk = "Microsoft.NET.Sdk", + Sources = { + new BuildItem.Source ("MyReceiver.cs") { + TextContent = () => +@"#if ANDROID +using Android.Content; + +namespace MultiTfmLib +{ + public class MyReceiver : BroadcastReceiver + { + public override void OnReceive (Context? context, Intent? intent) { } + } +} +#endif +" + }, + new BuildItem.Source ("SharedClass.cs") { + TextContent = () => +@"namespace MultiTfmLib +{ + public class SharedClass + { + public string Name => ""Hello""; + } +} +" + }, + }, + }; + multiTfmLib.TargetFrameworks = $"{dotnetVersion};{dotnetVersion}-android"; + multiTfmLib.SetProperty ("Nullable", "enable"); + multiTfmLib.SetProperty ("AndroidGenerateResourceDesigner", "false"); + multiTfmLib.SetProperty ("SupportedOSPlatformVersion", "24", + "$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'"); + if (runtime != AndroidRuntime.NativeAOT) { + multiTfmLib.SetRuntime (runtime); + } + + // 2. Non-Android library (net10.0 only) referencing the multi-TFM library + var coreLib = new DotNetStandard { + ProjectName = "CoreLib", + Sdk = "Microsoft.NET.Sdk", + Sources = { + new BuildItem.Source ("CoreClass.cs") { + TextContent = () => +@"namespace CoreLib +{ + public class CoreClass + { + public MultiTfmLib.SharedClass Shared => new (); + } +} +" + }, + }, + }; + coreLib.TargetFramework = dotnetVersion; + coreLib.AddReference (multiTfmLib); + + // 3. Android app that references CoreLib (NOT MultiTfmLib directly) + var app = new XamarinAndroidApplicationProject { + ProjectName = "MyApp", + Sources = { + new BuildItem.Source ("Usage.cs") { + TextContent = () => +@"public class Usage +{ + public CoreLib.CoreClass Core => new (); +} +" + }, + }, + }; + app.SetRuntime (runtime); + app.AddReference (coreLib); + + using var multiTfmBuilder = CreateDllBuilder (Path.Combine (path, multiTfmLib.ProjectName)); + Assert.IsTrue (multiTfmBuilder.Build (multiTfmLib), $"{multiTfmLib.ProjectName} should build"); + + using var coreBuilder = CreateDllBuilder (Path.Combine (path, coreLib.ProjectName)); + Assert.IsTrue (coreBuilder.Build (coreLib), $"{coreLib.ProjectName} should build"); + + using var appBuilder = CreateApkBuilder (Path.Combine (path, app.ProjectName)); + Assert.IsTrue (appBuilder.Build (app), $"{app.ProjectName} should build"); + + // Verify: MultiTfmLib.jlo.xml should NOT be empty (i.e. the assembly was scanned as an Android assembly) + var jloXml = appBuilder.Output.GetIntermediaryPath ( + Path.Combine ("android", "assets", "arm64-v8a", "MultiTfmLib.jlo.xml")); + FileAssert.Exists (jloXml); + + var jloXmlInfo = new FileInfo (jloXml); + Assert.IsTrue (jloXmlInfo.Length > 0, + "MultiTfmLib.jlo.xml should not be empty — the Android-TFM assembly was not loaded (wrong TFM loaded instead)"); + + var jloContent = File.ReadAllText (jloXml); + Assert.IsTrue (jloContent.Contains ("MyReceiver"), + $"MultiTfmLib.jlo.xml should contain the MyReceiver JCW type, but got: {jloContent}"); + } + } } From 730fad57092b6338ce86ab6254f62dd451904bcd Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 29 Apr 2026 09:57:46 -0500 Subject: [PATCH 2/6] Remove AndroidRuntime parameterization from MultiTfmTransitiveReference test Use default Mono runtime instead of parameterizing across all runtimes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../BuildWithLibraryTests.cs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs index 74052f203a2..a89917b6c26 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs @@ -10,7 +10,6 @@ using NUnit.Framework; using Xamarin.ProjectTools; using Xamarin.Android.Tasks; -using Xamarin.Android.Tools; namespace Xamarin.Android.Build.Tests { @@ -910,12 +909,8 @@ public void DotNetLibraryAarChanges () /// and producing empty .jlo.xml files. /// [Test] - public void MultiTfmTransitiveReference ([Values] AndroidRuntime runtime) + public void MultiTfmTransitiveReference () { - if (IgnoreUnsupportedConfiguration (runtime, release: false)) { - return; - } - var path = Path.Combine ("temp", TestName); var dotnetVersion = XABuildConfig.LatestDotNetTargetFramework; @@ -957,11 +952,7 @@ public class SharedClass multiTfmLib.SetProperty ("AndroidGenerateResourceDesigner", "false"); multiTfmLib.SetProperty ("SupportedOSPlatformVersion", "24", "$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'"); - if (runtime != AndroidRuntime.NativeAOT) { - multiTfmLib.SetRuntime (runtime); - } - - // 2. Non-Android library (net10.0 only) referencing the multi-TFM library + // 2.Non-Android library (net10.0 only) referencing the multi-TFM library var coreLib = new DotNetStandard { ProjectName = "CoreLib", Sdk = "Microsoft.NET.Sdk", @@ -996,7 +987,6 @@ public class CoreClass }, }, }; - app.SetRuntime (runtime); app.AddReference (coreLib); using var multiTfmBuilder = CreateDllBuilder (Path.Combine (path, multiTfmLib.ProjectName)); From 72ef61ed6b4c2be6cdcb9a8b7c25aeb16dacdc63 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 29 Apr 2026 12:00:42 -0500 Subject: [PATCH 3/6] Fix using: XABuildConfig is in Xamarin.Android.Tools, not Tasks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs index a89917b6c26..7241f394e61 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs @@ -9,7 +9,7 @@ using Mono.Cecil; using NUnit.Framework; using Xamarin.ProjectTools; -using Xamarin.Android.Tasks; +using Xamarin.Android.Tools; namespace Xamarin.Android.Build.Tests { From d6e980cd8f263fc57fea698ff6d4e0182813a07c Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 29 Apr 2026 13:38:14 -0500 Subject: [PATCH 4/6] Use hardcoded "net10.0" instead of XABuildConfig.LatestDotNetTargetFramework LatestDotNetTargetFramework does not exist on the release/10.0.1xx branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs index 7241f394e61..fb558936ae2 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildWithLibraryTests.cs @@ -9,7 +9,6 @@ using Mono.Cecil; using NUnit.Framework; using Xamarin.ProjectTools; -using Xamarin.Android.Tools; namespace Xamarin.Android.Build.Tests { @@ -912,7 +911,7 @@ public void DotNetLibraryAarChanges () public void MultiTfmTransitiveReference () { var path = Path.Combine ("temp", TestName); - var dotnetVersion = XABuildConfig.LatestDotNetTargetFramework; + var dotnetVersion = "net10.0"; // 1. Multi-TFM library (net10.0 + net10.0-android) with a BroadcastReceiver var multiTfmLib = new DotNetStandard { From e9cc18dbc72cf911272b50a29706358b69ff5406 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 29 Apr 2026 15:11:18 -0500 Subject: [PATCH 5/6] Use Load() in RunPipeline instead of pre-loading all assemblies The pre-loading approach caused FileNotFoundException for netstandard.dll on the 10.0 branch, where some dependencies (e.g. AppCompat) still target netstandard2.1. Pre-loading ALL assemblies triggered Cecil lazy reference resolution for assemblies that reference netstandard.dll. Instead, use resolver.Load(source.ItemSpec) in RunPipeline to load each assembly from its exact path. This ensures the correct TFM version is loaded without eagerly loading all assemblies upfront. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/AssemblyModifierPipeline.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs b/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs index 86b3543ea98..a22a24798d5 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs @@ -102,19 +102,13 @@ public override bool RunTask () var resolver = new DirectoryAssemblyResolver (this.CreateTaskLogger (), loadDebugSymbols: ReadSymbols, loadReaderParameters: readerParameters); - // Add SearchDirectories and pre-load ResolvedAssemblies into the resolver cache. - // Pre-loading ensures the correct TFM version is cached before any Cecil lazy - // reference resolution can find wrong-TFM copies from search directories (e.g., - // a net10.0 copy in a referencing project's output directory). + // Add SearchDirectories for the current architecture's ResolvedAssemblies foreach (var kvp in perArchAssemblies [sourceArch]) { ITaskItem assembly = kvp.Value; var path = Path.GetFullPath (Path.GetDirectoryName (assembly.ItemSpec)); if (!resolver.SearchDirectories.Contains (path)) { resolver.SearchDirectories.Add (path); } - if (resolver.Load (assembly.ItemSpec) == null) { - Log.LogDebugMessage ($"Could not pre-load assembly '{assembly.ItemSpec}' into resolver cache."); - } } // Set up the FixAbstractMethodsStep and AddKeepAlivesStep @@ -166,7 +160,14 @@ protected virtual void BuildPipeline (AssemblyPipeline pipeline, MSBuildLinkCont void RunPipeline (AssemblyPipeline pipeline, ITaskItem source, ITaskItem destination) { - var assembly = pipeline.Resolver.GetAssembly (source.ItemSpec); + // Use Load with the exact ItemSpec path to ensure the correct TFM version + // is loaded, rather than GetAssembly which strips the path and resolves by + // name through search directories (which may find wrong-TFM copies). + var assembly = pipeline.Resolver.Load (source.ItemSpec); + if (assembly == null) { + Log.LogDebugMessage ($"Could not load assembly '{source.ItemSpec}', skipping."); + return; + } var context = new StepContext (source, destination) { CodeGenerationTarget = codeGenerationTarget, From acbf762b09a576e2be45c2590beb7e4bf81bc950 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Thu, 30 Apr 2026 13:33:39 -0500 Subject: [PATCH 6/6] Throw FileNotFoundException instead of silently skipping on null Load Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Tasks/AssemblyModifierPipeline.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs b/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs index a22a24798d5..a6e5abbc0ac 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs @@ -163,11 +163,8 @@ void RunPipeline (AssemblyPipeline pipeline, ITaskItem source, ITaskItem destina // Use Load with the exact ItemSpec path to ensure the correct TFM version // is loaded, rather than GetAssembly which strips the path and resolves by // name through search directories (which may find wrong-TFM copies). - var assembly = pipeline.Resolver.Load (source.ItemSpec); - if (assembly == null) { - Log.LogDebugMessage ($"Could not load assembly '{source.ItemSpec}', skipping."); - return; - } + var assembly = pipeline.Resolver.Load (source.ItemSpec) + ?? throw new FileNotFoundException ($"Could not load assembly '{source.ItemSpec}'.", source.ItemSpec); var context = new StepContext (source, destination) { CodeGenerationTarget = codeGenerationTarget,