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.