Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace Microsoft.Android.Sdk.TrimmableTypeMap;

/// <summary>
/// Generates per-assembly acw-map.txt content from <see cref="JavaPeerInfo"/> records.
/// The acw-map.txt file maps managed type names to Java/ACW type names, consumed by
/// <c>_ConvertCustomView</c> 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"
/// </summary>
public static class AcwMapWriter
{
/// <summary>
/// Writes acw-map lines for the given <paramref name="peers"/> to the <paramref name="writer"/>.
/// 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.
/// </summary>
public static void Write (TextWriter writer, IEnumerable<JavaPeerInfo> 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);
}
}

/// <summary>
/// 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).
/// </summary>
public static bool WriteToFile (string filePath, IEnumerable<JavaPeerInfo> 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;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!-- Trimmable typemap: managed type mapping instead of native binary typemaps.
Generates per-assembly TypeMap .dll assemblies, a root _Microsoft.Android.TypeMaps.dll,
and JCW .java source files with registerNatives. -->
JCW .java source files with registerNatives, and per-assembly acw-map files. -->
<Project>

<UsingTask TaskName="Xamarin.Android.Tasks.GenerateTrimmableTypeMap" AssemblyFile="$(_XamarinAndroidBuildTasksAssembly)" />
Expand All @@ -14,6 +14,7 @@
<_TypeMapAssemblyName>_Microsoft.Android.TypeMaps</_TypeMapAssemblyName>
<_TypeMapOutputDirectory>$(IntermediateOutputPath)typemap\</_TypeMapOutputDirectory>
<_TypeMapJavaOutputDirectory>$(IntermediateOutputPath)typemap\java</_TypeMapJavaOutputDirectory>
<_PerAssemblyAcwMapDirectory>$(IntermediateOutputPath)acw-maps\</_PerAssemblyAcwMapDirectory>
</PropertyGroup>

<!-- Tell the runtime which assembly contains the TypeMap attributes -->
Expand Down Expand Up @@ -41,17 +42,53 @@
ResolvedAssemblies="@(_ResolvedAssemblies)"
OutputDirectory="$(_TypeMapOutputDirectory)"
JavaSourceOutputDirectory="$(_TypeMapJavaOutputDirectory)"
AcwMapDirectory="$(_PerAssemblyAcwMapDirectory)"
TargetFrameworkVersion="$(TargetFrameworkVersion)">
<Output TaskParameter="GeneratedAssemblies" ItemName="_GeneratedTypeMapAssemblies" />
<Output TaskParameter="GeneratedJavaFiles" ItemName="_GeneratedJavaFiles" />
<Output TaskParameter="PerAssemblyAcwMapFiles" ItemName="_PerAssemblyAcwMapFiles" />
</GenerateTrimmableTypeMap>

<ItemGroup>
<FileWrites Include="@(_GeneratedTypeMapAssemblies)" />
<FileWrites Include="@(_GeneratedJavaFiles)" />
<FileWrites Include="@(_PerAssemblyAcwMapFiles)" />
</ItemGroup>

<Touch Files="$(_AndroidStampDirectory)_GenerateJavaStubs.stamp" AlwaysCreate="True" />
</Target>

<!--
Merge per-assembly acw-map files into a single acw-map.txt.
Consumed by _ConvertCustomView (layout XML custom view fixups) and R8.
Runs after _GenerateJavaStubs produces the per-assembly files;
skipped on incremental builds when no *.txt is newer than acw-map.txt.
-->
<Target Name="_CollectPerAssemblyAcwMaps"
AfterTargets="_GenerateJavaStubs">
<ItemGroup>
<_PerAssemblyAcwMapFiles Include="$(_PerAssemblyAcwMapDirectory)*.txt" />
</ItemGroup>
</Target>

<Target Name="_MergeAcwMaps"
AfterTargets="_CollectPerAssemblyAcwMaps"
Inputs="@(_PerAssemblyAcwMapFiles)"
Outputs="$(_AcwMapFile)">

<ReadLinesFromFile File="%(_PerAssemblyAcwMapFiles.Identity)">
<Output TaskParameter="Lines" ItemName="_AcwMapLines" />
</ReadLinesFromFile>

<WriteLinesToFile
File="$(_AcwMapFile)"
Lines="@(_AcwMapLines)"
Overwrite="true"
WriteOnlyWhenDifferent="true" />

<ItemGroup>
<FileWrites Include="$(_AcwMapFile)" />
</ItemGroup>
</Target>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
namespace Xamarin.Android.Tasks;

/// <summary>
/// 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.
/// </summary>
public class GenerateTrimmableTypeMap : AndroidTask
{
Expand All @@ -29,6 +29,12 @@ public class GenerateTrimmableTypeMap : AndroidTask
[Required]
public string JavaSourceOutputDirectory { get; set; } = "";

/// <summary>
/// Directory for per-assembly acw-map.{AssemblyName}.txt files.
/// </summary>
[Required]
public string AcwMapDirectory { get; set; } = "";

/// <summary>
/// The .NET target framework version (e.g., "v11.0"). Used to set the System.Runtime
/// assembly reference version in generated typemap assemblies.
Expand All @@ -42,13 +48,21 @@ public class GenerateTrimmableTypeMap : AndroidTask
[Output]
public ITaskItem []? GeneratedJavaFiles { get; set; }

/// <summary>
/// Per-assembly acw-map files produced during scanning. Each file contains
/// ManagedName;JavaName lines for types in that assembly.
/// </summary>
[Output]
public ITaskItem []? PerAssemblyAcwMapFiles { get; set; }

public override bool RunTask ()
{
var systemRuntimeVersion = ParseTargetFrameworkVersion (TargetFrameworkVersion);
var assemblyPaths = GetJavaInteropAssemblyPaths (ResolvedAssemblies);

Directory.CreateDirectory (OutputDirectory);
Directory.CreateDirectory (JavaSourceOutputDirectory);
Directory.CreateDirectory (AcwMapDirectory);

var allPeers = ScanAssemblies (assemblyPaths);
if (allPeers.Count == 0) {
Expand All @@ -58,6 +72,7 @@ public override bool RunTask ()

GeneratedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, assemblyPaths);
GeneratedJavaFiles = GenerateJcwJavaSources (allPeers);
PerAssemblyAcwMapFiles = GeneratePerAssemblyAcwMaps (allPeers);

return !Log.HasLoggedErrors;
}
Expand Down Expand Up @@ -153,6 +168,32 @@ ITaskItem [] GenerateJcwJavaSources (List<JavaPeerInfo> allPeers)
return items;
}

ITaskItem [] GeneratePerAssemblyAcwMaps (List<JavaPeerInfo> allPeers)
{
var peersByAssembly = allPeers
.GroupBy (p => p.AssemblyName, StringComparer.Ordinal)
.OrderBy (g => g.Key, StringComparer.Ordinal);

var outputFiles = new List<ITaskItem> ();

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')) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<JavaPeerInfo> 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<JavaPeerInfo> ());
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]);
}
}
}