|
| 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