diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs index 48ca89f45bc..ae1e7cce9fe 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs @@ -34,4 +34,21 @@ public void Generate (IReadOnlyList peers, Stream stream, string a var emitter = new TypeMapAssemblyEmitter (_systemRuntimeVersion); emitter.Emit (model, stream, useSharedTypemapUniverse); } + + /// + /// Emits an empty typemap assembly (containing no type map entries) with the given + /// , writing it to . Used to satisfy + /// [assembly: TypeMapAssemblyTarget<T>("name")] references to per-assembly typemaps + /// that the trimmer removed (their target Java binding was unused): the runtime can still + /// Assembly.Load the stub, which contributes no mappings, instead of throwing + /// . + /// + /// Stream to write the output PE assembly to. + /// Assembly name for the generated stub. + public void GenerateEmpty (Stream stream, string assemblyName) + { + var builder = new PEAssemblyBuilder (_systemRuntimeVersion); + builder.EmitPreamble (assemblyName, assemblyName + ".dll"); + builder.WritePE (stream); + } } 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 1beb4ab9505..fbeb48e59f6 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,6 +1,8 @@ + + <_TrimmableRuntimeProviderJavaName Condition=" '$(_TrimmableRuntimeProviderJavaName)' == '' ">mono.MonoRuntimeProvider @@ -114,6 +116,27 @@ <_PostTrimDeletedCopiedJavaFiles Remove="@(_PostTrimDeletedCopiedJavaFiles)" /> + + + + + +/// Emits empty stub assemblies for per-assembly typemaps (_X.TypeMap.dll) that the root +/// _Microsoft.Android.TypeMaps assembly references via +/// [assembly: TypeMapAssemblyTarget<T>("_X.TypeMap")] but that ILLink trimmed away because +/// their target Java binding was unused. +/// +/// Those attributes carry an opaque assembly-name string rather than a metadata reference, so ILLink +/// cannot follow (or prune) them, and at startup CoreCLR's TypeMapping enumerates every +/// attribute and calls Assembly.Load on the named assembly, throwing +/// FileNotFoundException for the trimmed ones. Emitting an empty, entry-free stub for each keeps +/// Assembly.Load succeeding (the stub contributes no type map entries) without editing the +/// linked root assembly, so ILLink's reconciled assembly references are preserved. +/// +public class GenerateMissingTypeMapStubs : AndroidTask +{ + public override string TaskPrefix => "GMTS"; + + /// Directory holding every per-assembly typemap generated before trimming; defines the full referenced set. + [Required] + public string TypeMapDirectory { get; set; } = ""; + + /// Directory holding the surviving (post-trim) assemblies, e.g. the linked/ output. Stubs are written here. + [Required] + public string LinkedAssembliesDirectory { get; set; } = ""; + + /// The root typemap assembly name to skip, e.g. _Microsoft.Android.TypeMaps. + [Required] + public string RootTypeMapAssemblyName { get; set; } = ""; + + /// Used to derive the emitted assembly's System.Runtime reference version. + [Required] + public string TargetFrameworkVersion { get; set; } = ""; + + [Output] + public ITaskItem [] GeneratedStubs { get; set; } = []; + + public override bool RunTask () + { + var stubs = new List (); + if (!Directory.Exists (TypeMapDirectory) || !Directory.Exists (LinkedAssembliesDirectory)) { + Log.LogDebugMessage ($"TypeMap directory '{TypeMapDirectory}' or linked directory '{LinkedAssembliesDirectory}' not found; skipping stub generation."); + GeneratedStubs = stubs.ToArray (); + return true; + } + + var systemRuntimeVersion = ParseTargetFrameworkVersion (TargetFrameworkVersion); + var generator = new TypeMapAssemblyGenerator (systemRuntimeVersion); + + foreach (var file in Directory.EnumerateFiles (TypeMapDirectory, "_*.TypeMap.dll")) { + var name = Path.GetFileNameWithoutExtension (file); + if (string.IsNullOrEmpty (name) || string.Equals (name, RootTypeMapAssemblyName, StringComparison.Ordinal)) + continue; + var linkedPath = Path.Combine (LinkedAssembliesDirectory, name + ".dll"); + if (File.Exists (linkedPath)) + continue; // survived trimming; a real typemap already ships + + using var stream = new MemoryStream (); + generator.GenerateEmpty (stream, name); + Files.CopyIfStreamChanged (stream, linkedPath); + Log.LogDebugMessage ($"Generated empty typemap stub for trimmed assembly '{name}'."); + stubs.Add (new TaskItem (linkedPath)); + } + + if (stubs.Count > 0) + Log.LogDebugMessage ($"Generated {stubs.Count} empty typemap stub(s) for trimmed per-assembly typemaps."); + GeneratedStubs = stubs.ToArray (); + return !Log.HasLoggedErrors; + } + + 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."); + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateMissingTypeMapStubsTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateMissingTypeMapStubsTests.cs new file mode 100644 index 00000000000..0033d14a502 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateMissingTypeMapStubsTests.cs @@ -0,0 +1,87 @@ +using System.IO; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Framework; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests { + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class GenerateMissingTypeMapStubsTests : BaseTest { + + [Test] + public void Execute_TrimmedTypeMap_GeneratesLoadableStub () + { + var path = Path.Combine (Root, "temp", TestName); + var typeMapDir = Path.Combine (path, "typemap"); + var linkedDir = Path.Combine (path, "linked"); + Directory.CreateDirectory (typeMapDir); + Directory.CreateDirectory (linkedDir); + + // Pre-trim: two per-assembly typemaps plus the root were generated. + File.WriteAllText (Path.Combine (typeMapDir, "_Kept.TypeMap.dll"), "not-a-real-assembly"); + File.WriteAllText (Path.Combine (typeMapDir, "_Trimmed.TypeMap.dll"), "not-a-real-assembly"); + File.WriteAllText (Path.Combine (typeMapDir, "_Microsoft.Android.TypeMaps.dll"), "not-a-real-assembly"); + + // Post-trim (linked/): the root and one typemap survived; _Trimmed was trimmed away. + File.WriteAllText (Path.Combine (linkedDir, "_Kept.TypeMap.dll"), "survivor"); + File.WriteAllText (Path.Combine (linkedDir, "_Microsoft.Android.TypeMaps.dll"), "survivor-root"); + + var task = new GenerateMissingTypeMapStubs { + BuildEngine = new MockBuildEngine (TestContext.Out), + TypeMapDirectory = typeMapDir, + LinkedAssembliesDirectory = linkedDir, + RootTypeMapAssemblyName = "_Microsoft.Android.TypeMaps", + TargetFrameworkVersion = "v11.0", + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + + // Only the trimmed-away typemap should get a stub; the survivor and the root are left alone. + var stubs = task.GeneratedStubs.Select (i => i.ItemSpec).ToArray (); + Assert.AreEqual (1, stubs.Length, "Exactly one stub should be generated."); + var stubPath = Path.Combine (linkedDir, "_Trimmed.TypeMap.dll"); + CollectionAssert.Contains (stubs, stubPath); + FileAssert.Exists (stubPath); + + Assert.AreEqual ("survivor", File.ReadAllText (Path.Combine (linkedDir, "_Kept.TypeMap.dll")), + "A surviving typemap must not be overwritten."); + Assert.AreEqual ("survivor-root", File.ReadAllText (Path.Combine (linkedDir, "_Microsoft.Android.TypeMaps.dll")), + "The root typemap must not be overwritten."); + + // The stub must be a valid managed PE assembly named after the trimmed typemap. + using var stubStream = File.OpenRead (stubPath); + using var peReader = new PEReader (stubStream); + Assert.IsTrue (peReader.HasMetadata, "Stub should be a managed PE assembly."); + var reader = peReader.GetMetadataReader (); + var assemblyName = reader.GetString (reader.GetAssemblyDefinition ().Name); + Assert.AreEqual ("_Trimmed.TypeMap", assemblyName, "Stub assembly name should match the trimmed typemap."); + } + + [Test] + public void Execute_NothingTrimmed_GeneratesNoStubs () + { + var path = Path.Combine (Root, "temp", TestName); + var typeMapDir = Path.Combine (path, "typemap"); + var linkedDir = Path.Combine (path, "linked"); + Directory.CreateDirectory (typeMapDir); + Directory.CreateDirectory (linkedDir); + + File.WriteAllText (Path.Combine (typeMapDir, "_Kept.TypeMap.dll"), "not-a-real-assembly"); + File.WriteAllText (Path.Combine (linkedDir, "_Kept.TypeMap.dll"), "survivor"); + + var task = new GenerateMissingTypeMapStubs { + BuildEngine = new MockBuildEngine (TestContext.Out), + TypeMapDirectory = typeMapDir, + LinkedAssembliesDirectory = linkedDir, + RootTypeMapAssemblyName = "_Microsoft.Android.TypeMaps", + TargetFrameworkVersion = "v11.0", + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + Assert.IsEmpty (task.GeneratedStubs, "No stubs should be generated when nothing was trimmed."); + } + } +}