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