Skip to content

Commit 384a5a8

Browse files
Extract TrimmableTypeMapGenerator from MSBuild task
Move business logic into a core class in the TrimmableTypeMap project. The MSBuild task becomes a thin adapter. Tests instantiate the generator directly — no MSBuild ceremony needed for unit testing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6ceefe8 commit 384a5a8

4 files changed

Lines changed: 325 additions & 226 deletions

File tree

src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,24 @@ public IList<string> Generate (
4646
AssemblyManifestInfo assemblyInfo,
4747
string outputPath)
4848
{
49-
var doc = LoadOrCreateManifest (manifestTemplatePath);
49+
XDocument? template = null;
50+
if (!string.IsNullOrEmpty (manifestTemplatePath) && File.Exists (manifestTemplatePath)) {
51+
template = XDocument.Load (manifestTemplatePath);
52+
}
53+
return Generate (template, allPeers, assemblyInfo, outputPath);
54+
}
55+
56+
/// <summary>
57+
/// Generates the merged manifest from an optional pre-loaded template and writes it to <paramref name="outputPath"/>.
58+
/// Returns the list of additional content provider names (for ApplicationRegistration.java).
59+
/// </summary>
60+
public IList<string> Generate (
61+
XDocument? manifestTemplate,
62+
IReadOnlyList<JavaPeerInfo> allPeers,
63+
AssemblyManifestInfo assemblyInfo,
64+
string outputPath)
65+
{
66+
var doc = manifestTemplate ?? CreateDefaultManifest ();
5067
var manifest = doc.Root;
5168
if (manifest is null) {
5269
throw new InvalidOperationException ("Manifest document has no root element.");
@@ -134,12 +151,8 @@ public IList<string> Generate (
134151
return providerNames;
135152
}
136153

137-
XDocument LoadOrCreateManifest (string? templatePath)
154+
XDocument CreateDefaultManifest ()
138155
{
139-
if (!string.IsNullOrEmpty (templatePath) && File.Exists (templatePath)) {
140-
return XDocument.Load (templatePath);
141-
}
142-
143156
return new XDocument (
144157
new XDeclaration ("1.0", "utf-8", null),
145158
new XElement ("manifest",

src/Microsoft.Android.Sdk.TrimmableTypeMap/Microsoft.Android.Sdk.TrimmableTypeMap.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@
1010
<AssemblyOriginatorKeyFile>..\..\product.snk</AssemblyOriginatorKeyFile>
1111
</PropertyGroup>
1212

13+
<PropertyGroup>
14+
<MSBuildPackageReferenceVersion Condition=" '$(MSBuildPackageReferenceVersion)' == '' ">17.14.28</MSBuildPackageReferenceVersion>
15+
</PropertyGroup>
16+
1317
<ItemGroup>
18+
<PackageReference Include="Microsoft.Build.Framework" Version="$(MSBuildPackageReferenceVersion)" />
19+
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="$(MSBuildPackageReferenceVersion)" />
1420
<PackageReference Include="System.IO.Hashing" Version="$(SystemIOHashingPackageVersion)" />
1521
<PackageReference Include="System.Reflection.Metadata" Version="$(SystemReflectionMetadataPackageVersion)" />
1622
<InternalsVisibleTo Include="Microsoft.Android.Sdk.TrimmableTypeMap.Tests" Key="0024000004800000940000000602000000240000525341310004000011000000438ac2a5acfbf16cbd2b2b47a62762f273df9cb2795ceccdf77d10bf508e69e7a362ea7a45455bbf3ac955e1f2e2814f144e5d817efc4c6502cc012df310783348304e3ae38573c6d658c234025821fda87a0be8a0d504df564e2c93b2b878925f42503e9d54dfef9f9586d9e6f38a305769587b1de01f6c0410328b2c9733db" />
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Globalization;
6+
using System.IO;
7+
using System.Linq;
8+
using Microsoft.Build.Framework;
9+
using Microsoft.Build.Utilities;
10+
11+
namespace Microsoft.Android.Sdk.TrimmableTypeMap;
12+
13+
/// <summary>
14+
/// Configuration for manifest generation, passed from the MSBuild task.
15+
/// </summary>
16+
public record ManifestConfig (
17+
string PackageName,
18+
string? ApplicationLabel,
19+
string? VersionCode,
20+
string? VersionName,
21+
string? AndroidApiLevel,
22+
string? SupportedOSPlatformVersion,
23+
string? AndroidRuntime,
24+
bool Debug,
25+
bool NeedsInternet,
26+
bool EmbedAssemblies,
27+
string? ManifestPlaceholders,
28+
string? CheckedBuild,
29+
string? ApplicationJavaClass);
30+
31+
/// <summary>
32+
/// Result of the trimmable type map generation.
33+
/// </summary>
34+
public record TrimmableTypeMapResult (
35+
List<string> GeneratedAssemblies,
36+
List<string> GeneratedJavaFiles,
37+
string[]? AdditionalProviderSources);
38+
39+
/// <summary>
40+
/// Core logic for generating trimmable TypeMap assemblies, JCW Java sources, and manifest.
41+
/// Extracted from the MSBuild task so it can be tested directly without MSBuild ceremony.
42+
/// </summary>
43+
public class TrimmableTypeMapGenerator
44+
{
45+
readonly TaskLoggingHelper log;
46+
47+
public TrimmableTypeMapGenerator (TaskLoggingHelper log)
48+
{
49+
this.log = log;
50+
}
51+
52+
/// <summary>
53+
/// Runs the full generation pipeline: scan assemblies, generate typemap
54+
/// assemblies, generate JCW Java sources, and optionally generate the manifest.
55+
/// </summary>
56+
public TrimmableTypeMapResult Execute (
57+
IReadOnlyList<string> assemblyPaths,
58+
string outputDirectory,
59+
string javaSourceOutputDirectory,
60+
Version systemRuntimeVersion,
61+
HashSet<string> frameworkAssemblyNames,
62+
ManifestConfig? manifestConfig,
63+
string? manifestTemplatePath,
64+
string? mergedManifestOutputPath)
65+
{
66+
Directory.CreateDirectory (outputDirectory);
67+
Directory.CreateDirectory (javaSourceOutputDirectory);
68+
69+
var (allPeers, assemblyManifestInfo) = ScanAssemblies (assemblyPaths);
70+
71+
if (allPeers.Count == 0) {
72+
log.LogMessage (MessageImportance.Low, "No Java peer types found, skipping typemap generation.");
73+
return new TrimmableTypeMapResult ([], [], null);
74+
}
75+
76+
var generatedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, assemblyPaths, outputDirectory);
77+
78+
// Generate JCW .java files for user assemblies + framework Implementor types.
79+
// Framework binding types already have compiled JCWs in the SDK but their constructors
80+
// use the legacy TypeManager.Activate() JNI native which isn't available in the
81+
// trimmable runtime. Implementor types (View_OnClickListenerImplementor, etc.) are
82+
// in the mono.* Java package so we use the mono/ prefix to identify them.
83+
// We generate fresh JCWs that use Runtime.registerNatives() for activation.
84+
var jcwPeers = allPeers.Where (p =>
85+
!frameworkAssemblyNames.Contains (p.AssemblyName)
86+
|| p.JavaName.StartsWith ("mono/", StringComparison.Ordinal)).ToList ();
87+
log.LogMessage (MessageImportance.Low, "Generating JCW files for {0} types (filtered from {1} total).", jcwPeers.Count, allPeers.Count);
88+
var generatedJavaFiles = GenerateJcwJavaSources (jcwPeers, javaSourceOutputDirectory);
89+
90+
// Generate manifest if output path is configured
91+
string[]? additionalProviderSources = null;
92+
if (mergedManifestOutputPath is not null && mergedManifestOutputPath.Length > 0 && manifestConfig is not null && !string.IsNullOrEmpty (manifestConfig.PackageName)) {
93+
additionalProviderSources = GenerateManifest (allPeers, assemblyManifestInfo, manifestConfig, manifestTemplatePath, mergedManifestOutputPath);
94+
}
95+
96+
return new TrimmableTypeMapResult (generatedAssemblies, generatedJavaFiles, additionalProviderSources);
97+
}
98+
99+
// Future optimization: the scanner currently scans all assemblies on every run.
100+
// For incremental builds, we could:
101+
// 1. Add a Scan(allPaths, changedPaths) overload that only produces JavaPeerInfo
102+
// for changed assemblies while still indexing all assemblies for cross-assembly
103+
// resolution (base types, interfaces, activation ctors).
104+
// 2. Cache scan results per assembly to skip PE I/O entirely for unchanged assemblies.
105+
// Both require profiling to determine if they meaningfully improve build times.
106+
(List<JavaPeerInfo> peers, AssemblyManifestInfo manifestInfo) ScanAssemblies (IReadOnlyList<string> assemblyPaths)
107+
{
108+
using var scanner = new JavaPeerScanner ();
109+
var peers = scanner.Scan (assemblyPaths);
110+
var manifestInfo = scanner.ScanAssemblyManifestInfo ();
111+
log.LogMessage (MessageImportance.Low, "Scanned {0} assemblies, found {1} Java peer types.", assemblyPaths.Count, peers.Count);
112+
return (peers, manifestInfo);
113+
}
114+
115+
List<string> GenerateTypeMapAssemblies (List<JavaPeerInfo> allPeers, Version systemRuntimeVersion,
116+
IReadOnlyList<string> assemblyPaths, string outputDir)
117+
{
118+
// Build a map from assembly name → source path for timestamp comparison
119+
var sourcePathByName = new Dictionary<string, string> (StringComparer.Ordinal);
120+
foreach (var path in assemblyPaths) {
121+
var name = Path.GetFileNameWithoutExtension (path);
122+
sourcePathByName [name] = path;
123+
}
124+
125+
var peersByAssembly = allPeers
126+
.GroupBy (p => p.AssemblyName, StringComparer.Ordinal)
127+
.OrderBy (g => g.Key, StringComparer.Ordinal);
128+
129+
var generatedAssemblies = new List<string> ();
130+
var perAssemblyNames = new List<string> ();
131+
var generator = new TypeMapAssemblyGenerator (systemRuntimeVersion);
132+
bool anyRegenerated = false;
133+
134+
foreach (var group in peersByAssembly) {
135+
string assemblyName = $"_{group.Key}.TypeMap";
136+
string outputPath = Path.Combine (outputDir, assemblyName + ".dll");
137+
perAssemblyNames.Add (assemblyName);
138+
139+
if (IsUpToDate (outputPath, group.Key, sourcePathByName)) {
140+
log.LogMessage (MessageImportance.Low, " {0}: up to date, skipping", assemblyName);
141+
generatedAssemblies.Add (outputPath);
142+
continue;
143+
}
144+
145+
generator.Generate (group.ToList (), outputPath, assemblyName);
146+
generatedAssemblies.Add (outputPath);
147+
anyRegenerated = true;
148+
149+
log.LogMessage (MessageImportance.Low, " {0}: {1} types", assemblyName, group.Count ());
150+
}
151+
152+
// Root assembly references all per-assembly typemaps — regenerate if any changed
153+
string rootOutputPath = Path.Combine (outputDir, "_Microsoft.Android.TypeMaps.dll");
154+
if (anyRegenerated || !File.Exists (rootOutputPath)) {
155+
var rootGenerator = new RootTypeMapAssemblyGenerator (systemRuntimeVersion);
156+
rootGenerator.Generate (perAssemblyNames, rootOutputPath);
157+
log.LogMessage (MessageImportance.Low, " Root: {0} per-assembly refs", perAssemblyNames.Count);
158+
} else {
159+
log.LogMessage (MessageImportance.Low, " Root: up to date, skipping");
160+
}
161+
generatedAssemblies.Add (rootOutputPath);
162+
163+
log.LogMessage (MessageImportance.Low, "Generated {0} typemap assemblies.", generatedAssemblies.Count);
164+
return generatedAssemblies;
165+
}
166+
167+
internal static bool IsUpToDate (string outputPath, string assemblyName, Dictionary<string, string> sourcePathByName)
168+
{
169+
if (!File.Exists (outputPath)) {
170+
return false;
171+
}
172+
if (!sourcePathByName.TryGetValue (assemblyName, out var sourcePath)) {
173+
return false;
174+
}
175+
return File.GetLastWriteTimeUtc (outputPath) >= File.GetLastWriteTimeUtc (sourcePath);
176+
}
177+
178+
List<string> GenerateJcwJavaSources (List<JavaPeerInfo> allPeers, string javaSourceOutputDirectory)
179+
{
180+
var jcwGenerator = new JcwJavaSourceGenerator ();
181+
var files = jcwGenerator.Generate (allPeers, javaSourceOutputDirectory);
182+
log.LogMessage (MessageImportance.Low, "Generated {0} JCW Java source files.", files.Count);
183+
return files.ToList ();
184+
}
185+
186+
string[]? GenerateManifest (List<JavaPeerInfo> allPeers, AssemblyManifestInfo assemblyManifestInfo,
187+
ManifestConfig config, string? manifestTemplatePath, string mergedManifestOutputPath)
188+
{
189+
// Validate components
190+
ValidateComponents (allPeers, assemblyManifestInfo);
191+
if (log.HasLoggedErrors) {
192+
return null;
193+
}
194+
195+
string minSdk = "21";
196+
if (!string.IsNullOrEmpty (config.SupportedOSPlatformVersion) && Version.TryParse (config.SupportedOSPlatformVersion, out var sopv)) {
197+
minSdk = sopv.Major.ToString (CultureInfo.InvariantCulture);
198+
}
199+
200+
string targetSdk = config.AndroidApiLevel ?? "36";
201+
if (Version.TryParse (targetSdk, out var apiVersion)) {
202+
targetSdk = apiVersion.Major.ToString (CultureInfo.InvariantCulture);
203+
}
204+
205+
bool forceDebuggable = !string.IsNullOrEmpty (config.CheckedBuild);
206+
207+
var generator = new ManifestGenerator {
208+
PackageName = config.PackageName,
209+
ApplicationLabel = config.ApplicationLabel ?? config.PackageName,
210+
VersionCode = config.VersionCode ?? "",
211+
VersionName = config.VersionName ?? "",
212+
MinSdkVersion = minSdk,
213+
TargetSdkVersion = targetSdk,
214+
AndroidRuntime = config.AndroidRuntime ?? "coreclr",
215+
Debug = config.Debug,
216+
NeedsInternet = config.NeedsInternet,
217+
EmbedAssemblies = config.EmbedAssemblies,
218+
ForceDebuggable = forceDebuggable,
219+
ForceExtractNativeLibs = forceDebuggable,
220+
ManifestPlaceholders = config.ManifestPlaceholders,
221+
ApplicationJavaClass = config.ApplicationJavaClass,
222+
};
223+
224+
var providerNames = generator.Generate (manifestTemplatePath, allPeers, assemblyManifestInfo, mergedManifestOutputPath);
225+
return providerNames.ToArray ();
226+
}
227+
228+
public static Version ParseTargetFrameworkVersion (string tfv)
229+
{
230+
if (tfv.Length > 0 && (tfv [0] == 'v' || tfv [0] == 'V')) {
231+
tfv = tfv.Substring (1);
232+
}
233+
if (Version.TryParse (tfv, out var version)) {
234+
return version;
235+
}
236+
throw new ArgumentException ($"Cannot parse TargetFrameworkVersion '{tfv}' as a Version.");
237+
}
238+
239+
void ValidateComponents (List<JavaPeerInfo> allPeers, AssemblyManifestInfo assemblyManifestInfo)
240+
{
241+
// XA4213: component types must have a public parameterless constructor
242+
foreach (var peer in allPeers) {
243+
if (peer.ComponentAttribute is null || peer.IsAbstract) {
244+
continue;
245+
}
246+
if (!peer.ComponentAttribute.HasPublicDefaultConstructor) {
247+
log.LogError (null, "XA4213", null, null, 0, 0, 0, 0, "The type '{0}' must provide a public default constructor", peer.ManagedTypeName);
248+
}
249+
}
250+
251+
// Validate only one Application type
252+
var applicationTypes = new List<string> ();
253+
foreach (var peer in allPeers) {
254+
if (peer.ComponentAttribute?.Kind == ComponentKind.Application && !peer.IsAbstract) {
255+
applicationTypes.Add (peer.ManagedTypeName);
256+
}
257+
}
258+
259+
bool hasAssemblyLevelApplication = assemblyManifestInfo.ApplicationProperties is not null;
260+
if (applicationTypes.Count > 1) {
261+
log.LogError (null, "XA4212", null, null, 0, 0, 0, 0, "There can be only one type with an [Application] attribute; found: {0}", string.Join (", ", applicationTypes));
262+
} else if (applicationTypes.Count > 0 && hasAssemblyLevelApplication) {
263+
log.LogError (null, "XA4217", null, null, 0, 0, 0, 0, "Application cannot have both a type with an [Application] attribute and an [assembly:Application] attribute.");
264+
}
265+
}
266+
}

0 commit comments

Comments
 (0)