diff --git a/build-tools/installers/create-installers.targets b/build-tools/installers/create-installers.targets
index 222ce138ecd..c2cf2524afc 100644
--- a/build-tools/installers/create-installers.targets
+++ b/build-tools/installers/create-installers.targets
@@ -153,6 +153,8 @@
<_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Microsoft.Android.Sdk.Bindings.Gradle.targets" />
<_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Xamarin.Android.Build.Tasks.dll" />
<_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Xamarin.Android.Build.Tasks.pdb" />
+ <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Microsoft.Android.Sdk.TrimmableTypeMap.dll" />
+ <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Microsoft.Android.Sdk.TrimmableTypeMap.pdb" />
<_MSBuildFiles Include="@(_LocalizationLanguages->'$(MicrosoftAndroidSdkOutDir)%(Identity)\Microsoft.Android.Build.BaseTasks.resources.dll')" />
<_MSBuildFiles Include="@(_LocalizationLanguages->'$(MicrosoftAndroidSdkOutDir)%(Identity)\Xamarin.Android.Build.Tasks.resources.dll')" />
<_MSBuildFiles Include="@(_LocalizationLanguages->'$(MicrosoftAndroidSdkOutDir)%(Identity)\Xamarin.Android.Tools.AndroidSdk.resources.dll')" />
diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs
index b4df508b1a2..bfb4e6a1bbc 100644
--- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs
+++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs
@@ -39,7 +39,7 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap;
/// }
///
///
-sealed class JcwJavaSourceGenerator
+public sealed class JcwJavaSourceGenerator
{
///
/// Generates .java source files for all ACW types and writes them to the output directory.
diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/RootTypeMapAssemblyGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/RootTypeMapAssemblyGenerator.cs
index 14b49cfe986..6fa3e7a648b 100644
--- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/RootTypeMapAssemblyGenerator.cs
+++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/RootTypeMapAssemblyGenerator.cs
@@ -19,7 +19,7 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap;
/// [assembly: TypeMapAssemblyTarget<Java.Lang.Object>("_MyApp.TypeMap")]
///
///
-sealed class RootTypeMapAssemblyGenerator
+public sealed class RootTypeMapAssemblyGenerator
{
const string DefaultAssemblyName = "_Microsoft.Android.TypeMaps";
diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs
index f6586218d6a..34e84f64459 100644
--- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs
+++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs
@@ -8,7 +8,7 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap;
/// High-level API: builds the model from peers, then emits the PE assembly.
/// Composes + .
///
-sealed class TypeMapAssemblyGenerator
+public sealed class TypeMapAssemblyGenerator
{
readonly Version _systemRuntimeVersion;
diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Microsoft.Android.Sdk.TrimmableTypeMap.csproj b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Microsoft.Android.Sdk.TrimmableTypeMap.csproj
index bf04c5efde8..249bdc8def1 100644
--- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Microsoft.Android.Sdk.TrimmableTypeMap.csproj
+++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Microsoft.Android.Sdk.TrimmableTypeMap.csproj
@@ -6,12 +6,14 @@
enable
Nullable
Microsoft.Android.Sdk.TrimmableTypeMap
+ true
+ ..\..\product.snk
-
+
diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs
index 947912841e7..de3e1c9621d 100644
--- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs
+++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs
@@ -8,7 +8,7 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap;
/// Contains all data needed by downstream generators (TypeMap IL, UCO wrappers, JCW Java sources).
/// Generators consume this data model — they never touch PEReader/MetadataReader.
///
-sealed record JavaPeerInfo
+public sealed record JavaPeerInfo
{
///
/// JNI type name, e.g., "android/app/Activity".
@@ -116,7 +116,7 @@ sealed record JavaPeerInfo
/// Contains all data needed to generate a UCO wrapper, a JCW native declaration,
/// and a RegisterNatives call.
///
-sealed record MarshalMethodInfo
+public sealed record MarshalMethodInfo
{
///
/// JNI method name, e.g., "onCreate".
@@ -200,7 +200,7 @@ sealed record MarshalMethodInfo
///
/// Describes a JNI parameter for UCO method generation.
///
-sealed record JniParameterInfo
+public sealed record JniParameterInfo
{
///
/// JNI type descriptor, e.g., "Landroid/os/Bundle;", "I", "Z".
@@ -216,7 +216,7 @@ sealed record JniParameterInfo
///
/// Describes a Java constructor to emit in the JCW .java source file.
///
-sealed record JavaConstructorInfo
+public sealed record JavaConstructorInfo
{
///
/// JNI constructor signature, e.g., "(Landroid/content/Context;)V".
@@ -239,7 +239,7 @@ sealed record JavaConstructorInfo
/// Describes a Java field from an [ExportField] attribute.
/// The field is initialized by calling the annotated method.
///
-sealed record JavaFieldInfo
+public sealed record JavaFieldInfo
{
///
/// Java field name, e.g., "STATIC_INSTANCE".
@@ -270,7 +270,7 @@ sealed record JavaFieldInfo
///
/// Describes how to call the activation constructor for a Java peer type.
///
-sealed record ActivationCtorInfo
+public sealed record ActivationCtorInfo
{
///
/// The type that declares the activation constructor.
@@ -289,7 +289,7 @@ sealed record ActivationCtorInfo
public required ActivationCtorStyle Style { get; init; }
}
-enum ActivationCtorStyle
+public enum ActivationCtorStyle
{
///
/// Xamarin.Android style: (IntPtr handle, JniHandleOwnership transfer)
diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs
index 330c2c8f7e7..60b97feac0e 100644
--- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs
+++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs
@@ -14,7 +14,7 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap;
/// Phase 1: Build per-assembly indices (fast, O(1) lookups)
/// Phase 2: Analyze types using cached indices
///
-sealed class JavaPeerScanner : IDisposable
+public sealed class JavaPeerScanner : IDisposable
{
readonly Dictionary assemblyCache = new (StringComparer.Ordinal);
readonly Dictionary<(string typeName, string assemblyName), ActivationCtorInfo> activationCtorCache = new ();
diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets
index d13bdde64da..a06b771e122 100644
--- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets
+++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets
@@ -1,5 +1,16 @@
-
+
+
+
+
+ <_ResolvedAssemblies Include="@(_GeneratedTypeMapAssemblies)" />
+
+
+
diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets
index bd411a26749..eabaffb2745 100644
--- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets
+++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets
@@ -1,4 +1,17 @@
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets
index 51cae6aab85..6bb7062d75b 100644
--- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets
+++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets
@@ -1,12 +1,27 @@
+ Generates per-assembly TypeMap .dll assemblies, a root _Microsoft.Android.TypeMaps.dll,
+ and JCW .java source files with registerNatives. -->
+
+
+
+ <_TypeMapAssemblyName>_Microsoft.Android.TypeMaps
+ <_TypeMapOutputDirectory>$(IntermediateOutputPath)typemap\
+ <_TypeMapJavaOutputDirectory>$(IntermediateOutputPath)typemap\java
+
+
+
+
+
+
+
@@ -18,10 +33,25 @@
from the Cecil-based GenerateJavaStubs task. Extracting them into a shared target
requires decoupling from NativeCodeGenState first. See #10807. -->
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs
new file mode 100644
index 00000000000..f04d919153d
--- /dev/null
+++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs
@@ -0,0 +1,181 @@
+#nullable enable
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.Android.Build.Tasks;
+using Microsoft.Android.Sdk.TrimmableTypeMap;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace Xamarin.Android.Tasks;
+
+///
+/// Generates trimmable TypeMap assemblies and JCW Java source files from resolved assemblies.
+/// Runs before the trimmer to produce per-assembly typemap .dll files and a root
+/// _Microsoft.Android.TypeMaps.dll, plus .java files for ACW types with registerNatives.
+///
+public class GenerateTrimmableTypeMap : AndroidTask
+{
+ public override string TaskPrefix => "GTT";
+
+ [Required]
+ public ITaskItem [] ResolvedAssemblies { get; set; } = [];
+
+ [Required]
+ public string OutputDirectory { get; set; } = "";
+
+ [Required]
+ public string JavaSourceOutputDirectory { get; set; } = "";
+
+ ///
+ /// The .NET target framework version (e.g., "v11.0"). Used to set the System.Runtime
+ /// assembly reference version in generated typemap assemblies.
+ ///
+ [Required]
+ public string TargetFrameworkVersion { get; set; } = "";
+
+ [Output]
+ public ITaskItem []? GeneratedAssemblies { get; set; }
+
+ [Output]
+ public ITaskItem []? GeneratedJavaFiles { get; set; }
+
+ public override bool RunTask ()
+ {
+ var systemRuntimeVersion = ParseTargetFrameworkVersion (TargetFrameworkVersion);
+ var assemblyPaths = GetJavaInteropAssemblyPaths (ResolvedAssemblies);
+
+ Directory.CreateDirectory (OutputDirectory);
+ Directory.CreateDirectory (JavaSourceOutputDirectory);
+
+ var allPeers = ScanAssemblies (assemblyPaths);
+ if (allPeers.Count == 0) {
+ Log.LogDebugMessage ("No Java peer types found, skipping typemap generation.");
+ return !Log.HasLoggedErrors;
+ }
+
+ GeneratedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, assemblyPaths);
+ GeneratedJavaFiles = GenerateJcwJavaSources (allPeers);
+
+ return !Log.HasLoggedErrors;
+ }
+
+ // Future optimization: the scanner currently scans all assemblies on every run.
+ // For incremental builds, we could:
+ // 1. Add a Scan(allPaths, changedPaths) overload that only produces JavaPeerInfo
+ // for changed assemblies while still indexing all assemblies for cross-assembly
+ // resolution (base types, interfaces, activation ctors).
+ // 2. Cache scan results per assembly to skip PE I/O entirely for unchanged assemblies.
+ // Both require profiling to determine if they meaningfully improve build times.
+ List ScanAssemblies (IReadOnlyList assemblyPaths)
+ {
+ using var scanner = new JavaPeerScanner ();
+ var peers = scanner.Scan (assemblyPaths);
+ Log.LogDebugMessage ($"Scanned {assemblyPaths.Count} assemblies, found {peers.Count} Java peer types.");
+ return peers;
+ }
+
+ ITaskItem [] GenerateTypeMapAssemblies (List allPeers, Version systemRuntimeVersion,
+ IReadOnlyList assemblyPaths)
+ {
+ // Build a map from assembly name → source path for timestamp comparison
+ var sourcePathByName = new Dictionary (StringComparer.Ordinal);
+ foreach (var path in assemblyPaths) {
+ var name = Path.GetFileNameWithoutExtension (path);
+ sourcePathByName [name] = path;
+ }
+
+ var peersByAssembly = allPeers
+ .GroupBy (p => p.AssemblyName, StringComparer.Ordinal)
+ .OrderBy (g => g.Key, StringComparer.Ordinal);
+
+ var generatedAssemblies = new List ();
+ var perAssemblyNames = new List ();
+ var generator = new TypeMapAssemblyGenerator (systemRuntimeVersion);
+ bool anyRegenerated = false;
+
+ foreach (var group in peersByAssembly) {
+ string assemblyName = $"_{group.Key}.TypeMap";
+ string outputPath = Path.Combine (OutputDirectory, assemblyName + ".dll");
+ perAssemblyNames.Add (assemblyName);
+
+ if (IsUpToDate (outputPath, group.Key, sourcePathByName)) {
+ Log.LogDebugMessage ($" {assemblyName}: up to date, skipping");
+ generatedAssemblies.Add (new TaskItem (outputPath));
+ continue;
+ }
+
+ generator.Generate (group.ToList (), outputPath, assemblyName);
+ generatedAssemblies.Add (new TaskItem (outputPath));
+ anyRegenerated = true;
+
+ Log.LogDebugMessage ($" {assemblyName}: {group.Count ()} types");
+ }
+
+ // Root assembly references all per-assembly typemaps — regenerate if any changed
+ string rootOutputPath = Path.Combine (OutputDirectory, "_Microsoft.Android.TypeMaps.dll");
+ if (anyRegenerated || !File.Exists (rootOutputPath)) {
+ var rootGenerator = new RootTypeMapAssemblyGenerator (systemRuntimeVersion);
+ rootGenerator.Generate (perAssemblyNames, rootOutputPath);
+ Log.LogDebugMessage ($" Root: {perAssemblyNames.Count} per-assembly refs");
+ } else {
+ Log.LogDebugMessage ($" Root: up to date, skipping");
+ }
+ generatedAssemblies.Add (new TaskItem (rootOutputPath));
+
+ Log.LogDebugMessage ($"Generated {generatedAssemblies.Count} typemap assemblies.");
+ return generatedAssemblies.ToArray ();
+ }
+
+ static bool IsUpToDate (string outputPath, string assemblyName, Dictionary sourcePathByName)
+ {
+ if (!File.Exists (outputPath)) {
+ return false;
+ }
+ if (!sourcePathByName.TryGetValue (assemblyName, out var sourcePath)) {
+ return false;
+ }
+ return File.GetLastWriteTimeUtc (outputPath) >= File.GetLastWriteTimeUtc (sourcePath);
+ }
+
+ ITaskItem [] GenerateJcwJavaSources (List allPeers)
+ {
+ var jcwGenerator = new JcwJavaSourceGenerator ();
+ var files = jcwGenerator.Generate (allPeers, JavaSourceOutputDirectory);
+ Log.LogDebugMessage ($"Generated {files.Count} JCW Java source files.");
+
+ var items = new ITaskItem [files.Count];
+ for (int i = 0; i < files.Count; i++) {
+ items [i] = new TaskItem (files [i]);
+ }
+ return items;
+ }
+
+ static Version ParseTargetFrameworkVersion (string tfv)
+ {
+ if (tfv.Length > 0 && (tfv [0] == 'v' || tfv [0] == 'V')) {
+ tfv = tfv.Substring (1);
+ }
+ if (Version.TryParse (tfv, out var version)) {
+ return version;
+ }
+ throw new ArgumentException ($"Cannot parse TargetFrameworkVersion '{tfv}' as a Version.");
+ }
+
+ ///
+ /// Filters resolved assemblies to only those that reference Mono.Android or Java.Interop
+ /// (i.e., assemblies that could contain [Register] types). Skips BCL assemblies.
+ ///
+ static IReadOnlyList GetJavaInteropAssemblyPaths (ITaskItem [] items)
+ {
+ var paths = new List (items.Length);
+ foreach (var item in items) {
+ if (MonoAndroidHelper.IsMonoAndroidAssembly (item)) {
+ paths.Add (item.ItemSpec);
+ }
+ }
+ return paths;
+ }
+}
diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs
new file mode 100644
index 00000000000..b55c9acf734
--- /dev/null
+++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs
@@ -0,0 +1,233 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+using NUnit.Framework;
+using Xamarin.Android.Tasks;
+using Xamarin.ProjectTools;
+
+namespace Xamarin.Android.Build.Tests {
+ [TestFixture]
+ [Parallelizable (ParallelScope.Children)]
+ public class GenerateTrimmableTypeMapTests : BaseTest {
+
+ [Test]
+ public void Execute_EmptyAssemblyList_Succeeds ()
+ {
+ var path = Path.Combine ("temp", TestName);
+ var outputDir = Path.Combine (Root, path, "typemap");
+ var javaDir = Path.Combine (Root, path, "java");
+
+ var task = CreateTask ([], outputDir, javaDir);
+
+ Assert.IsTrue (task.Execute (), "Task should succeed with empty assembly list.");
+ Assert.IsNull (task.GeneratedAssemblies);
+ Assert.IsNull (task.GeneratedJavaFiles);
+ }
+
+ [Test]
+ public void Execute_WithMonoAndroid_ProducesOutputs ()
+ {
+ var path = Path.Combine ("temp", TestName);
+ var outputDir = Path.Combine (Root, path, "typemap");
+ var javaDir = Path.Combine (Root, path, "java");
+
+ var monoAndroidItem = FindMonoAndroidDll ();
+ if (monoAndroidItem is null) {
+ Assert.Ignore ("Mono.Android.dll not found; skipping.");
+ return;
+ }
+
+ var task = CreateTask (new [] { monoAndroidItem }, outputDir, javaDir);
+
+ Assert.IsTrue (task.Execute (), "Task should succeed.");
+ Assert.IsNotNull (task.GeneratedAssemblies);
+ Assert.IsNotEmpty (task.GeneratedAssemblies);
+
+ var assemblyPaths = task.GeneratedAssemblies.Select (i => i.ItemSpec).ToList ();
+ Assert.IsTrue (assemblyPaths.Any (p => p.Contains ("_Microsoft.Android.TypeMaps.dll")),
+ "Should produce root _Microsoft.Android.TypeMaps.dll");
+ Assert.IsTrue (assemblyPaths.Any (p => p.Contains ("_Mono.Android.TypeMap.dll")),
+ "Should produce _Mono.Android.TypeMap.dll");
+
+ foreach (var assembly in task.GeneratedAssemblies) {
+ FileAssert.Exists (assembly.ItemSpec);
+ }
+ }
+
+ [Test]
+ public void Execute_SecondRun_SkipsUpToDateAssemblies ()
+ {
+ var path = Path.Combine ("temp", TestName);
+ var outputDir = Path.Combine (Root, path, "typemap");
+ var javaDir = Path.Combine (Root, path, "java");
+
+ var monoAndroidItem = FindMonoAndroidDll ();
+ if (monoAndroidItem is null) {
+ Assert.Ignore ("Mono.Android.dll not found; skipping.");
+ return;
+ }
+
+ var assemblies = new [] { monoAndroidItem };
+
+ // First run: generates everything
+ var task1 = CreateTask (assemblies, outputDir, javaDir);
+ Assert.IsTrue (task1.Execute (), "First run should succeed.");
+
+ var typeMapPath = task1.GeneratedAssemblies
+ .Select (i => i.ItemSpec)
+ .First (p => p.Contains ("_Mono.Android.TypeMap.dll"));
+ var firstWriteTime = File.GetLastWriteTimeUtc (typeMapPath);
+
+ // Wait to ensure timestamp difference is detectable
+ Thread.Sleep (100);
+
+ // Second run: same inputs, outputs should be skipped (not rewritten)
+ var messages = new List ();
+ var task2 = CreateTask (assemblies, outputDir, javaDir, messages);
+ Assert.IsTrue (task2.Execute (), "Second run should succeed.");
+
+ var secondWriteTime = File.GetLastWriteTimeUtc (typeMapPath);
+ Assert.AreEqual (firstWriteTime, secondWriteTime,
+ "Typemap assembly should NOT be rewritten when source hasn't changed.");
+
+ Assert.IsTrue (messages.Any (m => m.Message.Contains ("up to date")),
+ "Should log 'up to date' for skipped assemblies.");
+ }
+
+ [Test]
+ public void Execute_SourceTouched_RegeneratesOnlyChangedAssembly ()
+ {
+ var path = Path.Combine ("temp", TestName);
+ var outputDir = Path.Combine (Root, path, "typemap");
+ var javaDir = Path.Combine (Root, path, "java");
+
+ var monoAndroidItem = FindMonoAndroidDll ();
+ if (monoAndroidItem is null) {
+ Assert.Ignore ("Mono.Android.dll not found; skipping.");
+ return;
+ }
+
+ // Copy Mono.Android.dll to a temp location so we can touch it
+ var tempDir = Path.Combine (Root, path, "assemblies");
+ Directory.CreateDirectory (tempDir);
+ var tempAssemblyPath = Path.Combine (tempDir, "Mono.Android.dll");
+ File.Copy (monoAndroidItem.ItemSpec, tempAssemblyPath, true);
+
+ var tempItem = new TaskItem (tempAssemblyPath);
+ tempItem.SetMetadata ("HasMonoAndroidReference", "True");
+ var assemblies = new [] { tempItem };
+
+ // First run
+ var task1 = CreateTask (assemblies, outputDir, javaDir);
+ Assert.IsTrue (task1.Execute (), "First run should succeed.");
+
+ var typeMapPath = task1.GeneratedAssemblies
+ .Select (i => i.ItemSpec)
+ .First (p => p.Contains ("_Mono.Android.TypeMap.dll"));
+ var firstWriteTime = File.GetLastWriteTimeUtc (typeMapPath);
+
+ // Touch the source assembly to simulate a change
+ Thread.Sleep (100);
+ File.SetLastWriteTimeUtc (tempAssemblyPath, DateTime.UtcNow);
+
+ // Second run: source is newer → should regenerate
+ var tempItem2 = new TaskItem (tempAssemblyPath);
+ tempItem2.SetMetadata ("HasMonoAndroidReference", "True");
+ var task2 = CreateTask (new [] { tempItem2 }, outputDir, javaDir);
+ Assert.IsTrue (task2.Execute (), "Second run should succeed.");
+
+ var secondWriteTime = File.GetLastWriteTimeUtc (typeMapPath);
+ Assert.Greater (secondWriteTime, firstWriteTime,
+ "Typemap assembly should be regenerated when source is touched.");
+ }
+
+ [Test]
+ public void Execute_InvalidTargetFrameworkVersion_Fails ()
+ {
+ var path = Path.Combine ("temp", TestName);
+ var outputDir = Path.Combine (Root, path, "typemap");
+ var javaDir = Path.Combine (Root, path, "java");
+
+ var errors = new List ();
+ var task = new GenerateTrimmableTypeMap {
+ BuildEngine = new MockBuildEngine (TestContext.Out, errors),
+ ResolvedAssemblies = [],
+ OutputDirectory = outputDir,
+ JavaSourceOutputDirectory = javaDir,
+ TargetFrameworkVersion = "not-a-version",
+ };
+
+ Assert.IsFalse (task.Execute (), "Task should fail with invalid TargetFrameworkVersion.");
+ Assert.IsNotEmpty (errors, "Should have logged an error.");
+ }
+
+ [TestCase ("v11.0")]
+ [TestCase ("v10.0")]
+ [TestCase ("11.0")]
+ public void Execute_ParsesTargetFrameworkVersion (string tfv)
+ {
+ var path = Path.Combine ("temp", TestName);
+ var outputDir = Path.Combine (Root, path, "typemap");
+ var javaDir = Path.Combine (Root, path, "java");
+
+ var task = CreateTask ([], outputDir, javaDir, tfv: tfv);
+ Assert.IsTrue (task.Execute (), $"Task should succeed with TargetFrameworkVersion='{tfv}'.");
+ }
+
+ [Test]
+ public void Execute_NoPeersFound_ReturnsEmpty ()
+ {
+ var path = Path.Combine ("temp", TestName);
+ var outputDir = Path.Combine (Root, path, "typemap");
+ var javaDir = Path.Combine (Root, path, "java");
+
+ // Use a real assembly that has no [Register] types
+ var testAssemblyDir = Path.GetDirectoryName (GetType ().Assembly.Location)!;
+ var nunitDll = Path.Combine (testAssemblyDir, "nunit.framework.dll");
+ if (!File.Exists (nunitDll)) {
+ Assert.Ignore ("nunit.framework.dll not found; skipping.");
+ return;
+ }
+
+ var messages = new List ();
+ var task = CreateTask (new [] { new TaskItem (nunitDll) }, outputDir, javaDir, messages);
+
+ Assert.IsTrue (task.Execute (), "Task should succeed with no peer types.");
+ Assert.IsNull (task.GeneratedAssemblies);
+ Assert.IsNull (task.GeneratedJavaFiles);
+ Assert.IsTrue (messages.Any (m => m.Message.Contains ("No Java peer types found")),
+ "Should log that no peers were found.");
+ }
+
+ GenerateTrimmableTypeMap CreateTask (ITaskItem [] assemblies, string outputDir, string javaDir,
+ IList? messages = null, string tfv = "v11.0")
+ {
+ return new GenerateTrimmableTypeMap {
+ BuildEngine = new MockBuildEngine (TestContext.Out, messages: messages),
+ ResolvedAssemblies = assemblies,
+ OutputDirectory = outputDir,
+ JavaSourceOutputDirectory = javaDir,
+ TargetFrameworkVersion = tfv,
+ };
+ }
+
+ static ITaskItem? FindMonoAndroidDll ()
+ {
+ var frameworkDir = TestEnvironment.MonoAndroidFrameworkDirectory;
+ if (string.IsNullOrEmpty (frameworkDir) || !Directory.Exists (frameworkDir)) {
+ return null;
+ }
+ var path = Path.Combine (frameworkDir, "Mono.Android.dll");
+ if (!File.Exists (path)) {
+ return null;
+ }
+ var item = new TaskItem (path);
+ item.SetMetadata ("HasMonoAndroidReference", "True");
+ return item;
+ }
+ }
+}
diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs
new file mode 100644
index 00000000000..8318413eeb2
--- /dev/null
+++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs
@@ -0,0 +1,52 @@
+using NUnit.Framework;
+using Xamarin.Android.Tasks;
+using Xamarin.ProjectTools;
+
+namespace Xamarin.Android.Build.Tests {
+ [TestFixture]
+ [Category ("Node-2")]
+ public class TrimmableTypeMapBuildTests : BaseTest {
+
+ [Test]
+ public void Build_WithTrimmableTypeMap_Succeeds ()
+ {
+ var proj = new XamarinAndroidApplicationProject ();
+ proj.SetRuntime (AndroidRuntime.CoreCLR);
+ proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable");
+
+ // Full Build will fail downstream (manifest generation not yet implemented for trimmable path),
+ // but _GenerateJavaStubs runs and completes before the failure point.
+ using var builder = CreateApkBuilder ();
+ builder.ThrowOnBuildFailure = false;
+ builder.Build (proj);
+
+ // Verify _GenerateJavaStubs ran by checking typemap outputs exist
+ var intermediateDir = builder.Output.GetIntermediaryPath ("typemap");
+ DirectoryAssert.Exists (intermediateDir);
+ }
+
+ [Test]
+ public void Build_WithTrimmableTypeMap_IncrementalBuild ()
+ {
+ var proj = new XamarinAndroidApplicationProject ();
+ proj.SetRuntime (AndroidRuntime.CoreCLR);
+ proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable");
+
+ // Full Build will fail downstream (manifest generation not yet implemented for trimmable path),
+ // but _GenerateJavaStubs runs and completes before the failure point.
+ using var builder = CreateApkBuilder ();
+ builder.ThrowOnBuildFailure = false;
+ builder.Build (proj);
+
+ // Verify _GenerateJavaStubs ran on the first build
+ var intermediateDir = builder.Output.GetIntermediaryPath ("typemap");
+ DirectoryAssert.Exists (intermediateDir);
+
+ // Second build with no changes — _GenerateJavaStubs should be skipped
+ builder.Build (proj);
+ Assert.IsTrue (
+ builder.Output.IsTargetSkipped ("_GenerateJavaStubs"),
+ "_GenerateJavaStubs should be skipped on incremental build.");
+ }
+ }
+}
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 79f6e450ae3..577a607b4d7 100644
--- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj
+++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj
@@ -229,6 +229,7 @@
+
diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj
index 6370a77e680..212fa1be735 100644
--- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj
+++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj
@@ -6,6 +6,8 @@
enable
false
Microsoft.Android.Sdk.TrimmableTypeMap.Tests
+ true
+ ..\..\product.snk