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