// Copyright 2018 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; import static com.google.common.truth.Truth.assertThat; import static java.nio.charset.StandardCharsets.UTF_8; import static org.hamcrest.CoreMatchers.containsString; import static org.junit.Assert.assertNotNull; import com.android.builder.core.VariantType; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.devtools.build.android.AarGeneratorAction.AarGeneratorOptions; import com.google.devtools.build.zip.ZipReader; import com.google.devtools.common.options.OptionsParser; import com.google.devtools.common.options.OptionsParsingException; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; /** Tests for {@link AarGeneratorAction}. */ @RunWith(JUnit4.class) public class AarGeneratorActionTest { private static class AarData { /** Templates for resource files generation. */ enum ResourceType { VALUE { @Override public String create(String... lines) { return String.format( "\n%s", Joiner.on("\n").join(lines)); } }, LAYOUT { @Override public String create(String... lines) { return String.format("" + "%s", Joiner.on("\n").join(lines)); } }, UNFORMATTED { @Override public String create(String... lines) { return String.format(Joiner.on("\n").join(lines)); } }; public abstract String create(String... lines); } private static class Builder { private final Path root; private final Path assetDir; private final Path resourceDir; private Path manifest; private Path rtxt; private Path classes; private Map filesToWrite = new HashMap<>(); private Map classesToWrite = new HashMap<>(); private ImmutableList.Builder proguardSpecs = ImmutableList.builder(); private boolean withEmptyRes = false; private boolean withEmptyAssets = false; public Builder(Path root) { this(root, "res", "assets"); } public Builder(Path root, String resourceRoot, String assetRoot) { this.root = root; assetDir = root.resolve(assetRoot); resourceDir = root.resolve(resourceRoot); manifest = root.resolve("fake-manifest-path"); rtxt = root.resolve("fake-rtxt-path"); classes = root.resolve("fake-classes-path"); } public Builder addResource(String path, ResourceType template, String... lines) { filesToWrite.put(resourceDir.resolve(path), template.create(lines)); return this; } public Builder withEmptyResources(boolean isEmpty) { this.withEmptyRes = isEmpty; return this; } public Builder addAsset(String path, String... lines) { filesToWrite.put(assetDir.resolve(path), Joiner.on("\n").join(lines)); return this; } public Builder withEmptyAssets(boolean isEmpty) { this.withEmptyAssets = isEmpty; return this; } public Builder createManifest(String path, String manifestPackage, String... lines) { this.manifest = root.resolve(path); filesToWrite.put(manifest, String.format("" + "" + "%s", manifestPackage, Joiner.on("\n").join(lines))); return this; } public Builder createRtxt(String path, String... lines) { this.rtxt = root.resolve(path); filesToWrite.put(rtxt, String.format("%s", Joiner.on("\n").join(lines))); return this; } public Builder createClassesJar(String path) { this.classes = root.resolve(path); classesToWrite.put("META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n"); return this; } public Builder addClassesFile(String filePackage, String filename, String... lines) { classesToWrite.put(filePackage.replace(".", "/") + "/" + filename, String.format("%s", Joiner.on("\n").join(lines))); return this; } public Builder addProguardSpec(String path, String... lines) { Path proguardSpecPath = root.resolve(path); proguardSpecs.add(proguardSpecPath); filesToWrite.put(proguardSpecPath, String.format("%s", Joiner.on("\n").join(lines))); return this; } public AarData build() throws IOException { writeFiles(); return new AarData(buildMerged(), manifest, rtxt, classes, proguardSpecs.build()); } private MergedAndroidData buildMerged() { return new MergedAndroidData( resourceDir, assetDir, manifest); } private void writeFiles() throws IOException { assertNotNull("A manifest is required.", manifest); assertNotNull("A resource file is required.", rtxt); assertNotNull("A classes jar is required.", classes); if (withEmptyRes) { Files.createDirectories(resourceDir); } if (withEmptyAssets) { Files.createDirectories(assetDir); } for (Map.Entry entry : filesToWrite.entrySet()) { Path file = entry.getKey(); // only write files in assets if assets has not been set to empty and same for resources if (!((file.startsWith(assetDir) && withEmptyAssets) || (file.startsWith(resourceDir) && withEmptyRes))) { Files.createDirectories(file.getParent()); Files.write(file, entry.getValue().getBytes(StandardCharsets.UTF_8)); assertThat(Files.exists(file)).isTrue(); } } if (!classesToWrite.isEmpty()) { writeClassesJar(); } } private void writeClassesJar() throws IOException { final ZipOutputStream zout = new ZipOutputStream(new FileOutputStream(classes.toFile())); for (Map.Entry file : classesToWrite.entrySet()) { ZipEntry entry = new ZipEntry(file.getKey()); zout.putNextEntry(entry); zout.write(file.getValue().getBytes(UTF_8)); zout.closeEntry(); } zout.close(); classes.toFile().setLastModified(AarGeneratorAction.DEFAULT_TIMESTAMP); } } final MergedAndroidData data; final Path manifest; final Path rtxt; final Path classes; final ImmutableList proguardSpecs; private AarData( MergedAndroidData data, Path manifest, Path rtxt, Path classes, ImmutableList proguardSpecs) { this.data = data; this.manifest = manifest; this.rtxt = rtxt; this.classes = classes; this.proguardSpecs = proguardSpecs; } } /** * Operation to perform on a file. */ private interface FileOperation { /** * Performs the operation on a file, given its name, modificationTime and contents. */ void perform(String name, long modificationTime, String contents); } /** * Runs a {@link FileOperation} on every entry in a zip file. * * @param zip {@link Path} of the zip file to traverse. * @param operation {@link FileOperation} to be run on every entry of the zip file. * @throws IOException if there is an error reading the zip file. */ private void traverseZipFile(Path zip, FileOperation operation) throws IOException { ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip)); ZipEntry z = zis.getNextEntry(); while (z != null) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; for (int count = 0; count != -1; count = zis.read(buffer)) { baos.write(buffer); } // Replace Windows path separators so that test cases are consistent across platforms. String name = z.getName().replace('\\', '/'); operation.perform( name, z.getTime(), new String(baos.toByteArray(), StandardCharsets.UTF_8)); z = zis.getNextEntry(); } } private Set getZipEntries(Path zip) throws IOException { final Set zipEntries = new HashSet<>(); traverseZipFile(zip, new FileOperation() { @Override public void perform(String name, long modificationTime, String contents) { zipEntries.add(name); } }); return zipEntries; } private Set getZipEntryTimestamps(Path zip) throws IOException { final Set timestamps = new HashSet<>(); traverseZipFile(zip, new FileOperation() { @Override public void perform(String name, long modificationTime, String contents) { timestamps.add(modificationTime); } }); return timestamps; } private Path tempDir; @Rule public ExpectedException thrown = ExpectedException.none(); @Before public void setUp() throws IOException { tempDir = Files.createTempDirectory(toString()); tempDir.toFile().deleteOnExit(); } @Test public void testCheckFlags() throws IOException, OptionsParsingException { Path manifest = tempDir.resolve("AndroidManifest.xml"); Files.createFile(manifest); Path rtxt = tempDir.resolve("R.txt"); Files.createFile(rtxt); Path classes = tempDir.resolve("classes.jar"); Files.createFile(classes); String[] args = new String[] {"--manifest", manifest.toString(), "--rtxt", rtxt.toString(), "--classes", classes.toString()}; OptionsParser optionsParser = OptionsParser.newOptionsParser(AarGeneratorOptions.class); optionsParser.parse(args); AarGeneratorOptions options = optionsParser.getOptions(AarGeneratorOptions.class); AarGeneratorAction.checkFlags(options); } @Test public void testCheckFlags_MissingClasses() throws IOException, OptionsParsingException { Path manifest = tempDir.resolve("AndroidManifest.xml"); Files.createFile(manifest); Path rtxt = tempDir.resolve("R.txt"); Files.createFile(rtxt); String[] args = new String[] {"--manifest", manifest.toString(), "--rtxt", rtxt.toString()}; OptionsParser optionsParser = OptionsParser.newOptionsParser(AarGeneratorOptions.class); optionsParser.parse(args); AarGeneratorOptions options = optionsParser.getOptions(AarGeneratorOptions.class); thrown.expect(IllegalArgumentException.class); thrown.expectMessage("classes must be specified. Building an .aar without" + " classes is unsupported."); AarGeneratorAction.checkFlags(options); } @Test public void testCheckFlags_MissingMultiple() throws IOException, OptionsParsingException { Path manifest = tempDir.resolve("AndroidManifest.xml"); Files.createFile(manifest); String[] args = new String[] {"--manifest", manifest.toString()}; OptionsParser optionsParser = OptionsParser.newOptionsParser(AarGeneratorOptions.class); optionsParser.parse(args); AarGeneratorOptions options = optionsParser.getOptions(AarGeneratorOptions.class); thrown.expect(IllegalArgumentException.class); thrown.expectMessage("rtxt, classes must be specified. Building an .aar without" + " rtxt, classes is unsupported."); AarGeneratorAction.checkFlags(options); } @Test public void testWriteAar() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addAsset("some/other/ft/data.txt", "bar") .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); } @Test public void testWriteAar_DefaultTimestamps() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addAsset("some/other/ft/data.txt", "bar") .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); assertThat(getZipEntryTimestamps(aar)).containsExactly(AarGeneratorAction.DEFAULT_TIMESTAMP); assertThat(aar.toFile().lastModified()).isEqualTo(AarGeneratorAction.DEFAULT_TIMESTAMP); } @Test public void testAssetResourceSubdirs() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data"), "xyz", "assets") .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addAsset("some/other/ft/data.txt", "bar") .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); // verify aar archive Set zipEntries = getZipEntries(aar); assertThat(zipEntries).contains("res/"); assertThat(zipEntries).contains("assets/"); } @Test public void testMissingManifest() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addAsset("some/other/ft/data.txt", "bar") .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); thrown.expect(IOException.class); thrown.expectMessage(containsString("fake-manifest-path")); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); } @Test public void testMissingRtxt() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addAsset("some/other/ft/data.txt", "bar") .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); thrown.expect(IOException.class); thrown.expectMessage(containsString("fake-rtxt-path")); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); } @Test public void testMissingClasses() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addAsset("some/other/ft/data.txt", "bar") .build(); thrown.expect(IOException.class); thrown.expectMessage(containsString("fake-classes-path")); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); } @Test public void testMissingResources() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .addAsset("some/other/ft/data.txt", "bar") .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); thrown.expect(IOException.class); thrown.expectMessage(containsString("res")); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); } @Test public void testEmptyResources() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .withEmptyResources(true) .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addAsset("some/other/ft/data.txt", "bar") .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); } @Test public void testMissingAssets() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); } @Test public void testEmptyAssets() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .withEmptyAssets(true) .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); AarGeneratorAction.writeAar(aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); } @Test public void testFullIntegration() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo", "") .createRtxt("R.txt", "int string app_name 0x7f050001", "int string hello_world 0x7f050002") .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addResource("layout/layout.xml", AarData.ResourceType.LAYOUT, "") .addAsset("some/other/ft/data.txt", "foo") .createClassesJar("classes.jar") .addClassesFile("com.google.android.apps.foo", "Test.class", "test file contents") .build(); MergedAndroidData md1 = new AarData.Builder(tempDir.resolve("d1")) .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addResource("layout/foo.xml", AarData.ResourceType.LAYOUT, "") .addAsset("some/other/ft/data1.txt", "bar") .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .build().data; MergedAndroidData md2 = new AarData.Builder(tempDir.resolve("d2")) .addResource("values/ids.xml", AarData.ResourceType.VALUE, "") .addResource("layout/bar.xml", AarData.ResourceType.LAYOUT, "") .addResource("drawable-mdpi/icon.png", AarData.ResourceType.UNFORMATTED, "Thttpt.") .addResource("drawable-xxxhdpi/icon.png", AarData.ResourceType.UNFORMATTED, "Double Thttpt.") .addAsset("some/other/ft/data2.txt", "foo") .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d2", "") .build().data; UnvalidatedAndroidData primary = new UnvalidatedAndroidData( ImmutableList.of(aarData.data.getResourceDir()), ImmutableList.of(aarData.data.getAssetDir()), aarData.data.getManifest()); DependencyAndroidData d1 = new DependencyAndroidData( ImmutableList.of(md1.getResourceDir()), ImmutableList.of(md1.getAssetDir()), md1.getManifest(), null, null, null); DependencyAndroidData d2 = new DependencyAndroidData( ImmutableList.of(md2.getResourceDir()), ImmutableList.of(md2.getAssetDir()), md2.getManifest(), null, null, null); Path working = tempDir; Path resourcesOut = working.resolve("resources"); Path assetsOut = working.resolve("assets"); MergedAndroidData mergedData = AndroidResourceMerger.mergeData( primary, ImmutableList.of(d1, d2), ImmutableList.of(), resourcesOut, assetsOut, null, VariantType.LIBRARY, null, /* filteredResources= */ ImmutableList.of(), true); AarGeneratorAction.writeAar( aar, mergedData, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); // verify aar archive Set zipEntries = getZipEntries(aar); assertThat(zipEntries).containsExactly( "AndroidManifest.xml", "R.txt", "classes.jar", "res/", "res/values/", "res/values/values.xml", "res/layout/", "res/layout/layout.xml", "res/layout/foo.xml", "res/layout/bar.xml", "res/drawable-mdpi-v4/", "res/drawable-mdpi-v4/icon.png", "res/drawable-xxxhdpi-v4/", "res/drawable-xxxhdpi-v4/icon.png", "assets/", "assets/some/", "assets/some/other/", "assets/some/other/ft/", "assets/some/other/ft/data.txt", "assets/some/other/ft/data1.txt", "assets/some/other/ft/data2.txt"); } @Test public void testProguardSpecs() throws Exception { Path aar = tempDir.resolve("foo.aar"); AarData aarData = new AarData.Builder(tempDir.resolve("data")) .createManifest("AndroidManifest.xml", "com.google.android.apps.foo.d1", "") .createRtxt("R.txt", "") .withEmptyResources(true) .withEmptyAssets(true) .createClassesJar("classes.jar") .addProguardSpec("spec1", "foo", "bar") .addProguardSpec("spec2", "baz") .build(); AarGeneratorAction.writeAar( aar, aarData.data, aarData.manifest, aarData.rtxt, aarData.classes, aarData.proguardSpecs); Set zipEntries = getZipEntries(aar); assertThat(zipEntries).contains("proguard.txt"); List proguardTxtContents = null; try (ZipReader aarReader = new ZipReader(aar.toFile())) { try (BufferedReader entryReader = new BufferedReader( new InputStreamReader( aarReader.getInputStream(aarReader.getEntry("proguard.txt")), StandardCharsets.UTF_8))) { proguardTxtContents = entryReader.lines().collect(Collectors.toList()); } } assertThat(proguardTxtContents).containsExactly("foo", "bar", "baz").inOrder(); } }