[Java.Interop] Restore pure-reflection registration for JavaProxyObject#1468
[Java.Interop] Restore pure-reflection registration for JavaProxyObject#1468simonrozsival wants to merge 9 commits into
Conversation
#1441 changed `JavaProxyObject.RegisterNativeMembers` from `private` to `internal` so the new reflection-free `JniRuntime.JniTypeManager.TryRegisterBuiltInNativeMembers ()` could call it directly. A side effect is that the `[JniAddNativeMethodRegistrationAttribute]`- annotated method now appears in Java.Interop's *reference* assembly. dotnet/android's trimmable typemap scanner reads reference assemblies and rejects any type carrying that attribute (XA4251), so every trimmable typemap build now fails on the built-in `JavaProxyObject` (see dotnet/android#11622). Restore the method to `private` (so it is stripped from the reference assembly) and expose a small attribute-free `internal` entry point, `RegisterBuiltInNativeMembers (JniType)`, for the built-in registration path to call. Runtime behavior is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Restores JavaProxyObject.RegisterNativeMembers to a private method to prevent [JniAddNativeMethodRegistrationAttribute] from appearing in Java.Interop’s reference assembly, while preserving the reflection-free built-in registration path via a new attribute-free internal entry point.
Changes:
- Update built-in registration flow to call
JavaProxyObject.RegisterBuiltInNativeMembers (JniType)instead of the attribute-annotated method. - Make
JavaProxyObject.RegisterNativeMembers (JniNativeMethodRegistrationArguments)private again and add an internal wrapper that performs the same registrations.
Show a summary per file
| File | Description |
|---|---|
| src/Java.Interop/Java.Interop/JniRuntime.JniTypeManager.cs | Switch built-in native member registration for JavaProxyObject to the new internal attribute-free entry point. |
| src/Java.Interop/Java.Interop/JavaProxyObject.cs | Make the attribute-annotated registration method private and introduce RegisterBuiltInNativeMembers (JniType) to keep built-in registration working without exposing the attribute in ref assemblies. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 0
#1441 added `JniRuntime.JniTypeManager.TryRegisterBuiltInNativeMembers ()` -- a reflection-free direct call to `JavaProxyObject.RegisterNativeMembers` -- and bumped that method to `internal` so the call would compile. That method is dead code: nothing in Java.Interop (or dotnet/android) ever calls it, and the base `JniTypeManager.RegisterNativeMembers` is a no-op. `JavaProxyObject`'s native members are -- and were before #1441 -- registered purely via reflection in `ReflectionJniTypeManager` (`FindAndCallRegisterMethod` discovering the `[JniAddNativeMethodRegistrationAttribute]`-annotated method), which finds private methods just fine via `GetRuntimeMethods ()`. Remove the unused `TryRegisterBuiltInNativeMembers` and restore `RegisterNativeMembers` to `private` (its state before #1441). Making it private again also strips the attribute-bearing method from the reference assembly, which fixes dotnet/android's trimmable typemap scanner falsely rejecting the built-in `JavaProxyObject` with XA4251 (dotnet/android#11622). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
/review |
|
✅ Java.Interop PR Reviewer completed successfully! |
|
/azp run |
|
No pipelines are associated with this pull request. |
There was a problem hiding this comment.
⚠️ Needs Changes — 1 blocking issue
The XA4251 root-cause analysis is sound and the core fix is right: reverting JavaProxyObject.RegisterNativeMembers to private keeps it out of the reference assembly while reflection still discovers it (FindAndCallRegisterMethod enumerates GetRuntimeMethods (), which includes private methods). 👍
The blocking problem is the companion deletion of JniRuntime.JniTypeManager.TryRegisterBuiltInNativeMembers. The PR describes it as dead code with "no behavior change," but neither holds:
- ❌ It has two live callers in this repo —
samples/Hello-NativeAOTFromJNI/NativeAotTypeManager.cs:124andsamples/Hello-NativeAOTFromAndroid/NativeAotTypeManager.cs:29. BothJniTypeManagersubclasses call it, so they no longer compile (CS0103).Hello-NativeAOTFromJNIis inJava.Interop.slnand is published + run in CI — onlycontinueOnError: truekeeps that red build from failing the pipeline (so the GitHub checks, currently justlicense/cla, won't catch it). - ❌ It's a functional regression for NativeAOT. NativeAOT type managers don't use the reflection path; this helper is the sole mechanism that registers
JavaProxyObject'sequals/hashCode/toStringnatives for them. Removing it drops those registrations.
Suggested path forward: keep the private revert, but instead of deleting the helper, give JavaProxyObject an internal, non-attributed registration helper that both the private attributed RegisterNativeMembers and TryRegisterBuiltInNativeMembers call. Then restore the method and its PublicAPI.Unshipped.txt entry. See the inline comment for specifics.
Please validate locally with dotnet build samples/Hello-NativeAOTFromJNI/Hello-NativeAOTFromJNI.csproj before merging, since CI won't flag this.
Generated by Java.Interop PR Reviewer for issue #1468 · 588.3 AIC · ⌖ 49.4 AIC · ⊞ 34.6K
Comment /review to run again
The previous commit deleted JniRuntime.JniTypeManager.TryRegisterBuiltInNativeMembers claiming it was unused. That was wrong: it has two live callers in the NativeAOT samples (samples/Hello-NativeAOTFromJNI/NativeAotTypeManager.cs and samples/Hello-NativeAOTFromAndroid/NativeAotTypeManager.cs), both JniTypeManager subclasses. Deleting it broke them (CS0103) and removed the only path that registers JavaProxyObject's equals/hashCode/toString natives for NativeAOT type managers (which don't use the reflection-based FindAndCallRegisterMethod path). The breakage was hidden in CI only because the NativeAOT sample steps use continueOnError: true. Keep the XA4251 fix (RegisterNativeMembers stays private, so the [JniAddNativeMethodRegistrationAttribute] is stripped from the reference assembly) and restore TryRegisterBuiltInNativeMembers by extracting the registration logic into a new attribute-free internal helper, JavaProxyObject.AddBuiltInRegistrations. Both the private attributed RegisterNativeMembers (reflection path) and TryRegisterBuiltInNativeMembers (NativeAOT path) call it. Restore the PublicAPI.Unshipped.txt entry as well. Verified: Java.Interop.dll builds clean; the reference assembly contains no method carrying [JniAddNativeMethodRegistration]; the sample's call to TryRegisterBuiltInNativeMembers resolves (no CS0103). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thanks for the careful review — you're right on both counts, and I've pushed a fix (b2a2a15).
Fix keeps the XA4251 change and restores the method, per your suggestion:
Verified locally:
|
Replaces the production TryRegisterBuiltInNativeMembers helper with a samples-only solution, restoring the pre-#1441 behavior for the NativeAOT type managers. Background: making JavaProxyObject.RegisterNativeMembers private (the XA4251 fix) is sufficient for the default Android runtime, which discovers and invokes it via reflection (FindAndCallRegisterMethod -> GetRuntimeMethods(), which returns private methods). The only thing that needed JavaProxyObject's registration via a non-reflection path was the two NativeAOT samples, which #1441 had rewritten to derive from the reflection-free JniRuntime.JniTypeManager base. That is why #1441 introduced TryRegisterBuiltInNativeMembers. Instead of carrying that helper in production, have the sample type managers derive from JniRuntime.ReflectionJniTypeManager again - exactly what the base JniRuntime.JniTypeManager provided before #1441. Built-in types such as JavaProxyObject/JavaProxyThrowable are then registered automatically via reflection, and the samples no longer need any custom registration code. Changes: - JavaProxyObject.RegisterNativeMembers: internal -> private. The marshalers stay (the default reflection runtime registers them by reflecting over this type); only the visibility changes, which strips the attribute from the reference assembly and fixes the dotnet/android XA4251 scanner failure. - Delete JniRuntime.JniTypeManager.TryRegisterBuiltInNativeMembers (+ its PublicAPI.Unshipped.txt entry); no longer needed. - Hello-NativeAOTFromJNI / Hello-NativeAOTFromAndroid: derive from JniRuntime.ReflectionJniTypeManager and drop the reflection-free overrides and hand-written JavaProxyObject registration. ReflectionJniTypeManager is [RequiresDynamicCode]/[RequiresUnreferencedCode], so the constructor suppresses IL2026/IL3050 with [UnconditionalSuppressMessage]. A #pragma is insufficient here: it silences the Roslyn analyzer but not the ILLink/ILC publish passes, which only honor the attribute (verified with a trim-publish of the real type manager against Java.Interop.dll). Net: production loses code (1-line visibility change + deletions) and the samples shrink substantially. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Reworked per review feedback — instead of carrying a registration helper in production, the NativeAOT samples now derive from Production is now smaller, not larger:
Samples: derive from A
Net: 47 insertions / 261 deletions.
|
|
/review |
|
✅ Java.Interop PR Reviewer completed successfully! |
…gers The [UnconditionalSuppressMessage] Justification strings already convey the necessary context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🤖 Review: ⚠️ Needs Changes
Thanks for iterating on this. The core change is right: making JavaProxyObject.RegisterNativeMembers private strips [JniAddNativeMethodRegistrationAttribute] from the reference assembly (fixing dotnet/android's XA4251 typemap-scanner rejection), while the runtime still discovers that private static method via reflection (ReflectionJniTypeManager.FindAndCallRegisterMethod → GetRuntimeMethods (), which finds non-public methods). Removing TryRegisterBuiltInNativeMembers is safe now that nothing calls it (it was unshipped API), and the previous review round's concerns (CS0103 from deleting a still-referenced helper / lost built-in registration) are genuinely resolved by switching the samples to ReflectionJniTypeManager. 👍
A few things to address before merge:
⚠️ Trimmer/AOT validation gap — the NativeAOT samples now derive from the AOT-incompatibleReflectionJniTypeManagerand blanket-suppress IL2026/IL3050. The suppressions mean a warning-free trim/AOT publish no longer demonstrates the reflected-over registration survives trimming, and the sample build/run steps run undercontinueOnError, so CI won't catch a runtime regression.App.csalso never creates aJavaProxyObject, so the sample doesn't actually exercise the scenario being fixed. Please confirm by running the published native binary. (inline)- 💡 Duplicate simple reference in
Hello-NativeAOTFromJNI—GetSimpleReferencesnow yieldsexample/ManagedTypetwice becauseManagedTypecarries[JniTypeSignature]and sits intypeMappings. (inline) - 💡 Dead
typeMappings == nullguard. (inline)
Process notes:
- The PR description is stale — it still describes the abandoned first approach ("Net change: 1 insertion, 18 deletions", "No behavior change",
TryRegisterBuiltInNativeMembersis "dead code"). The actual change is +31 / −261 and rewrites both NativeAOT sample type managers to reflection-based registration. Please update it so reviewers and the merge history stay accurate. - CI: only the
license/clacheck has reported; the Azure DevOpsdotnet.java-interopbuild is still pending. Not mergeable until that build is green.
Verdict:
Generated by Java.Interop PR Reviewer for issue #1468 · 1.3K AIC · ⌖ 48.9 AIC · ⊞ 34.6K
Comment /review to run again
Build 1465402's "run Hello-NativeAOTFromJNI" step failed at runtime:
System.NotSupportedException: Could not find System.Type corresponding to
Java type JniTypeSignature(TypeName=example/ManagedType ...)
at Java.Interop.ManagedPeer.RegisterNativeMembers(...)
at example.ManagedType.<clinit>(ManagedType.java:15)
Root cause: after #1441, JniRuntime.JniTypeManager.GetType() dispatches through
GetTypeForSimpleReference (singular), not GetTypesForSimpleReference (plural).
The previous sample rewrite only overrode the plural method, so app types like
example/ManagedType were never resolved (the base ReflectionJniTypeManager's
GetTypeForSimpleReference only knows built-in types), and ManagedPeer
registration threw before it could register the type.
Fix: override GetTypeForSimpleReference (singular) in both NativeAOT sample type
managers to resolve the sample's own managed types, falling back to the base for
built-ins. Registration and the reverse Type->JNI mapping continue to be handled
by the reflection base (the pre-#1441 behavior). The override carries the same
[return: DynamicallyAccessedMembers(...)] as the base to satisfy IL2093.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`typeMappings` is assigned in its field initializer and never set to null, so the `if (typeMappings == null) yield break;` guard in CreateSimpleReferencesEnumerator was unreachable dead code. Remove it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thanks — went through the review comments. Status against the current head ( 1. 2. Unreachable 3. Duplicate simple reference from 4. Trim/AOT suppression hiding risk + sample not exercising the scenario — partly addressed, partly by-design:
New build is running on |
|
/review |
|
✅ Java.Interop PR Reviewer completed successfully! |
There was a problem hiding this comment.
⚠️ Needs Changes (non-blocking) — core fix is correct
The XA4251 fix itself is sound and minimal, and I verified the key claims independently:
- ✅
JavaProxyObject.RegisterNativeMembersinternal→privateis safe. The default runtime discovers it throughReflectionJniTypeManager.FindAndCallRegisterMethod, which enumeratesmarshalType.GetRuntimeMethods()(returns private methods) and callsCreateDelegate(works on private statics). The[DynamicallyAccessedMembers]annotations onRegisterNativeMembers(... Type type ...)andFindAndCallRegisterMethod(Type marshalType, ...)preserve the method under trimming, so trimming-preservation and ref-assembly stripping are correctly independent. - ✅ Removing
TryRegisterBuiltInNativeMembersleaves no dangling callers insrc/(the two sample call sites were migrated), and thePublicAPI.Unshipped.txtentry was removed to match. - ✅ Prior-round feedback (the retracted "dead code" claim, the
typeMappings == nullguard, the duplicate simple references, and theGetType()→singularGetTypeForSimpleReferenceresolution bug) all look addressed at the current head. No duplicate-yield inGetTypesForSimpleReference— the base plural path reads a separate built-in dictionary, not the overridden singular.
Please address before merge
⚠️ Stale/inaccurate PR description. It still assertsTryRegisterBuiltInNativeMembersis "dead code" that "nothing... ever calls" (retracted in the thread — the samples called it) and states "Net change: 1 insertion, 18 deletions. No behavior change," whereas the actual diff is 44/-264 across 5 files and the samples' registration path changes from hand-rolled reflection-free to reflection-based. Since this becomes the squash-merge message, please update it to describe the final approach (samples derive fromReflectionJniTypeManager).⚠️ Untested behavioral change in the samples — see the inline comment onHello-NativeAOTFromJNI. FromAndroid isn't built/run in CI, and FromJNI's publish + run steps arecontinueOnError: true, so neither sample's AOT correctness is gated. Please confirm FromAndroid was actually published/run.- 💡 Suppression justification accuracy — see the inline comment; the
[UnconditionalSuppressMessage]text contradicts the attributes it suppresses.
CI
Only license/cla is visible and green; no build/test pipeline is reported on the PR (the /azp run bot replied "No pipelines are associated with this pull request"), and mergeable_state is blocked. I couldn't confirm a full green build from here — worth verifying the AzDO build is green before merge.
Nice, surgical fix overall — the comments above are about documentation accuracy and test coverage, not the correctness of the production change.
Generated by Java.Interop PR Reviewer for issue #1468 · 1.3K AIC · ⌖ 48.2 AIC · ⊞ 34.6K
Comment /review to run again
| internal const DynamicallyAccessedMemberTypes Methods = DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods; | ||
| internal const DynamicallyAccessedMemberTypes MethodsAndPrivateNested = Methods | DynamicallyAccessedMemberTypes.NonPublicNestedTypes; | ||
| internal const DynamicallyAccessedMemberTypes MethodsConstructors = MethodsAndPrivateNested | DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors; | ||
| class NativeAotTypeManager : JniRuntime.ReflectionJniTypeManager { |
There was a problem hiding this comment.
🤖 PublishAot=true sample now derives from ReflectionJniTypeManager, which is explicitly [RequiresDynamicCode]/[RequiresUnreferencedCode] ("not compatible with Native AOT"). It works here only because the assembly is rooted via TrimmerRootAssembly and the [DynamicallyAccessedMembers] annotations on the RegisterNativeMembers(Type) → FindAndCallRegisterMethod path preserve the reflected members — but it inverts what these samples exist to demonstrate (a reflection-free AOT type manager) by depending on a base the runtime declares AOT-incompatible.
The safety net is also thin: Hello-NativeAOTFromAndroid isn't built or run in CI at all, and Hello-NativeAOTFromJNI's publish + RunJavaSample steps are continueOnError: true (build-tools/automation/templates/core-tests.yaml:83,90), so an AOT regression in either sample won't fail the build. Please confirm the FromAndroid sample was actually published/run (not just "structurally identical"), and consider keeping at least one sample on the reflection-free path so the AOT pattern stays demonstrated.
Rule: Consider trimmer/NativeAOT impact
There was a problem hiding this comment.
Acknowledged — and this is a deliberate trade-off, now documented in a class-level comment (4de18b34).
You're right that deriving from ReflectionJniTypeManager inverts the "reflection-free AOT type manager" pattern the samples originally demonstrated. The reason we're accepting that here: these are samples, not product. .NET for Android — what we actually ship — does not pair ReflectionJniTypeManager with NativeAOT; our NativeAOT path uses the trimmable typemap (reflection-free registration), which is exactly what making JavaProxyObject.RegisterNativeMembers private unblocks more of. Re-implementing a hand-written reflection-free registration in the java-interop samples (what #1441 originally did) is real work with no product benefit, so for now the suppression is the pragmatic choice. This also isn't a new AOT hole — pre-#1441 the equivalent suppressions lived inside JniTypeManager itself (justified NotUsedInAndroid).
On the CI safety net — you're correct, and I won't overclaim:
Hello-NativeAOTFromAndroidis not built or run in CI (not inJava.Interop.sln; needs an Android device). I did not separately publish/run it — it's the same code shape as FromJNI, but I'm not asserting execution coverage for it.Hello-NativeAOTFromJNIis exercised: CI'sRunJavaSamplepublishes + runs it on the JVM, and that step is what caught the real type-resolution bug I then fixed ina8f3f2b4. The latest green build (run Hello-NativeAOTFromJNIsucceeded) confirms the published native binary actually runs.
If keeping one sample on the reflection-free path is something you'd like, I'm happy to do it as a follow-up — but I'd rather not expand this PR (its job is to unblock the dotnet/android bump).
Add a class-level comment to both NativeAOT sample type managers explaining the rationale for the [UnconditionalSuppressMessage] IL2026/IL3050 suppressions: - These are *samples*, not product code. .NET for Android (what we ship) does not pair ReflectionJniTypeManager with NativeAOT, so it isn't worth the effort to make these samples fully trim/AOT-clean right now. - The reflection paths were always trim/AOT-unsafe. Before #1441 the equivalent suppressions lived inside JniTypeManager itself (justified "NotUsedInAndroid"); #1441 simply moved that responsibility to callers via [RequiresDynamicCode]/[RequiresUnreferencedCode]. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…'s safe
The previous IL2026/IL3050 justifications ("does not require unreferenced code /
runtime code generation") read as the opposite of reality, since
ReflectionJniTypeManager is exactly [RequiresUnreferencedCode]/[RequiresDynamicCode].
Reword to describe why the suppression is correct for this sample:
- IL2026: the assembly is rooted via TrimmerRootAssembly and the reflected
registration members are preserved by the [DynamicallyAccessedMembers]
annotations on the RegisterNativeMembers(Type) -> FindAndCallRegisterMethod path.
- IL3050: registration uses CreateDelegate on compile-time-known static methods
(no MakeGenericType / expression compilation), so no runtime codegen is required.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Problem
dotnet/android's trimmable typemap scanner rejects any type carrying
[JniAddNativeMethodRegistrationAttribute]with XA4251, and it currently fails on the built-inJava.Interop.JavaProxyObject— blocking the Java.Interop bump in dotnet/android#11622.Root cause
#1441 added
JniRuntime.JniTypeManager.TryRegisterBuiltInNativeMembers ()— a reflection-free direct call toJavaProxyObject.RegisterNativeMembers— and changed that method fromprivatetointernalso the call would compile.But that method is dead code: nothing in Java.Interop or dotnet/android ever calls
TryRegisterBuiltInNativeMembers, and the baseJniTypeManager.RegisterNativeMembersis a no-op.JavaProxyObject's natives are — and were before #1441 — registered purely via reflection inReflectionJniTypeManager(FindAndCallRegisterMethoddiscovering the attributed method), which findsprivatemethods fine viaGetRuntimeMethods ().The
internalbump is what made the attribute leak into the reference assembly (private members are stripped from ref assemblies; internal ones are not), where the scanner sees it.Fix
TryRegisterBuiltInNativeMembers(+ itsPublicAPI.Unshipped.txtentry).JavaProxyObject.RegisterNativeMemberstoprivate— its state before Split reflection-based JniTypeManager and JniValueManager behavior #1441.Net change: 1 insertion, 18 deletions. No behavior change — registration still happens via reflection exactly as it did before #1441.
Verification
Java.Interop.csprojbuilds clean (0/0); PublicApiAnalyzer consistent.Register*method onJavaProxyObject(the attributed method is stripped), so the trimmable scanner no longer trips XA4251. The implementation assembly keeps the private attributed method for reflection.Unblocks dotnet/android#11622. cc @jonathanpeppers — review/approve once CI is green please? 🙏