diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs new file mode 100644 index 00000000000..8d4005d6390 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs @@ -0,0 +1,53 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +/// +/// Generates per-assembly acw-map.txt content from records. +/// The acw-map.txt file maps managed type names to Java/ACW type names, consumed by +/// _ConvertCustomView to fix up custom view names in layout XMLs. +/// +/// Format per type (3 lines): +/// Line 1: PartialAssemblyQualifiedName;JavaKey (always written) +/// Line 2: ManagedKey;JavaKey +/// Line 3: CompatJniName;JavaKey +/// +/// Java keys use dots (not slashes): e.g., "android.app.Activity" +/// +public static class AcwMapWriter +{ + /// + /// Writes acw-map lines for the given to the . + /// Per-assembly maps write all 3 line variants unconditionally. No conflict detection + /// is performed — the merged acw-map.txt is a simple concatenation consumed by + /// LoadMapFile which uses first-entry-wins semantics for duplicate keys. + /// + public static void Write (TextWriter writer, IEnumerable peers) + { + foreach (var peer in peers.OrderBy (p => p.ManagedTypeName, StringComparer.Ordinal)) { + string javaKey = peer.JavaName.Replace ('/', '.'); + string managedKey = peer.ManagedTypeName; + string partialAsmQualifiedName = $"{managedKey}, {peer.AssemblyName}"; + string compatJniName = peer.CompatJniName.Replace ('/', '.'); + + // Line 1: PartialAssemblyQualifiedName;JavaKey + writer.Write (partialAsmQualifiedName); + writer.Write (';'); + writer.WriteLine (javaKey); + + // Line 2: ManagedKey;JavaKey + writer.Write (managedKey); + writer.Write (';'); + writer.WriteLine (javaKey); + + // Line 3: CompatJniName;JavaKey + writer.Write (compatJniName); + writer.Write (';'); + writer.WriteLine (javaKey); + } + } +} 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 6bb7062d75b..b056316d7db 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,6 +1,6 @@ + JCW .java source files with registerNatives, and per-assembly acw-map files. --> @@ -14,6 +14,7 @@ <_TypeMapAssemblyName>_Microsoft.Android.TypeMaps <_TypeMapOutputDirectory>$(IntermediateOutputPath)typemap\ <_TypeMapJavaOutputDirectory>$(IntermediateOutputPath)typemap\java + <_PerAssemblyAcwMapDirectory>$(IntermediateOutputPath)acw-maps\ @@ -41,18 +42,64 @@ ResolvedAssemblies="@(_ResolvedAssemblies)" OutputDirectory="$(_TypeMapOutputDirectory)" JavaSourceOutputDirectory="$(_TypeMapJavaOutputDirectory)" + AcwMapDirectory="$(_PerAssemblyAcwMapDirectory)" TargetFrameworkVersion="$(TargetFrameworkVersion)"> + + + + + + <_PerAssemblyAcwMapFiles Remove="@(_PerAssemblyAcwMapFiles)" /> + <_PerAssemblyAcwMapFiles Include="$(_PerAssemblyAcwMapDirectory)*.txt" /> + + + + + + + + + + + + + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index f04d919153d..ecb6529357f 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -12,9 +12,9 @@ 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. +/// Generates trimmable TypeMap assemblies, JCW Java source files, and per-assembly +/// acw-map files from resolved assemblies. The acw-map files are later merged into +/// a single acw-map.txt consumed by _ConvertCustomView for layout XML fixups. /// public class GenerateTrimmableTypeMap : AndroidTask { @@ -29,6 +29,12 @@ public class GenerateTrimmableTypeMap : AndroidTask [Required] public string JavaSourceOutputDirectory { get; set; } = ""; + /// + /// Directory for per-assembly acw-map.{AssemblyName}.txt files. + /// + [Required] + public string AcwMapDirectory { get; set; } = ""; + /// /// The .NET target framework version (e.g., "v11.0"). Used to set the System.Runtime /// assembly reference version in generated typemap assemblies. @@ -42,6 +48,14 @@ public class GenerateTrimmableTypeMap : AndroidTask [Output] public ITaskItem []? GeneratedJavaFiles { get; set; } + /// + /// Per-assembly acw-map files produced during scanning. Each file contains + /// three lines per type: PartialAssemblyQualifiedName;JavaKey, + /// ManagedKey;JavaKey, and CompatJniName;JavaKey. + /// + [Output] + public ITaskItem []? PerAssemblyAcwMapFiles { get; set; } + public override bool RunTask () { var systemRuntimeVersion = ParseTargetFrameworkVersion (TargetFrameworkVersion); @@ -49,6 +63,7 @@ public override bool RunTask () Directory.CreateDirectory (OutputDirectory); Directory.CreateDirectory (JavaSourceOutputDirectory); + Directory.CreateDirectory (AcwMapDirectory); var allPeers = ScanAssemblies (assemblyPaths); if (allPeers.Count == 0) { @@ -58,6 +73,7 @@ public override bool RunTask () GeneratedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, assemblyPaths); GeneratedJavaFiles = GenerateJcwJavaSources (allPeers); + PerAssemblyAcwMapFiles = GeneratePerAssemblyAcwMaps (allPeers); return !Log.HasLoggedErrors; } @@ -153,6 +169,38 @@ ITaskItem [] GenerateJcwJavaSources (List allPeers) return items; } + ITaskItem [] GeneratePerAssemblyAcwMaps (List allPeers) + { + var peersByAssembly = allPeers + .GroupBy (p => p.AssemblyName, StringComparer.Ordinal) + .OrderBy (g => g.Key, StringComparer.Ordinal); + + var outputFiles = new List (); + + foreach (var group in peersByAssembly) { + var peers = group.ToList (); + string outputFile = Path.Combine (AcwMapDirectory, $"acw-map.{group.Key}.txt"); + + bool written; + using (var sw = MemoryStreamPool.Shared.CreateStreamWriter ()) { + AcwMapWriter.Write (sw, peers); + sw.Flush (); + written = Files.CopyIfStreamChanged (sw.BaseStream, outputFile); + } + + Log.LogDebugMessage (written + ? $" acw-map.{group.Key}.txt: {peers.Count} types" + : $" acw-map.{group.Key}.txt: unchanged"); + + var item = new TaskItem (outputFile); + item.SetMetadata ("AssemblyName", group.Key); + outputFiles.Add (item); + } + + Log.LogDebugMessage ($"Generated {outputFiles.Count} per-assembly ACW map files."); + return outputFiles.ToArray (); + } + static Version ParseTargetFrameworkVersion (string tfv) { if (tfv.Length > 0 && (tfv [0] == 'v' || tfv [0] == 'V')) { 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 index b55c9acf734..3c4665ae958 100644 --- 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 @@ -158,6 +158,7 @@ public void Execute_InvalidTargetFrameworkVersion_Fails () ResolvedAssemblies = [], OutputDirectory = outputDir, JavaSourceOutputDirectory = javaDir, + AcwMapDirectory = Path.Combine (Root, path, "acw-maps"), TargetFrameworkVersion = "not-a-version", }; @@ -211,6 +212,7 @@ GenerateTrimmableTypeMap CreateTask (ITaskItem [] assemblies, string outputDir, ResolvedAssemblies = assemblies, OutputDirectory = outputDir, JavaSourceOutputDirectory = javaDir, + AcwMapDirectory = Path.Combine (outputDir, "..", "acw-maps"), TargetFrameworkVersion = tfv, }; } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs new file mode 100644 index 00000000000..0f2945402de --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class AcwMapWriterTests : FixtureTestBase +{ + static string [] WriteLines (IEnumerable peers) + { + using var writer = new StringWriter (); + AcwMapWriter.Write (writer, peers); + var output = writer.ToString ().TrimEnd (); + if (output.Length == 0) { + return Array.Empty (); + } + return output.Split (new [] { Environment.NewLine }, StringSplitOptions.None); + } + + [Fact] + public void Write_SingleMcwType_ProducesThreeLines () + { + var peer = MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android"); + + var lines = WriteLines (new [] { peer }); + + Assert.Equal (3, lines.Length); + // Line 1: PartialAssemblyQualifiedName;JavaKey + Assert.Equal ("Android.App.Activity, Mono.Android;android.app.Activity", lines [0]); + // Line 2: ManagedKey;JavaKey + Assert.Equal ("Android.App.Activity;android.app.Activity", lines [1]); + // Line 3: CompatJniName;JavaKey + Assert.Equal ("android.app.Activity;android.app.Activity", lines [2]); + } + + [Fact] + public void Write_UserType_SlashesConvertedToDots () + { + var peer = new JavaPeerInfo { + JavaName = "crc64abcdef/MyActivity", + CompatJniName = "my.namespace/MyActivity", + ManagedTypeName = "My.Namespace.MyActivity", + ManagedTypeNamespace = "My.Namespace", + ManagedTypeShortName = "MyActivity", + AssemblyName = "MyApp", + }; + + var lines = WriteLines (new [] { peer }); + + Assert.Equal (3, lines.Length); + Assert.Equal ("My.Namespace.MyActivity, MyApp;crc64abcdef.MyActivity", lines [0]); + Assert.Equal ("My.Namespace.MyActivity;crc64abcdef.MyActivity", lines [1]); + Assert.Equal ("my.namespace.MyActivity;crc64abcdef.MyActivity", lines [2]); + } + + [Fact] + public void Write_MultipleTypes_OrderedByManagedName () + { + var peers = new [] { + MakeMcwPeer ("android/widget/TextView", "Android.Widget.TextView", "Mono.Android"), + MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android"), + MakeMcwPeer ("android/content/Context", "Android.Content.Context", "Mono.Android"), + }; + + var lines = WriteLines (peers); + + // 3 types × 3 lines each = 9 lines + Assert.Equal (9, lines.Length); + + // First type alphabetically: Android.App.Activity + Assert.StartsWith ("Android.App.Activity, Mono.Android;", lines [0]); + // Second: Android.Content.Context + Assert.StartsWith ("Android.Content.Context, Mono.Android;", lines [3]); + // Third: Android.Widget.TextView + Assert.StartsWith ("Android.Widget.TextView, Mono.Android;", lines [6]); + } + + [Fact] + public void Write_EmptyList_ProducesEmptyOutput () + { + var lines = WriteLines (Array.Empty ()); + Assert.Empty (lines); + } + + [Fact] + public void Write_MatchesExpectedAcwMapFormat () + { + // Verify the format matches what LoadMapFile expects: + // each line is "key;value" where LoadMapFile splits on ';' + var peer = MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android"); + + var lines = WriteLines (new [] { peer }); + + foreach (var line in lines) { + var parts = line.Split (new [] { ';' }, count: 2); + Assert.Equal (2, parts.Length); + Assert.False (string.IsNullOrWhiteSpace (parts [0]), "Key should not be empty"); + Assert.False (string.IsNullOrWhiteSpace (parts [1]), "Value should not be empty"); + } + } + + [Fact] + public void Write_FromScannedFixtures_ProducesValidOutput () + { + var peers = ScanFixtures (); + Assert.NotEmpty (peers); + + var lines = WriteLines (peers); + + foreach (var line in lines) { + var parts = line.Split (new [] { ';' }, count: 2); + Assert.Equal (2, parts.Length); + // No slashes in the output — they should all be converted to dots + Assert.DoesNotContain ("/", parts [1]); + } + } +}