Add command-line flags for global synthetics

Fixes: b/306120364
Bug: b/324270842
Change-Id: Iaf9b4a0f717f21d84666e6475ff408cea704bbc8
diff --git a/src/main/java/com/android/tools/r8/D8Command.java b/src/main/java/com/android/tools/r8/D8Command.java
index 85d86a6..d3b820e 100644
--- a/src/main/java/com/android/tools/r8/D8Command.java
+++ b/src/main/java/com/android/tools/r8/D8Command.java
@@ -16,7 +16,9 @@
 import com.android.tools.r8.keepanno.annotations.KeepForApi;
 import com.android.tools.r8.naming.MapConsumer;
 import com.android.tools.r8.naming.ProguardMapStringConsumer;
+import com.android.tools.r8.origin.ArchiveEntryOrigin;
 import com.android.tools.r8.origin.Origin;
+import com.android.tools.r8.origin.PathOrigin;
 import com.android.tools.r8.profile.art.ArtProfileForRewriting;
 import com.android.tools.r8.shaking.ProguardConfigurationParser;
 import com.android.tools.r8.shaking.ProguardConfigurationRule;
@@ -24,10 +26,14 @@
 import com.android.tools.r8.shaking.ProguardConfigurationSourceFile;
 import com.android.tools.r8.shaking.ProguardConfigurationSourceStrings;
 import com.android.tools.r8.startup.StartupProfileProvider;
+import com.android.tools.r8.synthesis.GlobalSyntheticsResourceBytes;
+import com.android.tools.r8.synthesis.GlobalSyntheticsResourceFile;
+import com.android.tools.r8.synthesis.GlobalSyntheticsUtils;
 import com.android.tools.r8.utils.AndroidApiLevel;
 import com.android.tools.r8.utils.AndroidApp;
 import com.android.tools.r8.utils.AssertionConfigurationWithDefault;
 import com.android.tools.r8.utils.DumpInputFlags;
+import com.android.tools.r8.utils.FileUtils;
 import com.android.tools.r8.utils.InternalGlobalSyntheticsProgramProvider;
 import com.android.tools.r8.utils.InternalOptions;
 import com.android.tools.r8.utils.InternalOptions.DesugarState;
@@ -38,7 +44,10 @@
 import com.android.tools.r8.utils.Reporter;
 import com.android.tools.r8.utils.StringDiagnostic;
 import com.android.tools.r8.utils.ThreadUtils;
+import com.android.tools.r8.utils.ZipUtils;
 import com.google.common.collect.ImmutableList;
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
@@ -90,6 +99,7 @@
   public static class Builder extends BaseCompilerCommand.Builder<D8Command, Builder> {
 
     private boolean intermediate = false;
+    private Path globalSyntheticsOutput = null;
     private GlobalSyntheticsConsumer globalSyntheticsConsumer = null;
     private final List<GlobalSyntheticsResourceProvider> globalSyntheticsResourceProviders =
         new ArrayList<>();
@@ -257,12 +267,28 @@
     }
 
     /**
+     * Set an output path for receiving the global synthetic content for the given compilation.
+     *
+     * <p>Note: this output is ignored if the compilation is not an "intermediate mode" compilation.
+     *
+     * <p>Note: setting this will clear out any consumer set by setGlobalSyntheticsConsumer.
+     */
+    public Builder setGlobalSyntheticsOutput(Path globalSyntheticsOutput) {
+      this.globalSyntheticsConsumer = null;
+      this.globalSyntheticsOutput = globalSyntheticsOutput;
+      return self();
+    }
+
+    /**
      * Set a consumer for receiving the global synthetic content for the given compilation.
      *
      * <p>Note: this consumer is ignored if the compilation is not an "intermediate mode"
      * compilation.
+     *
+     * <p>Note: setting this will clear out any output path set by setGlobalSyntheticsOutput.
      */
     public Builder setGlobalSyntheticsConsumer(GlobalSyntheticsConsumer globalSyntheticsConsumer) {
+      this.globalSyntheticsOutput = null;
       this.globalSyntheticsConsumer = globalSyntheticsConsumer;
       return self();
     }
