Repository: dotnet/android
Pull request #10015: [XABT] Move JLO scanning needed for typemap generation to a linker step.
Author: login: jpobst
Created: 2025-04-08T19:41:21Z | Merged: 2025-04-30T01:17:02Z

Context: https://github.com/dotnet/android/pull/9893
Context: https://github.com/dotnet/android/pull/9930

This commit moves the process of scanning for JLOs needed for the typemap generation task to a new `FindTypeMapObjectsStep` "linker step".

The types are serialized to new `<assembly>.typemap.xml` files that sit beside the source assembly.  A new file was used rather than adding more sections to the `.jlo.xml` file because this file contains a lot more types and will likely change more often on incremental builds, and we don't want to trigger ie: creating new Java stubs when it isn't needed.

This file is then read in the existing `<GenerateTypeMappings>` task to generate the final typemap file.

The existing method of JLO scanning for typemaps is still used when using LLVM marshal methods, as the marshal method rewriter runs after the linker steps, and when it changes the assemblies the MVID and type tokens no longer match the values saved in the `.typemap.xml` files.

Like https://github.com/dotnet/android/pull/9893, this temporarily leaves the old typemap generation code in place, guarded behind the `$(_AndroidJLOCheckedBuild)` flag.  This flag generates the typemap both the new and old way, and errors the build if there are differences.

Note that https://github.com/dotnet/android/pull/10024 updated our "pipeline" to save assemblies after *all* steps have been run. However, looking for JLOs needed for typemap generation has to be run *after* assemblies are saved.  This is because Release typemaps rely on assembly MVID and type tokens which change when the assemblies are modified.

Update the pipeline's "modified assembly saving" logic to itself be a pipeline step, so we can insert it *before* the new `FindTypeMapObjectsStep` step.


Diff:
diff --git a/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/AddKeepAlivesStep.cs b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/AddKeepAlivesStep.cs
index 2c1382c5458..63e6f2fc41f 100644
--- a/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/AddKeepAlivesStep.cs
+++ b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/AddKeepAlivesStep.cs
@@ -29,13 +29,13 @@ protected override void ProcessAssembly (AssemblyDefinition assembly)
 		}
 
 #if !ILLINK
-		public bool ProcessAssembly (AssemblyDefinition assembly, StepContext context)
+		public void ProcessAssembly (AssemblyDefinition assembly, StepContext context)
 		{
 			// Only run this step on user Android assemblies
 			if (!context.IsAndroidUserAssembly)
-				return false;
+				return;
 
-			return AddKeepAlives (assembly);
+			context.IsAssemblyModified |= AddKeepAlives (assembly);
 		}
 #endif  // !ILLINK
 
diff --git a/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindJavaObjectsStep.cs b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindJavaObjectsStep.cs
index 5adc2e27582..c5a44ec3b07 100644
--- a/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindJavaObjectsStep.cs
+++ b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindJavaObjectsStep.cs
@@ -29,7 +29,7 @@ public class FindJavaObjectsStep : BaseStep, IAssemblyModifierPipelineStep
 
 	public FindJavaObjectsStep (TaskLoggingHelper log) => Log = log;
 
-	public bool ProcessAssembly (AssemblyDefinition assembly, StepContext context)
+	public void ProcessAssembly (AssemblyDefinition assembly, StepContext context)
 	{
 		var destinationJLOXml = JavaObjectsXmlFile.GetJavaObjectsXmlFilePath (context.Destination.ItemSpec);
 		var scanned = ScanAssembly (assembly, context, destinationJLOXml);
@@ -37,11 +37,7 @@ public bool ProcessAssembly (AssemblyDefinition assembly, StepContext context)
 		if (!scanned) {
 			// We didn't scan for Java objects, so write an empty .xml file for later steps
 			JavaObjectsXmlFile.WriteEmptyFile (destinationJLOXml, Log);
-			return false;
 		}
-
-		// This step does not change the assembly
-		return false;
 	}
 
 	public bool ScanAssembly (AssemblyDefinition assembly, StepContext context, string destinationJLOXml)
diff --git a/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs
new file mode 100644
index 00000000000..bfc242ea63c
--- /dev/null
+++ b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs
@@ -0,0 +1,73 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+using Mono.Linker.Steps;
+using Xamarin.Android.Tasks;
+
+namespace MonoDroid.Tuner;
+
+/// <summary>
+/// Scans an assembly for JLOs that need to be in the typemap and writes them to an XML file.
+/// </summary>
+public class FindTypeMapObjectsStep : BaseStep, IAssemblyModifierPipelineStep
+{
+	public bool Debug { get; set; }
+
+	public bool ErrorOnCustomJavaObject { get; set; }
+
+	public TaskLoggingHelper Log { get; set; }
+
+	public FindTypeMapObjectsStep (TaskLoggingHelper log) => Log = log;
+
+	public void ProcessAssembly (AssemblyDefinition assembly, StepContext context)
+	{
+		var destinationTypeMapXml = TypeMapObjectsXmlFile.GetTypeMapObjectsXmlFilePath (context.Destination.ItemSpec);
+
+		// We only care about assemblies that can contains JLOs
+		if (!context.IsAndroidAssembly) {
+			Log.LogDebugMessage ($"Skipping assembly '{assembly.Name.Name}' because it is not an Android assembly");
+			TypeMapObjectsXmlFile.WriteEmptyFile (destinationTypeMapXml, Log);
+			return;
+		}
+
+		var types = ScanForJavaTypes (assembly);
+
+		var xml = new TypeMapObjectsXmlFile {
+			AssemblyName = assembly.Name.Name,
+		};
+
+		if (Debug) {
+			var (javaToManaged, managedToJava, foundJniNativeRegistration) = TypeMapCecilAdapter.GetDebugNativeEntries (types, Context);
+
+			xml.JavaToManagedDebugEntries.AddRange (javaToManaged);
+			xml.ManagedToJavaDebugEntries.AddRange (managedToJava);
+			xml.FoundJniNativeRegistration = foundJniNativeRegistration;
+		} else {
+			var genState = TypeMapCecilAdapter.GetReleaseGenerationState (types, Context, out var foundJniNativeRegistration);
+			xml.ModuleReleaseData = genState.TempModules.SingleOrDefault ().Value;
+		}
+
+		xml.Export (destinationTypeMapXml, Log);
+	}
+
+	List<TypeDefinition> ScanForJavaTypes (AssemblyDefinition assembly)
+	{
+		var types = new List<TypeDefinition> ();
+
+		var scanner = new XAJavaTypeScanner (Xamarin.Android.Tools.AndroidTargetArch.None, Log, Context) {
+			ErrorOnCustomJavaObject = ErrorOnCustomJavaObject
+		};
+
+		foreach (ModuleDefinition md in assembly.Modules) {
+			foreach (TypeDefinition td in md.Types) {
+				scanner.AddJavaType (td, types);
+			}
+		}
+
+		return types;
+	}
+}
diff --git a/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FixAbstractMethodsStep.cs b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FixAbstractMethodsStep.cs
index 0f979714bf0..82f1777de4b 100644
--- a/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FixAbstractMethodsStep.cs
+++ b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FixAbstractMethodsStep.cs
@@ -83,13 +83,13 @@ internal bool FixAbstractMethods (AssemblyDefinition assembly)
 		}
 
 #if !ILLINK
-		public bool ProcessAssembly (AssemblyDefinition assembly, StepContext context)
+		public void ProcessAssembly (AssemblyDefinition assembly, StepContext context)
 		{
 			// Only run this step on non-main user Android assemblies
 			if (context.IsMainAssembly || !context.IsAndroidUserAssembly)
-				return false;
+				return;
 
-			return FixAbstractMethods (assembly);
+			context.IsAssemblyModified |= FixAbstractMethods (assembly);
 		}
 #endif  // !ILLINK
 
diff --git a/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FixLegacyResourceDesignerStep.cs b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FixLegacyResourceDesignerStep.cs
index 1cfc19f2d9a..b4225d159da 100644
--- a/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FixLegacyResourceDesignerStep.cs
+++ b/src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FixLegacyResourceDesignerStep.cs
@@ -71,13 +71,13 @@ protected override void LoadDesigner ()
 		}
 
 #if !ILLINK
-		public bool ProcessAssembly (AssemblyDefinition assembly, Xamarin.Android.Tasks.StepContext context)
+		public void ProcessAssembly (AssemblyDefinition assembly, Xamarin.Android.Tasks.StepContext context)
 		{
 			// Only run this step on non-main user Android assemblies
 			if (context.IsMainAssembly || !context.IsAndroidUserAssembly)
-				return false;
+				return;
 
-			return ProcessAssemblyDesigner (assembly);
+			context.IsAssemblyModified |= ProcessAssemblyDesigner (assembly);
 		}
 #endif  // !ILLINK
 
diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs b/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs
index 18935bf8d77..143a3931604 100644
--- a/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs
+++ b/src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs
@@ -8,6 +8,7 @@
 using Java.Interop.Tools.TypeNameMappings;
 using Microsoft.Android.Build.Tasks;
 using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
 using Mono.Cecil;
 using MonoDroid.Tuner;
 using Xamarin.Android.Tools;
@@ -78,10 +79,6 @@ public override bool RunTask ()
 			ReadSymbols = ReadSymbols,
 		};
 
-		var writerParameters = new WriterParameters {
-			DeterministicMvid = Deterministic,
-		};
-
 		Dictionary<AndroidTargetArch, Dictionary<string, ITaskItem>> perArchAssemblies = MonoAndroidHelper.GetPerArchAssemblies (ResolvedAssemblies, Array.Empty<string> (), validate: false);
 
 		AssemblyPipeline? pipeline = null;
@@ -122,7 +119,7 @@ public override bool RunTask ()
 
 			Directory.CreateDirectory (Path.GetDirectoryName (destination.ItemSpec));
 
-			RunPipeline (pipeline!, source, destination, writerParameters);
+			RunPipeline (pipeline!, source, destination);
 		}
 
 		pipeline?.Dispose ();
