From bafa7e5af6c2f5a45db1a37f5c9056ec9b500d19 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 28 Apr 2026 13:21:56 +0200 Subject: [PATCH 1/5] [Mono.Android] Trimmable typemap: fix JavaCast/JavaAs behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve CreatePeer under the trimmable typemap to match legacy JavaCast/JavaAs contracts: - Bad-cast disambiguation: distinguish incompatible Java types (return null → InvalidCastException) from missing typemap entries (ArgumentException) and generator gaps (NotSupportedException). - Closed-generic activation: when the proxy targets an open generic (e.g. JavaList<>), activate the closed targetType (e.g. JavaList) via reflection using the (IntPtr, JniHandleOwnership) ctor. The [DynamicallyAccessedMembers(Constructors)] annotation on targetType guarantees the trimmer preserves the ctor metadata. - Type resolution: map IJavaPeerable/object/Exception to concrete peer types before proxy lookup, mirroring the legacy GetPeerType behavior. - TargetTypeMatches: restructure so open-generic proxies match only closed instantiations of their definition. - FindClass safety: catch ClassNotFoundException in TryGetProxyFromTargetType for types not present in the APK. - Don't synthesize activation from inherited ctors: match legacy GetConstructor() which doesn't find inherited constructors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaMarshalValueManager.cs | 114 +++++++++++++++++- .../TrimmableTypeMap.cs | 37 +++--- 2 files changed, 134 insertions(+), 17 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs index 2250cb24d67..f6124c11e04 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs @@ -514,9 +514,28 @@ void ProcessContext (HandleContext* context) if (RuntimeFeature.TrimmableTypeMap) { try { + // Mirror legacy GetPeerType: callers commonly request universal + // interfaces / boxes (IJavaPeerable, object, Exception) — map these + // to a concrete peer type so the proxy lookup can succeed. + var resolvedTargetType = ResolvePeerType (targetType); + var typeMap = TrimmableTypeMap.Instance; - var proxy = typeMap.GetProxyForJavaObject (reference.Handle, targetType); - var peer = proxy?.CreateInstance (reference.Handle, JniHandleOwnership.DoNotTransfer); + var proxy = typeMap.GetProxyForJavaObject (reference.Handle, resolvedTargetType); + + // Open-generic proxies (e.g. JavaList<>) cannot create closed + // instantiations (e.g. JavaList) via CreateInstance because + // the generated IL can't newobj an open generic type. Activate the + // closed targetType directly via its (IntPtr, JniHandleOwnership) + // ctor — [DAM(Constructors)] on targetType guarantees the trimmer + // preserves the ctor metadata. + IJavaPeerable? peer; + if (proxy is not null && proxy.TargetType.IsGenericTypeDefinition && + resolvedTargetType is not null && + resolvedTargetType.IsGenericType && !resolvedTargetType.IsGenericTypeDefinition) { + peer = ActivateUsingReflection (resolvedTargetType, reference.Handle, JniHandleOwnership.DoNotTransfer); + } else { + peer = proxy?.CreateInstance (reference.Handle, JniHandleOwnership.DoNotTransfer); + } if (peer is not null) { var peerState = peer.JniManagedPeerState | JniManagedPeerStates.Replaceable; if (global::Java.Interop.Runtime.IsGCUserPeer (peer.PeerReference.Handle)) { @@ -526,7 +545,22 @@ void ProcessContext (HandleContext* context) return peer; } - var targetName = targetType?.AssemblyQualifiedName ?? ""; + // Disambiguate the failure — match the contract of the base + // JniRuntime.JniValueManager.CreatePeer so JavaCast / JavaAs + // surface the right exception (or null) to callers: + // + // (a) target type has no Java mapping at all → ArgumentException + // (b) Java instance is not assignable to the target's Java class + // → return null (JavaAs returns null; JavaCast wraps to + // InvalidCastException via its `??` clause) + // (c) classes are compatible but no proxy / activation failed + // → NotSupportedException (genuine generator gap) + if (resolvedTargetType is not null && + IsIncompatibleCast (typeMap, ref reference, resolvedTargetType)) { + return null; + } + + var targetName = resolvedTargetType?.AssemblyQualifiedName ?? ""; var javaType = JniEnvironment.Types.GetJniTypeNameFromInstance (reference); throw new NotSupportedException ( @@ -541,6 +575,80 @@ void ProcessContext (HandleContext* context) return base.CreatePeer (ref reference, transfer, targetType); } + [return: DynamicallyAccessedMembers (Constructors)] + static Type? ResolvePeerType ([DynamicallyAccessedMembers (Constructors)] Type? type) + { + if (type is null) { + return null; + } + if (type == typeof (object) || type == typeof (IJavaPeerable)) { + return typeof (global::Java.Interop.JavaObject); + } + if (type == typeof (Exception)) { + return typeof (JavaException); + } + return type; + } + + static IJavaPeerable? ActivateUsingReflection ( + [DynamicallyAccessedMembers (Constructors)] + Type closedType, + IntPtr handle, + JniHandleOwnership transfer) + { + var ctor = closedType.GetConstructor (ActivationConstructorBindingFlags, null, XAConstructorSignature, null); + if (ctor is null) { + return null; + } + + return (IJavaPeerable) ctor.Invoke ([handle, transfer]); + } + + /// + /// When the trimmable typemap proxy lookup yields no peer for a non-null + /// , decide whether the caller's request is a + /// genuine bad cast (return true → caller returns null) or a missing typemap + /// entry (throw ). + /// Returns false when the target's Java class IS compatible with the + /// instance — letting the caller fall through to its NotSupportedException + /// branch. + /// + static bool IsIncompatibleCast ( + TrimmableTypeMap typeMap, + ref JniObjectReference reference, + Type targetType) + { + if (!typeMap.TryGetJniNameForManagedType (targetType, out var targetJniName)) { + throw new ArgumentException ( + $"Could not determine Java type corresponding to '{targetType.AssemblyQualifiedName}'.", + nameof (targetType)); + } + + var instanceClass = JniEnvironment.Types.GetObjectClass (reference); + JniObjectReference targetClass = default; + try { + try { + targetClass = JniEnvironment.Types.FindClass (targetJniName); + } catch (Exception e) { + throw new ArgumentException ( + $"Could not find Java class '{targetJniName}'.", + nameof (targetType), e); + } + + if (!JniEnvironment.Types.IsAssignableFrom (instanceClass, targetClass)) { + // Genuine bad cast — return null, callers translate this. + return true; + } + } finally { + JniObjectReference.Dispose (ref instanceClass); + JniObjectReference.Dispose (ref targetClass); + } + + // Classes are compatible — caller should throw NotSupportedException + // (real proxy/activation gap). + return false; + } + protected override bool TryConstructPeer ( IJavaPeerable self, ref JniObjectReference reference, diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index eefb7cc2ac5..2ca52f03f38 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -238,7 +238,16 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) var targetClass = default (JniObjectReference); try { objClass = JniEnvironment.Types.GetObjectClass (selfRef); - targetClass = JniEnvironment.Types.FindClass (targetJniName); + try { + targetClass = JniEnvironment.Types.FindClass (targetJniName); + } catch (Java.Lang.ClassNotFoundException) { + // FindClass throws for managed types whose Java peer class is + // not present in the APK (e.g. test types annotated with + // [JniTypeSignature("__missing__")]). Treat as "no match" so + // JavaMarshalValueManager.CreatePeer can surface the correct + // ArgumentException instead of leaking ClassNotFoundException. + return null; + } var isAssignable = JniEnvironment.Types.IsAssignableFrom (objClass, targetClass); return isAssignable ? proxy : null; } finally { @@ -273,22 +282,22 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) /// 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; + // Open generic proxy: match only when targetType is a closed instantiation + // of this generic (e.g. JavaList matches the JavaList<> proxy). + // IsAssignableFrom alone would incorrectly match unrelated open generics + // that are technically subclasses (e.g. JavaArray<> is assignable to + // JavaObject), and proxy.CreateInstance for an open generic always throws. + if (proxyTargetType.IsGenericTypeDefinition) { + for (Type? t = targetType; t is not null; t = t.BaseType) { + if (t.IsGenericType && !t.IsGenericTypeDefinition && + t.GetGenericTypeDefinition () == proxyTargetType) { + return true; + } } + return false; } - return false; + return targetType.IsAssignableFrom (proxyTargetType); } /// From a8049949b5c9b30dd36bc4389e3f127217b487c2 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 28 Apr 2026 13:22:11 +0200 Subject: [PATCH 2/5] [TrimmableTypeMap] Scanner fixes: invoker ctor, keyword types, array ranks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve activation ctor on invoker types for interface peers, so the generator picks the correct ctor signature (XA vs JI style). - Skip JNI keyword types (Z, B, C, S, I, J, F, D) in the scanner. These single-letter JNI names collided with primitive type handling in JniRuntime.JniTypeManager. - Skip all ArrayRank>0 types (JavaBooleanArray, JavaArray<>, etc.) — they were incorrectly added as aliases for java/lang/Object, causing alias resolution to select the open-generic JavaArray<> proxy. - Don't synthesize activation from inherited ctors in the generator, matching legacy Type.GetConstructor() behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/TypeMapAssemblyEmitter.cs | 8 +++-- .../Scanner/AssemblyIndex.cs | 4 +++ .../Scanner/JavaPeerScanner.cs | 35 +++++++++++++++++++ .../Scanner/JavaPeerScannerTests.cs | 16 +++++++++ .../TestFixtures/TestTypes.cs | 19 ++++++++++ 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs index 417b1096f60..6f8647dcabe 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs @@ -615,7 +615,9 @@ void EmitCreateInstance (JavaPeerProxyData proxy) if (jiCtor.IsOnLeafType) { EmitCreateInstanceViaJavaInteropNewobj (targetRef); } else { - EmitCreateInstanceInheritedJavaInteropCtor (targetRef, jiCtor); + // Legacy GetConstructor() doesn't find inherited ctors — + // match that behavior by returning null. + EmitCreateInstanceNoActivation (); } } return; @@ -633,7 +635,9 @@ void EmitCreateInstance (JavaPeerProxyData proxy) if (activationCtor.IsOnLeafType) { EmitCreateInstanceViaNewobj (targetTypeRef); } else { - EmitCreateInstanceInheritedCtor (targetTypeRef, activationCtor); + // Legacy GetConstructor() doesn't find inherited ctors — + // match that behavior by returning null. + EmitCreateInstanceNoActivation (); } } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs index 1db8dfd8309..9bf542f6b7b 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs @@ -235,10 +235,13 @@ internal RegisterInfo ParseJniTypeSignatureAttribute (CustomAttribute ca) doNotGenerateAcw = !generateJavaPeer; } + var isArrayType = TryGetNamedArgument (value, "ArrayRank", out var rank) && rank > 0; + return new RegisterInfo { JniName = jniName.Replace ('.', '/'), DoNotGenerateAcw = doNotGenerateAcw, IsFromJniTypeSignature = true, + IsArrayType = isArrayType, }; } @@ -529,6 +532,7 @@ sealed record RegisterInfo public string? Connector { get; init; } public bool DoNotGenerateAcw { get; init; } public bool IsFromJniTypeSignature { get; init; } + public bool IsArrayType { get; init; } } /// diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs index 2e09766a2fa..f683c3223e2 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs @@ -182,6 +182,15 @@ void ScanAssembly (AssemblyIndex index, Dictionary<(string ManagedName, string A index.AttributesByType.TryGetValue (typeHandle, out var attrInfo); if (registerInfo is not null && !string.IsNullOrEmpty (registerInfo.JniName)) { + // [JniTypeSignature] with ArrayRank > 0 represents a JNI array wrapper + // (e.g., JavaBooleanArray, JavaObjectArray, JavaPrimitiveArray). + // These are handled by the built-in tables in JniRuntime.JniTypeManager + // and must not be added to the typemap — keyword types (Z, B, etc.) + // would collide with GetPrimitiveArrayTypesForSimpleReference, and + // non-keyword array types would add unnecessary aliases. + if (registerInfo.IsArrayType) { + continue; + } jniName = registerInfo.JniName; compatJniName = jniName; doNotGenerateAcw = registerInfo.DoNotGenerateAcw; @@ -229,6 +238,14 @@ void ScanAssembly (AssemblyIndex index, Dictionary<(string ManagedName, string A invokerTypeName = TryFindInvokerTypeName (fullName, typeHandle, index); } + // Interface peers have no constructors of their own, so ResolveActivationCtor + // returns null. The invoker type holds the activation ctor — resolve it from + // there so the generator can pick the correct ctor signature + // (XamarinAndroid (IntPtr, JniHandleOwnership) vs JavaInterop (ref JniObjectReference, JniObjectReferenceOptions)). + if (activationCtor is null && invokerTypeName is not null) { + activationCtor = TryResolveActivationCtorOnInvoker (invokerTypeName); + } + var peer = new JavaPeerInfo { JavaName = jniName, CompatJniName = compatJniName, @@ -1280,6 +1297,24 @@ string ManagedTypeToJniDescriptor (string managedType) return null; } + /// + /// Resolve the activation ctor on a known invoker type (search all loaded assemblies). + /// Used for interface peers, whose own type definition has no constructors. + /// The assemblyCache typically contains 10–30 entries (app + framework assemblies), + /// and each lookup is an O(1) dictionary probe, so the linear scan is cheap. + /// + ActivationCtorInfo? TryResolveActivationCtorOnInvoker (string invokerTypeName) + { + foreach (var assembly in assemblyCache.Values) { + if (!assembly.TypesByFullName.TryGetValue (invokerTypeName, out var invokerHandle)) { + continue; + } + var invokerDef = assembly.Reader.GetTypeDefinition (invokerHandle); + return ResolveActivationCtor (invokerTypeName, invokerDef, assembly); + } + return null; + } + public void Dispose () { foreach (var index in assemblyCache.Values) { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs index 7d57a37657c..fddfaae55a9 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs @@ -131,4 +131,20 @@ public void Scan_JniTypeSignature_SubclassExtendsJavaPeer () var peer = FindFixtureByJavaName ("net/dot/jni/test/JavaDisposedObject"); Assert.NotNull (peer); } + + [Fact] + public void Scan_JniTypeSignature_ArrayRank_IsExcluded () + { + // Types with [JniTypeSignature(ArrayRank > 0)] represent JNI array wrappers + // (e.g., JavaBooleanArray with IsKeyword=true, or JavaObjectArray without). + // The scanner must skip all of them — they are handled by the built-in tables + // in JniRuntime.JniTypeManager, not the typemap. + var peers = ScanFixtures (); + + // Keyword primitive array (e.g., JavaBooleanArray with "Z") + Assert.DoesNotContain (peers, p => p.ManagedTypeName == "Java.Interop.TestTypes.KeywordPrimitiveArray"); + + // Non-keyword array (e.g., JavaObjectArray with "java/lang/Object", ArrayRank=1) + Assert.DoesNotContain (peers, p => p.ManagedTypeName == "Java.Interop.TestTypes.NonKeywordArrayType"); + } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs index 9b220bb6d03..a4363fe9877 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs @@ -969,4 +969,23 @@ public class NonGeneratedJavaObject : JavaObject { public NonGeneratedJavaObject () { } } + + /// + /// Mimics Java.Interop.JavaBooleanArray — a primitive array type with IsKeyword=true. + /// The scanner must skip all ArrayRank > 0 types because they are handled by the + /// built-in tables in JniRuntime.JniTypeManager. + /// + [Java.Interop.JniTypeSignature ("Z", IsKeyword = true, ArrayRank = 1, GenerateJavaPeer = false)] + public sealed class KeywordPrimitiveArray : JavaObject + { + } + + /// + /// Mimics Java.Interop.JavaObjectArray — a non-keyword array type with ArrayRank=1. + /// The scanner must also skip these to avoid adding unnecessary aliases. + /// + [Java.Interop.JniTypeSignature ("java/lang/Object", ArrayRank = 1, GenerateJavaPeer = false)] + public class NonKeywordArrayType : JavaObject + { + } } From c5062a607eb7136525a1af8374f8013761056db1 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 28 Apr 2026 13:22:34 +0200 Subject: [PATCH 3/5] [Tests] Re-enable 7 trimmable typemap tests Add trimmable-typemap variant of GetThis.java that uses mono.android.Runtime.register instead of ManagedPeer.registerNativeMembers. Java.Interop-Tests.targets swaps variants based on $(_AndroidTypeMapImplementation). Re-enabled tests: - JavaCast_BadInterfaceCast (bad-cast disambiguation) - JavaCast_BaseToGenericWrapper (closed-generic activation) - JavaCast_CheckForManagedSubclasses (bad-cast disambiguation) - JavaCast_InvalidTypeCastThrows (bad-cast disambiguation) - JavaAs_Exceptions (inherited ctor fix) - DisposeAccessesThis (trimmable GetThis.java) - CreateGenericValue (ArrayRank scanner fix) Updated remaining exclusion comments with accurate root-cause descriptions. Removed resolved TODO comments from test files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop-Tests.targets | 35 +++++++++++++++- .../net/dot/jni/test/GetThis.java | 41 +++++++++++++++++++ .../Java.Interop/JavaObjectExtensionsTests.cs | 4 -- .../NUnitInstrumentation.cs | 37 +++++++---------- 4 files changed, 90 insertions(+), 27 deletions(-) create mode 100644 tests/Mono.Android-Tests/Java.Interop-Tests/java-trimmable/net/dot/jni/test/GetThis.java diff --git a/tests/Mono.Android-Tests/Java.Interop-Tests/Java.Interop-Tests.targets b/tests/Mono.Android-Tests/Java.Interop-Tests/Java.Interop-Tests.targets index 2854ac5b224..b1fa7783883 100644 --- a/tests/Mono.Android-Tests/Java.Interop-Tests/Java.Interop-Tests.targets +++ b/tests/Mono.Android-Tests/Java.Interop-Tests/Java.Interop-Tests.targets @@ -10,7 +10,40 @@ - + + + + + + + + + + managedReferences = new ArrayList(); + + public GetThis () { + } + + public final GetThis getThis () { + return this; + } + + public void jiAddManagedReference (java.lang.Object obj) + { + managedReferences.add (obj); + } + + public void jiClearManagedReferences () + { + managedReferences.clear (); + } +} diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JavaObjectExtensionsTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JavaObjectExtensionsTests.cs index 9938951cae9..1b13316cc3d 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JavaObjectExtensionsTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JavaObjectExtensionsTests.cs @@ -15,7 +15,6 @@ namespace Java.InteropTests { [TestFixture] public class JavaObjectExtensionsTests { - // TODO: https://github.com/dotnet/android/issues/11170 — cannot create instance of open generic type under trimmable typemap [Test] public void JavaCast_BaseToGenericWrapper () { @@ -42,7 +41,6 @@ public void JavaCast_InterfaceCast () } } - // TODO: https://github.com/dotnet/android/issues/11170 — throws NotSupportedException instead of InvalidCastException under trimmable typemap [Test] public void JavaCast_BadInterfaceCast () { @@ -69,7 +67,6 @@ public void JavaCast_ObtainOriginalInstance () Assert.AreSame (list, al); } - // TODO: https://github.com/dotnet/android/issues/11170 — throws NotSupportedException instead of InvalidCastException under trimmable typemap [Test] public void JavaCast_InvalidTypeCastThrows () { @@ -78,7 +75,6 @@ public void JavaCast_InvalidTypeCastThrows () } } - // TODO: https://github.com/dotnet/android/issues/11170 — throws NotSupportedException instead of InvalidCastException under trimmable typemap [Test] public void JavaCast_CheckForManagedSubclasses () { diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs index d24d53ad324..f3e73568eed 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs @@ -34,10 +34,17 @@ protected NUnitInstrumentation(IntPtr handle, JniHandleOwnership transfer) // net.dot.jni.test.CallVirtualFromConstructorDerived Java class not in APK "Java.InteropTests.InvokeVirtualFromConstructorTests", - // net.dot.jni.internal.JavaProxyObject Java class not in APK — fixture setup fails (16 tests) + // net.dot.jni.internal.JavaProxyObject. calls + // net.dot.jni.ManagedPeer.registerNativeMembers, which the trimmable + // typemap path rejects (Native methods must be registered by JCW + // static initializer blocks). Fixing this requires a parallel + // Android-trimmable variant of JavaProxyObject.java that registers + // its native equals/hashCode/toString via mono.android.Runtime.register + // — an architectural change tracked separately from the JavaCast / JavaAs + // work in this PR. See https://github.com/dotnet/android/issues/11170. "Java.InteropTests.JavaObjectArray_object_ContractTest", - // net.dot.jni.internal.JavaProxyObject Java class not in APK + // Same root cause as above (JavaProxyObject static init). "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateArgumentState", "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateGenericArgumentState", "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateGenericObjectReferenceArgumentState", @@ -46,18 +53,10 @@ protected NUnitInstrumentation(IntPtr handle, JniHandleOwnership transfer) "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateValue", "Java.InteropTests.JniValueMarshaler_object_ContractTests.SpecificTypesAreUsed", - // No generated JavaPeerProxy for java/lang/Object with IJavaPeerable target type - "Java.InteropTests.JniValueMarshaler_IJavaPeerable_ContractTests.JniValueMarshalerContractTests`1.CreateGenericValue", - "Java.InteropTests.JniValueMarshaler_IJavaPeerable_ContractTests.JniValueMarshalerContractTests`1.CreateValue", - - // net.dot.jni.internal.JavaProxyThrowable — proxy throwable creation fails + // net.dot.jni.internal.JavaProxyThrowable static init — same JavaProxy* + // root cause as the JavaProxyObject exclusions above. "Java.InteropTests.JavaExceptionTests.InnerExceptionIsNotAProxy", - // IJavaInterfaceInvoker ctor trimmed / missing JavaPeerProxy for test types - "Java.InteropTests.JavaPeerableExtensionsTests.JavaAs", - "Java.InteropTests.JavaPeerableExtensionsTests.JavaAs_Exceptions", - "Java.InteropTests.JavaPeerableExtensionsTests.JavaAs_InstanceThatDoesNotImplementInterfaceReturnsNull", - // JNI method remapping not supported in trimmable typemap "Java.InteropTests.JniPeerMembersTests.ReplaceInstanceMethodName", "Java.InteropTests.JniPeerMembersTests.ReplaceInstanceMethodWithStaticMethod", @@ -67,18 +66,12 @@ protected NUnitInstrumentation(IntPtr handle, JniHandleOwnership transfer) // net.dot.jni.test.GenericHolder Java class not in APK "Java.InteropTests.JniTypeManagerTests.CannotCreateGenericHolderFromJava", - // JniPrimitiveArrayInfo lookup fails for JavaBooleanArray + // JniPrimitiveArrayInfo lookup fails for JavaBooleanArray — + // our typemap returns JavaBooleanArray for "Z" via JavaPrimitiveArray<> + // alias, which collides with the legacy GetPrimitiveArrayTypesForSimpleReference + // that expects only primitive CLR types. Out of scope for this PR. "Java.InteropTests.JniTypeManagerTests.GetType", - // net.dot.jni.test.GetThis — cannot register native members - "Java.InteropTests.JavaObjectTest.DisposeAccessesThis", - - // NotSupportedException instead of InvalidCastException — no generated JavaPeerProxy - "Java.InteropTests.JavaObjectExtensionsTests.JavaCast_BadInterfaceCast", - "Java.InteropTests.JavaObjectExtensionsTests.JavaCast_BaseToGenericWrapper", - "Java.InteropTests.JavaObjectExtensionsTests.JavaCast_CheckForManagedSubclasses", - "Java.InteropTests.JavaObjectExtensionsTests.JavaCast_InvalidTypeCastThrows", - // Open generic type handling differs from non-trimmable "Java.InteropTests.JnienvTest.NewOpenGenericTypeThrows", From d114f841266c42a06d09ba2d13212a7c82963a8a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 29 Apr 2026 12:00:46 +0200 Subject: [PATCH 4/5] Fix trimmable typemap generic alias lookup Allow exact type matches before applying open-generic target matching so alias lookup resolves open generic peers such as JavaList<>. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Android.Runtime/TrimmableTypeMap.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index 2ca52f03f38..4f7dbb27c69 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -282,6 +282,10 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) /// internal static bool TargetTypeMatches (Type targetType, Type proxyTargetType) { + if (targetType == proxyTargetType) { + return true; + } + // Open generic proxy: match only when targetType is a closed instantiation // of this generic (e.g. JavaList matches the JavaList<> proxy). // IsAssignableFrom alone would incorrectly match unrelated open generics From 77a4662c7b63824a150423058e7dd4eb62cf2ffc Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 29 Apr 2026 13:45:37 +0200 Subject: [PATCH 5/5] Address trimmable typemap review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../JavaMarshalValueManager.cs | 53 +++++++------------ 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs index f6124c11e04..b4791cfdde0 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/JavaMarshalValueManager.cs @@ -514,24 +514,15 @@ void ProcessContext (HandleContext* context) if (RuntimeFeature.TrimmableTypeMap) { try { - // Mirror legacy GetPeerType: callers commonly request universal - // interfaces / boxes (IJavaPeerable, object, Exception) — map these - // to a concrete peer type so the proxy lookup can succeed. + // Map universal request types to concrete peers before proxy lookup. var resolvedTargetType = ResolvePeerType (targetType); var typeMap = TrimmableTypeMap.Instance; var proxy = typeMap.GetProxyForJavaObject (reference.Handle, resolvedTargetType); - // Open-generic proxies (e.g. JavaList<>) cannot create closed - // instantiations (e.g. JavaList) via CreateInstance because - // the generated IL can't newobj an open generic type. Activate the - // closed targetType directly via its (IntPtr, JniHandleOwnership) - // ctor — [DAM(Constructors)] on targetType guarantees the trimmer - // preserves the ctor metadata. + // Open-generic proxies cannot instantiate closed targets. IJavaPeerable? peer; - if (proxy is not null && proxy.TargetType.IsGenericTypeDefinition && - resolvedTargetType is not null && - resolvedTargetType.IsGenericType && !resolvedTargetType.IsGenericTypeDefinition) { + if (ShouldActivateClosedGenericTarget (proxy, resolvedTargetType)) { peer = ActivateUsingReflection (resolvedTargetType, reference.Handle, JniHandleOwnership.DoNotTransfer); } else { peer = proxy?.CreateInstance (reference.Handle, JniHandleOwnership.DoNotTransfer); @@ -545,16 +536,7 @@ resolvedTargetType is not null && return peer; } - // Disambiguate the failure — match the contract of the base - // JniRuntime.JniValueManager.CreatePeer so JavaCast / JavaAs - // surface the right exception (or null) to callers: - // - // (a) target type has no Java mapping at all → ArgumentException - // (b) Java instance is not assignable to the target's Java class - // → return null (JavaAs returns null; JavaCast wraps to - // InvalidCastException via its `??` clause) - // (c) classes are compatible but no proxy / activation failed - // → NotSupportedException (genuine generator gap) + // Preserve CreatePeer's bad-cast vs missing-mapping behavior. if (resolvedTargetType is not null && IsIncompatibleCast (typeMap, ref reference, resolvedTargetType)) { return null; @@ -590,6 +572,17 @@ resolvedTargetType is not null && return type; } + static bool ShouldActivateClosedGenericTarget ( + [NotNullWhen (true)] JavaPeerProxy? proxy, + [NotNullWhen (true)] Type? resolvedTargetType) + { + return proxy is not null && + proxy.TargetType.IsGenericTypeDefinition && + resolvedTargetType is not null && + resolvedTargetType.IsGenericType && + !resolvedTargetType.IsGenericTypeDefinition; + } + static IJavaPeerable? ActivateUsingReflection ( [DynamicallyAccessedMembers (Constructors)] Type closedType, @@ -605,13 +598,8 @@ resolvedTargetType is not null && } /// - /// When the trimmable typemap proxy lookup yields no peer for a non-null - /// , decide whether the caller's request is a - /// genuine bad cast (return true → caller returns null) or a missing typemap - /// entry (throw ). - /// Returns false when the target's Java class IS compatible with the - /// instance — letting the caller fall through to its NotSupportedException - /// branch. + /// Returns true when 's Java class is not assignable from + /// . Throws when has no usable mapping. /// static bool IsIncompatibleCast ( TrimmableTypeMap typeMap, @@ -629,14 +617,14 @@ static bool IsIncompatibleCast ( try { try { targetClass = JniEnvironment.Types.FindClass (targetJniName); - } catch (Exception e) { + } catch (Java.Lang.ClassNotFoundException e) { throw new ArgumentException ( $"Could not find Java class '{targetJniName}'.", nameof (targetType), e); } if (!JniEnvironment.Types.IsAssignableFrom (instanceClass, targetClass)) { - // Genuine bad cast — return null, callers translate this. + // Bad cast: callers translate null to the expected result. return true; } } finally { @@ -644,8 +632,7 @@ static bool IsIncompatibleCast ( JniObjectReference.Dispose (ref targetClass); } - // Classes are compatible — caller should throw NotSupportedException - // (real proxy/activation gap). + // Compatible classes mean a proxy/activation gap. return false; }