[Build] Make universal APK generation incremental - #12230
Open
simonrozsival wants to merge 2 commits into
Open
Conversation
Preserve a current filesystem timestamp on the extracted universal APK and track all relevant bundletool, AAPT2, signing, and project inputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a301fa68-0e6c-4280-b2f2-bc3d4c9ae3de
Contributor
There was a problem hiding this comment.
Pull request overview
Improves incremental build behavior for universal APK generation from an App Bundle by fixing the timestamp mismatch caused by bundletool’s fixed ZIP entry timestamp, and by expanding input tracking so MSBuild can correctly skip _CreateUniversalApkFromBundle on no-op rebuilds.
Changes:
- Make
_CreateUniversalApkFromBundleincremental by tracking additional inputs (bundletool jar, AAPT2 selection, keystore, property cache, and imported MSBuild files) while retaining always-run behavior for custom signing or custom bundletool args. - Touch the extracted signed universal APK to ensure its timestamp reflects the build, preventing perpetual out-of-date detection.
- Add/adjust tests to validate the new incremental behavior and remove the prior NativeAOT exception now that timestamps update correctly.
Show a summary per file
| File | Description |
|---|---|
| src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets | Adds prep target + richer Inputs for _CreateUniversalApkFromBundle, passes Aapt2ToolExe through both BuildApkSet call sites, and touches the extracted signed APK to fix timestamp-based incrementality. |
| src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/IncrementalBuildTest.cs | Updates an existing incremental timestamp assertion and adds a new test verifying universal APK generation is skipped on no-op builds and re-runs when bundletool args change/clear. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 1
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a301fa68-0e6c-4280-b2f2-bc3d4c9ae3de
jonathanpeppers
added a commit
that referenced
this pull request
Jul 29, 2026
## Summary - update post-trim JCWs in place so unchanged Java sources keep stable timestamps - give linked `acw-map.txt` and `ApplicationRegistration.java` a single post-trim owner - make `_GenerateJavaStubs` react independently to pre-trim manifest changes and post-trim Java changes - persist and validate the expected linked-Java output set, recovering missing or stale files - preserve last known-good linked JCWs when generation fails - make the legacy `llvm-ir` post-trimming assembly pipeline incremental - reuse persisted TypeMap and Java output lists instead of wildcard/glob discovery - persist and compare fully qualified Java paths so the lists compare correctly on Windows - skip unchanged CoreCLR shrunk-assembly copies while preserving NativeAOT's always-run remap - stop copying `*.dll.config` files next to the shrunk assemblies, since assembly configuration files are not supported ## Motivation On a Release CoreCLR `dotnet new maui --sample-content` app, an ordinary managed source touch caused the trimmable path to delete and recreate all 566 linked JCWs. Their new timestamps forced javac and D8 to rebuild identical output: | Scenario | `llvm-ir` | Trimmable before | This PR | | --- | ---: | ---: | ---: | | C# source touch | 50.73s | 68.32s | 52.31s | | Android manifest touch | 5.63s | 39.74s | 10.97s | With the generic universal-APK no-op fix from #12230 applied, the additional trimmable-specific no-op changes in this PR reduce repeated `dotnet build` from 4.58 seconds to approximately 3.0-3.5 seconds: | Change | Real time | | --- | ---: | | #12230 only | 4.58s | | Use `typemap-assemblies.txt` for ILLink inputs | 4.02s | | Reuse post-trim Java list for `FileWrites` | 3.93s | | Persist/reuse pre-trim Java list | 3.85s | | Incremental CoreCLR shrunk-assembly copy | ~3.0-3.5s | The manifest case also exposed an output-ownership bug. The pre-trim pass overwrote the final post-trim files while the post-trim target remained up to date: | Output | Correct post-trim | Overwriting pre-trim | | --- | ---: | ---: | | `acw-map.txt` | 660,756 B / 6,186 lines | 4,927,847 B / 49,665 lines | | `ApplicationRegistration.java` | 341 B / 12 lines | 413 B / 13 lines | The overwritten registration source additionally registered `android.app.Application`, and its changed timestamp triggered javac and D8. ## Implementation - CoreCLR trimmed pre-generation no longer writes the final ACW map or application-registration source. - The post-trim pass uses content-based in-place updates instead of wiping `linked-java`. - `_GenerateJavaStubs` depends on both producer stamps: pre-trim for the manifest/shared outputs and post-trim for linked JCWs. - `linked-java-files.txt` records the expected JCW set. Missing or unexpected Java files, a missing ACW map, or a missing registration source invalidate the post-trim stamp. - Stale pruning is suppressed after generation errors so a partial result cannot delete the previous valid output set. - `_PostTrimmingPipeline` now has explicit inputs/outputs while a separate collector target continues to repopulate its dynamic assembly items. - `_AddTrimmableTypeMapToLinker` consumes `typemap-assemblies.txt` instead of wildcarding the output directory. - `java-files.txt` and `linked-java-files.txt` drive no-op `FileWrites`; migration fallbacks preserve outputs from older `obj` trees. - Both Java lists persist `%(FullPath)` and the post-trim output base directory is normalized to forward slashes. Otherwise the recorded relative/backslash paths never matched the globbed actual paths on Windows, so every build saw the whole expected set as `_UnexpectedPostTrimJavaFile` and invalidated the stamp. - CoreCLR uses an incremental shrunk-assembly copy target. NativeAOT retains the original always-run project-local remap/copy path. - The trimmable path no longer copies `@(_ResolvedAssemblies->'%(Identity).config')` into the shrunk locations. Optional source files cannot be modeled as a reliable one-to-one incremental transform, and the copy has no observable effect: assembly configuration files are unsupported in .NET for Android (see [`OneDotNet.md`](https://github.com/dotnet/android/blob/main/Documentation/guides/OneDotNet.md#net-configuration-files)), and the assembly store's `config_data` is never assigned from the store nor handed to the runtime — its only use in `assembly-store.cc` / `embedded-assemblies.cc` is a `log_debug` message that always prints `nullptr`. Dropping it makes `_RemoveRegisterAttributeCoreClr` a clean one-to-one transform for every project instead of forcing config-file projects onto an always-run path. ## Validation - Debug solution build - `Build_WithTrimmableTypeMap_PublishTrimmed_IncrementalChangesAvoidUnnecessaryJavaWork` - `Build_WithTrimmableTypeMap_PublishTrimmed_PostTrimJavaGenerationIsIncremental` - `Build_WithTrimmableTypeMap_PublishTrimmed_DeletesStaleLinkedJavaWhenLinkedJavaShrinks` - `Execute_MissingJavaSource_DoesNotPruneExistingOutput` - `Build_WithTrimmableTypeMap_MissingJavaListPreservesGeneratedJava` - `Build_WithTrimmableTypeMap_PublishTrimmed_MissingLinkedJavaListRegenerates` - parameterized `Build_WithTrimmableTypeMap_IncrementalBuild` - `AfterILLinkAdditionalStepsIsSkippedOnSecondBuild` - `BuildBasicApplicationCheckConfigFiles`, `AppProjectTargetsDoNotBreak` - API 36 arm64 emulator: both trimmable and `llvm-ir` apps launched and survived 100 Monkey events ## Follow-up Clean trimmable builds still carry 85 managed TypeMap DLLs through ILLink, `ProcessAssemblies`, compression, and packaging; 49 are empty stubs. Reducing that file fan-out is a separate, larger optimization. The `llvm-ir` path in `Microsoft.Android.Sdk.TypeMap.LlvmIr.targets` still copies `*.dll.config` files for the same non-reason; that copy can be removed separately. Related to #12184 Co-authored-by: Jonathan Peppers <jonathan.peppers@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_CreateUniversalApkFromBundletrack its bundletool, AAPT2, keystore, project, and build-property inputsAapt2ToolExethrough bothBuildApkSetcall sitesMotivation
Release builds default to
AndroidPackageFormats=aab;apk. On every unchangeddotnet build,_CreateUniversalApkFromBundlereran bundletool for approximately 2.3 seconds.The target declares the intermediate AAB as its input and the signed universal APK as its output. However,
universal.apkinside the bundletool APK set has a fixed ZIP timestamp, whichUnzipToFolderpreserved:The output therefore remained older than the unchanged input forever.
Results
Repeated no-op
dotnet build --no-restoreon a Release CoreCLR MAUI sample-content app:llvm-ir_CreateUniversalApkFromBundleandBuildApkSetare skipped after the first build.Incremental correctness
The target now tracks:
Custom signing credentials and non-empty
AndroidBundleToolExtraArgsdeliberately retain the previous always-run behavior. Property-cache entries track transitions when custom bundletool arguments, signing mode, or AAPT2 settings are cleared or changed.Validation
UniversalApkFromBundleIsIncrementalBuildPropsBreaksConvertResourcesCasesOnSecondBuildDiscovered while investigating #12184.