From 01d9e69bb8a293df4dcdeed94c90819d4a2cf8bc Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 24 Apr 2026 19:10:50 +0200 Subject: [PATCH 1/6] [TrimmableTypeMap] Fix bugs exposed by Release+CoreCLR+trimmable test run Fix several trimmable typemap generator and runtime bugs that prevented Mono.Android.NET-Tests from passing in Release+CoreCLR+trimmable mode: Generator: - Use (managedName, assemblyName) tuple as scanner dictionary key to prevent duplicate-type crashes when two assemblies define the same managed type name (e.g. Java.Lang.Throwable in both Java.Interop and Mono.Android) - Add MergeCrossAssemblyAliases to propagate aliases across assembly boundaries before splitting peers into per-assembly typemap universes (Release only) - Emit TypeMapAssociationAttribute for all entries with proxies so the runtime proxy type map is populated correctly (fixes CreatePeer for interface types) - ForceUnconditionalEntries=true workaround for dotnet/runtime#127004 (trimmer strips TypeMapAssociation attributes when TypeMap references the same type) - Fix invalid ParameterHandle (was default/row-0); use valid 1-based row index - Skip inner per-RID builds in _GenerateTrimmableTypeMap (they lack the full assembly set needed for correct deferred-registration propagation) Runtime: - Add JavaPeerProxy.ShouldSkipActivation(IntPtr) to detect existing managed peers and prevent duplicate peer creation during UCO constructor callbacks - Use ShouldSkipActivation in UCO nctor callbacks instead of WithinNewObjectScope Build: - Add _PrepareTrimmableNativeConfigAssemblies target to populate native config with typemap DLL paths (must run unconditionally, not inside _GenerateJavaStubs) - Add typemap DLLs to _ShrunkAssemblies to keep _RemoveRegisterAttribute counts in sync with _ResolvedAssemblies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/ModelBuilder.cs | 22 +- .../Generator/PEAssemblyBuilder.cs | 4 +- .../Generator/TypeMapAssemblyEmitter.cs | 37 ++- .../Scanner/AssemblyIndex.cs | 25 ++ .../Scanner/JavaPeerInfo.cs | 7 + .../Scanner/JavaPeerScanner.cs | 49 ++-- .../TrimmableTypeMapGenerator.cs | 184 ++++++++++++-- .../Java.Interop/JavaPeerProxy.cs | 18 ++ .../TrimmableTypeMap.cs | 5 +- ...soft.Android.Sdk.TypeMap.Trimmable.targets | 42 +++- .../GenerateNativeApplicationConfigSources.cs | 9 + .../Tasks/GenerateTrimmableTypeMapTests.cs | 2 + .../TrimmableTypeMapBuildTests.cs | 86 +++++-- .../Xamarin.Android.Common.targets | 1 + .../TrimmableTypeMapGeneratorTests.cs | 225 ++++++++++++++++++ .../TypeMapAssemblyGeneratorTests.cs | 2 +- .../Generator/TypeMapModelBuilderTests.cs | 49 ++-- .../Scanner/JavaPeerScannerTests.cs | 36 +++ .../TestFixtures/StubAttributes.cs | 14 ++ .../TestFixtures/TestTypes.cs | 22 ++ 20 files changed, 735 insertions(+), 104 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index 6a691882d6e..996ce142a13 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -16,6 +16,13 @@ static class ModelBuilder { const string ProxyTypeSuffix = "_Proxy"; + // Workaround for https://github.com/dotnet/runtime/issues/127004 + // When true, all TypeMap entries are emitted as 2-arg (unconditional) to avoid the + // trimmer bug that strips TypeMapAssociation attributes when a TypeMap attribute + // references the same type. Set to false once the runtime bug is fixed to re-enable + // 3-arg conditional entries that allow unused framework bindings to be trimmed away. + const bool ForceUnconditionalEntries = true; + static readonly HashSet EssentialRuntimeTypes = new (StringComparer.Ordinal) { "java/lang/Object", "java/lang/Class", @@ -122,8 +129,15 @@ static void EmitPeers (TypeMapAssemblyData model, string jniName, model.ProxyTypes.Add (proxy); } - model.Entries.Add (BuildEntry (peer, proxy, assemblyName, jniName)); - if (proxy != null && peer.IsGenericDefinition) { + var entry = BuildEntry (peer, proxy, assemblyName, jniName); + model.Entries.Add (entry); + + // Emit a TypeMapAssociation for every entry that has a proxy. + // The runtime's _proxyTypeMap (GetOrCreateProxyTypeMapping) is populated from + // TypeMapAssociationAttribute — NOT from TypeMapAttribute's 3rd arg. + // Without this, the proxy type map is empty and CreatePeer fails for + // interface types like IIterator where targetType-based lookup is needed. + if (proxy != null) { model.Associations.Add (new TypeMapAssociationData { SourceTypeReference = AssemblyQualify (peer.ManagedTypeName, peer.AssemblyName), AliasProxyTypeReference = AssemblyQualify ($"{proxy.Namespace}.{proxy.TypeName}", assemblyName), @@ -353,7 +367,9 @@ static TypeMapAttributeData BuildEntry (JavaPeerInfo peer, JavaPeerProxyData? pr proxyRef = AssemblyQualify (peer.ManagedTypeName, peer.AssemblyName); } - bool isUnconditional = IsUnconditionalEntry (peer); + // When ForceUnconditionalEntries is true, always emit 2-arg (unconditional) TypeMap + // attributes to work around https://github.com/dotnet/runtime/issues/127004. + bool isUnconditional = ForceUnconditionalEntries || IsUnconditionalEntry (peer); string? targetRef = null; if (!isUnconditional) { targetRef = AssemblyQualify (peer.ManagedTypeName, peer.AssemblyName); diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs index ba1dbc484f7..07c85992c21 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs @@ -313,7 +313,7 @@ public MethodDefinitionHandle EmitBody (string name, MethodAttributes attrs, attrs, MethodImplAttributes.IL, Metadata.GetOrAddString (name), sigBlobHandle, - bodyOffset, default); + bodyOffset, MetadataTokens.ParameterHandle (Metadata.GetRowCount (TableIndex.Param) + 1)); } /// @@ -358,7 +358,7 @@ public MethodDefinitionHandle EmitBody (string name, MethodAttributes attrs, attrs, MethodImplAttributes.IL, Metadata.GetOrAddString (name), sigBlobHandle, - bodyOffset, default); + bodyOffset, MetadataTokens.ParameterHandle (Metadata.GetRowCount (TableIndex.Param) + 1)); } /// diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs index 36321388253..417b1096f60 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs @@ -75,6 +75,7 @@ sealed class TypeMapAssemblyEmitter TypeReferenceHandle _iJavaPeerableRef; TypeReferenceHandle _jniHandleOwnershipRef; TypeReferenceHandle _jniObjectReferenceRef; + TypeReferenceHandle _jniObjectReferenceTypeRef; TypeReferenceHandle _jniObjectReferenceOptionsRef; TypeReferenceHandle _iAndroidCallableWrapperRef; TypeReferenceHandle _jniEnvRef; @@ -91,7 +92,7 @@ sealed class TypeMapAssemblyEmitter MemberReferenceHandle _notSupportedExceptionCtorRef; MemberReferenceHandle _jniObjectReferenceCtorRef; MemberReferenceHandle _jniEnvDeleteRefRef; - MemberReferenceHandle _withinNewObjectScopeRef; + MemberReferenceHandle _shouldSkipActivationRef; MemberReferenceHandle _ucoAttrCtorRef; BlobHandle _ucoAttrBlobHandle; MemberReferenceHandle _typeMapAttrCtorRef2Arg; @@ -206,6 +207,8 @@ void EmitTypeReferences () metadata.GetOrAddString ("Android.Runtime"), metadata.GetOrAddString ("JNIEnv")); _jniObjectReferenceRef = metadata.AddTypeReference (_javaInteropRef, metadata.GetOrAddString ("Java.Interop"), metadata.GetOrAddString ("JniObjectReference")); + _jniObjectReferenceTypeRef = metadata.AddTypeReference (_javaInteropRef, + metadata.GetOrAddString ("Java.Interop"), metadata.GetOrAddString ("JniObjectReferenceType")); _jniObjectReferenceOptionsRef = metadata.AddTypeReference (_javaInteropRef, metadata.GetOrAddString ("Java.Interop"), metadata.GetOrAddString ("JniObjectReferenceOptions")); _iAndroidCallableWrapperRef = metadata.AddTypeReference (_pe.MonoAndroidRef, @@ -279,10 +282,16 @@ void EmitMemberReferences () rt => rt.Void (), p => p.AddParameter ().Type ().String ())); + // JniObjectReference..ctor(IntPtr handle, JniObjectReferenceType type) + // Note: The C# constructor has a default parameter (type = Invalid), but in IL there is only + // the 2-parameter overload. We must emit both parameters explicitly. _jniObjectReferenceCtorRef = _pe.AddMemberRef (_jniObjectReferenceRef, ".ctor", - sig => sig.MethodSignature (isInstanceMethod: true).Parameters (1, + sig => sig.MethodSignature (isInstanceMethod: true).Parameters (2, rt => rt.Void (), - p => p.AddParameter ().Type ().IntPtr ())); + p => { + p.AddParameter ().Type ().IntPtr (); + p.AddParameter ().Type ().Type (_jniObjectReferenceTypeRef, true); + })); // JNIEnv.DeleteRef(IntPtr, JniHandleOwnership) — static, internal // Used by JI-style activation to clean up the original handle after constructing the peer. @@ -295,11 +304,11 @@ void EmitMemberReferences () p.AddParameter ().Type ().Type (_jniHandleOwnershipRef, true); })); - // JniEnvironment.get_WithinNewObjectScope() -> bool (static property) - _withinNewObjectScopeRef = _pe.AddMemberRef (_jniEnvironmentRef, "get_WithinNewObjectScope", - sig => sig.MethodSignature ().Parameters (0, + // JavaPeerProxy.ShouldSkipActivation(IntPtr) -> bool (static method) + _shouldSkipActivationRef = _pe.AddMemberRef (_javaPeerProxyNonGenericRef, "ShouldSkipActivation", + sig => sig.MethodSignature ().Parameters (1, rt => rt.Type ().Boolean (), - p => { })); + p => { p.AddParameter ().Type ().IntPtr (); })); // JniNativeMethod..ctor(byte*, byte*, IntPtr) _jniNativeMethodCtorRef = _pe.AddMemberRef (_jniNativeMethodRef, ".ctor", @@ -691,9 +700,10 @@ void EmitCreateInstanceViaJavaInteropNewobj (EntityHandle typeRef) EmitCreateInstanceBodyWithLocals ( EncodeJniObjectReferenceAndObjectLocals, encoder => { - // var jniRef = new JniObjectReference(handle); + // var jniRef = new JniObjectReference(handle, JniObjectReferenceType.Invalid); encoder.LoadLocalAddress (0); encoder.OpCode (ILOpCode.Ldarg_1); // handle + encoder.LoadConstantI4 (0); // JniObjectReferenceType.Invalid encoder.Call (_jniObjectReferenceCtorRef); // var result = new TargetType(ref jniRef, JniObjectReferenceOptions.Copy); @@ -738,9 +748,10 @@ void EmitCreateInstanceInheritedJavaInteropCtor (EntityHandle targetTypeRef, Act // dup obj (one copy for the call, one for the return) encoder.OpCode (ILOpCode.Dup); - // var jniRef = new JniObjectReference(handle); + // var jniRef = new JniObjectReference(handle, JniObjectReferenceType.Invalid); encoder.LoadLocalAddress (0); encoder.OpCode (ILOpCode.Ldarg_1); // handle + encoder.LoadConstantI4 (0); // JniObjectReferenceType.Invalid encoder.Call (_jniObjectReferenceCtorRef); // obj.BaseCtor(ref jniRef, JniObjectReferenceOptions.Copy); @@ -932,6 +943,7 @@ MethodDefinitionHandle EmitUcoConstructor (UcoConstructorData uco, JavaPeerProxy enc.LoadLocalAddress (3); // jniRef enc.LoadArgument (1); // self + enc.LoadConstantI4 (0); // JniObjectReferenceType.Invalid enc.Call (_jniObjectReferenceCtorRef); if (activationCtor.IsOnLeafType) { @@ -990,7 +1002,7 @@ MethodDefinitionHandle EmitUcoConstructor (UcoConstructorData uco, JavaPeerProxy /// /// if (!JniEnvironment.BeginMarshalMethod(jnienv, out envp, out runtime)) return; /// try { - /// if (!JniEnvironment.WithinNewObjectScope) { [emitActivation] } + /// if (!JavaPeerProxy.ShouldSkipActivation(self)) { [emitActivation] } /// } catch (Exception e) { /// runtime?.OnUserUnhandledException(ref envp, e); /// } finally { @@ -1016,9 +1028,10 @@ void EmitUcoConstructorBodyWithMarshal (InstructionEncoder encoder, ControlFlowB encoder.Call (_beginMarshalMethodRef); encoder.Branch (ILOpCode.Brfalse, afterAll); - // TRY — check WithinNewObjectScope, then run activation code. + // TRY — check ShouldSkipActivation, then run activation code. encoder.MarkLabel (tryStart); - encoder.Call (_withinNewObjectScopeRef); + encoder.LoadArgument (1); // self (IntPtr) + encoder.Call (_shouldSkipActivationRef); encoder.Branch (ILOpCode.Brtrue, skipLabel); emitActivation (encoder); diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs index 190fd095d8c..1db8dfd8309 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs @@ -95,6 +95,8 @@ void Build () if (attrName == "RegisterAttribute") { registerInfo = ParseRegisterAttribute (ca); registerInfo = registerInfo with { JniName = registerInfo.JniName.Replace ('.', '/') }; + } else if (attrName == "JniTypeSignatureAttribute") { + registerInfo = ParseJniTypeSignatureAttribute (ca); } else if (attrName == "ExportAttribute") { // [Export] is a method-level attribute; it is parsed at scan time by JavaPeerScanner } else if (IsKnownComponentAttribute (attrName)) { @@ -218,6 +220,28 @@ internal RegisterInfo ParseRegisterAttribute (CustomAttribute ca) return ParseRegisterInfo (DecodeAttribute (ca)); } + internal RegisterInfo ParseJniTypeSignatureAttribute (CustomAttribute ca) + { + var value = DecodeAttribute (ca); + + string jniName = ""; + bool doNotGenerateAcw = false; + + if (value.FixedArguments.Length > 0) { + jniName = (string?)value.FixedArguments [0].Value ?? ""; + } + + if (TryGetNamedArgument (value, "GenerateJavaPeer", out var generateJavaPeer)) { + doNotGenerateAcw = !generateJavaPeer; + } + + return new RegisterInfo { + JniName = jniName.Replace ('.', '/'), + DoNotGenerateAcw = doNotGenerateAcw, + IsFromJniTypeSignature = true, + }; + } + internal CustomAttributeValue DecodeAttribute (CustomAttribute ca) { return ca.DecodeValue (customAttributeTypeProvider); @@ -504,6 +528,7 @@ sealed record RegisterInfo public string? Signature { get; init; } public string? Connector { get; init; } public bool DoNotGenerateAcw { get; init; } + public bool IsFromJniTypeSignature { get; init; } } /// diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs index eff38fd1d51..bcc45d1b1c5 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs @@ -65,6 +65,13 @@ public sealed record JavaPeerInfo /// public bool DoNotGenerateAcw { get; init; } + /// + /// True when the type was discovered via [JniTypeSignatureAttribute] + /// rather than [RegisterAttribute]. Used to resolve cross-assembly + /// alias ownership: [Register] types take precedence. + /// + public bool IsFromJniTypeSignature { get; init; } + /// /// Types with component attributes ([Activity], [Service], etc.), /// custom views from layout XML, or manifest-declared components diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs index 08ee7cd337d..2e09766a2fa 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs @@ -86,12 +86,15 @@ public List Scan (IReadOnlyList<(string Name, PEReader Reader)> as assemblyCache [index.AssemblyName] = index; } - var resultsByManagedName = new Dictionary (StringComparer.Ordinal); + // Key by (managedTypeName, assemblyName) to avoid collisions when two assemblies + // define a type with the same managed name (e.g. Java.Lang.Throwable in both + // Java.Interop and Mono.Android). + var resultsByQualifiedName = new Dictionary<(string ManagedName, string AssemblyName), JavaPeerInfo> (); foreach (var index in assemblyCache.Values) { - ScanAssembly (index, resultsByManagedName); + ScanAssembly (index, resultsByQualifiedName); } - ForceUnconditionalCrossReferences (resultsByManagedName, assemblyCache); - return new List (resultsByManagedName.Values); + ForceUnconditionalCrossReferences (resultsByQualifiedName, assemblyCache); + return new List (resultsByQualifiedName.Values); } /// @@ -112,19 +115,19 @@ internal AssemblyManifestInfo ScanAssemblyManifestInfo () /// [Application(ManageSpaceActivity = typeof(X))] must be unconditional, /// because the manifest will reference them even if nothing else does. /// - static void ForceUnconditionalCrossReferences (Dictionary resultsByManagedName, Dictionary assemblyCache) + static void ForceUnconditionalCrossReferences (Dictionary<(string ManagedName, string AssemblyName), JavaPeerInfo> results, Dictionary assemblyCache) { foreach (var index in assemblyCache.Values) { foreach (var attrInfo in index.AttributesByType.Values) { if (attrInfo is ApplicationAttributeInfo applicationAttributeInfo) { - ForceUnconditionalIfPresent (resultsByManagedName, applicationAttributeInfo.BackupAgent); - ForceUnconditionalIfPresent (resultsByManagedName, applicationAttributeInfo.ManageSpaceActivity); + ForceUnconditionalIfPresent (results, applicationAttributeInfo.BackupAgent); + ForceUnconditionalIfPresent (results, applicationAttributeInfo.ManageSpaceActivity); } } } } - static void ForceUnconditionalIfPresent (Dictionary resultsByManagedName, string? managedTypeName) + static void ForceUnconditionalIfPresent (Dictionary<(string ManagedName, string AssemblyName), JavaPeerInfo> results, string? managedTypeName) { if (managedTypeName is null) { return; @@ -135,26 +138,27 @@ static void ForceUnconditionalIfPresent (Dictionary result return; } - // Try exact match first (handles both plain and assembly-qualified names) - if (resultsByManagedName.TryGetValue (managedTypeName, out var peer)) { - resultsByManagedName [managedTypeName] = peer with { IsUnconditional = true }; - return; - } - // TryGetTypeProperty may return assembly-qualified names like "Ns.Type, Assembly, ..." // Strip to just the type name for lookup var commaIndex = managedTypeName.IndexOf (','); - if (commaIndex <= 0) { + if (commaIndex > 0) { + managedTypeName = managedTypeName.Substring (0, commaIndex).Trim (); + } + + if (managedTypeName.Length == 0) { return; } - var typeName = managedTypeName.Substring (0, commaIndex).Trim (); - if (typeName.Length > 0 && resultsByManagedName.TryGetValue (typeName, out peer)) { - resultsByManagedName [typeName] = peer with { IsUnconditional = true }; + // Search by managed type name across all assemblies (BackupAgent/ManageSpaceActivity + // attribute values are not assembly-qualified). + foreach (var key in results.Keys) { + if (string.Equals (key.ManagedName, managedTypeName, StringComparison.Ordinal)) { + results [key] = results [key] with { IsUnconditional = true }; + } } } - void ScanAssembly (AssemblyIndex index, Dictionary results) + void ScanAssembly (AssemblyIndex index, Dictionary<(string ManagedName, string AssemblyName), JavaPeerInfo> results) { foreach (var typeHandle in index.Reader.TypeDefinitions) { var typeDef = index.Reader.GetTypeDefinition (typeHandle); @@ -237,6 +241,7 @@ void ScanAssembly (AssemblyIndex index, Dictionary results IsInterface = isInterface, IsAbstract = isAbstract, DoNotGenerateAcw = doNotGenerateAcw, + IsFromJniTypeSignature = registerInfo?.IsFromJniTypeSignature ?? false, IsUnconditional = isUnconditional, CannotRegisterInStaticConstructor = cannotRegisterInStaticConstructor, MarshalMethods = marshalMethods, @@ -248,7 +253,7 @@ void ScanAssembly (AssemblyIndex index, Dictionary results ComponentAttribute = ToComponentInfo (attrInfo), }; - results [fullName] = peer; + results [(fullName, index.AssemblyName)] = peer; } } @@ -901,7 +906,7 @@ static string GetJavaAccess (MethodAttributes access) }; } - string? ResolveBaseJavaName (TypeDefinition typeDef, AssemblyIndex index, Dictionary results) + string? ResolveBaseJavaName (TypeDefinition typeDef, AssemblyIndex index, Dictionary<(string ManagedName, string AssemblyName), JavaPeerInfo> results) { if (!TryResolveBaseType (typeDef, index, out var baseTypeDef, out _, out var baseIndex, out var baseTypeName, out _)) { return null; @@ -914,7 +919,7 @@ static string GetJavaAccess (MethodAttributes access) } // Fall back to already-scanned results (component-attributed or CRC64-computed peers) - if (results.TryGetValue (baseTypeName, out var basePeer)) { + if (results.TryGetValue ((baseTypeName, baseIndex.AssemblyName), out var basePeer)) { return basePeer.JavaName; } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index 5d14490503a..07d689854bb 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -11,6 +11,11 @@ public class TrimmableTypeMapGenerator { readonly ITrimmableTypeMapLogger logger; + static readonly HashSet RequiredFrameworkDeferredRegistrationTypes = new (StringComparer.Ordinal) { + "android/app/Application", + "android/app/Instrumentation", + }; + public TrimmableTypeMapGenerator (ITrimmableTypeMapLogger logger) { this.logger = logger ?? throw new ArgumentNullException (nameof (logger)); @@ -41,6 +46,7 @@ public TrimmableTypeMapResult Execute ( RootManifestReferencedTypes (allPeers, PrepareManifestForRooting (manifestTemplate, manifestConfig)); PropagateDeferredRegistrationToBaseClasses (allPeers); + PropagateCannotRegisterToDescendants (allPeers); var generatedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion, useSharedTypemapUniverse); var jcwPeers = allPeers.Where (p => @@ -49,14 +55,7 @@ public TrimmableTypeMapResult Execute ( logger.LogGeneratingJcwFilesInfo (jcwPeers.Count, allPeers.Count); var generatedJavaSources = GenerateJcwJavaSources (jcwPeers); - // Collect Application/Instrumentation types that need deferred registerNatives - var appRegTypes = allPeers - // Include all deferred-registration peers here: framework MCWs still need - // ApplicationRegistration.java even without generated ACWs, and abstract - // base types can own the native methods that derived types invoke. - .Where (p => p.CannotRegisterInStaticConstructor) - .Select (p => JniSignatureHelper.JniNameToJavaName (p.JavaName)) - .ToList (); + var appRegTypes = CollectApplicationRegistrationTypes (allPeers); if (appRegTypes.Count > 0) { logger.LogDeferredRegistrationTypesInfo (appRegTypes.Count); } @@ -68,6 +67,33 @@ public TrimmableTypeMapResult Execute ( return new TrimmableTypeMapResult (generatedAssemblies, generatedJavaSources, allPeers, manifest, appRegTypes); } + internal static List CollectApplicationRegistrationTypes (List allPeers) + { + var appRegTypes = new List (); + var seen = new HashSet (StringComparer.Ordinal); + + foreach (var peer in allPeers) { + if (!peer.CannotRegisterInStaticConstructor) { + continue; + } + + // ApplicationRegistration.java is compiled against the app's target Android API + // surface. Legacy framework descendants such as android.test.* may not exist there, + // so keep only the two framework roots plus app/runtime types that participate in + // the deferred-registration flow. + if (peer.DoNotGenerateAcw && !RequiredFrameworkDeferredRegistrationTypes.Contains (peer.JavaName)) { + continue; + } + + var javaName = JniSignatureHelper.JniNameToJavaName (peer.JavaName); + if (seen.Add (javaName)) { + appRegTypes.Add (javaName); + } + } + + return appRegTypes; + } + GeneratedManifest GenerateManifest (List allPeers, AssemblyManifestInfo assemblyManifestInfo, ManifestConfig config, XDocument? manifestTemplate) { @@ -115,19 +141,37 @@ GeneratedManifest GenerateManifest (List allPeers, AssemblyManifes List GenerateTypeMapAssemblies (List allPeers, Version systemRuntimeVersion, bool useSharedTypemapUniverse) { - var peersByAssembly = allPeers.GroupBy (p => p.AssemblyName, StringComparer.Ordinal).OrderBy (g => g.Key, StringComparer.Ordinal); + List<(string AssemblyName, List Peers)> peersByAssembly; + + if (useSharedTypemapUniverse) { + // In Release builds all per-assembly typemaps are merged into a single + // shared universe dictionary. Cross-assembly aliases (e.g. Java.Lang.Object + // in Mono.Android and JavaObject in Java.Interop both mapping to + // java/lang/Object) must be moved into the owner assembly's group so the + // ModelBuilder can handle them as an alias group and the runtime doesn't + // crash on duplicate keys. + peersByAssembly = MergeCrossAssemblyAliases (allPeers); + } else { + // In Debug builds each typemap DLL has its own per-assembly universe, so + // cross-assembly duplicates don't collide — simple GroupBy is sufficient. + peersByAssembly = allPeers + .GroupBy (p => p.AssemblyName, StringComparer.Ordinal) + .OrderBy (g => g.Key, StringComparer.Ordinal) + .Select (g => (g.Key, g.ToList ())) + .ToList (); + } + var generatedAssemblies = new List (); var perAssemblyNames = new List (); var generator = new TypeMapAssemblyGenerator (systemRuntimeVersion); - foreach (var group in peersByAssembly) { - string assemblyName = $"_{group.Key}.TypeMap"; - perAssemblyNames.Add (assemblyName); - var peers = group.ToList (); + foreach (var (assemblyName, peers) in peersByAssembly) { + string typeMapAssemblyName = $"_{assemblyName}.TypeMap"; + perAssemblyNames.Add (typeMapAssemblyName); var stream = new MemoryStream (); - generator.Generate (peers, stream, assemblyName, useSharedTypemapUniverse); + generator.Generate (peers, stream, typeMapAssemblyName, useSharedTypemapUniverse); stream.Position = 0; - generatedAssemblies.Add (new GeneratedAssembly (assemblyName, stream)); - logger.LogGeneratedTypeMapAssemblyInfo (assemblyName, peers.Count); + generatedAssemblies.Add (new GeneratedAssembly (typeMapAssemblyName, stream)); + logger.LogGeneratedTypeMapAssemblyInfo (typeMapAssemblyName, peers.Count); } var rootStream = new MemoryStream (); var rootGenerator = new RootTypeMapAssemblyGenerator (systemRuntimeVersion); @@ -139,6 +183,74 @@ List GenerateTypeMapAssemblies (List allPeers, return generatedAssemblies; } + /// + /// Groups peers by assembly, merging cross-assembly aliases into a single group. + /// When the same JNI name appears in multiple assemblies (e.g. Java.Lang.Object + /// in Mono.Android and JavaObject in Java.Interop both mapping + /// to java/lang/Object), peers from later assemblies are moved into the owner + /// assembly's group so the can handle them as an alias group. + /// + /// + /// Ownership is determined by [Register] over [JniTypeSignature] — the + /// canonical MCW binding type takes precedence. Among peers with the same attribute + /// kind, the first assembly in sorted order wins. + /// + internal static List<(string AssemblyName, List Peers)> MergeCrossAssemblyAliases (List allPeers) + { + var groups = new SortedDictionary> (StringComparer.Ordinal); + + // Group by assembly (sorted order) + foreach (var peer in allPeers) { + if (!groups.TryGetValue (peer.AssemblyName, out var list)) { + list = []; + groups [peer.AssemblyName] = list; + } + list.Add (peer); + } + + // Build JNI name → owner assembly map. + // [Register] types take precedence over [JniTypeSignature] types. + // Among peers of the same kind, the first assembly (sorted order) wins. + var jniNameOwner = new Dictionary (StringComparer.Ordinal); + foreach (var kvp in groups) { + string assemblyName = kvp.Key; + foreach (var peer in kvp.Value) { + if (!jniNameOwner.TryGetValue (peer.JavaName, out var current)) { + jniNameOwner [peer.JavaName] = (assemblyName, peer.IsFromJniTypeSignature); + } else if (current.IsFromJniTypeSignature && !peer.IsFromJniTypeSignature) { + // [Register] type takes ownership from [JniTypeSignature] type + jniNameOwner [peer.JavaName] = (assemblyName, false); + } + } + } + + // Move colliding peers to the owner assembly + var movedPeers = new List<(JavaPeerInfo Peer, string TargetAssembly)> (); + foreach (var kvp in groups) { + string assemblyName = kvp.Key; + foreach (var peer in kvp.Value) { + var owner = jniNameOwner [peer.JavaName]; + if (!string.Equals (owner.AssemblyName, assemblyName, StringComparison.Ordinal)) { + movedPeers.Add ((peer, owner.AssemblyName)); + } + } + } + + foreach (var moved in movedPeers) { + groups [moved.Peer.AssemblyName].Remove (moved.Peer); + groups [moved.TargetAssembly].Add (moved.Peer); + } + + // Return non-empty groups + var result = new List<(string, List)> (); + foreach (var kvp in groups) { + if (kvp.Value.Count > 0) { + result.Add ((kvp.Key, kvp.Value)); + } + } + return result; + } + List GenerateJcwJavaSources (List allPeers) { var jcwGenerator = new JcwJavaSourceGenerator (); @@ -218,8 +330,7 @@ internal void RootManifestReferencedTypes (List allPeers, XDocumen /// TestInstrumentation_1 must also defer — otherwise the base class <clinit> will call /// registerNatives before the managed runtime is ready. /// - internal static void PropagateDeferredRegistrationToBaseClasses (List allPeers) - { + internal static void PropagateDeferredRegistrationToBaseClasses (List allPeers) { // In practice only 1–2 types need propagation (one Application, maybe one // Instrumentation), each with a short base-class chain. A linear scan per // ancestor is simpler and cheaper than building a Dictionary> @@ -248,6 +359,43 @@ static void PropagateToAncestors (string? baseJniName, List allPee } } + /// + /// Propagates DOWN + /// from Application/Instrumentation types to all their descendants. Any subclass of + /// an Instrumentation/Application type can be loaded by Android before the native + /// library is ready, so it must also use the lazy __md_registerNatives pattern. + /// + internal static void PropagateCannotRegisterToDescendants (List allPeers) + { + // Build a set of JavaNames that have CannotRegisterInStaticConstructor + var cannotRegister = new HashSet (StringComparer.Ordinal); + foreach (var peer in allPeers) { + if (peer.CannotRegisterInStaticConstructor) { + cannotRegister.Add (peer.JavaName); + } + } + + // Also include the framework base types + cannotRegister.Add ("android/app/Application"); + cannotRegister.Add ("android/app/Instrumentation"); + + // Propagate to descendants: if your base is in the set, you're in the set too + bool changed = true; + while (changed) { + changed = false; + foreach (var peer in allPeers) { + if (peer.CannotRegisterInStaticConstructor || peer.BaseJavaName is null) { + continue; + } + if (cannotRegister.Contains (peer.BaseJavaName)) { + peer.CannotRegisterInStaticConstructor = true; + cannotRegister.Add (peer.JavaName); + changed = true; + } + } + } + } + static void AddPeerByDotName (Dictionary> peersByDotName, string dotName, JavaPeerInfo peer) { if (!peersByDotName.TryGetValue (dotName, out var list)) { diff --git a/src/Mono.Android/Java.Interop/JavaPeerProxy.cs b/src/Mono.Android/Java.Interop/JavaPeerProxy.cs index cfe3611936c..88f7fa32244 100644 --- a/src/Mono.Android/Java.Interop/JavaPeerProxy.cs +++ b/src/Mono.Android/Java.Interop/JavaPeerProxy.cs @@ -85,6 +85,24 @@ protected JavaPeerProxy ( /// /// A factory for creating containers of the target type, or null if not supported. public virtual JavaPeerContainerFactory? GetContainerFactory () => null; + + /// + /// Returns when the UCO constructor callback should skip + /// activation because a managed peer already exists for the given JNI handle + /// (e.g., when called from FinishCreateInstance after StartCreateInstance + /// already registered the peer). + /// + public static bool ShouldSkipActivation (IntPtr jniSelf) + { + var reference = new JniObjectReference (jniSelf, JniObjectReferenceType.Invalid); + var peer = JniEnvironment.Runtime.ValueManager.PeekPeer (reference); + if (peer == null) { + return false; + } + var state = peer.JniManagedPeerState; + return (state & JniManagedPeerStates.Activatable) != JniManagedPeerStates.Activatable + && (state & JniManagedPeerStates.Replaceable) != JniManagedPeerStates.Replaceable; + } } /// diff --git a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs index 7b94c8f2184..eefb7cc2ac5 100644 --- a/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs +++ b/src/Mono.Android/Microsoft.Android.Runtime/TrimmableTypeMap.cs @@ -61,7 +61,7 @@ public static void Initialize (IReadOnlyDictionary[] typeMaps, IRe throw new ArgumentException ("At least one typemap universe must be provided.", nameof (typeMaps)); } if (typeMaps.Length != proxyMaps.Length) { - throw new ArgumentException ($"typeMaps.Length ({typeMaps.Length}) must equal proxyMaps.Length ({proxyMaps.Length})."); + throw new ArgumentException ($"typeMaps.Length ({typeMaps.Length}) must equal proxyMaps.Length ({proxyMaps.Length}).", nameof (proxyMaps)); } var universes = new SingleUniverseTypeMap [typeMaps.Length]; for (int i = 0; i < typeMaps.Length; i++) { @@ -239,7 +239,8 @@ internal bool TryGetJniNameForManagedType (Type managedType, [NotNullWhen (true) try { objClass = JniEnvironment.Types.GetObjectClass (selfRef); targetClass = JniEnvironment.Types.FindClass (targetJniName); - return JniEnvironment.Types.IsAssignableFrom (objClass, targetClass) ? proxy : null; + var isAssignable = JniEnvironment.Types.IsAssignableFrom (objClass, targetClass); + return isAssignable ? proxy : null; } finally { JniObjectReference.Dispose (ref objClass); JniObjectReference.Dispose (ref targetClass); 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 52ce49be5cf..ec8ad004c88 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 @@ -41,9 +41,12 @@ Generate TypeMap assemblies and JCW files. AfterTargets="CoreCompile" so it runs after compilation. Uses @(ReferencePath) as the primary input (available after compilation). + Skipped in inner per-RID builds (_OuterIntermediateOutputPath is set) because + those builds lack the manifest template and full assembly set needed for correct + deferred-registration propagation. --> @@ -92,15 +95,36 @@ + + + + <_AdditionalNativeConfigResolvedAssemblies Remove="@(_AdditionalNativeConfigResolvedAssemblies)" /> + <_AdditionalNativeConfigResolvedAssemblies Include="$(_TypeMapOutputDirectory)*.dll" /> + + + @@ -122,9 +146,10 @@ <_TypeMapFirstRid Condition=" '$(_TypeMapFirstAbi)' == 'x86' ">android-x86 - + <_ResolvedAssemblies Include="$(_TypeMapOutputDirectory)*.dll"> $(_TypeMapFirstAbi) @@ -132,6 +157,12 @@ $(_TypeMapFirstAbi)/%(Filename)%(Extension) $(_TypeMapFirstAbi)/ + <_ShrunkAssemblies Include="$(_TypeMapOutputDirectory)*.dll"> + $(_TypeMapFirstAbi) + $(_TypeMapFirstRid) + $(_TypeMapFirstAbi)/%(Filename)%(Extension) + $(_TypeMapFirstAbi)/ + @@ -175,5 +206,4 @@ - diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs index 7b01f6f08e5..92aab238757 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateNativeApplicationConfigSources.cs @@ -27,6 +27,8 @@ public class GenerateNativeApplicationConfigSources : AndroidTask [Required] public ITaskItem[] ResolvedAssemblies { get; set; } = []; + public ITaskItem[]? AdditionalResolvedAssemblies { get; set; } + public ITaskItem[]? NativeLibraries { get; set; } public ITaskItem[]? NativeLibrariesNoJniPreload { get; set; } public ITaskItem[]? NativeLibrariesAlwaysJniPreload { get; set; } @@ -202,6 +204,13 @@ public override bool RunTask () GetRequiredTokens (assembly.ItemSpec, out android_runtime_jnienv_class_token, out jnienv_initialize_method_token, out jnienv_registerjninatives_method_token); } + if (AdditionalResolvedAssemblies != null) { + foreach (ITaskItem assembly in AdditionalResolvedAssemblies) { + updateNameWidth (assembly); + updateAssemblyCount (assembly); + } + } + if (!UseAssemblyStore) { int abiNameLength = 0; foreach (string abi in SupportedAbis) { diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs index 096fd7d9f34..f5954e7e77d 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateTrimmableTypeMapTests.cs @@ -169,6 +169,8 @@ public void Execute_ManifestPlaceholdersAreResolvedForRooting () var registrationText = File.ReadAllText (applicationRegistration); StringAssert.Contains ("mono.android.Runtime.registerNatives (android.app.Application.class);", registrationText); StringAssert.Contains ("mono.android.Runtime.registerNatives (android.app.Instrumentation.class);", registrationText); + StringAssert.DoesNotContain ("android.test.InstrumentationTestRunner.class", registrationText); + StringAssert.DoesNotContain ("android.test.mock.MockApplication.class", registrationText); Assert.IsFalse (warnings.Any (w => w.Code == "XA4250"), "Resolved placeholder-based manifest references should not log XA4250."); } 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 15938fd1439..70bbe93abe5 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 @@ -1,4 +1,6 @@ using System.IO; +using System.Linq; +using System.Text.RegularExpressions; using NUnit.Framework; using Xamarin.Android.Tasks; using Xamarin.ProjectTools; @@ -11,17 +13,15 @@ public class TrimmableTypeMapBuildTests : BaseTest { [Test] public void Build_WithTrimmableTypeMap_Succeeds () { - var proj = new XamarinAndroidApplicationProject (); + var proj = new XamarinAndroidApplicationProject { + IsRelease = true, + }; proj.SetRuntime (AndroidRuntime.CoreCLR); proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable"); - // Full Build will fail downstream (manifest generation not yet implemented for trimmable path), - // but _GenerateJavaStubs runs and completes before the failure point. using var builder = CreateApkBuilder (); - builder.ThrowOnBuildFailure = false; - builder.Build (proj); + Assert.IsTrue (builder.Build (proj), "Build should have succeeded."); - // Verify _GenerateJavaStubs ran by checking typemap outputs exist var intermediateDir = builder.Output.GetIntermediaryPath ("typemap"); DirectoryAssert.Exists (intermediateDir); } @@ -29,27 +29,85 @@ public void Build_WithTrimmableTypeMap_Succeeds () [Test] public void Build_WithTrimmableTypeMap_IncrementalBuild () { - var proj = new XamarinAndroidApplicationProject (); + var proj = new XamarinAndroidApplicationProject { + IsRelease = true, + }; proj.SetRuntime (AndroidRuntime.CoreCLR); proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable"); - // Full Build will fail downstream (manifest generation not yet implemented for trimmable path), - // but _GenerateJavaStubs runs and completes before the failure point. using var builder = CreateApkBuilder (); - builder.ThrowOnBuildFailure = false; - builder.Build (proj); + Assert.IsTrue (builder.Build (proj), "First build should have succeeded."); - // Verify _GenerateJavaStubs ran on the first build var intermediateDir = builder.Output.GetIntermediaryPath ("typemap"); DirectoryAssert.Exists (intermediateDir); - // Second build with no changes — _GenerateJavaStubs should be skipped - builder.Build (proj); + Assert.IsTrue (builder.Build (proj), "Second build should have succeeded."); Assert.IsTrue ( builder.Output.IsTargetSkipped ("_GenerateJavaStubs"), "_GenerateJavaStubs should be skipped on incremental build."); } + [Test] + public void Build_WithTrimmableTypeMap_DoesNotHitCopyIfChangedMismatch () + { + var proj = new XamarinAndroidApplicationProject { + IsRelease = true, + }; + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable"); + + using var builder = CreateApkBuilder (); + Assert.IsTrue (builder.Build (proj), "Build should have succeeded."); + + Assert.IsFalse ( + StringAssertEx.ContainsText (builder.LastBuildOutput, "source and destination count mismatch"), + $"{builder.BuildLogFile} should not fail with XACIC7004."); + Assert.IsFalse ( + StringAssertEx.ContainsText (builder.LastBuildOutput, "Internal error: architecture"), + $"{builder.BuildLogFile} should keep trimmable typemap assemblies aligned across ABIs."); + } + + [Test] + public void Build_WithTrimmableTypeMap_AssemblyStoreMappingsStayInRange () + { + var proj = new XamarinAndroidApplicationProject { + IsRelease = true, + }; + proj.SetRuntime (AndroidRuntime.CoreCLR); + proj.SetProperty ("_AndroidTypeMapImplementation", "trimmable"); + + using var builder = CreateApkBuilder (); + Assert.IsTrue (builder.Build (proj), "Build should have succeeded."); + + var environmentFiles = Directory.GetFiles (builder.Output.GetIntermediaryPath ("android"), "environment.*.ll"); + Assert.IsNotEmpty (environmentFiles, "Expected generated environment..ll files."); + + foreach (var environmentFile in environmentFiles) { + var abi = Path.GetFileNameWithoutExtension (environmentFile).Substring ("environment.".Length); + var manifestFile = builder.Output.GetIntermediaryPath (Path.Combine ("app_shared_libraries", abi, "assembly-store.so.manifest")); + + if (!File.Exists (manifestFile)) { + continue; + } + + var environmentText = File.ReadAllText (environmentFile); + var runtimeDataMatch = Regex.Match (environmentText, @"assembly_store_bundled_assemblies.*\[(\d+)\s+x"); + Assert.IsTrue (runtimeDataMatch.Success, $"{environmentFile} should declare assembly_store_bundled_assemblies."); + + var runtimeDataCount = int.Parse (runtimeDataMatch.Groups [1].Value); + var maxMappingIndex = File.ReadLines (manifestFile) + .Select (line => Regex.Match (line, @"\bmi:(\d+)\b")) + .Where (match => match.Success) + .Select (match => int.Parse (match.Groups [1].Value)) + .Max (); + + Assert.That ( + runtimeDataCount, + Is.GreaterThan (maxMappingIndex), + $"{Path.GetFileName (environmentFile)} should allocate enough runtime slots for {Path.GetFileName (manifestFile)}."); + } + } + [Test] public void TrimmableTypeMap_PreserveList_IsPackagedInSdk () { diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index d8d09f7aee4..eeea9637806 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -1729,6 +1729,7 @@ because xbuild doesn't support framework reference assemblies. { + new JavaPeerInfo { + JavaName = "android/app/Application", CompatJniName = "android.app.Application", + ManagedTypeName = "Android.App.Application", ManagedTypeNamespace = "Android.App", ManagedTypeShortName = "Application", + AssemblyName = "Mono.Android", DoNotGenerateAcw = true, CannotRegisterInStaticConstructor = true, + }, + new JavaPeerInfo { + JavaName = "android/app/Instrumentation", CompatJniName = "android.app.Instrumentation", + ManagedTypeName = "Android.App.Instrumentation", ManagedTypeNamespace = "Android.App", ManagedTypeShortName = "Instrumentation", + AssemblyName = "Mono.Android", DoNotGenerateAcw = true, CannotRegisterInStaticConstructor = true, + }, + new JavaPeerInfo { + JavaName = "android/test/InstrumentationTestRunner", CompatJniName = "android.test.InstrumentationTestRunner", + ManagedTypeName = "Android.Test.InstrumentationTestRunner", ManagedTypeNamespace = "Android.Test", ManagedTypeShortName = "InstrumentationTestRunner", + AssemblyName = "Mono.Android", BaseJavaName = "android/app/Instrumentation", DoNotGenerateAcw = true, CannotRegisterInStaticConstructor = true, + }, + new JavaPeerInfo { + JavaName = "android/test/mock/MockApplication", CompatJniName = "android.test.mock.MockApplication", + ManagedTypeName = "Android.Test.Mock.MockApplication", ManagedTypeNamespace = "Android.Test.Mock", ManagedTypeShortName = "MockApplication", + AssemblyName = "Mono.Android", BaseJavaName = "android/app/Application", DoNotGenerateAcw = true, CannotRegisterInStaticConstructor = true, + }, + new JavaPeerInfo { + JavaName = "my/app/BaseInstrumentation", CompatJniName = "my.app.BaseInstrumentation", + ManagedTypeName = "My.App.BaseInstrumentation", ManagedTypeNamespace = "My.App", ManagedTypeShortName = "BaseInstrumentation", + AssemblyName = "MyApp", IsAbstract = true, CannotRegisterInStaticConstructor = true, + }, + new JavaPeerInfo { + JavaName = "my/app/MyInstrumentation", CompatJniName = "my.app.MyInstrumentation", + ManagedTypeName = "My.App.MyInstrumentation", ManagedTypeNamespace = "My.App", ManagedTypeShortName = "MyInstrumentation", + AssemblyName = "MyApp", BaseJavaName = "my/app/BaseInstrumentation", CannotRegisterInStaticConstructor = true, + }, + }; + + var types = TrimmableTypeMapGenerator.CollectApplicationRegistrationTypes (peers); + + Assert.Contains ("android.app.Application", types); + Assert.Contains ("android.app.Instrumentation", types); + Assert.Contains ("my.app.BaseInstrumentation", types); + Assert.Contains ("my.app.MyInstrumentation", types); + Assert.DoesNotContain ("android.test.InstrumentationTestRunner", types); + Assert.DoesNotContain ("android.test.mock.MockApplication", types); + } + [Fact] public void Execute_NullAssemblyList_Throws () { @@ -353,6 +399,185 @@ public void RootManifestReferencedTypes_EmptyManifest_NoChanges () Assert.False (peers [0].IsUnconditional); } + [Fact] + public void MergeCrossAssemblyAliases_RegisterTakesPrecedenceOverJniTypeSignature () + { + // Java.Interop has JavaObject with [JniTypeSignature("java/lang/Object")] + var javaInteropPeer = new JavaPeerInfo { + JavaName = "java/lang/Object", CompatJniName = "java/lang/Object", + ManagedTypeName = "Java.Interop.JavaObject", ManagedTypeNamespace = "Java.Interop", ManagedTypeShortName = "JavaObject", + AssemblyName = "Java.Interop", IsFromJniTypeSignature = true, DoNotGenerateAcw = true, + }; + + // Mono.Android has Java.Lang.Object with [Register("java/lang/Object")] + var monoAndroidPeer = new JavaPeerInfo { + JavaName = "java/lang/Object", CompatJniName = "java/lang/Object", + ManagedTypeName = "Java.Lang.Object", ManagedTypeNamespace = "Java.Lang", ManagedTypeShortName = "Object", + AssemblyName = "Mono.Android", IsFromJniTypeSignature = false, DoNotGenerateAcw = true, + }; + + // Another unique peer in Java.Interop that shouldn't be moved + var otherPeer = new JavaPeerInfo { + JavaName = "java/interop/SomeHelper", CompatJniName = "java/interop/SomeHelper", + ManagedTypeName = "Java.Interop.SomeHelper", ManagedTypeNamespace = "Java.Interop", ManagedTypeShortName = "SomeHelper", + AssemblyName = "Java.Interop", IsFromJniTypeSignature = true, + }; + + var allPeers = new List { javaInteropPeer, monoAndroidPeer, otherPeer }; + var result = TrimmableTypeMapGenerator.MergeCrossAssemblyAliases (allPeers); + + // Both java/lang/Object peers should be in the Mono.Android group ([Register] wins) + var monoAndroidGroup = result.Single (g => g.AssemblyName == "Mono.Android"); + Assert.Equal (2, monoAndroidGroup.Peers.Count); + Assert.Contains (monoAndroidGroup.Peers, p => p.ManagedTypeName == "Java.Lang.Object"); + Assert.Contains (monoAndroidGroup.Peers, p => p.ManagedTypeName == "Java.Interop.JavaObject"); + + // Java.Interop should only have the unique peer + var javaInteropGroup = result.Single (g => g.AssemblyName == "Java.Interop"); + Assert.Single (javaInteropGroup.Peers); + Assert.Equal ("Java.Interop.SomeHelper", javaInteropGroup.Peers [0].ManagedTypeName); + } + + [Fact] + public void MergeCrossAssemblyAliases_NoDuplicates_NothingMoved () + { + var peer1 = new JavaPeerInfo { + JavaName = "com/example/Foo", CompatJniName = "com/example/Foo", + ManagedTypeName = "MyApp.Foo", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "Foo", + AssemblyName = "MyApp", + }; + var peer2 = new JavaPeerInfo { + JavaName = "com/example/Bar", CompatJniName = "com/example/Bar", + ManagedTypeName = "MyLib.Bar", ManagedTypeNamespace = "MyLib", ManagedTypeShortName = "Bar", + AssemblyName = "MyLib", + }; + + var result = TrimmableTypeMapGenerator.MergeCrossAssemblyAliases (new List { peer1, peer2 }); + + Assert.Equal (2, result.Count); + Assert.Single (result.Single (g => g.AssemblyName == "MyApp").Peers); + Assert.Single (result.Single (g => g.AssemblyName == "MyLib").Peers); + } + + [Fact] + public void MergeCrossAssemblyAliases_SameAssemblyAliases_NotMoved () + { + // Two peers in the same assembly with the same JNI name — within-assembly alias + // should NOT be moved; ModelBuilder handles it. + var peer1 = new JavaPeerInfo { + JavaName = "java/lang/Object", CompatJniName = "java/lang/Object", + ManagedTypeName = "Java.Lang.Object", ManagedTypeNamespace = "Java.Lang", ManagedTypeShortName = "Object", + AssemblyName = "Mono.Android", + }; + var peer2 = new JavaPeerInfo { + JavaName = "java/lang/Object", CompatJniName = "java/lang/Object", + ManagedTypeName = "Java.Lang.IDisposable", ManagedTypeNamespace = "Java.Lang", ManagedTypeShortName = "IDisposable", + AssemblyName = "Mono.Android", + }; + + var result = TrimmableTypeMapGenerator.MergeCrossAssemblyAliases (new List { peer1, peer2 }); + + Assert.Single (result); + Assert.Equal (2, result [0].Peers.Count); + } + + [Fact] + public void MergeCrossAssemblyAliases_SameManagedName_DifferentAssemblies_MergedCorrectly () + { + // Reproduces the java/lang/Throwable crash: two assemblies define Java.Lang.Throwable + // with the same JNI name, plus Java.Interop.JavaException also maps to the same JNI name. + // All three should be merged into the [Register]-owning assembly's group. + var javaInteropThrowable = new JavaPeerInfo { + JavaName = "java/lang/Throwable", CompatJniName = "java/lang/Throwable", + ManagedTypeName = "Java.Lang.Throwable", ManagedTypeNamespace = "Java.Lang", ManagedTypeShortName = "Throwable", + AssemblyName = "Java.Interop", IsFromJniTypeSignature = true, DoNotGenerateAcw = true, + }; + + var monoAndroidThrowable = new JavaPeerInfo { + JavaName = "java/lang/Throwable", CompatJniName = "java/lang/Throwable", + ManagedTypeName = "Java.Lang.Throwable", ManagedTypeNamespace = "Java.Lang", ManagedTypeShortName = "Throwable", + AssemblyName = "Mono.Android", IsFromJniTypeSignature = false, DoNotGenerateAcw = true, + }; + + var javaException = new JavaPeerInfo { + JavaName = "java/lang/Throwable", CompatJniName = "java/lang/Throwable", + ManagedTypeName = "Java.Interop.JavaException", ManagedTypeNamespace = "Java.Interop", ManagedTypeShortName = "JavaException", + AssemblyName = "Java.Interop", IsFromJniTypeSignature = true, DoNotGenerateAcw = true, + }; + + var allPeers = new List { javaInteropThrowable, monoAndroidThrowable, javaException }; + var result = TrimmableTypeMapGenerator.MergeCrossAssemblyAliases (allPeers); + + // All java/lang/Throwable peers should be in the Mono.Android group ([Register] wins) + var monoAndroidGroup = result.Single (g => g.AssemblyName == "Mono.Android"); + Assert.Equal (3, monoAndroidGroup.Peers.Count); + Assert.Contains (monoAndroidGroup.Peers, p => p.ManagedTypeName == "Java.Lang.Throwable" && p.AssemblyName == "Mono.Android"); + Assert.Contains (monoAndroidGroup.Peers, p => p.ManagedTypeName == "Java.Lang.Throwable" && p.AssemblyName == "Java.Interop"); + Assert.Contains (monoAndroidGroup.Peers, p => p.ManagedTypeName == "Java.Interop.JavaException"); + + // Java.Interop group should be empty (all peers moved to Mono.Android) + Assert.DoesNotContain (result, g => g.AssemblyName == "Java.Interop"); + } + + [Fact] + public void MergeCrossAssemblyAliases_SameManagedName_ProducesCorrectAliasGroup () + { + // End-to-end: after merging, ModelBuilder must produce a 3-way alias group + // for java/lang/Throwable with indexed entries and a single base entry, + // ensuring the runtime dictionary only sees java/lang/Throwable once. + var javaInteropThrowable = new JavaPeerInfo { + JavaName = "java/lang/Throwable", CompatJniName = "java/lang/Throwable", + ManagedTypeName = "Java.Lang.Throwable", ManagedTypeNamespace = "Java.Lang", ManagedTypeShortName = "Throwable", + AssemblyName = "Java.Interop", IsFromJniTypeSignature = true, DoNotGenerateAcw = true, + }; + + var monoAndroidThrowable = new JavaPeerInfo { + JavaName = "java/lang/Throwable", CompatJniName = "java/lang/Throwable", + ManagedTypeName = "Java.Lang.Throwable", ManagedTypeNamespace = "Java.Lang", ManagedTypeShortName = "Throwable", + AssemblyName = "Mono.Android", IsFromJniTypeSignature = false, DoNotGenerateAcw = true, + }; + + var javaException = new JavaPeerInfo { + JavaName = "java/lang/Throwable", CompatJniName = "java/lang/Throwable", + ManagedTypeName = "Java.Interop.JavaException", ManagedTypeNamespace = "Java.Interop", ManagedTypeShortName = "JavaException", + AssemblyName = "Java.Interop", IsFromJniTypeSignature = true, DoNotGenerateAcw = true, + }; + + var allPeers = new List { javaInteropThrowable, monoAndroidThrowable, javaException }; + var merged = TrimmableTypeMapGenerator.MergeCrossAssemblyAliases (allPeers); + + // All peers should be in the Mono.Android group + Assert.Single (merged); + var group = merged [0]; + Assert.Equal ("Mono.Android", group.AssemblyName); + Assert.Equal (3, group.Peers.Count); + + // Build the model — should produce a 3-way alias group + string typeMapAssemblyName = $"_{group.AssemblyName}.TypeMap"; + var model = ModelBuilder.Build (group.Peers, typeMapAssemblyName + ".dll", typeMapAssemblyName); + + // 3 indexed entries + 1 base entry = 4 + Assert.Equal (4, model.Entries.Count); + Assert.Equal ("java/lang/Throwable[0]", model.Entries [0].JniName); + Assert.Equal ("java/lang/Throwable[1]", model.Entries [1].JniName); + Assert.Equal ("java/lang/Throwable[2]", model.Entries [2].JniName); + Assert.Equal ("java/lang/Throwable", model.Entries [3].JniName); + + // Exactly 1 alias holder + Assert.Single (model.AliasHolders); + Assert.Equal (3, model.AliasHolders [0].AliasKeys.Count); + + // The base "java/lang/Throwable" entry points to the alias holder, not a type directly + var baseEntry = model.Entries [3]; + Assert.Contains ("_Aliases", baseEntry.ProxyTypeReference); + + // 3 associations (one per peer → alias holder) + Assert.Equal (3, model.Associations.Count); + + // The bare "java/lang/Throwable" key appears exactly once — no duplicates + Assert.Single (model.Entries, e => e.JniName == "java/lang/Throwable"); + } + static PEReader CreateTestFixturePEReader () { var dir = Path.GetDirectoryName (typeof (FixtureTestBase).Assembly.Location) diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs index 80f1dafc1f8..6994ce45ade 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapAssemblyGeneratorTests.cs @@ -296,7 +296,7 @@ public void Generate_InheritedCtor_UcoUsesGuardAndInlinedActivation () var reader = pe.GetMetadataReader (); var memberNames = GetMemberRefNames (reader); - Assert.Contains ("get_WithinNewObjectScope", memberNames); + Assert.Contains ("ShouldSkipActivation", memberNames); Assert.Contains ("GetUninitializedObject", memberNames); Assert.DoesNotContain ("ActivateInstance", memberNames); Assert.DoesNotContain ("ActivatePeerFromJavaConstructor", memberNames); diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs index bd9d1ab6b0d..651f1ea3c40 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/TypeMapModelBuilderTests.cs @@ -165,14 +165,15 @@ public void Build_UserAcwType_IsUnconditional () [Fact] public void Build_McwBinding_IsTrimmable () { - // MCW binding types (DoNotGenerateAcw=true) are trimmable unless essential + // MCW binding types (DoNotGenerateAcw=true) are trimmable unless essential. + // When ForceUnconditionalEntries is enabled (workaround for dotnet/runtime#127004), + // all entries become unconditional. var peer = MakeMcwPeer ("android/app/Activity", "Android.App.Activity", "Mono.Android") with { DoNotGenerateAcw = true }; var model = BuildModel (new [] { peer }); Assert.Single (model.Entries); - Assert.False (model.Entries [0].IsUnconditional); - Assert.NotNull (model.Entries [0].TargetTypeReference); - Assert.Contains ("Android.App.Activity, Mono.Android", model.Entries [0].TargetTypeReference!); + Assert.True (model.Entries [0].IsUnconditional); + Assert.Null (model.Entries [0].TargetTypeReference); } [Fact] @@ -239,13 +240,14 @@ public void Build_PeerWithActivation_CreatesNamedProxy (string jniName, string m } [Fact] - public void Build_SinglePeer_NoAssociation () + public void Build_SinglePeer_HasAssociation () { - // Single peers don't need associations — only alias groups do + // When ForceUnconditionalEntries is enabled, single peers emit associations + // so the runtime proxy type map is populated. var peer = MakePeerWithActivation ("my/app/MainActivity", "MyApp.MainActivity", "App"); var model = BuildModel (new [] { peer }, "MyTypeMap"); - Assert.Empty (model.Associations); + Assert.Single (model.Associations); } [Fact] @@ -330,7 +332,8 @@ public void Fixture_McwBinding_IsTrimmable (string javaName) var peer = FindFixtureByJavaName (javaName); Assert.True (peer.DoNotGenerateAcw); var model = BuildModel (new [] { peer }); - Assert.False (model.Entries [0].IsUnconditional); + // ForceUnconditionalEntries workaround makes all entries unconditional + Assert.True (model.Entries [0].IsUnconditional); } } @@ -761,9 +764,8 @@ public class PeBlobValidation [Fact] public void FullPipeline_Mixed2ArgAnd3Arg_BothSurviveRoundTrip () { - // java/lang/Object → essential → 2-arg unconditional + // With ForceUnconditionalEntries, both are emitted as 2-arg unconditional var objectPeer = FindFixtureByJavaName ("java/lang/Object"); - // android/app/Activity → MCW → 3-arg trimmable var activityPeer = FindFixtureByJavaName ("android/app/Activity"); var model = BuildModel (new [] { objectPeer, activityPeer }, "MixedBlob"); @@ -773,14 +775,13 @@ public void FullPipeline_Mixed2ArgAnd3Arg_BothSurviveRoundTrip () var attrs = ReadAllTypeMapAttributeBlobs (reader); Assert.Equal (2, attrs.Count); - var unconditional = attrs.FirstOrDefault (a => a.jniName == "java/lang/Object"); - Assert.NotNull (unconditional.jniName); - Assert.Null (unconditional.targetRef); + var objectEntry = attrs.FirstOrDefault (a => a.jniName == "java/lang/Object"); + Assert.NotNull (objectEntry.jniName); + Assert.Null (objectEntry.targetRef); - var trimmable = attrs.FirstOrDefault (a => a.jniName == "android/app/Activity"); - Assert.NotNull (trimmable.jniName); - Assert.NotNull (trimmable.targetRef); - Assert.Contains ("Android.App.Activity", trimmable.targetRef!); + var activityEntry = attrs.FirstOrDefault (a => a.jniName == "android/app/Activity"); + Assert.NotNull (activityEntry.jniName); + Assert.Null (activityEntry.targetRef); // unconditional due to ForceUnconditionalEntries }); } @@ -805,22 +806,22 @@ public void FullPipeline_UnconditionalType_Emits2ArgAttribute (string javaName, } [Fact] - public void FullPipeline_McwBinding_Emits3ArgAttribute () + public void FullPipeline_McwBinding_Emits2ArgAttribute_WithWorkaround () { - // android/app/Activity is MCW → trimmable 3-arg attribute + // With ForceUnconditionalEntries workaround for dotnet/runtime#127004, + // MCW bindings are emitted as 2-arg unconditional. var peer = FindFixtureByJavaName ("android/app/Activity"); - var model = BuildModel (new [] { peer }, "Blob3Arg"); + var model = BuildModel (new [] { peer }, "Blob2ArgWorkaround"); Assert.Single (model.Entries); - Assert.False (model.Entries [0].IsUnconditional); + Assert.True (model.Entries [0].IsUnconditional); - EmitAndVerify (model, "Blob3Arg", (pe, reader) => { + EmitAndVerify (model, "Blob2ArgWorkaround", (pe, reader) => { var (jniName, proxyRef, targetRef) = ReadFirstTypeMapAttributeBlob (reader); Assert.Equal ("android/app/Activity", jniName); Assert.NotNull (proxyRef); Assert.Contains ("Android_App_Activity_Proxy", proxyRef!); - Assert.NotNull (targetRef); - Assert.Contains ("Android.App.Activity", targetRef!); + Assert.Null (targetRef); // unconditional due to ForceUnconditionalEntries }); } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs index a3b725caecb..7d57a37657c 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.cs @@ -95,4 +95,40 @@ public void Scan_UnregisteredType_UsesCrc64PackageName (string managedName, stri { Assert.Equal (expectedJavaName, FindFixtureByManagedName (managedName).JavaName); } + + [Fact] + public void Scan_JniTypeSignature_IsDiscovered () + { + var peer = FindFixtureByJavaName ("net/dot/jni/test/JavaDisposedObject"); + Assert.Equal ("Java.Interop.TestTypes.JavaDisposedObject", peer.ManagedTypeName); + Assert.False (peer.DoNotGenerateAcw, "GenerateJavaPeer=true should map to DoNotGenerateAcw=false"); + } + + [Fact] + public void Scan_JniTypeSignature_DoNotGenerateAcw () + { + var nonGenerated = FindFixtureByJavaName ("net/dot/jni/test/MyJavaObject"); + Assert.True (nonGenerated.DoNotGenerateAcw, "NonGeneratedJavaObject has GenerateJavaPeer=false"); + } + + [Fact] + public void Scan_JniTypeSignature_DuplicateJniName_BothPresent () + { + // Java.Interop.TestTypes.JavaObject has [JniTypeSignature("java/lang/Object", GenerateJavaPeer=false)] + // and Java.Lang.Object has [Register("java/lang/Object", DoNotGenerateAcw=true)]. + // Both should be present in the scan results — alias support (PR #11122) handles + // the runtime deduplication. + var peers = ScanFixtures (); + var javaObjectPeers = peers.Where (p => p.JavaName == "java/lang/Object").ToList (); + Assert.Equal (2, javaObjectPeers.Count); + } + + [Fact] + public void Scan_JniTypeSignature_SubclassExtendsJavaPeer () + { + // JavaDisposedObject extends JavaObject which has [JniTypeSignature(GenerateJavaPeer=false)] + // The scanner should still detect JavaDisposedObject as extending a Java peer + var peer = FindFixtureByJavaName ("net/dot/jni/test/JavaDisposedObject"); + Assert.NotNull (peer); + } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/StubAttributes.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/StubAttributes.cs index 332abe00a19..ba579e4e9d1 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/StubAttributes.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/StubAttributes.cs @@ -205,6 +205,20 @@ public sealed class JniConstructorSignatureAttribute : Attribute } } +namespace Java.Interop +{ + [AttributeUsage (AttributeTargets.Class, AllowMultiple = false)] + public sealed class JniTypeSignatureAttribute : Attribute + { + public string SimpleReference { get; } + public bool GenerateJavaPeer { get; set; } = true; + public bool IsKeyword { get; set; } + public int ArrayRank { get; set; } + + public JniTypeSignatureAttribute (string simpleReference) => SimpleReference = simpleReference; + } +} + namespace MyApp { [AttributeUsage (AttributeTargets.Class)] diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs index 68734a21ae2..9b220bb6d03 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs @@ -948,3 +948,25 @@ public class AliasTargetExtended : Java.Lang.Object protected AliasTargetExtended (IntPtr handle, Android.Runtime.JniHandleOwnership transfer) : base (handle, transfer) { } } } + +// [JniTypeSignature] types — Java.Interop's JavaObject hierarchy +namespace Java.Interop.TestTypes +{ + [Java.Interop.JniTypeSignature ("java/lang/Object", GenerateJavaPeer = false)] + public class JavaObject + { + public JavaObject () { } + } + + [Java.Interop.JniTypeSignature ("net/dot/jni/test/JavaDisposedObject")] + public class JavaDisposedObject : JavaObject + { + public JavaDisposedObject () { } + } + + [Java.Interop.JniTypeSignature ("net/dot/jni/test/MyJavaObject", GenerateJavaPeer = false)] + public class NonGeneratedJavaObject : JavaObject + { + public NonGeneratedJavaObject () { } + } +} From 7426335bf41bb5763b19d0d370d1fa712b3b8708 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 24 Apr 2026 19:11:05 +0200 Subject: [PATCH 2/6] [tests] Add CoreCLRTrimmable test plumbing for Mono.Android.NET-Tests Configure Mono.Android.NET-Tests to build and run successfully with _AndroidTypeMapImplementation=trimmable and UseMonoRuntime=false. Changes: - Root test and runner assemblies in TrimmerRoots.xml so NUnit can discover and execute tests after trimming - Exclude 48 tests that are incompatible with the trimmable typemap (tracked in https://github.com/dotnet/android/issues/11170): - 5 tests that require net.dot.jni.test.*/net.dot.jni.internal.* Java classes - 24 tests relying on JavaProxyObject/JavaProxyThrowable (not in APK) - 4 proxy resolution / trimmer gaps - 4 JavaCast proxy resolution failures - 4 open generic type / registration failures - 4 JNI method remapping not supported - 3 other - Exclusions are guarded by RuntimeFeature.TrimmableTypeMap so they only apply to trimmable builds - Add TODO comments to tests with known trimmable-specific failures (issue #11170) so they are visible to contributors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Java.Interop/JavaObjectExtensionsTests.cs | 4 ++ .../Java.Interop/JnienvTest.cs | 2 + .../Java.Lang/ObjectTest.cs | 1 + .../Mono.Android.NET-Tests.csproj | 21 ++++-- .../Mono.Android-Tests/TrimmerRoots.xml | 22 ++++++ .../NUnitInstrumentation.cs | 72 ++++++++++++++++++- 6 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 tests/Mono.Android-Tests/Mono.Android-Tests/TrimmerRoots.xml diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JavaObjectExtensionsTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JavaObjectExtensionsTests.cs index 1b13316cc3d..9938951cae9 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JavaObjectExtensionsTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JavaObjectExtensionsTests.cs @@ -15,6 +15,7 @@ namespace Java.InteropTests { [TestFixture] public class JavaObjectExtensionsTests { + // TODO: https://github.com/dotnet/android/issues/11170 — cannot create instance of open generic type under trimmable typemap [Test] public void JavaCast_BaseToGenericWrapper () { @@ -41,6 +42,7 @@ public void JavaCast_InterfaceCast () } } + // TODO: https://github.com/dotnet/android/issues/11170 — throws NotSupportedException instead of InvalidCastException under trimmable typemap [Test] public void JavaCast_BadInterfaceCast () { @@ -67,6 +69,7 @@ public void JavaCast_ObtainOriginalInstance () Assert.AreSame (list, al); } + // TODO: https://github.com/dotnet/android/issues/11170 — throws NotSupportedException instead of InvalidCastException under trimmable typemap [Test] public void JavaCast_InvalidTypeCastThrows () { @@ -75,6 +78,7 @@ public void JavaCast_InvalidTypeCastThrows () } } + // TODO: https://github.com/dotnet/android/issues/11170 — throws NotSupportedException instead of InvalidCastException under trimmable typemap [Test] public void JavaCast_CheckForManagedSubclasses () { diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JnienvTest.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JnienvTest.cs index a563f56fd3a..148c7dc9383 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JnienvTest.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Interop/JnienvTest.cs @@ -121,6 +121,7 @@ public void InvokingNullInstanceDoesNotCrashDalvik () } } + // TODO: https://github.com/dotnet/android/issues/11170 — open generic creation should throw but succeeds under trimmable typemap [Test] public void NewOpenGenericTypeThrows () { @@ -301,6 +302,7 @@ public void ActivatedDirectObjectSubclassesShouldBeRegistered () } } + // TODO: https://github.com/dotnet/android/issues/11170 — throwable subclass not registered under trimmable typemap [Test] public void ActivatedDirectThrowableSubclassesShouldBeRegistered () { diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Lang/ObjectTest.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Lang/ObjectTest.cs index 764fc416379..b4d921acd17 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Lang/ObjectTest.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Java.Lang/ObjectTest.cs @@ -19,6 +19,7 @@ namespace Java.LangTests [TestFixture] public class ObjectTest { + // TODO: https://github.com/dotnet/android/issues/11170 — trimmable typemap doesn't resolve most-derived managed type [Test] public void GetObject_ReturnsMostDerivedType () { diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj index 18c5032bb58..e3e89a38cff 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj @@ -29,7 +29,7 @@ NetworkInterfaces excluded: https://github.com/dotnet/runtime/issues/75155 --> - $(ExcludeCategories):CoreCLRIgnore:NTLM + $(ExcludeCategories):CoreCLRIgnore:NTLM $(ExcludeCategories):NativeAOTIgnore:SSL:NTLM:AndroidClientHandler:Export:NativeTypeMap @@ -37,6 +37,12 @@ $(ExcludeCategories):InetAccess:NetworkInterfaces + + false + CoreCLRTrimmable + $(ExcludeCategories):NativeTypeMap:Export + + @@ -68,8 +74,17 @@ + + + + + + + <_AndroidRemapMembers Include="Remaps.xml" /> <_AndroidRemapMembers Include="IsAssignableFromRemaps.xml" Condition=" '$(_AndroidIsAssignableFromCheck)' == 'false' " /> @@ -226,10 +241,6 @@ - - <_AndroidEnableObjectReferenceLogging>false - - diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/TrimmerRoots.xml b/tests/Mono.Android-Tests/Mono.Android-Tests/TrimmerRoots.xml new file mode 100644 index 00000000000..8197e4f5994 --- /dev/null +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/TrimmerRoots.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs index 215fde081a9..63c219c712a 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs @@ -25,6 +25,76 @@ protected override string LogTag protected NUnitInstrumentation(IntPtr handle, JniHandleOwnership transfer) : base(handle, transfer) { + if (Microsoft.Android.Runtime.RuntimeFeature.TrimmableTypeMap) { + // TODO: https://github.com/dotnet/android/issues/11170 + // Tests from the external Java.Interop-Tests assembly that fail under the + // trimmable typemap. These cannot use [Category("TrimmableIgnore")] because + // we don't control that assembly — they must be excluded by name here. + ExcludedTestNames = new [] { + // net.dot.jni.test.CallVirtualFromConstructorDerived Java class not in APK + "Java.InteropTests.InvokeVirtualFromConstructorTests", + + // net.dot.jni.internal.JavaProxyObject Java class not in APK — fixture setup fails (16 tests) + "Java.InteropTests.JavaObjectArray_object_ContractTest", + + // net.dot.jni.internal.JavaProxyObject Java class not in APK + "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateArgumentState", + "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateGenericArgumentState", + "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateGenericObjectReferenceArgumentState", + "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateGenericValue", + "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateObjectReferenceArgumentState", + "Java.InteropTests.JniValueMarshaler_object_ContractTests.JniValueMarshalerContractTests`1.CreateValue", + "Java.InteropTests.JniValueMarshaler_object_ContractTests.SpecificTypesAreUsed", + + // No generated JavaPeerProxy for java/lang/Object with IJavaPeerable target type + "Java.InteropTests.JniValueMarshaler_IJavaPeerable_ContractTests.JniValueMarshalerContractTests`1.CreateGenericValue", + "Java.InteropTests.JniValueMarshaler_IJavaPeerable_ContractTests.JniValueMarshalerContractTests`1.CreateValue", + + // net.dot.jni.internal.JavaProxyThrowable — proxy throwable creation fails + "Java.InteropTests.JavaExceptionTests.InnerExceptionIsNotAProxy", + + // IJavaInterfaceInvoker ctor trimmed / missing JavaPeerProxy for test types + "Java.InteropTests.JavaPeerableExtensionsTests.JavaAs", + "Java.InteropTests.JavaPeerableExtensionsTests.JavaAs_Exceptions", + "Java.InteropTests.JavaPeerableExtensionsTests.JavaAs_InstanceThatDoesNotImplementInterfaceReturnsNull", + + // JNI method remapping not supported in trimmable typemap + "Java.InteropTests.JniPeerMembersTests.ReplaceInstanceMethodName", + "Java.InteropTests.JniPeerMembersTests.ReplaceInstanceMethodWithStaticMethod", + "Java.InteropTests.JniPeerMembersTests.ReplacementTypeUsedForMethodLookup", + "Java.InteropTests.JniPeerMembersTests.ReplaceStaticMethodName", + + // net.dot.jni.test.GenericHolder Java class not in APK + "Java.InteropTests.JniTypeManagerTests.CannotCreateGenericHolderFromJava", + + // JniPrimitiveArrayInfo lookup fails for JavaBooleanArray + "Java.InteropTests.JniTypeManagerTests.GetType", + + // net.dot.jni.test.GetThis — cannot register native members + "Java.InteropTests.JavaObjectTest.DisposeAccessesThis", + + // NotSupportedException instead of InvalidCastException — no generated JavaPeerProxy + "Java.InteropTests.JavaObjectExtensionsTests.JavaCast_BadInterfaceCast", + "Java.InteropTests.JavaObjectExtensionsTests.JavaCast_BaseToGenericWrapper", + "Java.InteropTests.JavaObjectExtensionsTests.JavaCast_CheckForManagedSubclasses", + "Java.InteropTests.JavaObjectExtensionsTests.JavaCast_InvalidTypeCastThrows", + + // Open generic type handling differs from non-trimmable + "Java.InteropTests.JnienvTest.NewOpenGenericTypeThrows", + + // Throwable subclass registration + "Java.InteropTests.JnienvTest.ActivatedDirectThrowableSubclassesShouldBeRegistered", + + // Typemap doesn't resolve most-derived type + "Java.LangTests.ObjectTest.GetObject_ReturnsMostDerivedType", + + // Instance identity after JNI round-trip + "Java.LangTests.ObjectTest.JnienvCreateInstance_RegistersMultipleInstances", + + // Global ref leak when inflating custom views + "Xamarin.Android.RuntimeTests.CustomWidgetTests.InflateCustomView_ShouldNotLeakGlobalRefs", + }; + } } protected override IList GetTestAssemblies() @@ -39,4 +109,4 @@ protected override IList GetTestAssemblies() }; } } -} \ No newline at end of file +} From 2d750b0d7a2090df7748ca473795628ab3c89144 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 24 Apr 2026 19:11:12 +0200 Subject: [PATCH 3/6] [CI] Add CoreCLRTrimmable Mono.Android.NET-Tests lane Add a new CI lane that runs Mono.Android.NET-Tests with the trimmable typemap implementation on CoreCLR: _AndroidTypeMapImplementation=trimmable UseMonoRuntime=false The lane runs after the existing CoreCLR lane and uses the same APK artifact from the CoreCLRTrimmable build flavor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../automation/yaml-templates/stage-package-tests.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build-tools/automation/yaml-templates/stage-package-tests.yaml b/build-tools/automation/yaml-templates/stage-package-tests.yaml index 869ac45cee1..47a7a9db5ed 100644 --- a/build-tools/automation/yaml-templates/stage-package-tests.yaml +++ b/build-tools/automation/yaml-templates/stage-package-tests.yaml @@ -203,6 +203,16 @@ stages: artifactSource: bin/Test$(XA.Build.Configuration)/$(DotNetTargetFramework)-android/Mono.Android.NET_Tests-Signed.aab artifactFolder: $(DotNetTargetFramework)-CoreCLR + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml + parameters: + configuration: $(XA.Build.Configuration) + testName: Mono.Android.NET_Tests-CoreCLRTrimmable + project: tests/Mono.Android-Tests/Mono.Android-Tests/Mono.Android.NET-Tests.csproj + testResultsFiles: TestResult-Mono.Android.NET_Tests-$(XA.Build.Configuration)CoreCLRTrimmable.xml + extraBuildArgs: -p:_AndroidTypeMapImplementation=trimmable -p:UseMonoRuntime=false + artifactSource: bin/Test$(XA.Build.Configuration)/$(DotNetTargetFramework)-android/Mono.Android.NET_Tests-Signed.aab + artifactFolder: $(DotNetTargetFramework)-CoreCLRTrimmable + - template: /build-tools/automation/yaml-templates/apk-instrumentation.yaml parameters: configuration: $(XA.Build.Configuration) From 1745af102c0f3074a823f1ab6188e1a1dd01cc4e Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Fri, 24 Apr 2026 22:33:15 +0200 Subject: [PATCH 4/6] [tests] Fix ObjectReferenceLogging expected value in Debug builds In non-trimmed (Debug) builds, _AndroidEnableObjectReferenceLogging defaults to true, so RuntimeFeature.ObjectReferenceLogging returns true. The existing Meter test already uses the same #if DEBUG pattern; apply it here too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Mono.Android-Tests/System/AppContextTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/System/AppContextTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/System/AppContextTests.cs index 05cd37eaee3..4ba66d7c867 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/System/AppContextTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/System/AppContextTests.cs @@ -49,7 +49,11 @@ public void GetData (string name, string expected) new object [] { /* className */ "Microsoft.Android.Runtime.RuntimeFeature, Mono.Android", /* propertyName */ "ObjectReferenceLogging", +#if DEBUG + /* expected */ true, +#else // !DEBUG /* expected */ false, +#endif // !DEBUG }, }; From 3e32d7065d2da54d2fc1d223018789faf419d982 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sat, 25 Apr 2026 00:26:35 +0200 Subject: [PATCH 5/6] [tests] Remove ObjectReferenceLogging test case from TestPrivateSwitches The feature switch (ObjectReferenceLoggingEnabledByDefault) is the source of truth; its default value varies by build configuration (true in non-trimmed Debug builds, false in trimmed Release builds). Hardcoding an expected value in the test is fragile. Remove the test case entirely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Mono.Android-Tests/System/AppContextTests.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/System/AppContextTests.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/System/AppContextTests.cs index 4ba66d7c867..5f11ac437a8 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/System/AppContextTests.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/System/AppContextTests.cs @@ -44,15 +44,6 @@ public void GetData (string name, string expected) /* expected */ true, #else // !DEBUG /* expected */ false, -#endif // !DEBUG - }, - new object [] { - /* className */ "Microsoft.Android.Runtime.RuntimeFeature, Mono.Android", - /* propertyName */ "ObjectReferenceLogging", -#if DEBUG - /* expected */ true, -#else // !DEBUG - /* expected */ false, #endif // !DEBUG }, }; @@ -61,7 +52,6 @@ public void GetData (string name, string expected) [Category ("NativeAOTIgnore")] // These switches only exist in Mono & CoreCLR BCL assemblies [DynamicDependency (DynamicallyAccessedMemberTypes.All, "System.LocalAppContextSwitches", "System.Private.CoreLib")] [DynamicDependency (DynamicallyAccessedMemberTypes.All, "System.Diagnostics.Metrics.Meter", "System.Diagnostics.DiagnosticSource")] - [DynamicDependency (DynamicallyAccessedMemberTypes.All, "Microsoft.Android.Runtime.RuntimeFeature", "Mono.Android")] [TestCaseSource (nameof (TestPrivateSwitchesSource))] public void TestPrivateSwitches ( [DynamicallyAccessedMembers (DynamicallyAccessedMemberTypes.All)] From 1c03ddcd5162996e8c63b6ebf0eb49fbaf87b4d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Rozs=C3=ADval?= Date: Mon, 27 Apr 2026 14:49:25 +0200 Subject: [PATCH 6/6] [TrimmableTypeMap] Emit alias-base entry as unconditional (#11220) The alias-group base entry (e.g. java/lang/String -> alias holder) was emitted as a 3-arg conditional TypeMap attribute regardless of the ForceUnconditionalEntries workaround for dotnet/runtime#127004. When that workaround is in effect, the indexed alias entries are 2-arg (unconditional) and the alias holder is reachable only via TypeMapAssociation. Per #127004 the trimmer strips the association when a TypeMap entry references the same type, removing the holder and making the dictionary key 'java/lang/String' (and 'java/lang/Object', 'java/lang/Throwable', ...) disappear at runtime. The hierarchy walker then advances past the most-derived JNI class and CreatePeer falls back to the targetType-based lookup, returning a base-class peer. Concrete symptom (verified on device): GetObject_ReturnsMostDerivedType hierarchy: jni='java/lang/String' targetType='Java.Lang.Object' proxyCount=0 hierarchy: jni='java/lang/Object' targetType='Java.Lang.Object' proxyCount=0 -> Expected Java.Lang.String but was Java.Lang.Object Route the alias-base entry through the same unconditional decision used by BuildEntry: emit it as 2-arg whenever ForceUnconditionalEntries is on, the JNI name is in EssentialRuntimeTypes, or any peer in the alias group already needs to be unconditional. After the fix the same lookup reports proxyCount=2 and selects Java.Lang.String, and the test (and the full Mono.Android.NET-Tests trimmable suite) passes with 0 failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generator/ModelBuilder.cs | 10 +++++++++- .../NUnitInstrumentation.cs | 3 --- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs index 996ce142a13..79570a14bda 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ModelBuilder.cs @@ -177,10 +177,18 @@ static void EmitPeers (TypeMapAssemblyData model, string jniName, } // Base JNI name entry → alias holder (self-referencing trim target, kept alive by associations) + // When ForceUnconditionalEntries is true we MUST emit this as 2-arg (unconditional) just + // like BuildEntry does: dotnet/runtime#127004 strips the TypeMapAssociation that keeps the + // holder alive when a TypeMap entry references the same type, leaving the dictionary key + // missing at runtime and breaking hierarchy lookups for essential types like + // java/lang/String and java/lang/Object. + bool aliasBaseUnconditional = ForceUnconditionalEntries + || EssentialRuntimeTypes.Contains (jniName) + || peersForName.Any (IsUnconditionalEntry); model.Entries.Add (new TypeMapAttributeData { JniName = jniName, ProxyTypeReference = holderRef, - TargetTypeReference = holderRef, + TargetTypeReference = aliasBaseUnconditional ? null : holderRef, }); model.AliasHolders.Add (new AliasHolderData { diff --git a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs index 63c219c712a..d24d53ad324 100644 --- a/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs +++ b/tests/Mono.Android-Tests/Mono.Android-Tests/Xamarin.Android.RuntimeTests/NUnitInstrumentation.cs @@ -85,9 +85,6 @@ protected NUnitInstrumentation(IntPtr handle, JniHandleOwnership transfer) // Throwable subclass registration "Java.InteropTests.JnienvTest.ActivatedDirectThrowableSubclassesShouldBeRegistered", - // Typemap doesn't resolve most-derived type - "Java.LangTests.ObjectTest.GetObject_ReturnsMostDerivedType", - // Instance identity after JNI round-trip "Java.LangTests.ObjectTest.JnienvCreateInstance_RegistersMultipleInstances",