diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs index bc5cbd02220..a6ce8727fa1 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs @@ -120,7 +120,7 @@ static void WriteClassDeclaration (JavaPeerInfo type, TextWriter writer, string? // implements clause — always includes IGCUserPeer, plus any implemented interfaces writer.Write ("\timplements\n\t\tmono.android.IGCUserPeer"); - foreach (var iface in type.ImplementedInterfaceJavaNames) { + foreach (var iface in type.JavaCallableWrapperInterfaceJavaNames ?? type.ImplementedInterfaceJavaNames) { writer.Write ($",\n\t\t{JniSignatureHelper.JniNameToJavaName (iface)}"); } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs index 86915bff704..f08fcd8e687 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs @@ -58,11 +58,16 @@ public sealed record JavaPeerInfo public string? BaseJavaName { get; init; } /// - /// JNI names of Java interfaces this type implements, e.g., ["android/view/View$OnClickListener"]. - /// Needed by JCW Java source generation ("implements" clause). + /// JNI names of direct Java interfaces this type implements, in managed metadata order. /// public IReadOnlyList ImplementedInterfaceJavaNames { get; init; } = Array.Empty (); + /// + /// Ordered JNI names to emit in the Java callable wrapper's implements clause. + /// Redundant parent interfaces and duplicate Java names are omitted. + /// + internal IReadOnlyList? JavaCallableWrapperInterfaceJavaNames { get; init; } + /// /// Java annotations forwarded from managed custom attributes decorated with /// Android.Runtime.AnnotationAttribute. diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs index 989afeda295..7cdd81fcd81 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs @@ -26,6 +26,7 @@ enum HashedPackageNamingPolicy { readonly record struct ResolvabilityResult (bool IsResolvable, string? UnresolvedTypeName, string? UnresolvedAssemblyName); readonly record struct PublicConstructorInfo (ImmutableArray ParameterTypes, string JniParameterSignature); + readonly record struct ImplementedInterfaceInfo (TypeRefData Type, string JavaName); readonly Dictionary assemblyCache = new (StringComparer.Ordinal); readonly Dictionary<(string typeName, string assemblyName), ActivationCtorInfo> activationCtorCache = new (); @@ -366,7 +367,7 @@ void ScanAssembly (AssemblyIndex index, Dictionary<(string ManagedName, string A var baseJavaName = ResolveBaseJavaName (typeDef, index, results); // Resolve implemented Java interface names - var implementedInterfaces = ResolveImplementedInterfaceJavaNames (typeDef, index); + var (implementedInterfaces, javaCallableWrapperInterfaces) = ResolveImplementedInterfaceJavaNames (typeDef, index); // Collect marshal methods (including constructors). // Override and interface detection is only for user ACW class types: @@ -403,6 +404,7 @@ void ScanAssembly (AssemblyIndex index, Dictionary<(string ManagedName, string A IsFrameworkAssembly = frameworkAssemblyNames.Contains (index.AssemblyName), BaseJavaName = baseJavaName, ImplementedInterfaceJavaNames = implementedInterfaces, + JavaCallableWrapperInterfaceJavaNames = javaCallableWrapperInterfaces, Annotations = annotationParser.Parse (typeDef.GetCustomAttributes (), index), IsInterface = isInterface, IsAbstract = isAbstract, @@ -1668,26 +1670,86 @@ static string GetJavaAccess (MethodAttributes access) return null; } - List ResolveImplementedInterfaceJavaNames (TypeDefinition typeDef, AssemblyIndex index) + (List ImplementedInterfaces, List JavaCallableWrapperInterfaces) ResolveImplementedInterfaceJavaNames ( + TypeDefinition typeDef, + AssemblyIndex index) { - var result = new List (); - var interfaceImpls = typeDef.GetInterfaceImplementations (); - - foreach (var implHandle in interfaceImpls) { + var interfaces = new List (); + var implementedInterfaces = new List (); + foreach (var implHandle in typeDef.GetInterfaceImplementations ()) { var impl = index.Reader.GetInterfaceImplementation (implHandle); - var ifaceJniName = ResolveInterfaceJniName (impl.Interface, index); - if (ifaceJniName is not null) { - result.Add (ifaceJniName); + var resolved = ResolveEntityHandle (impl.Interface, index); + if (resolved is null) { + continue; + } + + var javaName = ResolveRegisterJniName (resolved.ManagedTypeName, resolved.AssemblyName); + if (javaName is not null) { + interfaces.Add (new ImplementedInterfaceInfo (resolved, javaName)); + implementedInterfaces.Add (javaName); } } - return result; + var javaCallableWrapperInterfaces = new List (); + var addedJavaNames = new HashSet (StringComparer.Ordinal); + var assignabilityVisited = new HashSet<(string ManagedTypeName, string AssemblyName)> (); + foreach (var iface in interfaces) { + var isRedundant = false; + foreach (var other in interfaces) { + if (IsSameTypeDefinition (iface.Type, other.Type)) { + continue; + } + + assignabilityVisited.Clear (); + if (IsInterfaceAssignableFrom (iface.Type, other.Type, assignabilityVisited)) { + isRedundant = true; + break; + } + } + + if (isRedundant) { + continue; + } + + if (addedJavaNames.Add (iface.JavaName)) { + javaCallableWrapperInterfaces.Add (iface.JavaName); + } + } + + return (implementedInterfaces, javaCallableWrapperInterfaces); + } + + bool IsInterfaceAssignableFrom ( + TypeRefData target, + TypeRefData candidate, + HashSet<(string ManagedTypeName, string AssemblyName)> visited) + { + if (IsSameTypeDefinition (target, candidate)) { + return true; + } + + var candidateKey = (candidate.ManagedTypeName, candidate.AssemblyName); + if (!visited.Add (candidateKey) || + !TryResolveType (candidate.ManagedTypeName, candidate.AssemblyName, out var candidateHandle, out var candidateIndex)) { + return false; + } + + var candidateDefinition = candidateIndex.Reader.GetTypeDefinition (candidateHandle); + foreach (var implHandle in candidateDefinition.GetInterfaceImplementations ()) { + var impl = candidateIndex.Reader.GetInterfaceImplementation (implHandle); + var parent = ResolveEntityHandle (impl.Interface, candidateIndex); + if (parent is not null && IsInterfaceAssignableFrom (target, parent, visited)) { + return true; + } + } + + return false; } - string? ResolveInterfaceJniName (EntityHandle interfaceHandle, AssemblyIndex index) + static bool IsSameTypeDefinition (TypeRefData left, TypeRefData right) { - var resolved = ResolveEntityHandle (interfaceHandle, index); - return resolved is not null ? ResolveRegisterJniName (resolved.ManagedTypeName, resolved.AssemblyName) : null; + return string.Equals (left.ManagedTypeName, right.ManagedTypeName, StringComparison.Ordinal) && + string.Equals (left.AssemblyName, right.AssemblyName, StringComparison.Ordinal); } bool TryGetMethodRegisterInfo (MethodDefinition methodDef, AssemblyIndex index, out RegisterInfo? registerInfo, out ExportInfo? exportInfo) diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs index 176caf7e79d..cfd4b18210a 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/TrimmableTypeMapGenerator.cs @@ -103,7 +103,7 @@ internal bool ValidateJavaNames (IReadOnlyList peers, string? appl if (peer.BaseJavaName is not null) { ReportInvalidJniSourceType (peer.BaseJavaName); } - foreach (var interfaceName in peer.ImplementedInterfaceJavaNames) { + foreach (var interfaceName in peer.JavaCallableWrapperInterfaceJavaNames ?? peer.ImplementedInterfaceJavaNames) { ReportInvalidJniSourceType (interfaceName); } foreach (var constructor in peer.JavaConstructors) { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs index 097cf57c252..6b23e22c7b7 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/JcwJavaSourceGeneratorTests.cs @@ -131,6 +131,23 @@ public void Generate_ClickableView_UsesDotsForNestedInterfaceName () Assert.DoesNotContain ("View$OnClickListener", java); } + [Fact] + public void Generate_RedundantInterfaceView_OmitsParentsAndDuplicateJavaNames () + { + var java = GenerateFixture ("my/app/RedundantInterfaceView"); + + AssertContainsLine ( + """ + implements + mono.android.IGCUserPeer, + android.view.View.INamedClickListener, + android.view.View.OnLongClickListener + { + """, + java + ); + } + [Fact] public void Generate_DeclaredDollarKeyword_PreservesDollar () { diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.Behavior.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.Behavior.cs index d6b10c033ce..91d8ae99bd3 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.Behavior.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Scanner/JavaPeerScannerTests.Behavior.cs @@ -194,6 +194,23 @@ public void Scan_MultipleInterfaces_AllResolved () Assert.Empty (FindFixtureByJavaName ("my/app/MyHelper").ImplementedInterfaceJavaNames); } + [Fact] + public void Scan_JavaCallableWrapperInterfaces_OmitsParentsAndDuplicateJavaNames () + { + var peer = FindFixtureByJavaName ("my/app/RedundantInterfaceView"); + + Assert.Equal ([ + "android/view/View$OnClickListener", + "android/view/View$INamedClickListener", + "android/view/View$INamedClickListener", + "android/view/View$OnLongClickListener", + ], peer.ImplementedInterfaceJavaNames); + Assert.Equal ([ + "android/view/View$INamedClickListener", + "android/view/View$OnLongClickListener", + ], peer.JavaCallableWrapperInterfaceJavaNames); + } + [Theory] [InlineData ("android/app/Activity", "android/app/Activity")] [InlineData ("my/app/MainActivity", "my/app/MainActivity")] diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs index 5d0b6000196..a1b6d72e1c0 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/TestFixtures/TestTypes.cs @@ -187,6 +187,11 @@ public interface INamedClickListener : IOnClickListener string? Label { get; } } + [Register ("android/view/View$INamedClickListener")] + public interface INamedClickListenerAlias + { + } + [Register ("mono/android/view/View_IOnClickListenerImplementor")] public class View_IOnClickListenerImplementor : Java.Lang.Object { @@ -793,6 +798,17 @@ public void OnClick (Android.Views.View v) { } public string? Label => "test"; } + [Register ("my/app/RedundantInterfaceView")] + public class RedundantInterfaceView : Android.Views.View, Android.Views.IOnClickListener, + Android.Views.INamedClickListener, Android.Views.INamedClickListenerAlias, Android.Views.IOnLongClickListener + { + protected RedundantInterfaceView (IntPtr handle, JniHandleOwnership transfer) : base (handle, transfer) { } + + public void OnClick (Android.Views.View v) { } + public string? Label => "test"; + public bool OnLongClick (Android.Views.View v) => false; + } + // --- Override detection test types --- // These types override registered base methods WITHOUT [Register] on the override, // mimicking real user code where the attribute is only on the base class in Mono.Android.