blob: 7cbf81679b4c62259bc4ac4155e01048806f87a5 [file] [log] [blame]
// Copyright (c) 2017, 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.dexfilemerger;
import com.android.tools.r8.ByteDataView;
import com.android.tools.r8.CompilationFailedException;
import com.android.tools.r8.D8Command;
import com.android.tools.r8.DexFileMergerHelper;
import com.android.tools.r8.DexIndexedConsumer;
import com.android.tools.r8.DiagnosticsHandler;
import com.android.tools.r8.errors.Unreachable;
import com.android.tools.r8.origin.Origin;
import com.android.tools.r8.origin.PathOrigin;
import com.android.tools.r8.utils.ExceptionDiagnostic;
import com.android.tools.r8.utils.FileUtils;
import com.android.tools.r8.utils.OptionsParsing;
import com.android.tools.r8.utils.OptionsParsing.ParseContext;
import com.android.tools.r8.utils.StringDiagnostic;
import com.android.tools.r8.utils.ZipUtils;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
public class DexFileMerger {
/** File name prefix of a {@code .dex} file automatically loaded in an archive. */
private static final String DEX_PREFIX = "classes";
private static final String DEFAULT_OUTPUT_ARCHIVE_FILENAME = "classes.dex.jar";
private static final boolean PRINT_ARGS = false;
/** Strategies for outputting multiple {@code .dex} files supported by {@link DexFileMerger}. */
private enum MultidexStrategy {
/** Create exactly one .dex file. The operation will fail if .dex limits are exceeded. */
OFF,
/** Create exactly one <prefixN>.dex file with N taken from the (single) input archive. */
GIVEN_SHARD,
/**
* Assemble .dex files similar to {@link com.android.dx.command.dexer.Main dx}, with all but one
* file as large as possible.
*/
MINIMAL,
/**
* Allow some leeway and sometimes use additional .dex files to speed up processing. This option
* exists to give flexibility but it often (or always) may be identical to {@link #MINIMAL}.
*/
BEST_EFFORT;
public boolean isMultidexAllowed() {
switch (this) {
case OFF:
case GIVEN_SHARD:
return false;
case MINIMAL:
case BEST_EFFORT:
return true;
}
throw new AssertionError("Unknown: " + this);
}
public static MultidexStrategy parse(String value) {
switch (value) {
case "off":
return OFF;
case "given_shard":
return GIVEN_SHARD;
case "minimal":
return MINIMAL;
case "best_effort":
return BEST_EFFORT;
default:
throw new RuntimeException(
"Multidex argument must be either 'off', 'given_shard', 'minimal' or 'best_effort'.");
}
}
}
private static class Options {
List<String> inputArchives = new ArrayList<>();
String outputArchive = DEFAULT_OUTPUT_ARCHIVE_FILENAME;
MultidexStrategy multidexMode = MultidexStrategy.OFF;
String mainDexListFile = null;
boolean minimalMainDex = false;
boolean verbose = false;
String dexPrefix = DEX_PREFIX;
}
private static Options parseArguments(String[] args) throws IOException {
// We may have a single argument which is a parameter file path, prefixed with '@'.
if (args.length == 1 && args[0].startsWith("@")) {
// TODO(tamaskenez) Implement more sophisticated processing
// which is aligned with Blaze's
// com.google.devtools.common.options.ShellQuotedParamsFilePreProcessor
Path paramsFile = Paths.get(args[0].substring(1));
List<String> argsList = new ArrayList<>();
for (String s : Files.readAllLines(paramsFile)) {
s = s.trim();
if (s.isEmpty()) {
continue;
}
// Trim optional enclosing single quotes. Unescaping omitted for now.
if (s.length() >= 2 && s.startsWith("'") && s.endsWith("'")) {
s = s.substring(1, s.length() - 1);
}
argsList.add(s);
}
args = argsList.toArray(new String[argsList.size()]);
}
Options options = new Options();
ParseContext context = new ParseContext(args);
List<String> strings;
String string;
Boolean b;
while (context.head() != null) {
if (context.head().startsWith("@")) {
throw new RuntimeException("A params file must be the only argument: " + context.head());
}
strings = OptionsParsing.tryParseMulti(context, "--input");
if (strings != null) {
options.inputArchives.addAll(strings);
continue;
}
string = OptionsParsing.tryParseSingle(context, "--output", "-o");
if (string != null) {
options.outputArchive = string;
continue;
}
string = OptionsParsing.tryParseSingle(context, "--multidex", null);
if (string != null) {
options.multidexMode = MultidexStrategy.parse(string);
continue;
}
string = OptionsParsing.tryParseSingle(context, "--main-dex-list", null);
if (string != null) {
options.mainDexListFile = string;
continue;
}
b = OptionsParsing.tryParseBoolean(context, "--minimal-main-dex");
if (b != null) {
options.minimalMainDex = b;
continue;
}
b = OptionsParsing.tryParseBoolean(context, "--verbose");
if (b != null) {
options.verbose = b;
continue;
}
string = OptionsParsing.tryParseSingle(context, "--max-bytes-wasted-per-file", null);
if (string != null) {
System.err.println("Warning: '--max-bytes-wasted-per-file' is ignored.");
continue;
}
string = OptionsParsing.tryParseSingle(context, "--set-max-idx-number", null);
if (string != null) {
System.err.println("Warning: The '--set-max-idx-number' option is ignored.");
continue;
}
b = OptionsParsing.tryParseBoolean(context, "--forceJumbo");
if (b != null) {
System.err.println(
"Warning: '--forceJumbo' can be safely omitted. Strings will only use "
+ "jumbo-string indexing if necessary.");
continue;
}
string = OptionsParsing.tryParseSingle(context, "--dex_prefix", null);
if (string != null) {
options.dexPrefix = string;
continue;
}
throw new RuntimeException(String.format("Unknown options: '%s'.", context.head()));
}
return options;
}
/**
* Implements a DexIndexedConsumer writing into a ZipStream with support for custom dex file name
* prefix, reindexing a single dex output file to a nonzero index and reporting if any data has
* been written.
*/
private static class ArchiveConsumer implements DexIndexedConsumer {
private final Path path;
private final String prefix;
private final Integer singleFixedFileIndex;
private final Origin origin;
private ZipOutputStream stream = null;
private int highestIndexWritten = -1;
private final Map<Integer, Runnable> writers = new TreeMap<>();
private boolean hasWrittenSomething = false;
/** If singleFixedFileIndex is not null then we expect only one output dex file */
private ArchiveConsumer(Path path, String prefix, Integer singleFixedFileIndex) {
this.path = path;
this.prefix = prefix;
this.singleFixedFileIndex = singleFixedFileIndex;
this.origin = new PathOrigin(path);
}
private boolean hasWrittenSomething() {
return hasWrittenSomething;
}
private String getDexFileName(int fileIndex) {
if (singleFixedFileIndex != null) {
fileIndex = singleFixedFileIndex;
}
return prefix + (fileIndex == 0 ? "" : (fileIndex + 1)) + FileUtils.DEX_EXTENSION;
}
@Override
public synchronized void accept(
int fileIndex, ByteDataView data, Set<String> descriptors, DiagnosticsHandler handler) {
if (singleFixedFileIndex != null && fileIndex != 0) {
handler.error(new StringDiagnostic("Result does not fit into a single dex file."));
return;
}
// Make a copy of the actual bytes as they will possibly be accessed later by the runner.
final byte[] bytes = data.copyByteData();
writers.put(fileIndex, () -> writeEntry(fileIndex, bytes, descriptors, handler));
while (writers.containsKey(highestIndexWritten + 1)) {
++highestIndexWritten;
writers.get(highestIndexWritten).run();
writers.remove(highestIndexWritten);
}
}
/** Get or open the zip output stream. */
private synchronized ZipOutputStream getStream(DiagnosticsHandler handler) {
if (stream == null) {
try {
stream =
new ZipOutputStream(
Files.newOutputStream(
path, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING));
} catch (IOException e) {
handler.error(new ExceptionDiagnostic(e, origin));
}
}
return stream;
}
private void writeEntry(
int fileIndex, byte[] data, Set<String> descriptors, DiagnosticsHandler handler) {
try {
ZipUtils.writeToZipStream(
getStream(handler),
getDexFileName(fileIndex),
ByteDataView.of(data),
ZipEntry.DEFLATED);
hasWrittenSomething = true;
} catch (IOException e) {
handler.error(new ExceptionDiagnostic(e, origin));
}
}
@Override
public void finished(DiagnosticsHandler handler) {
if (!writers.isEmpty()) {
handler.error(
new StringDiagnostic(
"Failed to write zip, for a multidex output some of the classes.dex files were"
+ " not produced."));
}
try {
if (stream != null) {
stream.close();
stream = null;
}
} catch (IOException e) {
handler.error(new ExceptionDiagnostic(e, origin));
}
}
}
private static int parseFileIndexFromShardFilename(String inputArchive) {
Pattern namingPattern = Pattern.compile("([0-9]+)\\..*");
String name = new File(inputArchive).getName();
Matcher matcher = namingPattern.matcher(name);
if (!matcher.matches()) {
throw new RuntimeException(
String.format(
"Expect input named <N>.xxx.zip for --multidex=given_shard but got %s.", name));
}
int shard = Integer.parseInt(matcher.group(1));
if (shard <= 0) {
throw new RuntimeException(
String.format("Expect positive N in input named <N>.xxx.zip but got %d.", shard));
}
return shard;
}
public static void run(String[] args) throws CompilationFailedException, IOException {
Options options = parseArguments(args);
if (options.inputArchives.isEmpty()) {
throw new RuntimeException("Need at least one --input");
}
if (options.mainDexListFile != null && options.inputArchives.size() != 1) {
throw new RuntimeException(
"--main-dex-list only supported with exactly one --input, use DexFileSplitter for more");
}
if (!options.multidexMode.isMultidexAllowed()) {
if (options.mainDexListFile != null) {
throw new RuntimeException(
"--main-dex-list is only supported with multidex enabled, but mode is: "
+ options.multidexMode.toString());
}
if (options.minimalMainDex) {
throw new RuntimeException(
"--minimal-main-dex is only supported with multidex enabled, but mode is: "
+ options.multidexMode.toString());
}
}
D8Command.Builder builder = D8Command.builder();
Map<String, Integer> inputOrdering = new HashMap<>(options.inputArchives.size());
int sequenceNumber = 0;
for (String s : options.inputArchives) {
builder.addProgramFiles(Paths.get(s));
inputOrdering.put(s, sequenceNumber++);
}
// Determine enabling multidexing and file indexing.
Integer singleFixedFileIndex = null;
switch (options.multidexMode) {
case OFF:
singleFixedFileIndex = 0;
break;
case GIVEN_SHARD:
if (options.inputArchives.size() != 1) {
throw new RuntimeException("'--multidex=given_shard' requires exactly one --input.");
}
singleFixedFileIndex = parseFileIndexFromShardFilename(options.inputArchives.get(0)) - 1;
break;
case MINIMAL:
case BEST_EFFORT:
// Nothing to do.
break;
default:
throw new Unreachable("Unexpected enum: " + options.multidexMode);
}
if (options.mainDexListFile != null) {
builder.addMainDexListFiles(Paths.get(options.mainDexListFile));
}
ArchiveConsumer consumer =
new ArchiveConsumer(
Paths.get(options.outputArchive), options.dexPrefix, singleFixedFileIndex);
builder.setProgramConsumer(consumer);
DexFileMergerHelper.run(builder.build(), options.minimalMainDex, inputOrdering);
// If input was empty we still need to write out an empty zip.
if (!consumer.hasWrittenSomething()) {
File f = new File(options.outputArchive);
ZipOutputStream out = new ZipOutputStream(new FileOutputStream(f));
out.close();
}
}
public static void main(String[] args) {
try {
if (PRINT_ARGS) {
printArgs(args);
}
run(args);
} catch (CompilationFailedException | IOException e) {
System.err.println("Merge failed: " + e.getMessage());
System.exit(1);
}
}
private static void printArgs(String[] args) {
System.err.print("r8.DexFileMerger");
for (String s : args) {
System.err.printf(" %s", s);
}
System.err.println("");
}
}