From bc831805e4bb2b0376e0f2a1ba997d78bd3ebd32 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 7 Jul 2026 12:38:45 +0200 Subject: [PATCH 01/11] [TrimmableTypeMap] Emit array proxy for the System.String built-in mapping GetTypes ("[Ljava/lang/String;") dropped the System.String array family (System.String[], JavaObjectArray, JavaArray) on NativeAOT. The runtime enumerates array element types via GetTypesForSimpleReference, which injects System.String for java/lang/String through GetBuiltInTypeForSimpleReference. But System.String is neither a scanned Java peer (EmitArrayEntriesForPeer) nor a primitive (EmitPrimitiveArrayEntries), so no array proxy was generated for it. On NativeAOT the array path relies on the pre-generated proxy map (TryGetArrayProxy), so the System.String element type yielded nothing. (CoreCLR fabricates the types dynamically, so it was fine.) Emit a System.String array proxy alongside the primitives in the _Java.Interop.TypeMap assembly, using the reference-array family (Primitive is null). The proxy map key "System.String, System.Runtime" matches TrimmableTypeMap.TryGetManagedTypeKey (typeof (string)). Fixes the System.String portion of Java.InteropTests.JniTypeManagerTests.GetType on the trimmable/NativeAOT path. Boxed-nullable element types (bool?/int?/... via java/lang/Boolean etc.) have the same shape and will be covered/verified by follow-up test coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/ModelBuilder.cs | 25 +++++++++++++++++++ .../Generator/TypeMapModelBuilderTests.cs | 22 +++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index c372463c6b6..4a31101d9cf 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -694,6 +694,31 @@ static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRa AddArrayProxyAssociations (model, proxy, proxyReference); } } + + // System.String maps to java/lang/String but is a built-in reference type injected at runtime + // by TrimmableTypeMapTypeManager.GetBuiltInTypeForSimpleReference, so it is neither a scanned + // Java peer (EmitArrayEntriesForPeer) nor a primitive. Emit its array proxies here (Primitive is + // null, so it gets the reference-array family) so GetTypes ("[Ljava/lang/String;") yields + // System.String[] / JavaObjectArray / JavaArray on NativeAOT, matching CoreCLR. + for (int rank = 1; rank <= maxArrayRank; rank++) { + var proxy = new ArrayProxyData { + TypeName = ManagedTypeNameToArrayProxyTypeName ("System.String", rank), + ElementType = new TypeRefData { + ManagedTypeName = "System.String", + AssemblyName = "System.Runtime", + }, + Rank = rank, + }; + model.ArrayProxyTypes.Add (proxy); + var proxyReference = AssemblyQualify ($"{proxy.Namespace}.{proxy.TypeName}", model.AssemblyName); + model.Entries.Add (new TypeMapAttributeData { + MapKey = GetArrayProxyMapKey (proxy.ElementType), + ProxyTypeReference = proxyReference, + TargetTypeReference = proxyReference, + AnchorRank = rank, + }); + AddArrayProxyAssociations (model, proxy, proxyReference); + } } static string Brackets (int rank) => rank switch { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs index 0cf84e0146d..e41afbabcee 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs @@ -1180,7 +1180,8 @@ public void Build_EmitArrayEntries_PrimitiveEntries_SynthesizedForJavaInteropAss var primitiveEntries = model.Entries .Where (e => e.MapKey.StartsWith ("System.", StringComparison.Ordinal) && e.AnchorRank is not null) .ToList (); - Assert.Equal (36, primitiveEntries.Count); + // 12 primitives × 3 ranks + System.String × 3 ranks. + Assert.Equal (39, primitiveEntries.Count); var sbyteRank1 = primitiveEntries.Single (e => e.MapKey == "System.SByte, System.Runtime" && e.AnchorRank == 1); Assert.Equal ("_TypeMap.ArrayProxies.Primitive_SByte_ArrayProxy1, _Java.Interop.TypeMap", sbyteRank1.ProxyTypeReference); @@ -1216,6 +1217,25 @@ public void Build_EmitArrayEntries_PrimitiveEntries_SynthesizedForJavaInteropAss a.SourceTypeReference == concreteArrayTypeReference && a.AliasProxyTypeReference == rank1.ProxyTypeReference); } + + // System.String is a built-in reference mapping (java/lang/String), emitted with the + // reference-array family (JavaObjectArray, JavaArray, T[]) and no primitive concrete + // arrays or JavaPrimitiveArray. + var stringRank1 = primitiveEntries.Single (e => e.MapKey == "System.String, System.Runtime" && e.AnchorRank == 1); + Assert.Equal ("_TypeMap.ArrayProxies.System_String_ArrayProxy1, _Java.Interop.TypeMap", stringRank1.ProxyTypeReference); + Assert.Equal ("_TypeMap.ArrayProxies.System_String_ArrayProxy1, _Java.Interop.TypeMap", stringRank1.TargetTypeReference); + Assert.Contains (model.Associations, a => + a.SourceTypeReference == "System.String[], System.Runtime" && + a.AliasProxyTypeReference == stringRank1.ProxyTypeReference); + Assert.Contains (model.Associations, a => + a.SourceTypeReference == "Java.Interop.JavaObjectArray`1[[System.String, System.Runtime]], Java.Interop" && + a.AliasProxyTypeReference == stringRank1.ProxyTypeReference); + Assert.Contains (model.Associations, a => + a.SourceTypeReference == "Java.Interop.JavaArray`1[[System.String, System.Runtime]], Java.Interop" && + a.AliasProxyTypeReference == stringRank1.ProxyTypeReference); + Assert.DoesNotContain (model.Associations, a => + a.SourceTypeReference == "Java.Interop.JavaPrimitiveArray`1[[System.String, System.Runtime]], Java.Interop" && + a.AliasProxyTypeReference == stringRank1.ProxyTypeReference); } [Fact] From a52bec6b1f87d9d68cad81a47c2f401b07beb657 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 7 Jul 2026 12:50:00 +0200 Subject: [PATCH 02/11] [Java.Interop] Add GetType array coverage for boxed nullable value types JniTypeManagerTests.GetType covered primitive keyword arrays ([Z..[D) and the System.String reference array ([Ljava/lang/String;), but not the boxed nullable counterparts. Add assertions for [Ljava/lang/Boolean;, [Ljava/lang/Byte;, [Ljava/lang/Character;, [Ljava/lang/Short;, [Ljava/lang/Integer;, [Ljava/lang/Long;, [Ljava/lang/Float;, [Ljava/lang/Double;. Each boxed reference maps to Nullable (JniBuiltinSimpleReferenceToType / GetBuiltInTypeForSimpleReference), and the non-keyword array path yields JavaObjectArray and T?[] (no JavaArray/JavaPrimitiveArray). The expected types match the default ReflectionJniTypeManager behavior. These pass on the default typemap. On the trimmable typemap they exercise the same array-proxy gap just fixed for System.String; if the boxed element types lack pre-generated array proxies on NativeAOT they will fail, driving the follow-up implementation fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/JniTypeManagerTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeManagerTests.cs b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeManagerTests.cs index 198cb9c97fd..a8fcc8d771f 100644 --- a/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeManagerTests.cs +++ b/external/Java.Interop/tests/Java.Interop-Tests/Java.Interop/JniTypeManagerTests.cs @@ -161,6 +161,20 @@ static void AssertGetJniTypeInfoForType (Type type, string jniType, bool isKeywo AssertArrayTypesFromSignature (manager, "[[D", typeof (JavaObjectArray>)); AssertArrayTypesFromSignature (manager, "[[Ljava/lang/String;", typeof (JavaObjectArray>)); + // Nullable counterparts of the primitive value types: the boxed reference signatures + // java/lang/ map to Nullable (see JniBuiltinSimpleReferenceToType / + // GetBuiltInTypeForSimpleReference). Unlike the primitive keyword arrays above, these are + // non-keyword reference arrays, so the element type is the boxed Nullable and the array + // family is T?[] and JavaObjectArray (no JavaArray/JavaPrimitiveArray). + AssertArrayTypesFromSignature (manager, "[Ljava/lang/Boolean;", typeof (JavaObjectArray)); + AssertArrayTypesFromSignature (manager, "[Ljava/lang/Byte;", typeof (JavaObjectArray)); + AssertArrayTypesFromSignature (manager, "[Ljava/lang/Character;", typeof (JavaObjectArray)); + AssertArrayTypesFromSignature (manager, "[Ljava/lang/Short;", typeof (JavaObjectArray)); + AssertArrayTypesFromSignature (manager, "[Ljava/lang/Integer;", typeof (JavaObjectArray)); + AssertArrayTypesFromSignature (manager, "[Ljava/lang/Long;", typeof (JavaObjectArray)); + AssertArrayTypesFromSignature (manager, "[Ljava/lang/Float;", typeof (JavaObjectArray)); + AssertArrayTypesFromSignature (manager, "[Ljava/lang/Double;", typeof (JavaObjectArray)); + // Yes, these look weird... // Assume: class II {} Assert.AreEqual (null, GetType ("II")); From a732fc12d5f5170b2ff1d6ee66162317f4f5134c Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 7 Jul 2026 13:13:45 +0200 Subject: [PATCH 03/11] [TrimmableTypeMap] Emit array proxies for boxed nullable value types GetTypes ("[Ljava/lang/Boolean;") and the other boxed value-type array signatures dropped the Nullable array family (bool?[], JavaObjectArray) on NativeAOT. Like System.String, Nullable is a built-in reference mapping injected at runtime by GetBuiltInTypeForSimpleReference, so it is neither a scanned Java peer nor a primitive and had no pre-generated array proxy. Unlike String, the proxy map key is the tricky part: at runtime TryGetManagedTypeKey (typeof (bool?)) keyed on Type.FullName, whose type argument carries the full versioned assembly-qualified name (System.Nullable`1[[System.Boolean, System.Private.CoreLib, Version=...]]), which the generator can't stably reproduce. Normalize the generic key on both sides: * Runtime (BuildManagedTypeKey): build closed-generic keys from the open definition's FullName plus each type argument's normalized key (simple assembly name, no Version/Culture/PublicKeyToken), and treat Nullable as a System.Runtime type. typeof (bool?) -> "System.Nullable`1[[System.Boolean, System.Runtime]], System.Runtime". * Generator (ModelBuilder): emit reference-array proxies for the eight Nullable types that have a boxed java/lang mapping, keyed with the same normalized string. Normalizing (rather than emitting version-qualified duplicates) keeps a single canonical proxy per logical type. Fixes the boxed-nullable portion of JniTypeManagerTests.GetType on trimmable NativeAOT (added in the preceding test-coverage commit). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/ModelBuilder.cs | 44 +++++++++++++++ .../TrimmableTypeMap.cs | 54 +++++++++++++++---- .../Generator/TypeMapModelBuilderTests.cs | 17 +++++- 3 files changed, 103 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index 4a31101d9cf..874bcda5ac9 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -31,6 +31,21 @@ static class ModelBuilder new ("D", "Double", "System.Double", ["Java.Interop.JavaDoubleArray"]), ]; + // Nullable value types map to the boxed java/lang/ references (java/lang/Boolean etc.) via + // TrimmableTypeMapTypeManager.GetBuiltInTypeForSimpleReference. Like System.String they are neither + // scanned Java peers nor primitives, so their array proxies are emitted explicitly. Only the eight + // types with a boxed java/lang mapping are included (java/lang/Byte -> sbyte?, not byte?, etc.). + static readonly (string Name, string ManagedTypeName) [] NullableArrayProxies = [ + ("Boolean", "System.Boolean"), + ("SByte", "System.SByte"), + ("Char", "System.Char"), + ("Int16", "System.Int16"), + ("Int32", "System.Int32"), + ("Int64", "System.Int64"), + ("Single", "System.Single"), + ("Double", "System.Double"), + ]; + static readonly HashSet EssentialRuntimeTypes = new (StringComparer.Ordinal) { "java/lang/Object", "java/lang/Class", @@ -719,6 +734,35 @@ static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRa }); AddArrayProxyAssociations (model, proxy, proxyReference); } + + // Nullable counterparts of the primitive value types map to the boxed java/lang/ + // references and, like System.String, are built-in reference mappings (no scanned peer, no + // primitive proxy). Emit reference-array proxies (Primitive is null) so GetTypes + // ("[Ljava/lang/Boolean;") yields bool?[] / JavaObjectArray on NativeAOT. The element + // key uses the normalized generic form (simple assembly names) that + // TrimmableTypeMap.BuildManagedTypeKey produces at runtime for Nullable. + foreach (var nullablePrimitive in NullableArrayProxies) { + var elementTypeName = $"System.Nullable`1[[{nullablePrimitive.ManagedTypeName}, System.Runtime]]"; + for (int rank = 1; rank <= maxArrayRank; rank++) { + var proxy = new ArrayProxyData { + TypeName = $"Nullable_{nullablePrimitive.Name}_ArrayProxy{rank}", + ElementType = new TypeRefData { + ManagedTypeName = elementTypeName, + AssemblyName = "System.Runtime", + }, + Rank = rank, + }; + model.ArrayProxyTypes.Add (proxy); + var proxyReference = AssemblyQualify ($"{proxy.Namespace}.{proxy.TypeName}", model.AssemblyName); + model.Entries.Add (new TypeMapAttributeData { + MapKey = GetArrayProxyMapKey (proxy.ElementType), + ProxyTypeReference = proxyReference, + TargetTypeReference = proxyReference, + AnchorRank = rank, + }); + AddArrayProxyAssociations (model, proxy, proxyReference); + } + } } static string Brackets (int rank) => rank switch { diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index b5b34ea2b09..5fb831b43f8 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Runtime.InteropServices; +using System.Text; using System.Threading; using Android.Runtime; using Java.Interop; @@ -543,25 +544,58 @@ internal bool TryGetArrayProxy (Type elementType, int additionalRank, [NotNullWh static bool TryGetManagedTypeKey (Type type, [NotNullWhen (true)] out string? key) { - var fullName = type.FullName; - if (fullName is null) { - key = null; - return false; - } + key = BuildManagedTypeKey (type); + return key is not null; + } + // Builds the array-proxy map key for a managed type. Closed generic types use a normalized, + // version-independent form so the key matches the one emitted by the trimmable typemap generator, + // which references types by simple assembly name (no Version/Culture/PublicKeyToken). Without + // normalization a closed generic like Nullable would key on Type.FullName, whose type + // arguments carry the full versioned assembly-qualified name and would never match. + static string? BuildManagedTypeKey (Type type) + { var assemblyName = GetAssemblyNameForManagedTypeKey (type); if (assemblyName is null) { - key = null; - return false; + return null; } - key = $"{fullName}, {assemblyName}"; - return true; + if (type.IsGenericType && !type.IsGenericTypeDefinition) { + var definitionName = type.GetGenericTypeDefinition ().FullName; + if (definitionName is null) { + return null; + } + var arguments = type.GetGenericArguments (); + var builder = new StringBuilder (definitionName); + builder.Append ("[["); + for (int i = 0; i < arguments.Length; i++) { + if (i > 0) { + builder.Append ("],["); + } + var argumentKey = BuildManagedTypeKey (arguments [i]); + if (argumentKey is null) { + return null; + } + builder.Append (argumentKey); + } + builder.Append ("]], "); + builder.Append (assemblyName); + return builder.ToString (); + } + + var fullName = type.FullName; + if (fullName is null) { + return null; + } + return $"{fullName}, {assemblyName}"; } static string? GetAssemblyNameForManagedTypeKey (Type type) { - if (type.IsPrimitive || type == typeof (string)) { + // Primitives, string, and Nullable are surfaced through the System.Runtime reference + // assembly; the trimmable typemap generator emits their keys with "System.Runtime", so + // normalize to it here (the runtime implementation assembly is System.Private.CoreLib). + if (type.IsPrimitive || type == typeof (string) || Nullable.GetUnderlyingType (type) is not null) { return "System.Runtime"; } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs index e41afbabcee..d98137416f0 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs @@ -1180,8 +1180,8 @@ public void Build_EmitArrayEntries_PrimitiveEntries_SynthesizedForJavaInteropAss var primitiveEntries = model.Entries .Where (e => e.MapKey.StartsWith ("System.", StringComparison.Ordinal) && e.AnchorRank is not null) .ToList (); - // 12 primitives × 3 ranks + System.String × 3 ranks. - Assert.Equal (39, primitiveEntries.Count); + // 12 primitives × 3 ranks + System.String × 3 ranks + 8 Nullable × 3 ranks. + Assert.Equal (63, primitiveEntries.Count); var sbyteRank1 = primitiveEntries.Single (e => e.MapKey == "System.SByte, System.Runtime" && e.AnchorRank == 1); Assert.Equal ("_TypeMap.ArrayProxies.Primitive_SByte_ArrayProxy1, _Java.Interop.TypeMap", sbyteRank1.ProxyTypeReference); @@ -1236,6 +1236,19 @@ public void Build_EmitArrayEntries_PrimitiveEntries_SynthesizedForJavaInteropAss Assert.DoesNotContain (model.Associations, a => a.SourceTypeReference == "Java.Interop.JavaPrimitiveArray`1[[System.String, System.Runtime]], Java.Interop" && a.AliasProxyTypeReference == stringRank1.ProxyTypeReference); + + // Nullable boxed mappings (java/lang/Boolean etc.) use the normalized generic key + // (simple assembly names) that TrimmableTypeMap.BuildManagedTypeKey produces at runtime, and + // get the reference-array family. + var nullableBoolRank1 = primitiveEntries.Single (e => + e.MapKey == "System.Nullable`1[[System.Boolean, System.Runtime]], System.Runtime" && e.AnchorRank == 1); + Assert.Equal ("_TypeMap.ArrayProxies.Nullable_Boolean_ArrayProxy1, _Java.Interop.TypeMap", nullableBoolRank1.ProxyTypeReference); + Assert.Contains (model.Associations, a => + a.SourceTypeReference == "System.Nullable`1[[System.Boolean, System.Runtime]][], System.Runtime" && + a.AliasProxyTypeReference == nullableBoolRank1.ProxyTypeReference); + Assert.Contains (model.Associations, a => + a.SourceTypeReference == "Java.Interop.JavaObjectArray`1[[System.Nullable`1[[System.Boolean, System.Runtime]], System.Runtime]], Java.Interop" && + a.AliasProxyTypeReference == nullableBoolRank1.ProxyTypeReference); } [Fact] From eda8c3e6d0961ea554505328d3805e825aa17bac Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 7 Jul 2026 17:01:45 +0200 Subject: [PATCH 04/11] [TrimmableTypeMap] Split array-proxy rank: reference-1, primitive-3 The trimmable typemap generates array-proxy types (JavaObjectArray, T[], nested closures) up to a configurable rank so that array types can be resolved without Array.CreateInstance under NativeAOT. Emitting these uniformly at rank 3 caused a NativeAOT ILC type explosion: on an AppCompat app ILC compiled/preinitialized ~129,320 types and IlcCompile took 333s, which pushed the heaviest CI apps past the 30-min DefaultBuildTimeOut. Reference-type array proxies (scanned Java peers, System.String, boxed Nullable) are now capped at a separate, lower rank (_AndroidTrimmableTypeMapMaxReferenceArrayRank, default 1 for NativeAOT) while primitive arrays keep the higher rank (default 3). Jagged reference arrays (Foo[][]) are rare, and each nesting level multiplies the JavaObjectArray closure ILC must process, so this trims the bulk of the cost while preserving the common Foo[] and int[][][] cases. The anchor matrix (model.MaxArrayRank) stays uniform at max(primitive, reference) across all typemap assemblies so RootTypeMapAssemblyGenerator's rectangular [assembly][rank] sentinel layout is unchanged; only the emitted entries per rank differ. Measured on the same AppCompat NativeAOT app: ILC types 129,320 -> 22,673 (5.7x fewer) IlcCompile 333s -> 91s (3.65x faster) TypeMap DLLs 19.7MB -> 9.7MB (~2x smaller) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/ModelBuilder.cs | 35 ++++++++++----- .../Generator/TypeMapAssemblyGenerator.cs | 7 +-- .../TrimmableTypeMapGenerator.cs | 15 +++++-- ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 1 + ...soft.Android.Sdk.TypeMap.Trimmable.targets | 7 +++ .../Tasks/GenerateTrimmableTypeMap.cs | 14 +++++- .../Generator/TypeMapModelBuilderTests.cs | 45 +++++++++++++++++-- 7 files changed, 102 insertions(+), 22 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index 874bcda5ac9..8035b6b1c08 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -70,7 +70,7 @@ static readonly (string Name, string ManagedTypeName) [] NullableArrayProxies = /// Emit per-rank array TypeMap entries + __ArrayMapRank{N} sentinels /// for ranks 1... 0 disables array entry emission. /// - public static TypeMapAssemblyData Build (IReadOnlyList peers, string outputPath, string? assemblyName = null, int maxArrayRank = 0) + public static TypeMapAssemblyData Build (IReadOnlyList peers, string outputPath, string? assemblyName = null, int maxArrayRank = 0, int maxReferenceArrayRank = 0) { if (peers is null) { throw new ArgumentNullException (nameof (peers)); @@ -81,13 +81,20 @@ public static TypeMapAssemblyData Build (IReadOnlyList peers, stri if (maxArrayRank < 0) { throw new ArgumentOutOfRangeException (nameof (maxArrayRank), maxArrayRank, "Must be >= 0."); } + if (maxReferenceArrayRank < 0) { + throw new ArgumentOutOfRangeException (nameof (maxReferenceArrayRank), maxReferenceArrayRank, "Must be >= 0."); + } assemblyName ??= Path.GetFileNameWithoutExtension (outputPath); var model = new TypeMapAssemblyData { AssemblyName = assemblyName, ModuleName = Path.GetFileName (outputPath), - MaxArrayRank = maxArrayRank, + // The per-assembly __ArrayMapRank{N} anchor count must be uniform across every typemap + // assembly (the root generator builds a rectangular [assembly][rank] matrix), so the model + // always carries the overall maximum rank. Primitive and reference element types may + // populate different rank ranges within that shared anchor set. + MaxArrayRank = Math.Max (maxArrayRank, maxReferenceArrayRank), }; // Invoker types are NOT emitted as separate proxies or TypeMap entries. @@ -124,13 +131,14 @@ public static TypeMapAssemblyData Build (IReadOnlyList peers, stri EmitPeers (model, jniName, peersForName, assemblyName, usedProxyNames); - if (maxArrayRank > 0) { - EmitArrayEntries (model, jniName, peersForName, maxArrayRank); + // Java peer types are reference types: emit array proxies only up to the reference rank. + if (maxReferenceArrayRank > 0) { + EmitArrayEntries (model, jniName, peersForName, maxReferenceArrayRank); } } - if (maxArrayRank > 0 && string.Equals (assemblyName, "_Java.Interop.TypeMap", StringComparison.Ordinal)) { - EmitPrimitiveArrayEntries (model, maxArrayRank); + if (string.Equals (assemblyName, "_Java.Interop.TypeMap", StringComparison.Ordinal)) { + EmitPrimitiveArrayEntries (model, maxArrayRank, maxReferenceArrayRank); } BuildNativeRegistrations (model); @@ -680,7 +688,12 @@ static void EmitArrayEntriesForPeer (TypeMapAssemblyData model, JavaPeerInfo pee } } - static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRank) + // Emits array proxies for the built-in element types that are not scanned Java peers: + // * the keyword primitives (int/bool/...) up to maxArrayRank (jagged/multidim primitive arrays + // like int[][][] are cheap — a small fixed set of element types), + // * System.String and the boxed Nullable value types up to maxReferenceArrayRank (these map to + // java/lang/String and java/lang/, i.e. Java reference arrays). + static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRank, int maxReferenceArrayRank) { foreach (var primitive in PrimitiveArrayProxies) { for (int rank = 1; rank <= maxArrayRank; rank++) { @@ -715,7 +728,8 @@ static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRa // Java peer (EmitArrayEntriesForPeer) nor a primitive. Emit its array proxies here (Primitive is // null, so it gets the reference-array family) so GetTypes ("[Ljava/lang/String;") yields // System.String[] / JavaObjectArray / JavaArray on NativeAOT, matching CoreCLR. - for (int rank = 1; rank <= maxArrayRank; rank++) { + // It is a reference type, so it only goes up to maxReferenceArrayRank. + for (int rank = 1; rank <= maxReferenceArrayRank; rank++) { var proxy = new ArrayProxyData { TypeName = ManagedTypeNameToArrayProxyTypeName ("System.String", rank), ElementType = new TypeRefData { @@ -740,10 +754,11 @@ static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRa // primitive proxy). Emit reference-array proxies (Primitive is null) so GetTypes // ("[Ljava/lang/Boolean;") yields bool?[] / JavaObjectArray on NativeAOT. The element // key uses the normalized generic form (simple assembly names) that - // TrimmableTypeMap.BuildManagedTypeKey produces at runtime for Nullable. + // TrimmableTypeMap.BuildManagedTypeKey produces at runtime for Nullable. The boxed values are + // Java reference types, so they only go up to maxReferenceArrayRank. foreach (var nullablePrimitive in NullableArrayProxies) { var elementTypeName = $"System.Nullable`1[[{nullablePrimitive.ManagedTypeName}, System.Runtime]]"; - for (int rank = 1; rank <= maxArrayRank; rank++) { + for (int rank = 1; rank <= maxReferenceArrayRank; rank++) { var proxy = new ArrayProxyData { TypeName = $"Nullable_{nullablePrimitive.Name}_ArrayProxy{rank}", ElementType = new TypeRefData { diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs index 48ca89f45bc..4687558dba8 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs @@ -27,10 +27,11 @@ public TypeMapAssemblyGenerator (Version systemRuntimeVersion) /// /// When true, uses Java.Lang.Object as the shared anchor type. When false, emits a per-assembly anchor. /// - /// Max rank for per-rank array TypeMap entries. 0 disables. - public void Generate (IReadOnlyList peers, Stream stream, string assemblyName, bool useSharedTypemapUniverse = false, int maxArrayRank = 0) + /// Max rank for per-rank primitive array TypeMap entries. 0 disables. + /// Max rank for per-rank reference-type (Java peer, String, boxed Nullable) array TypeMap entries. 0 disables. + public void Generate (IReadOnlyList peers, Stream stream, string assemblyName, bool useSharedTypemapUniverse = false, int maxArrayRank = 0, int maxReferenceArrayRank = 0) { - var model = ModelBuilder.Build (peers, assemblyName + ".dll", assemblyName, maxArrayRank); + var model = ModelBuilder.Build (peers, assemblyName + ".dll", assemblyName, maxArrayRank, maxReferenceArrayRank); var emitter = new TypeMapAssemblyEmitter (_systemRuntimeVersion); emitter.Emit (model, stream, useSharedTypemapUniverse); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index f59e99e0334..99fcb4cf42b 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -35,6 +35,7 @@ public TrimmableTypeMapResult Execute ( XDocument? manifestTemplate = null, string? packageNamingPolicy = null, int maxArrayRank = 0, + int maxReferenceArrayRank = 0, bool generateTypeMapAssemblies = true) { _ = assemblies ?? throw new ArgumentNullException (nameof (assemblies)); @@ -43,6 +44,9 @@ public TrimmableTypeMapResult Execute ( if (maxArrayRank < 0) { throw new ArgumentOutOfRangeException (nameof (maxArrayRank), maxArrayRank, "Must be >= 0."); } + if (maxReferenceArrayRank < 0) { + throw new ArgumentOutOfRangeException (nameof (maxReferenceArrayRank), maxReferenceArrayRank, "Must be >= 0."); + } var (allPeers, assemblyManifestInfo) = ScanAssemblies (assemblies, packageNamingPolicy, frameworkAssemblyNames); if (allPeers.Count == 0) { @@ -56,7 +60,7 @@ public TrimmableTypeMapResult Execute ( PropagateCannotRegisterToDescendants (allPeers); var generatedAssemblies = generateTypeMapAssemblies - ? GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, useSharedTypemapUniverse, maxArrayRank) + ? GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, useSharedTypemapUniverse, maxArrayRank, maxReferenceArrayRank) : []; var jcwPeers = allPeers.Where (ShouldGenerateJcw).ToList (); logger.LogGeneratingJcwFilesInfo (jcwPeers.Count, allPeers.Count); @@ -178,7 +182,8 @@ List GenerateTypeMapAssemblies ( List allPeers, Version systemRuntimeVersion, bool useSharedTypemapUniverse, - int maxArrayRank) + int maxArrayRank, + int maxReferenceArrayRank) { List<(string AssemblyName, List Peers)> peersByAssembly; @@ -207,14 +212,16 @@ List GenerateTypeMapAssemblies ( string typeMapAssemblyName = $"_{assemblyName}.TypeMap"; perAssemblyNames.Add (typeMapAssemblyName); var stream = new MemoryStream (); - generator.Generate (peers, stream, typeMapAssemblyName, useSharedTypemapUniverse, maxArrayRank); + generator.Generate (peers, stream, typeMapAssemblyName, useSharedTypemapUniverse, maxArrayRank, maxReferenceArrayRank); stream.Position = 0; generatedAssemblies.Add (new GeneratedAssembly (typeMapAssemblyName, stream)); logger.LogGeneratedTypeMapAssemblyInfo (typeMapAssemblyName, peers.Count); } var rootStream = new MemoryStream (); var rootGenerator = new RootTypeMapAssemblyGenerator (systemRuntimeVersion); - rootGenerator.Generate (perAssemblyNames, useSharedTypemapUniverse, rootStream, maxArrayRank: maxArrayRank); + // The root generator builds the [assembly][rank] anchor matrix, so it needs the overall maximum + // rank across primitive and reference element types. + rootGenerator.Generate (perAssemblyNames, useSharedTypemapUniverse, rootStream, maxArrayRank: Math.Max (maxArrayRank, maxReferenceArrayRank)); rootStream.Position = 0; generatedAssemblies.Add (new GeneratedAssembly ("_Microsoft.Android.TypeMaps", rootStream)); logger.LogGeneratedRootTypeMapInfo (perAssemblyNames.Count); diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index 1beb4ab9505..8981130964c 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -80,6 +80,7 @@ TargetFrameworkVersion="$(TargetFrameworkVersion)" PackageNamingPolicy="$(_TrimmableTypeMapPackageNamingPolicy)" MaxArrayRank="$(_AndroidTrimmableTypeMapMaxArrayRank)" + MaxReferenceArrayRank="$(_AndroidTrimmableTypeMapMaxReferenceArrayRank)" GenerateTypeMapAssemblies="false" CleanJavaSourceOutputDirectory="true" AcwMapOutputFile="$(IntermediateOutputPath)acw-map.txt" diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 5090ba7fa6a..0e94bb86f9c 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -49,6 +49,12 @@ defaults to 0 otherwise, where dynamic code can use Array.CreateInstance directly. --> <_AndroidTrimmableTypeMapMaxArrayRank Condition=" '$(_AndroidTrimmableTypeMapMaxArrayRank)' == '' and ('$(PublishAot)' == 'true' or '$(DynamicCodeSupport)' == 'false') ">3 <_AndroidTrimmableTypeMapMaxArrayRank Condition=" '$(_AndroidTrimmableTypeMapMaxArrayRank)' == '' ">0 + + <_AndroidTrimmableTypeMapMaxReferenceArrayRank Condition=" '$(_AndroidTrimmableTypeMapMaxReferenceArrayRank)' == '' and ('$(PublishAot)' == 'true' or '$(DynamicCodeSupport)' == 'false') ">1 + <_AndroidTrimmableTypeMapMaxReferenceArrayRank Condition=" '$(_AndroidTrimmableTypeMapMaxReferenceArrayRank)' == '' ">0 _RecordTrimmableTypeMapFileWrites; $(IncrementalCleanDependsOn); @@ -140,6 +146,7 @@ EmbedAssemblies="$(EmbedAssembliesIntoApk)" PackageNamingPolicy="$(_TrimmableTypeMapPackageNamingPolicy)" MaxArrayRank="$(_AndroidTrimmableTypeMapMaxArrayRank)" + MaxReferenceArrayRank="$(_AndroidTrimmableTypeMapMaxReferenceArrayRank)" ManifestPlaceholders="$(AndroidManifestPlaceholders)" CheckedBuild="$(_AndroidCheckedBuild)" ApplicationJavaClass="$(AndroidApplicationJavaClass)" diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 6c7d18b5823..0602dfa005b 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -102,11 +102,20 @@ public void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeN /// /// Maximum array rank for which the generator emits per-rank __ArrayMapRank{N} - /// sentinels and TypeMap entries. 0 disables. Set via - /// $(_AndroidTrimmableTypeMapMaxArrayRank). + /// sentinels and primitive-element TypeMap entries (e.g. int[][][]). 0 disables. + /// Set via $(_AndroidTrimmableTypeMapMaxArrayRank). /// public int MaxArrayRank { get; set; } + /// + /// Maximum array rank for reference-type element TypeMap entries — scanned Java peers, + /// System.String, and boxed Nullable<T> (which map to Java reference arrays). + /// Kept lower than because jagged reference arrays are rare and each + /// nesting level multiplies the generated JavaObjectArray<T> closure. 0 disables. Set + /// via $(_AndroidTrimmableTypeMapMaxReferenceArrayRank). + /// + public int MaxReferenceArrayRank { get; set; } + public string? ManifestPlaceholders { get; set; } public string? CheckedBuild { get; set; } public string? ApplicationJavaClass { get; set; } @@ -213,6 +222,7 @@ public override bool RunTask () manifestTemplate: manifestTemplate, packageNamingPolicy: PackageNamingPolicy, maxArrayRank: MaxArrayRank, + maxReferenceArrayRank: MaxReferenceArrayRank, generateTypeMapAssemblies: GenerateTypeMapAssemblies); if (GenerateTypeMapAssemblies) { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs index d98137416f0..e506ba4bdeb 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs @@ -16,10 +16,10 @@ static TypeMapAssemblyData BuildModel (IReadOnlyList peers, string return ModelBuilder.Build (peers, outputPath, assemblyName); } - static TypeMapAssemblyData BuildModelWithArrays (IReadOnlyList peers, string? assemblyName = null, int maxArrayRank = 3) + static TypeMapAssemblyData BuildModelWithArrays (IReadOnlyList peers, string? assemblyName = null, int maxArrayRank = 3, int? maxReferenceArrayRank = null) { var outputPath = Path.Combine (Path.GetTempPath (), (assemblyName ?? "TestTypeMap") + ".dll"); - return ModelBuilder.Build (peers, outputPath, assemblyName, maxArrayRank); + return ModelBuilder.Build (peers, outputPath, assemblyName, maxArrayRank, maxReferenceArrayRank ?? maxArrayRank); } public class BasicStructure @@ -1251,6 +1251,45 @@ public void Build_EmitArrayEntries_PrimitiveEntries_SynthesizedForJavaInteropAss a.AliasProxyTypeReference == nullableBoolRank1.ProxyTypeReference); } + [Fact] + public void Build_ReferenceRankLowerThanPrimitiveRank_PeersGetReferenceRankOnly () + { + // Split ranks: reference types (Java peers) capped at rank 1, primitives at rank 3. + var peer = MakeMcwPeer ("com/example/Foo", "Com.Example.Foo", "App"); + var model = BuildModelWithArrays (new [] { peer }, maxArrayRank: 3, maxReferenceArrayRank: 1); + + // The peer (a reference type) gets exactly one array entry, at rank 1. + var peerEntries = model.Entries.Where (e => e.MapKey == "Com.Example.Foo, App" && e.AnchorRank is not null).ToList (); + Assert.Single (peerEntries); + Assert.Equal (1, peerEntries [0].AnchorRank); + + // The anchor set is still rank 3 (uniform across assemblies for the root matrix). + Assert.Equal (3, model.MaxArrayRank); + } + + [Fact] + public void Build_ReferenceRankLowerThanPrimitiveRank_PrimitivesRank3ReferencesRank1 () + { + var peer = MakeMcwPeer ("java/lang/Object", "Java.Lang.Object", "Java.Interop"); + var model = BuildModelWithArrays (new [] { peer }, assemblyName: "_Java.Interop.TypeMap", maxArrayRank: 3, maxReferenceArrayRank: 1); + + // Primitives keep ranks 1..3: 12 primitives × 3. + var primitiveInt = model.Entries.Where (e => e.MapKey == "System.Int32, System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).OrderBy (r => r).ToList (); + Assert.Equal (new int? [] { 1, 2, 3 }, primitiveInt); + + // System.String (reference) only rank 1. + var stringRanks = model.Entries.Where (e => e.MapKey == "System.String, System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).ToList (); + Assert.Equal (new int? [] { 1 }, stringRanks); + + // Nullable (boxed reference) only rank 1. + var nullableRanks = model.Entries.Where (e => e.MapKey == "System.Nullable`1[[System.Boolean, System.Runtime]], System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).ToList (); + Assert.Equal (new int? [] { 1 }, nullableRanks); + + // Totals: 12 primitives × 3 + (String + 8 nullables) × 1 = 36 + 9 = 45. + var builtinEntries = model.Entries.Count (e => e.MapKey.StartsWith ("System.", StringComparison.Ordinal) && e.AnchorRank is not null); + Assert.Equal (45, builtinEntries); + } + [Fact] public void Build_EmitArrayEntries_PrimitiveEntries_NotDuplicatedInOtherAssemblies () { @@ -1374,7 +1413,7 @@ public void FullPipeline_ArrayEntries_AttributeBlobsRoundTrip () { var peer = MakeMcwPeer ("foo/Bar", "Foo.Bar", "App"); var outputPath = Path.Combine (Path.GetTempPath (), "ArrBlobs.dll"); - var model = ModelBuilder.Build (new [] { peer }, outputPath, "ArrBlobs", maxArrayRank: 3); + var model = ModelBuilder.Build (new [] { peer }, outputPath, "ArrBlobs", maxArrayRank: 3, maxReferenceArrayRank: 3); EmitAndVerify (model, "ArrBlobs", (pe, reader) => { var arrayAttrs = ReadAllTypeMapAttributeBlobs (reader) From 1479d0e1155aed905706591024c243bd6a1b2456 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 7 Jul 2026 19:34:06 +0200 Subject: [PATCH 05/11] [TrimmableTypeMap] Treat System.String as a rank-3 built-in array element The split-rank change capped all reference-type array proxies at rank 1, which regressed multidimensional string arrays: GetTypes("[[Ljava/lang/String;") no longer yielded System.String[][] / JavaObjectArray>. That expectation is baseline Java.Interop behavior (JniTypeManagerTests.GetType, since #517) that the managed/llvm-ir/reflection type maps all satisfy, so the trimmable type map must satisfy it too now that it is the default. System.String is a single, fixed built-in element type (injected at runtime by GetBuiltInTypeForSimpleReference), not part of the scanned-peer population that drives the NativeAOT ILC type explosion. Emit its array proxies up to the primitive maxArrayRank instead of maxReferenceArrayRank, exactly like the keyword primitives. The cost is negligible: on the AppCompat NativeAOT app ILC types went from 22,673 -> 22,717 (+44) and IlcCompile stayed ~60-90s (vs rank-3's 129,320 types / 333s). Scanned Java peers stay capped at maxReferenceArrayRank (1); boxed Nullable built-ins likewise stay at rank 1 (no baseline coverage requires multidim boxed arrays). Also drop TryGetArrayProxy_ObjectLeaf_ReturnsAllRankTypes: its additionalRank:2 assertions expected Object[][] etc., which is exactly the reference-rank-2 case that split-rank intentionally no longer generates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Configuration.OperatingSystem.props | 11 ++++++++++ .../Generator/ModelBuilder.cs | 20 +++++++++++-------- .../Generator/TypeMapModelBuilderTests.cs | 10 +++++----- .../TrimmableTypeMapTypeManagerTests.cs | 17 ---------------- 4 files changed, 28 insertions(+), 30 deletions(-) create mode 100644 Configuration.OperatingSystem.props diff --git a/Configuration.OperatingSystem.props b/Configuration.OperatingSystem.props new file mode 100644 index 00000000000..47f9a358746 --- /dev/null +++ b/Configuration.OperatingSystem.props @@ -0,0 +1,11 @@ + + + + macOS + macOS + 26.5 (25F71) + 10 + 64 + darwin-x86_64 + + diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index 8035b6b1c08..b4a92c40b65 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -691,8 +691,10 @@ static void EmitArrayEntriesForPeer (TypeMapAssemblyData model, JavaPeerInfo pee // Emits array proxies for the built-in element types that are not scanned Java peers: // * the keyword primitives (int/bool/...) up to maxArrayRank (jagged/multidim primitive arrays // like int[][][] are cheap — a small fixed set of element types), - // * System.String and the boxed Nullable value types up to maxReferenceArrayRank (these map to - // java/lang/String and java/lang/, i.e. Java reference arrays). + // * System.String up to maxArrayRank (also a built-in element type, so multidimensional string + // arrays like String[][] stay resolvable), + // * the boxed Nullable value types up to maxReferenceArrayRank (these map to java/lang/, + // i.e. Java reference arrays). static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRank, int maxReferenceArrayRank) { foreach (var primitive in PrimitiveArrayProxies) { @@ -723,13 +725,15 @@ static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRa } } - // System.String maps to java/lang/String but is a built-in reference type injected at runtime - // by TrimmableTypeMapTypeManager.GetBuiltInTypeForSimpleReference, so it is neither a scanned - // Java peer (EmitArrayEntriesForPeer) nor a primitive. Emit its array proxies here (Primitive is - // null, so it gets the reference-array family) so GetTypes ("[Ljava/lang/String;") yields + // System.String maps to java/lang/String but is a built-in element type injected at runtime by + // TrimmableTypeMapTypeManager.GetBuiltInTypeForSimpleReference, so it is neither a scanned Java + // peer (EmitArrayEntriesForPeer) nor a keyword primitive. Emit its array proxies here (Primitive + // is null, so it gets the reference-array family) so GetTypes ("[Ljava/lang/String;") yields // System.String[] / JavaObjectArray / JavaArray on NativeAOT, matching CoreCLR. - // It is a reference type, so it only goes up to maxReferenceArrayRank. - for (int rank = 1; rank <= maxReferenceArrayRank; rank++) { + // String is a single, fixed built-in element type (not part of the scanned-peer explosion), so — + // like the keyword primitives — it goes up to maxArrayRank. This keeps multidimensional string + // arrays (String[][], "[[Ljava/lang/String;") resolvable, matching the other type-map backends. + for (int rank = 1; rank <= maxArrayRank; rank++) { var proxy = new ArrayProxyData { TypeName = ManagedTypeNameToArrayProxyTypeName ("System.String", rank), ElementType = new TypeRefData { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs index e506ba4bdeb..bb615c3fa77 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs @@ -1277,17 +1277,17 @@ public void Build_ReferenceRankLowerThanPrimitiveRank_PrimitivesRank3ReferencesR var primitiveInt = model.Entries.Where (e => e.MapKey == "System.Int32, System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).OrderBy (r => r).ToList (); Assert.Equal (new int? [] { 1, 2, 3 }, primitiveInt); - // System.String (reference) only rank 1. - var stringRanks = model.Entries.Where (e => e.MapKey == "System.String, System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).ToList (); - Assert.Equal (new int? [] { 1 }, stringRanks); + // System.String is a built-in element type (like the primitives), so it keeps ranks 1..3. + var stringRanks = model.Entries.Where (e => e.MapKey == "System.String, System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).OrderBy (r => r).ToList (); + Assert.Equal (new int? [] { 1, 2, 3 }, stringRanks); // Nullable (boxed reference) only rank 1. var nullableRanks = model.Entries.Where (e => e.MapKey == "System.Nullable`1[[System.Boolean, System.Runtime]], System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).ToList (); Assert.Equal (new int? [] { 1 }, nullableRanks); - // Totals: 12 primitives × 3 + (String + 8 nullables) × 1 = 36 + 9 = 45. + // Totals: (12 primitives + String) × 3 + 8 nullables × 1 = 39 + 8 = 47. var builtinEntries = model.Entries.Count (e => e.MapKey.StartsWith ("System.", StringComparison.Ordinal) && e.AnchorRank is not null); - Assert.Equal (45, builtinEntries); + Assert.Equal (47, builtinEntries); } [Fact] diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs index 8b4abf0d84f..7c5635e9dd3 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs @@ -255,23 +255,6 @@ public void TrimmableJavaProxyObject_ObjectMethodsUseJavaIdentitySemantics () } } - [Test] - public void TryGetArrayProxy_ObjectLeaf_ReturnsAllRankTypes () - { - AssumeTrimmableTypeMapEnabled (); - AssumeGeneratedArrayProxiesEnabled (); - - Assert.IsTrue (TrimmableTypeMap.Instance.TryGetArrayProxy (typeof (Java.Lang.Object), additionalRank: 1, out var objectArrayProxy)); - CollectionAssert.Contains (objectArrayProxy.GetArrayTypes (), typeof (JavaObjectArray)); - CollectionAssert.Contains (objectArrayProxy.GetArrayTypes (), typeof (Java.Interop.JavaArray)); - CollectionAssert.Contains (objectArrayProxy.GetArrayTypes (), typeof (Java.Lang.Object[])); - - Assert.IsTrue (TrimmableTypeMap.Instance.TryGetArrayProxy (typeof (Java.Lang.Object), additionalRank: 2, out var jaggedObjectArrayProxy)); - CollectionAssert.Contains (jaggedObjectArrayProxy.GetArrayTypes (), typeof (JavaObjectArray>)); - CollectionAssert.Contains (jaggedObjectArrayProxy.GetArrayTypes (), typeof (Java.Interop.JavaArray[])); - CollectionAssert.Contains (jaggedObjectArrayProxy.GetArrayTypes (), typeof (Java.Lang.Object[][])); - } - [Test] public void TryGetArrayProxy_PrimitiveLeaf_ReturnsAllRankTypes () { From 960b936942c9c9055f1c717e492c0e40d9f64052 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 7 Jul 2026 19:47:23 +0200 Subject: [PATCH 06/11] [TrimmableTypeMap] Treat boxed Nullable as rank-3 built-in array elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to promoting System.String: the boxed Nullable value types (java/lang/Boolean, java/lang/Integer, ...) are built-in element mappings just like System.String and the keyword primitives — a small, fixed set that is not part of the scanned-peer population driving the NativeAOT ILC type explosion. Emit their array proxies up to maxArrayRank instead of maxReferenceArrayRank so all built-in element types are treated uniformly and multidimensional boxed arrays (int?[][], "[[Ljava/lang/Integer;") resolve. Only scanned Java peers remain capped at maxReferenceArrayRank (1). Cost stays negligible on the AppCompat NativeAOT app: ILC types 22,717 -> 23,101 (+384) and IlcCompile ~56s (vs rank-3's 129,320 types / 333s). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/ModelBuilder.cs | 13 +++++++------ .../Generator/TypeMapModelBuilderTests.cs | 10 +++++----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index b4a92c40b65..d990b2ea650 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -693,8 +693,8 @@ static void EmitArrayEntriesForPeer (TypeMapAssemblyData model, JavaPeerInfo pee // like int[][][] are cheap — a small fixed set of element types), // * System.String up to maxArrayRank (also a built-in element type, so multidimensional string // arrays like String[][] stay resolvable), - // * the boxed Nullable value types up to maxReferenceArrayRank (these map to java/lang/, - // i.e. Java reference arrays). + // * the boxed Nullable value types up to maxArrayRank (also built-in element types mapping to + // java/lang/; a small fixed set, so multidim boxed arrays stay resolvable too). static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRank, int maxReferenceArrayRank) { foreach (var primitive in PrimitiveArrayProxies) { @@ -754,15 +754,16 @@ static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRa } // Nullable counterparts of the primitive value types map to the boxed java/lang/ - // references and, like System.String, are built-in reference mappings (no scanned peer, no + // references and, like System.String, are built-in element mappings (no scanned peer, no // primitive proxy). Emit reference-array proxies (Primitive is null) so GetTypes // ("[Ljava/lang/Boolean;") yields bool?[] / JavaObjectArray on NativeAOT. The element // key uses the normalized generic form (simple assembly names) that - // TrimmableTypeMap.BuildManagedTypeKey produces at runtime for Nullable. The boxed values are - // Java reference types, so they only go up to maxReferenceArrayRank. + // TrimmableTypeMap.BuildManagedTypeKey produces at runtime for Nullable. Like the primitives + // and System.String, these are a small fixed set of built-in element types (not the scanned-peer + // explosion), so they go up to maxArrayRank and keep multidim boxed arrays resolvable. foreach (var nullablePrimitive in NullableArrayProxies) { var elementTypeName = $"System.Nullable`1[[{nullablePrimitive.ManagedTypeName}, System.Runtime]]"; - for (int rank = 1; rank <= maxReferenceArrayRank; rank++) { + for (int rank = 1; rank <= maxArrayRank; rank++) { var proxy = new ArrayProxyData { TypeName = $"Nullable_{nullablePrimitive.Name}_ArrayProxy{rank}", ElementType = new TypeRefData { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs index bb615c3fa77..4592b575ffb 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs @@ -1281,13 +1281,13 @@ public void Build_ReferenceRankLowerThanPrimitiveRank_PrimitivesRank3ReferencesR var stringRanks = model.Entries.Where (e => e.MapKey == "System.String, System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).OrderBy (r => r).ToList (); Assert.Equal (new int? [] { 1, 2, 3 }, stringRanks); - // Nullable (boxed reference) only rank 1. - var nullableRanks = model.Entries.Where (e => e.MapKey == "System.Nullable`1[[System.Boolean, System.Runtime]], System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).ToList (); - Assert.Equal (new int? [] { 1 }, nullableRanks); + // Nullable is a built-in element type too (boxed java/lang/Boolean), so it keeps ranks 1..3. + var nullableRanks = model.Entries.Where (e => e.MapKey == "System.Nullable`1[[System.Boolean, System.Runtime]], System.Runtime" && e.AnchorRank is not null).Select (e => e.AnchorRank).OrderBy (r => r).ToList (); + Assert.Equal (new int? [] { 1, 2, 3 }, nullableRanks); - // Totals: (12 primitives + String) × 3 + 8 nullables × 1 = 39 + 8 = 47. + // Totals: (12 primitives + String + 8 nullables) × 3 = 21 × 3 = 63. var builtinEntries = model.Entries.Count (e => e.MapKey.StartsWith ("System.", StringComparison.Ordinal) && e.AnchorRank is not null); - Assert.Equal (47, builtinEntries); + Assert.Equal (63, builtinEntries); } [Fact] From 63fd113faa6d63c6d804ab48aa3cfa60236249b2 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 7 Jul 2026 22:57:13 +0200 Subject: [PATCH 07/11] Fix nullable value type codegen --- .../Generator/ModelBuilder.cs | 45 +++++++++++++++---- .../Generator/TypeMapModelBuilderTests.cs | 22 +++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index d990b2ea650..6768d3881f1 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -564,6 +564,26 @@ static TypeMapAttributeData BuildEntry (JavaPeerInfo peer, JavaPeerProxyData? pr static string AssemblyQualify (string typeName, string assemblyName) => $"{typeName}, {assemblyName}"; + static string AssemblyQualify (TypeRefData type) + { + if (type.GenericArguments.Count == 0) { + return AssemblyQualify (type.ManagedTypeName, type.AssemblyName); + } + + var builder = new StringBuilder (); + builder.Append (type.ManagedTypeName); + builder.Append ("[["); + for (int i = 0; i < type.GenericArguments.Count; i++) { + if (i > 0) { + builder.Append ("],["); + } + builder.Append (AssemblyQualify (type.GenericArguments [i])); + } + builder.Append ("]], "); + builder.Append (type.AssemblyName); + return builder.ToString (); + } + static string AddArrayRank (string typeReference, int rank) { if (rank == 0) { @@ -592,7 +612,7 @@ static string MakeNestedJavaObjectArrayTypeReference (string elementTypeReferenc static IReadOnlyList GetArrayTypeReferences (ArrayProxyData proxy) { - var elementType = AssemblyQualify (proxy.ElementType.ManagedTypeName, proxy.ElementType.AssemblyName); + var elementType = AssemblyQualify (proxy.ElementType); if (proxy.Primitive is null) { var rankOneTypes = new [] { MakeGenericTypeReference ("Java.Interop.JavaObjectArray`1", "Java.Interop", elementType), @@ -608,7 +628,7 @@ static IReadOnlyList GetArrayTypeReferences (ArrayProxyData proxy) MakeGenericTypeReference ("Java.Interop.JavaPrimitiveArray`1", "Java.Interop", elementType), ]; foreach (var concreteArrayType in proxy.Primitive.ConcreteArrayTypes) { - rankOnePrimitiveTypes.Add (AssemblyQualify (concreteArrayType.ManagedTypeName, concreteArrayType.AssemblyName)); + rankOnePrimitiveTypes.Add (AssemblyQualify (concreteArrayType)); } return ExpandRankOneTypes (rankOnePrimitiveTypes, proxy.Rank); } @@ -639,7 +659,7 @@ static void AddArrayProxyAssociations (TypeMapAssemblyData model, ArrayProxyData } static string GetArrayProxyMapKey (TypeRefData elementType) - => AssemblyQualify (elementType.ManagedTypeName, elementType.AssemblyName); + => AssemblyQualify (elementType); /// /// Emits per-rank array TypeMap entries for one peer, anchored to the per-assembly @@ -758,17 +778,26 @@ static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRa // primitive proxy). Emit reference-array proxies (Primitive is null) so GetTypes // ("[Ljava/lang/Boolean;") yields bool?[] / JavaObjectArray on NativeAOT. The element // key uses the normalized generic form (simple assembly names) that - // TrimmableTypeMap.BuildManagedTypeKey produces at runtime for Nullable. Like the primitives - // and System.String, these are a small fixed set of built-in element types (not the scanned-peer - // explosion), so they go up to maxArrayRank and keep multidim boxed arrays resolvable. + // TrimmableTypeMap.BuildManagedTypeKey produces at runtime for Nullable, while the model + // keeps the generic instantiation structured so the emitter writes a real Nullable type + // signature. Like the primitives and System.String, these are a small fixed set of built-in + // element types (not the scanned-peer explosion), so they go up to maxArrayRank and keep + // multidim boxed arrays resolvable. foreach (var nullablePrimitive in NullableArrayProxies) { - var elementTypeName = $"System.Nullable`1[[{nullablePrimitive.ManagedTypeName}, System.Runtime]]"; for (int rank = 1; rank <= maxArrayRank; rank++) { var proxy = new ArrayProxyData { TypeName = $"Nullable_{nullablePrimitive.Name}_ArrayProxy{rank}", ElementType = new TypeRefData { - ManagedTypeName = elementTypeName, + ManagedTypeName = "System.Nullable`1", AssemblyName = "System.Runtime", + GenericArguments = [ + new TypeRefData { + ManagedTypeName = nullablePrimitive.ManagedTypeName, + AssemblyName = "System.Runtime", + IsValueType = true, + }, + ], + IsValueType = true, }, Rank = rank, }; diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs index 4592b575ffb..3956e1fc7ad 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; using System.Reflection.PortableExecutable; using Xunit; @@ -1243,6 +1244,13 @@ public void Build_EmitArrayEntries_PrimitiveEntries_SynthesizedForJavaInteropAss var nullableBoolRank1 = primitiveEntries.Single (e => e.MapKey == "System.Nullable`1[[System.Boolean, System.Runtime]], System.Runtime" && e.AnchorRank == 1); Assert.Equal ("_TypeMap.ArrayProxies.Nullable_Boolean_ArrayProxy1, _Java.Interop.TypeMap", nullableBoolRank1.ProxyTypeReference); + var nullableBoolProxy = model.ArrayProxyTypes.Single (p => p.TypeName == "Nullable_Boolean_ArrayProxy1"); + Assert.Equal ("System.Nullable`1", nullableBoolProxy.ElementType.ManagedTypeName); + Assert.Equal ("System.Runtime", nullableBoolProxy.ElementType.AssemblyName); + Assert.True (nullableBoolProxy.ElementType.IsValueType); + var nullableBoolArgument = Assert.Single (nullableBoolProxy.ElementType.GenericArguments); + Assert.Equal ("System.Boolean", nullableBoolArgument.ManagedTypeName); + Assert.Equal ("System.Runtime", nullableBoolArgument.AssemblyName); Assert.Contains (model.Associations, a => a.SourceTypeReference == "System.Nullable`1[[System.Boolean, System.Runtime]][], System.Runtime" && a.AliasProxyTypeReference == nullableBoolRank1.ProxyTypeReference); @@ -1393,6 +1401,20 @@ public void FullPipeline_PrimitiveAliasArrayEntries_EmitWithoutConcreteArrayType Assert.Contains ("Primitive_Byte_ArrayProxy1", typeDefNames); Assert.Contains ("Primitive_Byte_ArrayProxy2", typeDefNames); Assert.Contains ("Primitive_UInt32_ArrayProxy1", typeDefNames); + Assert.Contains ("Nullable_Boolean_ArrayProxy1", typeDefNames); + + var typeRefNames = reader.TypeReferences + .Select (h => reader.GetString (reader.GetTypeReference (h).Name)) + .ToList (); + Assert.Contains ("Nullable`1", typeRefNames); + Assert.DoesNotContain (typeRefNames, name => name.Contains ("[[", StringComparison.Ordinal)); + + var typeSpecNames = Enumerable.Range (1, reader.GetTableRowCount (TableIndex.TypeSpec)) + .Select (MetadataTokens.TypeSpecificationHandle) + .Select (h => reader.GetTypeSpecification (h).DecodeSignature (SignatureTypeProvider.Instance, genericContext: null)) + .ToList (); + Assert.Contains ("System.Nullable`1[]", typeSpecNames); + Assert.Contains ("Java.Interop.JavaObjectArray`1>", typeSpecNames); var assocAttrs = ReadAllTypeMapAssociationAttributeBlobs (reader); Assert.Contains (assocAttrs, a => From 097ff8234587c633f13ebb27b8ce138a91eb0cf9 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Tue, 7 Jul 2026 23:11:36 +0200 Subject: [PATCH 08/11] [TrimmableTypeMap] Make JavaPeerProxy ctor accessible to typemaps Generated typemap proxy types can derive directly from the non-generic JavaPeerProxy base when they need to represent interfaces or open generic definitions. Make the base constructor protected so those generated proxies can call it from typemap assemblies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Mono.Android/Java.Interop/JavaPeerProxy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mono.Android/Java.Interop/JavaPeerProxy.cs b/src/Mono.Android/Java.Interop/JavaPeerProxy.cs index 2421f41d264..7c086dfefe8 100644 --- a/src/Mono.Android/Java.Interop/JavaPeerProxy.cs +++ b/src/Mono.Android/Java.Interop/JavaPeerProxy.cs @@ -42,7 +42,7 @@ public sealed class JavaPeerAliasesAttribute : Attribute [AttributeUsage (AttributeTargets.Class | AttributeTargets.Interface, Inherited = false, AllowMultiple = false)] public abstract class JavaPeerProxy : Attribute { - private protected JavaPeerProxy (string jniName, Type targetType) + protected JavaPeerProxy (string jniName, Type targetType) { ArgumentNullException.ThrowIfNull (jniName); ArgumentNullException.ThrowIfNull (targetType); From bd2dc4bac1fb537f6afb11f69a835b729bd1dc7e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 8 Jul 2026 09:58:53 +0200 Subject: [PATCH 09/11] [Tests] Keep rank-1 object array proxy coverage Restore object-leaf array proxy coverage for rank 1 and split the rank-2 reference-array assertions into an ignored test until higher-rank reference array proxy generation is revisited. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TrimmableTypeMapTypeManagerTests.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs index 7c5635e9dd3..b32127fbf73 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/TrimmableTypeMapTypeManagerTests.cs @@ -255,6 +255,31 @@ public void TrimmableJavaProxyObject_ObjectMethodsUseJavaIdentitySemantics () } } + [Test] + public void TryGetArrayProxy_ObjectLeaf_ReturnsRankOneTypes () + { + AssumeTrimmableTypeMapEnabled (); + AssumeGeneratedArrayProxiesEnabled (); + + Assert.IsTrue (TrimmableTypeMap.Instance.TryGetArrayProxy (typeof (Java.Lang.Object), additionalRank: 1, out var objectArrayProxy)); + CollectionAssert.Contains (objectArrayProxy.GetArrayTypes (), typeof (JavaObjectArray)); + CollectionAssert.Contains (objectArrayProxy.GetArrayTypes (), typeof (Java.Interop.JavaArray)); + CollectionAssert.Contains (objectArrayProxy.GetArrayTypes (), typeof (Java.Lang.Object[])); + } + + [Test] + [Ignore ("Higher-rank reference array proxies are not generated by default; re-enable when the rank-2 object-array plan lands.")] + public void TryGetArrayProxy_ObjectLeaf_ReturnsRankTwoTypes () + { + AssumeTrimmableTypeMapEnabled (); + AssumeGeneratedArrayProxiesEnabled (); + + Assert.IsTrue (TrimmableTypeMap.Instance.TryGetArrayProxy (typeof (Java.Lang.Object), additionalRank: 2, out var jaggedObjectArrayProxy)); + CollectionAssert.Contains (jaggedObjectArrayProxy.GetArrayTypes (), typeof (JavaObjectArray>)); + CollectionAssert.Contains (jaggedObjectArrayProxy.GetArrayTypes (), typeof (Java.Interop.JavaArray[])); + CollectionAssert.Contains (jaggedObjectArrayProxy.GetArrayTypes (), typeof (Java.Lang.Object[][])); + } + [Test] public void TryGetArrayProxy_PrimitiveLeaf_ReturnsAllRankTypes () { From 405fee6a6695b805d2db7a762ec7d418bedb115a Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 8 Jul 2026 10:12:10 +0200 Subject: [PATCH 10/11] [TrimmableTypeMap] Address array proxy review feedback Remove the accidental host-specific Configuration.OperatingSystem.props file from the PR. Clarify that MaxReferenceArrayRank applies only to scanned Java peer arrays, while built-in primitives, System.String, and boxed Nullable use MaxArrayRank. Drop the unused maxReferenceArrayRank parameter from EmitPrimitiveArrayEntries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Configuration.OperatingSystem.props | 11 ----------- .../Generator/ModelBuilder.cs | 4 ++-- ...icrosoft.Android.Sdk.TypeMap.Trimmable.targets | 8 ++++---- .../Tasks/GenerateTrimmableTypeMap.cs | 15 ++++++++------- 4 files changed, 14 insertions(+), 24 deletions(-) delete mode 100644 Configuration.OperatingSystem.props diff --git a/Configuration.OperatingSystem.props b/Configuration.OperatingSystem.props deleted file mode 100644 index 47f9a358746..00000000000 --- a/Configuration.OperatingSystem.props +++ /dev/null @@ -1,11 +0,0 @@ - - - - macOS - macOS - 26.5 (25F71) - 10 - 64 - darwin-x86_64 - - diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index 6768d3881f1..6bbf04d2c49 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -138,7 +138,7 @@ public static TypeMapAssemblyData Build (IReadOnlyList peers, stri } if (string.Equals (assemblyName, "_Java.Interop.TypeMap", StringComparison.Ordinal)) { - EmitPrimitiveArrayEntries (model, maxArrayRank, maxReferenceArrayRank); + EmitPrimitiveArrayEntries (model, maxArrayRank); } BuildNativeRegistrations (model); @@ -715,7 +715,7 @@ static void EmitArrayEntriesForPeer (TypeMapAssemblyData model, JavaPeerInfo pee // arrays like String[][] stay resolvable), // * the boxed Nullable value types up to maxArrayRank (also built-in element types mapping to // java/lang/; a small fixed set, so multidim boxed arrays stay resolvable too). - static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRank, int maxReferenceArrayRank) + static void EmitPrimitiveArrayEntries (TypeMapAssemblyData model, int maxArrayRank) { foreach (var primitive in PrimitiveArrayProxies) { for (int rank = 1; rank <= maxArrayRank; rank++) { diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets index 0e94bb86f9c..997a38ba646 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.targets @@ -49,10 +49,10 @@ defaults to 0 otherwise, where dynamic code can use Array.CreateInstance directly. --> <_AndroidTrimmableTypeMapMaxArrayRank Condition=" '$(_AndroidTrimmableTypeMapMaxArrayRank)' == '' and ('$(PublishAot)' == 'true' or '$(DynamicCodeSupport)' == 'false') ">3 <_AndroidTrimmableTypeMapMaxArrayRank Condition=" '$(_AndroidTrimmableTypeMapMaxArrayRank)' == '' ">0 - + <_AndroidTrimmableTypeMapMaxReferenceArrayRank Condition=" '$(_AndroidTrimmableTypeMapMaxReferenceArrayRank)' == '' and ('$(PublishAot)' == 'true' or '$(DynamicCodeSupport)' == 'false') ">1 <_AndroidTrimmableTypeMapMaxReferenceArrayRank Condition=" '$(_AndroidTrimmableTypeMapMaxReferenceArrayRank)' == '' ">0 diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs index 0602dfa005b..493a9daca71 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateTrimmableTypeMap.cs @@ -102,17 +102,18 @@ public void LogJniAddNativeMethodRegistrationAttributeError (string managedTypeN /// /// Maximum array rank for which the generator emits per-rank __ArrayMapRank{N} - /// sentinels and primitive-element TypeMap entries (e.g. int[][][]). 0 disables. - /// Set via $(_AndroidTrimmableTypeMapMaxArrayRank). + /// sentinels and built-in element TypeMap entries: primitive arrays + /// (e.g. int[][][]), System.String, and boxed Nullable<T>. + /// 0 disables. Set via $(_AndroidTrimmableTypeMapMaxArrayRank). /// public int MaxArrayRank { get; set; } /// - /// Maximum array rank for reference-type element TypeMap entries — scanned Java peers, - /// System.String, and boxed Nullable<T> (which map to Java reference arrays). - /// Kept lower than because jagged reference arrays are rare and each - /// nesting level multiplies the generated JavaObjectArray<T> closure. 0 disables. Set - /// via $(_AndroidTrimmableTypeMapMaxReferenceArrayRank). + /// Maximum array rank for scanned Java peer reference-type TypeMap entries. + /// Kept lower than because jagged arrays for arbitrary scanned + /// reference types are rare and each nesting level multiplies the generated + /// JavaObjectArray<T> closure. 0 disables. Set via + /// $(_AndroidTrimmableTypeMapMaxReferenceArrayRank). /// public int MaxReferenceArrayRank { get; set; } From 7a66dfec59fd69e48b3ba02c44fe501b08c2ef25 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Wed, 8 Jul 2026 11:04:23 +0200 Subject: [PATCH 11/11] [TrimmableTypeMap] Track reference array rank incrementally Add _AndroidTrimmableTypeMapMaxReferenceArrayRank to the build property cache so changing the reference-array rank invalidates _GenerateTrimmableTypeMap just like the primitive/built-in rank does. Add a regression test for the property-cache invalidation and clarify the TypeMapAssemblyGenerator parameter docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/TypeMapAssemblyGenerator.cs | 4 +-- .../TrimmableTypeMapBuildTests.cs | 27 +++++++++++++++++++ .../Xamarin.Android.Common.targets | 1 + 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs index 4687558dba8..292189fe3b6 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs @@ -27,8 +27,8 @@ public TypeMapAssemblyGenerator (Version systemRuntimeVersion) /// /// When true, uses Java.Lang.Object as the shared anchor type. When false, emits a per-assembly anchor. /// - /// Max rank for per-rank primitive array TypeMap entries. 0 disables. - /// Max rank for per-rank reference-type (Java peer, String, boxed Nullable) array TypeMap entries. 0 disables. + /// Max rank for per-rank built-in element array TypeMap entries. 0 disables. + /// Max rank for per-rank scanned Java peer reference-type array TypeMap entries. 0 disables. public void Generate (IReadOnlyList peers, Stream stream, string assemblyName, bool useSharedTypemapUniverse = false, int maxArrayRank = 0, int maxReferenceArrayRank = 0) { var model = ModelBuilder.Build (peers, assemblyName + ".dll", assemblyName, maxArrayRank, maxReferenceArrayRank); diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs index 431225f683c..b943a41e3df 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/TrimmableTypeMapBuildTests.cs @@ -304,6 +304,33 @@ public void Build_WithTrimmableTypeMap_ArrayRankChangeRegeneratesTypeMap () builder.Output.AssertTargetIsNotSkipped ("_GenerateTrimmableTypeMap"); } + [Test] + public void Build_WithTrimmableTypeMap_ReferenceArrayRankChangeRegeneratesTypeMap () + { + if (IgnoreUnsupportedConfiguration (AndroidRuntime.CoreCLR, release: true)) { + return; + } + + var proj = new XamarinAndroidApplicationProject { + IsRelease = true, + }; + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable"); + proj.SetProperty ("_AndroidTrimmableTypeMapMaxArrayRank", "0"); + proj.SetProperty ("_AndroidTrimmableTypeMapMaxReferenceArrayRank", "0"); + + using var builder = CreateApkBuilder (); + Assert.IsTrue (builder.Build (proj), "First build should have succeeded."); + builder.Output.AssertTargetIsNotSkipped ("_GenerateTrimmableTypeMap"); + + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true), "Second build should have succeeded."); + builder.Output.AssertTargetIsSkipped ("_GenerateTrimmableTypeMap"); + + proj.SetProperty ("_AndroidTrimmableTypeMapMaxReferenceArrayRank", "1"); + Assert.IsTrue (builder.Build (proj, doNotCleanupOnUpdate: true), "Reference array rank change build should have succeeded."); + builder.Output.AssertTargetIsNotSkipped ("_GenerateTrimmableTypeMap"); + } + [Test] public void Build_WithTrimmableTypeMap_DoesNotHitCopyIfChangedMismatch ([Values (AndroidRuntime.CoreCLR, AndroidRuntime.NativeAOT)] AndroidRuntime runtime) { diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index 1b4b676cb37..c20d49c88c4 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -993,6 +993,7 @@ because xbuild doesn't support framework reference assemblies. <_PropertyCacheItems Include="AndroidUseDesignerAssembly=$(AndroidUseDesignerAssembly)" /> <_PropertyCacheItems Include="_AndroidTypeMapImplementation=$(_AndroidTypeMapImplementation)" /> <_PropertyCacheItems Include="_AndroidTrimmableTypeMapMaxArrayRank=$(_AndroidTrimmableTypeMapMaxArrayRank)" /> + <_PropertyCacheItems Include="_AndroidTrimmableTypeMapMaxReferenceArrayRank=$(_AndroidTrimmableTypeMapMaxReferenceArrayRank)" /> <_PropertyCacheItems Include="_AndroidUseMarshalMethods=$(_AndroidUseMarshalMethods)" /> <_PropertyCacheItems Include="_AndroidJcwCodegenTarget=$(_AndroidJcwCodegenTarget)" /> <_PropertyCacheItems Include="_AndroidAssemblyStoreCompressionLevel=$(_AndroidAssemblyStoreCompressionLevel)" />