blob: 78ab87d53c0f11edefcf2015172bcc8f9036ff25 [file] [log] [blame]
// Copyright 2017 The Bazel Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.devtools.build.android.dexer;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.android.dex.Dex;
import com.android.dex.DexFormat;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Predicates;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.ByteStreams;
import com.google.common.io.Closer;
import com.google.devtools.build.android.Converters.ExistingPathConverter;
import com.google.devtools.build.android.Converters.PathConverter;
import com.google.devtools.common.options.Option;
import com.google.devtools.common.options.OptionDocumentationCategory;
import com.google.devtools.common.options.OptionEffectTag;
import com.google.devtools.common.options.OptionsBase;
import com.google.devtools.common.options.OptionsParser;
import com.google.devtools.common.options.ShellQuotedParamsFilePreProcessor;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* Shuffles .class.dex files from input archives into 1 or more archives each to be merged into a
* single final .dex file by {@link DexFileMerger}, respecting main dex list and other constraints
* similar to how dx would process these files if they were in a single input archive.
*/
class DexFileSplitter implements Closeable {
/**
* Commandline options.
*/
public static class Options extends OptionsBase {
@Option(
name = "input",
allowMultiple = true,
defaultValue = "null",
category = "input",
documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
effectTags = {OptionEffectTag.UNKNOWN},
converter = ExistingPathConverter.class,
abbrev = 'i',
help = "Input dex archive.")
public List<Path> inputArchives;
@Option(
name = "output",
defaultValue = ".",
category = "output",
documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
effectTags = {OptionEffectTag.UNKNOWN},
converter = PathConverter.class,
abbrev = 'o',
help = "Directory to write dex archives to merge."
)
public Path outputDirectory;
@Option(
name = "main-dex-list",
defaultValue = "null",
category = "multidex",
documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
effectTags = {OptionEffectTag.UNKNOWN},
converter = ExistingPathConverter.class,
help = "List of classes to be placed into \"main\" classes.dex file."
)
public Path mainDexListFile;
@Option(
name = "minimal-main-dex",
defaultValue = "false",
category = "multidex",
documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
effectTags = {OptionEffectTag.UNKNOWN},
help =
"If true, *only* classes listed in --main_dex_list file are placed into \"main\" "
+ "classes.dex file."
)
public boolean minimalMainDex;
// Undocumented dx option for testing multidex logic
@Option(
name = "set-max-idx-number",
defaultValue = "" + DexFormat.MAX_MEMBER_IDX,
documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
effectTags = {OptionEffectTag.UNKNOWN},
help = "Limit on fields and methods in a single dex file."
)
public int maxNumberOfIdxPerDex;
@Option(
name = "inclusion_filter_jar",
defaultValue = "null",
category = "input",
documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
effectTags = {OptionEffectTag.UNKNOWN},
converter = ExistingPathConverter.class,
help = "If given, only classes in the given Jar are included in outputs."
)
public Path inclusionFilterJar;
}
public static void main(String[] args) throws Exception {
OptionsParser optionsParser =
OptionsParser.builder()
.optionsClasses(Options.class)
.allowResidue(false)
.argsPreProcessor(new ShellQuotedParamsFilePreProcessor(FileSystems.getDefault()))
.build();
optionsParser.parseAndExitUponError(args);
splitIntoShards(optionsParser.getOptions(Options.class));
}
@VisibleForTesting
static void splitIntoShards(Options options) throws IOException {
checkArgument(
!options.minimalMainDex || options.mainDexListFile != null,
"--minimal-main-dex not allowed without --main-dex-list");
if (!Files.exists(options.outputDirectory)) {
Files.createDirectories(options.outputDirectory);
}
ImmutableSet<String> classesInMainDex =
options.mainDexListFile != null
? ImmutableSet.copyOf(Files.readAllLines(options.mainDexListFile, UTF_8))
: null;
ImmutableSet<String> expected =
options.inclusionFilterJar != null ? expectedEntries(options.inclusionFilterJar) : null;
try (Closer closer = Closer.create();
DexFileSplitter out =
new DexFileSplitter(options.outputDirectory, options.maxNumberOfIdxPerDex)) {
// 1. Scan inputs in order and keep first occurrence of each class, keeping all zips open.
// We don't process anything yet so we can shard in sorted order, which is what dx would do
// if presented with a single jar containing all the given inputs.
// TODO(kmb): Abandon alphabetic sorting to process each input fully before moving on (still
// requires scanning inputs twice for main dex list).
Predicate<ZipEntry> inclusionFilter = ZipEntryPredicates.suffixes(".dex", ".class");
if (expected != null) {
inclusionFilter = inclusionFilter.and(e -> expected.contains(e.getName()));
}
LinkedHashMap<String, ZipFile> deduped = new LinkedHashMap<>();
for (Path inputArchive : options.inputArchives) {
ZipFile zip = closer.register(new ZipFile(inputArchive.toFile()));
zip.stream()
.filter(inclusionFilter)
.forEach(e -> deduped.putIfAbsent(e.getName(), zip));
}
ImmutableList<Map.Entry<String, ZipFile>> files =
deduped
.entrySet()
.stream()
.sorted(Comparator.comparing(e -> e.getKey(), ZipEntryComparator::compareClassNames))
.collect(ImmutableList.toImmutableList());
// 2. Process each class in desired order, rolling from shard to shard as needed.
if (classesInMainDex == null || classesInMainDex.isEmpty()) {
out.processDexFiles(files, Predicates.alwaysTrue());
} else {
checkArgument(classesInMainDex.stream().noneMatch(s -> s.startsWith("j$/")),
"%s lists classes in package 'j$', which can't be included in classes.dex and can "
+ "cause runtime errors. Please avoid needing these classes in the main dex file.",
options.mainDexListFile);
// To honor --main_dex_list make two passes:
// 1. process only the classes listed in the given file
// 2. process the remaining files
Predicate<String> mainDexFilter = ZipEntryPredicates.classFileNameFilter(classesInMainDex);
out.processDexFiles(files, mainDexFilter);
// Fail if main_dex_list is too big, following dx's example
checkState(out.shardsWritten() == 0, "Too many classes listed in main dex list file "
+ "%s, main dex capacity exceeded", options.mainDexListFile);
if (options.minimalMainDex) {
out.nextShard(); // Start new .dex file if requested
}
out.processDexFiles(files, mainDexFilter.negate());
}
}
}
private static ImmutableSet<String> expectedEntries(Path filterJar) throws IOException {
try (ZipFile zip = new ZipFile(filterJar.toFile())) {
return zip.stream()
.filter(ZipEntryPredicates.suffixes(".class"))
.map(e -> e.getName() + ".dex")
.collect(ImmutableSet.toImmutableSet());
}
}
private final int maxNumberOfIdxPerDex;
private final Path outputDirectory;
/** Collect written zip files so we can conveniently wait for all of them to close when done. */
private final Closer closer = Closer.create();
private int curShard = 0;
/** Currently written file. */
private AsyncZipOut curOut;
private DexLimitTracker tracker;
private Boolean inCoreLib;
private DexFileSplitter(Path outputDirectory, int maxNumberOfIdxPerDex) throws IOException {
checkArgument(!Files.isRegularFile(outputDirectory), "Must be a directory: ", outputDirectory);
this.maxNumberOfIdxPerDex = maxNumberOfIdxPerDex;
this.outputDirectory = outputDirectory;
startShard();
}
private void nextShard() throws IOException {
// Eagerly tell the last shard that it's done so it can finish writing the zip file and release
// resources as soon as possible, without blocking the start of the next shard.
curOut.finishAsync(); // will NPE if called after close()
++curShard;
startShard();
}
private void startShard() throws IOException {
tracker = new DexLimitTracker(maxNumberOfIdxPerDex);
curOut =
closer.register(
new AsyncZipOut(
outputDirectory.resolve((curShard + 1) + ".shard.zip"),
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE));
}
private int shardsWritten() {
return curShard;
}
@Override
public void close() throws IOException {
if (curOut != null) {
curOut.finishAsync();
curOut = null;
++curShard;
}
// Wait for all shards to finish writing. We told them to finish already but need to wait for
// any pending writes so we're sure all output was successfully written.
closer.close();
}
private void processDexFiles(
ImmutableList<Map.Entry<String, ZipFile>> filesToProcess, Predicate<String> filter)
throws IOException {
for (Map.Entry<String, ZipFile> entry : filesToProcess) {
String filename = entry.getKey();
if (filter.test(filename)) {
ZipFile zipFile = entry.getValue();
processDexEntry(zipFile, zipFile.getEntry(filename));
}
}
}
private void processDexEntry(ZipFile zip, ZipEntry entry) throws IOException {
String filename = entry.getName();
checkState(filename.endsWith(".class.dex"),
"%s isn't a dex archive: %s", zip.getName(), filename);
checkState(entry.getMethod() == ZipEntry.STORED, "Expect to process STORED: %s", filename);
if (inCoreLib == null) {
inCoreLib = filename.startsWith("j$/");
} else if (inCoreLib != filename.startsWith("j$/")) {
// Put j$.xxx classes in separate file. This shouldn't normally happen (b/134705306).
nextShard();
inCoreLib = !inCoreLib;
}
if (inCoreLib) {
System.err.printf(
"WARNING: Unexpected file %s found. Please ensure this only happens in test APKs.%n",
filename);
}
try (InputStream entryStream = zip.getInputStream(entry)) {
// We don't want to use the Dex(InputStream) constructor because it closes the stream,
// which will break the for loop, and it has its own bespoke way of reading the file into
// a byte buffer before effectively calling Dex(byte[]) anyway.
// TODO(kmb) since entry is stored, mmap content and give to Dex(ByteBuffer) and output zip
byte[] content = new byte[(int) entry.getSize()];
ByteStreams.readFully(entryStream, content); // throws if file is smaller than expected
checkState(entryStream.read() == -1,
"Too many bytes in jar entry %s, expected %s", entry, entry.getSize());
Dex dexFile = new Dex(content);
if (tracker.track(dexFile)) {
nextShard();
tracker.track(dexFile);
}
curOut.writeAsync(entry, content);
}
}
}