Add support for AAR files to IndexJar (#1490)
Fixes #1489
diff --git a/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/IndexJar.java b/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/IndexJar.java
index a8040d6..fd10155 100644
--- a/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/IndexJar.java
+++ b/private/tools/java/com/github/bazelbuild/rules_jvm_external/jar/IndexJar.java
@@ -83,50 +83,20 @@
}
public PerJarIndexResults index(Path path) throws IOException {
+ if (path.toString().toLowerCase().endsWith(".aar")) {
+ return indexAar(path);
+ }
+ return indexJar(path);
+ }
+
+ private PerJarIndexResults indexJar(Path path) throws IOException {
SortedSet<String> packages = new TreeSet<>();
SortedSet<String> classes = new TreeSet<>();
SortedMap<String, SortedSet<String>> serviceImplementations = new TreeMap<>();
try (InputStream fis = new BufferedInputStream(Files.newInputStream(path));
ZipInputStream zis = new ZipInputStream(fis)) {
try {
- ZipEntry entry;
- while ((entry = zis.getNextEntry()) != null) {
- if (entry.getName().startsWith(SERVICES_DIRECTORY_PREFIX)
- && !SERVICES_DIRECTORY_PREFIX.equals(entry.getName())) {
- String serviceInterface = entry.getName().substring(SERVICES_DIRECTORY_PREFIX.length());
- SortedSet<String> implementingClasses = parseServiceImplementations(zis);
- serviceImplementations.put(serviceInterface, implementingClasses);
- }
- if (!entry.getName().endsWith(".class")) {
- continue;
- }
- if ("module-info.class".equals(entry.getName())
- || entry.getName().endsWith("/module-info.class")) {
- continue;
- }
- // package-info.class holds only package-level annotations and cannot
- // be referenced from production code.
- if ("package-info.class".equals(entry.getName())
- || entry.getName().endsWith("/package-info.class")) {
- continue;
- }
- // Skip inner classes, anonymous classes, and local classes (contain $)
- if (isInnerClass(entry.getName())) {
- continue;
- }
- String packageName = extractPackageName(entry.getName());
- packages.add(packageName);
- classes.add(extractClassName(entry.getName()));
-
- // Kotlin top-level functions, properties, and type aliases are compiled into a synthetic
- // file facade class and are otherwise invisible in the index. Fold their names in so they
- // can be attributed to this artifact (notably for split packages).
- if (isKotlinFileFacade(entry.getName())) {
- for (String declaration : KotlinTopLevel.topLevelDeclarationNames(zis.readAllBytes())) {
- classes.add(packageName.isEmpty() ? declaration : packageName + "." + declaration);
- }
- }
- }
+ processZipEntries(zis, packages, classes, serviceImplementations);
} catch (ZipException e) {
System.err.printf("Caught ZipException: %s%n", e);
}
@@ -134,6 +104,90 @@
}
}
+ private PerJarIndexResults indexAar(Path aarPath) throws IOException {
+ SortedSet<String> packages = new TreeSet<>();
+ SortedSet<String> classes = new TreeSet<>();
+ SortedMap<String, SortedSet<String>> serviceImplementations = new TreeMap<>();
+
+ try (InputStream fis = new BufferedInputStream(Files.newInputStream(aarPath));
+ ZipInputStream aarZis = new ZipInputStream(fis)) {
+ try {
+ ZipEntry aarEntry;
+ while ((aarEntry = aarZis.getNextEntry()) != null) {
+ if ("classes.jar".equals(aarEntry.getName())) {
+ processJarStream(aarZis, packages, classes, serviceImplementations);
+ } else if (aarEntry.getName().startsWith("libs/")
+ && aarEntry.getName().endsWith(".jar")) {
+ processJarStream(aarZis, packages, classes, serviceImplementations);
+ }
+ }
+ } catch (ZipException e) {
+ System.err.printf("Caught ZipException while processing AAR: %s%n", e);
+ }
+ }
+
+ return new PerJarIndexResults(packages, classes, serviceImplementations);
+ }
+
+ private void processJarStream(
+ InputStream jarStream,
+ SortedSet<String> packages,
+ SortedSet<String> classes,
+ SortedMap<String, SortedSet<String>> serviceImplementations)
+ throws IOException {
+ // Don't use try-with-resources here as we don't want to close the underlying stream
+ ZipInputStream jarZis = new ZipInputStream(jarStream);
+ processZipEntries(jarZis, packages, classes, serviceImplementations);
+ }
+
+ private void processZipEntries(
+ ZipInputStream zis,
+ SortedSet<String> packages,
+ SortedSet<String> classes,
+ SortedMap<String, SortedSet<String>> serviceImplementations)
+ throws IOException {
+
+ ZipEntry entry;
+ while ((entry = zis.getNextEntry()) != null) {
+ if (entry.getName().startsWith(SERVICES_DIRECTORY_PREFIX)
+ && !SERVICES_DIRECTORY_PREFIX.equals(entry.getName())) {
+ String serviceInterface = entry.getName().substring(SERVICES_DIRECTORY_PREFIX.length());
+ SortedSet<String> implementingClasses = parseServiceImplementations(zis);
+ serviceImplementations
+ .computeIfAbsent(serviceInterface, unused -> new TreeSet<>())
+ .addAll(implementingClasses);
+ }
+ if (!entry.getName().endsWith(".class")) {
+ continue;
+ }
+ if ("module-info.class".equals(entry.getName())
+ || entry.getName().endsWith("/module-info.class")) {
+ continue;
+ }
+ // package-info.class holds only package-level annotations and cannot
+ // be referenced from production code.
+ if ("package-info.class".equals(entry.getName())
+ || entry.getName().endsWith("/package-info.class")) {
+ continue;
+ }
+ if (isInnerClass(entry.getName())) {
+ continue;
+ }
+ String packageName = extractPackageName(entry.getName());
+ packages.add(packageName);
+ classes.add(extractClassName(entry.getName()));
+
+ // Kotlin top-level functions, properties, and type aliases are compiled into a synthetic
+ // file facade class and are otherwise invisible in the index. Fold their names in so they
+ // can be attributed to this artifact (notably for split packages).
+ if (isKotlinFileFacade(entry.getName())) {
+ for (String declaration : KotlinTopLevel.topLevelDeclarationNames(zis.readAllBytes())) {
+ classes.add(packageName.isEmpty() ? declaration : packageName + "." + declaration);
+ }
+ }
+ }
+ }
+
// Visible for testing
// Note that parseServiceImplementation does not close the passed InputStream, the caller is
// responsible for doing this.
diff --git a/private/tools/prebuilt/index_jar_deploy.jar b/private/tools/prebuilt/index_jar_deploy.jar
index 83a8f9f..b681565 100755
--- a/private/tools/prebuilt/index_jar_deploy.jar
+++ b/private/tools/prebuilt/index_jar_deploy.jar
Binary files differ
diff --git a/tests/com/github/bazelbuild/rules_jvm_external/jar/IndexJarTest.java b/tests/com/github/bazelbuild/rules_jvm_external/jar/IndexJarTest.java
index ce55372..9a24b8c 100644
--- a/tests/com/github/bazelbuild/rules_jvm_external/jar/IndexJarTest.java
+++ b/tests/com/github/bazelbuild/rules_jvm_external/jar/IndexJarTest.java
@@ -22,6 +22,7 @@
import com.google.devtools.build.runfiles.Runfiles;
import com.google.gson.Gson;
import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -30,6 +31,11 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
@@ -136,10 +142,173 @@
}
}
+ @Test
+ public void aarWithSinglePackage() throws Exception {
+ Path aarFile = new AarTestBuilder().withClass("com.example.TestClass").build();
+ try {
+ PerJarIndexResults result = new IndexJar().index(aarFile);
+ assertEquals(sortedSet("com.example"), result.getPackages());
+ assertEquals(new TreeMap<>(), result.getServiceImplementations());
+ } finally {
+ Files.deleteIfExists(aarFile);
+ }
+ }
+
+ @Test
+ public void aarWithMultiplePackages() throws Exception {
+ Path aarFile =
+ new AarTestBuilder()
+ .withClass("com.example.MainClass")
+ .withClass("com.example.model.User")
+ .withClass("com.example.utils.Helper")
+ .build();
+ try {
+ PerJarIndexResults result = new IndexJar().index(aarFile);
+ assertEquals(
+ sortedSet("com.example", "com.example.model", "com.example.utils"), result.getPackages());
+ assertEquals(new TreeMap<>(), result.getServiceImplementations());
+ } finally {
+ Files.deleteIfExists(aarFile);
+ }
+ }
+
+ @Test
+ public void aarWithServiceImplementations() throws Exception {
+ Path aarFile =
+ new AarTestBuilder()
+ .withClass("com.example.Service")
+ .withClass("com.example.ServiceImpl")
+ .withService("com.example.Service", "com.example.ServiceImpl")
+ .build();
+ try {
+ PerJarIndexResults result = new IndexJar().index(aarFile);
+ assertEquals(sortedSet("com.example"), result.getPackages());
+
+ TreeMap<String, TreeSet<String>> expectedServices = new TreeMap<>();
+ expectedServices.put("com.example.Service", sortedSet("com.example.ServiceImpl"));
+ assertEquals(expectedServices, result.getServiceImplementations());
+ } finally {
+ Files.deleteIfExists(aarFile);
+ }
+ }
+
+ @Test
+ public void aarMergesServiceImplementationsAcrossJars() throws Exception {
+ String service = "com.example.Service";
+ AarTestBuilder libJar =
+ new AarTestBuilder().withService(service, "com.third.party.LibraryServiceImpl");
+ Path aarFile =
+ new AarTestBuilder()
+ .withService(service, "com.example.ServiceImpl")
+ .withLibJar(libJar)
+ .build();
+ try {
+ PerJarIndexResults result = new IndexJar().index(aarFile);
+
+ TreeMap<String, TreeSet<String>> expectedServices = new TreeMap<>();
+ expectedServices.put(
+ service, sortedSet("com.example.ServiceImpl", "com.third.party.LibraryServiceImpl"));
+ assertEquals(expectedServices, result.getServiceImplementations());
+ } finally {
+ Files.deleteIfExists(aarFile);
+ }
+ }
+
+ @Test
+ public void aarWithLibsDirectory() throws Exception {
+ AarTestBuilder libJar = new AarTestBuilder().withClass("com.third.party.LibraryClass");
+
+ Path aarFile =
+ new AarTestBuilder().withClass("com.example.MainClass").withLibJar(libJar).build();
+ try {
+ PerJarIndexResults result = new IndexJar().index(aarFile);
+ // Should include packages from both classes.jar and libs/additional.jar
+ assertEquals(sortedSet("com.example", "com.third.party"), result.getPackages());
+ assertEquals(new TreeMap<>(), result.getServiceImplementations());
+ } finally {
+ Files.deleteIfExists(aarFile);
+ }
+ }
+
private InputStream streamOf(String string) {
return new ByteArrayInputStream(string.getBytes(StandardCharsets.UTF_8));
}
+ private static class AarTestBuilder {
+ private final List<String> classes = new ArrayList<>();
+ private final Map<String, List<String>> services = new LinkedHashMap<>();
+ private final List<AarTestBuilder> libJars = new ArrayList<>();
+
+ public AarTestBuilder withClass(String fullyQualifiedClassName) {
+ classes.add(fullyQualifiedClassName);
+ return this;
+ }
+
+ public AarTestBuilder withService(String serviceInterface, String... implementations) {
+ services.put(serviceInterface, Arrays.asList(implementations));
+ return this;
+ }
+
+ public AarTestBuilder withLibJar(AarTestBuilder libJarBuilder) {
+ libJars.add(libJarBuilder);
+ return this;
+ }
+
+ public Path build() throws IOException {
+ Path aarFile = Files.createTempFile("test-aar", ".aar");
+
+ try (ZipOutputStream aar = new ZipOutputStream(Files.newOutputStream(aarFile))) {
+ addAndroidManifest(aar);
+
+ byte[] classesJarBytes = createClassesJar();
+ aar.putNextEntry(new ZipEntry("classes.jar"));
+ aar.write(classesJarBytes);
+ aar.closeEntry();
+
+ for (int i = 0; i < libJars.size(); i++) {
+ AarTestBuilder libJarBuilder = libJars.get(i);
+ byte[] libJarBytes = libJarBuilder.createClassesJar();
+ aar.putNextEntry(new ZipEntry("libs/lib" + i + ".jar"));
+ aar.write(libJarBytes);
+ aar.closeEntry();
+ }
+ }
+
+ return aarFile;
+ }
+
+ private void addAndroidManifest(ZipOutputStream aar) throws IOException {
+ aar.putNextEntry(new ZipEntry("AndroidManifest.xml"));
+ String manifest =
+ "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
+ + "<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\""
+ + " package=\"com.example\">\n"
+ + "</manifest>\n";
+ aar.write(manifest.getBytes(StandardCharsets.UTF_8));
+ aar.closeEntry();
+ }
+
+ private byte[] createClassesJar() throws IOException {
+ ByteArrayOutputStream jarBytes = new ByteArrayOutputStream();
+ try (ZipOutputStream jar = new ZipOutputStream(jarBytes)) {
+ for (String className : classes) {
+ String classPath = className.replace('.', '/') + ".class";
+ jar.putNextEntry(new ZipEntry(classPath));
+ jar.closeEntry();
+ }
+
+ for (Map.Entry<String, List<String>> service : services.entrySet()) {
+ String servicePath = "META-INF/services/" + service.getKey();
+ jar.putNextEntry(new ZipEntry(servicePath));
+ String serviceContent = String.join("\n", service.getValue()) + "\n";
+ jar.write(serviceContent.getBytes(StandardCharsets.UTF_8));
+ jar.closeEntry();
+ }
+ }
+ return jarBytes.toByteArray();
+ }
+ }
+
@Test
public void skipsPackageInfo() throws Exception {
Path jar = Files.createTempFile("index-jar-package-info", ".jar");