From f0ced1fa3ac21f436ffbaaad7e0af2f5460949ab Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 27 Jun 2026 01:28:18 +0200 Subject: [PATCH 1/6] [trimmable] Improve trimmable typemap build incrementality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trimmable typemap generator (`_GenerateTrimmableTypeMap`) and the JCW copy (`_GenerateJavaStubs`) had three incrementality gaps: * The generated TypeMap DLLs are written with `CopyIfStreamChanged`, so an unchanged assembly keeps its old timestamp. Because those DLLs were the only `Outputs`, the target's inputs were always newer than its (untouched) outputs and it re-ran on every build. Add a dedicated `_GenerateTrimmableTypeMap.stamp` that is always touched, so the target is correctly skipped on no-op builds, and use it as the `_GenerateJavaStubs` sentinel. * `@(PrivateSdkAssemblies)` and `@(FrameworkAssemblies)` were not declared as inputs even though they can contribute managed↔Java mappings, so changes in them did not trigger regeneration. * Removing a managed type left its stale JCW `.java` (and compiled `.class`) behind. `GenerateTrimmableTypeMap` now reports `DeletedJavaFiles`; the target mirrors the deletion into `android/src` and busts the Java compile stamp so `_CompileJava` drops the stale class. Also skip `_GenerateTrimmableTypeMap` in design-time builds (project references may resolve to paths not produced when `SkipCompilerExecution=true`, and the output is not needed for IDE information); combined with the existing inner per-RID guard the generator runs exactly once per build. JCWs are now copied with `SkipUnchangedFiles="true"`. Add a `README.md` documenting the trimmable typemap build pipeline and its incrementality design, and build tests covering stale-JCW pruning and copying updated JCWs when the typemap assemblies are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../README.md | 147 ++++++++++++++++++ ...soft.Android.Sdk.TypeMap.Trimmable.targets | 55 +++++-- .../Tasks/GenerateTrimmableTypeMap.cs | 32 ++++ .../TrimmableTypeMapBuildTests.cs | 82 ++++++++++ 4 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 src/Microsoft.Android.Sdk.TrimmableTypeMap/README.md diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/README.md b/src/Microsoft.Android.Sdk.TrimmableTypeMap/README.md new file mode 100644 index 00000000000..0ccfd653e39 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/README.md @@ -0,0 +1,147 @@ +# Trimmable typemap build pipeline + +This document describes how the **trimmable** typemap implementation +(`_AndroidTypeMapImplementation=trimmable`) is produced during an Android app +build, and how the MSBuild targets are kept incremental. It is aimed at +contributors working on the targets in +`src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable*.targets` +and the `GenerateTrimmableTypeMap` MSBuild task. + +## Background + +The legacy typemap implementations (`llvm-ir`, `managed`) embed the +managed ↔ Java type mapping into native binaries. The **trimmable** +implementation instead generates a set of small managed *TypeMap assemblies* +(one per input assembly, plus a `_Microsoft.Android.TypeMaps` root) and the Java +Callable Wrapper (JCW) `*.java` sources from the same scan. This keeps the +mapping trimmer-friendly: unused entries are removed by the IL linker. + +The work happens in the `GenerateTrimmableTypeMap` task, invoked from the +`_GenerateTrimmableTypeMap` target. The runtime is selected by `_AndroidRuntime` +(`CoreCLR` or `NativeAOT`); the runtime-specific imports +(`*.Trimmable.CoreCLR.targets`, `*.Trimmable.NativeAOT.targets`) extend the +shared pipeline. + +## Target pipeline (CoreCLR, non-trimmed Debug build) + +``` +CoreCompile + └─► _GenerateTrimmableTypeMap (AfterTargets="CoreCompile") + • scans @(ReferencePath) + framework/SDK assemblies + the app .dll + • writes typemap/_*.TypeMap.dll + _Microsoft.Android.TypeMaps.dll + • writes typemap/java/**/*.java (JCWs) and acw-map.txt + • writes the merged AndroidManifest.xml + • touches typemap/_GenerateTrimmableTypeMap.stamp + ... +_ReadGeneratedTrimmableTypeMapAssemblies (reads typemap-assemblies.txt) +_PrepareTrimmableNativeConfigAssemblies (feeds _GeneratePackageManagerJava) +_PrepareTrimmableTypeMapAssemblies (feeds packaging / assembly store) +_CollectTrimmableTypeMapJavaFiles (globs the JCW *.java) +_GenerateJavaStubs (copies JCWs into android/src, manifest, acw-map) + └─► _CompileJava ─► _CompileToDalvik ─► packaging +``` + +`_GenerateJavaStubs` **overrides** the legacy target of the same name from +`BuildOrder.targets`; in the trimmable path the JCWs already exist, so this +target only copies them into `$(IntermediateOutputPath)android/src` and wires up +the manifest, `acw-map.txt`, and native config. + +For `CoreCLR` + `PublishTrimmed=true`, a second pass +(`_GeneratePostTrimTrimmableTypeMapJavaSources`, in the CoreCLR targets) +regenerates the JCWs from the **linked** assemblies into a `linked-java` +directory, which then becomes the source for `_GenerateJavaStubs`. + +## Incrementality design + +The pipeline follows the repository's +[MSBuild best practices](../../Documentation/guides/MSBuildBestPractices.md): +every expensive target declares `Inputs`/`Outputs`, re-emits its dynamic +`FileWrites`, and uses stamp files where a real output cannot serve as a +reliable timestamp sentinel. + +### 1. A stamp file is the generator's incremental sentinel + +`_GenerateTrimmableTypeMap` declares: + +```xml +Inputs="@(ReferencePath);@(PrivateSdkAssemblies);@(FrameworkAssemblies);$(IntermediateOutputPath)$(TargetFileName);$(_AndroidManifestAbs);$(_AndroidBuildPropertiesCache)" +Outputs="$(_TypeMapOutputDirectory)$(_TypeMapAssemblyName).dll;$(_TypeMapAssembliesListFile);$(_TrimmableTypeMapOutputStamp)" +``` + +The generated TypeMap DLLs are written with `Files.CopyIfStreamChanged`, so an +assembly whose **content** is unchanged keeps its old timestamp. If those DLLs +were the only `Outputs`, MSBuild would consider the target perpetually +out-of-date (its inputs are always newer than the untouched outputs) and re-run +it on every build. To avoid this, the target unconditionally `Touch`es a +dedicated stamp: + +```xml + +``` + +so the stamp is always newer than the inputs after a run, and the target is +correctly **skipped** when none of the inputs changed. + +> All assemblies that can contribute managed ↔ Java mappings must be +> inputs — including `@(PrivateSdkAssemblies)` and `@(FrameworkAssemblies)` — +> otherwise a change in one of them would not trigger regeneration. + +### 2. `_GenerateJavaStubs` keys off the stamp + +```xml +Inputs="$(_TrimmableTypeMapOutputStamp);@(_EnvironmentFiles)" +Outputs="$(_AndroidStampDirectory)_GenerateJavaStubs.stamp" +``` + +The stamp captures "the generator ran because its inputs changed" and is left +stable when the generator is skipped, so the JCW copy into `android/src` only +re-runs when something relevant actually changed. The copy uses +`SkipUnchangedFiles="true"` so unchanged JCWs do not churn downstream Java +compilation. For `CoreCLR` + `PublishTrimmed`, the JCWs are sourced from the +`linked-java` directory produced by `_GeneratePostTrimTrimmableTypeMapJavaSources`, +which is itself incremental; the stamp remains the sentinel so a no-op build +still skips `_GenerateJavaStubs`. + +### 3. Stale generated Java sources are pruned + +When a managed type is removed, its JCW must not linger. The task's +`DeleteStaleJavaSources` enumerates the JCW output directory and deletes any +`*.java` the current pass did not produce, returning them as `DeletedJavaFiles` +(with `RelativePath` metadata). The target mirrors each deletion into the +`android/src` copy and, if anything was deleted, deletes +`$(_AndroidCompileJavaStampFile)` so `_CompileJava` re-runs and drops the stale +`.class` outputs: + +```xml + + +``` + +### 4. Dynamic `FileWrites` are re-emitted on no-op builds + +The set of generated assemblies and JCWs is data-dependent, so a build that +*skips* `_GenerateTrimmableTypeMap` never executes the `ItemGroup` that registers +those files in `@(FileWrites)`. `_RecordTrimmableTypeMapFileWrites` re-reads the +generated outputs from `typemap-assemblies.txt` (and globs the JCWs) and +re-emits them — plus the stamp — into `@(FileWrites)` *before* MSBuild's +`IncrementalClean`, so the outputs are not seen as orphaned and deleted between +incremental builds. + +### 5. The generator does not run in design-time builds, and runs once + +`_GenerateTrimmableTypeMap` is gated on `'$(DesignTimeBuild)' != 'true'`: in a +design-time build, project references may resolve to target paths that are not +produced when `SkipCompilerExecution=true`, and the generator output is not +needed to provide IDE information. Combined with the +`'$(_OuterIntermediateOutputPath)' == ''` guard (which skips inner per-RID +builds), the generator runs exactly once per outer build. + +## Files + +| File | Role | +| ---- | ---- | +| `Microsoft.Android.Sdk.TypeMap.Trimmable.targets` | Shared pipeline: generation, Java stubs, packaging hookup, incremental `FileWrites`. | +| `Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets` | CoreCLR specifics, incl. the post-trim `linked-java` regeneration. | +| `Microsoft.Android.Sdk.TypeMap.Trimmable.NativeAOT.targets` | NativeAOT specifics (ILC inputs, proguard). | +| `Tasks/GenerateTrimmableTypeMap.cs` | The MSBuild task front-end for the generator. | +| `Microsoft.Android.Sdk.TrimmableTypeMap/**` | The generator/scanner library invoked by the task. | 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 0a3ff223f63..1eb761f59f3 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 @@ -34,6 +34,12 @@ <_PostTrimTrimmableTypeMapJavaStamp>$(_PostTrimTypeMapJavaBaseOutputDir)stamp/_GeneratePostTrimTrimmableTypeMapJavaSources.stamp <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true' ">$(_PostTrimTrimmableTypeMapJavaStamp) <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' ">$(_TypeMapOutputDirectory)$(_TypeMapAssemblyName).dll + + <_TrimmableTypeMapOutputStamp>$(_TypeMapOutputDirectory)_GenerateTrimmableTypeMap.stamp @@ -78,28 +84,36 @@ Generate TypeMap assemblies and JCW files. AfterTargets="CoreCompile" so it runs after compilation. Uses @(ReferencePath) as the primary input (available after compilation). + Skipped in design-time builds because project references may resolve to target + paths that are not produced when SkipCompilerExecution=true, and because the + generator output is not needed to provide IDE design-time information. Skipped in inner per-RID builds (_OuterIntermediateOutputPath is set) because those builds lack the manifest template and full assembly set needed for correct deferred-registration propagation. --> + Inputs="@(ReferencePath);@(PrivateSdkAssemblies);@(FrameworkAssemblies);$(IntermediateOutputPath)$(TargetFileName);$(_AndroidManifestAbs);$(_AndroidBuildPropertiesCache)" + Outputs="$(_TypeMapOutputDirectory)$(_TypeMapAssemblyName).dll;$(_TypeMapAssembliesListFile);$(_TrimmableTypeMapOutputStamp)"> <_TypeMapInputAssemblies Include="@(ReferencePath)" /> <_TypeMapInputAssemblies Include="@(ResolvedAssemblies)" /> <_TypeMapInputAssemblies Include="@(ResolvedFrameworkAssemblies)" /> + <_TypeMapInputAssemblies Include="@(PrivateSdkAssemblies)" /> + <_TypeMapInputAssemblies Include="@(FrameworkAssemblies)" /> + <_TypeMapFrameworkAssemblies Include="@(ResolvedFrameworkAssemblies)" /> + <_TypeMapFrameworkAssemblies Include="@(PrivateSdkAssemblies)" /> + <_TypeMapFrameworkAssemblies Include="@(FrameworkAssemblies)" /> <_TypeMapInputAssemblies Include="$(IntermediateOutputPath)$(TargetFileName)" Condition="Exists('$(IntermediateOutputPath)$(TargetFileName)')" /> + + + <_DeletedCopiedJavaFiles Remove="@(_DeletedCopiedJavaFiles)" /> + <_DeletedCopiedJavaFiles Include="@(_DeletedJavaFiles->'$(IntermediateOutputPath)android/src/%(RelativePath)')" /> + + + + + + + + + + + @@ -184,6 +217,7 @@ + @@ -249,21 +283,22 @@ so this target only handles JCW file copying, manifest, assembly store setup, and native config. We keep the name _GenerateJavaStubs because BuildOrder.targets references it. - Inputs uses the TypeMap DLL as a focused sentinel — _GenerateTrimmableTypeMap regenerates - the DLL whenever any of its own inputs (assemblies, manifest, etc.) change, so the DLL - timestamp is a reliable proxy for "something changed that requires re-copying". + Inputs uses the TypeMap output stamp as a focused sentinel — _GenerateTrimmableTypeMap + touches it whenever any of its own inputs (assemblies, manifest, etc.) change, and is + skipped (leaving the stamp stable) on no-op builds. The Copy below then updates + android/src only for Java sources whose content changed. We keep _GetGenerateJavaStubsInputs in DependsOnTargets so that downstream targets (_GetGeneratePackageManagerJavaInputs) can still read @(_GenerateJavaStubsInputs). --> <_TypeMapJavaFiles Include="$(_TypeMapJavaStubsSourceDirectory)/**/*.java" /> - + diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 9a03ef4ff21..0da28954c05 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -10,6 +10,7 @@ using Microsoft.Android.Sdk.TrimmableTypeMap; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; +using Xamarin.Android.Tools; namespace Xamarin.Android.Tasks; @@ -101,6 +102,8 @@ public void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeN [Output] public ITaskItem [] GeneratedJavaFiles { get; set; } = []; [Output] + public ITaskItem [] DeletedJavaFiles { get; set; } = []; + [Output] public string[]? AdditionalProviderSources { get; set; } public override bool RunTask () @@ -192,6 +195,7 @@ public override bool RunTask () GeneratedJavaFiles = JavaSourceInputDirectory.IsNullOrEmpty () ? WriteJavaSourcesToDisk (result.GeneratedJavaSources) : CopyJavaSourcesFromInputDirectory (result.GeneratedJavaSources); + DeletedJavaFiles = DeleteStaleJavaSources (GeneratedJavaFiles); // Write manifest to disk if generated if (result.Manifest is not null && !MergedAndroidManifestOutput.IsNullOrEmpty ()) { @@ -370,6 +374,34 @@ ITaskItem [] WriteJavaSourcesToDisk (IReadOnlyList javaSour return items.ToArray (); } + // Removes generated Java sources from a previous build that the current generation pass + // no longer produces (for example when a managed type is removed). Returns the deleted + // files (with a RelativePath metadata) so the targets can mirror the deletion into the + // android/src copies and force a Java recompilation. + ITaskItem [] DeleteStaleJavaSources (IReadOnlyCollection generatedJavaFiles) + { + var expectedFiles = new HashSet ( + generatedJavaFiles.Select (i => Path.GetFullPath (i.ItemSpec)), + Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + var deleted = new List (); + + foreach (var path in Directory.EnumerateFiles (JavaSourceOutputDirectory, "*.java", SearchOption.AllDirectories)) { + var fullPath = Path.GetFullPath (path); + if (expectedFiles.Contains (fullPath)) { + continue; + } + + File.Delete (fullPath); + Log.LogDebugMessage ($"Deleted stale generated Java source '{fullPath}'."); + + var item = new TaskItem (fullPath); + item.SetMetadata ("RelativePath", PathUtil.GetRelativePath (JavaSourceOutputDirectory, fullPath)); + deleted.Add (item); + } + + return deleted.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/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 0315b20bf34..ebb705712c8 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 @@ -67,6 +67,88 @@ public void Build_WithTrimmableTypeMap_IncrementalBuild ([Values] bool isRelease } } + [Test] + public void Build_WithTrimmableTypeMap_DeletesStaleGeneratedJavaSourcesAndCopies () + { + if (IgnoreUnsupportedConfiguration (AndroidRuntime.CoreCLR, release: false)) { + return; + } + + var proj = new XamarinAndroidApplicationProject (); + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable"); + + using var builder = CreateApkBuilder (); + Assert.IsTrue (builder.Build (proj), "First build should have succeeded."); + + var staleRelativePath = Path.Combine ("crc64stale", "Old.java"); + var staleClassPath = Path.Combine ("crc64stale", "Old.class"); + var staleGeneratedJava = builder.Output.GetIntermediaryPath (Path.Combine ("typemap", "java", staleRelativePath)); + var staleCopiedJava = builder.Output.GetIntermediaryPath (Path.Combine ("android", "src", staleRelativePath)); + var staleCompiledClass = builder.Output.GetIntermediaryPath (Path.Combine ("android", "bin", "classes", staleClassPath)); + var staleGeneratedJavaDirectory = Path.GetDirectoryName (staleGeneratedJava); + var staleCopiedJavaDirectory = Path.GetDirectoryName (staleCopiedJava); + var staleCompiledClassDirectory = Path.GetDirectoryName (staleCompiledClass); + if (staleGeneratedJavaDirectory is null || staleCopiedJavaDirectory is null || staleCompiledClassDirectory is null) { + throw new InvalidOperationException ("Could not determine stale Java output directories."); + } + Directory.CreateDirectory (staleGeneratedJavaDirectory); + Directory.CreateDirectory (staleCopiedJavaDirectory); + Directory.CreateDirectory (staleCompiledClassDirectory); + File.WriteAllText (staleGeneratedJava, "package crc64stale; public class Old {}"); + File.WriteAllText (staleCopiedJava, "package crc64stale; public class Old {}"); + File.WriteAllBytes (staleCompiledClass, []); + + proj.MainActivity += Environment.NewLine + "// Force trimmable typemap regeneration."; + proj.Touch ("MainActivity.cs"); + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true, saveProject: false), "Second build should have succeeded."); + builder.Output.AssertTargetIsNotSkipped ("_GenerateTrimmableTypeMap"); + builder.Output.AssertTargetIsNotSkipped ("_CompileJava"); + + FileAssert.DoesNotExist (staleGeneratedJava, "Regenerated trimmable typemap should delete stale Java sources."); + FileAssert.DoesNotExist (staleCopiedJava, "Regenerated trimmable typemap should delete stale android/src Java copies."); + FileAssert.DoesNotExist (staleCompiledClass, "Deleting stale copied Java sources should force Java recompilation and remove stale class outputs."); + } + + [Test] + public void Build_WithTrimmableTypeMap_CopiesUpdatedGeneratedJavaSources () + { + if (IgnoreUnsupportedConfiguration (AndroidRuntime.CoreCLR, release: false)) { + return; + } + + var proj = new XamarinAndroidApplicationProject (); + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable"); + + using var builder = CreateApkBuilder (); + Assert.IsTrue (builder.Build (proj), "First build should have succeeded."); + + var generatedJavaDirectory = builder.Output.GetIntermediaryPath (Path.Combine ("typemap", "java")); + var generatedJavaFiles = Directory.GetFiles (generatedJavaDirectory, "*.java", SearchOption.AllDirectories); + Assert.IsNotEmpty (generatedJavaFiles, "Test setup should have generated trimmable typemap Java sources."); + + var generatedJava = generatedJavaFiles [0]; + var relativePath = Path.GetRelativePath (generatedJavaDirectory, generatedJava); + var copiedJava = builder.Output.GetIntermediaryPath (Path.Combine ("android", "src", relativePath)); + var typeMapStamp = builder.Output.GetIntermediaryPath (Path.Combine ("typemap", "_GenerateTrimmableTypeMap.stamp")); + var javaStubsStamp = builder.Output.GetIntermediaryPath (Path.Combine ("stamp", "_GenerateJavaStubs.stamp")); + FileAssert.Exists (copiedJava, "First build should have copied generated Java sources to android/src."); + FileAssert.Exists (typeMapStamp, "First build should have written the trimmable typemap output stamp."); + FileAssert.Exists (javaStubsStamp, "First build should have written the Java stubs output stamp."); + + var updatedJava = File.ReadAllText (generatedJava) + "\n// Force generated Java copy regression.\n"; + File.WriteAllText (generatedJava, updatedJava); + var stampTime = DateTime.UtcNow; + File.SetLastWriteTimeUtc (typeMapStamp, stampTime); + File.SetLastWriteTimeUtc (javaStubsStamp, stampTime.AddSeconds (-5)); + + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true), "Second build should have succeeded."); + builder.Output.AssertTargetIsNotSkipped ("_GenerateJavaStubs"); + builder.Output.AssertTargetIsNotSkipped ("_CompileJava"); + Assert.AreEqual (updatedJava, File.ReadAllText (copiedJava), "Updated generated Java sources should be copied to android/src even when typemap assemblies do not change."); + } + [Test] public void Build_WithTrimmableTypeMap_ArrayRankChangeRegeneratesTypeMap () { From 4dab8525a96f9de4840fbe298586afe2f618d681 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 27 Jun 2026 11:14:47 +0200 Subject: [PATCH 2/6] [TrimmableTypeMap] Add PublishTrimmed android/src incrementality tests Add two build tests for the CoreCLR + PublishTrimmed trimmable typemap path, where the JCWs that get compiled and packaged come from the post-trim `typemap/linked-java` directory: * `..._KeepsAndroidSrcConsistentWithLinkedJava` asserts the correctness invariant that every linked-java JCW has an identical copy under `android/src`, after both the initial build and a no-op rebuild. * `..._PostTrimJavaGenerationIsIncremental` asserts that a no-op rebuild skips `_GeneratePostTrimTrimmableTypeMapJavaSources` and `_GenerateJavaStubs`. It is currently `[Ignore]`d because post-trim Java generation runs on every build today; it documents the incrementality gap to fix (make post-trim incremental and key `_GenerateJavaStubs` off `_TrimmableJavaSourceStamp`). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMapBuildTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) 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 ebb705712c8..83b7c15c2d5 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 @@ -149,6 +149,84 @@ public void Build_WithTrimmableTypeMap_CopiesUpdatedGeneratedJavaSources () Assert.AreEqual (updatedJava, File.ReadAllText (copiedJava), "Updated generated Java sources should be copied to android/src even when typemap assemblies do not change."); } + // The JCWs that actually get compiled and packaged are the ones copied into + // $(IntermediateOutputPath)android/src. For CoreCLR + PublishTrimmed those come from + // the post-trim `typemap/linked-java` directory, which `_GeneratePostTrimTrimmableTypeMapJavaSources` + // (re)generates from the linked assemblies. The incrementality contract is that + // android/src must always stay consistent with linked-java; if `_GenerateJavaStubs` is + // skipped while linked-java changed, android/src would be left stale. + static void AssertAndroidSrcMatchesLinkedJava (ProjectBuilder builder, string message) + { + var intermediate = builder.Output.GetIntermediaryPath (""); + var linkedJavaDirectory = Directory.GetDirectories (intermediate, "linked-java", SearchOption.AllDirectories).FirstOrDefault (); + Assert.IsNotNull (linkedJavaDirectory, $"{message}: post-trim linked-java directory should exist under '{intermediate}'."); + // The JCWs that get compiled live in the same intermediate tree under android/src. + var androidSrcDirectory = Path.Combine (Path.GetDirectoryName (Path.GetDirectoryName (linkedJavaDirectory)), "android", "src"); + DirectoryAssert.Exists (androidSrcDirectory, $"{message}: android/src directory should exist."); + + var linkedJavaFiles = Directory.GetFiles (linkedJavaDirectory, "*.java", SearchOption.AllDirectories); + Assert.IsNotEmpty (linkedJavaFiles, $"{message}: post-trim build should have generated linked-java JCWs."); + + foreach (var linkedJava in linkedJavaFiles) { + var relativePath = Path.GetRelativePath (linkedJavaDirectory, linkedJava); + var copiedJava = Path.Combine (androidSrcDirectory, relativePath); + FileAssert.Exists (copiedJava, $"{message}: linked-java JCW '{relativePath}' should be copied to android/src."); + Assert.AreEqual ( + File.ReadAllText (linkedJava), + File.ReadAllText (copiedJava), + $"{message}: android/src copy of '{relativePath}' should match the post-trim linked-java source."); + } + } + + [Test] + public void Build_WithTrimmableTypeMap_PublishTrimmed_KeepsAndroidSrcConsistentWithLinkedJava () + { + if (IgnoreUnsupportedConfiguration (AndroidRuntime.CoreCLR, release: true)) { + return; + } + + var proj = new XamarinAndroidApplicationProject { + IsRelease = true, + }; + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable"); + + using var builder = CreateApkBuilder (); + Assert.IsTrue (builder.Build (proj), "First build should have succeeded."); + AssertAndroidSrcMatchesLinkedJava (builder, "After first build"); + + // A no-op rebuild must not leave android/src out of sync with linked-java, even though + // the post-trim Java generation may run again. + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true, saveProject: false), "No-op rebuild should have succeeded."); + AssertAndroidSrcMatchesLinkedJava (builder, "After no-op rebuild"); + } + + [Test] + [Ignore ("Documents a known incrementality gap: _GeneratePostTrimTrimmableTypeMapJavaSources currently runs on every Release CoreCLR build. Re-enable once post-trim Java generation is made incremental and _GenerateJavaStubs keys off _TrimmableJavaSourceStamp.")] + public void Build_WithTrimmableTypeMap_PublishTrimmed_PostTrimJavaGenerationIsIncremental () + { + if (IgnoreUnsupportedConfiguration (AndroidRuntime.CoreCLR, release: true)) { + return; + } + + var proj = new XamarinAndroidApplicationProject { + IsRelease = true, + }; + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable"); + + using var builder = CreateApkBuilder (); + Assert.IsTrue (builder.Build (proj), "First build should have succeeded."); + + // A no-op rebuild should not regenerate the post-trim JCWs or recopy them. If + // _GeneratePostTrimTrimmableTypeMapJavaSources runs on every build, the JCWs that feed + // _GenerateJavaStubs are rewritten each time, which both wastes work and means + // _GenerateJavaStubs must re-run to stay consistent (otherwise android/src goes stale). + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true, saveProject: false), "No-op rebuild should have succeeded."); + builder.Output.AssertTargetIsSkipped ("_GeneratePostTrimTrimmableTypeMapJavaSources"); + builder.Output.AssertTargetIsSkipped ("_GenerateJavaStubs"); + } + [Test] public void Build_WithTrimmableTypeMap_ArrayRankChangeRegeneratesTypeMap () { From 8a7037204e081d7748b78af1c139bf6fd91aa26f Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 27 Jun 2026 11:51:02 +0200 Subject: [PATCH 3/6] [TrimmableTypeMap] Make post-trim Java generation incremental Addresses review feedback on the _GenerateJavaStubs incremental sentinel and fixes the underlying reason it was hard to get right: post-trim Java generation ran on every Release CoreCLR build. Root cause: _GeneratePostTrimTrimmableTypeMapJavaSources declared `@(ResolvedFileToPublish)` as its Inputs, which also contains non-assembly publish outputs (e.g. UnnamedProject.runtimeconfig.json) whose paths do not exist when the target runs. MSBuild treats a non-existent Input as out-of-date, so the target ran every build, wiped and regenerated `typemap/linked-java`, and forced downstream copying. Changes: * Compute the post-trim input assemblies in a new `_ComputePostTrimTrimmableTypeMapInputs` target, filtered to `.dll` files that exist on disk, and use that item as the target's Inputs. The target now skips on a no-op rebuild. * `_GenerateJavaStubs` keys its incrementality off `$(_TrimmableJavaSourceStamp)` (the post-trim stamp for CoreCLR + PublishTrimmed, the generator stamp otherwise) instead of the generator stamp alone, so it reacts to post-trim JCW regeneration while staying incremental (review feedback from @Copilot). The non-trim default of `_TrimmableJavaSourceStamp` is redefined from the TypeMap DLL to the generator stamp. * Re-emit the post-trim `linked-java` outputs (and their android/src copies and stamp) from `_RecordTrimmableTypeMapFileWrites` so MSBuild's IncrementalClean does not delete them on builds where post-trim is skipped. * `_GenerateTrimmableTypeMap` now declares only the stamp as its `Outputs` and touches only the stamp; the generated DLLs/list are written via CopyIfStreamChanged and are not consumed as timestamp Inputs elsewhere (review feedback from @jonathanpeppers). * Re-enable Build_WithTrimmableTypeMap_PublishTrimmed_PostTrimJavaGenerationIsIncremental (previously [Ignore]d); it now passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 23 +++++++++++--- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 31 ++++++++++++++----- .../TrimmableTypeMapBuildTests.cs | 1 - 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index b21afc1b6e4..be4d061e566 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -48,16 +48,29 @@ OutputFile="$(_ProguardProjectConfiguration)" /> - + BeforeTargets="_GeneratePostTrimTrimmableTypeMapJavaSources"> + <_PostTrimTrimmableTypeMapInputAssemblies Remove="@(_PostTrimTrimmableTypeMapInputAssemblies)" /> + <_PostTrimTrimmableTypeMapInputAssemblies Include="@(ResolvedFileToPublish)" - Condition=" '%(Extension)' == '.dll' and ('%(RuntimeIdentifier)' == '' or '$(_PostTrimTypeMapFirstRuntimeIdentifier)' == '' or '%(RuntimeIdentifier)' == '$(_PostTrimTypeMapFirstRuntimeIdentifier)') " /> + Condition=" '%(Extension)' == '.dll' and Exists('%(FullPath)') and ('%(RuntimeIdentifier)' == '' or '$(_PostTrimTypeMapFirstRuntimeIdentifier)' == '' or '%(RuntimeIdentifier)' == '$(_PostTrimTypeMapFirstRuntimeIdentifier)') " /> + + + $(_PostTrimTypeMapJavaOutputDirectory) <_TypeMapJavaStubsSourceDirectory Condition=" '$(_TypeMapJavaStubsSourceDirectory)' == '' ">$(_TypeMapJavaOutputDirectory) <_PostTrimTrimmableTypeMapJavaStamp>$(_PostTrimTypeMapJavaBaseOutputDir)stamp/_GeneratePostTrimTrimmableTypeMapJavaSources.stamp - <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true' ">$(_PostTrimTrimmableTypeMapJavaStamp) - <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' ">$(_TypeMapOutputDirectory)$(_TypeMapAssemblyName).dll <_TrimmableTypeMapOutputStamp>$(_TypeMapOutputDirectory)_GenerateTrimmableTypeMap.stamp + + <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true' ">$(_PostTrimTrimmableTypeMapJavaStamp) + <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' ">$(_TrimmableTypeMapOutputStamp) @@ -95,7 +99,7 @@ Condition=" '$(_AndroidTypeMapImplementation)' == 'trimmable' and '$(DesignTimeBuild)' != 'true' and '@(ReferencePath->Count())' != '0' and '$(_OuterIntermediateOutputPath)' == '' " AfterTargets="CoreCompile" Inputs="@(ReferencePath);@(PrivateSdkAssemblies);@(FrameworkAssemblies);$(IntermediateOutputPath)$(TargetFileName);$(_AndroidManifestAbs);$(_AndroidBuildPropertiesCache)" - Outputs="$(_TypeMapOutputDirectory)$(_TypeMapAssemblyName).dll;$(_TypeMapAssembliesListFile);$(_TrimmableTypeMapOutputStamp)"> + Outputs="$(_TrimmableTypeMapOutputStamp)"> <_TypeMapInputAssemblies Include="@(ReferencePath)" /> @@ -153,10 +157,11 @@ - - + + @@ -206,16 +211,26 @@ <_GeneratedTypeMapAssembliesForFileWrites Remove="@(_GeneratedTypeMapAssembliesForFileWrites)" /> <_TypeMapJavaFilesForFileWrites Remove="@(_TypeMapJavaFilesForFileWrites)" /> + <_TypeMapPostTrimJavaFilesForFileWrites Remove="@(_TypeMapPostTrimJavaFilesForFileWrites)" /> <_TypeMapJavaFilesForFileWrites Include="$(_TypeMapJavaOutputDirectory)/**/*.java" /> + + <_TypeMapPostTrimJavaFilesForFileWrites Include="$(_PostTrimTypeMapJavaOutputDirectory)/**/*.java" /> + + + @@ -292,7 +307,7 @@ --> 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 83b7c15c2d5..395f0c63aab 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 @@ -202,7 +202,6 @@ public void Build_WithTrimmableTypeMap_PublishTrimmed_KeepsAndroidSrcConsistentW } [Test] - [Ignore ("Documents a known incrementality gap: _GeneratePostTrimTrimmableTypeMapJavaSources currently runs on every Release CoreCLR build. Re-enable once post-trim Java generation is made incremental and _GenerateJavaStubs keys off _TrimmableJavaSourceStamp.")] public void Build_WithTrimmableTypeMap_PublishTrimmed_PostTrimJavaGenerationIsIncremental () { if (IgnoreUnsupportedConfiguration (AndroidRuntime.CoreCLR, release: true)) { From 6441109008ed8031842f025f8e085f8663d51a1b Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 27 Jun 2026 16:14:25 +0200 Subject: [PATCH 4/6] [TrimmableTypeMap] Prune stale android/src JCWs on the post-trim path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code-review feedback that the PublishTrimmed path could leave stale Java sources in android/src. On CoreCLR + PublishTrimmed, the JCWs that get compiled and packaged are copied into android/src from the post-trim `typemap/linked-java` directory. When a type is trimmed away between builds, _GeneratePostTrimTrimmableTypeMapJavaSources regenerates linked-java without that type's JCW, but `_GenerateJavaStubs` only copies (with SkipUnchangedFiles) and never removed the now-orphaned android/src copy — so a stale .java (and its .class) could be compiled into the app. The existing stale-pruning only covered the non-trim `typemap/java` pass. Fix, symmetric with the non-trim pass: * GenerateTrimmableTypeMap: when CleanJavaSourceOutputDirectory is set (the post-trim pass wipes its output dir before regenerating), snapshot the previous *.java set before the wipe and report DeletedJavaFiles as (previous - regenerated). This keeps the deletion precise — only files the generator itself previously produced are reported, never unrelated android/src sources like ApplicationRegistration.java. Replaces the post-write rescan in the clean case (it could never find anything in a freshly-wiped dir). * _GeneratePostTrimTrimmableTypeMapJavaSources: consume DeletedJavaFiles, mirror the deletions into the android/src copies, and bust the Java compile stamp so _CompileJava drops the stale class — reusing the block from _GenerateTrimmableTypeMap. Also from review: * Add Build_WithTrimmableTypeMap_PublishTrimmed_DeletesStaleAndroidSrcWhenLinkedJavaShrinks (fails before this fix, passes after). * Make AssertAndroidSrcMatchesLinkedJava derive both directories from GetIntermediaryPath instead of walking parents of a recursively-found dir, and drop the post-Assert.IsNotNull nullable reliance. * Remove the redundant BeforeTargets on _ComputePostTrimTrimmableTypeMapInputs (ordering is already guaranteed by the consumer's DependsOnTargets). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 18 +++++- .../Tasks/GenerateTrimmableTypeMap.cs | 46 +++++++++++++--- .../TrimmableTypeMapBuildTests.cs | 55 +++++++++++++++++-- 3 files changed, 103 insertions(+), 16 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index be4d061e566..1beb4ab9505 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -50,8 +50,7 @@ + AfterTargets="_ResolveAssemblies"> <_PostTrimTrimmableTypeMapInputAssemblies Remove="@(_PostTrimTrimmableTypeMapInputAssemblies)" /> + + <_PostTrimDeletedCopiedJavaFiles Remove="@(_PostTrimDeletedCopiedJavaFiles)" /> + <_PostTrimDeletedCopiedJavaFiles Include="@(_PostTrimDeletedJavaFiles->'$(IntermediateOutputPath)android/src/%(RelativePath)')" /> + + + + + + <_PostTrimTrimmableTypeMapInputAssemblies Remove="@(_PostTrimTrimmableTypeMapInputAssemblies)" /> <_PostTrimGeneratedJavaFiles Remove="@(_PostTrimGeneratedJavaFiles)" /> + <_PostTrimDeletedJavaFiles Remove="@(_PostTrimDeletedJavaFiles)" /> + <_PostTrimDeletedCopiedJavaFiles Remove="@(_PostTrimDeletedCopiedJavaFiles)" />