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..e725f0c4245 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AcwMapWriter.cs @@ -0,0 +1,82 @@ +#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 . + /// Conflict detection is NOT performed here — it happens at merge time when all per-assembly + /// maps are combined. Per-assembly maps write all 3 line variants unconditionally. + /// + 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); + } + } + + /// + /// Writes acw-map lines to a file, only updating the file if the content has changed. + /// Returns true if the file was written (content changed or file didn't exist). + /// + public static bool WriteToFile (string filePath, IEnumerable peers) + { + using var memoryStream = new MemoryStream (); + using (var writer = new StreamWriter (memoryStream, new System.Text.UTF8Encoding (encoderShouldEmitUTF8Identifier: false), bufferSize: 1024, leaveOpen: true)) { + Write (writer, peers); + } + + memoryStream.Position = 0; + byte[] newContent = memoryStream.ToArray (); + + if (File.Exists (filePath)) { + byte[] existingContent = File.ReadAllBytes (filePath); + if (existingContent.AsSpan ().SequenceEqual (newContent)) { + return false; + } + } + + string? directory = Path.GetDirectoryName (filePath); + if (directory != null) { + Directory.CreateDirectory (directory); + } + + File.WriteAllBytes (filePath, newContent); + return true; + } +} 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 0f207cf272c..ca166994bd6 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,17 +42,53 @@ ResolvedAssemblies="@(_ResolvedAssemblies)" OutputDirectory="$(_TypeMapOutputDirectory)" JavaSourceOutputDirectory="$(_TypeMapJavaOutputDirectory)" + AcwMapDirectory="$(_PerAssemblyAcwMapDirectory)" TargetFrameworkVersion="$(TargetFrameworkVersion)"> + + + + + + <_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..bb731a0aa12 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,13 @@ public class GenerateTrimmableTypeMap : AndroidTask [Output] public ITaskItem []? GeneratedJavaFiles { get; set; } + /// + /// Per-assembly acw-map files produced during scanning. Each file contains + /// ManagedName;JavaName lines for types in that assembly. + /// + [Output] + public ITaskItem []? PerAssemblyAcwMapFiles { get; set; } + public override bool RunTask () { var systemRuntimeVersion = ParseTargetFrameworkVersion (TargetFrameworkVersion); @@ -49,6 +62,7 @@ public override bool RunTask () Directory.CreateDirectory (OutputDirectory); Directory.CreateDirectory (JavaSourceOutputDirectory); + Directory.CreateDirectory (AcwMapDirectory); var allPeers = ScanAssemblies (assemblyPaths); if (allPeers.Count == 0) { @@ -58,6 +72,7 @@ public override bool RunTask () GeneratedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, assemblyPaths); GeneratedJavaFiles = GenerateJcwJavaSources (allPeers); + PerAssemblyAcwMapFiles = GeneratePerAssemblyAcwMaps (allPeers); return !Log.HasLoggedErrors; } @@ -153,6 +168,32 @@ 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 = AcwMapWriter.WriteToFile (outputFile, peers); + + 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/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..b7a31280a34 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/AcwMapWriterTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class AcwMapWriterTests : FixtureTestBase +{ + static string WriteToString (IEnumerable peers) + { + using var writer = new StringWriter (); + AcwMapWriter.Write (writer, peers); + return writer.ToString (); + } + + [Fact] + public void Write_SingleMcwType_ProducesThreeLines () + { + var peer = MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android"); + + var output = WriteToString (new [] { peer }); + var lines = output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None); + + 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 output = WriteToString (new [] { peer }); + var lines = output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None); + + 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 output = WriteToString (peers); + var lines = output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None); + + // 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 output = WriteToString (Array.Empty ()); + Assert.Equal ("", output); + } + + [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 output = WriteToString (new [] { peer }); + + foreach (var line in output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None)) { + 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 output = WriteToString (peers); + + foreach (var line in output.TrimEnd ().Split (new [] { '\n' }, StringSplitOptions.None)) { + 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]); + } + } +}