@@ -141,9 +138,26 @@ protected virtual void BuildPipeline (AssemblyPipeline pipeline, MSBuildLinkCont
 
 		findJavaObjectsStep.Initialize (context);
 		pipeline.Steps.Add (findJavaObjectsStep);
+
+		// SaveChangedAssemblyStep
+		var writerParameters = new WriterParameters {
+			DeterministicMvid = Deterministic,
+		};
+
+		var saveChangedAssemblyStep = new SaveChangedAssemblyStep (Log, writerParameters);
+		pipeline.Steps.Add (saveChangedAssemblyStep);
+
+		// FindTypeMapObjectsStep - this must be run after the assembly has been saved, as saving changes the MVID
+		var findTypeMapObjectsStep = new FindTypeMapObjectsStep (Log) {
+			ErrorOnCustomJavaObject = ErrorOnCustomJavaObject,
+			Debug = Debug,
+		};
+
+		findTypeMapObjectsStep.Initialize (context);
+		pipeline.Steps.Add (findTypeMapObjectsStep);
 	}
 
-	void RunPipeline (AssemblyPipeline pipeline, ITaskItem source, ITaskItem destination, WriterParameters writerParameters)
+	void RunPipeline (AssemblyPipeline pipeline, ITaskItem source, ITaskItem destination)
 	{
 		var assembly = pipeline.Resolver.GetAssembly (source.ItemSpec);
 
@@ -157,17 +171,36 @@ void RunPipeline (AssemblyPipeline pipeline, ITaskItem source, ITaskItem destina
 			IsUserAssembly = ResolvedUserAssemblies.Any (a => a.ItemSpec == source.ItemSpec),
 		};
 
-		var changed = pipeline.Run (assembly, context);
+		pipeline.Run (assembly, context);
+	}
+}
+
+class SaveChangedAssemblyStep : IAssemblyModifierPipelineStep
+{
+	public TaskLoggingHelper Log { get; set; }
 
-		if (changed) {
-			Log.LogDebugMessage ($"Saving modified assembly: {destination.ItemSpec}");
-			Directory.CreateDirectory (Path.GetDirectoryName (destination.ItemSpec));
-			writerParameters.WriteSymbols = assembly.MainModule.HasSymbols;
-			assembly.Write (destination.ItemSpec, writerParameters);
+	public WriterParameters WriterParameters { get; set; }
+
+	public SaveChangedAssemblyStep (TaskLoggingHelper log, WriterParameters writerParameters)
+	{
+		Log = log;
+		WriterParameters = writerParameters;
+	}
+
+	public void ProcessAssembly (AssemblyDefinition assembly, StepContext context)
+	{
+		if (context.IsAssemblyModified) {
+			Log.LogDebugMessage ($"Saving modified assembly: {context.Destination.ItemSpec}");
+			Directory.CreateDirectory (Path.GetDirectoryName (context.Destination.ItemSpec));
+			WriterParameters.WriteSymbols = assembly.MainModule.HasSymbols;
+			assembly.Write (context.Destination.ItemSpec, WriterParameters);
 		} else {
 			// If we didn't write a modified file, copy the original to the destination
-			CopyIfChanged (source, destination);
+			CopyIfChanged (context.Source, context.Destination);
 		}
+
+		// We just saved the assembly, so it is no longer modified
+		context.IsAssemblyModified = false;
 	}
 
 	void CopyIfChanged (ITaskItem source, ITaskItem destination)
diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMappings.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMappings.cs
index c148a1446c8..70a6a60f5a9 100644
--- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMappings.cs
+++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMappings.cs
@@ -3,6 +3,7 @@
 using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.IO;
+using System.Linq;
 using Java.Interop.Tools.Cecil;
 using Microsoft.Android.Build.Tasks;
 using Microsoft.Build.Framework;
@@ -11,6 +12,8 @@
 
 namespace Xamarin.Android.Tasks;
 
+// Note: If/When this is converted to an incremental task, every build still needs to set:
+// NativeCodeGenState.TemplateJniAddNativeMethodRegistrationAttributePresent
 public class GenerateTypeMappings : AndroidTask
 {
 	public override string TaskPrefix => "GTM";
@@ -20,25 +23,91 @@ public class GenerateTypeMappings : AndroidTask
 
 	public bool Debug { get; set; }
 
+	public bool EnableMarshalMethods { get;set; }
+
+	[Output]
+	public ITaskItem [] GeneratedBinaryTypeMaps { get; set; } = [];
+
 	[Required]
 	public string IntermediateOutputDirectory { get; set; } = "";
 
 	public bool SkipJniAddNativeMethodRegistrationAttributeScan { get; set; }
 
 	[Required]
-	public string TypemapOutputDirectory { get; set; } = "";
+	public ITaskItem [] ResolvedAssemblies { get; set; } = [];
 
-	[Output]
-	public ITaskItem [] GeneratedBinaryTypeMaps { get; set; } = [];
+	// This property is temporary and is used to ensure that the new "linker step"
+	// JLO scanning produces the same results as the old process. It will be removed
+	// once the process is complete.
+	public bool RunCheckedBuild { get; set; }
+
+	[Required]
+	public string [] SupportedAbis { get; set; } = [];
 
 	public string TypemapImplementation { get; set; } = "llvm-ir";
 
+	[Required]
+	public string TypemapOutputDirectory { get; set; } = "";
+
 	AndroidRuntime androidRuntime;
 
 	public override bool RunTask ()
 	{
+		var useMarshalMethods = !Debug && EnableMarshalMethods;
+
 		androidRuntime = MonoAndroidHelper.ParseAndroidRuntime (AndroidRuntime);
+		if (androidRuntime == Xamarin.Android.Tasks.AndroidRuntime.NativeAOT) {
+			// NativeAOT typemaps are generated in `Microsoft.Android.Sdk.ILLink.TypeMappingStep`
+			Log.LogDebugMessage ("Skipping type maps for NativeAOT.");
+			return !Log.HasLoggedErrors;
+		}
+
+		// If using marshal methods, we cannot use the .typemap.xml files currently because
+		// the type token ids were changed by the marshal method rewriter after we wrote the .xml files.
+		if (!useMarshalMethods)
+			GenerateAllTypeMappings ();
+
+		// Generate typemaps from the native code generator state (produced by the marshal method rewriter)
+		if (RunCheckedBuild || useMarshalMethods)
+			GenerateAllTypeMappingsFromNativeState (useMarshalMethods);
 
+		return !Log.HasLoggedErrors;
+	}
+
+	void GenerateAllTypeMappings ()
+	{
+		var allAssembliesPerArch = MonoAndroidHelper.GetPerArchAssemblies (ResolvedAssemblies, SupportedAbis, validate: true);
+
+		foreach (var set in allAssembliesPerArch)
+			GenerateTypeMap (set.Key, set.Value.Values.ToList ());
+	}
+
+	void GenerateTypeMap (AndroidTargetArch arch, List<ITaskItem> assemblies)
+	{
+		Log.LogDebugMessage ($"Generating type maps for architecture '{arch}'");
+
+		var state = TypeMapObjectsFileAdapter.Create (arch, assemblies, Log);
+
+		// An error was already logged to Log.LogError
+		if (state is null)
+			return;
+
+		if (TypemapImplementation != "llvm-ir") {
+			Log.LogDebugMessage ($"TypemapImplementation='{TypemapImplementation}' will write an empty native typemap.");
+			state.XmlFiles.Clear ();
+		}
+
+		var tmg = new TypeMapGenerator (Log, state, androidRuntime);
+		tmg.Generate (Debug, SkipJniAddNativeMethodRegistrationAttributeScan, TypemapOutputDirectory);
+
+		// Set for use by <GeneratePackageManagerJava/> task later
+		NativeCodeGenState.TemplateJniAddNativeMethodRegistrationAttributePresent = state.JniAddNativeMethodRegistrationAttributePresent;
+
+		AddOutputTypeMaps (tmg, state.TargetArch);
+	}
+
+	void GenerateAllTypeMappingsFromNativeState (bool useMarshalMethods)
+	{
 		// Retrieve the stored NativeCodeGenState
 		var nativeCodeGenStates = BuildEngine4.GetRegisteredTaskObjectAssemblyLocal<ConcurrentDictionary<AndroidTargetArch, NativeCodeGenState>> (
 			MonoAndroidHelper.GetProjectBuildSpecificTaskObjectKey (GenerateJavaStubs.NativeCodeGenStateRegisterTaskKey, WorkingDirectory, IntermediateOutputDirectory),
@@ -50,37 +119,42 @@ public override bool RunTask ()
 		foreach (var kvp in nativeCodeGenStates) {
 			NativeCodeGenState state = kvp.Value;
 			templateCodeGenState = state;
-			WriteTypeMappings (state);
+			GenerateTypeMapFromNativeState (state, useMarshalMethods);
 		}
 
 		if (templateCodeGenState is null)
 			throw new InvalidOperationException ($"Internal error: no native code generator state defined");
 
-		// Set for use by <GenerateNativeApplicationConfigSources> task later
-		NativeCodeGenState.TemplateJniAddNativeMethodRegistrationAttributePresent = templateCodeGenState.JniAddNativeMethodRegistrationAttributePresent;
-
-		return !Log.HasLoggedErrors;
+		// Set for use by <GenerateNativeApplicationConfigSources/> task later
+		if (useMarshalMethods)
+			NativeCodeGenState.TemplateJniAddNativeMethodRegistrationAttributePresent = templateCodeGenState.JniAddNativeMethodRegistrationAttributePresent;
 	}
 
-	void WriteTypeMappings (NativeCodeGenState state)
+	void GenerateTypeMapFromNativeState (NativeCodeGenState state, bool useMarshalMethods)
 	{
 		if (androidRuntime == Xamarin.Android.Tasks.AndroidRuntime.NativeAOT) {
 			// NativeAOT typemaps are generated in `Microsoft.Android.Sdk.ILLink.TypeMappingStep`
 			Log.LogDebugMessage ("Skipping type maps for NativeAOT.");
 			return;
 		}
-		Log.LogDebugMessage ($"Generating type maps for architecture '{state.TargetArch}'");
+		Log.LogDebugMessage ($"Generating type maps from native state for architecture '{state.TargetArch}' (RunCheckedBuild = {RunCheckedBuild})");
 
 		if (TypemapImplementation != "llvm-ir") {
 			Log.LogDebugMessage ($"TypemapImplementation='{TypemapImplementation}' will write an empty native typemap.");
 			state = new NativeCodeGenState (state.TargetArch, new TypeDefinitionCache (), state.Resolver, [], [], state.Classifier);
 		}
 
-		var tmg = new TypeMapGenerator (Log, state, androidRuntime);
+		var tmg = new TypeMapGenerator (Log, new NativeCodeGenStateAdapter (state), androidRuntime) { RunCheckedBuild = RunCheckedBuild && !useMarshalMethods };
 		tmg.Generate (Debug, SkipJniAddNativeMethodRegistrationAttributeScan, TypemapOutputDirectory);
 
-		string abi = MonoAndroidHelper.ArchToAbi (state.TargetArch);
+		AddOutputTypeMaps (tmg, state.TargetArch);
+	}
+
+	void AddOutputTypeMaps (TypeMapGenerator tmg, AndroidTargetArch arch)
+	{
+		string abi = MonoAndroidHelper.ArchToAbi (arch);
 		var items = new List<ITaskItem> ();
+
 		foreach (string file in tmg.GeneratedBinaryTypeMaps) {
 			var item = new TaskItem (file);
 			string fileName = Path.GetFileName (file);
@@ -90,6 +164,6 @@ void WriteTypeMappings (NativeCodeGenState state)
 			items.Add (item);
 		}
 
-		GeneratedBinaryTypeMaps = items.ToArray ();
+		GeneratedBinaryTypeMaps = GeneratedBinaryTypeMaps.Concat (items).ToArray ();
 	}
 }
diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/AssemblyPipeline.cs b/src/Xamarin.Android.Build.Tasks/Utilities/AssemblyPipeline.cs
index 5cf54ca78af..eba39ee60ed 100644
--- a/src/Xamarin.Android.Build.Tasks/Utilities/AssemblyPipeline.cs
+++ b/src/Xamarin.Android.Build.Tasks/Utilities/AssemblyPipeline.cs
@@ -20,14 +20,10 @@ public AssemblyPipeline (DirectoryAssemblyResolver resolver)
 		Resolver = resolver;
 	}
 
-	public bool Run (AssemblyDefinition assembly, StepContext context)
+	public void Run (AssemblyDefinition assembly, StepContext context)
 	{
-		var changed = false;
-
 		foreach (var step in Steps)
-			changed |= step.ProcessAssembly (assembly, context);
-
-		return changed;
+			step.ProcessAssembly (assembly, context);
 	}
 
 	protected virtual void Dispose (bool disposing)
@@ -51,7 +47,7 @@ public void Dispose ()
 
 public interface IAssemblyModifierPipelineStep
 {
-	bool ProcessAssembly (AssemblyDefinition assembly, StepContext context);
+	void ProcessAssembly (AssemblyDefinition assembly, StepContext context);
 }
 
 public class StepContext
@@ -60,6 +56,7 @@ public class StepContext
 	public ITaskItem Destination { get; }
 	public bool EnableMarshalMethods { get; set; }
 	public bool IsAndroidAssembly { get; set; }
+	public bool IsAssemblyModified { get; set; }
 	public bool IsDebug { get; set; }
 	public bool IsFrameworkAssembly { get; set; }
 	public bool IsMainAssembly { get; set; }
diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapCecilAdapter.cs b/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapCecilAdapter.cs
index 197948048ac..7ac2f437ddc 100644
--- a/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapCecilAdapter.cs
+++ b/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapCecilAdapter.cs
@@ -16,15 +16,25 @@ class TypeMapCecilAdapter
 {
 	public static (List<TypeMapDebugEntry> javaToManaged, List<TypeMapDebugEntry> managedToJava) GetDebugNativeEntries (NativeCodeGenState state)
 	{
+		var (javaToManaged, managedToJava, foundJniNativeRegistration) = GetDebugNativeEntries (state.AllJavaTypes, state.TypeCache);
+
+		state.JniAddNativeMethodRegistrationAttributePresent = foundJniNativeRegistration;
+
+		return (javaToManaged, managedToJava);
+	}
+
+	public static (List<TypeMapDebugEntry> javaToManaged, List<TypeMapDebugEntry> managedToJava, bool foundJniNativeRegistration) GetDebugNativeEntries (List<TypeDefinition> types, TypeDefinitionCache cache)
+	{
+		var javaDuplicates = new Dictionary<string, List<TypeMapDebugEntry>> (StringComparer.Ordinal);
 		var javaToManaged = new List<TypeMapDebugEntry> ();
 		var managedToJava = new List<TypeMapDebugEntry> ();
+		var foundJniNativeRegistration = false;
 
-		var javaDuplicates = new Dictionary<string, List<TypeMapDebugEntry>> (StringComparer.Ordinal);
-		foreach (TypeDefinition td in state.AllJavaTypes) {
-			UpdateApplicationConfig (state, td);
+		foreach (var td in types) {
+			foundJniNativeRegistration = JniAddNativeMethodRegistrationAttributeFound (foundJniNativeRegistration, td);
 
-			TypeMapDebugEntry entry = GetDebugEntry (td, state.TypeCache);
-			HandleDebugDuplicates (javaDuplicates, entry, td, state.TypeCache);
+			TypeMapDebugEntry entry = GetDebugEntry (td, cache);
+			HandleDebugDuplicates (javaDuplicates, entry, td, cache);
 
 			javaToManaged.Add (entry);
 			managedToJava.Add (entry);
@@ -32,7 +42,7 @@ public static (List<TypeMapDebugEntry> javaToManaged, List<TypeMapDebugEntry> ma
 
 		SyncDebugDuplicates (javaDuplicates);
 
-		return (javaToManaged, managedToJava);
+		return (javaToManaged, managedToJava, foundJniNativeRegistration);
 	}
 
 	public static ReleaseGenerationState GetReleaseGenerationState (NativeCodeGenState state)
@@ -40,17 +50,28 @@ public static ReleaseGenerationState GetReleaseGenerationState (NativeCodeGenSta
 		var genState = new ReleaseGenerationState ();
 
 		foreach (TypeDefinition td in state.AllJavaTypes) {
-			ProcessReleaseType (state, genState, td);
+			UpdateApplicationConfig (state, td);
+			ProcessReleaseType (state.TypeCache, genState, td);
 		}
 
 		return genState;
 	}
 
-	static void ProcessReleaseType (NativeCodeGenState state, ReleaseGenerationState genState, TypeDefinition td)
+	public static ReleaseGenerationState GetReleaseGenerationState (List<TypeDefinition> types, TypeDefinitionCache cache, out bool foundJniNativeRegistration)
 	{
-		UpdateApplicationConfig (state, td);
-		genState.AddKnownAssembly (GetAssemblyName (td));
+		var genState = new ReleaseGenerationState ();
+		foundJniNativeRegistration = false;
 
+		foreach (TypeDefinition td in types) {
+			foundJniNativeRegistration = JniAddNativeMethodRegistrationAttributeFound (foundJniNativeRegistration, td);
+			ProcessReleaseType (cache, genState, td);
+		}
+
+		return genState;
+	}
+
+	static void ProcessReleaseType (TypeDefinitionCache cache, ReleaseGenerationState genState, TypeDefinition td)
+	{
 		// We must NOT use Guid here! The reason is that Guid sort order is different than its corresponding
 		// byte array representation and on the runtime we need the latter in order to be able to binary search
 		// through the module array.
@@ -65,7 +86,6 @@ static void ProcessReleaseType (NativeCodeGenState state, ReleaseGenerationState
 			moduleData = new ModuleReleaseData {
 				Mvid = td.Module.Mvid,
 				MvidBytes = moduleUUID,
-				//Assembly = td.Module.Assembly,
 				AssemblyName = td.Module.Assembly.Name.Name,
 				TypesScratch = new Dictionary<string, TypeMapReleaseEntry> (StringComparer.Ordinal),
 				DuplicateTypes = new List<TypeMapReleaseEntry> (),
@@ -74,7 +94,7 @@ static void ProcessReleaseType (NativeCodeGenState state, ReleaseGenerationState
 			tempModules.Add (moduleUUID, moduleData);
 		}
 
-		string javaName = Java.Interop.Tools.TypeNameMappings.JavaNativeTypeManager.ToJniName (td, state.TypeCache);
+		string javaName = Java.Interop.Tools.TypeNameMappings.JavaNativeTypeManager.ToJniName (td, cache);
 		// We will ignore generic types and interfaces when generating the Java to Managed map, but we must not
 		// omit them from the table we output - we need the same number of entries in both java-to-managed and
 		// managed-to-java tables.  `SkipInJavaToManaged` set to `true` will cause the native assembly generator
@@ -98,8 +118,6 @@ static void ProcessReleaseType (NativeCodeGenState state, ReleaseGenerationState
 		}
 	}
 
-	static string GetAssemblyName (TypeDefinition td) => td.Module.Assembly.FullName;
-
 	static TypeMapDebugEntry GetDebugEntry (TypeDefinition td, TypeDefinitionCache cache)
 	{
 		return new TypeMapDebugEntry {
@@ -130,7 +148,6 @@ static string GetManagedTypeName (TypeDefinition td)
 		return $"{managedTypeName}, {td.Module.Assembly.Name.Name}";
 	}
 
-
 	static void HandleDebugDuplicates (Dictionary<string, List<TypeMapDebugEntry>> javaDuplicates, TypeMapDebugEntry entry, TypeDefinition td, TypeDefinitionCache cache)
 	{
 		List<TypeMapDebugEntry> duplicates;
@@ -147,6 +164,7 @@ static void HandleDebugDuplicates (Dictionary<string, List<TypeMapDebugEntry>> j
 				// Fix things up so the abstract type is first, and the `Invoker` is considered a duplicate.
 				duplicates.Insert (0, entry);
 				oldEntry.SkipInJavaToManaged = false;
+				oldEntry.IsInvoker = true;
 			} else {
 				// ¯\_(ツ)_/¯
 				duplicates.Add (entry);
@@ -179,15 +197,20 @@ static void SyncDebugDuplicates (Dictionary<string, List<TypeMapDebugEntry>> jav
 
 	static void UpdateApplicationConfig (NativeCodeGenState state, TypeDefinition javaType)
 	{
-		if (state.JniAddNativeMethodRegistrationAttributePresent || !javaType.HasCustomAttributes) {
-			return;
-		}
+		state.JniAddNativeMethodRegistrationAttributePresent = JniAddNativeMethodRegistrationAttributeFound (state.JniAddNativeMethodRegistrationAttributePresent, javaType);
+	}
 
+	static bool JniAddNativeMethodRegistrationAttributeFound (bool alreadyFound, TypeDefinition javaType)
+	{
+		if (alreadyFound || !javaType.HasCustomAttributes) {
+			return alreadyFound;
+		}
+		
 		foreach (CustomAttribute ca in javaType.CustomAttributes) {
-			if (!state.JniAddNativeMethodRegistrationAttributePresent && String.Compare ("JniAddNativeMethodRegistrationAttribute", ca.AttributeType.Name, StringComparison.Ordinal) == 0) {
-				state.JniAddNativeMethodRegistrationAttributePresent = true;
-				break;
+			if (string.Equals ("JniAddNativeMethodRegistrationAttribute", ca.AttributeType.Name, StringComparison.Ordinal)) {
+				return true;
 			}
 		}
+		return false;
 	}
 }
diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapGenerator.cs b/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapGenerator.cs
index cada872317d..00a0ede03ad 100644
--- a/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapGenerator.cs
+++ b/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapGenerator.cs
@@ -3,13 +3,18 @@
 using System.IO;
 using System.Linq;
 using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Framework;
 using Microsoft.Build.Utilities;
 using Mono.Cecil;
+using Xamarin.Android.Tools;
+using static Xamarin.Android.Tasks.TypeMapGenerator;
 
 namespace Xamarin.Android.Tasks
 {
 	class TypeMapGenerator
 	{
+		public bool RunCheckedBuild { get; set; }
+
 		internal sealed class ModuleUUIDArrayComparer : IComparer<ModuleReleaseData>
 		{
 			int Compare (byte[] left, byte[] right)
@@ -62,6 +67,14 @@ internal sealed class TypeMapDebugEntry
 			// It is not used to create the typemap.
 			public TypeDefinition TypeDefinition;
 
+			// These fields are only used by the XML adapter for temp storage while reading.
+			// It is not used to create the typemap.
+			public string? DuplicateForJavaToManagedKey { get; set; }
+			public bool IsInvoker { get; set; }
+			public bool IsMonoAndroid { get; set; } // Types in Mono.Android take precedence over other assemblies
+
+			public string Key => $"{JavaName}|{ManagedName}";
+
 			public override string ToString ()
 			{
 				return $"TypeMapDebugEntry{{JavaName={JavaName}, ManagedName={ManagedName}, SkipInJavaToManaged={SkipInJavaToManaged}, DuplicateForJavaToManaged={DuplicateForJavaToManaged}}}";
@@ -78,36 +91,26 @@ internal sealed class ModuleDebugData
 
 		internal sealed class ReleaseGenerationState
 		{
-			int assemblyId = 0;
+			// This field is only used by the Cecil adapter for temp storage while reading.
+			// It is not used to create the typemap.
+			public readonly Dictionary<Guid, byte []> MvidCache;
 
-			public readonly Dictionary<string, int> KnownAssemblies;
-			public readonly Dictionary <Guid, byte[]> MvidCache;
-			public readonly Dictionary<byte[], ModuleReleaseData> TempModules;
+			public readonly Dictionary<byte [], ModuleReleaseData> TempModules;
 
 			public ReleaseGenerationState ()
 			{
-				KnownAssemblies = new Dictionary<string, int> (StringComparer.Ordinal);
-				MvidCache = new Dictionary<Guid, byte[]> ();
-				TempModules = new Dictionary<byte[], ModuleReleaseData> ();
-			}
-
-			public void AddKnownAssembly (string assemblyName)
-			{
-				if (KnownAssemblies.ContainsKey (assemblyName)) {
-					return;
-				}
-
-				KnownAssemblies.Add (assemblyName, ++assemblyId);
+				MvidCache = new Dictionary<Guid, byte []> ();
+				TempModules = new Dictionary<byte [], ModuleReleaseData> ();
 			}
 		}
 
 		readonly TaskLoggingHelper log;
-		readonly NativeCodeGenState state;
+		readonly ITypeMapGeneratorAdapter state;
 		readonly AndroidRuntime runtime;
 
 		public IList<string> GeneratedBinaryTypeMaps { get; } = new List<string> ();
 
-		public TypeMapGenerator (TaskLoggingHelper log, NativeCodeGenState state, AndroidRuntime runtime)
+		public TypeMapGenerator (TaskLoggingHelper log, ITypeMapGeneratorAdapter state, AndroidRuntime runtime)
 		{
 			this.log = log ?? throw new ArgumentNullException (nameof (log));
 			this.state = state ?? throw new ArgumentNullException (nameof (state));
@@ -133,7 +136,7 @@ public void Generate (bool debugBuild, bool skipJniAddNativeMethodRegistrationAt
 
 		void GenerateDebugNativeAssembly (string outputDirectory)
 		{
-			(var javaToManaged, var managedToJava) = TypeMapCecilAdapter.GetDebugNativeEntries (state);
+			(var javaToManaged, var managedToJava) = state.GetDebugNativeEntries ();
 
 			var data = new ModuleDebugData {
 				EntryCount = (uint)javaToManaged.Count,
@@ -150,7 +153,7 @@ void GenerateDebugNativeAssembly (string outputDirectory)
 
 		void GenerateRelease (string outputDirectory)
 		{
-			var genState = TypeMapCecilAdapter.GetReleaseGenerationState (state);
+			var genState = state.GetReleaseGenerationState ();
 
 			ModuleReleaseData [] modules = genState.TempModules.Values.ToArray ();
 			Array.Sort (modules, new ModuleUUIDArrayComparer ());
@@ -188,9 +191,135 @@ void GenerateNativeAssembly (LLVMIR.LlvmIrComposer composer, LLVMIR.LlvmIrModule
 					throw;
 				} finally {
 					sw.Flush ();
-					Files.CopyIfStreamChanged (sw.BaseStream, outputFile);
+
+					if (RunCheckedBuild) {
+						if (Files.HasStreamChanged (sw.BaseStream, outputFile)) {
+							Files.CopyIfStreamChanged (sw.BaseStream, outputFile + "2");
+							log.LogError ("Output file changed");
+						} else {
+							log.LogMessage ($"RunCheckedBuild: Output file '{outputFile}' unchanged");
+						}
+					} else {
+						Files.CopyIfStreamChanged (sw.BaseStream, outputFile);
+					}
+				}
+			}
+		}
+	}
+
+	// This abstraction is temporary to facilitate the transition from the old
+	// typemap generator to the new one.  It will be removed once the transition
+	// is complete.
+	interface ITypeMapGeneratorAdapter
+	{
+		AndroidTargetArch TargetArch { get; }
+		bool JniAddNativeMethodRegistrationAttributePresent { get; set; }
+		(List<TypeMapDebugEntry> javaToManaged, List<TypeMapDebugEntry> managedToJava) GetDebugNativeEntries ();
+		ReleaseGenerationState GetReleaseGenerationState ();
+	}
+
+	class NativeCodeGenStateAdapter : ITypeMapGeneratorAdapter
+	{
+		readonly NativeCodeGenState state;
+
+		public NativeCodeGenStateAdapter (NativeCodeGenState state)
+		{
+			this.state = state ?? throw new ArgumentNullException (nameof (state));
+		}
+
+		public AndroidTargetArch TargetArch => state.TargetArch;
+
+		public bool JniAddNativeMethodRegistrationAttributePresent {
+			get => state.JniAddNativeMethodRegistrationAttributePresent;
+			set => state.JniAddNativeMethodRegistrationAttributePresent = value;
+		}
+
+		public (List<TypeMapDebugEntry> javaToManaged, List<TypeMapDebugEntry> managedToJava) GetDebugNativeEntries ()
+		{
+			return TypeMapCecilAdapter.GetDebugNativeEntries (state);
+		}
+
+		public ReleaseGenerationState GetReleaseGenerationState ()
+		{
+			return TypeMapCecilAdapter.GetReleaseGenerationState (state);
+		}
+	}
+
+	class TypeMapObjectsFileAdapter : ITypeMapGeneratorAdapter
+	{
+		public List<TypeMapObjectsXmlFile> XmlFiles { get; } = [];
+		public AndroidTargetArch TargetArch { get; }
+
+		public TypeMapObjectsFileAdapter (AndroidTargetArch targetArch)
+		{
+			TargetArch = targetArch;
+		}
+
+		public bool JniAddNativeMethodRegistrationAttributePresent { get; set; }
+
+		public (List<TypeMapDebugEntry> javaToManaged, List<TypeMapDebugEntry> managedToJava) GetDebugNativeEntries ()
+		{
+			var javaToManaged = new List<TypeMapDebugEntry> ();
+			var managedToJava = new List<TypeMapDebugEntry> ();
+
+			foreach (var xml in XmlFiles) {
+				javaToManaged.AddRange (xml.JavaToManagedDebugEntries);
+				managedToJava.AddRange (xml.ManagedToJavaDebugEntries);
+			}
+
+			// Handle entries with duplicate JavaNames
+			GroupDuplicateDebugEntries (javaToManaged);
+			GroupDuplicateDebugEntries (managedToJava);
+
+			return (javaToManaged, managedToJava);
+		}
+
+		void GroupDuplicateDebugEntries (List<TypeMapDebugEntry> debugEntries)
+		{
+			foreach (var group in debugEntries.GroupBy (ent => ent.JavaName).Where (g => g.Count () > 1)) {
+				// We need to sort:
+				// - Types in Mono.Android come first
+				// - Types that are not invokers come first
+				var entries = group
+					.OrderBy (e => e.IsMonoAndroid ? 0 : 1)
+					.ThenBy (e => e.IsInvoker ? 1 : 0)
+					.ToList ();
+
+				for (var i = 1; i < entries.Count; i++)
+					entries [i].DuplicateForJavaToManaged = entries [0];
+			}
+		}
+
+		public ReleaseGenerationState GetReleaseGenerationState ()
+		{
+			var state = new ReleaseGenerationState ();
+
+			foreach (var xml in XmlFiles)
+				if (xml.HasReleaseEntries)
+					state.TempModules.Add (xml.ModuleReleaseData.MvidBytes, xml.ModuleReleaseData);
+
+			return state;
+		}
+
+		public static TypeMapObjectsFileAdapter? Create (AndroidTargetArch targetArch, List<ITaskItem> assemblies, TaskLoggingHelper log)
+		{
+			var adapter = new TypeMapObjectsFileAdapter (targetArch);
+
+			foreach (var assembly in assemblies) {
+				var typeMapPath = TypeMapObjectsXmlFile.GetTypeMapObjectsXmlFilePath (assembly.ItemSpec);
+
+				if (!File.Exists (typeMapPath)) {
+					log.LogError ($"'{typeMapPath}' not found.");
+					return null;
 				}
+
+				var xml = TypeMapObjectsXmlFile.Import (typeMapPath);
+
+				if (xml.WasScanned)
+					adapter.XmlFiles.Add (xml);
 			}
+
+			return adapter;
 		}
 	}
 }
diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapObjectsXmlFile.cs b/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapObjectsXmlFile.cs
new file mode 100644
index 00000000000..13cd476cc25
--- /dev/null
+++ b/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapObjectsXmlFile.cs
@@ -0,0 +1,278 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Xml;
+using System.Xml.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using NuGet.Packaging;
+
+using ModuleReleaseData = Xamarin.Android.Tasks.TypeMapGenerator.ModuleReleaseData;
+using TypeMapDebugEntry = Xamarin.Android.Tasks.TypeMapGenerator.TypeMapDebugEntry;
+using TypeMapReleaseEntry = Xamarin.Android.Tasks.TypeMapGenerator.TypeMapReleaseEntry;
+
+namespace Xamarin.Android.Tasks;
+
+class TypeMapObjectsXmlFile
+{
+	static readonly XmlWriterSettings settings = new XmlWriterSettings {
+		Indent = true,
+		NewLineOnAttributes = false,
+		OmitXmlDeclaration = true,
+	};
+
+	static readonly TypeMapObjectsXmlFile unscanned = new TypeMapObjectsXmlFile { WasScanned = false };
+
+	public string? AssemblyName { get; set; }
+	public bool FoundJniNativeRegistration { get; set; }
+	public List<TypeMapDebugEntry> JavaToManagedDebugEntries { get; } = [];
+	public List<TypeMapDebugEntry> ManagedToJavaDebugEntries { get; } = [];
+	public ModuleReleaseData? ModuleReleaseData { get; set; }
+	public bool HasDebugEntries => JavaToManagedDebugEntries.Count > 0 || ManagedToJavaDebugEntries.Count > 0;
+	public bool HasReleaseEntries => ModuleReleaseData is not null;
+
+	public bool WasScanned { get; private set; }
+
+	public void Export (string filename, TaskLoggingHelper log)
+	{
+		if (!HasDebugEntries && ModuleReleaseData == null) {
+			WriteEmptyFile (filename, log);
+			return;
+		}
+
+		using var sw = MemoryStreamPool.Shared.CreateStreamWriter ();
+
+		using (var xml = XmlWriter.Create (sw, settings))
+			Export (xml);
+
+		sw.Flush ();
+
+		Files.CopyIfStreamChanged (sw.BaseStream, filename);
+
+		log.LogDebugMessage ($"Wrote '{filename}', {JavaToManagedDebugEntries.Count} JavaToManagedDebugEntries, {ManagedToJavaDebugEntries.Count} ManagedToJavaDebugEntries, FoundJniNativeRegistration: {FoundJniNativeRegistration}");
+	}
+
+	void Export (XmlWriter xml)
+	{
+		xml.WriteStartElement ("api");
+		xml.WriteAttributeString ("type", HasDebugEntries ? "debug" : "release");
+		xml.WriteAttributeStringIfNotDefault ("assembly-name", AssemblyName);
+		xml.WriteAttributeStringIfNotDefault ("found-jni-native-registration", FoundJniNativeRegistration);
+
+		if (HasDebugEntries)
+			ExportDebugData (xml);
+		else if (HasReleaseEntries)
+			ExportReleaseData (xml);
+
+		xml.WriteEndElement ();
+	}
+
+	void ExportDebugData (XmlWriter xml)
+	{
+		if (JavaToManagedDebugEntries.Count > 0) {
+			xml.WriteStartElement ("java-to-managed");
+
+			foreach (var entry in JavaToManagedDebugEntries)
+				WriteTypeMapDebugEntry (xml, entry);
+
+			xml.WriteEndElement ();
+		}
+
+		if (ManagedToJavaDebugEntries.Count > 0) {
+			xml.WriteStartElement ("managed-to-java");
+
+			foreach (var entry in ManagedToJavaDebugEntries)
+				WriteTypeMapDebugEntry (xml, entry);
+
+			xml.WriteEndElement ();
+		}
+	}
+
+	void WriteTypeMapDebugEntry (XmlWriter xml, TypeMapDebugEntry entry)
+	{
+		xml.WriteStartElement ("entry");
+		xml.WriteAttributeStringIfNotDefault ("java-name", entry.JavaName);
+		xml.WriteAttributeStringIfNotDefault ("managed-name", entry.ManagedName);
+		xml.WriteAttributeStringIfNotDefault ("skip-in-java-to-managed", entry.SkipInJavaToManaged);
+		xml.WriteAttributeStringIfNotDefault ("is-invoker", entry.IsInvoker);
+		xml.WriteEndElement ();
+	}
+
+	void ExportReleaseData (XmlWriter xml)
+	{
+		if (ModuleReleaseData is null)
+			return;
+
+		xml.WriteStartElement ("module");
+
+		xml.WriteAttributeStringIfNotDefault ("assembly-name", ModuleReleaseData.AssemblyName);
+		xml.WriteAttributeStringIfNotDefault ("mvid", ModuleReleaseData.Mvid.ToString ("N"));
+		xml.WriteAttributeStringIfNotDefault ("mvid-bytes", Convert.ToBase64String (ModuleReleaseData.MvidBytes));
+
+		if (ModuleReleaseData.Types?.Length > 0) {
+			xml.WriteStartElement ("types");
+
+			foreach (var entry in ModuleReleaseData.DuplicateTypes)
+				ExportTypeMapReleaseEntry (xml, entry, null);
+
+			xml.WriteEndElement ();
+		}
+
+		if (ModuleReleaseData.DuplicateTypes?.Count > 0) {
+			xml.WriteStartElement ("duplicates");
+
+			foreach (var entry in ModuleReleaseData.DuplicateTypes)
+				ExportTypeMapReleaseEntry (xml, entry, null);
+
+			xml.WriteEndElement ();
+		}
+
+		if (ModuleReleaseData.TypesScratch?.Count > 0) {
+			xml.WriteStartElement ("types-scratch");
+
+			foreach (var kvp in ModuleReleaseData.TypesScratch)
+				ExportTypeMapReleaseEntry (xml, kvp.Value, kvp.Key);
+
+			xml.WriteEndElement ();
+		}
+
+		xml.WriteEndElement ();
+	}
+
+	void ExportTypeMapReleaseEntry (XmlWriter xml, TypeMapReleaseEntry entry, string? key)
+	{
+		xml.WriteStartElement ("entry");
+
+		xml.WriteAttributeStringIfNotDefault ("key", key);
+		xml.WriteAttributeStringIfNotDefault ("java-name", entry.JavaName);
+		xml.WriteAttributeStringIfNotDefault ("managed-type-name", entry.ManagedTypeName);
+		xml.WriteAttributeStringIfNotDefault ("token", entry.Token.ToString ());
+		xml.WriteAttributeStringIfNotDefault ("skip-in-java-to-managed", entry.SkipInJavaToManaged);
+
+		xml.WriteEndElement ();
+	}
+
+	/// <summary>
+	/// Given an assembly path, return the path to the ".typemap.xml" file that should be next to it.
+	/// </summary>
+	public static string GetTypeMapObjectsXmlFilePath (string assemblyPath)
+		=> Path.ChangeExtension (assemblyPath, ".typemap.xml");
+
+	public static TypeMapObjectsXmlFile Import (string filename)
+	{
+		// If the file has zero length, then the assembly wasn't scanned because it couldn't contain JLOs.
+		// This check is much faster than loading and parsing an empty XML file.
+		var fi = new FileInfo (filename);
+
+		if (fi.Length == 0)
+			return unscanned;
+
+		var xml = XDocument.Load (filename);
+		var root = xml.Root ?? throw new InvalidOperationException ($"Invalid XML file '{filename}'");
+
+		var type = root.GetRequiredAttribute ("type");
+		var assemblyName = root.GetAttributeOrDefault ("assembly-name", (string?)null);
+		var foundJniNativeRegistration = root.GetAttributeOrDefault ("found-jni-native-registration", false);
+
+		var file = new TypeMapObjectsXmlFile {
+			WasScanned = true,
+			AssemblyName = assemblyName,
+			FoundJniNativeRegistration = foundJniNativeRegistration,
+		};
+
+		if (type == "debug")
+			ImportDebugData (root, file);
+		else if (type == "release")
+			ImportReleaseData (root, file);
+
+		return file;
+	}
+
+	static void ImportDebugData (XElement root, TypeMapObjectsXmlFile file)
+	{
+		var isMonoAndroid = root.GetAttributeOrDefault ("assembly-name", string.Empty) == "Mono.Android";
+		var javaToManaged = root.Element ("java-to-managed");
+
+		if (javaToManaged is not null) {
+			foreach (var entry in javaToManaged.Elements ("entry"))
+				file.JavaToManagedDebugEntries.Add (FromDebugEntryXml (entry, isMonoAndroid));
+		}
+
+		var managedToJava = root.Element ("managed-to-java");
+
+		if (managedToJava is not null) {
+			foreach (var entry in managedToJava.Elements ("entry"))
+				file.ManagedToJavaDebugEntries.Add (FromDebugEntryXml (entry, isMonoAndroid));
+		}
+	}
+
+	static void ImportReleaseData (XElement root, TypeMapObjectsXmlFile file)
+	{
+		var module = root.Element ("module");
+
+		if (module is null)
+			return;
+
+		file.ModuleReleaseData = new ModuleReleaseData {
+			AssemblyName = module.GetAttributeOrDefault ("assembly-name", string.Empty),
+			Mvid = Guid.Parse (module.GetAttributeOrDefault ("mvid", Guid.Empty.ToString ())),
+			MvidBytes = Convert.FromBase64String (module.GetAttributeOrDefault ("mvid-bytes", string.Empty)),
+			TypesScratch = new Dictionary<string, TypeMapReleaseEntry> (StringComparer.Ordinal),
+			DuplicateTypes = new List<TypeMapReleaseEntry> (),
+		};
+
+		if (module.Element ("types") is XElement types)
+			file.ModuleReleaseData.Types = types.Elements ("entry")
+				.Select (FromReleaseEntryXml)
+				.ToArray ();
+
+		if (module.Element ("duplicates") is XElement duplicates)
+			file.ModuleReleaseData.DuplicateTypes.AddRange (duplicates.Elements ("entry")
+				.Select (FromReleaseEntryXml));
+
+		if (module.Element ("types-scratch") is XElement typesScratch)
+			file.ModuleReleaseData.TypesScratch.AddRange (typesScratch.Elements ("entry")
+				.Select (elem => new KeyValuePair<string, TypeMapReleaseEntry> (elem.GetAttributeOrDefault ("key", string.Empty), FromReleaseEntryXml (elem))));
+	}
+
+	public static void WriteEmptyFile (string destination, TaskLoggingHelper log)
+	{
+		log.LogDebugMessage ($"Writing empty file '{destination}'");
+
+		// We write a zero byte file to indicate the file couldn't have JLO types and wasn't scanned
+		File.Create (destination).Dispose ();
+	}
+
+	static TypeMapDebugEntry FromDebugEntryXml (XElement entry, bool isMonoAndroid)
+	{
+		var javaName = entry.GetAttributeOrDefault ("java-name", string.Empty);
+		var managedName = entry.GetAttributeOrDefault ("managed-name", string.Empty);
+		var skipInJavaToManaged = entry.GetAttributeOrDefault ("skip-in-java-to-managed", false);
+		var isInvoker = entry.GetAttributeOrDefault ("is-invoker", false);
+
+		return new TypeMapDebugEntry {
+			JavaName = javaName,
+			ManagedName = managedName,
+			SkipInJavaToManaged = skipInJavaToManaged,
+			IsInvoker = isInvoker,
+			IsMonoAndroid = isMonoAndroid,
+		};
+	}
+
+	static TypeMapReleaseEntry FromReleaseEntryXml (XElement entry)
+	{
+		var javaName = entry.GetAttributeOrDefault ("java-name", string.Empty);
+		var managedTypeName = entry.GetAttributeOrDefault ("managed-type-name", string.Empty);
+		var token = entry.GetAttributeOrDefault ("token", 0u);
+		var skipInJavaToManaged = entry.GetAttributeOrDefault ("skip-in-java-to-managed", false);
+
+		return new TypeMapReleaseEntry {
+			JavaName = javaName,
+			ManagedTypeName = managedTypeName,
+			Token = token,
+			SkipInJavaToManaged = skipInJavaToManaged,
+		};
+	}
+}
diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj
index a15756ff32d..f3a0b70801b 100644
--- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj
+++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj
@@ -47,6 +47,7 @@
 
     <!-- Include only the linker sources required for LinkAssembliesNoShrink - AddKeepAlivesStep, FixAbstractMethodsStep, FixLegacyResourceDesignerStep -->
     <Compile Remove="Linker\**" />
+    <Compile Include="Linker\MonoDroid.Tuner\FindTypeMapObjectsStep.cs" />
     <Compile Include="Linker\MonoDroid.Tuner\FindJavaObjectsStep.cs" />
     <Compile Include="Linker\MonoDroid.Tuner\AddKeepAlivesStep.cs" />
     <Compile Include="Linker\MonoDroid.Tuner\AndroidLinkConfiguration.cs" />
diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets
index c04712421c4..2f70668561d 100644
--- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets
+++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets
@@ -1613,8 +1613,12 @@ because xbuild doesn't support framework reference assemblies.
   <GenerateTypeMappings
       AndroidRuntime="$(_AndroidRuntime)"
       Debug="$(AndroidIncludeDebugSymbols)"
+      EnableMarshalMethods="$(_AndroidUseMarshalMethods)"
       IntermediateOutputDirectory="$(IntermediateOutputPath)"
+      ResolvedAssemblies="@(_ResolvedAssemblies)"
+      RunCheckedBuild="$(_AndroidJLOCheckedBuild)"
       SkipJniAddNativeMethodRegistrationAttributeScan="$(_SkipJniAddNativeMethodRegistrationAttributeScan)"
+      SupportedAbis="@(_BuildTargetAbis)"
       TypemapImplementation="$(_AndroidTypeMapImplementation)"
       TypemapOutputDirectory="$(_NativeAssemblySourceDir)">
   </GenerateTypeMappings>


Reviews:
user: login: jonathanpeppers
body: Is the new trimmer step not setup for Release mode? I thought it would be listed here:
* https://github.com/dotnet/android/blob/main/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.ILLink.targets

The PR description mentions it won't work in combination with `$(AndroidEnableMarshalMethods)=true`, but would it work for a `Release` build with marshal methods off?
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-21T13:33:26Z
user: login: jonpryor
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T20:04:35Z
user: login: jonpryor
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T20:08:18Z
user: login: jonpryor
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T20:11:29Z
user: login: jonpryor
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T20:12:41Z
user: login: jonpryor
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T20:31:06Z
user: login: jpobst
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T02:00:06Z
user: login: grendello
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T13:50:29Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR refactors the typemap generation process by moving the JLO scanning to a new linker step and updating the overall assembly pipeline to better support incremental builds. Key changes include:
- Introducing a new FindTypeMapObjectsStep that writes typemap XML files beside source assemblies.
- Refactoring TypeMapGenerator and TypeMapCecilAdapter to use new adapter interfaces for handling debug and release typemap generation.
- Updating the AssemblyModifierPipeline to use void-based ProcessAssembly methods and adding a SaveChangedAssemblyStep.

### Reviewed Changes

Copilot reviewed 11 out of 13 changed files in this pull request and generated 2 comments.

<details>
<summary>Show a summary per file</summary>

| File | Description |
| ---- | ----------- |
| src/Xamarin.Android.Build.Tasks/Utilities/TypeMapObjectsXmlFile.cs | Introduces XML export logic for typemap objects with support for both debug and release entries. |
| src/Xamarin.Android.Build.Tasks/Utilities/TypeMapGenerator.cs | Refactors type mapping generation using new adapter abstractions and integrates the RunCheckedBuild flag. |
| src/Xamarin.Android.Build.Tasks/Utilities/TypeMapCecilAdapter.cs | Updates debug and release entry generation and duplicate handling logic. |
| src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMappings.cs | Updates the task to generate typemaps from both managed and native states, with changes for safe file output accumulation. |
| src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs | Modifies pipeline steps to use void-based ProcessAssembly and introduces a new step to save changed assemblies. |
| src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs | Adds a new linker step that scans for Java types needed for typemap generation and writes them to an XML file. |
</details>


<details>
<summary>Files not reviewed (2)</summary>

* **src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj**: Language not supported
* **src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets**: Language not supported
</details>



state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T18:31:37Z
user: login: jonpryor
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T20:27:53Z
user: login: jpobst
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T21:32:31Z
user: login: jonpryor
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T23:16:05Z

Review comments:
diff_hunk: @@ -0,0 +1,87 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+using Mono.Linker.Steps;
+using Xamarin.Android.Tasks;
+
+namespace MonoDroid.Tuner;
+
+/// <summary>
+/// Scans an assembly for JLOs that need to be in the typemap and writes them to an XML file.
+/// </summary>
+public class FindTypeMapObjectsStep : BaseStep, IAssemblyModifierPipelineStep
+{
+	public bool Debug { get; set; }
+
+	public bool ErrorOnCustomJavaObject { get; set; }
+
+	public TaskLoggingHelper Log { get; set; }
+
+	public FindTypeMapObjectsStep (TaskLoggingHelper log) => Log = log;
path: src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs
user: login: jonpryor
body: As a "ha ha only serious" / "future coding guidelines" question/discussion: should we start using [C# primary constructors](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/primary-constructors)?

```csharp
public partial class FindTypeMapObjectStep (TaskLoggingHelper Log) : BaseStep, IAssemblyModifierPipelineStep {
    // …
}
```
created_at: 2025-04-21T20:04:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+using Mono.Linker.Steps;
+using Xamarin.Android.Tasks;
+
+namespace MonoDroid.Tuner;
+
+/// <summary>
+/// Scans an assembly for JLOs that need to be in the typemap and writes them to an XML file.
+/// </summary>
+public class FindTypeMapObjectsStep : BaseStep, IAssemblyModifierPipelineStep
+{
+	public bool Debug { get; set; }
+
+	public bool ErrorOnCustomJavaObject { get; set; }
+
+	public TaskLoggingHelper Log { get; set; }
+
+	public FindTypeMapObjectsStep (TaskLoggingHelper log) => Log = log;
+
+	public void ProcessAssembly (AssemblyDefinition assembly, StepContext context)
+	{
+		var destinationTypeMapXml = TypeMapObjectsXmlFile.GetTypeMapObjectsXmlFilePath (context.Destination.ItemSpec);
+
+		// We only care about assemblies that can contains JLOs
+		if (!context.IsAndroidAssembly) {
+			Log.LogDebugMessage ($"Skipping assembly '{assembly.Name.Name}' because it is not an Android assembly");
+			TypeMapObjectsXmlFile.WriteEmptyFile (destinationTypeMapXml, Log);
+			return;
+		}
+
+		var types = ScanForJavaTypes (assembly);
+
+		var xml = new TypeMapObjectsXmlFile {
+			AssemblyName = assembly.Name.Name,
+		};
+
+		if (Debug) {
+			var (javaToManaged, managedToJava) = TypeMapCecilAdapter.GetDebugNativeEntries (types, Context, out var foundJniNativeRegistration);
path: src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs
user: login: jonpryor
body: If we have a tuple return type, why not have `out var foundJniNativeRegistration` be a separate tuple value?
created_at: 2025-04-21T20:08:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+using Mono.Linker.Steps;
+using Xamarin.Android.Tasks;
+
+namespace MonoDroid.Tuner;
+
+/// <summary>
+/// Scans an assembly for JLOs that need to be in the typemap and writes them to an XML file.
+/// </summary>
+public class FindTypeMapObjectsStep : BaseStep, IAssemblyModifierPipelineStep
+{
+	public bool Debug { get; set; }
+
+	public bool ErrorOnCustomJavaObject { get; set; }
+
+	public TaskLoggingHelper Log { get; set; }
+
+	public FindTypeMapObjectsStep (TaskLoggingHelper log) => Log = log;
+
+	public void ProcessAssembly (AssemblyDefinition assembly, StepContext context)
+	{
+		var destinationTypeMapXml = TypeMapObjectsXmlFile.GetTypeMapObjectsXmlFilePath (context.Destination.ItemSpec);
+
+		// We only care about assemblies that can contains JLOs
+		if (!context.IsAndroidAssembly) {
+			Log.LogDebugMessage ($"Skipping assembly '{assembly.Name.Name}' because it is not an Android assembly");
+			TypeMapObjectsXmlFile.WriteEmptyFile (destinationTypeMapXml, Log);
+			return;
+		}
+
+		var types = ScanForJavaTypes (assembly);
+
+		var xml = new TypeMapObjectsXmlFile {
+			AssemblyName = assembly.Name.Name,
+		};
+
+		if (Debug) {
+			var (javaToManaged, managedToJava) = TypeMapCecilAdapter.GetDebugNativeEntries (types, Context, out var foundJniNativeRegistration);
+
+			xml.JavaToManagedDebugEntries.AddRange (javaToManaged);
+			xml.ManagedToJavaDebugEntries.AddRange (managedToJava);
+			xml.FoundJniNativeRegistration = foundJniNativeRegistration;
+
+			if (!xml.HasDebugEntries) {
path: src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs
user: login: jonpryor
body: I wonder if this block and the "equivalent" block in the `else` branch should be moved to the end…
created_at: 2025-04-21T20:11:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+using Mono.Linker.Steps;
+using Xamarin.Android.Tasks;
+
+namespace MonoDroid.Tuner;
+
+/// <summary>
+/// Scans an assembly for JLOs that need to be in the typemap and writes them to an XML file.
+/// </summary>
+public class FindTypeMapObjectsStep : BaseStep, IAssemblyModifierPipelineStep
+{
+	public bool Debug { get; set; }
+
+	public bool ErrorOnCustomJavaObject { get; set; }
+
+	public TaskLoggingHelper Log { get; set; }
+
+	public FindTypeMapObjectsStep (TaskLoggingHelper log) => Log = log;
+
+	public void ProcessAssembly (AssemblyDefinition assembly, StepContext context)
+	{
+		var destinationTypeMapXml = TypeMapObjectsXmlFile.GetTypeMapObjectsXmlFilePath (context.Destination.ItemSpec);
+
+		// We only care about assemblies that can contains JLOs
+		if (!context.IsAndroidAssembly) {
+			Log.LogDebugMessage ($"Skipping assembly '{assembly.Name.Name}' because it is not an Android assembly");
+			TypeMapObjectsXmlFile.WriteEmptyFile (destinationTypeMapXml, Log);
+			return;
+		}
+
+		var types = ScanForJavaTypes (assembly);
+
+		var xml = new TypeMapObjectsXmlFile {
+			AssemblyName = assembly.Name.Name,
+		};
+
+		if (Debug) {
+			var (javaToManaged, managedToJava) = TypeMapCecilAdapter.GetDebugNativeEntries (types, Context, out var foundJniNativeRegistration);
+
+			xml.JavaToManagedDebugEntries.AddRange (javaToManaged);
+			xml.ManagedToJavaDebugEntries.AddRange (managedToJava);
+			xml.FoundJniNativeRegistration = foundJniNativeRegistration;
+
+			if (!xml.HasDebugEntries) {
+				Log.LogDebugMessage ($"No Java types found in '{assembly.Name.Name}'");
+				TypeMapObjectsXmlFile.WriteEmptyFile (destinationTypeMapXml, Log);
+				return;
+			}
+		} else {
+			var genState = TypeMapCecilAdapter.GetReleaseGenerationState (types, Context, out var foundJniNativeRegistration);
+			xml.ModuleReleaseData = genState.TempModules.SingleOrDefault ().Value;
+
+			if (xml.ModuleReleaseData == null) {
+				Log.LogDebugMessage ($"No Java types found in '{assembly.Name.Name}'");
+				TypeMapObjectsXmlFile.WriteEmptyFile (destinationTypeMapXml, Log);
+				return;
+			}
+		}
+
path: src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs
user: login: jonpryor
body: …to here, as:


```csharp
if (!xml.HasDebugEntries && xml.ModuleReleaseData == null) {
    Log.LogDebugMessage ("No Java types found…");
    TypeMapObjectsXmlFile.WriteEmptyFile (destinationTypeMapXml, Log)
    return;
}
```
created_at: 2025-04-21T20:12:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,271 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Xml;
+using System.Xml.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using NuGet.Packaging;
+
+using ModuleReleaseData = Xamarin.Android.Tasks.TypeMapGenerator.ModuleReleaseData;
+using TypeMapDebugEntry = Xamarin.Android.Tasks.TypeMapGenerator.TypeMapDebugEntry;
+using TypeMapReleaseEntry = Xamarin.Android.Tasks.TypeMapGenerator.TypeMapReleaseEntry;
+
+namespace Xamarin.Android.Tasks;
+
+class TypeMapObjectsXmlFile
+{
+	static XmlWriterSettings settings = new XmlWriterSettings {
+		Indent = true,
+		NewLineOnAttributes = false,
+		OmitXmlDeclaration = true,
+	};
+
+	static readonly TypeMapObjectsXmlFile unscanned = new TypeMapObjectsXmlFile { WasScanned = false };
+
+	public string? AssemblyName { get; set; }
+	public bool FoundJniNativeRegistration { get; set; }
+	public List<TypeMapDebugEntry> JavaToManagedDebugEntries { get; } = [];
+	public List<TypeMapDebugEntry> ManagedToJavaDebugEntries { get; } = [];
+	public ModuleReleaseData? ModuleReleaseData { get; set; }
+	public bool HasDebugEntries => JavaToManagedDebugEntries.Count > 0 || ManagedToJavaDebugEntries.Count > 0;
+	public bool HasReleaseEntries => ModuleReleaseData is not null;
+
+	public bool WasScanned { get; private set; }
+
+	public void Export (string filename)
path: src/Xamarin.Android.Build.Tasks/Utilities/TypeMapObjectsXmlFile.cs
user: login: jonpryor
body: Should *this* method be responsible for checking for `!this.HasDebugEntries && this.ModuleReleaseData == null` and writing an empty file if there are no entries to write?  This could simplify the call-sites.

Additionally, if we add a `Log` property to this class, the `No Java types found`/`Wrote …` message could *also* be moved here:

```csharp
public void Export (string filename, string assemblyName)
{
    if (!HasDebugEntries && ModuleReleaseData == null) {
        Log.LogDebugMessage ($"No Java types found in '{assemblyName}'");
        TypeMapObjectsXmlFile.WriteEmptyFile (filename, Log)
        return;
    }
    Log.LogDebugMessage ($"Wrote '{filename}', {JavaToManagedDebugEntries.Count} JavaToManagedDebugEntries, {ManagedToJavaDebugEntries.Count} ManagedToJavaDebugEntries, FoundJniNativeRegistration: {FoundJniNativeRegistration}")
    // …
}
```
created_at: 2025-04-21T20:31:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+using Mono.Linker.Steps;
+using Xamarin.Android.Tasks;
+
+namespace MonoDroid.Tuner;
+
+/// <summary>
+/// Scans an assembly for JLOs that need to be in the typemap and writes them to an XML file.
+/// </summary>
+public class FindTypeMapObjectsStep : BaseStep, IAssemblyModifierPipelineStep
+{
+	public bool Debug { get; set; }
+
+	public bool ErrorOnCustomJavaObject { get; set; }
+
+	public TaskLoggingHelper Log { get; set; }
+
+	public FindTypeMapObjectsStep (TaskLoggingHelper log) => Log = log;
path: src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs
user: login: jpobst
body: I don't love primary constructors, but if that's what we decide we want to standardize as a team it won't kill me.  😁 
created_at: 2025-04-22T02:00:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+using Mono.Linker.Steps;
+using Xamarin.Android.Tasks;
+
+namespace MonoDroid.Tuner;
+
+/// <summary>
+/// Scans an assembly for JLOs that need to be in the typemap and writes them to an XML file.
+/// </summary>
+public class FindTypeMapObjectsStep : BaseStep, IAssemblyModifierPipelineStep
+{
+	public bool Debug { get; set; }
+
+	public bool ErrorOnCustomJavaObject { get; set; }
+
+	public TaskLoggingHelper Log { get; set; }
+
+	public FindTypeMapObjectsStep (TaskLoggingHelper log) => Log = log;
path: src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs
user: login: grendello
body: I'm not a fan of primary constructors either... :)
created_at: 2025-04-22T13:50:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,73 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Build.Utilities;
+using Mono.Cecil;
+using Mono.Linker.Steps;
+using Xamarin.Android.Tasks;
+
+namespace MonoDroid.Tuner;
+
+/// <summary>
+/// Scans an assembly for JLOs that need to be in the typemap and writes them to an XML file.
+/// </summary>
+public class FindTypeMapObjectsStep : BaseStep, IAssemblyModifierPipelineStep
+{
+	public bool Debug { get; set; }
+
+	public bool ErrorOnCustomJavaObject { get; set; }
+
+	public TaskLoggingHelper Log { get; set; }
+
+	public FindTypeMapObjectsStep (TaskLoggingHelper log) => Log = log;
+
+	public void ProcessAssembly (AssemblyDefinition assembly, StepContext context)
+	{
+		var destinationTypeMapXml = TypeMapObjectsXmlFile.GetTypeMapObjectsXmlFilePath (context.Destination.ItemSpec);
+
+		// We only care about assemblies that can contains JLOs
+		if (!context.IsAndroidAssembly) {
+			Log.LogDebugMessage ($"Skipping assembly '{assembly.Name.Name}' because it is not an Android assembly");
+			TypeMapObjectsXmlFile.WriteEmptyFile (destinationTypeMapXml, Log);
+			return;
+		}
+
+		var types = ScanForJavaTypes (assembly);
+
+		var xml = new TypeMapObjectsXmlFile {
+			AssemblyName = assembly.Name.Name,
+		};
+
+		if (Debug) {
+			var (javaToManaged, managedToJava, foundJniNativeRegistration) = TypeMapCecilAdapter.GetDebugNativeEntries (types, Context);
+
+			xml.JavaToManagedDebugEntries.AddRange (javaToManaged);
+			xml.ManagedToJavaDebugEntries.AddRange (managedToJava);
+			xml.FoundJniNativeRegistration = foundJniNativeRegistration;
+		} else {
+			var genState = TypeMapCecilAdapter.GetReleaseGenerationState (types, Context, out var foundJniNativeRegistration);
+			xml.ModuleReleaseData = genState.TempModules.SingleOrDefault ().Value;
path: src/Xamarin.Android.Build.Tasks/Linker/MonoDroid.Tuner/FindTypeMapObjectsStep.cs
user: login: Copilot
body: GetReleaseGenerationState expects a TypeDefinitionCache as its second argument, but 'Context' (a StepContext) is passed here. Please update the call to provide the appropriate TypeDefinitionCache instance.
```suggestion
			var typeDefinitionCache = new TypeDefinitionCache(); // Initialize TypeDefinitionCache
			var genState = TypeMapCecilAdapter.GetReleaseGenerationState (types, typeDefinitionCache, out var foundJniNativeRegistration);
```
created_at: 2025-04-23T18:31:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,6 +164,6 @@ void WriteTypeMappings (NativeCodeGenState state)
 			items.Add (item);
 		}
 
-		GeneratedBinaryTypeMaps = items.ToArray ();
+		GeneratedBinaryTypeMaps = GeneratedBinaryTypeMaps.Concat (items).ToArray ();
path: src/Xamarin.Android.Build.Tasks/Tasks/GenerateTypeMappings.cs
user: login: Copilot
body: Concatenating to GeneratedBinaryTypeMaps without reinitializing it may lead to unintended duplicate entries if this method is called multiple times. Consider resetting or properly initializing the output array before concatenation.
```suggestion
		if (GeneratedBinaryTypeMaps == null)
			GeneratedBinaryTypeMaps = Array.Empty<ITaskItem>();

		var uniqueItems = new HashSet<ITaskItem>(GeneratedBinaryTypeMaps, TaskItemComparer.Instance);
		foreach (var item in items) {
			uniqueItems.Add(item);
		}

		GeneratedBinaryTypeMaps = uniqueItems.ToArray();
```
created_at: 2025-04-23T18:31:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -157,28 +171,47 @@ void RunPipeline (AssemblyPipeline pipeline, ITaskItem source, ITaskItem destina
 			IsUserAssembly = ResolvedUserAssemblies.Any (a => a.ItemSpec == source.ItemSpec),
 		};
 
-		var changed = pipeline.Run (assembly, context);
-
-		if (changed) {
-			Log.LogDebugMessage ($"Saving modified assembly: {destination.ItemSpec}");
-			Directory.CreateDirectory (Path.GetDirectoryName (destination.ItemSpec));
-			writerParameters.WriteSymbols = assembly.MainModule.HasSymbols;
-			assembly.Write (destination.ItemSpec, writerParameters);
-		} else {
-			// If we didn't write a modified file, copy the original to the destination
-			CopyIfChanged (source, destination);
-		}
+		pipeline.Run (assembly, context);
 	}
 
-	void CopyIfChanged (ITaskItem source, ITaskItem destination)
+	class SaveChangedAssemblyStep : IAssemblyModifierPipelineStep
path: src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs
user: login: jonpryor
body: Any particular reason that this is a nested type?
created_at: 2025-04-29T20:27:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -157,28 +171,47 @@ void RunPipeline (AssemblyPipeline pipeline, ITaskItem source, ITaskItem destina
 			IsUserAssembly = ResolvedUserAssemblies.Any (a => a.ItemSpec == source.ItemSpec),
 		};
 
-		var changed = pipeline.Run (assembly, context);
-
-		if (changed) {
-			Log.LogDebugMessage ($"Saving modified assembly: {destination.ItemSpec}");
-			Directory.CreateDirectory (Path.GetDirectoryName (destination.ItemSpec));
-			writerParameters.WriteSymbols = assembly.MainModule.HasSymbols;
-			assembly.Write (destination.ItemSpec, writerParameters);
-		} else {
-			// If we didn't write a modified file, copy the original to the destination
-			CopyIfChanged (source, destination);
-		}
+		pipeline.Run (assembly, context);
 	}
 
-	void CopyIfChanged (ITaskItem source, ITaskItem destination)
+	class SaveChangedAssemblyStep : IAssemblyModifierPipelineStep
path: src/Xamarin.Android.Build.Tasks/Tasks/AssemblyModifierPipeline.cs
user: login: jpobst
body: Nope, unnested.
created_at: 2025-04-29T21:32:31Z
author_association: CONTRIBUTOR

PR comments:
user: login: jpobst
created_at: 2025-04-08T21:31:10Z
body: A potential issue here is with cross-assembly duplicate detection.

Today, we are scanning *all* JLO derived types from *all* assemblies in a single loop, so duplicate detection logic like the following finds duplicates even if they are in different assemblies:

https://github.com/dotnet/android/blob/8c741263550cc57bafc30193fa61f39b7df019bc/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapCecilAdapter.cs#L141-L152

Because the new process only scans a single assembly at a time with Cecil, it will find duplicates in the same assembly, but not duplicates that may exist in other assemblies.  An example is `Java.Lang.Object`:

```
Existing process:
- JavaName: "java/lang/Object" 
- ManagedName: "Java.Interop.JavaObject, Java.Interop" 
- SkipInJavaToManaged: "False" 
- DuplicateForJavaToManaged
  - JavaName: "java/lang/Object" 
  - ManagedName: "Java.Lang.Object, Mono.Android" 

New process:
- JavaName: "java/lang/Object" 
- ManagedName: "Java.Interop.JavaObject, Java.Interop" 
- SkipInJavaToManaged: "False" 
- DuplicateForJavaToManaged: null
```

It looks like we have 2 ordering semantics we have to preserve in replicating this logic:

- A declared type should take precedence over an "invoker" type.
- Types in `Mono.Android` should take precedence over other assemblies.

Aside from these 2 cases, which type is considered the "primary" and which type(s) are considered the "duplicates" seems to be luck of the draw.
author_association: CONTRIBUTOR
user: login: jonpryor
created_at: 2025-04-08T22:38:51Z
body: @jpobst [wrote]():

> type is considered the "primary" and which type(s) are considered the "duplicates" seems to be luck of the draw.

This is correct *and* incomplete.  If you're only going by `Java.Lang.Object.GetObject(handle)`, then you are correct.

However, most codepaths are not hitting `Object.GetObject(IntPtr)`.  Most codepaths are hitting `Object.GetObject<T>(…)` or `Object.GetObject(…, Type targetType)`, which *constrains* which type that is used.

Thus, *even if* there are duplicate types (because bindings for a `.jar` are present in multiple assemblies), it generally won't matter because the APIs you're using will dictate the actual types used.
author_association: CONTRIBUTOR
user: login: jpobst
created_at: 2025-04-21T17:55:58Z
body: > Is the new trimmer step not setup for Release mode?

Yes, it runs as part of the `<AssemblyModifierPipeline>` task, which is run by both the `LinkAssembliesNoShrink` and `_AfterILLinkAdditionalSteps` targets.

It works for Release mode when `$(AndroidEnableMarshalMethods)=false`.
author_association: CONTRIBUTOR
user: login: jpobst
created_at: 2025-04-22T01:58:13Z
body: Addressed feedback.
author_association: CONTRIBUTOR
user: login: jonpryor
created_at: 2025-04-29T23:14:53Z
body: ```markdown
Context: ea399edb872588c16b124c4b9ab2232243b93016
Context: 787a2a6238942094f4a3fc62fb5595b46efa21ce
Context: b54ec05c4041ea2cb1e70058888a81e398df1e25
COntext: b11471bc574876e148ff40757c5d273fd6fbde9e

Move the process of scanning for `Java.Lang.Object` (JLO) and
`Java.Lang.Throwable` subclasses needed for the typemap generation
task to a new `FindTypeMapObjectsStep` "linker step".

The types are serialized to new `<assembly>.typemap.xml` files that
sit beside the source assembly.  A new file was used rather than adding
more sections to the `.jlo.xml` file because this file contains a lot
more types and will likely change more often on incremental builds, and
we don't want to trigger ie: creating new Java stubs when not needed.

This file is then read in the existing `<GenerateTypeMappings/>` task
to generate the final typemap file.

The existing method of JLO scanning for typemaps is still used when
using LLVM marshal methods, as the marshal method rewriter runs after
the linker steps, and when it changes the assemblies the MVID and type
tokens no longer match the values saved in the `.typemap.xml` files.

Like ea399edb, this temporarily leaves the old typemap generation code
in place, guarded behind the `$(_AndroidJLOCheckedBuild)` flag.
This flag generates the typemap both the new and old way, and errors
out if there are differences.

Note that b54ec05c updated our "pipeline" to save assemblies after
*all* steps have been run.  However, looking for JLOs needed for
typemap generation has to be run *after* assemblies are saved.
This is because Release typemaps rely on assembly MVID and type tokens
which change when the assemblies are modified.

Update the pipeline's "modified assembly saving" logic to itself be a
pipeline step, so we can insert it *before* the new
`FindTypeMapObjectsStep` step.

*Note*: previously, we scanned for all JLO subclasses from *all*
assemblies in a single loop, so [duplicate detection logic][0] finds
duplicates even if they are in different assemblies.

Consider `java.lang.Object`, which is bound as:

  * `Java.Lang.Object, Mono.Android`
  * `Java.Interop.JavaObject, Java.Interop`
  * *and also* several other unit test types as "aliases" (b11471bc)

When processing `Java.Interop.dll`, the `TypeMapDebugEntry` fields
would be:

  - JavaName: "java/lang/Object" 
  - ManagedName: "Java.Interop.JavaObject, Java.Interop" 
  - SkipInJavaToManaged: "False" 
  - DuplicateForJavaToManaged
    - JavaName: "java/lang/Object" 
    - ManagedName: "Java.Lang.Object, Mono.Android" 

In particular, `TypeMapDebugEntry.DuplicateForJavaToManaged` mentions
`Java.Lang.Object, Mono.Android` as the "main" typemap.

*Now* scanning is performed *per-assembly*; it will find duplicates in
the same assembly, but not duplicates that may exist in other
assemblies.  The `TypeMapDebugEntry` fields when processing
`Java.Interop.dll` are instead:

  - JavaName: "java/lang/Object" 
  - ManagedName: "Java.Interop.JavaObject, Java.Interop" 
  - SkipInJavaToManaged: "False" 
  - DuplicateForJavaToManaged: null

Note that `TypeMapDebugEntry.DuplicateForJavaToManaged` is null.


We have two ordering semantics we have to preserve in replicating
the original logic:

  - A declared type should take precedence over an "invoker" type.
  - Types in `Mono.Android` have precedence over other assemblies.

Aside from these two cases, which type is considered the "primary"
and which type(s) are considered the "duplicates" seems to be luck
of the draw.

Note that "luck" only enters the picture when using the non-generic
`JniRuntime.JniValueManager.GetPeer(JniObjectReference, Type?)` API
and providing `null` for the `Type?` parameter.  Whenever a `Type`
*is provided*, or a generic wrapper such as
`Java.Lang.Object.GetObject<T>()` is used, then value returned is
*constrained* to the specified type, reducing the likelihood of
obtaining an unexpected type.

[0]: https://github.com/dotnet/android/blob/8c741263550cc57bafc30193fa61f39b7df019bc/src/Xamarin.Android.Build.Tasks/Utilities/TypeMapCecilAdapter.cs#L141-L152
```

author_association: CONTRIBUTOR