Only keep throws clauses on kept methods

Bug: b/343448072
Bug: b/343907109
Change-Id: Id084959bfa879d0ccb5543c7856a8f5fe01b3872
diff --git a/src/main/java/com/android/tools/r8/graph/DexAnnotation.java b/src/main/java/com/android/tools/r8/graph/DexAnnotation.java
index efe3ead..ec6b70a 100644
--- a/src/main/java/com/android/tools/r8/graph/DexAnnotation.java
+++ b/src/main/java/com/android/tools/r8/graph/DexAnnotation.java
@@ -676,6 +676,10 @@
 
   }
 
+  public static boolean isThrowsAnnotation(DexAnnotation annotation, DexItemFactory factory) {
+    return factory.annotationThrows.isIdenticalTo(annotation.annotation.type);
+  }
+
   @SuppressWarnings("ReferenceEquality")
   public static boolean isAnnotationDefaultAnnotation(
       DexAnnotation annotation, DexItemFactory factory) {
diff --git a/src/main/java/com/android/tools/r8/shaking/AnnotationRemover.java b/src/main/java/com/android/tools/r8/shaking/AnnotationRemover.java
index 1fc0e25..f58aab9 100644
--- a/src/main/java/com/android/tools/r8/shaking/AnnotationRemover.java
+++ b/src/main/java/com/android/tools/r8/shaking/AnnotationRemover.java
@@ -103,22 +103,26 @@
         assert !DexAnnotation.isEnclosingClassAnnotation(annotation, dexItemFactory);
         assert options.passthroughDexCode
             || !DexAnnotation.isSignatureAnnotation(annotation, dexItemFactory);
-        if (config.exceptions && DexAnnotation.isThrowingAnnotation(annotation, dexItemFactory)) {
-          return true;
+        if (DexAnnotation.isThrowingAnnotation(annotation, dexItemFactory)) {
+          KeepMethodInfo methodInfo = keepInfo.asMethodInfo();
+          return methodInfo != null && !methodInfo.isThrowsRemovalAllowed(options);
         }
         if (DexAnnotation.isSourceDebugExtension(annotation, dexItemFactory)) {
           assert holder.isProgramClass();
           appView.setSourceDebugExtensionForType(
               holder.asProgramClass(), annotation.annotation.elements[0].value.asDexValueString());
+          // TODO(b/343909250): Is this supposed to be kept on all live items?
           return config.sourceDebugExtension;
         }
         if (config.methodParameters
             && DexAnnotation.isParameterNameAnnotation(annotation, dexItemFactory)) {
+          // TODO(b/343907109): This should be conditional on its own keep info bit.
           return true;
         }
         if (isAnnotationOnAnnotationClass
             && DexAnnotation.isAnnotationDefaultAnnotation(annotation, dexItemFactory)
             && shouldRetainAnnotationDefaultAnnotationOnAnnotationClass(annotation)) {
+          // TODO(b/343909250): Is this supposed to be kept on all live items?
           return true;
         }
         return false;
diff --git a/src/main/java/com/android/tools/r8/shaking/GlobalKeepInfoConfiguration.java b/src/main/java/com/android/tools/r8/shaking/GlobalKeepInfoConfiguration.java
index a2ab3d3..c924664 100644
--- a/src/main/java/com/android/tools/r8/shaking/GlobalKeepInfoConfiguration.java
+++ b/src/main/java/com/android/tools/r8/shaking/GlobalKeepInfoConfiguration.java
@@ -22,6 +22,8 @@
 
   boolean isForceKeepSignatureAttributeEnabled();
 
+  boolean isForceKeepExceptionsAttributeEnabled();
+
   boolean isKeepEnclosingMethodAttributeEnabled();
 
   boolean isKeepInnerClassesAttributeEnabled();
diff --git a/src/main/java/com/android/tools/r8/shaking/KeepMethodInfo.java b/src/main/java/com/android/tools/r8/shaking/KeepMethodInfo.java
index 6b5a8a2..caaba95 100644
--- a/src/main/java/com/android/tools/r8/shaking/KeepMethodInfo.java
+++ b/src/main/java/com/android/tools/r8/shaking/KeepMethodInfo.java
@@ -28,6 +28,7 @@
     return bottom().joiner();
   }
 
+  private final boolean allowThrowsRemoval;
   private final boolean allowClassInlining;
   private final boolean allowClosedWorldReasoning;
   private final boolean allowConstantArgumentOptimization;
@@ -45,6 +46,7 @@
 
   protected KeepMethodInfo(Builder builder) {
     super(builder);
+    this.allowThrowsRemoval = builder.isThrowsRemovalAllowed();
     this.allowClassInlining = builder.isClassInliningAllowed();
     this.allowClosedWorldReasoning = builder.isClosedWorldReasoningAllowed();
     this.allowConstantArgumentOptimization = builder.isConstantArgumentOptimizationAllowed();
@@ -86,6 +88,21 @@
         configuration.isKeepRuntimeInvisibleParameterAnnotationsEnabled());
   }
 
