diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/MetadataHelper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/MetadataHelper.cs index 185c2637dcf..54633913dfe 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/MetadataHelper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/MetadataHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; using System.Security.Cryptography; using System.Text; @@ -7,6 +8,8 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap; static class MetadataHelper { + static readonly Guid GeneratorModuleVersionId = typeof (TypeMapAssemblyGenerator).Module.ModuleVersionId; + /// /// Produces a deterministic MVID by hashing the module name together with content-dependent data. /// Assemblies with the same name but different content will have different MVIDs. @@ -66,6 +69,99 @@ public static byte [] ComputeContentFingerprint (TypeMapAssemblyData data) return sha.ComputeHash (stream.GetBuffer (), 0, checked ((int) stream.Length)); } + /// + /// Computes a fingerprint of every input that affects a generated per-assembly typemap. + /// Unlike , this is an incremental-build contract, + /// so it includes the generator binary identity and all model fields consumed by the emitter. + /// + public static byte [] ComputeIncrementalFingerprint (TypeMapAssemblyData data, Version systemRuntimeVersion, bool useSharedTypemapUniverse) + { + using var sha = SHA256.Create (); + using var stream = new MemoryStream (); + using var writer = new BinaryWriter (stream, Encoding.UTF8); + writer.Write (GeneratorModuleVersionId.ToByteArray ()); + writer.Write (systemRuntimeVersion.ToString ()); + writer.Write (useSharedTypemapUniverse); + writer.Write (data.AssemblyName); + writer.Write (data.ModuleName); + writer.Write (data.Entries.Count); + foreach (var entry in data.Entries) { + writer.Write (entry.MapKey); + writer.Write (entry.ProxyTypeReference); + writer.WriteOptionalString (entry.TargetTypeReference); + } + writer.Write (data.ProxyTypes.Count); + foreach (var proxy in data.ProxyTypes) { + writer.Write (proxy.TypeName); + writer.Write (proxy.JniName); + writer.Write (proxy.Namespace); + writer.WriteTypeRef (proxy.TargetType); + writer.WriteOptionalTypeRef (proxy.InvokerType); + writer.Write (proxy.InvokerActivationCtorStyle.HasValue); + if (proxy.InvokerActivationCtorStyle.HasValue) { + writer.Write ((byte) proxy.InvokerActivationCtorStyle.Value); + } + writer.WriteOptionalActivationCtor (proxy.ActivationCtor); + writer.Write (proxy.IsGenericDefinition); + writer.Write (proxy.CannotRegisterInStaticConstructor); + writer.Write (proxy.IsAcw); + writer.Write (proxy.UcoMethods.Count); + foreach (var method in proxy.UcoMethods) { + writer.WriteUcoMethod (method); + } + writer.Write (proxy.UcoConstructors.Count); + foreach (var constructor in proxy.UcoConstructors) { + writer.WriteUcoConstructor (constructor); + } + writer.Write (proxy.NativeRegistrations.Count); + foreach (var registration in proxy.NativeRegistrations) { + writer.WriteNativeRegistration (registration); + } + } + writer.Write (data.Associations.Count); + foreach (var assoc in data.Associations) { + writer.Write (assoc.SourceTypeReference); + writer.Write (assoc.AliasProxyTypeReference); + } + writer.Write (data.AliasHolders.Count); + foreach (var holder in data.AliasHolders) { + writer.Write (holder.TypeName); + writer.Write (holder.Namespace); + writer.Write (holder.AliasKeys.Count); + foreach (var aliasKey in holder.AliasKeys) { + writer.Write (aliasKey); + } + } + writer.Write (data.IgnoresAccessChecksTo.Count); + foreach (var assemblyName in data.IgnoresAccessChecksTo) { + writer.Write (assemblyName); + } + writer.Flush (); + return sha.ComputeHash (stream.GetBuffer (), 0, checked ((int) stream.Length)); + } + + /// + /// Computes a fingerprint of every input that affects the root typemap assembly. + /// + public static byte [] ComputeRootIncrementalFingerprint ( + IReadOnlyList perAssemblyTypeMapNames, + Version systemRuntimeVersion, + bool useSharedTypemapUniverse) + { + using var sha = SHA256.Create (); + using var stream = new MemoryStream (); + using var writer = new BinaryWriter (stream, Encoding.UTF8); + writer.Write (GeneratorModuleVersionId.ToByteArray ()); + writer.Write (systemRuntimeVersion.ToString ()); + writer.Write (useSharedTypemapUniverse); + writer.Write (perAssemblyTypeMapNames.Count); + foreach (var assemblyName in perAssemblyTypeMapNames) { + writer.Write (assemblyName); + } + writer.Flush (); + return sha.ComputeHash (stream.GetBuffer (), 0, checked ((int) stream.Length)); + } + static void WriteTypeRef (this BinaryWriter writer, TypeRefData type) { writer.Write (type.ManagedTypeName); @@ -78,15 +174,55 @@ static void WriteTypeRef (this BinaryWriter writer, TypeRefData type) } } + static void WriteOptionalTypeRef (this BinaryWriter writer, TypeRefData? type) + { + writer.Write (type is not null); + if (type is not null) { + writer.WriteTypeRef (type); + } + } + + static void WriteOptionalString (this BinaryWriter writer, string? value) + { + writer.Write (value is not null); + if (value is not null) { + writer.Write (value); + } + } + + static void WriteOptionalActivationCtor (this BinaryWriter writer, ActivationCtorData? constructor) + { + writer.Write (constructor is not null); + if (constructor is not null) { + writer.WriteTypeRef (constructor.DeclaringType); + writer.Write (constructor.IsOnLeafType); + writer.Write ((byte) constructor.Style); + } + } + static void WriteUcoMethod (this BinaryWriter writer, UcoMethodData method) { writer.Write (method.WrapperName); writer.Write (method.CallbackMethodName); writer.WriteTypeRef (method.CallbackType); writer.Write (method.JniSignature); + writer.WriteOptionalStrings (method.CallbackParameterTypeNames); + writer.WriteOptionalString (method.CallbackReturnTypeName); writer.WriteExportMethodDispatch (method.ExportMethodDispatch); } + static void WriteOptionalStrings (this BinaryWriter writer, IReadOnlyList? values) + { + writer.Write (values is not null); + if (values is null) { + return; + } + writer.Write (values.Count); + foreach (var value in values) { + writer.Write (value); + } + } + static void WriteExportMethodDispatch (this BinaryWriter writer, ExportMethodDispatchData? dispatch) { writer.Write (dispatch is not null); diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs index 1e449592a43..9e5ae23dd25 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs @@ -29,7 +29,22 @@ public TypeMapAssemblyGenerator (Version systemRuntimeVersion) /// public void Generate (IReadOnlyList peers, Stream stream, string assemblyName, bool useSharedTypemapUniverse = false) { - var model = ModelBuilder.Build (peers, assemblyName + ".dll", assemblyName); + var model = CreateModel (peers, assemblyName); + Generate (model, stream, useSharedTypemapUniverse); + } + + internal TypeMapAssemblyData CreateModel (IReadOnlyList peers, string assemblyName) + { + return ModelBuilder.Build (peers, assemblyName + ".dll", assemblyName); + } + + internal byte [] ComputeIncrementalFingerprint (TypeMapAssemblyData model, bool useSharedTypemapUniverse) + { + return MetadataHelper.ComputeIncrementalFingerprint (model, _systemRuntimeVersion, useSharedTypemapUniverse); + } + + internal void Generate (TypeMapAssemblyData model, Stream stream, bool useSharedTypemapUniverse) + { var emitter = new TypeMapAssemblyEmitter (_systemRuntimeVersion); emitter.Emit (model, stream, useSharedTypemapUniverse); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index 176caf7e79d..60c98619437 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -30,6 +30,13 @@ public TrimmableTypeMapGenerator (ITrimmableTypeMapLogger logger) /// Set to when callers do not consume method metadata for types /// that cannot generate Java callable wrappers. /// + /// + /// Optional incremental-build callback. It receives each output assembly name and a + /// content-model fingerprint, and returns whether that assembly should be emitted. + /// Assemblies skipped by the callback are omitted from + /// . When omitted, all typemap + /// assemblies are generated. + /// public TrimmableTypeMapResult Execute ( IReadOnlyList assemblies, Version systemRuntimeVersion, @@ -40,7 +47,8 @@ public TrimmableTypeMapResult Execute ( string? packageNamingPolicy = null, bool generateTypeMapAssemblies = true, bool errorOnCustomJavaObject = true, - bool collectMarshalMethodsForNonAcw = true) + bool collectMarshalMethodsForNonAcw = true, + Func? shouldGenerateTypeMapAssembly = null) { _ = assemblies ?? throw new ArgumentNullException (nameof (assemblies)); _ = systemRuntimeVersion ?? throw new ArgumentNullException (nameof (systemRuntimeVersion)); @@ -65,7 +73,7 @@ public TrimmableTypeMapResult Execute ( } var generatedAssemblies = generateTypeMapAssemblies - ? GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, useSharedTypemapUniverse) + ? GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, useSharedTypemapUniverse, shouldGenerateTypeMapAssembly) : []; var jcwPeers = allPeers.Where (ShouldGenerateJcw).ToList (); logger.LogGeneratingJcwFilesInfo (jcwPeers.Count, allPeers.Count); @@ -284,10 +292,11 @@ GeneratedManifest GenerateManifest (List allPeers, AssemblyManifes return (peers, manifestInfo); } - List GenerateTypeMapAssemblies ( + internal List GenerateTypeMapAssemblies ( List allPeers, Version systemRuntimeVersion, - bool useSharedTypemapUniverse) + bool useSharedTypemapUniverse, + Func? shouldGenerateTypeMapAssembly = null) { List<(string AssemblyName, List Peers)> peersByAssembly; @@ -315,18 +324,33 @@ List GenerateTypeMapAssemblies ( foreach (var (assemblyName, peers) in peersByAssembly) { string typeMapAssemblyName = $"_{assemblyName}.TypeMap"; perAssemblyNames.Add (typeMapAssemblyName); + var model = generator.CreateModel (peers, typeMapAssemblyName); + if (shouldGenerateTypeMapAssembly is not null) { + var fingerprint = generator.ComputeIncrementalFingerprint (model, useSharedTypemapUniverse); + if (!shouldGenerateTypeMapAssembly (typeMapAssemblyName, fingerprint)) { + continue; + } + } var stream = new MemoryStream (); - generator.Generate (peers, stream, typeMapAssemblyName, useSharedTypemapUniverse); + generator.Generate (model, stream, useSharedTypemapUniverse); stream.Position = 0; generatedAssemblies.Add (new GeneratedAssembly (typeMapAssemblyName, stream)); logger.LogGeneratedTypeMapAssemblyInfo (typeMapAssemblyName, peers.Count); } - var rootStream = new MemoryStream (); - var rootGenerator = new RootTypeMapAssemblyGenerator (systemRuntimeVersion); - rootGenerator.Generate (perAssemblyNames, useSharedTypemapUniverse, rootStream); - rootStream.Position = 0; - generatedAssemblies.Add (new GeneratedAssembly ("_Microsoft.Android.TypeMaps", rootStream)); - logger.LogGeneratedRootTypeMapInfo (perAssemblyNames.Count); + const string rootAssemblyName = "_Microsoft.Android.TypeMaps"; + bool generateRoot = true; + if (shouldGenerateTypeMapAssembly is not null) { + var rootFingerprint = MetadataHelper.ComputeRootIncrementalFingerprint (perAssemblyNames, systemRuntimeVersion, useSharedTypemapUniverse); + generateRoot = shouldGenerateTypeMapAssembly (rootAssemblyName, rootFingerprint); + } + if (generateRoot) { + var rootStream = new MemoryStream (); + var rootGenerator = new RootTypeMapAssemblyGenerator (systemRuntimeVersion); + rootGenerator.Generate (perAssemblyNames, useSharedTypemapUniverse, rootStream); + rootStream.Position = 0; + generatedAssemblies.Add (new GeneratedAssembly (rootAssemblyName, rootStream)); + logger.LogGeneratedRootTypeMapInfo (perAssemblyNames.Count); + } logger.LogGeneratedTypeMapAssembliesInfo (generatedAssemblies.Count); return generatedAssemblies; } 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 a2dfc834ca8..416e9d8eb27 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 @@ -46,6 +46,7 @@ Outputs without making the target run on every build. The stamp is always touched, so the target is correctly skipped when none of its Inputs changed. --> <_TrimmableTypeMapOutputStamp>$(_TypeMapOutputDirectory)_GenerateTrimmableTypeMap.stamp + <_TrimmableTypeMapFingerprintsFile>$(_TypeMapOutputDirectory)typemap-fingerprints.txt <_TrimmableRemoveRegisterFlag>$(_AndroidStampDirectory)_RemoveRegisterAttribute.stamp <_TrimmableRemoveRegisterTarget Condition=" '$(_AndroidRuntime)' == 'CoreCLR' ">_RemoveRegisterAttributeCoreClr <_TrimmableRemoveRegisterTarget Condition=" '$(_AndroidRuntime)' == 'NativeAOT' ">_RemoveRegisterAttributeNativeAot @@ -170,6 +171,7 @@ ApplicationJavaClass="$(AndroidApplicationJavaClass)" ErrorOnCustomJavaObject="$(AndroidErrorOnCustomJavaObject)" GeneratedAssembliesListFile="$(_TypeMapAssembliesListFile)" + TypeMapFingerprintsFile="$(_TrimmableTypeMapFingerprintsFile)" AcwMapOutputFile="$(_PreTrimTypeMapAcwMapOutputFile)" ApplicationRegistrationOutputFile="$(_PreTrimTypeMapApplicationRegistrationOutputFile)"> @@ -200,6 +202,7 @@ + @@ -274,6 +277,7 @@ + diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 1928954aad2..cf9614f94c9 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -84,6 +84,7 @@ public void LogCustomJavaObjectWarning (string managedTypeName) => public string? ApplicationRegistrationOutputFile { get; set; } public string? GeneratedAssembliesListFile { get; set; } + public string? TypeMapFingerprintsFile { get; set; } public string? ManifestTemplate { get; set; } @@ -173,6 +174,9 @@ public override bool RunTask () var peReaders = new List (); var assemblies = new List (); + var typeMapAssemblyNames = new List (); + var typeMapFingerprints = new SortedDictionary (StringComparer.Ordinal); + var priorTypeMapFingerprints = ReadTypeMapFingerprints (); TrimmableTypeMapResult? result = null; try { foreach (var (path, isFrameworkAssembly) in assemblyInputs) { @@ -222,14 +226,19 @@ public override bool RunTask () packageNamingPolicy: PackageNamingPolicy, generateTypeMapAssemblies: GenerateTypeMapAssemblies, errorOnCustomJavaObject: ErrorOnCustomJavaObject, - collectMarshalMethodsForNonAcw: false); + collectMarshalMethodsForNonAcw: false, + shouldGenerateTypeMapAssembly: TypeMapFingerprintsFile.IsNullOrEmpty () ? null : ShouldGenerateTypeMapAssembly); if (Log.HasLoggedErrors) { return false; } if (GenerateTypeMapAssemblies) { - GeneratedAssemblies = WriteAssembliesToDisk (result.GeneratedAssemblies, assemblyInputs.Select (i => i.Path).ToList ()); + if (TypeMapFingerprintsFile.IsNullOrEmpty ()) { + typeMapAssemblyNames.AddRange (result.GeneratedAssemblies.Select (assembly => assembly.Name)); + } + GeneratedAssemblies = WriteAssembliesToDisk (result.GeneratedAssemblies, typeMapAssemblyNames); WriteGeneratedAssembliesListFile (GeneratedAssemblies); + WriteTypeMapFingerprints (typeMapFingerprints); } GeneratedJavaFiles = JavaSourceInputDirectory.IsNullOrEmpty () ? WriteJavaSourcesToDisk (result.GeneratedJavaSources) @@ -286,6 +295,56 @@ public override bool RunTask () } return !Log.HasLoggedErrors; + + bool ShouldGenerateTypeMapAssembly (string assemblyName, byte [] fingerprint) + { + typeMapAssemblyNames.Add (assemblyName); + string fingerprintText = Files.ToHexString (fingerprint); + typeMapFingerprints.Add (assemblyName, fingerprintText); + string outputPath = Path.Combine (OutputDirectory, assemblyName + ".dll"); + bool generate = !File.Exists (outputPath) || + !priorTypeMapFingerprints.TryGetValue (assemblyName, out var priorFingerprint) || + !string.Equals (fingerprintText, priorFingerprint, StringComparison.Ordinal); + Log.LogDebugMessage ($" {assemblyName}: {(generate ? "changed, generating" : "unchanged, skipping emission")}"); + return generate; + } + } + + internal Dictionary ReadTypeMapFingerprints () + { + var fingerprints = new Dictionary (StringComparer.Ordinal); + if (TypeMapFingerprintsFile.IsNullOrEmpty () || !File.Exists (TypeMapFingerprintsFile)) { + return fingerprints; + } + try { + foreach (var line in File.ReadLines (TypeMapFingerprintsFile)) { + int separator = line.IndexOf ('\t'); + if (separator <= 0 || separator == line.Length - 1) { + Log.LogDebugMessage ($"Ignoring invalid trimmable typemap fingerprint cache '{TypeMapFingerprintsFile}'."); + return new Dictionary (StringComparer.Ordinal); + } + fingerprints [line.Substring (0, separator)] = line.Substring (separator + 1); + } + } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { + Log.LogDebugMessage ($"Could not read trimmable typemap fingerprint cache '{TypeMapFingerprintsFile}': {ex.Message}"); + fingerprints.Clear (); + } + return fingerprints; + } + + void WriteTypeMapFingerprints (IReadOnlyDictionary fingerprints) + { + if (TypeMapFingerprintsFile.IsNullOrEmpty ()) { + return; + } + var directory = Path.GetDirectoryName (TypeMapFingerprintsFile); + if (!directory.IsNullOrEmpty ()) { + Directory.CreateDirectory (directory); + } + var text = fingerprints.Count == 0 + ? "" + : string.Join (Environment.NewLine, fingerprints.Select (entry => $"{entry.Key}\t{entry.Value}")) + Environment.NewLine; + Files.CopyIfStringChanged (text, TypeMapFingerprintsFile); } static bool IsFrameworkAssemblyItem (ITaskItem item) => @@ -332,68 +391,21 @@ ITaskItem [] CopyJavaSourcesFromInputDirectory (IReadOnlyList assemblies, IReadOnlyList assemblyPaths) + ITaskItem [] WriteAssembliesToDisk (IReadOnlyList assemblies, IReadOnlyList assemblyNames) { - // Build a map from assembly name -> source path for timestamp comparison - var sourcePathByName = new Dictionary (StringComparer.Ordinal); - foreach (var path in assemblyPaths) { - var name = Path.GetFileNameWithoutExtension (path); - sourcePathByName [name] = path; - } - + var generatedByName = assemblies.ToDictionary (assembly => assembly.Name, StringComparer.Ordinal); var items = new List (); - bool anyRegenerated = false; - - foreach (var assembly in assemblies) { - if (assembly.Name == "_Microsoft.Android.TypeMaps") { - continue; // Handle root assembly separately below - } - - string outputPath = Path.Combine (OutputDirectory, assembly.Name + ".dll"); - // Extract the original assembly name from the typemap name (e.g., "_Foo.TypeMap" -> "Foo") - string originalName = assembly.Name; - if (originalName.StartsWith ("_", StringComparison.Ordinal) && originalName.EndsWith (".TypeMap", StringComparison.Ordinal)) { - originalName = originalName.Substring (1, originalName.Length - ".TypeMap".Length - 1); - } - - if (IsUpToDate (outputPath, originalName, sourcePathByName)) { - Log.LogDebugMessage ($" {assembly.Name}: up to date, skipping"); - } else { + foreach (var assemblyName in assemblyNames) { + string outputPath = Path.Combine (OutputDirectory, assemblyName + ".dll"); + if (generatedByName.TryGetValue (assemblyName, out var assembly)) { Files.CopyIfStreamChanged (assembly.Content, outputPath); - anyRegenerated = true; - Log.LogDebugMessage ($" {assembly.Name}: written"); + Log.LogDebugMessage ($" {assemblyName}: written"); } - items.Add (new TaskItem (outputPath)); } - - // Root assembly — regenerate if any per-assembly typemap changed - var rootAssembly = assemblies.FirstOrDefault (a => a.Name == "_Microsoft.Android.TypeMaps"); - if (rootAssembly is not null) { - string rootOutputPath = Path.Combine (OutputDirectory, rootAssembly.Name + ".dll"); - if (anyRegenerated || !File.Exists (rootOutputPath)) { - Files.CopyIfStreamChanged (rootAssembly.Content, rootOutputPath); - Log.LogDebugMessage ($" Root: written"); - } else { - Log.LogDebugMessage ($" Root: up to date, skipping"); - } - items.Add (new TaskItem (rootOutputPath)); - } - return items.ToArray (); } - static bool IsUpToDate (string outputPath, string assemblyName, Dictionary sourcePathByName) - { - if (!File.Exists (outputPath)) { - return false; - } - if (!sourcePathByName.TryGetValue (assemblyName, out var sourcePath)) { - return false; - } - return File.GetLastWriteTimeUtc (outputPath) >= File.GetLastWriteTimeUtc (sourcePath); - } - ITaskItem [] WriteJavaSourcesToDisk (IReadOnlyList javaSources) { var items = new List (); 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 e4b82397080..db956a6a4cd 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 @@ -101,13 +101,45 @@ public void Execute_SecondRun_OutputsAreUpToDate () .First (p => p.Contains ("_Mono.Android.TypeMap.dll")); var firstWriteTime = File.GetLastWriteTimeUtc (typeMapPath); - // Second run: same inputs — outputs should not be rewritten (CopyIfStreamChanged) - var task2 = CreateTask (assemblies, outputDir, javaDir); + // Second run: the persisted model fingerprint should avoid PE emission entirely. + var messages = new List (); + var task2 = CreateTask (assemblies, outputDir, javaDir, messages: messages); Assert.IsTrue (task2.Execute (), "Second run should succeed."); var secondWriteTime = File.GetLastWriteTimeUtc (typeMapPath); Assert.AreEqual (firstWriteTime, secondWriteTime, "Typemap assembly should NOT be rewritten when content hasn't changed."); + Assert.IsTrue (messages.Any (message => message.Message?.Contains ("_Mono.Android.TypeMap: unchanged, skipping emission", StringComparison.Ordinal) == true), + "Second run should skip typemap PE emission based on the persisted model fingerprint."); + } + + [Test] + public void ReadTypeMapFingerprints_UnreadableCache_Regenerates () + { + var path = Path.Combine ("temp", TestName); + var outputDir = Path.Combine (Root, path, "typemap"); + var javaDir = Path.Combine (Root, path, "java"); + var fingerprintsFile = Path.Combine (outputDir, "typemap-fingerprints.txt"); + Directory.CreateDirectory (outputDir); + File.WriteAllText (fingerprintsFile, "_Existing.TypeMap\tfingerprint"); + + using var fingerprintsLock = File.Open (fingerprintsFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + var task = CreateTask ([], outputDir, javaDir); + + Assert.IsEmpty (task.ReadTypeMapFingerprints (), "An unreadable incremental cache should regenerate every typemap assembly."); + } + + [Test] + public void ReadTypeMapFingerprints_InvalidCache_Regenerates () + { + var path = Path.Combine ("temp", TestName); + var outputDir = Path.Combine (Root, path, "typemap"); + var javaDir = Path.Combine (Root, path, "java"); + Directory.CreateDirectory (outputDir); + File.WriteAllText (Path.Combine (outputDir, "typemap-fingerprints.txt"), "invalid"); + var task = CreateTask ([], outputDir, javaDir); + + Assert.IsEmpty (task.ReadTypeMapFingerprints (), "An invalid incremental cache should regenerate every typemap assembly."); } [Test] @@ -407,6 +439,7 @@ GenerateTrimmableTypeMap CreateTask (ITaskItem [] assemblies, string outputDir, OutputDirectory = outputDir, JavaSourceOutputDirectory = javaDir, TargetFrameworkVersion = tfv, + TypeMapFingerprintsFile = Path.Combine (outputDir, "typemap-fingerprints.txt"), }; } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index b5512e330fe..59283a6a8e3 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -76,6 +76,10 @@ public void Build_WithTrimmableTypeMap_IncrementalBuild ([Values] bool isRelease AssertTrimmableTypeMapOutputs (intermediateDir); var typemapDlls = Directory.GetFiles (intermediateDir, "*.dll"); Assert.IsNotEmpty (typemapDlls, "First build should have generated typemap DLL(s)."); + var typemapFingerprints = Path.Combine (intermediateDir, "typemap-fingerprints.txt"); + FileAssert.Exists (typemapFingerprints, "First build should persist typemap fingerprints."); + var typemapFingerprintContent = File.ReadAllText (typemapFingerprints); + var typemapWriteTimes = typemapDlls.ToDictionary (path => path, File.GetLastWriteTimeUtc); string scanDgml = ""; DateTime scanDgmlTimestamp = default; @@ -106,6 +110,18 @@ public void Build_WithTrimmableTypeMap_IncrementalBuild ([Values] bool isRelease foreach (var typemapDll in typemapDlls) { FileAssert.Exists (typemapDll, $"No-op builds should preserve generated typemap assembly {typemapDll} when _GenerateTrimmableTypeMap is skipped."); } + + FileAssert.Exists (typemapFingerprints, "IncrementalClean should preserve typemap fingerprints on a no-op build."); + Assert.AreEqual (typemapFingerprintContent, File.ReadAllText (typemapFingerprints), "A no-op build should not change typemap fingerprints."); + + proj.MainActivity += Environment.NewLine + "// Force trimmable typemap regeneration."; + proj.Touch ("MainActivity.cs"); + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true, saveProject: false), "Changed-input build should have succeeded."); + builder.Output.AssertTargetIsNotSkipped ("_GenerateTrimmableTypeMap"); + foreach (var typemapDll in typemapDlls) { + Assert.AreEqual (typemapWriteTimes [typemapDll], File.GetLastWriteTimeUtc (typemapDll), + $"A source change that does not affect the typemap model should skip PE emission for {typemapDll}."); + } } [Test] diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index 89075a5832d..cc6a0ec36b9 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -340,6 +340,128 @@ public void Execute_CanSkipUnusedNonAcwMarshalMethods () } } + [Fact] + public void GenerateTypeMapAssemblies_UnchangedFingerprintsSkipAllEmission () + { + var peers = new List { + CreatePeer ("MyApp", "MyApp.MainActivity", "my/app/MainActivity"), + CreatePeer ("MyLibrary", "MyLibrary.Widget", "my/library/Widget"), + }; + var fingerprints = new Dictionary (StringComparer.Ordinal); + var generator = CreateGenerator (); + var first = generator.GenerateTypeMapAssemblies ( + peers, + new Version (11, 0), + useSharedTypemapUniverse: true, + (name, fingerprint) => { + fingerprints.Add (name, fingerprint); + return true; + }); + DisposeGeneratedAssemblies (first); + + var second = generator.GenerateTypeMapAssemblies ( + peers, + new Version (11, 0), + useSharedTypemapUniverse: true, + (name, fingerprint) => !fingerprints [name].SequenceEqual (fingerprint)); + + Assert.Empty (second); + } + + [Fact] + public void Execute_IncrementalCallbackPreservesGeneratedBytes () + { + using var fullReader = CreateTestFixturePEReader (); + using var incrementalReader = CreateTestFixturePEReader (); + var generator = CreateGenerator (); + var full = generator.Execute ( + [Input ("TestFixtures", fullReader)], + new Version (11, 0), + new HashSet ()); + var incremental = generator.Execute ( + [Input ("TestFixtures", incrementalReader)], + new Version (11, 0), + new HashSet (), + shouldGenerateTypeMapAssembly: (_, _) => true); + + Assert.Equal (full.GeneratedAssemblies.Count, incremental.GeneratedAssemblies.Count); + for (int i = 0; i < full.GeneratedAssemblies.Count; i++) { + Assert.Equal (full.GeneratedAssemblies [i].Name, incremental.GeneratedAssemblies [i].Name); + Assert.Equal (full.GeneratedAssemblies [i].Content.ToArray (), incremental.GeneratedAssemblies [i].Content.ToArray ()); + } + DisposeGeneratedAssemblies (full.GeneratedAssemblies); + DisposeGeneratedAssemblies (incremental.GeneratedAssemblies); + } + + [Fact] + public void GenerateTypeMapAssemblies_ChangedCrossAssemblyAliasRegeneratesOwner () + { + var owner = CreatePeer ("Owner", "Owner.JavaObject", "java/lang/Object"); + var alias = CreatePeer ("Alias", "Alias.JavaObject", "java/lang/Object") with { + IsFromJniTypeSignature = true, + }; + var unrelatedAliasPeer = CreatePeer ("Alias", "Alias.Widget", "alias/Widget"); + var peers = new List { owner, alias, unrelatedAliasPeer }; + var fingerprints = new Dictionary (StringComparer.Ordinal); + var generator = CreateGenerator (); + var first = generator.GenerateTypeMapAssemblies ( + peers, + new Version (11, 0), + useSharedTypemapUniverse: true, + (name, fingerprint) => { + fingerprints.Add (name, fingerprint); + return true; + }); + DisposeGeneratedAssemblies (first); + + peers [1] = alias with { + ManagedTypeName = "Alias.ChangedJavaObject", + ManagedTypeShortName = "ChangedJavaObject", + }; + var regenerated = generator.GenerateTypeMapAssemblies ( + peers, + new Version (11, 0), + useSharedTypemapUniverse: true, + (name, fingerprint) => !fingerprints [name].SequenceEqual (fingerprint)); + + var assembly = Assert.Single (regenerated); + Assert.Equal ("_Owner.TypeMap", assembly.Name); + DisposeGeneratedAssemblies (regenerated); + } + + [Fact] + public void GenerateTypeMapAssemblies_ChangedAssemblySetRegeneratesRoot () + { + var initialPeers = new List { + CreatePeer ("MyApp", "MyApp.MainActivity", "my/app/MainActivity"), + }; + var fingerprints = new Dictionary (StringComparer.Ordinal); + var generator = CreateGenerator (); + var first = generator.GenerateTypeMapAssemblies ( + initialPeers, + new Version (11, 0), + useSharedTypemapUniverse: true, + (name, fingerprint) => { + fingerprints.Add (name, fingerprint); + return true; + }); + DisposeGeneratedAssemblies (first); + + var peersWithLibrary = new List (initialPeers) { + CreatePeer ("MyLibrary", "MyLibrary.Widget", "my/library/Widget"), + }; + var regenerated = generator.GenerateTypeMapAssemblies ( + peersWithLibrary, + new Version (11, 0), + useSharedTypemapUniverse: true, + (name, fingerprint) => !fingerprints.TryGetValue (name, out var prior) || !prior.SequenceEqual (fingerprint)); + + Assert.Equal ( + ["_MyLibrary.TypeMap", "_Microsoft.Android.TypeMaps"], + regenerated.Select (assembly => assembly.Name)); + DisposeGeneratedAssemblies (regenerated); + } + [Fact] public void Execute_CollectsDeferredRegistrationTypes_ForAllApplicationAndInstrumentationSubtypes () { @@ -356,6 +478,27 @@ public void Execute_CollectsDeferredRegistrationTypes_ForAllApplicationAndInstru Assert.Contains ("my.app.IntermediateInstrumentation", result.ApplicationRegistrationTypes); } + static JavaPeerInfo CreatePeer (string assemblyName, string managedTypeName, string javaName) + { + int separator = managedTypeName.LastIndexOf ('.'); + return new JavaPeerInfo { + JavaName = javaName, + CompatJniName = javaName, + ManagedTypeName = managedTypeName, + ManagedTypeNamespace = separator < 0 ? "" : managedTypeName.Substring (0, separator), + ManagedTypeShortName = separator < 0 ? managedTypeName : managedTypeName.Substring (separator + 1), + AssemblyName = assemblyName, + DoNotGenerateAcw = true, + }; + } + + static void DisposeGeneratedAssemblies (IEnumerable assemblies) + { + foreach (var assembly in assemblies) { + assembly.Content.Dispose (); + } + } + [Fact] public void CollectApplicationRegistrationTypes_ExcludesLegacyFrameworkDescendants () {