Make SplitZipStep use proguard mapping on builds with obfuscation.
Summary:
Previously, we had some hacks in place to make zip splitting work
properly on builds with obfuscated class names, but they weren't
scalable. Now the split-zip step pulls in the proguard class name
mapping and uses that to apply primary dex policies more accurately.
diff --git a/src/com/facebook/buck/android/AndroidBinaryRule.java b/src/com/facebook/buck/android/AndroidBinaryRule.java
index 74d9fad..e802db5 100644
--- a/src/com/facebook/buck/android/AndroidBinaryRule.java
+++ b/src/com/facebook/buck/android/AndroidBinaryRule.java
@@ -1041,6 +1041,11 @@
final Optional<String> secondaryInputsDir;
if (shouldSplitDex()) {
+ Optional<Path> proguardMappingFile = Optional.absent();
+ if (packageType.isBuildWithObfuscation()) {
+ proguardMappingFile = Optional.of(getPathForProGuardDirectory().resolve("mapping.txt"));
+ }
+
// DexLibLoader expects that metadata.txt and secondary jar files are under this dir
// in assets.
String magicSecondaryDexSubdir = "assets/secondary-program-dex-jars";
@@ -1073,6 +1078,7 @@
primaryJarPath,
secondaryZipDir,
"secondary-%d.jar",
+ proguardMappingFile,
primaryDexSubstrings,
primaryDexClassesFile.transform(sourcePathResolver),
dexSplitMode.getDexSplitStrategy(),
diff --git a/src/com/facebook/buck/android/ProguardMapping.java b/src/com/facebook/buck/android/ProguardMapping.java
new file mode 100644
index 0000000..2bdc6c0
--- /dev/null
+++ b/src/com/facebook/buck/android/ProguardMapping.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2012-present Facebook, Inc.
+ *
+ * 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.facebook.buck.android;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Parser for ProGuard-generated mapping files. Currently only handles class mapping.
+ */
+public class ProguardMapping {
+ private static Pattern CLASS_LINE_PATTERN = Pattern.compile("([\\w.$]+) -> ([\\w.$]+):");
+
+ public static Map<String, String> readClassMapping(Iterable<String> lines) {
+ ImmutableMap.Builder<String, String> classMappingBuilder = ImmutableMap.builder();
+
+ for (String line : lines) {
+ if (line.charAt(0) == ' ') {
+ // This is a member mapping, which we don't handle yet.
+ continue;
+ }
+
+ Matcher matcher = CLASS_LINE_PATTERN.matcher(line);
+ if (!matcher.matches()) {
+ throw new IllegalArgumentException("Invalid line in proguard mapping: " + line);
+ }
+
+ classMappingBuilder.put(matcher.group(1), matcher.group(2));
+ }
+
+ return classMappingBuilder.build();
+ }
+}
diff --git a/src/com/facebook/buck/android/SplitZipStep.java b/src/com/facebook/buck/android/SplitZipStep.java
index d3b85d1..637b77b 100644
--- a/src/com/facebook/buck/android/SplitZipStep.java
+++ b/src/com/facebook/buck/android/SplitZipStep.java
@@ -26,11 +26,13 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.base.Function;
+import com.google.common.base.Functions;
import com.google.common.base.Joiner;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.hash.Hashing;
import com.google.common.io.Files;
@@ -41,12 +43,15 @@
import java.nio.file.Path;
import java.util.Collection;
import java.util.Collections;
+import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
+import javax.annotation.Nullable;
+
/**
* Split zipping tool designed to divide input code blobs into a set of output jar files such that
* none will exceed the DexOpt LinearAlloc limit or the dx method limit when passed through
@@ -71,6 +76,7 @@
private final String primaryJarPath;
private final String secondaryJarDir;
private final String secondaryJarPattern;
+ private final Optional<Path> proguardMappingFile;
private final ImmutableSet<String> primaryDexSubstrings;
private final Optional<Path> primaryDexClassesFile;
private final ZipSplitter.DexSplitStrategy dexSplitStrategy;
@@ -101,6 +107,7 @@
String primaryJarPath,
String secondaryJarDir,
String secondaryJarPattern,
+ Optional<Path> proguardMappingFile,
Set<String> primaryDexSubstrings,
Optional<Path> primaryDexClassesFile,
ZipSplitter.DexSplitStrategy dexSplitStrategy,
@@ -113,6 +120,7 @@
this.primaryJarPath = Preconditions.checkNotNull(primaryJarPath);
this.secondaryJarDir = Preconditions.checkNotNull(secondaryJarDir);
this.secondaryJarPattern = Preconditions.checkNotNull(secondaryJarPattern);
+ this.proguardMappingFile = Preconditions.checkNotNull(proguardMappingFile);
this.primaryDexSubstrings = ImmutableSet.copyOf(primaryDexSubstrings);
this.primaryDexClassesFile = Preconditions.checkNotNull(primaryDexClassesFile);
this.dexSplitStrategy = Preconditions.checkNotNull(dexSplitStrategy);
@@ -168,6 +176,7 @@
@VisibleForTesting
Predicate<String> createRequiredInPrimaryZipPredicate(ExecutionContext context)
throws IOException {
+ final Function<String, String> deobfuscate = createProguardDeobfuscator(context);
final ImmutableSet<String> primaryDexClassNames = getPrimaryDexClassNames(context);
return new Predicate<String>() {
@@ -181,12 +190,16 @@
return true;
}
- if (primaryDexClassNames.contains(classFileName)) {
+ // Drop the ".class" suffix and deobfuscate the class name before we apply our checks.
+ String internalClassName = Preconditions.checkNotNull(
+ deobfuscate.apply(classFileName.replaceAll("\\.class$", "")));
+
+ if (primaryDexClassNames.contains(internalClassName)) {
return true;
}
for (String substr : SplitZipStep.this.primaryDexSubstrings) {
- if (classFileName.contains(substr)) {
+ if (internalClassName.contains(substr)) {
return true;
}
}
@@ -195,6 +208,10 @@
};
}
+ /**
+ * Construct a {@link Set} of internal class names that should go into the primary dex.
+ * @return the Set.
+ */
private ImmutableSet<String> getPrimaryDexClassNames(ExecutionContext context)
throws IOException {
if (!primaryDexClassesFile.isPresent()) {
@@ -218,16 +235,40 @@
return !line.isEmpty() && !(line.charAt(0) == '#');
}
})
- .transform(new Function<String, String>() {
- @Override
- public String apply(String line) {
- // Append a ".class" so the input file can just contain the internal class name.
- return line + ".class";
- }
- })
.toSet();
}
+ /**
+ * Create a {@link Function} that will deobfuscate internal class names for this build.
+ * @return the Function.
+ */
+ private Function<String, String> createProguardDeobfuscator(ExecutionContext context)
+ throws IOException {
+ if (!proguardMappingFile.isPresent()) {
+ return Functions.identity();
+ }
+
+ Map<String, String> rawProguardMap = ProguardMapping.readClassMapping(
+ context.getProjectFilesystem().readLines(proguardMappingFile.get()));
+
+ ImmutableMap.Builder<String, String> internalNameBuilder = ImmutableMap.builder();
+ for (Map.Entry<String, String> entry : rawProguardMap.entrySet()) {
+ internalNameBuilder.put(
+ entry.getValue().replace('.', '/'),
+ entry.getKey().replace('.', '/'));
+ }
+ final Map<String, String> deobfuscator = internalNameBuilder.build();
+
+ return new Function<String, String>() {
+ @Nullable
+ @Override
+ public String apply(@Nullable String input) {
+ return deobfuscator.get(input);
+ }
+ };
+
+ }
+
@VisibleForTesting
static void writeMetaList(
BufferedWriter writer,
diff --git a/test/com/facebook/buck/android/ProguardMappingTest.java b/test/com/facebook/buck/android/ProguardMappingTest.java
new file mode 100644
index 0000000..4bf900d
--- /dev/null
+++ b/test/com/facebook/buck/android/ProguardMappingTest.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2012-present Facebook, Inc.
+ *
+ * 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.facebook.buck.android;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.Test;
+
+import java.util.Map;
+
+public class ProguardMappingTest {
+ @Test
+ public void testBasicParse() {
+ Map<String, String> mapping = ProguardMapping.readClassMapping(ImmutableList.of(
+ "foo.bar.Baz -> foo.bar.a:",
+ " member -> x",
+ "foo.bar.Baz$Qux -> foo.bar.Baz$Qux:"
+ ));
+ assertEquals(mapping, ImmutableMap.of(
+ "foo.bar.Baz", "foo.bar.a",
+ "foo.bar.Baz$Qux", "foo.bar.Baz$Qux"
+ ));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testInternalNameError() {
+ ProguardMapping.readClassMapping(ImmutableList.of(
+ "foo/bar/Baz -> foo/bar/a:"
+ ));
+ }
+}
diff --git a/test/com/facebook/buck/android/SplitZipStepTest.java b/test/com/facebook/buck/android/SplitZipStepTest.java
index bc3dd00..13a5b9c 100644
--- a/test/com/facebook/buck/android/SplitZipStepTest.java
+++ b/test/com/facebook/buck/android/SplitZipStepTest.java
@@ -101,6 +101,7 @@
/* primaryJarPath */ "",
/* secondaryJarDir */ "",
/* secondaryJarPattern */ "",
+ /* proguardMappingFile */ Optional.<Path>absent(),
/* primaryDexSubstrings */ ImmutableSet.of("List"),
Optional.of(primaryDexClassesFile),
ZipSplitter.DexSplitStrategy.MAXIMIZE_PRIMARY_DEX_SIZE,
@@ -152,6 +153,79 @@
}
@Test
+ public void testRequiredInPrimaryZipPredicateWithProguard() throws IOException {
+ Path proguardMappingFile = Paths.get("the/mapping.txt");
+ Path primaryDexClassesFile = Paths.get("the/manifest.txt");
+ SplitZipStep splitZipStep = new SplitZipStep(
+ /* inputPathsToSplit */ ImmutableSet.<String>of(),
+ /* secondaryJarMetaPath */ "",
+ /* primaryJarPath */ "",
+ /* secondaryJarDir */ "",
+ /* secondaryJarPattern */ "",
+ /* proguardMappingFile */ Optional.of(proguardMappingFile),
+ /* primaryDexSubstrings */ ImmutableSet.of("/primary/", "x/"),
+ Optional.of(primaryDexClassesFile),
+ ZipSplitter.DexSplitStrategy.MAXIMIZE_PRIMARY_DEX_SIZE,
+ DexStore.JAR,
+ /* pathToReportDir */ "",
+ /* useLinearAllocSplitDex */ true,
+ /* linearAllocHardLimit */ 4 * 1024 * 1024);
+ List<String> linesInMappingFile = ImmutableList.of(
+ "foo.bar.MappedPrimary -> foo.bar.a:",
+ "foo.bar.MappedSecondary -> foo.bar.b:",
+ "foo.bar.UnmappedPrimary -> foo.bar.UnmappedPrimary:",
+ "foo.bar.UnmappedSecondary -> foo.bar.UnmappedSecondary:",
+ "foo.primary.MappedPackage -> x.a:",
+ "foo.secondary.MappedPackage -> x.b:",
+ "foo.primary.UnmappedPackage -> foo.primary.UnmappedPackage:"
+ );
+ List<String> linesInManifestFile = ImmutableList.of(
+ // Actual primary dex classes.
+ "foo/bar/MappedPrimary",
+ "foo/bar/UnmappedPrimary",
+ // Red herrings!
+ "foo/bar/b",
+ "x/b"
+ );
+
+ ProjectFilesystem projectFilesystem = EasyMock.createMock(ProjectFilesystem.class);
+ EasyMock.expect(projectFilesystem.readLines(primaryDexClassesFile))
+ .andReturn(linesInManifestFile);
+ EasyMock.expect(projectFilesystem.readLines(proguardMappingFile))
+ .andReturn(linesInMappingFile);
+ ExecutionContext context = EasyMock.createMock(ExecutionContext.class);
+ EasyMock.expect(context.getProjectFilesystem()).andReturn(projectFilesystem).anyTimes();
+ EasyMock.replay(projectFilesystem, context);
+
+ Predicate<String> requiredInPrimaryZipPredicate = splitZipStep
+ .createRequiredInPrimaryZipPredicate(context);
+ assertTrue(
+ "Mapped class from primary list should be in primary.",
+ requiredInPrimaryZipPredicate.apply("foo/bar/a.class"));
+ assertTrue(
+ "Unmapped class from primary list should be in primary.",
+ requiredInPrimaryZipPredicate.apply("foo/bar/UnmappedPrimary.class"));
+ assertTrue(
+ "Mapped class from substring should be in primary.",
+ requiredInPrimaryZipPredicate.apply("x/a.class"));
+ assertTrue(
+ "Unmapped class from substring should be in primary.",
+ requiredInPrimaryZipPredicate.apply("foo/primary/UnmappedPackage.class"));
+ assertFalse(
+ "Mapped class with obfuscated name match should not be in primary.",
+ requiredInPrimaryZipPredicate.apply("foo/bar/b.class"));
+ assertFalse(
+ "Unmapped class name should not randomly be in primary.",
+ requiredInPrimaryZipPredicate.apply("foo/bar/UnmappedSecondary.class"));
+ assertFalse(
+ "Map class with obfuscated name substring should not be in primary.",
+ requiredInPrimaryZipPredicate.apply("x/b.class"));
+
+ EasyMock.verify(projectFilesystem, context);
+ }
+
+
+ @Test
public void testClassFilePattern() {
assertTrue(SplitZipStep.classFilePattern.matcher(
"com/facebook/orca/threads/ParticipantInfo.class").matches());