+  /**
+   * True if an item may have its exception throws clause removed.
+   *
+   * <p>This method requires knowledge of the global configuration as that can override the concrete
+   * value on a given item.
+   */
+  public boolean isThrowsRemovalAllowed(GlobalKeepInfoConfiguration configuration) {
+    return !configuration.isForceKeepExceptionsAttributeEnabled()
+        && internalIsThrowsRemovalAllowed();
+  }
+
+  boolean internalIsThrowsRemovalAllowed() {
+    return allowThrowsRemoval;
+  }
+
   KeepAnnotationCollectionInfo internalParameterAnnotationsInfo() {
     return parameterAnnotationsInfo;
   }
@@ -242,6 +259,7 @@
 
   public static class Builder extends KeepMemberInfo.Builder<Builder, KeepMethodInfo> {
 
+    private boolean allowThrowsRemoval;
     private boolean allowClassInlining;
     private boolean allowClosedWorldReasoning;
     private boolean allowConstantArgumentOptimization;
@@ -263,6 +281,7 @@
 
     protected Builder(KeepMethodInfo original) {
       super(original);
+      allowThrowsRemoval = original.internalIsThrowsRemovalAllowed();
       allowClassInlining = original.internalIsClassInliningAllowed();
       allowClosedWorldReasoning = original.internalIsClosedWorldReasoningAllowed();
       allowConstantArgumentOptimization = original.internalIsConstantArgumentOptimizationAllowed();
@@ -280,6 +299,23 @@
       parameterAnnotationsInfo = original.internalParameterAnnotationsInfo().toBuilder();
     }
 
+    public boolean isThrowsRemovalAllowed() {
+      return allowThrowsRemoval;
+    }
+
+    private Builder setAllowThrowsRemoval(boolean allowThrowsRemoval) {
+      this.allowThrowsRemoval = allowThrowsRemoval;
+      return self();
+    }
+
+    public Builder allowThrowsRemoval() {
+      return setAllowThrowsRemoval(true);
+    }
+
+    public Builder disallowThrowsRemoval() {
+      return setAllowThrowsRemoval(false);
+    }
+
     // Class inlining.
 
     public boolean isClassInliningAllowed() {
@@ -572,6 +608,7 @@
     @Override
     boolean internalIsEqualTo(KeepMethodInfo other) {
       return super.internalIsEqualTo(other)
+          && isThrowsRemovalAllowed() == other.internalIsThrowsRemovalAllowed()
           && isClassInliningAllowed() == other.internalIsClassInliningAllowed()
           && isClosedWorldReasoningAllowed() == other.internalIsClosedWorldReasoningAllowed()
           && isConstantArgumentOptimizationAllowed()
@@ -600,6 +637,7 @@
     @Override
     public Builder makeTop() {
       return super.makeTop()
+          .disallowThrowsRemoval()
           .disallowClassInlining()
           .disallowClosedWorldReasoning()
           .disallowConstantArgumentOptimization()
@@ -619,6 +657,7 @@
     @Override
     public Builder makeBottom() {
       return super.makeBottom()
+          .allowThrowsRemoval()
           .allowClassInlining()
           .allowClosedWorldReasoning()
           .allowConstantArgumentOptimization()
@@ -646,6 +685,11 @@
       super(builder);
     }
 
+    public Joiner disallowThrowsRemoval() {
+      builder.disallowThrowsRemoval();
+      return self();
+    }
+
     public Joiner disallowClassInlining() {
       builder.disallowClassInlining();
       return self();
@@ -733,7 +777,8 @@
       builder
           .getParameterAnnotationsInfo()
           .destructiveJoin(joiner.builder.getParameterAnnotationsInfo());
-      return applyIf(!joiner.builder.isClassInliningAllowed(), Joiner::disallowClassInlining)
+      return applyIf(!joiner.builder.isThrowsRemovalAllowed(), Joiner::disallowThrowsRemoval)
+          .applyIf(!joiner.builder.isClassInliningAllowed(), Joiner::disallowClassInlining)
           .applyIf(
               !joiner.builder.isClosedWorldReasoningAllowed(), Joiner::disallowClosedWorldReasoning)
           .applyIf(
diff --git a/src/main/java/com/android/tools/r8/shaking/RootSetUtils.java b/src/main/java/com/android/tools/r8/shaking/RootSetUtils.java
index 626dc9a..f6ff126 100644
--- a/src/main/java/com/android/tools/r8/shaking/RootSetUtils.java
+++ b/src/main/java/com/android/tools/r8/shaking/RootSetUtils.java
@@ -1673,6 +1673,11 @@
         context.markAsUsed();
       }
 
+      if (attributesConfig.exceptions && item.isMethod()) {
+        itemJoiner.computeIfAbsent().asMethodJoiner().disallowThrowsRemoval();
+        context.markAsUsed();
+      }
+
       if (appView.options().isMinificationEnabled() && !modifiers.allowsObfuscation) {
         itemJoiner.computeIfAbsent().disallowMinification();
         context.markAsUsed();
diff --git a/src/main/java/com/android/tools/r8/utils/InternalOptions.java b/src/main/java/com/android/tools/r8/utils/InternalOptions.java
index f565875..bbfe77c 100644
--- a/src/main/java/com/android/tools/r8/utils/InternalOptions.java
+++ b/src/main/java/com/android/tools/r8/utils/InternalOptions.java
@@ -825,6 +825,13 @@
   }
 
   @Override
+  public boolean isForceKeepExceptionsAttributeEnabled() {
+    return proguardConfiguration == null
+        || (isForceProguardCompatibilityEnabled()
+            && proguardConfiguration.getKeepAttributes().exceptions);
+  }
+
+  @Override
   public boolean isKeepEnclosingMethodAttributeEnabled() {
     return proguardConfiguration.getKeepAttributes().enclosingMethod;
   }
diff --git a/src/test/java/com/android/tools/r8/shaking/attributes/KeepExceptionsAttributeTest.java b/src/test/java/com/android/tools/r8/shaking/attributes/KeepExceptionsAttributeTest.java
new file mode 100644
index 0000000..43213c0
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/shaking/attributes/KeepExceptionsAttributeTest.java
@@ -0,0 +1,152 @@
+// 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.shaking.attributes;
+
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbsent;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isPresent;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+import com.android.tools.r8.SingleTestRunResult;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestBuilder;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.shaking.ProguardKeepAttributes;
+import com.android.tools.r8.utils.StringUtils;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.MethodSubject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class KeepExceptionsAttributeTest extends TestBase {
+
+  private final TestParameters parameters;
+  private final String[] EXPECTED = new String[] {"A.foo", "A.bar", "A.foo", "A.bar"};
+
+  @Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withAllRuntimesAndApiLevels().build();
+  }
+
+  public KeepExceptionsAttributeTest(TestParameters parameters) {
+    this.parameters = parameters;
+  }
+
+  @Test
+  public void testRuntime() throws Exception {
+    testForRuntime(parameters)
+        .apply(this::addInputs)
+        .run(parameters.getRuntime(), TestClass.class)
+        .assertSuccessWithOutputLines(EXPECTED);
+  }
+
+  @Test
+  public void testR8() throws Exception {
+    testForR8(parameters.getBackend())
+        .apply(this::addInputs)
+        .addKeepMainRule(TestClass.class)
+        .addKeepRules(
+            StringUtils.lines("-keep class " + typeName(A.class) + " {", "  *** foo(...);", "}"))
+        .addKeepAttributes(ProguardKeepAttributes.EXCEPTIONS)
+        .setMinApi(parameters)
+        .run(parameters.getRuntime(), TestClass.class)
+        .assertSuccessWithOutputLines(EXPECTED)
+        .inspect(this::inspect);
+  }
+
+  private void addInputs(TestBuilder<? extends SingleTestRunResult<?>, ?> builder) {
+    builder.addProgramClasses(E1.class, E2.class, I.class, A.class, B.class, TestClass.class);
+  }
+
+  private void inspect(CodeInspector inspector) throws NoSuchMethodException {
+    assertThat(inspector.clazz(E1.class), isPresent());
+    assertThat(inspector.clazz(E2.class), isAbsent());
+    assertThat(inspector.clazz(I.class), isPresent());
+    assertThat(inspector.clazz(A.class), isPresent());
+    assertThat(inspector.clazz(B.class), isPresent());
+    {
+      MethodSubject aFoo = inspector.method(A.class.getMethod("foo"));
+      assertThat(aFoo.getThrowsAnnotation(E1.class), isPresent());
+    }
+    {
+      MethodSubject aBar = inspector.method(A.class.getMethod("bar"));
+      assertThat(aBar.getThrowsAnnotation(E2.class), isAbsent());
+    }
+    {
+      MethodSubject aFoobar = inspector.method(A.class.getMethod("foobar"));
+      assertThat(aFoobar.getThrowsAnnotation(E1.class), isAbsent());
+      assertThat(aFoobar.getThrowsAnnotation(E2.class), isAbsent());
+    }
+  }
+
+  static class E1 extends Exception {}
+
+  static class E2 extends Exception {}
+
+  interface I {
+    void foo() throws E1;
+
+    void bar() throws E2;
+
+    void foobar() throws E1, E2;
+  }
+
+  static class A implements I {
+
+    public void foo() throws E1 {
+      if (System.nanoTime() < 0) {
+        throw new E1();
+      }
+      System.out.println("A.foo");
+    }
+
+    public void bar() throws E2 {
+      if (System.nanoTime() < 0) {
+        throw new E2();
+      }
+      System.out.println("A.bar");
+    }
+
+    public void foobar() throws E1, E2 {
+      foo();
+      bar();
+    }
+  }
+
+  static class B implements I {
+
+    public void foo() throws E1 {
+      if (System.nanoTime() < 0) {
+        throw new E1();
+      }
+      System.out.println("B.foo");
+    }
+
+    public void bar() throws E2 {
+      if (System.nanoTime() < 0) {
+        throw new E2();
+      }
+      System.out.println("B.bar");
+    }
+
+    public void foobar() throws E1, E2 {
+      foo();
+      bar();
+    }
+  }
+
+  static class TestClass {
+
+    public static void main(String[] args) throws Exception {
+      I i = System.nanoTime() > 0 ? new A() : new B();
+      i.foo();
+      i.bar();
+      i.foobar();
+    }
+  }
+}
diff --git a/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/AbsentMethodSubject.java b/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/AbsentMethodSubject.java
index aeeb8f4..a9ce82e 100644
--- a/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/AbsentMethodSubject.java
+++ b/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/AbsentMethodSubject.java
@@ -108,6 +108,11 @@
   }
 
   @Override
+  public AnnotationSubject getThrowsAnnotation(Class<?> clazz) {
+    throw new Unreachable("Cannot get the exceptions attribute/annotation for an absent method");
+  }
+
+  @Override
   public ProgramMethod getProgramMethod() {
     return null;
   }
diff --git a/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/FoundAnnotationSubject.java b/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/FoundAnnotationSubject.java
index 4d67e5d..aec24b6 100644
--- a/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/FoundAnnotationSubject.java
+++ b/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/FoundAnnotationSubject.java
@@ -67,4 +67,9 @@
   public DexTypeAnnotation asDexTypeAnnotation() {
     return annotation.asTypeAnnotation();
   }
+
+  @Override
+  public String toString() {
+    return annotation.toString();
+  }
 }
diff --git a/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/FoundMethodSubject.java b/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/FoundMethodSubject.java
index 8cc6ab0..6b28889 100644
--- a/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/FoundMethodSubject.java
+++ b/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/FoundMethodSubject.java
@@ -17,6 +17,7 @@
 import com.android.tools.r8.graph.CfCode.LocalVariableInfo;
 import com.android.tools.r8.graph.Code;
 import com.android.tools.r8.graph.DexAnnotation;
+import com.android.tools.r8.graph.DexAnnotationElement;
 import com.android.tools.r8.graph.DexCode;
 import com.android.tools.r8.graph.DexDebugEvent;
 import com.android.tools.r8.graph.DexDebugInfo;
@@ -27,6 +28,8 @@
 import com.android.tools.r8.graph.DexMethod;
 import com.android.tools.r8.graph.DexString;
 import com.android.tools.r8.graph.DexType;
+import com.android.tools.r8.graph.DexValue;
+import com.android.tools.r8.graph.DexValue.DexValueType;
 import com.android.tools.r8.graph.MethodAccessFlags;
 import com.android.tools.r8.graph.ProgramMethod;
 import com.android.tools.r8.ir.code.IRCode;
@@ -175,6 +178,29 @@
   }
 
   @Override
+  public AnnotationSubject getThrowsAnnotation(Class<?> clazz) {
+    ClassSubject exceptionClass = codeInspector.clazz(clazz);
+    if (exceptionClass.isAbsent() || !getMethod().hasAnyAnnotations()) {
+      return new AbsentAnnotationSubject();
+    }
+    for (DexAnnotation annotation : getMethod().annotations().getAnnotations()) {
+      if (DexAnnotation.isThrowsAnnotation(annotation, codeInspector.dexItemFactory)) {
+        for (DexAnnotationElement element : annotation.annotation.elements) {
+          for (DexValue value : element.value.asDexValueArray().getValues()) {
+            DexValueType type = value.asDexValueType();
+            String desc = type.value.toDescriptorString();
+            if (desc.equals(exceptionClass.getOriginalDescriptor())
+                || desc.equals(exceptionClass.getFinalDescriptor())) {
+              return new FoundAnnotationSubject(annotation, codeInspector);
+            }
+          }
+        }
+      }
+    }
+    return new AbsentAnnotationSubject();
+  }
+
+  @Override
   public ProgramMethod getProgramMethod() {
     return new ProgramMethod(clazz.getDexProgramClass(), getMethod());
   }
diff --git a/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/MethodSubject.java b/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/MethodSubject.java
index 02ecc3a..439a306 100644
--- a/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/MethodSubject.java
+++ b/src/test/testbase/java/com/android/tools/r8/utils/codeinspector/MethodSubject.java
@@ -65,6 +65,8 @@
 
   public abstract List<FoundAnnotationSubject> getParameterAnnotations(int index);
 
+  public abstract AnnotationSubject getThrowsAnnotation(Class<?> clazz);
+
   public abstract ProgramMethod getProgramMethod();
 
   public Iterator<InstructionSubject> iterateInstructions() {