From 80ab6d9f21bde9006f3c2a207b650de5e538d96a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 29 Jun 2026 15:28:37 +0200 Subject: [PATCH 1/7] [TrimmableTypeMap] Encode JNI boolean as System.Boolean in n_* callback refs Fixes #11773. The trimmable typemap generator emits, for each Java peer marshal method, a UCO forwarder that calls the MCW-generated `n_*` callback via a cross-assembly MemberRef. The MemberRef signature was built by `EncodeClrTypeForCallback`, which encoded a JNI boolean (`Z`) parameter/return as `System.SByte`. The real MCW `n_*` callbacks declare their JNI boolean as `System.Boolean` (e.g. `AndroidX.Fragment.App.FragmentManager.IOnBackStackChangedListener. n_OnBackStackChangeStarted_..._Z(IntPtr, IntPtr, IntPtr, bool)`). Because `System.SByte != System.Boolean` in metadata, the emitted MemberRef could not be bound to the real method, so ILC reported: ILC: Method '..._Proxy.n_OnBackStackChangeStarted_uco_*' will always throw because: Missing method 'Void ...n_OnBackStackChangeStarted_...(..., SByte)' and replaced the forwarder body with a throw -- breaking every binding/AndroidX interface listener callback that has a boolean parameter (and any boolean- returning callback) under NativeAOT. This was misdiagnosed as a trimming/preserve-edge problem. It is not: a rooted cross-assembly call to a non-public static method -- including one declared on an interface -- is preserved by ILC. The failure is purely a signature mismatch in the generated MemberRef. Reproduced minimally by emitting an `sbyte` MemberRef against a `bool` method and re-running ilc, which produces the exact "will always throw / Missing method ... SByte" diagnostic from the issue; switching the MemberRef to `bool` resolves and runs. The fix encodes JNI boolean as `System.Boolean` for the callback MemberRef. The UCO entry point keeps `byte` (blittable, unsigned) for the `[UnmanagedCallersOnly]` ABI; only the managed `n_*` MemberRef changes. This matches the constructor path, which already references managed boolean parameters as `System.Boolean`. Passing the UCO `byte` argument to the `bool` parameter is valid IL (both are stack-int32; JNI jboolean is always 0/1), mirroring the legacy LLVM-IR/marshal-methods path which also targets the real `bool` `n_*` callbacks. Four unit tests had codified the incorrect `sbyte` expectation; they are updated to assert `System.Boolean` for the callback signature while keeping `System.Byte` for the UCO ABI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/JniSignatureHelper.cs | 5 ++-- .../TypeMapAssemblyGeneratorTests.cs | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs index afa9ea3c3d9..ed66d5de8e2 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs @@ -130,13 +130,14 @@ public static void EncodeClrType (SignatureTypeEncoder encoder, JniParamKind kin /// /// Encodes a JNI type as its CLR equivalent matching the MCW-generated n_* callback - /// signatures. JNI boolean (Z) maps to sbyte (matching _JniMarshal_*_B delegates). + /// signatures. JNI boolean (Z) maps to bool (the MCW n_* callbacks declare the + /// parameter as , unlike the blittable byte used for the UCO ABI). /// Use this when constructing member references to n_* methods. /// public static void EncodeClrTypeForCallback (SignatureTypeEncoder encoder, JniParamKind kind) { switch (kind) { - case JniParamKind.Boolean: encoder.SByte (); break; // MCW n_* callbacks use sbyte for JNI boolean + case JniParamKind.Boolean: encoder.Boolean (); break; // MCW n_* callbacks declare JNI boolean as System.Boolean case JniParamKind.Byte: encoder.SByte (); break; case JniParamKind.Char: encoder.Char (); break; case JniParamKind.Short: encoder.Int16 (); break; diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index 28b2ad29b2a..07e93495407 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -798,7 +798,7 @@ public void EncodeClrType_ProducesCorrectPrimitiveTypeCode (int kindValue, byte } [Theory] - [InlineData (1, 0x04)] // Boolean → sbyte — matches MCW n_* callbacks + [InlineData (1, 0x02)] // Boolean → bool (System.Boolean) — matches MCW n_* callbacks [InlineData (2, 0x04)] // Byte → sbyte [InlineData (3, 0x03)] // Char → char [InlineData (4, 0x06)] // Short → int16 @@ -827,8 +827,8 @@ public void EncodeClrType_Boolean_DiffersFromCallback () var ucoBytes = ucoBlob.ToArray (); var cbBytes = cbBlob.ToArray (); Assert.NotEqual (ucoBytes, cbBytes); - Assert.Equal (0x05, ucoBytes [0]); // byte (unsigned) - Assert.Equal (0x04, cbBytes [0]); // sbyte (signed) + Assert.Equal (0x05, ucoBytes [0]); // byte (unsigned, blittable JNI ABI) + Assert.Equal (0x02, cbBytes [0]); // bool (System.Boolean, matches MCW n_* callback) } [Fact] @@ -868,11 +868,13 @@ public void EncodeClrType_NonBooleanTypes_IdenticalToCallback (int kindValue) } [Fact] - public void Generate_UcoMethod_BooleanReturn_WrapperUsesByte_CallbackUsesSByte () + public void Generate_UcoMethod_BooleanReturn_WrapperUsesByte_CallbackUsesBoolean () { - // Regression test: the UCO wrapper must use byte (unsigned, JNI ABI) for boolean, - // but the callback MemberRef must use sbyte (signed, MCW convention). - // A mismatch caused ILLink to fail resolving the member reference and trim n_* methods. + // Regression test: the UCO wrapper must use byte (unsigned, blittable JNI ABI) for + // boolean, but the callback MemberRef must use System.Boolean to match the MCW-generated + // n_* callback signature. Encoding the callback boolean as sbyte produced a MemberRef + // that could not be resolved to the real n_* method (SByte != Boolean), causing ILC to + // report "will always throw because: Missing method" for every boolean-bearing callback. var peer = MakeTouchHandlerCallbackDispatchPeer (); using var stream = GenerateAssembly (new [] { peer }, "BoolReturnTest"); using var pe = new PEReader (stream); @@ -889,13 +891,14 @@ public void Generate_UcoMethod_BooleanReturn_WrapperUsesByte_CallbackUsesSByte ( // Find the callback MemberRef that the UCO wrapper calls (n_OnTouch on the TouchHandler type) var callbackRef = FindCallbackMemberRef (reader, "n_OnTouch"); var callbackSig = callbackRef.DecodeMethodSignature (SignatureTypeProvider.Instance, null); - Assert.Equal ("System.SByte", callbackSig.ReturnType); + Assert.Equal ("System.Boolean", callbackSig.ReturnType); } [Fact] - public void Generate_UcoMethod_BooleanParam_WrapperUsesByte_CallbackUsesSByte () + public void Generate_UcoMethod_BooleanParam_WrapperUsesByte_CallbackUsesBoolean () { - // Regression test: boolean parameters must also use the correct encoding. + // Regression test: boolean parameters must also use the correct encoding — byte for the + // blittable UCO ABI, System.Boolean for the n_* callback MemberRef. var peer = MakeTouchHandlerCallbackDispatchPeer (); using var stream = GenerateAssembly (new [] { peer }, "BoolParamTest"); using var pe = new PEReader (stream); @@ -913,7 +916,7 @@ public void Generate_UcoMethod_BooleanParam_WrapperUsesByte_CallbackUsesSByte () // Find the callback MemberRef var callbackRef = FindCallbackMemberRef (reader, "n_OnFocusChange"); var callbackSig = callbackRef.DecodeMethodSignature (SignatureTypeProvider.Instance, null); - Assert.Equal ("System.SByte", callbackSig.ParameterTypes.Last ()); + Assert.Equal ("System.Boolean", callbackSig.ParameterTypes.Last ()); } [Fact] From 167b958d39e3d1c05c51434cc5e835e5a523e3dc Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 2 Jul 2026 13:31:18 +0200 Subject: [PATCH 2/7] [TrimmableTypeMap] Match n_* callback boolean/char to the real signature The trimmable typemap generator emits, for each Java peer marshal method, a UCO forwarder that calls the MCW-generated `n_*` callback via a cross-assembly MemberRef. For JNI boolean (`Z`) and char (`C`) the CLR type of that callback is *not* fixed: java-interop #1296 ("Avoid non-blittable types in native callback methods") changed the generator so `n_*` declares JNI boolean as blittable `sbyte` (was `bool`) and char as `ushort` (was `char`). Bindings therefore differ by the generator version that compiled them: modern Mono.Android uses `sbyte`/`ushort`, while older prebuilt bindings (e.g. Xamarin.AndroidX.Fragment) still declare `bool`/`char`. A single hardcoded choice is wrong for half the ecosystem: emitting a MemberRef whose boolean/char type doesn't match the real `n_*` method cannot bind under ILC, which then reports ILC: Method '..._Proxy.n_*_uco_*' will always throw because: Missing method '... n_* (...)' and replaces the forwarder with a throw -- breaking those interface listener callbacks under NativeAOT (observed as a native SIGSEGV when a boolean SSL validation callback fires mid-handshake in the CoreCLRTrimmable device tests). This supersedes the earlier `sbyte -> bool` flip, which only moved the breakage from AndroidX to Mono.Android. Fix: match each callback MemberRef to its real target. The scanner resolves the actual `n_*` static method (on the connector-declared invoker type, or the method's own declaring type) and captures its parameter/return CLR type names; the emitter mirrors that signature exactly. Unambiguous kinds still encode from the JNI descriptor; the `[UnmanagedCallersOnly]` entry keeps its blittable `byte`/`ushort` ABI and the forwarder body is unchanged (bool, sbyte, byte, char, ushort are all int32 on the eval stack). When a boolean/char callback's `n_*` signature cannot be resolved from metadata the generator now fails with a clear error rather than guessing. Tests: TestFixtures gains real `n_*` methods -- TouchHandler models a pre-#1296 binding (`bool`) and IOnLongClickListenerInvoker a post-#1296 one (`sbyte`). New tests assert the callback MemberRef mirrors each real method (bool vs sbyte), that both encodings can coexist in one generated assembly, and that an unresolved boolean callback throws. EncodeClrTypeForCallback now rejects boolean/char (they must come from the captured signature). Fixes #11773. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/JniSignatureHelper.cs | 69 +++++++++- .../Generator/Model/TypeMapAssemblyData.cs | 14 ++ .../Generator/ModelBuilder.cs | 2 + .../Generator/TypeMapAssemblyEmitter.cs | 44 +++++- .../Scanner/JavaPeerInfo.cs | 17 +++ .../Scanner/JavaPeerScanner.cs | 129 +++++++++++++++++- .../TypeMapAssemblyGeneratorTests.cs | 118 +++++++++++++--- .../TestFixtures/TestTypes.cs | 17 +++ 8 files changed, 377 insertions(+), 33 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs index ed66d5de8e2..4cc3b207899 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs @@ -129,17 +129,22 @@ public static void EncodeClrType (SignatureTypeEncoder encoder, JniParamKind kin } /// - /// Encodes a JNI type as its CLR equivalent matching the MCW-generated n_* callback - /// signatures. JNI boolean (Z) maps to bool (the MCW n_* callbacks declare the - /// parameter as , unlike the blittable byte used for the UCO ABI). - /// Use this when constructing member references to n_* methods. + /// Encodes a JNI type as its CLR equivalent for a n_* callback MemberRef. This is used only + /// for kinds that are unambiguous across generator versions; the ambiguous kinds — JNI + /// boolean and char (see ) — are instead emitted from the real + /// n_* method's captured signature via , because a binding may + /// declare them as either their managed (bool/char) or blittable (sbyte/ushort) + /// form depending on which generator compiled it (java-interop #1296). /// public static void EncodeClrTypeForCallback (SignatureTypeEncoder encoder, JniParamKind kind) { switch (kind) { - case JniParamKind.Boolean: encoder.Boolean (); break; // MCW n_* callbacks declare JNI boolean as System.Boolean + // Boolean and Char are ambiguous across generator versions (bool/sbyte, char/ushort) and must + // be emitted from the real n_* method's captured signature via EncodeClrTypeName — never guessed. + case JniParamKind.Boolean: + case JniParamKind.Char: + throw new ArgumentException ($"JNI {kind} maps to an ambiguous CLR callback type; emit it from the resolved n_* signature instead of the JNI descriptor."); case JniParamKind.Byte: encoder.SByte (); break; - case JniParamKind.Char: encoder.Char (); break; case JniParamKind.Short: encoder.Int16 (); break; case JniParamKind.Int: encoder.Int32 (); break; case JniParamKind.Long: encoder.Int64 (); break; @@ -150,6 +155,58 @@ public static void EncodeClrTypeForCallback (SignatureTypeEncoder encoder, JniPa } } + /// + /// True when a JNI kind maps to a CLR type that the MCW n_* callback may declare as either + /// its managed form or a blittable form depending on the generator version that compiled the + /// binding: JNI boolean is bool (pre-#1296) or sbyte (post-#1296), and JNI char is + /// char or ushort. For these, the callback MemberRef must be built from the real + /// n_* method's captured signature rather than guessed from the JNI descriptor. + /// + public static bool IsAmbiguousCallbackKind (JniParamKind kind) + => kind == JniParamKind.Boolean || kind == JniParamKind.Char; + + /// + /// True when a JNI method signature contains a boolean or char parameter/return — the kinds whose + /// n_* callback CLR type is ambiguous across generator versions and must therefore be + /// captured from the real method's metadata (see ). + /// + public static bool HasAmbiguousCallbackType (string jniSignature) + { + if (IsAmbiguousCallbackKind (ParseReturnType (jniSignature))) { + return true; + } + foreach (var kind in ParseParameterTypes (jniSignature)) { + if (IsAmbiguousCallbackKind (kind)) { + return true; + } + } + return false; + } + + /// + /// Encodes a captured CLR type-name string (e.g. "System.Boolean", "System.SByte", "System.IntPtr") + /// onto a signature, used to emit a callback MemberRef that mirrors the real n_* method. + /// + public static void EncodeClrTypeName (SignatureTypeEncoder encoder, string clrTypeName) + { + switch (clrTypeName) { + case "System.Boolean": encoder.Boolean (); break; + case "System.SByte": encoder.SByte (); break; + case "System.Byte": encoder.Byte (); break; + case "System.Char": encoder.Char (); break; + case "System.UInt16": encoder.UInt16 (); break; + case "System.Int16": encoder.Int16 (); break; + case "System.Int32": encoder.Int32 (); break; + case "System.UInt32": encoder.UInt32 (); break; + case "System.Int64": encoder.Int64 (); break; + case "System.UInt64": encoder.UInt64 (); break; + case "System.Single": encoder.Single (); break; + case "System.Double": encoder.Double (); break; + case "System.IntPtr": encoder.IntPtr (); break; + default: throw new ArgumentException ($"Cannot encode CLR type '{clrTypeName}' in a native callback MemberRef"); + } + } + /// /// Validates that a JNI type name has the expected structure (e.g., "com/example/MyClass"). /// diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/Model/TypeMapAssemblyData.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/Model/TypeMapAssemblyData.cs index 044a07f01b3..a50274c793d 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/Model/TypeMapAssemblyData.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/Model/TypeMapAssemblyData.cs @@ -327,6 +327,20 @@ sealed record UcoMethodData /// public required string JniSignature { get; init; } + /// + /// CLR type-name strings for the real n_* callback's JNI parameters (excluding the leading + /// jnienv/self IntPtr pair), captured from metadata. When present, the callback MemberRef is + /// emitted to mirror these exactly (correctly distinguishing bool/sbyte and char/ushort). Null + /// when the n_* signature was not resolved. + /// + public IReadOnlyList? CallbackParameterTypeNames { get; init; } + + /// + /// CLR return type-name string for the real n_* callback, captured from metadata. + /// Null when the n_* signature was not resolved. + /// + public string? CallbackReturnTypeName { get; init; } + /// /// Optional metadata for wrappers that dispatch directly to the managed target /// instead of forwarding to a generated n_* callback. diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index 2c457e43149..7c3c76791d4 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -364,6 +364,8 @@ static void BuildUcoMethods (JavaPeerInfo peer, JavaPeerProxyData proxy) AssemblyName = !mm.DeclaringAssemblyName.IsNullOrEmpty () ? mm.DeclaringAssemblyName : peer.AssemblyName, }, JniSignature = mm.JniSignature, + CallbackParameterTypeNames = mm.NativeCallbackParameterTypeNames, + CallbackReturnTypeName = mm.NativeCallbackReturnTypeName, ExportMethodDispatch = (mm.IsExport || mm.CallManagedMethodDirectly) ? new ExportMethodDispatchData { ManagedMethodName = mm.ManagedMethodName, ParameterTypes = mm.ManagedParameterTypes, diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs index b551349354d..8b0ebfa5825 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs @@ -1205,14 +1205,43 @@ MethodDefinitionHandle EmitUcoMethod (UcoMethodData uco, JavaPeerProxyData proxy JniSignatureHelper.EncodeClrType (p.AddParameter ().Type (), jniParams [j]); }); - // Callback member reference: uses MCW n_* types (sbyte for boolean) + // Callback member reference: must mirror the real MCW n_* method's signature. JNI boolean and + // char are ambiguous (bool/sbyte, char/ushort) across generator versions, so for those we use + // the signature captured from the actual n_* method; the unambiguous kinds fall back to the JNI + // descriptor. If an ambiguous kind can't be resolved from metadata we fail rather than guess. + bool hasCapturedCallbackSignature = uco.CallbackParameterTypeNames is { } capturedParams && + uco.CallbackReturnTypeName is not null && capturedParams.Count == jniParams.Count; + if (!hasCapturedCallbackSignature) { + if (JniSignatureHelper.IsAmbiguousCallbackKind (returnKind)) { + throw NativeCallbackSignatureUnresolved (uco); + } + foreach (var kind in jniParams) { + if (JniSignatureHelper.IsAmbiguousCallbackKind (kind)) { + throw NativeCallbackSignatureUnresolved (uco); + } + } + } + Action encodeCallbackSig = sig => sig.MethodSignature ().Parameters (paramCount, - rt => { if (isVoid) rt.Void (); else JniSignatureHelper.EncodeClrTypeForCallback (rt.Type (), returnKind); }, + rt => { + if (isVoid) { + rt.Void (); + } else if (hasCapturedCallbackSignature) { + JniSignatureHelper.EncodeClrTypeName (rt.Type (), uco.CallbackReturnTypeName!); + } else { + JniSignatureHelper.EncodeClrTypeForCallback (rt.Type (), returnKind); + } + }, p => { p.AddParameter ().Type ().IntPtr (); p.AddParameter ().Type ().IntPtr (); - for (int j = 0; j < jniParams.Count; j++) - JniSignatureHelper.EncodeClrTypeForCallback (p.AddParameter ().Type (), jniParams [j]); + for (int j = 0; j < jniParams.Count; j++) { + if (hasCapturedCallbackSignature) { + JniSignatureHelper.EncodeClrTypeName (p.AddParameter ().Type (), uco.CallbackParameterTypeNames! [j]); + } else { + JniSignatureHelper.EncodeClrTypeForCallback (p.AddParameter ().Type (), jniParams [j]); + } + } }); var callbackTypeHandle = _pe.ResolveTypeRef (uco.CallbackType); @@ -1232,6 +1261,13 @@ MethodDefinitionHandle EmitUcoMethod (UcoMethodData uco, JavaPeerProxyData proxy return handle; } + static InvalidOperationException NativeCallbackSignatureUnresolved (UcoMethodData uco) + => new InvalidOperationException ( + $"Trimmable typemap cannot emit the native callback reference '{uco.CallbackType.ManagedTypeName}.{uco.CallbackMethodName}' " + + $"(JNI signature '{uco.JniSignature}') because its JNI boolean/char parameter or return type is ambiguous " + + $"(bool vs sbyte, char vs ushort) and the real 'n_*' method's signature could not be resolved from metadata. " + + $"The referenced binding assembly must be available so the exact callback signature can be matched."); + void EmitUcoForwarderBody (TrackedInstructionEncoder encoder, ControlFlowBuilder cfb, JniParamKind returnKind, Action emitCallback) { bool isVoid = returnKind == JniParamKind.Void; diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs index 0307ac79638..6b6f4601c41 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs @@ -212,6 +212,23 @@ public sealed record MarshalMethodInfo /// public required string NativeCallbackName { get; init; } + /// + /// CLR type-name strings (e.g. "System.Boolean", "System.SByte", "System.IntPtr") for the + /// real native n_* callback's JNI parameters, in order, excluding the leading + /// jnienv/native__this IntPtr pair. Captured from the actual n_* method's + /// metadata signature so the emitted callback MemberRef matches it exactly — importantly, JNI + /// boolean is bool or sbyte and JNI char is char or ushort depending + /// on which generator version compiled the binding (see java-interop #1296). Null when the + /// n_* method's signature could not be resolved. + /// + internal IReadOnlyList? NativeCallbackParameterTypeNames { get; init; } + + /// + /// CLR return type-name string for the real native n_* callback, captured from metadata. + /// Null when the n_* method's signature could not be resolved. + /// + internal string? NativeCallbackReturnTypeName { get; init; } + /// /// True if this is a constructor registration. /// diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs index e184795495a..3121ccd1134 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs @@ -55,6 +55,98 @@ bool TryResolveType (string typeName, string assemblyName, out TypeDefinitionHan return false; } + /// + /// Resolves the type that declares the native n_* callback. When the [Register] connector + /// names a declaring type (e.g. an *Invoker in another assembly) that type is used; + /// otherwise the callback lives on the scanned method's own declaring type. + /// + bool TryResolveNativeCallbackType (MethodDefinition methodDef, AssemblyIndex index, + string declaringTypeName, string declaringAssemblyName, + [NotNullWhen (true)] out AssemblyIndex? callbackIndex, out TypeDefinitionHandle callbackTypeHandle) + { + if (!declaringTypeName.IsNullOrEmpty ()) { + if (!declaringAssemblyName.IsNullOrEmpty () && + TryResolveType (declaringTypeName, declaringAssemblyName, out callbackTypeHandle, out callbackIndex)) { + return true; + } + // Type-only connector (no assembly), or the named assembly wasn't indexed: + // search every indexed assembly for the type by full name. + foreach (var candidate in assemblyCache.Values) { + if (candidate.TypesByFullName.TryGetValue (declaringTypeName, out callbackTypeHandle)) { + callbackIndex = candidate; + return true; + } + } + callbackIndex = null; + callbackTypeHandle = default; + return false; + } + + callbackIndex = index; + callbackTypeHandle = methodDef.GetDeclaringType (); + return true; + } + + /// + /// Reads the real native n_* callback method's metadata signature so the emitter can + /// mirror it exactly. The n_* signature is + /// (IntPtr jnienv, IntPtr native__this, <native params...>); the leading IntPtr pair + /// is dropped and the remaining parameter type names (plus the return type name) are returned. + /// Distinguishing e.g. System.Boolean from System.SByte here is what lets the + /// callback MemberRef bind against bindings compiled by either the pre- or post-#1296 generator. + /// + static bool TryReadNativeCallbackSignature (AssemblyIndex callbackIndex, TypeDefinitionHandle callbackTypeHandle, + string nativeCallbackName, int jniParameterCount, + [NotNullWhen (true)] out IReadOnlyList? parameterTypeNames, [NotNullWhen (true)] out string? returnTypeName) + { + parameterTypeNames = null; + returnTypeName = null; + + var reader = callbackIndex.Reader; + var typeDef = reader.GetTypeDefinition (callbackTypeHandle); + foreach (var methodHandle in typeDef.GetMethods ()) { + var methodDef = reader.GetMethodDefinition (methodHandle); + if ((methodDef.Attributes & MethodAttributes.Static) == 0) { + continue; + } + if (reader.GetString (methodDef.Name) != nativeCallbackName) { + continue; + } + + var signature = methodDef.DecodeSignature (SignatureTypeProvider.Instance, genericContext: null); + // n_* callbacks take (IntPtr jnienv, IntPtr native__this) before the native parameters. + if (signature.ParameterTypes.Length != jniParameterCount + 2) { + continue; + } + + var names = new string [jniParameterCount]; + for (int i = 0; i < jniParameterCount; i++) { + names [i] = signature.ParameterTypes [i + 2]; + } + parameterTypeNames = names; + returnTypeName = signature.ReturnType; + return true; + } + + return false; + } + + /// + /// Captures the real n_* callback signature for a callback declared on + /// (used by the base-hierarchy [Register] paths, where the callback always lives on a named base type). + /// + (IReadOnlyList? ParameterTypeNames, string? ReturnTypeName) CaptureNativeCallbackSignature ( + TypeRefData declaringType, string nativeCallbackName, string jniSignature) + { + if (TryResolveType (declaringType.ManagedTypeName, declaringType.AssemblyName, out var handle, out var index)) { + int jniParameterCount = JniSignatureHelper.ParseParameterTypes (jniSignature).Count; + if (TryReadNativeCallbackSignature (index, handle, nativeCallbackName, jniParameterCount, out var parameterTypeNames, out var returnTypeName)) { + return (parameterTypeNames, returnTypeName); + } + } + return (null, null); + } + /// /// Looks up the [Register] JNI name for a type identified by name + assembly. /// @@ -1285,12 +1377,18 @@ static TypeRefData SubstituteGenericArguments (TypeRefData type, TypeRefData con var registerInfo = result.Value.Info; bool isConstructor = registerInfo.JniName == "" || registerInfo.JniName == ".ctor"; + string nativeCallbackName = GetNativeCallbackName (registerInfo.Connector, methodName, isConstructor); + var (callbackParameterTypeNames, callbackReturnTypeName) = isConstructor + ? (null, null) + : CaptureNativeCallbackSignature (result.Value.DeclaringType, nativeCallbackName, registerInfo.Signature); return new MarshalMethodInfo { JniName = registerInfo.JniName, JniSignature = registerInfo.Signature, Connector = registerInfo.Connector, ManagedMethodName = methodName, - NativeCallbackName = GetNativeCallbackName (registerInfo.Connector, methodName, isConstructor), + NativeCallbackName = nativeCallbackName, + NativeCallbackParameterTypeNames = callbackParameterTypeNames, + NativeCallbackReturnTypeName = callbackReturnTypeName, IsConstructor = isConstructor, DeclaringTypeName = result.Value.DeclaringType.ManagedTypeName, DeclaringAssemblyName = result.Value.DeclaringType.AssemblyName, @@ -1331,12 +1429,17 @@ static TypeRefData SubstituteGenericArguments (TypeRefData type, TypeRefData con // Check if the base property has [Register] var propRegister = TryGetPropertyRegisterInfo (basePropDef, baseIndex); if (propRegister is not null && propRegister.Signature is not null) { + string nativeCallbackName = GetNativeCallbackName (propRegister.Connector, getterName, false); + var (callbackParameterTypeNames, callbackReturnTypeName) = + CaptureNativeCallbackSignature (baseTypeRef, nativeCallbackName, propRegister.Signature); return new MarshalMethodInfo { JniName = propRegister.JniName, JniSignature = propRegister.Signature, Connector = propRegister.Connector, ManagedMethodName = getterName, - NativeCallbackName = GetNativeCallbackName (propRegister.Connector, getterName, false), + NativeCallbackName = nativeCallbackName, + NativeCallbackParameterTypeNames = callbackParameterTypeNames, + NativeCallbackReturnTypeName = callbackReturnTypeName, IsConstructor = false, DeclaringTypeName = baseTypeRef.ManagedTypeName, DeclaringAssemblyName = baseTypeRef.AssemblyName, @@ -1406,6 +1509,24 @@ void AddMarshalMethod (List methods, RegisterInfo registerInf } } + string nativeCallbackName = GetNativeCallbackName (registerInfo.Connector, managedName, isConstructor); + + // For methods that forward to a generated static n_* callback, capture the real n_* signature + // so the emitted MemberRef mirrors it exactly. This is essential for JNI boolean/char, which + // older bindings declare as bool/char and post-#1296 bindings declare as sbyte/ushort. We + // capture whenever the signature has an ambiguous (boolean/char) type, regardless of whether + // this method currently dispatches directly — a caller may re-target it to n_* forwarding. + IReadOnlyList? nativeCallbackParameterTypeNames = null; + string? nativeCallbackReturnTypeName = null; + if (!isConstructor && !isExport && JniSignatureHelper.HasAmbiguousCallbackType (jniSignature) && + TryResolveNativeCallbackType (methodDef, index, declaringTypeName, declaringAssemblyName, out var callbackIndex, out var callbackTypeHandle)) { + int jniParameterCount = JniSignatureHelper.ParseParameterTypes (jniSignature).Count; + if (TryReadNativeCallbackSignature (callbackIndex, callbackTypeHandle, nativeCallbackName, jniParameterCount, out var capturedParams, out var capturedReturn)) { + nativeCallbackParameterTypeNames = capturedParams; + nativeCallbackReturnTypeName = capturedReturn; + } + } + methods.Add (new MarshalMethodInfo { JniName = registerInfo.JniName, JniSignature = jniSignature, @@ -1413,7 +1534,9 @@ void AddMarshalMethod (List methods, RegisterInfo registerInf ManagedMethodName = managedName, DeclaringTypeName = declaringTypeName, DeclaringAssemblyName = declaringAssemblyName, - NativeCallbackName = GetNativeCallbackName (registerInfo.Connector, managedName, isConstructor), + NativeCallbackName = nativeCallbackName, + NativeCallbackParameterTypeNames = nativeCallbackParameterTypeNames, + NativeCallbackReturnTypeName = nativeCallbackReturnTypeName, ManagedParameterTypes = managedParameterTypes, ManagedParameterExportKinds = parameterKinds, ManagedReturnType = callManagedMethodDirectly ? EnrichTypeRefWithEnumInfo (managedTypeSig.ReturnType) : new TypeRefData { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index fd117fd33f3..1b1ee69e9a8 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -951,9 +951,7 @@ public void EncodeClrType_ProducesCorrectPrimitiveTypeCode (int kindValue, byte } [Theory] - [InlineData (1, 0x02)] // Boolean → bool (System.Boolean) — matches MCW n_* callbacks [InlineData (2, 0x04)] // Byte → sbyte - [InlineData (3, 0x03)] // Char → char [InlineData (4, 0x06)] // Short → int16 [InlineData (5, 0x08)] // Int → int32 [InlineData (6, 0x0A)] // Long → int64 @@ -968,20 +966,32 @@ public void EncodeClrTypeForCallback_ProducesCorrectPrimitiveTypeCode (int kindV Assert.Equal (expectedCode, blob.ToArray () [0]); } - [Fact] - public void EncodeClrType_Boolean_DiffersFromCallback () + [Theory] + [InlineData (1)] // Boolean → bool or sbyte + [InlineData (3)] // Char → char or ushort + public void EncodeClrTypeForCallback_AmbiguousKinds_Throw (int kindValue) { - var ucoBlob = new BlobBuilder (); - JniSignatureHelper.EncodeClrType (new SignatureTypeEncoder (ucoBlob), JniParamKind.Boolean); + // Boolean and Char cannot be encoded from the JNI descriptor because a binding may declare + // them as either their managed form (bool/char, pre-#1296) or blittable form (sbyte/ushort, + // post-#1296). They must be emitted from the real n_* method's captured signature. + var kind = (JniParamKind) kindValue; + var blob = new BlobBuilder (); + Assert.ThrowsAny (() => + JniSignatureHelper.EncodeClrTypeForCallback (new SignatureTypeEncoder (blob), kind)); + } - var cbBlob = new BlobBuilder (); - JniSignatureHelper.EncodeClrTypeForCallback (new SignatureTypeEncoder (cbBlob), JniParamKind.Boolean); + [Fact] + public void EncodeClrTypeName_EncodesBooleanAndSByteDistinctly () + { + // The two boolean encodings a real n_* callback can use must map to distinct metadata codes, + // so a captured signature is emitted faithfully (0x02 = ELEMENT_TYPE_BOOLEAN, 0x04 = I1/sbyte). + var boolBlob = new BlobBuilder (); + JniSignatureHelper.EncodeClrTypeName (new SignatureTypeEncoder (boolBlob), "System.Boolean"); + Assert.Equal (0x02, boolBlob.ToArray () [0]); - var ucoBytes = ucoBlob.ToArray (); - var cbBytes = cbBlob.ToArray (); - Assert.NotEqual (ucoBytes, cbBytes); - Assert.Equal (0x05, ucoBytes [0]); // byte (unsigned, blittable JNI ABI) - Assert.Equal (0x02, cbBytes [0]); // bool (System.Boolean, matches MCW n_* callback) + var sbyteBlob = new BlobBuilder (); + JniSignatureHelper.EncodeClrTypeName (new SignatureTypeEncoder (sbyteBlob), "System.SByte"); + Assert.Equal (0x04, sbyteBlob.ToArray () [0]); } [Fact] @@ -1023,11 +1033,10 @@ public void EncodeClrType_NonBooleanTypes_IdenticalToCallback (int kindValue) [Fact] public void Generate_UcoMethod_BooleanReturn_WrapperUsesByte_CallbackUsesBoolean () { - // Regression test: the UCO wrapper must use byte (unsigned, blittable JNI ABI) for - // boolean, but the callback MemberRef must use System.Boolean to match the MCW-generated - // n_* callback signature. Encoding the callback boolean as sbyte produced a MemberRef - // that could not be resolved to the real n_* method (SByte != Boolean), causing ILC to - // report "will always throw because: Missing method" for every boolean-bearing callback. + // The callback MemberRef must mirror the real n_* method's signature. The TouchHandler fixture + // models a *pre-#1296* binding whose n_OnTouch declares JNI boolean as System.Boolean, so the + // emitted ref must be Boolean — while the UCO entry keeps the blittable byte for the JNI ABI. + // (The modern sbyte counterpart is covered by Generate_UcoMethod_BooleanReturn_ModernBinding_*.) var peer = MakeTouchHandlerCallbackDispatchPeer (); using var stream = GenerateAssembly (new [] { peer }, "BoolReturnTest"); using var pe = new PEReader (stream); @@ -1050,8 +1059,9 @@ public void Generate_UcoMethod_BooleanReturn_WrapperUsesByte_CallbackUsesBoolean [Fact] public void Generate_UcoMethod_BooleanParam_WrapperUsesByte_CallbackUsesBoolean () { - // Regression test: boolean parameters must also use the correct encoding — byte for the - // blittable UCO ABI, System.Boolean for the n_* callback MemberRef. + // Boolean parameters follow the same rule: byte for the blittable UCO ABI, and — because the + // TouchHandler fixture models a pre-#1296 binding — System.Boolean for the n_* callback ref + // (matched from the real n_OnFocusChange method, not hardcoded). var peer = MakeTouchHandlerCallbackDispatchPeer (); using var stream = GenerateAssembly (new [] { peer }, "BoolParamTest"); using var pe = new PEReader (stream); @@ -1072,6 +1082,74 @@ public void Generate_UcoMethod_BooleanParam_WrapperUsesByte_CallbackUsesBoolean Assert.Equal ("System.Boolean", callbackSig.ParameterTypes.Last ()); } + [Fact] + public void Generate_UcoMethod_BooleanReturn_ModernBinding_CallbackUsesSByte () + { + // Counterpart to the pre-#1296 TouchHandler tests: the IOnLongClickListenerInvoker fixture + // models a *post-#1296* binding whose n_OnLongClick declares JNI boolean as the blittable + // sbyte. ImplicitMultiListener inherits that interface callback (no own [Register]) and thus + // forwards to the invoker's n_*, so the emitted callback MemberRef must be SByte — proving the + // boolean encoding is taken from each real n_* method rather than hardcoded to one form. + var peers = ScanFixtures (); + var peer = peers.First (p => p.JavaName == "my/app/ImplicitMultiListener"); + using var stream = GenerateAssembly (new [] { peer }, "ModernBoolReturnTest"); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + var ucoMethod = reader.MethodDefinitions + .Select (h => reader.GetMethodDefinition (h)) + .First (m => reader.GetString (m.Name).Contains ("onLongClick") && + reader.GetString (m.Name).Contains ("_uco_")); + var ucoSig = ucoMethod.DecodeSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.Byte", ucoSig.ReturnType); + + var callbackRefHandle = FindCallbackMemberRefHandle (reader, "n_OnLongClick_Landroid_view_View_", "Android.Views", "IOnLongClickListenerInvoker"); + var callbackSig = reader.GetMemberReference (callbackRefHandle).DecodeMethodSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.SByte", callbackSig.ReturnType); + } + + [Fact] + public void Generate_MixedBindings_EachCallbackMirrorsItsOwnNativeSignature () + { + // A single generated assembly may reference n_* callbacks from bindings compiled by different + // generator versions. Here the pre-#1296 TouchHandler (bool) and the post-#1296 + // IOnLongClickListenerInvoker (sbyte) coexist; each callback MemberRef must independently match + // its own real method — bool for one, sbyte for the other. + var touchPeer = MakeTouchHandlerCallbackDispatchPeer (); + var longClickPeer = ScanFixtures ().First (p => p.JavaName == "my/app/ImplicitMultiListener"); + using var stream = GenerateAssembly (new [] { touchPeer, longClickPeer }, "MixedBindingsTest"); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + var touchSig = FindCallbackMemberRef (reader, "n_OnTouch").DecodeMethodSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.Boolean", touchSig.ReturnType); + + var longClickRef = FindCallbackMemberRefHandle (reader, "n_OnLongClick_Landroid_view_View_", "Android.Views", "IOnLongClickListenerInvoker"); + var longClickSig = reader.GetMemberReference (longClickRef).DecodeMethodSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.SByte", longClickSig.ReturnType); + } + + [Fact] + public void Generate_UnresolvedBooleanCallback_ThrowsRatherThanGuessing () + { + // When a boolean/char callback's real n_* signature cannot be resolved from metadata, the + // generator must fail loudly instead of guessing bool-vs-sbyte (which would emit a MemberRef + // that silently fails to bind under ILC). Here we clear the captured signature to simulate an + // unavailable binding assembly. + var peer = ScanFixtures ().First (p => p.JavaName == "my/app/ImplicitMultiListener"); + var brokenPeer = peer with { + MarshalMethods = peer.MarshalMethods.Select (m => + m.JniSignature == "(Landroid/view/View;)Z" + ? m with { NativeCallbackParameterTypeNames = null, NativeCallbackReturnTypeName = null } + : m).ToList (), + }; + + var ex = Assert.Throws (() => { + using var stream = GenerateAssembly (new [] { brokenPeer }, "UnresolvedBoolTest"); + }); + Assert.Contains ("n_*", ex.Message); + } + [Fact] public void Generate_ExportUcoMethod_HasCatchAndFinallyRegions () { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs index 76d2cd61e2f..b95b2ea334c 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs @@ -134,6 +134,18 @@ public interface IOnLongClickListener bool OnLongClick (View v); } + [Register ("android/view/View$OnLongClickListener", DoNotGenerateAcw = true)] + internal sealed class IOnLongClickListenerInvoker : Java.Lang.Object, IOnLongClickListener + { + public IOnLongClickListenerInvoker (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + public bool OnLongClick (View v) => false; + + // Real MCW-style n_* callback. This invoker models the *post-#1296* generator, where JNI + // boolean is declared as the blittable System.SByte. The emitted callback MemberRef must + // mirror this (sbyte), even though the pre-#1296 TouchHandler above uses bool. + static sbyte n_OnLongClick_Landroid_view_View_ (IntPtr jnienv, IntPtr native__this, IntPtr v) => 0; + } + /// /// Interface with a registered property (for testing interface property implementation detection). /// @@ -362,6 +374,11 @@ public virtual void OnScroll (int x, float y, long timestamp, double velocity) { [Register ("setItems", "([Ljava/lang/String;)V", "GetSetItemsHandler")] public virtual void SetItems (string[]? items) { } + + // Real MCW-style n_* callbacks. This binding models the *pre-#1296* generator, where JNI + // boolean is declared as System.Boolean (bool). The emitted callback MemberRef must mirror this. + static bool n_OnTouch (IntPtr jnienv, IntPtr native__this, IntPtr v, int action) => false; + static void n_OnFocusChange (IntPtr jnienv, IntPtr native__this, IntPtr v, bool hasFocus) { } } // --- Covariant return test types --- From bb8b6492384103b135662773035bee30b17aca93 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 2 Jul 2026 13:38:33 +0200 Subject: [PATCH 3/7] [TrimmableTypeMap] Address review: drop null-forgiving, gate base-path capture - Remove the banned null-forgiving `!` operator in the callback-signature emitter; narrow the captured type-name locals with `is not null` instead. - Gate the base-hierarchy [Register] method/property n_* signature capture on HasAmbiguousCallbackType, matching AddMarshalMethod, so inherited non- boolean/char registrations don't pay for type resolution + method enumeration whose result is never used. No behavior change; all 600 TrimmableTypeMap generator tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/TypeMapAssemblyEmitter.cs | 14 ++++++++------ .../Scanner/JavaPeerScanner.cs | 11 ++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs index 8b0ebfa5825..e4589cbba81 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs @@ -1209,8 +1209,10 @@ MethodDefinitionHandle EmitUcoMethod (UcoMethodData uco, JavaPeerProxyData proxy // char are ambiguous (bool/sbyte, char/ushort) across generator versions, so for those we use // the signature captured from the actual n_* method; the unambiguous kinds fall back to the JNI // descriptor. If an ambiguous kind can't be resolved from metadata we fail rather than guess. - bool hasCapturedCallbackSignature = uco.CallbackParameterTypeNames is { } capturedParams && - uco.CallbackReturnTypeName is not null && capturedParams.Count == jniParams.Count; + var capturedParameterTypeNames = uco.CallbackParameterTypeNames; + var capturedReturnTypeName = uco.CallbackReturnTypeName; + bool hasCapturedCallbackSignature = capturedParameterTypeNames is not null && + capturedReturnTypeName is not null && capturedParameterTypeNames.Count == jniParams.Count; if (!hasCapturedCallbackSignature) { if (JniSignatureHelper.IsAmbiguousCallbackKind (returnKind)) { throw NativeCallbackSignatureUnresolved (uco); @@ -1226,8 +1228,8 @@ MethodDefinitionHandle EmitUcoMethod (UcoMethodData uco, JavaPeerProxyData proxy rt => { if (isVoid) { rt.Void (); - } else if (hasCapturedCallbackSignature) { - JniSignatureHelper.EncodeClrTypeName (rt.Type (), uco.CallbackReturnTypeName!); + } else if (hasCapturedCallbackSignature && capturedReturnTypeName is not null) { + JniSignatureHelper.EncodeClrTypeName (rt.Type (), capturedReturnTypeName); } else { JniSignatureHelper.EncodeClrTypeForCallback (rt.Type (), returnKind); } @@ -1236,8 +1238,8 @@ MethodDefinitionHandle EmitUcoMethod (UcoMethodData uco, JavaPeerProxyData proxy p.AddParameter ().Type ().IntPtr (); p.AddParameter ().Type ().IntPtr (); for (int j = 0; j < jniParams.Count; j++) { - if (hasCapturedCallbackSignature) { - JniSignatureHelper.EncodeClrTypeName (p.AddParameter ().Type (), uco.CallbackParameterTypeNames! [j]); + if (hasCapturedCallbackSignature && capturedParameterTypeNames is not null) { + JniSignatureHelper.EncodeClrTypeName (p.AddParameter ().Type (), capturedParameterTypeNames [j]); } else { JniSignatureHelper.EncodeClrTypeForCallback (p.AddParameter ().Type (), jniParams [j]); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs index 3121ccd1134..28ba10aafa7 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs @@ -1378,9 +1378,9 @@ static TypeRefData SubstituteGenericArguments (TypeRefData type, TypeRefData con var registerInfo = result.Value.Info; bool isConstructor = registerInfo.JniName == "" || registerInfo.JniName == ".ctor"; string nativeCallbackName = GetNativeCallbackName (registerInfo.Connector, methodName, isConstructor); - var (callbackParameterTypeNames, callbackReturnTypeName) = isConstructor - ? (null, null) - : CaptureNativeCallbackSignature (result.Value.DeclaringType, nativeCallbackName, registerInfo.Signature); + var (callbackParameterTypeNames, callbackReturnTypeName) = !isConstructor && JniSignatureHelper.HasAmbiguousCallbackType (registerInfo.Signature) + ? CaptureNativeCallbackSignature (result.Value.DeclaringType, nativeCallbackName, registerInfo.Signature) + : (null, null); return new MarshalMethodInfo { JniName = registerInfo.JniName, JniSignature = registerInfo.Signature, @@ -1430,8 +1430,9 @@ static TypeRefData SubstituteGenericArguments (TypeRefData type, TypeRefData con var propRegister = TryGetPropertyRegisterInfo (basePropDef, baseIndex); if (propRegister is not null && propRegister.Signature is not null) { string nativeCallbackName = GetNativeCallbackName (propRegister.Connector, getterName, false); - var (callbackParameterTypeNames, callbackReturnTypeName) = - CaptureNativeCallbackSignature (baseTypeRef, nativeCallbackName, propRegister.Signature); + var (callbackParameterTypeNames, callbackReturnTypeName) = JniSignatureHelper.HasAmbiguousCallbackType (propRegister.Signature) + ? CaptureNativeCallbackSignature (baseTypeRef, nativeCallbackName, propRegister.Signature) + : (null, null); return new MarshalMethodInfo { JniName = propRegister.JniName, JniSignature = propRegister.Signature, From 67a1063d2fab1359ef3852253856159a1c4e1696 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 2 Jul 2026 13:54:46 +0200 Subject: [PATCH 4/7] [TrimmableTypeMap] Add char/ushort n_* callback tests JNI char is ambiguous exactly like boolean (java-interop #1296 changed the n_* callback encoding from System.Char to blittable System.UInt16). Add fixtures and tests covering both eras so the char path is locked down the same way boolean is: - TouchHandler (pre-#1296) gains getKeyChar/setKeyChar with n_* declaring System.Char; new tests assert the callback MemberRef is Char (return and param) while the UCO entry keeps ushort. - New IOnKeyListener/IOnKeyListenerInvoker (post-#1296) declare n_OnKey_C with ushort, and KeyListenerImpl forwards to it via inheritance; a test asserts the callback MemberRef is UInt16. All 603 TrimmableTypeMap generator tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TypeMapAssemblyGeneratorTests.cs | 70 +++++++++++++++++++ .../TestFixtures/TestTypes.cs | 42 ++++++++++- 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index 1b1ee69e9a8..1d40e4dc299 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -1150,6 +1150,76 @@ public void Generate_UnresolvedBooleanCallback_ThrowsRatherThanGuessing () Assert.Contains ("n_*", ex.Message); } + [Fact] + public void Generate_UcoMethod_CharReturn_LegacyBinding_CallbackUsesChar () + { + // JNI char is ambiguous exactly like boolean: the pre-#1296 TouchHandler declares its + // n_GetKeyChar return as System.Char, so the callback MemberRef must be Char while the UCO + // entry keeps the blittable ushort JNI ABI. + var peer = MakeTouchHandlerCallbackDispatchPeer (); + using var stream = GenerateAssembly (new [] { peer }, "CharReturnLegacyTest"); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + var ucoMethod = reader.MethodDefinitions + .Select (h => reader.GetMethodDefinition (h)) + .First (m => reader.GetString (m.Name).Contains ("getKeyChar") && + reader.GetString (m.Name).Contains ("_uco_")); + var ucoSig = ucoMethod.DecodeSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.UInt16", ucoSig.ReturnType); + + var callbackSig = FindCallbackMemberRef (reader, "n_GetKeyChar").DecodeMethodSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.Char", callbackSig.ReturnType); + } + + [Fact] + public void Generate_UcoMethod_CharParam_LegacyBinding_CallbackUsesChar () + { + // Char parameters follow the same rule: ushort for the blittable UCO ABI, and — because + // TouchHandler models a pre-#1296 binding — System.Char for the n_SetKeyChar callback ref. + var peer = MakeTouchHandlerCallbackDispatchPeer (); + using var stream = GenerateAssembly (new [] { peer }, "CharParamLegacyTest"); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + var ucoMethod = reader.MethodDefinitions + .Select (h => reader.GetMethodDefinition (h)) + .First (m => reader.GetString (m.Name).Contains ("setKeyChar") && + reader.GetString (m.Name).Contains ("_uco_")); + var ucoSig = ucoMethod.DecodeSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.UInt16", ucoSig.ParameterTypes.Last ()); + + var callbackSig = FindCallbackMemberRef (reader, "n_SetKeyChar").DecodeMethodSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.Char", callbackSig.ParameterTypes.Last ()); + } + + [Fact] + public void Generate_UcoMethod_Char_ModernBinding_CallbackUsesUInt16 () + { + // Counterpart to the char TouchHandler tests: the IOnKeyListenerInvoker fixture models a + // *post-#1296* binding whose n_OnKey_C declares JNI char as the blittable ushort. + // KeyListenerImpl inherits that interface callback (no own [Register]) and forwards to the + // invoker's n_*, so both the char return and char parameter of the emitted MemberRef must be + // UInt16 — proving char, like boolean, is taken from each real n_* method rather than hardcoded. + var peer = ScanFixtures ().First (p => p.JavaName == "my/app/KeyListenerImpl"); + using var stream = GenerateAssembly (new [] { peer }, "CharModernTest"); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + var ucoMethod = reader.MethodDefinitions + .Select (h => reader.GetMethodDefinition (h)) + .First (m => reader.GetString (m.Name).Contains ("onKey") && + reader.GetString (m.Name).Contains ("_uco_")); + var ucoSig = ucoMethod.DecodeSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.UInt16", ucoSig.ReturnType); + Assert.Equal ("System.UInt16", ucoSig.ParameterTypes.Last ()); + + var callbackRefHandle = FindCallbackMemberRefHandle (reader, "n_OnKey_C", "Android.Views", "IOnKeyListenerInvoker"); + var callbackSig = reader.GetMemberReference (callbackRefHandle).DecodeMethodSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.UInt16", callbackSig.ReturnType); + Assert.Equal ("System.UInt16", callbackSig.ParameterTypes.Last ()); + } + [Fact] public void Generate_ExportUcoMethod_HasCatchAndFinallyRegions () { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs index b95b2ea334c..ceedbc96fe6 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs @@ -146,6 +146,25 @@ public IOnLongClickListenerInvoker (IntPtr handle, JniHandleOwnership transfer) static sbyte n_OnLongClick_Landroid_view_View_ (IntPtr jnienv, IntPtr native__this, IntPtr v) => 0; } + [Register ("android/view/View$OnKeyListener", "", "Android.Views.IOnKeyListenerInvoker")] + public interface IOnKeyListener + { + [Register ("onKey", "(C)C", "GetOnKey_CHandler:Android.Views.IOnKeyListenerInvoker")] + char OnKey (char c); + } + + [Register ("android/view/View$OnKeyListener", DoNotGenerateAcw = true)] + internal sealed class IOnKeyListenerInvoker : Java.Lang.Object, IOnKeyListener + { + public IOnKeyListenerInvoker (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + public char OnKey (char c) => c; + + // Real MCW-style n_* callback. This invoker models the *post-#1296* generator, where JNI char + // is declared as the blittable System.UInt16 (ushort). The emitted callback MemberRef must + // mirror this (ushort), unlike the pre-#1296 TouchHandler above which uses char. + static ushort n_OnKey_C (IntPtr jnienv, IntPtr native__this, ushort c) => c; + } + /// /// Interface with a registered property (for testing interface property implementation detection). /// @@ -375,10 +394,19 @@ public virtual void OnScroll (int x, float y, long timestamp, double velocity) { [Register ("setItems", "([Ljava/lang/String;)V", "GetSetItemsHandler")] public virtual void SetItems (string[]? items) { } + [Register ("getKeyChar", "()C", "GetGetKeyCharHandler")] + public virtual char GetKeyChar () => '\0'; + + [Register ("setKeyChar", "(C)V", "GetSetKeyCharHandler")] + public virtual void SetKeyChar (char c) { } + // Real MCW-style n_* callbacks. This binding models the *pre-#1296* generator, where JNI - // boolean is declared as System.Boolean (bool). The emitted callback MemberRef must mirror this. + // boolean is declared as System.Boolean (bool) and JNI char as System.Char (char). The + // emitted callback MemberRefs must mirror this. static bool n_OnTouch (IntPtr jnienv, IntPtr native__this, IntPtr v, int action) => false; static void n_OnFocusChange (IntPtr jnienv, IntPtr native__this, IntPtr v, bool hasFocus) { } + static char n_GetKeyChar (IntPtr jnienv, IntPtr native__this) => '\0'; + static void n_SetKeyChar (IntPtr jnienv, IntPtr native__this, char c) { } } // --- Covariant return test types --- @@ -693,6 +721,18 @@ public void OnClick (Android.Views.View v) { } public bool OnLongClick (Android.Views.View v) => false; } + /// + /// Implements a char-bearing interface without [Register], so its onKey marshal method forwards + /// to the post-#1296 invoker's ushort n_* callback (the modern char encoding). + /// + [Register ("my/app/KeyListenerImpl")] + public class KeyListenerImpl : Java.Lang.Object, Android.Views.IOnKeyListener + { + protected KeyListenerImpl (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + + public char OnKey (char c) => c; + } + /// /// Has one interface method with [Register] and one without. /// From e84be8205aace6c3dae6ad3e20a6d212449ac668 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 2 Jul 2026 15:12:00 +0200 Subject: [PATCH 5/7] [TrimmableTypeMap] Fall back to blittable n_* encoding when signature unreadable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes an XAGTT7009 build regression introduced by the metadata-driven callback signature capture: real trimmable-typemap builds failed with e.g. error XAGTT7009: ... cannot emit the native callback reference 'Org.XmlPull.V1.IXmlPullParserInvoker.n_IsEmptyElementTag' (JNI '()Z') because ... the real 'n_*' method's signature could not be resolved Root cause (confirmed from the CI build.binlog): GenerateTrimmableTypeMap scans the compile-time *reference* assemblies (Microsoft.Android.Ref/.../Mono.Android.dll was the only Mono.Android in the log; no runtime/impl assembly is fed). Reference assemblies strip the `private static n_*` methods, so the capture added in this PR returns null for every framework boolean/char callback, and the previous hard `throw` then failed the build. The original code never read the n_* method — it emitted the MemberRef by name from the connector metadata (present in the ref assembly's [Register] attributes) — so this was a self-inflicted regression. Fix: instead of throwing when the n_* signature can't be read, fall back to the blittable encoding (JNI boolean -> sbyte, char -> ushort). This is correct, not a blind guess: an unreadable n_* means a reference assembly, i.e. a framework binding (Mono.Android/Java.Interop), which is always produced by the current post-#1296 generator and therefore uses sbyte/ushort — matching the runtime n_* (verified: runtime Mono.Android n_IsEmptyElementTag is int8/sbyte). NuGet binding implementation assemblies (e.g. AndroidX) still ship the real n_* in lib/, so their signature is captured exactly, preserving the pre-#1296 `bool` fix for #11773. - EncodeClrTypeForCallback: boolean -> sbyte, char -> ushort (blittable fallback), used only when no captured signature is available. - Emitter: use captured signature when present, else the blittable fallback; the hard throw and its helper are removed. - Tests: replace the throws-when-unresolved test with one asserting the sbyte fallback; update the fallback encoding theory (boolean->0x04, char->0x07). Verified locally that a concrete peer scanned from the ref Mono.Android now generates n_IsEmptyElementTag as System.SByte without throwing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/JniSignatureHelper.cs | 22 +++++----- .../Generator/TypeMapAssemblyEmitter.cs | 28 +++--------- .../TypeMapAssemblyGeneratorTests.cs | 44 +++++++++---------- 3 files changed, 38 insertions(+), 56 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs index 4cc3b207899..0624a4b782f 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs @@ -129,22 +129,22 @@ public static void EncodeClrType (SignatureTypeEncoder encoder, JniParamKind kin } /// - /// Encodes a JNI type as its CLR equivalent for a n_* callback MemberRef. This is used only - /// for kinds that are unambiguous across generator versions; the ambiguous kinds — JNI - /// boolean and char (see ) — are instead emitted from the real - /// n_* method's captured signature via , because a binding may - /// declare them as either their managed (bool/char) or blittable (sbyte/ushort) - /// form depending on which generator compiled it (java-interop #1296). + /// Encodes a JNI type as its CLR equivalent for a n_* callback MemberRef, used as the + /// fallback when the real n_* method's signature cannot be read from metadata — which + /// happens for framework callbacks, because the generator scans the compile-time reference + /// assemblies (e.g. Microsoft.Android.Ref) and those strip the private static n_* + /// methods. Framework bindings are always produced by the current (post-#1296) generator, so the + /// blittable encoding is correct for them: JNI boolean → sbyte, JNI char → ushort. + /// When the n_* can be read (NuGet binding implementation assemblies such as AndroidX, + /// which may be pre-#1296 and declare bool/char), the captured signature is emitted via + /// instead of this fallback. /// public static void EncodeClrTypeForCallback (SignatureTypeEncoder encoder, JniParamKind kind) { switch (kind) { - // Boolean and Char are ambiguous across generator versions (bool/sbyte, char/ushort) and must - // be emitted from the real n_* method's captured signature via EncodeClrTypeName — never guessed. - case JniParamKind.Boolean: - case JniParamKind.Char: - throw new ArgumentException ($"JNI {kind} maps to an ambiguous CLR callback type; emit it from the resolved n_* signature instead of the JNI descriptor."); + case JniParamKind.Boolean: encoder.SByte (); break; // blittable modern n_* encoding (post-#1296) case JniParamKind.Byte: encoder.SByte (); break; + case JniParamKind.Char: encoder.UInt16 (); break; // blittable modern n_* encoding (post-#1296) case JniParamKind.Short: encoder.Int16 (); break; case JniParamKind.Int: encoder.Int32 (); break; case JniParamKind.Long: encoder.Int64 (); break; diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs index e4589cbba81..0a8a698a6a4 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs @@ -1205,24 +1205,17 @@ MethodDefinitionHandle EmitUcoMethod (UcoMethodData uco, JavaPeerProxyData proxy JniSignatureHelper.EncodeClrType (p.AddParameter ().Type (), jniParams [j]); }); - // Callback member reference: must mirror the real MCW n_* method's signature. JNI boolean and - // char are ambiguous (bool/sbyte, char/ushort) across generator versions, so for those we use - // the signature captured from the actual n_* method; the unambiguous kinds fall back to the JNI - // descriptor. If an ambiguous kind can't be resolved from metadata we fail rather than guess. + // Callback member reference: mirror the real MCW n_* method's signature. JNI boolean and char + // are ambiguous (bool/sbyte, char/ushort) across generator versions, so when we could read the + // real n_* method's signature (NuGet binding implementation assemblies, e.g. AndroidX) we use it + // exactly. When we couldn't — framework callbacks, because the generator scans the compile-time + // *reference* assemblies (Microsoft.Android.Ref) which strip the private static n_* methods — we + // fall back to the blittable encoding (sbyte/ushort). That is correct for those callbacks because + // framework bindings are always produced by the current (post-#1296) generator. var capturedParameterTypeNames = uco.CallbackParameterTypeNames; var capturedReturnTypeName = uco.CallbackReturnTypeName; bool hasCapturedCallbackSignature = capturedParameterTypeNames is not null && capturedReturnTypeName is not null && capturedParameterTypeNames.Count == jniParams.Count; - if (!hasCapturedCallbackSignature) { - if (JniSignatureHelper.IsAmbiguousCallbackKind (returnKind)) { - throw NativeCallbackSignatureUnresolved (uco); - } - foreach (var kind in jniParams) { - if (JniSignatureHelper.IsAmbiguousCallbackKind (kind)) { - throw NativeCallbackSignatureUnresolved (uco); - } - } - } Action encodeCallbackSig = sig => sig.MethodSignature ().Parameters (paramCount, rt => { @@ -1263,13 +1256,6 @@ MethodDefinitionHandle EmitUcoMethod (UcoMethodData uco, JavaPeerProxyData proxy return handle; } - static InvalidOperationException NativeCallbackSignatureUnresolved (UcoMethodData uco) - => new InvalidOperationException ( - $"Trimmable typemap cannot emit the native callback reference '{uco.CallbackType.ManagedTypeName}.{uco.CallbackMethodName}' " + - $"(JNI signature '{uco.JniSignature}') because its JNI boolean/char parameter or return type is ambiguous " + - $"(bool vs sbyte, char vs ushort) and the real 'n_*' method's signature could not be resolved from metadata. " + - $"The referenced binding assembly must be available so the exact callback signature can be matched."); - void EmitUcoForwarderBody (TrackedInstructionEncoder encoder, ControlFlowBuilder cfb, JniParamKind returnKind, Action emitCallback) { bool isVoid = returnKind == JniParamKind.Void; diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index 1d40e4dc299..ffaa3ee9bdf 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -951,7 +951,9 @@ public void EncodeClrType_ProducesCorrectPrimitiveTypeCode (int kindValue, byte } [Theory] + [InlineData (1, 0x04)] // Boolean → sbyte (blittable modern n_* fallback) [InlineData (2, 0x04)] // Byte → sbyte + [InlineData (3, 0x07)] // Char → uint16 (blittable modern n_* fallback) [InlineData (4, 0x06)] // Short → int16 [InlineData (5, 0x08)] // Int → int32 [InlineData (6, 0x0A)] // Long → int64 @@ -960,26 +962,15 @@ public void EncodeClrType_ProducesCorrectPrimitiveTypeCode (int kindValue, byte [InlineData (9, 0x18)] // Object → IntPtr public void EncodeClrTypeForCallback_ProducesCorrectPrimitiveTypeCode (int kindValue, byte expectedCode) { + // This is the fallback used when the real n_* method's signature can't be read from metadata + // (framework callbacks, whose reference assemblies strip the private static n_*). Framework + // bindings are always post-#1296, so boolean → sbyte and char → ushort here. var kind = (JniParamKind) kindValue; var blob = new BlobBuilder (); JniSignatureHelper.EncodeClrTypeForCallback (new SignatureTypeEncoder (blob), kind); Assert.Equal (expectedCode, blob.ToArray () [0]); } - [Theory] - [InlineData (1)] // Boolean → bool or sbyte - [InlineData (3)] // Char → char or ushort - public void EncodeClrTypeForCallback_AmbiguousKinds_Throw (int kindValue) - { - // Boolean and Char cannot be encoded from the JNI descriptor because a binding may declare - // them as either their managed form (bool/char, pre-#1296) or blittable form (sbyte/ushort, - // post-#1296). They must be emitted from the real n_* method's captured signature. - var kind = (JniParamKind) kindValue; - var blob = new BlobBuilder (); - Assert.ThrowsAny (() => - JniSignatureHelper.EncodeClrTypeForCallback (new SignatureTypeEncoder (blob), kind)); - } - [Fact] public void EncodeClrTypeName_EncodesBooleanAndSByteDistinctly () { @@ -1130,24 +1121,29 @@ public void Generate_MixedBindings_EachCallbackMirrorsItsOwnNativeSignature () } [Fact] - public void Generate_UnresolvedBooleanCallback_ThrowsRatherThanGuessing () + public void Generate_UnresolvedBooleanCallback_FallsBackToBlittable () { - // When a boolean/char callback's real n_* signature cannot be resolved from metadata, the - // generator must fail loudly instead of guessing bool-vs-sbyte (which would emit a MemberRef - // that silently fails to bind under ILC). Here we clear the captured signature to simulate an - // unavailable binding assembly. + // When a boolean/char callback's real n_* signature can't be read from metadata — which is the + // case for framework callbacks, because the generator scans the compile-time *reference* + // assemblies (Microsoft.Android.Ref) that strip the private static n_* methods — the emitter + // must fall back to the blittable sbyte/ushort encoding rather than fail the build (see #11802 + // CI regression: XAGTT7009 on Org.XmlPull.V1.IXmlPullParserInvoker.n_IsEmptyElementTag). That + // fallback is correct because framework bindings are always produced by the post-#1296 generator. var peer = ScanFixtures ().First (p => p.JavaName == "my/app/ImplicitMultiListener"); - var brokenPeer = peer with { + var unresolvedPeer = peer with { MarshalMethods = peer.MarshalMethods.Select (m => m.JniSignature == "(Landroid/view/View;)Z" ? m with { NativeCallbackParameterTypeNames = null, NativeCallbackReturnTypeName = null } : m).ToList (), }; - var ex = Assert.Throws (() => { - using var stream = GenerateAssembly (new [] { brokenPeer }, "UnresolvedBoolTest"); - }); - Assert.Contains ("n_*", ex.Message); + using var stream = GenerateAssembly (new [] { unresolvedPeer }, "UnresolvedBoolTest"); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + var callbackRefHandle = FindCallbackMemberRefHandle (reader, "n_OnLongClick_Landroid_view_View_", "Android.Views", "IOnLongClickListenerInvoker"); + var callbackSig = reader.GetMemberReference (callbackRefHandle).DecodeMethodSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.SByte", callbackSig.ReturnType); } [Fact] From 08ab9f6f43bdb239dbc6b8ef50d49822b0e72927 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 2 Jul 2026 16:33:34 +0200 Subject: [PATCH 6/7] [TrimmableTypeMap] Verify n_* leading IntPtr pair when matching callback Address review: TryReadNativeCallbackSignature matched the n_* method by name + arity, then sliced ParameterTypes[i+2] assuming the first two params are the (IntPtr jnienv, IntPtr native__this) pair. Also assert those two leading params are System.IntPtr before slicing, so a same-named static method with a coincidentally-matching arity can never feed the wrong native param types into the callback MemberRef. Cheap build-time invariant check; all real n_* callbacks begin with the IntPtr pair, so behavior is unchanged (603 tests pass). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Scanner/JavaPeerScanner.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs index 28ba10aafa7..7eff64e8b8f 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs @@ -115,9 +115,14 @@ static bool TryReadNativeCallbackSignature (AssemblyIndex callbackIndex, TypeDef var signature = methodDef.DecodeSignature (SignatureTypeProvider.Instance, genericContext: null); // n_* callbacks take (IntPtr jnienv, IntPtr native__this) before the native parameters. + // Verify both the arity and that leading pair so a same-named static method with a + // coincidentally-matching arity can never feed the wrong native param types into the ref. if (signature.ParameterTypes.Length != jniParameterCount + 2) { continue; } + if (signature.ParameterTypes [0] != "System.IntPtr" || signature.ParameterTypes [1] != "System.IntPtr") { + continue; + } var names = new string [jniParameterCount]; for (int i = 0; i < jniParameterCount; i++) { From 83e8e718aa30ac57fae019d5fbcae0ed2560b425 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Thu, 2 Jul 2026 17:35:43 +0200 Subject: [PATCH 7/7] [TrimmableTypeMap] Self-review: fix stale doc, add char-unresolved test - IsAmbiguousCallbackKind doc said the callback ref "must be built from the real n_* signature rather than guessed"; that predates the blittable fallback. Reword to match shipped behavior: captured when available, else fall back to blittable. - Add Generate_UnresolvedCharCallback_FallsBackToUInt16 so the unresolved->blittable end-to-end path is covered for char (ushort), mirroring the boolean (sbyte) test. All 604 TrimmableTypeMap generator tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/JniSignatureHelper.cs | 6 +++-- .../TypeMapAssemblyGeneratorTests.cs | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs index 0624a4b782f..4d83caa1322 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JniSignatureHelper.cs @@ -159,8 +159,10 @@ public static void EncodeClrTypeForCallback (SignatureTypeEncoder encoder, JniPa /// True when a JNI kind maps to a CLR type that the MCW n_* callback may declare as either /// its managed form or a blittable form depending on the generator version that compiled the /// binding: JNI boolean is bool (pre-#1296) or sbyte (post-#1296), and JNI char is - /// char or ushort. For these, the callback MemberRef must be built from the real - /// n_* method's captured signature rather than guessed from the JNI descriptor. + /// char or ushort. For these, the callback MemberRef is captured from the real + /// n_* method's metadata when available; if it can't be read (framework callbacks live in + /// reference assemblies that strip the private static n_*), it falls back to the blittable + /// encoding via . /// public static bool IsAmbiguousCallbackKind (JniParamKind kind) => kind == JniParamKind.Boolean || kind == JniParamKind.Char; diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index ffaa3ee9bdf..c6a75468349 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -1146,6 +1146,30 @@ public void Generate_UnresolvedBooleanCallback_FallsBackToBlittable () Assert.Equal ("System.SByte", callbackSig.ReturnType); } + [Fact] + public void Generate_UnresolvedCharCallback_FallsBackToUInt16 () + { + // Char shares the same ambiguity as boolean, so the unresolved fallback must apply to it too: + // when the real n_* signature can't be read (framework reference assemblies strip the private + // static n_*), the callback MemberRef falls back to the blittable ushort encoding. + var peer = ScanFixtures ().First (p => p.JavaName == "my/app/KeyListenerImpl"); + var unresolvedPeer = peer with { + MarshalMethods = peer.MarshalMethods.Select (m => + m.JniSignature == "(C)C" + ? m with { NativeCallbackParameterTypeNames = null, NativeCallbackReturnTypeName = null } + : m).ToList (), + }; + + using var stream = GenerateAssembly (new [] { unresolvedPeer }, "UnresolvedCharTest"); + using var pe = new PEReader (stream); + var reader = pe.GetMetadataReader (); + + var callbackRefHandle = FindCallbackMemberRefHandle (reader, "n_OnKey_C", "Android.Views", "IOnKeyListenerInvoker"); + var callbackSig = reader.GetMemberReference (callbackRefHandle).DecodeMethodSignature (SignatureTypeProvider.Instance, null); + Assert.Equal ("System.UInt16", callbackSig.ReturnType); + Assert.Equal ("System.UInt16", callbackSig.ParameterTypes.Last ()); + } + [Fact] public void Generate_UcoMethod_CharReturn_LegacyBinding_CallbackUsesChar () {