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 ()
{