Skip to content

[TrimmableTypeMap] Decompose _GenerateJavaStubs: shared tasks for trimmable path #10807

Description

@simonrozsival

Part of #10788
Blocking: #10800

Problem

The legacy _GenerateJavaStubs target in Microsoft.Android.Sdk.TypeMap.LlvmIr.targets is a mega-target that bundles 7 task invocations into a single MSBuild target:

  1. GenerateJavaCallableWrappers — generate JCW .java files
  2. GenerateACWMap — generate acw-map.txt (managed name → Java/ACW name mapping)
  3. GenerateJavaStubs — Cecil-based type scanning, builds NativeCodeGenState
  4. RewriteMarshalMethods — rewrite assemblies with marshal method stubs
  5. GenerateTypeMappings — generate LLVM IR typemap lookup tables
  6. GenerateMainAndroidManifest — merge user manifest + attribute-generated entries
  7. GenerateAdditionalProviderSources — generate runtime provider .java files

The trimmable path (#10779) overrides _GenerateJavaStubs to a no-op, which means tasks 1, 2, 6, and 7 are also skipped — but some of these are NOT typemap-specific and are needed by any build path.

Analysis: Which tasks are shared vs. typemap-specific

Shared tasks (needed by both legacy and trimmable)

TaskWhy it is sharedTrimmable path action
GenerateMainAndroidManifestEvery Android app needs a merged manifest. Users can hand-edit their manifest with component entries that have no C# attribute.Reuse as-is — needs to be extracted from the mega-target so both paths can invoke it. Currently depends on NativeCodeGenState from Cecil scanning — must be decoupled or fed from JavaPeerScanner results.
GenerateAdditionalProviderSourcesRuntime provider .java files are needed regardless of typemap strategy.Reuse as-is — depends on GenerateMainAndroidManifest output.
GenerateACWMapProduces acw-map.txt consumed by _ConvertCustomView to fix up custom view names in layout XMLs. Without it, custom views in layouts break.Generate from JavaPeerScanner results — the scanner already computes CRC64 JNI names. Writing ManagedName;JavaName lines from JavaPeerInfo is trivial. No need to reuse the Cecil-based task.

Typemap-specific tasks (legacy only, NOT needed by trimmable)

TaskWhy it is legacy-only
GenerateJavaCallableWrappersCecil-based JCW generation. Trimmable path generates JCWs from JavaPeerInfo in the generator (#10799).
GenerateJavaStubsCecil-based type scanning → NativeCodeGenState. Replaced by JavaPeerScanner (#10798).
RewriteMarshalMethodsRewrites assemblies with marshal method stubs. Trimmable path uses RegisterNatives instead.
GenerateTypeMappingsLLVM IR typemap generation. Replaced by TypeMapAttribute-based assembly.

What the legacy LLVM IR flow looks like today

_GenerateJavaStubs (LlvmIr.targets, single monolithic target)
├── GenerateJavaCallableWrappers → JCW .java files
├── GenerateACWMap → acw-map.txt (managed name → Java name)
├── GenerateJavaStubs → Cecil scan → NativeCodeGenState
├── RewriteMarshalMethods → assembly rewriting (Release)
├── GenerateTypeMappings → LLVM IR .ll files → compiled to .o
├── GenerateMainAndroidManifest → merged AndroidManifest.xml
└── GenerateAdditionalProviderSources → provider .java files
_ConvertCustomView (Common.targets, depends on acw-map.txt)
├── reads customview-map.txt (from _ConvertResourcesCases / Aapt2.targets)
├── reads acw-map.txt
└── replaces managed names with ACW names in layout XMLs

What the trimmable flow should look like

Per-assembly (runs once per assembly, MSBuild Inputs/Outputs skips unchanged):
_ScanAndGenerateTypeMap(Foo.dll) → Foo.TypeMap.dll + acw-map.Foo.txt + Foo JCW .java files
_ScanAndGenerateTypeMap(Bar.dll) → Bar.TypeMap.dll + acw-map.Bar.txt + Bar JCW .java files
_ScanAndGenerateTypeMap(Mono.Android.dll) → Mono.Android.TypeMap.dll + acw-map.Mono.Android.txt + ...
Merge (runs when any individual acw-map changes):
_MergeAcwMaps → concatenate acw-map.*.txt → acw-map.txt
Shared (unchanged):
GenerateMainAndroidManifest → merged AndroidManifest.xml
GenerateAdditionalProviderSources → provider .java files
Layout rewriting (unchanged):
_ConvertCustomView → reads acw-map.txt + customview-map.txt → fixes layout XMLs

Architecture: Per-assembly scanning

Each assembly is scanned independently. MSBuild Inputs/Outputs skips scanning for assemblies whose inputs haven't changed. This is a major improvement over the legacy flow which scans all assemblies in a single monolithic pass.

Per-assembly outputs

Each assembly scan+generate step produces:

  • Foo.TypeMap.dll — the TypeMap assembly for types in that assembly
  • acw-map.Foo.txt — per-assembly ManagedName;JavaName lines
  • JCW .java files — Java callable wrapper source files for types in that assembly
  • Component info — attribute data for manifest generation (activities, services, etc.)

Combining step: _MergeAcwMaps

A separate MSBuild target merges per-assembly acw-map.*.txt files into a single acw-map.txt. This target has Inputs="@(_PerAssemblyAcwMaps)" / Outputs="$(IntermediateOutputPath)acw-map.txt" so it only runs when any individual map changes.

The acw-map file is a flat list of Key;Value lines (order doesn't matter — the consumer LoadMapFile reads them into a Dictionary). The merge is a simple file concatenation using built-in MSBuild tasks:

<TargetName="_MergeAcwMaps"Inputs="@(_PerAssemblyAcwMaps)"Outputs="$(_AcwMapFile)">
<ReadLinesFromFileFile="%(_PerAssemblyAcwMaps.Identity)">
<OutputTaskParameter="Lines"ItemName="_AcwMapLines" />
</ReadLinesFromFile>
<WriteLinesToFileFile="$(_AcwMapFile)"Lines="@(_AcwMapLines)"Overwrite="true"WriteOnlyWhenDifferent="true" />
</Target>

Open question:WriteLinesToFile has a WriteOnlyWhenDifferent parameter — need to verify it works correctly for this use case (avoids unnecessary timestamp changes that would trigger downstream _ConvertCustomView). If not, fall back to the existing Files.CopyIfStreamChanged utility in a small custom task.

Cross-assembly conflict detection (XA4214/XA4215 — two types from different assemblies mapping to the same managed or Java key) currently lives in ACWMapGenerator. This validation could be done as a separate lightweight validation step after the merge, or deferred to consumption time (LoadMapFile already silently takes the first entry for duplicates).

Additionally, this combining step:

  • Collects component info from all assemblies → feeds GenerateMainAndroidManifest
  • Resolves cross-assembly references (e.g., [Application(BackupAgent=typeof(MyBackupAgent))] where MyBackupAgent is in a library assembly)

Incrementality and parallelization

Both incrementality and parallelization should be handled at the MSBuild level, not inside the task:

  • Incrementality: MSBuild Inputs/Outputs on the per-assembly scan target. If an assembly hasn't changed, MSBuild skips the target invocation entirely.
  • Parallelization: MSBuild BuildInParallel="true" on the MSBuild task, using the same pattern as _RunAotForAllRIDs in AssemblyResolution.targets — each assembly becomes a project-to-build item with AdditionalProperties.

The scan task itself should be simple: accept a single assembly path, scan it, produce outputs. MSBuild orchestrates which assemblies to scan and whether to run them in parallel.

Open question: Need to validate with MSBuild team that per-assembly BuildInParallel gives a net benefit given the short per-assembly scan times (<1s for Mono.Android.dll with ~12k types). Node spawning + IPC overhead might negate the parallelism. If so, a sequential batched approach with Inputs/Outputs incrementality (skip unchanged assemblies) may be sufficient.

Assembly resolution during scanning

When scanning MyApp.dll, the scanner needs to resolve type references to Mono.Android.dll (e.g., to walk inheritance chains for [Register] attributes on base types). This means Mono.Android.dll gets read during MyApp.dll's scan — but the SRM MetadataReader is memory-mapped, so only pages touched are loaded. The full type enumeration + attribute parsing only happens when Mono.Android.dll is the scan target.

Cross-assembly unconditional forcing

[Application(BackupAgent=typeof(MyBackupAgent))] in MyApp.dll references MyBackupAgent which might be defined in the same assembly or a library assembly:

  • Same-assembly case (common): handled by the scanner's Phase 3 during the assembly scan
  • Cross-assembly case (uncommon): handled in the combining step after all scans complete

GenerateMainAndroidManifest: Cecil dependency

The current GenerateMainAndroidManifest task depends on NativeCodeGenState which provides Cecil TypeDefinition objects. The manifest generation flow:

  1. ManifestDocument.Merge() iterates all Java types
  2. Checks inheritance via IsSubclassOf() (Cecil)
  3. Reads component attributes via FromTypeDefinition() (Cecil)
  4. Converts to XML via ToElement() (Cecil attribute reading)

This reads ~45+ properties from [Activity] alone, plus [IntentFilter], [MetaData], [Layout], [Property] sub-attributes. Total across all component attributes: ~160+ properties.

Decoupling approach: IManifestComponentInfo interface (Option A — no Cecil)

The scanner extracts ALL component attribute properties from SRM CustomAttribute blobs as raw name-value pairs. The manifest generator works against an interface:

interfaceIManifestComponentInfo{stringManagedTypeName{get;}stringJavaName{get;}ManifestComponentKindKind{get;}// Activity, Service, BroadcastReceiver, ContentProvider, Application, InstrumentationIReadOnlyDictionary<string,object?>AttributeProperties{get;}IReadOnlyList<IIntentFilterInfo>IntentFilters{get;}IReadOnlyList<IMetaDataInfo>MetaData{get;}}

Both Cecil path (from TypeDefinition + FromTypeDefinition()) and SRM path produce the same IManifestComponentInfo. The manifest generator works on this interface instead of Cecil types directly. The scanner doesn't need to understand the semantic meaning of properties — it passes them through as raw data.

Component attribute property counts

Attribute# Properties
ActivityAttribute~46
ApplicationAttribute~40
IntentFilterAttribute~22
ContentProviderAttribute~14
ServiceAttribute~12
BroadcastReceiverAttribute~10
InstrumentationAttribute~8
LayoutAttribute~5
MetaDataAttribute~3

Hand-edited manifest ordering dependency

ManifestDocument.Merge() preserves manually-declared component entries in the user's AndroidManifest.xml — if an <activity android:name="..."> already exists, it doesn't generate a duplicate from C# attributes.

This creates an ordering dependency: the scanner needs to mark manifest-referenced types as unconditional, but it can only know which types are in the manifest AFTER GenerateMainAndroidManifest runs. However, the scanner also provides data that feeds manifest generation.

Resolution:

  1. Scanner marks attribute-based types (types with [Activity], [Service], etc.) as unconditional — these are always in the manifest
  2. Scanner marks [Application] cross-references (BackupAgent, ManageSpaceActivity) as unconditional
  3. Post-manifest pass: After GenerateMainAndroidManifest runs, a separate step reads the merged manifest and marks any additional types referenced by android:name as unconditional (these are types that appear in the manifest without C# component attributes — hand-edited entries)

This post-manifest pass runs in the MSBuild task (not the scanner) and feeds the TypeMap generator to ensure hand-edited manifest entries are not trimmed.

No disk caching

The scan + generate pipeline runs in a single MSBuild task invocation. All intermediate state stays in memory for best efficiency.

MSBuild Inputs/Outputs handles target-level incrementality — the entire target is skipped if no input assemblies changed. There is no need for per-assembly disk serialization of scan results.

(Note: Issue #10798 originally mentioned "cached to disk" — this is outdated. In-memory pipeline is the correct approach.)

Proposed targets structure for #10800

The trimmable _GenerateJavaStubs should be decomposed into separate targets for incrementality:

<!-- Trimmable.targets --><!-- Scan all assemblies (per-assembly incremental), generate JCWs, acw-map.txt, and TypeMap assemblies -->
<TargetName="_GenerateTrimmableTypeMap"Inputs="@(_ResolvedAssemblies)"Outputs="$(IntermediateOutputPath)typemap\acw-map.txt;@(_TypeMapAssemblies)">
<GenerateTrimmableTypeMap ... />
</Target>
<!-- Shared: Generate merged AndroidManifest.xml from scanner component info -->
<TargetName="_GenerateAndroidManifest"DependsOnTargets="_GenerateTrimmableTypeMap"Inputs="$(IntermediateOutputPath)typemap\component-info.cache;$(AndroidManifest)"Outputs="$(IntermediateOutputPath)android\AndroidManifest.xml">
<GenerateMainAndroidManifest ... />
</Target>
<!-- Post-manifest: Mark hand-edited manifest entries as unconditional -->
<TargetName="_RootManifestReferencedTypes"DependsOnTargets="_GenerateAndroidManifest"Inputs="$(IntermediateOutputPath)android\AndroidManifest.xml"Outputs="$(IntermediateOutputPath)typemap\rooted-types.cache">
<!-- Read merged manifest, find types not already marked unconditional, update TypeMap -->
</Target>
<!-- Shared: Generate additional provider sources -->
<TargetName="_GenerateProviderSources"DependsOnTargets="_GenerateAndroidManifest">
<GenerateAdditionalProviderSources ... />
</Target>

This achieves:

  • Incremental builds: Only rescan when assemblies change
  • Manifest generation: Reuses existing shared task
  • ACW map: Generated from scanner results, consumed by _ConvertCustomView as before
  • Custom view fixup: _ConvertCustomView in Common.targets works unchanged
  • Hand-edited manifest support: Post-manifest pass ensures no type is incorrectly trimmed

External type rooting

The scanner also needs to mark types as unconditional (not trimmable) based on external sources:

SourceFileFormatAvailable when?
Layout XML custom viewscustomview-map.txtManagedTypeName;ResourceFilePath✅ Available — generated by _ConvertResourcesCases (Aapt2.targets, runs for all paths)
Manifest componentsmerged AndroidManifest.xmlandroid:name attrs with Java class namesAvailable after GenerateMainAndroidManifest runs
[Application] attributesAssembly metadataBackupAgent, ManageSpaceActivity type properties✅ Available — already extracted by JavaPeerScanner

Relationship to #10779

Issue #10779 (build targets refactoring) should extract GenerateMainAndroidManifest and GenerateAdditionalProviderSources from the LLVM IR mega-target into separate, shared targets that both legacy and trimmable paths can use. The current PR1 implementation keeps them inside the legacy _GenerateJavaStubs — they need to be pulled out.

Definition of Done

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Area: CoreCLRIssues that only occur when using CoreCLR.Area: NativeAOTIssues that only occur when using NativeAOT.needs-triageIssues that need to be assigned.trimmable-type-map

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions