Skip to content

[TrimmableTypeMap] SafeJavaCollectionFactory per-container construction#12131

Closed
simonrozsival wants to merge 8 commits into
dotnet:mainfrom
simonrozsival:dev/simonrozsival/trimmable-safe-container-converter-factory
Closed

[TrimmableTypeMap] SafeJavaCollectionFactory per-container construction#12131
simonrozsival wants to merge 8 commits into
dotnet:mainfrom
simonrozsival:dev/simonrozsival/trimmable-safe-container-converter-factory

Conversation

@simonrozsival

@simonrozsival simonrozsival commented Jul 16, 2026

Copy link
Copy Markdown
Member

Note

Draft. Independent of #12126 (the JavaListTest fix); this PR targets main directly.

What

Reworks the trimmable-typemap Java-collection converter (SafeJavaCollectionFactory) so the AOT-safe construction of JavaList<T> / JavaCollection<T> / JavaDictionary<K,V> is explicit and easy to reason about, and hardens a couple of edge cases. Net change touches the two implementation files, SafeJavaCollectionFactory.cs and ValueTypeFactory.cs, plus a targeted JavaConvertTest regression test (the factory name, public entry point name, and JavaConvert.cs are unchanged vs main).

Construction split into explicit, non-overlapping paths

Per-container private methods (TryCreateListFromJniHandle / TryCreateCollectionFromJniHandle / TryCreateDictionaryFromJniHandle) chained via ... || ..., each handling exactly one wrapper:

  • reference element/key/value argstypeof(JavaList<>).MakeGenericType(...) + Activator.CreateInstance, riding NativeAOT's __Canon canonical templates;
  • mapped primitive/nullable argsValueTypeFactory, rooting the exact instantiation with a direct new (no reflection);
  • any other value type (e.g. a custom struct) → NotSupportedException (no AOT-unsafe reflection fallback), covered for list, collection, and dictionary interface/concrete targets by a trimmable-path regression test.

Concrete-literal rooting (replaces [DynamicallyAccessedMembers] for reference wrappers)

Each reference path has an if (elementType == typeof(IJavaPeerable)) branch that constructs the concrete JavaList<IJavaPeerable> / JavaCollection<IJavaPeerable> / JavaDictionary<IJavaPeerable,IJavaPeerable> literal. That branch is effectively unreachable at runtime, but being a statically reachable reference to the concrete instantiation it forces the trimmer/ILC to keep the (IntPtr, JniHandleOwnership) activation constructor of the shared __Canon template. The else branch reuses exactly that canonical constructor for every other reference-argument instantiation. This roots the constructor through real code rather than relying on [DAM] flowing through reflection.

ValueTypeFactory's mixed reference/value dictionaries (JavaDictionary<string,int>, …) use the analogous CreateReferenceMixedDictionary<JavaDictionary<IJavaPeerable,T>> / <JavaDictionary<T,IJavaPeerable>> exemplars to root the JavaDictionary<__Canon,T> / JavaDictionary<T,__Canon> templates, replacing the previous static rooting-token fields. Value/value dictionaries continue to be rooted by the statically-emitted GVM cross-product (new JavaDictionary<TKey,T>()).

Hardening

  • Reject open / partially-open constructed targets (IList<>, IDictionary<T,int>, …) via a ContainsGenericParameters check up front, so they fail cleanly instead of throwing ArgumentException during activation.

Suppressions

The reflective paths carry the minimum verified suppressions: IL3050 (RequiresDynamicCode) and IL2055 on MakeGenericType, plus IL2072 on the Activator.CreateInstance calls whose constructed types ride the rooted canonical templates. The IL2055 justification is corrected to state the real cause — MakeGenericType cannot prove the runtime element type satisfies the [DAM(Constructors)] requirement on the wrapper's element parameter, a requirement only exercised by the dynamic-code path (the trimmable path never activates elements through the wrapper).

Validation

  • ✅ Builds locally (Mono.Android, Debug, net11.0) with no new warnings and no IL trim/AOT analysis errors.
  • ⚠️ A NativeAOT publish + runtime round-trip (e.g. marshaling JavaList<string>, JavaDictionary<string,object>, JavaDictionary<string,int>) is still recommended to confirm the concrete-literal anchor roots the canonical-shared activation constructor as intended; relying on CI/device tests for full confirmation.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

simonrozsival and others added 3 commits July 16, 2026 12:14
…rterFactory

Pure rename in preparation for the container converter redesign: the
factory will grow per-container factories and own non-generic collection
targets, so "Collection" no longer describes its scope. Also renames the
entry point TryGetFromJniHandleConverter -> TryCreateConverter and updates
the JavaConvert and ValueTypeFactory references.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f9ec4f09-ffc5-4f66-99bf-ec40ea2728e8
…ner factories

Split the container converter into explicit, per-container factories
(JavaListTypeFactory, JavaCollectionTypeFactory, JavaDictionaryTypeFactory)
chained via TryCreateFromJniHandle(...) || ..., following the AOT prototype:

