diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/README.md b/src/Microsoft.Android.Sdk.TrimmableTypeMap/README.md new file mode 100644 index 00000000000..941d583da37 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/README.md @@ -0,0 +1,165 @@ +# 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 (both passes) + +When a managed type is removed — or trimmed away on the `PublishTrimmed` path — +its JCW must not linger in `android/src`, where it would otherwise be compiled +and packaged. Both generator passes report the JCWs they no longer produce as +`DeletedJavaFiles` (with `RelativePath` metadata), and the owning 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 + + +``` + +The two passes compute the deleted set differently because of how each manages +its output directory: + +- **Pre-trim** (`_GenerateTrimmableTypeMap`, writing `typemap/java`): the task + scans the output directory and deletes any `*.java` the current pass did not + produce. +- **Post-trim** (`_GeneratePostTrimTrimmableTypeMapJavaSources`, writing + `typemap/linked-java` with `CleanJavaSourceOutputDirectory=true`): the + directory is wiped before regeneration, so the task snapshots the previous + `*.java` set *before* the wipe and reports `previous − regenerated`. This keeps + the deletion precise — only files the generator itself previously produced are + ever removed from `android/src`, never unrelated sources such as + `ApplicationRegistration.java`. + +The invariant is two-directional: **`android/src` contains exactly the JCWs the +active pass produces** — no missing files (copied via `_GenerateJavaStubs`) and +no stale files (pruned via `DeletedJavaFiles`). + +### 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.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index b21afc1b6e4..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 @@ -48,16 +48,28 @@ OutputFile="$(_ProguardProjectConfiguration)" /> + + + <_PostTrimTrimmableTypeMapInputAssemblies Remove="@(_PostTrimTrimmableTypeMapInputAssemblies)" /> + + <_PostTrimTrimmableTypeMapInputAssemblies Include="@(ResolvedFileToPublish)" + Condition=" '%(Extension)' == '.dll' and Exists('%(FullPath)') and ('%(RuntimeIdentifier)' == '' or '$(_PostTrimTypeMapFirstRuntimeIdentifier)' == '' or '%(RuntimeIdentifier)' == '$(_PostTrimTypeMapFirstRuntimeIdentifier)') " /> + + + - - <_PostTrimTrimmableTypeMapInputAssemblies Include="@(ResolvedFileToPublish)" - Condition=" '%(Extension)' == '.dll' and ('%(RuntimeIdentifier)' == '' or '$(_PostTrimTypeMapFirstRuntimeIdentifier)' == '' or '%(RuntimeIdentifier)' == '$(_PostTrimTypeMapFirstRuntimeIdentifier)') " /> - + + + + <_PostTrimDeletedCopiedJavaFiles Remove="@(_PostTrimDeletedCopiedJavaFiles)" /> + <_PostTrimDeletedCopiedJavaFiles Include="@(_PostTrimDeletedJavaFiles->'$(IntermediateOutputPath)android/src/%(RelativePath)')" /> + + + + + + <_PostTrimTrimmableTypeMapInputAssemblies Remove="@(_PostTrimTrimmableTypeMapInputAssemblies)" /> <_PostTrimGeneratedJavaFiles Remove="@(_PostTrimGeneratedJavaFiles)" /> + <_PostTrimDeletedJavaFiles Remove="@(_PostTrimDeletedJavaFiles)" /> + <_PostTrimDeletedCopiedJavaFiles Remove="@(_PostTrimDeletedCopiedJavaFiles)" /> + <_TrimmableTypeMapOutputStamp>$(_TypeMapOutputDirectory)_GenerateTrimmableTypeMap.stamp + <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' and '$(_AndroidRuntime)' == 'CoreCLR' and '$(PublishTrimmed)' == 'true' ">$(_PostTrimTrimmableTypeMapJavaStamp) - <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' ">$(_TypeMapOutputDirectory)$(_TypeMapAssemblyName).dll + <_TrimmableJavaSourceStamp Condition=" '$(_TrimmableJavaSourceStamp)' == '' ">$(_TrimmableTypeMapOutputStamp) @@ -78,28 +88,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="$(_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)')" /> + + + + + + + + + + + @@ -173,17 +211,28 @@ <_GeneratedTypeMapAssembliesForFileWrites Remove="@(_GeneratedTypeMapAssembliesForFileWrites)" /> <_TypeMapJavaFilesForFileWrites Remove="@(_TypeMapJavaFilesForFileWrites)" /> + <_TypeMapPostTrimJavaFilesForFileWrites Remove="@(_TypeMapPostTrimJavaFilesForFileWrites)" /> <_TypeMapJavaFilesForFileWrites Include="$(_TypeMapJavaOutputDirectory)/**/*.java" /> + + <_TypeMapPostTrimJavaFilesForFileWrites Include="$(_PostTrimTypeMapJavaOutputDirectory)/**/*.java" /> + + + + @@ -249,21 +298,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..fa4db37cfec 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 () @@ -129,8 +132,18 @@ public override bool RunTask () } Directory.CreateDirectory (OutputDirectory); - if (CleanJavaSourceOutputDirectory && Directory.Exists (JavaSourceOutputDirectory)) { - Directory.Delete (JavaSourceOutputDirectory, recursive: true); + string[]? priorJavaSnapshot = null; + if (CleanJavaSourceOutputDirectory) { + // Capture the previously generated set before wiping it, so DeleteStaleJavaSources can + // report which Java sources are no longer produced (e.g. a type that was trimmed away). + // An empty snapshot (first run, nothing to wipe) still routes through the snapshot-diff + // path so clean mode is handled consistently. + if (Directory.Exists (JavaSourceOutputDirectory)) { + priorJavaSnapshot = Directory.GetFiles (JavaSourceOutputDirectory, "*.java", SearchOption.AllDirectories); + Directory.Delete (JavaSourceOutputDirectory, recursive: true); + } else { + priorJavaSnapshot = []; + } } Directory.CreateDirectory (JavaSourceOutputDirectory); @@ -192,6 +205,7 @@ public override bool RunTask () GeneratedJavaFiles = JavaSourceInputDirectory.IsNullOrEmpty () ? WriteJavaSourcesToDisk (result.GeneratedJavaSources) : CopyJavaSourcesFromInputDirectory (result.GeneratedJavaSources); + DeletedJavaFiles = DeleteStaleJavaSources (GeneratedJavaFiles, priorJavaSnapshot); // Write manifest to disk if generated if (result.Manifest is not null && !MergedAndroidManifestOutput.IsNullOrEmpty ()) { @@ -370,6 +384,66 @@ 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 or trimmed away). 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. + // + // When the output directory was wiped before generation (CleanJavaSourceOutputDirectory, used + // by the post-trim pass), the stale files are already gone from disk; the previous contents + // are supplied via priorJavaSnapshot and the difference against the freshly generated set is + // reported. Otherwise the directory is scanned and any file the current pass did not produce + // is deleted. + ITaskItem [] DeleteStaleJavaSources (IReadOnlyCollection generatedJavaFiles, string[]? priorJavaSnapshot) + { + var expectedFiles = new HashSet ( + generatedJavaFiles.Select (i => Path.GetFullPath (i.ItemSpec)), + Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + var deleted = new List (); + + if (priorJavaSnapshot is not null) { + // If generation logged errors (e.g. a generated source was missing from the input + // directory, XA4255), GeneratedJavaFiles may be incomplete, so the prior-minus-current + // diff could wrongly flag a still-valid source as deleted. The build fails on logged + // errors anyway; skip pruning to avoid removing a file that should remain. + if (Log.HasLoggedErrors) { + return []; + } + + foreach (var path in priorJavaSnapshot) { + var fullPath = Path.GetFullPath (path); + if (expectedFiles.Contains (fullPath)) { + continue; + } + + Log.LogDebugMessage ($"Post-trim regeneration no longer produces generated Java source '{fullPath}'."); + deleted.Add (CreateDeletedJavaItem (fullPath)); + } + + return deleted.ToArray (); + } + + 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}'."); + deleted.Add (CreateDeletedJavaItem (fullPath)); + } + + return deleted.ToArray (); + } + + TaskItem CreateDeletedJavaItem (string fullPath) + { + var item = new TaskItem (fullPath); + item.SetMetadata ("RelativePath", PathUtil.GetRelativePath (JavaSourceOutputDirectory, fullPath)); + return item; + } + 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..431225f683c 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,217 @@ 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."); + } + + // 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 linkedJavaDirectory = builder.Output.GetIntermediaryPath (Path.Combine ("typemap", "linked-java")); + DirectoryAssert.Exists (linkedJavaDirectory, $"{message}: post-trim linked-java directory should exist."); + var androidSrcDirectory = builder.Output.GetIntermediaryPath (Path.Combine ("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] + public void Build_WithTrimmableTypeMap_PublishTrimmed_DeletesStaleAndroidSrcWhenLinkedJavaShrinks () + { + 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."); + + // Simulate a JCW the post-trim pass produced on a previous build but no longer + // produces (e.g. its type was trimmed away). It lives in both the post-trim + // `linked-java` source directory and its android/src copy, with a compiled .class. + var staleRelativePath = Path.Combine ("crc64stale", "Old.java"); + var staleClassPath = Path.Combine ("crc64stale", "Old.class"); + var staleLinkedJava = builder.Output.GetIntermediaryPath (Path.Combine ("typemap", "linked-java", staleRelativePath)); + var staleCopiedJava = builder.Output.GetIntermediaryPath (Path.Combine ("android", "src", staleRelativePath)); + var staleCompiledClass = builder.Output.GetIntermediaryPath (Path.Combine ("android", "bin", "classes", staleClassPath)); + var staleLinkedJavaDirectory = Path.GetDirectoryName (staleLinkedJava); + var staleCopiedJavaDirectory = Path.GetDirectoryName (staleCopiedJava); + var staleCompiledClassDirectory = Path.GetDirectoryName (staleCompiledClass); + if (staleLinkedJavaDirectory is null || staleCopiedJavaDirectory is null || staleCompiledClassDirectory is null) { + throw new InvalidOperationException ("Could not determine stale Java output directories."); + } + Directory.CreateDirectory (staleLinkedJavaDirectory); + Directory.CreateDirectory (staleCopiedJavaDirectory); + Directory.CreateDirectory (staleCompiledClassDirectory); + File.WriteAllText (staleLinkedJava, "package crc64stale; public class Old {}"); + File.WriteAllText (staleCopiedJava, "package crc64stale; public class Old {}"); + File.WriteAllBytes (staleCompiledClass, []); + + // Force the post-trim Java generation to re-run by removing its stamp (without a source + // change, which would trigger an unrelated incremental CrossGen rebuild). It wipes and + // regenerates linked-java (dropping the stale JCW), so android/src must drop its stale + // copy too. + var postTrimStamp = builder.Output.GetIntermediaryPath (Path.Combine ("stamp", "_GeneratePostTrimTrimmableTypeMapJavaSources.stamp")); + FileAssert.Exists (postTrimStamp, "First build should have written the post-trim Java stamp."); + File.Delete (postTrimStamp); + + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true, saveProject: false), "Second build should have succeeded."); + builder.Output.AssertTargetIsNotSkipped ("_GeneratePostTrimTrimmableTypeMapJavaSources"); + builder.Output.AssertTargetIsNotSkipped ("_CompileJava"); + + FileAssert.DoesNotExist (staleLinkedJava, "Post-trim regeneration should drop the stale linked-java JCW."); + FileAssert.DoesNotExist (staleCopiedJava, "Regenerated post-trim JCWs should delete the stale android/src copy that is no longer produced."); + FileAssert.DoesNotExist (staleCompiledClass, "Deleting the stale android/src copy should force Java recompilation and remove the stale class output."); + } + + [Test] + 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 () {