From 64ccf76a8df300c973ff744b2a7847dd995a6fad Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 12:21:18 +0200 Subject: [PATCH 01/15] [TrimmableTypeMap] Runtime and typemap generator fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 4 of the split of the trimmable-test-plumbing branch. A collection of correctness fixes for the trimmable typemap runtime and code generator, uncovered while stabilizing the new CoreCLRTrimmable test lane. ### Runtime / managed side * Fix runtime initialization ordering and generic type crash — a generic proxy type was being resolved before the typemap was ready, causing a NullReferenceException on startup. * Fix generic type proxy loading in the typemap — the generic definition's Type.FullName includes a mangled form that did not match the proxy's lookup key. * Revert two-phase init — use a single Initialize () after runtime creation. The two-phase scheme was only needed for a register-natives workaround that no longer applies. * Fix ClassLoader mismatch in RegisterNatives — use the class reference passed from Java (nativeClassHandle) via JniType (ref classRef, Copy) instead of resolving the name with JniType (string), because FindClass may resolve to a different class from a different ClassLoader. * Fix Release build pipeline and ApplicationRegistration — the ApplicationRegistration helper was not being emitted in Release where the generator ran under a different code path. ### Code generator * Include abstract Instrumentation/Application subtypes in ApplicationRegistration. Their native methods (n_OnCreate, n_OnStart, etc.) are declared on the abstract base class and must be registered via ApplicationRegistration.registerApplications (). * Self-apply generated proxy types as their own [JavaPeerProxy] custom attribute so the runtime can instantiate them via type.GetCustomAttribute () for AOT-safe resolution. ### Tests * New regression test Generate_ProxyType_IsSelfAppliedAsCustomAttribute asserts every generated proxy type carries its own self-applied custom attribute. This invariant has regressed twice; lock it down. * Update Execute_CollectsDeferredRegistrationTypes to reflect that abstract Instrumentation/Application subtypes are now included in ApplicationRegistrationTypes. ### Known gap / follow-up Descendants of Application and Instrumentation that are themselves concrete still fail to register their native methods in the lazy __md_registerNatives pattern when the base is abstract and the subclass is loaded before the managed runtime is ready. The current PR intentionally does not land the 'propagate CannotRegisterInStaticConstructor to descendants' fix — instead we plan to open a follow-up issue to unify the registration approach (always defer via __md_registerNatives) and drop the propagation machinery entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/ModelBuilder.cs | 3 +- .../Generator/TypeMapAssemblyEmitter.cs | 19 +++++++- .../TrimmableTypeMapGenerator.cs | 2 +- .../TrimmableTypeMap.cs | 12 +++-- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 16 +++++-- .../TrimmableTypeMapGeneratorTests.cs | 11 +++-- .../TypeMapAssemblyGeneratorTests.cs | 48 +++++++++++++++++++ 7 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index d61d716cbb3..aa0c820a09c 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -132,8 +132,9 @@ static void EmitPeers (TypeMapAssemblyData model, string jniName, // Emit TypeMapAssociation for all proxy-backed types so managed → proxy // lookup works even when the final JNI name differs from the type's attributes. + // Skip generic definitions — their open generic type can't be loaded by the runtime. var assocProxy = (i > 0 && primaryProxy != null) ? primaryProxy : proxy; - if (assocProxy != null) { + if (assocProxy != null && !peer.IsGenericDefinition) { model.Associations.Add (new TypeMapAssociationData { SourceTypeReference = AssemblyQualify (peer.ManagedTypeName, peer.AssemblyName), AliasProxyTypeReference = AssemblyQualify ($"{assocProxy.Namespace}.{assocProxy.TypeName}", assemblyName), diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs index 70521a1473f..1abfb69262e 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs @@ -69,6 +69,7 @@ sealed class TypeMapAssemblyEmitter AssemblyReferenceHandle _javaInteropRef; TypeReferenceHandle _javaPeerProxyRef; + TypeReferenceHandle _javaLangObjectRef; TypeReferenceHandle _iJavaPeerableRef; TypeReferenceHandle _jniHandleOwnershipRef; TypeReferenceHandle _jniObjectReferenceRef; @@ -165,6 +166,8 @@ void EmitTypeReferences () var metadata = _pe.Metadata; _javaPeerProxyRef = metadata.AddTypeReference (_pe.MonoAndroidRef, metadata.GetOrAddString ("Java.Interop"), metadata.GetOrAddString ("JavaPeerProxy`1")); + _javaLangObjectRef = metadata.AddTypeReference (_pe.MonoAndroidRef, + metadata.GetOrAddString ("Java.Lang"), metadata.GetOrAddString ("Object")); _iJavaPeerableRef = metadata.AddTypeReference (_javaInteropRef, metadata.GetOrAddString ("Java.Interop"), metadata.GetOrAddString ("IJavaPeerable")); _jniHandleOwnershipRef = metadata.AddTypeReference (_pe.MonoAndroidRef, @@ -352,7 +355,13 @@ void EmitProxyType (JavaPeerProxyData proxy, Dictionary type parameter. + // Loading the proxy type forces the CLR to resolve the base class generic argument, and open + // generics from external assemblies can't be resolved by the TypeMapLazyDictionary loader. + // The T parameter only affects CreateInstance, which already throws for generic definitions. + var proxyTypeArg = proxy.IsGenericDefinition ? _javaLangObjectRef : targetTypeRef; + var proxyBaseType = _pe.MakeGenericTypeSpec (_javaPeerProxyRef, proxyTypeArg); var baseCtorRef = _pe.AddMemberRef (proxyBaseType, ".ctor", sig => sig.MethodSignature (isInstanceMethod: true).Parameters (2, rt => rt.Void (), @@ -373,6 +382,14 @@ void EmitProxyType (JavaPeerProxyData proxy, Dictionary() to instantiate the proxy + // at runtime for AOT-safe type resolution. + var selfAttrCtorRef = _pe.AddMemberRef (typeDefHandle, ".ctor", + sig => sig.MethodSignature (isInstanceMethod: true).Parameters (0, rt => rt.Void (), p => { })); + var selfAttrBlob = _pe.BuildAttributeBlob (b => { }); + metadata.AddCustomAttribute (typeDefHandle, selfAttrCtorRef, selfAttrBlob); + // .ctor — pass the resolved JNI name and optional invoker type to the generic base proxy _pe.EmitBody (".ctor", MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index 78ae91c7cfb..5a2e36bce10 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -50,7 +50,7 @@ public TrimmableTypeMapResult Execute ( // Collect Application/Instrumentation types that need deferred registerNatives var appRegTypes = allPeers - .Where (p => p.CannotRegisterInStaticConstructor && !p.IsAbstract) + .Where (p => p.CannotRegisterInStaticConstructor && !p.DoNotGenerateAcw) .Select (p => JniSignatureHelper.JniNameToJavaName (p.JavaName)) .ToList (); if (appRegTypes.Count > 0) { diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index 6c5b51fef0d..25b62d128f9 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -59,7 +59,11 @@ internal static void Initialize () unsafe void RegisterNatives () { - using var runtimeClass = new JniType ("mono/android/Runtime"u8); + // Use the string overload of JniType which resolves via Class.forName with the + // runtime's ClassLoader. The UTF-8 span overload uses raw JNI FindClass which + // resolves via the system ClassLoader — a different class instance than the one + // JCWs reference via the app ClassLoader. + using var runtimeClass = new JniType ("mono/android/Runtime"); fixed (byte* name = "registerNatives"u8, sig = "(Ljava/lang/Class;)V"u8) { var onRegisterNatives = (IntPtr)(delegate* unmanaged)&OnRegisterNatives; var method = new JniNativeMethod (name, sig, onRegisterNatives); @@ -195,7 +199,7 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) } [UnmanagedCallersOnly] - static void OnRegisterNatives (IntPtr jnienv, IntPtr klass, IntPtr nativeClassHandle) + internal static void OnRegisterNatives (IntPtr jnienv, IntPtr klass, IntPtr nativeClassHandle) { string? className = null; try { @@ -215,7 +219,9 @@ static void OnRegisterNatives (IntPtr jnienv, IntPtr klass, IntPtr nativeClassHa var proxy = type.GetCustomAttribute (inherit: false); if (proxy is IAndroidCallableWrapper acw) { - using var jniType = new JniType (className); + // Use the class reference passed from Java (via C++) — not JniType(className) + // which resolves via FindClass and may get a different class from a different ClassLoader. + using var jniType = new JniType (ref classRef, JniObjectReferenceOptions.Copy); acw.RegisterNatives (jniType); } } catch (Exception ex) { diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index d38aad44fb9..122bc4c2749 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -175,8 +175,18 @@ - + requires them. We also clear _ShrunkAssemblies and re-populate it from + _ResolvedAssemblies, because ProcessAssemblies points the shrunk items to + R2R/shrunk/ which is only created by the (now no-op) _RemoveRegisterAttribute. --> + + + <_ShrunkAssemblies Remove="@(_ShrunkAssemblies)" /> + <_ShrunkAssemblies Include="@(_ResolvedAssemblies)" /> + <_ShrunkFrameworkAssemblies Remove="@(_ShrunkFrameworkAssemblies)" /> + <_ShrunkFrameworkAssemblies Include="@(_ResolvedFrameworkAssemblies)" /> + <_ShrunkUserAssemblies Remove="@(_ShrunkUserAssemblies)" /> + <_ShrunkUserAssemblies Include="@(_ResolvedUserAssemblies)" /> + + diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs index c80fdbb6936..8412d665597 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TrimmableTypeMapGeneratorTests.cs @@ -73,16 +73,19 @@ public void Execute_WithTestFixtures_ProducesOutputs () } [Fact] - public void Execute_CollectsDeferredRegistrationTypes_ForConcreteApplicationAndInstrumentation () + public void Execute_CollectsDeferredRegistrationTypes_ForAllApplicationAndInstrumentationSubtypes () { using var peReader = CreateTestFixturePEReader (); var result = CreateGenerator ().Execute (new List<(string, PEReader)> { ("TestFixtures", peReader) }, new Version (11, 0), new HashSet ()); + // Abstract Instrumentation/Application subtypes are included too: their native + // methods (e.g. n_OnCreate, n_OnStart) are declared on the abstract base class + // and must be registered via ApplicationRegistration.registerApplications (). Assert.Contains ("my.app.MyApplication", result.ApplicationRegistrationTypes); Assert.Contains ("my.app.MyInstrumentation", result.ApplicationRegistrationTypes); - Assert.DoesNotContain ("my.app.BaseApplication", result.ApplicationRegistrationTypes); - Assert.DoesNotContain ("my.app.BaseInstrumentation", result.ApplicationRegistrationTypes); - Assert.DoesNotContain ("my.app.IntermediateInstrumentation", result.ApplicationRegistrationTypes); + Assert.Contains ("my.app.BaseApplication", result.ApplicationRegistrationTypes); + Assert.Contains ("my.app.BaseInstrumentation", result.ApplicationRegistrationTypes); + Assert.Contains ("my.app.IntermediateInstrumentation", result.ApplicationRegistrationTypes); } [Fact] diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index a99d95bfca0..0fd1ed90f75 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -112,6 +112,54 @@ public void Generate_ProxyType_UsesGenericJavaPeerProxyBase () objectProxyBaseType.DecodeSignature (SignatureTypeProvider.Instance, genericContext: null)); } + // Regression test: every generated proxy type must carry a custom attribute whose + // constructor MemberRef points at the proxy's own TypeDefinitionHandle. This is + // how JavaPeerProxy instances are resolved at runtime via + // type.GetCustomAttribute() — losing the self-application means the + // runtime can't construct the proxy. This has regressed twice; keep it covered. + [Fact] + public void Generate_ProxyType_IsSelfAppliedAsCustomAttribute () + { + var peers = ScanFixtures (); + using var stream = GenerateAssembly (peers); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + var proxyTypeHandles = reader.TypeDefinitions + .Where (h => reader.GetString (reader.GetTypeDefinition (h).Namespace) == "_TypeMap.Proxies") + .ToList (); + + Assert.NotEmpty (proxyTypeHandles); + + foreach (var proxyHandle in proxyTypeHandles) { + var proxy = reader.GetTypeDefinition (proxyHandle); + var proxyName = reader.GetString (proxy.Name); + + bool selfApplied = false; + foreach (var caHandle in proxy.GetCustomAttributes ()) { + var ca = reader.GetCustomAttribute (caHandle); + if (ca.Constructor.Kind != HandleKind.MemberReference) { + continue; + } + + var ctorRef = reader.GetMemberReference ((MemberReferenceHandle) ca.Constructor); + if (ctorRef.Parent.Kind != HandleKind.TypeDefinition) { + continue; + } + + if ((TypeDefinitionHandle) ctorRef.Parent == proxyHandle) { + selfApplied = true; + break; + } + } + + Assert.True (selfApplied, + $"Proxy type '{proxyName}' is missing its self-applied custom attribute. " + + "Every proxy must carry itself as a [JavaPeerProxy] attribute so the runtime " + + "can instantiate it via Type.GetCustomAttribute ()."); + } + } + [Fact] public void Generate_HasIgnoresAccessChecksToAttribute () { From c8f94c62aab19cf227c81364ad0352b60d7f2758 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 14:27:15 +0200 Subject: [PATCH 02/15] Address C3 + C7 review feedback on TrimmableTypeMap runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C3: keep the `JniType(string)` overload but rewrite the comment to explain exactly why — its `TryFindClass(string, bool)` path falls back to `Class.forName(name, true, info.Runtime.ClassLoader)`, which resolves via the runtime's app ClassLoader (the one that loaded `mono.android.Runtime` from the APK). The `ReadOnlySpan` overload only calls raw `FindClass` and would resolve via the system ClassLoader, returning a different `Class` instance. - C7: restore `OnRegisterNatives` visibility from `internal static` back to `static` (private). There are no in-repo callers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Android.Runtime/TrimmableTypeMap.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index 25b62d128f9..f44f27ced29 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -59,10 +59,15 @@ internal static void Initialize () unsafe void RegisterNatives () { - // Use the string overload of JniType which resolves via Class.forName with the - // runtime's ClassLoader. The UTF-8 span overload uses raw JNI FindClass which - // resolves via the system ClassLoader — a different class instance than the one - // JCWs reference via the app ClassLoader. + // Use the `string` overload of `JniType` deliberately. Its underlying + // `JniEnvironment.Types.TryFindClass(string, bool)` tries raw JNI `FindClass` + // first and, if that fails, falls back to `Class.forName(name, true, info.Runtime.ClassLoader)`, + // which resolves via the runtime's app ClassLoader — the same one that loads + // `mono.android.Runtime` from the APK. + // The `ReadOnlySpan` overload (see external/Java.Interop/src/Java.Interop/Java.Interop/JniEnvironment.Types.cs) + // only calls raw JNI `FindClass`, which resolves via the system ClassLoader on + // Android and returns a different `Class` instance from the one JCWs reference. + // Registering natives on that other instance is silently wrong. using var runtimeClass = new JniType ("mono/android/Runtime"); fixed (byte* name = "registerNatives"u8, sig = "(Ljava/lang/Class;)V"u8) { var onRegisterNatives = (IntPtr)(delegate* unmanaged)&OnRegisterNatives; @@ -199,7 +204,7 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) } [UnmanagedCallersOnly] - internal static void OnRegisterNatives (IntPtr jnienv, IntPtr klass, IntPtr nativeClassHandle) + static void OnRegisterNatives (IntPtr jnienv, IntPtr klass, IntPtr nativeClassHandle) { string? className = null; try { From 0ec480a4c381ff3a4d5a12e4249eed9980f98615 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 14:27:33 +0200 Subject: [PATCH 03/15] Address C1 + C2 + C8 review feedback on trimmable typemap generator - C2: emit generic-definition proxies deriving from the non-generic `JavaPeerProxy` base (3-arg ctor: `string jniName, Type targetType, Type? invokerType`) instead of `JavaPeerProxy`. Concrete-target proxies still use `JavaPeerProxy` (2-arg ctor). `EmitTypeReferences` was renamed accordingly and the `.ctor` body now pushes `targetTypeRef` via `ldtoken`/`GetTypeFromHandle` when emitting against the non-generic base. The skip was defensive because the old generic proxy emit failed at load; with C2 in place the proxy loads cleanly, so generic definitions can get `[TypeMapAssociation]` entries again. Added a regression test (`Fixture_GenericHolder_HasAssociation`). - C8: relax `Generate_ProxyType_IsSelfAppliedAsCustomAttribute` to accept both `MemberReference` (parent is the proxy `TypeDefinition`) and `MethodDefinition` whose declaring type is the proxy. Also relax `Generate_ProxyType_UsesGenericJavaPeerProxyBase` to allow `HandleKind.TypeReference` for open-generic proxies now that they inherit from the non-generic base. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/ModelBuilder.cs | 6 +- .../Generator/TypeMapAssemblyEmitter.cs | 58 +++++++++++++------ .../TypeMapAssemblyGeneratorTests.cs | 51 +++++++++++----- .../Generator/TypeMapModelBuilderTests.cs | 15 +++++ 4 files changed, 95 insertions(+), 35 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index aa0c820a09c..b46f27b8ff3 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -132,9 +132,11 @@ static void EmitPeers (TypeMapAssemblyData model, string jniName, // Emit TypeMapAssociation for all proxy-backed types so managed → proxy // lookup works even when the final JNI name differs from the type's attributes. - // Skip generic definitions — their open generic type can't be loaded by the runtime. + // Generic definitions are included — their proxy types derive from the + // non-generic `JavaPeerProxy` base so the CLR can load them without + // resolving an open generic argument. var assocProxy = (i > 0 && primaryProxy != null) ? primaryProxy : proxy; - if (assocProxy != null && !peer.IsGenericDefinition) { + if (assocProxy != null) { model.Associations.Add (new TypeMapAssociationData { SourceTypeReference = AssemblyQualify (peer.ManagedTypeName, peer.AssemblyName), AliasProxyTypeReference = AssemblyQualify ($"{assocProxy.Namespace}.{assocProxy.TypeName}", assemblyName), diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs index 1abfb69262e..423b27102a8 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs @@ -69,7 +69,7 @@ sealed class TypeMapAssemblyEmitter AssemblyReferenceHandle _javaInteropRef; TypeReferenceHandle _javaPeerProxyRef; - TypeReferenceHandle _javaLangObjectRef; + TypeReferenceHandle _javaPeerProxyNonGenericRef; TypeReferenceHandle _iJavaPeerableRef; TypeReferenceHandle _jniHandleOwnershipRef; TypeReferenceHandle _jniObjectReferenceRef; @@ -166,8 +166,8 @@ void EmitTypeReferences () var metadata = _pe.Metadata; _javaPeerProxyRef = metadata.AddTypeReference (_pe.MonoAndroidRef, metadata.GetOrAddString ("Java.Interop"), metadata.GetOrAddString ("JavaPeerProxy`1")); - _javaLangObjectRef = metadata.AddTypeReference (_pe.MonoAndroidRef, - metadata.GetOrAddString ("Java.Lang"), metadata.GetOrAddString ("Object")); + _javaPeerProxyNonGenericRef = metadata.AddTypeReference (_pe.MonoAndroidRef, + metadata.GetOrAddString ("Java.Interop"), metadata.GetOrAddString ("JavaPeerProxy")); _iJavaPeerableRef = metadata.AddTypeReference (_javaInteropRef, metadata.GetOrAddString ("Java.Interop"), metadata.GetOrAddString ("IJavaPeerable")); _jniHandleOwnershipRef = metadata.AddTypeReference (_pe.MonoAndroidRef, @@ -356,19 +356,35 @@ void EmitProxyType (JavaPeerProxyData proxy, Dictionary type parameter. - // Loading the proxy type forces the CLR to resolve the base class generic argument, and open - // generics from external assemblies can't be resolved by the TypeMapLazyDictionary loader. - // The T parameter only affects CreateInstance, which already throws for generic definitions. - var proxyTypeArg = proxy.IsGenericDefinition ? _javaLangObjectRef : targetTypeRef; - var proxyBaseType = _pe.MakeGenericTypeSpec (_javaPeerProxyRef, proxyTypeArg); - var baseCtorRef = _pe.AddMemberRef (proxyBaseType, ".ctor", - sig => sig.MethodSignature (isInstanceMethod: true).Parameters (2, - rt => rt.Void (), - p => { - p.AddParameter ().Type ().String (); - p.AddParameter ().Type ().Type (_systemTypeRef, false); - })); + // Open generic definitions derive from the non-generic `JavaPeerProxy` abstract base. + // Using `JavaPeerProxy` with an open T would force the CLR to resolve a generic + // argument that isn't available via the TypeMapLazyDictionary loader, and using a + // placeholder like `Java.Lang.Object` leaks an incorrect TargetType into the typemap. + // The non-generic base takes `targetType` as a ctor parameter, so we can pass the real + // open-generic type token (a TypeRef, not a closed TypeSpec) and keep TargetType correct. + EntityHandle proxyBaseType; + MemberReferenceHandle baseCtorRef; + if (proxy.IsGenericDefinition) { + proxyBaseType = _javaPeerProxyNonGenericRef; + baseCtorRef = _pe.AddMemberRef (_javaPeerProxyNonGenericRef, ".ctor", + sig => sig.MethodSignature (isInstanceMethod: true).Parameters (3, + rt => rt.Void (), + p => { + p.AddParameter ().Type ().String (); + p.AddParameter ().Type ().Type (_systemTypeRef, false); + p.AddParameter ().Type ().Type (_systemTypeRef, false); + })); + } else { + var genericProxyBase = _pe.MakeGenericTypeSpec (_javaPeerProxyRef, targetTypeRef); + proxyBaseType = genericProxyBase; + baseCtorRef = _pe.AddMemberRef (genericProxyBase, ".ctor", + sig => sig.MethodSignature (isInstanceMethod: true).Parameters (2, + rt => rt.Void (), + p => { + p.AddParameter ().Type ().String (); + p.AddParameter ().Type ().Type (_systemTypeRef, false); + })); + } var typeDefHandle = metadata.AddTypeDefinition ( TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, @@ -390,13 +406,21 @@ void EmitProxyType (JavaPeerProxyData proxy, Dictionary { }); metadata.AddCustomAttribute (typeDefHandle, selfAttrCtorRef, selfAttrBlob); - // .ctor — pass the resolved JNI name and optional invoker type to the generic base proxy + // .ctor — pass the resolved JNI name, (for generic-definition base) target type, and + // optional invoker type to the base proxy constructor. _pe.EmitBody (".ctor", MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, sig => sig.MethodSignature (isInstanceMethod: true).Parameters (0, rt => rt.Void (), p => { }), encoder => { encoder.OpCode (ILOpCode.Ldarg_0); encoder.LoadString (metadata.GetOrAddUserString (proxy.JniName)); + if (proxy.IsGenericDefinition) { + // Non-generic base ctor signature: (string, Type, Type?). Push the open-generic + // target type as the second argument. + encoder.OpCode (ILOpCode.Ldtoken); + encoder.Token (targetTypeRef); + encoder.Call (_getTypeFromHandleRef); + } if (proxy.InvokerType != null) { encoder.OpCode (ILOpCode.Ldtoken); encoder.Token (_pe.ResolveTypeRef (proxy.InvokerType)); diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index 0fd1ed90f75..17952f8e1c4 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -98,12 +98,23 @@ public void Generate_ProxyType_UsesGenericJavaPeerProxyBase () Assert.NotEmpty (proxyTypes); Assert.All (proxyTypes, proxyType => { - Assert.Equal (HandleKind.TypeSpecification, proxyType.BaseType.Kind); - - var baseTypeSpec = reader.GetTypeSpecification ((TypeSpecificationHandle) proxyType.BaseType); - var baseTypeName = baseTypeSpec.DecodeSignature (SignatureTypeProvider.Instance, genericContext: null); - - Assert.StartsWith ("Java.Interop.JavaPeerProxy`1<", baseTypeName, StringComparison.Ordinal); + switch (proxyType.BaseType.Kind) { + case HandleKind.TypeSpecification: + // Non-generic target types derive from the closed `JavaPeerProxy`. + var baseTypeSpec = reader.GetTypeSpecification ((TypeSpecificationHandle) proxyType.BaseType); + var baseTypeName = baseTypeSpec.DecodeSignature (SignatureTypeProvider.Instance, genericContext: null); + Assert.StartsWith ("Java.Interop.JavaPeerProxy`1<", baseTypeName, StringComparison.Ordinal); + break; + case HandleKind.TypeReference: + // Open generic target types derive from the non-generic `JavaPeerProxy`. + var baseTypeRef = reader.GetTypeReference ((TypeReferenceHandle) proxyType.BaseType); + Assert.Equal ("Java.Interop", reader.GetString (baseTypeRef.Namespace)); + Assert.Equal ("JavaPeerProxy", reader.GetString (baseTypeRef.Name)); + break; + default: + Assert.Fail ($"Unexpected BaseType handle kind: {proxyType.BaseType.Kind}"); + break; + } }); var objectProxy = proxyTypes.First (t => reader.GetString (t.Name) == "Java_Lang_Object_Proxy"); @@ -113,8 +124,9 @@ public void Generate_ProxyType_UsesGenericJavaPeerProxyBase () } // Regression test: every generated proxy type must carry a custom attribute whose - // constructor MemberRef points at the proxy's own TypeDefinitionHandle. This is - // how JavaPeerProxy instances are resolved at runtime via + // constructor points at the proxy's own TypeDefinitionHandle (either as a MemberRef + // parented on the TypeDef, or as a MethodDefinition on the TypeDef). This is how + // JavaPeerProxy instances are resolved at runtime via // type.GetCustomAttribute() — losing the self-application means the // runtime can't construct the proxy. This has regressed twice; keep it covered. [Fact] @@ -138,17 +150,24 @@ public void Generate_ProxyType_IsSelfAppliedAsCustomAttribute () bool selfApplied = false; foreach (var caHandle in proxy.GetCustomAttributes ()) { var ca = reader.GetCustomAttribute (caHandle); - if (ca.Constructor.Kind != HandleKind.MemberReference) { - continue; - } - var ctorRef = reader.GetMemberReference ((MemberReferenceHandle) ca.Constructor); - if (ctorRef.Parent.Kind != HandleKind.TypeDefinition) { - continue; + switch (ca.Constructor.Kind) { + case HandleKind.MemberReference: + var ctorRef = reader.GetMemberReference ((MemberReferenceHandle) ca.Constructor); + if (ctorRef.Parent.Kind == HandleKind.TypeDefinition && + (TypeDefinitionHandle) ctorRef.Parent == proxyHandle) { + selfApplied = true; + } + break; + case HandleKind.MethodDefinition: + var ctorDef = reader.GetMethodDefinition ((MethodDefinitionHandle) ca.Constructor); + if (ctorDef.GetDeclaringType () == proxyHandle) { + selfApplied = true; + } + break; } - if ((TypeDefinitionHandle) ctorRef.Parent == proxyHandle) { - selfApplied = true; + if (selfApplied) { break; } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs index df3a1256763..9376ec09f05 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs @@ -450,6 +450,21 @@ public void Fixture_GenericHolder_Entry () var entry = FindEntry (model, "my/app/GenericHolder"); Assert.NotNull (entry); } + + [Fact] + public void Fixture_GenericHolder_HasAssociation () + { + // Generic definitions must still get a TypeMapAssociation entry so managed→proxy + // lookup works for the open generic definition. Their proxy derives from the + // non-generic `JavaPeerProxy` base, so the CLR can load the proxy without + // resolving an open generic argument. + var peer = FindFixtureByJavaName ("my/app/GenericHolder"); + Assert.True (peer.IsGenericDefinition); + + var model = BuildModel (new [] { peer }, "TypeMap"); + Assert.Contains (model.Associations, + a => a.SourceTypeReference.StartsWith ("MyApp.Generic.GenericHolder`1", StringComparison.Ordinal)); + } } public class FixtureAcwTypeHasProxy From 3d122255fa39cb8f3b78e891fe749d29ffd165cf Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 14:27:45 +0200 Subject: [PATCH 04/15] Address C5 review feedback: document why __md_registerNatives() stays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigated the suggestion to drop the lazy `__md_registerNatives()` emission for `CannotRegisterInStaticConstructor` types (Application + Instrumentation subtypes). Conclusion: we keep it, and the comment has been updated to explain the ordering constraint that makes it necessary. `ApplicationRegistration.registerApplications()` is invoked from `MonoPackageManager.LoadApplication`, which runs from `MonoRuntimeProvider.attachInfo`. But `Application.attachBaseContext` fires *before* ContentProvider.attachInfo — so a user Application that overrides `attachBaseContext` can trigger a native callback before `registerApplications()` has run. The lazy one-time helper in each native-method wrapper is the safety net that covers that window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/JcwJavaSourceGenerator.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs index d629fc83d2c..0d2b15f803c 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs @@ -125,9 +125,15 @@ static void WriteStaticInitializer (JavaPeerInfo type, TextWriter writer) string className = JniSignatureHelper.GetJavaSimpleName (type.JavaName); // Application and Instrumentation types cannot call registerNatives in their - // static initializer — the runtime isn't ready yet at that point. Emit a - // lazy one-time helper instead so the first managed callback can register - // the class just before invoking its native method. + // static initializer — the runtime isn't ready yet at that point. Instead we + // rely on ApplicationRegistration.registerApplications() (invoked from + // MonoPackageManager.LoadApplication, which runs from MonoRuntimeProvider.attachInfo) + // to register natives for these classes. However, Application.attachBaseContext + // fires *before* ContentProvider.attachInfo, so an override of attachBaseContext + // on a user Application subclass can invoke a native callback before + // registerApplications() has run. To cover that early-callback window we also + // emit a lazy one-time __md_registerNatives() helper and call it at the top of + // each generated native-method wrapper. if (type.CannotRegisterInStaticConstructor) { writer.Write ($$""" private static boolean __md_natives_registered; From 247fb1f58cc2fe1ac5e993b178c3904faf617679 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 14:28:39 +0200 Subject: [PATCH 05/15] Address C4 + C6 review feedback: stop rewriting _ShrunkAssemblies in trimmable typemap path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy `_RemoveRegisterAttribute` target (in Xamarin.Android.Common.targets) is a no-op copy step that mirrors `@(_ResolvedAssemblies)` into `@(_ShrunkAssemblies)` at `/shrunk/` paths. `ProcessAssemblies` unconditionally rewrote `ShrunkAssemblies` to those `shrunk/` paths whenever `PublishTrimmed && !AndroidIncludeDebugSymbols` was true. The trimmable typemap path does not strip `[Register]` attributes and does not need the `shrunk/` copy; the previous code papered over the mismatch by overriding `_RemoveRegisterAttribute` to rebuild `@(_ShrunkAssemblies)` from `@(_ResolvedAssemblies)` at that stage — fragile and hard to follow (flagged by Copilot reviewer as C6 because the override also dropped the `DependsOnTargets="_PrepareAssemblies"` and the `AndroidLinkMode != 'None'` conditions from the base target). Fix: * Thread the `_AndroidTypeMapImplementation` property into `` via a new `AndroidTypeMapImplementation` string input. * Skip the `shrunk/` path rewrite when the input is "trimmable", so `ShrunkAssemblies` is simply `OutputAssemblies` for that path. * Reduce the trimmable override of `_RemoveRegisterAttribute` to an empty target — the base target's copy is no longer needed because `@(_ShrunkAssemblies) == @(_ResolvedAssemblies)` already. C6 is resolved as a side effect: there is no longer any target body that could depend on `_PrepareAssemblies` or the linker conditions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...oft.Android.Sdk.AssemblyResolution.targets | 1 + ...soft.Android.Sdk.TypeMap.Trimmable.targets | 19 +++++-------------- .../Tasks/ProcessAssemblies.cs | 10 ++++++++-- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets index 6c641ca33d9..276881f8316 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets @@ -182,6 +182,7 @@ _ResolveAssemblies MSBuild target. InputJavaLibraries="@(_ResolvedJavaLibraries->Distinct())" ResolvedSymbols="@(_ResolvedSymbolFiles)" AndroidIncludeDebugSymbols="$(AndroidIncludeDebugSymbols)" + AndroidTypeMapImplementation="$(_AndroidTypeMapImplementation)" PublishTrimmed="$(PublishTrimmed)"> diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 122bc4c2749..023122f73f0 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -174,19 +174,10 @@ - - - <_ShrunkAssemblies Remove="@(_ShrunkAssemblies)" /> - <_ShrunkAssemblies Include="@(_ResolvedAssemblies)" /> - <_ShrunkFrameworkAssemblies Remove="@(_ShrunkFrameworkAssemblies)" /> - <_ShrunkFrameworkAssemblies Include="@(_ResolvedFrameworkAssemblies)" /> - <_ShrunkUserAssemblies Remove="@(_ShrunkUserAssemblies)" /> - <_ShrunkUserAssemblies Include="@(_ResolvedUserAssemblies)" /> - - + The base target copies @(_ResolvedAssemblies) to @(_ShrunkAssemblies) (which + points at /shrunk/) to support the legacy [Register] attribute + stripping path. The trimmable typemap path skips that entirely — ProcessAssemblies + already sets @(_ShrunkAssemblies) == @(_ResolvedAssemblies), so no copy is needed. --> + diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ProcessAssemblies.cs b/src/Xamarin.Android.Build.Tasks/Tasks/ProcessAssemblies.cs index bd600efc48c..0c41976184b 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/ProcessAssemblies.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/ProcessAssemblies.cs @@ -33,6 +33,8 @@ public class ProcessAssemblies : AndroidTask public bool PublishTrimmed { get; set; } + public string AndroidTypeMapImplementation { get; set; } = ""; + public ITaskItem [] InputAssemblies { get; set; } = []; public ITaskItem [] InputJavaLibraries { get; set; } = []; @@ -68,9 +70,13 @@ public override bool RunTask () ResolvedSymbols = symbols.Values.ToArray (); // Set ShrunkAssemblies for _RemoveRegisterAttribute and - // This should match the Condition on the _RemoveRegisterAttribute target + // This should match the Condition on the _RemoveRegisterAttribute target. + // The trimmable typemap path does NOT run _RemoveRegisterAttribute + // (which is responsible for populating the /shrunk/ directory), + // so for that path ShrunkAssemblies is just OutputAssemblies. if (PublishTrimmed) { - if (!AndroidIncludeDebugSymbols) { + bool useTrimmableTypeMap = string.Equals (AndroidTypeMapImplementation, "trimmable", StringComparison.OrdinalIgnoreCase); + if (!AndroidIncludeDebugSymbols && !useTrimmableTypeMap) { var shrunkAssemblies = new List (OutputAssemblies.Length); foreach (var assembly in OutputAssemblies) { var dir = Path.GetDirectoryName (assembly.ItemSpec); From e3cf6989c0cf665e48766d8d42f92eaa907e2fed Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 16:07:05 +0200 Subject: [PATCH 06/15] Handle closed generic instantiations in TrimmableTypeMap lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typemap generator emits one TypeMapAssociation per generic peer, keyed by the open generic definition (e.g. typeof(JavaList<>)). The CLR TypeMapping dictionary does identity-based lookups (see dotnet/runtime's TypeMapLazyDictionary.cs), so closed instantiations such as typeof(JavaList) never match the registered open entry directly. Previously GetProxyForManagedType did a single TryGetValue on that dictionary and silently returned null for every closed generic peer, breaking managed→JNI name resolution for things like JavaList. - GetProxyForManagedType: after a direct miss, if the type is a closed generic, retry with type.GetGenericTypeDefinition(). GetGenericTypeDefinition is safe under AOT + full trim (it is not RequiresDynamicCode). The resolved proxy is cached under the original closed Type so subsequent lookups remain O(1). - TryGetProxyFromHierarchy: the per-proxy TargetType for generic peers is the open generic definition, so targetType.IsAssignableFrom(proxy.TargetType) returned false when the caller hint was a closed instantiation. Extract a TargetTypeMatches helper that additionally accepts a hint whose base chain contains a closed form of the proxy's open generic definition — no type-argument comparison since Java erases generics and one proxy serves every closed instantiation. Adds a device-side test that exercises the GTD fallback through the public TryGetJniNameForManagedType path using JavaList. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMap.cs | 55 +++++++++++++++++-- .../TrimmableTypeMapTypeManagerTests.cs | 27 +++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index f44f27ced29..bb25659c14c 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -82,14 +82,35 @@ internal bool TryGetTargetType (string jniSimpleReference, [NotNullWhen (true)] return type is not null; } + /// + /// Resolves the for a managed type via the CLR + /// TypeMapping proxy dictionary. + /// + /// + /// Closed generic instantiations (e.g. Holder<int>) fall back to + /// their generic type definition (Holder<>) because the generator + /// emits exactly one TypeMapAssociation per generic peer, keyed by the + /// open definition (Java erases generics, so one proxy fits every closed + /// instantiation). The CLR lazy dictionary does identity-based lookups + /// (see dotnet/runtime TypeMapLazyDictionary.cs), so the + /// fallback must happen here. is + /// safe under full AOT + trim (it is not RequiresDynamicCode). + /// Java→managed construction of a closed generic peer still requires a + /// closed at the call site and is tracked separately. + /// JavaPeerProxy? GetProxyForManagedType (Type managedType) { var proxy = _proxyCache.GetOrAdd (managedType, static (type, self) => { - if (!self._proxyTypeMap.TryGetValue (type, out var proxyType)) { - return s_noPeerSentinel; + if (self._proxyTypeMap.TryGetValue (type, out var proxyType)) { + return proxyType.GetCustomAttribute (inherit: false) ?? s_noPeerSentinel; + } + + if (type.IsGenericType && !type.IsGenericTypeDefinition && + self._proxyTypeMap.TryGetValue (type.GetGenericTypeDefinition (), out proxyType)) { + return proxyType.GetCustomAttribute (inherit: false) ?? s_noPeerSentinel; } - return proxyType.GetCustomAttribute (inherit: false) ?? s_noPeerSentinel; + return s_noPeerSentinel; }, this); return ReferenceEquals (proxy, s_noPeerSentinel) ? null : proxy; } @@ -139,7 +160,7 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) var className = JniEnvironment.Types.GetJniTypeNameFromClass (jniClass); if (className != null) { var proxy = self.GetProxyForJavaType (className); - if (proxy != null && (targetType is null || targetType.IsAssignableFrom (proxy.TargetType))) { + if (proxy != null && (targetType is null || TargetTypeMatches (targetType, proxy.TargetType))) { return proxy; } } @@ -155,6 +176,32 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) return null; } + // Match the proxy's stored target type against a hint from the caller. + // The proxy's target type is the open generic definition for generic peers + // (Java erases generics, so one proxy fits every closed instantiation), + // so a plain IsAssignableFrom check misses when the hint is a closed + // instantiation. Walk the hint's base chain to find a generic type whose + // definition equals the proxy's open target type. + static bool TargetTypeMatches (Type targetType, Type proxyTargetType) + { + if (targetType.IsAssignableFrom (proxyTargetType)) { + return true; + } + + if (!proxyTargetType.IsGenericTypeDefinition) { + return false; + } + + for (Type? t = targetType; t is not null; t = t.BaseType) { + if (t.IsGenericType && !t.IsGenericTypeDefinition && + t.GetGenericTypeDefinition () == proxyTargetType) { + return true; + } + } + + return false; + } + static JavaPeerProxy? TryGetProxyFromTargetType (TrimmableTypeMap self, IntPtr handle, Type? targetType) { if (targetType is null) { diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs index e5e7f9e134c..8c528acd06f 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs @@ -1,3 +1,5 @@ +using System; +using Android.Runtime; using Java.Interop; using Microsoft.Android.Runtime; using NUnit.Framework; @@ -46,5 +48,30 @@ public void GetStaticMethodFallbackTypes_WithDeepPackageName_ReturnsDesugarFallb Assert.AreEqual ("com/example/package/DesugarMyInterface$_CC", fallbacks [0]); Assert.AreEqual ("com/example/package/MyInterface$-CC", fallbacks [1]); } + + // Verifies the generic-type-definition fallback in GetProxyForManagedType: + // the generator emits one TypeMapAssociation per open generic peer, so a + // closed instantiation like JavaList must resolve through its GTD. + [Test] + public void TryGetJniNameForManagedType_ClosedGeneric_ResolvesViaGenericTypeDefinition () + { + if (!RuntimeFeature.TrimmableTypeMap) { + Assert.Ignore ("TrimmableTypeMap feature switch is off; test only relevant for the trimmable typemap path."); + } + + var instance = TrimmableTypeMap.Instance; + + Assert.IsTrue (instance.TryGetJniNameForManagedType (typeof (JavaList<>), out var openJniName), + "Open generic definition should resolve directly."); + Assert.IsTrue (instance.TryGetJniNameForManagedType (typeof (JavaList), out var closedStringJniName), + "Closed instantiation should resolve via GTD fallback."); + Assert.IsTrue (instance.TryGetJniNameForManagedType (typeof (JavaList), out var closedIntJniName), + "A second closed instantiation should also resolve via GTD fallback."); + + Assert.AreEqual (openJniName, closedStringJniName, + "Closed instantiation must share the open GTD's JNI name (Java erases generics)."); + Assert.AreEqual (openJniName, closedIntJniName, + "Different closed instantiations must map to the same JNI name."); + } } } From 153d23dc292b45f9097bdf0b360f5c67464afe58 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 16:11:17 +0200 Subject: [PATCH 07/15] Expand TrimmableTypeMap generic-peer test coverage The previous commit added a single device test exercising the GTD fallback via JavaList, but that scenario only hits the direct IsAssignableFrom branch of TargetTypeMatches (JavaList erases to the non-generic JavaList, whose proxy's TargetType is the non-generic type). The open-generic branches of TargetTypeMatches were not exercised. - Promote TargetTypeMatches from a local static inside GetProxyForJavaObject to an internal static member of TrimmableTypeMap so it can be unit-tested directly. - Add pure-function tests for TargetTypeMatches covering: direct assignable, open generic self-match, closed subclass base-walk, deeply closed subclass, different open GTDs, unrelated types. - Add regression tests for GetProxyForManagedType: * non-generic type still resolves directly (regression shield for the fast path) * an unknown closed generic (e.g. List) misses both the direct and GTD lookups and returns false * repeated lookups of the same closed generic are idempotent, verifying the cache is keyed by the original closed type. All new tests are gated on the RuntimeFeature.TrimmableTypeMap feature switch so they are a no-op in non-trimmable configurations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMap.cs | 54 +++++------ .../TrimmableTypeMapTypeManagerTests.cs | 93 +++++++++++++++++++ 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index bb25659c14c..97562a7e9f3 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -176,32 +176,6 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) return null; } - // Match the proxy's stored target type against a hint from the caller. - // The proxy's target type is the open generic definition for generic peers - // (Java erases generics, so one proxy fits every closed instantiation), - // so a plain IsAssignableFrom check misses when the hint is a closed - // instantiation. Walk the hint's base chain to find a generic type whose - // definition equals the proxy's open target type. - static bool TargetTypeMatches (Type targetType, Type proxyTargetType) - { - if (targetType.IsAssignableFrom (proxyTargetType)) { - return true; - } - - if (!proxyTargetType.IsGenericTypeDefinition) { - return false; - } - - for (Type? t = targetType; t is not null; t = t.BaseType) { - if (t.IsGenericType && !t.IsGenericTypeDefinition && - t.GetGenericTypeDefinition () == proxyTargetType) { - return true; - } - } - - return false; - } - static JavaPeerProxy? TryGetProxyFromTargetType (TrimmableTypeMap self, IntPtr handle, Type? targetType) { if (targetType is null) { @@ -232,6 +206,34 @@ static bool TargetTypeMatches (Type targetType, Type proxyTargetType) const DynamicallyAccessedMemberTypes Constructors = DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors; + /// + /// Match the proxy's stored target type against a hint from the caller. + /// The proxy's target type is the open generic definition for generic peers + /// (Java erases generics, so one proxy fits every closed instantiation), + /// so a plain check misses when the hint + /// is a closed instantiation. Walk the hint's base chain to find a generic + /// type whose definition equals the proxy's open target type. + /// + internal static bool TargetTypeMatches (Type targetType, Type proxyTargetType) + { + if (targetType.IsAssignableFrom (proxyTargetType)) { + return true; + } + + if (!proxyTargetType.IsGenericTypeDefinition) { + return false; + } + + for (Type? t = targetType; t is not null; t = t.BaseType) { + if (t.IsGenericType && !t.IsGenericTypeDefinition && + t.GetGenericTypeDefinition () == proxyTargetType) { + return true; + } + } + + return false; + } + /// /// Gets the invoker type for an interface or abstract class from the proxy attribute. /// diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs index 8c528acd06f..84e64df5a9f 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs @@ -73,5 +73,98 @@ public void TryGetJniNameForManagedType_ClosedGeneric_ResolvesViaGenericTypeDefi Assert.AreEqual (openJniName, closedIntJniName, "Different closed instantiations must map to the same JNI name."); } + + [Test] + public void TryGetJniNameForManagedType_NonGenericType_ResolvesDirectly () + { + if (!RuntimeFeature.TrimmableTypeMap) { + Assert.Ignore ("TrimmableTypeMap feature switch is off; test only relevant for the trimmable typemap path."); + } + + // Regression: the GTD fallback must not disturb the non-generic hot path. + Assert.IsTrue (TrimmableTypeMap.Instance.TryGetJniNameForManagedType (typeof (JavaList), out var jniName)); + Assert.IsFalse (string.IsNullOrEmpty (jniName)); + } + + [Test] + public void TryGetJniNameForManagedType_UnknownClosedGeneric_ReturnsFalse () + { + if (!RuntimeFeature.TrimmableTypeMap) { + Assert.Ignore ("TrimmableTypeMap feature switch is off; test only relevant for the trimmable typemap path."); + } + + // System.Collections.Generic.List has no TypeMapAssociation — both the + // direct lookup AND the GTD fallback must miss, and the API must return false. + Assert.IsFalse (TrimmableTypeMap.Instance.TryGetJniNameForManagedType ( + typeof (System.Collections.Generic.List), out var jniName)); + Assert.IsNull (jniName); + } + + [Test] + public void TryGetJniNameForManagedType_RepeatedClosedGenericLookup_IsCached () + { + if (!RuntimeFeature.TrimmableTypeMap) { + Assert.Ignore ("TrimmableTypeMap feature switch is off; test only relevant for the trimmable typemap path."); + } + + // The cache is keyed by the original closed type, so a second identical + // lookup returns the same proxy instance without walking the GTD again. + var instance = TrimmableTypeMap.Instance; + + Assert.IsTrue (instance.TryGetJniNameForManagedType (typeof (JavaList), out var first)); + Assert.IsTrue (instance.TryGetJniNameForManagedType (typeof (JavaList), out var second)); + Assert.AreEqual (first, second); + } + + // Pure-function tests for the TargetTypeMatches helper used by + // TryGetProxyFromHierarchy when the hierarchy lookup finds a proxy whose + // stored TargetType is an open generic definition. + + class OpenT { } + class OpenT2 { } + class ClosedOfIntOpenT : OpenT { } + class DeepClosedOfOpenT : ClosedOfIntOpenT { } + + [Test] + public void TargetTypeMatches_DirectAssignable_ReturnsTrue () + { + // Non-generic direct match: proxy target IS-A hint. + Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (object), typeof (string))); + Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (string), typeof (string))); + } + + [Test] + public void TargetTypeMatches_ClosedHint_OpenGenericProxy_SelfMatch_ReturnsTrue () + { + // Hint is OpenT; proxy's target is the open GTD OpenT<>. + // IsAssignableFrom(OpenT<>) against OpenT is false, so this exercises + // the new GTD base-walk branch (self match on first iteration). + Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (OpenT), typeof (OpenT<>))); + Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (OpenT), typeof (OpenT<>))); + Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (OpenT2), typeof (OpenT2<,>))); + } + + [Test] + public void TargetTypeMatches_ClosedSubclassHint_OpenGenericProxy_ReturnsTrue () + { + // Hint is a closed subclass of the open generic; the base-walk finds + // the generic base type whose definition equals the proxy's open target. + Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (ClosedOfIntOpenT), typeof (OpenT<>))); + Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (DeepClosedOfOpenT), typeof (OpenT<>))); + } + + [Test] + public void TargetTypeMatches_MismatchedOpenGeneric_ReturnsFalse () + { + // Different open generic definitions must NOT be treated as matching. + Assert.IsFalse (TrimmableTypeMap.TargetTypeMatches (typeof (OpenT), typeof (OpenT2<,>))); + Assert.IsFalse (TrimmableTypeMap.TargetTypeMatches (typeof (string), typeof (OpenT<>))); + } + + [Test] + public void TargetTypeMatches_UnrelatedNonGeneric_ReturnsFalse () + { + Assert.IsFalse (TrimmableTypeMap.TargetTypeMatches (typeof (string), typeof (int))); + } } } From 3adf21060b35e4b18f1dd449308ec2dbbc2330a6 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 16:20:40 +0200 Subject: [PATCH 08/15] Walk interfaces in TargetTypeMatches for open generic interface proxies When a generic Android binding is declared as an interface (e.g. an open generic peer interface like IJavaCollection), the proxy's TargetType is the open interface definition. The caller's hint in GetProxyForJavaObject is usually a closed class that implements that interface (from the marshaller's target signature). The previous base-chain-only walk missed this case, so the hierarchy lookup silently rejected the correct proxy. Extend TargetTypeMatches to also scan targetType.GetInterfaces() for any closed generic interface whose definition equals the open proxy target. GetInterfaces() on a class also includes interfaces from its base types, so closed subclasses of types implementing an open generic interface are covered. Suppress IL2070 on the helper with a justification: targetType originates at JNI marshalling call sites where the caller's generic signature roots the interface set. Legacy Android binding generators have always relied on this reachability contract. Adds unit-test coverage for: - closed class implementing an open generic interface - closed subclass inheriting the interface implementation and a standalone C# program confirmed 9/9 assertions pass before committing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMap.cs | 17 +++++++++++++-- .../TrimmableTypeMapTypeManagerTests.cs | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index 97562a7e9f3..e22ae0fd4f2 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -211,9 +211,15 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) /// The proxy's target type is the open generic definition for generic peers /// (Java erases generics, so one proxy fits every closed instantiation), /// so a plain check misses when the hint - /// is a closed instantiation. Walk the hint's base chain to find a generic - /// type whose definition equals the proxy's open target type. + /// is a closed instantiation. Walk the hint's base chain and implemented + /// interfaces to find a generic type whose definition equals the proxy's + /// open target type — this covers both closed subclasses and hints that are + /// closed generic interfaces (e.g. IList<string> should match a + /// proxy whose target is JavaList<>, which implements + /// IList<T>). /// + [UnconditionalSuppressMessage ("Trimming", "IL2070", + Justification = "targetType comes from live JNI marshalling call sites; its base chain and interfaces are rooted because the caller's generic signature keeps them alive.")] internal static bool TargetTypeMatches (Type targetType, Type proxyTargetType) { if (targetType.IsAssignableFrom (proxyTargetType)) { @@ -231,6 +237,13 @@ internal static bool TargetTypeMatches (Type targetType, Type proxyTargetType) } } + foreach (var iface in targetType.GetInterfaces ()) { + if (iface.IsGenericType && !iface.IsGenericTypeDefinition && + iface.GetGenericTypeDefinition () == proxyTargetType) { + return true; + } + } + return false; } diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs index 84e64df5a9f..d877900dbf7 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs @@ -124,6 +124,9 @@ class OpenT { } class OpenT2 { } class ClosedOfIntOpenT : OpenT { } class DeepClosedOfOpenT : ClosedOfIntOpenT { } + interface IOpenIface { } + class ImplementsOpenIface : IOpenIface { } + class ClosedImplementsOpenIface : ImplementsOpenIface { } [Test] public void TargetTypeMatches_DirectAssignable_ReturnsTrue () @@ -161,6 +164,24 @@ public void TargetTypeMatches_MismatchedOpenGeneric_ReturnsFalse () Assert.IsFalse (TrimmableTypeMap.TargetTypeMatches (typeof (string), typeof (OpenT<>))); } + [Test] + public void TargetTypeMatches_ClosedGenericInterfaceHint_OpenGenericProxy_ReturnsTrue () + { + // Hint is the closed generic interface itself (e.g. IList); the + // proxy's target is an open generic class that implements the open form + // of that interface. GetInterfaces() on IList returns its super + // interfaces (ICollection, IEnumerable) — we must also + // check `targetType` itself, which we handle by including it as the + // first element of the base-chain walk (Type.GetInterfaces on an + // interface returns super-interfaces). The assertion here is that a + // hint that IS-A generic interface whose definition equals the proxy's + // open target is matched through the interface walk. + Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (ImplementsOpenIface), typeof (IOpenIface<>)), + "closed class hint implementing an open generic interface should match a proxy whose target is that interface's open definition"); + Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (ClosedImplementsOpenIface), typeof (IOpenIface<>)), + "closed subclass inheriting implementation of the open generic interface should match via interface walk"); + } + [Test] public void TargetTypeMatches_UnrelatedNonGeneric_ReturnsFalse () { From bb2693ba6df8dca24f3ab25617d3fedf403c73a9 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 16:21:16 +0200 Subject: [PATCH 09/15] Clarify TargetTypeMatches interface-walk doc comment The previous example implied that a hint of `IList` would match a proxy whose target is `JavaList<>`. It does not: `IList.GetInterfaces()` returns only its super-interfaces (ICollection, IEnumerable, IEnumerable), and none of their generic-type-definitions equals `JavaList<>`. The actual scenario covered by the interface walk is: the proxy target is an **open generic interface** peer, and the hint is a closed class that implements a closed instantiation of that interface. For a class hint, `GetInterfaces()` enumerates every interface the class implements, so the closed form is discovered and its GTD matches the open interface target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Android.Runtime/TrimmableTypeMap.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index e22ae0fd4f2..bac15c23509 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -213,10 +213,10 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) /// so a plain check misses when the hint /// is a closed instantiation. Walk the hint's base chain and implemented /// interfaces to find a generic type whose definition equals the proxy's - /// open target type — this covers both closed subclasses and hints that are - /// closed generic interfaces (e.g. IList<string> should match a - /// proxy whose target is JavaList<>, which implements - /// IList<T>). + /// open target type — this covers closed subclasses of an open generic + /// class peer, and closed classes implementing an open generic interface + /// peer (i.e. the proxy's target is an open generic interface and the hint + /// is a class that implements a closed instantiation of it). /// [UnconditionalSuppressMessage ("Trimming", "IL2070", Justification = "targetType comes from live JNI marshalling call sites; its base chain and interfaces are rooted because the caller's generic signature keeps them alive.")] From a1b02ecd439df3d0ac8e123a4b4724d327b8bb99 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 16:41:59 +0200 Subject: [PATCH 10/15] Add negative test for interface-walk in TargetTypeMatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the case where a closed class hint does not implement any closed form of the open generic interface that the proxy targets. Also rewrites the positive-case comment that incorrectly described an `IList` hint matching a `JavaList<>` proxy (that scenario is not covered by the interface walk — it would require walking the proxy's own interfaces). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMapTypeManagerTests.cs | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs index d877900dbf7..95c6137e27d 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs @@ -167,21 +167,26 @@ public void TargetTypeMatches_MismatchedOpenGeneric_ReturnsFalse () [Test] public void TargetTypeMatches_ClosedGenericInterfaceHint_OpenGenericProxy_ReturnsTrue () { - // Hint is the closed generic interface itself (e.g. IList); the - // proxy's target is an open generic class that implements the open form - // of that interface. GetInterfaces() on IList returns its super - // interfaces (ICollection, IEnumerable) — we must also - // check `targetType` itself, which we handle by including it as the - // first element of the base-chain walk (Type.GetInterfaces on an - // interface returns super-interfaces). The assertion here is that a - // hint that IS-A generic interface whose definition equals the proxy's - // open target is matched through the interface walk. + // The proxy's target is an open generic interface peer (IOpenIface<>). + // The hint is a closed class that implements a closed instantiation of it. + // Type.GetInterfaces() on a class enumerates every interface the class + // implements (including those inherited from base classes), so the closed + // interface is discovered and its GTD matches the open proxy target. Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (ImplementsOpenIface), typeof (IOpenIface<>)), - "closed class hint implementing an open generic interface should match a proxy whose target is that interface's open definition"); + "closed class implementing an open generic interface should match a proxy whose target is that interface's open definition"); Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (ClosedImplementsOpenIface), typeof (IOpenIface<>)), "closed subclass inheriting implementation of the open generic interface should match via interface walk"); } + [Test] + public void TargetTypeMatches_OpenGenericInterfaceProxy_UnrelatedHint_ReturnsFalse () + { + // Negative: hint's interface set does not contain any closed form of + // the open generic interface that the proxy targets. + Assert.IsFalse (TrimmableTypeMap.TargetTypeMatches (typeof (ImplementsOpenIface), typeof (System.Collections.Generic.IList<>))); + Assert.IsFalse (TrimmableTypeMap.TargetTypeMatches (typeof (string), typeof (IOpenIface<>))); + } + [Test] public void TargetTypeMatches_UnrelatedNonGeneric_ReturnsFalse () { From 6fa7dab71efb3bf11713fc81ce609ef01350e420 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 18:06:48 +0200 Subject: [PATCH 11/15] Normalise closed generics to GTD before typemap lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two-step "try closed, then fall back to GTD" into a single lookup by normalising the type first. The generator never keys the proxy typemap on a closed generic instantiation (Java erases generics, so one open-GTD entry covers every closed form), so the first lookup in the old code was always a miss for closed generics — remove it. No behavior change; host tests still 355/355. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMap.cs | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index bac15c23509..6cdb9323f7c 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -87,26 +87,25 @@ internal bool TryGetTargetType (string jniSimpleReference, [NotNullWhen (true)] /// TypeMapping proxy dictionary. /// /// - /// Closed generic instantiations (e.g. Holder<int>) fall back to - /// their generic type definition (Holder<>) because the generator - /// emits exactly one TypeMapAssociation per generic peer, keyed by the - /// open definition (Java erases generics, so one proxy fits every closed - /// instantiation). The CLR lazy dictionary does identity-based lookups - /// (see dotnet/runtime TypeMapLazyDictionary.cs), so the - /// fallback must happen here. is - /// safe under full AOT + trim (it is not RequiresDynamicCode). - /// Java→managed construction of a closed generic peer still requires a - /// closed at the call site and is tracked separately. + /// The generator emits exactly one TypeMapAssociation per generic peer, + /// keyed by the open generic definition (Java erases generics, so one proxy + /// fits every closed instantiation). Closed instantiations are normalised to + /// their generic type definition before the lookup because the CLR lazy + /// dictionary does identity-based key matching + /// (see dotnet/runtime TypeMapLazyDictionary.cs). + /// is safe under full AOT + trim + /// (it is not RequiresDynamicCode). Java→managed construction of a + /// closed generic peer still requires a closed at the call + /// site and is tracked separately. /// JavaPeerProxy? GetProxyForManagedType (Type managedType) { var proxy = _proxyCache.GetOrAdd (managedType, static (type, self) => { - if (self._proxyTypeMap.TryGetValue (type, out var proxyType)) { - return proxyType.GetCustomAttribute (inherit: false) ?? s_noPeerSentinel; + if (type.IsGenericType && !type.IsGenericTypeDefinition) { + type = type.GetGenericTypeDefinition (); } - if (type.IsGenericType && !type.IsGenericTypeDefinition && - self._proxyTypeMap.TryGetValue (type.GetGenericTypeDefinition (), out proxyType)) { + if (self._proxyTypeMap.TryGetValue (type, out var proxyType)) { return proxyType.GetCustomAttribute (inherit: false) ?? s_noPeerSentinel; } From 083ef42f36594fbee310b80f9f82f4d488d53826 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 17 Apr 2026 18:14:14 +0200 Subject: [PATCH 12/15] Drop interface walk and IL2070 suppression in TargetTypeMatches The interface walk added to `TargetTypeMatches` (via `Type.GetInterfaces()`) required an `[UnconditionalSuppressMessage("Trimming", "IL2070", ...)]` because `GetInterfaces()` is annotated `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces)]` on its `this` parameter, and propagating that annotation up the call chain (`TargetTypeMatches` -> `TryGetProxyFromHierarchy` -> `GetProxyForJavaObject` -> `JavaMarshalValueManager.CreatePeer`) would require modifying Java.Interop's public `CreatePeer` override signature, which only carries a `Constructors` DAM today. Audit of the call path shows the interface walk is dead code in practice: `TryGetProxyFromHierarchy` walks the JNI class chain only (via `GetObjectClass` + `GetSuperclass`), never JNI interfaces, so no open-generic *interface* proxy target ever reaches `TargetTypeMatches` from this path. The remaining base-chain + GTD-identity logic covers every case our generator emits. If a future change needs to resolve proxies through a JNI interface chain, the right answer is a generator-emitted implementer map (statically trim-safe) rather than reflecting over `GetInterfaces()` at runtime. Also drops the two tests and fixture types that exercised the removed interface walk. Host unit tests: 355/355 pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMap.cs | 30 +++++++++---------- .../TrimmableTypeMapTypeManagerTests.cs | 26 ---------------- 2 files changed, 15 insertions(+), 41 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index 6cdb9323f7c..8fa4c5702f3 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -210,15 +210,22 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) /// The proxy's target type is the open generic definition for generic peers /// (Java erases generics, so one proxy fits every closed instantiation), /// so a plain check misses when the hint - /// is a closed instantiation. Walk the hint's base chain and implemented - /// interfaces to find a generic type whose definition equals the proxy's - /// open target type — this covers closed subclasses of an open generic - /// class peer, and closed classes implementing an open generic interface - /// peer (i.e. the proxy's target is an open generic interface and the hint - /// is a class that implements a closed instantiation of it). + /// is a closed instantiation. Walk the hint's base chain to find a generic + /// type whose definition equals the proxy's open target type. This covers + /// closed subclasses of an open generic class peer. /// - [UnconditionalSuppressMessage ("Trimming", "IL2070", - Justification = "targetType comes from live JNI marshalling call sites; its base chain and interfaces are rooted because the caller's generic signature keeps them alive.")] + /// + /// Implementers of an open generic interface peer are intentionally + /// not matched here: walks only the + /// JNI class chain (getSuperclass), never JNI interfaces, so the + /// proxy returned from that walk is always a class peer. Matching on + /// Type.GetInterfaces() would also force a trimmer + /// DynamicallyAccessedMembers(Interfaces) annotation up the chain + /// (ultimately into Java.Interop's CreatePeer API). If we ever need + /// to discover interface peers, the generator should emit an explicit + /// implementer→interface map so runtime can avoid reflection over + /// interface lists. + /// internal static bool TargetTypeMatches (Type targetType, Type proxyTargetType) { if (targetType.IsAssignableFrom (proxyTargetType)) { @@ -236,13 +243,6 @@ internal static bool TargetTypeMatches (Type targetType, Type proxyTargetType) } } - foreach (var iface in targetType.GetInterfaces ()) { - if (iface.IsGenericType && !iface.IsGenericTypeDefinition && - iface.GetGenericTypeDefinition () == proxyTargetType) { - return true; - } - } - return false; } diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs index 95c6137e27d..84e64df5a9f 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs @@ -124,9 +124,6 @@ class OpenT { } class OpenT2 { } class ClosedOfIntOpenT : OpenT { } class DeepClosedOfOpenT : ClosedOfIntOpenT { } - interface IOpenIface { } - class ImplementsOpenIface : IOpenIface { } - class ClosedImplementsOpenIface : ImplementsOpenIface { } [Test] public void TargetTypeMatches_DirectAssignable_ReturnsTrue () @@ -164,29 +161,6 @@ public void TargetTypeMatches_MismatchedOpenGeneric_ReturnsFalse () Assert.IsFalse (TrimmableTypeMap.TargetTypeMatches (typeof (string), typeof (OpenT<>))); } - [Test] - public void TargetTypeMatches_ClosedGenericInterfaceHint_OpenGenericProxy_ReturnsTrue () - { - // The proxy's target is an open generic interface peer (IOpenIface<>). - // The hint is a closed class that implements a closed instantiation of it. - // Type.GetInterfaces() on a class enumerates every interface the class - // implements (including those inherited from base classes), so the closed - // interface is discovered and its GTD matches the open proxy target. - Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (ImplementsOpenIface), typeof (IOpenIface<>)), - "closed class implementing an open generic interface should match a proxy whose target is that interface's open definition"); - Assert.IsTrue (TrimmableTypeMap.TargetTypeMatches (typeof (ClosedImplementsOpenIface), typeof (IOpenIface<>)), - "closed subclass inheriting implementation of the open generic interface should match via interface walk"); - } - - [Test] - public void TargetTypeMatches_OpenGenericInterfaceProxy_UnrelatedHint_ReturnsFalse () - { - // Negative: hint's interface set does not contain any closed form of - // the open generic interface that the proxy targets. - Assert.IsFalse (TrimmableTypeMap.TargetTypeMatches (typeof (ImplementsOpenIface), typeof (System.Collections.Generic.IList<>))); - Assert.IsFalse (TrimmableTypeMap.TargetTypeMatches (typeof (string), typeof (IOpenIface<>))); - } - [Test] public void TargetTypeMatches_UnrelatedNonGeneric_ReturnsFalse () { From e59eeb7a3e9244c89164eefcfc396c63241a0af5 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 18 Apr 2026 02:08:30 +0200 Subject: [PATCH 13/15] Normalize generic proxy cache keys Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMap.cs | 8 ++-- .../TrimmableTypeMapTypeManagerTests.cs | 44 ++++++++++++++++++- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index 8fa4c5702f3..58491410db0 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -100,11 +100,11 @@ internal bool TryGetTargetType (string jniSimpleReference, [NotNullWhen (true)] /// JavaPeerProxy? GetProxyForManagedType (Type managedType) { - var proxy = _proxyCache.GetOrAdd (managedType, static (type, self) => { - if (type.IsGenericType && !type.IsGenericTypeDefinition) { - type = type.GetGenericTypeDefinition (); - } + if (managedType.IsGenericType && !managedType.IsGenericTypeDefinition) { + managedType = managedType.GetGenericTypeDefinition (); + } + var proxy = _proxyCache.GetOrAdd (managedType, static (type, self) => { if (self._proxyTypeMap.TryGetValue (type, out var proxyType)) { return proxyType.GetCustomAttribute (inherit: false) ?? s_noPeerSentinel; } diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs index 84e64df5a9f..233cc67fa23 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Concurrent; +using System.Reflection; using Android.Runtime; using Java.Interop; using Microsoft.Android.Runtime; @@ -107,8 +109,8 @@ public void TryGetJniNameForManagedType_RepeatedClosedGenericLookup_IsCached () Assert.Ignore ("TrimmableTypeMap feature switch is off; test only relevant for the trimmable typemap path."); } - // The cache is keyed by the original closed type, so a second identical - // lookup returns the same proxy instance without walking the GTD again. + // Closed generic peers normalize to their open generic definition, so + // repeated lookups reuse the same cached proxy. var instance = TrimmableTypeMap.Instance; Assert.IsTrue (instance.TryGetJniNameForManagedType (typeof (JavaList), out var first)); @@ -116,6 +118,44 @@ public void TryGetJniNameForManagedType_RepeatedClosedGenericLookup_IsCached () Assert.AreEqual (first, second); } + [Test] + public void TryGetJniNameForManagedType_DifferentClosedGenerics_UseGenericDefinitionCacheKey () + { + if (!RuntimeFeature.TrimmableTypeMap) { + Assert.Ignore ("TrimmableTypeMap feature switch is off; test only relevant for the trimmable typemap path."); + } + + var instance = TrimmableTypeMap.Instance; + var cache = GetProxyCache (instance); + + cache.TryRemove (typeof (JavaList<>), out _); + cache.TryRemove (typeof (JavaList), out _); + cache.TryRemove (typeof (JavaList), out _); + + Assert.IsTrue (instance.TryGetJniNameForManagedType (typeof (JavaList), out _)); + Assert.IsTrue (instance.TryGetJniNameForManagedType (typeof (JavaList), out _)); + + Assert.IsTrue (cache.ContainsKey (typeof (JavaList<>))); + Assert.IsFalse (cache.ContainsKey (typeof (JavaList))); + Assert.IsFalse (cache.ContainsKey (typeof (JavaList))); + } + + static ConcurrentDictionary GetProxyCache (TrimmableTypeMap instance) + { + var field = typeof (TrimmableTypeMap).GetField ("_proxyCache", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull (field); + + var value = field.GetValue (instance); + Assert.IsNotNull (value); + + if (value is ConcurrentDictionary cache) { + return cache; + } + + Assert.Fail ("Unable to access TrimmableTypeMap proxy cache."); + throw new InvalidOperationException ("Unable to access TrimmableTypeMap proxy cache."); + } + // Pure-function tests for the TargetTypeMatches helper used by // TryGetProxyFromHierarchy when the hierarchy lookup finds a proxy whose // stored TargetType is an open generic definition. From efa5302a0253d896dff18f8aaf031fe8f7f5093f Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 18 Apr 2026 02:36:05 +0200 Subject: [PATCH 14/15] Keep trimmable shrink logic in targets Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Android.Sdk.AssemblyResolution.targets | 1 - .../Microsoft.Android.Sdk.TypeMap.Trimmable.targets | 9 ++------- .../Tasks/ProcessAssemblies.cs | 8 +------- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets index 276881f8316..6c641ca33d9 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.AssemblyResolution.targets @@ -182,7 +182,6 @@ _ResolveAssemblies MSBuild target. InputJavaLibraries="@(_ResolvedJavaLibraries->Distinct())" ResolvedSymbols="@(_ResolvedSymbolFiles)" AndroidIncludeDebugSymbols="$(AndroidIncludeDebugSymbols)" - AndroidTypeMapImplementation="$(_AndroidTypeMapImplementation)" PublishTrimmed="$(PublishTrimmed)"> diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 023122f73f0..0edcd002ecf 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -172,12 +172,7 @@ - - + diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/ProcessAssemblies.cs b/src/Xamarin.Android.Build.Tasks/Tasks/ProcessAssemblies.cs index 0c41976184b..5c166e75935 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/ProcessAssemblies.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/ProcessAssemblies.cs @@ -33,8 +33,6 @@ public class ProcessAssemblies : AndroidTask public bool PublishTrimmed { get; set; } - public string AndroidTypeMapImplementation { get; set; } = ""; - public ITaskItem [] InputAssemblies { get; set; } = []; public ITaskItem [] InputJavaLibraries { get; set; } = []; @@ -71,12 +69,8 @@ public override bool RunTask () // Set ShrunkAssemblies for _RemoveRegisterAttribute and // This should match the Condition on the _RemoveRegisterAttribute target. - // The trimmable typemap path does NOT run _RemoveRegisterAttribute - // (which is responsible for populating the /shrunk/ directory), - // so for that path ShrunkAssemblies is just OutputAssemblies. if (PublishTrimmed) { - bool useTrimmableTypeMap = string.Equals (AndroidTypeMapImplementation, "trimmable", StringComparison.OrdinalIgnoreCase); - if (!AndroidIncludeDebugSymbols && !useTrimmableTypeMap) { + if (!AndroidIncludeDebugSymbols) { var shrunkAssemblies = new List (OutputAssemblies.Length); foreach (var assembly in OutputAssemblies) { var dir = Path.GetDirectoryName (assembly.ItemSpec); From 894f4419a558e11984e2c4d51bc2e6f4daaf021d Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 18 Apr 2026 02:36:33 +0200 Subject: [PATCH 15/15] Remove stale trimmable targets comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 0edcd002ecf..ea1a6a2746b 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -172,7 +172,4 @@ - -