- reference element/key/value arguments -> typeof(<>).MakeGenericType + Activator,
  riding NativeAOT's __Canon canonical templates;
- mapped primitive/nullable arguments -> ValueTypeFactory, rooting the exact
  instantiation with a direct `new` (no reflection);
- any other value type (custom struct) -> NotSupportedException, instead of the
  previous silent fall-through to a non-generic wrapper.

TryCreateConverter now also owns non-generic JavaList/JavaCollection/JavaDictionary
targets (and the raw IList/ICollection/IDictionary interfaces), delegating to the
existing FromJniHandle helpers (direct construction, no reflection). JavaConvert's
trimmable branch moves out of the IsGenericType guard so the factory is the single
entry point for all Java collection construction on the trimmable path.

NOTE: not yet built locally (requires the full framework); pending CI/local build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f9ec4f09-ffc5-4f66-99bf-ec40ea2728e8
The explicit <Compile> item still referenced the old
SafeJavaCollectionFactory.cs filename, breaking the build with CS2001.
Point it at the renamed SafeContainerConverterFactory.cs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f9ec4f09-ffc5-4f66-99bf-ec40ea2728e8
@simonrozsival
simonrozsival force-pushed the dev/simonrozsival/trimmable-safe-container-converter-factory branch from 6b95734 to b5b9561 Compare July 16, 2026 10:22
simonrozsival and others added 4 commits July 16, 2026 13:07
Convert ISafeContainerTypeFactory into an abstract SafeContainerTypeFactory
base class and move the MakeGenericType and CreateInstance reflection helpers
(with their trimming/AOT suppressions) onto it as protected members, so the
per-container factories use them via inheritance instead of sharing them
through the entry-point class.

MakeGenericType now takes a concrete reference-argument wrapper exemplar
(e.g. JavaList<IJavaPeerable>) whose DynamicallyAccessedMembers annotation
roots the __Canon template, making the AOT safety obvious. The dishonest
[return: DAM] is dropped in favor of scoping the constructor suppression to
CreateInstance. ValueTypeFactory's mixed reference/value dictionaries adopt
the same exemplar pattern, removing the dedicated rooting-token fields.

Also rename the dictionary GetValueTypeFactoryOrThrow helper to a pure
TryGetPrimitiveValueTypeFactory Try-pattern method, with an up-front
EnsureReferenceOrPrimitive validation, and revert the entry-point class name
back to SafeJavaCollectionFactory to reduce churn.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 50ab60e5-5b4d-4e5f-abd2-2878d87a10c9
…stification

Revert the JavaConvert.cs restructuring that hoisted the trimmable-typemap
branch out of the generic-target guard. Non-generic collection targets are
already handled identically by the existing IDictionary/IList/ICollection
fallback below, so routing them through SafeJavaCollectionFactory was
redundant. JavaConvert.cs now matches main except for the entry-point method
name. Drop the resulting dead TryCreateNonGenericConverter helper (and the
now-unused System.Collections import) from SafeJavaCollectionFactory.

Also correct the IL2055 suppression justifications on MakeGenericType and
CreateReferenceMixedDictionary: IL2055 is raised because the runtime element
arguments cannot be proven to satisfy the DynamicallyAccessedMembers
(Constructors) requirement on the wrapper's element type parameters, not
because of the wrapper's own constructor. On the trimmable typemap path the
wrapper never activates its elements (peer creation goes through JavaConvert
and the typemap), so the requirement is never exercised.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 50ab60e5-5b4d-4e5f-abd2-2878d87a10c9
…static factories

Replace the DynamicallyAccessedMembers-based rooting of the reference-argument
collection wrappers with a concrete-literal approach that is easier to reason
about. Each factory now branches on the IJavaPeerable element shape: that branch
is a statically reachable reference to the concrete JavaList<IJavaPeerable> /
JavaCollection<IJavaPeerable> / JavaDictionary<IJavaPeerable,IJavaPeerable>
instantiation, so the trimmer/ILC keeps the (IntPtr, JniHandleOwnership)
activation constructor of the shared __Canon template that the reflective else
branch reuses for every other reference-argument instantiation.

Also:
- Reject open and partially-open constructed target types (ContainsGenericParameters)
  up front so they fail cleanly instead of throwing ArgumentException during activation.
- Convert the per-container factories to static classes with a single static
  TryCreateFromJniHandle method (no abstract base class or instances).
- Trim the trimming/AOT suppressions to the verified minimum (IL3050 + IL2055 on
  MakeGenericType) and correct the IL2055 justification: it fires because
  MakeGenericType cannot prove the runtime element type satisfies the wrapper's
  DAM(Constructors) element-parameter requirement, which is never exercised on the
  trimmable path since the wrapper does not activate its elements.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 50ab60e5-5b4d-4e5f-abd2-2878d87a10c9
