Skip to content

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

@simonrozsival

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)

Task Why it is shared Trimmable path action
GenerateMainAndroidManifest Every 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.
GenerateAdditionalProviderSources Runtime provider .java files are needed regardless of typemap strategy. Reuse as-is — depends on GenerateMainAndroidManifest output.
GenerateACWMap Produces 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)

Task Why it is legacy-only
GenerateJavaCallableWrappers Cecil-based JCW generation. Trimmable path generates JCWs from JavaPeerInfo in the generator (#10799).
GenerateJavaStubs Cecil-based type scanning → NativeCodeGenState. Replaced by JavaPeerScanner (#10798).
RewriteMarshalMethods Rewrites assemblies with marshal method stubs. Trimmable path uses RegisterNatives instead.
GenerateTypeMappings LLVM 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:

<Target Name="_MergeAcwMaps"
        Inputs="@(_PerAssemblyAcwMaps)"
        Outputs="$(_AcwMapFile)">
  <ReadLinesFromFile File="%(_PerAssemblyAcwMaps.Identity)">
    <Output TaskParameter="Lines" ItemName="_AcwMapLines" />
  </ReadLinesFromFile>
  <WriteLinesToFile File="$(_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:

interface IManifestComponentInfo {
    string ManagedTypeName { get; }
    string JavaName { get; }
    ManifestComponentKind Kind { get; } // Activity, Service, BroadcastReceiver, ContentProvider, Application, Instrumentation
    IReadOnlyDictionary<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 -->
<Target Name="_GenerateTrimmableTypeMap"
        Inputs="@(_ResolvedAssemblies)"
        Outputs="$(IntermediateOutputPath)typemap\acw-map.txt;@(_TypeMapAssemblies)">
  <GenerateTrimmableTypeMap ... />
</Target>

<!-- Shared: Generate merged AndroidManifest.xml from scanner component info -->
<Target Name="_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 -->
<Target Name="_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 -->
<Target Name="_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:

Source File Format Available when?
Layout XML custom views customview-map.txt ManagedTypeName;ResourceFilePath ✅ Available — generated by _ConvertResourcesCases (Aapt2.targets, runs for all paths)
Manifest components merged AndroidManifest.xml android:name attrs with Java class names Available after GenerateMainAndroidManifest runs
[Application] attributes Assembly metadata BackupAgent, 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

  • GenerateMainAndroidManifest runs in the trimmable path (extracted from mega-target in [TrimmableTypeMap] Separate typemap build targets into dedicated .targets files #10779)
  • GenerateAdditionalProviderSources runs in the trimmable path
  • acw-map.txt is generated from JavaPeerInfo scanner results
  • _ConvertCustomView works correctly with the trimmable-generated acw-map.txt
  • Custom views in layout XMLs are correctly fixed up with ACW names
  • Hand-edited manifest entries are preserved
  • Types referenced in customview-map.txt are marked unconditional
  • Types referenced in merged manifest are marked unconditional (post-manifest pass)
  • Per-assembly incremental builds: unchanged assemblies are not rescanned
  • Cross-assembly unconditional forcing works in combining step

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

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions