Skip to content

[Build] Make universal APK generation incremental - #12230

Open
simonrozsival wants to merge 2 commits into
mainfrom
incremental-universal-apk
Open

[Build] Make universal APK generation incremental#12230
simonrozsival wants to merge 2 commits into
mainfrom
incremental-universal-apk

Conversation

@simonrozsival

Copy link
Copy Markdown
Member

Summary

  • touch the universal signed APK after extracting it from bundletool's APK set
  • make _CreateUniversalApkFromBundle track its bundletool, AAPT2, keystore, project, and build-property inputs
  • preserve the previous always-run behavior for custom signing credentials or custom bundletool arguments
  • pass Aapt2ToolExe through both BuildApkSet call sites

Motivation

Release builds default to AndroidPackageFormats=aab;apk. On every unchanged dotnet build, _CreateUniversalApkFromBundle reran 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.apk inside the bundletool APK set has a fixed ZIP timestamp, which UnzipToFolder preserved:

input AAB:  2026-07-24 14:23:03
output APK: 1981-01-01 01:01:02

The output therefore remained older than the unchanged input forever.

Results

Repeated no-op dotnet build --no-restore on a Release CoreCLR MAUI sample-content app:

Typemap Before This PR Improvement
llvm-ir 5.98s 4.06s -1.92s (-32.1%)
Trimmable 6.72s 4.58s -2.14s (-31.8%)

_CreateUniversalApkFromBundle and BuildApkSet are skipped after the first build.

Incremental correctness

The target now tracks:

  • intermediate AAB
  • Android build-properties cache
  • project and imported MSBuild files
  • bundletool jar
  • selected AAPT2 executable
  • signing keystore

Custom signing credentials and non-empty AndroidBundleToolExtraArgs deliberately 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

  • Debug solution build
  • UniversalApkFromBundleIsIncremental
    • unchanged rebuild skips bundletool
    • setting bundletool arguments reruns it
    • clearing bundletool arguments reruns it once, then the next no-op skips
  • BuildPropsBreaksConvertResourcesCasesOnSecondBuild
    • CoreCLR passed
    • NativeAOT passed; removes the previous 1981-timestamp assertion exception

Discovered while investigating #12184.

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
Copilot AI review requested due to automatic review settings July 24, 2026 17:12
@simonrozsival simonrozsival added Area: Performance Issues with performance. Area: App+Library Build Issues when building Library projects or Application projects. labels Jul 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 _CreateUniversalApkFromBundle incremental 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

Comment thread src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets Outdated
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: App+Library Build Issues when building Library projects or Application projects. Area: Performance Issues with performance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants