Add API for resolving duplicate definition conflicts.

Bug: b/241063980
Change-Id: I40294a8be3a8af70fa902d599c68a3df49004a29
diff --git a/src/main/java/com/android/tools/r8/BaseCompilerCommand.java b/src/main/java/com/android/tools/r8/BaseCompilerCommand.java
index b2e0f6d..22ad739 100644
--- a/src/main/java/com/android/tools/r8/BaseCompilerCommand.java
+++ b/src/main/java/com/android/tools/r8/BaseCompilerCommand.java
@@ -54,6 +54,7 @@
   private final DumpInputFlags dumpInputFlags;
   private final MapIdProvider mapIdProvider;
   private final SourceFileProvider sourceFileProvider;
+  private final ClassConflictResolver classConflictResolver;
 
   BaseCompilerCommand(boolean printHelp, boolean printVersion) {
     super(printHelp, printVersion);
@@ -72,6 +73,7 @@
     dumpInputFlags = DumpInputFlags.noDump();
     mapIdProvider = null;
     sourceFileProvider = null;
+    classConflictResolver = null;
   }
 
   BaseCompilerCommand(
@@ -90,7 +92,8 @@
       int threadCount,
       DumpInputFlags dumpInputFlags,
       MapIdProvider mapIdProvider,
-      SourceFileProvider sourceFileProvider) {
+      SourceFileProvider sourceFileProvider,
+      ClassConflictResolver classConflictResolver) {
     super(app);
     assert minApiLevel > 0;
     assert mode != null;
@@ -109,6 +112,7 @@
     this.dumpInputFlags = dumpInputFlags;
     this.mapIdProvider = mapIdProvider;
     this.sourceFileProvider = sourceFileProvider;
+    this.classConflictResolver = classConflictResolver;
   }
 
   /**
@@ -197,6 +201,10 @@
     return threadCount;
   }
 
+  ClassConflictResolver getClassConflictResolver() {
+    return classConflictResolver;
+  }
+
   DumpInputFlags getDumpInputFlags() {
     return dumpInputFlags;
   }
@@ -237,6 +245,7 @@
     private DumpInputFlags dumpInputFlags = DumpInputFlags.noDump();
     private MapIdProvider mapIdProvider = null;
     private SourceFileProvider sourceFileProvider = null;
+    private ClassConflictResolver classConflictResolver = null;
 
     abstract CompilationMode defaultCompilationMode();
 
@@ -720,5 +729,21 @@
     List<Consumer<Inspector>> getOutputInspections() {
       return outputInspections;
     }
+
+    /**
+     * Set a conflict resolver to determine which class definition to use in case of duplicates.
+     *
+     * <p>If no resolver is set, the compiler will fail compilation in case of duplicates.
+     *
+     * @param resolver Resolver for choosing between duplicate classes.
+     */
+    public B setClassConflictResolver(ClassConflictResolver resolver) {
+      this.classConflictResolver = resolver;
+      return self();
+    }
+
+    ClassConflictResolver getClassConflictResolver() {
+      return classConflictResolver;
+    }
   }
 }
diff --git a/src/main/java/com/android/tools/r8/ClassConflictResolver.java b/src/main/java/com/android/tools/r8/ClassConflictResolver.java
new file mode 100644
index 0000000..8136f17
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/ClassConflictResolver.java
@@ -0,0 +1,30 @@
+// Copyright (c) 2022, 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;
+
+import com.android.tools.r8.origin.Origin;
+import com.android.tools.r8.references.ClassReference;
+import java.util.Collection;
+
+@Keep
+public interface ClassConflictResolver {
+
+  /**
+   * Callback called in case the compiler is provided with duplicate class definitions.
+   *
+   * <p>The callback may be called multiple times for the same type with different origins, or it
+   * may be called once with all origins. Assuming the client has provided unique origins for the
+   * various inputs, the number of origins in any call will be at least two.
+   *
+   * <p>Note that all the duplicates are in the program's compilation unit. In other words, none of
+   * them are classpath or library definitions.
+   *
+   * @param reference The type reference of the duplicated class.
+   * @param origins The multiple origins of the class.
+   * @param handler Diagnostics handler for reporting.
+   * @return Returns the origin to use or null to fail compilation.
+   */
+  Origin resolveDuplicateClass(
+      ClassReference reference, Collection<Origin> origins, DiagnosticsHandler handler);
+}
diff --git a/src/main/java/com/android/tools/r8/D8Command.java b/src/main/java/com/android/tools/r8/D8Command.java
index 33e984b..62d96d8 100644
--- a/src/main/java/com/android/tools/r8/D8Command.java
+++ b/src/main/java/com/android/tools/r8/D8Command.java
@@ -28,6 +28,7 @@
 import com.android.tools.r8.utils.InternalOptions.DesugarState;
 import com.android.tools.r8.utils.InternalOptions.HorizontalClassMergerOptions;
 import com.android.tools.r8.utils.InternalOptions.LineNumberOptimization;
+import com.android.tools.r8.utils.ProgramClassCollection;
 import com.android.tools.r8.utils.Reporter;
 import com.android.tools.r8.utils.StringDiagnostic;
 import com.android.tools.r8.utils.ThreadUtils;
@@ -390,6 +391,7 @@
           getDumpInputFlags(),
           getMapIdProvider(),
           enableMissingLibraryApiModeling,
+          getClassConflictResolver(),
           factory);
     }
   }
@@ -476,6 +478,7 @@
       DumpInputFlags dumpInputFlags,
       MapIdProvider mapIdProvider,
       boolean enableMissingLibraryApiModeling,
+      ClassConflictResolver classConflictResolver,
       DexItemFactory factory) {
     super(
         inputApp,
@@ -493,7 +496,8 @@
         threadCount,
         dumpInputFlags,
         mapIdProvider,
-        null);
+        null,
+        classConflictResolver);
     this.intermediate = intermediate;
     this.globalSyntheticsConsumer = globalSyntheticsConsumer;
     this.desugarGraphConsumer = desugarGraphConsumer;
@@ -610,6 +614,10 @@
       horizontalClassMergerOptions.disable();
     }
 
+    internal.programClassConflictResolver =
+        ProgramClassCollection.wrappedConflictResolver(
+            getClassConflictResolver(), internal.reporter);
+
     internal.setDumpInputFlags(getDumpInputFlags(), skipDump);
     internal.dumpOptions = dumpOptions();
 
diff --git a/src/main/java/com/android/tools/r8/L8Command.java b/src/main/java/com/android/tools/r8/L8Command.java
index ee941db..fe430fc 100644
--- a/src/main/java/com/android/tools/r8/L8Command.java
+++ b/src/main/java/com/android/tools/r8/L8Command.java
@@ -23,6 +23,7 @@
 import com.android.tools.r8.utils.InternalOptions.DesugarState;
 import com.android.tools.r8.utils.InternalOptions.HorizontalClassMergerOptions;
 import com.android.tools.r8.utils.Pair;
+import com.android.tools.r8.utils.ProgramClassCollection;
 import com.android.tools.r8.utils.Reporter;
 import com.android.tools.r8.utils.StringDiagnostic;
 import com.android.tools.r8.utils.ThreadUtils;
@@ -102,6 +103,7 @@
       int threadCount,
       DumpInputFlags dumpInputFlags,
       MapIdProvider mapIdProvider,
+      ClassConflictResolver classConflictResolver,
       DexItemFactory factory) {
     super(
         inputApp,
@@ -119,7 +121,8 @@
         threadCount,
         dumpInputFlags,
         mapIdProvider,
-        null);
+        null,
+        classConflictResolver);
     this.d8Command = d8Command;
     this.r8Command = r8Command;
     this.desugaredLibrarySpecification = desugaredLibrarySpecification;
@@ -209,6 +212,10 @@
                 .build(),
             getAssertionsConfiguration());
 
+    internal.programClassConflictResolver =
+        ProgramClassCollection.wrappedConflictResolver(
+            getClassConflictResolver(), internal.reporter);
+
     if (!DETERMINISTIC_DEBUGGING) {
       assert internal.threadCount == ThreadUtils.NOT_SPECIFIED;
       internal.threadCount = getThreadCount();
@@ -421,6 +428,7 @@
           getThreadCount(),
           getDumpInputFlags(),
           getMapIdProvider(),
+          getClassConflictResolver(),
           factory);
     }
   }
diff --git a/src/main/java/com/android/tools/r8/R8Command.java b/src/main/java/com/android/tools/r8/R8Command.java
index b834389..85a6a63 100644
--- a/src/main/java/com/android/tools/r8/R8Command.java
+++ b/src/main/java/com/android/tools/r8/R8Command.java
@@ -37,6 +37,7 @@
 import com.android.tools.r8.utils.InternalOptions.DesugarState;
 import com.android.tools.r8.utils.InternalOptions.HorizontalClassMergerOptions;
 import com.android.tools.r8.utils.InternalOptions.LineNumberOptimization;
+import com.android.tools.r8.utils.ProgramClassCollection;
 import com.android.tools.r8.utils.Reporter;
 import com.android.tools.r8.utils.StringDiagnostic;
 import com.android.tools.r8.utils.ThreadUtils;
@@ -634,7 +635,8 @@
               getDumpInputFlags(),
               getMapIdProvider(),
               getSourceFileProvider(),
-              enableMissingLibraryApiModeling);
+              enableMissingLibraryApiModeling,
+              getClassConflictResolver());
 
       if (inputDependencyGraphConsumer != null) {
         inputDependencyGraphConsumer.finished();
@@ -802,7 +804,8 @@
       DumpInputFlags dumpInputFlags,
       MapIdProvider mapIdProvider,
       SourceFileProvider sourceFileProvider,
-      boolean enableMissingLibraryApiModeling) {
+      boolean enableMissingLibraryApiModeling,
+      ClassConflictResolver classConflictResolver) {
     super(
         inputApp,
         mode,
@@ -819,7 +822,8 @@
         threadCount,
         dumpInputFlags,
         mapIdProvider,
-        sourceFileProvider);
+        sourceFileProvider,
+        classConflictResolver);
     assert proguardConfiguration != null;
     assert mainDexKeepRules != null;
     this.mainDexKeepRules = mainDexKeepRules;
@@ -1014,6 +1018,10 @@
         SourceFileRewriter.computeSourceFileProvider(
             getSourceFileProvider(), proguardConfiguration, internal);
 
+    internal.programClassConflictResolver =
+        ProgramClassCollection.wrappedConflictResolver(
+            getClassConflictResolver(), internal.reporter);
+
     if (!DETERMINISTIC_DEBUGGING) {
       assert internal.threadCount == ThreadUtils.NOT_SPECIFIED;
       internal.threadCount = getThreadCount();
diff --git a/src/main/java/com/android/tools/r8/utils/ProgramClassCollection.java b/src/main/java/com/android/tools/r8/utils/ProgramClassCollection.java
index a5bbfcb..99b79e9 100644
--- a/src/main/java/com/android/tools/r8/utils/ProgramClassCollection.java
+++ b/src/main/java/com/android/tools/r8/utils/ProgramClassCollection.java
@@ -3,13 +3,16 @@
 // BSD-style license that can be found in the LICENSE file.
 package com.android.tools.r8.utils;
 
+import com.android.tools.r8.ClassConflictResolver;
 import com.android.tools.r8.dex.ApplicationReader.ProgramClassConflictResolver;
 import com.android.tools.r8.errors.DuplicateTypesDiagnostic;
 import com.android.tools.r8.graph.ClassKind;
 import com.android.tools.r8.graph.DexProgramClass;
 import com.android.tools.r8.graph.DexType;
+import com.android.tools.r8.origin.Origin;
 import com.android.tools.r8.references.Reference;
 import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.function.Supplier;
@@ -60,10 +63,28 @@
   public static ProgramClassConflictResolver defaultConflictResolver(Reporter reporter) {
     // The default conflict resolver only merges synthetic classes generated by D8 correctly.
     // All other conflicts are reported as a fatal error.
-    return (DexProgramClass a, DexProgramClass b) -> {
-      assert a.type == b.type;
-      if (a.accessFlags.isSynthetic() && b.accessFlags.isSynthetic()) {
-        return mergeClasses(reporter, a, b);
+    return wrappedConflictResolver(null, reporter);
+  }
+
+  public static ProgramClassConflictResolver wrappedConflictResolver(
+      ClassConflictResolver clientResolver, Reporter reporter) {
+    return (a, b) -> {
+      DexProgramClass clazz = mergeClasses(a, b);
+      if (clazz != null) {
+        return clazz;
+      }
+      if (clientResolver != null) {
+        List<Origin> origins = new ArrayList<>();
+        origins.add(a.getOrigin());
+        origins.add(b.getOrigin());
+        Origin origin =
+            clientResolver.resolveDuplicateClass(a.getClassReference(), origins, reporter);
+        if (origin == a.getOrigin()) {
+          return a;
+        }
+        if (origin == b.getOrigin()) {
+          return b;
+        }
       }
       throw reportDuplicateTypes(reporter, a, b);
     };
@@ -77,13 +98,22 @@
             ImmutableList.of(a.getOrigin(), b.getOrigin())));
   }
 
-  private static DexProgramClass mergeClasses(
-      Reporter reporter, DexProgramClass a, DexProgramClass b) {
+  private static DexProgramClass mergeClasses(DexProgramClass a, DexProgramClass b) {
+    assert a.type == b.type;
+    boolean syntheticA = a.accessFlags.isSynthetic();
+    boolean syntheticB = b.accessFlags.isSynthetic();
+    if (syntheticA && syntheticB) {
+      return mergeIfLegacySynthetics(a, b);
+    }
+    return null;
+  }
+
+  private static DexProgramClass mergeIfLegacySynthetics(DexProgramClass a, DexProgramClass b) {
     if (a.type.isLegacySynthesizedTypeAllowedDuplication()) {
       assert assertEqualClasses(a, b);
       return a;
     }
-    throw reportDuplicateTypes(reporter, a, b);
+    return null;
   }
 
   private static boolean assertEqualClasses(DexProgramClass a, DexProgramClass b) {
diff --git a/src/test/java/com/android/tools/r8/compilerapi/CompilerApiTestCollection.java b/src/test/java/com/android/tools/r8/compilerapi/CompilerApiTestCollection.java
index 905947d..a27cd51 100644
--- a/src/test/java/com/android/tools/r8/compilerapi/CompilerApiTestCollection.java
+++ b/src/test/java/com/android/tools/r8/compilerapi/CompilerApiTestCollection.java
@@ -9,6 +9,7 @@
 
 import com.android.tools.r8.ToolHelper;
 import com.android.tools.r8.compilerapi.assertionconfiguration.AssertionConfigurationTest;
+import com.android.tools.r8.compilerapi.classconflictresolver.ClassConflictResolverTest;
 import com.android.tools.r8.compilerapi.desugardependencies.DesugarDependenciesTest;
 import com.android.tools.r8.compilerapi.globalsynthetics.GlobalSyntheticsTest;
 import com.android.tools.r8.compilerapi.inputdependencies.InputDependenciesTest;
@@ -42,7 +43,9 @@
 
   private static final List<Class<? extends CompilerApiTest>> CLASSES_PENDING_BINARY_COMPATIBILITY =
       ImmutableList.of(
-          GlobalSyntheticsTest.ApiTest.class, EnableMissingLibraryApiModelingTest.ApiTest.class);
+          GlobalSyntheticsTest.ApiTest.class,
+          EnableMissingLibraryApiModelingTest.ApiTest.class,
+          ClassConflictResolverTest.ApiTest.class);
 
   private final TemporaryFolder temp;
 
diff --git a/src/test/java/com/android/tools/r8/compilerapi/classconflictresolver/ClassConflictResolverTest.java b/src/test/java/com/android/tools/r8/compilerapi/classconflictresolver/ClassConflictResolverTest.java
new file mode 100644
index 0000000..d328392
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/compilerapi/classconflictresolver/ClassConflictResolverTest.java
@@ -0,0 +1,116 @@
+// Copyright (c) 2022, 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.compilerapi.classconflictresolver;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tools.r8.ClassConflictResolver;
+import com.android.tools.r8.D8;
+import com.android.tools.r8.D8Command;
+import com.android.tools.r8.DexIndexedConsumer;
+import com.android.tools.r8.R8;
+import com.android.tools.r8.R8Command;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.compilerapi.CompilerApiTest;
+import com.android.tools.r8.compilerapi.CompilerApiTestRunner;
+import com.android.tools.r8.origin.Origin;
+import com.android.tools.r8.origin.PathOrigin;
+import com.android.tools.r8.utils.BooleanBox;
+import com.google.common.collect.ImmutableSet;
+import java.nio.file.Paths;
+import org.junit.Test;
+
+public class ClassConflictResolverTest extends CompilerApiTestRunner {
+
+  public ClassConflictResolverTest(TestParameters parameters) {
+    super(parameters);
+  }
+
+  @Override
+  public Class<? extends CompilerApiTest> binaryTestClass() {
+    return ApiTest.class;
+  }
+
+  @Test
+  public void testD8() throws Exception {
+    Origin originA = new PathOrigin(Paths.get("SourceA"));
+    Origin originB = new PathOrigin(Paths.get("SourceB"));
+    BooleanBox called = new BooleanBox(false);
+    new ApiTest(ApiTest.PARAMETERS)
+        .runD8(
+            originA,
+            originB,
+            (reference, origins, handler) -> {
+              called.set(true);
+              assertEquals(ImmutableSet.of(originA, originB), ImmutableSet.copyOf(origins));
+              return originA;
+            });
+    assertTrue(called.get());
+  }
+
+  @Test
+  public void testR8() throws Exception {
+    Origin originA = new PathOrigin(Paths.get("SourceA"));
+    Origin originB = new PathOrigin(Paths.get("SourceB"));
+    BooleanBox called = new BooleanBox(false);
+    new ApiTest(ApiTest.PARAMETERS)
+        .runR8(
+            originA,
+            originB,
+            (reference, origins, handler) -> {
+              called.set(true);
+              assertEquals(ImmutableSet.of(originA, originB), ImmutableSet.copyOf(origins));
+              return originA;
+            });
+    assertTrue(called.get());
+  }
+
+  public static class ApiTest extends CompilerApiTest {
+
+    public ApiTest(Object parameters) {
+      super(parameters);
+    }
+
+    public void runD8(Origin originA, Origin originB, ClassConflictResolver resolver)
+        throws Exception {
+      D8.run(
+          D8Command.builder()
+              .addClassProgramData(getBytesForClass(getMockClass()), originA)
+              .addClassProgramData(getBytesForClass(getMockClass()), originB)
+              .addLibraryFiles(getJava8RuntimeJar())
+              .setProgramConsumer(DexIndexedConsumer.emptyConsumer())
+              .setClassConflictResolver(resolver)
+              .build());
+    }
+
+    public void runR8(Origin originA, Origin originB, ClassConflictResolver resolver)
+        throws Exception {
+      R8.run(
+          R8Command.builder()
+              .addClassProgramData(getBytesForClass(getMockClass()), originA)
+              .addClassProgramData(getBytesForClass(getMockClass()), originB)
+              .addLibraryFiles(getJava8RuntimeJar())
+              .setDisableTreeShaking(true)
+              .setDisableMinification(true)
+              .setProgramConsumer(DexIndexedConsumer.emptyConsumer())
+              .setClassConflictResolver(resolver)
+              .build());
+    }
+
+    @Test
+    public void testD8() throws Exception {
+      Origin originA = new PathOrigin(Paths.get("SourceA"));
+      Origin originB = new PathOrigin(Paths.get("SourceB"));
+      runD8(originA, originB, (reference, origins, handler) -> originA);
+    }
+
+    @Test
+    public void testR8() throws Exception {
+      Origin originA = new PathOrigin(Paths.get("SourceA"));
+      Origin originB = new PathOrigin(Paths.get("SourceB"));
+      runR8(originA, originB, (reference, origins, handler) -> originA);
+    }
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/invalid/DuplicateDefinitionsTest.java b/src/test/java/com/android/tools/r8/invalid/DuplicateDefinitionsTest.java
index 90771b4..a52c762 100644
--- a/src/test/java/com/android/tools/r8/invalid/DuplicateDefinitionsTest.java
+++ b/src/test/java/com/android/tools/r8/invalid/DuplicateDefinitionsTest.java
@@ -4,25 +4,39 @@
 
 package com.android.tools.r8.invalid;
 
+import static com.android.tools.r8.DiagnosticsMatcher.diagnosticMessage;
 import static com.android.tools.r8.utils.codeinspector.Matchers.isPresent;
 import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.Assert.assertEquals;
 
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
 import com.android.tools.r8.jasmin.JasminBuilder;
 import com.android.tools.r8.jasmin.JasminBuilder.ClassBuilder;
 import com.android.tools.r8.jasmin.JasminTestBase;
-import com.android.tools.r8.utils.AndroidApp;
+import com.android.tools.r8.utils.AndroidApiLevel;
 import com.android.tools.r8.utils.codeinspector.ClassSubject;
-import com.android.tools.r8.utils.codeinspector.CodeInspector;
 import com.google.common.collect.ImmutableList;
-import java.io.ByteArrayOutputStream;
-import java.io.PrintStream;
-import java.nio.charset.Charset;
 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 DuplicateDefinitionsTest extends JasminTestBase {
 
+  @Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withDefaultDexRuntime().withApiLevel(AndroidApiLevel.B).build();
+  }
+
+  private final TestParameters parameters;
+
+  public DuplicateDefinitionsTest(TestParameters parameters) {
+    this.parameters = parameters;
+  }
+
   @Test
   public void testDuplicateMethods() throws Exception {
     JasminBuilder jasminBuilder = new JasminBuilder();
@@ -32,38 +46,37 @@
     classBuilder.addVirtualMethod("method", "V", ".limit locals 1", ".limit stack 0", "return");
     classBuilder.addVirtualMethod("method", "V", ".limit locals 1", ".limit stack 0", "return");
 
-    // Run D8 and intercept warnings.
-    PrintStream stderr = System.err;
-    ByteArrayOutputStream baos = new ByteArrayOutputStream();
-    System.setErr(new PrintStream(baos));
+    testForD8(parameters.getBackend())
+        .addProgramClassFileData(jasminBuilder.buildClasses())
+        .setMinApi(parameters.getApiLevel())
+        .compileWithExpectedDiagnostics(
+            diagnostics ->
+                diagnostics
+                    .assertOnlyWarnings()
+                    .assertWarningsMatch(
+                        diagnosticMessage(
+                            containsString(
+                                "Ignoring an implementation of the method `void"
+                                    + " C.main(java.lang.String[])` because it has multiple"
+                                    + " definitions")),
+                        diagnosticMessage(
+                            containsString(
+                                "Ignoring an implementation of the method `void C.method()` because"
+                                    + " it has multiple definitions"))))
+        .inspect(
+            inspector -> {
+              ClassSubject clazz = inspector.clazz("C");
+              assertThat(clazz, isPresent());
 
-    AndroidApp app = compileWithD8(jasminBuilder.build());
+              // There are two direct methods, but only because one is <init>.
+              assertEquals(
+                  2, clazz.getDexProgramClass().getMethodCollection().numberOfDirectMethods());
+              assertThat(clazz.method("void", "<init>", ImmutableList.of()), isPresent());
 
-    String output = new String(baos.toByteArray(), Charset.defaultCharset());
-    System.setOut(stderr);
-
-    // Check that warnings were emitted.
-    assertThat(
-        output,
-        containsString(
-            "Ignoring an implementation of the method `void C.main(java.lang.String[])` because "
-                + "it has multiple definitions"));
-    assertThat(
-        output,
-        containsString(
-            "Ignoring an implementation of the method `void C.method()` because "
-                + "it has multiple definitions"));
-
-    CodeInspector inspector = new CodeInspector(app);
-    ClassSubject clazz = inspector.clazz("C");
-    assertThat(clazz, isPresent());
-
-    // There are two direct methods, but only because one is <init>.
-    assertEquals(2, clazz.getDexProgramClass().getMethodCollection().numberOfDirectMethods());
-    assertThat(clazz.method("void", "<init>", ImmutableList.of()), isPresent());
-
-    // There is only one virtual method.
-    assertEquals(1, clazz.getDexProgramClass().getMethodCollection().numberOfVirtualMethods());
+              // There is only one virtual method.
+              assertEquals(
+                  1, clazz.getDexProgramClass().getMethodCollection().numberOfVirtualMethods());
+            });
   }
 
   @Test
@@ -75,26 +88,26 @@
     classBuilder.addStaticField("staticFld", "LC;", null);
     classBuilder.addStaticField("staticFld", "LC;", null);
 
-    // Run D8 and intercept warnings.
-    PrintStream stderr = System.err;
-    ByteArrayOutputStream baos = new ByteArrayOutputStream();
-    System.setErr(new PrintStream(baos));
+    testForD8(parameters.getBackend())
+        .addProgramClassFileData(jasminBuilder.buildClasses())
+        .setMinApi(parameters.getApiLevel())
+        .compileWithExpectedDiagnostics(
+            diagnostics ->
+                diagnostics
+                    .assertOnlyWarnings()
+                    .assertWarningsMatch(
+                        diagnosticMessage(
+                            containsString("Field `C C.fld` has multiple definitions")),
+                        diagnosticMessage(
+                            containsString("Field `C C.staticFld` has multiple definitions"))))
+        .inspect(
+            inspector -> {
+              ClassSubject clazz = inspector.clazz("C");
+              assertThat(clazz, isPresent());
 
-    AndroidApp app = compileWithD8(jasminBuilder.build());
-
-    String output = new String(baos.toByteArray(), Charset.defaultCharset());
-    System.setOut(stderr);
-
-    // Check that warnings were emitted.
-    assertThat(output, containsString("Field `C C.fld` has multiple definitions"));
-    assertThat(output, containsString("Field `C C.staticFld` has multiple definitions"));
-
-    CodeInspector inspector = new CodeInspector(app);
-    ClassSubject clazz = inspector.clazz("C");
-    assertThat(clazz, isPresent());
-
-    // Redundant fields have been removed.
-    assertEquals(1, clazz.getDexProgramClass().instanceFields().size());
-    assertEquals(1, clazz.getDexProgramClass().staticFields().size());
+              // Redundant fields have been removed.
+              assertEquals(1, clazz.getDexProgramClass().instanceFields().size());
+              assertEquals(1, clazz.getDexProgramClass().staticFields().size());
+            });
   }
 }
diff --git a/src/test/java/com/android/tools/r8/invalid/DuplicateProgramTypesTest.java b/src/test/java/com/android/tools/r8/invalid/DuplicateProgramTypesTest.java
index e8c8d39..1efe38f 100644
--- a/src/test/java/com/android/tools/r8/invalid/DuplicateProgramTypesTest.java
+++ b/src/test/java/com/android/tools/r8/invalid/DuplicateProgramTypesTest.java
@@ -10,10 +10,12 @@
 import static org.hamcrest.CoreMatchers.hasItems;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
+import com.android.tools.r8.BaseCompilerCommand;
 import com.android.tools.r8.CompilationFailedException;
 import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestDiagnosticMessages;
 import com.android.tools.r8.TestParameters;
 import com.android.tools.r8.TestParametersCollection;
 import com.android.tools.r8.ToolHelper;
@@ -21,6 +23,8 @@
 import com.android.tools.r8.origin.Origin;
 import com.android.tools.r8.position.Position;
 import com.android.tools.r8.references.Reference;
+import com.android.tools.r8.utils.AndroidApiLevel;
+import com.android.tools.r8.utils.SetUtils;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.Parameterized;
@@ -32,7 +36,7 @@
 
   @Parameterized.Parameters(name = "{0}")
   public static TestParametersCollection data() {
-    return getTestParameters().withNoneRuntime().build();
+    return getTestParameters().withDefaultDexRuntime().withApiLevel(AndroidApiLevel.B).build();
   }
 
   public DuplicateProgramTypesTest(TestParameters parameters) {
@@ -55,39 +59,113 @@
         }
       };
 
+  private void addDuplicateDefinitions(BaseCompilerCommand.Builder<?, ?> builder) throws Exception {
+    byte[] bytes = ToolHelper.getClassAsBytes(TestClass.class);
+    builder.addClassProgramData(bytes, originA);
+    builder.addClassProgramData(bytes, originB);
+  }
+
+  private void addResolvingHandler(BaseCompilerCommand.Builder<?, ?> builder) throws Exception {
+    builder.setClassConflictResolver(
+        (reference, origins, handler) -> {
+          assertEquals(
+              SetUtils.newIdentityHashSet(originA, originB), SetUtils.newIdentityHashSet(origins));
+          return originA;
+        });
+  }
+
+  private void addNonResolvingHandler(BaseCompilerCommand.Builder<?, ?> builder) throws Exception {
+    builder.setClassConflictResolver(
+        (reference, origins, handler) -> {
+          assertEquals(
+              SetUtils.newIdentityHashSet(originA, originB), SetUtils.newIdentityHashSet(origins));
+          return null;
+        });
+  }
+
+  private void checkErrorDiagnostic(TestDiagnosticMessages diagnostics) {
+    diagnostics.assertOnlyErrors();
+    diagnostics.assertErrorsCount(1);
+    DuplicateTypesDiagnostic diagnostic = (DuplicateTypesDiagnostic) diagnostics.getErrors().get(0);
+    assertEquals(Position.UNKNOWN, diagnostic.getPosition());
+    assertThat(diagnostic.getType(), equalTo(Reference.classFromClass(TestClass.class)));
+    assertThat(diagnostic.getOrigin(), anyOf(equalTo(originA), equalTo(originB)));
+    assertThat(diagnostic.getOrigins(), hasItems(originA, originB));
+    assertThat(
+        diagnostic.getDiagnosticMessage(),
+        allOf(
+            containsString("defined multiple"),
+            containsString("SourceA"),
+            containsString("SourceB")));
+  }
+
   @Test
-  public void test() throws Exception {
-    try {
-      byte[] bytes = ToolHelper.getClassAsBytes(TestClass.class);
-      testForD8()
-          .setMinApi(parameters.getRuntime())
-          .apply(
-              b -> {
-                b.getBuilder().addClassProgramData(bytes, originA);
-                b.getBuilder().addClassProgramData(bytes, originB);
-              })
-          .compileWithExpectedDiagnostics(
-              diagnostics -> {
-                diagnostics.assertOnlyErrors();
-                diagnostics.assertErrorsCount(1);
-                DuplicateTypesDiagnostic diagnostic =
-                    (DuplicateTypesDiagnostic) diagnostics.getErrors().get(0);
-                assertEquals(Position.UNKNOWN, diagnostic.getPosition());
-                assertThat(
-                    diagnostic.getType(), equalTo(Reference.classFromClass(TestClass.class)));
-                assertThat(diagnostic.getOrigin(), anyOf(equalTo(originA), equalTo(originB)));
-                assertThat(diagnostic.getOrigins(), hasItems(originA, originB));
-                assertThat(
-                    diagnostic.getDiagnosticMessage(),
-                    allOf(
-                        containsString("defined multiple"),
-                        containsString("SourceA"),
-                        containsString("SourceB")));
-              });
-    } catch (CompilationFailedException e) {
-      return; // Success.
-    }
-    fail("Expected test to fail with CompilationFailedException");
+  public void testDefaultError() throws Exception {
+    assertThrows(
+        CompilationFailedException.class,
+        () ->
+            testForD8(parameters.getBackend())
+                .setMinApi(parameters.getApiLevel())
+                .apply(b -> addDuplicateDefinitions(b.getBuilder()))
+                .compileWithExpectedDiagnostics(this::checkErrorDiagnostic));
+  }
+
+  @Test
+  public void testResolvedConflictD8() throws Exception {
+    testForD8(parameters.getBackend())
+        .setMinApi(parameters.getApiLevel())
+        .apply(
+            b -> {
+              addDuplicateDefinitions(b.getBuilder());
+              addResolvingHandler(b.getBuilder());
+            })
+        .run(parameters.getRuntime(), TestClass.class)
+        .assertSuccessWithOutputLines("Hello, world");
+  }
+
+  @Test
+  public void testResolvedConflictR8() throws Exception {
+    testForR8(parameters.getBackend())
+        .setMinApi(parameters.getApiLevel())
+        .addKeepMainRule(TestClass.class)
+        .apply(
+            b -> {
+              addDuplicateDefinitions(b.getBuilder());
+              addResolvingHandler(b.getBuilder());
+            })
+        .run(parameters.getRuntime(), TestClass.class)
+        .assertSuccessWithOutputLines("Hello, world");
+  }
+
+  @Test
+  public void testNonResolvedConflictD8() throws Exception {
+    assertThrows(
+        CompilationFailedException.class,
+        () ->
+            testForD8(parameters.getBackend())
+                .setMinApi(parameters.getApiLevel())
+                .apply(
+                    b -> {
+                      addDuplicateDefinitions(b.getBuilder());
+                      addNonResolvingHandler(b.getBuilder());
+                    })
+                .compileWithExpectedDiagnostics(this::checkErrorDiagnostic));
+  }
+
+  @Test
+  public void testNonResolvedConflictR8() throws Exception {
+    assertThrows(
+        CompilationFailedException.class,
+        () ->
+            testForR8(parameters.getBackend())
+                .setMinApi(parameters.getApiLevel())
+                .addKeepMainRule(TestClass.class)
+                .apply(
+                    b -> {
+                      addDuplicateDefinitions(b.getBuilder());
+                      addNonResolvingHandler(b.getBuilder());
+                    })
+                .compileWithExpectedDiagnostics(this::checkErrorDiagnostic));
   }
 
   static class TestClass {