@@ -288,11 +314,30 @@
     /** Add global synthetics resource files. */
     public Builder addGlobalSyntheticsFiles(Collection<Path> files) {
       for (Path file : files) {
-        addGlobalSyntheticsResourceProviders(new GlobalSyntheticsResourceFile(file));
+        addGlobalSyntheticsFileOrArchiveOfGlobalSynthetics(file);
       }
       return self();
     }
 
+    private void addGlobalSyntheticsFileOrArchiveOfGlobalSynthetics(Path file) {
+      if (!FileUtils.isZipFile(file)) {
+        addGlobalSyntheticsResourceProviders(new GlobalSyntheticsResourceFile(file));
+        return;
+      }
+      PathOrigin origin = new PathOrigin(file);
+      try {
+        ZipUtils.iter(
+            file,
+            (entry, input) ->
+                addGlobalSyntheticsResourceProviders(
+                    new GlobalSyntheticsResourceBytes(
+                        new ArchiveEntryOrigin(entry.getName(), origin),
+                        ByteStreams.toByteArray(input))));
+      } catch (IOException e) {
+        error(origin, e);
+      }
+    }
+
     /**
      * Set a consumer for receiving the keep rules to use when compiling the desugared library for
      * the program being compiled in this compilation.
@@ -495,7 +540,8 @@
         return new D8Command(isPrintHelp(), isPrintVersion());
       }
 
-      intermediate |= getProgramConsumer() instanceof DexFilePerClassFileConsumer;
+      final ProgramConsumer programConsumer = getProgramConsumer();
+      intermediate |= programConsumer instanceof DexFilePerClassFileConsumer;
 
       DexItemFactory factory = new DexItemFactory();
       DesugaredLibrarySpecification desugaredLibrarySpecification =
@@ -511,20 +557,24 @@
 
       // If compiling to CF with --no-desugaring then the target API is B for consistency with R8.
       int minApiLevel =
-          getProgramConsumer() instanceof ClassFileConsumer && getDisableDesugaring()
+          programConsumer instanceof ClassFileConsumer && getDisableDesugaring()
               ? AndroidApiLevel.B.getLevel()
               : getMinApiLevel();
 
+      GlobalSyntheticsConsumer globalConsumer =
+          GlobalSyntheticsUtils.determineGlobalSyntheticsConsumer(
+              intermediate, globalSyntheticsOutput, globalSyntheticsConsumer, programConsumer);
+
       return new D8Command(
           getAppBuilder().build(),
           getMode(),
-          getProgramConsumer(),
+          programConsumer,
           getMainDexListConsumer(),
           minApiLevel,
           getReporter(),
           getDesugaringState(),
           intermediate,
-          intermediate ? globalSyntheticsConsumer : null,
+          globalConsumer,
           isOptimizeMultidexForLinearAlloc(),
           getIncludeClassesChecksum(),
           getDexClassChecksumFilter(),
@@ -841,4 +891,5 @@
         .setEnableMissingLibraryApiModeling(enableMissingLibraryApiModeling)
         .build();
   }
+
 }
diff --git a/src/main/java/com/android/tools/r8/D8CommandParser.java b/src/main/java/com/android/tools/r8/D8CommandParser.java
index 49360fd..a16189b 100644
--- a/src/main/java/com/android/tools/r8/D8CommandParser.java
+++ b/src/main/java/com/android/tools/r8/D8CommandParser.java
@@ -30,6 +30,8 @@
   private static final Set<String> OPTIONS_WITH_ONE_PARAMETER =
       ImmutableSet.of(
           "--output",
+          "--globals",
+          "--globals-output",
           "--lib",
           "--classpath",
           "--pg-map",
@@ -53,6 +55,8 @@
         .add(ParseFlagInfoImpl.getDebug(true))
         .add(ParseFlagInfoImpl.getRelease(false))
         .add(ParseFlagInfoImpl.getOutput())
+        .add(ParseFlagInfoImpl.getGlobals())
+        .add(ParseFlagInfoImpl.getGlobalsOutput())
         .add(ParseFlagInfoImpl.getLib())
         .add(ParseFlagInfoImpl.getClasspath())
         .add(ParseFlagInfoImpl.getMinApi())
@@ -211,6 +215,7 @@
   private D8Command.Builder parse(String[] args, Origin origin, D8Command.Builder builder) {
     CompilationMode compilationMode = null;
     Path outputPath = null;
+    Path globalsOutputPath = null;
     OutputMode outputMode = null;
     boolean hasDefinedApiLevel = false;
     OrderedClassFileResourceProvider.Builder classpathBuilder =
@@ -279,6 +284,21 @@
           continue;
         }
         outputPath = Paths.get(nextArg);
+      } else if (arg.equals("--globals")) {
+        builder.addGlobalSyntheticsFiles(Paths.get(nextArg));
+      } else if (arg.equals("--globals-output")) {
+        if (globalsOutputPath != null) {
+          builder.error(
+              new StringDiagnostic(
+                  "Cannot output globals both to '"
+                      + globalsOutputPath.toString()
+                      + "' and '"
+                      + nextArg
+                      + "'",
+                  origin));
+          continue;
+        }
+        globalsOutputPath = Paths.get(nextArg);
       } else if (arg.equals("--lib")) {
         addLibraryArgument(builder, origin, nextArg);
       } else if (arg.equals("--classpath")) {
@@ -373,6 +393,9 @@
     if (outputPath == null) {
       outputPath = Paths.get(".");
     }
+    if (globalsOutputPath != null) {
+      builder.setGlobalSyntheticsOutput(globalsOutputPath);
+    }
     return builder.setOutput(outputPath, outputMode);
   }
 }
diff --git a/src/main/java/com/android/tools/r8/ParseFlagInfoImpl.java b/src/main/java/com/android/tools/r8/ParseFlagInfoImpl.java
index 3273271..eb1fed3 100644
--- a/src/main/java/com/android/tools/r8/ParseFlagInfoImpl.java
+++ b/src/main/java/com/android/tools/r8/ParseFlagInfoImpl.java
@@ -47,6 +47,23 @@
         "<file> must be an existing directory or a zip file.");
   }
 
+  public static ParseFlagInfoImpl getGlobals() {
+    return flag1(
+        "--globals",
+        "<file>",
+        "Global synthetics <file> from a previous intermediate compilation.",
+        "The <file> may be either a zip-archive of global synthetics or the",
+        "global-synthetic files directly.");
+  }
+
+  public static ParseFlagInfoImpl getGlobalsOutput() {
+    return flag1(
+        "--globals-output",
+        "<file>",
+        "Output global synthetics in <file>.",
+        "<file> must be an existing directory or a non-existent zip archive.");
+  }
+
   public static ParseFlagInfoImpl getLib() {
     return flag1("--lib", "<file|jdk-home>", "Add <file|jdk-home> as a library resource.");
   }
diff --git a/src/main/java/com/android/tools/r8/synthesis/GlobalSyntheticsResourceBytes.java b/src/main/java/com/android/tools/r8/synthesis/GlobalSyntheticsResourceBytes.java
new file mode 100644
index 0000000..08ace42
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/synthesis/GlobalSyntheticsResourceBytes.java
@@ -0,0 +1,32 @@
+// 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.synthesis;
+
+import com.android.tools.r8.GlobalSyntheticsResourceProvider;
+import com.android.tools.r8.ResourceException;
+import com.android.tools.r8.origin.Origin;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+
+public class GlobalSyntheticsResourceBytes implements GlobalSyntheticsResourceProvider {
+
+  private final Origin origin;
+  private final byte[] bytes;
+
+  public GlobalSyntheticsResourceBytes(Origin origin, byte[] bytes) {
+    this.origin = origin;
+    this.bytes = bytes;
+  }
+
+  @Override
+  public Origin getOrigin() {
+    return origin;
+  }
+
+  @Override
+  public InputStream getByteStream() throws ResourceException {
+    return new ByteArrayInputStream(bytes);
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/GlobalSyntheticsResourceFile.java b/src/main/java/com/android/tools/r8/synthesis/GlobalSyntheticsResourceFile.java
similarity index 80%
rename from src/main/java/com/android/tools/r8/GlobalSyntheticsResourceFile.java
rename to src/main/java/com/android/tools/r8/synthesis/GlobalSyntheticsResourceFile.java
index 598a074..cf5b767 100644
--- a/src/main/java/com/android/tools/r8/GlobalSyntheticsResourceFile.java
+++ b/src/main/java/com/android/tools/r8/synthesis/GlobalSyntheticsResourceFile.java
@@ -1,8 +1,10 @@
-// Copyright (c) 2022, the R8 project authors. Please see the AUTHORS file
+// 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;
+package com.android.tools.r8.synthesis;
 
+import com.android.tools.r8.GlobalSyntheticsResourceProvider;
+import com.android.tools.r8.ResourceException;
 import com.android.tools.r8.origin.Origin;
 import com.android.tools.r8.origin.PathOrigin;
 import java.io.IOException;
diff --git a/src/main/java/com/android/tools/r8/synthesis/GlobalSyntheticsUtils.java b/src/main/java/com/android/tools/r8/synthesis/GlobalSyntheticsUtils.java
new file mode 100644
index 0000000..e132952
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/synthesis/GlobalSyntheticsUtils.java
@@ -0,0 +1,107 @@
+// 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.synthesis;
+
+import com.android.tools.r8.ByteDataView;
+import com.android.tools.r8.ClassFileConsumer;
+import com.android.tools.r8.DexFilePerClassFileConsumer;
+import com.android.tools.r8.DexIndexedConsumer;
+import com.android.tools.r8.DiagnosticsHandler;
+import com.android.tools.r8.GlobalSyntheticsConsumer;
+import com.android.tools.r8.ProgramConsumer;
+import com.android.tools.r8.errors.Unreachable;
+import com.android.tools.r8.references.ClassReference;
+import com.android.tools.r8.utils.ArchiveBuilder;
+import com.android.tools.r8.utils.DirectoryBuilder;
+import com.android.tools.r8.utils.OutputBuilder;
+import com.android.tools.r8.utils.StringDiagnostic;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+public final class GlobalSyntheticsUtils {
+
+  private GlobalSyntheticsUtils() {}
+
+  public static GlobalSyntheticsConsumer determineGlobalSyntheticsConsumer(
+      boolean intermediate,
+      Path output,
+      GlobalSyntheticsConsumer consumer,
+      ProgramConsumer programConsumer) {
+    assert output == null || consumer == null;
+    if (!intermediate) {
+      return null;
+    }
+    if (consumer != null) {
+      return consumer;
+    }
+    if (output == null) {
+      return null;
+    }
+
+    // Output is non-null, and we must create a consumer compatible with the program consumer.
+    OutputBuilder builder =
+        Files.isDirectory(output) ? new DirectoryBuilder(output) : new ArchiveBuilder(output);
+    builder.open();
+
+    if (programConsumer instanceof DexIndexedConsumer) {
+      return new GlobalSyntheticsConsumer() {
+        boolean written = false;
+
+        @Override
+        public synchronized void accept(
+            ByteDataView data, ClassReference context, DiagnosticsHandler handler) {
+          assert context == null;
+          if (written) {
+            String msg = "Attempt to write multiple global-synthetics files in dex-indexed mode.";
+            handler.error(new StringDiagnostic(msg));
+            throw new RuntimeException(msg);
+          }
+          builder.addFile("classes.globals", data, handler);
+          builder.close(handler);
+          written = true;
+        }
+
+        @Override
+        public void finished(DiagnosticsHandler handler) {
+          // If not global info was written, close the builder with empty content.
+          if (!written) {
+            builder.close(handler);
+          }
+        }
+      };
+    }
+
+    if (programConsumer instanceof DexFilePerClassFileConsumer) {
+      return new GlobalSyntheticsConsumer() {
+
+        @Override
+        public void accept(ByteDataView data, ClassReference context, DiagnosticsHandler handler) {
+          builder.addFile(context.getBinaryName() + ".globals", data, handler);
+        }
+
+        @Override
+        public void finished(DiagnosticsHandler handler) {
+          builder.close(handler);
+        }
+      };
+    }
+
+    if (programConsumer instanceof ClassFileConsumer) {
+      return new GlobalSyntheticsConsumer() {
+        @Override
+        public void accept(ByteDataView data, ClassReference context, DiagnosticsHandler handler) {
+          builder.addFile(context.getBinaryName() + ".globals", data, handler);
+        }
+
+        @Override
+        public void finished(DiagnosticsHandler handler) {
+          builder.close(handler);
+        }
+      };
+    }
+
+    throw new Unreachable("Unexpected program consumer type");
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/synthesis/globals/GlobalSyntheticOutputCliTest.java b/src/test/java/com/android/tools/r8/synthesis/globals/GlobalSyntheticOutputCliTest.java
new file mode 100644
index 0000000..a07ca84
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/synthesis/globals/GlobalSyntheticOutputCliTest.java
@@ -0,0 +1,320 @@
+// 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.synthesis.globals;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tools.r8.D8;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.TestRuntime.CfRuntime;
+import com.android.tools.r8.ToolHelper;
+import com.android.tools.r8.ToolHelper.ProcessResult;
+import com.android.tools.r8.transformers.ClassFileTransformer.MethodPredicate;
+import com.android.tools.r8.utils.AndroidApiLevel;
+import com.android.tools.r8.utils.ListUtils;
+import com.android.tools.r8.utils.StringUtils;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+@RunWith(Parameterized.class)
+public class GlobalSyntheticOutputCliTest extends TestBase {
+
+  static final String EXPECTED =
+      StringUtils.lines("Hello", "all good...", "Hello again", "still good...");
+
+  private final TestParameters parameters;
+
+  @Parameterized.Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withDefaultDexRuntime().withApiLevel(AndroidApiLevel.N_MR1).build();
+  }
+
+  public GlobalSyntheticOutputCliTest(TestParameters parameters) {
+    this.parameters = parameters;
+  }
+
+  private static String getAndroidJar() {
+    return ToolHelper.getAndroidJar(AndroidApiLevel.LATEST).toString();
+  }
+
+  private String getApiLevelString() {
+    return "" + parameters.getApiLevel().getLevel();
+  }
+
+  private ProcessResult forkD8(String... args) throws IOException {
+    return forkD8(Arrays.asList(args));
+  }
+
+  private ProcessResult forkD8(List<String> args) throws IOException {
+    ImmutableList.Builder<String> command =
+        new ImmutableList.Builder<String>()
+            .add(CfRuntime.getSystemRuntime().getJavaExecutable().toString())
+            .add("-Dcom.android.tools.r8.enableApiOutliningAndStubbing=1")
+            .add("-cp")
+            .add(System.getProperty("java.class.path"))
+            .add(D8.class.getName())
+            .add("--min-api", getApiLevelString())
+            .add("--lib", getAndroidJar())
+            .addAll(args);
+    ProcessBuilder processBuilder = new ProcessBuilder(command.build());
+    ProcessResult result = ToolHelper.runProcess(processBuilder);
+    assertEquals(result.toString(), 0, result.exitCode);
+    return result;
+  }
+
+  @Test
+  public void testDexIndexedZip() throws Exception {
+    Path input1 = transformClass(TestClass1.class);
+    Path input2 = transformClass(TestClass2.class);
+    Path dexOut = temp.newFolder().toPath().resolve("out.jar");
+    Path globalsOut = temp.newFolder().toPath().resolve("out.zip");
+    forkD8(
+        input1.toString(),
+        input2.toString(),
+        "--intermediate",
+        "--output",
+        dexOut.toString(),
+        "--globals-output",
+        globalsOut.toString());
+
+    assertTrue(Files.exists(dexOut));
+    assertTrue(Files.exists(globalsOut));
+
+    Path finalOut = temp.newFolder().toPath().resolve("out.jar");
+    forkD8(dexOut.toString(), "--globals", globalsOut.toString(), "--output", finalOut.toString());
+
+    testForD8()
+        .addProgramFiles(finalOut)
+        .run(parameters.getRuntime(), TestClass1.class)
+        .assertSuccessWithOutput(EXPECTED);
+  }
+
+  @Test
+  public void testDexIndexedDir() throws Exception {
+    Path input1 = transformClass(TestClass1.class);
+    Path input2 = transformClass(TestClass2.class);
+    Path dexOut = temp.newFolder().toPath().resolve("out.jar");
+    Path globalsOut = temp.newFolder("out").toPath();
+    forkD8(
+        input1.toString(),
+        input2.toString(),
+        "--intermediate",
+        "--output",
+        dexOut.toString(),
+        "--globals-output",
+        globalsOut.toString());
+
+    Path expectedGlobalsFile = globalsOut.resolve("classes.globals");
+    assertTrue(Files.exists(dexOut));
+    assertTrue(Files.isDirectory(globalsOut));
+    assertTrue(Files.exists(expectedGlobalsFile));
+
+    Path finalOut = temp.newFolder().toPath().resolve("out.jar");
+    forkD8(
+        dexOut.toString(),
+        "--globals",
+        expectedGlobalsFile.toString(),
+        "--output",
+        finalOut.toString());
+
+    testForD8()
+        .addProgramFiles(finalOut)
+        .run(parameters.getRuntime(), TestClass1.class)
+        .assertSuccessWithOutput(EXPECTED);
+  }
+
+  @Test
+  public void testDexPerClassZip() throws Exception {
+    Path input1 = transformClass(TestClass1.class);
+    Path input2 = transformClass(TestClass2.class);
+    Path dexOut = temp.newFolder().toPath().resolve("out.jar");
+    Path globalsOut = temp.newFolder().toPath().resolve("out.zip");
+    forkD8(
+        input1.toString(),
+        input2.toString(),
+        "--file-per-class",
+        "--output",
+        dexOut.toString(),
+        "--globals-output",
+        globalsOut.toString());
+
+    assertTrue(Files.exists(dexOut));
+    assertTrue(Files.exists(globalsOut));
+
+    Path finalOut = temp.newFolder().toPath().resolve("out.jar");
+    forkD8(dexOut.toString(), "--globals", globalsOut.toString(), "--output", finalOut.toString());
+
+    testForD8()
+        .addProgramFiles(finalOut)
+        .run(parameters.getRuntime(), TestClass1.class)
+        .assertSuccessWithOutput(EXPECTED);
+  }
+
+  @Test
+  public void testDexPerClassDir() throws Exception {
+    Path input1 = transformClass(TestClass1.class);
+    Path input2 = transformClass(TestClass2.class);
+    Path dexOut = temp.newFolder().toPath().resolve("out.jar");
+    Path globalsOut = temp.newFolder("out").toPath();
+    forkD8(
+        input1.toString(),
+        input2.toString(),
+        "--file-per-class",
+        "--output",
+        dexOut.toString(),
+        "--globals-output",
+        globalsOut.toString());
+
+    assertTrue(Files.exists(dexOut));
+    assertTrue(Files.isDirectory(globalsOut));
+
+    List<Path> globalFiles =
+        ImmutableList.of(
+            globalsOut.resolve(binaryName(TestClass1.class) + ".globals"),
+            globalsOut.resolve(binaryName(TestClass2.class) + ".globals"));
+    globalFiles.forEach(f -> assertTrue(Files.exists(f)));
+
+    Path finalOut = temp.newFolder().toPath().resolve("out.jar");
+    forkD8(
+        ImmutableList.<String>builder()
+            .add(dexOut.toString())
+            .addAll(
+                ListUtils.flatMap(globalFiles, f -> ImmutableList.of("--globals", f.toString())))
+            .add("--output")
+            .add(finalOut.toString())
+            .build());
+
+    testForD8()
+        .addProgramFiles(finalOut)
+        .run(parameters.getRuntime(), TestClass1.class)
+        .assertSuccessWithOutput(EXPECTED);
+  }
+
+  @Test
+  public void testDexPerClassFileZip() throws Exception {
+    Path input1 = transformClass(TestClass1.class);
+    Path input2 = transformClass(TestClass2.class);
+    Path dexOut = temp.newFolder().toPath().resolve("out.jar");
+    Path globalsOut = temp.newFolder().toPath().resolve("out.zip");
+    forkD8(
+        input1.toString(),
+        input2.toString(),
+        "--file-per-class-file",
+        "--output",
+        dexOut.toString(),
+        "--globals-output",
+        globalsOut.toString());
+
+    assertTrue(Files.exists(dexOut));
+    assertTrue(Files.exists(globalsOut));
+
+    Path finalOut = temp.newFolder().toPath().resolve("out.jar");
+    forkD8(dexOut.toString(), "--globals", globalsOut.toString(), "--output", finalOut.toString());
+
+    testForD8()
+        .addProgramFiles(finalOut)
+        .run(parameters.getRuntime(), TestClass1.class)
+        .assertSuccessWithOutput(EXPECTED);
+  }
+
+  @Test
+  public void testDexPerClassFileDir() throws Exception {
+    Path input1 = transformClass(TestClass1.class);
+    Path input2 = transformClass(TestClass2.class);
+    Path dexOut = temp.newFolder().toPath().resolve("out.jar");
+    Path globalsOut = temp.newFolder("out").toPath();
+    forkD8(
+        input1.toString(),
+        input2.toString(),
+        "--file-per-class-file",
+        "--output",
+        dexOut.toString(),
+        "--globals-output",
+        globalsOut.toString());
+
+    assertTrue(Files.exists(dexOut));
+    assertTrue(Files.isDirectory(globalsOut));
+
+    List<Path> globalFiles =
+        ImmutableList.of(
+            globalsOut.resolve(binaryName(TestClass1.class) + ".globals"),
+            globalsOut.resolve(binaryName(TestClass2.class) + ".globals"));
+    globalFiles.forEach(f -> assertTrue(Files.exists(f)));
+
+    Path finalOut = temp.newFolder().toPath().resolve("out.jar");
+    forkD8(
+        ImmutableList.<String>builder()
+            .add(dexOut.toString())
+            .addAll(
+                ListUtils.flatMap(globalFiles, f -> ImmutableList.of("--globals", f.toString())))
+            .add("--output")
+            .add(finalOut.toString())
+            .build());
+
+    testForD8()
+        .addProgramFiles(finalOut)
+        .run(parameters.getRuntime(), TestClass1.class)
+        .assertSuccessWithOutput(EXPECTED);
+  }
+
+  // Transform the class such that its handlers use the AuthenticationRequiredException which
+  // is introduced with API level 25. This triggers the need for a class stub for the exception.
+  private Path transformClass(Class<?> clazz) throws IOException {
+    byte[] bytes =
+        transformer(clazz)
+            .transformTryCatchBlock(
+                MethodPredicate.all(),
+                (start, end, handler, type, visitor) -> {
+                  assertEquals("java/lang/Exception", type);
+                  visitor.visitTryCatchBlock(
+                      start, end, handler, "android/app/AuthenticationRequiredException");
+                })
+            .transform();
+    Path file = temp.newFolder().toPath().resolve("input.class");
+    Files.write(file, bytes);
+    return file;
+  }
+
+  static class TestClass1 {
+
+    public static void main(String[] args) {
+      Runnable r =
+          () -> {
+            try {
+              System.out.println("Hello");
+            } catch (Exception /* will be AuthenticationRequiredException */ e) {
+              System.out.println("fail...");
+              throw e;
+            }
+            System.out.println("all good...");
+          };
+      r.run();
+      TestClass2.main(args);
+    }
+  }
+
+  static class TestClass2 {
+
+    public static void main(String[] args) {
+      try {
+        System.out.println("Hello again");
+      } catch (Exception /* will be AuthenticationRequiredException */ e) {
+        System.out.println("fail...");
+        throw e;
+      }
+      System.out.println("still good...");
+    }
+  }
+}