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]);
+ }
+ }
+}