[TrimmableTypeMap] Add AOT-safe array and collection factories using MakeArrayType and MakeGenericType#12030
Conversation
Centralize safe runtime construction of arrays and generic Java collection wrappers so JavaConvert and JNIEnv no longer carry the trimmable-specific construction policy inline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix nullable annotations on factory Try methods and keep mixed dictionary creation from bypassing the explicit value-type support map. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Expand suppression justifications and comments with the source-grounded NativeAOT behavior for array and generic type construction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Consolidate primitive and nullable value-type rooting for safe arrays and Java collection wrappers into ValueTypeFactory<T>. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update the suppression text to say first-rank value vectors bypass CreateInstanceFromArrayType and are allocated through ValueTypeFactory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…elines The new nullable/collection marshaling paths made JavaConvert.GetJniHandleConverter AOT-reachable, exposing the legacy TryMakeGenericCollectionTypeFactory helper whose MakeGenericType() calls only suppressed IL2055 (trimming) and not IL3050 (AOT), leaking 6 NativeAOT warnings. Add the matching IL3050 suppression; that branch only runs when !RuntimeFeature.TrimmableTypeMap (Mono/CoreCLR), never under NativeAOT. Update BuildReleaseArm64 SimpleDotNet CoreCLR/NativeAOT apkdesc baselines to accept the expected size increase from the rooted array/collection typemap entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
Pull request overview
This PR refactors the trimmable typemap marshaling path to centralize AOT-safe construction of array and generic Java collection wrapper types, and extends runtime/test coverage to support nullable primitive arrays and nullable list/dictionary marshaling.
Changes:
- Introduces
SafeArrayFactory,SafeJavaCollectionFactory, andValueTypeFactory<T>to constrain and root AOT-safe array/generic instantiations. - Updates
JNIEnv,JavaConvert, andTrimmableTypeMapTypeManagerto use the new factories and adds nullable primitive array support. - Adds new tests and updates APK descriptor baselines to reflect output changes.
Show a summary per file
| File | Description |
|---|---|
| tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JavaConvertTest.cs | Adds runtime tests for nullable IList<T> and IDictionary<TKey,TValue> conversions via JavaConvert.FromJniHandle. |
| tests/Mono.Android-Tests/Mono.Android-Tests/Android.Runtime/JnienvArrayMarshaling.cs | Adds test coverage for JNIEnv nullable primitive array round-tripping and item set/get. |
| src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Resources/Base/BuildReleaseArm64SimpleDotNet.NativeAOT.apkdesc | Updates expected APK file/package sizes after runtime/type-map changes. |
| src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Resources/Base/BuildReleaseArm64SimpleDotNet.CoreCLR.apkdesc | Updates expected APK file/package sizes after runtime/type-map changes. |
| src/Mono.Android/Mono.Android.csproj | Adds the new factory helper source files to the build. |
| src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMapTypeManager.cs | Extends NativeAOT array-type discovery to use SafeArrayFactory when a generated proxy isn’t present. |
| src/Mono.Android/Java.Interop/ValueTypeFactory.cs | Adds the shared primitive/nullable rooting table for AOT-safe arrays and collection wrappers. |
| src/Mono.Android/Java.Interop/SafeJavaCollectionFactory.cs | Adds constrained AOT-safe generic wrapper type creation and handle-to-wrapper creation for lists/collections/dictionaries. |
| src/Mono.Android/Java.Interop/SafeArrayFactory.cs | Adds constrained AOT-safe array Type lookup and instance creation (including jagged arrays). |
| src/Mono.Android/Java.Interop/JavaConvert.cs | Switches trimmable-path generic collection conversion to the new factory-based approach. |
| src/Mono.Android/Android.Runtime/JNIEnv.cs | Routes trimmable array allocation through SafeArrayFactory and adds nullable primitive array marshaling support. |
Copilot's findings
- Files reviewed: 11/11 changed files
- Comments generated: 4
…d API - Remove the unused SafeJavaCollectionFactory.TryCreateInstance(Type, object?, out IJavaObject?) speculative overload (no call sites). - Convert the three new factory files to file-scoped namespaces per repo conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🤖 Android PR Review — ⚠️ Needs Changes (minor)
Runtime-only AOT-safe factories for managed arrays and Java collections on the trimmable-type-map path. The core design is well-reasoned: value types are rooted explicitly (typeof(T[]) / new T[] / direct generic constructor references), reference types ride NativeAOT's __Canon canonical templates, and unsupported value-type shapes are rejected (return null) rather than silently reaching an unrooted MakeGenericType/MakeArrayType. The [UnconditionalSuppressMessage] attributes are narrowly scoped with sound justifications after the shapes have been constrained. 👍
I verified the removed CoreCLR Array.CreateInstance catch-all is not a regression: ArrayCreateInstance is only ever reached with nullable primitives (via CreateManagedArrayFromObjectArray) or reference types (IJavaObject/Array converters). Enums are mapped to their underlying primitive by GetConverter, and GetArray<T> uses new T[cnt] directly — so no non-primitive value type flows into SafeArrayFactory.CreateInstance.
Findings (all inline)
| Sev | Location | Issue |
|---|---|---|
SafeJavaCollectionFactory.cs:139 |
Value/value IDictionary goes through the virtual-generic-method path (CreateDictionaryWithKey<TKey> → new JavaDictionary<TKey,T>) — the AOT-riskiest construct here — and has no test. Add an IDictionary<int,long> runtime test under the NativeAOT trimmable config. |
|
| 💡 | SafeJavaCollectionFactory.cs:77 |
TryCreateInstance(Type, object?, out ...) (construct-from-items) has zero callers. |
| 💡 | SafeArrayFactory.cs:19 |
Throwing GetArrayType(Type,int) overload is unused. |
| 💡 | JNIEnv.cs:954 |
Per-element closure/delegate allocation in CopyManagedObjectArray. |
Positives
- Boxed nullable-primitive array / list / dictionary support with round-trip tests (
GetArray_NullableInt32,FromJniHandle_IListNullableInt32,FromJniHandle_IDictionaryNullableInt32String). - Clear inline rationale for why the trimmable path avoids the reflection fallback and why each rooting trick exists.
- Correct JNI local/global ref lifetime handling in the new
NewObjectArrayhelpers.
CI
All completed dotnet-android legs on the previous commit's build were green; the build for the latest commit (b959f4a) was still in progress at review time. Not itself a blocker — but the value/value dictionary test above is worth landing before merge, since that's exactly the instantiation most likely to surface only under NativeAOT.
Generated by Android PR Reviewer for #12030 · 358.6 AIC · ⌖ 28.2 AIC · ⊞ 6.8K
Comment /review to run again
Check the value-type factory locals directly instead of via intermediate bools so the compiler tracks the non-null flow, clearing CS8602/CS8604 in the dictionary path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… API, avoid per-element closure - Add FromJniHandle_IDictionaryInt32Int64 to exercise the value/value dictionary branch (JavaDictionary<int,long> via the generic-virtual CreateDictionaryWithKey<TKey>), confirming that value-over-value instantiation is rooted under the trimmable/NativeAOT config. - Remove the unused throwing SafeArrayFactory.GetArrayType(Type,int) overload (only TryGetArrayType/CreateInstance are called). - Inline JavaConvert.WithLocalJniHandle in CopyManagedObjectArray to avoid allocating a capturing closure per array element. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
🤖 Code Review — ⚠️ Minor suggestions
Reviewed the AOT-safe array/collection factory refactor. Overall this is a well-constructed, carefully-reasoned change — the suppression justifications are precise, the value-type-vs-__Canon rooting model is explained clearly, and the new factories cleanly consolidate construction policy that was previously scattered across JNIEnv, JavaConvert, and TrimmableTypeMapTypeManager. Nullable-primitive array/collection marshaling has good round-trip test coverage (GetArray_NullableInt32, FromJniHandle_IListNullableInt32, FromJniHandle_IDictionaryInt32Int64).
Findings
⚠️ Performance (JavaConvert.cs:79) — collection type is resolved byTryGetCollectionTypefor a boolean gate, then re-resolved insideTryCreateFromJniHandle, causingMakeGenericType()to run twice per uncachedFromJniHandlecall for reference generic collections. Inline comment posted.- 💡 Formatting (
JNIEnv.cs:1106) — usenameof (elementType)instead of the string literal. Inline comment posted.
CI
The GitHub combined status shows pending (Azure DevOps dnceng-public validation not yet complete / no required checks reported). Neither finding is merge-blocking, but confirm the pipeline goes green before merge. The primitive/nullable rooting story is only exercised on the reflection-based runtimes by the added tests; NativeAOT rooting for exact value/value instantiations (JavaDictionary<int,long>) relies on generic-virtual rooting and isn't directly asserted under PublishAot in CI — worth keeping in mind for the validation follow-up.
Nice work on the documentation of the AOT reasoning.
Generated by Android PR Reviewer for #12030 · 144.5 AIC · ⌖ 18.8 AIC · ⊞ 6.8K
Comment /review to run again
…ctions Resolve conflicts from main's IsDynamicCodeSupported [FeatureGuard] refactor and its generated-proxy collection factory, which landed in the same regions this branch rewrites to runtime Safe*Factory construction: - JNIEnv.ArrayCreateInstance / TrimmableTypeMapTypeManager.GetArrayTypes: keep main's IsDynamicCodeSupported dynamic-code fast path; route the AOT path through SafeArrayFactory (generated proxy first, then runtime construction). - JavaConvert.GetJniHandleConverter: replace main's TryGetFactoryBasedConverter (generated-proxy path, removed on this branch) with SafeJavaCollectionFactory; keep main's IsDynamicCodeSupported/ManagedTypeMap else-chain and [RequiresDynamicCode] on TryMakeGenericCollectionTypeFactory. - BuildReleaseArm64SimpleDotNet.CoreCLR.apkdesc: kept the branch baseline; MUST be regenerated from a validation build (sizes shift after the merge). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fddff664-4661-4c06-8922-316731aa2b8d
…roid-trimmable-revisit-arrays-generic-collections
Addresses the two remaining PR review comments: - Use `nameof (elementType)` instead of the string literal in `CreateManagedArrayFromObjectArray`. - Gate the trimmable generic-collection converter on a new `SafeJavaCollectionFactory.IsSupportedCollectionType ()` check that does not resolve (or root) the closed collection type. Previously the gate called `TryGetCollectionType ()` (running `MakeGenericType ()`) purely as a boolean probe, and `TryCreateFromJniHandle ()` then resolved the type again, so `MakeGenericType ()` ran twice per uncached `FromJniHandle ()` marshal for reference generic collections. The type is now resolved at most once (reference collections), or not at all (value collections handled by the rooted `ValueTypeFactory` path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 155743e0-3f7e-489b-b351-80da73edc7d0
The NativeAOT Java-to-managed array reverse lookup (TrimmableTypeMapTypeManager.GetArrayTypes) fell back to returning only the `T[]` vector when no generated array proxy was present. That drops the JavaObjectArray<T>/JavaArray<T> (reference) and JavaArray<T>/JavaPrimitiveArray<T>/ concrete-primitive (e.g. JavaSByteArray) wrapper types that both the generated proxy and the CoreCLR path return, so marshaling a Java array to those wrapper types would fail to resolve once array proxies are no longer pre-generated. Replace the fallback with BuildRuntimeArrayTypes, which reproduces the generated JavaArrayProxy.GetArrayTypes() contract (see the generator's ModelBuilder.GetArrayTypeReferences) at runtime and AOT-safely: - Primitive leaves use PrimitiveArrayInfo's closed `typeof` tokens. - Reference leaves build [JavaObjectArray<T>, JavaArray<T>, T[]] via canonical (__Canon) MakeGenericType/MakeArrayType, the same rooting the trimmable typemap already relies on for reference generics. - Jagged (rank > 1) mirrors ExpandRankOneTypes. Behavioral note: non-primitive value-type leaves (e.g. Nullable<int>) return only the exact rooted vector (int?[]); JavaObjectArray<valueType> would require an unrooted value-type generic instantiation and the generator never emitted one. Why no test caught this: the existing TryGetArrayProxy_* tests only assert the *proxy* path (gated on generated proxies), and the marshaling round-trip tests only need `T[]`, so the no-proxy runtime fallback was never asserted. BuildRuntimeArrayTypes is a pure function, so the added TrimmableTypeMapTypeManagerTests exercise it directly on every config. Also inline JavaConvert.WithLocalJniHandle in JNIEnv.SetObjectArrayElementFromManagedValue to avoid allocating a capturing closure per element on the SetArrayItem path (matching CopyManagedObjectArray). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 155743e0-3f7e-489b-b351-80da73edc7d0
|
/review |
|
✅ Android PR Reviewer completed successfully! |
There was a problem hiding this comment.
🤖 Code Review — ⚠️ A couple of questions
Thorough, well-documented refactor. Consolidating the trimmable-typemap construction policy into SafeArrayFactory / SafeJavaCollectionFactory / ValueTypeFactory (with the AOT reasoning captured in comments and UnconditionalSuppressMessage justifications) is a clear improvement over the inline suppressions previously scattered across JNIEnv/JavaConvert. Nice touches: boxed-nullable primitive array support, the BuildRuntimeArrayTypes fallback that mirrors the generator's proxy contract, and the regression test (BuildRuntimeArrayTypes_ReferenceLeaf_ReturnsProxyContract) that guards the previously-uncaught "returns only [T[]]" bug fixed in the latest commit.
Findings
⚠️ Behavioral regression forbytecollections on the trimmable path — the oldScalarContainerFactoriesincludedtypeof (byte); the new factories omit it, soIList<byte>etc. now silently resolve tonullrather than marshaling or throwing. Flagged inline onJavaConvert.cs. Documented as out-of-scope, but worth an explicit error instead of a silentnull.⚠️ NativeAOT value/value dictionary rooting —ValueTypeFactory<T>.CreateDictionaryWithKey<TKey>builds value/valueJavaDictionary<TKey,T>via generic-virtual dispatch with no dedicated rooting token; the new test only exercises the Mono/CoreCLR path. Flagged inline onValueTypeFactory.cs— please confirm NativeAOT coverage.
Other observations (non-blocking)
- The
_ = ReferenceKeyDictionaryType;/ReferenceValueDictionaryTypediscard-to-root pattern works but is subtle; the existing comments help. SafeArrayFactory.TryCreateInstancehandles the rank-1 primitive case both via the explicit short-circuit and again insideTryGetVectorType— minor duplication, not a bug.
CI
Checks are currently pending (no failures reported); the ready-to-review label notes any failures are expected flakes. Not treating CI as blocking, but it should be green before merge.
Great work overall — the two
Generated by Android PR Reviewer for #12030 · 121.6 AIC · ⌖ 13.4 AIC · ⊞ 6.8K
Comment /review to run again
| var factoryConverter = TryGetFactoryBasedConverter (target); | ||
| if (factoryConverter != null) | ||
| return factoryConverter; | ||
| if (SafeJavaCollectionFactory.IsSupportedCollectionType (target)) { |
There was a problem hiding this comment.
🤖 ScalarContainerFactories/TryGetFactoryBasedConverter, which included typeof (byte). SafeJavaCollectionFactory / ValueTypeFactory.PrimitiveTypeFactories deliberately omit byte (and unsigned aliases), so IList<byte>, ICollection<byte>, and IDictionary<byte,...> now return false here and fall through to null (the non-generic IList/IDictionary checks below don't match generic interfaces). On the trimmable path this is a silent behavioral regression for byte collections that previously marshaled. The PR marks byte as out of scope, but the old path supported it — is dropping it at runtime (vs. throwing a clear NotSupportedException) intended? At minimum consider a targeted error so callers don't get a silent null.
{Rule: Preserve existing behavior / avoid silent regressions}
| IntPtr handle, | ||
| JniHandleOwnership transfer) | ||
| { | ||
| return new JavaDictionary<TKey, T> (handle, transfer); |
There was a problem hiding this comment.
🤖 CreateDictionaryWithKey<TKey> is a generic virtual method that constructs new JavaDictionary<TKey,T>() for value TKey/T (the value/value dictionary path). Generic-virtual dispatch instantiated over value types is one of the most fragile constructs under NativeAOT: each exact JavaDictionary<int,long>, JavaDictionary<int?,double>, ... instantiation (up to ~16×16) must be statically rooted, and unlike the reference/mixed cases there's no dedicated rooting token here — the instantiation is only reachable through this virtual call. The added FromJniHandle_IDictionaryInt32Int64 test exercises this on Mono/CoreCLR, where dynamic code masks the problem; it won't catch a missing NativeAOT template. Can you confirm this is covered by an actual NativeAOT run (e.g. the .NativeAOT.apkdesc scenario), or add explicit rooting tokens for these value/value shapes like the mixed-dictionary case does?
{Rule: NativeAOT template rooting for value-type generic instantiations}
Summary
This PR adds runtime-only safe factory helpers for the trimmable type map path:
SafeArrayFactorycentralizes managed arrayTypelookup and array instance creation.SafeJavaCollectionFactorycentralizes generic Java collection wrapperTypelookup and wrapper creation forJavaList<T>,JavaCollection<T>, andJavaDictionary<TKey,TValue>.ValueTypeFactory<T>is the shared primitive/nullable value-type rooting table used by both factories.JNIEnv,JavaConvert, andTrimmableTypeMapTypeManagernow call these helpers instead of carrying their own trimmable-specific construction policy inline.This also adds boxed nullable primitive array support (
int?[], etc.) and runtime coverage for nullable array/list/dictionary marshaling.Why using
MakeArrayType()/MakeGenericType()is OK hereThese APIs are annotated with
RequiresDynamicCode/RequiresUnreferencedCodebecause the general case is unsafe in NativeAOT/trimming:This PR does not suppress those warnings broadly. The suppressions are isolated in the safe factories and only after constraining the shapes to cases we know NativeAOT can support.
The runtime source path this is based on is:
RuntimeType.NativeAot.cs->RuntimeTypeInfo.MakeArrayType()/MakeGenericType()ExecutionEnvironmentImplementation.MappingTables.csTypeLoaderEnvironment.csTypeBuilder.TryBuildArrayType()/TypeBuilder.TryBuildGenericType()TemplateLocator.csStandardCanonicalizationAlgorithm.csThe key NativeAOT behavior is:
__Canon, and reference arrays/generic instantiations can use canonical templates.Array.CreateInstanceFromArrayType()immediately needs the arrayTypeHandleand allocates throughRuntimeAugments.NewArray(), so it must only see array types that are actually runtime-buildable/rooted.The factories enforce that model:
elementType.MakeArrayType()and thenArray.CreateInstanceFromArrayType()only for NativeAOT-buildable reference-array canonical shapes.ValueTypeFactory<T>.CreateArray()(new T[length]), which roots the exactT[]vector.typeof(JavaList<>).MakeGenericType(...), etc., relying on canonical generic templates such asJavaList<__Canon>.ValueTypeFactory<T>direct generic references and constructors, rooting exact instantiations such asJavaList<int>andJavaList<int?>.JavaDictionary<object,T>andJavaDictionary<T,object>).Unsupported value-type cases are rejected instead of falling through to unrooted
MakeGenericType()/MakeArrayType()usage.Scope
Included:
JavaList<T>/IList<T>JavaCollection<T>/ICollection<T>JavaDictionary<TKey,TValue>/IDictionary<TKey,TValue>bool,sbyte,char,short,int,long,float,double, plus nullable variantsOut of scope for this PR:
JavaSet<T>byteand unsigned aliasesJavaPeerContainerFactoryFollow-up
This PR intentionally leaves generated array proxy code,
JavaPeerContainerFactory, and related obsolete support code in place. A follow-up PR will remove that generator/runtime cleanup once this runtime-only factory path is validated.Validation
dotnet build src/Mono.Android/Mono.Android.csproj -v:minimal /p:Configuration=Debug --no-restore /p:JavaCPath=/Library/Java/JavaVirtualMachines/microsoft-21.jdk/Contents/Home/bin/javac /p:JarPath=/Library/Java/JavaVirtualMachines/microsoft-21.jdk/Contents/Home/bin/jar /p:JavaPath=/Library/Java/JavaVirtualMachines/microsoft-21.jdk/Contents/Home/bin/javadotnet test tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests.csproj -v:minimal --no-restore