Merge the three static per-container factory classes into three private static
methods on SafeJavaCollectionFactory (TryCreateListFromJniHandle,
TryCreateCollectionFromJniHandle, TryCreateDictionaryFromJniHandle), moving
their suppressions and helpers onto the class and making ActivationConstructorBinding
a private const. No behavior change.

Also rename the entry point TryCreateConverter back to TryGetFromJniHandleConverter,
which restores the original name and leaves JavaConvert.cs identical to main.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 50ab60e5-5b4d-4e5f-abd2-2878d87a10c9
@simonrozsival
simonrozsival marked this pull request as ready for review July 16, 2026 15:52
Copilot AI review requested due to automatic review settings July 16, 2026 15:52
@simonrozsival

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Android PR Reviewer completed successfully!

@simonrozsival simonrozsival changed the title [Mono.Android] SafeContainerConverterFactory: per-container factories for trimmable typemap [TrimmableTypeMap] SafeContainerConverterFactory per-container factories Jul 16, 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

Updates the trimmable-typemap Java collection construction path in src/Mono.Android/Java.Interop by refactoring how JavaList<> / JavaCollection<> / JavaDictionary<,> wrappers are instantiated from JNI handles, including new reflection/AOT-trimming suppressions and a reworked mixed reference/value dictionary creation helper.

Changes:

  • Refactors SafeJavaCollectionFactory to use explicit per-container creation methods (TryCreate*FromJniHandle) with stricter support checks and clearer error behavior for unsupported value-type generic arguments.
  • Reworks ValueTypeFactory<T> mixed dictionary creation to root canonical reference/value shapes via exemplar instantiations and centralized Activator.CreateInstance logic (with detailed suppression justifications).
  • Adds/updates documentation and supporting imports for the new construction paths.
Show a summary per file
File Description
src/Mono.Android/Java.Interop/SafeJavaCollectionFactory.cs Refactors converter selection and per-container wrapper creation; introduces additional reflection usage that needs trim-analyzer suppressions.
src/Mono.Android/Java.Interop/ValueTypeFactory.cs Reworks mixed reference/value JavaDictionary<,> creation via exemplar-rooted canonical templates and adds supporting reflection helpers/suppressions.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 5

Comment thread src/Mono.Android/Java.Interop/SafeJavaCollectionFactory.cs
Comment thread src/Mono.Android/Java.Interop/SafeJavaCollectionFactory.cs
Comment thread src/Mono.Android/Java.Interop/SafeJavaCollectionFactory.cs
Comment thread src/Mono.Android/Java.Interop/SafeJavaCollectionFactory.cs
Comment thread src/Mono.Android/Java.Interop/SafeJavaCollectionFactory.cs Outdated

@github-actions github-actions Bot 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.

🤖 Android PR Review — ⚠️ Needs Changes (minor)

Nicely done refactor. Splitting the container construction into explicit per-container TryCreate...FromJniHandle methods with concrete-literal IJavaPeerable rooting branches is far easier to reason about than the previous shape/enum indirection, and the XML docs + suppression justifications are excellent. Rejecting open/partially-open constructed types up front (ContainsGenericParameters) is a good hardening.

Findings

  • ⚠️ 1 behavioral change to confirm (inline): unsupported value-type arguments on a known container now throw NotSupportedException at marshal time instead of returning false and falling through to the non-generic FromJniHandle fallback in JavaConvert. Likely intended and arguably better, but it's runtime-observable — please confirm and consider a regression test.
  • 💡 1 minor readability nit (inline) on the Debug.Assert in ValueTypeFactory.cs.

Notes

  • CI: no status checks reported on the head commit yet (state: pending, total_count: 0). Not mergeable until CI is green.
  • Validation gap: the PR description itself flags that a NativeAOT publish + runtime round-trip hasn't been run to confirm the concrete-literal anchor actually roots the __Canon activation constructor. That's the load-bearing assumption of this change — recommend confirming via device/AOT CI before merge rather than static reasoning alone.

Positives: consistent formatting, tabs/Mono style, no ! null-forgiving operators, thorough suppression justifications, and existing JavaConvertTest coverage for the primitive/byte/nullable paths.

Generated by Android PR Reviewer for #12131 · 127.2 AIC · ⌖ 13.2 AIC · ⊞ 6.8K
Comment /review to run again

Comment thread src/Mono.Android/Java.Interop/ValueTypeFactory.cs Outdated
Comment thread src/Mono.Android/Java.Interop/SafeJavaCollectionFactory.cs
@simonrozsival simonrozsival changed the title [TrimmableTypeMap] SafeContainerConverterFactory per-container factories [TrimmableTypeMap] SafeJavaCollectionFactory per-container construction Jul 16, 2026
Add the missing trim suppressions, clarify activation behavior, simplify the mixed dictionary helper, and cover unsupported value-type containers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 70f63eb7-6599-414c-a947-d860705aa0fa
@simonrozsival

Copy link
Copy Markdown
Member Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants