Keep redundant interfaces if class and interface are kept.

Bug: b/318787479
Change-Id: I417d3a914246dc066ff36b96ca64a77a17e0804e
diff --git a/src/main/java/com/android/tools/r8/shaking/TreePruner.java b/src/main/java/com/android/tools/r8/shaking/TreePruner.java
index 57726f6..e8f037e 100644
--- a/src/main/java/com/android/tools/r8/shaking/TreePruner.java
+++ b/src/main/java/com/android/tools/r8/shaking/TreePruner.java
@@ -3,6 +3,8 @@
 // BSD-style license that can be found in the LICENSE file.
 package com.android.tools.r8.shaking;
 
+import static com.android.tools.r8.graph.DexProgramClass.asProgramClassOrNull;
+
 import com.android.tools.r8.graph.AppView;
 import com.android.tools.r8.graph.DefaultInstanceInitializerCode;
 import com.android.tools.r8.graph.DexClass;
@@ -140,8 +142,9 @@
       retainReachableInterfacesFrom(type, reachableInterfaces);
     }
     if (!reachableInterfaces.isEmpty()) {
+      boolean pinnedHolder = !appView.getKeepInfo(clazz).isOptimizationAllowed(appView.options());
       removeInterfacesImplementedDirectlyAndIndirectlyByClassFromSet(
-          clazz.superType, reachableInterfaces);
+          pinnedHolder, clazz.superType, reachableInterfaces, clazz);
     }
     if (reachableInterfaces.isEmpty()) {
       clazz.interfaces = DexTypeList.empty();
@@ -151,7 +154,7 @@
   }
 
   private void removeInterfacesImplementedDirectlyAndIndirectlyByClassFromSet(
-      DexType type, Set<DexType> interfaces) {
+      boolean pinnedRoot, DexType type, Set<DexType> interfaces, DexProgramClass context) {
     DexClass clazz = appView.definitionFor(type);
     if (clazz == null) {
       return;
@@ -162,13 +165,22 @@
       return;
     }
     for (DexType itf : clazz.interfaces) {
+      if (pinnedRoot) {
+        DexProgramClass itfClass = asProgramClassOrNull(appView.definitionFor(itf, context));
+        if (itfClass == null
+            || !appView.getKeepInfo(itfClass).isOptimizationAllowed(appView.options())) {
+          // If root-holder and interface are pinned then retain the interface on the root-holder.
+          continue;
+        }
+      }
       if (interfaces.remove(itf) && interfaces.isEmpty()) {
         return;
       }
     }
     if (clazz.superType != null) {
       assert !interfaces.isEmpty();
-      removeInterfacesImplementedDirectlyAndIndirectlyByClassFromSet(clazz.superType, interfaces);
+      removeInterfacesImplementedDirectlyAndIndirectlyByClassFromSet(
+          pinnedRoot, clazz.superType, interfaces, context);
     }
   }
 
diff --git a/src/test/java/com/android/tools/r8/ir/optimize/unusedinterfaces/UnusedButKeptInterfaceTest.java b/src/test/java/com/android/tools/r8/ir/optimize/unusedinterfaces/UnusedButKeptInterfaceTest.java
new file mode 100644
index 0000000..21f61ce
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/ir/optimize/unusedinterfaces/UnusedButKeptInterfaceTest.java
@@ -0,0 +1,77 @@
+// Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8.ir.optimize.unusedinterfaces;
+
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.utils.StringUtils;
+import java.util.Arrays;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+/* Regression test for b/318787479 */
+@RunWith(Parameterized.class)
+public class UnusedButKeptInterfaceTest extends TestBase {
+
+  static final String EXPECTED = StringUtils.lines("A: I", "B:", "C: I");
+
+  private final TestParameters parameters;
+
+  @Parameterized.Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withAllRuntimesAndApiLevels().build();
+  }
+
+  public UnusedButKeptInterfaceTest(TestParameters parameters) {
+    this.parameters = parameters;
+  }
+
+  @Test
+  public void testReference() throws Exception {
+    testForRuntime(parameters)
+        .addInnerClasses(UnusedButKeptInterfaceTest.class)
+        .run(parameters.getRuntime(), TestClass.class)
+        .assertSuccessWithOutput(EXPECTED);
+  }
+
+  @Test
+  public void testR8() throws Exception {
+    testForR8(parameters.getBackend())
+        .addInnerClasses(UnusedButKeptInterfaceTest.class)
+        .addKeepMainRule(TestClass.class)
+        .addKeepClassRules(I.class, A.class, B.class, C.class)
+        .setMinApi(parameters)
+        .run(parameters.getRuntime(), TestClass.class)
+        .assertSuccessWithOutput(EXPECTED);
+  }
+
+  interface I {}
+
+  static class A implements I {}
+
+  static class B extends A {}
+
+  static class C extends A implements I {}
+
+  static class TestClass {
+
+    static String innerName(Class<?> clazz) {
+      String typeName = clazz.getName();
+      return typeName.substring(typeName.lastIndexOf('$') + 1);
+    }
+
+    public static void main(String[] args) {
+      for (Class<?> c : Arrays.asList(A.class, B.class, C.class)) {
+        System.out.print(innerName(c) + ":");
+        for (Class<?> iface : c.getInterfaces()) {
+          System.out.print(" " + innerName(iface));
+        }
+        System.out.println();
+      }
+    }
+  }
+}